Skip to content

Commit 4d32733

Browse files
fix(galileo): preserve structured Luna selector data
1 parent 9aedf2f commit 4d32733

3 files changed

Lines changed: 247 additions & 6 deletions

File tree

‎evaluators/contrib/galileo/src/agent_control_evaluator_galileo/luna/client.py‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from hmac import new as hmac_new
1212
from json import dumps
1313
from time import time
14-
from typing import Literal, get_args
14+
from typing import Any, Literal, get_args
1515
from urllib.parse import urlsplit
1616

1717
import httpx
@@ -58,6 +58,7 @@
5858
"session",
5959
]
6060
SUPPORTED_SCORER_INVOKE_RECORD_TYPES = frozenset(get_args(ScorerInvokeRecordType))
61+
_MISSING_SELECTED_DATA = object()
6162

6263

6364
def _b64url(data: bytes) -> str:
@@ -280,6 +281,8 @@ def _scorer_invoke_record_from_step(
280281
*,
281282
selected_input: JSONValue,
282283
selected_output: JSONValue,
284+
selected_data: Any = _MISSING_SELECTED_DATA,
285+
payload_field: str = "input",
283286
) -> ScorerInvokeRecord | None:
284287
"""Translate a generic Agent Control step into Orbit's record contract.
285288
@@ -303,11 +306,18 @@ def _scorer_invoke_record_from_step(
303306
if step is None:
304307
return None
305308
try:
306-
record = record_from_step(
307-
step,
308-
selected_input=selected_input,
309-
selected_output=selected_output,
310-
)
309+
if selected_data is _MISSING_SELECTED_DATA:
310+
record = record_from_step(
311+
step,
312+
selected_input=selected_input,
313+
selected_output=selected_output,
314+
)
315+
else:
316+
record = record_from_step(
317+
step,
318+
selected_data=selected_data,
319+
payload_field=payload_field,
320+
)
311321
except UnsupportedStepTypeError:
312322
return None
313323
canonical_record = record.model_dump(mode="json", exclude_none=True)
@@ -489,6 +499,8 @@ async def invoke(
489499
input: JSONValue = None,
490500
output: JSONValue = None,
491501
step: Step | None = None,
502+
selected_data: Any = _MISSING_SELECTED_DATA,
503+
selected_data_payload_field: str = "input",
492504
execution_context: GalileoExecutionContext | None = None,
493505
config: ScorerInvokeConfig | JSONObject | None = None,
494506
timeout: float = DEFAULT_TIMEOUT_SECS,
@@ -504,6 +516,9 @@ async def invoke(
504516
input: Optional user/system prompt text.
505517
output: Optional model response text.
506518
step: Optional complete runtime step used for structured dual-write.
519+
selected_data: Raw selector-selected value used to construct the record,
520+
preserving its structure independently from serialized legacy inputs.
521+
selected_data_payload_field: Record input/output side for scalar selector values.
507522
execution_context: Optional authenticated Galileo scorer context.
508523
config: Optional Orbit-supported scorer invocation configuration.
509524
timeout: Request timeout in seconds.
@@ -547,6 +562,8 @@ async def invoke(
547562
step,
548563
selected_input=input,
549564
selected_output=output,
565+
selected_data=selected_data,
566+
payload_field=selected_data_payload_field,
550567
),
551568
execution_context=execution_context,
552569
config=invoke_config,

‎evaluators/contrib/galileo/src/agent_control_evaluator_galileo/luna/evaluator.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ async def _evaluate(
281281
scorer_kwargs = self._scorer_kwargs()
282282
if step is not None:
283283
scorer_kwargs["step"] = step
284+
scorer_kwargs["selected_data"] = data
285+
scorer_kwargs["selected_data_payload_field"] = self.config.payload_field
284286
if execution_context is not None:
285287
scorer_kwargs["execution_context"] = execution_context
286288
response = await self._get_client().invoke(

‎evaluators/contrib/galileo/tests/test_luna_evaluator.py‎

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,220 @@ def handler(request: httpx.Request) -> httpx.Response:
10261026
assert isinstance(nested_spans, list)
10271027
assert nested_spans[0]["name"] == "nested"
10281028

1029+
@patch.dict(os.environ, LUNA_ENV)
1030+
@pytest.mark.asyncio
1031+
async def test_evaluator_posts_trace_with_raw_structured_selector_data(self) -> None:
1032+
from agent_control_evaluator_galileo.luna import LunaEvaluator
1033+
1034+
captured: dict[str, object] = {}
1035+
1036+
def handler(request: httpx.Request) -> httpx.Response:
1037+
captured["body"] = json.loads(request.content.decode())
1038+
return httpx.Response(200, json={"score": 0.8, "status": "success"})
1039+
1040+
step = Step(
1041+
type="trace",
1042+
name="account_lookup_trace",
1043+
input={"messages": [{"role": "user", "content": "Find my account information."}]},
1044+
output={"content": "Your account belongs to Jane Doe."},
1045+
children=[
1046+
Step(
1047+
type="llm",
1048+
name="account_agent",
1049+
input=[{"role": "user", "content": "Find my account information."}],
1050+
output={"content": "Looking up the account."},
1051+
),
1052+
Step(
1053+
type="tool",
1054+
name="lookup_account",
1055+
input={"account_id": "123"},
1056+
output={"name": "Jane Doe"},
1057+
children=[
1058+
Step(
1059+
type="tool",
1060+
name="account_service",
1061+
input={"account_id": "123"},
1062+
output={"name": "Jane Doe"},
1063+
)
1064+
],
1065+
),
1066+
Step(
1067+
type="retriever",
1068+
name="account_docs",
1069+
input={"query": "account 123"},
1070+
output=[{"content": "Account owner: Jane Doe", "metadata": {"id": "d1"}}],
1071+
),
1072+
],
1073+
tools=[{"name": "lookup_account", "description": "Lookup", "input_schema": {}}],
1074+
ground_truth={"owner": "Jane Doe"},
1075+
)
1076+
selected = step.model_dump(mode="json")
1077+
evaluator = LunaEvaluator.from_dict(
1078+
{
1079+
"scorer_id": SCORER_ID,
1080+
"scorer_version_id": SCORER_VERSION_ID,
1081+
"scorer_label": "account-check",
1082+
"threshold": 0.5,
1083+
"config": {"request_timeout_seconds": 6},
1084+
"timeout_ms": 9000,
1085+
}
1086+
)
1087+
evaluator._client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
1088+
1089+
try:
1090+
result = await evaluator.evaluate_with_extensions(
1091+
selected,
1092+
step,
1093+
{
1094+
"namespace_key": "org-1",
1095+
"caller_id": "user-2",
1096+
"target_type": "log_stream",
1097+
"target_id": "run-3",
1098+
"metadata": {"project_id": "project-4"},
1099+
},
1100+
)
1101+
finally:
1102+
await evaluator._client.close()
1103+
1104+
assert result.error is None
1105+
body = captured["body"]
1106+
assert isinstance(body, dict)
1107+
assert body["scorer_id"] == SCORER_ID
1108+
assert body["scorer_version_id"] == SCORER_VERSION_ID
1109+
assert body["scorer_label"] == "account-check"
1110+
assert body["execution_context"] == {
1111+
"organization_id": "org-1",
1112+
"user_id": "user-2",
1113+
"project_id": "project-4",
1114+
"run_id": "run-3",
1115+
}
1116+
assert body["inputs"] == {
1117+
"query": json.dumps(step.input, ensure_ascii=False, sort_keys=True),
1118+
"response": json.dumps(step.output, ensure_ascii=False, sort_keys=True),
1119+
"ground_truth": {"owner": "Jane Doe"},
1120+
"tools": step.tools,
1121+
}
1122+
assert body["config"] == {"request_timeout_seconds": 6.0}
1123+
record = body["record"]
1124+
assert isinstance(record, dict)
1125+
assert record["type"] == "trace"
1126+
assert json.loads(record["input"]) == step.input
1127+
assert json.loads(record["output"]) == step.output
1128+
spans = record["spans"]
1129+
assert isinstance(spans, list)
1130+
assert [child["type"] for child in spans] == ["llm", "tool", "retriever"]
1131+
assert spans[0]["input"][0]["content"] == "Find my account information."
1132+
assert json.loads(spans[1]["input"]) == {"account_id": "123"}
1133+
assert spans[1]["spans"][0]["name"] == "account_service"
1134+
assert spans[2]["output"][0]["content"] == "Account owner: Jane Doe"
1135+
1136+
@patch.dict(os.environ, LUNA_ENV)
1137+
@pytest.mark.asyncio
1138+
async def test_evaluator_posts_session_with_raw_structured_selector_data(self) -> None:
1139+
from agent_control_evaluator_galileo.luna import LunaEvaluator
1140+
1141+
captured: dict[str, object] = {}
1142+
1143+
def handler(request: httpx.Request) -> httpx.Response:
1144+
captured["body"] = json.loads(request.content.decode())
1145+
return httpx.Response(200, json={"score": 0.8, "status": "success"})
1146+
1147+
step = Step(
1148+
type="session",
1149+
name="support_session",
1150+
input={"customer": "Jane Doe"},
1151+
output={"resolution": "account found"},
1152+
children=[
1153+
Step(
1154+
type="trace",
1155+
name="account_lookup",
1156+
input={"query": "account info"},
1157+
output={"owner": "Jane Doe"},
1158+
children=[
1159+
Step(
1160+
type="tool",
1161+
name="lookup_account",
1162+
input={"account_id": "123"},
1163+
output={"name": "Jane Doe"},
1164+
)
1165+
],
1166+
)
1167+
],
1168+
)
1169+
evaluator = LunaEvaluator.from_dict(
1170+
{"scorer_id": SCORER_ID, "scorer_version_id": SCORER_VERSION_ID}
1171+
)
1172+
evaluator._client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
1173+
1174+
try:
1175+
result = await evaluator.evaluate_with_context(step.model_dump(mode="json"), step)
1176+
finally:
1177+
await evaluator._client.close()
1178+
1179+
assert result.error is None
1180+
body = captured["body"]
1181+
assert isinstance(body, dict)
1182+
assert body["inputs"]["query"] == json.dumps(
1183+
step.input, ensure_ascii=False, sort_keys=True
1184+
)
1185+
assert body["inputs"]["response"] == json.dumps(
1186+
step.output, ensure_ascii=False, sort_keys=True
1187+
)
1188+
record = body["record"]
1189+
assert isinstance(record, dict)
1190+
assert record["type"] == "session"
1191+
assert json.loads(record["input"]) == step.input
1192+
assert json.loads(record["output"]) == step.output
1193+
traces = record["traces"]
1194+
assert isinstance(traces, list)
1195+
assert traces[0]["type"] == "trace"
1196+
assert traces[0]["spans"][0]["type"] == "tool"
1197+
1198+
@patch.dict(os.environ, LUNA_ENV)
1199+
@pytest.mark.asyncio
1200+
async def test_evaluator_does_not_post_trace_for_scalar_selector_or_invalid_hierarchy(
1201+
self,
1202+
) -> None:
1203+
from agent_control_evaluator_galileo.luna import LunaEvaluator
1204+
1205+
request_count = 0
1206+
1207+
def handler(_request: httpx.Request) -> httpx.Response:
1208+
nonlocal request_count
1209+
request_count += 1
1210+
return httpx.Response(200, json={"score": 0.8, "status": "success"})
1211+
1212+
evaluator = LunaEvaluator.from_dict(
1213+
{"scorer_id": SCORER_ID, "scorer_version_id": SCORER_VERSION_ID}
1214+
)
1215+
evaluator._client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
1216+
trace = Step(
1217+
type="trace",
1218+
name="trace",
1219+
input={"question": "q"},
1220+
children=[Step(type="llm", name="answer", input="q", output="a")],
1221+
)
1222+
invalid_session = Step(
1223+
type="session",
1224+
name="session",
1225+
input="q",
1226+
children=[Step(type="tool", name="invalid-child", input={"q": "q"})],
1227+
)
1228+
1229+
try:
1230+
scalar_result = await evaluator.evaluate_with_context("response text", trace)
1231+
hierarchy_result = await evaluator.evaluate_with_context(
1232+
invalid_session.model_dump(mode="json"), invalid_session
1233+
)
1234+
finally:
1235+
await evaluator._client.close()
1236+
1237+
assert scalar_result.error is not None
1238+
assert "structured selector values" in scalar_result.error
1239+
assert hierarchy_result.error is not None
1240+
assert "cannot contain 'tool' child steps" in hierarchy_result.error
1241+
assert request_count == 0
1242+
10291243
@pytest.mark.parametrize("field", ["children", "spans", "traces"])
10301244
@pytest.mark.asyncio
10311245
async def test_client_rejects_hierarchy_fields_from_step_context(self, field: str) -> None:
@@ -1247,6 +1461,8 @@ async def test_evaluator_contextual_hook_forwards_complete_step(self) -> None:
12471461
mock_invoke.assert_awaited_once_with(
12481462
scorer_id="scorer-123",
12491463
step=step,
1464+
selected_data="selected input",
1465+
selected_data_payload_field="input",
12501466
input="selected input",
12511467
output=None,
12521468
config=None,
@@ -1284,6 +1500,8 @@ async def test_evaluator_consumes_verified_opaque_extensions(self, target_type:
12841500
mock_invoke.assert_awaited_once_with(
12851501
scorer_id="scorer-123",
12861502
step=Step(type="llm", name="answer", input="prompt"),
1503+
selected_data="selected input",
1504+
selected_data_payload_field="input",
12871505
execution_context=GalileoExecutionContext(
12881506
organization_id="org-1",
12891507
user_id="verified-user-2",
@@ -1323,6 +1541,8 @@ async def test_evaluator_allows_missing_caller_id(self) -> None:
13231541
mock_invoke.assert_awaited_once_with(
13241542
scorer_id="scorer-123",
13251543
step=Step(type="llm", name="answer", input="prompt"),
1544+
selected_data="selected input",
1545+
selected_data_payload_field="input",
13261546
execution_context=GalileoExecutionContext(
13271547
organization_id="org-1",
13281548
user_id=None,
@@ -1363,6 +1583,8 @@ async def test_evaluator_omits_run_id_for_unsupported_target_type(self) -> None:
13631583
mock_invoke.assert_awaited_once_with(
13641584
scorer_id="scorer-123",
13651585
step=Step(type="llm", name="answer", input="prompt"),
1586+
selected_data="selected input",
1587+
selected_data_payload_field="input",
13661588
execution_context=GalileoExecutionContext(
13671589
organization_id="org-1",
13681590
user_id="verified-user-2",

0 commit comments

Comments
 (0)