Skip to content

Commit 9c48d6d

Browse files
nodeeeeeeclaude
andcommitted
Scope per-lecture image dir + section cache by stable slug, not num
Sequential caption-index `num` is reused across runs, so when a new video is added and num assignments shift, the second lecture's frames silently overwrite the first's inside images/L{num}/ while the first note's captions still reference that path. The 4_10_2026 zh.md captioned Network Security title slides but pointed at images from the 4_17_2026 video after discovery reordered. Fix: new LectureData.slug / dir_key derived from the frame dir name (or slide stem) so every lecture owns a unique images/L{num}_{slug}/ and sections/L{num}_{slug}_S{ci}.md path. The img-ref regex accepts the old L{num} format too, so the filter pass still parses legacy notes; regenerate affected notes with --force to get correct images. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cf14220 commit 9c48d6d

1 file changed

Lines changed: 51 additions & 29 deletions

File tree

note_generation.py

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@
105105
- Pure text slides (bullet points, definitions, titles) do not need images — the notes express text better than a screenshot.
106106
- **Be INCLUSIVE with images**: if a frame/slide shows a diagram, chart, table, code, or any non-trivial visual content, include it. Aim to include most of the content-rich images available, not just a few highlights. It's better to have more images with brief connecting text than to skip images.
107107
- **Each image MUST be placed inline, immediately after the paragraph that directly discusses the concept shown in that image.** If a frame shows content that the transcript doesn't fully cover, briefly describe the frame's content (using the description provided in the image hints) and then insert the image.
108-
- Format for slide images: `![Slide N](images/LXX/slide_NNN.png) *(one-sentence description)*`
109-
- Format for screen-capture frames: `![Frame N](images/LXX/frame_NNN.png) *(one-sentence description)*`
110-
(LXX is provided by the caller — do not modify it; the caption must be in parentheses wrapped in asterisks exactly as shown).
108+
- Format for slide images: `![Slide N](images/L**…/slide_NNN.png) *(one-sentence description)*`
109+
- Format for screen-capture frames: `![Frame N](images/L**…/frame_NNN.png) *(one-sentence description)*`
110+
(The subdirectory under `images/` is provided in the "Available images" list — copy it verbatim, including any slug suffix, and do not shorten or rewrite it. The caption must be in parentheses wrapped in asterisks exactly as shown.)
111111
9. Never fabricate technical details not present in the source material.
112112
""",
113113
chunk="""\
@@ -238,10 +238,10 @@ def _desc_has_visual(desc: str) -> bool:
238238

239239

240240
def _img_ref_pattern() -> re.Pattern:
241-
# Matches slide_001.png, frame_001.png, in images/L04/ or images/L04_F02/,
242-
# with an optional trailing italic caption: *(description)*
241+
# Matches slide_001.png / frame_001.png under images/L04/, images/L04_F02/,
242+
# or images/L04_<slug>[_F02]/ — with an optional trailing italic caption.
243243
return re.compile(
244-
r"!\[(?:Slide|Frame) \d+\]\((images/L\d{2}(?:_F\d{2})?/(?:slide|frame)_\d{3}\.png)\)"
244+
r"!\[(?:Slide|Frame) \d+\]\((images/L\d{2}[^/]*/(?:slide|frame)_\d{3}\.png)\)"
245245
r"(?:\s*\*\([^)]*\)\*)?"
246246
)
247247

