Commit
Merge pull request #1 from rasmusjy/spec-pack-implementation
commit
b046747
66 changed files with +7495 and −12
Jump to a changed file
- .gitignore +10 −0
- client/.gitignore +24 −0
- client/index.html +12 −0
- client/src/App.css +393 −0
- client/src/App.tsx +236 −0
- client/src/api.ts +68 −0
- client/src/audio.ts +40 −0
- client/src/components/BlockerImport.tsx +46 −0
- client/src/components/CoveragePanel.tsx +30 −0
- client/src/components/GeneratePanel.tsx +112 −0
- client/src/components/PushToTalkButton.tsx +83 −0
- client/src/components/QuestionCard.tsx +14 −0
- client/src/components/Transcript.tsx +22 −0
- client/src/index.css +50 −0
- client/src/main.tsx +10 −0
- client/src/tts.ts +9 −0
- client/tsconfig.app.json +29 −0
- client/tsconfig.json +4 −0
- client/vite.config.ts +26 −0
- package-lock.json +4026 −0
- package.json +37 −0
- server/app.ts +36 −0
- server/blockers/parse.test.ts +63 −0
- server/blockers/parse.ts +50 −0
- server/engine/coverage.test.ts +37 −0
- server/engine/coverage.ts +18 −0
- server/engine/interview.test.ts +91 −0
- server/engine/interview.ts +28 −0
- server/engine/summary.test.ts +31 −0
- server/engine/summary.ts +19 −0
- server/engine/turn.ts +49 −0
- server/generator/generate.test.ts +93 −0
- server/generator/generate.ts +92 −0
- server/generator/prompts.ts +61 −0
- server/generator/provenance.test.ts +31 −0
- server/generator/provenance.ts +33 −0
- server/index.ts +13 −0
- server/placeholder.test.ts +5 −0
- server/providers/factory.test.ts +20 −0
- server/providers/factory.ts +19 −0
- server/providers/llmAnthropic.test.ts +53 −0
- server/providers/llmAnthropic.ts +76 −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/sttOpenai.ts +33 −0
- server/providers/types.ts +31 −0
- server/routes/audio.test.ts +106 −0
- server/routes/audio.ts +44 −0
- server/routes/blockers.test.ts +118 −0
- server/routes/blockers.ts +42 −0
- server/routes/generate.test.ts +85 −0
- server/routes/generate.ts +34 −0
- server/routes/sessions.test.ts +110 −0
- server/routes/sessions.ts +50 −0
- server/smoke.test.ts +81 −0
- server/store/sessionStore.test.ts +82 −0
- server/store/sessionStore.ts +122 −0
- server/tsconfig.json +14 −0
- shared/index.ts +1 −0
- shared/tsconfig.json +10 −0
- shared/types.ts +114 −0
- spec/TASKS.md +12 −12
- tsconfig.base.json +14 −0
- vitest.config.ts +10 −0
added .gitignore +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +node_modules | |
| 2 | +dist | |
| 3 | +dist-ssr | |
| 4 | +*.local | |
| 5 | +data/sessions | |
| 6 | +.vscode/* | |
| 7 | +!.vscode/extensions.json | |
| 8 | +.idea | |
| 9 | +.DS_Store | |
| 10 | +*.log |
added client/.gitignore +24 −0
| @@ -0,0 +1,24 @@ | ||
| 1 | +# Logs | |
| 2 | +logs | |
| 3 | +*.log | |
| 4 | +npm-debug.log* | |
| 5 | +yarn-debug.log* | |
| 6 | +yarn-error.log* | |
| 7 | +pnpm-debug.log* | |
| 8 | +lerna-debug.log* | |
| 9 | + | |
| 10 | +node_modules | |
| 11 | +dist | |
| 12 | +dist-ssr | |
| 13 | +*.local | |
| 14 | + | |
| 15 | +# Editor directories and files | |
| 16 | +.vscode/* | |
| 17 | +!.vscode/extensions.json | |
| 18 | +.idea | |
| 19 | +.DS_Store | |
| 20 | +*.suo | |
| 21 | +*.ntvs* | |
| 22 | +*.njsproj | |
| 23 | +*.sln | |
| 24 | +*.sw? |
added client/index.html +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="en"> | |
| 3 | + <head> | |
| 4 | + <meta charset="UTF-8" /> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 6 | + <title>VoiceTask</title> | |
| 7 | + </head> | |
| 8 | + <body> | |
| 9 | + <div id="root"></div> | |
| 10 | + <script type="module" src="/src/main.tsx"></script> | |
| 11 | + </body> | |
| 12 | +</html> |
added client/src/App.css +393 −0
| @@ -0,0 +1,393 @@ | ||
| 1 | +.screen { | |
| 2 | + max-width: 960px; | |
| 3 | + margin: 0 auto; | |
| 4 | + padding: 32px 20px 64px; | |
| 5 | +} | |
| 6 | + | |
| 7 | +.subtitle { | |
| 8 | + color: var(--text); | |
| 9 | + margin-bottom: 32px; | |
| 10 | +} | |
| 11 | + | |
| 12 | +.muted { | |
| 13 | + color: var(--text); | |
| 14 | + opacity: 0.7; | |
| 15 | +} | |
| 16 | + | |
| 17 | +.error { | |
| 18 | + color: var(--danger); | |
| 19 | + margin-top: 12px; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/* Session list screen */ | |
| 23 | + | |
| 24 | +.create-session-form { | |
| 25 | + display: flex; | |
| 26 | + flex-direction: column; | |
| 27 | + gap: 12px; | |
| 28 | + padding: 20px; | |
| 29 | + border: 1px solid var(--border); | |
| 30 | + border-radius: 10px; | |
| 31 | + margin-bottom: 32px; | |
| 32 | + background: var(--bg-alt); | |
| 33 | +} | |
| 34 | + | |
| 35 | +.create-session-form label { | |
| 36 | + display: flex; | |
| 37 | + flex-direction: column; | |
| 38 | + gap: 4px; | |
| 39 | + font-size: 14px; | |
| 40 | + color: var(--text); | |
| 41 | +} | |
| 42 | + | |
| 43 | +.create-session-form input { | |
| 44 | + padding: 8px 10px; | |
| 45 | + border: 1px solid var(--border); | |
| 46 | + border-radius: 6px; | |
| 47 | + background: var(--bg); | |
| 48 | + color: var(--text-h); | |
| 49 | +} | |
| 50 | + | |
| 51 | +.create-session-form button { | |
| 52 | + align-self: flex-start; | |
| 53 | + padding: 8px 16px; | |
| 54 | + border: none; | |
| 55 | + border-radius: 6px; | |
| 56 | + background: var(--accent); | |
| 57 | + color: white; | |
| 58 | + cursor: pointer; | |
| 59 | +} | |
| 60 | + | |
| 61 | +.create-session-form button:disabled { | |
| 62 | + opacity: 0.6; | |
| 63 | + cursor: default; | |
| 64 | +} | |
| 65 | + | |
| 66 | +.session-list ul { | |
| 67 | + list-style: none; | |
| 68 | + padding: 0; | |
| 69 | + margin: 12px 0 0; | |
| 70 | + display: flex; | |
| 71 | + flex-direction: column; | |
| 72 | + gap: 8px; | |
| 73 | +} | |
| 74 | + | |
| 75 | +.session-item { | |
| 76 | + width: 100%; | |
| 77 | + display: flex; | |
| 78 | + justify-content: space-between; | |
| 79 | + align-items: center; | |
| 80 | + padding: 12px 16px; | |
| 81 | + border: 1px solid var(--border); | |
| 82 | + border-radius: 8px; | |
| 83 | + background: var(--bg); | |
| 84 | + cursor: pointer; | |
| 85 | + color: var(--text-h); | |
| 86 | + text-align: left; | |
| 87 | +} | |
| 88 | + | |
| 89 | +.session-item:hover { | |
| 90 | + border-color: var(--accent); | |
| 91 | +} | |
| 92 | + | |
| 93 | +.session-status { | |
| 94 | + font-size: 12px; | |
| 95 | + padding: 2px 8px; | |
| 96 | + border-radius: 999px; | |
| 97 | + background: var(--bg-alt); | |
| 98 | + text-transform: uppercase; | |
| 99 | + letter-spacing: 0.04em; | |
| 100 | +} | |
| 101 | + | |
| 102 | +.session-status.status-done { | |
| 103 | + color: var(--accent); | |
| 104 | +} | |
| 105 | + | |
| 106 | +/* Interview screen */ | |
| 107 | + | |
| 108 | +.interview-header { | |
| 109 | + display: flex; | |
| 110 | + justify-content: space-between; | |
| 111 | + align-items: center; | |
| 112 | + gap: 16px; | |
| 113 | +} | |
| 114 | + | |
| 115 | +.tts-toggle { | |
| 116 | + display: flex; | |
| 117 | + align-items: center; | |
| 118 | + gap: 6px; | |
| 119 | + font-size: 14px; | |
| 120 | + color: var(--text); | |
| 121 | + white-space: nowrap; | |
| 122 | +} | |
| 123 | + | |
| 124 | +.push-to-talk { | |
| 125 | + margin-bottom: 12px; | |
| 126 | +} | |
| 127 | + | |
| 128 | +.record-button { | |
| 129 | + width: 100%; | |
| 130 | + padding: 14px; | |
| 131 | + border-radius: 8px; | |
| 132 | + border: 1px solid var(--border); | |
| 133 | + background: var(--bg-alt); | |
| 134 | + color: var(--text-h); | |
| 135 | + cursor: pointer; | |
| 136 | + font-size: 15px; | |
| 137 | +} | |
| 138 | + | |
| 139 | +.record-button.recording { | |
| 140 | + background: var(--danger); | |
| 141 | + color: white; | |
| 142 | + border-color: var(--danger); | |
| 143 | +} | |
| 144 | + | |
| 145 | +.record-button:disabled { | |
| 146 | + opacity: 0.6; | |
| 147 | + cursor: default; | |
| 148 | +} | |
| 149 | + | |
| 150 | +.back-link { | |
| 151 | + background: none; | |
| 152 | + border: none; | |
| 153 | + color: var(--accent); | |
| 154 | + cursor: pointer; | |
| 155 | + padding: 0; | |
| 156 | + margin-bottom: 16px; | |
| 157 | + font-size: 14px; | |
| 158 | +} | |
| 159 | + | |
| 160 | +.interview-layout { | |
| 161 | + display: grid; | |
| 162 | + grid-template-columns: 1fr 220px; | |
| 163 | + gap: 24px; | |
| 164 | + align-items: start; | |
| 165 | +} | |
| 166 | + | |
| 167 | +@media (max-width: 720px) { | |
| 168 | + .interview-layout { | |
| 169 | + grid-template-columns: 1fr; | |
| 170 | + } | |
| 171 | +} | |
| 172 | + | |
| 173 | +.question-card { | |
| 174 | + padding: 16px 20px; | |
| 175 | + border-radius: 10px; | |
| 176 | + background: var(--accent-bg); | |
| 177 | + margin-bottom: 20px; | |
| 178 | +} | |
| 179 | + | |
| 180 | +.question-label { | |
| 181 | + display: block; | |
| 182 | + font-size: 12px; | |
| 183 | + text-transform: uppercase; | |
| 184 | + letter-spacing: 0.06em; | |
| 185 | + color: var(--accent); | |
| 186 | + margin-bottom: 6px; | |
| 187 | +} | |
| 188 | + | |
| 189 | +.question-text { | |
| 190 | + font-size: 18px; | |
| 191 | + color: var(--text-h); | |
| 192 | +} | |
| 193 | + | |
| 194 | +.transcript { | |
| 195 | + display: flex; | |
| 196 | + flex-direction: column; | |
| 197 | + gap: 12px; | |
| 198 | + margin-bottom: 20px; | |
| 199 | +} | |
| 200 | + | |
| 201 | +.transcript-empty { | |
| 202 | + color: var(--text); | |
| 203 | + opacity: 0.7; | |
| 204 | + margin-bottom: 20px; | |
| 205 | +} | |
| 206 | + | |
| 207 | +.segment { | |
| 208 | + padding: 10px 14px; | |
| 209 | + border-radius: 8px; | |
| 210 | + border: 1px solid var(--border); | |
| 211 | +} | |
| 212 | + | |
| 213 | +.segment-user { | |
| 214 | + background: var(--bg); | |
| 215 | +} | |
| 216 | + | |
| 217 | +.segment-interviewer { | |
| 218 | + background: var(--bg-alt); | |
| 219 | +} | |
| 220 | + | |
| 221 | +.segment-speaker { | |
| 222 | + display: block; | |
| 223 | + font-size: 12px; | |
| 224 | + color: var(--text); | |
| 225 | + opacity: 0.7; | |
| 226 | + margin-bottom: 4px; | |
| 227 | +} | |
| 228 | + | |
| 229 | +.answer-form { | |
| 230 | + display: flex; | |
| 231 | + gap: 8px; | |
| 232 | +} | |
| 233 | + | |
| 234 | +.answer-form input { | |
| 235 | + flex: 1; | |
| 236 | + padding: 10px 12px; | |
| 237 | + border: 1px solid var(--border); | |
| 238 | + border-radius: 6px; | |
| 239 | + background: var(--bg); | |
| 240 | + color: var(--text-h); | |
| 241 | +} | |
| 242 | + | |
| 243 | +.answer-form button { | |
| 244 | + padding: 10px 16px; | |
| 245 | + border: none; | |
| 246 | + border-radius: 6px; | |
| 247 | + cursor: pointer; | |
| 248 | +} | |
| 249 | + | |
| 250 | +.answer-form button[type='submit'] { | |
| 251 | + background: var(--accent); | |
| 252 | + color: white; | |
| 253 | +} | |
| 254 | + | |
| 255 | +.answer-form .done-button { | |
| 256 | + background: var(--bg-alt); | |
| 257 | + color: var(--text-h); | |
| 258 | + border: 1px solid var(--border); | |
| 259 | +} | |
| 260 | + | |
| 261 | +.answer-form button:disabled { | |
| 262 | + opacity: 0.6; | |
| 263 | + cursor: default; | |
| 264 | +} | |
| 265 | + | |
| 266 | +.generate-panel { | |
| 267 | + margin-top: 20px; | |
| 268 | + padding: 16px 20px; | |
| 269 | + border: 1px solid var(--border); | |
| 270 | + border-radius: 10px; | |
| 271 | +} | |
| 272 | + | |
| 273 | +.generate-panel h2 { | |
| 274 | + font-size: 16px; | |
| 275 | + margin-bottom: 12px; | |
| 276 | +} | |
| 277 | + | |
| 278 | +.generate-panel button { | |
| 279 | + padding: 8px 14px; | |
| 280 | + border-radius: 6px; | |
| 281 | + border: none; | |
| 282 | + background: var(--accent); | |
| 283 | + color: white; | |
| 284 | + cursor: pointer; | |
| 285 | + margin-right: 8px; | |
| 286 | +} | |
| 287 | + | |
| 288 | +.generate-panel button:disabled { | |
| 289 | + opacity: 0.6; | |
| 290 | + cursor: default; | |
| 291 | +} | |
| 292 | + | |
| 293 | +.generate-confirm, | |
| 294 | +.generate-overwrite { | |
| 295 | + margin-bottom: 12px; | |
| 296 | +} | |
| 297 | + | |
| 298 | +.generate-confirm button:last-child, | |
| 299 | +.generate-overwrite button { | |
| 300 | + background: var(--bg-alt); | |
| 301 | + color: var(--text-h); | |
| 302 | + border: 1px solid var(--border); | |
| 303 | +} | |
| 304 | + | |
| 305 | +.generate-result ul { | |
| 306 | + margin: 8px 0 0; | |
| 307 | + padding-left: 20px; | |
| 308 | +} | |
| 309 | + | |
| 310 | +.generate-warnings { | |
| 311 | + margin-top: 8px; | |
| 312 | + color: var(--text); | |
| 313 | + opacity: 0.85; | |
| 314 | +} | |
| 315 | + | |
| 316 | +.blocker-import { | |
| 317 | + margin-top: 20px; | |
| 318 | + padding: 16px 20px; | |
| 319 | + border: 1px solid var(--border); | |
| 320 | + border-radius: 10px; | |
| 321 | +} | |
| 322 | + | |
| 323 | +.blocker-import h2 { | |
| 324 | + font-size: 16px; | |
| 325 | + margin-bottom: 8px; | |
| 326 | +} | |
| 327 | + | |
| 328 | +.blocker-import button { | |
| 329 | + padding: 8px 14px; | |
| 330 | + border-radius: 6px; | |
| 331 | + border: 1px solid var(--border); | |
| 332 | + background: var(--bg-alt); | |
| 333 | + color: var(--text-h); | |
| 334 | + cursor: pointer; | |
| 335 | + margin-top: 4px; | |
| 336 | +} | |
| 337 | + | |
| 338 | +.blocker-import button:disabled { | |
| 339 | + opacity: 0.6; | |
| 340 | + cursor: default; | |
| 341 | +} | |
| 342 | + | |
| 343 | +.interview-done { | |
| 344 | + padding: 12px 16px; | |
| 345 | + border-radius: 8px; | |
| 346 | + background: var(--accent-bg); | |
| 347 | + color: var(--text-h); | |
| 348 | +} | |
| 349 | + | |
| 350 | +.interview-sidebar h2 { | |
| 351 | + font-size: 14px; | |
| 352 | + text-transform: uppercase; | |
| 353 | + letter-spacing: 0.06em; | |
| 354 | + color: var(--text); | |
| 355 | + opacity: 0.8; | |
| 356 | +} | |
| 357 | + | |
| 358 | +.coverage-panel { | |
| 359 | + list-style: none; | |
| 360 | + padding: 0; | |
| 361 | + margin: 0; | |
| 362 | + display: flex; | |
| 363 | + flex-direction: column; | |
| 364 | + gap: 8px; | |
| 365 | +} | |
| 366 | + | |
| 367 | +.coverage-item { | |
| 368 | + display: flex; | |
| 369 | + align-items: center; | |
| 370 | + gap: 8px; | |
| 371 | + font-size: 14px; | |
| 372 | + color: var(--text-h); | |
| 373 | +} | |
| 374 | + | |
| 375 | +.coverage-dot { | |
| 376 | + width: 10px; | |
| 377 | + height: 10px; | |
| 378 | + border-radius: 50%; | |
| 379 | + background: var(--border); | |
| 380 | + flex-shrink: 0; | |
| 381 | +} | |
| 382 | + | |
| 383 | +.coverage-missing .coverage-dot { | |
| 384 | + background: var(--border); | |
| 385 | +} | |
| 386 | + | |
| 387 | +.coverage-partial .coverage-dot { | |
| 388 | + background: #e0a83f; | |
| 389 | +} | |
| 390 | + | |
| 391 | +.coverage-clear .coverage-dot { | |
| 392 | + background: #2fa869; | |
| 393 | +} |
added client/src/App.tsx +236 −0
| @@ -0,0 +1,236 @@ | ||
| 1 | +import { useEffect, useRef, useState } from 'react' | |
| 2 | +import type { Session, SessionSummary } from 'shared/types' | |
| 3 | +import * as api from './api' | |
| 4 | +import './App.css' | |
| 5 | +import { BlockerImport } from './components/BlockerImport' | |
| 6 | +import { CoveragePanel } from './components/CoveragePanel' | |
| 7 | +import { GeneratePanel } from './components/GeneratePanel' | |
| 8 | +import { PushToTalkButton } from './components/PushToTalkButton' | |
| 9 | +import { QuestionCard } from './components/QuestionCard' | |
| 10 | +import { Transcript } from './components/Transcript' | |
| 11 | +import { speak } from './tts' | |
| 12 | + | |
| 13 | +function latestQuestion(session: Session): string | null { | |
| 14 | + for (let i = session.segments.length - 1; i >= 0; i--) { | |
| 15 | + const segment = session.segments[i] | |
| 16 | + if (segment.speaker === 'interviewer') return segment.text | |
| 17 | + } | |
| 18 | + return null | |
| 19 | +} | |
| 20 | + | |
| 21 | +function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { | |
| 22 | + const [sessions, setSessions] = useState<SessionSummary[]>([]) | |
| 23 | + const [name, setName] = useState('') | |
| 24 | + const [targetDir, setTargetDir] = useState('') | |
| 25 | + const [error, setError] = useState<string | null>(null) | |
| 26 | + const [creating, setCreating] = useState(false) | |
| 27 | + | |
| 28 | + useEffect(() => { | |
| 29 | + api.listSessions().then(setSessions).catch((err: Error) => setError(err.message)) | |
| 30 | + }, []) | |
| 31 | + | |
| 32 | + async function handleCreate(event: React.FormEvent) { | |
| 33 | + event.preventDefault() | |
| 34 | + setError(null) | |
| 35 | + setCreating(true) | |
| 36 | + try { | |
| 37 | + const session = await api.createSession({ name, targetDir }) | |
| 38 | + onOpen(session.id) | |
| 39 | + } catch (err) { | |
| 40 | + setError(err instanceof Error ? err.message : String(err)) | |
| 41 | + } finally { | |
| 42 | + setCreating(false) | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + return ( | |
| 47 | + <div className="screen session-list-screen"> | |
| 48 | + <h1>VoiceTask</h1> | |
| 49 | + <p className="subtitle">Talk through what you want to build. VoiceTask interviews you and writes the spec.</p> | |
| 50 | + | |
| 51 | + <form className="create-session-form" onSubmit={handleCreate}> | |
| 52 | + <h2>Start a new interview</h2> | |
| 53 | + <label> | |
| 54 | + Project name | |
| 55 | + <input value={name} onChange={(e) => setName(e.target.value)} required /> | |
| 56 | + </label> | |
| 57 | + <label> | |
| 58 | + Target directory | |
| 59 | + <input | |
| 60 | + value={targetDir} | |
| 61 | + onChange={(e) => setTargetDir(e.target.value)} | |
| 62 | + placeholder="/path/to/project" | |
| 63 | + required | |
| 64 | + /> | |
| 65 | + </label> | |
| 66 | + <button type="submit" disabled={creating}> | |
| 67 | + {creating ? 'Starting…' : 'Start interview'} | |
| 68 | + </button> | |
| 69 | + </form> | |
| 70 | + | |
| 71 | + {error && <p className="error">{error}</p>} | |
| 72 | + | |
| 73 | + <div className="session-list"> | |
| 74 | + <h2>Existing sessions</h2> | |
| 75 | + {sessions.length === 0 && <p className="muted">No sessions yet.</p>} | |
| 76 | + <ul> | |
| 77 | + {sessions.map((session) => ( | |
| 78 | + <li key={session.id}> | |
| 79 | + <button type="button" className="session-item" onClick={() => onOpen(session.id)}> | |
| 80 | + <span className="session-name">{session.name}</span> | |
| 81 | + <span className={`session-status status-${session.status}`}>{session.status}</span> | |
| 82 | + </button> | |
| 83 | + </li> | |
| 84 | + ))} | |
| 85 | + </ul> | |
| 86 | + </div> | |
| 87 | + </div> | |
| 88 | + ) | |
| 89 | +} | |
| 90 | + | |
| 91 | +function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () => void }) { | |
| 92 | + const [session, setSession] = useState<Session | null>(null) | |
| 93 | + const [answerText, setAnswerText] = useState('') | |
| 94 | + const [error, setError] = useState<string | null>(null) | |
| 95 | + const [submitting, setSubmitting] = useState(false) | |
| 96 | + const [ttsEnabled, setTtsEnabled] = useState(false) | |
| 97 | + const lastSpokenQuestion = useRef<string | null>(null) | |
| 98 | + | |
| 99 | + function refresh() { | |
| 100 | + return api.getSession(sessionId).then(setSession) | |
| 101 | + } | |
| 102 | + | |
| 103 | + useEffect(() => { | |
| 104 | + api.getSession(sessionId).then(setSession).catch((err: Error) => setError(err.message)) | |
| 105 | + }, [sessionId]) | |
| 106 | + | |
| 107 | + useEffect(() => { | |
| 108 | + if (!ttsEnabled || !session) return | |
| 109 | + const question = latestQuestion(session) | |
| 110 | + if (question && question !== lastSpokenQuestion.current) { | |
| 111 | + lastSpokenQuestion.current = question | |
| 112 | + speak(question) | |
| 113 | + } | |
| 114 | + }, [ttsEnabled, session]) | |
| 115 | + | |
| 116 | + async function submit(text: string) { | |
| 117 | + if (!text.trim()) return | |
| 118 | + setError(null) | |
| 119 | + setSubmitting(true) | |
| 120 | + try { | |
| 121 | + await api.submitAnswer(sessionId, { text }) | |
| 122 | + setAnswerText('') | |
| 123 | + await refresh() | |
| 124 | + } catch (err) { | |
| 125 | + setError(err instanceof Error ? err.message : String(err)) | |
| 126 | + } finally { | |
| 127 | + setSubmitting(false) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + async function submitAudio(blob: Blob) { | |
| 132 | + setError(null) | |
| 133 | + setSubmitting(true) | |
| 134 | + try { | |
| 135 | + await api.uploadAudio(sessionId, blob) | |
| 136 | + await refresh() | |
| 137 | + } catch (err) { | |
| 138 | + setError(err instanceof Error ? err.message : String(err)) | |
| 139 | + } finally { | |
| 140 | + setSubmitting(false) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + async function handleSubmit(event: React.FormEvent) { | |
| 145 | + event.preventDefault() | |
| 146 | + await submit(answerText) | |
| 147 | + } | |
| 148 | + | |
| 149 | + if (!session) { | |
| 150 | + return ( | |
| 151 | + <div className="screen interview-screen"> | |
| 152 | + <button type="button" className="back-link" onClick={onBack}> | |
| 153 | + ← Back to sessions | |
| 154 | + </button> | |
| 155 | + {error ? <p className="error">{error}</p> : <p>Loading…</p>} | |
| 156 | + </div> | |
| 157 | + ) | |
| 158 | + } | |
| 159 | + | |
| 160 | + return ( | |
| 161 | + <div className="screen interview-screen"> | |
| 162 | + <button type="button" className="back-link" onClick={onBack}> | |
| 163 | + ← Back to sessions | |
| 164 | + </button> | |
| 165 | + <div className="interview-header"> | |
| 166 | + <h1>{session.name}</h1> | |
| 167 | + <label className="tts-toggle"> | |
| 168 | + <input | |
| 169 | + type="checkbox" | |
| 170 | + checked={ttsEnabled} | |
| 171 | + onChange={(e) => setTtsEnabled(e.target.checked)} | |
| 172 | + /> | |
| 173 | + Read questions aloud | |
| 174 | + </label> | |
| 175 | + </div> | |
| 176 | + | |
| 177 | + <div className="interview-layout"> | |
| 178 | + <div className="interview-main"> | |
| 179 | + <QuestionCard question={latestQuestion(session)} /> | |
| 180 | + <Transcript segments={session.segments} /> | |
| 181 | + | |
| 182 | + {session.status === 'interviewing' ? ( | |
| 183 | + <> | |
| 184 | + <PushToTalkButton disabled={submitting} onRecorded={(blob) => void submitAudio(blob)} /> | |
| 185 | + <form className="answer-form" onSubmit={handleSubmit}> | |
| 186 | + <input | |
| 187 | + value={answerText} | |
| 188 | + onChange={(e) => setAnswerText(e.target.value)} | |
| 189 | + placeholder="Type your answer…" | |
| 190 | + disabled={submitting} | |
| 191 | + autoFocus | |
| 192 | + /> | |
| 193 | + <button type="submit" disabled={submitting || !answerText.trim()}> | |
| 194 | + Send | |
| 195 | + </button> | |
| 196 | + <button | |
| 197 | + type="button" | |
| 198 | + className="done-button" | |
| 199 | + disabled={submitting} | |
| 200 | + onClick={() => submit('done')} | |
| 201 | + > | |
| 202 | + Done | |
| 203 | + </button> | |
| 204 | + </form> | |
| 205 | + </> | |
| 206 | + ) : ( | |
| 207 | + <> | |
| 208 | + <p className="interview-done">Interview complete.</p> | |
| 209 | + <GeneratePanel sessionId={session.id} coverage={session.coverage} /> | |
| 210 | + <BlockerImport sessionId={session.id} onImported={refresh} /> | |
| 211 | + </> | |
| 212 | + )} | |
| 213 | + | |
| 214 | + {error && <p className="error">{error}</p>} | |
| 215 | + </div> | |
| 216 | + | |
| 217 | + <aside className="interview-sidebar"> | |
| 218 | + <h2>Coverage</h2> | |
| 219 | + <CoveragePanel coverage={session.coverage} /> | |
| 220 | + </aside> | |
| 221 | + </div> | |
| 222 | + </div> | |
| 223 | + ) | |
| 224 | +} | |
| 225 | + | |
| 226 | +function App() { | |
| 227 | + const [activeSessionId, setActiveSessionId] = useState<string | null>(null) | |
| 228 | + | |
| 229 | + if (activeSessionId) { | |
| 230 | + return <InterviewScreen sessionId={activeSessionId} onBack={() => setActiveSessionId(null)} /> | |
| 231 | + } | |
| 232 | + | |
| 233 | + return <SessionListScreen onOpen={setActiveSessionId} /> | |
| 234 | +} | |
| 235 | + | |
| 236 | +export default App |
added client/src/api.ts +68 −0
| @@ -0,0 +1,68 @@ | ||
| 1 | +import type { | |
| 2 | + AnswerRequest, | |
| 3 | + AnswerResponse, | |
| 4 | + BlockersResponse, | |
| 5 | + CreateSessionRequest, | |
| 6 | + GenerateRequest, | |
| 7 | + GenerateResponse, | |
| 8 | + Session, | |
| 9 | + SessionSummary, | |
| 10 | +} from 'shared/types' | |
| 11 | + | |
| 12 | +const BASE = '/api' | |
| 13 | + | |
| 14 | +async function readJsonOrThrow<T>(res: Response): Promise<T> { | |
| 15 | + if (!res.ok) { | |
| 16 | + const body = (await res.json().catch(() => null)) as { error?: string } | null | |
| 17 | + throw new Error(body?.error ?? `request failed with status ${res.status}`) | |
| 18 | + } | |
| 19 | + return res.json() as Promise<T> | |
| 20 | +} | |
| 21 | + | |
| 22 | +async function requestJson<T>(url: string, init?: RequestInit): Promise<T> { | |
| 23 | + const res = await fetch(url, { | |
| 24 | + ...init, | |
| 25 | + headers: { 'Content-Type': 'application/json', ...init?.headers }, | |
| 26 | + }) | |
| 27 | + return readJsonOrThrow<T>(res) | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function createSession(req: CreateSessionRequest): Promise<Session> { | |
| 31 | + return requestJson<Session>(`${BASE}/sessions`, { method: 'POST', body: JSON.stringify(req) }) | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function listSessions(): Promise<SessionSummary[]> { | |
| 35 | + return requestJson<SessionSummary[]>(`${BASE}/sessions`) | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function getSession(id: string): Promise<Session> { | |
| 39 | + return requestJson<Session>(`${BASE}/sessions/${id}`) | |
| 40 | +} | |
| 41 | + | |
| 42 | +export function submitAnswer(id: string, req: AnswerRequest): Promise<AnswerResponse> { | |
| 43 | + return requestJson<AnswerResponse>(`${BASE}/sessions/${id}/answer`, { | |
| 44 | + method: 'POST', | |
| 45 | + body: JSON.stringify(req), | |
| 46 | + }) | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function uploadAudio(id: string, audio: Blob): Promise<AnswerResponse> { | |
| 50 | + const form = new FormData() | |
| 51 | + form.append('audio', audio, 'clip.webm') | |
| 52 | + const res = await fetch(`${BASE}/sessions/${id}/audio`, { method: 'POST', body: form }) | |
| 53 | + return readJsonOrThrow<AnswerResponse>(res) | |
| 54 | +} | |
| 55 | + | |
| 56 | +export function generateSpecPack(id: string, req: GenerateRequest = {}): Promise<GenerateResponse> { | |
| 57 | + return requestJson<GenerateResponse>(`${BASE}/sessions/${id}/generate`, { | |
| 58 | + method: 'POST', | |
| 59 | + body: JSON.stringify(req), | |
| 60 | + }) | |
| 61 | +} | |
| 62 | + | |
| 63 | +export function importBlockers(id: string): Promise<BlockersResponse> { | |
| 64 | + return requestJson<BlockersResponse>(`${BASE}/sessions/${id}/blockers`, { | |
| 65 | + method: 'POST', | |
| 66 | + body: JSON.stringify({}), | |
| 67 | + }) | |
| 68 | +} |
added client/src/audio.ts +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +export interface PushToTalkRecorder { | |
| 2 | + start(): Promise<void> | |
| 3 | + stop(): Promise<Blob> | |
| 4 | +} | |
| 5 | + | |
| 6 | +export function createPushToTalkRecorder(): PushToTalkRecorder { | |
| 7 | + let mediaRecorder: MediaRecorder | null = null | |
| 8 | + let stream: MediaStream | null = null | |
| 9 | + let chunks: BlobPart[] = [] | |
| 10 | + | |
| 11 | + return { | |
| 12 | + async start() { | |
| 13 | + stream = await navigator.mediaDevices.getUserMedia({ audio: true }) | |
| 14 | + chunks = [] | |
| 15 | + mediaRecorder = new MediaRecorder(stream) | |
| 16 | + mediaRecorder.ondataavailable = (event) => { | |
| 17 | + if (event.data.size > 0) chunks.push(event.data) | |
| 18 | + } | |
| 19 | + mediaRecorder.start() | |
| 20 | + }, | |
| 21 | + | |
| 22 | + stop() { | |
| 23 | + return new Promise<Blob>((resolve, reject) => { | |
| 24 | + if (!mediaRecorder) { | |
| 25 | + reject(new Error('recording was not started')) | |
| 26 | + return | |
| 27 | + } | |
| 28 | + const recorder = mediaRecorder | |
| 29 | + const activeStream = stream | |
| 30 | + recorder.onstop = () => { | |
| 31 | + resolve(new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })) | |
| 32 | + activeStream?.getTracks().forEach((track) => track.stop()) | |
| 33 | + } | |
| 34 | + recorder.stop() | |
| 35 | + mediaRecorder = null | |
| 36 | + stream = null | |
| 37 | + }) | |
| 38 | + }, | |
| 39 | + } | |
| 40 | +} |
added client/src/components/BlockerImport.tsx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 1 | +import { useState } from 'react' | |
| 2 | +import * as api from '../api' | |
| 3 | + | |
| 4 | +interface BlockerImportProps { | |
| 5 | + sessionId: string | |
| 6 | + onImported: () => void | |
| 7 | +} | |
| 8 | + | |
| 9 | +export function BlockerImport({ sessionId, onImported }: BlockerImportProps) { | |
| 10 | + const [importing, setImporting] = useState(false) | |
| 11 | + const [message, setMessage] = useState<string | null>(null) | |
| 12 | + const [error, setError] = useState<string | null>(null) | |
| 13 | + | |
| 14 | + async function handleImport() { | |
| 15 | + setImporting(true) | |
| 16 | + setError(null) | |
| 17 | + setMessage(null) | |
| 18 | + try { | |
| 19 | + const { questions } = await api.importBlockers(sessionId) | |
| 20 | + if (questions.length === 0) { | |
| 21 | + setMessage('No blockers found in BLOCKED.md.') | |
| 22 | + } else { | |
| 23 | + setMessage(`Imported ${questions.length} blocker question(s). Resuming the interview.`) | |
| 24 | + onImported() | |
| 25 | + } | |
| 26 | + } catch (err) { | |
| 27 | + setError(err instanceof Error ? err.message : String(err)) | |
| 28 | + } finally { | |
| 29 | + setImporting(false) | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + <div className="blocker-import"> | |
| 35 | + <h2>Import blockers</h2> | |
| 36 | + <p className="muted"> | |
| 37 | + Reads BLOCKED.md from the target directory and resumes the interview with its open questions. | |
| 38 | + </p> | |
| 39 | + <button type="button" onClick={() => void handleImport()} disabled={importing}> | |
| 40 | + {importing ? 'Importing…' : 'Import BLOCKED.md'} | |
| 41 | + </button> | |
| 42 | + {message && <p>{message}</p>} | |
| 43 | + {error && <p className="error">{error}</p>} | |
| 44 | + </div> | |
| 45 | + ) | |
| 46 | +} |
added client/src/components/CoveragePanel.tsx +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +import { CATEGORY_IDS, type CategoryId, type Coverage } from 'shared/types' | |
| 2 | + | |
| 3 | +interface CoveragePanelProps { | |
| 4 | + coverage: Coverage | |
| 5 | +} | |
| 6 | + | |
| 7 | +const LABELS: Record<CategoryId, string> = { | |
| 8 | + goal: 'Goal', | |
| 9 | + users: 'Users', | |
| 10 | + 'core-flow': 'Core flow', | |
| 11 | + data: 'Data', | |
| 12 | + integrations: 'Integrations', | |
| 13 | + 'edge-cases': 'Edge cases', | |
| 14 | + constraints: 'Constraints', | |
| 15 | + 'non-goals': 'Non-goals', | |
| 16 | + verification: 'Verification', | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function CoveragePanel({ coverage }: CoveragePanelProps) { | |
| 20 | + return ( | |
| 21 | + <ul className="coverage-panel"> | |
| 22 | + {CATEGORY_IDS.map((category) => ( | |
| 23 | + <li key={category} className={`coverage-item coverage-${coverage[category]}`}> | |
| 24 | + <span className="coverage-dot" aria-hidden="true" /> | |
| 25 | + {LABELS[category]} | |
| 26 | + </li> | |
| 27 | + ))} | |
| 28 | + </ul> | |
| 29 | + ) | |
| 30 | +} |
added client/src/components/GeneratePanel.tsx +112 −0
| @@ -0,0 +1,112 @@ | ||
| 1 | +import { useState } from 'react' | |
| 2 | +import { CATEGORY_IDS, type CategoryId, type Coverage, type GenerateResponse } from 'shared/types' | |
| 3 | +import * as api from '../api' | |
| 4 | + | |
| 5 | +interface GeneratePanelProps { | |
| 6 | + sessionId: string | |
| 7 | + coverage: Coverage | |
| 8 | +} | |
| 9 | + | |
| 10 | +const LABELS: Record<CategoryId, string> = { | |
| 11 | + goal: 'Goal', | |
| 12 | + users: 'Users', | |
| 13 | + 'core-flow': 'Core flow', | |
| 14 | + data: 'Data', | |
| 15 | + integrations: 'Integrations', | |
| 16 | + 'edge-cases': 'Edge cases', | |
| 17 | + constraints: 'Constraints', | |
| 18 | + 'non-goals': 'Non-goals', | |
| 19 | + verification: 'Verification', | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) { | |
| 23 | + const [confirming, setConfirming] = useState(false) | |
| 24 | + const [generating, setGenerating] = useState(false) | |
| 25 | + const [result, setResult] = useState<GenerateResponse | null>(null) | |
| 26 | + const [error, setError] = useState<string | null>(null) | |
| 27 | + const [needsOverwrite, setNeedsOverwrite] = useState(false) | |
| 28 | + | |
| 29 | + const missingCategories = CATEGORY_IDS.filter((category) => coverage[category] === 'missing') | |
| 30 | + | |
| 31 | + async function runGenerate(overwrite: boolean) { | |
| 32 | + setGenerating(true) | |
| 33 | + setError(null) | |
| 34 | + setConfirming(false) | |
| 35 | + try { | |
| 36 | + const response = await api.generateSpecPack(sessionId, { overwrite }) | |
| 37 | + setResult(response) | |
| 38 | + setNeedsOverwrite(false) | |
| 39 | + } catch (err) { | |
| 40 | + const message = err instanceof Error ? err.message : String(err) | |
| 41 | + setError(message) | |
| 42 | + setNeedsOverwrite(message.includes('already exist')) | |
| 43 | + } finally { | |
| 44 | + setGenerating(false) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + function handleGenerateClick() { | |
| 49 | + if (missingCategories.length > 0) { | |
| 50 | + setConfirming(true) | |
| 51 | + return | |
| 52 | + } | |
| 53 | + void runGenerate(false) | |
| 54 | + } | |
| 55 | + | |
| 56 | + return ( | |
| 57 | + <div className="generate-panel"> | |
| 58 | + <h2>Generate spec pack</h2> | |
| 59 | + | |
| 60 | + {confirming ? ( | |
| 61 | + <div className="generate-confirm"> | |
| 62 | + <p> | |
| 63 | + These categories are still missing: {missingCategories.map((c) => LABELS[c]).join(', ')}. Generate | |
| 64 | + anyway? | |
| 65 | + </p> | |
| 66 | + <button type="button" onClick={() => void runGenerate(false)} disabled={generating}> | |
| 67 | + Generate anyway | |
| 68 | + </button> | |
| 69 | + <button type="button" onClick={() => setConfirming(false)} disabled={generating}> | |
| 70 | + Cancel | |
| 71 | + </button> | |
| 72 | + </div> | |
| 73 | + ) : ( | |
| 74 | + <button type="button" onClick={handleGenerateClick} disabled={generating}> | |
| 75 | + {generating ? 'Generating…' : 'Generate spec pack'} | |
| 76 | + </button> | |
| 77 | + )} | |
| 78 | + | |
| 79 | + {needsOverwrite && ( | |
| 80 | + <div className="generate-overwrite"> | |
| 81 | + <p>A spec pack already exists in the target directory.</p> | |
| 82 | + <button type="button" onClick={() => void runGenerate(true)} disabled={generating}> | |
| 83 | + Overwrite and regenerate | |
| 84 | + </button> | |
| 85 | + </div> | |
| 86 | + )} | |
| 87 | + | |
| 88 | + {error && <p className="error">{error}</p>} | |
| 89 | + | |
| 90 | + {result && ( | |
| 91 | + <div className="generate-result"> | |
| 92 | + <p>Wrote {result.files.length} files:</p> | |
| 93 | + <ul> | |
| 94 | + {result.files.map((file) => ( | |
| 95 | + <li key={file}>{file}</li> | |
| 96 | + ))} | |
| 97 | + </ul> | |
| 98 | + {result.warnings.length > 0 && ( | |
| 99 | + <div className="generate-warnings"> | |
| 100 | + <p>Warnings:</p> | |
| 101 | + <ul> | |
| 102 | + {result.warnings.map((warning) => ( | |
| 103 | + <li key={warning}>{warning}</li> | |
| 104 | + ))} | |
| 105 | + </ul> | |
| 106 | + </div> | |
| 107 | + )} | |
| 108 | + </div> | |
| 109 | + )} | |
| 110 | + </div> | |
| 111 | + ) | |
| 112 | +} |
added client/src/components/PushToTalkButton.tsx +83 −0
| @@ -0,0 +1,83 @@ | ||
| 1 | +import { useCallback, useEffect, useRef, useState } from 'react' | |
| 2 | +import { createPushToTalkRecorder } from '../audio' | |
| 3 | + | |
| 4 | +interface PushToTalkButtonProps { | |
| 5 | + disabled?: boolean | |
| 6 | + onRecorded: (blob: Blob) => void | |
| 7 | +} | |
| 8 | + | |
| 9 | +function isTypingTarget(target: EventTarget | null): boolean { | |
| 10 | + if (!(target instanceof HTMLElement)) return false | |
| 11 | + return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps) { | |
| 15 | + const [recording, setRecording] = useState(false) | |
| 16 | + const [error, setError] = useState<string | null>(null) | |
| 17 | + const recorderRef = useRef(createPushToTalkRecorder()) | |
| 18 | + const activeRef = useRef(false) | |
| 19 | + | |
| 20 | + const startRecording = useCallback(async () => { | |
| 21 | + if (disabled || activeRef.current) return | |
| 22 | + activeRef.current = true | |
| 23 | + setError(null) | |
| 24 | + try { | |
| 25 | + await recorderRef.current.start() | |
| 26 | + setRecording(true) | |
| 27 | + } catch (err) { | |
| 28 | + activeRef.current = false | |
| 29 | + setError(err instanceof Error ? err.message : 'could not access the microphone') | |
| 30 | + } | |
| 31 | + }, [disabled]) | |
| 32 | + | |
| 33 | + const stopRecording = useCallback(async () => { | |
| 34 | + if (!activeRef.current) return | |
| 35 | + activeRef.current = false | |
| 36 | + setRecording(false) | |
| 37 | + try { | |
| 38 | + const blob = await recorderRef.current.stop() | |
| 39 | + onRecorded(blob) | |
| 40 | + } catch (err) { | |
| 41 | + setError(err instanceof Error ? err.message : 'recording failed') | |
| 42 | + } | |
| 43 | + }, [onRecorded]) | |
| 44 | + | |
| 45 | + useEffect(() => { | |
| 46 | + function handleKeyDown(event: KeyboardEvent) { | |
| 47 | + if (event.code !== 'Space' || event.repeat || isTypingTarget(event.target)) return | |
| 48 | + event.preventDefault() | |
| 49 | + void startRecording() | |
| 50 | + } | |
| 51 | + | |
| 52 | + function handleKeyUp(event: KeyboardEvent) { | |
| 53 | + if (event.code !== 'Space' || isTypingTarget(event.target)) return | |
| 54 | + event.preventDefault() | |
| 55 | + void stopRecording() | |
| 56 | + } | |
| 57 | + | |
| 58 | + window.addEventListener('keydown', handleKeyDown) | |
| 59 | + window.addEventListener('keyup', handleKeyUp) | |
| 60 | + return () => { | |
| 61 | + window.removeEventListener('keydown', handleKeyDown) | |
| 62 | + window.removeEventListener('keyup', handleKeyUp) | |
| 63 | + } | |
| 64 | + }, [startRecording, stopRecording]) | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div className="push-to-talk"> | |
| 68 | + <button | |
| 69 | + type="button" | |
| 70 | + className={`record-button ${recording ? 'recording' : ''}`} | |
| 71 | + disabled={disabled} | |
| 72 | + onMouseDown={() => void startRecording()} | |
| 73 | + onMouseUp={() => void stopRecording()} | |
| 74 | + onMouseLeave={() => { | |
| 75 | + if (recording) void stopRecording() | |
| 76 | + }} | |
| 77 | + > | |
| 78 | + {recording ? 'Recording… release to send' : 'Hold to talk (or press space)'} | |
| 79 | + </button> | |
| 80 | + {error && <p className="error">{error}</p>} | |
| 81 | + </div> | |
| 82 | + ) | |
| 83 | +} |
added client/src/components/QuestionCard.tsx +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +interface QuestionCardProps { | |
| 2 | + question: string | null | |
| 3 | +} | |
| 4 | + | |
| 5 | +export function QuestionCard({ question }: QuestionCardProps) { | |
| 6 | + if (!question) return null | |
| 7 | + | |
| 8 | + return ( | |
| 9 | + <div className="question-card"> | |
| 10 | + <span className="question-label">Interviewer asks</span> | |
| 11 | + <p className="question-text">{question}</p> | |
| 12 | + </div> | |
| 13 | + ) | |
| 14 | +} |
added client/src/components/Transcript.tsx +22 −0
| @@ -0,0 +1,22 @@ | ||
| 1 | +import type { Segment } from 'shared/types' | |
| 2 | + | |
| 3 | +interface TranscriptProps { | |
| 4 | + segments: Segment[] | |
| 5 | +} | |
| 6 | + | |
| 7 | +export function Transcript({ segments }: TranscriptProps) { | |
| 8 | + if (segments.length === 0) { | |
| 9 | + return <p className="transcript-empty">No answers yet. Say or type what you want to build.</p> | |
| 10 | + } | |
| 11 | + | |
| 12 | + return ( | |
| 13 | + <div className="transcript"> | |
| 14 | + {segments.map((segment) => ( | |
| 15 | + <div key={segment.id} className={`segment segment-${segment.speaker}`}> | |
| 16 | + <span className="segment-speaker">{segment.speaker === 'user' ? 'You' : 'Interviewer'}</span> | |
| 17 | + <p className="segment-text">{segment.text}</p> | |
| 18 | + </div> | |
| 19 | + ))} | |
| 20 | + </div> | |
| 21 | + ) | |
| 22 | +} |
added client/src/index.css +50 −0
| @@ -0,0 +1,50 @@ | ||
| 1 | +:root { | |
| 2 | + --text: #3c3a41; | |
| 3 | + --text-h: #08060d; | |
| 4 | + --bg: #fff; | |
| 5 | + --bg-alt: #f6f5f8; | |
| 6 | + --border: #e5e4e7; | |
| 7 | + --accent: #7c3aed; | |
| 8 | + --accent-bg: rgba(124, 58, 237, 0.1); | |
| 9 | + --danger: #b3261e; | |
| 10 | + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; | |
| 11 | + | |
| 12 | + font: 16px/145% var(--sans); | |
| 13 | + color-scheme: light dark; | |
| 14 | + color: var(--text); | |
| 15 | + background: var(--bg); | |
| 16 | + -webkit-font-smoothing: antialiased; | |
| 17 | +} | |
| 18 | + | |
| 19 | +@media (prefers-color-scheme: dark) { | |
| 20 | + :root { | |
| 21 | + --text: #d1d0d6; | |
| 22 | + --text-h: #f3f4f6; | |
| 23 | + --bg: #16171d; | |
| 24 | + --bg-alt: #1c1d24; | |
| 25 | + --border: #2e303a; | |
| 26 | + --accent: #c084fc; | |
| 27 | + --accent-bg: rgba(192, 132, 252, 0.15); | |
| 28 | + --danger: #f2a29c; | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +* { | |
| 33 | + box-sizing: border-box; | |
| 34 | +} | |
| 35 | + | |
| 36 | +body { | |
| 37 | + margin: 0; | |
| 38 | +} | |
| 39 | + | |
| 40 | +h1, | |
| 41 | +h2 { | |
| 42 | + font-family: var(--sans); | |
| 43 | + font-weight: 600; | |
| 44 | + color: var(--text-h); | |
| 45 | + margin: 0 0 8px; | |
| 46 | +} | |
| 47 | + | |
| 48 | +p { | |
| 49 | + margin: 0; | |
| 50 | +} |
added client/src/main.tsx +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +import { StrictMode } from 'react' | |
| 2 | +import { createRoot } from 'react-dom/client' | |
| 3 | +import './index.css' | |
| 4 | +import App from './App.tsx' | |
| 5 | + | |
| 6 | +createRoot(document.getElementById('root')!).render( | |
| 7 | + <StrictMode> | |
| 8 | + <App /> | |
| 9 | + </StrictMode>, | |
| 10 | +) |
added client/src/tts.ts +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +export function speak(text: string): void { | |
| 2 | + if (!('speechSynthesis' in window)) return | |
| 3 | + window.speechSynthesis.cancel() | |
| 4 | + window.speechSynthesis.speak(new SpeechSynthesisUtterance(text)) | |
| 5 | +} | |
| 6 | + | |
| 7 | +export function stopSpeaking(): void { | |
| 8 | + if ('speechSynthesis' in window) window.speechSynthesis.cancel() | |
| 9 | +} |
added client/tsconfig.app.json +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | |
| 4 | + "target": "es2023", | |
| 5 | + "lib": ["ES2023", "DOM"], | |
| 6 | + "module": "esnext", | |
| 7 | + "types": ["vite/client"], | |
| 8 | + "allowArbitraryExtensions": true, | |
| 9 | + "skipLibCheck": true, | |
| 10 | + "strict": true, | |
| 11 | + "baseUrl": "..", | |
| 12 | + "paths": { "shared/*": ["shared/*"] }, | |
| 13 | + | |
| 14 | + /* Bundler mode */ | |
| 15 | + "moduleResolution": "bundler", | |
| 16 | + "allowImportingTsExtensions": true, | |
| 17 | + "verbatimModuleSyntax": true, | |
| 18 | + "moduleDetection": "force", | |
| 19 | + "noEmit": true, | |
| 20 | + "jsx": "react-jsx", | |
| 21 | + | |
| 22 | + /* Linting */ | |
| 23 | + "noUnusedLocals": true, | |
| 24 | + "noUnusedParameters": true, | |
| 25 | + "noFallthroughCasesInSwitch": true | |
| 26 | + }, | |
| 27 | + "include": ["src", "../shared/**/*.ts"], | |
| 28 | + "exclude": ["../shared/**/*.test.ts"] | |
| 29 | +} |
added client/tsconfig.json +4 −0
| @@ -0,0 +1,4 @@ | ||
| 1 | +{ | |
| 2 | + "files": [], | |
| 3 | + "references": [{ "path": "./tsconfig.app.json" }] | |
| 4 | +} |
added client/vite.config.ts +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +import { fileURLToPath } from 'node:url' | |
| 2 | +import { defineConfig } from 'vite' | |
| 3 | +import react from '@vitejs/plugin-react' | |
| 4 | + | |
| 5 | +const clientDir = fileURLToPath(new URL('.', import.meta.url)) | |
| 6 | +const sharedDir = fileURLToPath(new URL('../shared', import.meta.url)) | |
| 7 | + | |
| 8 | +export default defineConfig({ | |
| 9 | + root: clientDir, | |
| 10 | + plugins: [react()], | |
| 11 | + resolve: { | |
| 12 | + alias: { | |
| 13 | + shared: sharedDir, | |
| 14 | + }, | |
| 15 | + }, | |
| 16 | + server: { | |
| 17 | + fs: { allow: ['..'] }, | |
| 18 | + proxy: { | |
| 19 | + '/api': 'http://localhost:3001', | |
| 20 | + }, | |
| 21 | + }, | |
| 22 | + build: { | |
| 23 | + outDir: '../dist/client', | |
| 24 | + emptyOutDir: true, | |
| 25 | + }, | |
| 26 | +}) |
added package-lock.json +4026 −0
Line changes are not available for this file.
added package.json +37 −0
| @@ -0,0 +1,37 @@ | ||
| 1 | +{ | |
| 2 | + "name": "voicetask", | |
| 3 | + "private": true, | |
| 4 | + "version": "0.1.0", | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "dev": "concurrently -k -n server,client -c blue,green \"npm:dev:server\" \"npm:dev:client\"", | |
| 8 | + "dev:server": "tsx watch server/index.ts", | |
| 9 | + "dev:client": "vite --config client/vite.config.ts", | |
| 10 | + "build": "npm run build:client && npm run build:server", | |
| 11 | + "build:client": "vite build --config client/vite.config.ts", | |
| 12 | + "build:server": "tsc -p server/tsconfig.json", | |
| 13 | + "typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p client/tsconfig.app.json --noEmit && tsc -p shared/tsconfig.json --noEmit", | |
| 14 | + "test": "vitest run" | |
| 15 | + }, | |
| 16 | + "dependencies": { | |
| 17 | + "@anthropic-ai/sdk": "^0.71.0", | |
| 18 | + "@fastify/cors": "^10.0.2", | |
| 19 | + "@fastify/multipart": "^9.2.1", | |
| 20 | + "@fastify/static": "^8.2.0", | |
| 21 | + "fastify": "^5.6.1", | |
| 22 | + "react": "^19.2.7", | |
| 23 | + "react-dom": "^19.2.7", | |
| 24 | + "zod": "^3.25.76" | |
| 25 | + }, | |
| 26 | + "devDependencies": { | |
| 27 | + "@types/node": "^24.13.2", | |
| 28 | + "@types/react": "^19.2.17", | |
| 29 | + "@types/react-dom": "^19.2.3", | |
| 30 | + "@vitejs/plugin-react": "^6.0.3", | |
| 31 | + "concurrently": "^9.2.1", | |
| 32 | + "tsx": "^4.20.6", | |
| 33 | + "typescript": "~5.9.3", | |
| 34 | + "vite": "^8.1.1", | |
| 35 | + "vitest": "^3.2.4" | |
| 36 | + } | |
| 37 | +} |
added server/app.ts +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +import cors from '@fastify/cors' | |
| 2 | +import multipart from '@fastify/multipart' | |
| 3 | +import Fastify, { type FastifyInstance } from 'fastify' | |
| 4 | +import { createInterviewLlm, createSttProvider } from './providers/factory' | |
| 5 | +import type { InterviewLlm, SttProvider } from './providers/types' | |
| 6 | +import { registerAudioRoutes } from './routes/audio' | |
| 7 | +import { registerBlockerRoutes } from './routes/blockers' | |
| 8 | +import { registerGenerateRoutes } from './routes/generate' | |
| 9 | +import { registerSessionRoutes } from './routes/sessions' | |
| 10 | +import { createDefaultSessionStore, SessionStore } from './store/sessionStore' | |
| 11 | + | |
| 12 | +export interface AppDeps { | |
| 13 | + store: SessionStore | |
| 14 | + llm: InterviewLlm | |
| 15 | + stt: SttProvider | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance { | |
| 19 | + const resolved: AppDeps = { | |
| 20 | + store: deps.store ?? createDefaultSessionStore(), | |
| 21 | + llm: deps.llm ?? createInterviewLlm(), | |
| 22 | + stt: deps.stt ?? createSttProvider(), | |
| 23 | + } | |
| 24 | + | |
| 25 | + const app = Fastify({ logger: false }) | |
| 26 | + void app.register(cors, { origin: true }) | |
| 27 | + void app.register(multipart) | |
| 28 | + | |
| 29 | + app.get('/api/health', async () => ({ ok: true })) | |
| 30 | + registerSessionRoutes(app, resolved) | |
| 31 | + registerAudioRoutes(app, resolved) | |
| 32 | + registerGenerateRoutes(app, resolved) | |
| 33 | + registerBlockerRoutes(app, resolved) | |
| 34 | + | |
| 35 | + return app | |
| 36 | +} |
added server/blockers/parse.test.ts +63 −0
| @@ -0,0 +1,63 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { parseBlockedFile } from './parse' | |
| 3 | + | |
| 4 | +const TEMPLATE_EMPTY = `# BLOCKED | |
| 5 | + | |
| 6 | +Entries the coding agent could not resolve without a product decision. The agent appends entries; the human answers by editing the entry and adding an \`ANSWER:\` line, then relaunches per spec/HANDOFF.md. | |
| 7 | + | |
| 8 | +Entry format: | |
| 9 | + | |
| 10 | +\`\`\` | |
| 11 | +## B<n>: <one-line summary> | |
| 12 | +- Task: T<n> | |
| 13 | +- Question: <what decision is needed and why the spec does not answer it> | |
| 14 | +- Options considered: <a>, <b> | |
| 15 | +- Continued with: <what the agent did instead, or "skipped task"> | |
| 16 | +\`\`\` | |
| 17 | + | |
| 18 | +No entries yet. | |
| 19 | +` | |
| 20 | + | |
| 21 | +const TEMPLATE_WITH_ENTRIES = `# BLOCKED | |
| 22 | + | |
| 23 | +Entries the coding agent could not resolve without a product decision. | |
| 24 | + | |
| 25 | +## B1: which auth provider to use | |
| 26 | +- Task: T4 | |
| 27 | +- Question: Should sign-in use email/password or an OAuth provider? | |
| 28 | +- Options considered: email/password, Google OAuth | |
| 29 | +- Continued with: implemented email/password, left OAuth for later | |
| 30 | + | |
| 31 | +## B2: rate limit thresholds | |
| 32 | +- Task: T6 | |
| 33 | +- Question: What is the max requests per minute per user? | |
| 34 | +- Options considered: 60, 120 | |
| 35 | +- Continued with: skipped task | |
| 36 | +` | |
| 37 | + | |
| 38 | +describe('parseBlockedFile', () => { | |
| 39 | + it('returns no entries for the template placeholder text', () => { | |
| 40 | + expect(parseBlockedFile(TEMPLATE_EMPTY)).toEqual([]) | |
| 41 | + }) | |
| 42 | + | |
| 43 | + it('returns no entries for an empty string', () => { | |
| 44 | + expect(parseBlockedFile('')).toEqual([]) | |
| 45 | + }) | |
| 46 | + | |
| 47 | + it('parses each blocker entry into its fields', () => { | |
| 48 | + const entries = parseBlockedFile(TEMPLATE_WITH_ENTRIES) | |
| 49 | + expect(entries).toHaveLength(2) | |
| 50 | + expect(entries[0]).toEqual({ | |
| 51 | + id: 'B1', | |
| 52 | + summary: 'which auth provider to use', | |
| 53 | + task: 'T4', | |
| 54 | + question: 'Should sign-in use email/password or an OAuth provider?', | |
| 55 | + optionsConsidered: 'email/password, Google OAuth', | |
| 56 | + continuedWith: 'implemented email/password, left OAuth for later', | |
| 57 | + }) | |
| 58 | + expect(entries[1]).toMatchObject({ | |
| 59 | + id: 'B2', | |
| 60 | + question: 'What is the max requests per minute per user?', | |
| 61 | + }) | |
| 62 | + }) | |
| 63 | +}) |
added server/blockers/parse.ts +50 −0
| @@ -0,0 +1,50 @@ | ||
| 1 | +export interface BlockerEntry { | |
| 2 | + id: string | |
| 3 | + summary: string | |
| 4 | + task: string | null | |
| 5 | + question: string | |
| 6 | + optionsConsidered: string | null | |
| 7 | + continuedWith: string | null | |
| 8 | +} | |
| 9 | + | |
| 10 | +const ENTRY_HEADER = /^(B\d+):\s*(.*)$/ | |
| 11 | +const FIELD_PATTERNS: Array<{ key: keyof Omit<BlockerEntry, 'id' | 'summary'>; pattern: RegExp }> = [ | |
| 12 | + { key: 'task', pattern: /^-\s*Task:\s*(.*)$/ }, | |
| 13 | + { key: 'question', pattern: /^-\s*Question:\s*(.*)$/ }, | |
| 14 | + { key: 'optionsConsidered', pattern: /^-\s*Options considered:\s*(.*)$/ }, | |
| 15 | + { key: 'continuedWith', pattern: /^-\s*Continued with:\s*(.*)$/ }, | |
| 16 | +] | |
| 17 | + | |
| 18 | +export function parseBlockedFile(content: string): BlockerEntry[] { | |
| 19 | + const blocks = content.split(/^##\s+/m).slice(1) | |
| 20 | + const entries: BlockerEntry[] = [] | |
| 21 | + | |
| 22 | + for (const block of blocks) { | |
| 23 | + const lines = block.split('\n') | |
| 24 | + const headerMatch = lines[0]?.match(ENTRY_HEADER) | |
| 25 | + if (!headerMatch) continue | |
| 26 | + | |
| 27 | + const entry: BlockerEntry = { | |
| 28 | + id: headerMatch[1], | |
| 29 | + summary: headerMatch[2].trim(), | |
| 30 | + task: null, | |
| 31 | + question: '', | |
| 32 | + optionsConsidered: null, | |
| 33 | + continuedWith: null, | |
| 34 | + } | |
| 35 | + | |
| 36 | + for (const line of lines.slice(1)) { | |
| 37 | + for (const { key, pattern } of FIELD_PATTERNS) { | |
| 38 | + const match = line.match(pattern) | |
| 39 | + if (match) { | |
| 40 | + entry[key] = match[1].trim() | |
| 41 | + break | |
| 42 | + } | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + entries.push(entry) | |
| 47 | + } | |
| 48 | + | |
| 49 | + return entries | |
| 50 | +} |
added server/engine/coverage.test.ts +37 −0
| @@ -0,0 +1,37 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { CATEGORY_IDS, initialCoverage, type Coverage } from '../../shared/types' | |
| 3 | +import { isFullyCovered, weakestCategory } from './coverage' | |
| 4 | + | |
| 5 | +function allClear(): Coverage { | |
| 6 | + return Object.fromEntries(CATEGORY_IDS.map((category) => [category, 'clear'])) as Coverage | |
| 7 | +} | |
| 8 | + | |
| 9 | +describe('weakestCategory', () => { | |
| 10 | + it('returns the first category in fixed order when all are missing', () => { | |
| 11 | + expect(weakestCategory(initialCoverage())).toBe(CATEGORY_IDS[0]) | |
| 12 | + }) | |
| 13 | + | |
| 14 | + it('prefers missing over partial, and partial over clear, tie-broken by fixed order', () => { | |
| 15 | + const coverage: Coverage = { | |
| 16 | + ...initialCoverage(), | |
| 17 | + goal: 'clear', | |
| 18 | + users: 'partial', | |
| 19 | + 'core-flow': 'missing', | |
| 20 | + } | |
| 21 | + expect(weakestCategory(coverage)).toBe('core-flow') | |
| 22 | + }) | |
| 23 | + | |
| 24 | + it('returns null when every category is clear', () => { | |
| 25 | + expect(weakestCategory(allClear())).toBeNull() | |
| 26 | + }) | |
| 27 | +}) | |
| 28 | + | |
| 29 | +describe('isFullyCovered', () => { | |
| 30 | + it('is false until every category is clear', () => { | |
| 31 | + expect(isFullyCovered(initialCoverage())).toBe(false) | |
| 32 | + }) | |
| 33 | + | |
| 34 | + it('is true once every category is clear', () => { | |
| 35 | + expect(isFullyCovered(allClear())).toBe(true) | |
| 36 | + }) | |
| 37 | +}) |
added server/engine/coverage.ts +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +import { CATEGORY_IDS, type CategoryId, type Coverage, type CoverageLevel } from '../../shared/types' | |
| 2 | + | |
| 3 | +const LEVEL_PRIORITY: Record<CoverageLevel, number> = { missing: 0, partial: 1, clear: 2 } | |
| 4 | + | |
| 5 | +export function weakestCategory(coverage: Coverage): CategoryId | null { | |
| 6 | + let weakest: CategoryId | null = null | |
| 7 | + for (const category of CATEGORY_IDS) { | |
| 8 | + if (coverage[category] === 'clear') continue | |
| 9 | + if (weakest === null || LEVEL_PRIORITY[coverage[category]] < LEVEL_PRIORITY[coverage[weakest]]) { | |
| 10 | + weakest = category | |
| 11 | + } | |
| 12 | + } | |
| 13 | + return weakest | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function isFullyCovered(coverage: Coverage): boolean { | |
| 17 | + return CATEGORY_IDS.every((category) => coverage[category] === 'clear') | |
| 18 | +} |
added server/engine/interview.test.ts +91 −0
| @@ -0,0 +1,91 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { initialCoverage, type InterviewTurn, type Segment, type Session } from '../../shared/types' | |
| 3 | +import type { InterviewContext, InterviewLlm } from '../providers/types' | |
| 4 | +import { runInterviewTurn } from './interview' | |
| 5 | +import { RECENT_SEGMENT_WINDOW } from './summary' | |
| 6 | + | |
| 7 | +function makeSession(segments: Segment[]): Session { | |
| 8 | + return { | |
| 9 | + id: 'sess-1', | |
| 10 | + name: 'proj', | |
| 11 | + targetDir: '/tmp/proj', | |
| 12 | + createdAt: new Date().toISOString(), | |
| 13 | + segments, | |
| 14 | + coverage: initialCoverage(), | |
| 15 | + summary: 'earlier summary', | |
| 16 | + status: 'interviewing', | |
| 17 | + openBlockers: [], | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +function segment(id: string, speaker: Segment['speaker'], text: string): Segment { | |
| 22 | + return { id, ts: new Date().toISOString(), speaker, text } | |
| 23 | +} | |
| 24 | + | |
| 25 | +function stubLlm(turn: InterviewTurn, capture?: (ctx: InterviewContext) => void): InterviewLlm { | |
| 26 | + return { | |
| 27 | + async nextTurn(context) { | |
| 28 | + capture?.(context) | |
| 29 | + return turn | |
| 30 | + }, | |
| 31 | + async generateFile() { | |
| 32 | + throw new Error('not used in this test') | |
| 33 | + }, | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +describe('runInterviewTurn', () => { | |
| 38 | + it('short-circuits with done:true when the last user answer is exactly "done"', async () => { | |
| 39 | + const session = makeSession([segment('S1', 'user', 'Done')]) | |
| 40 | + const llm = stubLlm({ | |
| 41 | + coverage: initialCoverage(), | |
| 42 | + nextQuestion: 'should not be used', | |
| 43 | + contradiction: null, | |
| 44 | + done: false, | |
| 45 | + summaryUpdate: null, | |
| 46 | + }) | |
| 47 | + const turn = await runInterviewTurn(llm, session) | |
| 48 | + expect(turn.done).toBe(true) | |
| 49 | + expect(turn.coverage).toEqual(session.coverage) | |
| 50 | + }) | |
| 51 | + | |
| 52 | + it('passes a contradiction from the LLM straight through unchanged', async () => { | |
| 53 | + const session = makeSession([segment('S1', 'user', 'we only support web')]) | |
| 54 | + const contradiction = { segmentIds: ['S1', 'S3'], description: 'web-only vs mobile-only' } | |
| 55 | + const llm = stubLlm({ | |
| 56 | + coverage: initialCoverage(), | |
| 57 | + nextQuestion: 'Which is it, web or mobile?', | |
| 58 | + contradiction, | |
| 59 | + done: false, | |
| 60 | + summaryUpdate: null, | |
| 61 | + }) | |
| 62 | + const turn = await runInterviewTurn(llm, session) | |
| 63 | + expect(turn.contradiction).toEqual(contradiction) | |
| 64 | + }) | |
| 65 | + | |
| 66 | + it('sends only the last 40 segments plus the running summary once the transcript grows past the window', async () => { | |
| 67 | + const segments = Array.from({ length: RECENT_SEGMENT_WINDOW + 7 }, (_, i) => | |
| 68 | + segment(`S${i + 1}`, i % 2 === 0 ? 'user' : 'interviewer', `text ${i + 1}`), | |
| 69 | + ) | |
| 70 | + const session = makeSession(segments) | |
| 71 | + let seenContext: InterviewContext | undefined | |
| 72 | + const llm = stubLlm( | |
| 73 | + { | |
| 74 | + coverage: initialCoverage(), | |
| 75 | + nextQuestion: 'next?', | |
| 76 | + contradiction: null, | |
| 77 | + done: false, | |
| 78 | + summaryUpdate: null, | |
| 79 | + }, | |
| 80 | + (ctx) => { | |
| 81 | + seenContext = ctx | |
| 82 | + }, | |
| 83 | + ) | |
| 84 | + | |
| 85 | + await runInterviewTurn(llm, session) | |
| 86 | + | |
| 87 | + expect(seenContext?.segments).toHaveLength(RECENT_SEGMENT_WINDOW) | |
| 88 | + expect(seenContext?.segments[0].id).toBe('S8') | |
| 89 | + expect(seenContext?.summary).toBe('earlier summary') | |
| 90 | + }) | |
| 91 | +}) |
added server/engine/interview.ts +28 −0
| @@ -0,0 +1,28 @@ | ||
| 1 | +import type { InterviewTurn, Session } from '../../shared/types' | |
| 2 | +import type { InterviewLlm } from '../providers/types' | |
| 3 | +import { splitTranscriptWindow } from './summary' | |
| 4 | + | |
| 5 | +export function isDoneAnswer(text: string): boolean { | |
| 6 | + return text.trim().toLowerCase() === 'done' | |
| 7 | +} | |
| 8 | + | |
| 9 | +export async function runInterviewTurn(llm: InterviewLlm, session: Session): Promise<InterviewTurn> { | |
| 10 | + const lastSegment = session.segments[session.segments.length - 1] | |
| 11 | + if (lastSegment?.speaker === 'user' && isDoneAnswer(lastSegment.text)) { | |
| 12 | + return { | |
| 13 | + coverage: session.coverage, | |
| 14 | + nextQuestion: 'Interview complete.', | |
| 15 | + contradiction: null, | |
| 16 | + done: true, | |
| 17 | + summaryUpdate: null, | |
| 18 | + } | |
| 19 | + } | |
| 20 | + | |
| 21 | + const { recentSegments } = splitTranscriptWindow(session.segments) | |
| 22 | + | |
| 23 | + return llm.nextTurn({ | |
| 24 | + summary: session.summary, | |
| 25 | + segments: recentSegments, | |
| 26 | + coverage: session.coverage, | |
| 27 | + }) | |
| 28 | +} |
added server/engine/summary.test.ts +31 −0
| @@ -0,0 +1,31 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import type { Segment } from '../../shared/types' | |
| 3 | +import { RECENT_SEGMENT_WINDOW, splitTranscriptWindow } from './summary' | |
| 4 | + | |
| 5 | +function makeSegments(count: number): Segment[] { | |
| 6 | + return Array.from({ length: count }, (_, i) => ({ | |
| 7 | + id: `S${i + 1}`, | |
| 8 | + ts: new Date().toISOString(), | |
| 9 | + speaker: i % 2 === 0 ? 'user' : 'interviewer', | |
| 10 | + text: `segment ${i + 1}`, | |
| 11 | + })) | |
| 12 | +} | |
| 13 | + | |
| 14 | +describe('splitTranscriptWindow', () => { | |
| 15 | + it('keeps everything in the recent window when at or under the limit', () => { | |
| 16 | + const segments = makeSegments(RECENT_SEGMENT_WINDOW) | |
| 17 | + const { recentSegments, olderSegments } = splitTranscriptWindow(segments) | |
| 18 | + expect(recentSegments).toEqual(segments) | |
| 19 | + expect(olderSegments).toEqual([]) | |
| 20 | + }) | |
| 21 | + | |
| 22 | + it('keeps only the last 40 segments and puts the rest in olderSegments', () => { | |
| 23 | + const segments = makeSegments(RECENT_SEGMENT_WINDOW + 5) | |
| 24 | + const { recentSegments, olderSegments } = splitTranscriptWindow(segments) | |
| 25 | + expect(recentSegments).toHaveLength(RECENT_SEGMENT_WINDOW) | |
| 26 | + expect(recentSegments[0].id).toBe('S6') | |
| 27 | + expect(recentSegments[recentSegments.length - 1].id).toBe(`S${segments.length}`) | |
| 28 | + expect(olderSegments).toHaveLength(5) | |
| 29 | + expect(olderSegments.map((s) => s.id)).toEqual(['S1', 'S2', 'S3', 'S4', 'S5']) | |
| 30 | + }) | |
| 31 | +}) |
added server/engine/summary.ts +19 −0
| @@ -0,0 +1,19 @@ | ||
| 1 | +import type { Segment } from '../../shared/types' | |
| 2 | + | |
| 3 | +export const RECENT_SEGMENT_WINDOW = 40 | |
| 4 | + | |
| 5 | +export interface TranscriptWindow { | |
| 6 | + recentSegments: Segment[] | |
| 7 | + olderSegments: Segment[] | |
| 8 | +} | |
| 9 | + | |
| 10 | +export function splitTranscriptWindow(segments: Segment[]): TranscriptWindow { | |
| 11 | + if (segments.length <= RECENT_SEGMENT_WINDOW) { | |
| 12 | + return { recentSegments: segments, olderSegments: [] } | |
| 13 | + } | |
| 14 | + const splitIndex = segments.length - RECENT_SEGMENT_WINDOW | |
| 15 | + return { | |
| 16 | + recentSegments: segments.slice(splitIndex), | |
| 17 | + olderSegments: segments.slice(0, splitIndex), | |
| 18 | + } | |
| 19 | +} |
added server/engine/turn.ts +49 −0
| @@ -0,0 +1,49 @@ | ||
| 1 | +import type { AnswerResponse, InterviewTurn } from '../../shared/types' | |
| 2 | +import type { InterviewLlm } from '../providers/types' | |
| 3 | +import type { SessionStore } from '../store/sessionStore' | |
| 4 | +import { isDoneAnswer, runInterviewTurn } from './interview' | |
| 5 | + | |
| 6 | +export class SessionNotFoundError extends Error { | |
| 7 | + constructor(sessionId: string) { | |
| 8 | + super(`session not found: ${sessionId}`) | |
| 9 | + } | |
| 10 | +} | |
| 11 | + | |
| 12 | +export async function submitAnswer( | |
| 13 | + store: SessionStore, | |
| 14 | + llm: InterviewLlm, | |
| 15 | + sessionId: string, | |
| 16 | + text: string, | |
| 17 | +): Promise<AnswerResponse> { | |
| 18 | + const existing = await store.getSession(sessionId) | |
| 19 | + if (!existing) throw new SessionNotFoundError(sessionId) | |
| 20 | + | |
| 21 | + const { session: afterAnswer, segment } = await store.appendSegment(sessionId, 'user', text) | |
| 22 | + | |
| 23 | + let turn: InterviewTurn | |
| 24 | + let remainingBlockers = afterAnswer.openBlockers | |
| 25 | + | |
| 26 | + if (!isDoneAnswer(text) && afterAnswer.openBlockers.length > 0) { | |
| 27 | + const [nextQuestion, ...rest] = afterAnswer.openBlockers | |
| 28 | + remainingBlockers = rest | |
| 29 | + turn = { | |
| 30 | + coverage: afterAnswer.coverage, | |
| 31 | + nextQuestion, | |
| 32 | + contradiction: null, | |
| 33 | + done: false, | |
| 34 | + summaryUpdate: null, | |
| 35 | + } | |
| 36 | + } else { | |
| 37 | + turn = await runInterviewTurn(llm, afterAnswer) | |
| 38 | + } | |
| 39 | + | |
| 40 | + await store.appendSegment(sessionId, 'interviewer', turn.nextQuestion) | |
| 41 | + await store.updateTurn(sessionId, { | |
| 42 | + coverage: turn.coverage, | |
| 43 | + summary: turn.summaryUpdate ?? afterAnswer.summary, | |
| 44 | + status: turn.done ? 'done' : 'interviewing', | |
| 45 | + openBlockers: remainingBlockers, | |
| 46 | + }) | |
| 47 | + | |
| 48 | + return { segment, turn } | |
| 49 | +} |
added server/generator/generate.test.ts +93 −0
| @@ -0,0 +1,93 @@ | ||
| 1 | +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' | |
| 2 | +import { tmpdir } from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 5 | +import { initialCoverage, type Session } from '../../shared/types' | |
| 6 | +import { createLlmMock } from '../providers/llmMock' | |
| 7 | +import { GenerateFilesExistError, generateSpecPack } from './generate' | |
| 8 | + | |
| 9 | +function makeSession(targetDir: string): Session { | |
| 10 | + return { | |
| 11 | + id: 'sess-1', | |
| 12 | + name: 'my project', | |
| 13 | + targetDir, | |
| 14 | + createdAt: new Date().toISOString(), | |
| 15 | + segments: [{ id: 'S1', ts: new Date().toISOString(), speaker: 'user', text: 'we are building a todo app' }], | |
| 16 | + coverage: initialCoverage(), | |
| 17 | + summary: '', | |
| 18 | + status: 'done', | |
| 19 | + openBlockers: [], | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +describe('generateSpecPack', () => { | |
| 24 | + let targetDir: string | |
| 25 | + | |
| 26 | + beforeEach(async () => { | |
| 27 | + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-')) | |
| 28 | + }) | |
| 29 | + | |
| 30 | + afterEach(async () => { | |
| 31 | + await rm(targetDir, { recursive: true, force: true }) | |
| 32 | + }) | |
| 33 | + | |
| 34 | + it('writes all six spec pack files', async () => { | |
| 35 | + const session = makeSession(targetDir) | |
| 36 | + const result = await generateSpecPack(createLlmMock(), session) | |
| 37 | + | |
| 38 | + expect(result.files.sort()).toEqual( | |
| 39 | + [ | |
| 40 | + 'spec/SPEC.md', | |
| 41 | + 'spec/PLAN.md', | |
| 42 | + 'spec/TASKS.md', | |
| 43 | + 'spec/VERIFICATION.md', | |
| 44 | + 'spec/HANDOFF.md', | |
| 45 | + 'spec/sources.json', | |
| 46 | + ].sort(), | |
| 47 | + ) | |
| 48 | + | |
| 49 | + for (const file of result.files) { | |
| 50 | + const content = await readFile(path.join(targetDir, file), 'utf8') | |
| 51 | + expect(content.length).toBeGreaterThan(0) | |
| 52 | + } | |
| 53 | + }) | |
| 54 | + | |
| 55 | + it('emits sources.json mapping segment ids to text and timestamp', async () => { | |
| 56 | + const session = makeSession(targetDir) | |
| 57 | + await generateSpecPack(createLlmMock(), session) | |
| 58 | + const sources = JSON.parse(await readFile(path.join(targetDir, 'spec/sources.json'), 'utf8')) | |
| 59 | + expect(sources.S1).toEqual({ text: 'we are building a todo app', ts: session.segments[0].ts }) | |
| 60 | + }) | |
| 61 | + | |
| 62 | + it('removes the bogus [S999] marker and appends [unverified], keeping the valid [S1] marker', async () => { | |
| 63 | + const session = makeSession(targetDir) | |
| 64 | + const result = await generateSpecPack(createLlmMock(), session) | |
| 65 | + const specContent = await readFile(path.join(targetDir, 'spec/SPEC.md'), 'utf8') | |
| 66 | + | |
| 67 | + expect(specContent).toContain('[S1]') | |
| 68 | + expect(specContent).not.toContain('[S999]') | |
| 69 | + expect(specContent).toContain('[unverified]') | |
| 70 | + expect(result.warnings.some((w) => w.includes('SPEC.md'))).toBe(true) | |
| 71 | + }) | |
| 72 | + | |
| 73 | + it('fails on a second run without overwrite, and stores nothing new', async () => { | |
| 74 | + const session = makeSession(targetDir) | |
| 75 | + await generateSpecPack(createLlmMock(), session) | |
| 76 | + await expect(generateSpecPack(createLlmMock(), session)).rejects.toBeInstanceOf(GenerateFilesExistError) | |
| 77 | + }) | |
| 78 | + | |
| 79 | + it('creates a backup dir and regenerates when overwrite is set', async () => { | |
| 80 | + const session = makeSession(targetDir) | |
| 81 | + await generateSpecPack(createLlmMock(), session) | |
| 82 | + const result = await generateSpecPack(createLlmMock(), session, { overwrite: true }) | |
| 83 | + expect(result.files.length).toBe(6) | |
| 84 | + | |
| 85 | + const specDirEntries = await readdir(path.join(targetDir, 'spec')) | |
| 86 | + expect(specDirEntries.some((entry) => entry.startsWith('backup-'))).toBe(true) | |
| 87 | + }) | |
| 88 | + | |
| 89 | + it('fails when the target directory does not exist', async () => { | |
| 90 | + const session = makeSession(path.join(targetDir, 'does-not-exist')) | |
| 91 | + await expect(generateSpecPack(createLlmMock(), session)).rejects.toThrow() | |
| 92 | + }) | |
| 93 | +}) |
added server/generator/generate.ts +92 −0
| @@ -0,0 +1,92 @@ | ||
| 1 | +import { mkdir, rename, stat, writeFile } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { Session } from '../../shared/types' | |
| 4 | +import { SPEC_PACK_FILES, type InterviewLlm } from '../providers/types' | |
| 5 | +import { buildGeneratePrompt } from './prompts' | |
| 6 | +import { validateProvenance } from './provenance' | |
| 7 | + | |
| 8 | +const SOURCES_FILE = 'sources.json' | |
| 9 | + | |
| 10 | +export interface GenerateOptions { | |
| 11 | + overwrite?: boolean | |
| 12 | +} | |
| 13 | + | |
| 14 | +export interface GenerateResult { | |
| 15 | + files: string[] | |
| 16 | + warnings: string[] | |
| 17 | +} | |
| 18 | + | |
| 19 | +export class GenerateFilesExistError extends Error { | |
| 20 | + readonly existing: string[] | |
| 21 | + | |
| 22 | + constructor(existing: string[]) { | |
| 23 | + super(`spec pack files already exist: ${existing.join(', ')}`) | |
| 24 | + this.existing = existing | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +async function pathExists(target: string): Promise<boolean> { | |
| 29 | + try { | |
| 30 | + await stat(target) | |
| 31 | + return true | |
| 32 | + } catch { | |
| 33 | + return false | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +async function backupExistingFiles(specDir: string, existing: string[]): Promise<void> { | |
| 38 | + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') | |
| 39 | + const backupDir = path.join(specDir, `backup-${timestamp}`) | |
| 40 | + await mkdir(backupDir, { recursive: true }) | |
| 41 | + for (const file of existing) { | |
| 42 | + await rename(path.join(specDir, file), path.join(backupDir, file)) | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +export async function generateSpecPack( | |
| 47 | + llm: InterviewLlm, | |
| 48 | + session: Session, | |
| 49 | + options: GenerateOptions = {}, | |
| 50 | +): Promise<GenerateResult> { | |
| 51 | + const targetStat = await stat(session.targetDir).catch(() => null) | |
| 52 | + if (!targetStat || !targetStat.isDirectory()) { | |
| 53 | + throw new Error(`target directory does not exist: ${session.targetDir}`) | |
| 54 | + } | |
| 55 | + | |
| 56 | + const specDir = path.join(session.targetDir, 'spec') | |
| 57 | + await mkdir(specDir, { recursive: true }) | |
| 58 | + | |
| 59 | + const candidateFiles: string[] = [...SPEC_PACK_FILES, SOURCES_FILE] | |
| 60 | + const existing: string[] = [] | |
| 61 | + for (const file of candidateFiles) { | |
| 62 | + if (await pathExists(path.join(specDir, file))) existing.push(file) | |
| 63 | + } | |
| 64 | + | |
| 65 | + if (existing.length > 0) { | |
| 66 | + if (!options.overwrite) throw new GenerateFilesExistError(existing) | |
| 67 | + await backupExistingFiles(specDir, existing) | |
| 68 | + } | |
| 69 | + | |
| 70 | + const validSegmentIds = new Set(session.segments.map((segment) => segment.id)) | |
| 71 | + const warnings: string[] = [] | |
| 72 | + const writtenFiles: string[] = [] | |
| 73 | + | |
| 74 | + for (const file of SPEC_PACK_FILES) { | |
| 75 | + const prompt = buildGeneratePrompt(file, session.name, session.segments) | |
| 76 | + const rawContent = await llm.generateFile({ file, prompt, segments: session.segments }) | |
| 77 | + const { content, invalidMarkerCount } = validateProvenance(rawContent, validSegmentIds) | |
| 78 | + if (invalidMarkerCount > 0) { | |
| 79 | + warnings.push(`${file}: removed ${invalidMarkerCount} unverified provenance marker(s)`) | |
| 80 | + } | |
| 81 | + await writeFile(path.join(specDir, file), content, 'utf8') | |
| 82 | + writtenFiles.push(`spec/${file}`) | |
| 83 | + } | |
| 84 | + | |
| 85 | + const sources = Object.fromEntries( | |
| 86 | + session.segments.map((segment) => [segment.id, { text: segment.text, ts: segment.ts }]), | |
| 87 | + ) | |
| 88 | + await writeFile(path.join(specDir, SOURCES_FILE), JSON.stringify(sources, null, 2), 'utf8') | |
| 89 | + writtenFiles.push(`spec/${SOURCES_FILE}`) | |
| 90 | + | |
| 91 | + return { files: writtenFiles, warnings } | |
| 92 | +} |
added server/generator/prompts.ts +61 −0
| @@ -0,0 +1,61 @@ | ||
| 1 | +import type { Segment } from '../../shared/types' | |
| 2 | +import type { SpecPackFile } from '../providers/types' | |
| 3 | + | |
| 4 | +function renderTranscript(segments: Segment[]): string { | |
| 5 | + return segments.map((segment) => `[${segment.id}] ${segment.speaker}: ${segment.text}`).join('\n') | |
| 6 | +} | |
| 7 | + | |
| 8 | +export function buildGeneratePrompt(file: SpecPackFile, projectName: string, segments: Segment[]): string { | |
| 9 | + const transcript = renderTranscript(segments) | |
| 10 | + const header = `You are writing ${file} for a project called "${projectName}", based on a transcript of a Socratic spec interview with its developer. Write only what the transcript supports; do not invent requirements. Output English Markdown only, no commentary.` | |
| 11 | + | |
| 12 | + switch (file) { | |
| 13 | + case 'SPEC.md': | |
| 14 | + return [ | |
| 15 | + header, | |
| 16 | + 'Mirror the structure of a typical SPEC.md: Goal, Users, User stories, Functional requirements (FR-001, FR-002, ...), Edge cases, Out of scope, Assumptions.', | |
| 17 | + 'Every functional requirement line MUST end with one or more provenance markers, e.g. "[S3]" or "[S5][S9]", citing the transcript segment ids that justify it.', | |
| 18 | + '', | |
| 19 | + 'Transcript:', | |
| 20 | + transcript, | |
| 21 | + ].join('\n') | |
| 22 | + | |
| 23 | + case 'PLAN.md': | |
| 24 | + return [ | |
| 25 | + header, | |
| 26 | + 'Mirror the structure of a typical PLAN.md: Stack, Layout, key interfaces, API routes, and decisions already made, all derived from the transcript.', | |
| 27 | + '', | |
| 28 | + 'Transcript:', | |
| 29 | + transcript, | |
| 30 | + ].join('\n') | |
| 31 | + | |
| 32 | + case 'TASKS.md': | |
| 33 | + return [ | |
| 34 | + header, | |
| 35 | + 'Mirror the structure of a typical TASKS.md: an ordered checklist of implementation tasks, each with a Depends line and a Verify line.', | |
| 36 | + 'Every task MUST include at least one concrete verification command (e.g. `npm test`).', | |
| 37 | + '', | |
| 38 | + 'Transcript:', | |
| 39 | + transcript, | |
| 40 | + ].join('\n') | |
| 41 | + | |
| 42 | + case 'VERIFICATION.md': | |
| 43 | + return [ | |
| 44 | + header, | |
| 45 | + 'Describe what "done" looks like for this project and how a coding agent should verify it (commands to run, behaviors to check).', | |
| 46 | + '', | |
| 47 | + 'Transcript:', | |
| 48 | + transcript, | |
| 49 | + ].join('\n') | |
| 50 | + | |
| 51 | + case 'HANDOFF.md': | |
| 52 | + return [ | |
| 53 | + header, | |
| 54 | + 'Explain how to hand this spec pack to a sandboxed Claude Code session.', | |
| 55 | + 'It MUST include a ready-to-copy shell command line that launches such a session against this spec pack, using the `claude` CLI, e.g.: `claude "Work through spec/TASKS.md in order."`', | |
| 56 | + '', | |
| 57 | + 'Transcript:', | |
| 58 | + transcript, | |
| 59 | + ].join('\n') | |
| 60 | + } | |
| 61 | +} |
added server/generator/provenance.test.ts +31 −0
| @@ -0,0 +1,31 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { validateProvenance } from './provenance' | |
| 3 | + | |
| 4 | +describe('validateProvenance', () => { | |
| 5 | + it('leaves lines with only valid markers untouched', () => { | |
| 6 | + const content = '- FR-001: does a thing. [S1][S2]' | |
| 7 | + const { content: result, invalidMarkerCount } = validateProvenance(content, new Set(['S1', 'S2'])) | |
| 8 | + expect(result).toBe(content) | |
| 9 | + expect(invalidMarkerCount).toBe(0) | |
| 10 | + }) | |
| 11 | + | |
| 12 | + it('removes an invalid marker and appends [unverified]', () => { | |
| 13 | + const content = '- FR-002: does another thing. [S999]' | |
| 14 | + const { content: result, invalidMarkerCount } = validateProvenance(content, new Set(['S1'])) | |
| 15 | + expect(result).toBe('- FR-002: does another thing. [unverified]') | |
| 16 | + expect(invalidMarkerCount).toBe(1) | |
| 17 | + }) | |
| 18 | + | |
| 19 | + it('keeps valid markers on a line while removing only the invalid ones', () => { | |
| 20 | + const content = '- FR-003: mixed evidence. [S1][S999]' | |
| 21 | + const { content: result, invalidMarkerCount } = validateProvenance(content, new Set(['S1'])) | |
| 22 | + expect(result).toBe('- FR-003: mixed evidence. [S1] [unverified]') | |
| 23 | + expect(invalidMarkerCount).toBe(1) | |
| 24 | + }) | |
| 25 | + | |
| 26 | + it('does not duplicate [unverified] if already present', () => { | |
| 27 | + const content = '- FR-004: already flagged. [S999] [unverified]' | |
| 28 | + const { content: result } = validateProvenance(content, new Set()) | |
| 29 | + expect(result).toBe('- FR-004: already flagged. [unverified]') | |
| 30 | + }) | |
| 31 | +}) |
added server/generator/provenance.ts +33 −0
| @@ -0,0 +1,33 @@ | ||
| 1 | +const MARKER_PATTERN = /\[S(\d+)\]/g | |
| 2 | + | |
| 3 | +export interface ProvenanceResult { | |
| 4 | + content: string | |
| 5 | + invalidMarkerCount: number | |
| 6 | +} | |
| 7 | + | |
| 8 | +export function validateProvenance(content: string, validSegmentIds: ReadonlySet<string>): ProvenanceResult { | |
| 9 | + let invalidMarkerCount = 0 | |
| 10 | + | |
| 11 | + const lines = content.split('\n').map((line) => { | |
| 12 | + const markers = [...line.matchAll(MARKER_PATTERN)] | |
| 13 | + const invalidMarkers = markers.filter((match) => !validSegmentIds.has(`S${match[1]}`)) | |
| 14 | + if (invalidMarkers.length === 0) return line | |
| 15 | + | |
| 16 | + invalidMarkerCount += invalidMarkers.length | |
| 17 | + let updated = line | |
| 18 | + for (const match of invalidMarkers) { | |
| 19 | + updated = updated.replace(match[0], '') | |
| 20 | + } | |
| 21 | + | |
| 22 | + const leading = updated.match(/^\s*/)?.[0] ?? '' | |
| 23 | + const rest = updated.slice(leading.length).replace(/ {2,}/g, ' ').trimEnd() | |
| 24 | + updated = leading + rest | |
| 25 | + | |
| 26 | + if (!updated.includes('[unverified]')) { | |
| 27 | + updated = `${updated} [unverified]` | |
| 28 | + } | |
| 29 | + return updated | |
| 30 | + }) | |
| 31 | + | |
| 32 | + return { content: lines.join('\n'), invalidMarkerCount } | |
| 33 | +} |
added server/index.ts +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +import { buildApp } from './app' | |
| 2 | + | |
| 3 | +const PORT = Number(process.env.PORT ?? 3001) | |
| 4 | + | |
| 5 | +async function main() { | |
| 6 | + const app = buildApp() | |
| 7 | + await app.listen({ port: PORT, host: '0.0.0.0' }) | |
| 8 | +} | |
| 9 | + | |
| 10 | +main().catch((err) => { | |
| 11 | + console.error(err) | |
| 12 | + process.exit(1) | |
| 13 | +}) |
added server/placeholder.test.ts +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +import { expect, test } from 'vitest' | |
| 2 | + | |
| 3 | +test('placeholder', () => { | |
| 4 | + expect(true).toBe(true) | |
| 5 | +}) |
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 +19 −0
| @@ -0,0 +1,19 @@ | ||
| 1 | +import { createLlmAnthropic } from './llmAnthropic' | |
| 2 | +import { createLlmMock } from './llmMock' | |
| 3 | +import { createSttMock } from './sttMock' | |
| 4 | +import { createSttOpenai } from './sttOpenai' | |
| 5 | +import type { InterviewLlm, SttProvider } from './types' | |
| 6 | + | |
| 7 | +function isMockMode(): boolean { | |
| 8 | + return process.env.MOCK_PROVIDERS === '1' | |
| 9 | +} | |
| 10 | + | |
| 11 | +export function createSttProvider(): SttProvider { | |
| 12 | + if (isMockMode()) return createSttMock() | |
| 13 | + return createSttOpenai() | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function createInterviewLlm(): InterviewLlm { | |
| 17 | + if (isMockMode()) return createLlmMock() | |
| 18 | + return createLlmAnthropic() | |
| 19 | +} |
added server/providers/llmAnthropic.test.ts +53 −0
| @@ -0,0 +1,53 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { initialCoverage, type Segment } from '../../shared/types' | |
| 3 | +import { buildInterviewSystemPrompt, buildInterviewUserMessage } from './llmAnthropic' | |
| 4 | +import type { InterviewContext } from './types' | |
| 5 | + | |
| 6 | +function segment(id: string, speaker: Segment['speaker'], text: string): Segment { | |
| 7 | + return { id, ts: new Date().toISOString(), speaker, text } | |
| 8 | +} | |
| 9 | + | |
| 10 | +describe('buildInterviewSystemPrompt', () => { | |
| 11 | + it('names the weakest category as the required target of the next question', () => { | |
| 12 | + const context: InterviewContext = { | |
| 13 | + summary: '', | |
| 14 | + segments: [], | |
| 15 | + coverage: { ...initialCoverage(), goal: 'clear' }, | |
| 16 | + } | |
| 17 | + const prompt = buildInterviewSystemPrompt(context) | |
| 18 | + expect(prompt).toContain('weakest category right now is "users"') | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it('states every category is covered when nothing is weak', () => { | |
| 22 | + const allClear = Object.fromEntries( | |
| 23 | + Object.keys(initialCoverage()).map((c) => [c, 'clear']), | |
| 24 | + ) as ReturnType<typeof initialCoverage> | |
| 25 | + const context: InterviewContext = { summary: '', segments: [], coverage: allClear } | |
| 26 | + expect(buildInterviewSystemPrompt(context)).toContain('Every category is already "clear"') | |
| 27 | + }) | |
| 28 | + | |
| 29 | + it('instructs the exact-match "done" rule and the contradiction rule', () => { | |
| 30 | + const context: InterviewContext = { summary: '', segments: [], coverage: initialCoverage() } | |
| 31 | + const prompt = buildInterviewSystemPrompt(context) | |
| 32 | + expect(prompt).toContain('exactly "done"') | |
| 33 | + expect(prompt).toContain('contradiction') | |
| 34 | + }) | |
| 35 | +}) | |
| 36 | + | |
| 37 | +describe('buildInterviewUserMessage', () => { | |
| 38 | + it('includes the running summary and every recent segment with its id and speaker', () => { | |
| 39 | + const context: InterviewContext = { | |
| 40 | + summary: 'earlier: building a todo app', | |
| 41 | + segments: [segment('S1', 'user', 'it should sync across devices')], | |
| 42 | + coverage: initialCoverage(), | |
| 43 | + } | |
| 44 | + const message = buildInterviewUserMessage(context) | |
| 45 | + expect(message).toContain('earlier: building a todo app') | |
| 46 | + expect(message).toContain('[S1] user: it should sync across devices') | |
| 47 | + }) | |
| 48 | + | |
| 49 | + it('omits the summary section when there is no summary yet', () => { | |
| 50 | + const context: InterviewContext = { summary: '', segments: [], coverage: initialCoverage() } | |
| 51 | + expect(buildInterviewUserMessage(context)).not.toContain('Running summary') | |
| 52 | + }) | |
| 53 | +}) |
added server/providers/llmAnthropic.ts +76 −0
| @@ -0,0 +1,76 @@ | ||
| 1 | +import Anthropic from '@anthropic-ai/sdk' | |
| 2 | +import { betaZodOutputFormat } from '@anthropic-ai/sdk/helpers/beta/zod' | |
| 3 | +import { CATEGORY_IDS, InterviewTurnSchema, type InterviewTurn } from '../../shared/types' | |
| 4 | +import { weakestCategory } from '../engine/coverage' | |
| 5 | +import type { GenerateFileRequest, InterviewContext, InterviewLlm } from './types' | |
| 6 | + | |
| 7 | +const DEFAULT_MODEL = 'claude-opus-4-8' | |
| 8 | +const INTERVIEW_MAX_TOKENS = 4096 | |
| 9 | +const INTERVIEW_THINKING_BUDGET_TOKENS = 2048 | |
| 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 for a solo developer talking through a project idea.', | |
| 16 | + 'Ask exactly one targeted question per turn, chosen to fill the biggest gap in the spec.', | |
| 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, 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 = process.env.ANTHROPIC_API_KEY | |
| 42 | + if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set') | |
| 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 message = await client.beta.messages.parse({ | |
| 49 | + model, | |
| 50 | + max_tokens: INTERVIEW_MAX_TOKENS, | |
| 51 | + thinking: { type: 'enabled', budget_tokens: INTERVIEW_THINKING_BUDGET_TOKENS }, | |
| 52 | + system: buildInterviewSystemPrompt(context), | |
| 53 | + messages: [{ role: 'user', content: buildInterviewUserMessage(context) }], | |
| 54 | + output_format: betaZodOutputFormat(InterviewTurnSchema), | |
| 55 | + }) | |
| 56 | + if (!message.parsed_output) { | |
| 57 | + throw new Error('Anthropic response did not include a parsed InterviewTurn') | |
| 58 | + } | |
| 59 | + return message.parsed_output | |
| 60 | + }, | |
| 61 | + | |
| 62 | + async generateFile(request: GenerateFileRequest): Promise<string> { | |
| 63 | + const stream = client.beta.messages.stream({ | |
| 64 | + model, | |
| 65 | + max_tokens: GENERATE_MAX_TOKENS, | |
| 66 | + messages: [{ role: 'user', content: request.prompt }], | |
| 67 | + }) | |
| 68 | + const final = await stream.finalMessage() | |
| 69 | + const textBlock = final.content.find((block) => block.type === 'text') | |
| 70 | + if (!textBlock) { | |
| 71 | + throw new Error(`Anthropic response for ${request.file} contained no text block`) | |
| 72 | + } | |
| 73 | + return textBlock.text | |
| 74 | + }, | |
| 75 | + } | |
| 76 | +} |
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/sttOpenai.ts +33 −0
| @@ -0,0 +1,33 @@ | ||
| 1 | +import { EmptyTranscriptError, type SttProvider } from './types' | |
| 2 | + | |
| 3 | +const DEFAULT_STT_MODEL = 'gpt-4o-mini-transcribe' | |
| 4 | + | |
| 5 | +export function createSttOpenai(): SttProvider { | |
| 6 | + const apiKey = process.env.OPENAI_API_KEY | |
| 7 | + if (!apiKey) throw new Error('OPENAI_API_KEY is not set') | |
| 8 | + const model = process.env.STT_MODEL ?? DEFAULT_STT_MODEL | |
| 9 | + | |
| 10 | + return { | |
| 11 | + async transcribe(audio: Buffer, mimeType: string): Promise<string> { | |
| 12 | + const form = new FormData() | |
| 13 | + form.append('model', model) | |
| 14 | + form.append('file', new Blob([audio], { type: mimeType }), 'audio.webm') | |
| 15 | + | |
| 16 | + const res = await fetch('https://api.openai.com/v1/audio/transcriptions', { | |
| 17 | + method: 'POST', | |
| 18 | + headers: { Authorization: `Bearer ${apiKey}` }, | |
| 19 | + body: form, | |
| 20 | + }) | |
| 21 | + | |
| 22 | + if (!res.ok) { | |
| 23 | + const body = await res.text() | |
| 24 | + throw new Error(`OpenAI STT request failed (${res.status}): ${body}`) | |
| 25 | + } | |
| 26 | + | |
| 27 | + const data = (await res.json()) as { text?: string } | |
| 28 | + const text = (data.text ?? '').trim() | |
| 29 | + if (text.length === 0) throw new EmptyTranscriptError() | |
| 30 | + return text | |
| 31 | + }, | |
| 32 | + } | |
| 33 | +} |
added server/providers/types.ts +31 −0
| @@ -0,0 +1,31 @@ | ||
| 1 | +import type { Coverage, InterviewTurn, Segment } from '../../shared/types' | |
| 2 | + | |
| 3 | +export class EmptyTranscriptError extends Error { | |
| 4 | + constructor() { | |
| 5 | + super('transcript is empty after trimming') | |
| 6 | + } | |
| 7 | +} | |
| 8 | + | |
| 9 | +export interface SttProvider { | |
| 10 | + transcribe(audio: Buffer, mimeType: string): Promise<string> | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface InterviewContext { | |
| 14 | + summary: string | |
| 15 | + segments: Segment[] | |
| 16 | + coverage: Coverage | |
| 17 | +} | |
| 18 | + | |
| 19 | +export const SPEC_PACK_FILES = ['SPEC.md', 'PLAN.md', 'TASKS.md', 'VERIFICATION.md', 'HANDOFF.md'] as const | |
| 20 | +export type SpecPackFile = (typeof SPEC_PACK_FILES)[number] | |
| 21 | + | |
| 22 | +export interface GenerateFileRequest { | |
| 23 | + file: SpecPackFile | |
| 24 | + prompt: string | |
| 25 | + segments: Segment[] | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface InterviewLlm { | |
| 29 | + nextTurn(context: InterviewContext): Promise<InterviewTurn> | |
| 30 | + generateFile(request: GenerateFileRequest): Promise<string> | |
| 31 | +} |
added server/routes/audio.test.ts +106 −0
| @@ -0,0 +1,106 @@ | ||
| 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 { buildApp } from '../app' | |
| 8 | +import { createLlmMock } from '../providers/llmMock' | |
| 9 | +import { createSttMock } from '../providers/sttMock' | |
| 10 | +import { SessionStore } from '../store/sessionStore' | |
| 11 | + | |
| 12 | +function buildMultipartPayload( | |
| 13 | + fileBuffer: Buffer, | |
| 14 | + filename: string, | |
| 15 | + mimeType: string, | |
| 16 | +): { body: Buffer; contentType: string } { | |
| 17 | + const boundary = '----voicetaskTestBoundary1234567890' | |
| 18 | + const parts = [ | |
| 19 | + Buffer.from(`--${boundary}\r\n`), | |
| 20 | + Buffer.from(`Content-Disposition: form-data; name="audio"; filename="${filename}"\r\n`), | |
| 21 | + Buffer.from(`Content-Type: ${mimeType}\r\n\r\n`), | |
| 22 | + fileBuffer, | |
| 23 | + Buffer.from(`\r\n--${boundary}--\r\n`), | |
| 24 | + ] | |
| 25 | + return { body: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` } | |
| 26 | +} | |
| 27 | + | |
| 28 | +describe('audio route', () => { | |
| 29 | + let baseDir: string | |
| 30 | + let app: FastifyInstance | |
| 31 | + | |
| 32 | + beforeEach(async () => { | |
| 33 | + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-audio-')) | |
| 34 | + app = buildApp({ store: new SessionStore(baseDir), llm: createLlmMock(), stt: createSttMock() }) | |
| 35 | + }) | |
| 36 | + | |
| 37 | + afterEach(async () => { | |
| 38 | + await app.close() | |
| 39 | + await rm(baseDir, { recursive: true, force: true }) | |
| 40 | + }) | |
| 41 | + | |
| 42 | + async function createSession(): Promise<Session> { | |
| 43 | + return app | |
| 44 | + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } }) | |
| 45 | + .then((r) => r.json<Session>()) | |
| 46 | + } | |
| 47 | + | |
| 48 | + it('transcribes an uploaded clip, stores a segment, and triggers a turn', async () => { | |
| 49 | + const session = await createSession() | |
| 50 | + const { body, contentType } = buildMultipartPayload( | |
| 51 | + Buffer.from('we want push notifications', 'utf8'), | |
| 52 | + 'clip.webm', | |
| 53 | + 'text/plain', | |
| 54 | + ) | |
| 55 | + | |
| 56 | + const res = await app.inject({ | |
| 57 | + method: 'POST', | |
| 58 | + url: `/api/sessions/${session.id}/audio`, | |
| 59 | + headers: { 'content-type': contentType }, | |
| 60 | + payload: body, | |
| 61 | + }) | |
| 62 | + | |
| 63 | + expect(res.statusCode).toBe(200) | |
| 64 | + const responseBody = res.json<AnswerResponse>() | |
| 65 | + expect(responseBody.segment.speaker).toBe('user') | |
| 66 | + expect(responseBody.segment.text).toBe('we want push notifications') | |
| 67 | + expect(responseBody.turn.coverage[CATEGORY_IDS[0]]).toBe('clear') | |
| 68 | + | |
| 69 | + const reloaded = await app | |
| 70 | + .inject({ method: 'GET', url: `/api/sessions/${session.id}` }) | |
| 71 | + .then((r) => r.json<Session>()) | |
| 72 | + expect(reloaded.segments).toHaveLength(2) | |
| 73 | + expect(reloaded.segments[0]).toMatchObject({ speaker: 'user', text: 'we want push notifications' }) | |
| 74 | + }) | |
| 75 | + | |
| 76 | + it('rejects an empty audio upload with a 4xx and stores nothing', async () => { | |
| 77 | + const session = await createSession() | |
| 78 | + const { body, contentType } = buildMultipartPayload(Buffer.alloc(0), 'clip.webm', 'audio/webm') | |
| 79 | + | |
| 80 | + const res = await app.inject({ | |
| 81 | + method: 'POST', | |
| 82 | + url: `/api/sessions/${session.id}/audio`, | |
| 83 | + headers: { 'content-type': contentType }, | |
| 84 | + payload: body, | |
| 85 | + }) | |
| 86 | + | |
| 87 | + expect(res.statusCode).toBeGreaterThanOrEqual(400) | |
| 88 | + expect(res.statusCode).toBeLessThan(500) | |
| 89 | + | |
| 90 | + const reloaded = await app | |
| 91 | + .inject({ method: 'GET', url: `/api/sessions/${session.id}` }) | |
| 92 | + .then((r) => r.json<Session>()) | |
| 93 | + expect(reloaded.segments).toHaveLength(0) | |
| 94 | + }) | |
| 95 | + | |
| 96 | + it('returns 404 for an unknown session', async () => { | |
| 97 | + const { body, contentType } = buildMultipartPayload(Buffer.from('hello'), 'clip.webm', 'text/plain') | |
| 98 | + const res = await app.inject({ | |
| 99 | + method: 'POST', | |
| 100 | + url: '/api/sessions/nonexistent/audio', | |
| 101 | + headers: { 'content-type': contentType }, | |
| 102 | + payload: body, | |
| 103 | + }) | |
| 104 | + expect(res.statusCode).toBe(404) | |
| 105 | + }) | |
| 106 | +}) |
added server/routes/audio.ts +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +import type { FastifyInstance } from 'fastify' | |
| 2 | +import { SessionNotFoundError, submitAnswer } from '../engine/turn' | |
| 3 | +import { EmptyTranscriptError, type InterviewLlm, type SttProvider } from '../providers/types' | |
| 4 | +import type { SessionStore } from '../store/sessionStore' | |
| 5 | + | |
| 6 | +export interface AudioRouteDeps { | |
| 7 | + store: SessionStore | |
| 8 | + llm: InterviewLlm | |
| 9 | + stt: SttProvider | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function registerAudioRoutes(app: FastifyInstance, deps: AudioRouteDeps): void { | |
| 13 | + const { store, llm, stt } = deps | |
| 14 | + | |
| 15 | + app.post<{ Params: { id: string } }>('/api/sessions/:id/audio', async (request, reply) => { | |
| 16 | + const file = await request.file() | |
| 17 | + if (!file) return reply.code(400).send({ error: 'no audio file uploaded' }) | |
| 18 | + | |
| 19 | + const buffer = await file.toBuffer() | |
| 20 | + if (buffer.length === 0) { | |
| 21 | + return reply.code(400).send({ error: 'empty audio upload' }) | |
| 22 | + } | |
| 23 | + | |
| 24 | + let text: string | |
| 25 | + try { | |
| 26 | + text = await stt.transcribe(buffer, file.mimetype) | |
| 27 | + } catch (err) { | |
| 28 | + if (err instanceof EmptyTranscriptError) { | |
| 29 | + return reply.code(400).send({ error: 'empty transcript' }) | |
| 30 | + } | |
| 31 | + return reply.code(502).send({ error: err instanceof Error ? err.message : 'STT provider error' }) | |
| 32 | + } | |
| 33 | + | |
| 34 | + try { | |
| 35 | + const response = await submitAnswer(store, llm, request.params.id, text) | |
| 36 | + return reply.send(response) | |
| 37 | + } catch (err) { | |
| 38 | + if (err instanceof SessionNotFoundError) { | |
| 39 | + return reply.code(404).send({ error: 'session not found' }) | |
| 40 | + } | |
| 41 | + throw err | |
| 42 | + } | |
| 43 | + }) | |
| 44 | +} |
added server/routes/blockers.test.ts +118 −0
| @@ -0,0 +1,118 @@ | ||
| 1 | +import { mkdtemp, rm, writeFile } 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 type { AnswerResponse, BlockersResponse, Session } from '../../shared/types' | |
| 7 | +import { buildApp } from '../app' | |
| 8 | +import { createLlmMock } from '../providers/llmMock' | |
| 9 | +import { SessionStore } from '../store/sessionStore' | |
| 10 | + | |
| 11 | +const BLOCKED_MD = `# BLOCKED | |
| 12 | + | |
| 13 | +## B1: which auth provider | |
| 14 | +- Task: T4 | |
| 15 | +- Question: Should sign-in use email/password or an OAuth provider? | |
| 16 | +- Options considered: email/password, Google OAuth | |
| 17 | +- Continued with: implemented email/password | |
| 18 | + | |
| 19 | +## B2: rate limit thresholds | |
| 20 | +- Task: T6 | |
| 21 | +- Question: What is the max requests per minute per user? | |
| 22 | +- Options considered: 60, 120 | |
| 23 | +- Continued with: skipped task | |
| 24 | +` | |
| 25 | + | |
| 26 | +describe('blockers route', () => { | |
| 27 | + let storeDir: string | |
| 28 | + let targetDir: string | |
| 29 | + let app: FastifyInstance | |
| 30 | + | |
| 31 | + beforeEach(async () => { | |
| 32 | + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-')) | |
| 33 | + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-')) | |
| 34 | + app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() }) | |
| 35 | + }) | |
| 36 | + | |
| 37 | + afterEach(async () => { | |
| 38 | + await app.close() | |
| 39 | + await rm(storeDir, { recursive: true, force: true }) | |
| 40 | + await rm(targetDir, { recursive: true, force: true }) | |
| 41 | + }) | |
| 42 | + | |
| 43 | + async function createSession(): Promise<Session> { | |
| 44 | + return app | |
| 45 | + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir } }) | |
| 46 | + .then((r) => r.json<Session>()) | |
| 47 | + } | |
| 48 | + | |
| 49 | + it('reports no questions and changes nothing when BLOCKED.md is missing', async () => { | |
| 50 | + const session = await createSession() | |
| 51 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/blockers`, payload: {} }) | |
| 52 | + expect(res.statusCode).toBe(200) | |
| 53 | + expect(res.json<BlockersResponse>()).toEqual({ questions: [] }) | |
| 54 | + | |
| 55 | + const reloaded = await app | |
| 56 | + .inject({ method: 'GET', url: `/api/sessions/${session.id}` }) | |
| 57 | + .then((r) => r.json<Session>()) | |
| 58 | + expect(reloaded.segments).toHaveLength(0) | |
| 59 | + expect(reloaded.openBlockers).toEqual([]) | |
| 60 | + }) | |
| 61 | + | |
| 62 | + it('reports no questions when BLOCKED.md has no entries', async () => { | |
| 63 | + const session = await createSession() | |
| 64 | + await writeFile(path.join(targetDir, 'BLOCKED.md'), '# BLOCKED\n\nNo entries yet.\n', 'utf8') | |
| 65 | + | |
| 66 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/blockers`, payload: {} }) | |
| 67 | + expect(res.json<BlockersResponse>()).toEqual({ questions: [] }) | |
| 68 | + }) | |
| 69 | + | |
| 70 | + it('imports blockers, asks the first immediately, and queues the rest', async () => { | |
| 71 | + const session = await createSession() | |
| 72 | + await writeFile(path.join(targetDir, 'BLOCKED.md'), BLOCKED_MD, 'utf8') | |
| 73 | + | |
| 74 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/blockers`, payload: {} }) | |
| 75 | + expect(res.statusCode).toBe(200) | |
| 76 | + const body = res.json<BlockersResponse>() | |
| 77 | + expect(body.questions).toEqual([ | |
| 78 | + 'Should sign-in use email/password or an OAuth provider?', | |
| 79 | + 'What is the max requests per minute per user?', | |
| 80 | + ]) | |
| 81 | + | |
| 82 | + const afterImport = await app | |
| 83 | + .inject({ method: 'GET', url: `/api/sessions/${session.id}` }) | |
| 84 | + .then((r) => r.json<Session>()) | |
| 85 | + expect(afterImport.segments).toHaveLength(1) | |
| 86 | + expect(afterImport.segments[0]).toMatchObject({ | |
| 87 | + speaker: 'interviewer', | |
| 88 | + text: 'Should sign-in use email/password or an OAuth provider?', | |
| 89 | + }) | |
| 90 | + expect(afterImport.openBlockers).toEqual(['What is the max requests per minute per user?']) | |
| 91 | + expect(afterImport.status).toBe('interviewing') | |
| 92 | + }) | |
| 93 | + | |
| 94 | + it('asks the next queued blocker question instead of a normal coverage question', async () => { | |
| 95 | + const session = await createSession() | |
| 96 | + await writeFile(path.join(targetDir, 'BLOCKED.md'), BLOCKED_MD, 'utf8') | |
| 97 | + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/blockers`, payload: {} }) | |
| 98 | + | |
| 99 | + const res = await app.inject({ | |
| 100 | + method: 'POST', | |
| 101 | + url: `/api/sessions/${session.id}/answer`, | |
| 102 | + payload: { text: 'email/password' }, | |
| 103 | + }) | |
| 104 | + const body = res.json<AnswerResponse>() | |
| 105 | + expect(body.turn.nextQuestion).toBe('What is the max requests per minute per user?') | |
| 106 | + expect(body.turn.done).toBe(false) | |
| 107 | + | |
| 108 | + const reloaded = await app | |
| 109 | + .inject({ method: 'GET', url: `/api/sessions/${session.id}` }) | |
| 110 | + .then((r) => r.json<Session>()) | |
| 111 | + expect(reloaded.openBlockers).toEqual([]) | |
| 112 | + }) | |
| 113 | + | |
| 114 | + it('returns 404 for an unknown session', async () => { | |
| 115 | + const res = await app.inject({ method: 'POST', url: '/api/sessions/nonexistent/blockers', payload: {} }) | |
| 116 | + expect(res.statusCode).toBe(404) | |
| 117 | + }) | |
| 118 | +}) |
added server/routes/blockers.ts +42 −0
| @@ -0,0 +1,42 @@ | ||
| 1 | +import { readFile } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { FastifyInstance } from 'fastify' | |
| 4 | +import type { BlockersResponse } from '../../shared/types' | |
| 5 | +import { parseBlockedFile } from '../blockers/parse' | |
| 6 | +import type { SessionStore } from '../store/sessionStore' | |
| 7 | + | |
| 8 | +export interface BlockerRouteDeps { | |
| 9 | + store: SessionStore | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function registerBlockerRoutes(app: FastifyInstance, deps: BlockerRouteDeps): void { | |
| 13 | + const { store } = deps | |
| 14 | + | |
| 15 | + app.post<{ Params: { id: string } }>('/api/sessions/:id/blockers', async (request, reply) => { | |
| 16 | + const session = await store.getSession(request.params.id) | |
| 17 | + if (!session) return reply.code(404).send({ error: 'session not found' }) | |
| 18 | + | |
| 19 | + const filePath = path.join(session.targetDir, 'BLOCKED.md') | |
| 20 | + const content = await readFile(filePath, 'utf8').catch(() => null) | |
| 21 | + if (content === null) { | |
| 22 | + const response: BlockersResponse = { questions: [] } | |
| 23 | + return reply.send(response) | |
| 24 | + } | |
| 25 | + | |
| 26 | + const questions = parseBlockedFile(content) | |
| 27 | + .map((entry) => entry.question) | |
| 28 | + .filter((question) => question.length > 0) | |
| 29 | + | |
| 30 | + if (questions.length === 0) { | |
| 31 | + const response: BlockersResponse = { questions: [] } | |
| 32 | + return reply.send(response) | |
| 33 | + } | |
| 34 | + | |
| 35 | + const [firstQuestion, ...remaining] = questions | |
| 36 | + await store.appendSegment(request.params.id, 'interviewer', firstQuestion) | |
| 37 | + await store.updateTurn(request.params.id, { openBlockers: remaining, status: 'interviewing' }) | |
| 38 | + | |
| 39 | + const response: BlockersResponse = { questions } | |
| 40 | + return reply.send(response) | |
| 41 | + }) | |
| 42 | +} |
added server/routes/generate.test.ts +85 −0
| @@ -0,0 +1,85 @@ | ||
| 1 | +import { mkdtemp, readdir, 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 type { GenerateResponse, Session } from '../../shared/types' | |
| 7 | +import { buildApp } from '../app' | |
| 8 | +import { createLlmMock } from '../providers/llmMock' | |
| 9 | +import { SessionStore } from '../store/sessionStore' | |
| 10 | + | |
| 11 | +describe('generate route', () => { | |
| 12 | + let storeDir: string | |
| 13 | + let targetDir: string | |
| 14 | + let app: FastifyInstance | |
| 15 | + | |
| 16 | + beforeEach(async () => { | |
| 17 | + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-')) | |
| 18 | + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-')) | |
| 19 | + app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() }) | |
| 20 | + }) | |
| 21 | + | |
| 22 | + afterEach(async () => { | |
| 23 | + await app.close() | |
| 24 | + await rm(storeDir, { recursive: true, force: true }) | |
| 25 | + await rm(targetDir, { recursive: true, force: true }) | |
| 26 | + }) | |
| 27 | + | |
| 28 | + async function createSessionWithAnAnswer(): Promise<Session> { | |
| 29 | + const created = await app | |
| 30 | + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir } }) | |
| 31 | + .then((r) => r.json<Session>()) | |
| 32 | + await app.inject({ | |
| 33 | + method: 'POST', | |
| 34 | + url: `/api/sessions/${created.id}/answer`, | |
| 35 | + payload: { text: 'we are building a todo app' }, | |
| 36 | + }) | |
| 37 | + return app.inject({ method: 'GET', url: `/api/sessions/${created.id}` }).then((r) => r.json<Session>()) | |
| 38 | + } | |
| 39 | + | |
| 40 | + it('generates the six spec pack files', async () => { | |
| 41 | + const session = await createSessionWithAnAnswer() | |
| 42 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 43 | + expect(res.statusCode).toBe(200) | |
| 44 | + const body = res.json<GenerateResponse>() | |
| 45 | + expect(body.files).toHaveLength(6) | |
| 46 | + expect(body.files).toContain('spec/SPEC.md') | |
| 47 | + }) | |
| 48 | + | |
| 49 | + it('returns 409 on a second generate without overwrite', async () => { | |
| 50 | + const session = await createSessionWithAnAnswer() | |
| 51 | + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 52 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 53 | + expect(res.statusCode).toBe(409) | |
| 54 | + }) | |
| 55 | + | |
| 56 | + it('regenerates and creates a backup dir when overwrite is set', async () => { | |
| 57 | + const session = await createSessionWithAnAnswer() | |
| 58 | + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} }) | |
| 59 | + const res = await app.inject({ | |
| 60 | + method: 'POST', | |
| 61 | + url: `/api/sessions/${session.id}/generate`, | |
| 62 | + payload: { overwrite: true }, | |
| 63 | + }) | |
| 64 | + expect(res.statusCode).toBe(200) | |
| 65 | + const entries = await readdir(path.join(targetDir, 'spec')) | |
| 66 | + expect(entries.some((e) => e.startsWith('backup-'))).toBe(true) | |
| 67 | + }) | |
| 68 | + | |
| 69 | + it('returns 404 for an unknown session', async () => { | |
| 70 | + const res = await app.inject({ method: 'POST', url: '/api/sessions/nonexistent/generate', payload: {} }) | |
| 71 | + expect(res.statusCode).toBe(404) | |
| 72 | + }) | |
| 73 | + | |
| 74 | + it('returns 400 when the target directory does not exist', async () => { | |
| 75 | + const created = await app | |
| 76 | + .inject({ | |
| 77 | + method: 'POST', | |
| 78 | + url: '/api/sessions', | |
| 79 | + payload: { name: 'p', targetDir: path.join(targetDir, 'does-not-exist') }, | |
| 80 | + }) | |
| 81 | + .then((r) => r.json<Session>()) | |
| 82 | + const res = await app.inject({ method: 'POST', url: `/api/sessions/${created.id}/generate`, payload: {} }) | |
| 83 | + expect(res.statusCode).toBe(400) | |
| 84 | + }) | |
| 85 | +}) |
added server/routes/generate.ts +34 −0
| @@ -0,0 +1,34 @@ | ||
| 1 | +import type { FastifyInstance } from 'fastify' | |
| 2 | +import { GenerateRequestSchema } from '../../shared/types' | |
| 3 | +import { GenerateFilesExistError, generateSpecPack } from '../generator/generate' | |
| 4 | +import type { InterviewLlm } from '../providers/types' | |
| 5 | +import type { SessionStore } from '../store/sessionStore' | |
| 6 | + | |
| 7 | +export interface GenerateRouteDeps { | |
| 8 | + store: SessionStore | |
| 9 | + llm: InterviewLlm | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function registerGenerateRoutes(app: FastifyInstance, deps: GenerateRouteDeps): void { | |
| 13 | + const { store, llm } = deps | |
| 14 | + | |
| 15 | + app.post<{ Params: { id: string } }>('/api/sessions/:id/generate', async (request, reply) => { | |
| 16 | + const parsed = GenerateRequestSchema.safeParse(request.body ?? {}) | |
| 17 | + if (!parsed.success) { | |
| 18 | + return reply.code(400).send({ error: parsed.error.message }) | |
| 19 | + } | |
| 20 | + | |
| 21 | + const session = await store.getSession(request.params.id) | |
| 22 | + if (!session) return reply.code(404).send({ error: 'session not found' }) | |
| 23 | + | |
| 24 | + try { | |
| 25 | + const result = await generateSpecPack(llm, session, { overwrite: parsed.data.overwrite }) | |
| 26 | + return reply.send(result) | |
| 27 | + } catch (err) { | |
| 28 | + if (err instanceof GenerateFilesExistError) { | |
| 29 | + return reply.code(409).send({ error: err.message }) | |
| 30 | + } | |
| 31 | + return reply.code(400).send({ error: err instanceof Error ? err.message : 'generation failed' }) | |
| 32 | + } | |
| 33 | + }) | |
| 34 | +} |
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 +50 −0
| @@ -0,0 +1,50 @@ | ||
| 1 | +import type { FastifyInstance } from 'fastify' | |
| 2 | +import { AnswerRequestSchema, CreateSessionRequestSchema } from '../../shared/types' | |
| 3 | +import { SessionNotFoundError, submitAnswer } from '../engine/turn' | |
| 4 | +import type { InterviewLlm } from '../providers/types' | |
| 5 | +import type { SessionStore } from '../store/sessionStore' | |
| 6 | + | |
| 7 | +export interface SessionRouteDeps { | |
| 8 | + store: SessionStore | |
| 9 | + llm: InterviewLlm | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function registerSessionRoutes(app: FastifyInstance, deps: SessionRouteDeps): void { | |
| 13 | + const { store, llm } = deps | |
| 14 | + | |
| 15 | + app.post('/api/sessions', async (request, reply) => { | |
| 16 | + const parsed = CreateSessionRequestSchema.safeParse(request.body) | |
| 17 | + if (!parsed.success) { | |
| 18 | + return reply.code(400).send({ error: parsed.error.message }) | |
| 19 | + } | |
| 20 | + const session = await store.createSession(parsed.data.name, parsed.data.targetDir) | |
| 21 | + return reply.code(201).send(session) | |
| 22 | + }) | |
| 23 | + | |
| 24 | + app.get('/api/sessions', async () => { | |
| 25 | + return store.listSessions() | |
| 26 | + }) | |
| 27 | + | |
| 28 | + app.get<{ Params: { id: string } }>('/api/sessions/:id', async (request, reply) => { | |
| 29 | + const session = await store.getSession(request.params.id) | |
| 30 | + if (!session) return reply.code(404).send({ error: 'session not found' }) | |
| 31 | + return session | |
| 32 | + }) | |
| 33 | + | |
| 34 | + app.post<{ Params: { id: string } }>('/api/sessions/:id/answer', async (request, reply) => { | |
| 35 | + const parsed = AnswerRequestSchema.safeParse(request.body) | |
| 36 | + if (!parsed.success) { | |
| 37 | + return reply.code(400).send({ error: parsed.error.message }) | |
| 38 | + } | |
| 39 | + | |
| 40 | + try { | |
| 41 | + const response = await submitAnswer(store, llm, request.params.id, parsed.data.text) | |
| 42 | + return reply.send(response) | |
| 43 | + } catch (err) { | |
| 44 | + if (err instanceof SessionNotFoundError) { | |
| 45 | + return reply.code(404).send({ error: 'session not found' }) | |
| 46 | + } | |
| 47 | + throw err | |
| 48 | + } | |
| 49 | + }) | |
| 50 | +} |
added server/smoke.test.ts +81 −0
| @@ -0,0 +1,81 @@ | ||
| 1 | +import { mkdtemp, readFile, rm } from 'node:fs/promises' | |
| 2 | +import { tmpdir } from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | |
| 5 | +import { CATEGORY_IDS, type AnswerResponse, type GenerateResponse, type Session } from '../shared/types' | |
| 6 | +import { buildApp } from './app' | |
| 7 | +import { createLlmMock } from './providers/llmMock' | |
| 8 | +import { SessionStore } from './store/sessionStore' | |
| 9 | + | |
| 10 | +describe('end-to-end smoke test', () => { | |
| 11 | + let storeDir: string | |
| 12 | + let targetDir: string | |
| 13 | + | |
| 14 | + beforeAll(async () => { | |
| 15 | + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-smoke-store-')) | |
| 16 | + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-smoke-target-')) | |
| 17 | + }) | |
| 18 | + | |
| 19 | + afterAll(async () => { | |
| 20 | + await rm(storeDir, { recursive: true, force: true }) | |
| 21 | + await rm(targetDir, { recursive: true, force: true }) | |
| 22 | + }) | |
| 23 | + | |
| 24 | + it('interviews to completion with mocks and generates a valid spec pack', async () => { | |
| 25 | + const app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() }) | |
| 26 | + | |
| 27 | + const session = await app | |
| 28 | + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'smoke project', targetDir } }) | |
| 29 | + .then((r) => r.json<Session>()) | |
| 30 | + | |
| 31 | + let done = false | |
| 32 | + for (let i = 0; i < CATEGORY_IDS.length && !done; i++) { | |
| 33 | + const res = await app.inject({ | |
| 34 | + method: 'POST', | |
| 35 | + url: `/api/sessions/${session.id}/answer`, | |
| 36 | + payload: { text: `answer number ${i + 1}` }, | |
| 37 | + }) | |
| 38 | + expect(res.statusCode).toBe(200) | |
| 39 | + done = res.json<AnswerResponse>().turn.done | |
| 40 | + } | |
| 41 | + expect(done).toBe(true) | |
| 42 | + | |
| 43 | + const generateRes = await app.inject({ | |
| 44 | + method: 'POST', | |
| 45 | + url: `/api/sessions/${session.id}/generate`, | |
| 46 | + payload: {}, | |
| 47 | + }) | |
| 48 | + expect(generateRes.statusCode).toBe(200) | |
| 49 | + const generated = generateRes.json<GenerateResponse>() | |
| 50 | + | |
| 51 | + const expectedFiles = [ | |
| 52 | + 'spec/SPEC.md', | |
| 53 | + 'spec/PLAN.md', | |
| 54 | + 'spec/TASKS.md', | |
| 55 | + 'spec/VERIFICATION.md', | |
| 56 | + 'spec/HANDOFF.md', | |
| 57 | + 'spec/sources.json', | |
| 58 | + ] | |
| 59 | + expect(generated.files.sort()).toEqual([...expectedFiles].sort()) | |
| 60 | + | |
| 61 | + const sources = JSON.parse(await readFile(path.join(targetDir, 'spec/sources.json'), 'utf8')) as Record< | |
| 62 | + string, | |
| 63 | + { text: string; ts: string } | |
| 64 | + > | |
| 65 | + | |
| 66 | + const markerFiles = ['SPEC.md', 'PLAN.md', 'TASKS.md', 'VERIFICATION.md', 'HANDOFF.md'] | |
| 67 | + for (const file of markerFiles) { | |
| 68 | + const content = await readFile(path.join(targetDir, 'spec', file), 'utf8') | |
| 69 | + const markers = [...content.matchAll(/\[S(\d+)\]/g)] | |
| 70 | + for (const marker of markers) { | |
| 71 | + const segmentId = `S${marker[1]}` | |
| 72 | + expect(sources).toHaveProperty(segmentId) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + const handoff = await readFile(path.join(targetDir, 'spec/HANDOFF.md'), 'utf8') | |
| 77 | + expect(handoff).toContain('claude') | |
| 78 | + | |
| 79 | + await app.close() | |
| 80 | + }) | |
| 81 | +}) |
added server/store/sessionStore.test.ts +82 −0
| @@ -0,0 +1,82 @@ | ||
| 1 | +import { mkdtemp, rm } from 'node:fs/promises' | |
| 2 | +import { tmpdir } from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 5 | +import { initialCoverage } from '../../shared/types' | |
| 6 | +import { SessionStore } from './sessionStore' | |
| 7 | + | |
| 8 | +describe('SessionStore', () => { | |
| 9 | + let baseDir: string | |
| 10 | + let store: SessionStore | |
| 11 | + | |
| 12 | + beforeEach(async () => { | |
| 13 | + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-')) | |
| 14 | + store = new SessionStore(baseDir) | |
| 15 | + }) | |
| 16 | + | |
| 17 | + afterEach(async () => { | |
| 18 | + await rm(baseDir, { recursive: true, force: true }) | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it('creates a session and reloads it with matching data', async () => { | |
| 22 | + const created = await store.createSession('my project', '/tmp/target') | |
| 23 | + expect(created.name).toBe('my project') | |
| 24 | + expect(created.targetDir).toBe('/tmp/target') | |
| 25 | + expect(created.status).toBe('interviewing') | |
| 26 | + expect(created.coverage).toEqual(initialCoverage()) | |
| 27 | + | |
| 28 | + const reloaded = await store.getSession(created.id) | |
| 29 | + expect(reloaded).toEqual(created) | |
| 30 | + }) | |
| 31 | + | |
| 32 | + it('returns null for an unknown session id', async () => { | |
| 33 | + expect(await store.getSession('does-not-exist')).toBeNull() | |
| 34 | + }) | |
| 35 | + | |
| 36 | + it('assigns sequential segment ids S1..Sn', async () => { | |
| 37 | + const session = await store.createSession('proj', '/tmp/target') | |
| 38 | + const first = await store.appendSegment(session.id, 'user', 'hello') | |
| 39 | + expect(first.segment.id).toBe('S1') | |
| 40 | + const second = await store.appendSegment(session.id, 'interviewer', 'what next?') | |
| 41 | + expect(second.segment.id).toBe('S2') | |
| 42 | + const third = await store.appendSegment(session.id, 'user', 'more') | |
| 43 | + expect(third.segment.id).toBe('S3') | |
| 44 | + | |
| 45 | + const reloaded = await store.getSession(session.id) | |
| 46 | + expect(reloaded?.segments.map((s) => s.id)).toEqual(['S1', 'S2', 'S3']) | |
| 47 | + expect(reloaded?.segments.map((s) => s.text)).toEqual(['hello', 'what next?', 'more']) | |
| 48 | + }) | |
| 49 | + | |
| 50 | + it('updates coverage, summary, and status', async () => { | |
| 51 | + const session = await store.createSession('proj', '/tmp/target') | |
| 52 | + const coverage = { ...initialCoverage(), goal: 'clear' as const } | |
| 53 | + const updated = await store.updateTurn(session.id, { | |
| 54 | + coverage, | |
| 55 | + summary: 'a running summary', | |
| 56 | + status: 'done', | |
| 57 | + }) | |
| 58 | + expect(updated.coverage).toEqual(coverage) | |
| 59 | + expect(updated.summary).toBe('a running summary') | |
| 60 | + expect(updated.status).toBe('done') | |
| 61 | + }) | |
| 62 | + | |
| 63 | + it('lists sessions with id, name, status only', async () => { | |
| 64 | + const a = await store.createSession('alpha', '/tmp/a') | |
| 65 | + const b = await store.createSession('beta', '/tmp/b') | |
| 66 | + await store.updateTurn(b.id, { status: 'done' }) | |
| 67 | + | |
| 68 | + const list = await store.listSessions() | |
| 69 | + expect(list).toHaveLength(2) | |
| 70 | + expect(list).toEqual( | |
| 71 | + expect.arrayContaining([ | |
| 72 | + { id: a.id, name: 'alpha', status: 'interviewing' }, | |
| 73 | + { id: b.id, name: 'beta', status: 'done' }, | |
| 74 | + ]), | |
| 75 | + ) | |
| 76 | + }) | |
| 77 | + | |
| 78 | + it('lists no sessions when the base directory does not exist yet', async () => { | |
| 79 | + const emptyStore = new SessionStore(path.join(baseDir, 'nonexistent')) | |
| 80 | + expect(await emptyStore.listSessions()).toEqual([]) | |
| 81 | + }) | |
| 82 | +}) |
added server/store/sessionStore.ts +122 −0
| @@ -0,0 +1,122 @@ | ||
| 1 | +import { randomUUID } from 'node:crypto' | |
| 2 | +import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { | |
| 5 | + SessionSchema, | |
| 6 | + initialCoverage, | |
| 7 | + type Coverage, | |
| 8 | + type Segment, | |
| 9 | + type Session, | |
| 10 | + type SessionStatus, | |
| 11 | + type SessionSummary, | |
| 12 | + type Speaker, | |
| 13 | +} from '../../shared/types' | |
| 14 | + | |
| 15 | +export class SessionStore { | |
| 16 | + constructor(private readonly baseDir: string) {} | |
| 17 | + | |
| 18 | + private sessionDir(id: string): string { | |
| 19 | + return path.join(this.baseDir, id) | |
| 20 | + } | |
| 21 | + | |
| 22 | + private sessionFile(id: string): string { | |
| 23 | + return path.join(this.sessionDir(id), 'session.json') | |
| 24 | + } | |
| 25 | + | |
| 26 | + private async writeSession(session: Session): Promise<void> { | |
| 27 | + const dir = this.sessionDir(session.id) | |
| 28 | + await mkdir(dir, { recursive: true }) | |
| 29 | + const file = this.sessionFile(session.id) | |
| 30 | + const tmp = path.join(dir, `.session-${randomUUID()}.tmp`) | |
| 31 | + await writeFile(tmp, JSON.stringify(session, null, 2), 'utf8') | |
| 32 | + await rename(tmp, file) | |
| 33 | + } | |
| 34 | + | |
| 35 | + async createSession(name: string, targetDir: string): Promise<Session> { | |
| 36 | + const session: Session = { | |
| 37 | + id: randomUUID(), | |
| 38 | + name, | |
| 39 | + targetDir, | |
| 40 | + createdAt: new Date().toISOString(), | |
| 41 | + segments: [], | |
| 42 | + coverage: initialCoverage(), | |
| 43 | + summary: '', | |
| 44 | + status: 'interviewing', | |
| 45 | + openBlockers: [], | |
| 46 | + } | |
| 47 | + await this.writeSession(session) | |
| 48 | + return session | |
| 49 | + } | |
| 50 | + | |
| 51 | + async getSession(id: string): Promise<Session | null> { | |
| 52 | + try { | |
| 53 | + const raw = await readFile(this.sessionFile(id), 'utf8') | |
| 54 | + return SessionSchema.parse(JSON.parse(raw)) | |
| 55 | + } catch (err) { | |
| 56 | + if (isNotFound(err)) return null | |
| 57 | + throw err | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + async listSessions(): Promise<SessionSummary[]> { | |
| 62 | + let entries: string[] | |
| 63 | + try { | |
| 64 | + entries = await readdir(this.baseDir) | |
| 65 | + } catch (err) { | |
| 66 | + if (isNotFound(err)) return [] | |
| 67 | + throw err | |
| 68 | + } | |
| 69 | + const summaries: SessionSummary[] = [] | |
| 70 | + for (const id of entries) { | |
| 71 | + const session = await this.getSession(id) | |
| 72 | + if (session) summaries.push({ id: session.id, name: session.name, status: session.status }) | |
| 73 | + } | |
| 74 | + return summaries | |
| 75 | + } | |
| 76 | + | |
| 77 | + async appendSegment( | |
| 78 | + id: string, | |
| 79 | + speaker: Speaker, | |
| 80 | + text: string, | |
| 81 | + ): Promise<{ session: Session; segment: Segment }> { | |
| 82 | + const session = await this.getSession(id) | |
| 83 | + if (!session) throw new Error(`session not found: ${id}`) | |
| 84 | + const segment: Segment = { | |
| 85 | + id: `S${session.segments.length + 1}`, | |
| 86 | + ts: new Date().toISOString(), | |
| 87 | + speaker, | |
| 88 | + text, | |
| 89 | + } | |
| 90 | + session.segments.push(segment) | |
| 91 | + await this.writeSession(session) | |
| 92 | + return { session, segment } | |
| 93 | + } | |
| 94 | + | |
| 95 | + async updateTurn( | |
| 96 | + id: string, | |
| 97 | + update: { | |
| 98 | + coverage?: Coverage | |
| 99 | + summary?: string | |
| 100 | + status?: SessionStatus | |
| 101 | + openBlockers?: string[] | |
| 102 | + }, | |
| 103 | + ): Promise<Session> { | |
| 104 | + const session = await this.getSession(id) | |
| 105 | + if (!session) throw new Error(`session not found: ${id}`) | |
| 106 | + if (update.coverage) session.coverage = update.coverage | |
| 107 | + if (update.summary !== undefined) session.summary = update.summary | |
| 108 | + if (update.status) session.status = update.status | |
| 109 | + if (update.openBlockers) session.openBlockers = update.openBlockers | |
| 110 | + await this.writeSession(session) | |
| 111 | + return session | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +function isNotFound(err: unknown): boolean { | |
| 116 | + return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: string }).code === 'ENOENT' | |
| 117 | +} | |
| 118 | + | |
| 119 | +export function createDefaultSessionStore(): SessionStore { | |
| 120 | + const baseDir = process.env.DATA_DIR ?? path.join(process.cwd(), 'data', 'sessions') | |
| 121 | + return new SessionStore(baseDir) | |
| 122 | +} |
added server/tsconfig.json +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "bundler", | |
| 6 | + "lib": ["ES2022"], | |
| 7 | + "types": ["node"], | |
| 8 | + "outDir": "../dist/server", | |
| 9 | + "rootDir": "..", | |
| 10 | + "noEmit": false | |
| 11 | + }, | |
| 12 | + "include": ["**/*.ts", "../shared/**/*.ts"], | |
| 13 | + "exclude": ["**/*.test.ts", "../shared/**/*.test.ts"] | |
| 14 | +} |
added shared/index.ts +1 −0
| @@ -0,0 +1 @@ | ||
| 1 | +export * from './types' |
added shared/tsconfig.json +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "module": "ESNext", | |
| 5 | + "lib": ["ES2022"], | |
| 6 | + "noEmit": true, | |
| 7 | + "types": ["node"] | |
| 8 | + }, | |
| 9 | + "include": ["**/*.ts"] | |
| 10 | +} |
added shared/types.ts +114 −0
| @@ -0,0 +1,114 @@ | ||
| 1 | +import { z } from 'zod' | |
| 2 | + | |
| 3 | +export const CATEGORY_IDS = [ | |
| 4 | + 'goal', | |
| 5 | + 'users', | |
| 6 | + 'core-flow', | |
| 7 | + 'data', | |
| 8 | + 'integrations', | |
| 9 | + 'edge-cases', | |
| 10 | + 'constraints', | |
| 11 | + 'non-goals', | |
| 12 | + 'verification', | |
| 13 | +] as const | |
| 14 | + | |
| 15 | +export type CategoryId = (typeof CATEGORY_IDS)[number] | |
| 16 | + | |
| 17 | +export const CoverageLevelSchema = z.enum(['missing', 'partial', 'clear']) | |
| 18 | +export type CoverageLevel = z.infer<typeof CoverageLevelSchema> | |
| 19 | + | |
| 20 | +const coverageShape = Object.fromEntries( | |
| 21 | + CATEGORY_IDS.map((category) => [category, CoverageLevelSchema]), | |
| 22 | +) as Record<CategoryId, typeof CoverageLevelSchema> | |
| 23 | + | |
| 24 | +export const CoverageSchema = z.object(coverageShape) | |
| 25 | +export type Coverage = z.infer<typeof CoverageSchema> | |
| 26 | + | |
| 27 | +export function initialCoverage(): Coverage { | |
| 28 | + return Object.fromEntries(CATEGORY_IDS.map((category) => [category, 'missing'])) as Coverage | |
| 29 | +} | |
| 30 | + | |
| 31 | +export const SpeakerSchema = z.enum(['user', 'interviewer']) | |
| 32 | +export type Speaker = z.infer<typeof SpeakerSchema> | |
| 33 | + | |
| 34 | +export const SegmentSchema = z.object({ | |
| 35 | + id: z.string(), | |
| 36 | + ts: z.string(), | |
| 37 | + speaker: SpeakerSchema, | |
| 38 | + text: z.string(), | |
| 39 | +}) | |
| 40 | +export type Segment = z.infer<typeof SegmentSchema> | |
| 41 | + | |
| 42 | +export const SessionStatusSchema = z.enum(['interviewing', 'done']) | |
| 43 | +export type SessionStatus = z.infer<typeof SessionStatusSchema> | |
| 44 | + | |
| 45 | +export const SessionSchema = z.object({ | |
| 46 | + id: z.string(), | |
| 47 | + name: z.string(), | |
| 48 | + targetDir: z.string(), | |
| 49 | + createdAt: z.string(), | |
| 50 | + segments: z.array(SegmentSchema), | |
| 51 | + coverage: CoverageSchema, | |
| 52 | + summary: z.string(), | |
| 53 | + status: SessionStatusSchema, | |
| 54 | + openBlockers: z.array(z.string()), | |
| 55 | +}) | |
| 56 | +export type Session = z.infer<typeof SessionSchema> | |
| 57 | + | |
| 58 | +export const SessionSummarySchema = SessionSchema.pick({ id: true, name: true, status: true }) | |
| 59 | +export type SessionSummary = z.infer<typeof SessionSummarySchema> | |
| 60 | + | |
| 61 | +export const ContradictionSchema = z.object({ | |
| 62 | + segmentIds: z.array(z.string()), | |
| 63 | + description: z.string(), | |
| 64 | +}) | |
| 65 | +export type Contradiction = z.infer<typeof ContradictionSchema> | |
| 66 | + | |
| 67 | +export const InterviewTurnSchema = z.object({ | |
| 68 | + coverage: CoverageSchema, | |
| 69 | + nextQuestion: z.string(), | |
| 70 | + contradiction: ContradictionSchema.nullable(), | |
| 71 | + done: z.boolean(), | |
| 72 | + summaryUpdate: z.string().nullable(), | |
| 73 | +}) | |
| 74 | +export type InterviewTurn = z.infer<typeof InterviewTurnSchema> | |
| 75 | + | |
| 76 | +// API payloads | |
| 77 | + | |
| 78 | +export const CreateSessionRequestSchema = z.object({ | |
| 79 | + name: z.string().min(1), | |
| 80 | + targetDir: z.string().min(1), | |
| 81 | +}) | |
| 82 | +export type CreateSessionRequest = z.infer<typeof CreateSessionRequestSchema> | |
| 83 | + | |
| 84 | +export const AnswerRequestSchema = z.object({ | |
| 85 | + text: z.string().min(1), | |
| 86 | +}) | |
| 87 | +export type AnswerRequest = z.infer<typeof AnswerRequestSchema> | |
| 88 | + | |
| 89 | +export const AnswerResponseSchema = z.object({ | |
| 90 | + segment: SegmentSchema, | |
| 91 | + turn: InterviewTurnSchema, | |
| 92 | +}) | |
| 93 | +export type AnswerResponse = z.infer<typeof AnswerResponseSchema> | |
| 94 | + | |
| 95 | +export const GenerateRequestSchema = z.object({ | |
| 96 | + overwrite: z.boolean().optional(), | |
| 97 | +}) | |
| 98 | +export type GenerateRequest = z.infer<typeof GenerateRequestSchema> | |
| 99 | + | |
| 100 | +export const GenerateResponseSchema = z.object({ | |
| 101 | + files: z.array(z.string()), | |
| 102 | + warnings: z.array(z.string()), | |
| 103 | +}) | |
| 104 | +export type GenerateResponse = z.infer<typeof GenerateResponseSchema> | |
| 105 | + | |
| 106 | +export const BlockersResponseSchema = z.object({ | |
| 107 | + questions: z.array(z.string()), | |
| 108 | +}) | |
| 109 | +export type BlockersResponse = z.infer<typeof BlockersResponseSchema> | |
| 110 | + | |
| 111 | +export const ErrorResponseSchema = z.object({ | |
| 112 | + error: z.string(), | |
| 113 | +}) | |
| 114 | +export type ErrorResponse = z.infer<typeof ErrorResponseSchema> |
modified spec/TASKS.md +12 −12
| @@ -2,62 +2,62 @@ | ||
| 2 | 2 | |
| 3 | 3 | Work strictly in order unless a task's Depends line allows otherwise. One task at a time. Run the Verify commands before checking a task off. All tests run with `MOCK_PROVIDERS=1` (vitest config sets it globally). |
| 4 | 4 | |
| 5 | -- [ ] T1 Scaffold | |
| 5 | +- [x] T1 Scaffold | |
| 6 | 6 | - npm package with `server/`, `client/` (Vite React TS template), `shared/`. Scripts: `dev` (server + Vite concurrently), `build`, `typecheck` (tsc --noEmit over all three), `test` (vitest run). Add zod, fastify + plugins, @anthropic-ai/sdk, vitest. One placeholder test that asserts true. |
| 7 | 7 | - Depends: nothing |
| 8 | 8 | - Verify: `npm run typecheck` and `npm test` and `npm run build` all exit 0. |
| 9 | 9 | |
| 10 | -- [ ] T2 Shared types and session store | |
| 10 | +- [x] T2 Shared types and session store | |
| 11 | 11 | - Implement `shared/` types + zod schemas from PLAN.md. Implement `server/store/sessionStore.ts`: create, get, list, append segment, update coverage/summary/status, persisted to `data/sessions/<id>/session.json` atomically (write temp file, rename). |
| 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). |
| 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). |
| 24 | 24 | |
| 25 | -- [ ] T5 Interview engine | |
| 25 | +- [x] T5 Interview engine | |
| 26 | 26 | - `server/engine/`: coverage state handling, prompt construction (summary + last 40 segments), summary maintenance, contradiction passthrough from the LLM response, weakest-category question selection enforced in the prompt. Anthropic implementation `llmAnthropic.ts` per PLAN.md (compiles and is unit-tested for prompt construction only; no network in tests). |
| 27 | 27 | - Depends: T4 |
| 28 | 28 | - Verify: `npm test` (engine tests with llmMock: coverage progresses in category order, >40 segments triggers summary path, contradiction from mock is surfaced). |
| 29 | 29 | |
| 30 | -- [ ] T6 Audio route and OpenAI STT | |
| 30 | +- [x] T6 Audio route and OpenAI STT | |
| 31 | 31 | - `@fastify/multipart` upload route `POST /api/sessions/:id/audio`, `sttOpenai.ts` per PLAN.md, empty-transcript rejection, provider errors mapped to 502 with `{error}`. |
| 32 | 32 | - Depends: T4 |
| 33 | 33 | - Verify: `npm test` (audio route with sttMock: uploaded text buffer becomes a segment and triggers a turn; empty buffer returns 4xx and stores nothing). |
| 34 | 34 | |
| 35 | -- [ ] T7 Client: interview UI | |
| 35 | +- [x] T7 Client: interview UI | |
| 36 | 36 | - Session list/create screen, interview screen with Transcript, QuestionCard, CoveragePanel, text input, Done button. Typed api.ts wrappers. Vite dev proxy to the server. |
| 37 | 37 | - Depends: T4 (T5 makes it meaningful, but the API contract is enough to build against) |
| 38 | 38 | - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: `npm run dev` with MOCK_PROVIDERS=1, typing answers advances coverage in the panel. |
| 39 | 39 | |
| 40 | -- [ ] T8 Client: push-to-talk and TTS | |
| 40 | +- [x] T8 Client: push-to-talk and TTS | |
| 41 | 41 | - MediaRecorder hold-to-record button (spacebar and mouse), upload to the audio route, recording state indicator. TTS toggle speaking each new interviewer question via speechSynthesis. |
| 42 | 42 | - Depends: T6, T7 |
| 43 | 43 | - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: recording in Chrome produces a segment (mock mode). |
| 44 | 44 | |
| 45 | -- [ ] T9 Spec pack generator | |
| 45 | +- [x] T9 Spec pack generator | |
| 46 | 46 | - `server/generator/`: five per-file prompts, generation orchestration through the InterviewLlm provider, `sources.json` emission, provenance validation per FR-013, existing-file refusal + `overwrite` flag + backup to `spec/backup-<timestamp>/` on regenerate. |
| 47 | 47 | - Depends: T5 |
| 48 | 48 | - Verify: `npm test` (with llmMock into a temp dir: all six files written; bogus `[S999]` marker removed and `[unverified]` appended; second run without overwrite fails; with overwrite creates backup dir). |
| 49 | 49 | |
| 50 | -- [ ] T10 Generate route and UI | |
| 50 | +- [x] T10 Generate route and UI | |
| 51 | 51 | - `POST /api/sessions/:id/generate`, GeneratePanel with missing-category confirmation dialog per FR-008, result/warnings display. |
| 52 | 52 | - Depends: T7, T9 |
| 53 | 53 | - Verify: `npm test` (route test) and `npm run typecheck`. |
| 54 | 54 | |
| 55 | -- [ ] T11 Blocker import | |
| 55 | +- [x] T11 Blocker import | |
| 56 | 56 | - `server/blockers/parse.ts` for the BLOCKED.md format defined in the repo root `BLOCKED.md` template, `POST /api/sessions/:id/blockers`, engine mode that asks only imported questions, UI entry point. Missing/empty file handled per SPEC edge case. |
| 57 | 57 | - Depends: T5, T7 |
| 58 | 58 | - Verify: `npm test` (parser tests incl. empty file; route test: import then next turn asks a blocker question). |
| 59 | 59 | |
| 60 | -- [ ] T12 End-to-end smoke test | |
| 60 | +- [x] T12 End-to-end smoke test | |
| 61 | 61 | - Single vitest test: boot the server with mocks, create session (temp target dir), answer until done via the answer route, generate, assert all six files exist, all remaining `[S<n>]` markers resolve against sources.json, and HANDOFF.md contains the string `claude`. |
| 62 | 62 | - Depends: T9, T10 |
| 63 | 63 | - Verify: `npm test` runs it green; then `npm run typecheck`, `npm test`, `npm run build` all exit 0 as the final full check. |
added tsconfig.base.json +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "strict": true, | |
| 4 | + "target": "ES2022", | |
| 5 | + "moduleResolution": "bundler", | |
| 6 | + "esModuleInterop": true, | |
| 7 | + "forceConsistentCasingInFileNames": true, | |
| 8 | + "skipLibCheck": true, | |
| 9 | + "resolveJsonModule": true, | |
| 10 | + "noUnusedLocals": true, | |
| 11 | + "noUnusedParameters": true, | |
| 12 | + "noFallthroughCasesInSwitch": true | |
| 13 | + } | |
| 14 | +} |
added vitest.config.ts +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +import { defineConfig } from 'vitest/config' | |
| 2 | + | |
| 3 | +process.env.MOCK_PROVIDERS = '1' | |
| 4 | + | |
| 5 | +export default defineConfig({ | |
| 6 | + test: { | |
| 7 | + environment: 'node', | |
| 8 | + include: ['server/**/*.test.ts', 'shared/**/*.test.ts'], | |
| 9 | + }, | |
| 10 | +}) |