Skip to content

Commit 098ad30

Browse files
committed
Add /feedback endpoint
This patch adds the /feedback endpoint. Note: Authentication and user ID retrieval haven't been included yet, as they don't appear to be implemented in the Lightspeed Llama-stack version. TODOs were left in the code for those parts. Signed-off-by: Lucas Alvares Gomes <lucasagomes@gmail.com>
1 parent 31b24e2 commit 098ad30

File tree

20 files changed

+618
-9
lines changed

20 files changed

+618
-9
lines changed

lightspeed-stack.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@ llama_stack:
1515
# library_client_config_path: <path-to-llama-stack-run.yaml-file>
1616
url: http://localhost:8321
1717
api_key: xyzzy
18+
user_data_collection:
19+
feedback_disabled: false
20+
feedback_storage: "/tmp/data/feedback"

pdm.lock

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ dev = [
2323
"pytest>=8.3.2",
2424
"pytest-cov>=5.0.0",
2525
"pytest-mock>=3.14.0",
26+
"pytest-asyncio>=1.0.0",
2627
"pyright>=1.1.401",
2728
"pylint>=3.3.7",
2829
]
2930

3031
[tool.pytest.ini_options]
32+
asyncio_mode = "auto"
3133
pythonpath = [
3234
"src"
3335
]

