Skip to content

Commit 699048e

Browse files
committed
Fix DeepEvalAdapter to extract multi-turn conversations from service-normalized SESSION format
1 parent 9195576 commit 699048e

5 files changed

Lines changed: 334 additions & 5 deletions

File tree

‎src/bedrock_agentcore/evaluation/custom_code_based_evaluators/third_party/span_mappers/registry.py‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,63 @@ def map_spans(
125125
return result
126126

127127

128+
def _extract_message_text(messages: List[Dict[str, Any]]) -> Optional[str]:
129+
"""Extract text content from service message format.
130+
131+
Handles the nested structure: [{role: ..., content: {content: [{text: ...}]}}]
132+
as well as the variant: [{role: ..., content: {message: [{text: ...}]}}]
133+
"""
134+
for msg in messages:
135+
content = msg.get("content", msg.get("message", {}))
136+
if isinstance(content, dict):
137+
# Unwrap nested content/message key
138+
content = content.get("content", content.get("message", []))
139+
if isinstance(content, list):
140+
text = " ".join(c.get("text", "") for c in content if isinstance(c, dict)).strip()
141+
if text:
142+
return text
143+
elif isinstance(content, str) and content.strip():
144+
return content.strip()
145+
return None
146+
147+
128148
def _extract_from_service_format(session_spans: List[Dict[str, Any]]) -> Optional[SpanMapResult]:
129149
"""Extract fields from service-normalized span format.
130150
131-
The AgentCore evaluation service sends spans with gen_ai semantic convention
132-
events (gen_ai.user.message, gen_ai.choice) instead of body with input/output.
133-
This handles that format as a fallback when strands-evals mappers can't parse it.
151+
Handles two service formats:
152+
1. SESSION format with span_events[*].body (multi-turn conversations where the
153+
service collapses all ADOT spans into one span with multiple span_events)
154+
2. gen_ai semantic convention events (single-turn Strands spans)
134155
"""
135156
import json as _json
136157

158+
# --- Multi-turn: extract from span_events[*].body ---
159+
for span in session_spans:
160+
span_events = span.get("span_events", [])
161+
if len(span_events) >= 1:
162+
turns: List[Dict[str, Any]] = []
163+
last_input = None
164+
last_output = None
165+
for se in span_events:
166+
body = se.get("body", {})
167+
inp_msgs = (body.get("input") or {}).get("messages", [])
168+
out_msgs = (body.get("output") or {}).get("messages", [])
169+
user_text = _extract_message_text(inp_msgs) if inp_msgs else None
170+
asst_text = _extract_message_text(out_msgs) if out_msgs else None
171+
if user_text:
172+
turns.append({"role": "user", "content": user_text})
173+
last_input = user_text
174+
if asst_text:
175+
turns.append({"role": "assistant", "content": asst_text})
176+
last_output = asst_text
177+
if turns and last_input and last_output:
178+
return SpanMapResult(
179+
input=last_input,
180+
actual_output=last_output,
181+
turns=turns if len(turns) > 2 else None,
182+
)
183+
184+
# --- Single-turn: extract from gen_ai semantic convention events ---
137185
for span in session_spans:
138186
scope = span.get("scope", {}).get("name", "")
139187
events = span.get("events", [])

‎tests/bedrock_agentcore/evaluation/custom_code_based_evaluators/third_party/autoevals/test_error_handling.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def test_02_unrecognized_scope(self):
107107
]
108108
adapter = AutoEvalsAdapter(metric=_mock_scorer())
109109
result = adapter(_make_evaluator_input(spans=spans))
110-
_assert_error_response(result, "FIELD_EXTRACTION_ERROR")
110+
_assert_error_response(result, "MISSING_REQUIRED_FIELD")
111111

