feat(ai): narrative prompt and set-based staleness
This commit is contained in:
parent
aea000f654
commit
5cecc87010
2 changed files with 218 additions and 0 deletions
123
src/lib/ai/narrative.test.ts
Normal file
123
src/lib/ai/narrative.test.ts
Normal 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
95
src/lib/ai/narrative.ts
Normal 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.`;
|
||||
}
|
||||
Loading…
Reference in a new issue