feat(ai): commit summarisation service that never throws
This commit is contained in:
parent
8addf87eef
commit
bf7d024cef
1 changed files with 195 additions and 0 deletions
195
src/lib/ai/summarise.ts
Normal file
195
src/lib/ai/summarise.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue