Quote board silently empty: strict JSON parse rejects ~2/3 of highlight responses, failure is indistinguishable from "no quotes found" #279
Closed
opened 2026-08-05 16:00:21 +00:00 by claude-bot
·
1 comment
No Branch/Tag specified
main
feat/1328-fill-it-in
feat/1328-fill-frontend
feat/1328-fill-backend
feat/9-foundry-push
fix/9-foundry-live
feat/9-foundry-push-frontend
renovate/celery-5.x
feat/5-foundry-connect
feat/9-foundry-push-backend
feat/9-foundry-push-module
feat/5-foundry-frontend
feat/5-foundry-backend
feat/5-foundry-module
release/v4.8.0-rc1
docs/d341-convert-draft
feat/958-update-to-version
feat/958-convert-sheet
feat/958-pathbuilder-import-frontend
fix/1360-heading-order
feat/958-pathbuilder-import-backend
feat/11-weekly-digest
feat/1341-auto-update
docs/d332-superseded
chore/1345-names-cleanup
feat/1342-mentioned-pages
feat/1328-recipes
fix/1346-pgdg-key
perf/1247-shared-recording-stream
chore/132-gitleaks-allowlist
release/v4.7.1
spike/foundry-module
release/v4.7.1-rc1
docs/decisions-d332-d337
fix/bot-certifi
chore/release-4.7.0
chore/release-4.7.0-rc4
chore/deps-4.7.0
chore/release-4.7.0-rc3
fix/1280-quote-must-say-it
fix/1257-names-row-squeeze
chore/release-4.7.0-rc2
fix/1245-pg-upgrade-count
chore/release-4.7.0-rc1
feat/1257-names-in-prep
feat/1257-be
feat/1257-fe
feat/1257-rating-fixes
design/1328-fill-it-in
feat/1257-name-splits
fix/1326-redis-503
feat/1280-fact-suggestions
feat/1280-be
feat/1280-fe
design/1280-fact-suggestions
feat/1279-infobox
feat/1279-be
feat/1279-fe
feat/1321-shelf-copies
design/1279-infobox
feat/1243-table-find
feat/1257-who-rated
feat/1243-prep-drawer
feat/1243-sheet-pickers
design/1243-browse-in-place
feat/1315-hotwords-default
feat/1242-system-filters
feat/1257-more-name-styles
feat/506-leg3-hotwords
design/1242-system-filters
fix/1302-packs-polish
feat/1241-rules-library
design/1241-rules-library
feat/1245-postgres-18
integ/4.6.2
fix/1263-map-ui-2
fix/1263-map-server
fix/1263-map-ui-1
fix/1261-editor-link-menu
fix/1229-axe-4.14-name-mismatch
renovate/axllent-mailpit-1.x
renovate/eslint-monorepo
renovate/authlib-1.x
chore/release-4.6.0
chore/release-4.6.0-rc2
fix/460-rc2-screens
fix/460-rc2-server
fix/release-prerelease-exit
chore/release-4.6.0-rc1
feat/125-campaign-templates
feat/126-calendar
feat/1181-pictures-and-files
feat/1190-event-people
integ/4.6.0
feat/1191-line-of-the-night
feat/1200-starter-packs
chore/bot-multidict-and-playwright-group
fix/1202-this-week-answered-count
fix/122-124-leftovers
fix/1178-get-routes-that-write
fix/1203-journeys-check-names
design/125-campaign-templates
feat/126-calendar-engine
design/126-in-world-calendar
fix/1189-cited-stamp-in-range
fix/1189-v4-looser-check
fix/1189-richer-beats
fix/1113-export-bind-lists
fix/1154-release-notes-by-file
chore/release-4.5.0
chore/release-4.5.0-rc12
fix/1135-wiki-markup-as-text
fix/demo-usage-cost-in-window
chore/release-4.5.0-rc11
fix/1121-sheet-consistency
fix/1121-sheet-5e-terms-and-card-hp
copy/1122-busiest-pages
fix/1121-relevant-choices
fix/1121-wiki-create-npc-save-sheet-links
fix/1029-menu-stays-on-screen
docs/prototype-transcript-bin-wording
fix/1023-editor-save-row-back-scroll
fix/1020-search-exact-first
fix/1020-table-prep
fix/1020-shell-wordmark-footer-bell
fix/1020-wiki-article-party
fix/872-admin-kept-until-phone-tables
feat/l13-milestone-level-waiting
fix/1000-1001-bell-older-setup-context
fix/999-pronouns-in-prompts
fix/l19-other-class-feats
fix/1010-transcript-window-ordinary-confirm
docs/d254-d269-decisions
fix/702-member-search
fix/680-transcript-bin
fix/993-unidentified-prompts
fix/841-redis-timeouts
fix/990-binned-not-exported
fix/830-export-prep-shelf
fix/deps-audit-2026-10-01
fix/877-title-reads
fix/798-audio-received-at
build/lane11-17-j7
build/lane11-16-levelup
build/lane11-14-seams
build/lane11-14-seed
build/lane11-15-start
build/lane11-13-old-block-out
fix/980-review-r3
build/lane11-12-block
build/lane11-8-packs
fix/demo-seed-canonical-names
build/lane11-11-hot-half
docs/l11-decisions
build/lane11-7-convert
build/lane11-10-editing
fix/npm-audit-brace-expansion
build/lane11-9-sheet-page
fix/bot-pyjwt
build/lane11-6-content
fix/lane11-rank-names
build/lane11-2b-language
build/lane11-4-js-evaluator
build/lane11-3-server
build/lane11-5a-pf2e
build/lane11-5b-5e
feat/design-admin
build/lane11-2-evaluator
build/lane11-1-sheet-pieces
build/lane11-0-drawing
fix/943-quest-objectives-fk
fix/prepsheet-shelf-flake
fix/945-leftovers
build/demo-gm-copy
fix/901-email-landings
build/lane10-8-journeys
build/demo-seed-lanes-8-10
fix/887-gm-own-confirmation
build/lane9-5-j1-admin
fix/918-audience-click
fix/useasync-overtaken
ci/939-critical-path
test/937-backend-suite-speed
chore/claude-orchestration
fix/924-xp-gm-only
fix/session-stale-poll
docs/decisions-d239-d242
fix/930-pypdf
build/lane8-journeys
build/925-quest-editor
fix/894-private-page-pictures
build/lane8-p4-drafts
build/lane8-p7-map
build/lane8-p6-handouts-rules
fix/wiki-fixture-names
build/lane10-d227-test-usage
fix/rsvp-horizon-test-tuesday
fix/media-volume-owner
build/lane8-p3-editor
fix/912-unidentified-rename
build/lane8-p5-party-quests
build/lane8-handout-takeback
build/lane10-pickers
build/lane8-p2-article
build/lane8-6-map
build/lane10-7b-email-pages
build/lane8-5-handouts
build/lane10-7a-test-the-bot-pages
build/lane8-p1-pages
build/lane8-4-quests
build/lane8-2b-feeds
build/lane8-3-parties
build/lane8-2a-links
build/lane10-5-commands
build/lane9-6-seed
build/lane10-4b-reactions
build/lane9-4d-pages
build/lane9-4c-pages
build/lane10-4a-posts
build/lane10-3-email
fix/891-web-fonts
build/lane9-4b-pages
build/lane10-6-test-the-bot
build/lane10-2-notify-out
build/lane8-1-wiki-text
build/lane9-4a-frame
design/lane10-drawings
fix/j6-budget
fix/j5-phone-flake
fix/869-test-leaks
fix/843-record-start-followups
build/lane7-all
build/lane6-all
build/lane9-b2-server
chore/changelog-fragments
docs/decisions-d218
build/lane9-a-owner-overview
fix/rule-card-level-label
fix/834-retention-semantics
fix/o3c-record-without-announcement
docs/decisions-d179
docs/decisions-d131-d170
fix/851-commit-before-response
build/lane6-j3-seed
build/lane9-b1-d67-identity
fix/841-blocking-loop
build/lane9-c-backups-audit
fix/vote-race-and-start-commit
fix/recording-announcement
fix/proposal-read-leak
fix/last-admin-guard
build/lane6-p4c-planning
build/lane6-p4b-margin
build/lane6-p4a-sheet
build/lane5-j4-seed
build/lane6-base
build/lane7-pages-talk
build/lane5-pages
build/lane7-pages-safety
build/lane7-p3-safety
build/lane7-p2-notify
build/lane6-p3-planning
build/lane6-p2-history
build/lane6-p1b-items
build/lane7-p1-threads
build/lane5-p6-faces
build/lane6-p1a-sheet
build/lane5-p5-find
build/lane5-p4a-board
build/lane5-p4b-scenes
build/lane5-p3-live
build/lane5-base
build/lane5-p2-cards
docs/lane5-decisions
build/review-r2-wide
fix/review-r1
build/lane5-p1-scenes
build/demo-seed
build/lane4-journeys-j5
build/lane4-page-review-b
build/lane4-page-review-a
build/lane4-page-read
build/lane4-journeys
build/lane4-page-sess2
build/lane4-page-sess1
build/lane4-notes
build/lane4-discord-on-approval
build/lane4-scheduling
build/lane4-versions
build/760-session-number
build/lane4-decisions
build/797-erase-no-regenerate
build/test-race-audit
build/lane3-completions
build/794-dark-only
build/796-titles-scroll
build/791-retention-anchor
build/a11y-sweep
build/shell-followups
build/retention-preview
build/753-backup-readback
docs/d91-d93
build/lane3-j1
build/ci-harness-paths-selftest
fix/useauth-test-race
build/ci-harness-paths
build/777-ai-settings-read
build/lane3-settings-c
build/lane3-settings-b
build/lane3-j6
build/lane3-sessionform
build/lane3-settings-a
build/lane3-members
build/lane3-thisweek
build/lane3-story
chore/87-align-python-3.12
v4.8.0-rc1
v4.7.1
v4.7.1-rc1
v4.7.0
v4.7.0-rc4
v4.7.0-rc3
v4.7.0-rc2
v4.7.0-rc1
v4.6.2
v4.6.2-rc4
v4.6.2-rc3
v4.6.2-rc2
v4.6.2-rc1
v4.6.1
v4.6.1-rc1
v4.6.0
v4.6.0-rc2
v4.6.0-rc1
v4.5.3
v4.5.3-rc1
v4.5.2
v4.5.2-rc1
v4.5.1
v4.5.1-rc2
v4.5.1-rc1
v4.5.0
v4.5.0-rc12
v4.5.0-rc11
v4.5.0-rc10
v4.5.0-rc9
v4.5.0-rc8
v4.5.0-rc7
v4.5.0-rc6
v4.5.0-rc5
v4.5.0-rc4
v4.5.0-rc3
v4.5.0-rc2
v4.5.0-rc1
v4.4.1
v4.4.0
v4.3.0
v4.2.3
v4.2.2
v4.2.1
v4.2.0
v4.1.2
v4.1.1
v4.1.0
v4.0.1
v4.0.0
v3.11.4
v3.11.3
v3.11.2
v3.11.1
v3.11.0
v3.10.0
v3.9.1
v3.9.0
v3.8.0
v3.7.0
v3.6.1
v3.6.0
v3.5.0
v3.4.1
v3.4.0
v3.3.0
v3.2.0
v3.1.0
v3.0.0
v2.1.0
v2.0.0
v1.1.0
v1.0.0
v0.12.0
v0.11.0
v0.10.0
v0.9.1
v0.3.3
v0.9.0
v0.8.0
v0.3.2
v0.3.1
v0.3.0
v0.7.0
v0.6.0
v0.5.0
v0.4.0
v0.2.1
v0.2.0
v0.1.1
v0.1.0
Labels
Clear labels
backend
Issue related to the backend
bot
Issue related to the bot
bug
Something is not working
duplicate
This issue or pull request already exists
enhancement
New feature
frontend
Issue related to the frontend
help wanted
Need some help
invalid
Something is wrong
question
More information is needed
recording
Issue related to the recording system
security
Security hardening / vulnerability work
wontfix
This won't be fixed
No labels
backend
bot
bug
duplicate
enhancement
frontend
help wanted
invalid
question
recording
security
wontfix
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Quest-Board#279
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The player-facing quote board (#116) has produced zero rows on prod. Root cause is a strict JSON parse rejecting malformed model output, with a silent-failure path that made it look like the model simply found nothing memorable.
Distinct from #278 (which is the publication gate on
approve_audio). This is about rows never being written in the first place.Mechanism
llm_service.py:174-183sendsresponse_format: {"type":"json_object"}. The prod endpoint (llama.cpp router build b9029 fronting Qwen3.5-9B-UD-Q8_K_XL) does not enforce the JSON grammar — it returnsfinish_reason=stopwith structurally invalid JSON.}, missing]}, and an extra trailing}._parse_highlights_json(webapp/backend/app/services/audio_service.py:963-990) is strict.json.loadsfails, and the only salvage path is gated behindif not text.startswith("{")(:980) — so for a response that starts with{, the repair never runs at all. Verified by reading: a truncated-but-{-leading response goes straight tojson.loads→except→{}.audio_service.py:1041-1043: empty dict →return [].reminder_tasks.py:2133-2137logsINFO … extracted 0 highlights— identical to the legitimate "model found nothing" outcome. No warning, no error.Evidence
Reproduced against the live endpoint with the real 81,302-char transcript from session
d633ad3c: 9 prod-shaped calls, 6 returned structurally invalid JSON (~67% failure rate). Correlation was perfect — every compact-style response was malformed, every pretty-printed one parsed.Live worker log for that session:
HTTP 200, 14.5s, no exception logged. The block runs; the parse is what fails.
Forcing a parseable response showed 5/5 quotes passed the verbatim/hallucination substring check plus 3 moments — 8 rows would have been written. The filter is not the blocker.
Correcting an earlier assumption
The symptom was originally read as "zero highlights across 6 transcribed sessions." In fact only one session has ever attempted extraction: #116 landed in
86e6e66on 2026-07-17 and the prod worker was rebuilt 2026-07-27, so the 2026-07-29 session is the only one whoseprocess_audioran the extraction block. The other transcripts (2026-03-25 → 2026-07-15) predate the feature entirely. So this is 1 failure out of 1 attempt against a measured ~67% failure rate — not 6 for 6.Ruled out
highlights_in_discordonly governs Discord posting.max_quotesresolves correctly from the DB (both campaigns: 5).:2069-2071vs:2109-2111).:2138— that path logs a WARNING; zero such lines in the full 39,884-line worker log.max_tokens=2048truncation —finish_reason=stopon every malformed run, completion tokens 229-382.Why summarisation works and this doesn't
_summarise_llamacpp(audio_service.py:2098-2129) sends noresponse_format, nomax_tokens, and parses nothing — it returnscontent.strip()as prose. There is no JSON contract to violate.The shared helper has the same flaw
extract_json_object(webapp/backend/app/services/llm_service.py:283-303) also fails on a missing trailing}—rfind("}")can't recover a truncated tail. Everyjson_mode=Truecaller is exposed to the same endpoint behaviour: lore proposals,generation_service, stat-block generation. Those raise rather than silently returning[], which is why only highlights failed quietly — but they are equally fragile. Worth fixing once, centrally, rather than per-caller.Fix direction
if not text.startswith("{")gate so salvage always runs; usejson.JSONDecoder().raw_decode()(fixes the extra-trailing-}case outright); add bracket-balance repair (walk the string tracking depth outside string literals, truncate to the last complete element, append missing closers). All 6 captured malformed samples are recoverable this way.{}, and have the caller distinguish "parse failed" from "0 highlights". The indistinguishable INFO line is what hid this for a full release cycle.json_object. Constrained decoding viaresponse_format: {"type":"json_schema", …}or an explicitgrammarwould eliminate the class entirely. Worth testing against this router build.process_audio(reminder_tasks.py:2105), whose audio is already deleted. A GM-facing "regenerate highlights from transcript" action would let existing sessions be backfilled.webapp/backend/tests/test_highlights.py:100-104only feeds well-formed JSON or total garbage. Add fixtures for the three real corruption shapes.Do not drop
chat_template_kwargs: {"enable_thinking": False}in any fix — testing showed that removing it while keepingmax_tokens=2048yieldsfinish_reason=lengthwith empty content, as the whole budget goes to reasoning tokens.Not verified
Why the router build lets ungrammatical JSON through (the GPU host's llama.cpp logs weren't reachable). Retry-only is insufficient — at a 67% failure rate, retry-once still fails ~45% of the time, so the repair is the necessary part.
Labels: bug, backend
Fixed and deployed in v3.11.1 (PR #280, merged as
01c9f86).What shipped
repair_json_object()inllm_serviceis now the shared tolerant parser used byextract_json_objectand_parse_highlights_json. It cascades: clean parse →raw_decode(which alone fixes the extra-brace shape) → close the open bracket stack → truncate to the last parseable point and close. Every candidate is validated withjson.loadsbefore being returned, so it can only ever return real JSON.Two properties, both arrived at by getting them wrong first and being caught by tests:
extract_highlightsdoes, and there is a test proving it drops a repaired half-object.strict=Trueexists for callers that can't tolerate this.{out of a bare[...], silently dropping every later element and presenting the fragment as the whole response.test_workbench_rumor::test_parse_rumor_output_handles_malformed_replycaught it in the full-suite run. A valid non-object now reports failure so the caller's own shape-coercion fallback runs, as before.Both repair and total failure now log at WARNING with response size only — never content, which is table talk. That was the actual reason this survived a release:
INFO … extracted 0 highlightswas indistinguishable from "the model found nothing memorable".Also fixed the adjacent data-loss bug: the delete-then-reinsert cleared existing highlights before checking whether extraction returned anything, so every retry of a failing session destroyed a good set from an earlier successful run.
Tests
New
tests/test_json_repair.py(15 tests) plus 5 intest_highlights.py, built on the three corruption shapes captured verbatim from the live endpoint. Full backend suite: 836 passed (up from 815).Still open — worth a separate issue
This fixes the parsing, not the endpoint. The llama.cpp router still is not enforcing its JSON grammar and will keep returning malformed responses at roughly the measured ~2/3 rate on large prompts; the difference is that it now recovers and says so.
The follow-up worth doing is the constrained-decoding spike: whether
response_format: {"type":"json_schema", …}or an explicitgrammaris honoured by this build, and whether hitting the underlying llama.cpp server directly (bypassing the router) behaves differently — that would distinguish a router problem from a model problem. The new WARNING lines give a real failure rate to measure against, which the 9-sample estimate could not.Closing this one; the spike should be filed on its own.
extractingforever #287extractingforever #287restart: on-failurein docker-compose.yml —unless-stoppedbypasses docker-host's boot pacing #306