Skip to content

UN-4017 [FIX] Recover single-pass output from malformed LLM JSON, with always-on parse diagnostics - #2250

Open
Deepak-Kesavan wants to merge 8 commits into
mainfrom
UN-4017-json-repair-contract
Open

UN-4017 [FIX] Recover single-pass output from malformed LLM JSON, with always-on parse diagnostics#2250
Deepak-Kesavan wants to merge 8 commits into
mainfrom
UN-4017-json-repair-contract

Conversation

@Deepak-Kesavan

@Deepak-Kesavan Deepak-Kesavan commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

  • Let the JSON repair helper recover a dict-shaped result from a malformed LLM response, via an opt-in contract — the legacy behaviour is preserved verbatim for every existing caller.
  • Add always-on, PII-free parse diagnostics, plus an optional raw-response capture gated to non-production.
  • Declare json-repair, which the workers imported but nothing declared.

Why

  • Single-pass extraction passes the parsed LLM response straight through as the outputs map. When the model wraps its JSON in prose, a reasoning block, or a stray fence marker, the repair returns a list and the backend 500s at output_manager_helper.py:179. Confirmed in production.
  • The repair heuristics encode ~2 years of observed edge cases, so they must not change. The requirement was to extend, never to alter.
  • The response shapes causing this are open-ended and grow with each model, so the design has to fail safe on shapes nobody has seen rather than enumerate them.
  • Diagnosing these needed information the logs did not carry. A highlight or confidence mismatch is reported against a response that parsed fine, so failure-only capture would miss exactly those cases.
  • CI proved json_repair was installed nowhere: every contract test returned a raw string. The image builds the same way, so wherever it is absent every JSON prompt silently degrades to a raw string and stores {} with no error.

How

  • _repair_legacy holds the existing logic verbatim, with a comment recording why the "[" + json_str wrap exists (fragmentation recovery) so nobody "cleans it up".
  • With a JsonContract, the helper offers competing candidates — legacy parse, unwrapped parse, five salvage strategies — and picks the one carrying the most expected prompt keys. Scoring rather than first-match matters: "first dict" returns a schema example quoted in the model's prose instead of the answer.
  • Responses carrying // 0x.. metadata are never reshaped. Those comments are mapped onto the parsed structure positionally, so dropping or merging an element silently attaches line numbers to the wrong fields — worse than the 500. Detection requires the hex payload, because matching a bare // would treat ordinary document values ("N/A // pending", a path) as annotated and silently switch the fix off.
  • Adding a newly observed shape is one @salvager plus a corpus entry; _repair_legacy stays closed for modification.
  • Diagnostics log structure only — shapes, key counts, which parse step produced what — at WARNING on failure and DEBUG on success, carrying request_id and execution_id. Only contract-declared keys are echoed, because repair promotes document text into key position on malformed input.
  • Raw capture sits behind DEBUG_LOG_RAW_LLM_RESPONSE and a non-prod DEPLOYMENT_ENV, defaulting to production so unset and unrecognised values fail closed.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No behavioural change for any existing caller, and this is the claim the PR is built to defend. Passing no contract makes no salvager reachable, so the returned value cannot differ. Nothing in this repo passes a contract today.
  • Evidence: a frozen copy of the pre-change implementation lives in the tests, and a differential fuzz compares them across 2102 inputs including bytes — 0 differences. Any future edit to _repair_legacy fails that pin.
  • Two risks found in review and fixed, both listed here because they are the kind that would otherwise bite silently: the diagnostics narrowed the accepted input (bytes used to parse, then raised TypeError), and the annotation guard was broad enough to disable the fix for documents containing // in a value.
  • Diagnostics add log records on every repair. They emit no document values, are wrapped so they cannot raise, and cost ~0.8ms on a 55KB payload.
  • Declaring json-repair changes what ships: builds that were silently running without it will now have it. That is the intended fix, but it is a real behaviour change for any such environment.

Database Migrations

  • None.

Env Config

  • DEBUG_LOG_RAW_LLM_RESPONSE (default false), DEBUG_LOG_RAW_LLM_SINK (log | webhook), DEBUG_RAW_LLM_WEBHOOK_URL, DEBUG_LOG_RAW_LLM_MAX_CHARS (0 = no clipping), DEPLOYMENT_ENV (default production). All documented in workers/sample.env; chart wiring in Zipstack/unstract-cloud#1738.

