发布

  • [OPIK-5333] Inject trace spans into LLM-as-judge prompts; enrich thread {{context}} with tool calls (#6751)

    frostbyte_neo 发布于 2026-05-20 08:16:40 +00:00

    • [OPIK-5333] [BE] feat: inject spans into LLM-as-judge prompts via {{spans}} variable

    A user mapping a variable to the bare string "spans" (the same sentinel
    the Python-metric path already uses) now gets the JSON-serialized spans
    list substituted into the rendered prompt at evaluation time. Lets a
    trace-level LLM-as-judge metric like "count the spans: {{mySpans}}"
    actually see the spans — previously the only ways to get spans into the
    prompt were the agentic-tools path (over-threshold or toggle-forced) or
    switching to a Python metric.

    • OnlineScoringEngine.templateReferencesSpans(variables) — public helper
      the trace scorer uses to opt-in to the span fetch on the inline path.
    • OnlineScoringEngine.prepareLlmRequest(...) — both overloads now take a
      @NonNull List spans and inject the JSON-serialized list into any
      variable mapped to the "spans" sentinel. Empty list / no sentinel =
      no-op, so the path stays cheap for the common case.
    • OnlineScoringLlmAsJudgeScorer.score() — spansNeeded predicate now ORs
      in templateReferencesSpans(variables), so the spanService.getByTraceIds
      fetch fires whenever the template references {{spans}}, not only when
      the agentic-tools path could fire.
    • 3 new unit tests in OnlineScoringEngineTest covering sentinel
      detection, span substitution + sort-by-start_time wire order, and
      no-injection when the template doesn't reference the sentinel.

    Thread-level LLM-as-judge intentionally NOT updated — threads carry
    multiple traces, so {{spans}} would be ambiguous (whose spans?).

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [FE] feat: auto-fill {{spans}} as a reserved trace-evaluator variable

    Pairs with the backend's spans-injection feature: users typing
    {{spans}} in a trace-scoped LLM-as-judge prompt no longer have to
    manually map the variable to the "spans" sentinel — the FE seeds it
    automatically, and the schema validator accepts it without complaint.

    • constants/llm.ts: new RESERVED_LLM_JUDGE_TRACE_VARIABLES map
      ({ spans: "spans" }) — extensible if we add more sentinels later.
    • AddEditRuleDialog/LLMJudgeRuleDetails.tsx (v1 + v2): when the
      prompt-tag scanner finds a new variable, look it up in the reserved
      map for trace-scope rules and seed the path with the sentinel. User-
      supplied paths still win — we only fill blanks.
    • AddEditRuleDialog/schema.ts (v1 + v2): extend the trace-variable
      regex to also accept the bare spans sentinel; updated the error
      message to mention it. Span- and thread-scope schemas unchanged.

    Backend already handles the sentinel
    (OnlineScoringEngine.injectSpansIntoReplacements substitutes the JSON-
    serialized spans list at render time). This commit closes the UX loop:
    type {{spans}}, hit Save, done.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [FE] feat: auto-fill spans reserved arg for Python trace metrics too

    Extends the previous LLM-as-judge fix to the Python metric path: a
    score(self, spans, ...) parameter on a trace-scoped Python rule now
    auto-maps to the spans sentinel, matching the LLM-as-judge {{spans}}
    UX. User no longer has to interact with the variable-mapping dropdown
    (which only shows input/output/metadata paths and didn't surface
    spans at all).

    • Rename RESERVED_LLM_JUDGE_TRACE_VARIABLES →
      RESERVED_TRACE_EVALUATOR_VARIABLES since the same map drives both rule
      types; JSDoc updated to document both code paths.
    • PythonCodeRuleDetails.tsx (v1 + v2): when parsePythonMethodParameters
      pulls names off the score method signature, look them up in the
      reserved map for trace-scope rules and seed the path with the
      sentinel. Preserves any existing user-supplied path.
    • PythonCodeDetailsTraceFormSchema (v1 + v2): extend the regex to also
      accept the bare spans sentinel; updated error message to mention it.
      Span-scope Python schema unchanged.

    UX result: type def score(self, spans, ...), hit Save, done. Same
    recipe as LLM-as-judge.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [FE] feat: hide reserved trace variables (spans) from the mapping list

    Reserved trace-evaluator variables like {{spans}} are auto-filled with a
    fixed sentinel path and never need user input. Hide their row from the UI
    so users don't see a selector they can't usefully change. The variable
    stays in the form value, so the backend still receives the sentinel and
    the substitution still happens.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] fix: render {{spans}} as [] when trace has no spans

    The empty-spans short-circuit in injectSpansIntoReplacements skipped
    overwriting the bare "spans" literal that toVariableMapping deposits
    into the replacements map, so traces with no children leaked the word
    "spans" into the rendered LLM prompt instead of an empty JSON array.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] feat: detect {{spans}} in templates without sentinel mapping

    The FE auto-fills variables.spans = "spans" whenever the user types
    {{spans}} in a prompt, but API-created rules can skip that step and
    leave the prompt referencing {{spans}} with no matching entry in the
    variables map. In that case the prior gate (variables.containsValue ("spans")) returned false, spans weren't fetched, and the rendered
    prompt left {{spans}} unsubstituted.

    Extend templateReferencesSpans to OR in a Mustache/Jinja2/Python
    parse over the message templates. When a template references {{spans}}
    and the variables map doesn't bind spans to anything, mirror the FE
    auto-fill server-side: fetch spans and inject the JSON array under the
    spans key. Explicit user mappings (e.g. spans → input.foo) take
    precedence — only the unbound case opts in implicitly.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [FE] perf: hoist hiddenVariableNames to a stable module constant

    Each render of LLMJudgeRuleDetails / PythonCodeRuleDetails (v1+v2) was
    computing Object.keys(RESERVED_TRACE_EVALUATOR_VARIABLES) inline, allocating
    a new ["spans"] array on every render and invalidating
    LLMPromptMessagesVariables's variablesList useMemo for nothing.

    Pre-compute it once at module scope as a frozen readonly array and reuse the
    reference everywhere. The prop type widens to readonly string[] so callers
    can pass the frozen constant; the component only iterates it to build a Set.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] fix: scan multimodal contentArray for {{spans}} + extract shouldFetchSpans helper

    Two related fixes pulled out of the PR review:

    1. templateReferencesSpans only walked LlmAsJudgeMessage.content (the
      simple-string field), so {{spans}} inside a multimodal message's
      contentArray[*].text was missed. The renderer happily substitutes
      into structured-content text parts, so detection drifted from
      rendering and a multimodal prompt with {{spans}} would skip the
      fetch and leave the placeholder unsubstituted. Added a
      renderableTextOf(message) helper that streams both shapes; both
      are now scanned.

    2. Pulled the spans-fetch routing in score() into a package-private
      shouldFetchSpans(message) helper, mirroring the existing
      shouldUseAgenticTools extraction. Makes the gate unit-testable
      without spinning up the full reactive chain — added a 9-case
      parameterized truth-table test covering the agentic-tools and
      inline-template branches plus the short-circuit interactions.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] address PR comments: hide-by-value, DRY auto-fill, rename tests

    Three review findings rolled together:

    1. (Logical bug 🟠) LLMPromptMessagesVariables was hiding a row whenever
      the variable's name matched a reserved name, regardless of value. A
      user (or API caller) who mapped spans → input.spans couldn't see or
      edit that row — write-only after first parse. Replaced the
      hiddenVariableNames: readonly string[] prop with
      reservedSentinels: Readonly<Record<string, string>>; the row is
      hidden only when the variable's current value equals the sentinel
      for that name. Custom overrides stay visible.

    2. (DRY 🟢) The auto-fill block localVariables[v] = variables[v] || reservedDefault || "" was duplicated across v1+v2 ×
      {LLMJudgeRuleDetails, PythonCodeRuleDetails}. Extracted into
      resolveTraceEvaluatorVariableDefault(name, current, scope) in
      lib/llm.ts. Adding a new reserved trace variable now propagates to
      all four editors via a single source of truth.

    3. (Style 🟢) Renamed the test methods I added in this PR from the
      testX() form to the scenario-based names per
      .agents/skills/opik-backend/testing.md. Pre-existing methods left
      as-is to keep the rename limited to my own additions.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] feat: gate {{spans}} template path under isAgenticToolsEnabled

    Ship the inline {{spans}} template substitution under the same feature
    flag as agentic-tools so a single toggle flip turns both pathways on or
    off org-wide. When isAgenticToolsEnabled=false and the rule isn't on
    the experimentId branch, shouldFetchSpans returns false and
    injectSpansIntoReplacements substitutes an empty array into
    {{spans}} via the empty list threaded through prepareLlmRequest
    no I/O, no broken rendering.

    Truth table updated to cover the new gate: toggle-off + template/sentinel
    combinations all expect no fetch; toggle-off + experimentId still fetches
    (for the agentic-tools cache seed, with the template substitution
    piggy-backing on the same data).

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [FE] feat: gate {{spans}} auto-fill behind agentic_tools_enabled

    Mirror the backend gate (isAgenticToolsEnabled) on the frontend so the
    spans-in-prompts feature ships as a single togglable unit.

    When the FT is off in the four rule-detail editors (v1+v2 × {LLMJudge,
    PythonCode}):

    • The spans variable no longer auto-fills to its sentinel value, so the
      user can map it to a custom path like any other variable.
    • LLMPromptMessagesVariables receives reservedSentinels={undefined},
      so the spans row stays visible and editable instead of being hidden.

    When the FT is on, behavior is unchanged: auto-fill writes spans → "spans", the row is hidden, BE substitutes the spans JSON.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] fix: normalize feature-toggle state when backend omits a field

    Two small fixes for the type contract on FeatureToggles:

    • FE: setFeatures(data) was overwriting the entire state object with
      whatever the API returned, leaving keys the backend omits as
      undefined. A newer FE talking to an older BE could land
      AGENTIC_TOOLS_ENABLED = undefined in state, breaking the
      Record<FeatureToggleKeys, boolean> contract. Merge over
      DEFAULT_STATE so omitted keys keep their declared defaults.

    • BE: agenticToolsEnabled lacked @NotNull while every other toggle
      in ServiceTogglesConfig has it. Added for consistency — primitive
      booleans can't actually be null at runtime, but the annotation
      documents the contract and matches the surrounding style.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] code review polish: docstring + empty-mapping handling

    Code-review feedback on the branch surfaced two small things worth tightening:

    • OnlineScoringLlmAsJudgeScorer.shouldFetchSpans and
      OnlineScoringEngine.injectSpansIntoReplacements: the prior docstring on
      the toggle implied a clean "kill switch", but in reality the substitution
      runs unconditionally so toggle-off renders Spans: [] rather than the
      literal {{spans}}. Documented why: gating the substitution would
      resurrect the bare-word leak from rules whose variables map still carries
      the sentinel from before the toggle flipped. The current asymmetry is
      intentional, just needed to be spelled out.

    • resolveTraceEvaluatorVariableDefault was using if (currentMapping),
      which treated "" as "not set" and re-applied the sentinel auto-fill on
      every prompt re-parse — silently overwriting an API caller's deliberate
      spans: "". Switched to if (currentMapping !== undefined) so explicit
      empty strings stick.

    No behavior change in the common paths — existing tests stay green.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] feat: enrich thread {{context}} with each turn's spans (tool calls + I/O)

    Thread-scope LLM-as-judge rules had no way to see the agent's actual tool-use
    behavior — {{context}} only carried each trace's top-level user/assistant
    text, and the only path to spans was the agentic-tools tools-branch (which
    only fires for big threads, or on the experimentId test-suite-assertion
    path). Normal-sized conversations were effectively unobservable from the
    judge's POV.

    Now: when isAgenticToolsEnabled=true, the thread scorer fetches every span
    across every trace in the thread up front and threads them down through
    prepareEvaluation. renderThreadMessages substitutes {{context}} with
    an enriched shape — each trace's child spans are attached as a spans field
    on the assistant entry. Customer writes {{context}} once and gets the full
    conversation + reasoning trace.

    Wire-shape design (EnrichedThreadChatMessage): keeps the existing
    {role, content} shape, adds optional spans field on the assistant entry.
    With @JsonInclude(NON_NULL), the spans field is omitted whenever a trace
    has no spans (or the toggle is off and the scorer passed an empty list), so
    existing rules see today's wire shape unchanged. Backward-compat guaranteed.

    Size routing kept honest: estimateThreadContextTokens now serializes the
    enriched shape, so an enriched-but-big thread routes correctly to the
    agentic-tools path (skeleton + ReadTool drill-down) instead of inline-
    rendering an oversized prompt.

    Tools-branch path (prepareThreadLlmRequestWithTools) is intentionally
    unchanged — it still renders the compact skeleton, and the model uses
    ReadTool/JqTool to drill into individual traces' spans on demand. Pulling
    all spans inline there would defeat the size optimization the tools branch
    exists for.

    Tests cover:

    • wire-identical {role, content} shape when toggle off (empty spans list)
    • enriched shape with spans sorted by start_time when spans provided
    • estimateThreadContextTokens reflects span size for the routing gate

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] polish: thread spans-fetch comment, FQN cleanup, toggle-gate test

    Non-functional polish from an independent code review of the thread-scope
    enrichment work:

    • Documented the deliberate "fetch spans before path decision" ordering in
      the thread scorer so the size estimate routes honestly. The cost (wasted
      I/O when the tools path wins) is now spelled out in-code, not only in
      the commit history.
    • Replaced fully-qualified java.util.stream.Collectors, com.comet.opik .api.Span, com.comet.opik.domain.SpanType references in the test
      file and scorer with the already-imported (or now-imported) short
      forms. Pure cleanup.
    • New scorer-level test skipsSpanFetchWhenAgenticToolsDisabled proves
      the toggle gate by asserting verifyNoInteractions(spanService) on a
      full thread-scoring flow with the flag off. Locks in the contract so a
      future "simplification" of the gate would fail fast.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] perf: project Span → SpanForLlm before rendering into prompts

    The raw Span record carries ~28 fields — audit metadata (createdBy,
    lastUpdatedBy, createdAt), out-of-band annotations (feedbackScores,
    comments), cost data (totalEstimatedCost), system IDs (projectId,
    projectName), tags, usage, ttft, source, environment — almost none of
    which help a judge reason about agent behavior. Pasting them inline
    burns tokens on noise and risks confusing the judge ("am I supposed to
    defer to these feedback scores?").

    Introduce SpanForLlm: a lean projection with just the 11 fields a
    judge actually uses — name, type, start/end/duration, in/out, metadata,
    model, provider, errorInfo. @JsonInclude(NON_NULL) keeps the per-span
    JSON tight (a successful tool span doesn't pad with error_info: null,
    a non-LLM span doesn't carry model/provider).

    Applied on both render paths:

    • Trace-scope {{spans}}: injectSpansIntoReplacements projects before
      serialization (and before the agentic-tools cap, so the cap reflects
      what the judge actually sees).
    • Thread-scope {{context}}: EnrichedThreadChatMessage.spans now
      holds List<SpanForLlm>; fromTraceToThreadEnriched projects
      per-trace before nesting.

    New test prepareLlmRequestRendersSpansWithLeanProjection builds a Span
    populated with every dropped field set to a unique sentinel string and
    asserts none of them appear in the rendered prompt. Locks the projection
    contract — a future "let me just add this one field" PR breaks loudly.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] feat: nest child spans recursively in SpanForLlm projection

    A flat sorted list of spans is fine for "what happened in what order"
    evals, but loses the call structure that matters for agentic eval —
    "did the planner properly decompose into sub-tools? did sub-agent X
    call sub-agent Y, or were they siblings?". The judge would have to
    mentally resolve parent-id references that we'd dropped anyway in the
    lean projection.

    Switch SpanForLlm to a recursive shape: each node carries a spans
    field with its children (also SpanForLlm), built from parent_span_id
    links. Tree depth matches the actual call hierarchy the SDK recorded.

    buildSpanTree(spans) reconstructs the tree:

    • groups input by parent_span_id
    • identifies roots (parent null OR parent not in the input set —
      orphans get promoted so subtree views stay well-formed)
    • recursively projects each node, sorting siblings by start_time at
      every level so chronology is preserved within each branch

    Applied on both render paths:

    • Trace-scope {{spans}}injectSpansIntoReplacements builds the
      tree directly from the per-trace span list.
    • Thread-scope {{context}} — per-trace spans in the input group are
      fed through buildSpanTree before nesting under the assistant entry.

    Leaf spans are omitted via @JsonInclude(NON_NULL), so the JSON stays
    shaped like normal data (no "spans": [] padding on every leaf).

    New test buildSpanTreeReconstructsHierarchyAndPromotesOrphans covers
    the structural contract: shuffled input → correct two-root tree
    (real root + orphan), sibling ordering, leaf with no children, three
    levels of nesting (root → child-A → grandchild).

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [NA] [BE] test: bump ExperimentProjectMigrationJobTest startup buffer 3s → 10s

    The prime-V1 assertion at line 161 runs at ~T+3.1s on busy CI runners
    (when this test happens to share an Integration Group with several other
    container-heavy tests), past the prior 3s experimentProjectMigration. startupDelay — at which point the migration job has already flipped V1
    → V2, the assertion sees V2, and the test fails deterministically rather
    than flakily.

    10s gives ~3× headroom for setup contention without measurably extending
    the test (the second phase polls until V2 anyway, so the longer
    startupDelay just adds time the test would have spent waiting either way).

    The test moves between Integration Groups depending on greedy line-count
    bin-packing (see .github/scripts/discover-backend-tests.sh) so anyone
    whose PR happens to shift this test into a busy group lights the same
    flake — this fix is for everyone, not specifically for OPIK-5333.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • Revert "[NA] [BE] test: bump ExperimentProjectMigrationJobTest startup buffer 3s → 10s"

    This reverts commit 68a176033b.

    • [OPIK-5333] [BE] feat: enrich thread Python eval conversation with spans

    Closes the gap called out in the PR description: thread-scope Python online
    evals were stuck on the legacy [{role, content}, ...] conversation shape
    regardless of the agentic-tools feature flag, while LLM-as-judge thread
    evals already received the enriched shape with each turn's tool calls and
    I/O nested under the assistant entry. Same customer use-case ("evaluate the
    agent's tool-use across a conversation"), inconsistent backend.

    Unify by:

    • Promoting SpanForLlm from a nested record inside OnlineScoringEngine
      to a top-level com.comet.opik.api.SpanForLlm. Lives alongside Span
      and Trace so both render paths (LLM-as-judge in OnlineScoringEngine,
      Python in TraceThreadPythonEvaluatorRequest.ChatMessage) can reference
      it without a package cycle.
    • Extending TraceThreadPythonEvaluatorRequest.ChatMessage with an
      optional spans: List<SpanForLlm> field. @JsonInclude(NON_NULL) keeps
      the wire shape backward-compatible — when toggle off, the field is
      omitted and the JSON is byte-identical to today.
    • Dropping the redundant OnlineScoringEngine.EnrichedThreadChatMessage
      record. fromTraceToThreadEnriched now returns
      List<TraceThreadPythonEvaluatorRequest.ChatMessage> directly so both
      paths share one type.
    • Injecting SpanService into OnlineScoringTraceThreadUserDefinedMetric PythonScorer, reactively fetching every span in the thread when the
      toggle is on, and routing through fromTraceToThreadEnriched instead
      of the legacy fromTraceToThread. Mirrors the LLM-as-judge thread
      scorer's pattern exactly.

    Python users now get the same nested tool-call tree the LLM judge sees —
    their score(self, conversation, ...) method receives a list of dicts
    where each assistant entry carries spans with full input/output, model,
    provider, error_info, and nested children. The lean SpanForLlm
    projection (11 fields) keeps the payload focused on agent behavior, same
    as the trace-scope path.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] test: lock in toggle-on Python thread enrichment

    Adds the end-to-end counterpart to skipsSpanFetchWhenAgenticToolsDisabled —
    exercises the full toggle-on path:

    toggle on
    → spanService.getByTraceIds(traceIds) fires once with the right id set
    → fromTraceToThreadEnriched grouops the spans by trace and nests them
    → captured ChatMessage list sent to PythonEvaluatorService has user/assistant
    entries with spans populated on the assistant turn only

    Catches future regressions where someone "simplifies" by routing back to
    the legacy fromTraceToThread or quietly drops the SpanService fetch.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] feat: unify trace-scope Python spans kwarg on the lean SpanForLlm projection

    Closes the last inconsistency in the spans-render matrix: trace-scope
    Python metrics were the only path still sending the full ~28-field Span
    record to the runner, while every other path (trace LLM-as-judge
    {{spans}}, thread LLM-as-judge {{context}}, thread Python conversation)
    already projected through SpanForLlm and buildSpanTree.

    OnlineScoringEngine.toReplacements(variables, trace, spans) now puts
    buildSpanTree(spans) under the spans key instead of the raw flat
    list. One shared helper, one wire shape across all four paths.

    What the customer's Python score(self, spans, ...) method sees changes:

    Before: list of dicts with ~28 fields each (id, project_id, trace_id,
    parent_span_id, name, type, in/out, metadata, model, provider,
    tags, usage, ttft, error_info, source, environment, created_at,
    created_by, last_updated_at, last_updated_by, feedback_scores,
    comments, total_estimated_cost, ...)

    After: list of root-span dicts with 11 fields each (name, type,
    start_time, end_time, duration, input, output, metadata, model,
    provider, error_info) plus a recursive spans field carrying
    child spans — same projection the LLM judge sees.

    This IS a backward-compatible-breaking change for existing trace-scope
    Python metrics that read dropped fields (id, parent_span_id, usage,
    feedback_scores, etc.). Worth flagging to anyone with such metrics —
    they'll need to update them or pull the dropped data elsewhere. Most
    "agent-behavior" metrics only read name/type/input/output anyway, so
    the practical break should be narrow.

    Updated existing fetchesSpansAndPassesThemAsListWhenSpansArgumentPresent
    test to assert the new SpanForLlm shape end-to-end, with a known span
    name so the projection contract is explicit in the test.

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

    • [OPIK-5333] [BE] perf: move thread Python scorer's blocking prep onto boundedElastic

    The Python thread scorer's prepareScoring calls projectService.get(...) —
    synchronous JDBC. It was wrapped in Mono.fromCallable but without
    .subscribeOn, so the blocking call ran on whichever thread emitted the
    upstream Mono. Before this PR that was the consumer loop thread; after
    the toggle-on span-pre-fetch landed, it can also be the spanService DB
    thread. Both are bad — consumer loop can stall message draining, DB
    thread can starve the JDBC pool.

    Pin the callable to Schedulers.boundedElastic() — the standard reactor
    scheduler for wrapping blocking calls in reactive glue.

    The trace and thread LLM-as-judge scorers are unaffected: trace scorer's
    prepareEvaluation is pure CPU and stays on parallel(); thread LLM-as-judge
    prepareEvaluation is also pure CPU (its projectService.get() lives in the
    post-LLM .map block, which is a separate concern not introduced here).

    Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com


    Co-authored-by: Sasha sasha@Sashas-MacBook-Pro.local
    Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com
    Co-authored-by: Aliaksandr Pyrkh aliaksandr@comet.com

    下载附件