Commit
T4: session and answer routes
commit
990a51f
6 changed files with +229 and −8
Jump to a changed file
- server/app.ts +26 −0
- server/engine/interview.ts +25 −0
- server/index.ts +2 −7
- server/routes/sessions.test.ts +110 −0
- server/routes/sessions.ts +65 −0
- 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). |