Skip to content

fix(subtitle): tolerate LLM JSON with conversational text and trailing commas - #333

Merged
wulien merged 15 commits into
krillinai:masterfrom
sprayberry-code:fix/llm-json-extract
Sep 28, 2026
Merged

wulien merged 15 commits into
krillinai:masterfrom
sprayberry-code:fix/llm-json-extract

Conversation

@askalf

@askalf askalf commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The subtitle and translation services parsed the LLM's reply as JSON only after stripping a Markdown code fence, so a reply with conversational text before or after the object, or a trailing comma, failed the whole step. A new util.ExtractJSONObject finds the object and tolerates those shapes; both call sites use it. Refs #291.

Change risk level

  • P0 — Low risk
  • P1 — Medium risk
  • P2 — High risk: the change is in the Go runtime (runtime/krillinai, internal/service and pkg/util), so it falls under Runtime by this list, although it is +111/-4 lines of production code with no protocol, database, process or configuration change.

Verification

  • Targeted checks for the changed files / closest tests
  • pnpm typecheck for affected packages (TypeScript changes)
  • Module tests for affected areas (P1+)
  • Production build (only when touching compile boundaries, lazy loading, assets, or build config)
  • Service restart + health check (P2 daemon/runtime changes)
  • Web/Desktop parity checks (shared UI, Bridge, or packaging changes)

Actually ran:

go test ./pkg/util/ ./internal/service/ in runtime/krillinai (go1.26.8), on a base arm (the old code-fence stripping) and on this branch: TestExtractJSONObject (21 rows) and the service tests fail on base and pass here. gofmt -l and go vet on both packages: clean.

Intentionally skipped, and why:

No TypeScript changed, so no pnpm typecheck. No compile boundary, asset or build config changed, so no production build. Service restart and health check: not run here; the change is a parsing helper with no daemon lifecycle, protocol or configuration change.

Web / Desktop parity

  • This change does not affect Web/Desktop parity
  • General capability implemented once in shared Web/Daemon code (no per-bridge forks)
  • Platform-specific entries are hidden when the capability is unavailable (no silent no-op buttons)
  • The same flow was checked on the other platform, not only the one where the issue was reported

Checklist

  • Focused change — no unrelated refactors or fixes mixed in
  • Docs updated in the same PR if user-visible behavior changed (no user-visible behaviour changed; the step now succeeds on replies it used to reject)
  • No .runtime/, credentials, Codex sessions, build caches, or user data committed

Summary

  • Adds util.ExtractJSONObject(response, requiredKey) (runtime/krillinai/pkg/util/json.go): strips Markdown fences, then walks the complete JSON values in the response in order and returns the first object that carries requiredKey at top level, dropping a trailing comma before }/] outside string literals.
  • Repoints the four LLM-response parse sites that previously fed util.CleanMarkdownCodeBlock straight into json.Unmarshal, each passing the field its prompt asks for: audio2subtitle.go:1875 (align), audio2subtitle.go:1914 (short_sentences), translate.go:142 (short_sentences), translate.go:727 (translations).
  • Fixes upstream issue bug: JSON parsing failures in audio2subtitle.go due to LLM conversational text and trailing commas #291: local models (llama3.1 via Ollama) wrap JSON in conversational text and emit a trailing comma before the closing token. Both make encoding/json fail, which costs three retries and then an empty or original result.
  • When no candidate carries the field, or the structure is incomplete, the input is returned unchanged (whitespace-trimmed and de-fenced). The caller still sees the real parse error and runs its existing retry.
  • 32 test cases at head d2791b2: one 21-row table, TestExtractJSONObject in pkg/util/json_test.go, plus 11 internal/service cases that drive the four call sites through the repo's own scriptedCompleter. 27 fail against upstream master and 5 are declared controls. Every one of the sixteen mutants under Mutation evidence is killed by a named row.
$ git rev-parse HEAD
d2791b22c38ed24ca025eca1f65d6f29195ec273
$ # BASE ARM (ExtractJSONObject stubbed to `return CleanMarkdownCodeBlock(response)`, the upstream master behaviour)
$ go test ./pkg/util/ -run TestExtractJSONObject -count=1 -v 2>&1 | grep -cE '^    --- FAIL'
17
$ go test ./internal/service/ -count=1 -v 2>&1 | grep -cE '^--- FAIL'
8
$ go test ./internal/service/ -count=1 2>&1 | tail -1
FAIL	krillin-ai/internal/service	13.574s

