feat(ai): commit summarisation service that never throws

This commit is contained in:
Dave 2026-09-16 16:16:08 +02:00
parent 8addf87eef
commit bf7d024cef

195
src/lib/ai/summarise.ts Normal file
View file

@ -0,0 +1,195 @@
import "server-only";
import { getAdminPb } from "@/lib/pb/admin";
import { getProvider, getSystemPrompt, AiError } from "@/lib/ai";
import { PROMPT_VERSION } from "@/lib/ai/preamble";
import {
BATCH_SIZE,
buildBatchPrompt,
chunk,
parseBatchResponse,
type SummarisableCommit,
} from "@/lib/ai/batch";
import { aiConfigured } from "@/lib/ai/settings";
import type { CommitRecord } from "@/lib/pb/types";
/** After this many consecutive failures, stop retrying a commit on every run. */
const MAX_ATTEMPTS = 3;
export interface SummariseResult {
considered: number;
summarised: number;
failed: number;
skipped: boolean;
error?: string;
}
/**
* Which commits still need a one-liner.
*
* Merges are excluded — they are not units of work and carry no meaning worth
* translating. A commit is pending when it has no summary, or its summary was
* written under an older PROMPT_VERSION, and it has not already failed
* MAX_ATTEMPTS times.
*/
async function pendingCommits(projectId: string): Promise<CommitRecord[]> {
const pb = await getAdminPb();
return pb.collection("commits").getFullList<CommitRecord>({
filter: [
`project = "${projectId}"`,
"is_merge = false",
`(summary = "" || summary_version < ${PROMPT_VERSION})`,
`summary_attempts < ${MAX_ATTEMPTS}`,
].join(" && "),
sort: "-authored_at",
fields: "id,subject,type,scope,summary_attempts",
});
}
/**
* Generate one-liners for a project's pending commits.
*
* Never throws. Summaries are an enhancement over data that is already correct
* and already displayed — the dashboard, the board and every card must keep
* working when the provider is misconfigured, down, or rate limited. Callers
* read the returned counts; they do not catch.
*/
export async function summariseProject(projectId: string): Promise<SummariseResult> {
if (!(await aiConfigured())) {
return { considered: 0, summarised: 0, failed: 0, skipped: true };
}
const pending = await pendingCommits(projectId);
if (pending.length === 0) {
return { considered: 0, summarised: 0, failed: 0, skipped: false };
}
const pb = await getAdminPb();
let provider;
let system: string;
try {
provider = await getProvider();
system = await getSystemPrompt();
} catch (error) {
return {
considered: pending.length,
summarised: 0,
failed: 0,
skipped: true,
error: error instanceof AiError ? error.message : "AI is not configured",
};
}
let summarised = 0;
let failed = 0;
let lastError: string | undefined;
for (const batch of chunk(pending, BATCH_SIZE)) {
const items: SummarisableCommit[] = batch.map((c) => ({
id: c.id,
subject: c.subject || "",
type: c.type || "other",
scope: c.scope || "",
}));
let text: string;
try {
const result = await provider.complete({
system,
user: buildBatchPrompt(items),
// Roughly 40 tokens per sentence plus headroom for thinking.
maxTokens: 4096,
});
text = result.text;
} catch (error) {
lastError = error instanceof AiError ? error.message : "Request failed";
// Count the attempt against every commit in the batch, so a message that
// reliably breaks the provider eventually stops being retried forever.
for (const commit of batch) {
await pb
.collection("commits")
.update(commit.id, { summary_attempts: (commit.summary_attempts ?? 0) + 1 })
.catch(() => {});
}
failed += batch.length;
// A non-retryable failure — a bad key, a refusal — will fail identically
// for every remaining batch. Stop rather than burning through them.
if (error instanceof AiError && !error.retryable) break;
continue;
}
const summaries = parseBatchResponse(text, items);
for (const commit of batch) {
const summary = summaries.get(commit.id);
if (!summary) {
// Absent from the response. Left unsummarised deliberately — never
// filled in from a neighbouring line.
await pb
.collection("commits")
.update(commit.id, { summary_attempts: (commit.summary_attempts ?? 0) + 1 })
.catch(() => {});
failed += 1;
continue;
}
await pb.collection("commits").update(commit.id, {
summary,
summary_version: PROMPT_VERSION,
summary_attempts: 0,
});
summarised += 1;
}
}
return {
considered: pending.length,
summarised,
failed,
skipped: false,
error: lastError,
};
}
/** Counts for the owner's admin panel. Silent degradation must be visible. */
export async function summaryStatus(projectId: string): Promise<{
total: number;
summarised: number;
stale: number;
failing: number;
}> {
const pb = await getAdminPb();
const count = async (filter: string) =>
(await pb.collection("commits").getList(1, 1, { filter, fields: "id" })).totalItems;
const base = `project = "${projectId}" && is_merge = false`;
return {
total: await count(base),
summarised: await count(`${base} && summary != "" && summary_version >= ${PROMPT_VERSION}`),
stale: await count(`${base} && summary != "" && summary_version < ${PROMPT_VERSION}`),
failing: await count(`${base} && summary_attempts >= ${MAX_ATTEMPTS}`),
};
}
/** Clear the failure counters so capped commits are retried once more. */
export async function resetSummaryFailures(projectId: string): Promise<number> {
const pb = await getAdminPb();
const stuck = await pb.collection("commits").getFullList<CommitRecord>({
filter: `project = "${projectId}" && summary_attempts >= ${MAX_ATTEMPTS}`,
fields: "id",
});
for (const commit of stuck) {
await pb.collection("commits").update(commit.id, { summary_attempts: 0 });
}
return stuck.length;
}