Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b11f16c
first iteration
ErlingHauan May 7, 2026
2c3bf8f
move feedback endpoint out of agent route
ErlingHauan May 12, 2026
88c85b6
remove unused X-API-KEY logic from AltinityAgentClient
ErlingHauan May 12, 2026
5b8bc3f
fix dialog text and styling
ErlingHauan May 13, 2026
ac24cc0
refactor MessageFeedback and tests
ErlingHauan May 13, 2026
f2fab70
split bundled chat-types imports into per-file imports
ErlingHauan May 13, 2026
24cc893
use centralized mock texts in MessageFeedback.test.tsx
ErlingHauan May 13, 2026
930e405
extract UserFeedback type; simplify onMessageFeedback handlers
ErlingHauan May 13, 2026
43467a2
remove unnecessary error logging
ErlingHauan May 13, 2026
9cfa604
extract decorateMessagesWithTraceIds to utils file
ErlingHauan May 13, 2026
ad008e6
validate developer owns trace before writing feedback to it
ErlingHauan May 13, 2026
f31a524
reset feedback states when closing dialog
ErlingHauan May 14, 2026
f97a73c
fix typing to pass strict null checks
ErlingHauan May 15, 2026
b02142b
use state for dialog
ErlingHauan May 15, 2026
1621480
use StudioFormGroup in MessageFeedback
ErlingHauan May 15, 2026
8077f75
feedback endpoint returns 403 without message
ErlingHauan May 15, 2026
fbe5e42
simplify designer backend code
ErlingHauan May 15, 2026
4e467b9
add cancel button to dialog
ErlingHauan May 15, 2026
4b409b8
make useChatFeedbackMutation take org, app as params
ErlingHauan May 15, 2026
7dc71aa
add tests to Messages
ErlingHauan May 15, 2026
a9d6817
make feedback endpoint idempotent
ErlingHauan May 15, 2026
cdc4560
use PUT/upsert so repeat requests overwrites previous feedback on tha…
ErlingHauan May 18, 2026
b96728d
update texts based on feedback
ErlingHauan May 18, 2026
91c6202
add cancellation token support
ErlingHauan May 18, 2026
db81a9f
small fixes
ErlingHauan May 18, 2026
dece4a1
add required prop messages to test setup
ErlingHauan May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/AI/agents/agents/graph/nodes/assistant_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ def _check_cancelled():
"tools_used": list(tool_results.keys()),
"sources": cited_sources, # Only sources that were actually cited
"mode": "chat",
"trace_id": main_span.trace_id,
"traceId": main_span.trace_id,
}

# Add this Q&A to conversation history for future context
Expand Down
4 changes: 4 additions & 0 deletions src/AI/agents/agents/graph/nodes/reviewer_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from agents.workflows.reviewer.pipeline import (
execute_reviewer_workflow,
)
from shared.utils.langfuse_utils import get_current_trace_id
from shared.utils.logging_utils import get_logger

log = get_logger(__name__)
Expand Down Expand Up @@ -193,6 +194,7 @@ async def handle(state: AgentState) -> AgentState:
"author": "assistant",
"content": summary,
"filesChanged": changed_files,
"traceId": get_current_trace_id(),
},
)
)
Expand Down Expand Up @@ -265,6 +267,7 @@ async def handle(state: AgentState) -> AgentState:
"author": "assistant",
"content": summary,
"filesChanged": changed_files,
"traceId": get_current_trace_id(),
},
)
)
Expand Down Expand Up @@ -300,6 +303,7 @@ async def handle(state: AgentState) -> AgentState:
"author": "assistant",
"content": f"## Error\n\nAn error occurred during processing: {str(e)}",
"filesChanged": [],
"traceId": get_current_trace_id(),
},
)
)
Expand Down
3 changes: 2 additions & 1 deletion src/AI/agents/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

# Get configuration
config = get_config()
from api.routes import register_websocket_routes, agent_router
from api.routes import register_websocket_routes, agent_router, feedback_router