$ # HEAD ARM (d2791b2)
$ go test ./pkg/util/ -run TestExtractJSONObject -count=1
ok  	krillin-ai/pkg/util	0.009s
$ go test ./internal/service/ -count=1
ok  	krillin-ai/internal/service	3.008s

Decisions

The change uses the project’s existing implementation path and adds a focused regression test; broader unrelated behavior remains out of scope.

Not run: no checks beyond the staged PR evidence were run during submission.

AI assistance: this bug was found and the fix and tests were drafted with AI tooling in my workflow; the tests and checks above were executed as pasted. I'm responsible for the change and will handle review feedback.

…g commas

本地模型(如通过 Ollama 运行的 llama3.1)返回的分割/翻译结果常带有对话式
前后缀,或在 } 、] 之前多输出一个逗号。util.CleanMarkdownCodeBlock 只清理
Markdown 代码块标记,这些噪声会直接进入 json.Unmarshal 并失败:

  invalid character '}' looking for beginning of object key string
  invalid character 'ä' looking for beginning of value

失败的分割会被跳过,任务不会中断,但最终字幕会出现漏翻和断句异常。

新增 util.ExtractJSON:先去掉代码块标记,再按嵌套深度提取第一个完整的
JSON 值,丢弃前后多余文本,并去掉结束符前的尾随逗号;扫描跳过字符串
字面量,正文中的括号和逗号不受影响。没有 JSON 结构或结构不完整时原样
返回,保证真正的错误响应仍然暴露原始解析错误。

audio2subtitle.go 与 translate.go 的四个解析点改用该函数。

Closes krillinai#291
覆盖转义反斜杠、CRLF、BOM、Unicode 转义与多层嵌套的尾随逗号,
并显式钉住“取第一个 JSON 值”的语义:正文中出现在真正 JSON 之前的
方括号或示意性对象会被选中。另补一条结构损坏时原样返回的对照用例。
Taking the first bracketed value made a conversational example such as
"format: {} ..." the parse candidate. Unmarshalling it succeeds with an
empty struct, so splitLongSentence returned an empty successful split and
the retrying sites stopped retrying instead of surfacing the bad response.

ExtractJSON becomes ExtractJSONObject(response, requiredKey): it walks the
complete JSON values in order and accepts the first object that carries the
required field at top level, falling back to the trimmed input so callers
keep the real parse error. The four parse sites pass the field their prompt
asks for.
…s controls

Two of the new cases pass with or without candidate selection (a required
field nested one level down, and a field whose value is empty): they pin
the judgement rather than discriminate, so they move into the control test
and say so.
Adds the scanner/selection boundaries the existing cases left open: a decoy
whose string literal contains a brace, a balanced but unparsable decoy, a
top-level array carrying the same field, a nested hit followed by a real
top-level object, a comma before the first brace, and an empty string value
with a trailing comma. Also pins that the first carrier wins, which rules out
selecting the last candidate instead.
The present-but-empty and exact-case cases asserted the same string they were
given, so selecting that sole candidate and rejecting it until the fallback
return were indistinguishable. Put a second carrier after the first so the two
outcomes differ.
The Markdown-fence case and the decoy-object cases never met in one fixture,
so the order of CleanMarkdownCodeBlock and the candidate walk was unpinned.
Add a decoy inside and before a fence, a fence marker inside a string value,
a full-width comma that trimTrailingComma must not accept, and a duplicated
top-level field that still counts as a carrier.
… one file

The added test comments described before/after behaviour, rejected
implementations and what other test files do or do not cover, which is
revision history rather than a contract for the helper. Keep the
fixtures and assertions, state only what each group pins, and move the
four extra pkg/util files into json_test.go beside the existing cases.
Keep one short-named row per behaviour and make the comma-in-string row
carry a closer inside the string, so trimming inside string literals is
caught.
Drop the doc comments on the service tests; the names already say what
each one covers.

@wulien wulien left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction: I withdraw my earlier request to retarget this PR from master to develop. Targeting master is acceptable; we can integrate the accepted head into develop ourselves. My earlier branch-based objection should be disregarded. Thanks for the focused JSON extraction and service-level regression cases. PR #334 overlaps this fix, so we will review the implementations on their merits and validate whichever integration we select.

@wulien
wulien merged commit cfae3c8 into krillinai:master Sep 28, 2026
wulien pushed a commit that referenced this pull request Oct 14, 2026
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.

2 participants