From 30446153c8fe055e4dfbf215abdcb871f143344f Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 16:24:38 +0200 Subject: [PATCH] feat(ai): card narratives regenerated when the commit set changes --- src/lib/ai/narrate.ts | 128 ++++++++++++++++++++++++++++++++++++++++++ src/lib/git/ingest.ts | 5 ++ 2 files changed, 133 insertions(+) create mode 100644 src/lib/ai/narrate.ts diff --git a/src/lib/ai/narrate.ts b/src/lib/ai/narrate.ts new file mode 100644 index 0000000..dc53afb --- /dev/null +++ b/src/lib/ai/narrate.ts @@ -0,0 +1,128 @@ +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import { getProvider, getSystemPrompt, AiError } from "@/lib/ai"; +import { PROMPT_VERSION } from "@/lib/ai/preamble"; +import { aiConfigured } from "@/lib/ai/settings"; +import { + buildNarrativePrompt, + narrativeIsStale, + shaFingerprint, + type NarrativeCommit, +} from "@/lib/ai/narrative"; +import type { CardRecord, CommitRecord } from "@/lib/pb/types"; + +export interface NarrateResult { + considered: number; + written: number; + skipped: boolean; + error?: string; +} + +/** + * Regenerate narratives for a project's cards where the commit set has changed. + * + * Never throws, for the same reason as summariseProject: narratives are an + * enhancement over a board that already works. + * + * One request per card, not batched. A narrative is a few paragraphs of + * connected prose about one piece of work; batching would mean the model + * writing several unrelated updates in one response, and the index-matching + * problem from Plan 3b with far more text at stake per mismatch. + */ +export async function narrateProject(projectId: string): Promise { + if (!(await aiConfigured())) { + return { considered: 0, written: 0, skipped: true }; + } + + const pb = await getAdminPb(); + + const cards = await pb.collection("cards").getFullList({ + filter: `project = "${projectId}" && archived = false`, + fields: "id,title,description,narrative,narrative_version,narrative_shas", + }); + + let provider; + let system: string; + + try { + provider = await getProvider(); + system = await getSystemPrompt(); + } catch (error) { + return { + considered: 0, + written: 0, + skipped: true, + error: error instanceof AiError ? error.message : "AI is not configured", + }; + } + + let considered = 0; + let written = 0; + let lastError: string | undefined; + + for (const card of cards) { + const commits = await pb.collection("commits").getFullList({ + filter: `card = "${card.id}" && is_merge = false`, + sort: "authored_at", + fields: "sha,subject,summary,authored_at", + }); + + const shas = commits.map((c) => c.sha); + + const stale = narrativeIsStale( + { + narrative: card.narrative ?? "", + version: card.narrative_version ?? 0, + shas: card.narrative_shas ?? "", + }, + shas, + PROMPT_VERSION + ); + + if (!stale) continue; + + // A card with no commits gets no narrative. Writing one would be invention, + // and an empty card showing confident prose about work that has not started + // is exactly the failure this phase must not have. + if (commits.length === 0) continue; + + considered += 1; + + const items: NarrativeCommit[] = commits.map((c) => ({ + sha: c.sha, + subject: c.subject || "", + summary: c.summary || "", + authoredAt: c.authored_at, + })); + + try { + const result = await provider.complete({ + system, + user: buildNarrativePrompt( + { title: card.title, description: card.description ?? "" }, + items + ), + maxTokens: 4096, + }); + + if (!result.text) { + lastError = "The provider returned an empty narrative"; + continue; + } + + await pb.collection("cards").update(card.id, { + narrative: result.text.slice(0, 8000), + narrative_version: PROMPT_VERSION, + narrative_shas: shaFingerprint(shas), + }); + written += 1; + } catch (error) { + lastError = error instanceof AiError ? error.message : "Request failed"; + // A non-retryable failure will fail identically for every other card. + if (error instanceof AiError && !error.retryable) break; + } + } + + return { considered, written, skipped: false, error: lastError }; +} diff --git a/src/lib/git/ingest.ts b/src/lib/git/ingest.ts index 82425a3..ec1208a 100644 --- a/src/lib/git/ingest.ts +++ b/src/lib/git/ingest.ts @@ -184,6 +184,11 @@ export async function ingestProject( try { const { summariseProject } = await import("@/lib/ai/summarise"); await summariseProject(project.id); + + // Narratives prefer the one-liners, so summarisation must run first or the + // first narrative after a push would be written from raw subjects. + const { narrateProject } = await import("@/lib/ai/narrate"); + await narrateProject(project.id); } catch (error) { console.error(`[ingest] summarisation failed for ${project.id}`, error); }