[OPIK-7354] [BE] [FE] fix: accept flat judge output and stop escaping judge prompts - #7758
Conversation
…e prompts
LLM-as-judge online-eval rules returned empty scores non-deterministically: the
built-in templates instructed a flat {"score": ...} object while
InstructionStrategy appended a nested, schema-named one, and only the nested
shape parsed. Whichever the judge picked was a coin flip.
- toFeedbackScores takes the rule's schema and accepts a flat single-score
object, so rules created before this fix keep scoring without being re-created.
Multi-score schemas still parse nothing rather than guess which score a
nameless object belongs to.
- Quoted score values are parsed instead of silently stored as 0
(decimalValue() answers ZERO for every non-numeric node, so "0.8" became 0.0 —
a wrong score rather than a missing one). Values that are neither a number nor
a boolean are reported instead of stored.
- Array reasons are joined instead of dropped; asText() on an array yields "".
- Judge answers that cannot be read are surfaced on the rule's logs via
logUnreadableResponse, rather than looking like a successful run that happened
to produce no score.
- MustacheParser no longer HTML-escapes substituted values, so a trace's JSON
reaches the judge as written instead of "-escaped. render() and
renderUnescaped() collapse into a single non-escaping render(), matching the
frontend preview and PythonTemplateParser. This also repairs multimodal URLs
assembled from template variables on the scoring path, which never went
through the unescape shim: escaping rewrote '&' and '=' inside substituted
values, so a base64 data URI lost its '=' padding to '='. URLs written
literally in a template were unaffected, since Mustache escapes substituted
values only.
Frontend built-in templates: drop the contradicting output-format blocks from
Meaning Match and Structured Output Compliance, delimit Meaning Match's
item-to-score from its few-shot examples, drop the field-name instruction from
AnswerRelevance, and declare the 0.0-1.0 answer-relevance scores as DOUBLE
instead of INTEGER.
⏱️ pre-commit per-hook timing
⏭️ 39 skipped (no matching files changed)
|
| var topLevelKeys = StreamSupport.stream( | ||
| Spliterators.spliteratorUnknownSize(structuredResponse.fieldNames(), | ||
| Spliterator.ORDERED | Spliterator.NONNULL), | ||
| false) | ||
| .toList(); | ||
| log.warn( | ||
| "Invalid LLM output format for feedback scores. Expected structure: { '<scoreName>': { 'score': <number|boolean>, 'reason': <string> } }, or { 'score': <number|boolean>, 'reason': <string> } for a single-score schema. Top-level keys: '{}'. Raw response (truncated): '{}'", | ||
| topLevelKeys, StringTruncator.truncate(content, MAX_REPORTED_RESPONSE_CHARS, null)); | ||
| return ParsedFeedbackScores.problem(ResponseProblem.Kind.NO_SCORE_FIELDS, | ||
| topLevelKeys.isEmpty() ? "(none)" : quoteAll(topLevelKeys)); |
There was a problem hiding this comment.
Unbounded error-path response accumulation
The no-score path calls toList() on every top-level field before formatting the warning, so large judge responses trigger an unbounded diagnostic allocation and log message on the synchronous scoring path — should we cap the field names and report omitted fields before logging and returning NO_SCORE_FIELDS?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringEngine.java
around lines 1132-1141 inside `toFeedbackScores`, the code builds `topLevelKeys` using a
stream and `.toList()` over all `structuredResponse.fieldNames()` just to log them and
set `ResponseProblem.Kind.NO_SCORE_FIELDS`. Refactor this to avoid unbounded allocation:
collect only a fixed maximum number of field names (e.g., same cap as
MAX_REPORTED_RESPONSE_CHARS or a small constant), track how many additional keys
exist/are omitted, and log an abbreviated representation like "first N keys: ... (and M
more)" while setting evidence similarly. Ensure the synchronous scoring path never
formats/allocates the full list and that the warning message remains bounded in size.
There was a problem hiding this comment.
Fixed in 77711b5 — correct, and the earlier round only addressed the display. The full field list was still materialised, stored on ResponseProblem and re-mapped by withUserFacingNames; only the rendering was capped.
The cap now sits where the names are collected (.limit(MAX_REPORTED_FIELD_NAMES)), with the omitted count carried alongside so the message stays truthful — the total comes from ObjectNode.size(), which is O(1). The user-visible text is byte-identical; the existing assertion on it was kept to prove that, and only the retained-list assertion changed (hasSize(500) → hasSize(10) plus omittedFields == 490).
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Commit 77711b5 addressed this comment by limiting collected field names to MAX_REPORTED_FIELD_NAMES before toList(), tracking omitted fields, and rendering the bounded list with an omission count.
- Keep the judge's raw answer out of the rule's logs, which are persisted per workspace: NOT_JSON / NOT_A_JSON_OBJECT now report the answer's size, and the answer itself stays in the server log. Matches "Never Log" in .agents/skills/opik-backend/SKILL.md and AgenticScoringService.summarizeResponse, which reports shape rather than text for the same reason. NO_SCORE_FIELDS keeps reporting the field names: they are structural and are the diagnostic that matters. - Cap the reported field names. They come from the judge's answer, so one reply with thousands of top-level fields would otherwise write a huge row. - Copy the lists in toParsed() so a caller cannot mutate a parsed result through the accumulator's own lists. - Structure the internal logs as key='value', dropping the static "expected structure" text that repeated on every occurrence and carried no per-event information. The user-facing messages stay prose: they render on the rule's Logs page for a customer, not in ops telemetry. Move the parsing tests to OnlineScoringEngineParsingTest. toFeedbackScores and logUnreadableResponse are pure static functions, but OnlineScoringEngineTest starts MySQL, ClickHouse and Zookeeper for its end-to-end tests, so asserting that a quoted score parses required a Docker daemon and ~45s. The 30 parsing tests now run in under a second; the end-to-end Redis-flow test stays behind.
| structuredResponse = OBJECT_MAPPER.readTree(content); | ||
| if (!structuredResponse.isObject()) { | ||
| log.info("ChatResponse content returned into an empty JSON result"); | ||
| return ParsedFeedbackScores.empty(); | ||
| log.info("Judge answer was not a JSON object: size='{}' response='{}'", sizeOf(content), | ||
| StringTruncator.truncate(content, MAX_REPORTED_RESPONSE_CHARS, null)); | ||
| return ParsedFeedbackScores.problem(ResponseProblem.Kind.NOT_A_JSON_OBJECT, sizeOf(content)); |
There was a problem hiding this comment.
Empty judge replies fail scoring
extractJson passes empty replies to ObjectMapper.readTree(String), which returns Java null, so structuredResponse.isObject() throws before producing ParsedFeedbackScores.problem(...) or calling logUnreadableResponse — should we treat a null tree as an unreadable response before dereferencing it?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringEngine.java
around lines 1107-1114 inside the `toFeedbackScores(ChatResponse chatResponse,
List<LlmAsJudgeOutputSchema> schema)` method, add a null/empty guard after
`OBJECT_MAPPER.readTree(content)` so you never call `structuredResponse.isObject()` when
`structuredResponse` is null (e.g., when `extractJson` yields "" or whitespace). If the
parsed node is null (or the input `content` is blank), immediately return
`ParsedFeedbackScores.problem(...)` using the same classification used for invalid JSON
(likely `ResponseProblem.Kind.NOT_JSON`) rather than letting the exception escape. Keep
the existing log+return behavior for real JSON parsing failures, but ensure the
null-tree path is handled explicitly before dereferencing.
There was a problem hiding this comment.
Skipping — this doesn't reproduce on the Jackson version in use. readTree("") returns a MissingNode, not Java null, so isObject() is safely false and the empty case already returns a NOT_A_JSON_OBJECT problem.
Two pieces of evidence: feedbackParsingArguments already includes an empty-string case expecting zero scores, and it passes — a null tree would make it error on the dereference, not pass. And the observed log for an empty reply is Judge answer was not a JSON object: size='0 chars' response='', which only reaches that branch via a non-null node.
readTree returning null for empty input was older Jackson behaviour.
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Thanks for the detailed correction—MissingNode makes the dereference safe here. I’ll save this to memory once the PR is merged.
| log.error("Judge answer was not valid JSON: size='{}' response='{}'", sizeOf(content), | ||
| StringTruncator.truncate(content, MAX_REPORTED_RESPONSE_CHARS, null), e); | ||
| return ParsedFeedbackScores.problem(ResponseProblem.Kind.NOT_JSON, sizeOf(content)); |
There was a problem hiding this comment.
Noisy error telemetry for judge responses
Handled JsonProcessingException parse failures are logged at ERROR with a full stack trace even though they return NOT_JSON, so expected unreadable responses create noisy logs that obscure real backend failures; should we log NOT_JSON and NOT_A_JSON_OBJECT at WARN without the throwable, reserving stack traces for unexpected system failures?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringEngine.java
around lines 1117-1119 inside `toFeedbackScores(...)`, the catch for
`JsonProcessingException` logs at `ERROR` with the full throwable stack trace even
though this is a handled/unreadable-judge-output fallback that returns
`ResponseProblem.Kind.NOT_JSON`. Change this logging to `WARN` and log only the
actionable context (size and truncated response), without passing `e`/omitting the stack
trace. Also update the adjacent `NOT_A_JSON_OBJECT` branch (around lines 1112-1114 in
the same method) from `INFO` to `WARN` so unreadable judge answers use a consistent,
actionable severity.
There was a problem hiding this comment.
Commit 40e08c5 addressed this comment by changing both handled parse-failure branches to WARN and removing the throwable stack trace from invalid JSON logging.
There was a problem hiding this comment.
Fixed in 40e08c5 — both unreadable-response paths now log at WARN, and the JsonProcessingException case no longer passes the throwable:
WARN Judge answer was not valid JSON: size='35 chars'
error='Unrecognized token 'Sure': was expecting (JSON String, Number …)'
response='Sure! Let me evaluate that for you.'
Jackson's reason is extracted via getOriginalMessage(), so the diagnostic survives without a stack trace. NOT_A_JSON_OBJECT moved from INFO to WARN too, so the same class of outcome reports at the same severity.
Agreed on the reasoning: a judge answering in prose is an expected outcome this PR deliberately surfaces, so it shouldn't sit in the same bucket as system failures.
🤖 Reply posted via /address-github-pr-comments
| void testToFeedbackScores(String aiMessage, int expectedSize) { | ||
| var feedbackScores = OnlineScoringEngine.toFeedbackScores(chatResponse(aiMessage), THREE_SCORE_SCHEMA) | ||
| .scores(); | ||
|
|
||
| assertThat(feedbackScores).hasSize(expectedSize); | ||
|
|
||
| if (expectedSize > 0) { | ||
| var scoresMap = feedbackScores.stream() | ||
| .collect(Collectors.toMap(FeedbackScoreBatchItem::name, Function.identity())); |
There was a problem hiding this comment.
testToFeedbackScores only asserts scores() size, so malformed, empty, or ambiguous inputs can pass even when the parser omits the appropriate ResponseProblem; the if (expectedSize > 0) branch also mixes two different assertion flows (full per-score checks vs. size-only checks) into one test, hiding which checks apply to each case. Should we split the valid-JSON case into its own dedicated test with the full relevance/conciseness/technical-accuracy assertions, and turn the parameterized test into a size-and-problem-kind check for the zero-score cases — extending feedbackParsingArguments() to also supply an expected ResponseProblem.Kind (or null for the valid case) and asserting parsed.problem() accordingly?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/events/OnlineScoringEngineParsingTest.java:
1. Split the current single parameterized test into two: a dedicated test for the
valid/readable JSON case that keeps the existing
relevance/conciseness/technical-accuracy assertions, and a parameterized test covering
only the malformed/empty/ambiguous "size-only" scenarios. 2. For the parameterized test,
change `feedbackParsingArguments()` to return tuples of (aiMessage, expectedSize,
expectedProblemKind), where each unusable/ambiguous case expects the appropriate
`OnlineScoringEngine.ResponseProblem.Kind` (e.g., NOT_JSON/NO_SCORE_FIELDS as dictated
by the input). 3. Update the parameterized test body to parse once, assert `scores()`
size, and assert `parsed.problem()` is null when expectedProblemKind is null, otherwise
assert `parsed.problem().kind()` equals the expected kind, removing the `if
(expectedSize > 0)` branching entirely.
There was a problem hiding this comment.
Skipping — the coverage this asks for already exists, as separate tests rather than as extra columns on the parameterized one.
OnlineScoringEngineParsingTest now asserts the problem kind explicitly for each shape: whenResponseIsNotJson_thenReportsItAsUnreadable (NOT_JSON), whenResponseIsJsonButNotAnObject_thenReportsIt (NOT_A_JSON_OBJECT), whenResponseHasNoScoreField_thenReportsTheTopLevelKeys and whenResponseIsAnEmptyObject_thenNamesTheAbsentFields (NO_SCORE_FIELDS), plus whenScoreValueIsUnusable_... for the per-score case. So restructuring testToFeedbackScores would move assertions rather than add them.
The point about if (expectedSize > 0) mixing two assertion flows is fair as a readability matter — noting it rather than reshaping a pre-existing test in this PR.
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Thanks for the clarification—separate tests already cover those failure shapes, so restructuring would not add coverage. I’ll save this context to memory once the PR is merged.
thiagohora
left a comment
There was a problem hiding this comment.
Reviewed the parsing/escaping changes across the backend parser, Mustache renderer, and the built-in frontend templates. The direction is good — the diagnostics and the flat-score fallback are real improvements. Six inline comments below, each verified against the branch.
The two I'd treat as blocking:
MustacheParser.render()— dropping HTML escaping leaves the matchingunescapeHtml4on media URLs inOnlineScoringEngine(lines 844/851/858), so those URLs now get mangled rather than restored.toFeedbackScoresflat fallback — gating onfoundNothing()lets an unrelated nested object carrying ascorekey win the nested pass, so the trace gets a wrongly-named score and the real one is dropped, with no warning surfaced.
The rest are a user-facing warning that reports internal assertion_N keys, a boolean-parsing narrowing that drops "yes"/"no", and two built-in template issues (User frustration still INTEGER; Meaning Match exemplars no longer JSON).
Three lower-severity items I did not comment inline: the unbounded new BigDecimal(text) in toScoreValue (a >9.2e9 score would fail the whole scoreBatchOfTraces insert, not just that score); the Fern pages for answer_relevance/meaning_match still documenting the removed output-format blocks including answer_relevance_score; and the fixed 300 ms Mono.delay in testRedisFlowScoresTraceWhenJudgeAnswersFlat, where the same class already uses Awaitility.await().untilAsserted(...) (lines 473, 643).
| * JSON structure from the judge (OPIK-7354). Escaping also mangled {@code =} and backticks, not just | ||
| * quotes. Matches the frontend preview and {@link PythonTemplateParser}, which never escaped. | ||
| */ | ||
| private static final MustacheFactory MF = new DefaultMustacheFactory() { |
There was a problem hiding this comment.
Escape/unescape pair is now asymmetric.
Dropping HTML escaping here removes only one half of a matched pair — OnlineScoringEngine.buildUserMessageFromContentParts still calls StringEscapeUtils.unescapeHtml4 on every rendered image/video/audio URL (lines 844, 851, 858). That unescape now runs against raw, never-escaped strings.
Concretely: a multimodal judge rule renders an image_url whose value contains an entity-looking sequence, e.g. https://cdn.example.com/i.png?id=1®v=2. Previously Mustache escaped the & and unescapeHtml4 restored the original exactly; now the URL sent to the provider becomes ...?id=1®v=2, so the judge scores against a 404/wrong asset or the provider rejects the request.
The unescapeHtml4 calls should go away together with the escaping.
There was a problem hiding this comment.
Fixed in 40e08c5 — removed all three unescapeHtml4 calls, so the pair is symmetric again.
You're right that this was the half I missed. I had checked MessageContentNormalizer and concluded the scoring path never unescaped, without noticing the three calls in the file I was editing. Verified your example: escaping used to turn & into & and these calls turned it back, so the round trip was lossless; with the escaping gone they were "restoring" never-escaped text and ?id=1®v=2 became ?id=1®v=2 → ®.
Worth noting the PR description previously claimed this change repaired multimodal URLs. That claim was wrong for exactly this reason and has been corrected.
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Commit 40e08c5 addressed this comment by removing all three unescapeHtml4 calls and passing rendered multimedia URLs directly to the content builders.
| }); | ||
| // The nested shape recognised nothing, so fall back to the flat one; if that recognises nothing | ||
| // either, no pass understood the answer. | ||
| if (collected.foundNothing()) { |
There was a problem hiding this comment.
A stray nested object can hijack the score name and silently suppress the flat fallback.
The fallback is gated on collected.foundNothing(), but the nested pass accepts any top-level property whose value is an object carrying a score key — it never checks the name against the rule's schema.
For a single-score rule (schema ["Meaning Match"]) with an answer like:
{"score": true, "reason": ["matches"], "details": {"score": 0.4, "reason": "partial overlap"}}the nested pass skips score/reason (not objects) but accepts details, so foundNothing() is false here and collectFlatSingleScore never runs. The trace gets a feedback score literally named details = 0.4 — nothing downstream filters names against the schema (OnlineScoringLlmAsJudgeScorer.evaluate only applies scoreNameMapping) — while the intended Meaning Match = 1 is dropped. No warning is surfaced either, since problem() is null and unreadableScoreNames is empty.
Worth restricting the nested pass to names present in the rule schema, or running the flat fallback whenever the top level has a score key rather than only when nothing at all was collected.
There was a problem hiding this comment.
Fixed in 40e08c5 — the nested pass now only accepts names the rule declares, matched case-insensitively and stored under the schema's own spelling.
Both halves of what you describe are covered: {"score": true, "details": {"score": 0.4}} no longer stores a score called details, and because that pass now recognises nothing, the flat fallback runs and Meaning Match = 1 is recorded. A name the rule doesn't declare becomes a reported NO_SCORE_FIELDS problem naming the keys the judge actually sent, rather than silent junk data.
I took the "restrict to schema names" option over "run the fallback whenever there's a top-level score" because it also closes the pre-existing half — a judge inventing a name used to get that name persisted as a real score, which no downstream filter caught.
Test: whenAnUnrelatedNestedObjectCarriesAScore_thenTheFlatScoreStillWins, plus cases for an invented name and a case-different one.
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Commit 40e08c5 addressed this comment by restricting nested score collection to case-insensitive names declared in the rule schema and storing the schema’s spelling. Unrecognized nested objects are ignored, allowing the flat single-score fallback to record the intended score.
| * indistinguishable from a genuine zero (OPIK-7354). Quoted numbers and booleans are parsed, since | ||
| * judges quote them routinely; anything else yields empty so the caller can report it. | ||
| */ | ||
| private static Optional<BigDecimal> toScoreValue(JsonNode actualScore) { |
There was a problem hiding this comment.
Common textual booleans now produce no score at all.
The unconditional decimalValue() fallback is replaced by a whitelist: boolean node, number node, text equal to "true"/"false", or BigDecimal-parsable text. That omits the spellings judges most often emit for boolean metrics — "yes"/"no", "pass"/"fail", "Y"/"N".
A BOOLEAN rule (e.g. the built-in Meaning Match) on Anthropic/Ollama via InstructionStrategy answering {"Meaning Match": {"score": "yes", "reason": "same fact"}} now returns empty from toScoreValue, lands in unreadableScoreNames, and writes no row to feedback_scores — a gap in the rule's metric chart where a value was previously always recorded.
Suggest widening the textual mapping to the usual affirmative/negative synonyms before falling through to unreadable.
There was a problem hiding this comment.
Fixed in 40e08c5 — via BooleanUtils.toBooleanObject, which covers true/false, yes/no, y/n, t/f and on/off, plus a four-entry map for pass/passed/fail/failed which it doesn't handle.
Accurate diagnosis of a regression I introduced: before my earlier commit decimalValue() returned 0 for "yes" — wrong, but a value existed; after it, nothing was recorded at all. Reusing commons-lang3 rather than hand-rolling the list means we also pick up on/off and t/f.
Tests: parameterized over both word sets.
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Commit 40e08c5 addressed this comment by mapping common boolean spellings through BooleanUtils.toBooleanObject and adding support for pass/passed/fail/failed. These values now produce storable 0/1 scores instead of being marked unreadable.
| var parsed = OnlineScoringEngine.toFeedbackScores(chatResponse, | ||
| message.llmAsJudgeCode().schema()); | ||
| OnlineScoringEngine.logSkippedNullScores(userFacingLogger, parsed, "traceId", trace.id()); | ||
| OnlineScoringEngine.logUnreadableResponse(userFacingLogger, parsed, "traceId", trace.id()); |
There was a problem hiding this comment.
The new warning reports internal keys instead of the user's names.
logUnreadableResponse is called with the raw parsed result here, before scoreNameMapping is applied at lines 387-388.
On the test-suite path (TestSuiteAssertionSampler → TestSuiteEvaluatorMapper.renameSchemaToAssertionKeys) every schema name is rewritten to assertion_1, assertion_2, … with the user's assertion text held in scoreNameMapping. So when a judge quotes a score value, the rule's Logs page shows "Could not read the score value for 'assertion_2' on traceId '…'" — a name that appears nowhere in the user's test suite. The message added to make this diagnosable doesn't say which assertion broke.
Applying scoreNameMapping before the log call (or passing it in) would fix it.
There was a problem hiding this comment.
Fixed in 40e08c5 — scoreNameMapping is now applied before the log calls, via a new ParsedFeedbackScores.withUserFacingNames(mapping).
This turned out to be purely an ordering bug: the rename already existed inline in the .map() below, so it ran after logging. Moving it above the log calls means one re-key now feeds both the logs and the stored scores, and the inline version is gone — so the two can no longer disagree.
One thing your comment doesn't mention but is worth recording: the pre-existing logSkippedNullScores had the same flaw, so the "skipped null score" message has always named assertion_N too. Both are fixed by the same change, since it re-keys nullScoreNames and unreadableScoreNames as well as the scores.
Test: whenAMappingIsGiven_thenNamesAreRekeyed.
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Commit 40e08c5 addressed this comment by applying scoreNameMapping before logging, ensuring both unreadable and skipped-null score logs use user-facing names.
| description: | ||
| "Answer relevance score checks if the output is relevant to the question", | ||
| type: LLM_SCHEMA_TYPE.INTEGER, | ||
| type: LLM_SCHEMA_TYPE.DOUBLE, |
There was a problem hiding this comment.
The sibling thread template has the same contradiction and wasn't fixed.
This INTEGER → DOUBLE fix is right, but "User frustration" at line 944 still has type: LLM_SCHEMA_TYPE.INTEGER while its prompt asks for "a frustration score as a decimal value between 0.0 and 1.0" with graded bands (0.8-0.9, 0.6-0.7, …) and an example of "score": 1.0.
On a structured-output provider (OpenAI/Gemini/OpenRouter/VertexAI) ToolCallingStrategy builds a JsonIntegerSchema for the score field, so the model is forced to emit an integer — every frustration score lands as 0 or 1 and the gradations the prompt asks for are unreachable. On non-structured providers InstructionStrategy renders the example as "score": 1 while the prompt body says decimal, which is exactly the contradictory-instruction case OPIK-7354 is about.
Same one-line change applies there.
There was a problem hiding this comment.
Fixed in 40e08c5 — User frustration now declares DOUBLE.
Confirmed the mechanism you describe: ToolCallingStrategy builds a JsonIntegerSchema for the score field, so on a structured-output provider the model can only emit an integer and the graded bands in the prompt are unreachable.
I'd fixed both Answer relevance entries and missed this one purely because I was grepping for that score name rather than auditing every schema type against its prompt.
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Commit 40e08c5 addressed this comment by changing User frustration from LLM_SCHEMA_TYPE.INTEGER to LLM_SCHEMA_TYPE.DOUBLE, allowing decimal scores and graded bands.
| ' "score": true or false,\n' + | ||
| ' "reason": ["short reason for the response"]\n' + | ||
| "}\n" + | ||
| "## Examples\n" + |
There was a problem hiding this comment.
The few-shot exemplars now demonstrate a shape the parser can't read.
The Meaning Match examples that showed the required JSON output ({"score": true, "reason": [...]}) are replaced with prose exemplars (→ TRUE — the output conveys …). Few-shot examples sitting immediately before the item to score are the strongest signal in the prompt, and they now contradict the structured-output instruction appended afterwards.
On providers without native structured output (Anthropic, Bedrock, Ollama, CUSTOM_LLM — all routed to InstructionStrategy), a judge that follows the exemplars replies → TRUE — the output matches the ground truth. toFeedbackScores then fails OBJECT_MAPPER.readTree, returns a NOT_JSON problem, and the trace gets no score — the silent no-score outcome this PR set out to remove.
If the intent was to shorten the prompt, keeping the exemplars in JSON form would preserve the demonstration without reintroducing a competing format spec.
There was a problem hiding this comment.
Fixed in 40e08c5 — the exemplars are JSON again, but in the nested schema-named shape ({"{score_name}": {"score": true, "reason": "..."}}) rather than the flat one this PR removed, so they demonstrate a parseable answer without reintroducing a competing format spec. {score_name} follows the convention the three thread templates already use, and reason is a string rather than the array that was causing reasons to be dropped.
You're right about the failure mode: on InstructionStrategy providers a judge copying → TRUE — … produces a reply that fails readTree, which is precisely the silent no-score outcome this PR exists to remove.
The same change is applied to meaning_match.mdx in both docs/ and docs-v2/, which reproduce the prompt verbatim.
🤖 Reply posted via /address-github-pr-comments
There was a problem hiding this comment.
Commit 40e08c5 addressed this comment by replacing the prose Meaning Match exemplars with nested, schema-named JSON examples containing score and reason, preserving parseable output guidance.
Blocking:
- Drop the unescapeHtml4 calls on rendered image/video/audio URLs. They were the
other half of the HTML escaping removed earlier, so they had started
"restoring" text that was never escaped: a URL containing an entity-looking
sequence (?id=1®v=2) reached the provider mangled.
- Accept only score names the rule declares. The nested pass took any top-level
object carrying a 'score', so {"score": true, "details": {"score": 0.4}} stored
a score called 'details' and — because the flat fallback runs only when that
pass found nothing — dropped the real one. Matched case-insensitively and
stored under the schema's own spelling; a name the rule does not declare is now
reported rather than stored.
Also:
- Read the boolean spellings judges actually use, via BooleanUtils
(true/false, yes/no, y/n, t/f, on/off) plus pass/fail. "yes" previously stored
0, then after the earlier tightening scored nothing at all.
- Reject a score outside the storable range instead of letting it reach the
insert. The column is Decimal(18, 9) and ClickHouse throws ARGUMENT_OUT_OF_BOUND
rather than saturating, which fails the whole batch and loses every other score
in it; the bean-validation bounds do not run on this path.
- Re-key score names before logging, so the rule's logs name the assertion the
user wrote instead of the internal assertion_N key. Replaces the rename that ran
after the log calls, so one re-key now feeds both the logs and the stored scores.
- Report unreadable judge answers at WARN without a stack trace. A judge replying
in prose is an expected outcome we surface, not a backend failure.
- Built-in templates: User frustration declares DOUBLE, matching its 0.0-1.0
prompt; Meaning Match exemplars are JSON again, in the nested schema-named shape
that agrees with the appended instruction rather than the flat one removed
earlier — prose exemplars invited a reply the parser cannot read.
- meaning_match docs follow the template. The answer_relevance and
structure_output_compliance pages document the Python SDK metrics, whose prompts
are unchanged, so they are left alone.
- The new end-to-end test waits with Awaitility instead of a fixed sleep, matching
the rest of the class.
Fixed in 40e08c5 — all six inline comments, plus the three lower-severity items you listed without inline comments:
Also fixed one of the bot's findings in the same commit: unreadable answers now log at Thanks — the two you flagged as blocking were both real, and the first was a half-completed change of mine rather than a pre-existing issue. 🤖 Reply posted via /address-github-pr-comments |
Backend Tests - Integration Group 41 829 tests 1 829 ✅ 9m 8s ⏱️ Results for commit 4bb6503. ♻️ This comment has been updated with latest results. |
ManualEvaluationResourceTest built its rules with PODAM, so the schema carried
random score names while the mocked reply hardcodes {"Relevance": ...} and the
assertions expect a score called "Relevance". That only worked because the parser
took the name from the response; now that it accepts only names the rule declares,
the score was rejected and the awaits timed out.
The judge is always told the rule's schema names, so a mocked reply has to use
them for the fixture to represent anything real.
- Carry the judge's field names in ResponseProblem as data rather than a rendered string, so withUserFacingNames can translate them. A declared score whose value is not an object is skipped before reaching the score lists, so it was reported through the problem — where nothing re-keyed it and the user saw the internal assertion_N name. - Strip control characters from those names before they are quoted. They come from the judge's answer and land in a persisted, user-visible log line, where a newline could forge an entry. - Round a score carrying more precision than feedback_scores.value keeps. ClickHouse silently drops the extra digits rather than rejecting them, so the stored number would differ from the one scoring used. Values already within scale are untouched, keeping their exact representation. Only reachable for a quoted score or a small magnitude: an unquoted JSON number large enough to exceed the scale is parsed as a double and loses the digits first. - One renderFields() feeds both the internal log and the user's message, which previously implemented the same "(none)"-or-quoted-list rendering separately.
The rebuild loop only re-added text, image and converted video, so audio and file content in the same message were silently dropped.
|
🌙 Nightly cleanup: The test environment for this PR ( |
…at the source A judge answer can claim one declared score under several case-variant keys. Both were stored, collapsing to whichever row won on timestamp, so the first one now wins. Reported field names are capped where they are collected rather than where they are rendered, with the omitted count carried alongside.
|
🔄 Test environment deployment process has started Phase 1: Deploying base version You can monitor the progress here. |
|
✅ Test environment is now available! To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml) Access Information
The deployment has completed successfully and the version has been verified. |
Python SDK E2E Tests Results (Python 3.14)295 tests 287 ✅ 3m 34s ⏱️ Results for commit 77711b5. ♻️ This comment has been updated with latest results. |
…the JVM locale Lowercasing for lookups used the default locale, which nothing in the image pins. Under a Turkish or Azeri locale that stops 'FAIL' mapping to false and breaks case-insensitive score-name matching. Also: the unusable-value warning now names the storable range, since an out-of-range number is rejected on the same path as one we cannot read; the diagnostic records carry @builder(toBuilder = true) and @nonnull components; and two names now say what they do (sanitizeAndQuote, accept's declaredName).
…swer out of logs A judge answer can carry a score under a name the rule never declares. It was dropped at DEBUG while the declared scores parsed, so the run looked clean and the user never learned a score had been discarded. Those names are now carried on the parsed result and reported. Also: the three parse-failure warnings no longer echo the judge's answer, which this branch had introduced -- size, the parser error and the structural field names remain. Both diagnostic records snapshot the lists they are handed, unset lists read as empty rather than null, and the internal construction sites name their fields instead of passing three same-typed lists positionally.
|
🌙 Nightly cleanup: The test environment for this PR ( |
A rule can be saved with a blank score name -- the schema validates @NotNull, not @notblank -- and an item built in code skips bean validation, so the flat fallback copied that name onto a stored score, where it forms part of the primary key. Also: logUnreadableResponse becomes logResponseIssues, since it reports undeclared scores as well as unreadable answers, and the rendered field-name list is emitted as one double-quoted value so a key='value' reader is not confused by the commas and single quotes inside it.
thiagohora
left a comment
There was a problem hiding this comment.
Opik reviewer — follow-up pass (round 2)
Second pass on this branch, scoped to critical issues only as requested, plus a re-check of the previous round's comments.
Previous round (6 comments, 2026-08-06) — all verified fixed in the head
| Comment | Status |
|---|---|
Escape/unescape pair now asymmetric (MustacheParser) |
✅ All three unescapeHtml4 calls removed from OnlineScoringEngine |
| Stray nested object hijacks the score name, suppresses the flat fallback | ✅ declaredNames + ignoreUndeclared(); flat fallback now reached |
| Common textual booleans produce no score | ✅ BooleanUtils.toBooleanObject + pass/passed/fail/failed map |
Warning reports assertion_N instead of the user's names |
✅ withUserFacingNames(...) applied before both log calls |
User frustration still INTEGER |
✅ Now DOUBLE |
| Meaning Match exemplars are prose the parser can't read | llm.ts comment below |
This pass
4 findings — 2 high · 2 medium. Low-severity and style findings were deliberately not posted.
Each was run through an adversarial verification gate (a verifier attempts to refute it against the repo before it posts); the Gemini one was confirmed by executing a probe against the pinned langchain4j-core 1.18.0.
Not re-raised here: the three open baz-reviewer comments from 2026-08-11 (log naming/structure, blank score names) — those are still unaddressed but are not critical.
React 👍/👎 on each comment — your feedback helps tune what it flags.
…ferently
The built-in Meaning Match template told the judge to answer under a literal
{score_name} key. Nothing substitutes single-brace placeholders, so on providers
that take the output format as advice the model saw two contradictory key names
and the rule recorded nothing. All three copies now use the declared name.
Restricting stored scores to declared names also stopped scoring rules whose
prompt asks for a different name, and discarded everything when a rule declares
no schema at all. A renamed score is now attributed when the rule declares
exactly one, after the flat pass so a stray nested object still cannot outrank a
flat score, and an empty schema keeps the judge's own names.
Also guards the Gemini conversion against a Video that carries inline base64
rather than a url, which threw and lost the payload on MinIO-backed installs.
… not the value Each line put the score name before the text identifying what happened, so no single literal matched an event class when searching. The descriptor now comes first and the value last, across all seven.
…red reasons Names and values the judge chose reached the internal debug logs unsanitised and unbounded, so a newline could forge a log entry and a huge key could flood one. One sanitiser now serves both the debug logs and the user-facing messages. Array reasons also lost any element that was an object or an array: asText() is empty for those, and the blank filter then dropped them. Those elements are now serialised so the reason keeps what the judge wrote.
… throwing A test-suite evaluator config can carry no schema at all -- TestSuiteEvaluatorMapper guards for that in three places and passes the code through unchanged -- so the scorer handed null to toFeedbackScores and the @nonnull added on this branch threw before any score was parsed. A missing schema is now read like an empty one, which already keeps the judge's own names. The sanitiser test also asserted only that a long name was absent, which passed even if the name were omitted entirely. It now pins the exact sanitised value.
Bean validation does not cascade into the schema list, so a config can carry a null entry or one with no name. Both reached the name lookup and threw, aborting the read of an otherwise usable answer. Unusable entries are now dropped once, so neither the lookup nor the single-score passes can trip over them. Also names the normalised local for what it holds, and pins the value, reason and source in the missing-schema test rather than the score name alone.
| /** | ||
| * Reads a judge's answer into scores, owning both the attribution passes and the state they build up. | ||
| */ | ||
| private static final class CollectedScores { |
There was a problem hiding this comment.
Nit: Use Lombok and move this out of this class, it's already quite big
| var relevance = scoresMap.get("Relevance"); | ||
| assertThat(relevance.value()).isEqualTo(BigDecimal.valueOf(5)); | ||
| assertThat(relevance.source()).isEqualTo(ScoreSource.ONLINE_SCORING); | ||
|
|
||
| var conciseness = scoresMap.get("Conciseness"); | ||
| assertThat(conciseness.value()).isEqualTo(new BigDecimal("4.0")); | ||
| assertThat(conciseness.source()).isEqualTo(ScoreSource.ONLINE_SCORING); | ||
|
|
||
| var techAccuracy = scoresMap.get("Technical Accuracy"); | ||
| assertThat(techAccuracy.value()).isEqualTo(BigDecimal.ZERO); | ||
| assertThat(techAccuracy.source()).isEqualTo(ScoreSource.ONLINE_SCORING); |
There was a problem hiding this comment.
DRY, extract it to an assertion method
| chatResponse("{\"Relevance\":{\"score\":1,\"reason\":\"first\"}," | ||
| + "\"relevance\":{\"score\":0,\"reason\":\"second\"}}"), |
There was a problem hiding this comment.
Avoid hard-coded values, use dtos and podam
Details
LLM-as-judge online-evaluation rules returned scores on some traces and
{"scores": []}on others, non-deterministically, for the same rule and template. The judge was given two contradictory output-format instructions in one prompt — the built-in template body asked for a flat{"score": ...}object while the backend appended a nested, schema-named one — and only the nested shape parsed. Which one the model followed was effectively a coin flip.toFeedbackScoresnow takes the rule's schema and also accepts a flat single-score object, so rules created before this fix keep scoring without being re-created. Schemas with several scores still parse nothing rather than guess which score a nameless object belongs to.0.JsonNode.decimalValue()answersZEROfor every non-numeric node, so"score": "0.8"was recorded as0.0— a wrong score rather than a missing one, indistinguishable from a genuine zero. Values that are neither a number nor a boolean are now reported instead of stored.asText()on an array yields"").MustacheParserno longer HTML-escapes substituted values, so a trace's JSON reaches the judge as written rather than as"-escaped text.render()andrenderUnescaped()collapse into a single non-escapingrender(), matching the frontend preview andPythonTemplateParser. This also repairs multimodal URLs assembled from template variables on the scoring path, which never went through the unescape shim: escaping rewrote&and=inside substituted values, so a base64 data URI lost its=padding to=. URLs written literally in a template were unaffected, since Mustache escapes substituted values only.DOUBLEinstead ofINTEGER(an integer constraint collapses them to 0 or 1 on providers using native JSON-schema output).Review rounds
a100d5d— judge-answer diagnostics:NOT_JSON/NOT_A_JSON_OBJECTreport its size instead; the answer itself stays in the server log. The answer echoes the scored trace's content and these logs are persisted per workspace, so this follows "Never Log" in.agents/skills/opik-backend/SKILL.mdandAgenticScoringService.summarizeResponse, which reports shape rather than text for the same reason.NO_SCORE_FIELDSstill reports the field names: they are structural, and they are the diagnostic that actually identifies a mismatch.toParsed()copies its lists, so a caller cannot mutate a parsed result through the accumulator's own lists.key='value', and no longer repeat a static "expected structure" paragraph on every occurrence. The user-facing messages stay prose — they render on the rule's Logs page for a customer, not in ops telemetry.40e08c5— judge-answer handling:scorefield could claim the score name, so a judge that wrapped its answer in an extra key ({"details": {"score": 0.4}}) both stored a score under a name the rule never declared and suppressed the flat fallback.BooleanUtils.toBooleanObject, coveringtrue/false,yes/no,y/n,t/f,on/off, pluspass/fail. These previously produced no score at all.scoreNameMappingis applied before the log calls, so warnings name the score the user configured rather than the internalassertion_Nkey.WARNwithout a stack trace, usingJsonProcessingException.getOriginalMessage()so the raw answer isn't echoed through the exception message.User frustrationdeclaresDOUBLE.96d880c— translating, sanitising and rounding what the judge sends:ValidationUtils.SCALEare rounded withRoundingMode.HALF_UPrather than truncated by ClickHouse on insert, and values outsideMIN/MAX_FEEDBACK_SCORE_VALUEare rejected up front instead of failing the whole batch withCode 69 ARGUMENT_OUT_OF_BOUND. Narrower than it first appears: an unquoted JSON number large enough to lose precision is already adoubleby the time Jackson hands it over, so the scale path is only reached for quoted values and small magnitudes.ResponseProblem: kind, evidence, offending field names) instead of as pre-rendered strings, and a single renderer produces both the internal log and the user-facing message so they cannot drift.25bbc6e— Gemini content conversion:OpikGeminiChatModel.convertMessagerebuilt a message's content list from onlyTextContent,ImageContentand the convertedVideoContent, so any other content type silently fell off the list despite a comment claiming otherwise. It now special-casesVideoContentand passes everything else through. This only ever affected audio or file content arriving alongside video, since the rebuild is skipped entirely for messages without video.5e6715e— the new test fixture is built throughLlmAsJudgeOutputSchema.builder(), per.agents/skills/opik-backend/SKILL.md.77711b52— determinism and proportion in what gets reported:{"Score": ..., "score": ...}). Both resolved to the same canonical name and both were stored;insertFeedbackScoresbinds the list without deduping, andfeedback_scoresis aReplacingMergeTreekeyed on(workspace_id, project_id, entity_type, entity_id, author, name)versioned bylast_updated_at, which is anow64(9)column default the insert never sets. So the two rows collapsed to whichever won on a timestamp the application does not control — a nondeterministic score. The answer's first occurrence now wins.ResponseProblemand re-mapped bywithUserFacingNames, with only the display capped. The omitted count is now carried alongside, so the message is unchanged while the retained list is bounded.\p{Cntrl}is hoisted to astatic final Pattern; it is applied inside a per-namemap, so it was being recompiled per element.LlmAsJudgeOutputSchema.builder(), matching the sibling change above.1421bd46— locale-independent matching, and proportionate diagnostics:"FAIL"lowercases to"faıl"and stops mapping tofalse, and case-insensitive score-name matching breaks for any name containing anI. All three sites now useLocale.ROOT. This does not fire on the shipped image (noLANG/LC_ALL, no-Duser.language, so the default is en/C) — it is taken because the lowercasing is a lookup key rather than display text.ValidationUtilsrather than literals.ResponseProblemandParsedFeedbackScorescarry@Builder(toBuilder = true), the former with@NonNullcomponents, and the rename path rebuilds throughtoBuilder()instead of re-listing every component positionally.c5353e58— reporting what was discarded, and keeping the answer out of the logs:DEBUGwhile the declared scores parsed, so the run looked successful and the user never learned a score had been discarded — the same silent-nothing-happened shape this PR exists to remove, introduced by the declared-name restriction added earlier in this branch. Those names are now carried on the parsed result and reported on the rule's Logs page.foundNothing()deliberately still ignores them, so an answer of only undeclared scores continues to reach the flat fallback and thenNO_SCORE_FIELDS.origin/mainlogged none of it. Size, the Jackson error and the structural field names remain. This matchesAgenticScoringService.summarizeResponse, which the scorer already used to report a response's length and shape rather than its text.List<String>positionally, where a swap would compile silently and mislabel every diagnostic.37ee8357— a blank declared name must not reach storage:LlmAsJudgeOutputSchema.nameis@NotNullrather than@NotBlank, and an item built in code skips bean validation. The flat fallback added by this PR copied that name onto a stored score, wherenameis part of the primary key.accept()now rejects it, covering the nested path too. Fixing it at the API boundary instead was tried and backed out:@NotBlankis inert without@Validon the schema list, and adding that also activates the dormant@NotNullontypeanddescription, changing create/update validation for existing callers — wider than this PR, and tracked separately.logUnreadableResponseis nowlogResponseIssues: it reports undeclared scores as well as unreadable answers, so the old name had stopped being accurate.key='value'reader.56dd6063— keep scoring rules the name restriction had silenced:{score_name}key. Nothing substitutes single-brace placeholders — Mustache expands{{var}}— so on providers that treat the output format as advice the model saw two contradictory key names and the rule recorded nothing. All three copies now use the declared name; the placeholder belongs only to the custom template, where the user replaces it by hand. Introduced by the earlier commit in this branch that replaced the prose exemplars.relevance_scoreagainst a schema entry namedRelevance), which stored scores before this branch. A third attribution pass now handles that when the schema declares exactly one score and the answer carries exactly one score-bearing object. It runs after the flat pass, so a stray nested object still cannot outrank a flat score. Attribution is by arity, so an invented name on a single-score schema is now attributed rather than reported — the trade being that one plausible score beats none on a background path.@NotNull, not@NotEmpty) discarded every score the judge returned and reported that the answer had none of the expected fields, which was untrue. With nothing declared there is nothing to match against, and nothing that could be hijacked, so the judge's own names are kept.Video.url()unconditionally, but aVideocarries either a url or inline base64: MinIO-staged attachments arrive as base64, so this threw and, had it not thrown, would have dropped the payload. Pre-existing, but in the method this branch rewrote and the same silently-lose-input class.Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
Testing
Commands run locally (macOS; Maven with Docker testcontainers for the container-backed suites, Node for the frontend):
mvn -o test -Dtest=OnlineScoringEngineParsingTest— 64 tests, green, no containers. All parsing and reporting behaviour.mvn -o test -Dtest=GeminiVideoSupportTest— 11 tests, green, no containers.mvn -o test -Dtest=MustacheParserTest,ExperimentItemProcessorTest,ExperimentMessageRendererTest,TestSuiteEvaluatorMapperTest,TestSuiteAssertionSamplerTest,PythonTemplateParserTest— 80 tests, green.mvn -o test -Dtest=OnlineScoringEngineTest— 121 tests, green, before the parsing tests were split out into their own class. Not re-run since the split (see "Not run" below).mvn -o test-compile— green.npm run lint && npm run typecheck(frontend) — green.pre-commiton the changed files — spotless, eslint and frontend typecheck all green.The parsing tests were moved out of
OnlineScoringEngineTestintoOnlineScoringEngineParsingTest:toFeedbackScoresandlogUnreadableResponseare pure static functions, but that class starts MySQL, ClickHouse and Zookeeper for its end-to-end tests, so asserting that a quoted score parses previously required a Docker daemon and ~45s. The end-to-end Redis-flow test stays behind, because it genuinely needs the containers.Scenarios validated by new tests:
"score": nullis reported as skipped rather than dropped."0.8","1","true","TRUE"," false ","yes","pass") are parsed; unusable values ("high","",[],{}) are reported instead of stored as0.& < > ' " =` in substituted values, and keeps substituted JSON structurally intact.Expected size: 2 but was: 1).Expected size: 1 but was: 2).PASS/TRUE/FAIL/FAILED, which exercise the pass-fail map rather thanBooleanUtils.""or whitespace) stores nothing rather than writing a blank name into the score table. Verified to fail without the guard.Cannot invoke "java.net.URI.toString()" because ... Video.url() is null).Expected size: 1 but was: 0 in: [], matching the reported symptom) and pass with it.Verified against a deployed PR environment (
pr-7758) with a script driving the API end to end — fresh project, rules created before the traces they score, then polling for stored scores and reading the rule's Logs page. Ten flows, all passing, on both provider families: OpenAI (native JSON-schema output,ToolCallingStrategy) and Anthropic (appended text instruction,InstructionStrategy). Covered: trace/span/thread scorers, nested and flat prompts, non-escaped rendering,DOUBLEvsINTEGER, excess-precision rounding against real ClickHouse, and an out-of-range score rejected while its sibling stored with the warning surfacing to the user.The built-in Meaning Match template was additionally exercised by hand, since it reaches the backend only through the rule-creation form: a rule created from the template in the UI against Anthropic scored three traces correctly — true for a reworded match, false for a wrong painter, true for "seven continents" against "7" — with no warnings on the rule's log. The judge's reasons came back echoing the template's own exemplar wording. Every scripted run used custom prompts, so this path had not been covered before.
That run also confirmed empirically what the
INTEGERtoDOUBLEtemplate change is for: told to emit0.75, aDOUBLEschema stores0.75on both providers, while anINTEGERschema stores0.0on OpenAI (the JSON schema constrains the model, which floors) and0.75on Anthropic (the type is only advisory). The pre-fix templates therefore scored differently per provider.Not run:
OnlineScoringEngineTesthas not been re-run since the parsing tests were split out of it. The edit there was the deletion of one contiguous block and the class compiles, but its end-to-end tests have not executed against the final state — CI covers this.mvn test) was attempted locally and aborted: too many concurrent testcontainers for one machine, so unrelated classes failed withContainerLaunchExceptionon ClickHouse and MySQL startup. An environment limit, not a code failure — the same classes pass when run targeted. CI is the real signal.AudioContentis unchanged by this PR, since audio-only messages already reached it untouched.Documentation
meaning_match.mdx(bothdocsanddocs-v2) is updated to match the revised built-in template: the documented output format and few-shot examples showed the flat shape this PR removes, so leaving them would have documented a format the parser no longer receives from the template. The other metric pages describe the Python SDK metrics rather than the built-in prompt text, so they remain accurate and are unchanged.