src/app/endpoints/feedback.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Handler for REST API call to provide info."""
2+
3+
import logging
4+
from typing import Any
5+
from pathlib import Path
6+
import json
7+
from datetime import datetime, UTC
8+
9+
from fastapi import APIRouter, Request, HTTPException, Depends, status
10+
11+
from configuration import configuration
12+
from models.responses import FeedbackResponse, StatusResponse
13+
from models.requests import FeedbackRequest
14+
from utils.suid import get_suid
15+
16+
logger = logging.getLogger(__name__)
17+
router = APIRouter(prefix="/feedback", tags=["feedback"])
18+
19+
# Response for the feedback endpoint
20+
feedback_response: dict[int | str, dict[str, Any]] = {
21+
200: {"response": "Feedback received and stored"},
22+
}
23+
24+
25+
def is_feedback_enabled() -> bool:
26+
"""Check if feedback is enabled.
27+
28+
Returns:
29+
bool: True if feedback is enabled, False otherwise.
30+
"""
31+
return not configuration.user_data_collection_configuration.feedback_disabled
32+
33+
34+
# TODO(lucasagomes): implement this function to retrieve user ID from auth
35+
def retrieve_user_id(auth: Any) -> str: # pylint: disable=unused-argument
36+
"""Retrieve the user ID from the authentication handler.
37+
38+
Args:
39+
auth: The Authentication handler (FastAPI Depends) that will
40+
handle authentication Logic.
41+
42+
Returns:
43+
str: The user ID.
44+
"""
45+
return "user_id_placeholder"
46+
47+
48+
# TODO(lucasagomes): implement this function to handle authentication
49+
async def auth_dependency(_request: Request) -> bool:
50+
"""Authenticate dependency to ensure the user is authenticated.
51+
52+
Args:
53+
request (Request): The FastAPI request object.
54+
55+
Raises:
56+
HTTPException: If the user is not authenticated.
57+
"""
58+
return True
59+
60+
61+
async def assert_feedback_enabled(_request: Request) -> None:
62+
"""Check if feedback is enabled.
63+
64+
Args:
65+
request (Request): The FastAPI request object.
66+
67+
Raises:
68+
HTTPException: If feedback is disabled.
69+
"""
70+
feedback_enabled = is_feedback_enabled()
71+
if not feedback_enabled:
72+
raise HTTPException(
73+
status_code=status.HTTP_403_FORBIDDEN,
74+
detail="Forbidden: Feedback is disabled",
75+
)
76+
77+
78+
@router.post("", responses=feedback_response)
79+
def feedback_endpoint_handler(
80+
_request: Request,
81+
feedback_request: FeedbackRequest,
82+
_ensure_feedback_enabled: Any = Depends(assert_feedback_enabled),
83+
auth: Any = Depends(auth_dependency),
84+
) -> FeedbackResponse:
85+
"""Handle feedback requests.
86+
87+
Args:
88+
feedback_request: The request containing feedback information.
89+
ensure_feedback_enabled: The feedback handler (FastAPI Depends) that
90+
will handle feedback status checks.
91+
auth: The Authentication handler (FastAPI Depends) that will
92+
handle authentication Logic.
93+
94+
Returns:
95+
Response indicating the status of the feedback storage request.
96+
"""
97+
logger.debug("Feedback received %s", str(feedback_request))
98+
99+
user_id = retrieve_user_id(auth)
100+
try:
101+
store_feedback(user_id, feedback_request.model_dump(exclude={"model_config"}))
102+
except Exception as e:
103+
logger.error("Error storing user feedback: %s", e)
104+
raise HTTPException(
105+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
106+
detail={
107+
"response": "Error storing user feedback",
108+
"cause": str(e),
109+
},
110+
) from e
111+
112+
return FeedbackResponse(response="feedback received")
113+
114+
115+
def store_feedback(user_id: str, feedback: dict) -> None:
116+
"""Store feedback in the local filesystem.
117+
118+
Args:
119+
user_id: The user ID (UUID).
120+
feedback: The feedback to store.
121+
"""
122+
logger.debug("Storing feedback for user %s", user_id)
123+
# Creates storage path only if it doesn't exist. The `exist_ok=True` prevents
124+
# race conditions in case of multiple server instances trying to set up storage
125+
# at the same location.
126+
storage_path = Path(
127+
configuration.user_data_collection_configuration.feedback_storage or ""
128+
)
129+
storage_path.mkdir(parents=True, exist_ok=True)
130+
131+
current_time = str(datetime.now(UTC))
132+
data_to_store = {"user_id": user_id, "timestamp": current_time, **feedback}
133+
134+
# stores feedback in a file under unique uuid
135+
feedback_file_path = storage_path / f"{get_suid()}.json"
136+
with open(feedback_file_path, "w", encoding="utf-8") as feedback_file:
137+
json.dump(data_to_store, feedback_file)
138+
139+
logger.info("Feedback stored sucessfully at %s", feedback_file_path)
140+
141+
142+
@router.get("/status")
143+
def feedback_status() -> StatusResponse:
144+
"""Handle feedback status requests.
145+
146+
Returns:
147+
Response indicating the status of the feedback.
148+
"""
149+
logger.debug("Feedback status requested")
150+
feedback_status_enabled = is_feedback_enabled()
151+
return StatusResponse(
152+
functionality="feedback", status={"enabled": feedback_status_enabled}
153+
)

src/app/routers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from fastapi import FastAPI
44

5-
from app.endpoints import info, models, root, query, health, config
5+
from app.endpoints import info, models, root, query, health, config, feedback
66

77

88
def include_routers(app: FastAPI) -> None:
@@ -17,3 +17,4 @@ def include_routers(app: FastAPI) -> None:
1717
app.include_router(query.router, prefix="/v1")
1818
app.include_router(health.router, prefix="/v1")
1919
app.include_router(config.router, prefix="/v1")
20+
app.include_router(feedback.router, prefix="/v1")

src/configuration.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Any, Optional
55

66
import yaml
7-
from models.config import Configuration, LLamaStackConfiguration
7+
from models.config import Configuration, LLamaStackConfiguration, UserDataCollection
88

99
logger = logging.getLogger(__name__)
1010

@@ -51,5 +51,13 @@ def llama_stack_configuration(self) -> LLamaStackConfiguration:
5151
), "logic error: configuration is not loaded"
5252
return self._configuration.llama_stack
5353

54+
@property
55+
def user_data_collection_configuration(self) -> UserDataCollection:
56+
"""Return user data collection configuration."""
57+
assert (
58+
self._configuration is not None
59+
), "logic error: configuration is not loaded"
60+
return self._configuration.user_data_collection
61+
5462

5563
configuration: AppConfig = AppConfig()

src/models/config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,24 @@ def check_llama_stack_model(self) -> Self:
6060
return self
6161

6262

63+
class UserDataCollection(BaseModel):
64+
"""User data collection configuration."""
65+
66+
feedback_disabled: bool = True
67+
feedback_storage: Optional[str] = None
68+
69+
@model_validator(mode="after")
70+
def check_storage_location_is_set_when_needed(self) -> Self:
71+
"""Check that storage_location is set when enabled."""
72+
if not self.feedback_disabled and self.feedback_storage is None:
73+
raise ValueError("feedback_storage is required when feedback is enabled")
74+
return self
75+
76+
6377
class Configuration(BaseModel):
6478
"""Global service configuration."""
6579

6680
name: str
6781
service: ServiceConfiguration
6882
llama_stack: LLamaStackConfiguration
83+
user_data_collection: UserDataCollection

src/models/requests.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
from typing import Optional, Self
44

5-
from pydantic import BaseModel, model_validator
6-
5+
from pydantic import BaseModel, model_validator, field_validator
76
from llama_stack_client.types.agents.turn_create_params import Document
87

8+
from utils import suid
9+
910

1011
class Attachment(BaseModel):
1112
"""Model representing an attachment that can be send from UI as part of query.
@@ -130,3 +131,72 @@ def validate_provider_and_model(self) -> Self:
130131
if self.provider and not self.model:
131132
raise ValueError("Model must be specified if provider is specified")
132133
return self
134+
135+
136+
class FeedbackRequest(BaseModel):
137+
"""Model representing a feedback request.
138+
139+
Attributes:
140+
conversation_id: The required conversation ID (UUID).
141+
user_question: The required user question.
142+
llm_response: The required LLM response.
143+
sentiment: The optional sentiment.
144+
user_feedback: The optional user feedback.
145+
146+
Example:
147+
```python
148+
feedback_request = FeedbackRequest(
149+
conversation_id="12345678-abcd-0000-0123-456789abcdef",
150+
user_question="what are you doing?",
151+
user_feedback="Great service!",
152+
llm_response="I don't know",
153+
sentiment=-1,
154+
)
155+
```
156+
"""
157+
158+
conversation_id: str
159+
user_question: str
160+
llm_response: str
161+
sentiment: Optional[int] = None
162+
user_feedback: Optional[str] = None
163+
164+
# provides examples for /docs endpoint
165+
model_config = {
166+
"json_schema_extra": {
167+
"examples": [
168+
{
169+
"conversation_id": "12345678-abcd-0000-0123-456789abcdef",
170+
"user_question": "foo",
171+
"llm_response": "bar",
172+
"user_feedback": "Great service!",
173+
"sentiment": 1,
174+
}
175+
]
176+
}
177+
}
178+
179+
@field_validator("conversation_id")
180+
@classmethod
181+
def check_uuid(cls, value: str) -> str:
182+
"""Check if conversation ID has the proper format."""
183+
if not suid.check_suid(value):
184+
raise ValueError(f"Improper conversation ID {value}")
185+
return value
186+
187+
@field_validator("sentiment")
188+
@classmethod
189+
def check_sentiment(cls, value: Optional[int]) -> Optional[int]:
190+
"""Check if sentiment value is as expected."""
191+
if value not in {-1, 1, None}:
192+
raise ValueError(
193+
f"Improper sentiment value of {value}, needs to be -1 or 1"
194+
)
195+
return value
196+
197+
@model_validator(mode="after")
198+
def check_sentiment_or_user_feedback_set(self) -> Self:
199+
"""Ensure that either 'sentiment' or 'user_feedback' is set."""
200+
if self.sentiment is None and self.user_feedback is None:
201+
raise ValueError("Either 'sentiment' or 'user_feedback' must be set")
202+
return self

0 commit comments

Comments
 (0)