diff --git a/src/lib/ai/summarise.ts b/src/lib/ai/summarise.ts new file mode 100644 index 0000000..dc6e20c --- /dev/null +++ b/src/lib/ai/summarise.ts @@ -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 { + const pb = await getAdminPb(); + + return pb.collection("commits").getFullList({ + 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 { + 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 { + const pb = await getAdminPb(); + + const stuck = await pb.collection("commits").getFullList({ + 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; +}