feat(intake): stateless interview service

This commit is contained in:
Dave 2026-09-16 17:55:36 +02:00
parent bac4694ab1
commit a74f3e79b5

138
src/lib/intake/interview.ts Normal file
View file

@ -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<ProjectRecord>(projectId);
const commits = await pb.collection("commits").getFullList<CommitRecord>({
filter: `project = "${projectId}" && scope != "" && is_merge = false`,
fields: "scope",
});
const counts = new Map<string, number>();
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<InterviewStep> {
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<ReturnType<typeof intakeContext>>;
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,
}),
},
};
}