feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) (#39)
CI / Deploy via GitOps (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 23s

Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it.

- ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only).
- ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set.
- ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments.
- Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check.
- Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge.

Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment.

Closes #33, #34, #42, #43, #44.

Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #39.
This commit is contained in:
2026-10-03 14:13:37 +00:00
committed by mathias
parent 4bafd5f082
commit 4a2067063d
104 changed files with 14214 additions and 311 deletions
+28 -3
View File
@@ -50,8 +50,11 @@ These caused real mistakes that were caught and corrected; the corrections are l
- **No global cross-tenant *video* table** — videos stay per-user (data-model). Transcripts ARE
shared since ADR-021 (public caption content, keyed by `(provider, provider_video_id)`, non-RLS)
so re-analysis never re-fetches; the *videos* half of cross-tenant dedup stays a Future C concern.
- **No audio-download + speech-to-text in the core path** — captions-first (ADR-007). STT is a
deferred, bounded optional component.
- **No YouTube audio-download + speech-to-text in the core path** — captions-first (ADR-007).
That rejection stands. ADR-033 superseded the STT deferral **for user-supplied audio only**:
an authenticated user may upload a recording and get a *private* transcript (own RLS table,
never the shared `transcripts`). It is off by default (`TAPIR_UPLOADS_ENABLED`) and does not
open the door to scraping audio.
- **No public SaaS / sign-up / billing / Google OAuth verification at scale** — Future C,
deferred behind the Stage 0 gate (ADR-008).
@@ -97,7 +100,7 @@ The repo is **green and shipping** — last tag `v0.15.0`. `task check` passes (
- Adapters present under `internal/adapters/`: `youtube` (captions-first `VideoSource`,
timedtext/InnerTube acquisition per ADR-010), `summarizer` + `llm` (the copied AI router,
now a resilient endpoint chain — local primary → local fallback → external worst-case,
parse-failure-aware, ADR-004 + ADR-022), `store` (Postgres, golang-migrate migrations 001–015),
parse-failure-aware, ADR-004 + ADR-022), `store` (Postgres, golang-migrate migrations 001–019),
`secrets` (file-backed `SecretStore`). The brain HTTP sink (ADR-005) is the remaining
optional sink.
- Stage 1 is open (ADR-012): multi-user with **DB-enforced** isolation — Postgres RLS `FORCE`d
@@ -109,6 +112,28 @@ The repo is **green and shipping** — last tag `v0.15.0`. `task check` passes (
(`TestTranscriptsTableIsSharedNotRLS`). The engine reads stored transcripts before any caption
fetch (`usecase.resolveTranscript`), so re-analysis — re-summarize, paste-a-URL, onboarding
burst — never re-touches YouTube. Per-user summaries/videos stay RLS-scoped.
- Audio upload (ADR-033, migration 017): `internal/uploads` (validation by **probing**, intake,
one-at-a-time in-process worker), `internal/adapters/audio` (ffprobe/ffmpeg), and
`internal/adapters/transcribe` (gateway client + iguana→berget chain). Transcripts are private
in `uploaded_transcripts`; the audio is never retained; chat reuses `chat.Service`. The
isolation test pins the **exact set** of non-RLS tables (`TestRowSecurityOffTablesAreExactlyTheKnownSet`).
The runtime image does not yet contain ffmpeg — that is a pending decision recorded in ADR-033.
Two-engine reconciliation (ADR-034, migration 019, off unless `TAPIR_RECONCILE_BASE_MODEL`):
`internal/reconcile` (word diff, disputes, a local-model adjudicator) and
`transcribe.Ensemble`; every model must be a local alias, enforced at start-up.
Progress and logging (ADR-035, migration 020): `internal/progress.Tracker` is the ONE writer of a
job's phases (saved to the row for the page, logged by the same call); engines and the reconciler
report through `progress.From(ctx)`, so `uploads.Transcriber` is unchanged, and a transcriber may
implement `uploads.Planner` to declare its own phases. The page tells running / quiet / lost from
two timestamps (`alive_at` heartbeat vs `progress_at`) and never shows a percentage that does not
come from real counts. **Nothing logged may carry transcript text, words from it, or the spool path.**
Errors reach a log, a row or a page as `errkind.Of(err)` (a kind such as `timeout` or `http_502`),
never `err.Error()`: error text can hold a provider's response body, the spool path or transcript
words (ADR-035 decision 8). Local-only for anything that carries transcript text: `config.IsLocalModel`.
`TAPIR_SHOT_DIR=… go test -run TestRenderUploadProgressStatesForReview ./internal/web` renders every
state for a screenshot review.
**Lint caveat:** `task check` does not fail on lint (#41: the Taskfile `|| echo` swallows
findings) — run `golangci-lint run ./...` yourself until that is fixed.
- `cmd/tapir` subcommands: `list`, `show`, `auth` (interactive host-side OAuth), `run` (batch
watch→summarize), `serve` (the HTMX+Templ web reader/writer under `internal/web`, a new
transport over the unchanged engine/ports — ADR-003). `tapir env` prints config.