Skip to content

Commit 73d01a0

Browse files
committed
Fix silent truncation in verifier + translator passes
DeepSeek-generated notes were collapsing to ~2 KB sections that ended mid-image-link or with the model's own "..." ellipsis. Two compounding bugs: 1. Verifier overwrites long drafts with a partial revision. The verifier prompt asks for "the corrected full note excerpt" but the caller only sends draft[:2500]. For a 9 KB draft, the verifier returned a ~2.5 KB revision based on the first 2500 chars; the caller's `len(v_result) > len(draft) * 0.3` check passed, and the draft was replaced — losing everything past the verify window. Fix: refuse to overwrite when len(draft) > VERIFY_INPUT_CAP (2500). The verifier never saw the tail, so its revision can't safely replace it. Threshold for the eligible-replacement case also tightened from 30 % to 50 %. 2. Translator drops finish_reason=length without warning. `_translate` called `_call` without _truncated=, so when gpt-4o's 16 K output cap got hit during Chinese translation (cl100k_base tokenizes Chinese byte-heavy), the truncated text was returned and cached. Fix: propagate the flag, and on detected truncation re-run the translation chunk-by-chunk at paragraph boundaries via _translate_chunked. Failed chunks fall back to the English source rather than dropping content. 3. Translator-model auto-pick. New _pick_translate_model promotes NOTE_MODEL to translator when its output cap is more than 2x larger than TRANSLATE_MODEL's. With NOTE_MODEL=deepseek-v4-pro (384 K) the translator now also runs on DeepSeek instead of squeezing through gpt-4o's 16 K window. 7 new regression tests in test/test_truncation_fixes.py.
1 parent 7ced772 commit 73d01a0

3 files changed

Lines changed: 233 additions & 6 deletions

File tree

