Skip to content

[bot] Merge master/45892f79 into rel/dev - #1774

Merged
yenkins-admin merged 3 commits into
rel/devfrom
snapshot-master-45892f79-to-rel/dev
Sep 3, 2026
Merged

[bot] Merge master/45892f79 into rel/dev#1774
yenkins-admin merged 3 commits into
rel/devfrom
snapshot-master-45892f79-to-rel/dev

Conversation

@yenkins-admin

Copy link
Copy Markdown
Contributor

🚀 Automated PR to perform merge from master into rel/dev with changes up to 45892f7 (created by https://github.com/gooddata/gooddata-python-sdk/actions/runs/33765025522).

tychtjan and others added 3 commits September 3, 2026 13:50
Finding a gen-ai trace means polling until Langfuse has ingested it -- anywhere
from one round trip to the full retry budget. None of that work produces a
verdict; the pass/fail is already decided by the time it starts. Charging the
item's latency for it both slowed the run and made the reported agent latency
wrong.

Each evaluate_agentic_* now hands its Langfuse block to a linker instead of
running it inline. The CLI injects a BackgroundTraceLinker that collects the
queue and drains it after the agent phase, before any report is rendered, so
scores are always final before the command exits. Direct library callers keep
the synchronous default and are behaviour-compatible.

Alongside that:

- Per-phase latency (agent / judge / simulated user / Langfuse) recorded per
  run and aggregated across K runs, exposed as an additive latency_breakdown_s.
- --concurrency reaches the agentic path, partitioned by an explicit
  PARALLEL_SAFE_TEST_KINDS allowlist; anything absent runs serially.
- --timers gates the per-turn [timer] output (off by default). --judge-model
  selects the LLM-as-judge model (default gpt-4o).
- An unreadable judge response raises JudgeResponseError instead of scoring 0.
- An item's user_context (a WIDGET/VIEW attachment the question refers to) is
  relayed to the chat request as userContext, and survives the Langfuse dataset
  round trip.

FIXES FOUND WHILE REVIEWING THE ABOVE

Each was reproduced broken first, then re-verified by re-injecting the defect
and confirming the new test fails.

Judge contract:
- A judge fault on one run of K discarded every run already graded, dropped
  their Langfuse scores, and reported an item whose pass@K was ALREADY
  satisfied as a failure -- the same "a parse bug reads as a pass-rate drop"
  that JudgeResponseError exists to prevent, one layer up. A fault is now
  confined to its own run; pass@K holds on the graded runs, pass^K requires
  every run graded, and an item with no graded run at all errors.
  dashboard_summary needed it most: it judges once PER CRITERION, so one bad
  body lost all of them.
- choices == [] (content filters, gateway error envelopes) escaped as a bare
  IndexError, past the typed error, with none of the body or metadata.
- {"score": 2} was reported as a confident FAIL -- int(score) == 1 was the last
  place an invented 0 survived. Only 0 and 1 are verdicts now.
- {"score": "1"} regressed; JSON mode quotes numbers routinely, so it is
  coerced again.
- The temperature fallback matched "temperature" in str(exc), and the openai
  SDK stringifies the whole response body into the message. Gateways echo the
  request inside it, so any 400 -- context_length_exceeded included -- was
  misread, silently dropping temperature=0 from every later verdict. Now read
  off the provider's structured error.
- openai>=1.45 is required: 1.40-1.44 lack max_completion_tokens (verified
  against the wheel) and would TypeError on every judge call.

Interrupts:
- A bare `with ThreadPoolExecutor(...)` exits via shutdown(wait=True) with
  cancel_futures left False, so Ctrl-C ran every QUEUED item to completion
  first. Reproduced: SIGINT at 0.30s, interrupt observed at 9.00s, all 6 items
  run. Both pools now cancel; drain() had the same defect and sits outside the
  abandon() guard, so it cancels on the pool itself.

Langfuse:
- The sessionId filter never reached the server: _TraceAPI.list had no such
  parameter and it is the only client make_langfuse_client returns. So every
  attempt downloaded a full limit=100 page of the whole window and filtered it
  locally -- and the endpoint returns newest-first, so once a window held more
  than 100 traces the item's OWN trace was evicted and it spent its entire
  budget on a page that could never contain it.
- The 120s budget is affordable only because the batch blocks nobody. Direct
  library callers poll inline, on their own critical path (the tavern e2e suite
  under a step timeout), where 120s tripled a miss from ~35s to ~110s. The
  budget now follows the mode: 35s inline (identical to the old ladder, to the
  second), 120s batched.
- A local --dataset cannot be attached to a Langfuse run, but linking still
  happens off exported credentials, so every conversation earned a raw 404 from
  dataset-run-items at the very end of the run. Now warned before the run
  starts and reported once per run with its cause.

Reporting:
- pass@K answers "did any run pass", so a 5/5 item and a 1/5 item were
  identical in every output -- quality_score reads the best run alone. Every
  agentic kind already computed the count and dropped it. Now surfaced as
  runs_passed / pass_power_k per item and passed_all_runs per run, with "4/5
  runs passed" in the console.
- agentic_conversation takes no k and drives its fixture once, but runs = k was
  set unconditionally, so --runs 5 claimed five runs and divided one
  conversation's latency by five.
- An errored item lost the phase timings it had managed to take.

Tests that certified nothing:
- The window-pinning guard counted call arity, so inlining _dt.now() inside
  _link_traces -- the exact drift its docstring describes -- passed.
- abandon() had no effective coverage: the only test reaching it never calls
  drain(), so replacing its body with `pass` left 69 tests green.
- test_resolve_connection_uses_profile read an exported GOODDATA_TOKEN instead
  of the profile it stubs, and one test popped TAVERN_E2E_SKIP_TRACE_LINK with
  no guard, unsetting it for every module collected afterwards.

Docs:
- Audited every factual claim in the README. The retry budget is no longer one
  number; runs_passed / pass_power_k / passed_all_runs were undocumented; the
  local-dataset linking behaviour was unexplained; and the experiment run name
  was wrong (and had been on master): the code builds
  {dataset_name}_{timestamp}_{model} with _effort-{level} and _run{N} suffixes.
- --concurrency's --help omitted agentic_kda_skill from the forced-serial list.

SIMPLIFICATION PASS

types-check was failing on this branch, and the local check disagreed because
CI runs `uv run ty` (the locked version), not `uvx ty` (latest). Two real
diagnostics in langfuse_source._infer_test_kind: isinstance(metadata, dict)
does not narrow the following subscript past object. Binding the value before
the isinstance check fixes it and drops a double lookup. A first attempt also
proved that py314 defers annotation evaluation (PEP 649), so a missing TypeVar
passed the suite here and would have NameError'd at import on py310-313.

The eight evaluate_agentic_* functions each carried a byte-identical ~36-line
Langfuse prologue, so every change above had to be made eight times -- and two
AST-walking tests existed only to stop the eight copies from drifting. That
block is now one helper: RunIdentity / RunTraceContext / submit_trace_scoring.
`def _link_traces` 8 -> 1, `build_run_context(` 9 -> 2, `suffix_needed` 12 ->
0. The structural tests were retargeted at the invariant's new home rather than
deleted, and conversation.py resolves its dataset name eagerly so a queued task
no longer retains the whole ConversationFixture until drain.

Also: emit_line replaces six hand-rolled stdout.write+flush pairs; a shared
_first_of collapses three input-then-metadata lookup ladders; runs_total
replaces an expression duplicated between ItemReport and the console renderer;
PhaseTimings.as_dict() is wired into the JSON report, which was dead code, as
was the langfuse_s field it now populates; the unused context-manager protocol
and the dead total_s are gone; _response_metadata's five copy-pasted try/except
blocks collapse to one guarded helper; summary._grade no longer writes
detail[key] for its caller to overwrite. Comment bloat trimmed where one idea
was stated four to eight times over.

The three slowest tests were time.sleep(0.3) negative assertions -- slow, and
timing-flaky on a loaded box. They now join the pool for real, which is what
would actually let a queued task start.

Behaviour preservation was checked rather than assumed: all 33 score-writing
calls were compared against the pre-refactor tree (identical modulo the
mechanical renames), as were the conversation-id expressions, the run-name
suffix policy and the dataset names. The three retargeted structural tests and
the three interrupt tests were each mutation-tested by re-breaking the code
they guard and confirming the right test fails.

SECOND SIMPLIFICATION PASS

The first pass traded duplicated logic for duplicated argument plumbing of
about the same size, so it removed only ~74 net lines. This pass went after the
plumbing:

- The eight *AssertionError classes each redeclared the same seven-attribute
  payload (and two of the eight declared `timings` while the runner getattr'd
  it from all eight). They now share an AgenticAssertionError base in
  core/models.py.
- The eight-line preamble (datetime aliases, client fallback, window_start)
  that opened every evaluate_agentic_* is one call to open_trace_window().
- RunTraceContext gained observe()/score()/quality(), so the deferred _langfuse
  import and the five-argument observe() call disappear from all eight kinds.
- Seven of eight kinds built their `detail` dict twice -- once on the failure
  path, once on success -- with nothing keeping the two literals in step.
  Hoisted to one local per kind, proven equivalent by AST comparison on both
  paths.

Across the eight agentic kinds: `__tracebackhide__` 8 -> 0, deferred `_langfuse
import (` 8 -> 0, `from datetime import` 17 -> 2, `try_make_langfuse_client` 18
-> 4, `suffix_needed` 12 -> 0, `def _link_traces` 8 -> 1.

Tests: the six recurring patch-block shapes are now file-local context
managers, 41 copies of an inline MagicMock scaffold are gone, and the three
slowest tests (0.3s sleeps used as negative assertions, flaky on a loaded box)
now join the pool for real. Suite wall time 4.1s -> 2.6s.

REVIEW FINDINGS FIXED

Eight CodeRabbit findings, each reproduced before it was touched, each now
covered by a regression test that fails when the fix is reverted:

- An item that errored after earlier runs passed reached runs_passed ==
  runs_total and was reported as pass^K -- unanimity claimed for an item whose
  last run had no verdict at all.
- The JSON report counted an errored item as both `failed` and `errored`,
  because `failed` was computed by subtraction.
- A blank `test_kind` ("") beat both structural inference and the CLI --kind
  default, so the item was skipped as an unsupported kind.
- Avg/run divided by the requested K while the Runs column showed
  runs_effective, so agentic_conversation reported a per-run latency for four
  runs that never happened. Fixed on ItemReport.avg_latency_s so the JSON
  report gets it too.
- A CLI test let _apply_judge_model write GD_EVAL_JUDGE_MODEL straight into
  os.environ without monkeypatch recording it, leaking the judge model into
  every later test.

- Ctrl-C during the batched drain could hang for the rest of the batch budget.
  cancel_futures only drops what has not started; a poll already running sits
  in find_traces_per_conversation's backoff, and the interpreter joins executor
  workers at exit. Verified in the real CLI against staging, with Langfuse
  pointed at a closed port: Ctrl-C during the drain exited after 105.4s before
  this change and 1.1s after. The linker now publishes a cancellation Event for
  the duration of one drain -- fresh per drain, so one run's interrupt cannot
  stop the next --model pass -- and the backoff is served in slices that check
  it. A set event is left in place when the drain unwinds, because
  shutdown(wait=False) returns before the workers notice and clearing it would
  send a late worker back to an uninterruptible sleep.
- Fixing the blank test_kind above introduced a second bug: _first_of returned
  the first *string* it found, so a blank expectedOutput.test_kind shadowed a
  valid metadata.test_kind. The sources are now checked one at a time.
- The README claimed both that a direct library caller gets langfuse_s = 0.0 and
  that langfuse_s is populated whenever credentials are exported. Linking does
  happen either way; only the CLI path measures its duration.

LIVE VALIDATION

Measured on staging (18 agentic_general_question items x 2 runs, gpt-5.6-luna):
--concurrency 1, master 462s vs 233s here; --concurrency 2, master 774s vs
114s. --concurrency has no effect on master's agentic path, so both master runs
did the same serial work and differed only by ingestion lag -- and one of them
orphaned two traces. Reported latency per run falls 12.7s -> 5.2s, which is not
a speedup but the removal of Langfuse polling from a number that is supposed to
measure the agent.

Pass rates unchanged: 18/18 on both trees, plus 33/33 on the 33-item guardrail
dataset. pass^K differed there (32 vs 29); re-running the three differing items
three times per tree put master at 2/3, 1/3, 2/3 and this branch at 2/3, 2/3,
2/3, so that gap is agent non-determinism on borderline refusal prompts, not a
regression.

REVIEW FEEDBACK

Rationale for the change itself has been taken back out of the source comments and
left here, where it belongs: the openai>=1.45 justification above stood duplicated
above the pin in pyproject.toml, the pass@K/pass^K asymmetry ran to a twelve-line
essay arguing its own history, and the same "keeping two literals in step was a
standing drift hazard" paragraph had been pasted into eight modules. Comments that
state a live constraint a future editor must not break -- the two ThreadPoolExecutor
shutdown notes, the PARALLEL_SAFE_TEST_KINDS invariant, the `is not None` session
filter -- are kept. Comment lines on this diff's source additions: 337 -> 301.
A ticket key that had been left in a test docstring is gone with them; the
GDAI-2179 literals that remain are Langfuse dataset names, which is what a real
one is called.

Verification: 685 pass, 0 failures -- also per test file standalone, in reverse
module order, and with GOODDATA_TOKEN, TAVERN_E2E_SKIP_TRACE_LINK,
GD_EVAL_TIMERS and GD_EVAL_JUDGE_MODEL exported. ruff check, ruff format
--check and `uv run ty` clean. Behaviour preservation was proven, not assumed:
all 41 score/observe calls and every detail dict compared against the
pre-refactor tree, 1126 assertion statements compared across 35 test files, and
the six safety-critical tests mutation-tested by re-breaking the code they
guard.

risk: medium
…nking

Judge faults reach every kind the same way. general_question and guardrail
called judge.score directly, so an unreadable body raised out of the runner's
K loop: the item errored while pass_at_k stayed True, and the remaining runs
never ran. Both now go through score_run and return an ungraded run; the
runner counts those in ItemReport.runs_ungraded, keeps evaluating, and errors
the item only when no run was graded. dashboard_summary returns the same shape
instead of raising, so one all-ungraded run out of K no longer errors the item.

dashboard_summary could report PASS with zero gating criteria graded: the
no-verdict guard accepted any graded bool, rubric bools included, and an
ungraded gating criterion was a no-op for `passed`. An ungraded mandatory
criterion now disqualifies the pass, and the guard keys on gating criteria.

The local sessionId filter in _fetch_traces_for_session only ran when the
client lacked the parameter, which the httpx client now declares, so a server
that ignores the parameter returned the whole window and the max-latency pick
could attach scores to a foreign trace. It is an unconditional post-check now.

The drain cancellation Event was a module global left set after an interrupt,
so a later inline link read it and broke before its first fetch, and the test
suite's order decided whether link_cancel_event() was None. It is a ContextVar
set per task on the worker thread: late workers still see it, nothing else can.

_rejects_temperature stringified a non-dict body, which for the openai SDK is
the raw response text a gateway echoes the request into. A non-dict body is
not read at all.

JSON `runs` and EvalReport.total_runs use runs_total, so agentic_conversation
no longer reports the requested K or divides one run's latency by it.
ChatClient.ask forwards item.user_context, which the single-turn path dropped.

risk: medium -- pass/fail semantics change for dashboard_summary items with an
ungraded gating criterion (PASS becomes FAIL) and for general_question /
guardrail items with a judge fault on one run (ERROR becomes PASS/FAIL over the
graded runs). Covered by 14 new tests, each verified red before the fix.
perf: take Langfuse trace linking off the eval item critical path
@yenkins-admin
yenkins-admin merged commit 9dc605e into rel/dev Sep 3, 2026
3 checks passed
@yenkins-admin
yenkins-admin deleted the snapshot-master-45892f79-to-rel/dev branch September 3, 2026 14:08
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.94911% with 79 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.58%. Comparing base (bd7672d) to head (45892f7).
⚠️ Report is 571 commits behind head on rel/dev.

Files with missing lines Patch % Lines
...al/src/gooddata_eval/core/agentic/visualization.py 36.66% 19 Missing ⚠️
...a-eval/src/gooddata_eval/core/agentic/guardrail.py 73.33% 12 Missing ⚠️
...eval/src/gooddata_eval/core/agentic/alert_skill.py 52.17% 11 Missing ⚠️
...val/src/gooddata_eval/core/agentic/conversation.py 38.88% 11 Missing ⚠️
...val/src/gooddata_eval/core/agentic/metric_skill.py 76.92% 9 Missing ⚠️
...eval/src/gooddata_eval/core/agentic/search_tool.py 55.00% 9 Missing ⚠️
...a-eval/src/gooddata_eval/core/agentic/_langfuse.py 95.45% 3 Missing ⚠️
...ddata-eval/src/gooddata_eval/cli/agentic_runner.py 97.18% 2 Missing ⚠️
...al/src/gooddata_eval/core/evaluators/_llm_judge.py 98.07% 2 Missing ⚠️
...al/src/gooddata_eval/core/agentic/_trace_linker.py 99.09% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           rel/dev    #1774      +/-   ##
===========================================
+ Coverage    80.82%   81.58%   +0.75%     
===========================================
  Files          272      275       +3     
  Lines        19414    19863     +449     
===========================================
+ Hits         15692    16205     +513     
+ Misses        3722     3658      -64     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants