Skip to content

Commit 369fe1d

Browse files
committed
fix: support legacy gen_ai.prompt/completion attributes for Ollama traces
Instrumentors like opentelemetry-instrumentation-ollama emit flat indexed attributes instead of the current gen_ai.input.messages JSON arrays. Without a fallback, user text and agent responses are not extracted from Ollama traces. Closes #88
1 parent bf9fe11 commit 369fe1d

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

src/agentevals/extraction.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,43 @@
5858

5959
FORMAT_DETECTION_SPAN_LIMIT = 10
6060

61+
62+
def _parse_legacy_indexed_attrs(attrs: dict[str, Any], prefix: str) -> list[dict]:
63+
"""Parse flat gen_ai.{prefix}.N.* attributes into a message list."""
64+
messages: dict[int, dict] = {}
65+
for key, value in attrs.items():
66+
if not key.startswith(prefix):
67+
continue
68+
rest = key[len(prefix):]
69+
parts = rest.split(".", 1)
70+
if not parts[0].isdigit():
71+
continue
72+
idx = int(parts[0])
73+
msg = messages.setdefault(idx, {})
74+
if len(parts) < 2:
75+
continue
76+
field = parts[1]
77+
if field == "role":
78+
msg["role"] = value
79+
elif field == "content":
80+
msg["content"] = value
81+
elif field.startswith("tool_calls."):
82+
tc_rest = field[len("tool_calls."):]
83+
tc_parts = tc_rest.split(".", 1)
84+
if not tc_parts[0].isdigit() or len(tc_parts) < 2:
85+
continue
86+
tc_map = msg.setdefault("_tc", {})
87+
tc_map.setdefault(int(tc_parts[0]), {})[tc_parts[1]] = value
88+
result = []
89+
for idx in sorted(messages):
90+
msg = messages[idx].copy()
91+
tc_map = msg.pop("_tc", {})
92+
if tc_map:
93+
msg["tool_calls"] = [tc_map[i] for i in sorted(tc_map)]
94+
result.append(msg)
95+
return result
96+
97+
6198
# ---------------------------------------------------------------------------
6299
# Pure extraction functions (operate on flat attribute dicts)
63100
# ---------------------------------------------------------------------------
@@ -92,6 +129,12 @@ def extract_user_text_from_attrs(attrs: dict[str, Any]) -> str | None:
92129
if text:
93130
return text
94131

132+
for msg in reversed(_parse_legacy_indexed_attrs(attrs, "gen_ai.prompt.")):
133+
if msg.get("role") in USER_ROLES:
134+
text = extract_text_from_message(msg)
135+
if text:
136+
return text
137+
95138
return None
96139

97140

@@ -118,6 +161,12 @@ def extract_agent_response_from_attrs(attrs: dict[str, Any]) -> str | None:
118161
if text:
119162
return text
120163

164+
for msg in reversed(_parse_legacy_indexed_attrs(attrs, "gen_ai.completion.")):
165+
if msg.get("role") in ASSISTANT_ROLES:
166+
text = extract_text_from_message(msg)
167+
if text:
168+
return text
169+
121170
return None
122171

123172

tests/test_extraction.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,3 +802,53 @@ def test_absent_type_and_description(self):
802802
result = extract_tool_call_from_attrs(attrs)
803803
assert "type" not in result
804804
assert "description" not in result
805+
806+
807+
# ---------------------------------------------------------------------------
808+
# Legacy gen_ai.prompt.* / gen_ai.completion.* attributes (Ollama style)
809+
# ---------------------------------------------------------------------------
810+
811+
812+
class TestLegacyGenAIAttributes:
813+
def test_user_text_from_legacy_prompt(self):
814+
attrs = {
815+
"gen_ai.prompt.0.role": "user",
816+
"gen_ai.prompt.0.content": "Hi! Can you help me?",
817+
"gen_ai.request.model": "llama3.2:3b",
818+
}
819+
assert extract_user_text_from_attrs(attrs) == "Hi! Can you help me?"
820+
821+
def test_user_text_prefers_last_user_in_legacy_prompt(self):
822+
attrs = {
823+
"gen_ai.prompt.0.role": "user",
824+
"gen_ai.prompt.0.content": "First message",
825+
"gen_ai.prompt.1.role": "assistant",
826+
"gen_ai.prompt.1.content": "Response",
827+
"gen_ai.prompt.2.role": "user",
828+
"gen_ai.prompt.2.content": "Follow-up",
829+
}
830+
assert extract_user_text_from_attrs(attrs) == "Follow-up"
831+
832+
def test_agent_response_from_legacy_completion(self):
833+
attrs = {
834+
"gen_ai.completion.0.role": "assistant",
835+
"gen_ai.completion.0.content": "You rolled a 4 on a 6-sided die.",
836+
"gen_ai.request.model": "llama3.2:3b",
837+
}
838+
assert extract_agent_response_from_attrs(attrs) == "You rolled a 4 on a 6-sided die."
839+
840+
def test_legacy_prompt_ignored_when_standard_attr_present(self):
841+
attrs = {
842+
OTEL_GENAI_INPUT_MESSAGES: json.dumps([{"role": "user", "content": "Standard wins"}]),
843+
"gen_ai.prompt.0.role": "user",
844+
"gen_ai.prompt.0.content": "Legacy loses",
845+
}
846+
assert extract_user_text_from_attrs(attrs) == "Standard wins"
847+
848+
def test_legacy_completion_ignored_when_standard_attr_present(self):
849+
attrs = {
850+
OTEL_GENAI_OUTPUT_MESSAGES: json.dumps([{"role": "assistant", "content": "Standard wins"}]),
851+
"gen_ai.completion.0.role": "assistant",
852+
"gen_ai.completion.0.content": "Legacy loses",
853+
}
854+
assert extract_agent_response_from_attrs(attrs) == "Standard wins"

0 commit comments

Comments
 (0)