feat(ai): read the importer's deck and transcript cache; studio DraftWriter fits DraftSink as is

Retrieval now reads <dataDir>/cache/decks/<courseId>/<deckId>/text.json and
<dataDir>/cache/transcripts/<courseId>/<lessonId>.json (skipped when the lesson
has a transcript file). DraftSink returns { location?, branch?, commit? }, so
the studio's createDraftWriter(ctx) needs no adapter; generateDraft computes
paths itself and reports the draft branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 04:36:48 +01:00
1 parent 08d3e3f642
commit 6fb0019cd9
6 files changed
+156 -12

No files matched your search

@@ -128,6 +128,18 @@ function lessonTitleOf(course: LoadedCourse, lessonId: string | undefined): stri
return lessonId ? course.lessonIndex.get(lessonId)?.lesson.title : undefined;
}
async function readJson<T>(path: string): Promise<T | null> {
try {
const f = Bun.file(path);
if (!(await f.exists()) || f.size > MAX_FILE * 25) return null;
return (await f.json()) as T;
} catch {
return null;
}
}
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
export async function buildCourseIndex(course: LoadedCourse, dataDir: string): Promise<CourseIndex> {
const bm = new Bm25<Chunk>();
const seen = new Set<string>();
@@ -179,8 +191,50 @@ export async function buildCourseIndex(course: LoadedCourse, dataDir: string): P
});
}
// Derived text from the importer and transcription jobs: <dataDir>/cache/<courseId>/**.
const decks = new Map((course.manifest?.decks ?? []).map((d) => [d.id, d]));
// The importer's deck cache: <dataDir>/cache/decks/<courseId>/<deckId>/text.json = [{ page, text }].
for (const deck of decks.values()) {
const pages = await readJson<{ page?: number; text?: string }[]>(join(dataDir, "cache", "decks", course.id, deck.id, "text.json"));
if (!Array.isArray(pages)) continue;
const lessonId = course.lessonIndex.has(deck.attachTo) ? deck.attachTo : undefined;
for (const p of pages) {
const text = typeof p?.text === "string" ? p.text.trim() : "";
if (!text) continue;
for (const part of splitLong(text)) {
add({ path: posix(deck.file), kind: "deck", text: part, heading: p.page ? `Slide ${p.page}` : deck.title, lessonId, lessonTitle: lessonTitleOf(course, lessonId) });
}
}
}
// The importer's transcription cache: <dataDir>/cache/transcripts/<courseId>/<lessonId>.json =
// { lessonId, text, segments: [{ start, end, text }] }. Skipped when the lesson has a transcript file in the
// course, which the transcription job writes alongside and which is indexed above.
const trDir = join(dataDir, "cache", "transcripts", course.id);
for (const name of await readdir(trDir).catch(() => [] as string[])) {
if (extname(name) !== ".json") continue;
const lessonId = name.slice(0, -5);
const entry = course.lessonIndex.get(lessonId);
if (!entry || entry.lesson.transcript) continue;
const tr = await readJson<{ text?: string; segments?: { start?: number; text?: string }[] }>(join(trDir, name));
if (!tr) continue;
const segs = Array.isArray(tr.segments) ? tr.segments.filter((s) => typeof s?.text === "string" && s.text.trim()) : [];
const blocks: { at: number; text: string }[] = [];
if (segs.length) {
for (const s of segs) {
const last = blocks[blocks.length - 1];
if (last && last.text.length + s.text!.length < MAX_CHUNK) last.text += ` ${s.text!.trim()}`;
else blocks.push({ at: Number(s.start) || 0, text: s.text!.trim() });
}
} else if (typeof tr.text === "string" && tr.text.trim()) {
blocks.push(...splitLong(tr.text.trim()).map((t) => ({ at: -1, text: t })));
}
for (const b of blocks) {
add({ path: entry.lesson.file ? posix(entry.lesson.file) : `transcripts/${lessonId}`, kind: "transcript", text: b.text, heading: b.at >= 0 ? `at ${clock(b.at)}` : undefined, lessonId, lessonTitle: entry.lesson.title });
}
}
// Older layout: plain text under <dataDir>/cache/<courseId>/**.
const cacheRoot = join(dataDir, "cache", course.id);
await walk(cacheRoot, async (abs, rel) => {
if (!CACHE_TEXT.has(extname(rel).toLowerCase())) return true;