Skip to content

Commit 51fe41e

Browse files
nodeeeeeeclaude
andcommitted
Intelligent auto-matching: multi-strategy scoring for video-slide pairs
Replaces naive token-overlap matching with a scored multi-strategy system: 1. Lecture number match (Week3 → L3, Lec2 → L2) +100 pts 2. Date match (video "06/03/2026" → slide "ann060326") +80 pts 3. Filename token overlap (Jaccard similarity) +0..30 pts 4. Transcript keyword overlap with slide filename +10 per hit 5. Slide version preference ("With notes" +5, "Review" +3, annotated +2) 6. Lecture subfolder preference +2 pts Server-side computation (main.js) reads transcript content for keyword extraction. Suggestions attached to scan results so the renderer just uses them. Both Electron and Flet GUIs updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 315c8d4 commit 51fe41e

3 files changed

Lines changed: 166 additions & 49 deletions

File tree

electron/main.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,95 @@ function registerIpc() {
841841
try { mapping = JSON.parse(fs.readFileSync(mapFile, 'utf8')); } catch {}
842842
}
843843

844+
// ── Smart auto-suggest for each caption ──────────────────────────────────
845+
// Strategy: lecture-number match → date match → transcript keyword overlap
846+
function autoSuggest(capStem) {
847+
const cl = capStem.toLowerCase().replace(/[-_]/g, ' ');
848+
849+
// 1. Extract number from caption: Week3, Lec2, L5, etc.
850+
const numMatch = cl.match(/week\s*(\d+)|lec(?:ture)?\s*(\d+)|\bl(\d+)\b/);
851+
const capNum = numMatch ? (numMatch[1] || numMatch[2] || numMatch[3]) : null;
852+
853+
// 2. Extract date from caption: "06/03/2026" or "06_03_2026"
854+
const dateMatch = capStem.match(/(\d{2})[/_](\d{2})[/_](\d{4})/);
855+
const capDate = dateMatch ? dateMatch[1] + dateMatch[2] + dateMatch[3].slice(2) : null;
856+
857+
// 3. Read first few lines of transcript for keyword extraction
858+
let capKeywords = new Set();
859+
const capFile = path.join(capDir, capStem + '.json');
860+
if (fs.existsSync(capFile)) {
861+
try {
862+
const capData = JSON.parse(fs.readFileSync(capFile, 'utf8'));
863+
const segs = capData.segments || [];
864+
// Sample ~20 segments evenly for topic keywords
865+
const step = Math.max(1, Math.floor(segs.length / 20));
866+
const words = segs.filter((_, i) => i % step === 0)
867+
.map(s => s.text || '').join(' ').toLowerCase()
868+
.replace(/[^a-z0-9\s]/g, '').split(/\s+/)
869+
.filter(w => w.length > 4);
870+
capKeywords = new Set(words);
871+
} catch {}
872+
}
873+
874+
let bestScore = 0;
875+
let bestSlide = null;
876+
877+
for (const s of slides) {
878+
const sl = s.name.toLowerCase().replace(/[-_]/g, ' ');
879+
let score = 0;
880+
881+
// Strategy 1: Lecture number match
882+
const slNumMatch = sl.match(/l(?:ecture)?\s*(\d+)/);
883+
if (capNum && slNumMatch && capNum === slNumMatch[1]) {
884+
score += 100;
885+
}
886+
887+
// Strategy 2: Date match (video date → slide annotation date)
888+
// e.g. "06/03/2026" matches "ann060326" in slide filename
889+
if (capDate) {
890+
if (sl.includes(capDate) || s.name.includes(capDate)) {
891+
score += 80;
892+
}
893+
}
894+
895+
// Strategy 3: Token overlap between caption stem and slide name
896+
const capTokens = new Set(cl.split(/\s+/).filter(t => t.length > 1));
897+
const slTokens = new Set(sl.split(/\s+/).filter(t => t.length > 1));
898+
const inter = [...capTokens].filter(t => slTokens.has(t)).length;
899+
if (capTokens.size && slTokens.size) {
900+
score += (inter / Math.max(capTokens.size, slTokens.size)) * 30;
901+
}
902+
903+
// Strategy 4: Transcript keyword overlap with slide filename words
904+
if (capKeywords.size > 0) {
905+
const slWords = sl.replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(w => w.length > 4);
906+
const kwHits = slWords.filter(w => capKeywords.has(w)).length;
907+
score += kwHits * 10;
908+
}
909+
910+
// Preference: "with notes" > "review" > annotated > plain
911+
if (/with\s*notes/i.test(s.name)) score += 5;
912+
else if (/review/i.test(s.name)) score += 3;
913+
else if (/ann\d/i.test(s.name)) score += 2;
914+
915+
// Preference: files in a "Lectures" subfolder
916+
if (/lecture/i.test(s.rel)) score += 2;
917+
918+
if (score > bestScore) {
919+
bestScore = score;
920+
bestSlide = s.rel;
921+
}
922+
}
923+
924+
return { suggested: bestScore > 5 ? bestSlide : null, score: bestScore };
925+
}
926+
927+
// Attach suggestions to captions
928+
for (const cap of captions) {
929+
const { suggested } = autoSuggest(cap.stem);
930+
cap.suggested = suggested;
931+
}
932+
844933
return { captions, slides, titles, mapping, base };
845934
});
846935

