gooddata-eval 1.77.1.dev2__py3-none-any.whl → 1.78.1.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. gooddata_eval/cli/agentic_runner.py +30 -0
  2. gooddata_eval/cli/main.py +21 -11
  3. gooddata_eval/core/_retry.py +71 -0
  4. gooddata_eval/core/agentic/_conversation_context.py +59 -10
  5. gooddata_eval/core/agentic/alert_skill.py +7 -0
  6. gooddata_eval/core/agentic/anomaly_detection.py +7 -0
  7. gooddata_eval/core/agentic/conversation.py +14 -0
  8. gooddata_eval/core/agentic/dashboard_skill.py +6 -0
  9. gooddata_eval/core/agentic/document_skill.py +7 -0
  10. gooddata_eval/core/agentic/general_question.py +11 -2
  11. gooddata_eval/core/agentic/guardrail.py +11 -2
  12. gooddata_eval/core/agentic/kda_skill.py +7 -0
  13. gooddata_eval/core/agentic/metric_skill.py +7 -0
  14. gooddata_eval/core/agentic/search_tool.py +9 -0
  15. gooddata_eval/core/agentic/visualization.py +8 -0
  16. gooddata_eval/core/agentic/what_if.py +7 -0
  17. gooddata_eval/core/chat/sse_client.py +28 -59
  18. gooddata_eval/core/dataset/langfuse_source.py +29 -2
  19. gooddata_eval/core/evaluators/__init__.py +5 -2
  20. gooddata_eval/core/evaluators/_llm_judge.py +1 -0
  21. gooddata_eval/core/evaluators/_rubric.py +94 -0
  22. gooddata_eval/core/evaluators/summary.py +12 -64
  23. gooddata_eval/core/evaluators/takeaway.py +245 -0
  24. gooddata_eval/core/interaction.py +57 -0
  25. gooddata_eval/core/models.py +47 -0
  26. gooddata_eval/core/reporting/console.py +52 -0
  27. gooddata_eval/core/reporting/html_report.py +40 -5
  28. gooddata_eval/core/reporting/json_report.py +19 -0
  29. gooddata_eval/core/reporting/report_template.html +1700 -192
  30. gooddata_eval/core/runner.py +67 -1
  31. gooddata_eval/core/summary/http_client.py +11 -1
  32. gooddata_eval/core/takeaway/__init__.py +1 -0
  33. gooddata_eval/core/takeaway/evaluate.py +275 -0
  34. gooddata_eval/core/takeaway/http_client.py +104 -0
  35. gooddata_eval/core/timing.py +110 -0
  36. {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/METADATA +256 -24
  37. {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/RECORD +40 -33
  38. {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/WHEEL +0 -0
  39. {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/entry_points.txt +0 -0
  40. {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  import sys
7
7
  import time
8
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from datetime import datetime, timezone
9
10
  from typing import Any, TypedDict
10
11
 
11
12
  from gooddata_eval.core.agentic._gate import DEFAULT_GATE, EvalGate, normalize_gate
@@ -28,6 +29,7 @@ from gooddata_eval.core.evaluators._llm_judge import JudgeResponseError
28
29
  from gooddata_eval.core.evaluators.visualization import requires_execution_of
29
30
  from gooddata_eval.core.models import AgenticEvalOutcome, CreatedVisualization, DatasetItem
30
31
  from gooddata_eval.core.runner import EvalReport, ItemReport
32
+ from gooddata_eval.core.timing import TraceRecorder
31
33
 
32
34
 
33
35
  class _LfKw(TypedDict, total=False):
@@ -375,6 +377,18 @@ def _apply_failed_runs(item_report: ItemReport, source: Any) -> None:
375
377
  item_report.failed_runs = list(failed_runs)
376
378
 
377
379
 
380
+ def _apply_run_latencies(item_report: ItemReport, source: Any) -> None:
381
+ """Copy the per-run wall-clock times off an outcome or a failure, if the kind timed them.
382
+
383
+ Read from both like ``_apply_failed_runs``: a failing item reaches the report through the
384
+ exception. A kind that records none leaves the empty default, so the item adds no sample
385
+ to the latency distribution rather than a zero.
386
+ """
387
+ run_latencies = getattr(source, "run_latencies_s", None)
388
+ if run_latencies:
389
+ item_report.run_latencies_s = list(run_latencies)
390
+
391
+
378
392
  def _apply_timings(item_report: ItemReport, timings: Any) -> None:
379
393
  """Copy an outcome's phase breakdown onto the item report, if the kind recorded one.
380
394
 
@@ -429,6 +443,7 @@ def run_agentic_items(
429
443
  # latency. Drained below before this function returns, so every score is written
430
444
  # before the caller renders a report or decides an exit code.
431
445
  linker = BackgroundTraceLinker()
446
+ report.started_at = datetime.now(timezone.utc)
432
447
  _t0 = time.perf_counter()
433
448
 
434
449
  def _process_item(index: int, item: DatasetItem) -> ItemReport:
@@ -447,6 +462,10 @@ def run_agentic_items(
447
462
  # None, not False, for the kinds _dispatch_agentic passes no gate to: ItemReport.passed
448
463
  # then falls back to pass_at_k and gate_passed keeps meaning "a gate ran".
449
464
  gated = item.test_kind not in UNGATED_AGENTIC_TEST_KINDS
465
+ # Started in this worker, where the item runs, so a concurrent item cannot write into it.
466
+ # Every kind reaches the chat client the same way, which is why the per-trace latencies are
467
+ # collected here once rather than handed out by each evaluator.
468
+ traces = TraceRecorder()
450
469
  t0 = time.perf_counter()
451
470
  try:
452
471
  outcome = _dispatch_agentic(
@@ -482,6 +501,7 @@ def run_agentic_items(
482
501
  _apply_timings(item_report, getattr(outcome, "timings", None))
483
502
  _apply_run_counts(item_report, outcome)
484
503
  _apply_failed_runs(item_report, outcome)
504
+ _apply_run_latencies(item_report, outcome)
485
505
  except AssertionError as exc:
486
506
  item_report.gate_passed = False if gated else None
487
507
  item_report.runs = k
@@ -492,6 +512,7 @@ def run_agentic_items(
492
512
  _apply_timings(item_report, getattr(exc, "timings", None))
493
513
  _apply_run_counts(item_report, exc)
494
514
  _apply_failed_runs(item_report, exc)
515
+ _apply_run_latencies(item_report, exc)
495
516
  # Read off the counts, not off the gate: pass^K fails items where runs did pass,
496
517
  # and reporting those as pass_at_k False would contradict the Langfuse score of
497
518
  # the same name. Kinds that report no count read as 0, i.e. a clean failure.
@@ -512,6 +533,7 @@ def run_agentic_items(
512
533
  _apply_timings(item_report, getattr(exc, "timings", None))
513
534
  _apply_run_counts(item_report, exc)
514
535
  _apply_failed_runs(item_report, exc)
536
+ _apply_run_latencies(item_report, exc)
515
537
  # Every run ungraded, by definition of this error -- said explicitly rather than
516
538
  # left to _apply_run_counts, whose source is detail["unscored_runs"] and which a
517
539
  # kind that attaches no diagnostics would leave at 0.
@@ -525,8 +547,15 @@ def run_agentic_items(
525
547
  # judge broke should not also report the agent as costing 0s. Kinds that
526
548
  # attach no timings to the exception keep their 0.0 defaults.
527
549
  _apply_timings(item_report, getattr(exc, "timings", None))
550
+ # Phase timings stay, but the per-turn response times do not: this item reports no
551
+ # runs (and so no run latencies), and keeping its turns would put them in the
552
+ # response population with no attempt beside them. An item that completed 2 of 3
553
+ # runs and then raised would otherwise show up in the per-response table and not in
554
+ # the per-conversation one.
555
+ traces.discard()
528
556
  finally:
529
557
  item_report.latency_s = time.perf_counter() - t0
558
+ item_report.trace_latencies_s = traces.stop()
530
559
 
531
560
  if on_item_done is not None:
532
561
  try:
@@ -607,6 +636,7 @@ def run_agentic_items(
607
636
  flush=True,
608
637
  )
609
638
  report.wall_clock_s = time.perf_counter() - _t0
639
+ report.finished_at = datetime.now(timezone.utc)
610
640
 
611
641
  if langfuse is not None:
612
642
  try:
gooddata_eval/cli/main.py CHANGED
@@ -7,7 +7,7 @@ import sys
7
7
  import threading
8
8
  from datetime import datetime, timezone
9
9
  from pathlib import Path
10
- from typing import get_args
10
+ from typing import Any, get_args
11
11
 
12
12
  import httpx
13
13
  from gooddata_api_client.exceptions import ApiException, ApiTypeError
@@ -36,32 +36,31 @@ from gooddata_eval.core.reporting.html_report import load_report_files, write_ht
36
36
  from gooddata_eval.core.reporting.json_report import build_multi_model_report, write_multi_model_report
37
37
  from gooddata_eval.core.runner import ItemReport, run_items
38
38
  from gooddata_eval.core.summary.http_client import SummaryClient
39
+ from gooddata_eval.core.takeaway.http_client import TakeawayClient
39
40
  from gooddata_eval.core.timing import TIMERS_ENV_VAR
40
41
  from gooddata_eval.core.workspace import ModelResolutionError, WorkspaceModelController
41
42
 
42
43
  _EXIT_OK = 0
43
44
  _EXIT_OPERATIONAL_ERROR = 2
44
- _SUMMARY_TEST_KIND = "dashboard_summary"
45
45
 
46
46
 
47
47
  class _RoutingBackend:
48
48
  """Dispatch each item to the right backend by test_kind.
49
49
 
50
- `dashboard_summary` items go to the dedicated summary endpoint; everything
51
- else uses the conversational chat endpoint.
50
+ Kinds in `by_kind` go to their dedicated endpoint client (`dashboard_summary` to the
51
+ summary endpoint, `document_takeaway` to the takeaway endpoint); everything else uses
52
+ the conversational chat endpoint.
52
53
  """
53
54
 
54
- def __init__(self, chat: ChatClient, summary: SummaryClient):
55
+ def __init__(self, chat: ChatClient, by_kind: dict[str, Any]):
55
56
  self._chat = chat
56
- self._summary = summary
57
+ self._by_kind = by_kind
57
58
 
58
59
  def ask(self, item: DatasetItem) -> ChatResult:
59
- if item.test_kind == _SUMMARY_TEST_KIND:
60
- return self._summary.ask(item)
61
- return self._chat.ask(item)
60
+ return self._by_kind.get(item.test_kind, self._chat).ask(item)
62
61
 
63
62
  def close(self) -> None:
64
- for backend in (self._chat, self._summary):
63
+ for backend in (self._chat, *self._by_kind.values()):
65
64
  if hasattr(backend, "close"):
66
65
  backend.close()
67
66
 
@@ -614,7 +613,14 @@ def _run(config: RunConfig) -> int:
614
613
  turn_timeout_s=config.turn_timeout_s,
615
614
  item_timeout_s=config.item_timeout_s,
616
615
  ),
617
- SummaryClient(host=config.host, token=config.token, workspace_id=config.workspace_id),
616
+ {
617
+ "dashboard_summary": SummaryClient(
618
+ host=config.host, token=config.token, workspace_id=config.workspace_id
619
+ ),
620
+ "document_takeaway": TakeawayClient(
621
+ host=config.host, token=config.token, workspace_id=config.workspace_id
622
+ ),
623
+ },
618
624
  )
619
625
  try:
620
626
  single_report = run_items(
@@ -649,6 +655,10 @@ def _run(config: RunConfig) -> int:
649
655
  report.items.extend(agentic_report.items)
650
656
  report.items.extend(single_report.items)
651
657
  report.wall_clock_s = (agentic_report.wall_clock_s if agentic_report else 0.0) + single_report.wall_clock_s
658
+ # The two phases run back to back, so the merged run spans the earliest start to the latest finish.
659
+ phases = [r for r in (agentic_report, single_report) if r is not None]
660
+ report.started_at = min((r.started_at for r in phases if r.started_at), default=None)
661
+ report.finished_at = max((r.finished_at for r in phases if r.finished_at), default=None)
652
662
 
653
663
  skipped_kinds = sorted({i.test_kind for i in report.items if i.skipped})
654
664
  if skipped_kinds:
@@ -0,0 +1,71 @@
1
+ # (C) 2026 GoodData Corporation
2
+ """Bounded exponential-backoff retry shared by the gen-ai HTTP clients.
3
+
4
+ The budget is read from the ``GOODDATA_EVAL_CHAT_*`` environment on every call, so the chat
5
+ and takeaway clients retune together.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import time
13
+ from collections.abc import Callable
14
+ from typing import TypeVar
15
+
16
+ _log = logging.getLogger(__name__)
17
+
18
+ T = TypeVar("T")
19
+
20
+ # 500 is here on evidence, not on principle: in one visualization eval batch it hard-failed
21
+ # 10 of 56 runs with zero retry attempts, and every affected question scored normally when the
22
+ # same question/model ran again the next day -- i.e. transient gen-ai faults, not deterministic
23
+ # server bugs. A genuinely deterministic 500 still terminates, just after the bounded backoff.
24
+ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
25
+
26
+
27
+ def int_env(name: str, default: int) -> int:
28
+ """Read an int from the environment, falling back to ``default`` when unset or blank."""
29
+ raw = os.getenv(name)
30
+ return int(raw) if raw else default
31
+
32
+
33
+ def float_env(name: str, default: float) -> float:
34
+ """Read a float from the environment, falling back to ``default`` when unset or blank."""
35
+ raw = os.getenv(name)
36
+ return float(raw) if raw else default
37
+
38
+
39
+ # Retry budget defaults, giving a ~2 min worst-case cap per send (5/10/20/40/60s).
40
+ # Each is overridable via env so CI can retune without cutting a new release --
41
+ # read per call rather than at import, so an exported value cannot silently
42
+ # rewrite what a test that patches these attributes expects.
43
+ _MAX_RETRIES_DEFAULT = 5
44
+ _INITIAL_BACKOFF_S_DEFAULT = 5.0
45
+ _BACKOFF_FACTOR_DEFAULT = 2.0
46
+ _MAX_BACKOFF_S_DEFAULT = 60.0
47
+
48
+
49
+ def retry_transient(operation: Callable[[], T], *, is_retryable: Callable[[Exception], bool]) -> T:
50
+ """Run ``operation``; retry retryable failures with bounded exponential backoff."""
51
+ max_retries = int_env("GOODDATA_EVAL_CHAT_MAX_RETRIES", _MAX_RETRIES_DEFAULT)
52
+ delay = float_env("GOODDATA_EVAL_CHAT_INITIAL_BACKOFF_S", _INITIAL_BACKOFF_S_DEFAULT)
53
+ factor = float_env("GOODDATA_EVAL_CHAT_BACKOFF_FACTOR", _BACKOFF_FACTOR_DEFAULT)
54
+ max_backoff = float_env("GOODDATA_EVAL_CHAT_MAX_BACKOFF_S", _MAX_BACKOFF_S_DEFAULT)
55
+ for attempt in range(max_retries + 1): # 0..N => N retries + 1 initial attempt
56
+ try:
57
+ return operation()
58
+ except Exception as exc: # noqa: PERF203 — retry loop: per-attempt try/except is intentional
59
+ if attempt == max_retries or not is_retryable(exc):
60
+ raise
61
+ sleep_s = min(delay, max_backoff)
62
+ _log.warning(
63
+ "Transient gen-ai error (attempt %d/%d): %s; retrying in %.0fs",
64
+ attempt + 1,
65
+ max_retries + 1,
66
+ exc,
67
+ sleep_s,
68
+ )
69
+ time.sleep(sleep_s)
70
+ delay *= factor
71
+ raise AssertionError("unreachable") # loop either returns or raises
@@ -37,14 +37,36 @@ CONFIRMATION_REPLY = "Yes, go ahead."
37
37
  # Asked to pick from a list (which dashboard to bind an alert to), a real user picks one.
38
38
  NO_ANSWER_REPLY = "Use your best judgement and go ahead. If you need me to pick from a list, take the first option."
39
39
 
40
- # Part of the metric and alert skills' designed flow, not a clarification.
40
+ # Part of the metric and alert skills' designed flow, not a clarification; so is a retry of a
41
+ # corrected proposal. Keep `use` and `run` out: "Which one should I use?" is a choice question.
42
+ _RETRY_VERBS = r"retry|try\s+(?:(?:it|this|that)\s+)?(?:again|once\s+more)|resubmit"
43
+ _APPROVAL_VERBS = rf"go\s+ahead|proceed|create|save|update|apply|{_RETRY_VERBS}"
44
+ _SHOULD_I = r"\b(?:should|shall|may|can)\s+i\s+"
45
+ _WANT_ME_TO = r"\b(?:do\s+you\s+want|would\s+you\s+like)\s+me\s+to\s+"
41
46
  _CONFIRMATION_RE = re.compile(
42
- r"\b(?:should|shall|may|can)\s+i\s+(?:go\s+ahead|proceed|create|save|update|apply|add|set\s+(?:it|this)\s+up)"
43
- r"|\bdo\s+you\s+want\s+me\s+to\s+(?:go\s+ahead|proceed|create|save|update|apply)"
44
- r"|\bwould\s+you\s+like\s+me\s+to\s+(?:go\s+ahead|proceed|create|save|update|apply)"
47
+ rf"{_SHOULD_I}(?:{_APPROVAL_VERBS}|add|set\s+(?:it|this)\s+up)\b"
48
+ rf"|{_WANT_ME_TO}(?:{_APPROVAL_VERBS})\b"
45
49
  r"|\bplease\s+confirm\b|\bconfirm\s+(?:if|whether|that)\b",
46
50
  re.IGNORECASE,
47
51
  )
52
+ # "Should I retry with gross or net revenue?" offers a choice, so it stays a question, as does
53
+ # a second option asked right after ("Should I retry? Or adjust the formula first?"). The
54
+ # clause runs to the sentence end: a dot inside a token ("2.5") does not end it. Only a
55
+ # prose "or"/"Or" counts: MAQL writes its OR in uppercase. An "or" that hands the turn back
56
+ # to the user ("..., or would you like to adjust it?", "or not?") is not a second option.
57
+ _NOT_AN_OPTION = (
58
+ r"not\b|(?:would|do)\s+you\s+(?:like|want|prefer)\s+to\b|(?:is|are)\s+there\b"
59
+ r"|anything\b|something\b|if\s+you\b|let\s+me\s+know\b"
60
+ )
61
+ _RETRY_CHOICE_RE = re.compile(
62
+ rf"(?:{_SHOULD_I}|{_WANT_ME_TO})(?:{_RETRY_VERBS})\b(?:[^?.!]|\.(?=\S))*"
63
+ rf"(?:[?.!]\s+)?(?:(?-i:\b[Oo]r\b)|\balternatively\b)(?!\s+(?:{_NOT_AN_OPTION}))",
64
+ re.IGNORECASE,
65
+ )
66
+ # Inline code, {type/id} references and quoted literals are the proposal itself; an OR
67
+ # inside a formula is not a choice offered to the user. A single quote counts only outside a
68
+ # word, so the apostrophes in "don't ... it's" do not mask the text between them.
69
+ _FORMULA_SPAN_RE = re.compile(r"`+[^`]*`+|\{[^{}]*\}|\"[^\"\n]*\"|(?<!\w)'[^'\n]*'(?!\w)")
48
70
 
49
71
  # Checked after _CONFIRMATION_RE, so "please confirm" stays a confirmation.
50
72
  _REQUEST_RE = re.compile(
@@ -77,7 +99,7 @@ def classify_reply(chat_result: ChatResult, rendered_text: str) -> ReplyKind:
77
99
  text = (rendered_text or "").strip()
78
100
  if not text:
79
101
  return "no_action"
80
- if _CONFIRMATION_RE.search(text):
102
+ if _CONFIRMATION_RE.search(text) and not _RETRY_CHOICE_RE.search(_FORMULA_SPAN_RE.sub(" ", text)):
81
103
  return "confirmation"
82
104
  if "?" in text or _REQUEST_RE.search(text):
83
105
  return "question"
@@ -238,6 +260,33 @@ class ClarificationJudge:
238
260
 
239
261
  _SIM_USER_MODEL = "gpt-4o"
240
262
 
263
+ # The simulated user plays a business user: choosing the formula is the assistant's job.
264
+ _NO_DEFINITIONS = (
265
+ "Never write MAQL, SQL, formulas or object definitions. If asked to approve one, reply that it is fine to go ahead."
266
+ )
267
+ # An uppercase SELECT counts once a reference, `(`, `*`, DISTINCT or a call follows it, so
268
+ # "SELECT the second option" and "Select the first one" are left alone; in any case it counts
269
+ # once an aggregation call follows, as in "select sum(revenue)". An
270
+ # aggregation call is a formula even without SELECT: in uppercase ("SUM (units)") or in any
271
+ # case with the parenthesis attached ("sum(units) / count(orders)"), so prose such as
272
+ # "Count (all) is fine" is left alone. Lowercase SQL counts by its shape (`*`, a column list,
273
+ # or a table followed by a clause), so "Select the first option from the list" is left alone.
274
+ _MAQL_RE = re.compile(
275
+ r"\bSELECT\s+(?:[{<(*]|DISTINCT\b|\w+\s*\()|(?i:\bselect\s+(?:sum|count|avg|min|max|median|distinct)\s*\()"
276
+ r"|(?i:\bselect\s+(?:\*|[\w.]+(?:\s*,\s*[\w.]+)+)\s+from\b)"
277
+ r"|(?i:\bselect\b[^?!]*?\bfrom\s+[\w.]+\s+(?:where|group\s+by|order\s+by|join)\b)"
278
+ r"|\b(?:SUM|COUNT|AVG|MIN|MAX|MEDIAN)\s*\(|(?i:\b(?:sum|count|avg|min|max|median)\()"
279
+ r"|[{<](?:metric|fact|label|attribute|dataset)/"
280
+ )
281
+
282
+
283
+ def _without_maql(reply: str | None) -> str | None:
284
+ """A reply that slipped MAQL in past the prompt leaves the choice to the assistant.
285
+
286
+ Both callers answer a question, so the fallback must answer one too, not just approve.
287
+ """
288
+ return NO_ANSWER_REPLY if reply and _MAQL_RE.search(reply) else reply
289
+
241
290
 
242
291
  def _chat(prompt_system: str, prompt_user: str) -> str | None:
243
292
  try:
@@ -271,12 +320,12 @@ def reply_from_facts(transcript: str, question: str, facts: list[str]) -> str:
271
320
  return NO_ANSWER_REPLY
272
321
  reply = _chat(
273
322
  "You play a business user talking to an analytics assistant. Answer its question briefly, "
274
- "using ONLY the facts listed below. Never invent requirements. If the facts do not answer "
275
- f"the question, reply exactly: {NO_ANSWER_REPLY}",
323
+ f"using ONLY the facts listed below. Never invent requirements. {_NO_DEFINITIONS} If the facts "
324
+ f"do not answer the question, reply exactly: {NO_ANSWER_REPLY}",
276
325
  f"Conversation so far:\n{transcript}\n\nThe assistant now asks:\n{question}\n\n"
277
326
  "Facts you know:\n" + "\n".join(f"- {f}" for f in facts),
278
327
  )
279
- return reply or NO_ANSWER_REPLY
328
+ return _without_maql(reply) or NO_ANSWER_REPLY
280
329
 
281
330
 
282
331
  def reply_restating(transcript: str, question: str) -> str:
@@ -289,7 +338,7 @@ def reply_restating(transcript: str, question: str) -> str:
289
338
  "You play a business user talking to an analytics assistant. The assistant just asked for "
290
339
  "something that was already said or found earlier in the conversation. Reply briefly, "
291
340
  "starting with 'As I said earlier,' and repeat only that information, taken from the "
292
- "conversation. Do not add anything new.",
341
+ f"conversation. Do not add anything new. {_NO_DEFINITIONS}",
293
342
  f"Conversation so far:\n{transcript}\n\nThe assistant now asks:\n{question}",
294
343
  )
295
- return reply or "As I said earlier in this conversation — please use what we already established."
344
+ return _without_maql(reply) or "As I said earlier in this conversation — please use what we already established."
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  import json
7
7
  import os
8
8
  import re
9
+ import time
9
10
  from dataclasses import dataclass, field
10
11
  from typing import Any
11
12
 
@@ -587,6 +588,8 @@ class AlertRunResult:
587
588
  # also report operator/threshold/metric/recipients as False.
588
589
  exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED
589
590
  turns_used: int = 0
591
+ # This run's own wall time, from just before its chat call to the end of its scoring.
592
+ run_latency_s: float = 0.0
590
593
 
591
594
 
592
595
  @dataclass
@@ -802,6 +805,7 @@ def run_agentic_alert_skill(
802
805
  # that simply runs out of range() is correctly labelled without a trailing else.
803
806
  exit_reason = LoopExit.BUDGET_EXHAUSTED
804
807
  turns_used = 0
808
+ run_started = time.monotonic()
805
809
  for _iteration in range(max_iterations):
806
810
  turns_used = _iteration + 1
807
811
  try:
@@ -903,6 +907,7 @@ def run_agentic_alert_skill(
903
907
  reasoning_step_events=all_reasoning_step_events,
904
908
  exit_reason=exit_reason,
905
909
  turns_used=turns_used,
910
+ run_latency_s=time.monotonic() - run_started,
906
911
  )
907
912
  finally:
908
913
  if alert_id_to_delete:
@@ -1122,6 +1127,7 @@ def evaluate_agentic_alert_skill(
1122
1127
  exc.runs_passed = runs_passed
1123
1128
  exc.runs_effective = runs_effective
1124
1129
  exc.failed_runs = failed_runs
1130
+ exc.run_latencies_s = [r.run_latency_s for r in summary.run_results]
1125
1131
  raise exc
1126
1132
  return AgenticEvalOutcome(
1127
1133
  runs_passed=runs_passed,
@@ -1133,4 +1139,5 @@ def evaluate_agentic_alert_skill(
1133
1139
  # Also on the success path: pass@K clears the gate with one passing run, so a
1134
1140
  # 1/3 item reports success while two of its runs failed for reasons worth reading.
1135
1141
  failed_runs=failed_runs,
1142
+ run_latencies_s=[r.run_latency_s for r in summary.run_results],
1136
1143
  )
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
  import logging
29
29
  import os
30
30
  import re
31
+ import time
31
32
  from dataclasses import dataclass, field
32
33
  from typing import Any
33
34
 
@@ -284,6 +285,8 @@ class AnomalyRunResult:
284
285
  response_id: str | None = None
285
286
  tool_call_events: list[ToolCallEvent] = field(default_factory=list)
286
287
  reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
288
+ # This run's own wall time, from just before its chat call to the end of its scoring.
289
+ run_latency_s: float = 0.0
287
290
 
288
291
 
289
292
  @dataclass
@@ -393,6 +396,7 @@ def run_agentic_anomaly_detection(
393
396
  all_tool_call_events.extend(result.tool_call_events or [])
394
397
  all_reasoning_step_events.extend(result.reasoning_step_events or [])
395
398
 
399
+ run_started = time.monotonic()
396
400
  for iteration in range(max_iterations):
397
401
  try:
398
402
  chat_result = client.send_message(conv_id, current_question)
@@ -440,6 +444,7 @@ def run_agentic_anomaly_detection(
440
444
  response_id=response_id,
441
445
  tool_call_events=all_tool_call_events,
442
446
  reasoning_step_events=all_reasoning_step_events,
447
+ run_latency_s=time.monotonic() - run_started,
443
448
  )
444
449
 
445
450
  try:
@@ -651,6 +656,7 @@ def evaluate_agentic_anomaly_detection(
651
656
  exc.runs_passed = runs_passed
652
657
  exc.runs_effective = len(summary.run_results)
653
658
  exc.failed_runs = failed_runs
659
+ exc.run_latencies_s = [r.run_latency_s for r in summary.run_results]
654
660
  raise exc
655
661
 
656
662
  return AgenticEvalOutcome(
@@ -661,4 +667,5 @@ def evaluate_agentic_anomaly_detection(
661
667
  response_id=best.response_id,
662
668
  detail=detail,
663
669
  failed_runs=failed_runs,
670
+ run_latencies_s=[r.run_latency_s for r in summary.run_results],
664
671
  )
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
  import json
20
20
  import os
21
21
  import re
22
+ import time
22
23
  from dataclasses import dataclass, field
23
24
  from datetime import date
24
25
  from typing import ClassVar, Literal
@@ -612,6 +613,9 @@ class ConversationResult:
612
613
  stalled_turns: int = 0
613
614
  mode: ConversationMode = "legacy"
614
615
  judge_model: str | None = None
616
+ # The whole conversation's wall time, from just before its first message to the end of its
617
+ # last turn's scoring; conversation create/delete is not part of it.
618
+ run_latency_s: float = 0.0
615
619
 
616
620
 
617
621
  def _metric_creations(tool_call_events: list[ToolCallEvent]) -> list[tuple[str, bool]]:
@@ -771,6 +775,7 @@ def run_agentic_conversation(
771
775
  tool_index_offset = 0
772
776
  reasoning_index_offset = 0
773
777
  current_context = user_context
778
+ run_latency_s = 0.0
774
779
 
775
780
  try:
776
781
  if initial_conversation_id is not None:
@@ -779,10 +784,14 @@ def run_agentic_conversation(
779
784
  conversation_id = client.create_conversation()
780
785
  owns_conversation = True
781
786
 
787
+ run_started = time.monotonic()
788
+ conversation_swap_s = 0.0 # fresh_conversation_per_turn's delete/create, which is not the run's work
782
789
  for turn_index, turn in enumerate(fixture.turns):
783
790
  if fresh_conversation_per_turn and turn_index > 0:
791
+ swap_started = time.monotonic()
784
792
  client.delete_conversation(conversation_id)
785
793
  conversation_id = client.create_conversation()
794
+ conversation_swap_s += time.monotonic() - swap_started
786
795
  transcript = []
787
796
  active_skills = set()
788
797
  # Before the skip below: a turn whose expectation cannot resolve still changed what
@@ -1006,6 +1015,8 @@ def run_agentic_conversation(
1006
1015
  )
1007
1016
  )
1008
1017
 
1018
+ # Taken here, not after the finally: its cleanup is not part of the run.
1019
+ run_latency_s = time.monotonic() - run_started - conversation_swap_s
1009
1020
  finally:
1010
1021
  if owns_conversation and conversation_id:
1011
1022
  client.delete_conversation(conversation_id)
@@ -1039,6 +1050,7 @@ def run_agentic_conversation(
1039
1050
  stalled_turns=stalled,
1040
1051
  mode=resolved_mode,
1041
1052
  judge_model=judge.model_name if judged else None,
1053
+ run_latency_s=run_latency_s,
1042
1054
  )
1043
1055
 
1044
1056
 
@@ -1246,6 +1258,7 @@ def evaluate_agentic_conversation(
1246
1258
  # for. Saying so explicitly stops the report claiming K runs that never happened.
1247
1259
  exc.runs_passed = 0
1248
1260
  exc.runs_effective = 1
1261
+ exc.run_latencies_s = [result.run_latency_s]
1249
1262
  raise exc
1250
1263
  return AgenticEvalOutcome(
1251
1264
  reasoning_steps=result.reasoning_steps,
@@ -1254,4 +1267,5 @@ def evaluate_agentic_conversation(
1254
1267
  detail=detail,
1255
1268
  runs_passed=1,
1256
1269
  runs_effective=1,
1270
+ run_latencies_s=[result.run_latency_s],
1257
1271
  )
@@ -1167,6 +1167,8 @@ class DashboardRunResult:
1167
1167
  tool_call_events: list[ToolCallEvent] = field(default_factory=list)
1168
1168
  reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
1169
1169
  timings: PhaseTimings = field(default_factory=PhaseTimings)
1170
+ # This run's own wall time, from just before its chat call to the end of its scoring.
1171
+ run_latency_s: float = 0.0
1170
1172
 
1171
1173
 
1172
1174
  @dataclass
@@ -1399,6 +1401,7 @@ def _execute_single_dashboard_run(
1399
1401
  tool_index_offset = 0
1400
1402
  reasoning_index_offset = 0
1401
1403
 
1404
+ run_started = time.monotonic()
1402
1405
  for iteration in range(max_iterations):
1403
1406
  turns += 1
1404
1407
  agent_started = time.monotonic()
@@ -1475,6 +1478,7 @@ def _execute_single_dashboard_run(
1475
1478
  tool_call_events=all_tool_call_events,
1476
1479
  reasoning_step_events=all_reasoning_step_events,
1477
1480
  timings=timings,
1481
+ run_latency_s=time.monotonic() - run_started,
1478
1482
  )
1479
1483
 
1480
1484
 
@@ -1703,6 +1707,7 @@ def evaluate_agentic_dashboard_skill(
1703
1707
  exc.runs_passed = runs_passed
1704
1708
  exc.runs_effective = runs_effective
1705
1709
  exc.failed_runs = failed_runs
1710
+ exc.run_latencies_s = [r.run_latency_s for r in summary.run_results]
1706
1711
  raise exc
1707
1712
  return AgenticEvalOutcome(
1708
1713
  runs_passed=runs_passed,
@@ -1713,4 +1718,5 @@ def evaluate_agentic_dashboard_skill(
1713
1718
  detail=detail,
1714
1719
  timings=item_timings,
1715
1720
  failed_runs=failed_runs,
1721
+ run_latencies_s=[r.run_latency_s for r in summary.run_results],
1716
1722
  )
@@ -638,6 +638,8 @@ class DocumentRunResult:
638
638
  tool_call_events: list[ToolCallEvent] = field(default_factory=list)
639
639
  reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
640
640
  timings: PhaseTimings = field(default_factory=PhaseTimings)
641
+ # This run's own wall time, from just before its chat call to the end of its scoring.
642
+ run_latency_s: float = 0.0
641
643
 
642
644
  @property
643
645
  def diagnostics(self) -> dict[str, bool]:
@@ -711,6 +713,7 @@ def _execute_single_document_run(
711
713
  reasoning_index_offset = 0
712
714
  first_turn_asked = False
713
715
 
716
+ run_started = time.monotonic()
714
717
  for iteration in range(max_iterations):
715
718
  turns += 1
716
719
  agent_started = time.monotonic()
@@ -781,6 +784,7 @@ def _execute_single_document_run(
781
784
  tool_call_events=all_tool_call_events,
782
785
  reasoning_step_events=all_reasoning_step_events,
783
786
  timings=timings,
787
+ run_latency_s=time.monotonic() - run_started,
784
788
  )
785
789
 
786
790
 
@@ -980,6 +984,7 @@ def evaluate_agentic_document_skill(
980
984
  + " | ".join(unscored)
981
985
  )
982
986
  exc_judge.timings = item_timings
987
+ exc_judge.run_latencies_s = [r.run_latency_s for r in summary.run_results]
983
988
  raise exc_judge
984
989
 
985
990
  runs_passed = sum(1 for r in summary.run_results if r.evaluation.strict_pass)
@@ -1033,6 +1038,7 @@ def evaluate_agentic_document_skill(
1033
1038
  exc.runs_passed = runs_passed
1034
1039
  exc.runs_effective = runs_effective
1035
1040
  exc.failed_runs = failed_runs
1041
+ exc.run_latencies_s = [r.run_latency_s for r in summary.run_results]
1036
1042
  raise exc
1037
1043
  return AgenticEvalOutcome(
1038
1044
  runs_passed=runs_passed,
@@ -1043,4 +1049,5 @@ def evaluate_agentic_document_skill(
1043
1049
  detail=detail,
1044
1050
  timings=item_timings,
1045
1051
  failed_runs=failed_runs,
1052
+ run_latencies_s=[r.run_latency_s for r in summary.run_results],
1046
1053
  )
@@ -91,6 +91,8 @@ class GeneralQuestionResult:
91
91
  judge_error: str | None = None
92
92
  tool_call_events: list[ToolCallEvent] = field(default_factory=list)
93
93
  reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list)
94
+ # This run's own wall time, from just before its chat call to the end of its scoring.
95
+ run_latency_s: float = 0.0
94
96
 
95
97
 
96
98
  @dataclass
@@ -153,6 +155,9 @@ def _run_single_general_question(
153
155
  judge_error=verdict.error,
154
156
  tool_call_events=list(chat_result.tool_call_events or []),
155
157
  reasoning_step_events=list(chat_result.reasoning_step_events or []),
158
+ # item_started is taken just before the chat call and total_elapsed right after
159
+ # scoring, so this is the whole run without a second clock read.
160
+ run_latency_s=total_elapsed,
156
161
  )
157
162
 
158
163
 
@@ -224,6 +229,7 @@ def _attach_diagnostics(
224
229
  failed_runs: list[dict],
225
230
  runs_passed: int,
226
231
  runs_effective: int,
232
+ run_latencies_s: list[float],
227
233
  ) -> None:
228
234
  """Hang the report payload on a raised error, whichever error it is.
229
235
 
@@ -238,6 +244,7 @@ def _attach_diagnostics(
238
244
  error.runs_passed = runs_passed
239
245
  error.runs_effective = runs_effective
240
246
  error.failed_runs = failed_runs
247
+ error.run_latencies_s = run_latencies_s
241
248
 
242
249
 
243
250
  def _run_detail(run: GeneralQuestionResult) -> dict:
@@ -363,6 +370,7 @@ def evaluate_agentic_general_question(
363
370
  # `runs_passed` is then 0, so nothing here needs a graded run to exist.
364
371
  runs_passed = sum(1 for r in summary.scored_run_results if r.passed)
365
372
  runs_effective = len(summary.run_results)
373
+ run_latencies_s = [r.run_latency_s for r in summary.run_results]
366
374
 
367
375
  best = summary.best
368
376
  detail = {
@@ -389,7 +397,7 @@ def evaluate_agentic_general_question(
389
397
  f"judge returned no readable verdict for any of the {len(summary.run_results)} run(s): "
390
398
  + " | ".join(unscored)
391
399
  )
392
- _attach_diagnostics(error, best, detail, failed_runs, runs_passed, runs_effective)
400
+ _attach_diagnostics(error, best, detail, failed_runs, runs_passed, runs_effective, run_latencies_s)
393
401
  error.timings = item_timings
394
402
  raise error
395
403
 
@@ -398,7 +406,7 @@ def evaluate_agentic_general_question(
398
406
  exc = GeneralQuestionAssertionError(
399
407
  f"General question assertion failed. {gate_note} passed={best.passed}. Reasoning: {best.reasoning}"
400
408
  )
401
- _attach_diagnostics(exc, best, detail, failed_runs, runs_passed, runs_effective)
409
+ _attach_diagnostics(exc, best, detail, failed_runs, runs_passed, runs_effective, run_latencies_s)
402
410
  exc.timings = item_timings
403
411
  raise exc
404
412
  return AgenticEvalOutcome(
@@ -412,4 +420,5 @@ def evaluate_agentic_general_question(
412
420
  # Also on the success path: pass@K clears the gate with one passing run, so a
413
421
  # 1/3 item reports success while two of its runs failed for reasons worth reading.
414
422
  failed_runs=failed_runs,
423
+ run_latencies_s=run_latencies_s,
415
424
  )