feat(intake): stateless interview service
This commit is contained in:
parent
bac4694ab1
commit
a74f3e79b5
1 changed files with 138 additions and 0 deletions
138
src/lib/intake/interview.ts
Normal file
138
src/lib/intake/interview.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue