profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

T3: provider interfaces, factory, and deterministic mocks

SttProvider/InterviewLlm interfaces, sttMock (echo/incrementing
transcript), llmMock (fixed-order category script + minimal spec
pack file mocks with a bogus provenance marker), and an env-based
factory that forces mocks under MOCK_PROVIDERS=1.
commit 006890c

8 changed files with +246 and −1

Jump to a changed file
  1. server/providers/factory.test.ts +20 −0
  2. server/providers/factory.ts +17 −0
  3. server/providers/llmMock.test.ts +54 −0
  4. server/providers/llmMock.ts +91 −0
  5. server/providers/sttMock.test.ts +17 −0
  6. server/providers/sttMock.ts +21 −0
  7. server/providers/types.ts +25 −0
  8. 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).