profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

T4: session and answer routes

buildApp() wires Fastify with injectable store/llm deps.
POST/GET /api/sessions, GET /api/sessions/:id, and
POST /api/sessions/:id/answer append segments, run an interview-engine
stub against the InterviewLlm provider, and persist coverage/status.
Exact-match "done" detection lives in engine/interview.ts independent
of provider output.
commit 990a51f

6 changed files with +229 and −8

Jump to a changed file
  1. server/app.ts +26 −0
  2. server/engine/interview.ts +25 −0
  3. server/index.ts +2 −7
  4. server/routes/sessions.test.ts +110 −0
  5. server/routes/sessions.ts +65 −0
  6. spec/TASKS.md +1 −1
added server/app.ts +26 −0
@@ -0,0 +1,26 @@
1 +import cors from '@fastify/cors'
2 +import Fastify, { type FastifyInstance } from 'fastify'
3 +import { createInterviewLlm } from './providers/factory'
4 +import type { InterviewLlm } from './providers/types'
5 +import { registerSessionRoutes } from './routes/sessions'
6 +import { createDefaultSessionStore, SessionStore } from './store/sessionStore'
7 +
8 +export interface AppDeps {
9 + store: SessionStore
10 + llm: InterviewLlm
11 +}
12 +
13 +export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
14 + const resolved: AppDeps = {
15 + store: deps.store ?? createDefaultSessionStore(),
16 + llm: deps.llm ?? createInterviewLlm(),
17 + }
18 +
19 + const app = Fastify({ logger: false })
20 + void app.register(cors, { origin: true })
21 +
22 + app.get('/api/health', async () => ({ ok: true }))
23 + registerSessionRoutes(app, resolved)
24 +
25 + return app
26 +}
added server/engine/interview.ts +25 −0
@@ -0,0 +1,25 @@
1 +import type { InterviewTurn, Session } from '../../shared/types'
2 +import type { InterviewLlm } from '../providers/types'
3 +
4 +export function isDoneAnswer(text: string): boolean {
5 + return text.trim().toLowerCase() === 'done'
6 +}
7 +
8 +export async function runInterviewTurn(llm: InterviewLlm, session: Session): Promise<InterviewTurn> {
9 + const lastSegment = session.segments[session.segments.length - 1]
10 + if (lastSegment?.speaker === 'user' && isDoneAnswer(lastSegment.text)) {
11 + return {
12 + coverage: session.coverage,
13 + nextQuestion: 'Interview complete.',
14 + contradiction: null,
15 + done: true,
16 + summaryUpdate: null,
17 + }
18 + }
19 +
20 + return llm.nextTurn({
21 + summary: session.summary,
22 + segments: session.segments,
23 + coverage: session.coverage,
24 + })
25 +}
modified server/index.ts +2 −7
@@ -1,14 +1,9 @@
1 -import Fastify from 'fastify'
2 -import cors from '@fastify/cors'
1 +import { buildApp } from './app'
3 2
4 3 const PORT = Number(process.env.PORT ?? 3001)
5 4
6 5 async function main() {
7 - const app = Fastify({ logger: true })
8 - await app.register(cors, { origin: true })
9 -
10 - app.get('/api/health', async () => ({ ok: true }))
11 -
6 + const app = buildApp()
12 7 await app.listen({ port: PORT, host: '0.0.0.0' })
13 8 }
14 9
added server/routes/sessions.test.ts +110 −0
@@ -0,0 +1,110 @@
1 +import { mkdtemp, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import type { FastifyInstance } from 'fastify'
5 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 +import { CATEGORY_IDS, type AnswerResponse, type Session } from '../../shared/types'
7 +import { createLlmMock } from '../providers/llmMock'
8 +import { SessionStore } from '../store/sessionStore'
9 +import { buildApp } from '../app'
10 +
11 +describe('session routes', () => {
12 + let baseDir: string
13 + let app: FastifyInstance
14 +
15 + beforeEach(async () => {
16 + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-routes-'))
17 + app = buildApp({ store: new SessionStore(baseDir), llm: createLlmMock() })
18 + })
19 +
20 + afterEach(async () => {
21 + await app.close()
22 + await rm(baseDir, { recursive: true, force: true })
23 + })
24 +
25 + it('creates a session', async () => {
26 + const res = await app.inject({
27 + method: 'POST',
28 + url: '/api/sessions',
29 + payload: { name: 'my project', targetDir: '/tmp/target' },
30 + })
31 + expect(res.statusCode).toBe(201)
32 + const session = res.json<Session>()
33 + expect(session.name).toBe('my project')
34 + expect(session.status).toBe('interviewing')
35 + })
36 +
37 + it('lists and fetches sessions', async () => {
38 + const created = await app
39 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
40 + .then((r) => r.json<Session>())
41 +
42 + const list = await app.inject({ method: 'GET', url: '/api/sessions' })
43 + expect(list.json()).toEqual([{ id: created.id, name: 'p', status: 'interviewing' }])
44 +
45 + const fetched = await app.inject({ method: 'GET', url: `/api/sessions/${created.id}` })
46 + expect(fetched.json<Session>()).toEqual(created)
47 +
48 + const missing = await app.inject({ method: 'GET', url: '/api/sessions/nonexistent' })
49 + expect(missing.statusCode).toBe(404)
50 + })
51 +
52 + it('answering produces a user segment, an interviewer segment, and a coverage update', async () => {
53 + const created = await app
54 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
55 + .then((r) => r.json<Session>())
56 +
57 + const res = await app.inject({
58 + method: 'POST',
59 + url: `/api/sessions/${created.id}/answer`,
60 + payload: { text: 'we are building a todo app' },
61 + })
62 + expect(res.statusCode).toBe(200)
63 + const body = res.json<AnswerResponse>()
64 + expect(body.segment.speaker).toBe('user')
65 + expect(body.segment.text).toBe('we are building a todo app')
66 + expect(body.turn.coverage[CATEGORY_IDS[0]]).toBe('clear')
67 + expect(body.turn.done).toBe(false)
68 +
69 + const session = await app
70 + .inject({ method: 'GET', url: `/api/sessions/${created.id}` })
71 + .then((r) => r.json<Session>())
72 + expect(session.segments).toHaveLength(2)
73 + expect(session.segments[0]).toMatchObject({ id: 'S1', speaker: 'user' })
74 + expect(session.segments[1]).toMatchObject({ id: 'S2', speaker: 'interviewer' })
75 + expect(session.coverage).toEqual(body.turn.coverage)
76 + expect(session.status).toBe('interviewing')
77 + })
78 +
79 + it('ends the session when the answer is exactly "done"', async () => {
80 + const created = await app
81 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
82 + .then((r) => r.json<Session>())
83 +
84 + const res = await app.inject({
85 + method: 'POST',
86 + url: `/api/sessions/${created.id}/answer`,
87 + payload: { text: 'done' },
88 + })
89 + const body = res.json<AnswerResponse>()
90 + expect(body.turn.done).toBe(true)
91 +
92 + const session = await app
93 + .inject({ method: 'GET', url: `/api/sessions/${created.id}` })
94 + .then((r) => r.json<Session>())
95 + expect(session.status).toBe('done')
96 + })
97 +
98 + it('rejects invalid answer payloads', async () => {
99 + const created = await app
100 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
101 + .then((r) => r.json<Session>())
102 +
103 + const res = await app.inject({
104 + method: 'POST',
105 + url: `/api/sessions/${created.id}/answer`,
106 + payload: {},
107 + })
108 + expect(res.statusCode).toBe(400)
109 + })
110 +})
added server/routes/sessions.ts +65 −0
@@ -0,0 +1,65 @@
1 +import type { FastifyInstance } from 'fastify'
2 +import {
3 + AnswerRequestSchema,
4 + CreateSessionRequestSchema,
5 + type AnswerResponse,
6 +} from '../../shared/types'
7 +import { runInterviewTurn } from '../engine/interview'
8 +import type { InterviewLlm } from '../providers/types'
9 +import type { SessionStore } from '../store/sessionStore'
10 +
11 +export interface SessionRouteDeps {
12 + store: SessionStore
13 + llm: InterviewLlm
14 +}
15 +
16 +export function registerSessionRoutes(app: FastifyInstance, deps: SessionRouteDeps): void {
17 + const { store, llm } = deps
18 +
19 + app.post('/api/sessions', async (request, reply) => {
20 + const parsed = CreateSessionRequestSchema.safeParse(request.body)
21 + if (!parsed.success) {
22 + return reply.code(400).send({ error: parsed.error.message })
23 + }
24 + const session = await store.createSession(parsed.data.name, parsed.data.targetDir)
25 + return reply.code(201).send(session)
26 + })
27 +
28 + app.get('/api/sessions', async () => {
29 + return store.listSessions()
30 + })
31 +
32 + app.get<{ Params: { id: string } }>('/api/sessions/:id', async (request, reply) => {
33 + const session = await store.getSession(request.params.id)
34 + if (!session) return reply.code(404).send({ error: 'session not found' })
35 + return session
36 + })
37 +
38 + app.post<{ Params: { id: string } }>('/api/sessions/:id/answer', async (request, reply) => {
39 + const parsed = AnswerRequestSchema.safeParse(request.body)
40 + if (!parsed.success) {
41 + return reply.code(400).send({ error: parsed.error.message })
42 + }
43 +
44 + const existing = await store.getSession(request.params.id)
45 + if (!existing) return reply.code(404).send({ error: 'session not found' })
46 +
47 + const { session: afterAnswer, segment } = await store.appendSegment(
48 + request.params.id,
49 + 'user',
50 + parsed.data.text,
51 + )
52 +
53 + const turn = await runInterviewTurn(llm, afterAnswer)
54 +
55 + await store.appendSegment(request.params.id, 'interviewer', turn.nextQuestion)
56 + await store.updateTurn(request.params.id, {
57 + coverage: turn.coverage,
58 + summary: turn.summaryUpdate ?? afterAnswer.summary,
59 + status: turn.done ? 'done' : 'interviewing',
60 + })
61 +
62 + const response: AnswerResponse = { segment, turn }
63 + return reply.send(response)
64 + })
65 +}
modified spec/TASKS.md +1 −1
@@ -17,7 +17,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a
17 17 - Depends: T2
18 18 - Verify: `npm test` (mock behavior tests: echo, turn script reaches done, factory returns mocks under MOCK_PROVIDERS=1).
19 19
20 -- [ ] T4 Session and answer routes
20 +- [x] T4 Session and answer routes
21 21 - `POST /api/sessions`, `GET /api/sessions`, `GET /api/sessions/:id`, `POST /api/sessions/:id/answer` wired to store + interview engine stub that calls the InterviewLlm provider. "done" detection per SPEC (exact match, case-insensitive, trimmed).
22 22 - Depends: T3
23 23 - Verify: `npm test` (route tests via fastify.inject: create, answer produces interviewer segment + coverage update, done ends session).