Repository navigation
[issue-8134] [SDK] fix(g-eval): locate the score token by content instead of a fixed offset - #8152
Conversation
The logprob scorer assumed the score digit is always the fourth token
and that scores are single digits. Both assumptions break in the wild:
a two-digit score ("score": 10) tokenizes across two tokens, so only
the first digit is averaged and a confident 10 scores ~0.1; and
tokenizer variants that fold the whitespace after the colon into the
score token (" 0") make every candidate non-decimal, so a perfectly
parseable response raises MetricComputationError instead.
Locate the score digits by reconstructing the token stream and
matching the "score" key, strip candidate tokens before the decimal
check, and for a two-token span combine the two positions' candidates
("1" + "0" -> 10) while counting a first-position candidate only
when it covers the whole span. Falls back to the legacy fixed offset
when the key cannot be found in the reconstructed text.
Related: comet-ml#8134 (defect 3 of 4)
|
Hi @feiiiiii5 Thanks for the contribution! When you have a chance, could you take a look at the automated review comments from Baz? For each comment, please either address it with a code change or reply with a brief explanation if you believe the current implementation is the right approach. Thanks! |
…loads The token-stream locator matched the first "score": <digits> in the reconstructed text, but json.loads (and therefore the no-logprob text path) resolves duplicate keys to the LAST one. A model that repeats the score key would be scored from the stale first key in the logprob path while the text path reports the final value. Take the last match so both paths agree.
When the score key cannot be located, the legacy fixed offset is used with no diagnostics; a malformed response that lands on an unrelated token is then hard to diagnose. Log the reconstructed text at debug level before falling back.
|
Thanks @andrescrz — all Baz comments on this PR are now either addressed or answered inline: locator-failure diagnostics in ecb2385, duplicate-"score"-key resolution (last-wins, mirroring json.loads and the text path) in 137faf9 with regression tests, and the two-token probability note answered inline as a documented approximation of the existing weighted-average design. |
|
Hi @feiiiiii5 Thanks for the contribution! There's another round of Baz comments. When you have a chance, could you take a look again? For each comment, please either address it with a code change or reply with a brief explanation if you believe the current implementation is the right approach. Thanks! |
|
Thanks @andrescrz — addressing the second round of Baz comments:
Let me know if you would like the remaining two as code in this PR or as a follow-up. |
Tighten the duplicate-score-key regression to the exact weighted value of the second key's candidates (pytest.approx, abs=1e-9) instead of a direction-only range, and add two provider-shaped GEval.score tests through the real LiteLLM branch (split-score 10, folded whitespace), asserting the exact weighted values plus reason and provider kwargs.
|
Hi @andrescrz — the remaining two second-round items are addressed in 4f14e81 (both landed in this PR, per my 09-09 note; no follow-up preference was indicated):
|
Extend the provider-shaped coverage to GEval.ascore, which calls aget_provider_response independently of the sync path: two async twins (split-score 10, folded whitespace) with identical fixtures and the same exact expectations as their sync counterparts (same sync parser, so identical input must yield identical values). Verified both fail on the pre-fix parser (0.09916... / MetricComputationError) and pass with it.
|
Hi @andrescrz — the async-coverage Baz comment is addressed in 88a2b7c: two async twins of the provider-shaped tests through the public GEval.ascore LiteLLM branch (split-score 10 and folded whitespace, same exact expectations as the sync versions since the parser is shared), plus an async provider stub for aget_provider_response. Both fail on the pre-fix parser (0.09916… / MetricComputationError) and pass with the fix; [email protected] check clean. |
petrotiurin
left a comment
There was a problem hiding this comment.
Thank you for opening this PR @feiiiiii5 ! Your approach looks good, but I have some concerns (outlined below), please take a look!
- _decimal_candidates: narrow typehints (entry: Any -> list[tuple[str, float)]), wrap entry in _to_dict - degrade to the text path when the located span carries no probability mass (punctuation-carrying tokens) instead of raising - locator fallback log: reconstructed length only, no response text - tests: drop redundant SimpleNamespace import; lift provider fixtures to module constants + parametrize sync/async; pin punctuation-split degrade
The branch that sums to zero probability mass on the located digits was the only degradation path that had already found the score key, so a low-fidelity text score was indistinguishable in the logs from a provider that never returns logprobs. Log it at DEBUG like the sibling locator-failure branch and pin the message in the regression test.
|
@petrotiurin all six of your points are on the branch, plus one from Baz's pass on top. Mapping each to its commit so the re-review is a single pass:
Verified locally at Nothing is open on my side. The six inline replies carry the before/after detail if any entry above reads differently than you intended. |
petrotiurin
left a comment
There was a problem hiding this comment.
Thank you for your hard work on this and for addressing the comments @feiiiiii5 ! One final concern that needs to be addressed and I think we can go ahead and merge this PR.
`matches[-1]` mirrors json.loads for a duplicate key at the top level, but not
for a nested one. On `{"score":7, "reason": "ok", "breakdown": {"score":3}}` the
locator landed on the nested 3 and weighted the average from its logprobs, while
json.loads and the text path both read the top-level 7. Measured on that
response: main 0.6219, this branch before the change 0.2740, text path 0.7.
Prefer the last match that agrees with the parsed value, and keep the positional
choice when the reconstruction is not parseable (several token streams in the
suite end mid-object, so that branch is the common path there).
Comparing against the parsed value identifies the value, not the position.
On {"score":7, "reason": "ok", "breakdown": {"score":7}} both digits agree,
so the reversed scan still returned the nested one and the weighted average
was built from the breakdown token's logprobs. Measured on that response:
main 0.6219349153822014, 0e48cce 0.7809998433984686, text path 0.7.
Restrict agreeing matches to the shallowest brace depth first, then take the
last of those, which keeps duplicate top-level keys resolving to the final
one. Differentials over 32 reconstructed texts show selection changes only
where a nested score restates the top-level value; every other shape picks
the same span as before.
The value-agreement path replaces the positional default whenever it finds agreeing matches, so a matches[0] locator no longer changes any outcome that has a parsed value to agree with. Last-wins is then only reachable through an unparseable reconstruction, and the existing test for that path has a single score key, so nothing pinned it. Two keys in a truncated stream do. This one passes on the previous head too: it closes a hole in coverage rather than demonstrating a fix.
|
@petrotiurin your concern is closed on The check you suggested compares the located digits against the parsed value, which rejects a nested key whose value differs. It cannot reject one whose value is the same: The rule now restricts agreeing matches to the shallowest brace depth before taking the last of them, so duplicate top-level keys still resolve to the final one. Over 32 reconstructed texts the selected span changes only where a nested score restates the top-level value; every other shape selects the same span as before. Suite is 15 passed, Two things that came out of measuring it, both in the body: the |
The depth rule counted brace characters in the text preceding each match, so braces inside a string value read as structure. A reason carrying three closing braces left the nested key shallower than the top-level one and the weighted score was built from the wrong token: 0.7809998433984686 where main and the text path both give 0.6219349153822014. The same rule copied a growing prefix per match, which is quadratic in the response length: 58.1 ms at 64KB against 5.5 ms for the walk. Walk the first object once with a pattern that matches a whole JSON string or a single brace, reading the key only at depth 1 and stopping when that object closes. Strings are consumed whole, so neither a brace nor a score echo inside a reason can move the structure, and last assignment wins so duplicate top-level keys still resolve as json.loads does. Nothing is validated, so a truncated reconstruction behaves as before, which lets the json.loads agreement check and its bare except go away. Design and reference implementation from petrotiurin on the PR thread.
petrotiurin
left a comment
There was a problem hiding this comment.
LGTM, thank you for fixing this and for addressing all the comments! Really appreciate the responsiveness here!
Addresses Baz's comment on 39270cd: a tool input that failed json.loads fell back to the streamed string without a log. It is now logged at DEBUG with the content block index and the exception text, as the Claude, Nova, Llama and Mistral aggregators and invoke_model stream_wrappers do, and without the input itself: tool input can carry user data, and users turn DEBUG on (petrotiurin on comet-ml#8152). A new unit test checks that the log names the block and leaves the input out; it fails on 39270cd. Live, claude-sonnet-4-6 with maxTokens 40 stops at max_tokens after streaming '{"city": "Paris"'; the span keeps that string and the log reads "Could not parse tool input of content block 0: Expecting ',' delimiter: line 1 column 17 (char 16)". Co-Authored-By: Claude <[email protected]>
…ock converse_stream traces (#8527) * [issue-8526] [SDK] fix: keep each streamed tool call complete in Bedrock converse_stream traces converse_stream sends a tool call's input as JSON string fragments, one per contentBlockDelta. The aggregator merged each delta with dict.update(), so every fragment replaced the previous one and the span kept only the last piece (for example '"}'). toolUseId and name, which arrive in contentBlockStart, were never read, and parallel tool calls all landed in the same entry. Rebuild each tool call from its contentBlockStart and deltas, keyed by contentBlockIndex, the way the OpenAI and Mistral aggregators key tool-call fragments by index. The first tool call stays in content[0]; each further one gets its own content entry. Text streaming is unchanged. Co-Authored-By: Claude <[email protected]> * fix(bedrock): give converse_stream spans the converse content layout Addresses the three points in aadereiko's review: - toolUse.input was the joined JSON string in converse_stream spans and a parsed document in converse spans. The joined input now goes through json.loads. An empty input (Claude streams a no-argument call as "") becomes {}, as in converse, and input that does not parse (e.g. cut off by maxTokens) is kept as the streamed string. - Content entries followed arrival order. Blocks are now collected by contentBlockIndex and written out sorted, one entry per block, so the first tool call no longer shares content[0] with the empty text placeholder. This is the layout Baz suggested in the first review. - A missing contentBlockIndex defaulted to 0, which merged two calls. botocore marks the index as required on start, delta and stop events; without it, a start now opens the next block and a delta goes to the latest one. The block ordering runs after the per-event try/except, and the stream wrapper calls the aggregator in `finally`, so the index goes through int() while each event is handled: a malformed index drops that event instead of raising from the user's stream loop. Tests: the unit tests are one parametrized test with 8 cases; 7 fail on the previous head and on main, and the text-only case is unchanged. Live in us-east-1, the converse_stream span has the same layout as the converse span for the same request (one call, two calls, a no-argument call) on GPT-6 Sol, Luna and Astra, claude-sonnet-4-6 and Nova Pro, and tests/library_integration/bedrock/test_converse.py passes (13). Co-Authored-By: Claude <[email protected]> * fix(bedrock): log converse_stream tool input that does not parse Addresses Baz's comment on 39270cd: a tool input that failed json.loads fell back to the streamed string without a log. It is now logged at DEBUG with the content block index and the exception text, as the Claude, Nova, Llama and Mistral aggregators and invoke_model stream_wrappers do, and without the input itself: tool input can carry user data, and users turn DEBUG on (petrotiurin on #8152). A new unit test checks that the log names the block and leaves the input out; it fails on 39270cd. Live, claude-sonnet-4-6 with maxTokens 40 stops at max_tokens after streaming '{"city": "Paris"'; the span keeps that string and the log reads "Could not parse tool input of content block 0: Expecting ',' delimiter: line 1 column 17 (char 16)". Co-Authored-By: Claude <[email protected]> * test(bedrock): check converse_stream tool calls in the logged trace and span Addresses Baz's two comments on f3e4de0: - Nothing checked what track_bedrock logs for a streamed tool call: the unit tests call aggregate_converse_stream_chunks directly, and the live tool-use test in test_converse.py asserts output=ANY_DICT because live ids and inputs vary. A new unit test streams two tool calls (block 1 first) from a stand-in client through track_bedrock and asserts the exact output of the trace and of the span. It needs no credentials, so the unit test workflow runs it on pull requests. It fails on main's aggregator and on four changes the aggregator tests still pass: converse_decorator logging no response key as output, wrap_stream not collecting events, converse_stream wired to the invoke_agent aggregator, and the trace not ended with the stream output. - The cut-off input log test now also asserts that the streamed string is kept. The cut-off-input case of the parametrized test already checked that value; now the log test checks it too. Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
Details
"score": 10) tokenizes across two tokens, so only the first digit was averaged: a confident 10 parsed as ~0.099. A tokenizer folding the whitespace after the colon into the score token (" 0") made every candidate fail the decimal check and raisedMetricComputationErroron a perfectly parseable response."score"key only at depth 1, stopping when that object closes. Last assignment wins, so duplicate top-level keys resolve the wayjson.loadsdoes, and a nested"score"cannot capture the position because it sits deeper. Candidate tokens are stripped before the decimal check; a two-token span combines both positions' candidates ("1" + "0"-> 10).Change checklist
Issues
Related: #8134 (defect 3 of 4 —
Resolvesintentionally avoided so the four-defect issue stays open for defects 1 and 2)AI-WATERMARK
AI-WATERMARK: yes
Tools: OpenCode (Sisyphus agent session)
Model(s): muse-spark
Scope: review-feedback commits only —
5486cc3(typehints,_to_dictwrap, no-mass degrade, content-free log line, test dedup/parametrize, punctuation-split regression test),3b8cfe9(DEBUG fallback log + message assertion),0e48cce(locator value-agreement fix, two regression tests, two docstring corrections),ae048eadf(locator brace-depth fix + one regression test),670fdd61e(one test-only commit closing a coverage hole the depth rule opened),3b056b5d9(locator rewritten to a single-pass structural walk, design and reference implementation frompetrotiurinon the thread, plus three regression tests)Human verification:
pytest tests/unit/evaluation/metrics/llm_judges/g_eval/(18 passed at3b056b5d9, 15 at670fdd61e, 13 at0e48cce),pytest tests/unit/evaluation/metrics/llm_judges/126 passed, new tests verified to fail on the pre-fix parser,[email protected]check + format clean,[email protected]clean; mutation table below re-run whole at each head, each mutation restoring the file byte-for-byteTesting
At
3b056b5d9:PYTHONPATH=src python -m pytest tests/unit/evaluation/metrics/llm_judges/g_eval/ -q→18 passedPYTHONPATH=src python -m pytest tests/unit/evaluation/metrics/llm_judges/ -q→126 passed3b056b5d9fail against670fdd61e'ssrc/and only those three do; the fifteen existing tests pass unmodified on the new locatoruvx [email protected] check+format --checkon both touched files → clean;uvx [email protected] --config-file pyproject.toml --disable-error-code misc --ignore-missing-importsonparser.py→Success: no issues found in 1 source filePYTHONPATH=src python -m pytest tests/unit -q --tb=no -rf→1 failed, 5161 passed, 3 skipped in 243.78s. The failure istests/unit/evaluation/metrics/test_heuristics.py::test_evaluation__levenshtein_ratio, in a file this diff does not touch;importlib.util.find_spec("rapidfuzz")returnsNonehere, so an optional dependency is missing rather than anything in this change being wrongHistorical, at earlier heads:
test_punctuation_carrying_split_digits_degrade_to_text_pathfails on the pre-PR parser withMetricComputationErrorand passes post-fix with1.0. A broadertests/unit/evaluation/sweep at that time showed the same failure count on the clean tree (91 failed / 927 passed) and on this branch (91 failed / 930 passed); those 91 are pre-existing environment failures unrelated to this diff.Documentation
N/A — no user-facing API or config change.
Follow-up round (3b8cfe9). On Baz's question, the zero-probability-mass fallback now logs at DEBUG with the metric name, matching the sibling locator-failure branch, and
test_punctuation_carrying_split_digits_degrade_to_text_pathasserts the message as well as the value. Measured against its immediate predecessor 5486cc3, which degrades silently, the assertion fails on an empty capture; against the pre-PR parser the test fails outright withMetricComputationError. Post-fix: 11 passed intests/unit/evaluation/metrics/llm_judges/g_eval/,ruff checkandruff format --checkclean,mypyclean onparser.py.Follow-up round (
0e48cce, addressing the nested-key concern). Reproduced first, then applied the suggested agreement check. Measured on{"score":7, "reason": "ok", "breakdown": {"score":3}}, with the top-level digit at entry 3 and the nested one last in text order:d53961c43b8cfe9(before this round)30e48cceTwo tests added:
test_nested_score_key_does_not_capture_the_located_token— fails on3b8cfe9withAssertionError: scored from the nested key: 0.27397830512740046, passes here.test_unparseable_reconstruction_keeps_the_positional_match— pins the fallback when the reconstructed stream is not valid JSON.Mutations, each restoring
parser.pybyte-for-byte (sha256 checked) and re-running the suite:3b8cfe9)matches[0])0e48cceit kills nothingexceptThe third row is why the fallback stays: removing it fails 6 of the 13 tests, not just the new one, because the reconstructed text is not valid JSON in several existing fixtures (their token streams stop before the document closes). In those cases the positional choice is kept, which is today's behaviour.
Two notes on the suggestion. I did not hoist the
json.loadsinto the one further down: that one parseschoice_dict's message content, this one parses the reconstructed token text, and those can differ (the truncated-stream case), so the parses are not interchangeable. And the two docstring claims that said last-match-wins mirrorsjson.loadswere true for top-level duplicates only, so both now state the value-agreement rule.Verification: 13 passed in
tests/unit/evaluation/metrics/llm_judges/g_eval/;uvx [email protected] checkandformat --checkclean on both files;uvx [email protected] --config-file sdks/python/pyproject.toml --disable-error-code miscclean onparser.py(run with--ignore-missing-importslocally, since this environment has nolitellmstubs).Follow-up round (
ae048eadf+670fdd61e, on the three remaining locator claims).Each claim was measured as one response per claim across three trees —
origin/main, thisPR at
0e48cce, and after the change at670fdd61e— instead of being reasoned about:json.loadsmain0e48cce670fdd61e{"score":7, "reason": "ok", "breakdown": {"score":7}}{"\u0073core": 7, "reason": "ok"}MetricComputationError{"score": 1e1, "reason": "ok"}Row 1 is real and is fixed here. Value agreement does not identify a position: when a
breakdown restates the top-level score, both digits agree, so the reversed scan kept
returning the nested one and the weighted average came off the breakdown token's
distribution.
mainscores 0.6219349153822014 on that response, so this was a regressionthe previous round introduced — and a worse shape than the differing-value case, because it
yields a plausible number rather than an obviously wrong one. The rule is now: take the
agreeing matches, restrict them to the shallowest brace depth, then take the last of those,
which keeps duplicate top-level keys resolving to the final one.
test_nested_score_key_with_the_same_value_does_not_win_the_locatorfails pre-fix withassert [13] == [3]and passes post-fix. Selection differential over 32 reconstructed texts(top level only; breakdown with the same and with a different value; two criteria; criteria
inside an array; braces inside a string; duplicate top-level keys; doubly nested) changes
the chosen span in 5 of them, and all 5 are the equal-valued-nested family — the other 27
select the same span as before.
Rows 2 and 3 are not changed, and the numbers say why. The escaped key returns
Nonefrom the locator, so the caller falls back to the fixed offset and lands on 0.7, the same
value
json.loadsgives, and better thanmain, which raisesMetricComputationErroronthat response. This PR therefore does not worsen the escaped-key case; it leaves it on a
path that already resolves it. The exponent row is identical on all three trees, so it is
inherited rather than introduced; consuming the full number grammar would also extend the
digit span onto
e1, which_weighted_score_sumscannot weight because it filterscandidates on
isdecimal(). Fixing that means changing the weighting code, and it is onlyreachable if the integer contract in
_COT_SYSTEM_PROMPT("SCORE VALUE MUST BE AN INTEGER")is relaxed. Left out rather than widening this diff.
Corrections to my own previous round, found while measuring this one.
matches[0]row in the table above is retracted. Re-run at0e48cceit kills nothing(13 passed): the value-agreement step overrides the positional default whenever any match
agrees, so first-vs-last only survives on an unparseable reconstruction, and the test for
that path used a single score key. The row was true of an earlier head and I carried it
forward without re-measuring it.
670fdd61ecloses the coverage hole that explanation describes: two score keys in atruncated stream, where last-wins is the only rule left standing. It passes on
0e48ccetoo, so it demonstrates missing coverage rather than a fixed defect.
Mutation table at
670fdd61e, 15 tests as the baseline. Each mutation was applied toparser.py, the suite run, then the file restored byte-for-byte (sha256e81072a313fb,checked before and after). This table is superseded by the one at
3b056b5d9below: thecode it mutates no longer exists.
match = agreed[-1][0])matches[-1], i.e.3b8cfe9)matches[0])The last row is why the fallback stays load-bearing: it takes the punctuation-split
degradation test and both unparseable-reconstruction tests, plus all four provider-path e2e
cases (sync and async, two parameterizations each).
Verification at
670fdd61e:PYTHONPATH=src pytest tests/unit/evaluation/metrics/llm_judges/g_eval/→ 15 passed;uvx [email protected] checkandformat --checkclean on both files;uvx [email protected] --config-file pyproject.toml --ignore-missing-importsclean onparser.py(the--ignore-missing-importsis localonly, this machine has no
litellmstubs; CI runs without it, andtests/is excluded frommypy by
.pre-commit-config.yaml). Source diff is 23 lines added / 5 removed inparser.py; the rest is tests.Follow-up round (
3b056b5d9, the depth rule replaced by a structural walk).petrotiurinreproduced two defects in the rule the previous round added, and supplied thedesign and a reference implementation. Both reproduce here, measured before changing
anything.
The depth rule counted
{and}characters in the text preceding each match, so bracesinside a JSON string value read as structure. With
}}}in a reason the top-level key cameout at depth 1 and the nested one at depth 0, so "shallowest agreeing match" selected the
nested key. The same rule copied a growing prefix and ran two full
.count()scans peragreeing match, which is quadratic in the response length.
main670fdd61e3b056b5d9{"score":7, "reason": "returns }}} here", "breakdown": {"score": 7}}{"reason": "ok", "breakdown": {"score": 7}}, the only score nestedMetricComputationErrorNone,MetricComputationErrorTiming on a rubric-shaped response that doubles each step (top-level score plus N criteria,
each
{"name": ..., "score": 7}, four-character tokens):670fdd61e3b056b5d9The walk is one pass:
_STRUCTURE_REmatches either a whole JSON string, escapes includedand an unterminated tail tolerated, or a single brace. Strings are consumed whole, so
neither a brace nor a
"score"echo inside a reason can move the structure. The key is readonly at depth 1 and the walk stops when that object closes. Last assignment wins, so
duplicate top-level keys still resolve as
json.loadsdoes. Nothing is validated, so atruncated reconstruction behaves exactly as before, which lets the
json.loadsagreementcheck and its bare
exceptgo away.jsonstays imported:parser.py:111still uses it.Three tests added. The fifteen existing ones pass unmodified, and against
670fdd61e'ssrc/the file gives three failures which are exactly the three new tests.test_braces_inside_a_string_do_not_shift_the_score_depth— fails pre-fix withassert [13] == [3].test_score_only_nested_falls_back_instead_of_scoring_from_it— pins the decision on theconsequence below; fails pre-fix, which scored 0.6219349153822014 from the nested key.
test_walk_stops_at_the_first_object_close—{"score": 7} and {"score": 3}locatedentry 9 pre-fix, entry 4 now.
The consequence flagged on the thread is taken, not absorbed. A response whose only
"score"is nested now returnsNone, falls through to the legacy offset and raises,matching
mainand the text path. Otherwise the same response succeeds or fails purely onwhether the provider returned logprobs.
One shape where the walk is stricter than the rule it replaces. Prose carrying a complete
brace pair before the JSON,
Use {this} format: {"score": 7}: the walk closes on the prosebrace and returns
None, where the old rule reached the real key. On that input all threetrees still end in
MetricComputationError, becauseparser.py:111parses the messagecontent with a strict
json.loadsand rejects anything prose-wrapped before the locatedscore matters. So the narrowing changes no outcome today, and where it eventually shows up it
degrades to the legacy offset, which is
main's behaviour. Raised on the thread rather thansilently widened; continuing past a close while
spanis stillNoneis a one-line changeand is deliberately not made here.
A distinction Baz's report and the thread did not separate. Baz's literal example,
{"reason":"{", "score":7}, does not reproduce: it has one match, so the shallowest-of-agreeing step has nothing to choose between and it locates correctly.
mainis what raisesthere, because the fixed offset lands on the
{token and the zero-mass degrade did notexist before this PR. Only the
}}}variant breaks the depth rule, since the comparisonneeds a second match to pick wrongly.
Mutation table at
3b056b5d9, 18 tests as the baseline, each mutation restoredbyte-for-byte with sha256
765c826echecked before and after:depth >= 1instead of== 1test_walk_stops_at_the_first_object_closeNonewhen there is no top-level keyNo test pins the linear scan. A timing bound loose enough to survive a loaded runner would
not kill the quadratic version either, since 58 ms at 64KB passes anything sane, so that
measurement lives in this description rather than in the suite.
Follow-up, not touched here.
parser.py:111reads the reason with a strictjson.loadson the raw message content, while every other read goes through
parsing_helpers.extract_json_content_or_raise(:21). That is why a prose-wrapped ormarkdown-fenced response fails on the logprob path and succeeds without logprobs. Verified,
and out of scope for this diff.
Verification at
3b056b5d9: 18 passed intests/unit/evaluation/metrics/llm_judges/g_eval/, 126 passed intests/unit/evaluation/metrics/llm_judges/;uvx [email protected] checkandformat --checkclean on both touched files;
uvx [email protected] --config-file pyproject.toml --disable-error-code misc --ignore-missing-importsonparser.py→Success: no issues found in 1 source file. The commit is 36 lines added / 36 removed inparser.pyplus 108lines of tests. Full
tests/unitat this head: 1 failed, 5161 passed, 3 skipped in 243.78s,the failure being
test_evaluation__levenshtein_ratioon a missing optionalrapidfuzzin afile outside the diff.