electron/renderer/app.js

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -673,29 +673,9 @@ function _alignSlideOptionsHtml(selected = '(none)') {
673673
return html;
674674
}
675675

676-
function _alignAutoSuggest(capStem) {
677-
// Simple heuristic: week number → lecture number, or token overlap
678-
const capLower = capStem.toLowerCase().replace(/[-_]/g, ' ');
679-
const weekMatch = capLower.match(/week\s*(\d+)/);
680-
const lecMatch = capLower.match(/lec(?:ture)?\s*(\d+)/);
681-
const capNum = weekMatch ? weekMatch[1] : (lecMatch ? lecMatch[1] : null);
682-
683-
let bestScore = 0, bestRel = '';
684-
for (const s of AlignState.slideOptions) {
685-
const sl = s.name.toLowerCase().replace(/[-_]/g, ' ');
686-
// Lecture number match
687-
const slNum = sl.match(/l(?:ecture)?\s*(\d+)/i);
688-
if (capNum && slNum && capNum === slNum[1]) return s.rel;
689-
// Token overlap
690-
const capTokens = new Set(capLower.split(/\s+/));
691-
const slTokens = new Set(sl.split(/\s+/));
692-
const inter = [...capTokens].filter(t => slTokens.has(t)).length;
693-
const union = new Set([...capTokens, ...slTokens]).size;
694-
const score = union ? inter / union : 0;
695-
if (score > bestScore) { bestScore = score; bestRel = s.rel; }
696-
}
697-
return bestScore > 0.05 ? bestRel : '(none)';
698-
}
676+
// Auto-suggest is now computed server-side in main.js (align:scan handler)
677+
// using lecture-number matching, date matching, transcript keyword overlap,
678+
// and slide version preferences (with notes > review > plain).
699679

700680
function _alignRebuildRows() {
701681
const container = document.getElementById('align-match-rows');
@@ -1469,10 +1449,10 @@ async function attachPageHandlers() {
14691449
if (data.mapping[cap.stem] && data.mapping[cap.stem].length) {
14701450
initSlides = data.mapping[cap.stem];
14711451
} else {
1472-
const suggested = _alignAutoSuggest(cap.stem);
1473-
initSlides = suggested !== '(none)' ? [suggested] : ['(none)'];
1452+
// Use server-side suggestion (computed with transcript keywords + date matching)
1453+
initSlides = cap.suggested ? [cap.suggested] : ['(none)'];
14741454
}
1475-
return { stem: cap.stem, title, aligned: cap.aligned, slides: initSlides };
1455+
return { stem: cap.stem, title, aligned: cap.aligned, transcribed: cap.transcribed, slides: initSlides };
14761456
});
14771457

14781458
_alignRebuildRows();

gui.py

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,32 +1229,80 @@ def build_align(page: ft.Page, console: OutputConsole) -> ft.Column:
12291229
# State: rows = [ {stem, title, aligned, container, slide_dropdowns: [dd,...]} ]
12301230
_match_state: dict = {"rows": [], "base": None, "slide_opts": []}
12311231

