diff --git a/src/lib/intake/interview.ts b/src/lib/intake/interview.ts new file mode 100644 index 0000000..6a91946 --- /dev/null +++ b/src/lib/intake/interview.ts @@ -0,0 +1,138 @@ +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import { getProvider, getSystemPrompt, AiError } from "@/lib/ai"; +import { aiConfigured } from "@/lib/ai/settings"; +import { canAskMore, sanitiseTurns, type Turn } from "@/lib/intake/turn"; +import { buildBriefPrompt, buildQuestionPrompt, type IntakeContext } from "@/lib/intake/prompt"; +import { parseBrief, type Brief } from "@/lib/intake/brief"; +import { deriveSizeBand, type SizeBand } from "@/lib/intake/size"; +import type { CommitRecord, ProjectRecord } from "@/lib/pb/types"; + +export interface InterviewStep { + kind: "question" | "brief" | "unavailable"; + question?: string; + brief?: Brief & { sizeBand: SizeBand }; + message?: string; +} + +/** + * The project's real commit scopes, used for the size band and to steer `area` + * toward something that exists rather than a name the model invents. + */ +async function intakeContext(projectId: string): Promise<{ + context: IntakeContext; + knownScopes: string[]; +}> { + const pb = await getAdminPb(); + + const project = await pb.collection("projects").getOne(projectId); + + const commits = await pb.collection("commits").getFullList({ + filter: `project = "${projectId}" && scope != "" && is_merge = false`, + fields: "scope", + }); + + const counts = new Map(); + for (const commit of commits) { + counts.set(commit.scope, (counts.get(commit.scope) ?? 0) + 1); + } + + // Most-used first, capped: a list of sixty scopes is noise in a prompt. + const knownScopes = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([scope]) => scope); + + return { + context: { projectName: project.name, knownAreas: knownScopes }, + knownScopes, + }; +} + +/** + * Advance the interview by one step. + * + * Stateless: the transcript arrives from the browser and is sanitised on every + * call. The question cap is enforced here, in code — a model that wants an + * eighth question does not get one. + * + * Never throws. A provider failure returns `unavailable`, and the caller offers + * the plain request box instead. + */ +export async function nextStep(input: { + projectId: string; + turns: Turn[]; + finishNow: boolean; +}): Promise { + if (!(await aiConfigured())) { + return { kind: "unavailable", message: "No AI provider is configured." }; + } + + const turns = sanitiseTurns(input.turns); + + let provider; + let system: string; + let ctx: Awaited>; + + try { + provider = await getProvider(); + system = await getSystemPrompt(); + ctx = await intakeContext(input.projectId); + } catch (error) { + return { + kind: "unavailable", + message: error instanceof AiError ? error.message : "The assistant is unavailable.", + }; + } + + // The cap, and the client's own "that's enough". + const finish = input.finishNow || !canAskMore(turns); + + if (!finish) { + try { + const result = await provider.complete({ + system, + user: buildQuestionPrompt(ctx.context, turns), + maxTokens: 1024, + }); + + const question = result.text.trim(); + if (question) return { kind: "question", question }; + // An empty question is not worth another round trip; write it up instead. + } catch (error) { + return { + kind: "unavailable", + message: error instanceof AiError ? error.message : "The assistant is unavailable.", + }; + } + } + + let raw = ""; + try { + const result = await provider.complete({ + system, + user: buildBriefPrompt(ctx.context, turns), + maxTokens: 4096, + }); + raw = result.text; + } catch { + // Fall through: parseBrief degrades to the client's own answers rather + // than losing an interview they have already sat through. + } + + const brief = parseBrief(raw, turns); + + return { + kind: "brief", + brief: { + ...brief, + // Derived in code, never taken from the model's output. + sizeBand: deriveSizeBand({ + subtaskCount: brief.subtasks.length, + knownScopes: ctx.knownScopes, + suggestedArea: brief.area, + }), + }, + }; +}