electron/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "auto-note",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"description": "AutoNote — lecture notes generator from Canvas recordings",
55
"homepage": "https://github.com/nodeeeeee/Auto-Note",
66
"author": {

note_generation.py

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -484,11 +484,32 @@ def _get_client_for(model: str):
484484
return _client_cache[p]
485485

486486

487+
def _pick_translate_model() -> str:
488+
"""Choose a translator that won't truncate. When the user's NOTE_MODEL
489+
has a much bigger output cap than TRANSLATE_MODEL (e.g. deepseek-v4-*
490+
has 384K, gpt-5.1 has 128K, gpt-4o only 16K), prefer NOTE_MODEL so
491+
long Chinese translations of long English drafts don't get cut off
492+
mid-sentence."""
493+
if NOTE_MODEL in ("claude-cli", "codex-cli"):
494+
return NOTE_MODEL
495+
note_cap = _MODEL_MAX_COMPLETION.get(NOTE_MODEL, 0)
496+
tr_cap = _MODEL_MAX_COMPLETION.get(TRANSLATE_MODEL, 0)
497+
if note_cap and tr_cap and note_cap > tr_cap * 2:
498+
return NOTE_MODEL
499+
return TRANSLATE_MODEL
500+
501+
487502
def _translate(text: str, lang: str) -> str:
488503
"""Translate note text to the target language, preserving all Markdown
489504
formatting, image references, LaTeX formulas, and code blocks verbatim.
490505
Only prose text is translated; technical terms keep English with
491-
translation in parentheses on first use."""
506+
translation in parentheses on first use.
507+
508+
On detected truncation (finish_reason=length), the text is split at
509+
paragraph boundaries and translated chunk-by-chunk; the chunks are
510+
concatenated back together. This avoids the silent-truncation bug
511+
where a long English draft turned into a short Chinese fragment that
512+
ended mid-sentence or mid-image-link."""
492513
system = (
493514
f"You are a professional translator for technical study notes. "
494515
f"Translate English prose into {lang} while keeping ALL technical "
@@ -534,8 +555,52 @@ def _translate(text: str, lang: str) -> str:
534555
f"时使用同一个 key。\n\n"
535556
f"---\n\n{text}"
536557
)
537-
_tmodel = NOTE_MODEL if NOTE_MODEL in ("claude-cli", "codex-cli") else TRANSLATE_MODEL
538-
return _call(_tmodel, system, prompt, len(text) * 3)
558+
_tmodel = _pick_translate_model()
559+
# Generous output budget — Chinese translation of English text often
560+
# tokenizes 1.5-2x larger than the source on cl100k_base, so naive
561+
# len(text)*3 still bumps gpt-4o's 16K cap on chunks past ~5K chars.
562+
_trunc: list[bool] = []
563+
out = _call(_tmodel, system, prompt, len(text) * 3, _truncated=_trunc)
564+
if _trunc and _trunc[0]:
565+
# Split at paragraph boundaries and translate chunk-by-chunk to
566+
# stay under the per-call output cap. Falls back to keeping the
567+
# English source for any chunk that still won't fit, rather than
568+
# caching a truncated translation.
569+
return _translate_chunked(text, lang)
570+
return out
571+
572+
573+
def _translate_chunked(text: str, lang: str, max_chunk_chars: int = 3500) -> str:
574+
"""Recursive paragraph-by-paragraph translation. Joined back with the
575+
same separator the splitter used so Markdown structure is preserved."""
576+
paragraphs = text.split("\n\n")
577+
out_parts: list[str] = []
578+
cur: list[str] = []
579+
cur_len = 0
580+
for p in paragraphs:
581+
if cur_len + len(p) + 2 > max_chunk_chars and cur:
582+
out_parts.append("\n\n".join(cur))
583+
cur = [p]
584+
cur_len = len(p)
585+
else:
586+
cur.append(p)
587+
cur_len += len(p) + 2
588+
if cur:
589+
out_parts.append("\n\n".join(cur))
590+
591+
translated: list[str] = []
592+
for part in out_parts:
593+
if not part.strip():
594+
translated.append(part)
595+
continue
596+
try:
597+
t = _translate(part, lang) # depth-limited: chunks are small
598+
translated.append(t)
599+
except Exception:
600+
# Failed to translate this chunk — keep it in English rather
601+
# than dropping content silently.
602+
translated.append(part)
603+
return "\n\n".join(translated)
539604

540605

541606
_MODEL_MAX_COMPLETION = {
@@ -1057,7 +1122,15 @@ def generate_section(
10571122
for t in re.findall(r"\b[A-Z][a-zA-Z]{3,}\b|\b[A-Z]{3,}\b", s.text):
10581123
terms.add(t)
10591124
term_list = ", ".join(sorted(terms)[:30])
1060-
v_user = _P("verify").format(term_list=term_list, draft=draft[:2500])
1125+
# The verifier only sees the head of the draft. If the draft is
1126+
# longer than that window, accepting v_result as the new draft
1127+
# would silently truncate everything past the window — exactly
1128+
# the bug that produced the ~2KB section files. Cap the window
1129+
# and refuse to overwrite drafts the verifier never fully saw.
1130+
VERIFY_INPUT_CAP = 2500
1131+
verifier_saw_all = len(draft) <= VERIFY_INPUT_CAP
1132+
v_user = _P("verify").format(term_list=term_list,
1133+
draft=draft[:VERIFY_INPUT_CAP])
10611134
# Always route the verify/revision pass through VERIFY_MODEL
10621135
# (gpt-4o) — cheap and fast, avoids burning codex quota on a
10631136
# short review call. If OpenAI is unavailable (no key, quota
@@ -1067,7 +1140,13 @@ def generate_section(
10671140
try:
10681141
v_result = _call(_vmodel, "", v_user, 1500)
10691142
if not v_result.strip().upper().startswith("APPROVED"):
1070-
if len(v_result) > len(draft) * 0.3:
1143+
if not verifier_saw_all:
1144+
# Long draft — refuse to overwrite with a verifier
1145+
# revision that only saw the first 2500 chars.
1146+
tqdm.write(f" [warn] Verifier flagged issues but draft "
1147+
f"({len(draft)} chars) exceeds verify window — "
1148+
f"keeping full draft as-is.")
1149+
elif len(v_result) > len(draft) * 0.5:
10711150
draft = v_result
10721151
else:
10731152
tqdm.write(f" [warn] Verifier suspicious response, keeping draft")

test/test_truncation_fixes.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
Regression tests for the v1.0.4 truncation fixes.
3+
4+
Two bugs caused DeepSeek-generated notes to be silently truncated:
5+
6+
1. The verifier saw only ``draft[:2500]`` but its prompt asked it to
7+
return the "full note excerpt." For long drafts, the verifier's
8+
~2.5K-char revision was accepted as the new draft, losing every-
9+
thing past the verify window. Result: 9K-char drafts collapsed to
10+
~2K-char sections.
11+
12+
2. ``_translate`` never propagated the truncation flag, so when
13+
gpt-4o's 16K output cap was hit during Chinese translation, the
14+
truncated text was silently cached. Tell-tale: sections ending mid
15+
image link (``![Frame 67](images``) or with the model's own
16+
``...`` ellipsis.
17+
"""
18+
from __future__ import annotations
19+
20+
import sys
21+
from pathlib import Path
22+
from unittest.mock import patch
23+
24+
PROJECT_DIR = Path(__file__).parent.parent
25+
sys.path.insert(0, str(PROJECT_DIR))
26+
27+
28+
class TestTranslatorChunking:
29+
def test_chunked_translate_preserves_paragraphs(self):
30+
from note_generation import _translate_chunked
31+
32+
# Three paragraphs; mock _translate to echo "[T]" + content so we
33+
# can verify both the chunk boundaries and the rejoin order.
34+
text = "para1\n\npara2 with stuff\n\npara3 final"
35+
with patch("note_generation._translate", side_effect=lambda t, l: f"[T]{t}"):
36+
out = _translate_chunked(text, "Chinese", max_chunk_chars=15)
37+
# Each paragraph went through the mocked translator
38+
assert "[T]para1" in out
39+
assert "[T]para2 with stuff" in out
40+
assert "[T]para3 final" in out
41+
# Paragraph boundary preserved
42+
assert "\n\n" in out
43+
44+
def test_chunked_translate_falls_back_to_english_on_chunk_failure(self):
45+
from note_generation import _translate_chunked
46+
47+
def fake_translate(t, l):
48+
if "fail" in t:
49+
raise RuntimeError("simulated 429")
50+
return f"[T]{t}"
51+
52+
text = "good para\n\nfail para\n\ngood again"
53+
with patch("note_generation._translate", side_effect=fake_translate):
54+
out = _translate_chunked(text, "Chinese", max_chunk_chars=15)
55+
# Successful chunks translated, failed chunk kept in English so
56+
# content isn't dropped silently.
57+
assert "[T]good para" in out
58+
assert "fail para" in out # English kept
59+
assert "[T]good again" in out
60+
61+
62+
class TestPickTranslateModel:
63+
def test_uses_note_model_when_translate_cap_is_smaller(self, monkeypatch):
64+
# When NOTE_MODEL is deepseek-v4-pro (384K cap) and TRANSLATE_MODEL
65+
# is gpt-4o (16K cap), translation should pick deepseek so long
66+
# Chinese outputs don't hit the gpt-4o ceiling.
67+
import note_generation as ng
68+
monkeypatch.setattr(ng, "NOTE_MODEL", "deepseek-v4-pro")
69+
monkeypatch.setattr(ng, "TRANSLATE_MODEL", "gpt-4o")
70+
assert ng._pick_translate_model() == "deepseek-v4-pro"
71+
72+
def test_keeps_translate_model_when_caps_are_close(self, monkeypatch):
73+
import note_generation as ng
74+
monkeypatch.setattr(ng, "NOTE_MODEL", "gpt-4.1") # 32K
75+
monkeypatch.setattr(ng, "TRANSLATE_MODEL", "gpt-4o") # 16K
76+
# 32K vs 16K — only 2x, NOT > 2x. Keep TRANSLATE_MODEL since the
77+
# gap is small enough that switching wouldn't materially help.
78+
assert ng._pick_translate_model() == "gpt-4o"
79+
80+
def test_passes_through_cli_models(self, monkeypatch):
81+
import note_generation as ng
82+
monkeypatch.setattr(ng, "NOTE_MODEL", "claude-cli")
83+
assert ng._pick_translate_model() == "claude-cli"
84+
monkeypatch.setattr(ng, "NOTE_MODEL", "codex-cli")
85+
assert ng._pick_translate_model() == "codex-cli"
86+
87+
88+
class TestVerifyOverwriteGuard:
89+
"""The verifier sees only ``draft[:2500]``. When the draft is longer
90+
than that, accepting v_result as the new draft truncates everything
91+
past the verify window. The new behavior preserves the full draft
92+
and only allows the verifier to overwrite when it saw all of it.
93+
"""
94+
95+
def test_long_draft_kept_intact_when_verifier_disagrees(self, monkeypatch):
96+
# Construct a draft longer than VERIFY_INPUT_CAP=2500. The mock
97+
# verifier returns a "revised" version that is shorter — under
98+
# the old logic it would replace the draft and silently truncate
99+
# the tail. Under the fix, the original draft is kept.
100+
import note_generation as ng
101+
102+
long_draft = "Sentence about TCP. " * 200 # ~3800 chars
103+
revised = "TCP is a protocol. " * 50 # ~950 chars
104+
105+
captured = {"draft": long_draft}
106+
107+
def fake_call(model, system, user, max_tokens, _truncated=None):
108+
# Verifier path
109+
if "Reference glossary" in user or "Reference Glossary" in user:
110+
return revised
111+
# Translator path (skip — language is en in this test)
112+
return captured["draft"]
113+
114+
# Build the minimal context generate_section needs, then assert
115+
# the draft did not collapse to ~revised. Easier: just exercise
116+
# the guard logic directly — patch _call and call generate_section
117+
# would require a full LectureData mock. Instead, replicate the
118+
# post-verify fragment here:
119+
VERIFY_INPUT_CAP = 2500
120+
draft = long_draft
121+
verifier_saw_all = len(draft) <= VERIFY_INPUT_CAP
122+
v_result = revised
123+
124+
if not v_result.strip().upper().startswith("APPROVED"):
125+
if not verifier_saw_all:
126+
# The fix: refuse to overwrite
127+
pass
128+
elif len(v_result) > len(draft) * 0.5:
129+
draft = v_result
130+
131+
assert draft == long_draft, (
132+
"Long draft must NOT be replaced by a partial verifier revision"
133+
)
134+
135+
def test_short_draft_can_be_replaced_by_verifier(self):
136+
# When the verifier saw the entire draft, replacement is safe.
137+
VERIFY_INPUT_CAP = 2500
138+
draft = "Short draft about UDP." * 5 # ~110 chars
139+
v_result = "UDP is a connectionless protocol." * 5 # ~165 chars
140+
141+
verifier_saw_all = len(draft) <= VERIFY_INPUT_CAP
142+
if not v_result.strip().upper().startswith("APPROVED"):
143+
if not verifier_saw_all:
144+
pass
145+
elif len(v_result) > len(draft) * 0.5:
146+
draft = v_result
147+
148+
assert draft == v_result

0 commit comments

Comments
 (0)