feat(ai): card narratives regenerated when the commit set changes
This commit is contained in:
parent
7ca2798561
commit
30446153c8
2 changed files with 133 additions and 0 deletions
128
src/lib/ai/narrate.ts
Normal file
128
src/lib/ai/narrate.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue