Spaces:
Running on Zero
Running on Zero
|
Download DESIGN.md from osina/voice-notes: direct link, hf CLI and curl.
- Browser
- Download file 8.96 kB
-
https://huggingface.co/spaces/osina/voice-notes/resolve/main/DESIGN.md
- Command line
-
hf download hf://spaces/osina/voice-notes/DESIGN.md
-
curl -L -o DESIGN.md https://huggingface.co/spaces/osina/voice-notes/resolve/main/DESIGN.md
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. |