// Live seed interview — per-turn handler. // // Stateless on the server: the client passes the running thread + the draft // extracted so far, plus the user's latest reply. We call the LLM with the // character + interview prompts and get back { assistant turn, updated draft, // confidence, ready }. import "server-only"; import { defaultGateway, chatJSON, type Message } from "./gateway"; import { loadPrompt } from "./prompts"; export interface SeedDraft { title: string; problem: string; targetUser: string; desiredOutcome: string; initialHypothesis?: string; constraints?: string[]; } export interface InterviewTurn { role: "socrates" | "user"; text: string; } export interface InterviewStepArgs { /** Running interview history. Empty array on the first call → Socrates opens. */ history: InterviewTurn[]; /** Latest user reply (empty string for the opening turn). */ userText: string; /** Draft extracted so far. Empty on the first call. */ draft: SeedDraft; } export interface InterviewStepResult { text: string; draft: SeedDraft; confidence: number; ready: boolean; inputTokens: number; outputTokens: number; provider: string; model: string; } const interviewJsonSchema = { type: "object", additionalProperties: false, required: ["text", "draft", "confidence", "ready"], properties: { text: { type: "string", minLength: 1 }, draft: { type: "object", additionalProperties: false, required: ["title", "problem", "targetUser", "desiredOutcome"], properties: { title: { type: "string" }, problem: { type: "string" }, targetUser: { type: "string" }, desiredOutcome: { type: "string" }, initialHypothesis: { type: "string" }, constraints: { type: "array", items: { type: "string" } }, }, }, confidence: { type: "number", minimum: 0, maximum: 1 }, ready: { type: "boolean" }, }, } as const; interface RawResponse { text?: string; draft?: Partial; confidence?: number; ready?: boolean; } export async function interviewStep(args: InterviewStepArgs): Promise { const character = loadPrompt("socrates/character.md"); const interview = loadPrompt("socrates/interview.md"); const userTurnsSoFar = args.history.filter(t => t.role === "user").length; const remaining = Math.max(0, 5 - userTurnsSoFar - (args.userText.trim().length > 0 ? 1 : 0)); const messages: Message[] = [ { role: "system", content: [ character, "---", interview, "---", `Draft so far (your previous extraction):\n\`\`\`json\n${JSON.stringify(args.draft, null, 2)}\n\`\`\``, `Turns remaining before ready signal becomes mandatory: ${remaining}`, ].join("\n\n"), }, ]; // Replay history so the LLM has full context. for (const t of args.history) { messages.push({ role: t.role === "socrates" ? "assistant" : "user", content: t.text }); } if (args.userText.trim().length > 0) { messages.push({ role: "user", content: args.userText }); } else if (args.history.length === 0) { messages.push({ role: "user", content: "Begin the interview." }); } const gateway = defaultGateway(); const { value, result } = await chatJSON(gateway, messages, { temperature: 0.4, maxTokens: 768, jsonSchema: { name: "interview_step", schema: interviewJsonSchema as Record }, jsonObjectMode: true, maxRepairs: 2, }); // Normalize / merge — the LLM may emit a partial draft; we union with the // prior draft so a user backtracking doesn't wipe a previously-confirmed field. const incoming: Partial = value?.draft ?? {}; const draft: SeedDraft = { title: nonEmpty(incoming.title) ?? args.draft.title ?? "", problem: nonEmpty(incoming.problem) ?? args.draft.problem ?? "", targetUser: nonEmpty(incoming.targetUser) ?? args.draft.targetUser ?? "", desiredOutcome: nonEmpty(incoming.desiredOutcome) ?? args.draft.desiredOutcome ?? "", initialHypothesis: nonEmpty(incoming.initialHypothesis) ?? args.draft.initialHypothesis, constraints: Array.isArray(incoming.constraints) ? incoming.constraints : args.draft.constraints, }; return { text: typeof value?.text === "string" && value.text.length > 0 ? value.text : "[empty response]", draft, confidence: clamp01(value?.confidence ?? 0), ready: !!value?.ready, inputTokens: result.inputTokens, outputTokens: result.outputTokens, provider: gateway.provider, model: gateway.model, }; } function nonEmpty(s: unknown): string | undefined { if (typeof s !== "string") return undefined; const t = s.trim(); return t.length > 0 ? t : undefined; } function clamp01(n: number): number { if (Number.isNaN(n)) return 0; return Math.max(0, Math.min(1, n)); }