Skip to content

Commit 3ac2400

Browse files
committed
Auto-inject frames the LLM forgot to embed
Empirically DeepSeek V4 Pro is inconsistent at including `![Frame N](path)` markdown even when the prompt explicitly asks for inclusiveness. On EE2022 Stage 3 we observed: • 01_04 (Wed): 23 frames extracted → 0 embedded • 04_02 (Wed): 3 → 1 • 10_04 (Fri): 3 → 0 • 04_03 (Wed): 21 → 3 (~14 % kept) Strengthening the prompt is unreliable since the LLM's image-decision is judgment-based. Instead, add a deterministic post-process: `_ensure_frames_embedded` runs after `_clean_artifacts` and before translation. For every frame in `img_map` that the draft did not reference, it appends `![Frame N](path) *(caption)*` at section end, using the cached vision description (first-sentence-truncated) as the caption. The downstream `filter_images_pass` still drops junk frames, so we get a guaranteed-include floor without losing the junk-filter ceiling. 6 new regression tests cover: missing-frame append, deduplication of already-referenced frames, no-op on empty img_map, slide-vs-frame prefix selection, caption fallback to "Frame N" when no description, and first-sentence caption truncation.
1 parent a6d7916 commit 3ac2400

3 files changed

Lines changed: 200 additions & 1 deletion

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.6",
3+
"version": "1.0.7",
44
"description": "AutoNote — lecture notes generator from Canvas recordings",
55
"homepage": "https://github.com/nodeeeeee/Auto-Note",
66
"author": {

note_generation.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,55 @@ def _clean_artifacts(text: str) -> str:
888888
return "\n".join(cleaned)
889889

890890

891+
def _ensure_frames_embedded(
892+
draft: str,
893+
slides: list,
894+
img_map: dict,
895+
img_cache: dict,
896+
out_dir: Path,
897+
source: str,
898+
) -> str:
899+
"""Append any frames the LLM forgot to include in its draft.
900+
901+
The `_build_chunk_prompt` lists every available frame in the prompt
902+
under "Available images", but DeepSeek V4 (and some other models)
903+
are inconsistent at actually emitting the `![Frame N](path)` markdown
904+
even when explicitly asked. This safety net keeps the contract:
905+
every extracted frame is given a chance to surface in the final
906+
note. The downstream image-filter pass (`filter_images_pass`) still
907+
runs and can drop junk frames — we just guarantee they reach that
908+
pass instead of being silently dropped by the LLM.
909+
"""
910+
if not img_map:
911+
return draft
912+
appended: list[str] = []
913+
for s in slides:
914+
if s.index not in img_map:
915+
continue
916+
rel = img_map[s.index].relative_to(out_dir)
917+
rel_str = str(rel).replace("\\", "/")
918+
# Already cited somewhere in the draft? Skip.
919+
if rel_str in draft:
920+
continue
921+
cache_key = f"page_{s.index}"
922+
desc = (img_cache.get(cache_key, "") or "").strip()
923+
# First sentence of the description as the caption (max 140 chars).
924+
caption = desc[:140]
925+
for end in ".。!?!?":
926+
idx = caption.find(end)
927+
if 25 < idx < len(caption):
928+
caption = caption[:idx + 1]
929+
break
930+
if not caption:
931+
caption = f"Frame {s.index + 1}" if source == "screenshare" \
932+
else f"Slide {s.index + 1}"
933+
prefix = "Frame" if source == "screenshare" else "Slide"
934+
appended.append(f"![{prefix} {s.index + 1}]({rel_str}) *({caption})*")
935+
if not appended:
936+
return draft
937+
return draft.rstrip() + "\n\n" + "\n\n".join(appended) + "\n"
938+
939+
891940
_BAD_LABEL = re.compile(
892941
r"^\s*(\d+|[A-Z]{2,4}\d{4}[\s\-].*|CS\d+.*|AY\d+.*|\[.*\]|"
893942
r".*NUS Confidential.*|.*©\s*CS\d+.*|\(c\)\s*CS\d+.*|Page\s+\d+)\s*$",
@@ -1152,6 +1201,17 @@ def generate_section(
11521201
# Strip pipeline artifacts that may have leaked into the draft
11531202
draft = _clean_artifacts(draft)
11541203

1204+
# Frame-completeness safety net: DeepSeek V4 (and other models under
1205+
# certain phrasings) is inconsistent at embedding `![Frame N](...)`
1206+
# markdown even when the prompt asks for it — empirically the LLM
1207+
# keeps anywhere from 0–100% of the available frames per chunk.
1208+
# Auto-append any frame in img_map that didn't make it into the draft
1209+
# so we never silently lose the visual content the user asked us to
1210+
# extract. The downstream image-filter pass still drops junk frames.
1211+
draft = _ensure_frames_embedded(
1212+
draft, chunk, img_map, ld.img_cache, out_dir, ld.source,
1213+
)
1214+
11551215
# Translate to target language if not English
11561216
if NOTE_LANGUAGE != "en" and draft:
11571217
lang = _LANG_NAMES.get(NOTE_LANGUAGE, NOTE_LANGUAGE)

test/test_frame_injection.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""
2+
Regression tests for the v1.0.7 frame-injection safety net.
3+
4+
DeepSeek V4 Pro (and some other models) is inconsistent at embedding
5+
`![Frame N](path)` markdown even when the prompt explicitly asks for
6+
it — empirically observed dropping 23-of-23 frames on one EE2022
7+
lecture and 0/3 on another. `_ensure_frames_embedded` post-processes
8+
the draft and appends any frame in `img_map` that the LLM didn't cite.
9+
"""
10+
from __future__ import annotations
11+
12+
import sys
13+
from pathlib import Path
14+
from types import SimpleNamespace
15+
16+
PROJECT_DIR = Path(__file__).parent.parent
17+
sys.path.insert(0, str(PROJECT_DIR))
18+
19+
20+
def _make_slide(idx, label="Slide"):
21+
return SimpleNamespace(index=idx, label=label, text="", word_count=0,
22+
has_code=False)
23+
24+
25+
class TestEnsureFramesEmbedded:
26+
def test_appends_missing_frames_with_caption(self, tmp_path):
27+
from note_generation import _ensure_frames_embedded
28+
out_dir = tmp_path
29+
# Create dummy frame paths under out_dir
30+
frames_dir = out_dir / "images" / "L01_Foo"
31+
frames_dir.mkdir(parents=True)
32+
f1 = frames_dir / "frame_001.png"; f1.touch()
33+
f2 = frames_dir / "frame_002.png"; f2.touch()
34+
35+
slides = [_make_slide(0), _make_slide(1)]
36+
img_map = {0: f1, 1: f2}
37+
img_cache = {
38+
"page_0": "Renewable Energy Integration – Power Electronic Convertors. Diagram.",
39+
"page_1": "Synchronous reactance equivalent circuit.",
40+
}
41+
# Draft with NO frame markdown — the bug case.
42+
draft = "Some Chinese 内容 about the topic."
43+
44+
out = _ensure_frames_embedded(
45+
draft, slides, img_map, img_cache, out_dir, source="screenshare",
46+
)
47+
assert "![Frame 1](" in out
48+
assert "![Frame 2](" in out
49+
assert "Renewable Energy Integration" in out # caption preserved
50+
assert "Synchronous reactance" in out
51+
52+
def test_keeps_already_referenced_frames(self, tmp_path):
53+
from note_generation import _ensure_frames_embedded
54+
out_dir = tmp_path
55+
frames_dir = out_dir / "images" / "L01_Foo"
56+
frames_dir.mkdir(parents=True)
57+
f1 = frames_dir / "frame_001.png"; f1.touch()
58+
f2 = frames_dir / "frame_002.png"; f2.touch()
59+
60+
slides = [_make_slide(0), _make_slide(1)]
61+
img_map = {0: f1, 1: f2}
62+
img_cache = {"page_0": "first", "page_1": "second"}
63+
# Draft already references frame 1 — don't duplicate it.
64+
draft = "Foo. ![Frame 1](images/L01_Foo/frame_001.png) *(first)* Bar."
65+
66+
out = _ensure_frames_embedded(
67+
draft, slides, img_map, img_cache, out_dir, source="screenshare",
68+
)
69+
# Frame 1 not duplicated
70+
assert out.count("![Frame 1](") == 1
71+
# Frame 2 appended
72+
assert "![Frame 2](" in out
73+
74+
def test_no_op_when_img_map_empty(self, tmp_path):
75+
from note_generation import _ensure_frames_embedded
76+
slides = [_make_slide(0), _make_slide(1)]
77+
draft = "Some prose."
78+
out = _ensure_frames_embedded(
79+
draft, slides, {}, {}, tmp_path, source="screenshare",
80+
)
81+
assert out == draft
82+
83+
def test_uses_slide_prefix_for_slide_pdf_source(self, tmp_path):
84+
from note_generation import _ensure_frames_embedded
85+
out_dir = tmp_path
86+
slides_dir = out_dir / "images" / "L01_Foo"
87+
slides_dir.mkdir(parents=True)
88+
s1 = slides_dir / "slide_001.png"; s1.touch()
89+
90+
slides = [_make_slide(0)]
91+
img_map = {0: s1}
92+
img_cache = {"page_0": "Architecture diagram."}
93+
draft = "Prose without images."
94+
95+
out = _ensure_frames_embedded(
96+
draft, slides, img_map, img_cache, out_dir, source="slides",
97+
)
98+
# Slide-PDF source uses "Slide N" prefix, not "Frame N"
99+
assert "![Slide 1](" in out
100+
assert "![Frame 1](" not in out
101+
102+
def test_caption_falls_back_to_index_when_no_description(self, tmp_path):
103+
from note_generation import _ensure_frames_embedded
104+
out_dir = tmp_path
105+
frames_dir = out_dir / "images" / "L01_Foo"
106+
frames_dir.mkdir(parents=True)
107+
f1 = frames_dir / "frame_007.png"; f1.touch()
108+
109+
slides = [_make_slide(6)] # 0-based index 6 → frame_007
110+
img_map = {6: f1}
111+
# Empty cache — no description available.
112+
out = _ensure_frames_embedded(
113+
"draft", slides, img_map, {}, out_dir, source="screenshare",
114+
)
115+
assert "![Frame 7](" in out
116+
assert "*(Frame 7)*" in out
117+
118+
def test_long_description_truncated_at_first_sentence(self, tmp_path):
119+
from note_generation import _ensure_frames_embedded
120+
out_dir = tmp_path
121+
frames_dir = out_dir / "images" / "L01_Foo"
122+
frames_dir.mkdir(parents=True)
123+
f1 = frames_dir / "frame_001.png"; f1.touch()
124+
125+
slides = [_make_slide(0)]
126+
img_map = {0: f1}
127+
# Two sentences — only the first should appear in the caption.
128+
img_cache = {"page_0": "First sentence about reactance. Second sentence about flux."}
129+
130+
out = _ensure_frames_embedded(
131+
"draft", slides, img_map, img_cache, out_dir, source="screenshare",
132+
)
133+
# Caption text appears as `*(...)*` — find it
134+
import re
135+
m = re.search(r"\*\(([^)]+)\)\*", out)
136+
assert m is not None
137+
caption = m.group(1)
138+
assert "First sentence" in caption
139+
assert "Second sentence" not in caption

0 commit comments

Comments
 (0)