Relevant Docs

  • None.

Related Issues or PRs

Dependencies Versions

  • Declares json-repair>=0.42 in workers/pyproject.toml; the lock resolves 0.63.2. A lower bound rather than a pin, by decision — the salvagers are verified against 0.42.0, 0.57.1 and 0.63.2.

Notes on Testing

  • 226 tests: legacy characterisation, model explanations, code blocks, token-limit truncation at each awkward boundary, annotated responses, the debug sinks, and the raw-capture gate.
  • Differential fuzz on the no-contract path: 2102 inputs, 0 differences.
  • Absolute-value pins against dependency drift. The characterisation pin cannot catch it — _original_impl calls the same installed json_repair, so a version bump moves both sides together. One of those pins documents current behaviour rather than endorsing it: on the no-contract path a fenced response is shredded into ["json\n{", 'invoice_number": "INV-001', {...}].
  • No activated call site in this repo, deliberately: the single-pass caller lives in the cloud plugin. This PR is the mechanism, the regression safety net, and the diagnostics.

Screenshots

Checklist

I have read and understood the Contribution Guidelines.

…LM JSON

Single-pass extraction passes the parsed LLM response straight through as
the outputs map. When the model wraps its JSON in prose, a reasoning block
or a stray fence marker, the repair helper returns a list, and the backend
500s on outputs.get(prompt.prompt_key) in handle_prompt_output_update.

The repair logic encodes two years of observed edge cases, so it is not
touched. _repair_legacy holds it verbatim and callers that pass no
contract reach exactly the same result as before — the new behaviour is
unreachable without opting in. A frozen copy of the pre-change function
lives in the tests; 3322 fuzzed inputs show zero differences on the
default path, and any future edit to the legacy branch fails the pin.

With a JsonContract the helper offers competing candidates — the legacy
parse, the unwrapped parse, and five salvage strategies — and picks the
one carrying the most expected prompt keys. Scoring rather than
first-match matters: "first dict" returns a schema example quoted in the
model's prose instead of the answer. Handling a new response shape is one
@salvager plus a corpus entry.

Responses carrying highlight/confidence metadata are never reshaped.
Those `// 0x..` comments are mapped onto the parsed structure positionally,
so dropping or merging an element silently attaches line numbers to the
wrong fields — worse than the 500. Detected via `//` and `%%%`, with an
opt-in escape hatch for callers that do no positional mapping.

Adds diagnostics for the shapes this class of bug keeps producing. A
PII-free trace (shapes, key counts, which parse step produced what) is
always logged on failure and is safe in production. The raw response can
additionally be captured behind DEBUG_LOG_RAW_LLM_RESPONSE, which is
ignored unless REGION is non-prod so it cannot dump document content in
US/EU. The webhook sink POSTs the untruncated payload out of band; the
log sink elides the middle rather than the tail, where parse-breaking
debris lives.

Tests: 155 covering the legacy pin, model explanations, code blocks,
token-limit truncation at each awkward boundary, annotated responses and
the debug sinks.
A highlight or confidence mismatch is reported against a response that
parsed perfectly well, so capturing only failures misses exactly the
cases that generate those reports. The parse trace now runs on every
repair — WARNING on failure, DEBUG on success — and carries request_id
via the log filter plus execution_id explicitly.

Adds a metadata alignment check for responses carrying `// 0x..`
comments. It compares the comment stream against the structure those
comments are mapped onto and warns on two signatures that are invisible
in the extracted output: a count mismatch, where a phantom comment
shifted every later field, and a malformed comment, where the greedy
`//(.*)` capture swallowed document text and that field's line number is
garbage while the counts still line up. Word-confidence blocks are
stripped first, mirroring the consumer, so they do not invent leaves.
This is the trail a "highlight is on the wrong line" report follows, and
it detects the known `//`-inside-a-URL gap without fixing it.

Raw payload capture stays behind DEBUG_LOG_RAW_LLM_RESPONSE and a
non-prod REGION allowlist. DEBUG_LOG_RAW_LLM_MAX_CHARS=0 now disables
clipping; staging sets it, since the log backend's own per-entry cap is
the only real limit below the webhook sink.
Selecting the webhook sink is a decision to keep document content out of
the log pipeline. Falling back to the log sink on a missing or mistyped
DEBUG_RAW_LLM_WEBHOOK_URL inverted that, turning a config typo into a
privacy regression. Fail closed and warn instead.
REGION carries two different things: geography in production (US, EU) and
an environment name everywhere else (STAGING, DEV). Keying the raw-capture
gate off it answered "is this production" only by coincidence, and the
chart defaults REGION to "STAGING", so an environment that forgot to
override it would have read as non-prod and opened the gate.

DEPLOYMENT_ENV says only what it means and defaults to "production", so
unset values, unrecognised values, and any region added in future all fail
closed. REGION goes back to meaning geography.
@greptile-apps

greptile-apps Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds contract-aware recovery for malformed LLM JSON while preserving legacy behavior for callers that do not opt in.

  • Adds candidate-based parsing and salvage strategies selected by expected output shape and prompt keys.
  • Adds always-on structural diagnostics, annotation-alignment checks, and gated raw-response debugging.
  • Declares the JSON repair dependency and adds extensive characterization and regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
workers/executor/executors/json_repair_helper.py Adds contract-aware JSON salvage and privacy-conscious diagnostics; both previously reported diagnostic disclosure defects are fixed.
workers/tests/test_json_repair_characterisation.py Adds legacy compatibility, malformed-response recovery, annotation safety, diagnostic redaction, clipping, and debug-sink tests.
workers/pyproject.toml Declares json-repair as a direct worker dependency.
workers/sample.env Documents structural diagnostics and gated raw-response debug settings.
workers/uv.lock Updates the worker lockfile for the declared JSON repair dependency.
uv.lock Regenerates the repository lockfile metadata and dependency resolution.

Reviews (4): Last reviewed commit: "UN-4017 [FIX] Address review: input narr..." | Re-trigger Greptile

Comment thread workers/executor/executors/json_repair_helper.py Outdated
Comment thread workers/executor/executors/json_repair_helper.py Outdated
…clared

CI proved the ImportError fallback is live: every contract test failed with
"still a str" because json_repair is in no pyproject.toml and no uv.lock,
so `uv sync --locked` never installs it and _repair_legacy returns the raw
string for everything.

That is not confined to CI. The worker image builds the same way, and
json_repair reaches it only via the `uv pip install -e "$plugin_dir" || true`
step in worker-unified.Dockerfile — a step that swallows its own failure.
Wherever it is absent, every JSON-enforced prompt silently degrades to a raw
string and answer_prompt stores {} with no error.

Declared with a lower bound rather than a pin: the library is a lenient
parser whose heuristics are expected to evolve, and the salvagers are
verified against 0.42.0, 0.57.1 and 0.63.2 (the version this lock resolves).
The characterisation pin compares against whatever is installed, so it
cannot paper over a behaviour change.
…imit=1

Two review findings, both real.

The shape trace claimed to be PII-free on the grounds that dict keys are
prompt field names rather than document content. That holds for a
well-formed response and this code runs on malformed ones, where repair
routinely promotes document text into key position — prose containing
"{field: value}" parses to a dict keyed on document text. Since the trace
is logged unconditionally, including in production, that was a leak.
Only keys the caller declared in its contract are echoed now; every other
key becomes its length.

_head_tail computed both halves as limit//2, so DEBUG_LOG_RAW_LLM_MAX_CHARS=1
gave a tail of 0 and text[-0:] is the whole string — the cap emitted the
entire payload it was set to bound.
…ve, guard

Six findings from review, all reproduced before fixing.