112112
def test_03_spans_missing_body_input(self):
113113
spans = [

‎tests/bedrock_agentcore/evaluation/custom_code_based_evaluators/third_party/deepeval/test_adapter.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,3 +396,130 @@ def test_conversational_metric_single_turn_returns_error(self):
396396

397397
assert result.errorCode == "FIELD_EXTRACTION_ERROR"
398398
assert "multi-turn" in result.errorMessage.lower() or "Multiple" in result.errorMessage
399+
400+
401+
class TestDeepEvalAdapterServiceNormalizedMultiTurn:
402+
"""Tests for conversational metrics with service-normalized SESSION format.
403+
404+
The AgentCore service collapses multi-turn ADOT docs into one span with
405+
span_events[*].body. These tests verify the adapter correctly extracts
406+
all turns and passes a ConversationalTestCase to the metric.
407+
"""
408+
409+
def _make_session_evaluator_input(self, num_turns=3):
410+
"""Build EvaluatorInput in service-normalized SESSION format."""
411+
span_events = []
412+
for i in range(num_turns):
413+
span_events.append({
414+
"body": {
415+
"input": {
416+
"messages": [
417+
{"role": "user", "content": {"content": [{"text": f"User turn {i+1}"}]}}
418+
]
419+
},
420+
"output": {
421+
"messages": [
422+
{"role": "assistant", "content": {"message": [{"text": f"Bot turn {i+1}"}]}}
423+
]
424+
},
425+
}
426+
})
427+
spans = [
428+
{
429+
"traceId": "t-session",
430+
"spanId": "s-session",
431+
"source": "adot_cw",
432+
"attributes": {"session.id": "multi-turn-session"},
433+
"span_events": span_events,
434+
}
435+
]
436+
return EvaluatorInput(
437+
evaluation_level="SESSION",
438+
session_spans=spans,
439+
)
440+
441+
def test_conversational_metric_receives_all_turns(self):
442+
"""Multi-turn metric gets ConversationalTestCase with correct turn count."""
443+
from deepeval.metrics import BaseConversationalMetric
444+
from deepeval.test_case import ConversationalTestCase
445+
446+
metric = MagicMock(spec=BaseConversationalMetric)
447+
type(metric).__name__ = "GoalAccuracyMetric"
448+
metric.threshold = 0.5
449+
metric.score = 0.9
450+
metric.reason = "Goal achieved"
451+
del metric.success
452+
453+
captured_test_case = {}
454+
455+
def measure_side_effect(test_case):
456+
captured_test_case["tc"] = test_case
457+
metric.score = 0.9
458+
metric.reason = "Goal achieved"
459+
460+
metric.measure = MagicMock(side_effect=measure_side_effect)
461+
adapter = DeepEvalAdapter(metric=metric)
462+
463+
result = adapter(self._make_session_evaluator_input(num_turns=4))
464+
465+
assert result.value == 0.9
466+
assert result.label == "Pass"
467+
tc = captured_test_case["tc"]
468+
assert isinstance(tc, ConversationalTestCase)
469+
assert len(tc.turns) == 8 # 4 user + 4 assistant turns
470+
471+
def test_conversational_metric_turn_content_correct(self):
472+
"""Verify turn content is correctly extracted from nested message format."""
473+
from deepeval.metrics import BaseConversationalMetric
474+
from deepeval.test_case import ConversationalTestCase
475+
476+
metric = MagicMock(spec=BaseConversationalMetric)
477+
type(metric).__name__ = "RoleAdherenceMetric"
478+
metric.threshold = 0.5
479+
metric.score = 1.0
480+
metric.reason = "No violations"
481+
del metric.success
482+
483+
captured_test_case = {}
484+
485+
def measure_side_effect(test_case):
486+
captured_test_case["tc"] = test_case
487+
metric.score = 1.0
488+
489+
metric.measure = MagicMock(side_effect=measure_side_effect)
490+
adapter = DeepEvalAdapter(metric=metric)
491+
492+
result = adapter(self._make_session_evaluator_input(num_turns=2))
493+
494+
assert result.value == 1.0
495+
tc = captured_test_case["tc"]
496+
assert tc.turns[0].role == "user"
497+
assert tc.turns[0].content == "User turn 1"
498+
assert tc.turns[1].role == "assistant"
499+
assert tc.turns[1].content == "Bot turn 1"
500+
assert tc.turns[2].role == "user"
501+
assert tc.turns[2].content == "User turn 2"
502+
assert tc.turns[3].role == "assistant"
503+
assert tc.turns[3].content == "Bot turn 2"
504+
505+
def test_five_turn_session_evaluation(self):
506+
"""Realistic 5-turn session evaluation (matches typical MACE migration)."""
507+
from deepeval.metrics import BaseConversationalMetric
508+
509+
metric = MagicMock(spec=BaseConversationalMetric)
510+
type(metric).__name__ = "ConversationCompletenessMetric"
511+
metric.threshold = 0.5
512+
metric.score = 0.75
513+
metric.reason = "Mostly complete"
514+
del metric.success
515+
516+
metric.measure = MagicMock(side_effect=lambda tc: None)
517+
adapter = DeepEvalAdapter(metric=metric)
518+
519+
result = adapter(self._make_session_evaluator_input(num_turns=5))
520+
521+
assert result.value == 0.75
522+
assert result.label == "Pass"
523+
metric.measure.assert_called_once()
524+
tc = metric.measure.call_args[0][0]
525+
assert len(tc.turns) == 10 # 5 user + 5 assistant

‎tests/bedrock_agentcore/evaluation/custom_code_based_evaluators/third_party/deepeval/test_error_handling.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def test_15_unrecognized_scope_deepeval(self):
185185
]
186186
adapter = DeepEvalAdapter(metric=_mock_metric())
187187
result = adapter(_make_evaluator_input(spans=spans))
188-
_assert_error_response(result, "FIELD_EXTRACTION_ERROR")
188+
_assert_error_response(result, "MISSING_REQUIRED_FIELD")
189189

190190
def test_16_spans_missing_body_input(self):
191191
spans = [

‎tests/bedrock_agentcore/evaluation/custom_code_based_evaluators/third_party/span_mappers/test_span_mappers.py‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,157 @@ def test_span_map_result_fields(self):
112112
assert result.tools_called == [{"name": "tool1", "input_parameters": {"a": 1}, "output": "result"}]
113113
assert result.expected_output is None
114114
assert result.system_prompt is None
115+
116+
117+
def _make_service_normalized_session_spans(num_turns=3):
118+
"""Build service-normalized SESSION format spans (span_events[*].body).
119+
120+
This is the format the AgentCore service sends to Lambda for SESSION-level
121+
evaluators: one span with multiple span_events, each representing a turn.
122+
"""
123+
span_events = []
124+
for i in range(num_turns):
125+
span_events.append({
126+
"body": {
127+
"input": {
128+
"messages": [
129+
{"role": "user", "content": {"content": [{"text": f"User message {i+1}"}]}}
130+
]
131+
},
132+
"output": {
133+
"messages": [
134+
{"role": "assistant", "content": {"message": [{"text": f"Assistant response {i+1}"}]}}
135+
]
136+
},
137+
}
138+
})
139+
return [
140+
{
141+
"traceId": "trace-multi",
142+
"spanId": "span-multi",
143+
"source": "adot_cw",
144+
"attributes": {"session.id": "session-1"},
145+
"span_events": span_events,
146+
}
147+
]
148+
149+
150+
class TestServiceNormalizedMultiTurn:
151+
"""Tests for multi-turn extraction from service-normalized SESSION format."""
152+
153+
def test_extracts_all_turns_from_span_events(self):
154+
spans = _make_service_normalized_session_spans(num_turns=3)
155+
result = map_spans(spans)
156+
157+
assert result.turns is not None
158+
assert len(result.turns) == 6 # 3 user + 3 assistant
159+
assert result.turns[0] == {"role": "user", "content": "User message 1"}
160+
assert result.turns[1] == {"role": "assistant", "content": "Assistant response 1"}
161+
assert result.turns[4] == {"role": "user", "content": "User message 3"}
162+
assert result.turns[5] == {"role": "assistant", "content": "Assistant response 3"}
163+
164+
def test_input_and_output_are_last_turn(self):
165+
spans = _make_service_normalized_session_spans(num_turns=3)
166+
result = map_spans(spans)
167+
168+
assert result.input == "User message 3"
169+
assert result.actual_output == "Assistant response 3"
170+
171+
def test_single_span_event_returns_none_turns(self):
172+
"""Single span_event should NOT populate turns (not multi-turn)."""
173+
spans = _make_service_normalized_session_spans(num_turns=1)
174+
result = map_spans(spans)
175+
176+
# With only 1 turn (2 entries: user+assistant), turns should be None
177+
assert result.turns is None
178+
# But input/output should still be extracted
179+
assert result.input == "User message 1"
180+
assert result.actual_output == "Assistant response 1"
181+
182+
def test_handles_string_content_variant(self):
183+
"""Test spans where content is a plain string instead of list of dicts."""
184+
spans = [
185+
{
186+
"traceId": "t1",
187+
"spanId": "s1",
188+
"attributes": {"session.id": "sess"},
189+
"span_events": [
190+
{
191+
"body": {
192+
"input": {"messages": [{"role": "user", "content": "Hello plain"}]},
193+
"output": {"messages": [{"role": "assistant", "content": "Hi plain"}]},
194+
}
195+
},
196+
{
197+
"body": {
198+
"input": {"messages": [{"role": "user", "content": "Follow up"}]},
199+
"output": {"messages": [{"role": "assistant", "content": "Got it"}]},
200+
}
201+
},
202+
],
203+
}
204+
]
205+
result = map_spans(spans)
206+
207+
assert result.turns is not None
208+
assert len(result.turns) == 4
209+
assert result.turns[0] == {"role": "user", "content": "Hello plain"}
210+
assert result.turns[3] == {"role": "assistant", "content": "Got it"}
211+
212+
def test_handles_nested_content_dict_variant(self):
213+
"""Test the {content: {content: [{text: ...}]}} nesting."""
214+
spans = [
215+
{
216+
"traceId": "t1",
217+
"spanId": "s1",
218+
"attributes": {"session.id": "sess"},
219+
"span_events": [
220+
{
221+
"body": {
222+
"input": {
223+
"messages": [
224+
{"role": "user", "content": {"content": [{"text": "Turn 1 input"}]}}
225+
]
226+
},
227+
"output": {
228+
"messages": [
229+
{"role": "assistant", "content": {"message": [{"text": "Turn 1 output"}]}}
230+
]
231+
},
232+
}
233+
},
234+
{
235+
"body": {
236+
"input": {
237+
"messages": [
238+
{"role": "user", "content": {"content": [{"text": "Turn 2 input"}]}}
239+
]
240+
},
241+
"output": {
242+
"messages": [
243+
{"role": "assistant", "content": {"message": [{"text": "Turn 2 output"}]}}
244+
]
245+
},
246+
}
247+
},
248+
],
249+
}
250+
]
251+
result = map_spans(spans)
252+
253+
assert result.turns is not None
254+
assert len(result.turns) == 4
255+
assert result.turns[0]["content"] == "Turn 1 input"
256+
assert result.turns[1]["content"] == "Turn 1 output"
257+
assert result.turns[2]["content"] == "Turn 2 input"
258+
assert result.turns[3]["content"] == "Turn 2 output"
259+
260+
def test_five_turns_for_session_evaluation(self):
261+
"""Realistic test: 5-turn conversation as sent by the service."""
262+
spans = _make_service_normalized_session_spans(num_turns=5)
263+
result = map_spans(spans)
264+
265+
assert result.turns is not None
266+
assert len(result.turns) == 10
267+
assert result.input == "User message 5"
268+
assert result.actual_output == "Assistant response 5"

0 commit comments

Comments
 (0)