Repository navigation
fix(subtitle): tolerate LLM JSON with conversational text and trailing commas - #333
Merged
Merged
Conversation
…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
reviewed
Sep 28, 2026
Collaborator
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.ExtractJSONObjectfinds the object and tolerates those shapes; both call sites use it. Refs #291.Change risk level
runtime/krillinai,internal/serviceandpkg/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
pnpm typecheckfor affected packages (TypeScript changes)Actually ran:
go test ./pkg/util/ ./internal/service/inruntime/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 -landgo veton 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
Checklist
.runtime/, credentials, Codex sessions, build caches, or user data committedSummary
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 carriesrequiredKeyat top level, dropping a trailing comma before}/]outside string literals.util.CleanMarkdownCodeBlockstraight intojson.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).encoding/jsonfail, which costs three retries and then an empty or original result.d2791b2: one 21-row table,TestExtractJSONObjectinpkg/util/json_test.go, plus 11internal/servicecases that drive the four call sites through the repo's ownscriptedCompleter. 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.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.