Input narrowing (breaks this PR's core promise). _is_annotated ran a regex
over the payload on every call, so repair_json_with_best_structure(b'{"a":1}')
raised TypeError where it previously returned {"a": 1} — json.loads accepts
bytes, so _repair_legacy always did. The diagnostics now ignore non-str
input. The differential fuzz covers bytes: 2102 inputs, 0 differences.

Alignment false positive on the exact class it was built for. The consumer
strips word-confidence blocks and THEN parses; the check stripped them for
the comment count but took leaves from a parse of the UNSTRIPPED text, so
every well-formed highlight+word-confidence response reported MISMATCH
(400 fields -> "comments=400 leaves=401"). Both counts now come from the
stripped text.

The reshape guard silently disabled the fix. Matching a bare "//" meant any
document value containing one — "N/A // pending", a path — was treated as
annotated, so the salvage was skipped and the AttributeError this PR exists
to prevent still happened. The annotation is hex, so require "// 0x"; the
word-confidence marker must appear as a pair, not as one stray separator.

The marker was hardcoded while the consumer reads WORD_CONFIDENCE_MARKER,
so an operator override broke the guard and the check silently. Same env
var, same default.

The webhook posted synchronously on the calling thread — up to its timeout
per prompt against a slow collector, on every repair, contradicting the
"cannot slow an extraction" claim. Dispatched on a daemon thread.

_log_cleansing_chain was unguarded, so any future addition inside it could
fail an extraction. Wrapped.

Also: absolute-value tests against dependency drift. The characterisation
pin cannot catch it — _original_impl calls the same installed json_repair,
so a version bump moves both sides together — and json-repair is declared
with a lower bound. And test hygiene: a real DNS call, a thread-local
LogContext leaking across tests, and an assertion that accepted either
outcome.
@Deepak-Kesavan

Copy link
Copy Markdown
Contributor Author

Review pass — findings addressed

Correction to an earlier claim in this PR

I wrote that "the characterisation pin compares against whatever is installed, so it cannot paper over a behaviour change." That is backwards. _original_impl calls the same installed json_repair, so a version bump moves both sides identically and the pin stays green. The pin protects against edits to _repair_legacy, not against dependency drift.

Since json-repair is declared with a lower bound (>=0.42) rather than pinned, drift is real — _reparse_without_wrap exists precisely because newer releases can mis-tokenise an intact response. Added absolute-value tests (Part 6) instead: they assert the exact output customers receive, so a routine uv lock refresh that changes extraction fails loudly rather than shipping.

One of those pins documents current behaviour rather than endorsing it: on the no-contract path a fenced response is shredded into ["json\n{", 'invoice_number": "INV-001', {...}]. That is today's production behaviour, and the reason the contract path offers an unwrapped candidate.

Fixed

Severity Finding
Critical _skeleton echoed dict keys into an always-on WARNING trace. Keys are not schema-only on malformed responses — a prompt returning a map keyed by extracted values (party→role, line-item→amount) put PII in production logs with no flag set. Only contract-declared keys are echoed now; everything else becomes its length.
Major repair_json_with_best_structure(b'...') raised TypeError where it previously returned a dict — json.loads accepts bytes. A regex scan in the diagnostics narrowed the accepted input, which is exactly the class of change this PR promises not to make.
Major The alignment check reported MISMATCH on every well-formed highlight+word-confidence response — it stripped %%% for the comment count but took leaves from a parse of the unstripped text. A 400-field payload reported comments=400 leaves=401. A false positive on the precise class the check exists for.
Major The reshape guard matched a bare //, so any document value containing one ("N/A // pending", a path) was treated as annotated, the salvage was skipped, and the AttributeError this PR fixes still occurred — silently. Now requires the hex payload // 0x, and a paired word-confidence marker.
Minor %%% was hardcoded while the consumer reads WORD_CONFIDENCE_MARKER; an operator override broke the guard and the check with nothing to notice.
Minor The webhook posted synchronously on the calling thread — up to its timeout per prompt against a slow collector, on every repair, contradicting "cannot slow an extraction". Now a daemon thread.
Minor _log_cleansing_chain was unguarded; any future addition inside it could fail an extraction. Wrapped.
Minor Test hygiene: a real DNS call to .invalid, a thread-local LogContext leaking into later tests, and an assertion that accepted either outcome.

DEBUG_LOG_RAW_LLM_MAX_CHARS=1 also emitted the whole payload (tail = 1//2 = 0, and text[-0:] is the entire string).

226 tests pass. Differential fuzz: 2102 inputs including bytes, 0 differences on the no-contract path.

Deliberately not fixed

The consumer-side //-in-a-value offset (re.findall(r"//(.*)") over raw text) is left as a known issue by agreement — it predates this PR. The new alignment check reports it rather than fixing it.

No activated call site in this repo — by design

Worth stating plainly for reviewers: nothing in this repo passes a contract, so the reported 500 is not fixed by this PR alone. The only tracked caller is answer_prompt.py:438 (single-prompt, where a list is valid and no contract is wanted). The single-pass path that produces the failure lives in the cloud plugin and uses a separate copy of this module. That wiring is a follow-up; this PR is the mechanism, the regression safety net, and the diagnostics.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 14.5
e2e-coowners e2e 1 0 0 0 1.1
e2e-etl e2e 1 0 0 0 8.2
e2e-login e2e 2 0 0 0 1.2
e2e-prompt-studio e2e 1 0 0 0 4.2
e2e-smoke e2e 2 0 0 0 2.5
e2e-workflow e2e 1 0 0 0 14.9
integration-backend integration 290 0 0 26 46.4
integration-connectors integration 1 0 0 7 8.1
integration-workers integration 140 0 0 1 50.1
unit-backend unit 1001 0 0 1 39.6
unit-connectors unit 63 0 0 0 9.8
unit-core unit 33 0 0 0 1.3
unit-platform-service unit 15 0 0 0 2.6
unit-rig unit 117 0 0 0 5.4
unit-sdk1 unit 528 0 0 0 30.2
unit-workers unit 1532 0 0 1 96.2
TOTAL 3731 0 0 36 336.2

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

Deepak-Kesavan added a commit that referenced this pull request Aug 20, 2026
…n from this PR

Two reasons converge on removing it.

The uv-lock automation (.github/workflows/uv-lock-automation.yaml) fires on
any `**/pyproject.toml` change and installs uv 0.6.14, which is older than
whatever wrote the repo's root uv.lock at revision 3. It regenerates every
lock, rolling that one back to revision 1 and stripping ~1070 upload-time
fields. It re-runs on every synchronize, so reverting by hand cannot stick
while this PR touches a pyproject — @pk-zipstack's request to revert is only
achievable by not triggering it.

And the declaration does not belong here. This PR is two guards; the
json-repair dependency question — test group vs runtime, and the live OSS
consequence @pk-zipstack identified at answer_prompt.py:438 — is the subject
of #2250, which already declares it.

Consequence, stated plainly: the cloud PR's real-repair tests keep skipping in
CI until #2250 lands, exactly as they do today. That is visible in the test
report rather than silent.
Deepak-Kesavan added a commit that referenced this pull request Aug 20, 2026
…l API (#2251)

* UN-4017 [FIX] Reject non-mapping outputs at the prompt-output internal API

`outputs` is indexed by prompt key downstream — OutputManagerHelper.
handle_prompt_output_update does outputs.get(prompt.prompt_key) — so a list
raised AttributeError inside the helper and surfaced to the caller as a bare
500 with no usable reason:

    internal_views.py:84            prompt_output
      -> output_manager_helper.py:179  handle_prompt_output_update
          output = outputs.get(prompt.prompt_key)
    AttributeError: 'list' object has no attribute 'get'

Reachable from ordinary traffic, not just a malformed client: single-pass
extraction passes the LLM's parsed JSON straight through as the outputs map,
and that parse yields a list whenever the model wraps its answer in prose, a
reasoning block or a stray fence marker. That is why it was intermittent and
appeared model-dependent.

The view already validated prompt_ids and document_id and simply did not
check this one. Validating it here means no future executor can 500 the
backend the same way, and the caller gets the reason instead of a 500.

Absent `outputs` still defaults to {} and is accepted, unchanged.

* UN-4017 [FIX] Say "JSON array", not "non-object JSON", in the outputs error

A JSON array is valid JSON. Calling the response "non-object JSON" reads as
"malformed JSON" and sends the reader looking for a parse failure that did
not happen. Name the actual shape and why it cannot be used here.

* UN-4017 [FIX] Guard the in-backend execution path too, not just the internal API

handle_prompt_output_update has two callers, and the first commit only
covered one. prompt_studio_helper._handle_response is the other: it feeds
response["output"] straight in, and _fetch_single_pass_response dispatches
the identical single_pass_extraction executor, so it receives the identical
shapes. Guarding only the worker -> internal API route left this path able
to 500 exactly as before — and the stated point of the boundary check was
that no executor can 500 the backend this way.

_handle_response is the shared choke point for both in-backend callers
(single prompt and single pass), so the guard goes there. Raises the local
AnswerFetchError with 422, matching the source-side guard rather than the
generic 500 that class defaults to.

The single-pass advice about prompts asking for lists is only emitted when
is_single_pass, since that interaction does not exist on the single-prompt
path and would misdirect there.

* UN-4017 [FIX] Guard metadata too, and fix two tests that proved less than claimed

Review found the comment's central claim was false. `metadata` reaches
handle_prompt_output_update unchecked and is indexed five times at its lines
135-139 — context, challenge_data, highlight_data, confidence_data,
word_confidence_data — all unconditional and evaluated BEFORE the
`if not prompts` early exit and before the outputs.get at 179. So a caller
posting valid `outputs` with `"metadata": []` still produced the exact 500
this PR claims to eliminate. Both fields are now checked.

test_helper_is_never_reached_for_invalid_outputs passed either way: with the
guard removed, filter() raised on the fake prompt id before the helper was
reached, and patching `status` hid the resulting ValidationError. Patch the
ORM instead, and assert the 400.

The docstring's "no pytest-django in this repo yet" note was copy-pasted from
test_task_status.py and is wrong in both places: pytest-django>=4.12.0 is
declared at backend/pyproject.toml:79, backend/conftest.py already imports
django.test, and the unit-backend rig group collects this file today.

`prompt_ids` is deliberately still unchecked: a non-list makes filter() raise
ValidationError which the existing except already turns into a 500, the
producer always sends a list, and the `if not prompt_ids` check covers the
realistic cases. Noting it so the omission is a decision, not an oversight.

* UN-4017 [FIX] Declare json-repair in the workers test group

json_repair_helper imports json_repair, but nothing in OSS declares it — the
image only gets it transitively, from cloud plugin dependencies that
copy_cloud_deps merges into requirements.txt. So the OSS test environment is
the one place it is absent, and the helper silently takes
`except ImportError: return json_str` there.

That makes any test asserting real repair behaviour worse than useless: it
goes green while pinning the fallback. Verified — with json_repair blocked,
prose and object-plus-prose come back as str and are still rejected, and a
clean object takes the json.loads fast path, so three of four such tests pass
for the wrong reason.

Test group only. Declaring it as a runtime dependency of workers is a
separate question, tracked with the broader parser work.

* Commit uv.lock changes

* UN-4017 [FIX] Address review: in-backend message, metadata guard, lockfile revert

Three points from @pk-zipstack.

The in-backend detail hardcoded "LLM returned a JSON array" while
interpolating the real type, so `response["output"] = None` produced "LLM
returned a JSON array ... (got NoneType)" — self-contradictory, and it sends
the reader looking for an array that was never there. The cloud half of this
ticket makes exactly that argument and pins it with a test; this half did the
opposite. Names the type now, no shape claim.

`metadata` was still passed unchecked three lines below the guard, and
handle_prompt_output_update indexes it five times before its `if not prompts`
early exit — the same exposure this PR fixes in internal_views and describes
as "missed on the first pass". Both fields are checked here now.

Reverts the root uv.lock. The "Commit uv.lock changes" automation regenerated
it with an older uv, rolling `revision = 3` back to `1` and stripping every
upload-time field: ~2100 lines of pure churn, no dependency, version or hash
actually changed. Nothing in this PR touches a root or backend/ dependency —
the json-repair addition is in workers/ and lands in workers/uv.lock. Left as
it was, it would silently roll the lock format back for everyone and conflict
with any other in-flight lock change.

* Commit uv.lock changes

* Commit uv.lock changes

* UN-4017 [MISC] Drop the json-repair declaration and the lockfile churn from this PR

Two reasons converge on removing it.

The uv-lock automation (.github/workflows/uv-lock-automation.yaml) fires on
any `**/pyproject.toml` change and installs uv 0.6.14, which is older than
whatever wrote the repo's root uv.lock at revision 3. It regenerates every
lock, rolling that one back to revision 1 and stripping ~1070 upload-time
fields. It re-runs on every synchronize, so reverting by hand cannot stick
while this PR touches a pyproject — @pk-zipstack's request to revert is only
achievable by not triggering it.

And the declaration does not belong here. This PR is two guards; the
json-repair dependency question — test group vs runtime, and the live OSS
consequence @pk-zipstack identified at answer_prompt.py:438 — is the subject
of #2250, which already declares it.

Consequence, stated plainly: the cloud PR's real-repair tests keep skipping in
CI until #2250 lands, exactly as they do today. That is visible in the test
report rather than silent.

* UN-4017 [FIX] Only offer the prompt advice for outputs, never for metadata

Regression I introduced when merging the two fields into one loop, and the
same class of misdirection the shape-claim rewrite had just removed.

`metadata` is executor-assembled — run_id, file_name, context, plus the
highlight/confidence blocks — not LLM output. A non-dict there is our defect,
so "Rephrase that prompt to describe the value of its own field" is a dead
end: no wording change can affect it. The neutral message in internal_views
had this right; this path regressed relative to it.

Gated on the field. Test asserts the advice is absent for metadata, alongside
the existing one asserting it is absent for single-prompt — the field
dimension was untested, which is why generalising into a loop slipped past.

* UN-4017 [FIX] Declare json-repair so the real-repair tests run on this PR alone

These two PRs need to stand on their own, so the dependency comes back here
rather than being deferred.

json_repair_helper imports json_repair, and nothing in OSS declares it — the
image only receives it transitively, from cloud plugin dependencies that
copy_cloud_deps merges into requirements.txt. The rig syncs the workers `test`
group instead, so json_repair is absent there and the helper silently takes
`except ImportError: return json_str`.

That is what made the cloud PR's TestAgainstRealRepairOutput skip. Left alone,
the suite that exists precisely because stubbing everything hid the original
bad split would ship inert.

Test group only, and a lower bound rather than a pin.

Note on the lockfile: the uv-lock automation regenerates every lock with an
older uv on any pyproject change, which rewrites the root uv.lock. Its trigger
is `**/pyproject.toml`, so a follow-up commit touching only uv.lock reverts
that churn without re-firing it.

* Commit uv.lock changes

* UN-4017 [MISC] Revert the automation's root uv.lock churn

The uv-lock automation installs uv 0.6.14, older than whatever wrote the root
lock at revision 3, so it rolls the format back to revision 1 and strips every
upload-time field — ~2100 lines, with no dependency, version or hash actually
changing. This PR alters no root or backend dependency; its json-repair
addition is in workers/ and lands in workers/uv.lock.

Reverting held last time only until the next push. This commit touches no
pyproject, and the workflow's trigger is `**/pyproject.toml`, so it will not
re-fire and the revert stands.

The automation does this to every PR that touches any pyproject. Pinning its
uv to the version that produced revision 3 would fix it at the source.

* Commit uv.lock changes

* UN-4017 [MISC] Drop the json-repair declaration and the root lockfile churn

Keeps this PR to the two guards. The dependency cannot be declared here
without cost: GitHub matches the uv-lock automation's `paths` filter against
the whole PR diff, not the individual push, so any PR containing a pyproject
change re-fires it on every sync — and that workflow installs uv 0.6.14, older
than whatever wrote the root uv.lock at revision 3, so it rewrites ~2100 lines
with no dependency, version or hash actually changing. Reverting it cannot
stick while the declaration is present; it came back on both attempts.

Consequence, stated plainly: TestAgainstRealRepairOutput in the paired cloud PR
skips wherever json_repair is absent, which includes the rig. It runs in the
image, where cloud plugin dependencies supply json-repair transitively, and
locally with the plugin installed. That is visible in the test report rather
than silent.

The underlying gap is unchanged and independent of these PRs: OSS declares
json_repair nowhere, so answer_prompt.py:438-442 persists {} for any answer
that is not strictly valid JSON. Worth its own fix, along with pinning that
workflow's uv.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant