Commit
T3: provider interfaces, factory, and deterministic mocks
commit
006890c
8 changed files with +246 and −1
Jump to a changed file
- server/providers/factory.test.ts +20 −0
- server/providers/factory.ts +17 −0
- server/providers/llmMock.test.ts +54 −0
- server/providers/llmMock.ts +91 −0
- server/providers/sttMock.test.ts +17 −0
- server/providers/sttMock.ts +21 −0
- server/providers/types.ts +25 −0
- spec/TASKS.md +1 −1
added server/providers/factory.test.ts +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +import { afterEach, describe, expect, it, vi } from 'vitest' | |
| 2 | +import { createInterviewLlm, createSttProvider } from './factory' | |
| 3 | + | |
| 4 | +describe('provider factory', () => { | |
| 5 | + afterEach(() => { | |
| 6 | + vi.unstubAllEnvs() | |
| 7 | + }) | |
| 8 | + | |
| 9 | + it('returns mock providers when MOCK_PROVIDERS=1', () => { | |
| 10 | + vi.stubEnv('MOCK_PROVIDERS', '1') | |
| 11 | + expect(createSttProvider()).toHaveProperty('transcribe') | |
| 12 | + expect(createInterviewLlm()).toHaveProperty('nextTurn') | |
| 13 | + }) | |
| 14 | + | |
| 15 | + it('refuses to create real providers when MOCK_PROVIDERS is not set', () => { | |
| 16 | + vi.stubEnv('MOCK_PROVIDERS', '') | |
| 17 | + expect(() => createSttProvider()).toThrow() | |
| 18 | + expect(() => createInterviewLlm()).toThrow() | |
| 19 | + }) | |
| 20 | +}) |
added server/providers/factory.ts +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +import { createLlmMock } from './llmMock' | |
| 2 | +import { createSttMock } from './sttMock' | |
| 3 | +import type { InterviewLlm, SttProvider } from './types' | |
| 4 | + | |
| 5 | +function isMockMode(): boolean { | |
| 6 | + return process.env.MOCK_PROVIDERS === '1' | |
| 7 | +} | |
| 8 | + | |
| 9 | +export function createSttProvider(): SttProvider { | |
| 10 | + if (isMockMode()) return createSttMock() | |
| 11 | + throw new Error('real STT provider not configured yet (set MOCK_PROVIDERS=1)') | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function createInterviewLlm(): InterviewLlm { | |
| 15 | + if (isMockMode()) return createLlmMock() | |
| 16 | + throw new Error('real LLM provider not configured yet (set MOCK_PROVIDERS=1)') | |
| 17 | +} |
added server/providers/llmMock.test.ts +54 −0
| @@ -0,0 +1,54 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { CATEGORY_IDS, initialCoverage, type Segment } from '../../shared/types' | |
| 3 | +import { createLlmMock } from './llmMock' | |
| 4 | + | |
| 5 | +function segment(id: string, speaker: Segment['speaker'], text: string): Segment { | |
| 6 | + return { id, ts: new Date().toISOString(), speaker, text } | |
| 7 | +} | |
| 8 | + | |
| 9 | +describe('llmMock interview script', () => { | |
| 10 | + it('clears one category per turn in fixed order and eventually reaches done', async () => { | |
| 11 | + const llm = createLlmMock() | |
| 12 | + let coverage = initialCoverage() | |
| 13 | + const segments: Segment[] = [] | |
| 14 | + | |
| 15 | + for (let i = 0; i < CATEGORY_IDS.length; i++) { | |
| 16 | + segments.push(segment(`S${i + 1}`, 'user', `answer ${i}`)) | |
| 17 | + const turn = await llm.nextTurn({ summary: '', segments, coverage }) | |
| 18 | + expect(turn.coverage[CATEGORY_IDS[i]]).toBe('clear') | |
| 19 | + coverage = turn.coverage | |
| 20 | + if (i < CATEGORY_IDS.length - 1) { | |
| 21 | + expect(turn.done).toBe(false) | |
| 22 | + expect(turn.nextQuestion).toContain(CATEGORY_IDS[i + 1]) | |
| 23 | + } else { | |
| 24 | + expect(turn.done).toBe(true) | |
| 25 | + } | |
| 26 | + } | |
| 27 | + | |
| 28 | + expect(Object.values(coverage).every((level) => level === 'clear')).toBe(true) | |
| 29 | + }) | |
| 30 | + | |
| 31 | + it('ends the interview when the last user segment is exactly "done"', async () => { | |
| 32 | + const llm = createLlmMock() | |
| 33 | + const coverage = initialCoverage() | |
| 34 | + const segments: Segment[] = [segment('S1', 'user', ' Done ')] | |
| 35 | + const turn = await llm.nextTurn({ summary: '', segments, coverage }) | |
| 36 | + expect(turn.done).toBe(true) | |
| 37 | + expect(turn.coverage).toEqual(coverage) | |
| 38 | + }) | |
| 39 | +}) | |
| 40 | + | |
| 41 | +describe('llmMock generation', () => { | |
| 42 | + it('emits a SPEC.md with a valid marker and a bogus marker', async () => { | |
| 43 | + const llm = createLlmMock() | |
| 44 | + const content = await llm.generateFile({ file: 'SPEC.md', prompt: '', segments: [] }) | |
| 45 | + expect(content).toContain('[S1]') | |
| 46 | + expect(content).toContain('[S999]') | |
| 47 | + }) | |
| 48 | + | |
| 49 | + it('emits a HANDOFF.md with a claude launch command', async () => { | |
| 50 | + const llm = createLlmMock() | |
| 51 | + const content = await llm.generateFile({ file: 'HANDOFF.md', prompt: '', segments: [] }) | |
| 52 | + expect(content).toContain('claude') | |
| 53 | + }) | |
| 54 | +}) |
added server/providers/llmMock.ts +91 −0
| @@ -0,0 +1,91 @@ | ||
| 1 | +import { CATEGORY_IDS, type Coverage, type InterviewTurn } from '../../shared/types' | |
| 2 | +import type { GenerateFileRequest, InterviewContext, InterviewLlm } from './types' | |
| 3 | + | |
| 4 | +export function createLlmMock(): InterviewLlm { | |
| 5 | + return { | |
| 6 | + async nextTurn(context: InterviewContext): Promise<InterviewTurn> { | |
| 7 | + return mockNextTurn(context) | |
| 8 | + }, | |
| 9 | + async generateFile(request: GenerateFileRequest): Promise<string> { | |
| 10 | + return mockFileContent(request.file) | |
| 11 | + }, | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 15 | +function mockNextTurn(context: InterviewContext): InterviewTurn { | |
| 16 | + const lastUserSegment = [...context.segments].reverse().find((s) => s.speaker === 'user') | |
| 17 | + if (lastUserSegment && lastUserSegment.text.trim().toLowerCase() === 'done') { | |
| 18 | + return { | |
| 19 | + coverage: context.coverage, | |
| 20 | + nextQuestion: 'Understood, wrapping up the interview.', | |
| 21 | + contradiction: null, | |
| 22 | + done: true, | |
| 23 | + summaryUpdate: null, | |
| 24 | + } | |
| 25 | + } | |
| 26 | + | |
| 27 | + const clearedCount = CATEGORY_IDS.filter((category) => context.coverage[category] === 'clear').length | |
| 28 | + const categoryToClear = CATEGORY_IDS[clearedCount] | |
| 29 | + | |
| 30 | + if (!categoryToClear) { | |
| 31 | + return { | |
| 32 | + coverage: context.coverage, | |
| 33 | + nextQuestion: 'All categories covered, ready to generate the spec pack.', | |
| 34 | + contradiction: null, | |
| 35 | + done: true, | |
| 36 | + summaryUpdate: null, | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + const coverage: Coverage = { ...context.coverage, [categoryToClear]: 'clear' } | |
| 41 | + const nextCategory = CATEGORY_IDS[clearedCount + 1] | |
| 42 | + const done = nextCategory === undefined | |
| 43 | + | |
| 44 | + return { | |
| 45 | + coverage, | |
| 46 | + nextQuestion: done | |
| 47 | + ? 'All categories covered, ready to generate the spec pack.' | |
| 48 | + : `[mock] Tell me about ${nextCategory}.`, | |
| 49 | + contradiction: null, | |
| 50 | + done, | |
| 51 | + summaryUpdate: null, | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +function mockFileContent(file: GenerateFileRequest['file']): string { | |
| 56 | + switch (file) { | |
| 57 | + case 'SPEC.md': | |
| 58 | + return [ | |
| 59 | + '# SPEC: Mock', | |
| 60 | + '', | |
| 61 | + '## Functional requirements', | |
| 62 | + '', | |
| 63 | + '- FR-001: The mock system does the first thing. [S1]', | |
| 64 | + '- FR-002: The mock system does the second thing. [S999]', | |
| 65 | + '', | |
| 66 | + ].join('\n') | |
| 67 | + case 'PLAN.md': | |
| 68 | + return '# PLAN: Mock\n\nMinimal mock plan.\n' | |
| 69 | + case 'TASKS.md': | |
| 70 | + return [ | |
| 71 | + '# TASKS: Mock', | |
| 72 | + '', | |
| 73 | + '- [ ] T1 Mock task', | |
| 74 | + ' - Verify: `npm test` exits 0.', | |
| 75 | + '', | |
| 76 | + ].join('\n') | |
| 77 | + case 'VERIFICATION.md': | |
| 78 | + return '# VERIFICATION: Mock\n\nRun `npm test`.\n' | |
| 79 | + case 'HANDOFF.md': | |
| 80 | + return [ | |
| 81 | + '# HANDOFF: Mock', | |
| 82 | + '', | |
| 83 | + 'Launch a sandboxed session with:', | |
| 84 | + '', | |
| 85 | + '```', | |
| 86 | + 'claude "Work through spec/TASKS.md in order."', | |
| 87 | + '```', | |
| 88 | + '', | |
| 89 | + ].join('\n') | |
| 90 | + } | |
| 91 | +} |
added server/providers/sttMock.test.ts +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { createSttMock } from './sttMock' | |
| 3 | + | |
| 4 | +describe('sttMock', () => { | |
| 5 | + it('echoes valid UTF-8 text buffers back as the transcript', async () => { | |
| 6 | + const stt = createSttMock() | |
| 7 | + const result = await stt.transcribe(Buffer.from('hello from a test', 'utf8'), 'text/plain') | |
| 8 | + expect(result).toBe('hello from a test') | |
| 9 | + }) | |
| 10 | + | |
| 11 | + it('returns an incrementing mock transcript for non-text buffers', async () => { | |
| 12 | + const stt = createSttMock() | |
| 13 | + const binary = Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x80, 0x80]) | |
| 14 | + expect(await stt.transcribe(binary, 'audio/webm')).toBe('mock transcript 1') | |
| 15 | + expect(await stt.transcribe(binary, 'audio/webm')).toBe('mock transcript 2') | |
| 16 | + }) | |
| 17 | +}) |
added server/providers/sttMock.ts +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +import type { SttProvider } from './types' | |
| 2 | + | |
| 3 | +export function createSttMock(): SttProvider { | |
| 4 | + let counter = 0 | |
| 5 | + | |
| 6 | + return { | |
| 7 | + async transcribe(audio: Buffer): Promise<string> { | |
| 8 | + const echoed = decodeIfUtf8Text(audio) | |
| 9 | + if (echoed !== null) return echoed | |
| 10 | + counter += 1 | |
| 11 | + return `mock transcript ${counter}` | |
| 12 | + }, | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +function decodeIfUtf8Text(buffer: Buffer): string | null { | |
| 17 | + if (buffer.length === 0) return null | |
| 18 | + const decoded = buffer.toString('utf8') | |
| 19 | + const roundTrip = Buffer.from(decoded, 'utf8') | |
| 20 | + return roundTrip.equals(buffer) ? decoded : null | |
| 21 | +} |
added server/providers/types.ts +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +import type { Coverage, InterviewTurn, Segment } from '../../shared/types' | |
| 2 | + | |
| 3 | +export interface SttProvider { | |
| 4 | + transcribe(audio: Buffer, mimeType: string): Promise<string> | |
| 5 | +} | |
| 6 | + | |
| 7 | +export interface InterviewContext { | |
| 8 | + summary: string | |
| 9 | + segments: Segment[] | |
| 10 | + coverage: Coverage | |
| 11 | +} | |
| 12 | + | |
| 13 | +export const SPEC_PACK_FILES = ['SPEC.md', 'PLAN.md', 'TASKS.md', 'VERIFICATION.md', 'HANDOFF.md'] as const | |
| 14 | +export type SpecPackFile = (typeof SPEC_PACK_FILES)[number] | |
| 15 | + | |
| 16 | +export interface GenerateFileRequest { | |
| 17 | + file: SpecPackFile | |
| 18 | + prompt: string | |
| 19 | + segments: Segment[] | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface InterviewLlm { | |
| 23 | + nextTurn(context: InterviewContext): Promise<InterviewTurn> | |
| 24 | + generateFile(request: GenerateFileRequest): Promise<string> | |
| 25 | +} |
modified spec/TASKS.md +1 −1
| @@ -12,7 +12,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 12 | 12 | - Depends: T1 |
| 13 | 13 | - Verify: `npm test` (store unit tests: create/reload roundtrip, sequential segment ids S1..Sn, list). |
| 14 | 14 | |
| 15 | -- [ ] T3 Provider interfaces, factory, mocks | |
| 15 | +- [x] T3 Provider interfaces, factory, mocks | |
| 16 | 16 | - `server/providers/types.ts`, `factory.ts`, `sttMock.ts`, `llmMock.ts` exactly as specified in PLAN.md (including the echo-text behavior of sttMock and the deterministic interview script and bad-marker SPEC.md of llmMock). |
| 17 | 17 | - Depends: T2 |
| 18 | 18 | - Verify: `npm test` (mock behavior tests: echo, turn script reaches done, factory returns mocks under MOCK_PROVIDERS=1). |