# Configure logging
logging.basicConfig(
Expand Down Expand Up @@ -107,6 +107,7 @@ async def lifespan(app: FastAPI):
# Register routes
register_websocket_routes(app)
app.include_router(agent_router)
app.include_router(feedback_router)


@app.get("/favicon.ico")
Expand Down
3 changes: 2 additions & 1 deletion src/AI/agents/api/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""API routes module"""
from .websocket import register_websocket_routes
from .agent import router as agent_router
from .feedback import router as feedback_router

__all__ = ['register_websocket_routes', 'agent_router']
__all__ = ['register_websocket_routes', 'agent_router', 'feedback_router']
64 changes: 64 additions & 0 deletions src/AI/agents/api/routes/feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""User feedback API routes"""

from typing import Optional

from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, field_validator

from shared.utils.langfuse_utils import get_trace_developer, score_validation
from shared.utils.logging_utils import get_logger

router = APIRouter()
log = get_logger(__name__)

FEEDBACK_SCORE_NAME = "user_feedback"
FEEDBACK_COMMENT_MAX_LENGTH = 10000
DEVELOPER_HEADER = "X-Developer"


class FeedbackReq(BaseModel):
"""User feedback (thumbs up/down) on an assistant message, recorded as a Langfuse score."""

trace_id: str
thumbs_up: bool
comment: Optional[str] = None

@field_validator("trace_id")
@classmethod
def _validate_trace_id(cls, v: str) -> str:
if not v.strip():
raise ValueError("trace_id must be non-empty")
return v

@field_validator("comment")
@classmethod
def _validate_comment(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
if len(v) > FEEDBACK_COMMENT_MAX_LENGTH:
raise ValueError(
f"comment must not exceed {FEEDBACK_COMMENT_MAX_LENGTH} characters"
)
return v
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@router.post("/api/feedback", status_code=204)
async def submit_feedback(req: FeedbackReq, request: Request):
"""Records user feedback as a Langfuse score on the given trace."""
caller = request.headers.get(DEVELOPER_HEADER)
if not caller:
raise HTTPException(
status_code=400, detail=f"Missing {DEVELOPER_HEADER} header"
)

trace_owner = get_trace_developer(req.trace_id)
if trace_owner != caller:
raise HTTPException(status_code=403)

score_validation(
name=FEEDBACK_SCORE_NAME,
passed=req.thumbs_up,
trace_id=req.trace_id,
comment=req.comment,
)
return Response(status_code=204)
68 changes: 58 additions & 10 deletions src/AI/agents/shared/utils/langfuse_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Langfuse tracking utilities"""

from contextlib import contextmanager

from langfuse import Langfuse, get_client

from shared.config.base_config import get_config
from shared.utils.logging_utils import get_logger

Expand All @@ -24,8 +27,8 @@ def init_langfuse():
return None

try:
from opentelemetry.sdk.trace import TracerProvider as _OtelTracerProvider
from opentelemetry import trace as _otel_trace
from opentelemetry.sdk.trace import TracerProvider as _OtelTracerProvider

# Give Langfuse its own dedicated TracerProvider so it is isolated from
# the global OTel provider that third-party libraries (fastmcp) share.
Expand All @@ -47,7 +50,9 @@ def init_langfuse():
_otel_trace.set_tracer_provider(_OtelTracerProvider())

_initialized = True
log.info(f"Langfuse initialized successfully (host: {config.LANGFUSE_HOST}, release: {config.LANGFUSE_RELEASE}, env: {config.LANGFUSE_ENVIRONMENT})")
log.info(
f"Langfuse initialized successfully (host: {config.LANGFUSE_HOST}, release: {config.LANGFUSE_RELEASE}, env: {config.LANGFUSE_ENVIRONMENT})"
)

return _client

Expand Down Expand Up @@ -141,6 +146,7 @@ def flush_langfuse():
except Exception as e:
log.debug(f"Failed to flush Langfuse: {e}")


def score_validation(
name: str,
passed: bool,
Expand Down Expand Up @@ -169,22 +175,28 @@ def score_validation(
if comment:
kwargs["comment"] = comment
client.create_score(**kwargs)
log.debug("Langfuse score '%s' = %s written to trace %s", name, passed, trace_id)
log.debug(
"Langfuse score '%s' = %s written to trace %s", name, passed, trace_id
)
except Exception as e:
log.debug("Failed to create Langfuse score '%s': %s", name, e)


# For backward compatibility with code that expects these functions
# These are no-ops now since Langfuse handles things differently
def start_run_safe(run_name: str = None, **kwargs):
"""
Legacy compatibility function. Langfuse uses traces instead of runs.
Returns a dummy context manager.
"""

class DummyContext:
def __enter__(self):
return self

def __exit__(self, *args):
pass

return DummyContext()


Expand Down Expand Up @@ -225,13 +237,45 @@ def __exit__(self, *args):
pass


def _has_active_trace() -> bool:
"""Return True when a Langfuse trace context is currently active."""
def get_trace_developer(trace_id: str) -> str | None:
"""Return the developer stored on a Langfuse trace's root-span metadata."""
client = get_langfuse_client()
if not client or not trace_id:
return None
try:
trace = client.api.trace.get(trace_id)
except Exception as exc:
log.debug("Langfuse trace lookup failed for %s: %s", trace_id, exc)
return None

observations = getattr(trace, "observations", None) or []
root_observation = next(
(
obs
for obs in observations
if not getattr(obs, "parent_observation_id", None)
),
None,
)
if root_observation is None:
return None
metadata = getattr(root_observation, "metadata", None) or {}
return metadata.get("developer")
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def get_current_trace_id() -> str | None:
if not is_langfuse_enabled():
return None
try:
trace_id = get_client().get_current_trace_id()
return trace_id is not None
return get_client().get_current_trace_id()
except Exception:
return False
log.exception("Failed to get current Langfuse trace ID")
return None


def _has_active_trace() -> bool:
"""Return True when a Langfuse trace context is currently active."""
return get_current_trace_id() is not None


@contextmanager
Expand All @@ -245,7 +289,9 @@ def trace_span(name: str, **kwargs):
yield _NoopSpan()
return

with get_client().start_as_current_observation(as_type="span", name=name, **kwargs) as span:
with get_client().start_as_current_observation(
as_type="span", name=name, **kwargs
) as span:
yield span


Expand All @@ -259,5 +305,7 @@ def trace_generation(name: str, **kwargs):
yield _NoopSpan()
return

with get_client().start_as_current_observation(name=name, as_type="generation", **kwargs) as span:
with get_client().start_as_current_observation(
name=name, as_type="generation", **kwargs
) as span:
yield span
116 changes: 116 additions & 0 deletions src/AI/agents/tests/api/test_feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from unittest.mock import patch

from fastapi.testclient import TestClient

from api.main import app

FEEDBACK_PATH = "/api/feedback"
VALID_TRACE_ID = "trace-abc-123"
DEVELOPER = "ola"
OTHER_DEVELOPER = "kari"
DEVELOPER_HEADER = {"X-Developer": DEVELOPER}


def _post_feedback(payload, headers=None):
return TestClient(app).post(FEEDBACK_PATH, json=payload, headers=headers)


class TestFeedbackEndpoint:
def test_thumbs_up_writes_score_and_returns_204(self):
with (
patch("api.routes.feedback.get_trace_developer", return_value=DEVELOPER),
patch("api.routes.feedback.score_validation") as mock_score,
):
response = _post_feedback(
{"trace_id": VALID_TRACE_ID, "thumbs_up": True},
headers=DEVELOPER_HEADER,
)

assert response.status_code == 204
mock_score.assert_called_once_with(
name="user_feedback",
passed=True,
trace_id=VALID_TRACE_ID,
comment=None,
)

def test_thumbs_down_with_comment_is_forwarded(self):
with (
patch("api.routes.feedback.get_trace_developer", return_value=DEVELOPER),
patch("api.routes.feedback.score_validation") as mock_score,
):
response = _post_feedback(
{
"trace_id": VALID_TRACE_ID,
"thumbs_up": False,
"comment": "Svaret var ikke nyttig.",
},
headers=DEVELOPER_HEADER,
)

assert response.status_code == 204
mock_score.assert_called_once_with(
name="user_feedback",
passed=False,
trace_id=VALID_TRACE_ID,
comment="Svaret var ikke nyttig.",
)

def test_missing_developer_header_returns_400(self):
with patch("api.routes.feedback.score_validation") as mock_score:
response = _post_feedback({"trace_id": VALID_TRACE_ID, "thumbs_up": True})

assert response.status_code == 400
mock_score.assert_not_called()

def test_unknown_trace_owner_returns_403(self):
with (
patch("api.routes.feedback.get_trace_developer", return_value=None),
patch("api.routes.feedback.score_validation") as mock_score,
):
response = _post_feedback(
{"trace_id": VALID_TRACE_ID, "thumbs_up": True},
headers=DEVELOPER_HEADER,
)

assert response.status_code == 403
mock_score.assert_not_called()

def test_owner_mismatch_returns_403(self):
with (
patch(
"api.routes.feedback.get_trace_developer", return_value=OTHER_DEVELOPER
),
patch("api.routes.feedback.score_validation") as mock_score,
):
response = _post_feedback(
{"trace_id": VALID_TRACE_ID, "thumbs_up": True},
headers=DEVELOPER_HEADER,
)

assert response.status_code == 403
mock_score.assert_not_called()

def test_empty_trace_id_returns_422(self):
with patch("api.routes.feedback.score_validation") as mock_score:
response = _post_feedback(
{"trace_id": "", "thumbs_up": True},
headers=DEVELOPER_HEADER,
)

assert response.status_code == 422
mock_score.assert_not_called()

def test_comment_over_max_length_returns_422(self):
with patch("api.routes.feedback.score_validation") as mock_score:
response = _post_feedback(
{
"trace_id": VALID_TRACE_ID,
"thumbs_up": True,
"comment": "x" * 10001,
},
headers=DEVELOPER_HEADER,
)

assert response.status_code == 422
mock_score.assert_not_called()
Loading
Loading