voice-notes / DESIGN.md
osina's picture
Build Voice Notes demo Space
64c1bdf verified
|
Raw History Blame Contribute Delete
8.96 kB
# Design Note
Voice Notes is a FastAPI + SQLite service with self-hosted `faster-whisper` STT, a
provider-agnostic OpenAI-compatible LLM, and a vanilla JS dashboard. README covers
setup and API usage; this note covers the important design decisions.
## Architecture
```text
Browser: upload / live PCM / typed text
| | |
ffmpeg wrap WAV |
\__________/ |
| |
faster-whisper |
\_____________________/
|
Note saved before the LLM
|
LLM -> Pydantic validation -> one repair retry
|
task action -> {note, intent, action}
```
- `app/api` handles HTTP validation, status codes, pagination, and WebSocket setup.
- `app/services` contains orchestration, ffmpeg/STT, LLM extraction, task resolution,
and streaming.
- `app/repositories.py` owns every SQLAlchemy query; `app/models.py` defines `notes` and
`tasks`.
- STT and LLM sit behind `Transcriber` and `IntentExtractor` protocols, so tests use
fakes instead of downloading a model or calling the network.
## Key invariants
1. A successful transcription becomes a committed note **before** LLM extraction.
2. A failed LLM or task action never deletes the transcript; the note keeps
`processing_error`.
3. Task mutation and note linking commit together, or roll back together.
4. The final full transcription is authoritative; live partials are provisional.
5. Task lookup is deterministic and never guesses on ambiguity.
6. Timestamps are UTC; due dates are date-only.
## Data model
| Table | Important fields |
|---|---|
| `notes` | `id`, `raw_transcript`, `created_at`, `source`, `task_id`, `intent`, `processing_error` |
| `tasks` | `id`, `title`, `description`, `status`, `priority`, `due_date`, `created_at`, `updated_at` |
A note optionally links to one task. Deleting a task sets `note.task_id` to `NULL`;
notes are never cascade-deleted. SQLite foreign-key enforcement is enabled per
connection so that behavior actually holds.
## Request flows
**Voice upload:** size-check the upload, convert to 16 kHz mono WAV with ffmpeg,
transcribe with faster-whisper, persist the note, extract the intent, execute the
action, return `{note, intent, action}`. If there is no transcript, no note is created.
If the LLM or task action fails after transcription, the note remains.
**Text:** `POST /api/notes` joins the same pipeline after note creation with
`source=text`. Voice and text share validation, extraction, and task behavior.
**Live stream:** the browser sends 16 kHz mono `int16` PCM over
`WS /api/voice/stream`. The server buffers it, emits provisional partials from rolling
10-second chunks, and on `stop` transcribes the complete recording before running the
normal pipeline. `cancel` or disconnect discards the buffer.
## LLM prompt and validation
The client uses only the common OpenAI-compatible contract:
`POST {LLM_BASE_URL}/chat/completions`, reading `choices[0].message.content`. It does
not require tools, streaming, or provider-specific structured output.
The system prompt includes:
- current UTC datetime plus tomorrow and the current week bounds;
- definitions of `CREATE_TASK`, `UPDATE_TASK_STATUS`, `QUERY_TASKS`, `GENERAL_NOTE`;
- the exact JSON shape and per-intent rules;
- "use `null` instead of guessing" instructions;
- a prompt-injection guard treating the transcript as data;
- four short few-shot examples with dynamic dates.
Output handling:
1. Strip optional markdown fences, take the first-to-last brace span, and require a
JSON object.
2. Validate with Pydantic: enums, dates, non-blank fields, date ranges, and
cross-field payload rules (for example, `CREATE_TASK` requires `task` and must not
include `update`/`query`).
3. On invalid JSON or schema errors, append the bad output and error to the prompt and
ask for corrected JSON once. Default is two attempts total.
4. Terminal validation failure returns `422 intent_extraction_failed` with the
persisted note attached.
5. Network, timeout, HTTP, malformed-envelope, empty-content, and unconfigured LLM
failures return `503 llm_unavailable` and are not retried.
Using one repair retry keeps latency and cost bounded while fixing the common case:
formatting mistakes. Availability failures belong to infrastructure, not prompt repair.
## Intent execution
| Intent | Behavior |
|---|---|
| `CREATE_TASK` | Creates a `PENDING` task; missing priority defaults to `MEDIUM`; links the note. |
| `UPDATE_TASK_STATUS` | Resolves exact ID first, then normalized title; updates and links the note. |
| `QUERY_TASKS` | Applies status/priority/due filters, returns up to 100 tasks plus total; note is not linked. |
| `GENERAL_NOTE` | Saves the note only. |
Task resolution lowercases and strips punctuation, removes common stop words, and
compares the reference to task titles using substring or token overlap. The best score
wins; ties return `409` with candidate IDs/titles. No match returns `404`. Cancelled
tasks are excluded from fuzzy matching but can still be found by exact ID. Guessing
would be worse than asking the client to disambiguate.
## API and errors
The API exposes health, voice upload, WebSocket streaming, text notes, paginated notes
with source/date/task filters, and paginated task reads/updates/deletes with
status/priority/due/title filters. Full request/response details are in the README.
REST errors use `{"error": {"code", "message", "details"}}`; WebSocket errors use
`{"type": "error", "error": {...}}`. Common statuses are `413` (too large), `422`
(validation/audio/intent failure), `503` (LLM unavailable), `404` (missing), `409`
(ambiguous task), and `500` (unexpected error). Intent-related failures attach the
persisted note at the top level of the response.
## STT, streaming, and runtime
- faster-whisper is lazy-loaded and cached; inference is serialized with a lock.
- Uploads use ffmpeg normalization, VAD, and the configured beam size.
- Live partials re-transcribe a rolling window with `beam_size=1`; the final pass uses
the normal beam size. This is not word-by-word streaming, but it gives useful
feedback without a streaming STT server.
- If `AudioWorklet` or the WebSocket is unavailable, the dashboard falls back to
`MediaRecorder`/file upload.
- SQLite uses `create_all` at startup; there is no migration tool. All queries are in
the repository layer, so Postgres can replace it later.
- Sessions are per request; `expire_on_commit=False` keeps response serialization
simple. `GET /health` does not depend on the LLM, so the dashboard and docs are
usable before configuration.
## Security
Implemented: prompt-injection guard, escaped dashboard output, SQLAlchemy parameter
binding, temporary upload files with size/time limits, and ignored `.env` secrets.
Not implemented and intentionally out of scope: authentication, per-user isolation,
rate limiting, CSRF protection (no cookie auth), moderation, and audit logs. Do not
expose this service publicly without adding auth and rate limits.
## Testing
The suite has 40 offline tests using fake STT/LLM adapters; it never downloads Whisper
or calls a network LLM. It covers voice/text pipelines, all intents, filters and
pagination, ambiguous task references, invalid LLM output and note preservation, upload
limits, deletion behavior, frontend/static serving, and WebSocket streaming. Coverage
is about 88% of `app/`; the gaps are mostly real adapters and error branches. The real
adapters were smoke-tested manually with `samples/create_task.wav`.
Run `uv run pytest`, `uv run ruff check .`, and `uv run ruff format --check .`. See the
README for the quick start and end-to-end curl.
## Trade-offs
| Decision | Benefit | Cost |
|---|---|---|
| Note committed before LLM | Transcript durability | One extra write on failure |
| Bring-your-own OpenAI-compatible LLM | No provider lock-in or bundled key | Reviewer must configure one |
| Synchronous processing | Simple and testable | Slow model holds the request |
| SQLite + `create_all` | Zero setup | Single-node, no migrations |
| Heuristic task matching | Deterministic and cheap | Paraphrases can miss |
| Rolling-window partials | Live feedback without streaming STT | Extra CPU; provisional text |
| No framework dashboard | No build step or Node | Manual DOM/state management |
| No auth or semantic search | Smaller scope | Not production-ready; no fuzzy note retrieval |
## What I would probably do with more time
1. **System App.** Making this a system app that can be activated with a wake word seems like a most obvious thing.
2. **Background processing.** Move STT/extraction to a queue with status endpoints.
3. **Better streaming STT.** Current is slow.
4. **Semantic search.** Embed `raw_transcript` into FAISS/Qdrant/pgvector; add search.
5. **Observability.** Structured logs, stage latency, request IDs, and the like.