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.
- gooddata_eval/cli/agentic_runner.py +30 -0
- gooddata_eval/cli/main.py +21 -11
- gooddata_eval/core/_retry.py +71 -0
- gooddata_eval/core/agentic/_conversation_context.py +59 -10
- gooddata_eval/core/agentic/alert_skill.py +7 -0
- gooddata_eval/core/agentic/anomaly_detection.py +7 -0
- gooddata_eval/core/agentic/conversation.py +14 -0
- gooddata_eval/core/agentic/dashboard_skill.py +6 -0
- gooddata_eval/core/agentic/document_skill.py +7 -0
- gooddata_eval/core/agentic/general_question.py +11 -2
- gooddata_eval/core/agentic/guardrail.py +11 -2
- gooddata_eval/core/agentic/kda_skill.py +7 -0
- gooddata_eval/core/agentic/metric_skill.py +7 -0
- gooddata_eval/core/agentic/search_tool.py +9 -0
- gooddata_eval/core/agentic/visualization.py +8 -0
- gooddata_eval/core/agentic/what_if.py +7 -0
- gooddata_eval/core/chat/sse_client.py +28 -59
- gooddata_eval/core/dataset/langfuse_source.py +29 -2
- gooddata_eval/core/evaluators/__init__.py +5 -2
- gooddata_eval/core/evaluators/_llm_judge.py +1 -0
- gooddata_eval/core/evaluators/_rubric.py +94 -0
- gooddata_eval/core/evaluators/summary.py +12 -64
- gooddata_eval/core/evaluators/takeaway.py +245 -0
- gooddata_eval/core/interaction.py +57 -0
- gooddata_eval/core/models.py +47 -0
- gooddata_eval/core/reporting/console.py +52 -0
- gooddata_eval/core/reporting/html_report.py +40 -5
- gooddata_eval/core/reporting/json_report.py +19 -0
- gooddata_eval/core/reporting/report_template.html +1700 -192
- gooddata_eval/core/runner.py +67 -1
- gooddata_eval/core/summary/http_client.py +11 -1
- gooddata_eval/core/takeaway/__init__.py +1 -0
- gooddata_eval/core/takeaway/evaluate.py +275 -0
- gooddata_eval/core/takeaway/http_client.py +104 -0
- gooddata_eval/core/timing.py +110 -0
- {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/METADATA +256 -24
- {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/RECORD +40 -33
- {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/WHEEL +0 -0
- {gooddata_eval-1.77.1.dev2.dist-info → gooddata_eval-1.78.1.dev1.dist-info}/entry_points.txt +0 -0
- {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
|
-
`
|
|
51
|
-
|
|
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,
|
|
55
|
+
def __init__(self, chat: ChatClient, by_kind: dict[str, Any]):
|
|
55
56
|
self._chat = chat
|
|
56
|
-
self.
|
|
57
|
+
self._by_kind = by_kind
|
|
57
58
|
|
|
58
59
|
def ask(self, item: DatasetItem) -> ChatResult:
|
|
59
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
)
|