From 28cd6b84eed11a5cd8246129928871252fa820a6 Mon Sep 17 00:00:00 2001 From: Sergey Zeltyn Date: Thu, 4 Jun 2026 15:14:50 +0300 Subject: [PATCH 1/2] fix(analytics): handle new Langfuse trace input format after PR#33 PR#33 removed the eval wrapper span (start_as_current_observation), making the LangGraph callback the trace root. The top-level input is now a list of LangChain message dicts instead of {"intent": ...}, causing AttributeError in the trace comparison pipeline. Co-Authored-By: Claude Sonnet 4.6 --- .../src/langfuse_adapter.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/analytics/trace_comparison_rules/src/langfuse_adapter.py b/analytics/trace_comparison_rules/src/langfuse_adapter.py index 4baca19..0f061db 100644 --- a/analytics/trace_comparison_rules/src/langfuse_adapter.py +++ b/analytics/trace_comparison_rules/src/langfuse_adapter.py @@ -70,8 +70,19 @@ async def load_trace(self, file_path: str) -> TraceIR: # Create trace IR trace = TraceIR(trace_id=trace_id) - # Extract task formulation from top-level input field - trace.task_formulation = data.get("input", {}).get("intent") + # Extract task formulation from top-level input field. + # Old format (pre-PR#33): {"intent": "...", "task_name": "...", ...} + # New format (post-PR#33): list of LangChain message dicts — LangGraph + # callback is now the trace root, so input is the raw message list. + raw_input = data.get("input") + if isinstance(raw_input, dict): + trace.task_formulation = raw_input.get("intent") + elif isinstance(raw_input, list): + # Extract from the first user-role message content + trace.task_formulation = next( + (m.get("content") for m in raw_input if isinstance(m, dict) and m.get("role") == "user"), + None, + ) # Get observations list observations = data.get("observations", []) From 6e0ae6e46a8b728de2d8c90f835956251a393329 Mon Sep 17 00:00:00 2001 From: Sergey Zeltyn Date: Thu, 4 Jun 2026 16:34:28 +0300 Subject: [PATCH 2/2] fix(analytics): guard task_formulation against non-string content Address PR review: LangChain message `content` may be a list (multi-modal blocks), so extracting it directly could break downstream code that calls .strip() and string concatenation on task_formulation. Only assign when the candidate is a string; otherwise leave as None (downstream already handles None with an "Unknown Task" fallback). Co-Authored-By: Claude Sonnet 4.6 --- .../trace_comparison_rules/src/langfuse_adapter.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/analytics/trace_comparison_rules/src/langfuse_adapter.py b/analytics/trace_comparison_rules/src/langfuse_adapter.py index 0f061db..cf0fa0d 100644 --- a/analytics/trace_comparison_rules/src/langfuse_adapter.py +++ b/analytics/trace_comparison_rules/src/langfuse_adapter.py @@ -74,15 +74,19 @@ async def load_trace(self, file_path: str) -> TraceIR: # Old format (pre-PR#33): {"intent": "...", "task_name": "...", ...} # New format (post-PR#33): list of LangChain message dicts — LangGraph # callback is now the trace root, so input is the raw message list. + # LangChain message `content` may be a list (multi-modal blocks), so we + # only assign when the extracted value is a string; downstream code + # calls .strip() / concatenation on task_formulation. raw_input = data.get("input") + candidate: Any = None if isinstance(raw_input, dict): - trace.task_formulation = raw_input.get("intent") + candidate = raw_input.get("intent") elif isinstance(raw_input, list): - # Extract from the first user-role message content - trace.task_formulation = next( + candidate = next( (m.get("content") for m in raw_input if isinstance(m, dict) and m.get("role") == "user"), None, ) + trace.task_formulation = candidate if isinstance(candidate, str) else None # Get observations list observations = data.get("observations", [])