feat(ai): card narratives regenerated when the commit set changes

This commit is contained in:
Dave 2026-09-16 16:24:38 +02:00
parent 7ca2798561
commit 30446153c8
2 changed files with 133 additions and 0 deletions

128
src/lib/ai/narrate.ts Normal file
View file

@ -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<NarrateResult> {
if (!(await aiConfigured())) {
return { considered: 0, written: 0, skipped: true };
}
const pb = await getAdminPb();
const cards = await pb.collection("cards").getFullList<CardRecord>({
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<CommitRecord>({
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 };
}

View file

@ -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);
}