1232-
def _auto_suggest(cap_stem: str, slides: list, base: Path) -> list[str]:
1233-
"""Find auto-matched slides for a caption. Returns list of rel paths."""
1232+
def _auto_suggest(cap_stem: str, slides: list, base: Path,
1233+
cap_dir: Path | None = None) -> list[str]:
1234+
"""Find auto-matched slides for a caption using multi-strategy scoring.
1235+
1236+
Strategies (scored and summed):
1237+
1. Lecture number match (Week3 → L3) — +100
1238+
2. Date match (06/03/2026 → ann060326) — +80
1239+
3. Filename token overlap — +0..30
1240+
4. Transcript keyword overlap with slide — +10 per hit
1241+
5. Preference: "with notes" > "review" — +5/+3/+2
1242+
"""
12341243
import re as _re
1235-
results: list[str] = []
1236-
cap_lower = cap_stem.lower().replace("-", " ").replace("_", " ")
1237-
cap_tokens = set(cap_lower.split())
1244+
cl = cap_stem.lower().replace("-", " ").replace("_", " ")
1245+
1246+
# Extract number from caption
1247+
num_m = _re.search(r"week\s*(\d+)|lec(?:ture)?\s*(\d+)|\bl(\d+)\b", cl)
1248+
cap_num = (num_m.group(1) or num_m.group(2) or num_m.group(3)) if num_m else None
1249+
1250+
# Extract date from caption (e.g. "06_03_2026")
1251+
date_m = _re.search(r"(\d{2})[/_](\d{2})[/_](\d{4})", cap_stem)
1252+
cap_date = (date_m.group(1) + date_m.group(2) + date_m.group(3)[2:]) if date_m else None
1253+
1254+
# Read transcript keywords
1255+
cap_keywords: set[str] = set()
1256+
if cap_dir:
1257+
cap_file = cap_dir / f"{cap_stem}.json"
1258+
if cap_file.exists():
1259+
try:
1260+
import json as _json
1261+
segs = _json.loads(cap_file.read_text()).get("segments", [])
1262+
step = max(1, len(segs) // 20)
1263+
words = " ".join(s.get("text", "") for s in segs[::step][:20]).lower()
1264+
cap_keywords = {w for w in _re.findall(r"[a-z]{5,}", words)}
1265+
except Exception:
1266+
pass
1267+
12381268
best_score, best_rel = 0.0, ""
12391269
for sp in slides:
12401270
sl = sp.stem.lower().replace("-", " ").replace("_", " ")
1241-
sl_tokens = set(sl.split())
1242-
if cap_tokens and sl_tokens:
1243-
score = len(cap_tokens & sl_tokens) / len(cap_tokens | sl_tokens)
1244-
if score > best_score:
1245-
best_score = score
1246-
best_rel = str(sp.relative_to(base))
1247-
cap_num = _re.search(r"week\s*(\d+)|lec(?:ture)?\s*(\d+)|[Ll](\d+)", cap_stem)
1248-
sl_num = _re.search(r"[Ll](?:ecture)?\s*(\d+)", sp.stem)
1249-
if cap_num and sl_num:
1250-
cn = next(g for g in cap_num.groups() if g)
1251-
sn = sl_num.group(1)
1252-
if cn == sn:
1253-
best_rel = str(sp.relative_to(base))
1254-
best_score = 1.0
1255-
if best_score > 0.05 and best_rel:
1256-
results.append(best_rel)
1257-
return results
1271+
score = 0.0
1272+
1273+
# Strategy 1: lecture number
1274+
sl_num_m = _re.search(r"l(?:ecture)?\s*(\d+)", sl)
1275+
if cap_num and sl_num_m and cap_num == sl_num_m.group(1):
1276+
score += 100
1277+
1278+
# Strategy 2: date match
1279+
if cap_date and cap_date in sp.name:
1280+
score += 80
1281+
1282+
# Strategy 3: token overlap
1283+
ct = set(cl.split())
1284+
st = set(sl.split())
1285+
if ct and st:
1286+
score += (len(ct & st) / max(len(ct), len(st))) * 30
1287+
1288+
# Strategy 4: transcript keyword overlap
1289+
if cap_keywords:
1290+
sl_words = set(_re.findall(r"[a-z]{5,}", sl))
1291+
score += len(cap_keywords & sl_words) * 10
1292+
1293+
# Strategy 5: prefer annotated versions
1294+
if _re.search(r"with\s*notes", sp.name, _re.IGNORECASE):
1295+
score += 5
1296+
elif _re.search(r"review", sp.name, _re.IGNORECASE):
1297+
score += 3
1298+
elif _re.search(r"ann\d", sp.name, _re.IGNORECASE):
1299+
score += 2
1300+
1301+
if score > best_score:
1302+
best_score = score
1303+
best_rel = str(sp.relative_to(base))
1304+
1305+
return [best_rel] if best_score > 5 and best_rel else []
12581306

12591307
def _make_slide_dropdown(initial: str = "(none)") -> ft.Dropdown:
12601308
"""Create a single slide-file dropdown."""
@@ -1465,7 +1513,7 @@ def _scan_matching(_) -> None:
14651513
if not initials:
14661514
initials = ["(none)"]
14671515
else:
1468-
initials = _auto_suggest(cap.stem, slides, base)
1516+
initials = _auto_suggest(cap.stem, slides, base, cap_dir)
14691517
if not initials:
14701518
initials = ["(none)"]
14711519

0 commit comments

Comments
 (0)