Skip to content

Commit bb355b3

Browse files
authored
Merge pull request #1672 from macekond/feat/eval-conversation-tracking
feat(eval): conversation tracking and --preserve-failed flag
2 parents 726a0b0 + 63a79b6 commit bb355b3

9 files changed

Lines changed: 256 additions & 7 deletions

File tree

packages/gooddata-eval/src/gooddata_eval/cli/main.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ def _build_parser() -> argparse.ArgumentParser:
9898
)
9999
run.add_argument("--json", dest="json_path", help="Write a JSON report to this path.")
100100
run.add_argument("--quiet", action="store_true", help="Suppress per-item progress output.")
101+
run.add_argument(
102+
"--preserve-failed",
103+
action="store_true",
104+
dest="preserve_failed",
105+
help="Keep failed conversations on the server for post-mortem inspection.",
106+
)
101107
run.add_argument(
102108
"--langfuse",
103109
action="store_true",
@@ -331,7 +337,12 @@ def on_langfuse_item_done(
331337

332338
# --- non-agentic items (single-turn, use Evaluator) ---
333339
backend = _RoutingBackend(
334-
ChatClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
340+
ChatClient(
341+
host=config.host,
342+
token=config.token,
343+
workspace_id=config.workspace_id,
344+
preserve_failed=config.preserve_failed,
345+
),
335346
SummaryClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
336347
)
337348
try:
@@ -421,6 +432,7 @@ def main(argv: list[str] | None = None) -> int:
421432
log_to_langfuse=args.langfuse,
422433
quiet=args.quiet,
423434
kind=args.kind,
435+
preserve_failed=args.preserve_failed,
424436
)
425437
return _run(config)
426438
except (

packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ class _SseAccumulator:
106106
call_id_to_event_index: dict[str, int] = field(default_factory=dict)
107107
reasoning_steps: list[dict[str, Any]] = field(default_factory=list)
108108
adhoc_viz_args: list[dict[str, Any]] = field(default_factory=list)
109+
response_id: str | None = None
109110

110111

111112
def _handle_text(content: dict[str, Any], acc: _SseAccumulator) -> None:
@@ -176,7 +177,9 @@ def _build_chat_result(acc: _SseAccumulator) -> ChatResult:
176177
"objects": [acc.adhoc_viz_args[-1]],
177178
"reasoning": "\n".join(acc.viz_reasoning_parts),
178179
}
179-
return ChatResult.model_validate(payload)
180+
result = ChatResult.model_validate(payload)
181+
result.response_id = acc.response_id
182+
return result
180183

181184

182185
def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
@@ -204,9 +207,13 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
204207
if code in _RETRYABLE_STATUS_CODES:
205208
raise TransientChatError(message, status_code=code, detail=detail)
206209
raise ChatError(message, status_code=code, detail=detail)
210+
if event_data.get("responseId") and not acc.response_id:
211+
acc.response_id = event_data["responseId"]
207212
item = event_data.get("item")
208213
if not item:
209214
continue
215+
if item.get("responseId") and not acc.response_id:
216+
acc.response_id = item["responseId"]
210217
role = item.get("role")
211218
content: dict[str, Any] = item.get("content") or {}
212219
ctype = content.get("type")
@@ -227,10 +234,13 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
227234
class ChatClient:
228235
"""Single-turn AI chat client over the GoodData AI conversation endpoints."""
229236

230-
def __init__(self, host: str, token: str, workspace_id: str, *, timeout: float = 300.0):
237+
def __init__(
238+
self, host: str, token: str, workspace_id: str, *, timeout: float = 300.0, preserve_failed: bool = False
239+
):
231240
self._base = f"{host.rstrip('/')}/api/v1/ai/workspaces/{workspace_id}/chat/conversations"
232241
self._auth = {"Authorization": f"Bearer {token}"}
233242
self._client = httpx.Client(timeout=timeout)
243+
self._preserve_failed = preserve_failed
234244

235245
def create_conversation(self) -> str:
236246
def _do() -> str:
@@ -264,12 +274,29 @@ def _do() -> ChatResult:
264274
return _retry_transient(_do, is_retryable=_is_retryable_exc)
265275

266276
def ask(self, item: DatasetItem) -> ChatResult:
267-
"""Run one single-turn conversation: create, send, parse, clean up."""
277+
"""Run one conversation: create, send, parse, clean up.
278+
279+
The conversation_id is attached to the returned ChatResult for tracing.
280+
When ``preserve_failed`` is set, failed conversations are kept on the
281+
server so they can be inspected after the run; the conversation_id is
282+
attached to the raised exception as well.
283+
"""
268284
conversation_id = self.create_conversation()
285+
success = False
269286
try:
270-
return self.send_message(conversation_id, item.question)
287+
result = self.send_message(conversation_id, item.question)
288+
result.conversation_id = conversation_id
289+
success = True
290+
return result
291+
except Exception as exc:
292+
try:
293+
object.__setattr__(exc, "conversation_id", conversation_id)
294+
except TypeError:
295+
pass # C-extension exception that rejects __setattr__
296+
raise
271297
finally:
272-
self.delete_conversation(conversation_id)
298+
if success or not self._preserve_failed:
299+
self.delete_conversation(conversation_id)
273300

274301
def close(self) -> None:
275302
self._client.close()

packages/gooddata-eval/src/gooddata_eval/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ class RunConfig:
1919
log_to_langfuse: bool = False
2020
quiet: bool = False
2121
kind: str = "visualization"
22+
preserve_failed: bool = False

packages/gooddata-eval/src/gooddata_eval/core/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ class ChatResult(BaseModel):
9494
created_visualizations: CreatedVisualizations | None = Field(default=None, alias="createdVisualizations")
9595
tool_call_events: list[ToolCallEvent] = Field(default_factory=list, alias="toolCallEvents")
9696
reasoning_step_count: int = Field(default=0, alias="reasoningStepCount")
97+
conversation_id: str | None = Field(default=None, alias="conversationId")
98+
response_id: str | None = Field(default=None, alias="responseId")
9799

98100

99101
class SummaryInput(BaseModel):

packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def _build_run_dict(report: EvalReport) -> dict:
3434
"latency_s": round(item.latency_s, 3),
3535
"avg_latency_s": round(item.avg_latency_s, 3),
3636
"detail": item.best_detail,
37+
"conversation_id": item.conversation_id,
38+
"response_id": item.response_id,
3739
}
3840
for item in report.items
3941
},

