feat(ai): narrative prompt and set-based staleness

This commit is contained in:
Dave 2026-09-16 16:23:32 +02:00
parent aea000f654
commit 5cecc87010
2 changed files with 218 additions and 0 deletions

View file

@ -0,0 +1,123 @@
import { describe, it, expect } from "vitest";
import {
buildNarrativePrompt,
narrativeIsStale,
shaFingerprint,
type NarrativeCard,
type NarrativeCommit,
} from "@/lib/ai/narrative";
const card: NarrativeCard = {
title: "Add a POS system",
description: "Card payments at the counter.",
};
function commit(sha: string, subject: string, date = "2026-06-01"): NarrativeCommit {
return { sha, subject, summary: "", authoredAt: `${date}T10:00:00Z` };
}
describe("shaFingerprint", () => {
it("is stable regardless of order", () => {
expect(shaFingerprint(["b", "a", "c"])).toBe(shaFingerprint(["a", "c", "b"]));
});
it("changes when a sha is added or removed", () => {
expect(shaFingerprint(["a", "b"])).not.toBe(shaFingerprint(["a", "b", "c"]));
expect(shaFingerprint(["a", "b"])).not.toBe(shaFingerprint(["a"]));
});
it("handles an empty set", () => {
expect(shaFingerprint([])).toBe("");
});
});
describe("narrativeIsStale", () => {
it("is stale when there is no narrative yet", () => {
expect(narrativeIsStale({ narrative: "", version: 1, shas: "" }, ["a"], 1)).toBe(true);
});
it("is not stale when the commit set and version match", () => {
const fp = shaFingerprint(["a", "b"]);
expect(narrativeIsStale({ narrative: "text", version: 1, shas: fp }, ["b", "a"], 1)).toBe(false);
});
it("is stale when a commit is added", () => {
const fp = shaFingerprint(["a"]);
expect(narrativeIsStale({ narrative: "text", version: 1, shas: fp }, ["a", "b"], 1)).toBe(true);
});
it("is stale when the prompt version moves on", () => {
// The text was written under rules that no longer exist.
const fp = shaFingerprint(["a"]);
expect(narrativeIsStale({ narrative: "text", version: 1, shas: fp }, ["a"], 2)).toBe(true);
});
it("is NOT stale merely because the linker ran again", () => {
// Re-linking happens on every push. If an unchanged commit set counted as
// stale, every push would regenerate every narrative on the project and
// rewrite text the client has already read.
const fp = shaFingerprint(["a", "b", "c"]);
const stored = { narrative: "text", version: 1, shas: fp };
expect(narrativeIsStale(stored, ["c", "b", "a"], 1)).toBe(false);
});
it("is not stale for a card with no commits and no narrative", () => {
// Nothing to write about. Generating anything here would be invention.
expect(narrativeIsStale({ narrative: "", version: 1, shas: "" }, [], 1)).toBe(false);
});
});
describe("buildNarrativePrompt", () => {
const commits = [
commit("aaa", "feat(pos): card reader integration", "2026-06-01"),
commit("bbb", "fix(pos): handle declined payments", "2026-06-05"),
];
it("includes the card title and description", () => {
const prompt = buildNarrativePrompt(card, commits);
expect(prompt).toContain("Add a POS system");
expect(prompt).toContain("Card payments at the counter.");
});
it("lists commits oldest first", () => {
const prompt = buildNarrativePrompt(card, [commits[1], commits[0]]);
expect(prompt.indexOf("card reader")).toBeLessThan(prompt.indexOf("declined payments"));
});
it("prefers an existing one-liner over the raw subject", () => {
const withSummary: NarrativeCommit = {
...commits[0],
summary: "Connected the card reader.",
};
const prompt = buildNarrativePrompt(card, [withSummary]);
expect(prompt).toContain("Connected the card reader.");
expect(prompt).not.toContain("feat(pos): card reader integration");
});
it("falls back to the raw subject when there is no one-liner", () => {
const prompt = buildNarrativePrompt(card, commits);
expect(prompt).toContain("card reader integration");
});
it("instructs the model not to claim completion", () => {
const prompt = buildNarrativePrompt(card, commits).toLowerCase();
expect(prompt).toContain("complete");
});
it("includes dates so the narrative can describe a sequence", () => {
const prompt = buildNarrativePrompt(card, commits);
expect(prompt).toContain("2026-06-01");
});
it("flattens newlines so one commit is one line", () => {
const prompt = buildNarrativePrompt(card, [commit("ccc", "one\ntwo")]);
expect(prompt).toContain("one two");
});
it("caps a very long commit list", () => {
const many = Array.from({ length: 500 }, (_, i) => commit(`s${i}`, `subject ${i}`));
const prompt = buildNarrativePrompt(card, many);
// A card with 500 commits must not blow the context window or the bill.
expect(prompt.length).toBeLessThan(40_000);
});
});

95
src/lib/ai/narrative.ts Normal file
View file

@ -0,0 +1,95 @@
/**
* Card progress narratives.
*
* Keyed on the SET of linked commit SHAs rather than a timestamp, because
* re-linking runs on every push. A time-based check would regenerate every
* narrative on every push — rewriting text a client has already read, and
* paying for it each time.
*/
/** Most recent commits kept when a card has more than this. */
const MAX_COMMITS_IN_PROMPT = 120;
export interface NarrativeCard {
title: string;
description: string;
}
export interface NarrativeCommit {
sha: string;
subject: string;
/** The Plan 3b one-liner, or "" when not generated. */
summary: string;
authoredAt: string;
}
export interface StoredNarrative {
narrative: string;
version: number;
/** The fingerprint the stored text was written from. */
shas: string;
}
/** Order-independent identity for a set of SHAs. */
export function shaFingerprint(shas: string[]): string {
if (shas.length === 0) return "";
return [...shas].sort().join(",");
}
export function narrativeIsStale(
stored: StoredNarrative,
currentShas: string[],
promptVersion: number
): boolean {
// Nothing to write about. Generating here would be invention.
if (currentShas.length === 0 && !stored.narrative) return false;
if (!stored.narrative) return true;
if (stored.version !== promptVersion) return true;
return stored.shas !== shaFingerprint(currentShas);
}
function flatten(value: string): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}
export function buildNarrativePrompt(
card: NarrativeCard,
commits: NarrativeCommit[]
): string {
const ordered = [...commits].sort((a, b) =>
a.authoredAt.localeCompare(b.authoredAt)
);
// Keep the most recent when a card is enormous: the oldest commits on a
// long-running card tell the client least about where it stands now.
const kept =
ordered.length > MAX_COMMITS_IN_PROMPT
? ordered.slice(ordered.length - MAX_COMMITS_IN_PROMPT)
: ordered;
const lines = kept.map((commit) => {
const date = commit.authoredAt.replace(" ", "T").slice(0, 10);
// The one-liner is already in the client's language; prefer it.
const text = flatten(commit.summary || commit.subject);
return `- ${date}: ${text}`;
});
const omitted = ordered.length - kept.length;
const note = omitted > 0 ? `\n(${omitted} earlier entries omitted.)` : "";
return `Write a short progress update for the client about this piece of work.
Work item: ${flatten(card.title)}
${card.description ? `What was asked for: ${flatten(card.description)}` : ""}
What has happened, oldest first:
${lines.join("\n")}${note}
Write two or three short paragraphs covering what was built, anything that changed along the way, and where the work stands according to these entries.
Do not say the work is complete, finished or done — whether it is finished is recorded separately on the project board, not in this history. Do not mention dates for future work. Do not invent anything that is not above.
Reply with the paragraphs only.`;
}