profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
llmAnthropic.ts 4,344 bytes
1 import Anthropic from '@anthropic-ai/sdk'
2 import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'
3 import { CATEGORY_IDS, InterviewTurnSchema, type InterviewTurn } from '../../shared/types'
4 import { readEnvKey } from '../config/runtimeConfig'
5 import { weakestCategory } from '../engine/coverage'
6 import { ProviderNotConfiguredError, type GenerateFileRequest, type InterviewContext, type InterviewLlm } from './types'
7
8 const DEFAULT_MODEL = 'claude-opus-4-8'
9 const INTERVIEW_MAX_TOKENS = 16000
10 const GENERATE_MAX_TOKENS = 32000
11
12 export function buildInterviewSystemPrompt(context: InterviewContext): string {
13 const weakest = weakestCategory(context.coverage)
14 return [
15 'You are a Socratic spec interviewer talking through a project idea with someone who may not be a developer or ever have written a spec before. Assume no technical background unless they use technical terms themselves.',
16 'Ask exactly one targeted question per turn, chosen to fill the biggest gap in the spec. Use plain, everyday language, short sentences, no jargon, no acronyms, no words like "requirements", "schema", "edge case", or "non-goal" in the question itself. Ask about the underlying idea instead (e.g. ask what should happen when something goes wrong, not "what are the edge cases").',
17 `Coverage categories, in tie-break priority order: ${CATEGORY_IDS.join(', ')}.`,
18 weakest
19 ? `The weakest category right now is "${weakest}". Your next question must target it, unless a contradiction takes priority.`
20 : 'Every category is already "clear".',
21 'If the latest user answer conflicts with an earlier statement, do not ask a coverage question: set "contradiction" to describe both statements in plain language, referencing their segment ids, instead.',
22 'The interview ends only when the user answer is exactly "done" (case-insensitive, trimmed), never for any other reason, even if "done" appears inside a longer answer.',
23 'Reply with only the InterviewTurn structure described by the output schema.',
24 ].join('\n')
25 }
26
27 export function buildInterviewUserMessage(context: InterviewContext): string {
28 const lines: string[] = []
29 if (context.summary) {
30 lines.push('Running summary of earlier segments:', context.summary, '')
31 }
32 lines.push('Recent transcript segments:')
33 for (const segment of context.segments) {
34 lines.push(`[${segment.id}] ${segment.speaker}: ${segment.text}`)
35 }
36 lines.push('', `Current coverage: ${JSON.stringify(context.coverage)}`)
37 return lines.join('\n')
38 }
39
40 export function createLlmAnthropic(): InterviewLlm {
41 const apiKey = readEnvKey('ANTHROPIC_API_KEY')
42 if (!apiKey) throw new ProviderNotConfiguredError('Anthropic interview')
43 const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL
44 const client = new Anthropic({ apiKey })
45
46 return {
47 async nextTurn(context: InterviewContext): Promise<InterviewTurn> {
48 const runTurn = async (): Promise<InterviewTurn> => {
49 const message = await client.messages.parse({
50 model,
51 max_tokens: INTERVIEW_MAX_TOKENS,
52 thinking: { type: 'adaptive' },
53 system: buildInterviewSystemPrompt(context),
54 messages: [{ role: 'user', content: buildInterviewUserMessage(context) }],
55 output_config: { format: zodOutputFormat(InterviewTurnSchema) },
56 })
57 if (!message.parsed_output) {
58 throw new Error('Anthropic response did not include a parsed InterviewTurn')
59 }
60 return message.parsed_output
61 }
62 try {
63 return await runTurn()
64 } catch (err) {
65 // API errors were already retried by the SDK; retry once only on schema/parse failures
66 if (err instanceof Anthropic.APIError) throw err
67 return runTurn()
68 }
69 },
70
71 async generateFile(request: GenerateFileRequest): Promise<string> {
72 const stream = client.messages.stream({
73 model,
74 max_tokens: GENERATE_MAX_TOKENS,
75 thinking: { type: 'adaptive' },
76 messages: [{ role: 'user', content: request.prompt }],
77 })
78 const final = await stream.finalMessage()
79 const textBlock = final.content.find((block) => block.type === 'text')
80 if (!textBlock) {
81 throw new Error(`Anthropic response for ${request.file} contained no text block`)
82 }
83 return textBlock.text
84 },
85 }
86 }
87