packages/gooddata-eval/src/gooddata_eval/core/runner.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class ItemReport:
3131
runs: int = 0
3232
latency_s: float = 0.0 # total wall-clock across this item's runs
3333
best_detail: dict = field(default_factory=dict)
34+
conversation_id: str | None = None
35+
response_id: str | None = None
3436

3537
@property
3638
def avg_latency_s(self) -> float:
@@ -112,6 +114,8 @@ def _run_one_item(
112114
for run_index in range(1, runs + 1):
113115
t0 = time.perf_counter()
114116
chat_result = backend.ask(item)
117+
report.conversation_id = getattr(chat_result, "conversation_id", None) or report.conversation_id
118+
report.response_id = getattr(chat_result, "response_id", None) or report.response_id
115119
evaluation = evaluator.evaluate(item, chat_result)
116120
latency = time.perf_counter() - t0
117121
report.runs += 1
@@ -123,7 +127,9 @@ def _run_one_item(
123127
if on_run_done is not None:
124128
on_run_done(run_index, runs, evaluation.passed, latency)
125129
except Exception as e: # agent/network/parse failure for this item
126-
report.error = f"{type(e).__name__}: {e}"
130+
conv_id = getattr(e, "conversation_id", None)
131+
report.conversation_id = conv_id or report.conversation_id
132+
report.error = f"{type(e).__name__}: {e}" + (f" [conversation_id={conv_id}]" if conv_id else "")
127133
if best is not None:
128134
report.best_detail = best.detail
129135
return report

packages/gooddata-eval/tests/test_cli.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,62 @@ def test_cli_rejects_zero_concurrency(monkeypatch, fixtures_dir):
512512
assert exit_code == 2
513513

514514

515+
def test_cli_preserve_failed_flag_parsed(monkeypatch, fixtures_dir):
516+
"""--preserve-failed sets preserve_failed=True in RunConfig and is passed to ChatClient."""
517+
monkeypatch.setattr(cli_main, "resolve_connection", lambda host, token, profile: ("https://h", "tok"))
518+
captured_kwargs: dict = {}
519+
520+
class _FakeController:
521+
def __init__(self, *a, **k): ...
522+
def get_active(self):
523+
return ActiveLlmProvider(provider_id="p", default_model_id="gpt-5.2")
524+
525+
def resolve_and_activate(self, requested, provider=None):
526+
return ResolvedModel(provider_id="p", model_id="gpt-5.2", switched=False, provider_name="P")
527+
528+
def restore(self, original): ...
529+
def close(self): ...
530+
531+
monkeypatch.setattr(cli_main, "WorkspaceModelController", _FakeController)
532+
533+
original_chat_client = cli_main.ChatClient
534+
535+
def _capture_chat_client(**kwargs):
536+
captured_kwargs.update(kwargs)
537+
return object()
538+
539+
monkeypatch.setattr(cli_main, "ChatClient", _capture_chat_client)
540+
541+
def _fake_run(items, backend, *, runs, model, workspace_id, **kw):
542+
return EvalReport(
543+
model=model,
544+
workspace_id=workspace_id,
545+
items=[
546+
ItemReport(id="i1", dataset_name="d", test_kind="visualization", question="q", pass_at_k=True, runs=1)
547+
],
548+
)
549+
550+
monkeypatch.setattr(cli_main, "run_items", _fake_run)
551+
552+
exit_code = cli_main.main(
553+
[
554+
"run",
555+
"--host",
556+
"https://h",
557+
"--token",
558+
"tok",
559+
"--workspace",
560+
"ws1",
561+
"--dataset",
562+
str(fixtures_dir / "sample_dataset"),
563+
"--preserve-failed",
564+
"--quiet",
565+
]
566+
)
567+
assert exit_code == 0
568+
assert captured_kwargs.get("preserve_failed") is True
569+
570+
515571
def test_cli_rejects_negative_concurrency(monkeypatch, fixtures_dir):
516572
monkeypatch.setattr(cli_main, "resolve_connection", lambda host, token, profile: ("https://h", "tok"))
517573
exit_code = cli_main.main(

packages/gooddata-eval/tests/test_runner.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,31 @@ def on_done(index: int, total: int, report: ItemReport) -> None:
233233
assert sorted(done_ids) == [f"c{i}" for i in range(5)]
234234

235235

236+
def test_run_items_error_report_includes_conversation_id():
237+
"""When an exception has a conversation_id, it appears in the error report."""
238+
239+
class _ConvIdBackend:
240+
def ask(self, item: DatasetItem) -> ChatResult:
241+
exc = RuntimeError("agent error")
242+
exc.conversation_id = "conv-xyz" # type: ignore[attr-defined]
243+
raise exc
244+
245+
report = run_items([_item()], _ConvIdBackend(), runs=1)
246+
assert report.errored == 1
247+
assert "conversation_id=conv-xyz" in report.items[0].error
248+
249+
250+
def test_run_items_error_report_omits_conversation_id_when_absent():
251+
"""When no conversation_id is set, the error string has no bracket suffix."""
252+
253+
class _PlainBackend:
254+
def ask(self, item: DatasetItem) -> ChatResult:
255+
raise RuntimeError("plain error")
256+
257+
report = run_items([_item()], _PlainBackend(), runs=1)
258+
assert "conversation_id" not in report.items[0].error
259+
260+
236261
def test_run_items_callback_exception_is_logged_not_swallowed(capsys):
237262
"""A raising callback prints a traceback to stderr but the run continues."""
238263
backend = _FakeBackend([_chat_with(_viz_obj())] * 2)

packages/gooddata-eval/tests/test_sse_client.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66
from gooddata_eval.core.chat import sse_client as sse_mod
77
from gooddata_eval.core.chat.sse_client import ChatClient, ChatError, TransientChatError, parse_sse_lines
8+
from gooddata_eval.core.models import DatasetItem
89

910

1011
def test_parse_sse_lines_collects_text_and_visualization(fixtures_dir):
@@ -222,3 +223,120 @@ def test_float_env_uses_default_when_unset(monkeypatch):
222223
def test_float_env_reads_override(monkeypatch):
223224
monkeypatch.setenv("GD_TEST_FLOAT", "1.5")
224225
assert sse_mod._float_env("GD_TEST_FLOAT", 5.0) == 1.5
226+
227+
228+
def test_parse_sse_lines_captures_response_id_from_event_data():
229+
"""response_id is captured from the top-level event_data."""
230+
lines = [
231+
'data: {"responseId": "resp-123", "item": {"role": "assistant", "content": {"type": "text", "text": "hi"}}}',
232+
]
233+
result = parse_sse_lines(lines)
234+
assert result.response_id == "resp-123"
235+
236+
237+
def test_parse_sse_lines_captures_response_id_from_item():
238+
"""response_id is captured from item when not present at top level."""
239+
lines = [
240+
'data: {"item": {"role": "assistant", "responseId": "resp-456", "content": {"type": "text", "text": "hi"}}}',
241+
]
242+
result = parse_sse_lines(lines)
243+
assert result.response_id == "resp-456"
244+
245+
246+
def test_parse_sse_lines_first_response_id_wins():
247+
"""Only the first responseId encountered is kept."""
248+
lines = [
249+
'data: {"responseId": "first", "item": {"role": "assistant", "content": {"type": "text", "text": "a"}}}',
250+
'data: {"responseId": "second", "item": {"role": "assistant", "content": {"type": "text", "text": "b"}}}',
251+
]
252+
result = parse_sse_lines(lines)
253+
assert result.response_id == "first"
254+
255+
256+
def test_ask_attaches_conversation_id_to_result(monkeypatch):
257+
"""ChatClient.ask() sets conversation_id on the returned ChatResult."""
258+
calls = []
259+
260+
def handler(request):
261+
if request.method == "POST" and request.url.path.endswith("/conversations"):
262+
return httpx.Response(200, json={"conversationId": "conv-abc"})
263+
if request.method == "POST" and "messages" in str(request.url):
264+
return httpx.Response(200, content=_OK_SSE)
265+
if request.method == "DELETE":
266+
calls.append("delete")
267+
return httpx.Response(204)
268+
return httpx.Response(404)
269+
270+
client = _client_with_handler(handler)
271+
item = DatasetItem(id="t1", dataset_name="d", test_kind="visualization", question="q", expected_output={})
272+
result = client.ask(item)
273+
assert result.conversation_id == "conv-abc"
274+
assert "delete" in calls # conversation cleaned up on success
275+
276+
277+
def test_ask_preserve_failed_keeps_conversation_on_error(monkeypatch):
278+
"""With preserve_failed=True, failed conversations are not deleted."""
279+
calls = []
280+
281+
def handler(request):
282+
if request.method == "POST" and request.url.path.endswith("/conversations"):
283+
return httpx.Response(200, json={"conversationId": "conv-fail"})
284+
if request.method == "POST" and "messages" in str(request.url):
285+
return httpx.Response(200, content=_NONRETRY_SSE)
286+
if request.method == "DELETE":
287+
calls.append("delete")
288+
return httpx.Response(204)
289+
return httpx.Response(404)
290+
291+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: None)
292+
client = ChatClient(host="https://example.invalid", token="t", workspace_id="w", preserve_failed=True)
293+
client._client = httpx.Client(transport=httpx.MockTransport(handler))
294+
295+
item = DatasetItem(id="t1", dataset_name="d", test_kind="visualization", question="q", expected_output={})
296+
with pytest.raises(ChatError):
297+
client.ask(item)
298+
assert "delete" not in calls # conversation preserved
299+
300+
301+
def test_ask_without_preserve_failed_deletes_on_error(monkeypatch):
302+
"""Without preserve_failed, conversations are deleted even on error."""
303+
calls = []
304+
305+
def handler(request):
306+
if request.method == "POST" and request.url.path.endswith("/conversations"):
307+
return httpx.Response(200, json={"conversationId": "conv-del"})
308+
if request.method == "POST" and "messages" in str(request.url):
309+
return httpx.Response(200, content=_NONRETRY_SSE)
310+
if request.method == "DELETE":
311+
calls.append("delete")
312+
return httpx.Response(204)
313+
return httpx.Response(404)
314+
315+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: None)
316+
client = _client_with_handler(handler)
317+
318+
item = DatasetItem(id="t1", dataset_name="d", test_kind="visualization", question="q", expected_output={})
319+
with pytest.raises(ChatError):
320+
client.ask(item)
321+
assert "delete" in calls # conversation deleted
322+
323+
324+
def test_ask_attaches_conversation_id_to_exception(monkeypatch):
325+
"""On failure, conversation_id is attached to the raised exception."""
326+
327+
def handler(request):
328+
if request.method == "POST" and request.url.path.endswith("/conversations"):
329+
return httpx.Response(200, json={"conversationId": "conv-exc"})
330+
if request.method == "POST" and "messages" in str(request.url):
331+
return httpx.Response(200, content=_NONRETRY_SSE)
332+
if request.method == "DELETE":
333+
return httpx.Response(204)
334+
return httpx.Response(404)
335+
336+
monkeypatch.setattr(sse_mod.time, "sleep", lambda s: None)
337+
client = _client_with_handler(handler)
338+
339+
item = DatasetItem(id="t1", dataset_name="d", test_kind="visualization", question="q", expected_output={})
340+
with pytest.raises(ChatError) as ei:
341+
client.ask(item)
342+
assert ei.value.conversation_id == "conv-exc"

0 commit comments

Comments
 (0)