-
Notifications
You must be signed in to change notification settings - Fork 141
fix: DeepEvalAdapter to extract multi-turn conversations from service-normalized SESSION format #622
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix: DeepEvalAdapter to extract multi-turn conversations from service-normalized SESSION format #622
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -125,15 +125,63 @@ def map_spans( | |
| return result | ||
|
|
||
|
|
||
| def _extract_message_text(messages: List[Dict[str, Any]]) -> Optional[str]: | ||
| """Extract text content from service message format. | ||
|
|
||
| Handles the nested structure: [{role: ..., content: {content: [{text: ...}]}}] | ||
| as well as the variant: [{role: ..., content: {message: [{text: ...}]}}] | ||
| """ | ||
| for msg in messages: | ||
| content = msg.get("content", msg.get("message", {})) | ||
| if isinstance(content, dict): | ||
| # Unwrap nested content/message key | ||
| content = content.get("content", content.get("message", [])) | ||
| if isinstance(content, list): | ||
| text = " ".join(c.get("text", "") for c in content if isinstance(c, dict)).strip() | ||
| if text: | ||
| return text | ||
| elif isinstance(content, str) and content.strip(): | ||
| return content.strip() | ||
| return None | ||
|
|
||
|
|
||
| def _extract_from_service_format(session_spans: List[Dict[str, Any]]) -> Optional[SpanMapResult]: | ||
| """Extract fields from service-normalized span format. | ||
|
|
||
| The AgentCore evaluation service sends spans with gen_ai semantic convention | ||
| events (gen_ai.user.message, gen_ai.choice) instead of body with input/output. | ||
| This handles that format as a fallback when strands-evals mappers can't parse it. | ||
| Handles two service formats: | ||
| 1. SESSION format with span_events[*].body (multi-turn conversations where the | ||
| service collapses all ADOT spans into one span with multiple span_events) | ||
| 2. gen_ai semantic convention events (single-turn Strands spans) | ||
| """ | ||
| import json as _json | ||
|
|
||
| # --- Multi-turn: extract from span_events[*].body --- | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P0] I think this still misses the exact one-span/many-event case this PR is trying to fix. When the CloudWatch mapper recognizes the span, it returns valid input/output from the first event, so the fallback in |
||
| for span in session_spans: | ||
| span_events = span.get("span_events", []) | ||
| if len(span_events) >= 1: | ||
| turns: List[Dict[str, Any]] = [] | ||
| last_input = None | ||
| last_output = None | ||
| for se in span_events: | ||
| body = se.get("body", {}) | ||
| inp_msgs = (body.get("input") or {}).get("messages", []) | ||
| out_msgs = (body.get("output") or {}).get("messages", []) | ||
| user_text = _extract_message_text(inp_msgs) if inp_msgs else None | ||
| asst_text = _extract_message_text(out_msgs) if out_msgs else None | ||
| if user_text: | ||
| turns.append({"role": "user", "content": user_text}) | ||
| last_input = user_text | ||
| if asst_text: | ||
| turns.append({"role": "assistant", "content": asst_text}) | ||
| last_output = asst_text | ||
| if turns and last_input and last_output: | ||
| return SpanMapResult( | ||
| input=last_input, | ||
| actual_output=last_output, | ||
| turns=turns if len(turns) > 2 else None, | ||
| ) | ||
|
|
||
| # --- Single-turn: extract from gen_ai semantic convention events --- | ||
| for span in session_spans: | ||
| scope = span.get("scope", {}).get("name", "") | ||
| events = span.get("events", []) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,7 +107,7 @@ def test_02_unrecognized_scope(self): | |
| ] | ||
| adapter = AutoEvalsAdapter(metric=_mock_scorer()) | ||
| result = adapter(_make_evaluator_input(spans=spans)) | ||
| _assert_error_response(result, "FIELD_EXTRACTION_ERROR") | ||
| _assert_error_response(result, "MISSING_REQUIRED_FIELD") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P0] I do not think this expected error code matches the current behavior. The full third-party suite has |
||
|
|
||
| def test_03_spans_missing_body_input(self): | ||
| spans = [ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P0] Could we reuse the existing CloudWatch message parsing here? Real Strands bodies encode user content as
{"content": "[{\"text\": ...}]"}, and multi-turn input can contain interleaved user/assistant history. This helper returns the serialized JSON literally and selects the first historical message. Against three realinvoke_agentevents, it produced six turns but every user turn still contained JSON syntax. PR #454 also established the chronological-role/latest-user behavior, so sharing that parsing logic would keep this path consistent.