@@ -335,18 +335,15 @@ def filter_images_pass(
335335
"""
336336
pattern = _img_ref_pattern()
337337

338-
# Build unified lookup: image rel-path → (SlideInfo, LectureData)
339-
# Mirrors the backward-compat naming used in render_chunk_images:
340-
# file_idx==1 → images/L04/slide_001.png
341-
# file_idx>=2 → images/L04_F02/slide_001.png
338+
# Build unified lookup: image rel-path → (SlideInfo, LectureData).
339+
# Path scheme matches render_chunk_images: images/{dir_key}/…, where
340+
# dir_key = L{num:02d}_{slug}[_F{idx:02d}].
342341
slide_ld_lookup: dict[str, tuple[SlideInfo, "LectureData"]] = {}
343342
for ld in lectures:
344-
prefix = (f"L{ld.num:02d}" if ld.file_idx == 1
345-
else f"L{ld.num:02d}_F{ld.file_idx:02d}")
346343
# Use frame_NNN for screenshare, slide_NNN for traditional
347344
img_prefix = "frame" if ld.source == "screenshare" else "slide"
348345
for s in ld.slides:
349-
key = f"images/{prefix}/{img_prefix}_{s.index+1:03d}.png"
346+
key = f"images/{ld.dir_key}/{img_prefix}_{s.index+1:03d}.png"
350347
slide_ld_lookup[key] = (s, ld)
351348

352349
# Collect unique paths and decide keep/remove
@@ -966,10 +963,12 @@ def _build_chunk_prompt(
966963

967964
# ── Section-by-section generation ────────────────────────────────────────────
968965

969-
def _section_path(sections_dir: Path, lec_num: int, ci: int, file_idx: int = 1) -> Path:
970-
if file_idx == 1:
971-
return sections_dir / f"L{lec_num:02d}_S{ci:02d}.md"
972-
return sections_dir / f"L{lec_num:02d}_F{file_idx:02d}_S{ci:02d}.md"
966+
def _section_path(sections_dir: Path, ld: "LectureData", ci: int) -> Path:
967+
# Keyed by `dir_key` (L{num}_{slug}[_F{idx}]) so the cache survives
968+
# caption-list reshuffles — a section generated for one lecture can
969+
# never be served to a different lecture that happens to land on the
970+
# same sequential `num` on a later run.
971+
return sections_dir / f"{ld.dir_key}_S{ci:02d}.md"
973972

974973

975974
def generate_section(
@@ -987,7 +986,7 @@ def generate_section(
987986
force: bool = False,
988987
) -> str:
989988
"""Generate (or load from cache) one section and save it to sections_dir."""
990-
sec_file = _section_path(sections_dir, lec_num, ci, ld.file_idx)
989+
sec_file = _section_path(sections_dir, ld, ci)
991990

992991
if not force and sec_file.exists() and sec_file.stat().st_size > 500:
993992
if bar:
@@ -1275,6 +1274,32 @@ def __init__(self, num: int, slide_path: Path, alignment_path: Path | None,
12751274
self.img_cache: dict = {}
12761275
self.img_map: dict[int, Path] = {}
12771276

1277+
@property
1278+
def slug(self) -> str:
1279+
# Stable, filesystem-safe identifier derived from the lecture's
1280+
# source. Used to disambiguate per-lecture image directories and
1281+
# section cache files so different lectures never share paths —
1282+
# previously `num` (a sequential index over the captions list)
1283+
# shifted when videos were added, causing e.g. 4_17's frames to
1284+
# overwrite 4_10's inside images/L11/ and leaving stale captions
1285+
# pointing at the wrong images.
1286+
if self.source == "screenshare" and self.frame_dir is not None:
1287+
base = self.frame_dir.name
1288+
else:
1289+
base = self.slide_path.stem
1290+
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", base).strip("_")[:48]
1291+
return safe or "lec"
1292+
1293+
@property
1294+
def dir_key(self) -> str:
1295+
# The per-lecture path component for both images/ and the section
1296+
# cache: `L{num:02d}_{slug}` (plus `_F{file_idx:02d}` when a single
1297+
# lecture has more than one slide file).
1298+
base = f"L{self.num:02d}_{self.slug}"
1299+
if self.file_idx > 1:
1300+
base = f"{base}_F{self.file_idx:02d}"
1301+
return base
1302+
12781303
def load(self, out_dir: Path) -> None:
12791304
if self.source == "screenshare" and self.frame_dir:
12801305
self._load_from_frames(out_dir)
@@ -1364,17 +1389,17 @@ def _load_from_transcript(self, out_dir: Path) -> None:
13641389
}
13651390

13661391
def render_chunk_images(self, slide_indices: list[int]) -> dict[int, Path]:
1367-
"""Render only the slides in this chunk into images/L{num}[_F{idx}]/, cache results."""
1392+
"""Render only the slides in this chunk into images/{dir_key}/, cache results."""
13681393
if self.source == "transcript_only":
13691394
# No slide file and no frames to render.
13701395
return {}
13711396
if self.source == "screenshare":
13721397
# For screen share, frames are already extracted — copy them
1373-
# into the notes images/ directory using frame_NNN naming
1374-
if self.file_idx == 1:
1375-
img_dir = self._out_dir / "images" / f"L{self.num:02d}"
1376-
else:
1377-
img_dir = self._out_dir / "images" / f"L{self.num:02d}_F{self.file_idx:02d}"
1398+
# into the notes images/ directory using frame_NNN naming.
1399+
# `dir_key` embeds the lecture slug so unrelated lectures
1400+
# that happen to share the same `num` don't clobber each
1401+
# other's frames under a common images/L{num}/ path.
1402+
img_dir = self._out_dir / "images" / self.dir_key
13781403
img_dir.mkdir(parents=True, exist_ok=True)
13791404
import shutil
13801405
for i in slide_indices:
@@ -1391,10 +1416,7 @@ def render_chunk_images(self, slide_indices: list[int]) -> dict[int, Path]:
13911416
self.img_map[i] = dst
13921417
return {i: self.img_map[i] for i in slide_indices if i in self.img_map}
13931418

1394-
if self.file_idx == 1:
1395-
img_dir = self._out_dir / "images" / f"L{self.num:02d}"
1396-
else:
1397-
img_dir = self._out_dir / "images" / f"L{self.num:02d}_F{self.file_idx:02d}"
1419+
img_dir = self._out_dir / "images" / self.dir_key
13981420
needed = [i for i in slide_indices if i not in self.img_map]
13991421
if needed:
14001422
new = render_slide_images(self.slide_path, img_dir, needed)
@@ -1456,7 +1478,7 @@ def merge_sections(
14561478
n_chunks = max(1, (len(ld.slides) + CHAPTER_SIZE - 1) // CHAPTER_SIZE)
14571479
file_parts: list[str] = []
14581480
for ci in range(1, n_chunks + 1):
1459-
sec_file = _section_path(sections_dir, ld.num, ci, ld.file_idx)
1481+
sec_file = _section_path(sections_dir, ld, ci)
14601482
if sec_file.exists() and sec_file.stat().st_size > 500:
14611483
file_parts.append(sec_file.read_text(encoding="utf-8"))
14621484
else:

0 commit comments

Comments
 (0)