From feb108def39ce9af4d851705e5d7d25f391d4bc3 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:33:34 +0200
Subject: [PATCH 001/299] docs(research): measure the audio length ceiling
before designing chunking (#33)
No transcription ceiling found up to 90 min: one 174 MB request through the
LiteLLM NodePort transcribed in 440 s. The first real failure is the public
gateway ingress rejecting bodies over ~8-10 MB with 413, not whisper or a
Metal OOM. Also records that srt arrives wrapped in a JSON envelope, that the
two backends differ in timestamp granularity, and that the existing chat path
truncates at 18k chars (~22 min of speech).
Synthetic Swedish TTS fixture only; no real transcript.
Co-Authored-By: Claude Sonnet 5.5
---
.../audio-length-ceiling-2026-10-01.md | 110 ++++++++++++++++++
1 file changed, 110 insertions(+)
create mode 100644 docs/research/audio-length-ceiling-2026-10-01.md
diff --git a/docs/research/audio-length-ceiling-2026-10-01.md b/docs/research/audio-length-ceiling-2026-10-01.md
new file mode 100644
index 0000000..e9ed2ba
--- /dev/null
+++ b/docs/research/audio-length-ceiling-2026-10-01.md
@@ -0,0 +1,110 @@
+# Audio length ceiling — measured before designing chunking (tapir#33, 2026-10-01)
+
+Question (#33, "the main technical risk"): at what recording length does Swedish
+transcription through the LiteLLM gateway first fail, and with what failure mode?
+Measured **before** any chunking design, with iguana not holding a large ollama model,
+so the result is the ceiling and not the Metal OOM of infra#273.
+
+## Answer
+
+**No transcription ceiling was found at or below 90 minutes.** A single request carrying
+90 minutes of 16 kHz mono PCM (174 MB) transcribed in one pass in 440 s. The first thing
+that actually fails is **not whisper**: it is the public gateway ingress, which rejects the
+upload with `413` at roughly 5.5 minutes of WAV.
+
+| Route | First failure | Failure mode |
+|---|---|---|
+| `https://llm-api.d-ma.be` (openresty ingress) | between 8.0 MB and 10.5 MB body (~4–5.5 min of WAV) | `413 Request Entity Too Large`, HTML body from `openresty`, returned instantly |
+| `http://koala:30401` (LiteLLM NodePort) → `iguana/kb-whisper` | **none up to 90 min / 174 MB** | — |
+| `http://koala:30401` → `berget/kb-whisper` | **none up to 22 min / 42 MB** (largest tried) | — |
+
+The ingress limit is the reason the 8:20 / 15 MB recording in #33 "failed repeatedly" and a
+30-second slice succeeded. That failure was attributed to a Metal OOM; at least in the
+size-dependent form described, a body-size cap explains it without one. (The OOM
+mechanism in infra#273 is separately real; this run did not provoke it.)
+
+The exact ingress cap was bracketed (8.0 MB passed, 10.5 MB failed), not read from the
+ingress configuration, which lives in `infra`. "About 10 MB" is an inference.
+
+## Method
+
+- **Audio.** No known-good Swedish recording is available that is safe to use: the only real
+ one (`IMG_1233.wav`) is the private `/bygge` recording (#31 custody still open) and was
+ deliberately not touched. Instead: macOS `say -v Alva` (Swedish) on iguana, a 1,171-char
+ five-paragraph meeting script → 82.5 s of 16 kHz mono PCM WAV (`ffmpeg -ar 16000 -ac 1`).
+ Concatenated ×4, ×8, ×16, ×32, ×44, ×66 with Python `wave` (the issue's own recipe).
+ Nothing here is a real transcript; none of it is committed.
+- **Request.** `POST /v1/audio/transcriptions`, multipart `file=`, `model=iguana/kb-whisper`,
+ `response_format=verbose_json`, `-H 'Expect:'` (per #33's traps), master key supplied only
+ through `op run` and never on argv.
+- **Confound control.** `ollama /api/ps` on iguana was read before every step. Resident:
+ nothing, or `nomic-embed-text` (0.37 GB). Never a large model.
+
+## Results (`iguana/kb-whisper`, NodePort)
+
+| Audio | WAV size | HTTP | Wall | Realtime factor | Segments | Chars |
+|---|---|---|---|---|---|---|
+| 0:30 | 0.96 MB | 200 | 2.8 s | 11× | — | — |
+| 2:30 / 3:20 / 4:10 | 4.8 / 6.4 / 8.0 MB | 200 (ingress) | 12.9 / 16.0 / 19.9 s | 12× | — | — |
+| 5:30 (×4) | 10.5 MB | **413 ingress**, 200 NodePort in 27 s | 27 s | 12× | 121 | 4,557 |
+| 11:00 (×8) | 21.1 MB | 200 | 53 s | 12× | 241 | 9,094 |
+| 22:00 (×16) | 42.3 MB | 200 | 105 s | 13× | 482 | 18,201 |
+| 44:01 (×32) | 84.5 MB | 200 | 209 s | 13× | 966 | 36,438 |
+| 60:32 (×44) | 116.2 MB | 200 | 291 s | 12× | 1,332 | 50,145 |
+| 90:48 (×66) | 174.3 MB | 200 | 440 s | 12× | 2,005 | 75,165 |
+
+- Throughput is flat at ~12× realtime. 90 minutes costs ~7.5 minutes of wall time. This is
+ a minutes-long job, not a request/response one (see ADR-033 on ADR-018).
+- Segments per source repetition stay at 30.1–30.4 from ×4 to ×66, and segment times are
+ monotonic with `last_end` within 0.2 s of `duration`. No drift and no repetition-loop
+ hallucination appeared across 90 minutes. This is a weak check — the input is a repeated
+ loop and synthetic voice — not evidence of accuracy.
+- **Only `iguana/kb-whisper`'s `x4` run reported `last_end` 360.0 s against `duration`
+ 330.2 s.** Whisper's final segment can end past the audio; clamp to `duration`.
+
+## Findings that change the design
+
+1. **Use the NodePort route, not the public hostname.** Tapir runs in-cluster beside the
+ gateway; `llm-api.d-ma.be` is the external door and has a body cap. #33 said "use the
+ gateway, never the host" and named the public URL; the gateway is right, that URL is not.
+ `TAPIR_GATEWAY_URL` already points at `koala:30401/v1`.
+2. **`response_format=srt` survives the LiteLLM hop, but wrapped.** The response is JSON
+ `{"text": "", "usage": …}`, not a bare SRT body — a client that parses the body
+ as SRT gets a parse error on `{`. #33 question 2 is answered: it passes through, inside
+ an envelope.
+3. **The two backends return different timestamp granularity.** `iguana/kb-whisper`
+ segments are **whole seconds** (`0→1`, `1→4`, `4→7`); `berget/kb-whisper` returns real
+ sub-second times (`0.051→1.192`) and a different `language` token (`swedish` vs `sv`).
+ `verbose_json` and the default JSON were byte-identical on iguana. An adapter that
+ falls back between the two must normalise both, and "which endpoint served this" is
+ a quality fact, not just a log line.
+4. **The existing chat path cannot answer about most of a long recording.** At
+ 828 chars/min (this fixture; real speech is plausibly a little denser),
+ `TAPIR_MAX_TRANSCRIPT_CHARS`'s default of **18,000** is ~21.7 minutes. A 60-minute
+ transcript is 50 k chars, a 90-minute one 75 k. `chat.Service` truncates by keeping
+ the head (`internal/adapters/chat/chat.go`), so questions about the last two-thirds of
+ a one-hour meeting get an answer from a transcript that does not contain it. #33 says
+ chat "retrieves rather than generates" and therefore has no ceiling; it **stuffs and
+ truncates**, and its ceiling is the model context. See ADR-033.
+5. **Chunking is not needed for v1 on length.** Nothing failed up to 90 minutes. The
+ chunking-and-stitching recipe in #33 (offset timestamps, renumber cues, strip `\r`) is
+ not built. It stays the answer *if* a longer cap is wanted or the OOM mechanism forces
+ smaller requests — see ADR-033's resolution triggers.
+
+## What this did not measure
+
+- **Metal OOM under contention.** Deliberately avoided. A big resident ollama model
+ during a long transcription is still the known way to wedge iguana permanently.
+- **Real speech.** TTS is clean, single-voice, no crosstalk or room noise. WER and
+ speaker overlap in a Teams export are untested. Speed is probably representative;
+ accuracy is not claimed.
+- **Compressed input.** Only WAV was sent. Whether `m4a`/`mp3`/`opus` can reach whisper
+ without a server-side transcode is not established here (ADR-033 treats it as needing one).
+- **Concurrency.** One request at a time. Two simultaneous 90-minute jobs on iguana were
+ not tried.
+- **`berget/kb-whisper` beyond 22 min / 42 MB.** Enough to know the fallback is not
+ size-capped at meeting length for 22 minutes; not proven at 90.
+- **Time-of-day / other tenants.** One afternoon, one run per length.
+
+Scripts were throwaway shell/Python in a session scratchpad and are not committed; the
+method above reproduces them.
--
2.54.0
From 38b3f1cf3397cbc4073ed4626a200059b4b4545f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:35:04 +0200
Subject: [PATCH 002/299] docs(adr): ADR-033 upload audio -> private Swedish
transcript -> chat (#33)
Proposed, decider pending. Supersedes ADR-007's STT deferral for
user-supplied audio only; the YouTube audio-download rejection stands.
Settles #28 decision 1 as a separate RLS-scoped uploaded_transcripts table
and records the measured length ceiling, the ingress 413, and the 18k-char
chat truncation that #33 assumed did not exist. Two DECISION NEEDED items
(ffmpeg in the runtime image; berget fallback for private audio).
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 191 insertions(+), 1 deletion(-)
diff --git a/DECISIONS.md b/DECISIONS.md
index 44eb2b2..12dc6a9 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -123,7 +123,9 @@ secret surface (see VISION Stage 2); it gets the existing, vetted secret path.
## ADR-007 — Captions-first; audio-download + speech-to-text deferred
**Status:** Accepted (2026-06-02). Acquisition mechanism superseded by ADR-010 (Data API
-`captions.download` → player/timedtext baseUrl); captions-first stance and STT deferral stand.
+`captions.download` → player/timedtext baseUrl); captions-first stance and STT deferral stand
+**for YouTube**. STT deferral superseded **for user-supplied audio only** by ADR-033; the
+YouTube audio-download rejection is unchanged.
**Context.** YouTube's Data API does not expose transcripts. Options: official captions
(clean, limited coverage) vs. audio download + local Whisper (broad coverage, ToS-grey,
@@ -1314,6 +1316,191 @@ roll back. No schema, no structural change.
---
+## ADR-033 — Upload audio, get a private Swedish transcript, discuss it (user-supplied STT)
+
+**Status:** Proposed (2026-10-01). **Decider: Mathias — pending.** Issue #33 (audio-only subset of
+#28). **Supersedes ADR-007's speech-to-text deferral for user-supplied audio only.** The
+YouTube audio-download rejection in ADR-007 **stands untouched**. Builds on ADR-012 (isolation),
+ADR-018 (single replica), ADR-021 (**the constraint**), ADR-022 (endpoint chain), ADR-025
+(honest status), ADR-027 (chat). ADR-014 item 4 ("Whisper reconsideration is a future ADR,
+gated on caption data") is a different question — whether to transcribe YouTube videos with no
+captions — and this ADR does not answer it. Measurements: `docs/research/audio-length-ceiling-2026-10-01.md`.
+
+**Context.** The maintainer has a concrete need: a Swedish Teams voice-only export, 30–90
+minutes, to upload and then *discuss* (what was decided, what is open, who owes what). Every
+piece exists — a Whisper endpoint on the gateway (infra#274), chat-with-transcript (ADR-027) —
+and nothing connects them.
+
+ADR-007 deferred STT for three reasons: yt-dlp fragility, ToS-grey scraping, and GPU contention
+with the JEPA PoC. Upload-STT shares **none** of them: the user owns the file, nothing is
+scraped, there is no caption rate gate, and the contention moved from koala to iguana. What it
+does share is landing Whisper in the codebase, which ADR-007 deferred — so a new ADR is required,
+and ADR-017's process note records the one time a guardrail-reversing change shipped without one.
+
+**The constraint that shapes everything.** ADR-021 made `transcripts` a shared, **non-RLS**
+table on the explicit grounds that it holds only public caption content and nothing
+user-identifying. A recorded internal meeting is the opposite: private third-party content owned
+by one user. The existing isolation test asserts `transcripts` *is* non-RLS, so a naive reuse
+would leak across tenants **and pass CI green**.
+
+**What was measured, not argued** (research doc above):
+- **No transcription ceiling up to 90 minutes.** One 174 MB request through the LiteLLM NodePort
+ took 440 s (~12× realtime). The first failure is the public ingress `llm-api.d-ma.be`
+ returning `413` above roughly 8–10 MB (~5 min of WAV) — not whisper, and not a Metal OOM.
+ The 8:20 failure recorded in #33 is consistent with that cap.
+- **`response_format=srt` survives LiteLLM, wrapped** in a JSON envelope (`{"text": ""}`).
+- **The two backends differ.** `iguana/kb-whisper` returns whole-second segments;
+ `berget/kb-whisper` returns sub-second times and a different `language` token.
+- **The existing chat path truncates at 18,000 chars (~22 min of speech), keeping the head.**
+ #33 assumed chat "retrieves rather than generates" and so has no length ceiling. It does
+ not retrieve; it stuffs the transcript into the prompt and cuts the tail.
+
+**Unknowns, stated.** Real-speech accuracy; compressed-input behaviour; concurrent long jobs on
+iguana; whether the models chat offers can hold an hour of Swedish (~50 k chars); and the Metal
+OOM, deliberately not provoked.
+
+**Decision.**
+
+1. **Audio only; transcript is the only thing kept.** An authenticated user uploads an audio
+ file; Tapir transcribes it and stores the transcript. The audio itself is deleted after
+ transcription (temp file, removed in a `defer`, swept at start-up) — no custody problem to
+ solve, and nothing to hand to #31. Video is rejected with a message naming #28. YouTube
+ audio-download stays rejected. No public/anonymous upload (ADR-008).
+
+2. **Private store: a separate RLS-scoped table, `uploaded_transcripts`** (resolves #28's
+ decision 1). `user_id NOT NULL REFERENCES users ON DELETE CASCADE`, `ENABLE` + `FORCE ROW LEVEL
+ SECURITY`, the same `tapir.current_user_id` policy as every other user-owned table. It carries
+ the transcript text and its timestamped segments (re-transcribing 90 minutes costs ~7
+ minutes; discarding the timestamps is irreversible), `status`, the `endpoint` that served
+ it, `audio_seconds`, and a typed `error_kind`. **Not** a `user_id` column on `transcripts`
+ with a partial policy, even though that would reuse more code: ADR-021's exception is
+ precisely that *that table holds no private row*, the isolation test asserts it has no
+ `user_id`, and a partial policy would turn "single exception" into "single exception, with a
+ private lane inside it" — the shape under which a future query drops the policy clause and
+ leaks. Two tables keep both boundaries independently assertable.
+
+3. **The isolation test asserts both boundaries and the set.** (a) User A cannot read, update or
+ delete user B's uploaded transcript; no GUC set → deny-all. (b) `transcripts` is still
+ non-RLS with no `user_id`. (c) **The set of public-schema tables with row security off is
+ asserted exactly** — so a new table added without RLS fails CI instead of passing it. (c) is
+ new; the existing test only checks the named tables.
+
+4. **Validate by probing, reject clearly.** Format and duration cannot be read from a filename
+ or MIME type — a Teams "voice-only" recording is typically an `.mp4` container with no video
+ stream, which is indistinguishable from video by extension. The upload is probed
+ (`ffprobe`): accept any container with an audio stream and **no** video stream; reject video
+ with "video is #28"; reject unreadable files. Limits, all configurable, all rejected
+ synchronously with a specific message rather than by timeout: **200 MB** upload and
+ **90 minutes** duration. 90 minutes is *the longest length measured*, not a ceiling that was
+ found; raising it is a measurement, not an edit.
+
+5. **Transcode server-side, always, to 16 kHz mono PCM WAV** — even when the input already is,
+ rather than assuming. whisper-server's `--convert` is rejected (its `--tmp-dir` is created at
+ process start and rots under macOS periodic cleanup). The transcoder sits behind a port; the
+ tests use a fake. **This puts `ffmpeg` and `ffprobe` in the runtime image**, which is
+ distroless-static today. **Provisional: copy static ffmpeg/ffprobe binaries into the existing
+ distroless image** (no shell, no package manager retained). This is a heavy new dependency
+ and is **the maintainer's call** — see Open items.
+
+6. **Transcription adapter via the gateway, never the public hostname.** A `Transcriber` port;
+ the implementation calls `POST {TAPIR_GATEWAY_URL}/audio/transcriptions` where the gateway
+ URL is the in-cluster NodePort (`koala:30401/v1`). Primary `iguana/kb-whisper`, fallback
+ `berget/kb-whisper` (immune to iguana contention, which can wedge permanently — infra#273).
+ - **Fallback triggers on any primary failure** (transport error, timeout, non-2xx). It never
+ branches on the body: `{"error":"failed to process audio"}` has had at least five distinct
+ causes.
+ - **`response_format=verbose_json`**, normalised to one segment shape: times clamped to the
+ audio duration (the final segment can end past it), one language token. Not `srt`: the
+ envelope adds a parse step and iguana's segment timing is the same either way.
+ - **No chunking in v1.** Nothing failed to 90 minutes (measured). Chunk-and-stitch is the
+ answer *if* the cap is raised or smaller requests become necessary — see triggers.
+ - **The serving endpoint is recorded per row**, and surfaced, because the two backends
+ differ in timestamp granularity: which one served a transcript is a quality fact.
+ - **Berget is a cloud endpoint.** On fallback the audio of a private meeting leaves the
+ homelab. #33 asks for this fallback; it is configurable (`TAPIR_TRANSCRIBE_FALLBACK_MODEL`,
+ empty disables), recorded per row, and called out in Open items rather than defaulted
+ silently.
+ - Swedish only (`language=sv`). Language detection and routing (#28 decision 2) are not
+ touched.
+ - `-H 'Expect:'`, multipart field `file` — encoded in the adapter and its tests, not rediscovered.
+
+7. **Jobs run in-process, one at a time (ADR-018 stands).** Transcription is minutes (~7.5 min
+ for 90) but a single goroutine behind a one-slot semaphore: two simultaneous 90-minute jobs on
+ iguana were not measured, so concurrency is 1 until they are. No worker, no new deployable,
+ no second replica. Honest status per ADR-025: `queued → transcribing → done | failed`, polled
+ by the same `/status` pattern, every terminal state a rendered card, never a stuck spinner.
+ Audio is not retained, so a restart mid-job **cannot resume**: at start-up any
+ `queued`/`transcribing` row is marked `failed` with `error_kind = interrupted`, and the user
+ re-uploads. The single-replica assumption gains a second dependent (the semaphore is
+ process-local) and is recorded here.
+
+8. **Chat reuses `chat.Service`; it is not forked.** The uploaded-transcript chat handlers
+ look the row up through the RLS-scoped store, so a non-owner gets 404 by the same mechanism
+ as ADR-027's summary gate. ADR-027's decisions (ephemeral, switchable models, trust-the-model)
+ carry over unchanged. **One thing must differ:** the 18,000-char default cuts a one-hour
+ meeting to its first third, so uploaded transcripts take their own cap,
+ `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS`. **Provisional default 120,000 chars (~2.4 h at
+ measured density)**, with the existing `Truncated` flag surfaced honestly. The default is a
+ guess until the chat slice measures what the offered models actually accept; it is not
+ evidence that they can.
+
+9. **Not decided here.** Rich `AVSNITT`/`ALTERNATIV` analysis (gated on #30 — `analyze_srt.py`
+ hardcodes `max_tokens: 6000`); video, keyframes, synced playback (#28); language detection;
+ per-transcript deletion UX (account deletion cascades; see Open items); retention of audio.
+
+**On the sequencing question #28 asks of itself** — *is this the thing that best serves the
+Stage-0 gate, or the most interesting thing to build?* Recorded answer: it proceeds **ahead of
+#32** (the demand test) as the maintainer's explicit, knowing call (#28, comment of 2026-10-01),
+on a concrete need rather than speculation. It is not a return-nudge (ADR-020/027): it adds
+nothing that prompts a user to come back, so it does not contaminate the unprompted-return signal
+ADR-016 measures. The honest risk is the one ADR-016 names: it is larger than anything ADR-020
+deferred. The scope cut — chat first, no rich analysis, no video — is what keeps it bounded.
+
+**Consequences.**
+- **Positive.** The user's request is met end to end with existing parts. ADR-021's exception stays
+ singular and now provably so. The measured ceiling removes chunking from v1. No audio is stored.
+- **Negative / watch.**
+ - Tapir's runtime image gains `ffmpeg`: a larger image and a new CVE surface to patch.
+ - iguana becomes load-bearing for a user-facing feature (#28 decision 3; infra#273 is still the
+ dependency that matters) — a Metal OOM now means *every upload fails over to berget*, i.e.
+ private audio leaving the homelab, silently unless the per-row endpoint is surfaced.
+ - A 90-minute job holds the single slot for ~7.5 minutes; a second user's upload queues behind it.
+ - Chat over a long transcript is bounded by model context, not by anything in Tapir.
+ - Tapir's own ingress body limit for a 200 MB upload is **unverified** (it lives in `infra`).
+
+**Validation / resolution triggers** (each observable by someone other than the author):
+- *Chunking*: build it when the duration cap is raised above 90 min, or when a measured
+ request at the current cap fails with whisper-side (not ingress) errors.
+- *Berget-first*: if more than a quarter of uploads in any week are served by the fallback,
+ swap the primary/fallback order and revisit infra#273.
+- *Retrieval chat*: if the chat slice finds an offered model cannot answer a question whose
+ answer is in the last third of a 60-minute transcript, chunked retrieval replaces the larger cap.
+- *Worker*: if a transcription job ever degrades the reading UI, or Tapir runs more than one
+ replica, ADR-018 is revisited.
+- *The decision itself*: if the maintainer has not used the feature unprompted within the
+ ADR-016 window, it was the interesting thing, not the useful one — remove it.
+
+**Open items this ADR creates.**
+- **DECISION NEEDED — ffmpeg in the runtime image.** *Options:* (A) static ffmpeg/ffprobe copied
+ into distroless **(recommended — the only option that satisfies "accept m4a/mp3/opus/mp4,
+ validate duration, reject video"; pure-Go decoders cover wav/mp3 only)**; (B) accept WAV
+ only, convert client-side (fails "I just want to upload it"); (C) a transcode sidecar
+ (second deployable, ADR-018's reasoning against). *Blocks:* the Dockerfile slice, not the
+ tests, which use the port.
+- **DECISION NEEDED — berget fallback for private audio.** *Options:* (A) enabled by default as
+ #33 specifies, with the serving endpoint shown **(recommended, matches the issue)**; (B)
+ disabled by default, a failed iguana job fails honestly and the user retries. *Blocks:*
+ the production default only; the adapter ships either way.
+- Verify Tapir's ingress `proxy-body-size` against the 200 MB cap (`infra`).
+- Per-transcript deletion is not built; account deletion cascades. A private-content store
+ without a delete button is a gap worth a ticket, not a silent omission.
+
+**Reversibility.** Additive: one table (down-migration drops it), one adapter, one upload
+surface, one image change. Removing the upload route removes the feature; `transcripts`, `videos`
+and ADR-021 are untouched.
+
+---
+
## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
@@ -1338,6 +1525,9 @@ maps to the ADR that settles it.
| Reverse the Dex-write invite flow (Google OIDC only) | Some intended Future-B users won't use Google; OIDC-only leaves them with no onboarding path — invite flow is load-bearing | ADR-017 |
| k8s CronJob for scheduled discovery (vs in-process) | At Future-B scale the in-process scheduler is simpler to deploy; CronJob's failure-isolation benefit was weighed and traded away knowingly (revisit if >1 replica or load grows) | ADR-018 |
| Cached-transcript-first onboarding burst (instant, zero-fetch picks) | Live pilot DB: ~3% cross-user video overlap, 0 cached-and-unsummarized, a new user's newest-20 are 20/20 uncached — newest-first and cached-first are structurally incompatible. Empty lever at pilot scale | ADR-028 |
+| Uploaded transcripts as a `user_id` column + partial RLS policy on `transcripts` | Turns ADR-021's single exception into an exception with a private lane inside it; the isolation test asserts `transcripts` has no `user_id`. A separate RLS table keeps both boundaries independently assertable | ADR-033 |
+| whisper-server `--convert` for server-side transcode | Its `--tmp-dir` is created at process start and rots under macOS periodic cleanup | ADR-033 |
+| Calling the public `llm-api.d-ma.be` from in-cluster Tapir | Its ingress returns 413 above ~8–10 MB (~5 min of WAV); the NodePort has no such cap | ADR-033 |
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
not a silent reversal.
--
2.54.0
From 066ae6966a3a3c8b2edef770bfa0cf30edb68510 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:36:22 +0200
Subject: [PATCH 003/299] test(store): isolation tests for the private
uploaded_transcripts table (red)
Drives RLS directly as the non-superuser app role: deny-all with no scope,
own-rows-only scoped reads, zero-row cross-user UPDATE/DELETE, rejected
cross-user INSERT, FORCE + NOT NULL user_id, cascade on DeleteUser. Also pins
the EXACT set of public tables with row security off, so a new table added
without RLS fails CI rather than passing it (ADR-033). The existing test only
named transcripts; #33's "the only non-RLS table" is false -- invitations,
user_identities and schema_migrations are off too.
Red: relation "uploaded_transcripts" does not exist. Set test passes today
and guards the migration that follows.
Co-Authored-By: Claude Sonnet 5.5
---
.../uploaded_transcripts_isolation_test.go | 196 ++++++++++++++++++
1 file changed, 196 insertions(+)
create mode 100644 internal/adapters/store/uploaded_transcripts_isolation_test.go
diff --git a/internal/adapters/store/uploaded_transcripts_isolation_test.go b/internal/adapters/store/uploaded_transcripts_isolation_test.go
new file mode 100644
index 0000000..d8bd6f0
--- /dev/null
+++ b/internal/adapters/store/uploaded_transcripts_isolation_test.go
@@ -0,0 +1,196 @@
+package store_test
+
+import (
+ "context"
+ "sort"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stretchr/testify/require"
+)
+
+// Isolation proof for ADR-033: an uploaded transcript is PRIVATE content owned by
+// one user, so it lives in its own RLS-scoped table — never in the shared,
+// non-RLS `transcripts` (ADR-021). These tests drive the database directly (raw
+// SQL as the non-superuser `app` role) so they observe row-level security itself,
+// not any application-layer WHERE clause. The superuser pool is used only to seed
+// and to read pg_catalog; it bypasses RLS and so must never be the pool whose
+// answers the assertions rest on.
+
+// seedUploadedTranscript inserts one finished upload for userID as the superuser
+// (RLS bypassed so both users' rows land regardless of any GUC) and returns its id.
+// The text is synthetic — this repo is public, and a real transcript must never
+// appear in it (#33).
+func seedUploadedTranscript(t *testing.T, super *pgxpool.Pool, userID, text string) string {
+ t.Helper()
+ ctx := context.Background()
+ _, err := super.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING`, userID)
+ require.NoError(t, err)
+ var id string
+ require.NoError(t, super.QueryRow(ctx,
+ `INSERT INTO uploaded_transcripts (user_id, title, status, content)
+ VALUES ($1, 'synthetic fixture', 'done', $2) RETURNING id`,
+ userID, text).Scan(&id))
+ return id
+}
+
+func TestUploadedTranscriptsRLS_ScopedReadsSeeOnlyOwnRows(t *testing.T) {
+ newStore(t)
+ super := rawPool(t)
+ resetDB(t, super)
+ seedUploadedTranscript(t, super, userA, "text owned by A")
+ seedUploadedTranscript(t, super, userB, "text owned by B")
+ app := appPool(t, super)
+
+ require.Equal(t, 0, scopedCount(t, app, "", "uploaded_transcripts"),
+ "no tapir.current_user_id must be deny-all: this proves RLS is live, not bypassed")
+ require.Equal(t, 1, scopedCount(t, app, userA, "uploaded_transcripts"),
+ "user A must see exactly its own upload")
+ require.Equal(t, 1, scopedCount(t, app, userB, "uploaded_transcripts"),
+ "user B must see exactly its own upload")
+
+ // Count alone cannot distinguish "A's row" from "B's row" — read the content.
+ var got string
+ ctx := context.Background()
+ tx, err := app.Begin(ctx)
+ require.NoError(t, err)
+ defer tx.Rollback(ctx) //nolint:errcheck
+ _, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, userA)
+ require.NoError(t, err)
+ require.NoError(t, tx.QueryRow(ctx, `SELECT content FROM uploaded_transcripts`).Scan(&got))
+ require.Equal(t, "text owned by A", got, "A's scoped read must return A's text, never B's")
+}
+
+func TestUploadedTranscriptsRLS_CrossUserWritesAffectZeroRows(t *testing.T) {
+ newStore(t)
+ super := rawPool(t)
+ resetDB(t, super)
+ seedUploadedTranscript(t, super, userA, "text owned by A")
+ bID := seedUploadedTranscript(t, super, userB, "text owned by B")
+ app := appPool(t, super)
+
+ require.Equal(t, int64(0), scopedRowsAffected(t, app, userA,
+ `UPDATE uploaded_transcripts SET content = 'hacked' WHERE id = $1`, bID),
+ "user A scoped UPDATE must touch zero of B's uploads")
+ require.Equal(t, int64(0), scopedRowsAffected(t, app, userA,
+ `DELETE FROM uploaded_transcripts WHERE id = $1`, bID),
+ "user A scoped DELETE must touch zero of B's uploads")
+
+ var content string
+ require.NoError(t, super.QueryRow(context.Background(),
+ `SELECT content FROM uploaded_transcripts WHERE id = $1`, bID).Scan(&content))
+ require.Equal(t, "text owned by B", content, "B's upload must survive A's writes unchanged")
+}
+
+func TestUploadedTranscriptsRLS_InsertForAnotherUserIsRejected(t *testing.T) {
+ newStore(t)
+ super := rawPool(t)
+ resetDB(t, super)
+ seedUploadedTranscript(t, super, userA, "text owned by A")
+ _, err := super.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1)`, userB)
+ require.NoError(t, err)
+ app := appPool(t, super)
+
+ ctx := context.Background()
+ tx, err := app.Begin(ctx)
+ require.NoError(t, err)
+ defer tx.Rollback(ctx) //nolint:errcheck
+ _, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, userA)
+ require.NoError(t, err)
+
+ // Scoped as A, plant a row that claims to belong to B. The policy's WITH CHECK
+ // (defaulting to USING under FOR ALL) must refuse it.
+ _, err = tx.Exec(ctx,
+ `INSERT INTO uploaded_transcripts (user_id, title, status, content)
+ VALUES ($1, 'planted', 'done', 'planted')`, userB)
+ require.Error(t, err, "a row scoped as A must not be insertable on behalf of B")
+ require.Contains(t, err.Error(), "row-level security")
+}
+
+func TestUploadedTranscripts_RowSecurityIsEnabledForcedAndKeyedByUser(t *testing.T) {
+ newStore(t)
+ super := rawPool(t)
+ ctx := context.Background()
+
+ var enabled, forced bool
+ require.NoError(t, super.QueryRow(ctx,
+ `SELECT relrowsecurity, relforcerowsecurity FROM pg_class
+ WHERE oid = 'public.uploaded_transcripts'::regclass`).Scan(&enabled, &forced))
+ require.True(t, enabled, "uploaded_transcripts must have row-level security enabled")
+ require.True(t, forced,
+ "FORCE is what binds the table-owner role tapir; without it the policy is dead in production")
+
+ var userIDNotNull bool
+ require.NoError(t, super.QueryRow(ctx,
+ `SELECT is_nullable = 'NO' FROM information_schema.columns
+ WHERE table_name = 'uploaded_transcripts' AND column_name = 'user_id'`).Scan(&userIDNotNull))
+ require.True(t, userIDNotNull, "an upload with no owner would match no policy and be unreachable")
+}
+
+func TestUploadedTranscripts_DeleteUserRemovesTheirUploads(t *testing.T) {
+ s := newStore(t)
+ super := rawPool(t)
+ resetDB(t, super)
+ seedUploadedTranscript(t, super, userA, "text owned by A")
+ bID := seedUploadedTranscript(t, super, userB, "text owned by B")
+
+ require.NoError(t, s.DeleteUser(context.Background(), userA))
+
+ var aRows, bRows int
+ ctx := context.Background()
+ require.NoError(t, super.QueryRow(ctx,
+ `SELECT count(*) FROM uploaded_transcripts WHERE user_id = $1`, userA).Scan(&aRows))
+ require.NoError(t, super.QueryRow(ctx,
+ `SELECT count(*) FROM uploaded_transcripts WHERE id = $1`, bID).Scan(&bRows))
+ require.Equal(t, 0, aRows, "deleting a user must remove their private transcripts (ON DELETE CASCADE)")
+ require.Equal(t, 1, bRows, "deleting A must not touch B's transcripts")
+}
+
+// rowSecurityOffTables are the public-schema tables that deliberately have row
+// security OFF. The list is exact on purpose: a NEW table added without RLS must
+// fail this test and force a decision, instead of passing CI by omission — the
+// failure mode ADR-033 names, where a naive reuse "leaks private content across
+// tenants and passes CI green".
+//
+// - transcripts: shared public caption content, the single exception for CONTENT
+// (ADR-021). It is the one that must never gain a private row.
+// - user_identities: the Dex-subject -> user_id map, read at login before any
+// user is known (migration 004). Holds no user content.
+// - invitations: exists before the user does, so there is no user to scope by;
+// the single-use token is the capability (migration 009).
+// - schema_migrations: golang-migrate bookkeeping.
+//
+// #33 describes `transcripts` as "the only non-RLS table". That is true of content
+// and false of tables; this test asserts the real set.
+var rowSecurityOffTables = []string{
+ "invitations",
+ "schema_migrations",
+ "transcripts",
+ "user_identities",
+}
+
+func TestRowSecurityOffTablesAreExactlyTheKnownSet(t *testing.T) {
+ newStore(t)
+ super := rawPool(t)
+
+ rows, err := super.Query(context.Background(),
+ `SELECT c.relname FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND NOT c.relrowsecurity
+ ORDER BY c.relname`)
+ require.NoError(t, err)
+ defer rows.Close()
+ var got []string
+ for rows.Next() {
+ var name string
+ require.NoError(t, rows.Scan(&name))
+ got = append(got, name)
+ }
+ require.NoError(t, rows.Err())
+
+ want := append([]string{}, rowSecurityOffTables...)
+ sort.Strings(want)
+ require.Equal(t, want, got,
+ "every public table must have row security ON unless it is in rowSecurityOffTables; "+
+ "adding a table here is a decision that needs an ADR")
+}
--
2.54.0
From e36c4c1e0ca8690dc690cc0a2ea6d16f545a7008 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:36:50 +0200
Subject: [PATCH 004/299] feat(store): migration 017 -- private RLS-scoped
uploaded_transcripts (ADR-033)
Separate table, not a user_id on the shared transcripts (ADR-021). ENABLE +
FORCE ROW LEVEL SECURITY with the migration-003 policy, user_id NOT NULL
ON DELETE CASCADE, typed error_kind, per-row serving endpoint, JSONB segments.
CHECK constraints: done => content, failed => error_kind.
Up and down are one commit: golang-migrate pairs them.
Mutation-checked: dropping FORCE, the policy, or the cascade each turn a test
red. FORCE is only observable structurally -- the app role is not the table
owner, so no behavioural test can see it.
Co-Authored-By: Claude Sonnet 5.5
---
.../017_uploaded_transcripts.down.sql | 3 ++
.../017_uploaded_transcripts.up.sql | 48 +++++++++++++++++++
2 files changed, 51 insertions(+)
create mode 100644 internal/adapters/store/migrations/017_uploaded_transcripts.down.sql
create mode 100644 internal/adapters/store/migrations/017_uploaded_transcripts.up.sql
diff --git a/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql b/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql
new file mode 100644
index 0000000..8040680
--- /dev/null
+++ b/internal/adapters/store/migrations/017_uploaded_transcripts.down.sql
@@ -0,0 +1,3 @@
+-- Drops the private uploaded-transcript store and every transcript in it. There
+-- is no revert of the data: the original audio was never kept (ADR-033).
+DROP TABLE uploaded_transcripts;
diff --git a/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql b/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql
new file mode 100644
index 0000000..bdd311a
--- /dev/null
+++ b/internal/adapters/store/migrations/017_uploaded_transcripts.up.sql
@@ -0,0 +1,48 @@
+-- Migration 017: uploaded_transcripts — PRIVATE transcripts of audio a user
+-- uploaded themselves (ADR-033, tapir#33).
+--
+-- This is deliberately NOT the shared `transcripts` table (migration 015,
+-- ADR-021). That table is non-RLS on the explicit grounds that it holds only
+-- public caption content and nothing user-identifying. A recorded meeting is the
+-- opposite: private third-party content owned by exactly one user. Reusing
+-- `transcripts` — or adding a user_id to it with a partial policy — would put a
+-- private row in the one table the isolation test asserts is shared, and would
+-- pass CI green. A separate table keeps ADR-021's exception singular.
+--
+-- Row-level security follows migration 003 exactly: ENABLE + FORCE (the app
+-- connects as the table owner, which bypasses RLS unless forced), and a policy
+-- keyed off the per-request GUC tapir.current_user_id. With no GUC set the
+-- predicate is NULL and the table is deny-all.
+--
+-- The original audio is never stored (ADR-033): this row is all that is kept.
+-- segments holds the timestamped cues; re-transcribing 90 minutes costs ~7
+-- minutes, so discarding the timestamps would be irreversible.
+CREATE TABLE uploaded_transcripts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'queued'
+ CHECK (status IN ('queued', 'transcribing', 'done', 'failed')),
+ content TEXT,
+ segments JSONB,
+ endpoint TEXT, -- which backend served it (iguana | berget); a quality fact
+ audio_seconds DOUBLE PRECISION,
+ error_kind TEXT, -- typed, never a provider's error string (ADR-033)
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ finished_at TIMESTAMPTZ,
+ CONSTRAINT uploaded_transcripts_done_has_content
+ CHECK (status <> 'done' OR content IS NOT NULL),
+ CONSTRAINT uploaded_transcripts_failed_has_error_kind
+ CHECK (status <> 'failed' OR error_kind IS NOT NULL)
+);
+
+CREATE INDEX idx_uploaded_transcripts_user ON uploaded_transcripts (user_id, created_at DESC);
+
+ALTER TABLE uploaded_transcripts ENABLE ROW LEVEL SECURITY;
+ALTER TABLE uploaded_transcripts FORCE ROW LEVEL SECURITY;
+CREATE POLICY uploaded_transcripts_isolation ON uploaded_transcripts
+ FOR ALL
+ USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
+
+COMMENT ON TABLE uploaded_transcripts IS
+ 'PRIVATE per-user transcripts of user-supplied audio. RLS-scoped (ADR-012). Never merge into the shared transcripts table (ADR-021, ADR-033).';
--
2.54.0
From 4b557209c8cef73e2393452a7913c52f4e8fee1e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:37:30 +0200
Subject: [PATCH 005/299] test(store): Store API for uploaded transcripts (red)
Lifecycle queued -> transcribing -> done|failed, per-row serving endpoint,
segments JSONB round trip, owner-only get/write (not-found, not forbidden),
list without transcript text, and the start-up sweep that fails unfinished
rows as `interrupted` for every user (audio is not retained, so no resume).
Red: CreateUpload and friends are undefined.
Co-Authored-By: Claude Sonnet 5.5
---
.../store/uploaded_transcripts_test.go | 188 ++++++++++++++++++
1 file changed, 188 insertions(+)
create mode 100644 internal/adapters/store/uploaded_transcripts_test.go
diff --git a/internal/adapters/store/uploaded_transcripts_test.go b/internal/adapters/store/uploaded_transcripts_test.go
new file mode 100644
index 0000000..38d3726
--- /dev/null
+++ b/internal/adapters/store/uploaded_transcripts_test.go
@@ -0,0 +1,188 @@
+package store_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/store"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// These exercise the Store API over uploaded_transcripts. The test Store connects
+// as the superuser, which BYPASSES row-level security — so the cross-user cases
+// here prove the query's own user_id predicate (defence in depth), while the
+// database-enforced guarantee is proven separately, as the non-superuser app
+// role, in uploaded_transcripts_isolation_test.go. Text is synthetic; this repo is
+// public and no real transcript may appear in it (#33).
+
+func seedUsers(t *testing.T, ids ...string) {
+ t.Helper()
+ super := rawPool(t)
+ resetDB(t, super)
+ for _, id := range ids {
+ _, err := super.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1)`, id)
+ require.NoError(t, err)
+ }
+}
+
+func TestCreateUpload_StartsQueuedAndIsOwnedByTheCreator(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA)
+ ctx := context.Background()
+
+ id, err := s.CreateUpload(ctx, userA, "Teams export")
+ require.NoError(t, err)
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.Equal(t, id, got.ID)
+ assert.Equal(t, userA, got.UserID)
+ assert.Equal(t, "Teams export", got.Title)
+ assert.Equal(t, domain.UploadQueued, got.Status)
+ assert.Empty(t, got.Content)
+ assert.True(t, got.FinishedAt.IsZero(), "an unfinished upload has no finish time")
+}
+
+func TestUploadLifecycle_CompletedKeepsTextSegmentsEndpointAndDuration(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA)
+ ctx := context.Background()
+ id, err := s.CreateUpload(ctx, userA, "m")
+ require.NoError(t, err)
+
+ require.NoError(t, s.StartTranscribing(ctx, userA, id))
+ mid, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.Equal(t, domain.UploadTranscribing, mid.Status)
+
+ want := domain.Transcription{
+ Text: "God morgon allihopa. Vi går igenom läget.",
+ Segments: []domain.Segment{
+ {Start: 0.0, End: 1.5, Text: "God morgon allihopa."},
+ {Start: 1.5, End: 4.25, Text: "Vi går igenom läget."},
+ },
+ Endpoint: "iguana/kb-whisper",
+ AudioSeconds: 4.25,
+ }
+ require.NoError(t, s.CompleteUpload(ctx, userA, id, want))
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.Equal(t, domain.UploadDone, got.Status)
+ assert.Equal(t, want.Text, got.Content)
+ assert.Equal(t, want.Segments, got.Segments, "segment times must survive the JSONB round trip")
+ assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is a quality fact and is recorded per row")
+ assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
+ assert.False(t, got.FinishedAt.IsZero())
+ assert.Empty(t, got.ErrorKind)
+}
+
+func TestUploadLifecycle_FailedRecordsTypedKindAndNoContent(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA)
+ ctx := context.Background()
+ id, err := s.CreateUpload(ctx, userA, "m")
+ require.NoError(t, err)
+ require.NoError(t, s.StartTranscribing(ctx, userA, id))
+
+ require.NoError(t, s.FailUpload(ctx, userA, id, domain.UploadErrTranscriptionFailed))
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.Equal(t, domain.UploadFailed, got.Status)
+ assert.Equal(t, domain.UploadErrTranscriptionFailed, got.ErrorKind)
+ assert.Empty(t, got.Content)
+ assert.False(t, got.FinishedAt.IsZero())
+}
+
+func TestGetUpload_AnotherUsersIDIsNotFound(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA, userB)
+ ctx := context.Background()
+ id, err := s.CreateUpload(ctx, userA, "A's meeting")
+ require.NoError(t, err)
+
+ _, err = s.GetUpload(ctx, userB, id)
+
+ assert.ErrorIs(t, err, store.ErrUploadNotFound,
+ "a non-owner must get not-found, not forbidden: existence is not disclosed")
+}
+
+func TestUploadWrites_ByAnotherUserChangeNothing(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA, userB)
+ ctx := context.Background()
+ id, err := s.CreateUpload(ctx, userA, "A's meeting")
+ require.NoError(t, err)
+
+ assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound)
+ assert.ErrorIs(t, s.CompleteUpload(ctx, userB, id, domain.Transcription{Text: "planted"}), store.ErrUploadNotFound)
+ assert.ErrorIs(t, s.FailUpload(ctx, userB, id, domain.UploadErrTranscriptionFailed), store.ErrUploadNotFound)
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.Equal(t, domain.UploadQueued, got.Status, "B's writes must leave A's upload exactly as it was")
+ assert.Empty(t, got.Content)
+}
+
+func TestListUploads_ReturnsOnlyTheCallersNewestFirstWithoutTranscriptText(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA, userB)
+ ctx := context.Background()
+ first, err := s.CreateUpload(ctx, userA, "first")
+ require.NoError(t, err)
+ second, err := s.CreateUpload(ctx, userA, "second")
+ require.NoError(t, err)
+ _, err = s.CreateUpload(ctx, userB, "B's")
+ require.NoError(t, err)
+ require.NoError(t, s.StartTranscribing(ctx, userA, first))
+ require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"}))
+
+ got, err := s.ListUploads(ctx, userA)
+
+ require.NoError(t, err)
+ require.Len(t, got, 2, "A must see only A's uploads")
+ assert.Equal(t, second, got[0].ID, "newest first")
+ assert.Equal(t, first, got[1].ID)
+ assert.Empty(t, got[1].Content, "a list row must not drag the whole transcript along")
+ assert.Equal(t, domain.UploadDone, got[1].Status)
+}
+
+func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA, userB)
+ super := rawPool(t)
+ ctx := context.Background()
+ for subject, uid := range map[string]string{"sub-a": userA, "sub-b": userB} {
+ _, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid)
+ require.NoError(t, err)
+ }
+ queued, err := s.CreateUpload(ctx, userA, "queued")
+ require.NoError(t, err)
+ running, err := s.CreateUpload(ctx, userB, "running")
+ require.NoError(t, err)
+ require.NoError(t, s.StartTranscribing(ctx, userB, running))
+ done, err := s.CreateUpload(ctx, userA, "done")
+ require.NoError(t, err)
+ require.NoError(t, s.StartTranscribing(ctx, userA, done))
+ require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"}))
+
+ n, err := s.FailInterruptedUploads(ctx)
+
+ require.NoError(t, err)
+ assert.Equal(t, 2, n, "the queued and the transcribing row, across both users")
+ for _, c := range []struct{ user, id string }{{userA, queued}, {userB, running}} {
+ got, err := s.GetUpload(ctx, c.user, c.id)
+ require.NoError(t, err)
+ assert.Equal(t, domain.UploadFailed, got.Status)
+ assert.Equal(t, domain.UploadErrInterrupted, got.ErrorKind,
+ "audio is not retained, so a restart cannot resume: the honest state is failed/interrupted")
+ }
+ kept, err := s.GetUpload(ctx, userA, done)
+ require.NoError(t, err)
+ assert.Equal(t, domain.UploadDone, kept.Status, "a finished transcript must survive the sweep")
+ assert.Equal(t, "kept", kept.Content)
+}
--
2.54.0
From c29c0a7127634c2fcab33c77d50707eb439a8538 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:37:39 +0200
Subject: [PATCH 006/299] feat(domain): types for private uploaded transcripts
(ADR-033)
Segment, Transcription, UploadStatus, UploadErrorKind and UploadedTranscript.
Error kinds are typed, never a provider's string. UploadedTranscript is a
distinct type from the shared public-caption Transcript on purpose.
Co-Authored-By: Claude Sonnet 5.5
---
internal/domain/domain.go | 59 +++++++++++++++++++++++++++++++++++++++
1 file changed, 59 insertions(+)
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index 5cf988b..374678f 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -111,3 +111,62 @@ type Summary struct {
FallbackUsed bool
CreatedAt time.Time
}
+
+// Segment is one timed span of a transcription. Times are seconds from the start
+// of the audio.
+type Segment struct {
+ Start float64 `json:"start"`
+ End float64 `json:"end"`
+ Text string `json:"text"`
+}
+
+// Transcription is the result of transcribing user-supplied audio (ADR-033):
+// the full text, its timed segments, which backend served it, and how long the
+// audio was. Endpoint is recorded because the backends differ in timestamp
+// granularity — which one served a transcript is a quality fact.
+type Transcription struct {
+ Text string
+ Segments []Segment
+ Endpoint string
+ AudioSeconds float64
+}
+
+// UploadStatus is the state of an uploaded-audio transcription job.
+type UploadStatus string
+
+const (
+ UploadQueued UploadStatus = "queued"
+ UploadTranscribing UploadStatus = "transcribing"
+ UploadDone UploadStatus = "done"
+ UploadFailed UploadStatus = "failed"
+)
+
+// UploadErrorKind is the typed reason an upload failed. It is deliberately NOT a
+// provider's error string: the whisper server's "failed to process audio" has had
+// at least five distinct causes, so nothing may branch on it (ADR-033).
+type UploadErrorKind string
+
+const (
+ // UploadErrInterrupted: the process restarted mid-job. The audio is not
+ // retained, so the job cannot resume; the user re-uploads.
+ UploadErrInterrupted UploadErrorKind = "interrupted"
+ // UploadErrTranscriptionFailed: every configured transcription endpoint failed.
+ UploadErrTranscriptionFailed UploadErrorKind = "transcription_failed"
+)
+
+// UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is
+// never stored in, or merged with, the shared public-caption Transcript (ADR-021).
+// Content and Segments are empty until Status is UploadDone.
+type UploadedTranscript struct {
+ ID string
+ UserID string
+ Title string
+ Status UploadStatus
+ Content string
+ Segments []Segment
+ Endpoint string
+ AudioSeconds float64
+ ErrorKind UploadErrorKind
+ CreatedAt time.Time
+ FinishedAt time.Time // zero until the job reaches done or failed
+}
--
2.54.0
From 836a501576a89d1da6938bd9e15ba38eac4fada1 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:38:25 +0200
Subject: [PATCH 007/299] feat(store): Store API for private uploaded
transcripts (ADR-033)
Create/Start/Complete/Fail/Get/List plus the start-up sweep that fails
unfinished rows as interrupted (audio is not retained, so no resume). Every
call goes through withUser AND names user_id in its predicate, so isolation
does not rest on RLS alone and stays testable under the superuser test
connection; a non-owner gets ErrUploadNotFound, never a distinct forbidden.
List drops transcript text.
Mutation-checked: removing the user predicate from Get or from the UPDATEs
turns the cross-user tests red.
Co-Authored-By: Claude Sonnet 5.5
---
.../adapters/store/uploaded_transcripts.go | 199 ++++++++++++++++++
1 file changed, 199 insertions(+)
create mode 100644 internal/adapters/store/uploaded_transcripts.go
diff --git a/internal/adapters/store/uploaded_transcripts.go b/internal/adapters/store/uploaded_transcripts.go
new file mode 100644
index 0000000..7252761
--- /dev/null
+++ b/internal/adapters/store/uploaded_transcripts.go
@@ -0,0 +1,199 @@
+package store
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// ErrUploadNotFound is returned when an uploaded transcript does not exist OR is
+// not the caller's. The two are deliberately indistinguishable: a non-owner learns
+// nothing about whether someone else's upload exists (ADR-033).
+var ErrUploadNotFound = errors.New("store: uploaded transcript not found")
+
+// Uploaded transcripts are PRIVATE per-user content, stored in their own
+// RLS-scoped table (migration 017) — never in the shared `transcripts` (ADR-021).
+// Every method goes through withUser, so row-level security confines it to the
+// caller's rows; each statement ALSO names user_id in its predicate, so the
+// isolation does not rest on a single mechanism and stays testable under the
+// superuser connection the unit tests use.
+
+// CreateUpload records a new upload in the queued state and returns its id.
+func (s *Store) CreateUpload(ctx context.Context, userID, title string) (string, error) {
+ var id string
+ if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
+ return tx.QueryRow(ctx,
+ `INSERT INTO uploaded_transcripts (user_id, title) VALUES ($1, $2) RETURNING id`,
+ userID, title).Scan(&id)
+ }); err != nil {
+ return "", fmt.Errorf("store: create upload: %w", err)
+ }
+ return id, nil
+}
+
+// StartTranscribing moves an upload to transcribing.
+func (s *Store) StartTranscribing(ctx context.Context, userID, id string) error {
+ return s.updateUpload(ctx, userID, id, "start transcribing",
+ `UPDATE uploaded_transcripts SET status = 'transcribing'
+ WHERE id = $1 AND user_id = $2`)
+}
+
+// CompleteUpload stores the finished transcription and moves the upload to done.
+func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error {
+ segs, err := json.Marshal(t.Segments)
+ if err != nil {
+ return fmt.Errorf("store: complete upload: marshal segments: %w", err)
+ }
+ return s.updateUpload(ctx, userID, id, "complete upload",
+ `UPDATE uploaded_transcripts
+ SET status = 'done', content = $3, segments = $4, endpoint = $5,
+ audio_seconds = $6, error_kind = NULL, finished_at = NOW()
+ WHERE id = $1 AND user_id = $2`,
+ t.Text, segs, t.Endpoint, t.AudioSeconds)
+}
+
+// FailUpload moves an upload to failed with a typed reason.
+func (s *Store) FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error {
+ return s.updateUpload(ctx, userID, id, "fail upload",
+ `UPDATE uploaded_transcripts
+ SET status = 'failed', error_kind = $3, finished_at = NOW()
+ WHERE id = $1 AND user_id = $2`,
+ string(kind))
+}
+
+// updateUpload runs one UPDATE keyed on ($1 = id, $2 = userID) plus extra args,
+// and maps "no row touched" to ErrUploadNotFound — which is what a write to another
+// user's upload looks like under RLS (the row is invisible, not forbidden).
+func (s *Store) updateUpload(ctx context.Context, userID, id, op, sql string, args ...any) error {
+ all := append([]any{id, userID}, args...)
+ return s.withUser(ctx, userID, func(tx pgx.Tx) error {
+ ct, err := tx.Exec(ctx, sql, all...)
+ if err != nil {
+ return fmt.Errorf("store: %s: %w", op, err)
+ }
+ if ct.RowsAffected() == 0 {
+ return ErrUploadNotFound
+ }
+ return nil
+ })
+}
+
+// GetUpload returns one of the caller's uploads, including the transcript text.
+func (s *Store) GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error) {
+ var u domain.UploadedTranscript
+ err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
+ row := tx.QueryRow(ctx, uploadSelect+` WHERE id = $1 AND user_id = $2`, id, userID)
+ return scanUpload(row, &u, true)
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrUploadNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("store: get upload: %w", err)
+ }
+ return &u, nil
+}
+
+// ListUploads returns the caller's uploads, newest first, WITHOUT transcript text
+// or segments: a list view must not drag every transcript along.
+func (s *Store) ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error) {
+ var out []domain.UploadedTranscript
+ err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
+ rows, err := tx.Query(ctx, uploadSelect+` WHERE user_id = $1 ORDER BY created_at DESC, id`, userID)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var u domain.UploadedTranscript
+ if err := scanUpload(rows, &u, false); err != nil {
+ return err
+ }
+ out = append(out, u)
+ }
+ return rows.Err()
+ })
+ if err != nil {
+ return nil, fmt.Errorf("store: list uploads: %w", err)
+ }
+ return out, nil
+}
+
+// FailInterruptedUploads is the start-up sweep. The audio of an upload is never
+// retained (ADR-033), so a job that was queued or transcribing when the process
+// stopped can never resume; leaving it would be a stuck spinner. Every such row,
+// for every user, becomes failed/interrupted and the user re-uploads. It
+// enumerates users from the un-RLS'd identity map and scopes each pass with
+// withUser, exactly as scheduled discovery does (ADR-018). Returns rows failed.
+func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
+ users, err := s.ListAllUsers(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("store: fail interrupted uploads: %w", err)
+ }
+ total := 0
+ for _, u := range users {
+ err := s.withUser(ctx, u.UserID, func(tx pgx.Tx) error {
+ ct, err := tx.Exec(ctx,
+ `UPDATE uploaded_transcripts
+ SET status = 'failed', error_kind = $2, finished_at = NOW()
+ WHERE user_id = $1 AND status IN ('queued', 'transcribing')`,
+ u.UserID, string(domain.UploadErrInterrupted))
+ total += int(ct.RowsAffected())
+ return err
+ })
+ if err != nil {
+ return total, fmt.Errorf("store: fail interrupted uploads: user %s: %w", u.UserID, err)
+ }
+ }
+ return total, nil
+}
+
+const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
+ audio_seconds, error_kind, created_at, finished_at FROM uploaded_transcripts`
+
+// scanUpload scans one uploadSelect row. withText controls whether the transcript
+// body and segments are kept (GetUpload) or dropped (ListUploads).
+func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
+ var (
+ status, errKind, endpoint, content *string
+ segments []byte
+ seconds *float64
+ finished *time.Time
+ )
+ if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
+ &seconds, &errKind, &u.CreatedAt, &finished); err != nil {
+ return err
+ }
+ u.Status = domain.UploadStatus(deref(status))
+ u.Endpoint = deref(endpoint)
+ u.ErrorKind = domain.UploadErrorKind(deref(errKind))
+ if seconds != nil {
+ u.AudioSeconds = *seconds
+ }
+ if finished != nil {
+ u.FinishedAt = *finished
+ }
+ if !withText {
+ return nil
+ }
+ u.Content = deref(content)
+ if len(segments) > 0 {
+ if err := json.Unmarshal(segments, &u.Segments); err != nil {
+ return fmt.Errorf("unmarshal segments: %w", err)
+ }
+ }
+ return nil
+}
+
+func deref(s *string) string {
+ if s == nil {
+ return ""
+ }
+ return *s
+}
--
2.54.0
From f42b8e23fcddc5fba5ad1d9fad12f22226c384de Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:39:20 +0200
Subject: [PATCH 008/299] test(uploads): validation policy -- video, no-audio,
too-long, unreadable (red)
Probe-based, not extension-based: a Teams voice-only export is typically an
.mp4 with no video stream, indistinguishable from video by name (ADR-033).
Red: package uploads does not exist.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/validate_test.go | 95 +++++++++++++++++++++++++++++++
1 file changed, 95 insertions(+)
create mode 100644 internal/uploads/validate_test.go
diff --git a/internal/uploads/validate_test.go b/internal/uploads/validate_test.go
new file mode 100644
index 0000000..b8dc4c6
--- /dev/null
+++ b/internal/uploads/validate_test.go
@@ -0,0 +1,95 @@
+package uploads_test
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+var testLimits = uploads.Limits{MaxBytes: 200 << 20, MaxDuration: 90 * time.Minute}
+
+func TestValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ probe uploads.Probe
+ wantReason uploads.Reason // "" = accepted
+ wantInMsg []string
+ }{
+ {
+ name: "audio-only file within the duration limit is accepted",
+ probe: uploads.Probe{Duration: 45 * time.Minute, HasAudio: true},
+ },
+ {
+ name: "a recording exactly at the limit is accepted",
+ probe: uploads.Probe{Duration: 90 * time.Minute, HasAudio: true},
+ },
+ {
+ name: "a file with a video stream is rejected and names issue 28",
+ probe: uploads.Probe{Duration: 4 * time.Minute, HasAudio: true, HasVideo: true},
+ wantReason: uploads.ReasonVideo,
+ wantInMsg: []string{"video", "#28"},
+ },
+ {
+ name: "video with no audio is still rejected as video, not as missing audio",
+ probe: uploads.Probe{Duration: 4 * time.Minute, HasVideo: true},
+ wantReason: uploads.ReasonVideo,
+ wantInMsg: []string{"#28"},
+ },
+ {
+ name: "a file with no audio stream is rejected",
+ probe: uploads.Probe{Duration: 4 * time.Minute},
+ wantReason: uploads.ReasonNoAudio,
+ wantInMsg: []string{"no audio"},
+ },
+ {
+ name: "a recording over the limit is rejected, stating both lengths",
+ probe: uploads.Probe{Duration: 102 * time.Minute, HasAudio: true},
+ wantReason: uploads.ReasonTooLong,
+ wantInMsg: []string{"1 h 42 min", "1 h 30 min"},
+ },
+ {
+ name: "zero duration is unreadable, not accepted as an empty recording",
+ probe: uploads.Probe{HasAudio: true},
+ wantReason: uploads.ReasonUnreadable,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := uploads.Validate(tt.probe, testLimits)
+
+ if tt.wantReason == "" {
+ require.NoError(t, err)
+ return
+ }
+ var rej *uploads.RejectError
+ require.True(t, errors.As(err, &rej), "want a *RejectError, got %v", err)
+ assert.Equal(t, tt.wantReason, rej.Reason)
+ for _, s := range tt.wantInMsg {
+ assert.Contains(t, rej.Message, s)
+ }
+ })
+ }
+}
+
+func TestFormatDuration(t *testing.T) {
+ tests := []struct {
+ d time.Duration
+ want string
+ }{
+ {90 * time.Minute, "1 h 30 min"},
+ {102 * time.Minute, "1 h 42 min"},
+ {45 * time.Minute, "45 min"},
+ {2 * time.Hour, "2 h"},
+ {30 * time.Second, "30 s"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.want, func(t *testing.T) {
+ assert.Equal(t, tt.want, uploads.FormatDuration(tt.d))
+ })
+ }
+}
--
2.54.0
From e3e1f17c40a2b7172e5d1c00d433c749dcfa92f7 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:39:51 +0200
Subject: [PATCH 009/299] feat(uploads): validation policy for probed audio
uploads (ADR-033)
Video is checked first so it is always refused as video; limits and reasons
are typed so callers and tests branch on Reason, not message text.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/validate.go | 82 ++++++++++++++++++++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 internal/uploads/validate.go
diff --git a/internal/uploads/validate.go b/internal/uploads/validate.go
new file mode 100644
index 0000000..8dfea85
--- /dev/null
+++ b/internal/uploads/validate.go
@@ -0,0 +1,82 @@
+// Package uploads is the use case behind "upload an audio recording" (ADR-033):
+// validate the file by PROBING it, keep the transcript private, never retain the
+// audio. It depends only on ports (a Prober, a store, a job Submitter) — the
+// concrete ffprobe adapter, the web handler and the transcription worker are wired
+// from outside.
+package uploads
+
+import (
+ "fmt"
+ "time"
+)
+
+// Limits bound what an upload may be. Both are rejected synchronously, with a
+// specific message, rather than by a timeout (ADR-033).
+type Limits struct {
+ MaxBytes int64
+ MaxDuration time.Duration
+}
+
+// Probe is what inspecting an uploaded file revealed. It is the input to Validate
+// because a name or MIME type cannot say whether a container carries video: a
+// Teams voice-only export is typically an .mp4 with no video stream.
+type Probe struct {
+ Duration time.Duration
+ HasAudio bool
+ HasVideo bool
+}
+
+// Reason is the machine-checkable class of a rejection.
+type Reason string
+
+const (
+ ReasonVideo Reason = "video"
+ ReasonNoAudio Reason = "no_audio"
+ ReasonTooLong Reason = "too_long"
+ ReasonTooLarge Reason = "too_large"
+ ReasonUnreadable Reason = "unreadable"
+)
+
+// RejectError is a synchronous, user-presentable refusal. Message is written for
+// the person who uploaded the file; Reason is for code and tests.
+type RejectError struct {
+ Reason Reason
+ Message string
+}
+
+func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message }
+
+// Validate decides whether a probed file may be transcribed. Video is checked
+// first so a video file is always refused as video, whatever its length.
+func Validate(p Probe, l Limits) error {
+ switch {
+ case p.HasVideo:
+ return &RejectError{Reason: ReasonVideo, Message: "This file contains a video track. " +
+ "Only audio recordings are supported for now; video upload is tracked in issue #28."}
+ case !p.HasAudio:
+ return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."}
+ case p.Duration <= 0:
+ return &RejectError{Reason: ReasonUnreadable, Message: "Could not read a length from this file; it may be damaged."}
+ case p.Duration > l.MaxDuration:
+ return &RejectError{Reason: ReasonTooLong, Message: fmt.Sprintf(
+ "This recording is %s long; the limit is %s.", FormatDuration(p.Duration), FormatDuration(l.MaxDuration))}
+ }
+ return nil
+}
+
+// FormatDuration renders a length for people: "1 h 42 min", "45 min", "30 s".
+func FormatDuration(d time.Duration) string {
+ d = d.Round(time.Second)
+ h := int(d / time.Hour)
+ m := int(d % time.Hour / time.Minute)
+ switch {
+ case h > 0 && m > 0:
+ return fmt.Sprintf("%d h %d min", h, m)
+ case h > 0:
+ return fmt.Sprintf("%d h", h)
+ case m > 0:
+ return fmt.Sprintf("%d min", m)
+ default:
+ return fmt.Sprintf("%d s", int(d/time.Second))
+ }
+}
--
2.54.0
From b98e9df46214762511872d66a0aedd05b6ad395f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:40:03 +0200
Subject: [PATCH 010/299] test(uploads): Accept -- size cap, probe verdicts,
cleanup on every failure path (red)
Red: uploads.Job, Intake and ErrUnreadable are undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/intake_test.go | 212 ++++++++++++++++++++++++++++++++
1 file changed, 212 insertions(+)
create mode 100644 internal/uploads/intake_test.go
diff --git a/internal/uploads/intake_test.go b/internal/uploads/intake_test.go
new file mode 100644
index 0000000..0fcafc1
--- /dev/null
+++ b/internal/uploads/intake_test.go
@@ -0,0 +1,212 @@
+package uploads_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/iotest"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+type fakeStore struct {
+ created []string // titles
+ failed map[string]domain.UploadErrorKind
+ nextID string
+}
+
+func (f *fakeStore) CreateUpload(_ context.Context, _, title string) (string, error) {
+ f.created = append(f.created, title)
+ return f.nextID, nil
+}
+
+func (f *fakeStore) FailUpload(_ context.Context, _, id string, kind domain.UploadErrorKind) error {
+ if f.failed == nil {
+ f.failed = map[string]domain.UploadErrorKind{}
+ }
+ f.failed[id] = kind
+ return nil
+}
+
+type fakeProber struct {
+ probe uploads.Probe
+ err error
+ paths []string
+ seen []byte // content of the file at probe time
+}
+
+func (f *fakeProber) Probe(_ context.Context, path string) (uploads.Probe, error) {
+ f.paths = append(f.paths, path)
+ f.seen, _ = os.ReadFile(path)
+ return f.probe, f.err
+}
+
+type fakeSubmitter struct {
+ jobs []uploads.Job
+ err error
+}
+
+func (f *fakeSubmitter) Submit(j uploads.Job) error {
+ f.jobs = append(f.jobs, j)
+ return f.err
+}
+
+type rig struct {
+ in *uploads.Intake
+ st *fakeStore
+ pr *fakeProber
+ sub *fakeSubmitter
+ tmp string
+}
+
+func newRig(t *testing.T) *rig {
+ t.Helper()
+ r := &rig{
+ st: &fakeStore{nextID: "up-1"},
+ pr: &fakeProber{probe: uploads.Probe{Duration: 10 * time.Minute, HasAudio: true}},
+ sub: &fakeSubmitter{},
+ tmp: t.TempDir(),
+ }
+ r.in = uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: 90 * time.Minute}, r.tmp)
+ return r
+}
+
+func (r *rig) tmpFiles(t *testing.T) []string {
+ t.Helper()
+ ents, err := os.ReadDir(r.tmp)
+ require.NoError(t, err)
+ var names []string
+ for _, e := range ents {
+ names = append(names, e.Name())
+ }
+ return names
+}
+
+func TestAccept_ValidAudioIsStoredProbedQueuedAndSubmitted(t *testing.T) {
+ r := newRig(t)
+ audio := bytes.Repeat([]byte("a"), 500)
+
+ id, err := r.in.Accept(context.Background(), "user-1", "Teams export", bytes.NewReader(audio))
+
+ require.NoError(t, err)
+ assert.Equal(t, "up-1", id)
+ assert.Equal(t, []string{"Teams export"}, r.st.created)
+ assert.Equal(t, audio, r.pr.seen, "the prober must see the complete uploaded bytes, on disk")
+ require.Len(t, r.sub.jobs, 1)
+ job := r.sub.jobs[0]
+ assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path}, job)
+ assert.Equal(t, filepath.Dir(job.Path), r.tmp, "the temp file lives in the configured directory")
+ got, err := os.ReadFile(job.Path)
+ require.NoError(t, err)
+ assert.Equal(t, audio, got, "the handed-over file still exists: the worker, not Accept, deletes it")
+}
+
+func TestAccept_OversizeBodyIsRejectedAndLeavesNothingBehind(t *testing.T) {
+ r := newRig(t)
+
+ _, err := r.in.Accept(context.Background(), "user-1", "big", strings.NewReader(strings.Repeat("a", 1025)))
+
+ var rej *uploads.RejectError
+ require.True(t, errors.As(err, &rej))
+ assert.Equal(t, uploads.ReasonTooLarge, rej.Reason)
+ assert.Contains(t, rej.Message, "limit")
+ assert.Empty(t, r.pr.paths, "a file already over the size cap must not be probed")
+ assert.Empty(t, r.st.created, "a rejected upload creates no row")
+ assert.Empty(t, r.sub.jobs)
+ assert.Empty(t, r.tmpFiles(t), "the partial temp file must be removed")
+}
+
+func TestAccept_BodyExactlyAtTheLimitIsAccepted(t *testing.T) {
+ r := newRig(t)
+
+ _, err := r.in.Accept(context.Background(), "user-1", "edge", strings.NewReader(strings.Repeat("a", 1024)))
+
+ require.NoError(t, err)
+}
+
+func TestAccept_ProbeVerdictRejectsWithoutRowAndRemovesTheFile(t *testing.T) {
+ tests := []struct {
+ name string
+ probe uploads.Probe
+ reason uploads.Reason
+ }{
+ {"video", uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true}, uploads.ReasonVideo},
+ {"too long", uploads.Probe{Duration: 2 * time.Hour, HasAudio: true}, uploads.ReasonTooLong},
+ {"no audio", uploads.Probe{Duration: time.Minute}, uploads.ReasonNoAudio},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := newRig(t)
+ r.pr.probe = tt.probe
+
+ _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
+
+ var rej *uploads.RejectError
+ require.True(t, errors.As(err, &rej))
+ assert.Equal(t, tt.reason, rej.Reason)
+ assert.Empty(t, r.st.created)
+ assert.Empty(t, r.sub.jobs)
+ assert.Empty(t, r.tmpFiles(t))
+ })
+ }
+}
+
+func TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError(t *testing.T) {
+ t.Run("ffprobe says the file is not media", func(t *testing.T) {
+ r := newRig(t)
+ r.pr.err = uploads.ErrUnreadable
+
+ _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("not media"))
+
+ var rej *uploads.RejectError
+ require.True(t, errors.As(err, &rej), "the user's file is the problem: tell them")
+ assert.Equal(t, uploads.ReasonUnreadable, rej.Reason)
+ assert.Empty(t, r.tmpFiles(t))
+ })
+ t.Run("the prober itself broke", func(t *testing.T) {
+ r := newRig(t)
+ r.pr.err = errors.New("exec: ffprobe: executable file not found")
+
+ _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
+
+ require.Error(t, err)
+ var rej *uploads.RejectError
+ assert.False(t, errors.As(err, &rej), "our fault must not be reported as the user's bad file")
+ assert.Empty(t, r.st.created)
+ assert.Empty(t, r.tmpFiles(t))
+ })
+}
+
+func TestAccept_ClientDisconnectMidUploadLeavesNothingBehind(t *testing.T) {
+ r := newRig(t)
+ body := io.MultiReader(strings.NewReader("partial"), iotest.ErrReader(errors.New("client went away")))
+
+ _, err := r.in.Accept(context.Background(), "user-1", "t", body)
+
+ require.Error(t, err)
+ assert.Empty(t, r.pr.paths)
+ assert.Empty(t, r.st.created)
+ assert.Empty(t, r.tmpFiles(t))
+}
+
+func TestAccept_SubmitFailureFailsTheRowHonestlyAndRemovesTheFile(t *testing.T) {
+ r := newRig(t)
+ r.sub.err = errors.New("worker stopped")
+
+ _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
+
+ require.Error(t, err)
+ assert.Equal(t, domain.UploadErrInterrupted, r.st.failed["up-1"],
+ "a row that will never run must not sit queued: that is the stuck spinner ADR-025 forbids")
+ assert.Empty(t, r.tmpFiles(t))
+}
--
2.54.0
From 1910d3dfb5284b1b033b64ed332744b7fb2d1461 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:40:13 +0200
Subject: [PATCH 011/299] feat(uploads): Intake -- spool, probe, validate,
queue; clean up on every failure (ADR-033)
Bounded read (cap+1 byte tells 'at the limit' from 'over'); a file that fails
validation creates no row; a prober fault is our error, not the user's bad
file; a row whose job cannot be submitted is failed, never left queued.
Mutation-checked: cap off-by-one, missing cleanup and deleting the handed-over
file each turn a test red.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/intake.go | 115 +++++++++++++++++++++++++++++++++++++
1 file changed, 115 insertions(+)
create mode 100644 internal/uploads/intake.go
diff --git a/internal/uploads/intake.go b/internal/uploads/intake.go
new file mode 100644
index 0000000..d33cba5
--- /dev/null
+++ b/internal/uploads/intake.go
@@ -0,0 +1,115 @@
+package uploads
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// ErrUnreadable is what a Prober returns when the FILE is the problem (not media,
+// truncated, damaged), as opposed to the prober itself failing. Accept turns it
+// into a user-facing rejection; any other prober error is our fault and surfaces
+// as an internal error — a missing ffprobe must not read as "your file is bad".
+var ErrUnreadable = errors.New("uploads: file is not readable media")
+
+// Prober inspects a file on disk. The ffprobe adapter implements it.
+type Prober interface {
+ Probe(ctx context.Context, path string) (Probe, error)
+}
+
+// Store is the slice of the private transcript store the intake needs.
+type Store interface {
+ CreateUpload(ctx context.Context, userID, title string) (string, error)
+ FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
+}
+
+// Job is one accepted upload awaiting transcription. Path is a temp file the
+// receiver owns: it must delete it when finished (the audio is never retained).
+type Job struct {
+ UserID string
+ UploadID string
+ Path string
+}
+
+// Submitter hands accepted jobs to the in-process worker (ADR-033).
+type Submitter interface {
+ Submit(Job) error
+}
+
+// Intake validates an uploaded recording and, if it passes, queues it.
+type Intake struct {
+ store Store
+ prober Prober
+ sub Submitter
+ limits Limits
+ tmpDir string
+}
+
+// NewIntake wires an Intake. tmpDir is where uploads are spooled; it must exist.
+func NewIntake(s Store, p Prober, sub Submitter, l Limits, tmpDir string) *Intake {
+ return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir}
+}
+
+// Accept spools src to disk (bounded by MaxBytes), probes it, validates it, records
+// a queued upload and submits the job. It returns the upload id. A file that fails
+// validation yields a *RejectError and creates NO row — rejection is synchronous
+// and leaves nothing behind. On success the temp file is handed to the job and the
+// worker deletes it; on every failure path Accept deletes it itself.
+func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reader) (string, error) {
+ f, err := os.CreateTemp(in.tmpDir, "upload-*")
+ if err != nil {
+ return "", fmt.Errorf("uploads: spool: %w", err)
+ }
+ path := f.Name()
+ handedOver := false
+ defer func() {
+ if !handedOver {
+ _ = os.Remove(path)
+ }
+ }()
+
+ // Read one byte past the cap so "exactly at the limit" and "over" are told apart.
+ n, err := io.Copy(f, io.LimitReader(src, in.limits.MaxBytes+1))
+ if cerr := f.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil {
+ return "", fmt.Errorf("uploads: receive: %w", err)
+ }
+ if n > in.limits.MaxBytes {
+ return "", &RejectError{Reason: ReasonTooLarge, Message: fmt.Sprintf(
+ "This file is larger than the %d MB limit.", in.limits.MaxBytes>>20)}
+ }
+
+ probe, err := in.prober.Probe(ctx, path)
+ if errors.Is(err, ErrUnreadable) {
+ return "", &RejectError{
+ Reason: ReasonUnreadable,
+ Message: "This file could not be read as audio; it may be damaged or not a recording.",
+ }
+ }
+ if err != nil {
+ return "", fmt.Errorf("uploads: probe: %w", err)
+ }
+ if err := Validate(probe, in.limits); err != nil {
+ return "", err
+ }
+
+ id, err := in.store.CreateUpload(ctx, userID, title)
+ if err != nil {
+ return "", fmt.Errorf("uploads: create: %w", err)
+ }
+ if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path}); err != nil {
+ // A row that will never run must not sit queued: that is the stuck spinner.
+ if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrInterrupted); ferr != nil {
+ return "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr)
+ }
+ return "", fmt.Errorf("uploads: submit: %w", err)
+ }
+ handedOver = true
+ return id, nil
+}
--
2.54.0
From 0ab067c5105853d3da11c2f909222ec16bd29e17 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:40:29 +0200
Subject: [PATCH 012/299] test(audio): ffprobe adapter -- cover art is not
video, exit code vs missing binary (red)
Red: package audio does not exist.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/ffprobe_test.go | 129 ++++++++++++++++++++++++
1 file changed, 129 insertions(+)
create mode 100644 internal/adapters/audio/ffprobe_test.go
diff --git a/internal/adapters/audio/ffprobe_test.go b/internal/adapters/audio/ffprobe_test.go
new file mode 100644
index 0000000..609a84c
--- /dev/null
+++ b/internal/adapters/audio/ffprobe_test.go
@@ -0,0 +1,129 @@
+package audio_test
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/audio"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+func TestParseProbe(t *testing.T) {
+ tests := []struct {
+ name string
+ json string
+ want uploads.Probe
+ }{
+ {
+ name: "audio-only m4a",
+ json: `{"streams":[{"codec_type":"audio","disposition":{"attached_pic":0}}],"format":{"duration":"2701.5"}}`,
+ want: uploads.Probe{Duration: 2701*time.Second + 500*time.Millisecond, HasAudio: true},
+ },
+ {
+ name: "mp4 with a real video stream",
+ json: `{"streams":[{"codec_type":"video","disposition":{"attached_pic":0}},{"codec_type":"audio"}],"format":{"duration":"240.0"}}`,
+ want: uploads.Probe{Duration: 240 * time.Second, HasAudio: true, HasVideo: true},
+ },
+ {
+ name: "mp3 whose only video stream is attached cover art is NOT video",
+ json: `{"streams":[{"codec_type":"audio"},{"codec_type":"video","disposition":{"attached_pic":1}}],"format":{"duration":"60"}}`,
+ want: uploads.Probe{Duration: 60 * time.Second, HasAudio: true},
+ },
+ {
+ name: "no streams at all",
+ json: `{"streams":[],"format":{"duration":"10"}}`,
+ want: uploads.Probe{Duration: 10 * time.Second},
+ },
+ {
+ name: "duration absent means zero, which Validate rejects as unreadable",
+ json: `{"streams":[{"codec_type":"audio"}],"format":{}}`,
+ want: uploads.Probe{HasAudio: true},
+ },
+ {
+ name: "duration reported as N/A means zero",
+ json: `{"streams":[{"codec_type":"audio"}],"format":{"duration":"N/A"}}`,
+ want: uploads.Probe{HasAudio: true},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := audio.ParseProbe([]byte(tt.json))
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestParseProbe_GarbageIsAnError(t *testing.T) {
+ _, err := audio.ParseProbe([]byte("not json"))
+ assert.Error(t, err)
+}
+
+// stub writes an executable shell script standing in for ffprobe. This is a fake
+// at the process boundary: the real binary is not on every host (it is not on
+// koala), and what is under test is how Probe drives and interprets a process.
+func stub(t *testing.T, body string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "ffprobe")
+ require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755))
+ return p
+}
+
+func TestFFprobeProbe_ParsesWhatTheProcessPrints(t *testing.T) {
+ bin := stub(t, `echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"90"}}'`)
+
+ got, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/ignored")
+
+ require.NoError(t, err)
+ assert.Equal(t, uploads.Probe{Duration: 90 * time.Second, HasAudio: true}, got)
+}
+
+func TestFFprobeProbe_PassesTheFilePathAsTheLastArgument(t *testing.T) {
+ out := filepath.Join(t.TempDir(), "args")
+ bin := stub(t, `for a; do last="$a"; done; printf '%s' "$last" > `+out+`
+echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`)
+
+ _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-123")
+
+ require.NoError(t, err)
+ got, err := os.ReadFile(out)
+ require.NoError(t, err)
+ assert.Equal(t, "/spool/upload-123", string(got))
+}
+
+func TestFFprobeProbe_NonzeroExitMeansTheFileIsUnreadable(t *testing.T) {
+ bin := stub(t, `echo "Invalid data found when processing input" >&2; exit 1`)
+
+ _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/x")
+
+ assert.ErrorIs(t, err, uploads.ErrUnreadable)
+}
+
+func TestFFprobeProbe_MissingBinaryIsOurFaultNotAnUnreadableFile(t *testing.T) {
+ _, err := (&audio.FFprobe{Bin: filepath.Join(t.TempDir(), "no-such-ffprobe")}).Probe(context.Background(), "/x")
+
+ require.Error(t, err)
+ assert.False(t, errors.Is(err, uploads.ErrUnreadable),
+ "a missing ffprobe must not be reported to the user as their file being bad")
+}
+
+func TestFFprobeProbe_StopsWhenTheContextIsCancelled(t *testing.T) {
+ bin := stub(t, `sleep 30`)
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ start := time.Now()
+ _, err := (&audio.FFprobe{Bin: bin}).Probe(ctx, "/x")
+
+ require.Error(t, err)
+ assert.Less(t, time.Since(start), 5*time.Second)
+ assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a timeout is not a verdict on the file")
+}
--
2.54.0
From f84389028d31d35e083c5553432eac8d4def6de6 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:40:51 +0200
Subject: [PATCH 013/299] feat(audio): ffprobe adapter implementing
uploads.Prober (ADR-033)
Cover art is not video; non-zero exit is the file's fault (ErrUnreadable),
a missing binary / cancelled ctx / bad output is ours. WaitDelay bounds a
cancelled probe whose child holds the pipe open.
Checked against real ffprobe on iguana: JSON shape matches the fixtures and
/etc/hosts exits 1 with 'Invalid data found when processing input'. The unit
tests drive a stub process; ffprobe is not installed on koala.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/ffprobe.go | 95 ++++++++++++++++++++++++++++++
1 file changed, 95 insertions(+)
create mode 100644 internal/adapters/audio/ffprobe.go
diff --git a/internal/adapters/audio/ffprobe.go b/internal/adapters/audio/ffprobe.go
new file mode 100644
index 0000000..d4d27b6
--- /dev/null
+++ b/internal/adapters/audio/ffprobe.go
@@ -0,0 +1,95 @@
+// Package audio holds the adapters that inspect and convert user-supplied audio
+// with ffmpeg's tools (ADR-033). They sit behind small ports so the use case is
+// tested with fakes and only this package knows a subprocess is involved.
+package audio
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os/exec"
+ "strconv"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+// FFprobe implements uploads.Prober by running ffprobe on the spooled file.
+type FFprobe struct {
+ // Bin is the ffprobe executable ("ffprobe" resolves via PATH).
+ Bin string
+}
+
+// killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close.
+const killGrace = 2 * time.Second
+
+// Probe runs ffprobe and classifies the outcome. A non-zero exit means ffprobe
+// looked at the file and could not read it: that is the FILE's fault and returns
+// uploads.ErrUnreadable. A missing binary, a cancelled context or unparsable
+// output is OURS and is returned as an ordinary error, so it is never reported to
+// the user as their file being bad.
+func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error) {
+ bin := f.Bin
+ if bin == "" {
+ bin = "ffprobe"
+ }
+ cmd := exec.CommandContext(ctx, bin,
+ "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path)
+ cmd.WaitDelay = killGrace
+ out, err := cmd.Output()
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr)
+ }
+ var exit *exec.ExitError
+ if errors.As(err, &exit) {
+ return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, exit.Stderr)
+ }
+ return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err)
+ }
+ p, err := ParseProbe(out)
+ if err != nil {
+ return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err)
+ }
+ return p, nil
+}
+
+// probeJSON is the subset of `ffprobe -print_format json` output that matters.
+type probeJSON struct {
+ Streams []struct {
+ CodecType string `json:"codec_type"`
+ Disposition struct {
+ AttachedPic int `json:"attached_pic"`
+ } `json:"disposition"`
+ } `json:"streams"`
+ Format struct {
+ Duration string `json:"duration"`
+ } `json:"format"`
+}
+
+// ParseProbe turns ffprobe's JSON into a uploads.Probe. A video stream that is
+// only attached cover art (an mp3/m4a with an album picture) is not video. A
+// missing or "N/A" duration yields zero, which uploads.Validate rejects as
+// unreadable rather than accepting an empty recording.
+func ParseProbe(data []byte) (uploads.Probe, error) {
+ var j probeJSON
+ if err := json.Unmarshal(data, &j); err != nil {
+ return uploads.Probe{}, fmt.Errorf("decode: %w", err)
+ }
+ var p uploads.Probe
+ for _, s := range j.Streams {
+ switch s.CodecType {
+ case "audio":
+ p.HasAudio = true
+ case "video":
+ if s.Disposition.AttachedPic == 0 {
+ p.HasVideo = true
+ }
+ }
+ }
+ if secs, err := strconv.ParseFloat(j.Format.Duration, 64); err == nil && secs > 0 {
+ p.Duration = time.Duration(secs * float64(time.Second))
+ }
+ return p, nil
+}
--
2.54.0
From e1ed6ea6f481e0b124b7dc0ac908ec3fb10bc889 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:41:42 +0200
Subject: [PATCH 014/299] test(web): upload routes -- form, multipart POST,
rejections, owner-only, honest status (red)
Real Router + real Intake + real Postgres; only ffprobe and the worker are
faked. Covers: the form states the limits up front; accepted audio queues a
row and redirects; video / too-long / too-large are rejected synchronously
(422/422/413) with a message and NO row; a non-owner gets 404 without the
title leaking; unfinished states keep polling and terminal states stop (no
stuck spinner); a done transcript names the endpoint that served it.
Red: App.Uploads / App.UploadLimits are undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_handler_test.go | 276 +++++++++++++++++++++++++++
1 file changed, 276 insertions(+)
create mode 100644 internal/web/uploads_handler_test.go
diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go
new file mode 100644
index 0000000..2914e2c
--- /dev/null
+++ b/internal/web/uploads_handler_test.go
@@ -0,0 +1,276 @@
+package web_test
+
+import (
+ "bytes"
+ "context"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+ "git.d-ma.be/mathias/tapir/internal/web"
+)
+
+// Upload route tests drive the real Router, the real Intake and the real Postgres
+// store; only the process boundary (ffprobe) and the transcription worker are
+// faked. All audio bytes and transcript text are synthetic — this repo is public
+// and no real recording or transcript may appear in it (#33).
+
+const otherUserID = "22222222-2222-2222-2222-222222222222"
+
+type uploadProber struct{ probe uploads.Probe }
+
+func (p uploadProber) Probe(context.Context, string) (uploads.Probe, error) { return p.probe, nil }
+
+type uploadSubmitter struct{ jobs []uploads.Job }
+
+func (s *uploadSubmitter) Submit(j uploads.Job) error { s.jobs = append(s.jobs, j); return nil }
+
+func newUploadApp(t *testing.T, probe uploads.Probe, maxBytes int64) (*web.App, *uploadSubmitter) {
+ t.Helper()
+ app := newApp(t)
+ resetDB(t, rawPool(t))
+ sub := &uploadSubmitter{}
+ limits := uploads.Limits{MaxBytes: maxBytes, MaxDuration: 90 * time.Minute}
+ s := newStore(t)
+ app.Uploads = uploads.NewIntake(s, uploadProber{probe: probe}, sub, limits, t.TempDir())
+ app.UploadLimits = limits
+ return app, sub
+}
+
+var audioOK = uploads.Probe{Duration: 45 * time.Minute, HasAudio: true}
+
+func uploadReq(t *testing.T, title, filename string, content []byte) *http.Request {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ if title != "" {
+ require.NoError(t, mw.WriteField("title", title))
+ }
+ if filename != "" {
+ fw, err := mw.CreateFormFile("file", filename)
+ require.NoError(t, err)
+ _, err = fw.Write(content)
+ require.NoError(t, err)
+ }
+ require.NoError(t, mw.Close())
+ req := httptest.NewRequest(http.MethodPost, "/uploads", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ return req
+}
+
+func seedUpload(t *testing.T, owner, title, status, content string) string {
+ t.Helper()
+ p := rawPool(t)
+ _, err := p.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING`, owner)
+ require.NoError(t, err)
+ var id string
+ switch status {
+ case "done":
+ require.NoError(t, p.QueryRow(context.Background(),
+ `INSERT INTO uploaded_transcripts (user_id, title, status, content, endpoint, audio_seconds, finished_at)
+ VALUES ($1, $2, 'done', $3, 'berget/kb-whisper', 2700, NOW()) RETURNING id`,
+ owner, title, content).Scan(&id))
+ case "failed":
+ require.NoError(t, p.QueryRow(context.Background(),
+ `INSERT INTO uploaded_transcripts (user_id, title, status, error_kind, finished_at)
+ VALUES ($1, $2, 'failed', $3, NOW()) RETURNING id`,
+ owner, title, content).Scan(&id)) // content carries the error_kind here
+ default:
+ require.NoError(t, p.QueryRow(context.Background(),
+ `INSERT INTO uploaded_transcripts (user_id, title, status) VALUES ($1, $2, $3) RETURNING id`,
+ owner, title, status).Scan(&id))
+ }
+ return id
+}
+
+func get(path string) *http.Request { return httptest.NewRequest(http.MethodGet, path, nil) }
+
+func TestUploadsPage_ShowsAMultipartFormWithAFileField(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+
+ rec := do(t, app, get("/uploads"))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ b := body(t, rec)
+ assert.Contains(t, b, `enctype="multipart/form-data"`)
+ assert.Contains(t, b, `name="file"`, "the multipart field is `file`")
+ assert.Contains(t, b, "200 MB", "the size limit is stated up front, not discovered by failing")
+ assert.Contains(t, b, "1 h 30 min", "the length limit is stated up front")
+}
+
+func TestUploadsPage_ListsOnlyTheCallersUploads(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ seedUpload(t, userID, "My weekly sync", "queued", "")
+ seedUpload(t, otherUserID, "Someone else's board meeting", "queued", "")
+
+ b := body(t, do(t, app, get("/uploads")))
+
+ assert.Contains(t, b, "My weekly sync")
+ assert.NotContains(t, b, "Someone else's board meeting")
+}
+
+func TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt(t *testing.T) {
+ app, sub := newUploadApp(t, audioOK, 200<<20)
+
+ rec := do(t, app, uploadReq(t, "Sprint review", "recording.m4a", []byte("synthetic audio bytes")))
+
+ require.Equal(t, http.StatusSeeOther, rec.Code)
+ var id, status, title string
+ require.NoError(t, rawPool(t).QueryRow(context.Background(),
+ `SELECT id, status, title FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&id, &status, &title))
+ assert.Equal(t, "queued", status)
+ assert.Equal(t, "Sprint review", title)
+ assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
+ require.Len(t, sub.jobs, 1)
+ assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path}, sub.jobs[0])
+ spooled, err := os.ReadFile(sub.jobs[0].Path)
+ require.NoError(t, err)
+ assert.Equal(t, "synthetic audio bytes", string(spooled))
+}
+
+func TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+
+ rec := do(t, app, uploadReq(t, "", "Teams meeting 2026-09-30.mp4", []byte("x")))
+
+ require.Equal(t, http.StatusSeeOther, rec.Code)
+ var title string
+ require.NoError(t, rawPool(t).QueryRow(context.Background(),
+ `SELECT title FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&title))
+ assert.Equal(t, "Teams meeting 2026-09-30", title)
+}
+
+func TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow(t *testing.T) {
+ tests := []struct {
+ name string
+ probe uploads.Probe
+ maxBytes int64
+ content string
+ wantStatus int
+ wantInBody []string
+ }{
+ {
+ name: "video names issue 28",
+ probe: uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true},
+ maxBytes: 1 << 20,
+ content: "x",
+ wantStatus: http.StatusUnprocessableEntity,
+ wantInBody: []string{"video", "#28"},
+ },
+ {
+ name: "too long states both lengths",
+ probe: uploads.Probe{Duration: 2 * time.Hour, HasAudio: true},
+ maxBytes: 1 << 20,
+ content: "x",
+ wantStatus: http.StatusUnprocessableEntity,
+ wantInBody: []string{"2 h", "1 h 30 min"},
+ },
+ {
+ name: "too large is 413",
+ probe: audioOK,
+ maxBytes: 4,
+ content: "more than four bytes",
+ wantStatus: http.StatusRequestEntityTooLarge,
+ wantInBody: []string{"limit"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ app, sub := newUploadApp(t, tt.probe, tt.maxBytes)
+
+ rec := do(t, app, uploadReq(t, "t", "r.m4a", []byte(tt.content)))
+
+ assert.Equal(t, tt.wantStatus, rec.Code)
+ for _, s := range tt.wantInBody {
+ assert.Contains(t, body(t, rec), s)
+ }
+ var n int
+ require.NoError(t, rawPool(t).QueryRow(context.Background(),
+ `SELECT count(*) FROM uploaded_transcripts`).Scan(&n))
+ assert.Zero(t, n, "a rejected upload leaves no row")
+ assert.Empty(t, sub.jobs)
+ })
+ }
+}
+
+func TestUploadPost_WithNoFilePartIs400(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+
+ rec := do(t, app, uploadReq(t, "only a title", "", nil))
+
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ assert.Contains(t, body(t, rec), "choose")
+}
+
+func TestUploadRoutes_AreNotMountedWithoutAnUploader(t *testing.T) {
+ app := newApp(t)
+ resetDB(t, rawPool(t))
+
+ assert.Equal(t, http.StatusNotFound, do(t, app, get("/uploads")).Code)
+}
+
+func TestUploadPage_OwnerSeesTitleStatusAndNonOwnerGetsNotFound(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ mine := seedUpload(t, userID, "Roadmap sync", "queued", "")
+ theirs := seedUpload(t, otherUserID, "Their private meeting", "queued", "")
+
+ own := do(t, app, get("/uploads/"+mine))
+ require.Equal(t, http.StatusOK, own.Code)
+ assert.Contains(t, body(t, own), "Roadmap sync")
+
+ for _, path := range []string{"/uploads/" + theirs, "/uploads/" + theirs + "/status"} {
+ rec := do(t, app, get(path))
+ assert.Equal(t, http.StatusNotFound, rec.Code, path)
+ assert.NotContains(t, body(t, rec), "Their private meeting", "a 404 must not leak the title")
+ }
+}
+
+func TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ tests := []struct {
+ name, status, content string
+ polls bool
+ wantInBody string
+ }{
+ {"queued", "queued", "", true, "queued"},
+ {"transcribing", "transcribing", "", true, "ranscrib"},
+ {"done", "done", "Synthetic transcript text.", false, "Synthetic transcript text."},
+ {"failed interrupted", "failed", "interrupted", false, "upload it again"},
+ {"failed transcription", "failed", "transcription_failed", false, "Transcription failed"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ id := seedUpload(t, userID, "m-"+tt.name, tt.status, tt.content)
+
+ rec := do(t, app, get("/uploads/"+id+"/status"))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ b := body(t, rec)
+ assert.Contains(t, b, tt.wantInBody)
+ if tt.polls {
+ assert.Contains(t, b, `hx-trigger="every`, "an unfinished upload must keep polling")
+ } else {
+ assert.NotContains(t, b, "hx-trigger", "a finished upload must not poll forever: no stuck spinner")
+ }
+ })
+ }
+}
+
+func TestUploadStatus_DoneNamesTheEndpointThatServedIt(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "berget/kb-whisper",
+ "which backend served a transcript is a quality fact, and berget means the audio left the homelab")
+ assert.True(t, strings.Contains(b, "45 min"), "the audio length is shown")
+}
--
2.54.0
From 1bc98813fc029ff111ed2db2bc5833737e193b6f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:42:40 +0200
Subject: [PATCH 015/299] test(web): read the response body once; match the
message's real case
Two defects in my red tests, found when going green: body() drains the
recorder so a second call read empty, and two assertions expected a different
case than the user-facing message. The assertions were not weakened.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_handler_test.go | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go
index 2914e2c..c43e982 100644
--- a/internal/web/uploads_handler_test.go
+++ b/internal/web/uploads_handler_test.go
@@ -189,8 +189,9 @@ func TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow(t *testing.T) {
rec := do(t, app, uploadReq(t, "t", "r.m4a", []byte(tt.content)))
assert.Equal(t, tt.wantStatus, rec.Code)
+ b := body(t, rec)
for _, s := range tt.wantInBody {
- assert.Contains(t, body(t, rec), s)
+ assert.Contains(t, b, s)
}
var n int
require.NoError(t, rawPool(t).QueryRow(context.Background(),
@@ -207,7 +208,7 @@ func TestUploadPost_WithNoFilePartIs400(t *testing.T) {
rec := do(t, app, uploadReq(t, "only a title", "", nil))
assert.Equal(t, http.StatusBadRequest, rec.Code)
- assert.Contains(t, body(t, rec), "choose")
+ assert.Contains(t, body(t, rec), "Choose an audio file")
}
func TestUploadRoutes_AreNotMountedWithoutAnUploader(t *testing.T) {
@@ -240,7 +241,7 @@ func TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop(t *testing.T
polls bool
wantInBody string
}{
- {"queued", "queued", "", true, "queued"},
+ {"queued", "queued", "", true, "Queued"},
{"transcribing", "transcribing", "", true, "ranscrib"},
{"done", "done", "Synthetic transcript text.", false, "Synthetic transcript text."},
{"failed interrupted", "failed", "interrupted", false, "upload it again"},
--
2.54.0
From e12a5e7e8959aed5a42caea0f76b9544496dbe46 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:42:58 +0200
Subject: [PATCH 016/299] feat(web): upload routes -- form, multipart intake,
honest status (ADR-033)
GET/POST /uploads, GET /uploads/{id}, GET /uploads/{id}/status, mounted only
when an Uploader is wired. The multipart body is streamed to Intake, never
buffered. Rejections are synchronous: 422 (video, too long, unreadable) or 413
with the user-facing message and no row. Another user's id is a 404 that does
not leak the title. Unfinished states poll; done/failed render without
hx-trigger so nothing polls forever (ADR-025). A done transcript names the
endpoint that served it.
handlers.go, uploads.go and the templ pair are one commit: the App fields and
Store methods are what make the handlers compile.
Mutation-checked: dropping the 413 mapping, the not-found mapping, or letting
a done card poll each turn a test red.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/handlers.go | 21 ++
internal/web/uploads.go | 191 +++++++++++++++++
internal/web/uploads.templ | 91 ++++++++
internal/web/uploads_templ.go | 381 ++++++++++++++++++++++++++++++++++
4 files changed, 684 insertions(+)
create mode 100644 internal/web/uploads.go
create mode 100644 internal/web/uploads.templ
create mode 100644 internal/web/uploads_templ.go
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
index bbf17d0..b297dce 100644
--- a/internal/web/handlers.go
+++ b/internal/web/handlers.go
@@ -13,6 +13,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
)
// Store is the read/write surface the web handlers depend on — a narrow port over
@@ -53,6 +54,12 @@ type Store interface {
// the most recent discovery pass, shown on the account page as a warning.
ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error)
+ // Uploaded-audio transcripts (ADR-033): the caller's own PRIVATE uploads,
+ // stored apart from the shared public captions. Both are user-scoped; a
+ // non-owner's id is ErrUploadNotFound.
+ GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error)
+ ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error)
+
// SetTranscriptStatus clears or updates a video's transcript backoff state.
// Used by handleRetryNow to clear rate_limited_at before immediate processing.
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
@@ -100,6 +107,12 @@ type App struct {
// summary view shows no "dig deeper" affordance. It holds no caption-fetch
// dependency, so an enabled chat cannot reach YouTube or the rate gate.
Chat Chatter
+ // Uploads, when non-nil, accepts audio recordings for private transcription
+ // (ADR-033). Nil = the /uploads routes are not mounted. UploadLimits are the
+ // same limits the Uploader enforces, shown on the form so they are stated up
+ // front rather than discovered by failing.
+ Uploads Uploader
+ UploadLimits uploads.Limits
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
@@ -166,6 +179,14 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /v/{videoId}/chat", a.handleChat)
app.HandleFunc("POST /v/{videoId}/chat", a.handleChatMessage)
}
+ // Audio upload → private transcript (ADR-033). Mounted only when an Uploader is
+ // wired; every handler is user-scoped, so another user's upload is a 404.
+ if a.Uploads != nil {
+ app.HandleFunc("GET /uploads", a.handleUploadsPage)
+ app.HandleFunc("POST /uploads", a.handleUploadPost)
+ app.HandleFunc("GET /uploads/{id}", a.handleUploadDetail)
+ app.HandleFunc("GET /uploads/{id}/status", a.handleUploadStatus)
+ }
app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
new file mode 100644
index 0000000..abb8711
--- /dev/null
+++ b/internal/web/uploads.go
@@ -0,0 +1,191 @@
+package web
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "unicode/utf8"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/store"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+// Uploader accepts an uploaded recording for transcription (ADR-033).
+// *uploads.Intake satisfies it. It is the whole of what the handlers know about
+// validation, spooling and queueing.
+type Uploader interface {
+ Accept(ctx context.Context, userID, title string, src io.Reader) (id string, err error)
+}
+
+const (
+ // uploadOverhead is headroom above the file-size cap for the multipart framing
+ // and the title field; the cap itself is enforced on the file part by Intake.
+ uploadOverhead = 1 << 20
+ maxTitleRunes = 200
+ // uploadPollEvery is how often an unfinished upload re-asks for its status.
+ uploadPollEvery = "5s"
+)
+
+// uploadsView is what the /uploads page renders.
+type uploadsView struct {
+ Limits uploads.Limits
+ Rows []domain.UploadedTranscript
+ Error string // a synchronous rejection message, empty when none
+}
+
+// handleUploadsPage renders the upload form and the caller's own uploads.
+func (a *App) handleUploadsPage(w http.ResponseWriter, r *http.Request) {
+ userID, ok := a.currentUserID(w, r)
+ if !ok {
+ return
+ }
+ a.renderUploadsPage(w, r, userID, http.StatusOK, "")
+}
+
+func (a *App) renderUploadsPage(w http.ResponseWriter, r *http.Request, userID string, status int, msg string) {
+ rows, err := a.Store.ListUploads(r.Context(), userID)
+ if err != nil {
+ a.serverError(w, r, "list uploads", err)
+ return
+ }
+ a.renderStatus(w, r, status, UploadsPage(uploadsView{Limits: a.UploadLimits, Rows: rows, Error: msg}))
+}
+
+// handleUploadPost streams the multipart body: the optional title field, then the
+// file part, which goes straight to Intake without being buffered in memory. A
+// rejection is synchronous — a status code and a message on the upload page, no
+// row, no timeout (ADR-033).
+func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
+ userID, ok := a.currentUserID(w, r)
+ if !ok {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, a.UploadLimits.MaxBytes+uploadOverhead)
+ mr, err := r.MultipartReader()
+ if err != nil {
+ a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
+ return
+ }
+ title := ""
+ for {
+ part, err := mr.NextPart()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ a.uploadFailure(w, r, userID, err)
+ return
+ }
+ switch part.FormName() {
+ case "title":
+ b, _ := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
+ title = clipRunes(strings.TrimSpace(string(b)), maxTitleRunes)
+ case "file":
+ if title == "" {
+ title = titleFromFilename(part.FileName())
+ }
+ id, err := a.Uploads.Accept(r.Context(), userID, title, part)
+ if err != nil {
+ a.uploadFailure(w, r, userID, err)
+ return
+ }
+ http.Redirect(w, r, "/uploads/"+id, http.StatusSeeOther)
+ return
+ }
+ }
+ a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
+}
+
+// uploadFailure maps an Accept (or multipart) error to a response: a validation
+// rejection is the user's to fix (422, or 413 for size) and carries its own
+// message; anything else is ours.
+func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
+ var rej *uploads.RejectError
+ var tooBig *http.MaxBytesError
+ switch {
+ case errors.As(err, &rej):
+ status := http.StatusUnprocessableEntity
+ if rej.Reason == uploads.ReasonTooLarge {
+ status = http.StatusRequestEntityTooLarge
+ }
+ a.renderUploadsPage(w, r, userID, status, rej.Message)
+ case errors.As(err, &tooBig):
+ a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
+ "This file is larger than the upload limit.")
+ default:
+ a.serverError(w, r, "upload", err)
+ }
+}
+
+// handleUploadDetail renders one upload's page (GET /uploads/{id}). The row is
+// read through the user-scoped store, so another user's id is a 404 — existence is
+// not disclosed, and the title never appears in the response.
+func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.loadOwnedUpload(w, r)
+ if !ok {
+ return
+ }
+ a.render(w, r, UploadPage(*u))
+}
+
+// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
+// keep polling; terminal states render without hx-trigger so the page stops
+// asking (ADR-025: never a stuck spinner).
+func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.loadOwnedUpload(w, r)
+ if !ok {
+ return
+ }
+ a.render(w, r, uploadStatus(*u))
+}
+
+func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
+ userID, ok := a.currentUserID(w, r)
+ if !ok {
+ return nil, false
+ }
+ u, err := a.Store.GetUpload(r.Context(), userID, r.PathValue("id"))
+ if errors.Is(err, store.ErrUploadNotFound) {
+ http.NotFound(w, r)
+ return nil, false
+ }
+ if err != nil {
+ a.serverError(w, r, "get upload", err)
+ return nil, false
+ }
+ return u, true
+}
+
+// titleFromFilename is the default title: the file's name without its extension.
+func titleFromFilename(name string) string {
+ base := filepath.Base(name)
+ t := strings.TrimSuffix(base, filepath.Ext(base))
+ if t == "" || t == "." {
+ return "Untitled recording"
+ }
+ return clipRunes(t, maxTitleRunes)
+}
+
+func clipRunes(s string, n int) string {
+ if utf8.RuneCountInString(s) <= n {
+ return s
+ }
+ return string([]rune(s)[:n])
+}
+
+// uploadFailureMessage turns a typed failure kind into words for the user. It is
+// keyed on OUR kinds, never on a provider's error string (ADR-033).
+func uploadFailureMessage(k domain.UploadErrorKind) string {
+ switch k {
+ case domain.UploadErrInterrupted:
+ return "The server restarted before this recording was transcribed. Please upload it again."
+ case domain.UploadErrTranscriptionFailed:
+ return "Transcription failed on every available service. Please upload it again later."
+ default:
+ return "This upload failed. Please upload it again."
+ }
+}
diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ
new file mode 100644
index 0000000..ffcb759
--- /dev/null
+++ b/internal/web/uploads.templ
@@ -0,0 +1,91 @@
+package web
+
+import (
+ "fmt"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+// UploadsPage is the upload form plus the caller's own uploads (ADR-033).
+templ UploadsPage(v uploadsView) {
+ @Layout("Tapir — Upload a recording") {
+
+ Upload a recording
+
+ Audio only. Up to { fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20) } and
+ { uploads.FormatDuration(v.Limits.MaxDuration) }. The transcript is private to you;
+ the audio is deleted once it has been transcribed.
+
+ if v.Error != "" {
+ { v.Error }
+ }
+
+ if len(v.Rows) > 0 {
+
+ Your recordings
+
+ for _, u := range v.Rows {
+
+ { u.Title }
+ { string(u.Status) }
+
+ }
+
+
+ }
+
+ }
+}
+
+// UploadPage is one upload's page: its title and the polling status region.
+templ UploadPage(u domain.UploadedTranscript) {
+ @Layout("Tapir — " + u.Title) {
+
+ { u.Title }
+ @uploadStatus(u)
+ All recordings
+
+ }
+}
+
+// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
+// page re-asks; done and failed render without it, so nothing polls forever.
+templ uploadStatus(u domain.UploadedTranscript) {
+ switch u.Status {
+ case domain.UploadDone:
+
+
+ Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
+ of audio by { u.Endpoint } .
+
+
+ Transcript
+ { u.Content }
+
+
+ case domain.UploadFailed:
+
+
{ uploadFailureMessage(u.ErrorKind) }
+
+ default:
+
+ if u.Status == domain.UploadTranscribing {
+
Transcribing… a long recording takes a few minutes.
+ } else {
+
Queued — waiting for its turn to be transcribed.
+ }
+
+ }
+}
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go
new file mode 100644
index 0000000..a874853
--- /dev/null
+++ b/internal/web/uploads_templ.go
@@ -0,0 +1,381 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package web
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+// UploadsPage is the upload form plus the caller's own uploads (ADR-033).
+func UploadsPage(v uploadsView) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Upload a recording Audio only. Up to ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 17, Col: 67}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " and ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(v.Limits.MaxDuration))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 18, Col: 50}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ". The transcript is private to you; the audio is deleted once it has been transcribed.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.Error != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 22, Col: 57}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(v.Rows) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Your recordings ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, u := range v.Rows {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 35, Col: 63}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Status))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 45}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Tapir — Upload a recording").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// UploadPage is one upload's page: its title and the polling status region.
+func UploadPage(u domain.UploadedTranscript) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var9 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var9 == nil {
+ templ_7745c5c3_Var9 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 50, Col: 16}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = uploadStatus(u).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "All recordings
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Tapir — "+u.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
+// page re-asks; done and failed render without it, so nothing polls forever.
+func uploadStatus(u domain.UploadedTranscript) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var12 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var12 == nil {
+ templ_7745c5c3_Var12 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ switch u.Status {
+ case domain.UploadDone:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Transcribed ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 64, Col: 95}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " of audio by ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 48}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " .
Transcript ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 69, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case domain.UploadFailed:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 74, Col: 42}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ default:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if u.Status == domain.UploadTranscribing {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
Transcribing… a long recording takes a few minutes.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
Queued — waiting for its turn to be transcribed.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
--
2.54.0
From bb285d8757b40dfeffa25f7f1eca5130cdf053f3 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:43:51 +0200
Subject: [PATCH 017/299] test(transcribe): gateway client and iguana->berget
chain (red)
Gateway: field is `file`, no Expect header, a computed Content-Length (not
chunked, not buffered -- a 90 min WAV is ~170 MB), verbose_json normalised
with the last segment clamped to the audio duration, errors independent of
the body, an empty transcript is a failure. Chain: ANY primary failure falls
back and the row records the fallback endpoint; a cancelled job never falls
back to the cloud.
Red: package transcribe does not exist.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/chain_test.go | 99 +++++++++++
internal/adapters/transcribe/gateway_test.go | 174 +++++++++++++++++++
2 files changed, 273 insertions(+)
create mode 100644 internal/adapters/transcribe/chain_test.go
create mode 100644 internal/adapters/transcribe/gateway_test.go
diff --git a/internal/adapters/transcribe/chain_test.go b/internal/adapters/transcribe/chain_test.go
new file mode 100644
index 0000000..dc9af18
--- /dev/null
+++ b/internal/adapters/transcribe/chain_test.go
@@ -0,0 +1,99 @@
+package transcribe_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+type fakeBackend struct {
+ name string
+ text string
+ err error
+ calls int
+}
+
+func (f *fakeBackend) Name() string { return f.name }
+func (f *fakeBackend) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
+ f.calls++
+ if f.err != nil {
+ return domain.Transcription{}, f.err
+ }
+ return domain.Transcription{Text: f.text, Endpoint: f.name}, nil
+}
+
+func TestChain_PrimarySuccessNeverTouchesTheFallback(t *testing.T) {
+ primary := &fakeBackend{name: "iguana/kb-whisper", text: "primary text"}
+ fallback := &fakeBackend{name: "berget/kb-whisper", text: "fallback text"}
+
+ got, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Equal(t, "primary text", got.Text)
+ assert.Equal(t, "iguana/kb-whisper", got.Endpoint)
+ assert.Zero(t, fallback.calls, "audio must not leave the homelab when the local endpoint worked")
+}
+
+func TestChain_AnyPrimaryFailureFallsBackAndRecordsTheFallbackEndpoint(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ }{
+ {"server error", &transcribe.StatusError{Code: 500}},
+ {"gateway timeout", &transcribe.StatusError{Code: 504}},
+ {"client error", &transcribe.StatusError{Code: 400}},
+ {"transport failure", errors.New("dial tcp: connection refused")},
+ {"empty transcript", transcribe.ErrEmptyTranscript},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ primary := &fakeBackend{name: "iguana/kb-whisper", err: tt.err}
+ fallback := &fakeBackend{name: "berget/kb-whisper", text: "fallback text"}
+
+ got, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Equal(t, "fallback text", got.Text)
+ assert.Equal(t, "berget/kb-whisper", got.Endpoint,
+ "the row must say berget served it: that means the audio left the homelab")
+ })
+ }
+}
+
+func TestChain_BothFailingReportsBothErrors(t *testing.T) {
+ primary := &fakeBackend{name: "iguana/kb-whisper", err: errors.New("iguana down")}
+ fallback := &fakeBackend{name: "berget/kb-whisper", err: errors.New("berget down")}
+
+ _, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "iguana down")
+ assert.Contains(t, err.Error(), "berget down")
+}
+
+func TestChain_WithoutAFallbackAPrimaryFailureIsTheResult(t *testing.T) {
+ primary := &fakeBackend{name: "iguana/kb-whisper", err: errors.New("iguana down")}
+
+ _, err := transcribe.NewChain(primary, nil).Transcribe(context.Background(), "/a.wav")
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "iguana down")
+}
+
+func TestChain_ACancelledJobDoesNotFallBack(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ primary := &fakeBackend{name: "iguana/kb-whisper", err: context.Canceled}
+ fallback := &fakeBackend{name: "berget/kb-whisper", text: "x"}
+
+ _, err := transcribe.NewChain(primary, fallback).Transcribe(ctx, "/a.wav")
+
+ assert.ErrorIs(t, err, context.Canceled)
+ assert.Zero(t, fallback.calls, "a cancelled job must not ship the audio to a cloud endpoint")
+}
diff --git a/internal/adapters/transcribe/gateway_test.go b/internal/adapters/transcribe/gateway_test.go
new file mode 100644
index 0000000..eb3b677
--- /dev/null
+++ b/internal/adapters/transcribe/gateway_test.go
@@ -0,0 +1,174 @@
+package transcribe_test
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// The httptest server stands in for the LiteLLM gateway: it is the process
+// boundary, so a fake here is legitimate. The JSON it returns mirrors what the
+// real gateway returned for iguana/kb-whisper and berget/kb-whisper on 2026-10-01
+// (docs/research/audio-length-ceiling-2026-10-01.md). All text is synthetic.
+
+const verboseJSON = `{
+ "text": " God morgon allihopa.\n Vi går igenom läget.\n",
+ "language": "swedish",
+ "duration": 4.25,
+ "segments": [
+ {"id": 0, "start": 0.0, "end": 1.0, "text": " God morgon allihopa."},
+ {"id": 1, "start": 1.0, "end": 9.0, "text": " Vi går igenom läget."}
+ ]
+}`
+
+func wavFile(t *testing.T, content string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "a.wav")
+ require.NoError(t, os.WriteFile(p, []byte(content), 0o600))
+ return p
+}
+
+type seen struct {
+ method, path, auth, expect, contentType string
+ contentLength int64
+ transferEncoding []string
+ fields map[string]string
+ file string
+ fileField string
+ bodyLen int
+}
+
+// serve starts a gateway fake that records the request it received and replies
+// with status and body.
+func serve(t *testing.T, status int, reply string, got *seen) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got != nil {
+ got.method, got.path = r.Method, r.URL.Path
+ got.auth = r.Header.Get("Authorization")
+ got.expect = r.Header.Get("Expect")
+ got.contentType = r.Header.Get("Content-Type")
+ got.contentLength = r.ContentLength
+ got.transferEncoding = r.TransferEncoding
+ raw, _ := io.ReadAll(r.Body)
+ got.bodyLen = len(raw)
+ r.Body = io.NopCloser(bytes.NewReader(raw))
+ require.NoError(t, r.ParseMultipartForm(32<<20))
+ got.fields = map[string]string{}
+ for k, v := range r.MultipartForm.Value {
+ got.fields[k] = v[0]
+ }
+ for field, fhs := range r.MultipartForm.File {
+ got.fileField = field
+ f, _ := fhs[0].Open()
+ b, _ := io.ReadAll(f)
+ got.file = string(b)
+ }
+ }
+ w.WriteHeader(status)
+ _, _ = io.WriteString(w, reply)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestGateway_SendsTheRequestTheGatewayExpects(t *testing.T) {
+ var got seen
+ srv := serve(t, 200, verboseJSON, &got)
+ g := transcribe.NewGateway(srv.URL+"/v1", "secret-key", "iguana/kb-whisper", srv.Client())
+
+ _, err := g.Transcribe(context.Background(), wavFile(t, "RIFF-synthetic-pcm"))
+
+ require.NoError(t, err)
+ assert.Equal(t, http.MethodPost, got.method)
+ assert.Equal(t, "/v1/audio/transcriptions", got.path)
+ assert.Equal(t, "Bearer secret-key", got.auth)
+ assert.Equal(t, "file", got.fileField, "the multipart field is `file`; `audio_file` is rejected as 'Invalid request'")
+ assert.Equal(t, "RIFF-synthetic-pcm", got.file)
+ assert.Equal(t, "iguana/kb-whisper", got.fields["model"])
+ assert.Equal(t, "verbose_json", got.fields["response_format"])
+ assert.Equal(t, "sv", got.fields["language"])
+ assert.Empty(t, got.expect, "no Expect: 100-continue: it trips the proxy for any body over 1 KB")
+}
+
+func TestGateway_SendsAKnownContentLengthNotChunked(t *testing.T) {
+ var got seen
+ srv := serve(t, 200, verboseJSON, &got)
+ g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
+
+ _, err := g.Transcribe(context.Background(), wavFile(t, "0123456789"))
+
+ require.NoError(t, err)
+ assert.Empty(t, got.transferEncoding, "a 90-minute WAV is ~170 MB: stream it with a computed length, not chunked and not buffered")
+ assert.Equal(t, int64(got.bodyLen), got.contentLength)
+}
+
+func TestGateway_NormalisesTheVerboseJSONResponse(t *testing.T) {
+ srv := serve(t, 200, verboseJSON, nil)
+ g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
+
+ got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
+
+ require.NoError(t, err)
+ assert.Equal(t, "God morgon allihopa.\n Vi går igenom läget.", got.Text)
+ assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is part of the result")
+ assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
+ assert.Equal(t, []domain.Segment{
+ {Start: 0, End: 1, Text: "God morgon allihopa."},
+ // The final segment ended at 9.0 s for 4.25 s of audio: whisper's last
+ // window can run past the end. Clamped to the audio duration.
+ {Start: 1, End: 4.25, Text: "Vi går igenom läget."},
+ }, got.Segments)
+}
+
+func TestGateway_ErrorsDoNotDependOnTheBody(t *testing.T) {
+ // {"error":"failed to process audio"} has had at least five distinct causes, so
+ // no code may branch on it. Two different bodies must be treated identically.
+ for _, reply := range []string{`{"error":"failed to process audio"}`, `{"error":"something else entirely"}`} {
+ srv := serve(t, 500, reply, nil)
+ g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
+
+ _, err := g.Transcribe(context.Background(), wavFile(t, "x"))
+
+ var se *transcribe.StatusError
+ require.ErrorAs(t, err, &se)
+ assert.Equal(t, 500, se.Code)
+ }
+}
+
+func TestGateway_AnEmptyTranscriptIsAFailureNotASuccess(t *testing.T) {
+ srv := serve(t, 200, `{"text":" ","language":"swedish","duration":3,"segments":[]}`, nil)
+ g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
+
+ _, err := g.Transcribe(context.Background(), wavFile(t, "x"))
+
+ assert.ErrorIs(t, err, transcribe.ErrEmptyTranscript)
+}
+
+func TestGateway_MalformedJSONIsAnError(t *testing.T) {
+ srv := serve(t, 200, `not json`, nil)
+ g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
+
+ _, err := g.Transcribe(context.Background(), wavFile(t, "x"))
+
+ assert.Error(t, err)
+}
+
+func TestGateway_MissingAudioFileIsAnError(t *testing.T) {
+ g := transcribe.NewGateway("http://127.0.0.1:1/v1", "k", "m", http.DefaultClient)
+
+ _, err := g.Transcribe(context.Background(), filepath.Join(t.TempDir(), "nope.wav"))
+
+ assert.Error(t, err)
+}
--
2.54.0
From cae423750c04f9c9193c2b5d1dcbfe57de605e16 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:44:34 +0200
Subject: [PATCH 018/299] feat(transcribe): gateway client with an
iguana->berget fallback chain (ADR-033)
Streams the WAV from disk with a computed Content-Length (no buffering, no
chunking), posts verbose_json with field `file` and language=sv, clamps the
final segment to the audio duration, and treats an empty transcript as a
failure. The chain falls back on ANY primary error, records the serving
endpoint, and never falls back once the context is cancelled.
gateway.go and chain.go are one commit: the chain's tests use the gateway's
StatusError and ErrEmptyTranscript, so neither compiles alone.
Mutation-checked: field name, clamp, cancel-fallback, content length and the
recorded endpoint each turn a test red.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/chain.go | 53 +++++++++
internal/adapters/transcribe/gateway.go | 147 ++++++++++++++++++++++++
2 files changed, 200 insertions(+)
create mode 100644 internal/adapters/transcribe/chain.go
create mode 100644 internal/adapters/transcribe/gateway.go
diff --git a/internal/adapters/transcribe/chain.go b/internal/adapters/transcribe/chain.go
new file mode 100644
index 0000000..68df323
--- /dev/null
+++ b/internal/adapters/transcribe/chain.go
@@ -0,0 +1,53 @@
+package transcribe
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// Backend is one transcription endpoint. *Gateway satisfies it; Name is the value
+// recorded as the serving endpoint.
+type Backend interface {
+ Name() string
+ Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error)
+}
+
+// Chain tries a primary (local) backend and, if it fails for ANY reason, a
+// fallback. The fallback exists because iguana can wedge permanently on a Metal
+// OOM (infra#273) — which is also why a fallback is a data-egress decision: the
+// berget endpoint is a cloud service, so the result records which one served it.
+type Chain struct {
+ primary Backend
+ fallback Backend // nil disables the fallback
+}
+
+// NewChain builds a Chain. A nil fallback means a primary failure is the result.
+func NewChain(primary, fallback Backend) *Chain {
+ return &Chain{primary: primary, fallback: fallback}
+}
+
+// Transcribe returns the first backend's success. It falls back on any primary
+// error — transport, timeout, non-2xx, empty transcript — and never inspects a
+// response body to decide. A cancelled context stops the chain: a job the user or
+// shutdown cancelled must not ship its audio to a cloud endpoint as a last act.
+func (c *Chain) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
+ t, err := c.primary.Transcribe(ctx, wavPath)
+ if err == nil {
+ t.Endpoint = c.primary.Name()
+ return t, nil
+ }
+ if c.fallback == nil || ctx.Err() != nil {
+ return domain.Transcription{}, fmt.Errorf("%s: %w", c.primary.Name(), err)
+ }
+ t, ferr := c.fallback.Transcribe(ctx, wavPath)
+ if ferr != nil {
+ return domain.Transcription{}, errors.Join(
+ fmt.Errorf("%s: %w", c.primary.Name(), err),
+ fmt.Errorf("%s: %w", c.fallback.Name(), ferr))
+ }
+ t.Endpoint = c.fallback.Name()
+ return t, nil
+}
diff --git a/internal/adapters/transcribe/gateway.go b/internal/adapters/transcribe/gateway.go
new file mode 100644
index 0000000..e885c71
--- /dev/null
+++ b/internal/adapters/transcribe/gateway.go
@@ -0,0 +1,147 @@
+// Package transcribe turns a WAV file into a timed Swedish transcript by calling
+// the LiteLLM gateway's OpenAI-compatible /audio/transcriptions endpoint
+// (ADR-033). A Gateway is one model alias; a Chain tries a local one and falls
+// back to a second.
+package transcribe
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "strings"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// ErrEmptyTranscript is returned when the endpoint answered 200 but with no text.
+// An empty transcript is not a success: chat over it could only answer "nothing".
+var ErrEmptyTranscript = errors.New("transcribe: endpoint returned an empty transcript")
+
+// StatusError is a non-2xx answer from the gateway. Nothing may branch on the
+// body — {"error":"failed to process audio"} has had at least five distinct
+// causes — so only the status code is exposed for logic; Snippet is for logs.
+type StatusError struct {
+ Code int
+ Snippet string
+}
+
+func (e *StatusError) Error() string {
+ return fmt.Sprintf("transcribe: gateway returned HTTP %d: %s", e.Code, e.Snippet)
+}
+
+// Gateway transcribes through one model alias on the gateway. baseURL is the
+// gateway's /v1 root — the in-cluster NodePort, not the public hostname, whose
+// ingress rejects bodies over ~8-10 MB with 413 (docs/research/audio-length-ceiling).
+type Gateway struct {
+ baseURL string
+ key string
+ model string
+ client *http.Client
+}
+
+// NewGateway builds a Gateway. The client should carry no overall Timeout: a 90
+// minute recording takes minutes, so the deadline belongs to the caller's context.
+func NewGateway(baseURL, key, model string, client *http.Client) *Gateway {
+ return &Gateway{baseURL: strings.TrimRight(baseURL, "/"), key: key, model: model, client: client}
+}
+
+// Name is the model alias; it is what gets recorded as the serving endpoint.
+func (g *Gateway) Name() string { return g.model }
+
+// Transcribe uploads the WAV at path and returns the normalised transcription.
+// The body is streamed from disk with a COMPUTED Content-Length: a 90 minute WAV
+// is ~170 MB, so it is neither buffered in memory nor sent chunked.
+func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcription, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return domain.Transcription{}, fmt.Errorf("transcribe: open audio: %w", err)
+ }
+ defer f.Close()
+ info, err := f.Stat()
+ if err != nil {
+ return domain.Transcription{}, fmt.Errorf("transcribe: stat audio: %w", err)
+ }
+
+ var prefix bytes.Buffer
+ mw := multipart.NewWriter(&prefix)
+ for _, kv := range [][2]string{{"model", g.model}, {"response_format", "verbose_json"}, {"language", "sv"}} {
+ if err := mw.WriteField(kv[0], kv[1]); err != nil {
+ return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
+ }
+ }
+ // The multipart field is `file`; `audio_file` is rejected as "Invalid request".
+ if _, err := mw.CreateFormFile("file", "audio.wav"); err != nil {
+ return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
+ }
+ suffix := []byte("\r\n--" + mw.Boundary() + "--\r\n")
+
+ body := io.MultiReader(bytes.NewReader(prefix.Bytes()), f, bytes.NewReader(suffix))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.baseURL+"/audio/transcriptions", body)
+ if err != nil {
+ return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
+ }
+ req.ContentLength = int64(prefix.Len()) + info.Size() + int64(len(suffix))
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.Header.Set("Authorization", "Bearer "+g.key)
+ // No "Expect: 100-continue": it trips the proxy for any body over 1 KB.
+
+ resp, err := g.client.Do(req)
+ if err != nil {
+ return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ snip, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
+ return domain.Transcription{}, &StatusError{Code: resp.StatusCode, Snippet: strings.TrimSpace(string(snip))}
+ }
+
+ t, err := parseVerbose(resp.Body)
+ if err != nil {
+ return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
+ }
+ t.Endpoint = g.model
+ return t, nil
+}
+
+// verboseJSON is the subset of the verbose_json response used. Both backends
+// return it; they differ in segment granularity (iguana: whole seconds, berget:
+// sub-second) and in the language token, neither of which matters here.
+type verboseJSON struct {
+ Text string `json:"text"`
+ Duration float64 `json:"duration"`
+ Segments []struct {
+ Start float64 `json:"start"`
+ End float64 `json:"end"`
+ Text string `json:"text"`
+ } `json:"segments"`
+}
+
+// parseVerbose normalises a verbose_json body. Whisper's final window can end past
+// the audio (9.0 s for 4.25 s of audio was observed), so segment ends are clamped
+// to the reported duration.
+func parseVerbose(r io.Reader) (domain.Transcription, error) {
+ var v verboseJSON
+ if err := json.NewDecoder(r).Decode(&v); err != nil {
+ return domain.Transcription{}, fmt.Errorf("decode response: %w", err)
+ }
+ text := strings.TrimSpace(v.Text)
+ if text == "" {
+ return domain.Transcription{}, ErrEmptyTranscript
+ }
+ t := domain.Transcription{Text: text, AudioSeconds: v.Duration}
+ for _, s := range v.Segments {
+ seg := domain.Segment{Start: s.Start, End: s.End, Text: strings.TrimSpace(s.Text)}
+ if v.Duration > 0 {
+ seg.End = min(seg.End, v.Duration)
+ seg.Start = min(seg.Start, seg.End)
+ }
+ t.Segments = append(t.Segments, seg)
+ }
+ return t, nil
+}
--
2.54.0
From 2227cfff7e034da7c9a5a944e308faae58ccce48 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:46:14 +0200
Subject: [PATCH 019/299] test(transcribe): opt-in live check against the real
gateway
The httptest fake proves only what we THINK the gateway accepts. Skipped
unless TAPIR_LIVE_WAV and TAPIR_LIVE_GATEWAY_URL are set. Run once on
2026-10-01 with 11 min of synthetic Swedish (21 MB, above the 8-10 MB ingress
cap, via the NodePort): both iguana/kb-whisper (52.8 s) and berget/kb-whisper
(35.4 s) accepted the streamed, computed-Content-Length body and returned
usable verbose_json. Synthetic audio only; this repo is public.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/live_test.go | 49 +++++++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 internal/adapters/transcribe/live_test.go
diff --git a/internal/adapters/transcribe/live_test.go b/internal/adapters/transcribe/live_test.go
new file mode 100644
index 0000000..fc48bab
--- /dev/null
+++ b/internal/adapters/transcribe/live_test.go
@@ -0,0 +1,49 @@
+package transcribe_test
+
+import (
+ "context"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
+)
+
+// TestGateway_LiveGateway is an opt-in check against the REAL gateway, because
+// the httptest fake above can only prove what we think the gateway accepts. It
+// is skipped unless TAPIR_LIVE_WAV names a 16 kHz mono WAV and the gateway
+// coordinates are set:
+//
+// TAPIR_LIVE_WAV=/path/a.wav TAPIR_LIVE_GATEWAY_URL=http://koala:30401/v1 \
+// TAPIR_LIVE_MODEL=iguana/kb-whisper op run --env-file ~/.op-env -- \
+// go test ./internal/adapters/transcribe -run LiveGateway -v
+//
+// Use SYNTHETIC audio only: this repo is public and the gateway may be a cloud
+// endpoint (#33).
+func TestGateway_LiveGateway(t *testing.T) {
+ wav := os.Getenv("TAPIR_LIVE_WAV")
+ url := os.Getenv("TAPIR_LIVE_GATEWAY_URL")
+ if wav == "" || url == "" {
+ t.Skip("set TAPIR_LIVE_WAV and TAPIR_LIVE_GATEWAY_URL to run against the real gateway")
+ }
+ model := os.Getenv("TAPIR_LIVE_MODEL")
+ if model == "" {
+ model = "iguana/kb-whisper"
+ }
+ key := os.Getenv("LITELLM_MASTER_KEY")
+ require.NotEmpty(t, key, "LITELLM_MASTER_KEY must be set (via op run)")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Minute)
+ defer cancel()
+ got, err := transcribe.NewGateway(url, key, model, &http.Client{}).Transcribe(ctx, wav)
+
+ require.NoError(t, err)
+ assert.NotEmpty(t, got.Text)
+ assert.NotEmpty(t, got.Segments)
+ assert.Equal(t, model, got.Endpoint)
+ t.Logf("endpoint=%s audio_s=%.1f segments=%d chars=%d", got.Endpoint, got.AudioSeconds, len(got.Segments), len(got.Text))
+}
--
2.54.0
From 3bc33fc34cf8f6dd70b683da55208613f2ba7271 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:46:14 +0200
Subject: [PATCH 020/299] test(audio): ffmpeg transcode to 16 kHz mono PCM,
always (red)
Red: audio.FFmpeg is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/ffmpeg_test.go | 58 ++++++++++++++++++++++++++
1 file changed, 58 insertions(+)
create mode 100644 internal/adapters/audio/ffmpeg_test.go
diff --git a/internal/adapters/audio/ffmpeg_test.go b/internal/adapters/audio/ffmpeg_test.go
new file mode 100644
index 0000000..1aaf6d1
--- /dev/null
+++ b/internal/adapters/audio/ffmpeg_test.go
@@ -0,0 +1,58 @@
+package audio_test
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/audio"
+)
+
+func TestFFmpegToWAV_AsksForSixteenKilohertzMonoPCMAlways(t *testing.T) {
+ // Always transcode, even when the input already is 16 kHz mono: a Teams export
+ // often is, and the point is to not ASSUME (ADR-033). Records the argv.
+ rec := filepath.Join(t.TempDir(), "argv")
+ bin := stub(t, `printf '%s\n' "$@" > `+rec+`; : > "$(eval echo \${$#})"`)
+
+ err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))
+
+ require.NoError(t, err)
+ raw, err := os.ReadFile(rec)
+ require.NoError(t, err)
+ argv := strings.Split(strings.TrimSpace(string(raw)), "\n")
+ joined := " " + strings.Join(argv, " ") + " "
+ for _, want := range []string{" -ac 1 ", " -ar 16000 ", " -c:a pcm_s16le ", " -vn ", " -i /spool/in "} {
+ assert.Contains(t, joined, want)
+ }
+ assert.True(t, strings.HasSuffix(strings.TrimSpace(joined), "out.wav"), "the output path is the last argument")
+}
+
+func TestFFmpegToWAV_NonzeroExitIsAnErrorCarryingStderr(t *testing.T) {
+ bin := stub(t, `echo "moov atom not found" >&2; exit 1`)
+
+ err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/in", filepath.Join(t.TempDir(), "o.wav"))
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "moov atom not found")
+}
+
+func TestFFmpegToWAV_ZeroExitWithNoOutputIsAnError(t *testing.T) {
+ bin := stub(t, `exit 0`)
+
+ err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/in", filepath.Join(t.TempDir(), "never-written.wav"))
+
+ require.Error(t, err)
+}
+
+func TestFFmpegToWAV_MissingBinaryIsAnError(t *testing.T) {
+ err := (&audio.FFmpeg{Bin: filepath.Join(t.TempDir(), "no-ffmpeg")}).ToWAV(context.Background(), "/in", "/out.wav")
+
+ require.Error(t, err)
+ assert.False(t, errors.Is(err, context.Canceled))
+}
--
2.54.0
From 4352446f368747b6eb39f4ea58e998730a52115a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:46:34 +0200
Subject: [PATCH 021/299] feat(audio): ffmpeg adapter -- always transcode to 16
kHz mono PCM (ADR-033)
Replaces whisper-server --convert. A zero exit that wrote nothing is an error.
Also fixes the red test's stub, which wrote an empty file and so (correctly)
tripped the no-output check.
Checked against real ffmpeg on iguana with the same argv: AAC .m4a in,
pcm_s16le 16000 Hz mono WAV out, duration preserved.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/ffmpeg.go | 45 ++++++++++++++++++++++++++
internal/adapters/audio/ffmpeg_test.go | 4 ++-
2 files changed, 48 insertions(+), 1 deletion(-)
create mode 100644 internal/adapters/audio/ffmpeg.go
diff --git a/internal/adapters/audio/ffmpeg.go b/internal/adapters/audio/ffmpeg.go
new file mode 100644
index 0000000..ee04971
--- /dev/null
+++ b/internal/adapters/audio/ffmpeg.go
@@ -0,0 +1,45 @@
+package audio
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+// FFmpeg converts any audio container to the strict format whisper wants: 16 kHz
+// mono signed 16-bit PCM WAV (ADR-033). It ALWAYS transcodes, even when the input
+// already is that format — a Teams export often is, and the transcode is then
+// nearly free; assuming it would be the bug. This replaces whisper-server's
+// --convert, whose --tmp-dir is created at process start and rots under macOS
+// periodic cleanup.
+type FFmpeg struct {
+ // Bin is the ffmpeg executable ("ffmpeg" resolves via PATH).
+ Bin string
+}
+
+// ToWAV writes src, converted, to dst. Video streams are dropped (-vn): by now the
+// file has already been probed as audio-only, but a cover-art stream must not end
+// up in the WAV.
+func (f *FFmpeg) ToWAV(ctx context.Context, src, dst string) error {
+ bin := f.Bin
+ if bin == "" {
+ bin = "ffmpeg"
+ }
+ cmd := exec.CommandContext(ctx, bin,
+ "-y", "-v", "error", "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", dst)
+ cmd.WaitDelay = killGrace
+ if out, err := cmd.CombinedOutput(); err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return fmt.Errorf("audio: ffmpeg: %w", ctxErr)
+ }
+ return fmt.Errorf("audio: ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
+ }
+ // A zero exit that wrote nothing is still a failure: never hand whisper a
+ // missing file and let the error surface three layers later.
+ if info, err := os.Stat(dst); err != nil || info.Size() == 0 {
+ return fmt.Errorf("audio: ffmpeg wrote no output at %s", dst)
+ }
+ return nil
+}
diff --git a/internal/adapters/audio/ffmpeg_test.go b/internal/adapters/audio/ffmpeg_test.go
index 1aaf6d1..4442c80 100644
--- a/internal/adapters/audio/ffmpeg_test.go
+++ b/internal/adapters/audio/ffmpeg_test.go
@@ -18,7 +18,9 @@ func TestFFmpegToWAV_AsksForSixteenKilohertzMonoPCMAlways(t *testing.T) {
// Always transcode, even when the input already is 16 kHz mono: a Teams export
// often is, and the point is to not ASSUME (ADR-033). Records the argv.
rec := filepath.Join(t.TempDir(), "argv")
- bin := stub(t, `printf '%s\n' "$@" > `+rec+`; : > "$(eval echo \${$#})"`)
+ bin := stub(t, `printf '%s\n' "$@" > `+rec+`
+for a; do last="$a"; done
+printf data > "$last"`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))
--
2.54.0
From 52417e6b47d89fcdfd82321dc399fb0c0f0cf5ad Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:47:22 +0200
Subject: [PATCH 022/299] test(uploads): in-process transcription worker (red)
Success path deletes both the upload and the WAV; failures are typed and still
delete the audio; a shutdown mid-job leaves the row for the start-up sweep
(it is not a transcription failure); a hung endpoint is failed by the job
timeout, never an eternal spinner; strictly one job at a time (two long jobs on
iguana were never measured); stopping deletes queued audio and refuses new jobs.
Red: uploads.Worker is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker_test.go | 275 ++++++++++++++++++++++++++++++++
1 file changed, 275 insertions(+)
create mode 100644 internal/uploads/worker_test.go
diff --git a/internal/uploads/worker_test.go b/internal/uploads/worker_test.go
new file mode 100644
index 0000000..27e2c11
--- /dev/null
+++ b/internal/uploads/worker_test.go
@@ -0,0 +1,275 @@
+package uploads_test
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+type wStore struct {
+ mu sync.Mutex
+ started []string
+ completed map[string]domain.Transcription
+ failed map[string]domain.UploadErrorKind
+ startErr error
+}
+
+func newWStore() *wStore {
+ return &wStore{completed: map[string]domain.Transcription{}, failed: map[string]domain.UploadErrorKind{}}
+}
+
+func (s *wStore) StartTranscribing(_ context.Context, _, id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.started = append(s.started, id)
+ return s.startErr
+}
+
+func (s *wStore) CompleteUpload(_ context.Context, _, id string, t domain.Transcription) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.completed[id] = t
+ return nil
+}
+
+func (s *wStore) FailUpload(_ context.Context, _, id string, k domain.UploadErrorKind) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.failed[id] = k
+ return nil
+}
+
+func (s *wStore) snapshot() (map[string]domain.Transcription, map[string]domain.UploadErrorKind) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ c := map[string]domain.Transcription{}
+ f := map[string]domain.UploadErrorKind{}
+ for k, v := range s.completed {
+ c[k] = v
+ }
+ for k, v := range s.failed {
+ f[k] = v
+ }
+ return c, f
+}
+
+type fakeConverter struct {
+ err error
+ srcs []string
+ dsts []string
+}
+
+func (c *fakeConverter) ToWAV(_ context.Context, src, dst string) error {
+ c.srcs = append(c.srcs, src)
+ c.dsts = append(c.dsts, dst)
+ if c.err != nil {
+ return c.err
+ }
+ return os.WriteFile(dst, []byte("wav"), 0o600)
+}
+
+type fakeTranscriber struct {
+ result domain.Transcription
+ err error
+ paths []string
+ // block, when non-nil, holds every call until it is closed or ctx ends.
+ block chan struct{}
+ running atomic.Int32
+ maxSeen atomic.Int32
+}
+
+func (f *fakeTranscriber) Transcribe(ctx context.Context, wav string) (domain.Transcription, error) {
+ f.paths = append(f.paths, wav)
+ n := f.running.Add(1)
+ defer f.running.Add(-1)
+ for {
+ m := f.maxSeen.Load()
+ if n <= m || f.maxSeen.CompareAndSwap(m, n) {
+ break
+ }
+ }
+ if f.block != nil {
+ select {
+ case <-f.block:
+ case <-ctx.Done():
+ return domain.Transcription{}, ctx.Err()
+ }
+ }
+ return f.result, f.err
+}
+
+type wrig struct {
+ w *uploads.Worker
+ st *wStore
+ conv *fakeConverter
+ tr *fakeTranscriber
+ tmp string
+}
+
+func newWRig(t *testing.T, timeout time.Duration) *wrig {
+ t.Helper()
+ r := &wrig{
+ st: newWStore(),
+ conv: &fakeConverter{},
+ tr: &fakeTranscriber{result: domain.Transcription{Text: "synthetic text", Endpoint: "iguana/kb-whisper", AudioSeconds: 3}},
+ tmp: t.TempDir(),
+ }
+ r.w = uploads.NewWorker(r.st, r.conv, r.tr, r.tmp, timeout)
+ return r
+}
+
+// spool writes a fake uploaded file (what Intake would hand over) and returns a Job.
+func (r *wrig) spool(t *testing.T, id string) uploads.Job {
+ t.Helper()
+ p := filepath.Join(r.tmp, "upload-"+id)
+ require.NoError(t, os.WriteFile(p, []byte("audio"), 0o600))
+ return uploads.Job{UserID: "u1", UploadID: id, Path: p}
+}
+
+func (r *wrig) leftovers(t *testing.T) []string {
+ t.Helper()
+ ents, err := os.ReadDir(r.tmp)
+ require.NoError(t, err)
+ var n []string
+ for _, e := range ents {
+ n = append(n, e.Name())
+ }
+ return n
+}
+
+func TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ job := r.spool(t, "a")
+
+ r.w.Process(context.Background(), job)
+
+ c, f := r.st.snapshot()
+ assert.Equal(t, []string{"a"}, r.st.started)
+ assert.Equal(t, "synthetic text", c["a"].Text)
+ assert.Equal(t, "iguana/kb-whisper", c["a"].Endpoint)
+ assert.Empty(t, f)
+ require.Len(t, r.conv.srcs, 1)
+ assert.Equal(t, job.Path, r.conv.srcs[0], "the converter reads the uploaded file")
+ assert.Equal(t, r.conv.dsts, r.tr.paths, "whisper is sent the converted WAV, not the original")
+ assert.Equal(t, r.tmp, filepath.Dir(r.conv.dsts[0]))
+ assert.Empty(t, r.leftovers(t), "the audio is never retained: both the upload and the WAV are deleted")
+}
+
+func TestWorkerProcess_FailuresAreTypedAndStillDeleteTheAudio(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(*wrig)
+ }{
+ {"conversion fails", func(r *wrig) { r.conv.err = errors.New("moov atom not found") }},
+ {"every transcription endpoint fails", func(r *wrig) { r.tr.err = errors.New("iguana down; berget down") }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ tt.setup(r)
+ job := r.spool(t, "a")
+
+ r.w.Process(context.Background(), job)
+
+ c, f := r.st.snapshot()
+ assert.Empty(t, c)
+ assert.Equal(t, domain.UploadErrTranscriptionFailed, f["a"])
+ assert.Empty(t, r.leftovers(t))
+ })
+ }
+ t.Run("conversion failure never reaches whisper", func(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.conv.err = errors.New("bad")
+
+ r.w.Process(context.Background(), r.spool(t, "a"))
+
+ assert.Empty(t, r.tr.paths)
+ })
+}
+
+func TestWorkerProcess_ShutdownMidJobLeavesTheRowForTheStartupSweep(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.tr.block = make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ job := r.spool(t, "a")
+
+ go func() { r.w.Process(ctx, job); close(done) }()
+ require.Eventually(t, func() bool { return r.tr.running.Load() == 1 }, 2*time.Second, 5*time.Millisecond)
+ cancel()
+ <-done
+
+ _, f := r.st.snapshot()
+ assert.Empty(t, f, "a shutdown is not a transcription failure: the row stays for FailInterruptedUploads to mark interrupted")
+ assert.Empty(t, r.leftovers(t), "but the audio is still deleted")
+}
+
+func TestWorkerProcess_AJobThatOutlivesItsTimeoutIsFailedNotStuck(t *testing.T) {
+ r := newWRig(t, 50*time.Millisecond)
+ r.tr.block = make(chan struct{}) // never released
+
+ r.w.Process(context.Background(), r.spool(t, "a"))
+
+ _, f := r.st.snapshot()
+ assert.Equal(t, domain.UploadErrTranscriptionFailed, f["a"], "a hung endpoint must end in failed, not an eternal spinner (ADR-025)")
+ assert.Empty(t, r.leftovers(t))
+}
+
+func TestWorkerProcess_StartFailureDropsTheJobAndDeletesTheAudio(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.st.startErr = errors.New("db down")
+
+ r.w.Process(context.Background(), r.spool(t, "a"))
+
+ assert.Empty(t, r.tr.paths, "never transcribe a job whose row could not be moved to transcribing")
+ _, f := r.st.snapshot()
+ assert.Contains(t, f, "a")
+ assert.Empty(t, r.leftovers(t))
+}
+
+func TestWorkerRun_ProcessesOneJobAtATime(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.tr.block = make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go r.w.Run(ctx)
+
+ for _, id := range []string{"a", "b", "c"} {
+ require.NoError(t, r.w.Submit(r.spool(t, id)))
+ }
+ require.Eventually(t, func() bool { return r.tr.running.Load() == 1 }, 2*time.Second, 5*time.Millisecond)
+ time.Sleep(50 * time.Millisecond) // give a (wrong) second job time to start
+ assert.Equal(t, int32(1), r.tr.running.Load(), "two simultaneous long jobs on iguana were never measured: concurrency is 1")
+
+ close(r.tr.block)
+ require.Eventually(t, func() bool { c, _ := r.st.snapshot(); return len(c) == 3 }, 3*time.Second, 5*time.Millisecond)
+ assert.Equal(t, int32(1), r.tr.maxSeen.Load())
+}
+
+func TestWorkerRun_StoppingDeletesQueuedAudioAndRefusesNewJobs(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.tr.block = make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ stopped := make(chan struct{})
+ go func() { r.w.Run(ctx); close(stopped) }()
+ require.NoError(t, r.w.Submit(r.spool(t, "a")))
+ require.Eventually(t, func() bool { return r.tr.running.Load() == 1 }, 2*time.Second, 5*time.Millisecond)
+ require.NoError(t, r.w.Submit(r.spool(t, "b"))) // queued behind the running job
+
+ cancel()
+ <-stopped
+
+ assert.Empty(t, r.leftovers(t), "audio of queued jobs is deleted on stop: the rows are swept to interrupted at next start")
+ assert.ErrorIs(t, r.w.Submit(r.spool(t, "c")), uploads.ErrStopped)
+}
--
2.54.0
From 3cfc95640e9b5494326b57e87fed4f87799cdda4 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:54:52 +0200
Subject: [PATCH 023/299] test(uploads): a queued job at shutdown must never be
started; timeout test fails fast
Mutation-testing the worker showed deleting stop()'s cleanup went unnoticed:
Process was incidentally deleting the audio of jobs it should never have
started. The stop test now asserts only the running job was converted (red on
the old code). The timeout test no longer hangs for the 10-minute go test
default when the timeout is removed.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker_test.go | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/internal/uploads/worker_test.go b/internal/uploads/worker_test.go
index 27e2c11..da5235f 100644
--- a/internal/uploads/worker_test.go
+++ b/internal/uploads/worker_test.go
@@ -218,8 +218,14 @@ func TestWorkerProcess_ShutdownMidJobLeavesTheRowForTheStartupSweep(t *testing.T
func TestWorkerProcess_AJobThatOutlivesItsTimeoutIsFailedNotStuck(t *testing.T) {
r := newWRig(t, 50*time.Millisecond)
r.tr.block = make(chan struct{}) // never released
+ done := make(chan struct{})
- r.w.Process(context.Background(), r.spool(t, "a"))
+ go func() { r.w.Process(context.Background(), r.spool(t, "a")); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("Process did not return: a hung endpoint was not bounded by the job timeout")
+ }
_, f := r.st.snapshot()
assert.Equal(t, domain.UploadErrTranscriptionFailed, f["a"], "a hung endpoint must end in failed, not an eternal spinner (ADR-025)")
@@ -270,6 +276,7 @@ func TestWorkerRun_StoppingDeletesQueuedAudioAndRefusesNewJobs(t *testing.T) {
cancel()
<-stopped
+ assert.Len(t, r.conv.srcs, 1, "a job still queued at shutdown must never be started: only the running job was converted")
assert.Empty(t, r.leftovers(t), "audio of queued jobs is deleted on stop: the rows are swept to interrupted at next start")
assert.ErrorIs(t, r.w.Submit(r.spool(t, "c")), uploads.ErrStopped)
}
--
2.54.0
From a8dadb8ca40c9f4e088133a912ec840f550e5aeb Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:54:52 +0200
Subject: [PATCH 024/299] feat(uploads): in-process transcription worker, one
job at a time (ADR-033)
Queue -> StartTranscribing -> ffmpeg to 16 kHz WAV -> gateway chain ->
CompleteUpload, with a per-job timeout so a hung endpoint ends in failed, not
a spinner. The upload and the WAV are deleted on every path (audio is never
retained). A shutdown mid-job is not a failure: the row is left for the
start-up sweep. Cancellation wins over a non-empty queue, so queued jobs are
never started on shutdown; stop() deletes their audio and refuses new jobs.
Concurrency is 1 on purpose: two simultaneous long jobs on iguana were never
measured, and a Metal OOM there latches whisper (infra#273). The queue is
process-local, so ADR-018's single-replica assumption now has a second
dependent.
Mutation-checked, -race clean: cleanup, shutdown-vs-failure, timeout,
concurrency and stop-cleanup each turn a test red.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker.go | 191 +++++++++++++++++++++++++++++++++++++
1 file changed, 191 insertions(+)
create mode 100644 internal/uploads/worker.go
diff --git a/internal/uploads/worker.go b/internal/uploads/worker.go
new file mode 100644
index 0000000..7b32ab0
--- /dev/null
+++ b/internal/uploads/worker.go
@@ -0,0 +1,191 @@
+package uploads
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "sync"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// ErrStopped is returned by Submit once the worker has stopped.
+var ErrStopped = errors.New("uploads: worker stopped")
+
+// Converter turns the uploaded file into the 16 kHz mono PCM WAV whisper wants.
+type Converter interface {
+ ToWAV(ctx context.Context, src, dst string) error
+}
+
+// Transcriber turns a WAV into a timed transcription. The gateway chain
+// (iguana primary, berget fallback) implements it.
+type Transcriber interface {
+ Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error)
+}
+
+// WorkerStore is the slice of the private transcript store the worker drives.
+type WorkerStore interface {
+ StartTranscribing(ctx context.Context, userID, id string) error
+ CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error
+ FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
+}
+
+// Worker transcribes accepted uploads in-process, ONE AT A TIME (ADR-033). It is
+// not a second deployable: it shares the web process's lifetime, which is why it
+// inherits ADR-018's single-replica assumption — the queue is process-local. The
+// concurrency of one is deliberate: two simultaneous 90-minute jobs on iguana were
+// never measured, and a Metal OOM there latches the whisper backend (infra#273).
+type Worker struct {
+ store WorkerStore
+ conv Converter
+ tr Transcriber
+ tmpDir string
+ timeout time.Duration
+
+ mu sync.Mutex
+ queue []Job
+ stopped bool
+ wake chan struct{}
+}
+
+// NewWorker wires a Worker. timeout bounds each job (conversion + transcription);
+// a job that outlives it is failed rather than left spinning.
+func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout time.Duration) *Worker {
+ return &Worker{store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1)}
+}
+
+// Submit queues a job. It never blocks. It satisfies Submitter.
+func (w *Worker) Submit(j Job) error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.stopped {
+ return ErrStopped
+ }
+ w.queue = append(w.queue, j)
+ select {
+ case w.wake <- struct{}{}:
+ default:
+ }
+ return nil
+}
+
+// Run processes queued jobs until ctx ends, then deletes the audio of anything
+// still queued and refuses new jobs. The rows of those jobs stay queued or
+// transcribing and are failed as interrupted by the start-up sweep
+// (store.FailInterruptedUploads): the audio is not retained, so they cannot resume.
+func (w *Worker) Run(ctx context.Context) {
+ defer w.stop()
+ for {
+ job, ok := w.next(ctx)
+ if !ok {
+ return
+ }
+ w.Process(ctx, job)
+ }
+}
+
+// next blocks for the next job. Cancellation wins over a non-empty queue: a job
+// still queued at shutdown must not be STARTED (it would begin a long conversion
+// with an already-cancelled context); stop() deletes its audio instead.
+func (w *Worker) next(ctx context.Context) (Job, bool) {
+ for {
+ if ctx.Err() != nil {
+ return Job{}, false
+ }
+ w.mu.Lock()
+ if len(w.queue) > 0 {
+ j := w.queue[0]
+ w.queue = w.queue[1:]
+ w.mu.Unlock()
+ return j, true
+ }
+ w.mu.Unlock()
+ select {
+ case <-w.wake:
+ case <-ctx.Done():
+ return Job{}, false
+ }
+ }
+}
+
+func (w *Worker) stop() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.stopped = true
+ for _, j := range w.queue {
+ _ = os.Remove(j.Path)
+ }
+ w.queue = nil
+}
+
+// Process runs one job: row -> transcribing, convert, transcribe, store. The
+// uploaded file and the converted WAV are deleted on every path — the audio is
+// never retained (ADR-033). A failure is recorded with a TYPED kind; a shutdown
+// mid-job is not a failure and leaves the row for the start-up sweep.
+func (w *Worker) Process(ctx context.Context, job Job) {
+ defer func() { _ = os.Remove(job.Path) }()
+ log := slog.Default().With("upload", job.UploadID)
+ start := time.Now()
+
+ if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
+ log.Error("upload: start transcribing", "err", err)
+ w.fail(ctx, job, log)
+ return
+ }
+
+ wav, err := w.tempWAV()
+ if err != nil {
+ log.Error("upload: temp wav", "err", err)
+ w.fail(ctx, job, log)
+ return
+ }
+ defer func() { _ = os.Remove(wav) }()
+
+ jobCtx, cancel := context.WithTimeout(ctx, w.timeout)
+ defer cancel()
+ if err := w.conv.ToWAV(jobCtx, job.Path, wav); err != nil {
+ log.Error("upload: convert", "err", err)
+ w.fail(ctx, job, log)
+ return
+ }
+ t, err := w.tr.Transcribe(jobCtx, wav)
+ if err != nil {
+ log.Error("upload: transcribe", "err", err, "elapsed", time.Since(start).Round(time.Second))
+ w.fail(ctx, job, log)
+ return
+ }
+ if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
+ log.Error("upload: store transcript", "err", err)
+ w.fail(ctx, job, log)
+ return
+ }
+ log.Info("upload transcribed", "endpoint", t.Endpoint, "audio_s", t.AudioSeconds,
+ "segments", len(t.Segments), "elapsed", time.Since(start).Round(time.Second))
+}
+
+// fail records a transcription failure — unless the PARENT context ended, which
+// is a shutdown, not a failure: the row is left for the start-up sweep.
+func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger) {
+ if ctx.Err() != nil {
+ return
+ }
+ if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, domain.UploadErrTranscriptionFailed); err != nil {
+ log.Error("upload: record failure", "err", err)
+ }
+}
+
+func (w *Worker) tempWAV() (string, error) {
+ f, err := os.CreateTemp(w.tmpDir, "wav-*")
+ if err != nil {
+ return "", fmt.Errorf("uploads: temp wav: %w", err)
+ }
+ name := f.Name()
+ if err := f.Close(); err != nil {
+ _ = os.Remove(name)
+ return "", fmt.Errorf("uploads: temp wav: %w", err)
+ }
+ return name, nil
+}
--
2.54.0
From fb521f2b89450c618679097313f1eec51c28f2bc Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:55:11 +0200
Subject: [PATCH 025/299] test(config): upload and transcription settings (red)
Off by default until the image has ffmpeg; 200 MB / 90 min (the longest length
measured); iguana primary, berget fallback that an explicit empty value
disables; zero or negative limits are refused rather than silently rejecting
every upload.
Red: the Config fields do not exist.
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads_test.go | 123 ++++++++++++++++++++++++++++++++
1 file changed, 123 insertions(+)
create mode 100644 internal/config/uploads_test.go
diff --git a/internal/config/uploads_test.go b/internal/config/uploads_test.go
new file mode 100644
index 0000000..c55d77f
--- /dev/null
+++ b/internal/config/uploads_test.go
@@ -0,0 +1,123 @@
+package config
+
+import (
+ "os"
+ "testing"
+ "time"
+)
+
+// clearUploadEnv unsets every upload knob so a test observes the default, not the
+// host environment.
+func clearUploadEnv(t *testing.T) {
+ t.Helper()
+ for _, k := range []string{
+ "TAPIR_UPLOADS_ENABLED", "TAPIR_UPLOAD_MAX_MB", "TAPIR_UPLOAD_MAX_MINUTES",
+ "TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT",
+ "TAPIR_UPLOAD_TMP_DIR", "TAPIR_FFMPEG_BIN", "TAPIR_FFPROBE_BIN",
+ "TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS",
+ } {
+ unset(t, k)
+ }
+}
+
+func TestLoad_UploadDefaults(t *testing.T) {
+ clearUploadEnv(t)
+
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ if c.UploadsEnabled {
+ t.Error("uploads must be OFF by default: the runtime image needs ffmpeg first (ADR-033)")
+ }
+ if c.UploadMaxBytes != 200<<20 {
+ t.Errorf("UploadMaxBytes = %d, want 200 MB", c.UploadMaxBytes)
+ }
+ if c.UploadMaxDuration != 90*time.Minute {
+ t.Errorf("UploadMaxDuration = %v, want 90m (the longest length measured, not a found ceiling)", c.UploadMaxDuration)
+ }
+ if c.TranscribeModel != "iguana/kb-whisper" {
+ t.Errorf("TranscribeModel = %q", c.TranscribeModel)
+ }
+ if c.TranscribeFallbackModel != "berget/kb-whisper" {
+ t.Errorf("TranscribeFallbackModel = %q", c.TranscribeFallbackModel)
+ }
+ if c.TranscribeTimeout != 30*time.Minute {
+ t.Errorf("TranscribeTimeout = %v, want 30m (a 90 min recording took ~7.5 min)", c.TranscribeTimeout)
+ }
+ if c.UploadTmpDir != os.TempDir() {
+ t.Errorf("UploadTmpDir = %q, want os.TempDir()", c.UploadTmpDir)
+ }
+ if c.FFmpegBin != "ffmpeg" || c.FFprobeBin != "ffprobe" {
+ t.Errorf("bins = %q, %q", c.FFmpegBin, c.FFprobeBin)
+ }
+ if c.MaxUploadedTranscriptChars != 120000 {
+ t.Errorf("MaxUploadedTranscriptChars = %d, want 120000", c.MaxUploadedTranscriptChars)
+ }
+}
+
+func TestLoad_UploadValuesAreParsed(t *testing.T) {
+ clearUploadEnv(t)
+ setEnv(t, map[string]string{
+ "TAPIR_UPLOADS_ENABLED": "true",
+ "TAPIR_UPLOAD_MAX_MB": "50",
+ "TAPIR_UPLOAD_MAX_MINUTES": "30",
+ "TAPIR_TRANSCRIBE_MODEL": "koala/whisper",
+ "TAPIR_TRANSCRIBE_TIMEOUT": "10m",
+ "TAPIR_UPLOAD_TMP_DIR": "/var/spool/tapir",
+ "TAPIR_FFMPEG_BIN": "/opt/ffmpeg",
+ "TAPIR_FFPROBE_BIN": "/opt/ffprobe",
+ "TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS": "60000",
+ })
+
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ if !c.UploadsEnabled || c.UploadMaxBytes != 50<<20 || c.UploadMaxDuration != 30*time.Minute ||
+ c.TranscribeModel != "koala/whisper" || c.TranscribeTimeout != 10*time.Minute ||
+ c.UploadTmpDir != "/var/spool/tapir" || c.FFmpegBin != "/opt/ffmpeg" ||
+ c.FFprobeBin != "/opt/ffprobe" || c.MaxUploadedTranscriptChars != 60000 {
+ t.Errorf("values not applied: %+v", c)
+ }
+}
+
+func TestLoad_EmptyTranscribeFallbackDisablesIt(t *testing.T) {
+ clearUploadEnv(t)
+ t.Setenv("TAPIR_TRANSCRIBE_FALLBACK_MODEL", "")
+
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ if c.TranscribeFallbackModel != "" {
+ t.Errorf("TranscribeFallbackModel = %q: an explicit empty value must disable the cloud fallback, "+
+ "so private audio can be kept on the homelab", c.TranscribeFallbackModel)
+ }
+}
+
+func TestLoad_UploadRejectsInvalidValues(t *testing.T) {
+ tests := []struct{ key, val string }{
+ {"TAPIR_UPLOADS_ENABLED", "maybe"},
+ {"TAPIR_UPLOAD_MAX_MB", "lots"},
+ {"TAPIR_UPLOAD_MAX_MB", "0"},
+ {"TAPIR_UPLOAD_MAX_MB", "-5"},
+ {"TAPIR_UPLOAD_MAX_MINUTES", "0"},
+ {"TAPIR_UPLOAD_MAX_MINUTES", "forever"},
+ {"TAPIR_TRANSCRIBE_TIMEOUT", "soon"},
+ {"TAPIR_TRANSCRIBE_TIMEOUT", "0s"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.key+"="+tt.val, func(t *testing.T) {
+ clearUploadEnv(t)
+ t.Setenv(tt.key, tt.val)
+
+ if _, err := Load(); err == nil {
+ t.Errorf("Load accepted %s=%q; a zero or negative limit would reject every upload", tt.key, tt.val)
+ }
+ })
+ }
+}
--
2.54.0
From b8798dbd5da48abc974fa82fd026842504b23b69 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:55:32 +0200
Subject: [PATCH 026/299] feat(config): upload and transcription settings
(ADR-033)
Embedded UploadConfig. Off by default until the image carries ffmpeg; 200 MB /
90 min; iguana primary with a berget fallback that an explicit empty
TAPIR_TRANSCRIBE_FALLBACK_MODEL disables (private audio then never leaves the
homelab); zero or negative limits fail at load instead of rejecting every
upload at runtime.
uploads.go and the two-line hook in config.go are one commit: the tests need both.
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/config.go | 7 +++
internal/config/uploads.go | 110 +++++++++++++++++++++++++++++++++++++
2 files changed, 117 insertions(+)
create mode 100644 internal/config/uploads.go
diff --git a/internal/config/config.go b/internal/config/config.go
index 01f01f5..9b43d10 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -20,6 +20,9 @@ import (
// Config is Tapir's fully-resolved runtime configuration.
type Config struct {
+ // UploadConfig is the audio-upload and private-transcription settings (ADR-033).
+ UploadConfig
+
// UserID is the Tapir user the run operates as. Stage 0 has exactly one.
// It must be a UUID: it keys the UUID user_id/video_id columns (data-model).
UserID string
@@ -320,6 +323,10 @@ func Load() (Config, error) {
}
c.OnboardMaxVideoSeconds = onboardMax
+ if c.UploadConfig, err = loadUploadConfig(); err != nil {
+ return Config{}, err
+ }
+
return c, nil
}
diff --git a/internal/config/uploads.go b/internal/config/uploads.go
new file mode 100644
index 0000000..a496b20
--- /dev/null
+++ b/internal/config/uploads.go
@@ -0,0 +1,110 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+)
+
+// UploadConfig holds the settings for audio upload and private transcription
+// (ADR-033). It is embedded in Config, so its fields read as c.UploadsEnabled etc.
+type UploadConfig struct {
+ // UploadsEnabled mounts the /uploads routes and starts the worker. Off by
+ // default: the runtime image must contain ffmpeg and ffprobe first (ADR-033).
+ UploadsEnabled bool
+ // UploadMaxBytes caps one upload. UploadMaxDuration caps one recording; 90
+ // minutes is the longest length MEASURED, not a ceiling that was found, so
+ // raising it is a measurement, not an edit.
+ UploadMaxBytes int64
+ UploadMaxDuration time.Duration
+ // TranscribeModel is the primary gateway alias; TranscribeFallbackModel is the
+ // fallback, tried on ANY primary failure. The fallback is a cloud endpoint
+ // (berget): set it EMPTY to keep private audio on the homelab.
+ TranscribeModel string
+ TranscribeFallbackModel string
+ // TranscribeTimeout bounds one job (conversion + transcription); a 90 minute
+ // recording took ~7.5 minutes on iguana.
+ TranscribeTimeout time.Duration
+ // UploadTmpDir is where uploads are spooled and converted. Audio is deleted
+ // after transcription, so this holds at most the in-flight files.
+ UploadTmpDir string
+ FFmpegBin string
+ FFprobeBin string
+ // MaxUploadedTranscriptChars bounds the transcript sent to a chat model for an
+ // UPLOADED recording. The 18,000-char default for captions is ~22 minutes of
+ // speech; an hour of meeting is ~50,000 chars. A guess until measured against
+ // the models chat offers.
+ MaxUploadedTranscriptChars int
+}
+
+const (
+ defaultUploadMaxMB = 200
+ defaultUploadMaxMinutes = 90
+ defaultTranscribeModel = "iguana/kb-whisper"
+ defaultTranscribeFallbackModel = "berget/kb-whisper"
+ defaultTranscribeTimeout = 30 * time.Minute
+ defaultMaxUploadedChars = 120000
+)
+
+func loadUploadConfig() (UploadConfig, error) {
+ u := UploadConfig{
+ TranscribeModel: envOr("TAPIR_TRANSCRIBE_MODEL", defaultTranscribeModel),
+ TranscribeFallbackModel: lookupOr("TAPIR_TRANSCRIBE_FALLBACK_MODEL", defaultTranscribeFallbackModel),
+ UploadTmpDir: envOr("TAPIR_UPLOAD_TMP_DIR", os.TempDir()),
+ FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"),
+ FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"),
+ }
+
+ if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" {
+ on, err := strconv.ParseBool(v)
+ if err != nil {
+ return UploadConfig{}, fmt.Errorf("config: TAPIR_UPLOADS_ENABLED=%q: %w", v, err)
+ }
+ u.UploadsEnabled = on
+ }
+
+ mb, err := positiveInt("TAPIR_UPLOAD_MAX_MB", defaultUploadMaxMB)
+ if err != nil {
+ return UploadConfig{}, err
+ }
+ u.UploadMaxBytes = int64(mb) << 20
+
+ mins, err := positiveInt("TAPIR_UPLOAD_MAX_MINUTES", defaultUploadMaxMinutes)
+ if err != nil {
+ return UploadConfig{}, err
+ }
+ u.UploadMaxDuration = time.Duration(mins) * time.Minute
+
+ timeout, err := durationOr("TAPIR_TRANSCRIBE_TIMEOUT", defaultTranscribeTimeout)
+ if err != nil {
+ return UploadConfig{}, err
+ }
+ if timeout <= 0 {
+ return UploadConfig{}, fmt.Errorf("config: TAPIR_TRANSCRIBE_TIMEOUT must be positive, got %v", timeout)
+ }
+ u.TranscribeTimeout = timeout
+
+ chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars)
+ if err != nil {
+ return UploadConfig{}, err
+ }
+ if chars < 0 {
+ chars = 0
+ }
+ u.MaxUploadedTranscriptChars = chars
+ return u, nil
+}
+
+// positiveInt is intOr that refuses zero and negatives: a zero upload limit would
+// reject every file, which is a misconfiguration to fail on, not to run with.
+func positiveInt(key string, fallback int) (int, error) {
+ n, err := intOr(key, fallback)
+ if err != nil {
+ return 0, err
+ }
+ if n <= 0 {
+ return 0, fmt.Errorf("config: %s must be positive, got %d", key, n)
+ }
+ return n, nil
+}
--
2.54.0
From 3e802adade640be4ed5ea600a3328eec61c74f09 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:55:51 +0200
Subject: [PATCH 027/299] test(cmd): buildUploads wiring -- off by default,
sweep first, private spool (red)
Red: buildUploads is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_test.go | 106 ++++++++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
create mode 100644 cmd/tapir/uploads_test.go
diff --git a/cmd/tapir/uploads_test.go b/cmd/tapir/uploads_test.go
new file mode 100644
index 0000000..6d33f92
--- /dev/null
+++ b/cmd/tapir/uploads_test.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/config"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// fakeUploadsStore satisfies the store surface buildUploads needs, and counts the
+// start-up sweep.
+type fakeUploadsStore struct {
+ sweeps int
+ sweepErr error
+}
+
+func (f *fakeUploadsStore) CreateUpload(context.Context, string, string) (string, error) {
+ return "id", nil
+}
+
+func (f *fakeUploadsStore) FailUpload(context.Context, string, string, domain.UploadErrorKind) error {
+ return nil
+}
+
+func (f *fakeUploadsStore) StartTranscribing(context.Context, string, string) error { return nil }
+
+func (f *fakeUploadsStore) CompleteUpload(context.Context, string, string, domain.Transcription) error {
+ return nil
+}
+
+func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) {
+ f.sweeps++
+ return 2, f.sweepErr
+}
+
+func uploadCfg(t *testing.T, enabled bool) config.Config {
+ t.Helper()
+ var cfg config.Config
+ cfg.GatewayURL = "http://gateway.invalid/v1"
+ cfg.UploadsEnabled = enabled
+ cfg.UploadMaxBytes = 200 << 20
+ cfg.UploadTmpDir = filepath.Join(t.TempDir(), "spool")
+ cfg.TranscribeModel = "iguana/kb-whisper"
+ cfg.TranscribeFallbackModel = "berget/kb-whisper"
+ return cfg
+}
+
+func TestBuildUploads_DisabledMountsNothingAndSweepsNothing(t *testing.T) {
+ st := &fakeUploadsStore{}
+
+ in, w, err := buildUploads(context.Background(), uploadCfg(t, false), st, quietLog())
+
+ require.NoError(t, err)
+ assert.Nil(t, in, "a typed-nil Intake must never reach web.App.Uploads: it would mount the routes over nothing")
+ assert.Nil(t, w)
+ assert.Zero(t, st.sweeps)
+}
+
+func TestBuildUploads_EnabledFailsInterruptedRowsBeforeAnythingElseStarts(t *testing.T) {
+ st := &fakeUploadsStore{}
+
+ in, w, err := buildUploads(context.Background(), uploadCfg(t, true), st, quietLog())
+
+ require.NoError(t, err)
+ require.NotNil(t, in)
+ require.NotNil(t, w)
+ assert.Equal(t, 1, st.sweeps,
+ "audio is not retained, so rows left queued/transcribing by a restart can never resume: fail them at start-up, not never")
+}
+
+func TestBuildUploads_SweepFailureStopsStartup(t *testing.T) {
+ st := &fakeUploadsStore{sweepErr: errors.New("db down")}
+
+ _, _, err := buildUploads(context.Background(), uploadCfg(t, true), st, quietLog())
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "db down")
+}
+
+func TestBuildUploads_CreatesTheSpoolDirectoryPrivately(t *testing.T) {
+ cfg := uploadCfg(t, true)
+
+ _, _, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
+
+ require.NoError(t, err)
+ info, err := os.Stat(cfg.UploadTmpDir)
+ require.NoError(t, err)
+ assert.True(t, info.IsDir())
+ assert.Equal(t, os.FileMode(0o700), info.Mode().Perm(), "spooled audio is private third-party content")
+}
+
+func TestBuildUploads_EnabledWithoutAGatewayIsAnError(t *testing.T) {
+ cfg := uploadCfg(t, true)
+ cfg.GatewayURL = ""
+
+ _, _, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
+
+ assert.Error(t, err, "uploads on with nowhere to transcribe must fail loudly at start-up, not at the first upload")
+}
--
2.54.0
From eb9357a5de6e19ea4f0501783afdf50cd54b2af0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:56:11 +0200
Subject: [PATCH 028/299] test(cmd): start-up removes spooled audio left by a
crash (red)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_test.go | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/cmd/tapir/uploads_test.go b/cmd/tapir/uploads_test.go
index 6d33f92..a18c02e 100644
--- a/cmd/tapir/uploads_test.go
+++ b/cmd/tapir/uploads_test.go
@@ -104,3 +104,24 @@ func TestBuildUploads_EnabledWithoutAGatewayIsAnError(t *testing.T) {
assert.Error(t, err, "uploads on with nowhere to transcribe must fail loudly at start-up, not at the first upload")
}
+
+func TestBuildUploads_RemovesAudioLeftBehindByACrash(t *testing.T) {
+ cfg := uploadCfg(t, true)
+ require.NoError(t, os.MkdirAll(cfg.UploadTmpDir, 0o700))
+ leftovers := []string{"upload-123", "wav-456"}
+ for _, n := range leftovers {
+ require.NoError(t, os.WriteFile(filepath.Join(cfg.UploadTmpDir, n), []byte("audio"), 0o600))
+ }
+ unrelated := filepath.Join(cfg.UploadTmpDir, "someone-elses-file")
+ require.NoError(t, os.WriteFile(unrelated, []byte("keep"), 0o600))
+
+ _, _, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
+
+ require.NoError(t, err)
+ for _, n := range leftovers {
+ _, err := os.Stat(filepath.Join(cfg.UploadTmpDir, n))
+ assert.True(t, os.IsNotExist(err), "%s: audio is never retained, so a spooled file from a crashed run must not survive start-up", n)
+ }
+ _, err = os.Stat(unrelated)
+ assert.NoError(t, err, "only our own spool files are removed")
+}
--
2.54.0
From 9b3175db0979816f4c9f11dfe2f9ed9ce880d766 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:56:21 +0200
Subject: [PATCH 029/299] feat(cmd): buildUploads -- spool, sweep, fail
interrupted rows, wire the chain (ADR-033)
Returns nils when disabled (never a typed-nil Intake). When enabled: private
0700 spool dir, delete audio left by a crash, fail rows a restart left queued
or transcribing (the audio is not retained, so they cannot resume), then build
the iguana->berget chain, worker and intake. An empty
TAPIR_TRANSCRIBE_FALLBACK_MODEL keeps private audio on the homelab. Enabling
with no gateway fails at start-up, not at the first upload.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads.go | 85 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 85 insertions(+)
create mode 100644 cmd/tapir/uploads.go
diff --git a/cmd/tapir/uploads.go b/cmd/tapir/uploads.go
new file mode 100644
index 0000000..ebe5339
--- /dev/null
+++ b/cmd/tapir/uploads.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/audio"
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
+ "git.d-ma.be/mathias/tapir/internal/config"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+// uploadsStore is the store surface the upload wiring needs. *store.Store
+// satisfies it.
+type uploadsStore interface {
+ uploads.Store
+ uploads.WorkerStore
+ FailInterruptedUploads(ctx context.Context) (int, error)
+}
+
+// buildUploads wires audio upload and private transcription (ADR-033). It returns
+// nils when uploads are disabled — never a typed-nil Intake, which would mount the
+// /uploads routes over nothing. When enabled it, in order: prepares a private spool
+// directory, deletes any audio a crashed run left in it, fails the rows such a run
+// left queued or transcribing (the audio is not retained, so they can never
+// resume), and only then builds the intake and the worker. The caller runs the
+// worker. The fallback endpoint is a cloud service; an empty
+// TAPIR_TRANSCRIBE_FALLBACK_MODEL keeps private audio on the homelab.
+func buildUploads(ctx context.Context, cfg config.Config, st uploadsStore, log *slog.Logger) (*uploads.Intake, *uploads.Worker, error) {
+ if !cfg.UploadsEnabled {
+ return nil, nil, nil
+ }
+ if cfg.GatewayURL == "" {
+ return nil, nil, fmt.Errorf("uploads: TAPIR_UPLOADS_ENABLED is set but there is no TAPIR_GATEWAY_URL to transcribe through")
+ }
+
+ if err := os.MkdirAll(cfg.UploadTmpDir, 0o700); err != nil {
+ return nil, nil, fmt.Errorf("uploads: spool dir: %w", err)
+ }
+ removeSpooledAudio(cfg.UploadTmpDir, log)
+
+ n, err := st.FailInterruptedUploads(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("uploads: fail interrupted rows: %w", err)
+ }
+ if n > 0 {
+ log.Warn("uploads: marked interrupted by a restart (audio is not retained; they must be re-uploaded)", "count", n)
+ }
+
+ // No overall client timeout: a 90 minute recording takes minutes. The deadline
+ // is the worker's per-job context.
+ client := &http.Client{}
+ primary := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeModel, client)
+ var fallback transcribe.Backend
+ if cfg.TranscribeFallbackModel != "" && cfg.TranscribeFallbackModel != cfg.TranscribeModel {
+ fallback = transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeFallbackModel, client)
+ }
+
+ worker := uploads.NewWorker(st, &audio.FFmpeg{Bin: cfg.FFmpegBin}, transcribe.NewChain(primary, fallback),
+ cfg.UploadTmpDir, cfg.TranscribeTimeout)
+ intake := uploads.NewIntake(st, &audio.FFprobe{Bin: cfg.FFprobeBin}, worker,
+ uploadLimits(cfg), cfg.UploadTmpDir)
+ return intake, worker, nil
+}
+
+func uploadLimits(cfg config.Config) uploads.Limits {
+ return uploads.Limits{MaxBytes: cfg.UploadMaxBytes, MaxDuration: cfg.UploadMaxDuration}
+}
+
+// removeSpooledAudio deletes the spool files this process family creates
+// (upload-*, wav-*) and nothing else.
+func removeSpooledAudio(dir string, log *slog.Logger) {
+ for _, pat := range []string{"upload-*", "wav-*"} {
+ matches, _ := filepath.Glob(filepath.Join(dir, pat))
+ for _, m := range matches {
+ if err := os.Remove(m); err != nil {
+ log.Warn("uploads: remove leftover audio", "path", m, "err", err)
+ }
+ }
+ }
+}
--
2.54.0
From 1c5d85f5c652c06cb3b6bee9214bdb049fd4d2cb Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:56:22 +0200
Subject: [PATCH 030/299] feat(cmd): serve mounts audio upload when enabled
(ADR-033)
Wires buildUploads into cmdServe: Intake and limits into the web App, the
worker on the server's ctx. Logs the two facts an operator must not miss: the
queue is process-local (single replica), and a configured fallback is a cloud
endpoint that private audio can leave the homelab through.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/main.go | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go
index 95de372..4df1cb7 100644
--- a/cmd/tapir/main.go
+++ b/cmd/tapir/main.go
@@ -207,6 +207,27 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
log.Info("web chat enabled (stored-transcript only)", "models", chatModels(cfg))
}
+ // Audio upload -> private transcript (ADR-033). Off unless TAPIR_UPLOADS_ENABLED:
+ // the image must carry ffmpeg first. The worker is IN-PROCESS and one job at a
+ // time, so it inherits the single-replica assumption below (its queue is
+ // process-local); it stops with the same ctx as the server.
+ intake, worker, err := buildUploads(ctx, cfg, st, log)
+ if err != nil {
+ return err
+ }
+ if intake != nil {
+ app.Uploads = intake
+ app.UploadLimits = uploadLimits(cfg)
+ go worker.Run(ctx)
+ log.Info("audio upload enabled", "model", cfg.TranscribeModel, "fallback", cfg.TranscribeFallbackModel,
+ "max_mb", cfg.UploadMaxBytes>>20, "max_minutes", int(cfg.UploadMaxDuration.Minutes()))
+ log.Warn("audio upload assumes a SINGLE replica: the transcription queue is process-local (ADR-033, ADR-018)")
+ if cfg.TranscribeFallbackModel != "" {
+ log.Warn("audio upload fallback is a CLOUD endpoint: if the primary fails, private audio leaves the homelab",
+ "fallback", cfg.TranscribeFallbackModel)
+ }
+ }
+
// User onboarding is handled by the IdP (Authentik invite flow), not Tapir —
// the Dex local-password provisioning path was removed (ADR-019). An
// authenticated subject with no Tapir user is routed to /register.
--
2.54.0
From 867afefb088f3dfa6ac0771317b33fc98793ca4c Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:56:51 +0200
Subject: [PATCH 031/299] test(chat): a request may carry its own transcript
budget (red)
The 18,000-char default is ~22 min of speech and keeps only the head; an
uploaded hour is ~50,000 chars. Red: Request.MaxChars is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/chat/maxchars_test.go | 53 +++++++++++++++++++++++++
1 file changed, 53 insertions(+)
create mode 100644 internal/adapters/chat/maxchars_test.go
diff --git a/internal/adapters/chat/maxchars_test.go b/internal/adapters/chat/maxchars_test.go
new file mode 100644
index 0000000..f3f9541
--- /dev/null
+++ b/internal/adapters/chat/maxchars_test.go
@@ -0,0 +1,53 @@
+package chat
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+// A Request may carry its own transcript budget. The Service's cap (18,000 chars by
+// default) is sized for YouTube captions; an uploaded recording is much longer
+// (~50,000 chars an hour), and keeping only the head of it would answer questions
+// about the last two-thirds of a meeting from text that does not contain them
+// (ADR-033). The per-request value overrides; zero means "the service default".
+
+func TestAnswerMaxCharsOverridesTheServiceCap(t *testing.T) {
+ tests := []struct {
+ name string
+ serviceCap int
+ requestCap int
+ transcriptLen int
+ wantKept int
+ wantTruncated bool
+ }{
+ {"request cap above the service cap keeps the whole transcript", 10, 100, 50, 50, false},
+ {"request cap below the service cap bounds tighter", 100, 10, 50, 10, true},
+ {"zero request cap falls back to the service cap", 20, 0, 50, 20, true},
+ {"negative request cap is treated as unset", 20, -1, 50, 20, true},
+ {"request cap exceeded is reported as truncated", 10, 30, 50, 30, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ f := &factory{answer: "answer"}
+ s := New(f.make, []string{"koala/phi4-mini"}, tt.serviceCap)
+
+ reply, err := s.Answer(context.Background(), Request{
+ Model: "koala/phi4-mini",
+ Transcript: strings.Repeat("x", tt.transcriptLen),
+ Question: "what was decided?",
+ MaxChars: tt.requestCap,
+ })
+
+ if err != nil {
+ t.Fatalf("Answer: %v", err)
+ }
+ if got := strings.Count(f.used.lastSystem, "x"); got != tt.wantKept {
+ t.Errorf("transcript chars sent = %d, want %d", got, tt.wantKept)
+ }
+ if reply.Truncated != tt.wantTruncated {
+ t.Errorf("Truncated = %v, want %v", reply.Truncated, tt.wantTruncated)
+ }
+ })
+ }
+}
--
2.54.0
From ec29c5e7618894c67c6e885e32e4c688ecc93e33 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:57:01 +0200
Subject: [PATCH 032/299] style(chat): gofumpt the new test
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/chat/maxchars_test.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/internal/adapters/chat/maxchars_test.go b/internal/adapters/chat/maxchars_test.go
index f3f9541..87f1275 100644
--- a/internal/adapters/chat/maxchars_test.go
+++ b/internal/adapters/chat/maxchars_test.go
@@ -38,7 +38,6 @@ func TestAnswerMaxCharsOverridesTheServiceCap(t *testing.T) {
Question: "what was decided?",
MaxChars: tt.requestCap,
})
-
if err != nil {
t.Fatalf("Answer: %v", err)
}
--
2.54.0
From c72d366935f9d7a4b49b51e0640ceb19005ab79e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:57:01 +0200
Subject: [PATCH 033/299] feat(chat): per-request transcript budget so a long
recording is not cut to its head (ADR-033)
chat.Request.MaxChars overrides the Service cap when positive. The 18,000-char
default (~22 min of speech) suits captions; an uploaded hour is ~50,000 chars
and truncation keeps only the head. This is reuse of the existing chat path,
not a fork.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/chat/chat.go | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/internal/adapters/chat/chat.go b/internal/adapters/chat/chat.go
index 8cd0610..a774260 100644
--- a/internal/adapters/chat/chat.go
+++ b/internal/adapters/chat/chat.go
@@ -41,6 +41,11 @@ type Request struct {
Transcript string
History []Turn
Question string
+ // MaxChars, when positive, overrides the Service's transcript budget for this
+ // request. The Service default is sized for YouTube captions (~22 minutes of
+ // speech); an uploaded recording is much longer, and truncation keeps only the
+ // head (ADR-033). Zero or negative means "use the Service default".
+ MaxChars int
}
// Reply is the model's answer plus whether the transcript was bounded to fit the
@@ -113,7 +118,11 @@ func (s *Service) Answer(ctx context.Context, req Request) (Reply, error) {
model = s.DefaultModel("")
}
- transcript, truncated := truncate(req.Transcript, s.maxChars)
+ budget := s.maxChars
+ if req.MaxChars > 0 {
+ budget = req.MaxChars
+ }
+ transcript, truncated := truncate(req.Transcript, budget)
system := buildSystem(transcript, truncated)
user := buildUser(req.History, req.Question)
--
2.54.0
From 10ca45fc0a0c6f1e58c31d400c4f0b8152cfddba Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:57:47 +0200
Subject: [PATCH 034/299] test(web): chat over an uploaded transcript reuses
the ADR-027 path (red)
Owner sees the panel posting to /uploads/{id}/chat; an unfinished upload
redirects to its status; ANOTHER USER'S upload is 404 on GET and POST and no
model is ever called with it; the owner's transcript is sent with the upload
budget (not the 18,000-char caption default); an unoffered model falls back; a
model failure is inline and keeps the conversation; routes are absent without a
chat backend; the done card links to chat only when chat is wired.
Red: App.UploadChatMaxChars is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_chat_handler_test.go | 167 ++++++++++++++++++++++
1 file changed, 167 insertions(+)
create mode 100644 internal/web/uploads_chat_handler_test.go
diff --git a/internal/web/uploads_chat_handler_test.go b/internal/web/uploads_chat_handler_test.go
new file mode 100644
index 0000000..e03d8f5
--- /dev/null
+++ b/internal/web/uploads_chat_handler_test.go
@@ -0,0 +1,167 @@
+package web_test
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/chat"
+ "git.d-ma.be/mathias/tapir/internal/web"
+)
+
+// Chat over an uploaded transcript REUSES the ADR-027 chat path (the same
+// Chatter, the same panel) — only the lookup differs: the row comes from the
+// user-scoped private store, so another user's upload is a 404 and, crucially, no
+// model is ever called with it. Transcript text is synthetic; this repo is public.
+
+const meetingText = "Anders ansvarar för virket. Maria undersöker leverantörer."
+
+func newUploadChatApp(t *testing.T) (*web.App, *fakeChatter) {
+ t.Helper()
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ fc := &fakeChatter{
+ models: []string{"koala/phi4-mini", "iguana/gemma4-26b"},
+ reply: chat.Reply{Answer: "Anders owns the timber order."},
+ }
+ app.Chat = fc
+ app.UploadChatMaxChars = 120000
+ return app, fc
+}
+
+func chatPost(path string, kv url.Values) *http.Request {
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(kv.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return req
+}
+
+func TestUploadChatPage_OwnerOfAFinishedUploadSeesThePanelPostingToTheUploadRoute(t *testing.T) {
+ app, _ := newUploadChatApp(t)
+ id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
+
+ rec := do(t, app, get("/uploads/"+id+"/chat"))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ b := body(t, rec)
+ assert.Contains(t, b, `action="/uploads/`+id+`/chat"`, "the form posts to the UPLOAD route, not /v/{id}/chat")
+ assert.Contains(t, b, "Roadmap sync")
+ assert.Contains(t, b, "koala/phi4-mini")
+ assert.Contains(t, b, "recording", "wording says recording, not video")
+}
+
+func TestUploadChatPage_AnUnfinishedUploadRedirectsToItsStatusPage(t *testing.T) {
+ app, fc := newUploadChatApp(t)
+ id := seedUpload(t, userID, "m", "transcribing", "")
+
+ rec := do(t, app, get("/uploads/"+id+"/chat"))
+
+ assert.Equal(t, http.StatusSeeOther, rec.Code)
+ assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
+ assert.Zero(t, fc.callCount)
+}
+
+func TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled(t *testing.T) {
+ app, fc := newUploadChatApp(t)
+ theirs := seedUpload(t, otherUserID, "Their private meeting", "done", meetingText)
+
+ getRec := do(t, app, get("/uploads/"+theirs+"/chat"))
+ postRec := do(t, app, chatPost("/uploads/"+theirs+"/chat", url.Values{"question": {"what did they say?"}}))
+
+ assert.Equal(t, http.StatusNotFound, getRec.Code)
+ assert.Equal(t, http.StatusNotFound, postRec.Code)
+ assert.NotContains(t, body(t, getRec), "Their private meeting")
+ assert.Zero(t, fc.callCount, "someone else's transcript must never reach a model")
+}
+
+func TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget(t *testing.T) {
+ app, fc := newUploadChatApp(t)
+ app.UploadChatMaxChars = 77777
+ id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
+
+ rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{
+ "question": {"Who owns the timber order?"},
+ "model": {"iguana/gemma4-26b"},
+ }))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ assert.Equal(t, 1, fc.callCount)
+ assert.Equal(t, meetingText, fc.gotReq.Transcript)
+ assert.Equal(t, "Who owns the timber order?", fc.gotReq.Question)
+ assert.Equal(t, "iguana/gemma4-26b", fc.gotReq.Model)
+ assert.Equal(t, 77777, fc.gotReq.MaxChars,
+ "an uploaded transcript takes its own budget: the 18,000-char caption default would cut an hour to its first third")
+ assert.Contains(t, body(t, rec), "Anders owns the timber order.")
+}
+
+func TestUploadChatPost_AnUnofferedModelFallsBackToTheDefault(t *testing.T) {
+ app, fc := newUploadChatApp(t)
+ id := seedUpload(t, userID, "m", "done", meetingText)
+
+ do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"evil/model"}}))
+
+ assert.Equal(t, "koala/phi4-mini", fc.gotReq.Model)
+}
+
+func TestUploadChatPost_UnfinishedUploadCallsNoModel(t *testing.T) {
+ app, fc := newUploadChatApp(t)
+ id := seedUpload(t, userID, "m", "queued", "")
+
+ rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
+
+ assert.Equal(t, http.StatusSeeOther, rec.Code)
+ assert.Zero(t, fc.callCount)
+}
+
+func TestUploadChatPost_PriorTurnsRideAlongAndATruncatedReplyIsSaidSo(t *testing.T) {
+ app, fc := newUploadChatApp(t)
+ fc.reply = chat.Reply{Answer: "second answer", Truncated: true}
+ id := seedUpload(t, userID, "m", "done", meetingText)
+
+ rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{
+ "question": {"and who else?"},
+ "hq": {"first question"},
+ "ha": {"first answer"},
+ }))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ assert.Equal(t, []chat.Turn{{Question: "first question", Answer: "first answer"}}, fc.gotReq.History)
+ b := body(t, rec)
+ assert.Contains(t, b, "first answer")
+ assert.Contains(t, b, "second answer")
+ assert.Contains(t, b, "end of the recording", "a long recording cut to a budget must say so, in its own words")
+}
+
+func TestUploadChatPost_AModelFailureIsShownInlineNotAs500(t *testing.T) {
+ app, fc := newUploadChatApp(t)
+ fc.err = errors.New("gateway down")
+ id := seedUpload(t, userID, "m", "done", meetingText)
+
+ rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "hq": {"earlier"}, "ha": {"kept"}}))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ b := body(t, rec)
+ assert.Contains(t, b, "couldn't answer")
+ assert.Contains(t, b, "kept", "the conversation survives a failed turn")
+}
+
+func TestUploadChatRoutes_AreNotMountedWithoutAChatBackend(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", meetingText)
+
+ assert.Equal(t, http.StatusNotFound, do(t, app, get("/uploads/"+id+"/chat")).Code)
+}
+
+func TestUploadStatus_DoneOffersTheChatLinkOnlyWhenChatIsWired(t *testing.T) {
+ appWith, _ := newUploadChatApp(t)
+ id := seedUpload(t, userID, "m", "done", meetingText)
+ assert.Contains(t, body(t, do(t, appWith, get("/uploads/"+id+"/status"))), `href="/uploads/`+id+`/chat"`)
+
+ appWithout, _ := newUploadApp(t, audioOK, 200<<20)
+ id2 := seedUpload(t, userID, "m2", "done", meetingText)
+ assert.NotContains(t, body(t, do(t, appWithout, get("/uploads/"+id2+"/status"))), "/chat")
+}
--
2.54.0
From a39f58b730fd8e641651517c16835cb2b138901d Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:58:25 +0200
Subject: [PATCH 035/299] test(web): match the HTML-escaped tail of the inline
chat error
templ escapes the apostrophe in "couldn't", so the assertion could never match
the rendered page. A test defect, not a loosening: the failure must still be
shown inline with the conversation kept.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_chat_handler_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/web/uploads_chat_handler_test.go b/internal/web/uploads_chat_handler_test.go
index e03d8f5..bc1ce72 100644
--- a/internal/web/uploads_chat_handler_test.go
+++ b/internal/web/uploads_chat_handler_test.go
@@ -145,7 +145,7 @@ func TestUploadChatPost_AModelFailureIsShownInlineNotAs500(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
b := body(t, rec)
- assert.Contains(t, b, "couldn't answer")
+ assert.Contains(t, b, "answer just now", "the failure is inline (the apostrophe is HTML-escaped, so match the tail)")
assert.Contains(t, b, "kept", "the conversation survives a failed turn")
}
--
2.54.0
From 4fdb212cef0815b917f8efedc25cb6c55b4dba74 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 16:58:25 +0200
Subject: [PATCH 036/299] feat(web): chat with an uploaded transcript, reusing
the ADR-027 path (ADR-033)
GET/POST /uploads/{id}/chat, mounted only when both an Uploader and a Chatter
are wired. The row comes from the user-scoped private store, so another user's
upload is a 404 before any model is involved. The owner's transcript goes to
the SAME Chatter with App.UploadChatMaxChars instead of the 18,000-char caption
default. The chat panel is shared, not forked: chatView gains PostURL/Subject
(empty = a YouTube video, so existing callers are unchanged). The done card
links to chat only when chat is wired.
Mutation-checked: dropping the upload budget, ignoring the done-status guard,
or scoping the lookup to the wrong user each turn tests red.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/chat.go | 24 ++
internal/web/handlers.go | 10 +
internal/web/uploads.go | 83 ++++-
internal/web/uploads.templ | 25 +-
internal/web/uploads_templ.go | 178 ++++++++--
internal/web/views.templ | 8 +-
internal/web/views_templ.go | 622 ++++++++++++++++++----------------
7 files changed, 609 insertions(+), 341 deletions(-)
diff --git a/internal/web/chat.go b/internal/web/chat.go
index b8e2d80..71aaa31 100644
--- a/internal/web/chat.go
+++ b/internal/web/chat.go
@@ -6,6 +6,8 @@ import (
"net/http"
"strings"
+ "github.com/a-h/templ"
+
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
@@ -43,6 +45,28 @@ type chatView struct {
History []chat.Turn
Truncated bool
Error string
+ // PostURL and Subject let the same chat panel serve an UPLOADED recording
+ // (ADR-033): where the form posts, and what the transcript is called. Empty
+ // means a YouTube video (/v/{VideoID}/chat, "video"), so existing callers are
+ // unchanged.
+ PostURL templ.SafeURL
+ Subject string
+}
+
+// postURL is where the chat form posts.
+func (v chatView) postURL() templ.SafeURL {
+ if v.PostURL != "" {
+ return v.PostURL
+ }
+ return chatURL(v.VideoID)
+}
+
+// subject is what the transcript is called in the panel's wording.
+func (v chatView) subject() string {
+ if v.Subject != "" {
+ return v.Subject
+ }
+ return "video"
}
// handleChat renders the chat page for a summarized video (GET). Entry is scoped
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
index b297dce..b9b1ed2 100644
--- a/internal/web/handlers.go
+++ b/internal/web/handlers.go
@@ -113,6 +113,10 @@ type App struct {
// front rather than discovered by failing.
Uploads Uploader
UploadLimits uploads.Limits
+ // UploadChatMaxChars is the transcript budget for chat over an uploaded
+ // recording. The caption default (18,000 chars, ~22 minutes of speech) would
+ // keep only the first third of an hour-long meeting.
+ UploadChatMaxChars int
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
@@ -186,6 +190,12 @@ func (a *App) Router() http.Handler {
app.HandleFunc("POST /uploads", a.handleUploadPost)
app.HandleFunc("GET /uploads/{id}", a.handleUploadDetail)
app.HandleFunc("GET /uploads/{id}/status", a.handleUploadStatus)
+ // Chat over the uploaded transcript reuses the ADR-027 Chatter; mounted only
+ // when one is wired.
+ if a.Chat != nil {
+ app.HandleFunc("GET /uploads/{id}/chat", a.handleUploadChat)
+ app.HandleFunc("POST /uploads/{id}/chat", a.handleUploadChatMessage)
+ }
}
app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index abb8711..295ced5 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -9,6 +9,9 @@ import (
"strings"
"unicode/utf8"
+ "github.com/a-h/templ"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
@@ -129,7 +132,7 @@ func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- a.render(w, r, UploadPage(*u))
+ a.render(w, r, UploadPage(*u, a.Chat != nil))
}
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
@@ -140,7 +143,7 @@ func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- a.render(w, r, uploadStatus(*u))
+ a.render(w, r, uploadStatus(*u, a.Chat != nil))
}
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
@@ -189,3 +192,79 @@ func uploadFailureMessage(k domain.UploadErrorKind) string {
return "This upload failed. Please upload it again."
}
}
+
+// handleUploadChat renders the chat page for a FINISHED upload (GET). The row is
+// read through the user-scoped store, so another user's id is a 404 before any
+// model is involved. An unfinished upload has no transcript to ask about yet, so
+// it goes back to its status page.
+func (a *App) handleUploadChat(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.loadOwnedUpload(w, r)
+ if !ok {
+ return
+ }
+ if u.Status != domain.UploadDone {
+ http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
+ return
+ }
+ a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, a.Chat.DefaultModel(""), nil, false, "")))
+}
+
+// handleUploadChatMessage answers one question against the owner's uploaded
+// transcript (POST), reusing the ADR-027 Chatter. Nothing is persisted: the prior
+// turns ride in the form. The transcript goes to the model with the UPLOAD budget,
+// not the caption default. A model failure is inline, not a 500.
+func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.loadOwnedUpload(w, r)
+ if !ok {
+ return
+ }
+ if u.Status != domain.UploadDone {
+ http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ model := a.resolveModel(r.FormValue("model"), "")
+ history := parseHistory(r.Form["hq"], r.Form["ha"])
+ question := strings.TrimSpace(r.FormValue("question"))
+
+ view := a.uploadChatView(*u, model, history, false, "")
+ if question != "" {
+ reply, err := a.Chat.Answer(r.Context(), chat.Request{
+ Model: model,
+ Transcript: u.Content,
+ History: history,
+ Question: question,
+ MaxChars: a.UploadChatMaxChars,
+ })
+ if err != nil {
+ a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "err", err)
+ view.Error = "That model couldn't answer just now. Try again, or switch models."
+ } else {
+ view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer})
+ view.Truncated = reply.Truncated
+ }
+ }
+ if isHTMX(r) {
+ a.render(w, r, chatPanel(view))
+ return
+ }
+ a.render(w, r, UploadChatPage(*u, view))
+}
+
+func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history []chat.Turn, truncated bool, errMsg string) chatView {
+ return chatView{
+ VideoID: u.ID,
+ Title: u.Title,
+ Available: true,
+ Models: a.Chat.Models(),
+ Selected: model,
+ History: history,
+ Truncated: truncated,
+ Error: errMsg,
+ PostURL: templ.SafeURL("/uploads/" + u.ID + "/chat"),
+ Subject: "recording",
+ }
+}
diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ
index ffcb759..b084c10 100644
--- a/internal/web/uploads.templ
+++ b/internal/web/uploads.templ
@@ -44,11 +44,11 @@ templ UploadsPage(v uploadsView) {
}
// UploadPage is one upload's page: its title and the polling status region.
-templ UploadPage(u domain.UploadedTranscript) {
+templ UploadPage(u domain.UploadedTranscript, chatEnabled bool) {
@Layout("Tapir — " + u.Title) {
{ u.Title }
- @uploadStatus(u)
+ @uploadStatus(u, chatEnabled)
All recordings
}
@@ -56,7 +56,7 @@ templ UploadPage(u domain.UploadedTranscript) {
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
// page re-asks; done and failed render without it, so nothing polls forever.
-templ uploadStatus(u domain.UploadedTranscript) {
+templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
switch u.Status {
case domain.UploadDone:
@@ -64,6 +64,9 @@ templ uploadStatus(u domain.UploadedTranscript) {
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by
{ u.Endpoint } .
+ if chatEnabled {
+
Ask about this recording
+ }
Transcript
{ u.Content }
@@ -89,3 +92,19 @@ templ uploadStatus(u domain.UploadedTranscript) {
}
}
+
+// UploadChatPage is the no-JS full page for chatting with an uploaded transcript:
+// the same chat panel as a video's (ADR-027), scoped to this recording.
+templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
+ @Layout("Tapir — Ask about " + u.Title) {
+
+ { u.Title }
+
+ Ask about this recording
+ Answers come only from this recording's transcript, which is private to you.
+ @chatPanel(v)
+
+ Back to the transcript
+
+ }
+}
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go
index a874853..c848d97 100644
--- a/internal/web/uploads_templ.go
+++ b/internal/web/uploads_templ.go
@@ -5,8 +5,10 @@ package web
//lint:file-ignore SA4006 This context is only used if a nested component is present.
-import "github.com/a-h/templ"
-import templruntime "github.com/a-h/templ/runtime"
+import (
+ "github.com/a-h/templ"
+ templruntime "github.com/a-h/templ/runtime"
+)
import (
"fmt"
@@ -173,7 +175,7 @@ func UploadsPage(v uploadsView) templ.Component {
}
// UploadPage is one upload's page: its title and the polling status region.
-func UploadPage(u domain.UploadedTranscript) templ.Component {
+func UploadPage(u domain.UploadedTranscript, chatEnabled bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -223,7 +225,7 @@ func UploadPage(u domain.UploadedTranscript) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = uploadStatus(u).Render(ctx, templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = uploadStatus(u, chatEnabled).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -243,7 +245,7 @@ func UploadPage(u domain.UploadedTranscript) templ.Component {
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
// page re-asks; done and failed render without it, so nothing polls forever.
-func uploadStatus(u domain.UploadedTranscript) templ.Component {
+func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -292,84 +294,107 @@ func uploadStatus(u domain.UploadedTranscript) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ".
Transcript ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ".")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var15 string
- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 69, Col: 40}
+ if chatEnabled {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Ask about this recording
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- case domain.UploadFailed:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Transcript ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 74, Col: 42}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 72, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- default:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/" + u.ID + "/status")
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 80, Col: 43}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 77, Col: 42}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-trigger=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ default:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-trigger=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("every " + uploadPollEvery)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 84, Col: 43}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if u.Status == domain.UploadTranscribing {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
Transcribing… a long recording takes a few minutes.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Transcribing… a long recording takes a few minutes.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
Queued — waiting for its turn to be transcribed.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
Queued — waiting for its turn to be transcribed.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -378,4 +403,87 @@ func uploadStatus(u domain.UploadedTranscript) templ.Component {
})
}
+// UploadChatPage is the no-JS full page for chatting with an uploaded transcript:
+// the same chat panel as a video's (ADR-027), scoped to this recording.
+func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var20 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var20 == nil {
+ templ_7745c5c3_Var20 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var21 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 101, Col: 16}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " Ask about this recording Answers come only from this recording's transcript, which is private to you.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = chatPanel(v).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Back to the transcript
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Tapir — Ask about "+u.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/views.templ b/internal/web/views.templ
index 602dafd..55d9c9b 100644
--- a/internal/web/views.templ
+++ b/internal/web/views.templ
@@ -583,7 +583,7 @@ templ chatPanel(v chatView) {
}
if v.Truncated {
- Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.
+ Working from a bounded portion of a long transcript — answers about the end of the { v.subject() } may be incomplete.
}
if v.Error != "" {
{ v.Error }
@@ -591,8 +591,8 @@ templ chatPanel(v chatView) {
diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go
index 1a0562b..8f2732f 100644
--- a/internal/web/views_templ.go
+++ b/internal/web/views_templ.go
@@ -5,8 +5,10 @@ package web
//lint:file-ignore SA4006 This context is only used if a nested component is present.
-import "github.com/a-h/templ"
-import templruntime "github.com/a-h/templ/runtime"
+import (
+ "github.com/a-h/templ"
+ templruntime "github.com/a-h/templ/runtime"
+)
import (
"fmt"
@@ -292,7 +294,7 @@ func flashBanner(code string) templ.Component {
}
ctx = templ.ClearChildren(ctx)
if f, ok := flashFor(code); ok {
- var templ_7745c5c3_Var9 = []any{"flash", "flash-" + f.Kind}
+ templ_7745c5c3_Var9 := []any{"flash", "flash-" + f.Kind}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -824,7 +826,7 @@ func VideoCard(r store.SummaryRow) templ.Component {
templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- var templ_7745c5c3_Var26 = []any{"card", templ.KV("card-pending", !r.Summarized)}
+ templ_7745c5c3_Var26 := []any{"card", templ.KV("card-pending", !r.Summarized)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -2152,148 +2154,174 @@ func chatPanel(v chatView) templ.Component {
return templ_7745c5c3_Err
}
if v.Truncated {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- if v.Error != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "
Working from a bounded portion of a long transcript — answers about the end of the ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var88 string
- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error)
+ templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(v.subject())
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 589, Col: 48}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 586, Col: 129}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, " may be incomplete.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var89 string
+ templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 589, Col: 48}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, " ")
+ var templ_7745c5c3_Var91 string
+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(v.postURL()))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 595, Col: 33}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\" hx-target=\"#chat-panel\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, t := range v.History {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "Model ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- for _, m := range v.Models {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if m == v.Selected {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, " selected")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, ">")
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "Model ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, m := range v.Models {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if m == v.Selected {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, " selected")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, ">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var95 string
+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(m)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 607, Col: 60}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, " Switch models to compare answers on the same transcript. Ask thinking… ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, " Switch models to compare answers on the same transcript. Ask thinking… ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -2320,12 +2348,12 @@ func RegisterPage(email, errMsg string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var95 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var95 == nil {
- templ_7745c5c3_Var95 = templ.NopComponent
+ templ_7745c5c3_Var97 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var97 == nil {
+ templ_7745c5c3_Var97 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Var96 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Var98 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
@@ -2337,59 +2365,59 @@ func RegisterPage(email, errMsg string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "Complete your registration ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "Complete your registration ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if email != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "Signed in as ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "
Signed in as ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var97 string
- templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(email)
+ var templ_7745c5c3_Var99 string
+ templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 628, Col: 40}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, ".
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, ".")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "Choose a display name to finish setting up your Tapir account.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "Choose a display name to finish setting up your Tapir account.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errMsg != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var98 string
- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
+ var templ_7745c5c3_Var100 string
+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 632, Col: 42}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "Display name Register ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "Display name Register ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
- templ_7745c5c3_Err = Layout("Tapir — Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var96), templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = Layout("Tapir — Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var98), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -2417,12 +2445,12 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var99 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var99 == nil {
- templ_7745c5c3_Var99 = templ.NopComponent
+ templ_7745c5c3_Var101 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var101 == nil {
+ templ_7745c5c3_Var101 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Var100 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_Var102 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
@@ -2438,43 +2466,43 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, " Account Display name ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, " Account Display name ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var101 string
- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName))
+ var templ_7745c5c3_Var103 string
+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 656, Col: 36}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if email != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "Signed in as ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, " Signed in as ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var102 string
- templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(email)
+ var templ_7745c5c3_Var104 string
+ templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 659, Col: 16}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "Summarization Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "Summarization Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -2482,178 +2510,178 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(channelErrors) > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "Unavailable channels ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "Unavailable channels ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var103 string
- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors)))
+ var templ_7745c5c3_Var105 string
+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 676, Col: 100}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, " These may have been deleted or made private on YouTube.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, " These may have been deleted or made private on YouTube.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, ce := range channelErrors {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var104 string
- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(ce.ChannelName)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 682, Col: 57}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, " unavailable since ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var105 string
- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(ce.FirstSeen.Format("2006-01-02"))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 684, Col: 89}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "Connected accounts ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- if len(conns) == 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "No connected video accounts yet.
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- for _, c := range conns {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var106 string
- templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider))
+ templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(ce.ChannelName)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 699, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 682, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " unavailable since ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if c.ProviderAccount != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var107 string
- templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 701, Col: 49}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
+ var templ_7745c5c3_Var107 string
+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(ce.FirstSeen.Format("2006-01-02"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 684, Col: 89}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "")
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "Connected accounts ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(conns) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "No connected video accounts yet.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, c := range conns {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var108 string
- templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status)
+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 703, Col: 38}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 699, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "
connected ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var109 string
- templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02"))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 705, Col: 83}
+ if c.ProviderAccount != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var109 string
+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 701, Col: 49}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "
Disconnect ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, " connected ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var111 string
+ templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 705, Col: 83}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "
Disconnect ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !hasYouTube(conns) {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "Connect YouTube
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "Connect YouTube
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "Delete account Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.
Delete account… This permanently deletes your account and all data. Are you sure?
Yes, permanently delete my account ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "Delete account Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.
Delete account… This permanently deletes your account and all data. Are you sure?
Yes, permanently delete my account ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
- templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var100), templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var102), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -2681,51 +2709,51 @@ func summarizeModeControl(auto bool) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var111 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var111 == nil {
- templ_7745c5c3_Var111 = templ.NopComponent
+ templ_7745c5c3_Var113 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var113 == nil {
+ templ_7745c5c3_Var113 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "Current mode: ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var112 string
- templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 743, Col: 53}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "Current mode: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var114 string
- templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto))
+ templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 752, Col: 79}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 743, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var116 string
+ templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 752, Col: 79}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -2753,38 +2781,38 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var115 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var115 == nil {
- templ_7745c5c3_Var115 = templ.NopComponent
+ templ_7745c5c3_Var117 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var117 == nil {
+ templ_7745c5c3_Var117 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -2796,7 +2824,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -2804,7 +2832,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -2830,81 +2858,81 @@ func actionButton(verb string, isActive bool) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var118 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var118 == nil {
- templ_7745c5c3_Var118 = templ.NopComponent
+ templ_7745c5c3_Var120 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var120 == nil {
+ templ_7745c5c3_Var120 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- var templ_7745c5c3_Var119 = []any{"action", templ.KV("active", isActive)}
- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var119...)
+ templ_7745c5c3_Var121 := []any{"action", templ.KV("active", isActive)}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var121...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "\" class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var123 string
+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var121).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "\" aria-pressed=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var124 string
+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(isActive))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 790, Col: 38}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if isActive {
- var templ_7745c5c3_Var123 string
- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(verb))
+ var templ_7745c5c3_Var125 string
+ templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(verb))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 793, Col: 31}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- var templ_7745c5c3_Var124 string
- templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(verb))
+ var templ_7745c5c3_Var126 string
+ templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(verb))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 795, Col: 22}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
--
2.54.0
From 3464cdf87652c8a59241ab79b5de5c1584228ca4 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:01:06 +0200
Subject: [PATCH 037/299] test(e2e): opt-in live upload -> transcript ->
grounded chat check
Real ffprobe/ffmpeg, the live gateway and a real chat model, a private
embedded Postgres. Skipped unless TAPIR_E2E_AUDIO is set. Run 2026-10-01 with
82 s of synthetic Swedish as AAC .m4a:
- primary: iguana/kb-whisper, 82.6 s audio, 1,186 chars; asked who owns the
timber order, the answer named Anders and the 15 November deadline from the
transcript; the spool directory was empty afterwards.
- primary forced to fail (TAPIR_TRANSCRIBE_MODEL=iguana/does-not-exist): the
fallback served it and the row recorded endpoint=berget/kb-whisper.
Synthetic audio only; this repo is public.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/e2e_upload_test.go | 170 +++++++++++++++++++++++++++++++++++
1 file changed, 170 insertions(+)
create mode 100644 cmd/tapir/e2e_upload_test.go
diff --git a/cmd/tapir/e2e_upload_test.go b/cmd/tapir/e2e_upload_test.go
new file mode 100644
index 0000000..0cf9ecc
--- /dev/null
+++ b/cmd/tapir/e2e_upload_test.go
@@ -0,0 +1,170 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ embeddedpostgres "github.com/fergusstrange/embedded-postgres"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/store"
+ "git.d-ma.be/mathias/tapir/internal/config"
+ "git.d-ma.be/mathias/tapir/internal/web"
+)
+
+// TestE2E_UploadTranscribeAndChat is the end-to-end check for tapir#33: upload a
+// real audio file, watch it become a transcript through the REAL ffprobe, ffmpeg
+// and gateway, then ask a question and get a grounded answer from a REAL model.
+// It is opt-in — it needs real binaries and the live gateway — and skipped unless
+// TAPIR_E2E_AUDIO is set:
+//
+// TAPIR_E2E_AUDIO=/path/meeting.m4a TAPIR_E2E_FFMPEG=/path/ffmpeg \
+// TAPIR_E2E_FFPROBE=/path/ffprobe TAPIR_E2E_QUESTION='Vem ansvarar för virket?' \
+// TAPIR_E2E_EXPECT=anders op run --env-file ~/.op-env -- \
+// go test ./cmd/tapir -run E2E_Upload -v -count=1
+//
+// Use SYNTHETIC audio only: this repo is public, and a fallback to berget sends
+// the audio to a cloud endpoint (#33).
+func TestE2E_UploadTranscribeAndChat(t *testing.T) {
+ audioPath := os.Getenv("TAPIR_E2E_AUDIO")
+ if audioPath == "" {
+ t.Skip("set TAPIR_E2E_AUDIO (and friends) to run the live end-to-end check")
+ }
+ key := os.Getenv("LITELLM_MASTER_KEY")
+ require.NotEmpty(t, key, "LITELLM_MASTER_KEY must be set (via op run)")
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ // A private embedded Postgres: nothing here may touch a real database.
+ port := uint32(55000 + os.Getpid()%1000)
+ rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-e2e-%d", os.Getpid()))
+ pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port).
+ RuntimePath(rt).DataPath(filepath.Join(rt, "data")).BinariesPath(filepath.Join(rt, "bin")).
+ CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")))
+ require.NoError(t, pg.Start())
+ t.Cleanup(func() { _ = pg.Stop(); _ = os.RemoveAll(rt) })
+ dsn := fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
+
+ st, err := store.New(ctx, dsn)
+ require.NoError(t, err)
+ t.Cleanup(st.Close)
+ raw, err := pgxpool.New(ctx, dsn)
+ require.NoError(t, err)
+ t.Cleanup(raw.Close)
+ const uid = "11111111-1111-1111-1111-111111111111"
+ _, err = raw.Exec(ctx, `INSERT INTO users (id) VALUES ($1)`, uid)
+ require.NoError(t, err)
+ _, err = raw.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ('e2e', $1)`, uid)
+ require.NoError(t, err)
+
+ cfg, err := config.Load()
+ require.NoError(t, err)
+ cfg.GatewayKey = key
+ cfg.UploadsEnabled = true
+ cfg.UploadTmpDir = filepath.Join(t.TempDir(), "spool")
+ cfg.FFmpegBin = envOrDefault("TAPIR_E2E_FFMPEG", "ffmpeg")
+ cfg.FFprobeBin = envOrDefault("TAPIR_E2E_FFPROBE", "ffprobe")
+
+ intake, worker, err := buildUploads(ctx, cfg, st, quietLog())
+ require.NoError(t, err)
+ go worker.Run(ctx)
+ app := &web.App{
+ Store: st, Identity: st, Auth: web.StubAuth{U: web.User{Subject: "e2e"}},
+ Uploads: intake, UploadLimits: uploadLimits(cfg),
+ Chat: buildChat(cfg), UploadChatMaxChars: cfg.MaxUploadedTranscriptChars,
+ }
+ srv := httptest.NewServer(app.Router())
+ t.Cleanup(srv.Close)
+ noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+
+ // 1. Upload.
+ f, err := os.Open(audioPath)
+ require.NoError(t, err)
+ defer f.Close()
+ pr, pw := io.Pipe()
+ mw := multipart.NewWriter(pw)
+ go func() {
+ _ = mw.WriteField("title", "E2E recording")
+ fw, _ := mw.CreateFormFile("file", filepath.Base(audioPath))
+ _, _ = io.Copy(fw, f)
+ _ = mw.Close()
+ _ = pw.Close()
+ }()
+ req, err := http.NewRequest(http.MethodPost, srv.URL+"/uploads", pr)
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ resp, err := noRedirect.Do(req)
+ require.NoError(t, err)
+ _ = resp.Body.Close()
+ require.Equal(t, http.StatusSeeOther, resp.StatusCode, "an accepted upload redirects to its page")
+ loc := resp.Header.Get("Location")
+ require.True(t, strings.HasPrefix(loc, "/uploads/"), loc)
+ t.Logf("accepted -> %s", loc)
+
+ // 2. Wait for the transcript: unfinished states poll, terminal states do not.
+ var page string
+ require.Eventually(t, func() bool {
+ r, err := http.Get(srv.URL + loc + "/status")
+ if err != nil {
+ return false
+ }
+ b, _ := io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ page = string(b)
+ return !strings.Contains(page, "hx-trigger")
+ }, 12*time.Minute, 3*time.Second, "the upload never reached a terminal state")
+ require.Contains(t, page, "Transcribed", "terminal state was not 'done': %.300s", page)
+
+ var status, endpoint, content string
+ var secs float64
+ require.NoError(t, raw.QueryRow(ctx,
+ `SELECT status, endpoint, content, audio_seconds FROM uploaded_transcripts WHERE user_id = $1`, uid).
+ Scan(&status, &endpoint, &content, &secs))
+ t.Logf("done: endpoint=%s audio_s=%.1f transcript_chars=%d", endpoint, secs, len(content))
+ assert.Equal(t, "done", status)
+ assert.NotEmpty(t, endpoint)
+ assert.NotEmpty(t, content)
+ entries, err := os.ReadDir(cfg.UploadTmpDir)
+ require.NoError(t, err)
+ assert.Empty(t, entries, "the audio must be deleted once transcribed")
+
+ // 3. Ask a question; the answer must come from the transcript.
+ q := envOrDefault("TAPIR_E2E_QUESTION", "Who is responsible for the timber order?")
+ chatResp, err := http.PostForm(srv.URL+loc+"/chat", url.Values{"question": {q}})
+ require.NoError(t, err)
+ answerPage, _ := io.ReadAll(chatResp.Body)
+ _ = chatResp.Body.Close()
+ require.Equal(t, http.StatusOK, chatResp.StatusCode)
+ t.Logf("question: %s", q)
+ if want := os.Getenv("TAPIR_E2E_EXPECT"); want != "" {
+ assert.Contains(t, strings.ToLower(string(answerPage)), strings.ToLower(want),
+ "the answer should be grounded in the transcript")
+ }
+ t.Logf("answer page excerpt: %.600s", afterMarker(string(answerPage), `class="chat-turn chat-a"`))
+}
+
+func envOrDefault(k, d string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return d
+}
+
+func afterMarker(s, m string) string {
+ if i := strings.Index(s, m); i >= 0 {
+ return s[i:]
+ }
+ return "(no answer turn found)"
+}
--
2.54.0
From f9d6df9f846f8247d0daf3282a54a5a5f23082e0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:01:20 +0200
Subject: [PATCH 038/299] docs(use-cases): audio_upload.feature -- scenarios
for #33 (ADR-033)
Co-Authored-By: Claude Sonnet 5.5
---
docs/use-cases/audio_upload.feature | 101 ++++++++++++++++++++++++++++
1 file changed, 101 insertions(+)
create mode 100644 docs/use-cases/audio_upload.feature
diff --git a/docs/use-cases/audio_upload.feature b/docs/use-cases/audio_upload.feature
new file mode 100644
index 0000000..4665a37
--- /dev/null
+++ b/docs/use-cases/audio_upload.feature
@@ -0,0 +1,101 @@
+Feature: Upload an audio recording, get a private Swedish transcript, discuss it
+ As someone with a recorded meeting on my computer
+ I want to upload it and then ask questions about what was said
+ So that I can find out what was decided, what is open and who owes what
+
+ # ADR-033, tapir#33. The load-bearing constraint: an uploaded transcript is
+ # PRIVATE content, so it lives in its own RLS-scoped store and never in the
+ # shared public-caption `transcripts` table (ADR-021). Rejection is synchronous,
+ # the audio is never retained, and a failed job ends in a terminal state with a
+ # message — never a spinner (ADR-025). Audio only: video is #28.
+
+ Background:
+ Given I am a registered user with audio upload enabled
+
+ Scenario: The upload form states the limits up front
+ When I open the upload page
+ Then I see the size limit and the length limit before I choose a file
+
+ Scenario: An accepted recording is queued and I am taken to its page
+ When I upload an audio file within the limits
+ Then it is recorded as queued for me
+ And I am redirected to its page
+
+ Scenario: A recording with no title is named after its file
+ When I upload an audio file and give no title
+ Then its title is the filename without its extension
+
+ Scenario: A file containing video is rejected, naming issue 28
+ When I upload a file that has a video track
+ Then it is rejected with a message that video is tracked in issue 28
+ And nothing is recorded
+
+ Scenario: A recording over the length limit is rejected with both lengths
+ When I upload a recording longer than the limit
+ Then it is rejected stating the recording's length and the limit
+ And nothing is recorded
+
+ Scenario: A file over the size limit is rejected as too large
+ When I upload a file larger than the size limit
+ Then it is rejected with 413 and a message about the limit
+ And nothing is recorded
+
+ Scenario: An audio-only MP4 is not mistaken for video
+ When I upload an mp4 container that has an audio stream and no video stream
+ Then it is accepted
+ And an mp3 whose only video stream is cover art is accepted too
+
+ Scenario: A file that is not media is rejected as unreadable
+ When I upload a file ffprobe cannot read
+ Then it is rejected as the file's fault
+
+ Scenario: A broken ffprobe is reported as our fault, not as a bad file
+ When the prober itself fails
+ Then the upload is an error rather than a rejection of my file
+
+ Scenario: A recording becomes a transcript and the audio is deleted
+ Given I have uploaded a recording
+ When it has been transcribed
+ Then the transcript is stored privately for me
+ And the audio is deleted
+
+ Scenario: The endpoint that served a transcript is recorded and shown
+ Given a recording was transcribed by the fallback endpoint
+ When I view its page
+ Then it names the fallback endpoint, because that means the audio left the homelab
+
+ Scenario: The local endpoint is tried first and the fallback only on failure
+ When the primary transcription endpoint fails for any reason
+ Then the fallback endpoint transcribes it
+ But when the primary succeeds the fallback is never contacted
+
+ Scenario: A failed transcription ends in a message, never a spinner
+ When every transcription endpoint fails
+ Then the upload is failed with a typed reason
+ And its page shows that reason and stops polling
+
+ Scenario: A restart fails the jobs it interrupted
+ Given the service restarted while a recording was queued or transcribing
+ When it starts again
+ Then those uploads are failed as interrupted
+ And any audio left in the spool is deleted
+
+ Scenario: Only one recording is transcribed at a time
+ When several recordings are queued
+ Then they are transcribed one after another
+
+ Scenario: My uploads are invisible to other users
+ Given another user has uploaded a recording
+ When I ask for it by its id
+ Then I get 404 and no part of its title or text
+ And the chat for it calls no model
+
+ Scenario: I can ask about my recording and get a grounded answer
+ Given my recording has been transcribed
+ When I ask a question about it
+ Then the answer comes from my transcript through the same chat as a video's
+
+ Scenario: A long recording is not cut to its first third
+ Given my recording's transcript is longer than the caption default
+ When I ask a question about it
+ Then the transcript is sent with the upload budget, not the 18,000-character caption default
--
2.54.0
From 07375f75dec88b07b4645822557cd16dc8854582 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:01:38 +0200
Subject: [PATCH 039/299] test(acceptance): map audio_upload.feature scenarios
to their covering tests
Co-Authored-By: Claude Sonnet 5.5
---
test/acceptance/scenario_coverage_test.go | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go
index ccabadb..76bc7b9 100644
--- a/test/acceptance/scenario_coverage_test.go
+++ b/test/acceptance/scenario_coverage_test.go
@@ -101,6 +101,26 @@ var scenarioCoverage = map[string]string{
"A multi-turn conversation is ephemeral": "TestChatMultiTurnHistoryIsEphemeral",
"Chat is reachable only from my own summary view": "TestChatOnlyReachableForOwnVideo",
+ // audio_upload.feature (ADR-033, #33)
+ "The upload form states the limits up front": "TestUploadsPage_ShowsAMultipartFormWithAFileField",
+ "An accepted recording is queued and I am taken to its page": "TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt",
+ "A recording with no title is named after its file": "TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension",
+ "A file containing video is rejected, naming issue 28": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
+ "A recording over the length limit is rejected with both lengths": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
+ "A file over the size limit is rejected as too large": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
+ "An audio-only MP4 is not mistaken for video": "TestParseProbe",
+ "A file that is not media is rejected as unreadable": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
+ "A broken ffprobe is reported as our fault, not as a bad file": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
+ "A recording becomes a transcript and the audio is deleted": "TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio",
+ "The endpoint that served a transcript is recorded and shown": "TestUploadStatus_DoneNamesTheEndpointThatServedIt",
+ "The local endpoint is tried first and the fallback only on failure": "TestChain_AnyPrimaryFailureFallsBackAndRecordsTheFallbackEndpoint",
+ "A failed transcription ends in a message, never a spinner": "TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop",
+ "A restart fails the jobs it interrupted": "TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser",
+ "Only one recording is transcribed at a time": "TestWorkerRun_ProcessesOneJobAtATime",
+ "My uploads are invisible to other users": "TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled",
+ "I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
+ "A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
+
// summarize_new_video.feature
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",
"A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped",
--
2.54.0
From 0fc25a24edc55293018643edad5e361ac90f91b4 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:02:05 +0200
Subject: [PATCH 040/299] docs(data-model): uploaded_transcripts, and fix the
stale claim that transcripts is RLS'd
Adds the private uploaded_transcripts entity and the exact-set isolation rule
(ADR-033). Also corrects two lists that still named `transcripts` as an RLS'd
table: it has been shared non-RLS since ADR-021 / migration 015, and these
lists sit next to the very boundary that matters.
Co-Authored-By: Claude Sonnet 5.5
---
docs/data-model.md | 38 +++++++++++++++++++++++++++++++++++---
1 file changed, 35 insertions(+), 3 deletions(-)
diff --git a/docs/data-model.md b/docs/data-model.md
index f290124..a9e49c2 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -18,7 +18,12 @@ only opaque references to them; the secret material lives in ESO/1Password (ADR-
not LLM re-summarization but a rate-gated, reputation-risky caption fetch (ADR-010/014), which
is paid per re-fetch regardless of user count — so persisting public caption content once and
sharing it strictly beats the coupling it removes. Everything else each user owns is
- self-contained; `rls_test.go` proves transcripts is the single exception.
+ self-contained; `rls_test.go` proves transcripts is the single exception for *content*.
+ **Uploaded-audio transcripts are the opposite of public caption content** (private, owned by
+ one user) and therefore live in their own RLS-scoped `uploaded_transcripts` table, never in
+ `transcripts` (ADR-033). The isolation test now also pins the **exact set** of tables with row
+ security off (`transcripts`, `user_identities`, `invitations`, `schema_migrations`), so a new
+ table added without RLS fails CI.
- **Secrets by reference only.** Tables hold a `secret_ref` (opaque string/UUID resolved via
the `SecretStore` port), never tokens or keys.
- **The brain sink is just a delivery target.** No brain-specific tables. Whether a summary
@@ -41,6 +46,7 @@ erDiagram
VIDEO ||--o| SUMMARY : "has at most one"
SUMMARY ||--o{ SINK_DELIVERY : "delivered via"
USER ||--o{ CHANNEL_ERROR : "reports unavailable channels"
+ USER ||--o{ UPLOADED_TRANSCRIPT : "uploads (private, ADR-033)"
USER {
uuid id PK
@@ -136,12 +142,27 @@ erDiagram
timestamptz first_seen
timestamptz last_seen
}
+ UPLOADED_TRANSCRIPT {
+ uuid id PK
+ uuid user_id FK "-> USER, ON DELETE CASCADE, NOT NULL; RLS ENABLE+FORCE (migration 017)"
+ text title
+ text status "queued | transcribing | done | failed"
+ text content "nullable until done; the transcript text"
+ jsonb segments "nullable until done; [{start,end,text}] in seconds"
+ text endpoint "which backend served it: iguana/... | berget/... (berget = audio left the homelab)"
+ float audio_seconds
+ text error_kind "typed: interrupted | transcription_failed — never a provider string"
+ timestamptz created_at
+ timestamptz finished_at
+ }
```
`SUMMARY_ACTION` has `UNIQUE (user_id, video_id, action)`; `VIDEO_CONNECTION` has
`UNIQUE (user_id, provider)` (one connection per provider — reconnect upserts in place).
RLS (`ENABLE` + `FORCE`) is on **every solid user-owned table above** — `users`, `videos`,
-`transcripts`, `summaries`, `summary_actions`, `video_connections`, `channel_errors`.
+`summaries`, `summary_actions`, `video_connections`, `channel_errors`, `uploaded_transcripts`.
+(`transcripts` is deliberately **not** in this list: since ADR-021 it is shared public caption
+content with no `user_id`.)
`sink_deliveries` is RLS'd via an `EXISTS` on its parent summary; `user_identities` is
intentionally **not** RLS'd (auth plumbing). See the *Isolation invariant* section for the
mechanism.
@@ -197,6 +218,14 @@ mechanism.
the most recent discovery pass. Upserted per scheduler pass (`last_seen` refreshed each run);
surfaced on the account page as a warning. Cascades on user deletion. Primary key is
`(user_id, channel_id)`. FORCE RLS'd.
+- **UPLOADED_TRANSCRIPTS** (migration 017, ADR-033) — the **private** transcript of audio a user
+ uploaded. Deliberately a separate table from the shared `transcripts`: that one is non-RLS on the
+ grounds that it holds only public caption content, and a recorded meeting is the opposite.
+ `user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing →
+ done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK
+ constraints). `endpoint` records which backend served it — `berget/...` means the audio left the
+ homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90
+ minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion.
- **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
Stage-0 gate query (VISION §Stage 0: "returned and used in ≥2 distinct weeks").
@@ -209,7 +238,10 @@ enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the
Enforcement is **Postgres Row-Level Security** (migration `003_rls.up.sql`):
- RLS is `ENABLE`d **and** `FORCE`d on every user-owned table — `users`, `videos`,
- `transcripts`, `summaries`, `summary_actions`, `video_connections`, `channel_errors`.
+ `summaries`, `summary_actions`, `video_connections`, `channel_errors`, and (migration 017)
+ `uploaded_transcripts`. `transcripts` is **not** one of them: ADR-021 made it shared public
+ content, and migration 003's original per-user `transcripts` policy was dropped with that
+ table in migration 015.
`FORCE` is load-bearing: the app connects as the table **owner** (`tapir` role), and owners
bypass RLS unless forced.
- Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by
--
2.54.0
From 39aeda7c6987fecb890b6732a2b7a8523db100f7 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:02:05 +0200
Subject: [PATCH 041/299] docs(homelab): speech-to-text endpoints, the ingress
413, and the upload knobs
Co-Authored-By: Claude Sonnet 5.5
---
docs/homelab-integration.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md
index 1b805a6..5104020 100644
--- a/docs/homelab-integration.md
+++ b/docs/homelab-integration.md
@@ -12,6 +12,20 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
- **Off-cluster (host / NodePort):** `http://koala:30401/v1/` — service `litellm-nodeport` (`4000:30401`).
- NOTE: the earlier `31234` was **wrong** — `31234` is the `llama-swap` NodePort (`8080:31234`),
not LiteLLM. Use `30401` for off-cluster LiteLLM. `confirm` resolved.
+- **Speech-to-text (ADR-033):** the same gateway serves `POST /v1/audio/transcriptions` for
+ `iguana/kb-whisper` (primary, local) and `berget/kb-whisper` (fallback, EU cloud — private audio
+ leaves the homelab if it is used). **Call it via the NodePort / in-cluster address above, NOT
+ `https://llm-api.d-ma.be`:** that public ingress (openresty) returns `413` for bodies over
+ roughly 8–10 MB (~5 minutes of 16 kHz WAV), while the NodePort took 174 MB (90 minutes) in one
+ request (measured 2026-10-01, `docs/research/audio-length-ceiling-2026-10-01.md`). Send multipart
+ field `file`, `response_format=verbose_json`, no `Expect: 100-continue`. iguana's whisper-server
+ can wedge permanently after a Metal OOM (infra#273); never branch on its
+ `{"error":"failed to process audio"}` string — it has had at least five causes. Tapir's knobs:
+ `TAPIR_UPLOADS_ENABLED` (off by default), `TAPIR_UPLOAD_MAX_MB` (200), `TAPIR_UPLOAD_MAX_MINUTES`
+ (90), `TAPIR_TRANSCRIBE_MODEL`, `TAPIR_TRANSCRIBE_FALLBACK_MODEL` (empty disables the cloud
+ fallback), `TAPIR_TRANSCRIBE_TIMEOUT` (30m), `TAPIR_UPLOAD_TMP_DIR`, `TAPIR_FFMPEG_BIN`,
+ `TAPIR_FFPROBE_BIN`, `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` (120000). Needs `ffmpeg` + `ffprobe`
+ in the runtime image; Tapir's own ingress body limit for a 200 MB upload is unverified (infra).
- **Auth key:** treat as a secret via the SecretStore port; do not hardcode in committed code.
NOTE: the documented `sk-local-123` returned `401` on `30401/v1/models` (2026-06-02) — it is
stale or the gateway now enforces the `LITELLM_MASTER_KEY` (1Password `op://HomeLab/LITELLM_MASTER_KEY`).
--
2.54.0
From 8f16ab671aa4a2c9176a9e29e5d9867d25c85641 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:02:11 +0200
Subject: [PATCH 042/299] docs(claude): audio upload supersedes the STT
deferral for user-supplied audio only
The 'no audio-download + STT' bullet is narrowed to YouTube (ADR-007 stands);
adds the upload packages, the exact-set isolation test and the pending ffmpeg
decision to the build state.
Co-Authored-By: Claude Sonnet 5.5
---
CLAUDE.md | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index cf4a939..02c5454 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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–017),
`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,12 @@ 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.
- `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.
--
2.54.0
From 4b09dc8f36fdb1c9835204d65c6cfbd25bf19001 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:07:09 +0200
Subject: [PATCH 043/299] test(config): chat defaults for uploads come from
measurement, not a guess (red)
iguana/gemma4-26b answered correctly at 90,000 chars but SILENTLY dropped the
head of a 120,000-char transcript (prompt tokens fell to 16k; 'not in the
text'); koala/phi4-mini has an 8,192-token context and answers HTTP 400 at
~13k tokens. So the budget is 90,000 and the upload chat model defaults to
gemma4-26b. Red: UploadChatModel is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads_test.go | 24 +++++++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
diff --git a/internal/config/uploads_test.go b/internal/config/uploads_test.go
index c55d77f..3d120bf 100644
--- a/internal/config/uploads_test.go
+++ b/internal/config/uploads_test.go
@@ -14,7 +14,7 @@ func clearUploadEnv(t *testing.T) {
"TAPIR_UPLOADS_ENABLED", "TAPIR_UPLOAD_MAX_MB", "TAPIR_UPLOAD_MAX_MINUTES",
"TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT",
"TAPIR_UPLOAD_TMP_DIR", "TAPIR_FFMPEG_BIN", "TAPIR_FFPROBE_BIN",
- "TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS",
+ "TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", "TAPIR_UPLOAD_CHAT_MODEL",
} {
unset(t, k)
}
@@ -52,8 +52,26 @@ func TestLoad_UploadDefaults(t *testing.T) {
if c.FFmpegBin != "ffmpeg" || c.FFprobeBin != "ffprobe" {
t.Errorf("bins = %q, %q", c.FFmpegBin, c.FFprobeBin)
}
- if c.MaxUploadedTranscriptChars != 120000 {
- t.Errorf("MaxUploadedTranscriptChars = %d, want 120000", c.MaxUploadedTranscriptChars)
+ if c.MaxUploadedTranscriptChars != 90000 {
+ t.Errorf("MaxUploadedTranscriptChars = %d, want 90000: iguana/gemma4-26b answered correctly at 90,000 chars "+
+ "but silently dropped the head of a 120,000-char transcript (docs/research/chat-context)", c.MaxUploadedTranscriptChars)
+ }
+ if c.UploadChatModel != "iguana/gemma4-26b" {
+ t.Errorf("UploadChatModel = %q: koala/phi4-mini has an 8,192-token context and answers HTTP 400 on an hour of speech", c.UploadChatModel)
+ }
+}
+
+func TestLoad_UploadChatModelOverride(t *testing.T) {
+ clearUploadEnv(t)
+ t.Setenv("TAPIR_UPLOAD_CHAT_MODEL", "koala/qwen36-35b-a3b")
+
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ if c.UploadChatModel != "koala/qwen36-35b-a3b" {
+ t.Errorf("UploadChatModel = %q", c.UploadChatModel)
}
}
--
2.54.0
From af66419a44ce8942db4f458ea58fd3d414f37327 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:07:20 +0200
Subject: [PATCH 044/299] feat(config): measured chat budget (90,000) and
upload chat model (gemma4-26b)
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads.go | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/internal/config/uploads.go b/internal/config/uploads.go
index a496b20..ac4942a 100644
--- a/internal/config/uploads.go
+++ b/internal/config/uploads.go
@@ -33,9 +33,15 @@ type UploadConfig struct {
FFprobeBin string
// MaxUploadedTranscriptChars bounds the transcript sent to a chat model for an
// UPLOADED recording. The 18,000-char default for captions is ~22 minutes of
- // speech; an hour of meeting is ~50,000 chars. A guess until measured against
- // the models chat offers.
+ // speech; an hour of meeting is ~50,000 chars. MEASURED 2026-10-01 against the
+ // real models: iguana/gemma4-26b answers correctly to 90,000 chars but silently
+ // drops the head of a 120,000-char transcript, so 90,000 is the ceiling of the
+ // best model, not a round number.
MaxUploadedTranscriptChars int
+ // UploadChatModel is the model chat over an uploaded recording opens with. It is
+ // NOT the summarizer default: koala/phi4-mini has an 8,192-token context and
+ // returns HTTP 400 on an hour of speech (~13,000 tokens).
+ UploadChatModel string
}
const (
@@ -44,7 +50,8 @@ const (
defaultTranscribeModel = "iguana/kb-whisper"
defaultTranscribeFallbackModel = "berget/kb-whisper"
defaultTranscribeTimeout = 30 * time.Minute
- defaultMaxUploadedChars = 120000
+ defaultMaxUploadedChars = 90000
+ defaultUploadChatModel = "iguana/gemma4-26b"
)
func loadUploadConfig() (UploadConfig, error) {
@@ -54,6 +61,7 @@ func loadUploadConfig() (UploadConfig, error) {
UploadTmpDir: envOr("TAPIR_UPLOAD_TMP_DIR", os.TempDir()),
FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"),
FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"),
+ UploadChatModel: envOr("TAPIR_UPLOAD_CHAT_MODEL", defaultUploadChatModel),
}
if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" {
--
2.54.0
From 4b6ac2a2135fc63f3f98a25d6d03757b0683a8ee Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:07:20 +0200
Subject: [PATCH 045/299] test(web): chat over an upload opens with the upload
chat model (red)
Red: App.UploadChatModel is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_chat_handler_test.go | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/internal/web/uploads_chat_handler_test.go b/internal/web/uploads_chat_handler_test.go
index bc1ce72..ff6f96f 100644
--- a/internal/web/uploads_chat_handler_test.go
+++ b/internal/web/uploads_chat_handler_test.go
@@ -165,3 +165,18 @@ func TestUploadStatus_DoneOffersTheChatLinkOnlyWhenChatIsWired(t *testing.T) {
id2 := seedUpload(t, userID, "m2", "done", meetingText)
assert.NotContains(t, body(t, do(t, appWithout, get("/uploads/"+id2+"/status"))), "/chat")
}
+
+func TestUploadChat_OpensWithTheUploadChatModelNotTheSummarizerDefault(t *testing.T) {
+ // koala/phi4-mini is first in the offered list (it is the summarizer default) and
+ // has an 8,192-token context: an hour of speech returns HTTP 400 there. Chat over
+ // an upload must open with the model configured for it.
+ app, fc := newUploadChatApp(t)
+ app.UploadChatModel = "iguana/gemma4-26b"
+ id := seedUpload(t, userID, "m", "done", meetingText)
+
+ page := body(t, do(t, app, get("/uploads/"+id+"/chat")))
+ do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
+
+ assert.Contains(t, page, `
Date: Thu, 1 Oct 2026 17:07:33 +0200
Subject: [PATCH 046/299] feat(web): chat over an upload opens with
App.UploadChatModel
The Chatter's own default is the summarizer's (koala/phi4-mini, 8,192-token
context), which answers HTTP 400 on an hour of speech. Empty keeps the old
behaviour.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/handlers.go | 5 +++++
internal/web/uploads.go | 4 ++--
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
index b9b1ed2..3867181 100644
--- a/internal/web/handlers.go
+++ b/internal/web/handlers.go
@@ -117,6 +117,11 @@ type App struct {
// recording. The caption default (18,000 chars, ~22 minutes of speech) would
// keep only the first third of an hour-long meeting.
UploadChatMaxChars int
+ // UploadChatModel is the model chat over an uploaded recording opens with. Empty
+ // means the Chatter's own default. It exists because that default is the
+ // summarizer's (koala/phi4-mini, 8,192-token context) and cannot hold an hour of
+ // speech.
+ UploadChatModel string
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index 295ced5..bd65624 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -206,7 +206,7 @@ func (a *App) handleUploadChat(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
return
}
- a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, a.Chat.DefaultModel(""), nil, false, "")))
+ a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, a.Chat.DefaultModel(a.UploadChatModel), nil, false, "")))
}
// handleUploadChatMessage answers one question against the owner's uploaded
@@ -226,7 +226,7 @@ func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
- model := a.resolveModel(r.FormValue("model"), "")
+ model := a.resolveModel(r.FormValue("model"), a.UploadChatModel)
history := parseHistory(r.Form["hq"], r.Form["ha"])
question := strings.TrimSpace(r.FormValue("question"))
--
2.54.0
From 3bb4e30f4c55a4e0c3c85bcd69b14f9f5bbd8e46 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:07:33 +0200
Subject: [PATCH 047/299] test(cmd): wireUploads sets the budget and model, and
never a typed-nil Intake (red)
I wired Uploads and UploadLimits in cmdServe but not UploadChatMaxChars: chat
over a long recording would have silently used the 18,000-char caption
default. A helper that main and the e2e test both call makes that a tested fact.
Red: wireUploads is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_test.go | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/cmd/tapir/uploads_test.go b/cmd/tapir/uploads_test.go
index a18c02e..7e95a7a 100644
--- a/cmd/tapir/uploads_test.go
+++ b/cmd/tapir/uploads_test.go
@@ -12,6 +12,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/web"
)
// fakeUploadsStore satisfies the store surface buildUploads needs, and counts the
@@ -125,3 +126,31 @@ func TestBuildUploads_RemovesAudioLeftBehindByACrash(t *testing.T) {
_, err = os.Stat(unrelated)
assert.NoError(t, err, "only our own spool files are removed")
}
+
+func TestWireUploads_SetsEverythingTheWebAppNeedsForUploadsAndChat(t *testing.T) {
+ cfg := uploadCfg(t, true)
+ cfg.UploadMaxDuration = 90 * 60 * 1e9
+ cfg.MaxUploadedTranscriptChars = 90000
+ cfg.UploadChatModel = "iguana/gemma4-26b"
+ in, _, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
+ require.NoError(t, err)
+ app := &web.App{}
+
+ wireUploads(app, cfg, in)
+
+ assert.NotNil(t, app.Uploads)
+ assert.Equal(t, cfg.UploadMaxBytes, app.UploadLimits.MaxBytes, "the form states the limits Intake enforces")
+ assert.Equal(t, cfg.UploadMaxDuration, app.UploadLimits.MaxDuration)
+ assert.Equal(t, 90000, app.UploadChatMaxChars,
+ "without this the chat would use the 18,000-char caption default and cut an hour to its first third")
+ assert.Equal(t, "iguana/gemma4-26b", app.UploadChatModel)
+}
+
+func TestWireUploads_NilIntakeLeavesTheAppUntouched(t *testing.T) {
+ app := &web.App{}
+
+ wireUploads(app, uploadCfg(t, false), nil)
+
+ assert.Nil(t, app.Uploads, "a nil *Intake must not become a non-nil interface that mounts the routes")
+ assert.Zero(t, app.UploadChatMaxChars)
+}
--
2.54.0
From e6738bcd3e0c2a61cf9b7024e43881335ddb8160 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:07:40 +0200
Subject: [PATCH 048/299] feat(cmd): wireUploads -- limits, chat budget and
chat model into the web App
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads.go | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/cmd/tapir/uploads.go b/cmd/tapir/uploads.go
index ebe5339..0da0d53 100644
--- a/cmd/tapir/uploads.go
+++ b/cmd/tapir/uploads.go
@@ -12,6 +12,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/uploads"
+ "git.d-ma.be/mathias/tapir/internal/web"
)
// uploadsStore is the store surface the upload wiring needs. *store.Store
@@ -83,3 +84,17 @@ func removeSpooledAudio(dir string, log *slog.Logger) {
}
}
}
+
+// wireUploads connects a built Intake to the web App: the routes, the limits the
+// form states, and the chat settings for an uploaded recording. A nil intake
+// leaves the App untouched — assigning a nil *Intake to the Uploader interface
+// would produce a non-nil interface and mount the routes over nothing.
+func wireUploads(app *web.App, cfg config.Config, in *uploads.Intake) {
+ if in == nil {
+ return
+ }
+ app.Uploads = in
+ app.UploadLimits = uploadLimits(cfg)
+ app.UploadChatMaxChars = cfg.MaxUploadedTranscriptChars
+ app.UploadChatModel = cfg.UploadChatModel
+}
--
2.54.0
From ad2e3e0e8e33a7029eb0051d34b30f772608a6cd Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:07:41 +0200
Subject: [PATCH 049/299] fix(cmd): serve now actually sets the upload chat
budget and model
cmdServe assigned Uploads and UploadLimits but not UploadChatMaxChars, so chat
over a long recording would have used the 18,000-char caption default. It now
calls wireUploads, which is tested.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/main.go | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go
index 4df1cb7..b94fe5d 100644
--- a/cmd/tapir/main.go
+++ b/cmd/tapir/main.go
@@ -215,9 +215,8 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
if err != nil {
return err
}
+ wireUploads(app, cfg, intake)
if intake != nil {
- app.Uploads = intake
- app.UploadLimits = uploadLimits(cfg)
go worker.Run(ctx)
log.Info("audio upload enabled", "model", cfg.TranscribeModel, "fallback", cfg.TranscribeFallbackModel,
"max_mb", cfg.UploadMaxBytes>>20, "max_minutes", int(cfg.UploadMaxDuration.Minutes()))
--
2.54.0
From 548bfaee854b234f0bf162e112cfbf5b7865966a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:07:41 +0200
Subject: [PATCH 050/299] test(e2e): use the same wireUploads as serve
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/e2e_upload_test.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/cmd/tapir/e2e_upload_test.go b/cmd/tapir/e2e_upload_test.go
index 0cf9ecc..c4bf649 100644
--- a/cmd/tapir/e2e_upload_test.go
+++ b/cmd/tapir/e2e_upload_test.go
@@ -82,9 +82,9 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
go worker.Run(ctx)
app := &web.App{
Store: st, Identity: st, Auth: web.StubAuth{U: web.User{Subject: "e2e"}},
- Uploads: intake, UploadLimits: uploadLimits(cfg),
- Chat: buildChat(cfg), UploadChatMaxChars: cfg.MaxUploadedTranscriptChars,
+ Chat: buildChat(cfg),
}
+ wireUploads(app, cfg, intake) // the same wiring cmdServe uses
srv := httptest.NewServer(app.Router())
t.Cleanup(srv.Close)
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
--
2.54.0
From 4d3e2a471a25a325522034821de6a289c705f884 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:08:13 +0200
Subject: [PATCH 051/299] docs(research): chat over a long transcript -- what
the offered models really hold
phi4-mini 400s at ~13k tokens; gemma4-26b is correct to 90,000 chars and
silently loses the HEAD at 120,000 (no error, no truncation flag). The
end-needle test alone passed at 120k -- only a head-needle exposed it.
Records the default-model trap and the iguana contention hazard.
Co-Authored-By: Claude Sonnet 5.5
---
...ontext-over-long-transcripts-2026-10-01.md | 66 +++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 docs/research/chat-context-over-long-transcripts-2026-10-01.md
diff --git a/docs/research/chat-context-over-long-transcripts-2026-10-01.md b/docs/research/chat-context-over-long-transcripts-2026-10-01.md
new file mode 100644
index 0000000..b9fc97d
--- /dev/null
+++ b/docs/research/chat-context-over-long-transcripts-2026-10-01.md
@@ -0,0 +1,66 @@
+# Chat over a long transcript — what the offered models really hold (tapir#33, 2026-10-01)
+
+Question: ADR-033 reuses `chat.Service`, which stuffs the transcript into the prompt and
+cuts the tail. #33 assumed chat "retrieves rather than generates" and so has no length
+ceiling. It does not retrieve, so its ceiling is the model's context. What is it, for the
+models chat actually offers?
+
+## Answer
+
+| Model (via the NodePort gateway) | Result | Limit found |
+|---|---|---|
+| `koala/phi4-mini` | correct to 30,000 chars; **HTTP 400 at 50,000** (13,332 tokens vs an 8,192-token context) | ~22,000 chars in production, where the client adds `max_tokens: 1500` |
+| `iguana/gemma4-26b` | correct at 18k, 50k and **90,000 chars** (26.5 k tokens); **at 120,000 chars it silently loses the head** | **90,000 chars** |
+| `berget/mistral-small` | answered "the transcript does not contain it" for a fact **at the end** of an 18,000-char and a 50,000-char transcript | unreliable here, one run each — see caveats |
+
+The dangerous one is gemma at 120,000 chars: **no error, no truncation flag** — the reported
+prompt tokens fell from 26,548 (90 k chars) to 16,387 (120 k chars), and the model answered
+"it does not say who the chair is" about a sentence at the very start of the transcript. The
+serving layer truncated the prompt from the front. `chat.Service` reports `Truncated: false`
+because *it* did not cut anything.
+
+## What this changes
+
+1. **The budget for an uploaded transcript is 90,000 chars, not a round 120,000.** That is the
+ best offered model's measured limit, ~100 minutes of speech at the fixture's density.
+ `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` defaults to it.
+2. **Chat over an upload must not open on the summarizer default.** The offered list is
+ `[SummarizerModel, FallbackModel, CloudFallbackModel]`, so `koala/phi4-mini` is first — and
+ 400s on an hour of speech. `TAPIR_UPLOAD_CHAT_MODEL` (default `iguana/gemma4-26b`) is what
+ the upload chat opens with; the switcher still lists every model.
+3. **A per-model budget does not exist yet.** Someone who switches the dropdown to
+ `phi4-mini` on a long recording gets a loud HTTP 400, shown inline as "couldn't answer".
+ That fails honestly but unhelpfully. Filed as a follow-up.
+4. **The "retrieval chat" trigger in ADR-033 is now concrete:** any recording past 90,000 chars
+ (≈100 min) cannot be answered by stuffing, for any offered model.
+
+## Method
+
+`POST /v1/chat/completions` through `http://koala:30401/v1`, `temperature: 0`,
+`max_tokens: 200` (production sends 1500 for chat via the summary token cap), the same system
+framing as `chat.Service`. The transcript is the 60-minute synthetic fixture from
+`audio-length-ceiling-2026-10-01.md` truncated/repeated to N chars, with one unique sentence
+planted either at the **end** ("lagret på Kvarngatan ska tömmas … av Pelle", question: who/when)
+or at the **head** ("kvällens ordförande är Ingrid", question: who chairs). Needle at the
+end alone would have missed the gemma failure — a serving layer that truncates from the front
+keeps the tail, so the end-needle passed at 120,000 chars; only the head-needle exposed it.
+Grounded = the answer contains the planted fact.
+
+## What this did not measure
+
+- **Real speech**: the fixture is a repeated 82-second script, so retrieval "difficulty" is
+ artificially easy except for the planted needle. One question per run, one run per cell.
+- **`berget/mistral-small`'s failure**: it missed an end-needle at 18,000 chars where the
+ others succeeded. One run; could be the model, the prompt framing, or position bias. Not
+ investigated; do not read it as a ranking.
+- **Latency under load**, **concurrent** chat while whisper is transcribing, and in particular
+ the hazard below.
+- **`koala/qwen36-35b-a3b` or any frontier model** — not in the offered list.
+
+## A hazard this surfaces and does not solve
+
+`iguana/gemma4-26b` is a large ollama model on the same host as `iguana/kb-whisper`. infra#273
+is precisely that a large resident ollama model can Metal-OOM whisper-server permanently. Making
+gemma the default *chat* model for uploads means chatting with a transcript can load the model
+that wedges the transcriber. The primary-fails-over-to-berget design limits the blast radius but
+does not remove it. Recorded in ADR-033 and filed.
--
2.54.0
From 77fe6954e543c3b397e82ae36e18a1bf76e35eee Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:08:28 +0200
Subject: [PATCH 052/299] docs(adr): ADR-033 decision 8 -- chat budget and
model from measurement
The proposed 120,000-char budget was a guess; measured, gemma4-26b silently
loses the head at that size and phi4-mini (first in the offered list) 400s on
an hour of speech. Budget is now 90,000, uploads open on gemma4-26b, the
retrieval trigger is concrete, and the iguana contention hazard is recorded.
ADR-033 is still Proposed, so this is an in-place correction, not a supersession.
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 44 +++++++++++++++++++++++++++++++-------------
1 file changed, 31 insertions(+), 13 deletions(-)
diff --git a/DECISIONS.md b/DECISIONS.md
index 12dc6a9..a0710cf 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1353,11 +1353,13 @@ would leak across tenants **and pass CI green**.
`berget/kb-whisper` returns sub-second times and a different `language` token.
- **The existing chat path truncates at 18,000 chars (~22 min of speech), keeping the head.**
#33 assumed chat "retrieves rather than generates" and so has no length ceiling. It does
- not retrieve; it stuffs the transcript into the prompt and cuts the tail.
+ not retrieve; it stuffs the transcript into the prompt and cuts the tail. Measured against
+ the offered models: `phi4-mini` 400s at ~13k tokens; `gemma4-26b` is right to 90,000 chars and
+ silently drops the head at 120,000.
**Unknowns, stated.** Real-speech accuracy; compressed-input behaviour; concurrent long jobs on
-iguana; whether the models chat offers can hold an hour of Swedish (~50 k chars); and the Metal
-OOM, deliberately not provoked.
+iguana; and the Metal OOM, deliberately not provoked. (Whether the offered chat models can hold
+an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
**Decision.**
@@ -1436,13 +1438,21 @@ OOM, deliberately not provoked.
8. **Chat reuses `chat.Service`; it is not forked.** The uploaded-transcript chat handlers
look the row up through the RLS-scoped store, so a non-owner gets 404 by the same mechanism
- as ADR-027's summary gate. ADR-027's decisions (ephemeral, switchable models, trust-the-model)
- carry over unchanged. **One thing must differ:** the 18,000-char default cuts a one-hour
- meeting to its first third, so uploaded transcripts take their own cap,
- `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS`. **Provisional default 120,000 chars (~2.4 h at
- measured density)**, with the existing `Truncated` flag surfaced honestly. The default is a
- guess until the chat slice measures what the offered models actually accept; it is not
- evidence that they can.
+ as ADR-027's summary gate, **before any model is called**. ADR-027's decisions (ephemeral,
+ switchable models, trust-the-model) carry over unchanged. **Two things must differ, and both
+ were measured** (`docs/research/chat-context-over-long-transcripts-2026-10-01.md`):
+ - **The transcript budget.** The 18,000-char caption default cuts a one-hour meeting to its
+ first third. Uploaded transcripts take their own `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS`,
+ default **90,000** — the measured ceiling of the best offered model (`iguana/gemma4-26b`),
+ which answers correctly there but **silently loses the head of a 120,000-char transcript**
+ with no error and no truncation flag. (An earlier draft of this ADR proposed 120,000 as a
+ guess; the measurement refuted it.) The mechanism is a per-request `chat.Request.MaxChars`.
+ - **The opening model.** The offered list leads with the summarizer default `koala/phi4-mini`,
+ whose 8,192-token context returns HTTP 400 on an hour of speech (~13,000 tokens). Chat over
+ an upload opens on `TAPIR_UPLOAD_CHAT_MODEL` (default `iguana/gemma4-26b`); the switcher
+ still lists every model, and picking `phi4-mini` on a long recording fails loudly and
+ inline. A per-model budget is not built.
+ Past ~90,000 chars (≈100 minutes) no offered model can answer by stuffing; see triggers.
9. **Not decided here.** Rich `AVSNITT`/`ALTERNATIV` analysis (gated on #30 — `analyze_srt.py`
hardcodes `max_tokens: 6000`); video, keyframes, synced playback (#28); language detection;
@@ -1465,7 +1475,13 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
dependency that matters) — a Metal OOM now means *every upload fails over to berget*, i.e.
private audio leaving the homelab, silently unless the per-row endpoint is surfaced.
- A 90-minute job holds the single slot for ~7.5 minutes; a second user's upload queues behind it.
- - Chat over a long transcript is bounded by model context, not by anything in Tapir.
+ - Chat over a long transcript is bounded by model context, not by anything in Tapir, and the
+ failure at the edge is *silent* for gemma (the head is dropped, no error). The 90,000 default
+ sits just inside the measured limit and is only as good as that single measurement.
+ - **The default chat model for uploads is a large ollama model on iguana — the same host as
+ `iguana/kb-whisper`.** infra#273 is exactly that a large resident ollama model can Metal-OOM
+ whisper-server permanently. Chatting with a transcript can therefore load the model that
+ wedges the transcriber. Not solved here; the berget fallback limits the blast radius.
- Tapir's own ingress body limit for a 200 MB upload is **unverified** (it lives in `infra`).
**Validation / resolution triggers** (each observable by someone other than the author):
@@ -1473,8 +1489,10 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
request at the current cap fails with whisper-side (not ingress) errors.
- *Berget-first*: if more than a quarter of uploads in any week are served by the fallback,
swap the primary/fallback order and revisit infra#273.
-- *Retrieval chat*: if the chat slice finds an offered model cannot answer a question whose
- answer is in the last third of a 60-minute transcript, chunked retrieval replaces the larger cap.
+- *Retrieval chat*: build chunked retrieval when a recording's transcript exceeds the measured
+ 90,000-char ceiling (≈100 minutes) — i.e. the moment the duration cap is raised above that, or a
+ real recording turns out denser than the fixture — or when the offered model list changes and
+ the ceiling is re-measured lower.
- *Worker*: if a transcription job ever degrades the reading UI, or Tapir runs more than one
replica, ADR-018 is revisited.
- *The decision itself*: if the maintainer has not used the feature unprompted within the
--
2.54.0
From 1c726a810e68507c00dbfcab7da086a2d0d4a37e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:08:33 +0200
Subject: [PATCH 053/299] docs(homelab): upload chat budget is 90000 and the
chat model is gemma4-26b
Co-Authored-By: Claude Sonnet 5.5
---
docs/homelab-integration.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md
index 5104020..3a003b9 100644
--- a/docs/homelab-integration.md
+++ b/docs/homelab-integration.md
@@ -24,7 +24,9 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
`TAPIR_UPLOADS_ENABLED` (off by default), `TAPIR_UPLOAD_MAX_MB` (200), `TAPIR_UPLOAD_MAX_MINUTES`
(90), `TAPIR_TRANSCRIBE_MODEL`, `TAPIR_TRANSCRIBE_FALLBACK_MODEL` (empty disables the cloud
fallback), `TAPIR_TRANSCRIBE_TIMEOUT` (30m), `TAPIR_UPLOAD_TMP_DIR`, `TAPIR_FFMPEG_BIN`,
- `TAPIR_FFPROBE_BIN`, `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` (120000). Needs `ffmpeg` + `ffprobe`
+ `TAPIR_FFPROBE_BIN`, `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` (90000 — the measured ceiling of
+ `iguana/gemma4-26b`, which silently drops the head above it), `TAPIR_UPLOAD_CHAT_MODEL`
+ (`iguana/gemma4-26b`; `koala/phi4-mini`'s 8,192-token context 400s on an hour of speech). Needs `ffmpeg` + `ffprobe`
in the runtime image; Tapir's own ingress body limit for a 200 MB upload is unverified (infra).
- **Auth key:** treat as a secret via the SecretStore port; do not hardcode in committed code.
NOTE: the documented `sk-local-123` returned `401` on `30401/v1/models` (2026-06-02) — it is
--
2.54.0
From 4a0c51d877e0b9f75fa566249f2b142898e576f8 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:13:54 +0200
Subject: [PATCH 054/299] test(audio): attacker-supplied input must not steer
ffmpeg to other files or URLs (red)
A file's CONTENT can tell ffmpeg to open other paths or URLs (HLS, concat
playlists). ffmpeg 7.0.2 refuses the ones tried, but that is version luck and
the image's ffmpeg is still to be chosen (#34). Pin protocol_whitelist to
file,pipe and format_whitelist to the demuxers real audio uses, before the input.
Red: audio.AllowedDemuxers is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/ffmpeg_test.go | 19 +++++++++
internal/adapters/audio/ffprobe_test.go | 57 +++++++++++++++++++++++++
2 files changed, 76 insertions(+)
diff --git a/internal/adapters/audio/ffmpeg_test.go b/internal/adapters/audio/ffmpeg_test.go
index 4442c80..9a4a852 100644
--- a/internal/adapters/audio/ffmpeg_test.go
+++ b/internal/adapters/audio/ffmpeg_test.go
@@ -58,3 +58,22 @@ func TestFFmpegToWAV_MissingBinaryIsAnError(t *testing.T) {
require.Error(t, err)
assert.False(t, errors.Is(err, context.Canceled))
}
+
+func TestFFmpegToWAV_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) {
+ rec := filepath.Join(t.TempDir(), "argv")
+ bin := stub(t, `printf '%s\n' "$@" > `+rec+`
+for a; do last="$a"; done
+printf data > "$last"`)
+
+ err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "o.wav"))
+
+ require.NoError(t, err)
+ args := argvOf(t, rec)
+ pw, fw, in := indexOf(args, "-protocol_whitelist"), indexOf(args, "-format_whitelist"), indexOf(args, "-i")
+ require.GreaterOrEqual(t, pw, 0, "argv: %v", args)
+ require.GreaterOrEqual(t, fw, 0, "argv: %v", args)
+ assert.Equal(t, "file,pipe", args[pw+1])
+ assert.Equal(t, audio.AllowedDemuxers, args[fw+1])
+ assert.Less(t, pw, in, "input options must come BEFORE -i or ffmpeg applies them to the output")
+ assert.Less(t, fw, in)
+}
diff --git a/internal/adapters/audio/ffprobe_test.go b/internal/adapters/audio/ffprobe_test.go
index 609a84c..214320b 100644
--- a/internal/adapters/audio/ffprobe_test.go
+++ b/internal/adapters/audio/ffprobe_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -127,3 +128,59 @@ func TestFFprobeProbe_StopsWhenTheContextIsCancelled(t *testing.T) {
assert.Less(t, time.Since(start), 5*time.Second)
assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a timeout is not a verdict on the file")
}
+
+// argvStub records the arguments it was called with into a file and then behaves as
+// a successful ffprobe, so a test can assert exactly what the process was asked.
+func argvStub(t *testing.T) (bin, argvFile string) {
+ t.Helper()
+ argvFile = filepath.Join(t.TempDir(), "argv")
+ bin = stub(t, `printf '%s\n' "$@" > `+argvFile+`
+echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`)
+ return bin, argvFile
+}
+
+func argvOf(t *testing.T, file string) []string {
+ t.Helper()
+ raw, err := os.ReadFile(file)
+ require.NoError(t, err)
+ return strings.Split(strings.TrimSpace(string(raw)), "\n")
+}
+
+func indexOf(args []string, want string) int {
+ for i, a := range args {
+ if a == want {
+ return i
+ }
+ }
+ return -1
+}
+
+func TestFFprobeProbe_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) {
+ // The input is attacker-supplied. ffmpeg can be told by a file's CONTENT to open
+ // other files or URLs (HLS and concat playlists); recent versions refuse by
+ // default, but that is version luck. Pin it: local files and pipes only, and
+ // only the demuxers real audio recordings use.
+ bin, argvFile := argvStub(t)
+
+ _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1")
+
+ require.NoError(t, err)
+ args := argvOf(t, argvFile)
+ pw := indexOf(args, "-protocol_whitelist")
+ require.GreaterOrEqual(t, pw, 0, "argv: %v", args)
+ assert.Equal(t, "file,pipe", args[pw+1])
+ fw := indexOf(args, "-format_whitelist")
+ require.GreaterOrEqual(t, fw, 0, "argv: %v", args)
+ assert.Equal(t, audio.AllowedDemuxers, args[fw+1])
+ assert.Equal(t, "/spool/upload-1", args[len(args)-1], "the path stays the last argument")
+}
+
+func TestAllowedDemuxers_ExcludeThePlaylistFormats(t *testing.T) {
+ for _, bad := range []string{"concat", "hls", "applehttp", "dash", "rtsp", "sdp", "image2"} {
+ assert.NotContains(t, strings.Split(audio.AllowedDemuxers, ","), bad,
+ "%s can make ffmpeg open other files or URLs named inside the upload", bad)
+ }
+ for _, good := range []string{"wav", "mov", "mp4", "m4a", "mp3", "ogg", "matroska", "flac"} {
+ assert.Contains(t, strings.Split(audio.AllowedDemuxers, ","), good)
+ }
+}
--
2.54.0
From 291a360a83331bbe470bd86d5f1a815a1e38752e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:14:29 +0200
Subject: [PATCH 055/299] feat(audio): restrict ffmpeg/ffprobe input to local
files and audio demuxers
The upload is attacker-supplied and its CONTENT can steer ffmpeg to open other
files or URLs (HLS/concat playlists). Pins -protocol_whitelist file,pipe and
-format_whitelist to the containers real recordings use, before the input
(after -i it would apply to the output).
Verified with the real static ffmpeg 7.0.2 on koala: real AAC .m4a and WAV
still probe/convert; a crafted concat file is rejected with 'Format not on
whitelist'. Note ffmpeg 7.0.2 ALREADY refused the playlists tried without the
whitelist, so the live check cannot prove the whitelist -- the argv unit tests
do; this protects against a different ffmpeg version being put in the image.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/ffmpeg.go | 6 ++++--
internal/adapters/audio/ffprobe.go | 18 ++++++++++++++++--
2 files changed, 20 insertions(+), 4 deletions(-)
diff --git a/internal/adapters/audio/ffmpeg.go b/internal/adapters/audio/ffmpeg.go
index ee04971..609cd50 100644
--- a/internal/adapters/audio/ffmpeg.go
+++ b/internal/adapters/audio/ffmpeg.go
@@ -27,8 +27,10 @@ func (f *FFmpeg) ToWAV(ctx context.Context, src, dst string) error {
if bin == "" {
bin = "ffmpeg"
}
- cmd := exec.CommandContext(ctx, bin,
- "-y", "-v", "error", "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", dst)
+ // Input restrictions go BEFORE -i: placed after it they would apply to the output.
+ args := append([]string{"-y", "-v", "error"}, inputRestrictions...)
+ args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", dst)
+ cmd := exec.CommandContext(ctx, bin, args...)
cmd.WaitDelay = killGrace
if out, err := cmd.CombinedOutput(); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
diff --git a/internal/adapters/audio/ffprobe.go b/internal/adapters/audio/ffprobe.go
index d4d27b6..5b75b08 100644
--- a/internal/adapters/audio/ffprobe.go
+++ b/internal/adapters/audio/ffprobe.go
@@ -21,6 +21,19 @@ type FFprobe struct {
Bin string
}
+// AllowedDemuxers are the input formats an upload may be. The input is
+// attacker-supplied, and a file's CONTENT can tell ffmpeg to open other files or
+// URLs (HLS and concat playlists, image2 sequences, network demuxers). Recent
+// ffmpeg refuses the ones tried (7.0.2: HLS without a .m3u8 name is not sniffed,
+// concat rejects unsafe paths), but that is version luck; this list is not. It
+// holds the containers real recordings use and none of the playlist or network
+// formats. "mov" covers .mp4/.m4a/.3gp; "matroska" covers .webm.
+const AllowedDemuxers = "wav,mov,mp4,m4a,3gp,mp3,ogg,matroska,webm,flac,aac,opus,aiff,amr,asf"
+
+// inputRestrictions are ffmpeg/ffprobe INPUT options and must precede the input.
+// Local files and pipes only: no network, no other protocols.
+var inputRestrictions = []string{"-protocol_whitelist", "file,pipe", "-format_whitelist", AllowedDemuxers}
+
// killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close.
const killGrace = 2 * time.Second
@@ -34,8 +47,9 @@ func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error)
if bin == "" {
bin = "ffprobe"
}
- cmd := exec.CommandContext(ctx, bin,
- "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path)
+ args := append([]string{"-v", "error"}, inputRestrictions...)
+ args = append(args, "-print_format", "json", "-show_format", "-show_streams", path)
+ cmd := exec.CommandContext(ctx, bin, args...)
cmd.WaitDelay = killGrace
out, err := cmd.Output()
if err != nil {
--
2.54.0
From 6ebb4e49e9bc4d2b38b1c27fe63bdef54b0be0ac Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 17:14:29 +0200
Subject: [PATCH 056/299] test(audio): opt-in live ffprobe check on hostile
playlists and real audio
Skipped unless TAPIR_LIVE_FFPROBE is set. Synthetic/crafted files only.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/hostile_live_test.go | 32 ++++++++++++++++++++
1 file changed, 32 insertions(+)
create mode 100644 internal/adapters/audio/hostile_live_test.go
diff --git a/internal/adapters/audio/hostile_live_test.go b/internal/adapters/audio/hostile_live_test.go
new file mode 100644
index 0000000..35a0958
--- /dev/null
+++ b/internal/adapters/audio/hostile_live_test.go
@@ -0,0 +1,32 @@
+package audio_test
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/audio"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+// TestFFprobeLive_HostilePlaylistsAreRefusedAndRealAudioIsNot runs the REAL ffprobe
+// (opt-in: TAPIR_LIVE_FFPROBE) on crafted concat/HLS files and on a real m4a.
+func TestFFprobeLive_HostilePlaylistsAreRefusedAndRealAudioIsNot(t *testing.T) {
+ bin := os.Getenv("TAPIR_LIVE_FFPROBE")
+ if bin == "" {
+ t.Skip("set TAPIR_LIVE_FFPROBE (and TAPIR_LIVE_HOSTILE_DIR, TAPIR_LIVE_M4A) to run against the real ffprobe")
+ }
+ p := &audio.FFprobe{Bin: bin}
+ for _, name := range []string{"up-concat", "up-hls"} {
+ _, err := p.Probe(context.Background(), os.Getenv("TAPIR_LIVE_HOSTILE_DIR")+"/"+name)
+ assert.ErrorIs(t, err, uploads.ErrUnreadable, name)
+ }
+ got, err := p.Probe(context.Background(), os.Getenv("TAPIR_LIVE_M4A"))
+ require.NoError(t, err)
+ assert.True(t, got.HasAudio)
+ assert.False(t, got.HasVideo)
+ assert.InDelta(t, 82.5, got.Duration.Seconds(), 1)
+}
--
2.54.0
From 1e192e53ef4905b9dd941b0eaba77ce6f3ac15c4 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:41:49 +0200
Subject: [PATCH 057/299] docs(adr): ADR-033 accepts files with a video track,
uses the audio, says so
The first draft rejected video and named #28. The maintainer's first real
recording -- a Teams meeting with the camera off -- is still an .mp4 with a
video track, so that rejected the primary use case. Decision 1 and 4 now:
accept, extract audio only, discard the video, record had_video, and tell the
user. A file with no audio stream is still rejected. Video features stay #28.
In-place amendment: the ADR is still Proposed.
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 36 +++++++++++++++++++++++++-----------
1 file changed, 25 insertions(+), 11 deletions(-)
diff --git a/DECISIONS.md b/DECISIONS.md
index a0710cf..3aafc8c 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1363,11 +1363,16 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
**Decision.**
-1. **Audio only; transcript is the only thing kept.** An authenticated user uploads an audio
- file; Tapir transcribes it and stores the transcript. The audio itself is deleted after
- transcription (temp file, removed in a `defer`, swept at start-up) — no custody problem to
- solve, and nothing to hand to #31. Video is rejected with a message naming #28. YouTube
- audio-download stays rejected. No public/anonymous upload (ADR-008).
+1. **Audio is what is used; the transcript is the only thing kept.** An authenticated user
+ uploads a recording; Tapir transcribes its audio and stores the transcript. The audio itself
+ is deleted after transcription (temp file, removed in a `defer`, swept at start-up) — no
+ custody problem to solve, and nothing to hand to #31. **A file that also carries a video
+ track is accepted: its audio is extracted, the video is discarded, and the user is told so**
+ (see 4). *Amended 2026-10-01, the same day this ADR was drafted: the first draft rejected
+ video and named #28. The maintainer's first real recording — a Teams meeting with the camera
+ off — is still an `.mp4` with a video track, so rejecting video rejected the primary use
+ case.* This keeps no video and analyses none: playback, keyframes and what-is-shown analysis
+ remain #28. YouTube audio-download stays rejected. No public/anonymous upload (ADR-008).
2. **Private store: a separate RLS-scoped table, `uploaded_transcripts`** (resolves #28's
decision 1). `user_id NOT NULL REFERENCES users ON DELETE CASCADE`, `ENABLE` + `FORCE ROW LEVEL
@@ -1387,14 +1392,20 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
asserted exactly** — so a new table added without RLS fails CI instead of passing it. (c) is
new; the existing test only checks the named tables.
-4. **Validate by probing, reject clearly.** Format and duration cannot be read from a filename
- or MIME type — a Teams "voice-only" recording is typically an `.mp4` container with no video
- stream, which is indistinguishable from video by extension. The upload is probed
- (`ffprobe`): accept any container with an audio stream and **no** video stream; reject video
- with "video is #28"; reject unreadable files. Limits, all configurable, all rejected
+4. **Validate by probing, reject clearly, and say what was ignored.** Format, duration and
+ what a file contains cannot be read from a filename or MIME type — a Teams recording is an
+ `.mp4` whether or not the camera was on. The upload is probed (`ffprobe`): **accept any
+ container with an audio stream, whether or not it also has a video stream** (an mp3's cover
+ art counts as neither). Whether a real video track was present is recorded on the row
+ (`had_video`) and the transcript page says so plainly — *this file contained a video track;
+ only its audio was used and the video was discarded* — so the user is never left to wonder
+ what happened to the picture. Reject a file with **no** audio stream (including a screen
+ recording with no microphone) and an unreadable one. Limits, all configurable, all rejected
synchronously with a specific message rather than by timeout: **200 MB** upload and
**90 minutes** duration. 90 minutes is *the longest length measured*, not a ceiling that was
- found; raising it is a measurement, not an edit.
+ found; raising it is a measurement, not an edit. The size cap now matters more: a video file
+ is much larger than its audio and is spooled whole before the audio is extracted, so a long
+ recording with screen sharing can exceed 200 MB while its audio is a few tens of MB.
5. **Transcode server-side, always, to 16 kHz mono PCM WAV** — even when the input already is,
rather than assuming. whisper-server's `--convert` is rejected (its `--tmp-dir` is created at
@@ -1483,6 +1494,9 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
whisper-server permanently. Chatting with a transcript can therefore load the model that
wedges the transcriber. Not solved here; the berget fallback limits the blast radius.
- Tapir's own ingress body limit for a 200 MB upload is **unverified** (it lives in `infra`).
+ - Accepting video means large files are spooled before their audio is extracted, and ffmpeg
+ demuxes (but, with `-vn`, does not decode) video it then throws away. A client-side audio
+ extraction would avoid the upload cost; it is not built.
**Validation / resolution triggers** (each observable by someone other than the author):
- *Chunking*: build it when the duration cap is raised above 90 min, or when a measured
--
2.54.0
From 6cbb7b2df055a18ea9d9a2b4996ededfc5774c5f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:42:04 +0200
Subject: [PATCH 058/299] test(uploads): a recording with a video track is
accepted (red)
A Teams meeting with the camera off is still an .mp4 with a video track.
Video with no audio is now rejected as having no audio.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/validate_test.go | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/internal/uploads/validate_test.go b/internal/uploads/validate_test.go
index b8dc4c6..c5dcd78 100644
--- a/internal/uploads/validate_test.go
+++ b/internal/uploads/validate_test.go
@@ -29,16 +29,14 @@ func TestValidate(t *testing.T) {
probe: uploads.Probe{Duration: 90 * time.Minute, HasAudio: true},
},
{
- name: "a file with a video stream is rejected and names issue 28",
- probe: uploads.Probe{Duration: 4 * time.Minute, HasAudio: true, HasVideo: true},
- wantReason: uploads.ReasonVideo,
- wantInMsg: []string{"video", "#28"},
+ name: "a recording that also has a video stream is accepted: the audio is what is used",
+ probe: uploads.Probe{Duration: 4 * time.Minute, HasAudio: true, HasVideo: true},
},
{
- name: "video with no audio is still rejected as video, not as missing audio",
+ name: "video with no audio is rejected as having no audio, and says so",
probe: uploads.Probe{Duration: 4 * time.Minute, HasVideo: true},
- wantReason: uploads.ReasonVideo,
- wantInMsg: []string{"#28"},
+ wantReason: uploads.ReasonNoAudio,
+ wantInMsg: []string{"no audio"},
},
{
name: "a file with no audio stream is rejected",
--
2.54.0
From ab62cdaf1cce0bbd799bc4b214301d32f6dcdd1a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:42:13 +0200
Subject: [PATCH 059/299] test(uploads): Accept records whether the file had a
video track (red)
Red: Store.CreateUpload does not take hadVideo yet.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/intake_test.go | 37 ++++++++++++++++++++++++++++-----
1 file changed, 32 insertions(+), 5 deletions(-)
diff --git a/internal/uploads/intake_test.go b/internal/uploads/intake_test.go
index 0fcafc1..fb2e39c 100644
--- a/internal/uploads/intake_test.go
+++ b/internal/uploads/intake_test.go
@@ -20,13 +20,15 @@ import (
)
type fakeStore struct {
- created []string // titles
- failed map[string]domain.UploadErrorKind
- nextID string
+ created []string // titles
+ hadVideo []bool
+ failed map[string]domain.UploadErrorKind
+ nextID string
}
-func (f *fakeStore) CreateUpload(_ context.Context, _, title string) (string, error) {
+func (f *fakeStore) CreateUpload(_ context.Context, _, title string, hadVideo bool) (string, error) {
f.created = append(f.created, title)
+ f.hadVideo = append(f.hadVideo, hadVideo)
return f.nextID, nil
}
@@ -140,7 +142,7 @@ func TestAccept_ProbeVerdictRejectsWithoutRowAndRemovesTheFile(t *testing.T) {
probe uploads.Probe
reason uploads.Reason
}{
- {"video", uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true}, uploads.ReasonVideo},
+ {"video with no audio", uploads.Probe{Duration: time.Minute, HasVideo: true}, uploads.ReasonNoAudio},
{"too long", uploads.Probe{Duration: 2 * time.Hour, HasAudio: true}, uploads.ReasonTooLong},
{"no audio", uploads.Probe{Duration: time.Minute}, uploads.ReasonNoAudio},
}
@@ -210,3 +212,28 @@ func TestAccept_SubmitFailureFailsTheRowHonestlyAndRemovesTheFile(t *testing.T)
"a row that will never run must not sit queued: that is the stuck spinner ADR-025 forbids")
assert.Empty(t, r.tmpFiles(t))
}
+
+func TestAccept_AFileWithAVideoTrackIsAcceptedAndRecordsThatItHadOne(t *testing.T) {
+ tests := []struct {
+ name string
+ probe uploads.Probe
+ wantVideo bool
+ }{
+ {"audio only", uploads.Probe{Duration: 10 * time.Minute, HasAudio: true}, false},
+ {"audio with a video track (a Teams meeting with the camera off)", uploads.Probe{Duration: 10 * time.Minute, HasAudio: true, HasVideo: true}, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := newRig(t)
+ r.pr.probe = tt.probe
+
+ id, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
+
+ require.NoError(t, err)
+ assert.Equal(t, "up-1", id)
+ assert.Equal(t, []bool{tt.wantVideo}, r.st.hadVideo,
+ "the row must say whether a video track was present, so the page can say it was ignored")
+ assert.Len(t, r.sub.jobs, 1, "it is queued like any other recording")
+ })
+ }
+}
--
2.54.0
From eb55c7d9d18b3b0e1332624385e88fa0fb8a40c0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:42:13 +0200
Subject: [PATCH 060/299] test(store): had_video is recorded on creation and
survives done/failed (red)
Red: CreateUpload takes no hadVideo and the row has no HadVideo.
Co-Authored-By: Claude Sonnet 5.5
---
.../store/uploaded_transcripts_test.go | 66 +++++++++++++++----
1 file changed, 55 insertions(+), 11 deletions(-)
diff --git a/internal/adapters/store/uploaded_transcripts_test.go b/internal/adapters/store/uploaded_transcripts_test.go
index 38d3726..a313468 100644
--- a/internal/adapters/store/uploaded_transcripts_test.go
+++ b/internal/adapters/store/uploaded_transcripts_test.go
@@ -33,7 +33,7 @@ func TestCreateUpload_StartsQueuedAndIsOwnedByTheCreator(t *testing.T) {
seedUsers(t, userA)
ctx := context.Background()
- id, err := s.CreateUpload(ctx, userA, "Teams export")
+ id, err := s.CreateUpload(ctx, userA, "Teams export", false)
require.NoError(t, err)
got, err := s.GetUpload(ctx, userA, id)
@@ -50,7 +50,7 @@ func TestUploadLifecycle_CompletedKeepsTextSegmentsEndpointAndDuration(t *testin
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
- id, err := s.CreateUpload(ctx, userA, "m")
+ id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
@@ -84,7 +84,7 @@ func TestUploadLifecycle_FailedRecordsTypedKindAndNoContent(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
- id, err := s.CreateUpload(ctx, userA, "m")
+ id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
@@ -102,7 +102,7 @@ func TestGetUpload_AnotherUsersIDIsNotFound(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
- id, err := s.CreateUpload(ctx, userA, "A's meeting")
+ id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
require.NoError(t, err)
_, err = s.GetUpload(ctx, userB, id)
@@ -115,7 +115,7 @@ func TestUploadWrites_ByAnotherUserChangeNothing(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
- id, err := s.CreateUpload(ctx, userA, "A's meeting")
+ id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
require.NoError(t, err)
assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound)
@@ -132,11 +132,11 @@ func TestListUploads_ReturnsOnlyTheCallersNewestFirstWithoutTranscriptText(t *te
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
- first, err := s.CreateUpload(ctx, userA, "first")
+ first, err := s.CreateUpload(ctx, userA, "first", false)
require.NoError(t, err)
- second, err := s.CreateUpload(ctx, userA, "second")
+ second, err := s.CreateUpload(ctx, userA, "second", false)
require.NoError(t, err)
- _, err = s.CreateUpload(ctx, userB, "B's")
+ _, err = s.CreateUpload(ctx, userB, "B's", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, first))
require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"}))
@@ -160,12 +160,12 @@ func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T
_, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid)
require.NoError(t, err)
}
- queued, err := s.CreateUpload(ctx, userA, "queued")
+ queued, err := s.CreateUpload(ctx, userA, "queued", false)
require.NoError(t, err)
- running, err := s.CreateUpload(ctx, userB, "running")
+ running, err := s.CreateUpload(ctx, userB, "running", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userB, running))
- done, err := s.CreateUpload(ctx, userA, "done")
+ done, err := s.CreateUpload(ctx, userA, "done", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, done))
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"}))
@@ -186,3 +186,47 @@ func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T
assert.Equal(t, domain.UploadDone, kept.Status, "a finished transcript must survive the sweep")
assert.Equal(t, "kept", kept.Content)
}
+
+func TestCreateUpload_RecordsWhetherTheFileHadAVideoTrack(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA)
+ ctx := context.Background()
+ withVideo, err := s.CreateUpload(ctx, userA, "camera off", true)
+ require.NoError(t, err)
+ audioOnly, err := s.CreateUpload(ctx, userA, "voice only", false)
+ require.NoError(t, err)
+
+ gotVideo, err := s.GetUpload(ctx, userA, withVideo)
+ require.NoError(t, err)
+ gotAudio, err := s.GetUpload(ctx, userA, audioOnly)
+ require.NoError(t, err)
+ listed, err := s.ListUploads(ctx, userA)
+ require.NoError(t, err)
+
+ assert.True(t, gotVideo.HadVideo, "the page must be able to say the video was ignored")
+ assert.False(t, gotAudio.HadVideo)
+ byID := map[string]bool{}
+ for _, u := range listed {
+ byID[u.ID] = u.HadVideo
+ }
+ assert.Equal(t, map[string]bool{withVideo: true, audioOnly: false}, byID, "the list carries it too")
+}
+
+func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA)
+ ctx := context.Background()
+ done, err := s.CreateUpload(ctx, userA, "d", true)
+ require.NoError(t, err)
+ failed, err := s.CreateUpload(ctx, userA, "f", true)
+ require.NoError(t, err)
+ require.NoError(t, s.StartTranscribing(ctx, userA, done))
+ require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "x", Endpoint: "e"}))
+ require.NoError(t, s.FailUpload(ctx, userA, failed, domain.UploadErrTranscriptionFailed))
+
+ for _, id := range []string{done, failed} {
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job")
+ }
+}
--
2.54.0
From 7aec2538be62c858b4923d0f973e0acbefae57cc Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:42:33 +0200
Subject: [PATCH 061/299] test(web): a video file is accepted and the user is
told the video was ignored (red)
Video with no audio is rejected for having none. A file with a video track is
accepted and marked had_video; every state of its page (queued, transcribing,
done, failed) says only its audio was used and the video was discarded, and a
plain audio file shows no such notice.
Red: the had_video column does not exist.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_handler_test.go | 67 ++++++++++++++++++++++++++--
1 file changed, 64 insertions(+), 3 deletions(-)
diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go
index c43e982..9b342a6 100644
--- a/internal/web/uploads_handler_test.go
+++ b/internal/web/uploads_handler_test.go
@@ -158,12 +158,12 @@ func TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow(t *testing.T) {
wantInBody []string
}{
{
- name: "video names issue 28",
- probe: uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true},
+ name: "video with no audio is rejected for having no audio",
+ probe: uploads.Probe{Duration: time.Minute, HasVideo: true},
maxBytes: 1 << 20,
content: "x",
wantStatus: http.StatusUnprocessableEntity,
- wantInBody: []string{"video", "#28"},
+ wantInBody: []string{"no audio"},
},
{
name: "too long states both lengths",
@@ -275,3 +275,64 @@ func TestUploadStatus_DoneNamesTheEndpointThatServedIt(t *testing.T) {
"which backend served a transcript is a quality fact, and berget means the audio left the homelab")
assert.True(t, strings.Contains(b, "45 min"), "the audio length is shown")
}
+
+func markHadVideo(t *testing.T, id string) {
+ t.Helper()
+ _, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET had_video = TRUE WHERE id = $1`, id)
+ require.NoError(t, err)
+}
+
+func TestUploadPost_AFileWithAVideoTrackIsAcceptedAndTheRowRemembersIt(t *testing.T) {
+ // A Teams meeting with the camera off is still an .mp4 with a video track.
+ app, sub := newUploadApp(t, uploads.Probe{Duration: 62 * time.Minute, HasAudio: true, HasVideo: true}, 200<<20)
+
+ rec := do(t, app, uploadReq(t, "Project notes", "Teams meeting.mp4", []byte("synthetic bytes")))
+
+ require.Equal(t, http.StatusSeeOther, rec.Code, "it must be accepted, not rejected as video")
+ var hadVideo bool
+ require.NoError(t, rawPool(t).QueryRow(context.Background(),
+ `SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
+ assert.True(t, hadVideo)
+ assert.Len(t, sub.jobs, 1)
+}
+
+func TestUploadPost_AudioOnlyIsNotMarkedAsHavingHadVideo(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+
+ do(t, app, uploadReq(t, "Voice memo", "memo.m4a", []byte("x")))
+
+ var hadVideo bool
+ require.NoError(t, rawPool(t).QueryRow(context.Background(),
+ `SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
+ assert.False(t, hadVideo)
+}
+
+func TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ states := []struct{ status, content string }{
+ {"queued", ""}, {"transcribing", ""}, {"done", "Synthetic transcript."}, {"failed", "interrupted"},
+ }
+ for _, st := range states {
+ t.Run(st.status, func(t *testing.T) {
+ withVideo := seedUpload(t, userID, "camera off "+st.status, st.status, st.content)
+ markHadVideo(t, withVideo)
+ audioOnly := seedUpload(t, userID, "voice only "+st.status, st.status, st.content)
+
+ notice := body(t, do(t, app, get("/uploads/"+withVideo+"/status")))
+ plain := body(t, do(t, app, get("/uploads/"+audioOnly+"/status")))
+
+ assert.Contains(t, notice, "video track")
+ assert.Contains(t, notice, "only its audio was used", "say what happened, not just that something did")
+ assert.Contains(t, notice, "discarded")
+ assert.NotContains(t, plain, "video track", "no notice when there was no video")
+ })
+ }
+}
+
+func TestUploadPage_AlsoCarriesTheVideoNotice(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "camera off", "done", "Synthetic transcript.")
+ markHadVideo(t, id)
+
+ assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "only its audio was used")
+}
--
2.54.0
From 1157854441e52a9877ffde6436a135cfd1bc5c77 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:42:33 +0200
Subject: [PATCH 062/299] test(cmd): fake store follows the CreateUpload
signature (red)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cmd/tapir/uploads_test.go b/cmd/tapir/uploads_test.go
index 7e95a7a..9a55b62 100644
--- a/cmd/tapir/uploads_test.go
+++ b/cmd/tapir/uploads_test.go
@@ -22,7 +22,7 @@ type fakeUploadsStore struct {
sweepErr error
}
-func (f *fakeUploadsStore) CreateUpload(context.Context, string, string) (string, error) {
+func (f *fakeUploadsStore) CreateUpload(context.Context, string, string, bool) (string, error) {
return "id", nil
}
--
2.54.0
From 3c4d2880f3af098f3a12b7247449ced9f2fef9ae Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:42:33 +0200
Subject: [PATCH 063/299] feat(store): migration 018 --
uploaded_transcripts.had_video
Up and down are one commit: golang-migrate pairs them. RLS is unchanged: the
column is on the already-FORCE'd table, so the exact-set isolation test still
holds.
Co-Authored-By: Claude Sonnet 5.5
---
.../018_uploaded_transcripts_had_video.down.sql | 1 +
.../migrations/018_uploaded_transcripts_had_video.up.sql | 9 +++++++++
2 files changed, 10 insertions(+)
create mode 100644 internal/adapters/store/migrations/018_uploaded_transcripts_had_video.down.sql
create mode 100644 internal/adapters/store/migrations/018_uploaded_transcripts_had_video.up.sql
diff --git a/internal/adapters/store/migrations/018_uploaded_transcripts_had_video.down.sql b/internal/adapters/store/migrations/018_uploaded_transcripts_had_video.down.sql
new file mode 100644
index 0000000..8e08fad
--- /dev/null
+++ b/internal/adapters/store/migrations/018_uploaded_transcripts_had_video.down.sql
@@ -0,0 +1 @@
+ALTER TABLE uploaded_transcripts DROP COLUMN had_video;
diff --git a/internal/adapters/store/migrations/018_uploaded_transcripts_had_video.up.sql b/internal/adapters/store/migrations/018_uploaded_transcripts_had_video.up.sql
new file mode 100644
index 0000000..6a560e3
--- /dev/null
+++ b/internal/adapters/store/migrations/018_uploaded_transcripts_had_video.up.sql
@@ -0,0 +1,9 @@
+-- Migration 018: record whether an uploaded file carried a video track (ADR-033,
+-- amended 2026-10-01).
+--
+-- A Teams meeting with the camera off is still an .mp4 with a video track, so video
+-- files are ACCEPTED: only the audio is extracted and the video is discarded. This
+-- flag lets the transcript page say so — "this file contained a video track; only its
+-- audio was used" — so the user is never left to wonder what happened to the picture.
+-- It outlives the job: completing or failing an upload does not clear it.
+ALTER TABLE uploaded_transcripts ADD COLUMN had_video BOOLEAN NOT NULL DEFAULT FALSE;
--
2.54.0
From 603d02e50429136a75e06cd908e3376ef6748469 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:42:40 +0200
Subject: [PATCH 064/299] feat(domain): UploadedTranscript.HadVideo
Co-Authored-By: Claude Sonnet 5.5
---
internal/domain/domain.go | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index 374678f..f570839 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -167,6 +167,10 @@ type UploadedTranscript struct {
Endpoint string
AudioSeconds float64
ErrorKind UploadErrorKind
- CreatedAt time.Time
- FinishedAt time.Time // zero until the job reaches done or failed
+ // HadVideo records that the uploaded file carried a video track. Only its audio
+ // was used and the video was discarded; the page says so. It is set at upload
+ // and never cleared (ADR-033).
+ HadVideo bool
+ CreatedAt time.Time
+ FinishedAt time.Time // zero until the job reaches done or failed
}
--
2.54.0
From 85cf89c86c6c0771694a656afa918a0b1013cfc5 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:42:50 +0200
Subject: [PATCH 065/299] feat(store): CreateUpload records had_video; Get/List
return it
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/store/uploaded_transcripts.go | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/internal/adapters/store/uploaded_transcripts.go b/internal/adapters/store/uploaded_transcripts.go
index 7252761..9e2cc81 100644
--- a/internal/adapters/store/uploaded_transcripts.go
+++ b/internal/adapters/store/uploaded_transcripts.go
@@ -25,12 +25,14 @@ var ErrUploadNotFound = errors.New("store: uploaded transcript not found")
// superuser connection the unit tests use.
// CreateUpload records a new upload in the queued state and returns its id.
-func (s *Store) CreateUpload(ctx context.Context, userID, title string) (string, error) {
+// hadVideo records that the file carried a video track (only its audio is used);
+// it is kept for the life of the row so the page can say so.
+func (s *Store) CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error) {
var id string
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
- `INSERT INTO uploaded_transcripts (user_id, title) VALUES ($1, $2) RETURNING id`,
- userID, title).Scan(&id)
+ `INSERT INTO uploaded_transcripts (user_id, title, had_video) VALUES ($1, $2, $3) RETURNING id`,
+ userID, title, hadVideo).Scan(&id)
}); err != nil {
return "", fmt.Errorf("store: create upload: %w", err)
}
@@ -155,7 +157,7 @@ func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
}
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
- audio_seconds, error_kind, created_at, finished_at FROM uploaded_transcripts`
+ audio_seconds, error_kind, had_video, created_at, finished_at FROM uploaded_transcripts`
// scanUpload scans one uploadSelect row. withText controls whether the transcript
// body and segments are kept (GetUpload) or dropped (ListUploads).
@@ -167,7 +169,7 @@ func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error
finished *time.Time
)
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
- &seconds, &errKind, &u.CreatedAt, &finished); err != nil {
+ &seconds, &errKind, &u.HadVideo, &u.CreatedAt, &finished); err != nil {
return err
}
u.Status = domain.UploadStatus(deref(status))
--
2.54.0
From 01eab1ab6f9cadab30ae4eef0de55288251a305e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:43:00 +0200
Subject: [PATCH 066/299] feat(uploads): a video track is no longer a reason to
reject (ADR-033)
Only no-audio, unreadable length and too-long are refused. ReasonVideo is gone.
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/validate.go | 15 +++++++--------
1 file changed, 7 insertions(+), 8 deletions(-)
diff --git a/internal/uploads/validate.go b/internal/uploads/validate.go
index 8dfea85..d2e4f49 100644
--- a/internal/uploads/validate.go
+++ b/internal/uploads/validate.go
@@ -18,8 +18,8 @@ type Limits struct {
}
// Probe is what inspecting an uploaded file revealed. It is the input to Validate
-// because a name or MIME type cannot say whether a container carries video: a
-// Teams voice-only export is typically an .mp4 with no video stream.
+// because a name or MIME type cannot say what a container holds: a Teams recording
+// is an .mp4 whether or not the camera was on.
type Probe struct {
Duration time.Duration
HasAudio bool
@@ -30,7 +30,6 @@ type Probe struct {
type Reason string
const (
- ReasonVideo Reason = "video"
ReasonNoAudio Reason = "no_audio"
ReasonTooLong Reason = "too_long"
ReasonTooLarge Reason = "too_large"
@@ -46,13 +45,13 @@ type RejectError struct {
func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message }
-// Validate decides whether a probed file may be transcribed. Video is checked
-// first so a video file is always refused as video, whatever its length.
+// Validate decides whether a probed file may be transcribed. A video track is NOT
+// a reason to refuse: a Teams meeting with the camera off is still an .mp4 with a
+// video track, so rejecting video would reject the primary use case. Only the audio
+// is used (Probe.HasVideo is recorded so the user can be told the video was
+// ignored). What IS refused: no audio at all, an unreadable length, too long.
func Validate(p Probe, l Limits) error {
switch {
- case p.HasVideo:
- return &RejectError{Reason: ReasonVideo, Message: "This file contains a video track. " +
- "Only audio recordings are supported for now; video upload is tracked in issue #28."}
case !p.HasAudio:
return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."}
case p.Duration <= 0:
--
2.54.0
From f102199097f5d587b4cf4b1feea7968f7f03b78b Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:43:00 +0200
Subject: [PATCH 067/299] feat(uploads): Intake passes whether the file had a
video track to the store
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/intake.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/internal/uploads/intake.go b/internal/uploads/intake.go
index d33cba5..56249b7 100644
--- a/internal/uploads/intake.go
+++ b/internal/uploads/intake.go
@@ -23,7 +23,7 @@ type Prober interface {
// Store is the slice of the private transcript store the intake needs.
type Store interface {
- CreateUpload(ctx context.Context, userID, title string) (string, error)
+ CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error)
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
}
@@ -99,7 +99,7 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
return "", err
}
- id, err := in.store.CreateUpload(ctx, userID, title)
+ id, err := in.store.CreateUpload(ctx, userID, title, probe.HasVideo)
if err != nil {
return "", fmt.Errorf("uploads: create: %w", err)
}
--
2.54.0
From 517549680920abb494f5ad38c496215fc774e6a5 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:43:14 +0200
Subject: [PATCH 068/299] test(web): match the notice's real casing
My assertion expected a lowercase 'only'; the sentence begins 'Only'. The user
facing wording is unchanged.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_handler_test.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go
index 9b342a6..1b9fdcd 100644
--- a/internal/web/uploads_handler_test.go
+++ b/internal/web/uploads_handler_test.go
@@ -322,7 +322,7 @@ func TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas
plain := body(t, do(t, app, get("/uploads/"+audioOnly+"/status")))
assert.Contains(t, notice, "video track")
- assert.Contains(t, notice, "only its audio was used", "say what happened, not just that something did")
+ assert.Contains(t, notice, "Only its audio was used", "say what happened, not just that something did")
assert.Contains(t, notice, "discarded")
assert.NotContains(t, plain, "video track", "no notice when there was no video")
})
@@ -334,5 +334,5 @@ func TestUploadPage_AlsoCarriesTheVideoNotice(t *testing.T) {
id := seedUpload(t, userID, "camera off", "done", "Synthetic transcript.")
markHadVideo(t, id)
- assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "only its audio was used")
+ assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "Only its audio was used")
}
--
2.54.0
From cca0a7461b67393148bd8e6d827f47e0da4af9f9 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:43:14 +0200
Subject: [PATCH 069/299] feat(web): tell the user when a video track was
ignored, in every state
A Teams meeting with the camera off is still a video file. It is accepted, only
its audio is transcribed, and the page says so in queued, transcribing, done and
failed -- not just at the end.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.templ | 15 +++++
internal/web/uploads_templ.go | 117 ++++++++++++++++++++++++----------
2 files changed, 100 insertions(+), 32 deletions(-)
diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ
index b084c10..f4bf11b 100644
--- a/internal/web/uploads.templ
+++ b/internal/web/uploads.templ
@@ -60,6 +60,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
switch u.Status {
case domain.UploadDone:
+ @videoNotice(u.HadVideo)
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by { u.Endpoint } .
@@ -74,6 +75,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
case domain.UploadFailed:
+ @videoNotice(u.HadVideo)
{ uploadFailureMessage(u.ErrorKind) }
default:
@@ -84,6 +86,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
hx-trigger={ "every " + uploadPollEvery }
hx-swap="outerHTML"
>
+ @videoNotice(u.HadVideo)
if u.Status == domain.UploadTranscribing {
Transcribing… a long recording takes a few minutes.
} else {
@@ -108,3 +111,15 @@ templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
}
}
+
+// videoNotice tells the user a video track was present and what became of it. A
+// Teams meeting with the camera off is still a video file; it is accepted, only the
+// audio is transcribed, and the picture is thrown away — so say so, in every state,
+// instead of leaving the user to wonder (ADR-033).
+templ videoNotice(hadVideo bool) {
+ if hadVideo {
+
+ This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.
+
+ }
+}
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go
index c848d97..396b7f2 100644
--- a/internal/web/uploads_templ.go
+++ b/internal/web/uploads_templ.go
@@ -5,10 +5,8 @@ package web
//lint:file-ignore SA4006 This context is only used if a nested component is present.
-import (
- "github.com/a-h/templ"
- templruntime "github.com/a-h/templ/runtime"
-)
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
@@ -268,133 +266,153 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
ctx = templ.ClearChildren(ctx)
switch u.Status {
case domain.UploadDone:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Transcribed ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = videoNotice(u.HadVideo).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Transcribed ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 64, Col: 95}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " of audio by ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " of audio by ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 48}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 66, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " .
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ".")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if chatEnabled {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Ask about this recording
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">Ask about this recording")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
Transcript ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Transcript ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 72, Col: 40}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 73, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.UploadFailed:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = videoNotice(u.HadVideo).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 77, Col: 42}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 79, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-swap=\"outerHTML\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = videoNotice(u.HadVideo).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if u.Status == domain.UploadTranscribing {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Transcribing… a long recording takes a few minutes.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
Transcribing… a long recording takes a few minutes.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
Queued — waiting for its turn to be transcribed.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
Queued — waiting for its turn to be transcribed.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -438,20 +456,20 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 101, Col: 16}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 104, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " Ask about this recording Answers come only from this recording's transcript, which is private to you.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " Ask about this recording Answers come only from this recording's transcript, which is private to you.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -459,20 +477,20 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Back to the transcript
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">Back to the transcript")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -486,4 +504,39 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
})
}
+// videoNotice tells the user a video track was present and what became of it. A
+// Teams meeting with the camera off is still a video file; it is accepted, only the
+// audio is transcribed, and the picture is thrown away — so say so, in every state,
+// instead of leaving the user to wonder (ADR-033).
+func videoNotice(hadVideo bool) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var24 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var24 == nil {
+ templ_7745c5c3_Var24 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if hadVideo {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
var _ = templruntime.GeneratedTemplate
--
2.54.0
From 1fa7ca3a8cf3a9447c50cf0247941d2a95ec58a1 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:43:48 +0200
Subject: [PATCH 070/299] test(e2e): a video file (camera-off Teams meeting) is
transcribed and the user is told
Run 2026-10-01 with 82 s of synthetic Swedish muxed with black video (H.264 +
AAC .mp4, built with the real ffmpeg): accepted, had_video=true, transcribed by
iguana/kb-whisper, the page said only its audio was used, and chat answered.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/e2e_upload_test.go | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/cmd/tapir/e2e_upload_test.go b/cmd/tapir/e2e_upload_test.go
index c4bf649..2459374 100644
--- a/cmd/tapir/e2e_upload_test.go
+++ b/cmd/tapir/e2e_upload_test.go
@@ -129,10 +129,16 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
var status, endpoint, content string
var secs float64
+ var hadVideo bool
require.NoError(t, raw.QueryRow(ctx,
- `SELECT status, endpoint, content, audio_seconds FROM uploaded_transcripts WHERE user_id = $1`, uid).
- Scan(&status, &endpoint, &content, &secs))
- t.Logf("done: endpoint=%s audio_s=%.1f transcript_chars=%d", endpoint, secs, len(content))
+ `SELECT status, endpoint, content, audio_seconds, had_video FROM uploaded_transcripts WHERE user_id = $1`, uid).
+ Scan(&status, &endpoint, &content, &secs, &hadVideo))
+ t.Logf("done: endpoint=%s audio_s=%.1f transcript_chars=%d had_video=%v", endpoint, secs, len(content), hadVideo)
+ if os.Getenv("TAPIR_E2E_HAD_VIDEO") == "1" {
+ // A Teams meeting with the camera off: a video file whose audio is what matters.
+ assert.True(t, hadVideo, "the file had a video track and the row must say so")
+ assert.Contains(t, page, "Only its audio was used", "and the user must be told")
+ }
assert.Equal(t, "done", status)
assert.NotEmpty(t, endpoint)
assert.NotEmpty(t, content)
--
2.54.0
From 364a28eecdd9e581c3af2070debba5983fbdb745 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:43:55 +0200
Subject: [PATCH 071/299] docs(use-cases): a video track is accepted and the
user is told; no-audio is rejected
Co-Authored-By: Claude Sonnet 5.5
---
docs/use-cases/audio_upload.feature | 23 +++++++++++++++--------
1 file changed, 15 insertions(+), 8 deletions(-)
diff --git a/docs/use-cases/audio_upload.feature b/docs/use-cases/audio_upload.feature
index 4665a37..ba31cda 100644
--- a/docs/use-cases/audio_upload.feature
+++ b/docs/use-cases/audio_upload.feature
@@ -7,7 +7,9 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
# PRIVATE content, so it lives in its own RLS-scoped store and never in the
# shared public-caption `transcripts` table (ADR-021). Rejection is synchronous,
# the audio is never retained, and a failed job ends in a terminal state with a
- # message — never a spinner (ADR-025). Audio only: video is #28.
+ # message — never a spinner (ADR-025). A file with a video track (a Teams meeting
+ # with the camera off is one) is accepted: only its audio is used and the user is
+ # told. Video playback, keyframes and analysis of what is shown remain #28.
Background:
Given I am a registered user with audio upload enabled
@@ -25,9 +27,15 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
When I upload an audio file and give no title
Then its title is the filename without its extension
- Scenario: A file containing video is rejected, naming issue 28
- When I upload a file that has a video track
- Then it is rejected with a message that video is tracked in issue 28
+ Scenario: A file with a video track is accepted and I am told the video was ignored
+ When I upload a recording that also has a video track
+ Then it is accepted and queued like any other
+ And its page says only its audio was used and the video was discarded
+ And it says so while queued, while transcribing, when done and when failed
+
+ Scenario: A video file with no audio is rejected for having no audio
+ When I upload a video that has no audio track
+ Then it is rejected with a message that it has no audio
And nothing is recorded
Scenario: A recording over the length limit is rejected with both lengths
@@ -40,10 +48,9 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
Then it is rejected with 413 and a message about the limit
And nothing is recorded
- Scenario: An audio-only MP4 is not mistaken for video
- When I upload an mp4 container that has an audio stream and no video stream
- Then it is accepted
- And an mp3 whose only video stream is cover art is accepted too
+ Scenario: Cover art on an audio file is not a video track
+ When I upload an mp3 whose only video stream is attached cover art
+ Then it is treated as audio and no video notice is shown
Scenario: A file that is not media is rejected as unreadable
When I upload a file ffprobe cannot read
--
2.54.0
From adaaa9918f5c64631df3f6bb24e41aea25b1510a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:43:55 +0200
Subject: [PATCH 072/299] test(acceptance): map the video-track scenarios to
their covering tests
Co-Authored-By: Claude Sonnet 5.5
---
test/acceptance/scenario_coverage_test.go | 37 ++++++++++++-----------
1 file changed, 19 insertions(+), 18 deletions(-)
diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go
index 76bc7b9..a2c8ae4 100644
--- a/test/acceptance/scenario_coverage_test.go
+++ b/test/acceptance/scenario_coverage_test.go
@@ -102,24 +102,25 @@ var scenarioCoverage = map[string]string{
"Chat is reachable only from my own summary view": "TestChatOnlyReachableForOwnVideo",
// audio_upload.feature (ADR-033, #33)
- "The upload form states the limits up front": "TestUploadsPage_ShowsAMultipartFormWithAFileField",
- "An accepted recording is queued and I am taken to its page": "TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt",
- "A recording with no title is named after its file": "TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension",
- "A file containing video is rejected, naming issue 28": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
- "A recording over the length limit is rejected with both lengths": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
- "A file over the size limit is rejected as too large": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
- "An audio-only MP4 is not mistaken for video": "TestParseProbe",
- "A file that is not media is rejected as unreadable": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
- "A broken ffprobe is reported as our fault, not as a bad file": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
- "A recording becomes a transcript and the audio is deleted": "TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio",
- "The endpoint that served a transcript is recorded and shown": "TestUploadStatus_DoneNamesTheEndpointThatServedIt",
- "The local endpoint is tried first and the fallback only on failure": "TestChain_AnyPrimaryFailureFallsBackAndRecordsTheFallbackEndpoint",
- "A failed transcription ends in a message, never a spinner": "TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop",
- "A restart fails the jobs it interrupted": "TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser",
- "Only one recording is transcribed at a time": "TestWorkerRun_ProcessesOneJobAtATime",
- "My uploads are invisible to other users": "TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled",
- "I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
- "A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
+ "The upload form states the limits up front": "TestUploadsPage_ShowsAMultipartFormWithAFileField",
+ "An accepted recording is queued and I am taken to its page": "TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt",
+ "A recording with no title is named after its file": "TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension",
+ "A file with a video track is accepted and I am told the video was ignored": "TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas",
+ "A video file with no audio is rejected for having no audio": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
+ "A recording over the length limit is rejected with both lengths": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
+ "A file over the size limit is rejected as too large": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
+ "Cover art on an audio file is not a video track": "TestParseProbe",
+ "A file that is not media is rejected as unreadable": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
+ "A broken ffprobe is reported as our fault, not as a bad file": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
+ "A recording becomes a transcript and the audio is deleted": "TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio",
+ "The endpoint that served a transcript is recorded and shown": "TestUploadStatus_DoneNamesTheEndpointThatServedIt",
+ "The local endpoint is tried first and the fallback only on failure": "TestChain_AnyPrimaryFailureFallsBackAndRecordsTheFallbackEndpoint",
+ "A failed transcription ends in a message, never a spinner": "TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop",
+ "A restart fails the jobs it interrupted": "TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser",
+ "Only one recording is transcribed at a time": "TestWorkerRun_ProcessesOneJobAtATime",
+ "My uploads are invisible to other users": "TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled",
+ "I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
+ "A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
// summarize_new_video.feature
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",
--
2.54.0
From 365dff5c8588e8f4028280d4b1ce0021ec44a368 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 18:43:55 +0200
Subject: [PATCH 073/299] docs(data-model): had_video
Co-Authored-By: Claude Sonnet 5.5
---
docs/data-model.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/data-model.md b/docs/data-model.md
index a9e49c2..2d448b2 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -152,6 +152,7 @@ erDiagram
text endpoint "which backend served it: iguana/... | berget/... (berget = audio left the homelab)"
float audio_seconds
text error_kind "typed: interrupted | transcription_failed — never a provider string"
+ bool had_video "the file carried a video track; only its audio was used (migration 018)"
timestamptz created_at
timestamptz finished_at
}
@@ -223,7 +224,7 @@ mechanism.
grounds that it holds only public caption content, and a recorded meeting is the opposite.
`user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing →
done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK
- constraints). `endpoint` records which backend served it — `berget/...` means the audio left the
+ constraints). `had_video` (migration 018) records that the file carried a video track — a Teams meeting with the camera off is still a video file — so the page can say only its audio was used and the video was discarded. `endpoint` records which backend served it — `berget/...` means the audio left the
homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90
minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion.
- **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
--
2.54.0
From 5368d5f8c28357e4675cbce2ee39f158ddd4ae75 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 19:28:06 +0200
Subject: [PATCH 074/299] test(web): a rejected upload is logged with its
reason, never the filename or title (red)
Rejections are synchronous and leave no row, so an operator could not tell 'the
user never retried' from 'the page refused it' (it happened during the first
real-recording trial). The reason is operational; the filename and title are
the user's private content.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_handler_test.go | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go
index 1b9fdcd..a94400a 100644
--- a/internal/web/uploads_handler_test.go
+++ b/internal/web/uploads_handler_test.go
@@ -3,6 +3,7 @@ package web_test
import (
"bytes"
"context"
+ "log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -336,3 +337,21 @@ func TestUploadPage_AlsoCarriesTheVideoNotice(t *testing.T) {
assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "Only its audio was used")
}
+
+func TestUploadPost_ARejectionIsLoggedWithItsReasonAndNeverTheFilenameOrTitle(t *testing.T) {
+ // Rejections are synchronous and leave no row, so without a log line an operator
+ // cannot tell "the user never retried" from "the page refused it". The reason is
+ // operational; the filename and title are the user's private content.
+ var buf bytes.Buffer
+ app, _ := newUploadApp(t, uploads.Probe{Duration: time.Minute, HasVideo: true}, 1<<20)
+ app.Log = slog.New(slog.NewTextHandler(&buf, nil))
+
+ rec := do(t, app, uploadReq(t, "Secret project notes", "client-acme-roadmap.mp4", []byte("x")))
+
+ require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
+ logged := buf.String()
+ assert.Contains(t, logged, "upload rejected")
+ assert.Contains(t, logged, "reason=no_audio")
+ assert.NotContains(t, logged, "Secret project notes", "the title is private content")
+ assert.NotContains(t, logged, "client-acme-roadmap", "the filename is private content")
+}
--
2.54.0
From 206fc2f5cabad7d3b4700833df5883e271bc5ed2 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 19:28:10 +0200
Subject: [PATCH 075/299] feat(web): log upload rejections by reason only
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index bd65624..1f5e90c 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -111,12 +111,15 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin
var tooBig *http.MaxBytesError
switch {
case errors.As(err, &rej):
+ // Logged by reason only: the filename and title are private content.
+ a.logger().Info("upload rejected", "reason", string(rej.Reason))
status := http.StatusUnprocessableEntity
if rej.Reason == uploads.ReasonTooLarge {
status = http.StatusRequestEntityTooLarge
}
a.renderUploadsPage(w, r, userID, status, rej.Message)
case errors.As(err, &tooBig):
+ a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
"This file is larger than the upload limit.")
default:
--
2.54.0
From 500e11962be0ce02aafd3f34594e0ea27efbc9fa Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 19:34:54 +0200
Subject: [PATCH 076/299] test(transcribe): rebuild Text from segments so a
split word is not broken (red)
Found by comparing the first real one-hour transcript against pianissimo:
whisper.cpp wraps at ~60 chars and can wrap INSIDE a word. The continuation
segment has no leading space, the text field joins segments with newlines, and
a segment can be a lone '.'. Measured 2026-10-01: 7% of segments (96/1332) in a
synthetic hour, 42/1302 lines in the real one. My earlier tests used short
sentences and could not see it. berget's segments are whole sentences with no
leading space, so the two backends need different joins.
Red: Text still joins by newline.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/gateway_test.go | 60 +++++++++++++++++++-
1 file changed, 59 insertions(+), 1 deletion(-)
diff --git a/internal/adapters/transcribe/gateway_test.go b/internal/adapters/transcribe/gateway_test.go
index eb3b677..6a8c221 100644
--- a/internal/adapters/transcribe/gateway_test.go
+++ b/internal/adapters/transcribe/gateway_test.go
@@ -121,7 +121,7 @@ func TestGateway_NormalisesTheVerboseJSONResponse(t *testing.T) {
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
require.NoError(t, err)
- assert.Equal(t, "God morgon allihopa.\n Vi går igenom läget.", got.Text)
+ assert.Equal(t, "God morgon allihopa. Vi går igenom läget.", got.Text)
assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is part of the result")
assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
assert.Equal(t, []domain.Segment{
@@ -172,3 +172,61 @@ func TestGateway_MissingAudioFileIsAnError(t *testing.T) {
assert.Error(t, err)
}
+
+// whisper.cpp wraps its output at ~60 characters, and a wrap can land INSIDE a word:
+// the continuation segment has no leading space, the `text` field joins segments
+// with newlines, and a segment can be a lone ".". Measured on real gateway output
+// 2026-10-01: 7% of a one-hour recording's segments were such fragments. Joining by
+// newline hands the chat model broken words ("nyt\nt"), so Text is rebuilt from the
+// segments: a segment that begins with a space starts a new word, one that does not
+// continues the previous one.
+func TestGateway_RejoinsWordsWhisperCppSplitAcrossSegments(t *testing.T) {
+ reply := `{
+ "text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen\n.\n Det kräver ett nyt\nt godkännande från beställaren.\n Framförallt på arbetspl\natsen.\n",
+ "language": "swedish", "duration": 20,
+ "segments": [
+ {"start": 0, "end": 4, "text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen"},
+ {"start": 4, "end": 5, "text": "."},
+ {"start": 5, "end": 8, "text": " Det kräver ett nyt"},
+ {"start": 8, "end": 11, "text": "t godkännande från beställaren."},
+ {"start": 11, "end": 14, "text": " Framförallt på arbetspl"},
+ {"start": 14, "end": 16, "text": "atsen."}
+ ]}`
+ srv := serve(t, 200, reply, nil)
+ g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
+
+ got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
+
+ require.NoError(t, err)
+ assert.Equal(t, "Vi ligger just nu 12 procent över den ursprungliga kalkylen. "+
+ "Det kräver ett nytt godkännande från beställaren. Framförallt på arbetsplatsen.", got.Text)
+ assert.Len(t, got.Segments, 6, "the timed segments are kept as the backend gave them (trimmed)")
+ assert.Equal(t, ".", got.Segments[1].Text)
+}
+
+func TestGateway_SentenceLevelSegmentsWithNoLeadingSpaceAreJoinedWithASpace(t *testing.T) {
+ // berget/kb-whisper returns whole sentences with no leading space; rebuilding by
+ // concatenation would glue them ("allihopa!Vi").
+ reply := `{"text": "God morgon allihopa!\nVi går igenom läget.", "language": "sv", "duration": 5,
+ "segments": [
+ {"start": 0.05, "end": 1.2, "text": "God morgon allihopa!"},
+ {"start": 1.6, "end": 4.2, "text": "Vi går igenom läget."}
+ ]}`
+ srv := serve(t, 200, reply, nil)
+ g := transcribe.NewGateway(srv.URL+"/v1", "k", "berget/kb-whisper", srv.Client())
+
+ got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
+
+ require.NoError(t, err)
+ assert.Equal(t, "God morgon allihopa! Vi går igenom läget.", got.Text)
+}
+
+func TestGateway_WithNoSegmentsTheTextFieldIsUsedWithWhitespaceCollapsed(t *testing.T) {
+ srv := serve(t, 200, `{"text": " Hej allihopa.\n Vi börjar. ", "duration": 3, "segments": []}`, nil)
+ g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
+
+ got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
+
+ require.NoError(t, err)
+ assert.Equal(t, "Hej allihopa. Vi börjar.", got.Text)
+}
--
2.54.0
From d11afb5ff17096fbb0402f260b052f3876fc47c1 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 19:35:14 +0200
Subject: [PATCH 077/299] fix(transcribe): rebuild the transcript from segments
so split words are whole
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
whisper.cpp wraps at ~60 chars and can wrap inside a word; the response's text
field joins segments with newlines, so chat saw 'nyt' / 't godkännande'. Text
is now rebuilt from the raw segments: concatenated when any segment carries a
leading space (whisper.cpp), joined by a space when none do (berget's sentence
level), whitespace collapsed; the text field is only a fallback.
Checked against the first real one-hour transcript with pianissimo's vocabulary
as an independent yardstick: 36 spans changed, 31 now consist entirely of words
pianissimo also produced, words unknown to pianissimo fell 224 -> 177 and word
agreement rose 92.4% -> 93.1%. Five rejoined spans still contain an unknown
word (names/loanwords or an unlucky glue) -- a residual risk, small.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/gateway.go | 46 ++++++++++++++++++++++---
1 file changed, 42 insertions(+), 4 deletions(-)
diff --git a/internal/adapters/transcribe/gateway.go b/internal/adapters/transcribe/gateway.go
index e885c71..33cf464 100644
--- a/internal/adapters/transcribe/gateway.go
+++ b/internal/adapters/transcribe/gateway.go
@@ -110,8 +110,9 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
}
// verboseJSON is the subset of the verbose_json response used. Both backends
-// return it; they differ in segment granularity (iguana: whole seconds, berget:
-// sub-second) and in the language token, neither of which matters here.
+// return it; they differ in segment granularity (iguana: whole seconds and
+// sub-sentence pieces, berget: sub-second sentences), in the language token, and in
+// whether a segment's text starts with a space.
type verboseJSON struct {
Text string `json:"text"`
Duration float64 `json:"duration"`
@@ -124,13 +125,21 @@ type verboseJSON struct {
// parseVerbose normalises a verbose_json body. Whisper's final window can end past
// the audio (9.0 s for 4.25 s of audio was observed), so segment ends are clamped
-// to the reported duration.
+// to the reported duration. Text is rebuilt from the segments, not taken from the
+// response's `text` field: see joinSegments.
func parseVerbose(r io.Reader) (domain.Transcription, error) {
var v verboseJSON
if err := json.NewDecoder(r).Decode(&v); err != nil {
return domain.Transcription{}, fmt.Errorf("decode response: %w", err)
}
- text := strings.TrimSpace(v.Text)
+ raw := make([]string, 0, len(v.Segments))
+ for _, s := range v.Segments {
+ raw = append(raw, s.Text)
+ }
+ text := joinSegments(raw)
+ if text == "" {
+ text = collapseSpace(v.Text)
+ }
if text == "" {
return domain.Transcription{}, ErrEmptyTranscript
}
@@ -145,3 +154,32 @@ func parseVerbose(r io.Reader) (domain.Transcription, error) {
}
return t, nil
}
+
+// joinSegments rebuilds the running text from raw (untrimmed) segment texts.
+//
+// whisper.cpp wraps its output at ~60 characters and a wrap can land INSIDE a word
+// ("nyt" | "t godkännande"): a segment that begins with a space starts a new word,
+// one that does not continues the previous one, and the response's own `text`
+// field joins them with newlines, which breaks the word. So when any segment
+// carries a leading space the raw texts are concatenated as they are. Backends
+// that return whole sentences with no leading space (berget) are joined with a
+// space instead — concatenating those would glue sentences together.
+func joinSegments(raw []string) string {
+ leadingSpace := false
+ for _, s := range raw {
+ if strings.HasPrefix(s, " ") {
+ leadingSpace = true
+ break
+ }
+ }
+ sep := " "
+ if leadingSpace {
+ sep = ""
+ }
+ return collapseSpace(strings.Join(raw, sep))
+}
+
+// collapseSpace turns every run of whitespace (including newlines) into one space.
+func collapseSpace(s string) string {
+ return strings.Join(strings.Fields(s), " ")
+}
--
2.54.0
From ebc6cc3ff808829fa2dd73f7a5496d9fcbfac134 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 19:45:56 +0200
Subject: [PATCH 078/299] docs(research): kb-whisper vs pianissimo on one real
hour of Swedish speech
A near tie on accuracy (blind A/B by the speaker: 17 vs 13 on the 30 spans that
change meaning) and a 6x difference in speed (40 s vs 4m08s for 63.7 min).
Different error profiles: pianissimo better on terms and numbers, kb-whisper on
everyday words. Aggregate numbers only -- no transcript text or domain terms,
this repo is public. Also records the mid-word wrapping bug the comparison found
and its fix.
Co-Authored-By: Claude Sonnet 5.5
---
...asr-kb-whisper-vs-pianissimo-2026-10-01.md | 81 +++++++++++++++++++
1 file changed, 81 insertions(+)
create mode 100644 docs/research/asr-kb-whisper-vs-pianissimo-2026-10-01.md
diff --git a/docs/research/asr-kb-whisper-vs-pianissimo-2026-10-01.md b/docs/research/asr-kb-whisper-vs-pianissimo-2026-10-01.md
new file mode 100644
index 0000000..6a51e23
--- /dev/null
+++ b/docs/research/asr-kb-whisper-vs-pianissimo-2026-10-01.md
@@ -0,0 +1,81 @@
+# kb-whisper vs pianissimo on one real hour of Swedish speech (tapir#33, 2026-10-01)
+
+One recording, one speaker: a 63.7-minute Teams session of the maintainer dictating project
+notes (domain-heavy, English loanwords and product names mixed into Swedish). **No transcript
+text and no domain terms are recorded here**: this repo is public and the content is private.
+Only aggregate numbers.
+
+Backends, both through the LiteLLM gateway and Tapir's own adapter, unchanged:
+`iguana/kb-whisper` (whisper.cpp, KB's Swedish fine-tune) and `iguana/pianissimo` (KlangAI
+Swedish Parakeet/FastConformer-TDT, MLX 8-bit, served by a small FastAPI shell on iguana:8102).
+
+## Result: a near tie on accuracy, a large difference in speed
+
+| | kb-whisper | pianissimo |
+|---|---|---|
+| Wall time for 63.7 min | 4m08s (~15x realtime) | **40 s (~95x realtime)** |
+| Segments | 1,302 (timed pieces, wrapped at ~60 chars) | 434 (sentences) |
+| Words (normalised) | 6,606 | 6,510 |
+| Punctuation | light, every segment ends in a full stop | heavier (138 vs 82 commas) |
+| Repetition loops | none | none |
+
+Word-level agreement between them: **92.4%** as first computed, **93.1%** after the segment
+rejoin fix below. 380 aligned disagreement spans, median one word each.
+
+**Blind A/B by the speaker** (the only ground truth available), on the 30 spans a model
+(Claude Opus 5.5) had picked as changing meaning, shuffled and unlabelled: **pianissimo 17,
+kb-whisper 13**, no "neither", no "can't tell".
+
+| Category of the 30 | pianissimo | kb-whisper |
+|---|---|---|
+| Technical term / English loanword | 8 | 4 |
+| Number / date / unit | 4 | 1 |
+| Name / proper noun | 3 | 3 |
+| Function word / small word | 1 | 2 |
+| Other (everyday words) | 1 | 3 |
+
+The reading: pianissimo is better on terminology and numbers, kb-whisper on ordinary Swedish
+words; names are even. Their errors differ in kind: kb-whisper's tend to be garbled words, a
+couple were wrong numbers; pianissimo's tend to be fluent substitutions that read as plausible
+(a wrong institution, an invented person, a dropped sign), which a reader or an LLM is less
+likely to catch.
+
+Opus's own lean agreed with the speaker's blind answer on **30 of 30**. That is not a measure
+of Opus over all 380 spans: it chose spans it could decide, and every one of the 30 carried a
+lean. Its full classification of the 380 (counts only): function/small word 81, filler or
+false start 65, technical term 58, compound split/join 46, other 39, name 28, casing or
+punctuation residue 26, inflection 23, number/date/unit 10, content omission or insertion
+(>= 3 words) 4.
+
+## A bug this comparison found in Tapir (fixed)
+
+kb-whisper's output is wrapped at ~60 characters and **a wrap can fall inside a word**; the
+continuation segment has no leading space, the response's `text` field joins segments with
+newlines, and a segment can be a lone `.`. Tapir stored that joined text, so chat received
+broken words. Measured on real gateway output: 7% of segments (96/1,332) in a synthetic hour,
+42/1,302 lines in the real one. berget's segments are whole sentences with no leading space, so
+the two need different joins. `transcribe.joinSegments` now rebuilds the text from the segments.
+Checked against the real transcript with pianissimo's vocabulary as an independent yardstick:
+36 spans changed, 31 now entirely known words, words unknown to pianissimo 224 -> 177.
+Five rejoined spans still contain an unknown word (a name or an unlucky glue): small residual
+risk. Earlier tests used short sentences and could not see this.
+
+## What this does not show
+
+- **One recording, one speaker, one domain.** No claim about other voices, accents, crosstalk,
+ or room noise. 17 vs 13 is not a significant difference.
+- **The other ~350 spans** were not judged by the speaker. They are mostly spelling, compounds,
+ fillers and small words; Opus's counts there are a model's judgement, not ground truth.
+- Errors **both** models share in text they agree on are invisible to this method.
+- Speed was measured once each, on an otherwise idle iguana; pianissimo's 40 s includes the
+ gateway hop and a 122 MB WAV upload.
+- Whether pianissimo survives the infra#273 Metal-OOM contention the way whisper does not was
+ not tested; both run on iguana.
+
+## What it suggests for Tapir (not decided, filed as an issue)
+
+Pianissimo is a **second local backend on the same host, through the same adapter**. That
+makes it a natural middle tier ahead of the cloud fallback (kb-whisper -> pianissimo ->
+berget), so a wedged whisper no longer sends private audio off the homelab. At 40 s an hour it
+is also cheap enough to run **both** on every upload and give chat both texts, or flag where
+they disagree; the maintainer's blind answers show they err in different places.
--
2.54.0
From e182a6b07d1466d0d9004e71d7bd3955a682bf66 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:14:27 +0200
Subject: [PATCH 079/299] docs(research): can a local model adjudicate two ASR
transcripts?
Eleven local models, 120 calls each, scored against the speaker's blind answers
on 30 disagreements, with pass criteria fixed before the run. gemma4-31b (97%)
and gemma4-26b (92%) pass; nothing else clears accuracy and order-consistency.
Records why 31b is slow (MLX gives the same speed; Low Power Mode is the
unproven suspect) and the caveats: 30 items, one recording, thinking models
under-budgeted. Aggregate numbers only; this repo is public.
Co-Authored-By: Claude Sonnet 5.5
---
...ocal-model-adjudicator-sweep-2026-10-01.md | 75 +++++++++++++++++++
1 file changed, 75 insertions(+)
create mode 100644 docs/research/local-model-adjudicator-sweep-2026-10-01.md
diff --git a/docs/research/local-model-adjudicator-sweep-2026-10-01.md b/docs/research/local-model-adjudicator-sweep-2026-10-01.md
new file mode 100644
index 0000000..d98abd6
--- /dev/null
+++ b/docs/research/local-model-adjudicator-sweep-2026-10-01.md
@@ -0,0 +1,75 @@
+# Can a local model adjudicate two ASR transcripts? (tapir#33, 2026-10-01)
+
+Question: when `iguana/kb-whisper` and `iguana/pianissimo` disagree about a word or phrase,
+can a **local** model say which reading is right, well enough to merge the two transcripts?
+Ground truth: the speaker's own blind answers on 30 disagreements of one real recording
+(see `asr-kb-whisper-vs-pianissimo-2026-10-01.md`; no transcript text is recorded here,
+this repo is public). Reference: Claude Opus 5.5 agreed with the speaker on all 30.
+
+## Method
+
+- **Items:** 30 disagreement spans that Opus had picked as changing meaning, the speaker's
+ verdict as truth (pianissimo right 17, kb-whisper right 13).
+- **Prompt:** one fixed English instruction (two transcriptions of Swedish speech differ at
+ ONE marked spot; say which reading the speaker most likely said; reply `ANSWER: 1|2|UNSURE`
+ plus one sentence). Each item was asked in **both reading orders** and in **two contexts**:
+ narrow (+-8 words) and wide (+-100 words). 120 calls per model, temperature 0.
+- **Models:** 11, all local (`koala/*`, `iguana/*`): nothing left the homelab. Run one iguana
+ model at a time, each unloaded afterwards, to avoid the memory pressure behind infra#273;
+ whisper was confirmed healthy afterwards.
+- **Pass criteria, fixed before the run:** wide context, strict accuracy >= 80% (an
+ unparseable or "unsure" answer counts as wrong), order-swap consistency >= 85%,
+ unparseable <= 10%.
+
+## Results (wide context, 60 calls per model)
+
+| Model | Correct | 95% CI | Same pick when order swapped | Unparseable | s/call | Pass |
+|---|---|---|---|---|---|---|
+| `iguana/gemma4-31b` | 58 = 97% | 89-99% | 93% | 0 | 10.4 | **yes** |
+| `iguana/gemma4-26b` | 55 = 92% | 82-96% | 93% | 0 | 2.0 | **yes** |
+| `iguana/ornith-35b` | 51 = 85% | 74-92% | 77% | 0 | 2.5 | no (consistency) |
+| `koala/phi4-14b` | 50 = 83% | 72-91% | 80% | 0 | 1.0 | no (consistency) |
+| `koala/qwen36-35b-a3b` | 49 = 82% | 70-89% | 83% | 0 | 3.3 | no (consistency) |
+| `iguana/granite4` | 47 = 78% | 66-87% | 83% | 0 | 4.6 | no |
+| `iguana/qwen3-14b` | 44 = 73% | 61-83% | 87% | 0 | 4.4 | no (accuracy) |
+| `iguana/gpt-oss-20b` | 40 = 67% | 54-77% | 100% | 17 | 11.2 | no (unparseable) |
+| `koala/phi4-mini` | 37 = 62% | 49-73% | 24% | 1 | 0.5 | no |
+| `iguana/gemma4-26b-think` | 23 = 38% | 27-51% | 100% | 37 | 15.3 | no (unparseable) |
+| `koala/qwen35-9b-fast` | 0 | n/a | n/a | 60 | 7.2 | no (no output) |
+
+Narrow context only (+-8 words) costs 6-12 points for the models that pass: gemma4-31b 92%,
+gemma4-26b 83%, ornith-35b 80%. Context is not decoration: Opus also used evidence from
+elsewhere in the recording (an amount stated earlier, a term that recurs).
+
+Position bias: the passing models pick "Reading 1" 47-51% of the time. `phi4-mini` picks it 15%
+and flips its answer when the order is swapped 76% of the time: it carries almost no signal.
+
+## Reading it
+
+- **Two models pass:** the gemma4 pair. Both clear the bar and neither shows position bias.
+ 26b is ~5x faster; their intervals overlap, so 30 items cannot separate 92% from 97%.
+- **The thinking models were not tested fairly.** `gpt-oss-20b` and `gemma4-26b-think` spend
+ the 600-token budget reasoning and often never reach the answer line. When gemma4-26b-think
+ did answer, it was right 23 of 23; gpt-oss-20b 93% of the answered. A larger budget would
+ make them eligible; at 15 s a call it would not make them practical.
+
+## What this does not show
+
+- **Thirty items, one recording, one speaker, one domain.** The 30 were chosen by Opus as
+ decidable, so they overstate how easy the typical disagreement is; ~350 others are mostly
+ spelling, compounds and fillers, which no ground truth covers.
+- Opus agreeing 30/30 is not a measure of Opus over all 380 disagreements.
+- Accuracy was measured at temperature 0 on one prompt; no prompt tuning was tried.
+- The speaker's answers are the only truth; they are one person's recollection of what they said.
+
+## Why gemma4-31b is slow, and what was ruled out
+
+On iguana (M2 Ultra, 64 GB) the dense 31B runs at **103 tok/s prefill, 7.9 tok/s decode**; the
+26B MoE at 641 and 39.6. Ruled out as the cause: memory pressure (no swap, 45% free, model 100%
+on GPU), context length (32k vs 4k made no difference), and the **inference engine** — MLX 4-bit
+of the same model measured 76 tok/s prefill and 8.6 decode, the same within noise. (Terse
+output saves only ~3.6 s of ~9.3 s because prefill dominates.) Two unrelated engines at the same
+speed points at the machine: ~140 GB/s effective memory bandwidth on a part rated for 800, and
+**iguana has macOS Low Power Mode on** (`pmset` `lowpowermode 1`). That is the leading suspect
+and is **not proven**: turning it off needs root and was not done. A first MLX benchmark was
+wrong because `mlx_lm.server` caches prompts; the figures above are from the corrected run.
--
2.54.0
From de9871c9c3de9849c33b69afd061a930031ddcb2 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:15:12 +0200
Subject: [PATCH 080/299] docs(adr): ADR-034 transcribe with two engines,
reconcile with a local model
Proposed, decider pending. Two local engines on one WAV; word-level diff with
pianissimo as the base (readability and timing, NOT accuracy -- that was a tie);
trivial disputes resolved without a model; the rest adjudicated by a LOCAL model
(alias must start koala/ or iguana/) whose only freedom is choosing between the
two readings. Reconciliation never fails a job; the page says what was done and
lists the uncertain spots. Off by default. Validation: re-upload the real
recording and keep the feature only if the reconciled reading is right >= 85% of
the time and no worse than the better single engine.
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 109 insertions(+)
diff --git a/DECISIONS.md b/DECISIONS.md
index 3aafc8c..97bf6d3 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1533,6 +1533,113 @@ and ADR-021 are untouched.
---
+## ADR-034 — Transcribe with two engines and let a local model reconcile their disagreements
+
+**Status:** Proposed (2026-10-01). **Decider: Mathias — pending.** Issue #40 (decision B) and #33.
+Builds on ADR-033 (private upload transcripts), ADR-022 (endpoint chain, a pattern reused), ADR-018
+(single replica), ADR-025 (honest status). Evidence:
+`docs/research/asr-kb-whisper-vs-pianissimo-2026-10-01.md` and
+`docs/research/local-model-adjudicator-sweep-2026-10-01.md`.
+
+**Context.** On the first real one-hour recording `iguana/kb-whisper` and `iguana/pianissimo` were
+a near tie on accuracy (the speaker's blind verdicts on the 30 meaning-changing disagreements:
+pianissimo 17, kb-whisper 13) and they **err in different places**: pianissimo is better on terms and
+numbers, kb-whisper on ordinary words; kb-whisper's errors garble, pianissimo's read as plausible.
+Pianissimo is ~6x faster (40 s vs 4m08s for 63.7 min) and runs through the same adapter. Two engines
+that disagree in ~380 places per hour, with a ~92% word agreement, is information a single-engine
+pipeline throws away. Separately, eleven local models were scored on adjudicating those disagreements
+against the speaker's answers: **`gemma4-31b` 97% and `gemma4-26b` 92%** passed the pre-set bar
+(>= 80% accuracy, >= 85% order-swap consistency, <= 10% unparseable), nothing else did.
+
+**Unknowns, stated.** The adjudicator evidence is 30 items from one speaker and one domain, chosen by
+Opus as decidable; the other ~350 disagreements (fillers, compounds, small words) have no ground
+truth. Whether a *merged* transcript is better than the better single engine has not been measured.
+Reconcile time per hour is estimated, not measured. `gemma4-31b`'s slowness is unexplained (Low Power
+Mode on iguana is the unproven suspect).
+
+**Decision.**
+
+1. **Two engines, one WAV, both local, one after the other.** Each upload is transcribed by
+ `iguana/pianissimo` and `iguana/kb-whisper` from the same converted WAV, sequentially (concurrency
+ stays 1, ADR-033). Each engine fails independently; the berget fallback (ADR-033) applies only when
+ **both** fail. Off by default: with no second engine configured nothing changes.
+
+2. **Align by word, with pianissimo as the base text.** Both texts are tokenised (lower case, letters
+ and digits only) and diffed word by word (Myers); neighbouring differences separated by a single
+ equal word are merged into one **dispute**. The base is pianissimo: sentence-level prose with
+ punctuation and millisecond timestamps, where kb-whisper's output is wrapped fragments. **This
+ choice is about readability and timing, not accuracy — accuracy was a tie — so it is the cheaper
+ mistake, not a measured winner.** A replaced span takes the alternative's own words.
+
+3. **Resolve the trivial ones without a model.** A dispute that is identical once spaces are removed
+ (a compound split or joined) keeps the base; so does a dispute that is only a short filler word
+ on one side. Everything else is adjudicated. (Opus's classification of 380 disputes found ~46
+ compound and ~26 punctuation-only residue, ~65 filler.)
+
+4. **A local model adjudicates the rest, bounded.** Each dispute goes to a local chat model (default
+ `iguana/gemma4-26b`; `gemma4-31b` is configurable and scored higher but is ~5x slower) with ~100
+ words of context either side and the two readings, in the wording that was measured. The reading
+ order alternates by dispute so position bias averages out. The verdict is *base*, *alternative* or
+ *unsure*; **unsure keeps the base and is flagged**. An optional order-check mode asks twice with the
+ order swapped and treats disagreement as unsure; it is **off by default** because it doubles the time
+ and the measured consistency of the passing models is 93%.
+ - **The model must be local.** Its alias must begin `koala/` or `iguana/`; a cloud alias is a
+ start-up configuration error. Private meeting content never goes to a cloud model for this step.
+ - **Bounded impact.** The reconciler can only choose between two readings the engines already
+ produced; it cannot write new text. Speech that says "answer 2" can at worst flip one dispute.
+
+5. **Reconciliation never fails a job.** One engine fails → its text is used, recorded as
+ single-engine. The adjudicator is down (three consecutive errors) → the base text is used
+ unreconciled, recorded as skipped with the reason, and the remaining disputes are not retried one
+ timeout at a time. Both engines fail → ADR-033's fallback path.
+
+6. **What is stored and shown.** `content` is the merged text. `segments` stay the base engine's timed
+ segments, untouched: `content` is authoritative for chat, `segments` only for time. `endpoint`
+ lists every contributor and the reconciler. A new `reconciliation` column (migration 019, same
+ RLS-scoped table) records the status, counts, and the **uncertain and changed spots with a minute
+ mark and both readings**, so the user can check them against the audio. The transcript page says in
+ plain words what was done — "reconciled from two transcripts: N differences, K uncertain" — and
+ lists the uncertain spots. A reconciled transcript is never presented as simply "the" transcript.
+
+7. **Configuration.** `TAPIR_TRANSCRIBE_SECOND_MODEL` (empty = off), `TAPIR_RECONCILE_MODEL`
+ (required when a second engine is set), `TAPIR_RECONCILE_CHECK_ORDER` (off). The default job
+ timeout rises from 30 to 60 minutes: ~4 min of kb-whisper and ~40 s of pianissimo per hour of audio,
+ plus the adjudication.
+
+8. **Not decided here.** A cloud adjudicator (rejected: private content); tuning the prompt; making
+ `gemma4-31b` the default; a "reconciling" progress state (the page says "transcribing" for the whole
+ job); feeding the uncertain spots to chat; whether pianissimo should also be a middle tier in the
+ fallback chain (#40, option A).
+
+**Consequences.**
+- **Positive.** Uses the finding that the engines err differently; stays local end to end; degrades
+ to today's behaviour at every failure; and is honest on the page about what it did.
+- **Negative / watch.**
+ - Job time grows: roughly 300 adjudicated disputes x ~2 s on `gemma4-26b` ~ 10 minutes per hour of
+ audio — an estimate, not a measurement; double with order-check on, ~5x with `gemma4-31b`.
+ - Two gemma-class calls sit on iguana beside whisper. During the sweep a 17-20 GB model was resident
+ beside whisper-server without wedging it, far below the 52 GB that did (infra#273); the hazard is
+ reduced, not removed.
+ - A merged transcript can be wrong where both engines are wrong, and where the adjudicator picks
+ the wrong one (it was right 92% on a hand-picked set, ~97% for 31b).
+ - Reading the page takes more words: the reconciliation summary is new surface to keep honest.
+
+**Validation / resolution triggers.**
+- **The test that decides it:** re-upload the same real recording after merge; the speaker judges, blind,
+ ~30 disputes where the reconciled text differs from pianissimo alone. Keep the feature only if the
+ reconciled reading is right at least 85% of the time **and** no worse than the better single engine.
+ If not, switch it off (config) and keep the single-engine path.
+- Reconcile time above 25 minutes per hour of audio: drop to a smaller adjudicator or skip more
+ disputes automatically.
+- A different recording or speaker in which the engines agree far less (or far more): re-measure before
+ trusting the 92%/97% figures.
+
+**Reversibility.** Off by default and additive: the second engine, the reconciler and the column are
+unused when the settings are empty; the down-migration drops the column; the single-engine path is
+unchanged.
+
+---
+
## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
@@ -1560,6 +1667,8 @@ maps to the ADR that settles it.
| Uploaded transcripts as a `user_id` column + partial RLS policy on `transcripts` | Turns ADR-021's single exception into an exception with a private lane inside it; the isolation test asserts `transcripts` has no `user_id`. A separate RLS table keeps both boundaries independently assertable | ADR-033 |
| whisper-server `--convert` for server-side transcode | Its `--tmp-dir` is created at process start and rots under macOS periodic cleanup | ADR-033 |
| Calling the public `llm-api.d-ma.be` from in-cluster Tapir | Its ingress returns 413 above ~8–10 MB (~5 min of WAV); the NodePort has no such cap | ADR-033 |
+| A cloud model (e.g. Opus) as the adjudicator | It scored 30/30 but would send private meeting content to a cloud API; local gemma4-26b/31b passed the same bar | ADR-034 |
+| Pick one engine globally (pianissimo only, or kb-whisper only) | A near tie on accuracy with different error profiles; choosing one discards the information in their disagreement | ADR-034 |
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
not a silent reversal.
--
2.54.0
From 00fd884c6c1b0e59bd466727566ca4905baa0c18 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:15:44 +0200
Subject: [PATCH 081/299] test(reconcile): tokenising for word-level comparison
(red)
Keeps the word as written, keys it without case/punctuation/hyphens/plus, joins a
lone dash or full stop to its neighbour so it is not a word the engines can
disagree about. Red: package reconcile does not exist.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/tokens_test.go | 66 +++++++++++++++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 internal/reconcile/tokens_test.go
diff --git a/internal/reconcile/tokens_test.go b/internal/reconcile/tokens_test.go
new file mode 100644
index 0000000..7a3fe97
--- /dev/null
+++ b/internal/reconcile/tokens_test.go
@@ -0,0 +1,66 @@
+package reconcile_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+func texts(ts []reconcile.Token) []string {
+ out := make([]string, len(ts))
+ for i, t := range ts {
+ out[i] = t.Text
+ }
+ return out
+}
+
+func keys(ts []reconcile.Token) []string {
+ out := make([]string, len(ts))
+ for i, t := range ts {
+ out[i] = t.Key
+ }
+ return out
+}
+
+func TestTokenize_KeepsTheWordAsWrittenAndKeysItWithoutCaseOrPunctuation(t *testing.T) {
+ got := reconcile.Tokenize("Hej, allihopa! Vi går igenom ÅÄÖ.")
+
+ assert.Equal(t, []string{"Hej,", "allihopa!", "Vi", "går", "igenom", "ÅÄÖ."}, texts(got),
+ "the original spelling and punctuation are kept for the merged text")
+ assert.Equal(t, []string{"hej", "allihopa", "vi", "går", "igenom", "åäö"}, keys(got),
+ "the key is lower case with punctuation removed, Swedish letters lowered correctly")
+}
+
+func TestTokenize_KeysIgnoreHyphensAndPlusSoOnlyWordsDecideEquality(t *testing.T) {
+ got := reconcile.Tokenize("end-to-end T+1 5 000 kr")
+
+ assert.Equal(t, []string{"endtoend", "t1", "5", "000", "kr"}, keys(got))
+}
+
+func TestTokenize_APunctuationOnlyWordJoinsThePreviousWordInsteadOfBecomingOne(t *testing.T) {
+ got := reconcile.Tokenize("Vi går – sen tillbaka . Slut")
+
+ assert.Equal(t, []string{"Vi", "går –", "sen", "tillbaka .", "Slut"}, texts(got),
+ "a dash or a lone full stop is not a word the engines can disagree about")
+ assert.Equal(t, []string{"vi", "går", "sen", "tillbaka", "slut"}, keys(got))
+}
+
+func TestTokenize_LeadingPunctuationOnlyWordJoinsTheNextWord(t *testing.T) {
+ got := reconcile.Tokenize("— Hej då")
+
+ assert.Equal(t, []string{"— Hej", "då"}, texts(got))
+}
+
+func TestTokenize_NewlinesAndRunsOfSpaceAreJustSeparators(t *testing.T) {
+ got := reconcile.Tokenize(" Hej\n allihopa \t Vi ")
+
+ assert.Equal(t, []string{"Hej", "allihopa", "Vi"}, texts(got))
+}
+
+func TestTokenize_EmptyAndPunctuationOnlyInputGiveNoTokens(t *testing.T) {
+ assert.Empty(t, reconcile.Tokenize(""))
+ assert.Empty(t, reconcile.Tokenize(" \n "))
+ assert.Empty(t, reconcile.Tokenize("— . !"))
+}
--
2.54.0
From 984d92e3a9997699446c2e73b48622da33db1558 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:15:44 +0200
Subject: [PATCH 082/299] test(reconcile): word-level diff is minimal, tiles
both inputs and is fast (red)
A random-input property test compares the equal runs against a dynamic-programming
LCS: a diff that is valid but not minimal would turn real agreement into disputes
the model is then asked about. A 6,500-word, 380-edit case mirrors a real hour.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/diff_test.go | 139 ++++++++++++++++++++++++++++++++
1 file changed, 139 insertions(+)
create mode 100644 internal/reconcile/diff_test.go
diff --git a/internal/reconcile/diff_test.go b/internal/reconcile/diff_test.go
new file mode 100644
index 0000000..c75204e
--- /dev/null
+++ b/internal/reconcile/diff_test.go
@@ -0,0 +1,139 @@
+package reconcile_test
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+func words(s string) []string { return strings.Fields(s) }
+
+// apply rebuilds b from a using the ops, copying equal runs from a and the
+// replacement/insertion runs from b. If Diff is right this must equal b.
+func apply(a, b []string, ops []reconcile.Op) []string {
+ var out []string
+ for _, o := range ops {
+ switch o.Kind {
+ case reconcile.Equal:
+ out = append(out, a[o.AStart:o.AEnd]...)
+ default:
+ out = append(out, b[o.BStart:o.BEnd]...)
+ }
+ }
+ return out
+}
+
+func TestDiff_Cases(t *testing.T) {
+ tests := []struct {
+ name string
+ a, b string
+ want []reconcile.Op
+ }{
+ {"identical", "a b c", "a b c", []reconcile.Op{{Kind: reconcile.Equal, AStart: 0, AEnd: 3, BStart: 0, BEnd: 3}}},
+ {"both empty", "", "", nil},
+ {"one word replaced", "a b c", "a x c", []reconcile.Op{
+ {Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
+ {Kind: reconcile.Replace, AStart: 1, AEnd: 2, BStart: 1, BEnd: 2},
+ {Kind: reconcile.Equal, AStart: 2, AEnd: 3, BStart: 2, BEnd: 3},
+ }},
+ {"word only in b", "a c", "a b c", []reconcile.Op{
+ {Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
+ {Kind: reconcile.Insert, AStart: 1, AEnd: 1, BStart: 1, BEnd: 2},
+ {Kind: reconcile.Equal, AStart: 1, AEnd: 2, BStart: 2, BEnd: 3},
+ }},
+ {"word only in a", "a b c", "a c", []reconcile.Op{
+ {Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
+ {Kind: reconcile.Delete, AStart: 1, AEnd: 2, BStart: 1, BEnd: 1},
+ {Kind: reconcile.Equal, AStart: 2, AEnd: 3, BStart: 1, BEnd: 2},
+ }},
+ {"nothing in common", "a b", "x y z", []reconcile.Op{
+ {Kind: reconcile.Replace, AStart: 0, AEnd: 2, BStart: 0, BEnd: 3},
+ }},
+ {"a is empty", "", "x y", []reconcile.Op{{Kind: reconcile.Insert, AStart: 0, AEnd: 0, BStart: 0, BEnd: 2}}},
+ {"b is empty", "x y", "", []reconcile.Op{{Kind: reconcile.Delete, AStart: 0, AEnd: 2, BStart: 0, BEnd: 0}}},
+ {"adjacent delete and insert read as one replace", "a b c d", "a x y d", []reconcile.Op{
+ {Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
+ {Kind: reconcile.Replace, AStart: 1, AEnd: 3, BStart: 1, BEnd: 3},
+ {Kind: reconcile.Equal, AStart: 3, AEnd: 4, BStart: 3, BEnd: 4},
+ }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, reconcile.Diff(words(tt.a), words(tt.b)))
+ })
+ }
+}
+
+// lcsLen is a plain dynamic-programming reference. Myers must find an edit script
+// whose equal runs add up to exactly this: a diff that is merely valid but not
+// minimal would turn real agreement into disputes the adjudicator is then asked about.
+func lcsLen(a, b []string) int {
+ prev := make([]int, len(b)+1)
+ for i := 1; i <= len(a); i++ {
+ cur := make([]int, len(b)+1)
+ for j := 1; j <= len(b); j++ {
+ if a[i-1] == b[j-1] {
+ cur[j] = prev[j-1] + 1
+ } else {
+ cur[j] = max(prev[j], cur[j-1])
+ }
+ }
+ prev = cur
+ }
+ return prev[len(b)]
+}
+
+func TestDiff_RebuildsBAndKeepsEveryCommonWordOnRandomInputs(t *testing.T) {
+ rng := rand.New(rand.NewSource(7))
+ alphabet := []string{"a", "b", "c", "d", "e", "f"}
+ gen := func(n int) []string {
+ out := make([]string, n)
+ for i := range out {
+ out[i] = alphabet[rng.Intn(len(alphabet))]
+ }
+ return out
+ }
+ for i := 0; i < 400; i++ {
+ a, b := gen(rng.Intn(25)), gen(rng.Intn(25))
+
+ ops := reconcile.Diff(a, b)
+
+ require.Equal(t, append([]string{}, b...), append([]string{}, apply(a, b, ops)...), "a=%v b=%v ops=%v", a, b, ops)
+ common := 0
+ prevA, prevB := 0, 0
+ for _, o := range ops {
+ require.Equal(t, prevA, o.AStart, "ops must tile a without gaps: %v", ops)
+ require.Equal(t, prevB, o.BStart, "ops must tile b without gaps: %v", ops)
+ prevA, prevB = o.AEnd, o.BEnd
+ if o.Kind == reconcile.Equal {
+ common += o.AEnd - o.AStart
+ require.Equal(t, a[o.AStart:o.AEnd], b[o.BStart:o.BEnd], "an Equal op must really be equal")
+ }
+ }
+ require.Equal(t, len(a), prevA)
+ require.Equal(t, len(b), prevB)
+ require.Equal(t, lcsLen(a, b), common, "the diff must be minimal: a=%v b=%v", a, b)
+ }
+}
+
+func TestDiff_HandlesAnHoursWorthOfWordsFast(t *testing.T) {
+ rng := rand.New(rand.NewSource(3))
+ a := make([]string, 6500)
+ for i := range a {
+ a[i] = string(rune('a'+rng.Intn(26))) + string(rune('a'+rng.Intn(26)))
+ }
+ b := append([]string{}, a...)
+ for i := 0; i < 380; i++ { // ~380 disputes, as measured on a real hour
+ b[rng.Intn(len(b))] = "zz" + string(rune('a'+rng.Intn(26)))
+ }
+
+ ops := reconcile.Diff(a, b)
+
+ assert.NotEmpty(t, ops)
+ assert.Equal(t, append([]string{}, b...), append([]string{}, apply(a, b, ops)...))
+}
--
2.54.0
From aa5607b002d4aa4c3bf502586f60f7c2a2d76a76 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:16:31 +0200
Subject: [PATCH 083/299] feat(reconcile): tokenise for word-level comparison
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/tokens.go | 57 ++++++++++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 internal/reconcile/tokens.go
diff --git a/internal/reconcile/tokens.go b/internal/reconcile/tokens.go
new file mode 100644
index 0000000..30899fa
--- /dev/null
+++ b/internal/reconcile/tokens.go
@@ -0,0 +1,57 @@
+// Package reconcile merges two transcripts of the same speech: it finds where they
+// disagree word by word, resolves the trivial disagreements itself, and asks a LOCAL
+// model to choose between the two readings of the rest (ADR-034). It depends on
+// nothing outside the standard library and the domain; the model, the engines and
+// the store are wired in from outside.
+package reconcile
+
+import (
+ "strings"
+ "unicode"
+)
+
+// Token is one word of a transcript. Text is the word as written, with its casing
+// and punctuation, so the merged transcript reads naturally; Key is what two engines
+// are compared on: lower case, letters and digits only.
+type Token struct {
+ Text string
+ Key string
+}
+
+// Tokenize splits s on whitespace. A "word" that has no letter or digit (a dash, a
+// lone full stop) is not something two engines can disagree about, so it is joined
+// to the neighbouring word instead of becoming a token of its own.
+func Tokenize(s string) []Token {
+ var out []Token
+ pending := ""
+ for _, w := range strings.Fields(s) {
+ k := key(w)
+ if k == "" {
+ if len(out) > 0 {
+ out[len(out)-1].Text += " " + w
+ } else if pending == "" {
+ pending = w
+ } else {
+ pending += " " + w
+ }
+ continue
+ }
+ text := w
+ if pending != "" {
+ text = pending + " " + w
+ pending = ""
+ }
+ out = append(out, Token{Text: text, Key: k})
+ }
+ return out
+}
+
+func key(w string) string {
+ var b strings.Builder
+ for _, r := range w {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ b.WriteRune(unicode.ToLower(r))
+ }
+ }
+ return b.String()
+}
--
2.54.0
From 92a39da45c6e988b5aebcaf63c41a98b1d94ee7a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:16:31 +0200
Subject: [PATCH 084/299] feat(reconcile): minimal word-level diff (Myers) with
a bounded edit distance
Prefix/suffix trimmed first; adjacent deletes and inserts read as one Replace;
beyond 3,000 edits (two texts sharing almost nothing) the middle is one Replace
instead of gigabytes of trace. Mutation-checked: a wrong tie-break, missing
replace coalescing, wrong suffix accounting and a punctuation-bearing key each
turn tests red.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/diff.go | 187 +++++++++++++++++++++++++++++++++++++
1 file changed, 187 insertions(+)
create mode 100644 internal/reconcile/diff.go
diff --git a/internal/reconcile/diff.go b/internal/reconcile/diff.go
new file mode 100644
index 0000000..b558f85
--- /dev/null
+++ b/internal/reconcile/diff.go
@@ -0,0 +1,187 @@
+package reconcile
+
+// OpKind is the kind of one run in an edit script.
+type OpKind int
+
+const (
+ // Equal: A[AStart:AEnd] and B[BStart:BEnd] are the same words.
+ Equal OpKind = iota
+ // Replace: different words on both sides.
+ Replace
+ // Delete: words only in A.
+ Delete
+ // Insert: words only in B.
+ Insert
+)
+
+// Op is one run of the edit script. The ops of a Diff tile both inputs: each op starts
+// where the previous one ended, in A and in B.
+type Op struct {
+ Kind OpKind
+ AStart, AEnd int
+ BStart, BEnd int
+}
+
+// maxEdits bounds the Myers search. Memory grows with the square of the edit
+// distance, so two texts that share almost nothing (one engine produced garbage)
+// are reported as one Replace instead of spending gigabytes proving it. A real
+// hour disagrees in roughly 400 places, i.e. about 800 edits, far below this.
+const maxEdits = 3000
+
+// Diff returns a minimal edit script from a to b (Myers, O(ND)). Adjacent deletions
+// and insertions are reported as one Replace.
+func Diff(a, b []string) []Op {
+ pre := 0
+ for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
+ pre++
+ }
+ suf := 0
+ for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
+ suf++
+ }
+ midA, midB := a[pre:len(a)-suf], b[pre:len(b)-suf]
+
+ steps, ok := myers(midA, midB)
+ if !ok {
+ steps = steps[:0]
+ for range midA {
+ steps = append(steps, stepDel)
+ }
+ for range midB {
+ steps = append(steps, stepIns)
+ }
+ }
+
+ var ops []Op
+ ai, bi := 0, 0
+ emit := func(k OpKind, da, db int) {
+ if da == 0 && db == 0 {
+ return
+ }
+ if k == Equal && len(ops) > 0 && ops[len(ops)-1].Kind == Equal {
+ ops[len(ops)-1].AEnd += da
+ ops[len(ops)-1].BEnd += db
+ } else {
+ ops = append(ops, Op{Kind: k, AStart: ai, AEnd: ai + da, BStart: bi, BEnd: bi + db})
+ }
+ ai += da
+ bi += db
+ }
+ emit(Equal, pre, pre)
+ for i := 0; i < len(steps); {
+ if steps[i] == stepEq {
+ j := i
+ for j < len(steps) && steps[j] == stepEq {
+ j++
+ }
+ emit(Equal, j-i, j-i)
+ i = j
+ continue
+ }
+ dels, inss := 0, 0
+ for i < len(steps) && steps[i] != stepEq {
+ if steps[i] == stepDel {
+ dels++
+ } else {
+ inss++
+ }
+ i++
+ }
+ switch {
+ case dels > 0 && inss > 0:
+ emit(Replace, dels, inss)
+ case dels > 0:
+ emit(Delete, dels, 0)
+ default:
+ emit(Insert, 0, inss)
+ }
+ }
+ emit(Equal, suf, suf)
+ return ops
+}
+
+type step int8
+
+const (
+ stepEq step = iota
+ stepDel
+ stepIns
+)
+
+// myers returns one step per element of the edit script, in order. ok is false when
+// the edit distance exceeds maxEdits.
+func myers(a, b []string) ([]step, bool) {
+ n, m := len(a), len(b)
+ if n == 0 && m == 0 {
+ return nil, true
+ }
+ limit := min(n+m, maxEdits)
+ off := limit + 1
+ v := make([]int32, 2*limit+3)
+ var trace [][]int32 // trace[d] is v as it stood at the start of round d, for k in [-d, d]
+
+ for d := 0; d <= limit; d++ {
+ snap := make([]int32, 2*d+1)
+ for k := -d; k <= d; k++ {
+ snap[k+d] = v[off+k]
+ }
+ trace = append(trace, snap)
+ for k := -d; k <= d; k += 2 {
+ var x int
+ if k == -d || (k != d && v[off+k-1] < v[off+k+1]) {
+ x = int(v[off+k+1])
+ } else {
+ x = int(v[off+k-1]) + 1
+ }
+ y := x - k
+ for x < n && y < m && a[x] == b[y] {
+ x++
+ y++
+ }
+ v[off+k] = int32(x)
+ if x >= n && y >= m {
+ return backtrack(trace, n, m, d), true
+ }
+ }
+ }
+ return nil, false
+}
+
+func backtrack(trace [][]int32, n, m, d int) []step {
+ var rev []step
+ x, y := n, m
+ for ; d >= 0; d-- {
+ snap := trace[d]
+ at := func(k int) int { return int(snap[k+d]) }
+ k := x - y
+ var prevK int
+ if k == -d || (k != d && at(k-1) < at(k+1)) {
+ prevK = k + 1
+ } else {
+ prevK = k - 1
+ }
+ prevX := 0
+ if d > 0 {
+ prevX = at(prevK)
+ }
+ prevY := prevX - prevK
+ for x > prevX && y > prevY {
+ rev = append(rev, stepEq)
+ x--
+ y--
+ }
+ if d > 0 {
+ if x == prevX {
+ rev = append(rev, stepIns)
+ } else {
+ rev = append(rev, stepDel)
+ }
+ }
+ x, y = prevX, prevY
+ }
+ out := make([]step, len(rev))
+ for i, s := range rev {
+ out[len(rev)-1-i] = s
+ }
+ return out
+}
--
2.54.0
From 4e43327792ef22a60da8035a8b9f700e9316274f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:16:55 +0200
Subject: [PATCH 085/299] test(reconcile): finding disputes, resolving the
trivial ones, and their context (red)
A dispute carries both readings with their own spelling; differences separated by
a single shared word are one decision, two shared words keep them apart; compound
splits/joins and a short run of filler words are resolved without a model, while
numbers, content words, and 'nej' (an answer) are not.
Red: FindDisputes, AutoResolve and ContextAround are undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/disputes_test.go | 131 ++++++++++++++++++++++++++++
1 file changed, 131 insertions(+)
create mode 100644 internal/reconcile/disputes_test.go
diff --git a/internal/reconcile/disputes_test.go b/internal/reconcile/disputes_test.go
new file mode 100644
index 0000000..dfc6c54
--- /dev/null
+++ b/internal/reconcile/disputes_test.go
@@ -0,0 +1,131 @@
+package reconcile_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+func disputes(base, alt string) []reconcile.Dispute {
+ return reconcile.FindDisputes(reconcile.Tokenize(base), reconcile.Tokenize(alt))
+}
+
+func TestFindDisputes_NoneWhenTheTextsAgreeWordForWord(t *testing.T) {
+ assert.Empty(t, disputes("Vi går igenom läget.", "vi går igenom läget"),
+ "casing and punctuation are not disagreements")
+}
+
+func TestFindDisputes_OneReplacedWordIsOneDisputeCarryingBothReadings(t *testing.T) {
+ got := disputes("Vi ska onboardade kunderna.", "Vi ska omvårdade kunderna.")
+
+ require.Len(t, got, 1)
+ d := got[0]
+ assert.Equal(t, "onboardade", d.Base)
+ assert.Equal(t, "omvårdade", d.Alt)
+ assert.Equal(t, 2, d.BaseStart)
+ assert.Equal(t, 3, d.BaseEnd)
+ assert.Equal(t, 2, d.AltStart)
+ assert.Equal(t, 3, d.AltEnd)
+}
+
+func TestFindDisputes_ReadingsKeepTheirOwnSpellingAndPunctuation(t *testing.T) {
+ got := disputes("Det blir T+1, alltså.", "Det blir T 1, alltså.")
+
+ require.Len(t, got, 1)
+ assert.Equal(t, "T+1,", got[0].Base)
+ assert.Equal(t, "T 1,", got[0].Alt, "the alternative's words replace the span as the engine wrote them")
+}
+
+func TestFindDisputes_AWordOnlyOneEngineHeardHasAnEmptyOtherSide(t *testing.T) {
+ got := disputes("Man får tillbaka ett förslag.", "Man får tillbaka ett coolt förslag.")
+
+ require.Len(t, got, 1)
+ assert.Equal(t, "", got[0].Base)
+ assert.Equal(t, "coolt", got[0].Alt)
+ assert.Equal(t, got[0].BaseStart, got[0].BaseEnd, "an insertion has an empty base range at its position")
+ assert.Equal(t, 4, got[0].BaseStart)
+}
+
+func TestFindDisputes_TwoDifferencesSeparatedByOneSharedWordAreOneDispute(t *testing.T) {
+ // "x ... y" around a single agreed word is one decision for the reader, not two.
+ got := disputes("a b c d e", "a x c y e")
+
+ require.Len(t, got, 1)
+ assert.Equal(t, "b c d", got[0].Base)
+ assert.Equal(t, "x c y", got[0].Alt)
+}
+
+func TestFindDisputes_TwoSharedWordsKeepTheDisputesApart(t *testing.T) {
+ got := disputes("a b c d e f", "a x c d y f")
+
+ require.Len(t, got, 2)
+ assert.Equal(t, "b", got[0].Base)
+ assert.Equal(t, "e", got[1].Base)
+}
+
+func TestFindDisputes_ChainsOfCloseDifferencesMergeIntoOne(t *testing.T) {
+ got := disputes("a b c d e f g", "a x c y e z g")
+
+ require.Len(t, got, 1)
+ assert.Equal(t, "b c d e f", got[0].Base)
+}
+
+func TestFindDisputes_LeavesIndicesThatAddressBothTokenSlices(t *testing.T) {
+ base := reconcile.Tokenize("Hej Anders och Maria")
+ alt := reconcile.Tokenize("Hej Sanders och Maria")
+
+ got := reconcile.FindDisputes(base, alt)
+
+ require.Len(t, got, 1)
+ assert.Equal(t, "Anders", base[got[0].BaseStart].Text)
+ assert.Equal(t, "Sanders", alt[got[0].AltStart].Text)
+}
+
+func TestAutoResolve(t *testing.T) {
+ tests := []struct {
+ name string
+ base, alt string
+ resolved bool
+ }{
+ {"a compound written as two words or one", "i crossborder delen", "i crossborderdelen", true},
+ {"a compound with a hyphen", "crossborder-delen", "crossborder delen", true},
+ {"a filler only the alternative heard", "vi ska ju betala", "vi ska betala", true},
+ {"a filler only the base heard", "vi ska betala", "vi ska ju betala", true},
+ {"two fillers in a row", "vi ska ja ju betala", "vi ska betala", true},
+ {"a different word is for the model", "vi ska onboardade", "vi ska omvårdade", false},
+ {"a different number is for the model", "totalt 1050 kronor", "totalt 2050 kronor", false},
+ {"a content word only one side heard is for the model", "ett förslag", "ett coolt förslag", false},
+ {"three filler words are too many to wave through", "vi ska ju då liksom betala", "vi ska betala", false},
+ {"a filler swapped for a content word is for the model", "vi ska ju betala", "vi ska nu betala", false},
+ {"nej is an answer, not a filler", "men nej det går inte", "men det går inte", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := disputes(tt.base, tt.alt)
+ require.Len(t, got, 1, "the fixture must be exactly one dispute")
+
+ assert.Equal(t, tt.resolved, reconcile.AutoResolve(got[0]))
+ })
+ }
+}
+
+func TestContextAround_TakesWordsFromTheBaseOnBothSidesAndStopsAtTheEdges(t *testing.T) {
+ base := reconcile.Tokenize("ett två tre fyra fem sex sju åtta nio tio")
+ d := reconcile.Dispute{BaseStart: 4, BaseEnd: 6} // "fem sex"
+
+ before, after := reconcile.ContextAround(base, d, 3)
+
+ assert.Equal(t, "två tre fyra", before)
+ assert.Equal(t, "sju åtta nio", after)
+
+ b2, a2 := reconcile.ContextAround(base, reconcile.Dispute{BaseStart: 0, BaseEnd: 1}, 3)
+ assert.Equal(t, "", b2)
+ assert.Equal(t, "två tre fyra", a2)
+
+ b3, a3 := reconcile.ContextAround(base, reconcile.Dispute{BaseStart: 9, BaseEnd: 10}, 3)
+ assert.Equal(t, "sju åtta nio", b3)
+ assert.Equal(t, "", a3)
+}
--
2.54.0
From 2600e6c45ab40a6c34486ce1f56200810d9de388 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:17:21 +0200
Subject: [PATCH 086/299] feat(reconcile): find disputes, resolve trivial ones
without a model, give context
Disputes carry both readings with their own spelling; differences separated by
one shared word merge into one decision. Compound splits/joins and one or two
filler particles are resolved without a model; 'nej', numbers and content words
never are. Mutation-checked (merge distance, filler limit, 'nej', compound rule).
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/disputes.go | 100 +++++++++++++++++++++++++++++++++
1 file changed, 100 insertions(+)
create mode 100644 internal/reconcile/disputes.go
diff --git a/internal/reconcile/disputes.go b/internal/reconcile/disputes.go
new file mode 100644
index 0000000..27b0be5
--- /dev/null
+++ b/internal/reconcile/disputes.go
@@ -0,0 +1,100 @@
+package reconcile
+
+import "strings"
+
+// Dispute is one place where the two transcripts disagree. BaseStart:BaseEnd and
+// AltStart:AltEnd are token ranges into the two Tokenize results; an empty range
+// means that engine heard nothing there. Base and Alt are the two readings as each
+// engine wrote them, so a replaced span takes the alternative's own spelling.
+type Dispute struct {
+ BaseStart, BaseEnd int
+ AltStart, AltEnd int
+ Base, Alt string
+}
+
+// FindDisputes aligns base against alt word by word and returns the disagreements.
+// Differences separated by a single shared word are merged into one dispute: for a
+// reader that is one decision, and asking about "b" and "d" separately in "b c d"
+// hides that "c" sits between them. Two shared words keep disputes apart.
+func FindDisputes(base, alt []Token) []Dispute {
+ a, b := keysOf(base), keysOf(alt)
+ ops := Diff(a, b)
+ var out []Dispute
+ for i := 0; i < len(ops); i++ {
+ if ops[i].Kind == Equal {
+ continue
+ }
+ cur := ops[i]
+ for i+2 < len(ops) && ops[i+1].Kind == Equal && ops[i+1].AEnd-ops[i+1].AStart == 1 && ops[i+2].Kind != Equal {
+ cur.AEnd, cur.BEnd = ops[i+2].AEnd, ops[i+2].BEnd
+ i += 2
+ }
+ out = append(out, Dispute{
+ BaseStart: cur.AStart, BaseEnd: cur.AEnd,
+ AltStart: cur.BStart, AltEnd: cur.BEnd,
+ Base: join(base[cur.AStart:cur.AEnd]),
+ Alt: join(alt[cur.BStart:cur.BEnd]),
+ })
+ }
+ return out
+}
+
+// fillers are Swedish discourse particles. A dispute that is only one or two of them
+// on one side changes no meaning, so it is resolved without asking a model. The list
+// is deliberately short and leaves out words that can be an answer ("nej") or that
+// carry content.
+var fillers = map[string]bool{
+ "ju": true, "då": true, "liksom": true, "alltså": true, "så": true, "ja": true,
+ "eh": true, "ehm": true, "öh": true, "mm": true, "hm": true, "typ": true, "va": true, "asså": true,
+}
+
+// maxFillerRun is how many filler words may be waved through together.
+const maxFillerRun = 2
+
+// AutoResolve reports whether a dispute needs no model, in which case the base
+// reading is kept: either the two sides are the same letters once spaces are removed
+// (a compound written as one word or two), or one side is empty and the other is a
+// short run of filler words.
+func AutoResolve(d Dispute) bool {
+ baseKeys, altKeys := keysOfText(d.Base), keysOfText(d.Alt)
+ if strings.Join(baseKeys, "") == strings.Join(altKeys, "") {
+ return true
+ }
+ return (len(baseKeys) == 0 && onlyFillers(altKeys)) || (len(altKeys) == 0 && onlyFillers(baseKeys))
+}
+
+func onlyFillers(keys []string) bool {
+ if len(keys) == 0 || len(keys) > maxFillerRun {
+ return false
+ }
+ for _, k := range keys {
+ if !fillers[k] {
+ return false
+ }
+ }
+ return true
+}
+
+// ContextAround returns up to window words of the base before and after the dispute,
+// as the base wrote them.
+func ContextAround(base []Token, d Dispute, window int) (before, after string) {
+ return join(base[max(0, d.BaseStart-window):d.BaseStart]), join(base[d.BaseEnd:min(len(base), d.BaseEnd+window)])
+}
+
+func keysOf(ts []Token) []string {
+ out := make([]string, len(ts))
+ for i, t := range ts {
+ out[i] = t.Key
+ }
+ return out
+}
+
+func keysOfText(s string) []string { return keysOf(Tokenize(s)) }
+
+func join(ts []Token) string {
+ parts := make([]string, len(ts))
+ for i, t := range ts {
+ parts[i] = t.Text
+ }
+ return strings.Join(parts, " ")
+}
--
2.54.0
From 481321a75caa1c2776e9688edb0f9dad75cbb511 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:17:42 +0200
Subject: [PATCH 087/299] test(reconcile): the adjudicator sends the measured
prompt and maps answers back (red)
Readings in the requested order; 1/2 mapped to base/alt under both orders;
UNSURE, an unparseable or out-of-range reply is unsure rather than a guess; an
empty reading is described, not blank; a model error is an error, distinguishable
from 'unsure'. The optional order-checked wrapper asks twice with the order swapped
and keeps only agreement.
Red: Request, Verdict, NewLLMAdjudicator and NewOrderChecked are undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/adjudicate_test.go | 145 ++++++++++++++++++++++++++
1 file changed, 145 insertions(+)
create mode 100644 internal/reconcile/adjudicate_test.go
diff --git a/internal/reconcile/adjudicate_test.go b/internal/reconcile/adjudicate_test.go
new file mode 100644
index 0000000..beaf539
--- /dev/null
+++ b/internal/reconcile/adjudicate_test.go
@@ -0,0 +1,145 @@
+package reconcile_test
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+type fakeCompleter struct {
+ replies []string
+ err error
+ systems []string
+ users []string
+}
+
+func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string, error) {
+ f.systems = append(f.systems, system)
+ f.users = append(f.users, user)
+ if f.err != nil {
+ return "", f.err
+ }
+ i := len(f.users) - 1
+ if i >= len(f.replies) {
+ i = len(f.replies) - 1
+ }
+ return f.replies[i], nil
+}
+
+var req = reconcile.Request{Before: "kunderna ska då", After: "De ska kunna betala", Base: "onboardade", Alt: "omvårdade"}
+
+func TestLLMAdjudicator_SendsTheMeasuredPromptWithTheReadingsInTheRequestedOrder(t *testing.T) {
+ fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
+ a := reconcile.NewLLMAdjudicator(fc)
+
+ _, err := a.Adjudicate(context.Background(), req)
+
+ require.NoError(t, err)
+ require.Len(t, fc.users, 1)
+ assert.Contains(t, fc.systems[0], "two automatic transcriptions of the same Swedish speech")
+ assert.Contains(t, fc.systems[0], "ANSWER: 1")
+ u := fc.users[0]
+ assert.Contains(t, u, "Text before the spot:\nkunderna ska då")
+ assert.Contains(t, u, "[[ THE SPOT ]]")
+ assert.Contains(t, u, "Text after the spot:\nDe ska kunna betala")
+ assert.Contains(t, u, "Reading 1: onboardade\nReading 2: omvårdade", "base first when AltFirst is false")
+}
+
+func TestLLMAdjudicator_AltFirstSwapsWhichReadingIsNumberOne(t *testing.T) {
+ fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
+ r := req
+ r.AltFirst = true
+
+ _, err := reconcile.NewLLMAdjudicator(fc).Adjudicate(context.Background(), r)
+
+ require.NoError(t, err)
+ assert.Contains(t, fc.users[0], "Reading 1: omvårdade\nReading 2: onboardade")
+}
+
+func TestLLMAdjudicator_MapsTheAnswerBackToBaseOrAltWhicheverOrderWasShown(t *testing.T) {
+ tests := []struct {
+ name string
+ altFirst bool
+ reply string
+ want reconcile.Verdict
+ }{
+ {"1 with base first is base", false, "ANSWER: 1", reconcile.VerdictBase},
+ {"2 with base first is alt", false, "ANSWER: 2", reconcile.VerdictAlt},
+ {"1 with alt first is alt", true, "ANSWER: 1", reconcile.VerdictAlt},
+ {"2 with alt first is base", true, "ANSWER: 2", reconcile.VerdictBase},
+ {"unsure is unsure", false, "ANSWER: UNSURE", reconcile.VerdictUnsure},
+ {"case and trailing reasoning are tolerated", false, "answer: 2\nThe second reading fits the context.", reconcile.VerdictAlt},
+ {"reasoning before the answer line is tolerated", true, "Hmm.\nANSWER: 2", reconcile.VerdictBase},
+ {"no answer line is unsure, not a guess", false, "I think the first one.", reconcile.VerdictUnsure},
+ {"an empty reply is unsure", false, "", reconcile.VerdictUnsure},
+ {"an out of range number is unsure", false, "ANSWER: 3", reconcile.VerdictUnsure},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := req
+ r.AltFirst = tt.altFirst
+
+ got, err := reconcile.NewLLMAdjudicator(&fakeCompleter{replies: []string{tt.reply}}).Adjudicate(context.Background(), r)
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestLLMAdjudicator_AnEmptyReadingIsDescribedNotLeftBlank(t *testing.T) {
+ fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
+ r := req
+ r.Base = ""
+
+ _, err := reconcile.NewLLMAdjudicator(fc).Adjudicate(context.Background(), r)
+
+ require.NoError(t, err)
+ assert.Contains(t, fc.users[0], "Reading 1: (nothing: the words are simply absent)")
+}
+
+func TestLLMAdjudicator_ACompleterErrorIsReturnedNotTurnedIntoAVerdict(t *testing.T) {
+ boom := errors.New("gateway down")
+
+ _, err := reconcile.NewLLMAdjudicator(&fakeCompleter{err: boom}).Adjudicate(context.Background(), req)
+
+ assert.ErrorIs(t, err, boom, "the caller must be able to tell 'the model is down' from 'the model is unsure'")
+}
+
+func TestOrderChecked_AsksTwiceWithTheOrderSwappedAndKeepsOnlyAgreement(t *testing.T) {
+ tests := []struct {
+ name string
+ replies []string // for [base-first call, alt-first call]
+ want reconcile.Verdict
+ }{
+ {"both orders pick the base", []string{"ANSWER: 1", "ANSWER: 2"}, reconcile.VerdictBase},
+ {"both orders pick the alternative", []string{"ANSWER: 2", "ANSWER: 1"}, reconcile.VerdictAlt},
+ {"it flips with the order, so it is position bias, not a judgement", []string{"ANSWER: 1", "ANSWER: 1"}, reconcile.VerdictUnsure},
+ {"one order unsure is unsure", []string{"ANSWER: 1", "ANSWER: UNSURE"}, reconcile.VerdictUnsure},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fc := &fakeCompleter{replies: tt.replies}
+
+ got, err := reconcile.NewOrderChecked(reconcile.NewLLMAdjudicator(fc)).Adjudicate(context.Background(), req)
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ require.Len(t, fc.users, 2)
+ assert.True(t, strings.Contains(fc.users[0], "Reading 1: onboardade"), "first call shows the base first")
+ assert.True(t, strings.Contains(fc.users[1], "Reading 1: omvårdade"), "second call shows the alternative first")
+ })
+ }
+}
+
+func TestOrderChecked_AnErrorOnEitherCallIsAnError(t *testing.T) {
+ _, err := reconcile.NewOrderChecked(reconcile.NewLLMAdjudicator(&fakeCompleter{err: errors.New("down")})).Adjudicate(context.Background(), req)
+
+ assert.Error(t, err)
+}
--
2.54.0
From 2bfe84ee6409fd86949ee081441b8918652ab835 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:18:11 +0200
Subject: [PATCH 088/299] test(reconcile): a number or word that merely starts
with 1 or 2 is not an answer
Mutation-testing the adjudicator showed dropping the word boundary went unnoticed:
'ANSWER: 12' would be read as 1.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/adjudicate_test.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/internal/reconcile/adjudicate_test.go b/internal/reconcile/adjudicate_test.go
index beaf539..01aff56 100644
--- a/internal/reconcile/adjudicate_test.go
+++ b/internal/reconcile/adjudicate_test.go
@@ -79,6 +79,8 @@ func TestLLMAdjudicator_MapsTheAnswerBackToBaseOrAltWhicheverOrderWasShown(t *te
{"no answer line is unsure, not a guess", false, "I think the first one.", reconcile.VerdictUnsure},
{"an empty reply is unsure", false, "", reconcile.VerdictUnsure},
{"an out of range number is unsure", false, "ANSWER: 3", reconcile.VerdictUnsure},
+ {"a number that merely starts with 1 is not 1", false, "ANSWER: 12", reconcile.VerdictUnsure},
+ {"a word that merely starts with 2 is not 2", false, "ANSWER: 2nd option", reconcile.VerdictUnsure},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
--
2.54.0
From 5e54c9beff53be289797aaa5fe0033d91dff9d48 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:18:11 +0200
Subject: [PATCH 089/299] feat(reconcile): LLM adjudicator with the measured
prompt, plus an order-checked wrapper
Sends the exact wording that scored 92% (gemma4-26b) and 97% (gemma4-31b); maps
1/2 back to base/alt under both orders; an unreadable reply is unsure, a transport
error is an error. OrderChecked asks twice with the order swapped and keeps only
agreement. Mutation-checked: answer mapping, unparseable-as-guess, the word
boundary and the order check each turn a test red.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/adjudicate.go | 119 +++++++++++++++++++++++++++++++
1 file changed, 119 insertions(+)
create mode 100644 internal/reconcile/adjudicate.go
diff --git a/internal/reconcile/adjudicate.go b/internal/reconcile/adjudicate.go
new file mode 100644
index 0000000..6959a60
--- /dev/null
+++ b/internal/reconcile/adjudicate.go
@@ -0,0 +1,119 @@
+package reconcile
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// Verdict is an adjudicator's decision about one dispute. The zero value is
+// VerdictUnsure: with no decision the base reading stands and the spot is flagged.
+type Verdict int
+
+const (
+ VerdictUnsure Verdict = iota
+ VerdictBase
+ VerdictAlt
+)
+
+// Request is one dispute as the adjudicator sees it: the surrounding words, the two
+// readings, and which of them is shown as "Reading 1". Alternating AltFirst between
+// disputes averages out position bias.
+type Request struct {
+ Before, After string
+ Base, Alt string
+ AltFirst bool
+}
+
+// Adjudicator decides which reading of a dispute the speaker most likely said.
+type Adjudicator interface {
+ Adjudicate(ctx context.Context, r Request) (Verdict, error)
+}
+
+// Completer is the chat surface the LLM adjudicator needs. *llm.Client satisfies it.
+type Completer interface {
+ Complete(ctx context.Context, system, user string) (string, error)
+}
+
+// systemPrompt is the wording that was MEASURED (docs/research/
+// local-model-adjudicator-sweep-2026-10-01.md): gemma4-26b 92% and gemma4-31b 97% against
+// the speaker's blind answers. Changing it invalidates those figures.
+const systemPrompt = "You compare two automatic transcriptions of the same Swedish speech. A single person is dictating project notes " +
+ "(finance and payments jargon, with English terms mixed into Swedish). The two transcriptions differ at ONE marked spot. " +
+ "Decide which reading is what the speaker most likely actually said, using the surrounding text and ordinary Swedish. " +
+ "Reply with exactly one line, either 'ANSWER: 1', 'ANSWER: 2' or 'ANSWER: UNSURE', then one short sentence of reasoning."
+
+var answerRe = regexp.MustCompile(`(?i)ANSWER:\s*(1|2|UNSURE)\b`)
+
+// LLMAdjudicator asks a chat model. It never invents an answer: a reply it cannot read
+// is VerdictUnsure, and a transport error is returned as an error so the caller can tell
+// "the model is down" from "the model is unsure".
+type LLMAdjudicator struct{ c Completer }
+
+// NewLLMAdjudicator wraps a Completer.
+func NewLLMAdjudicator(c Completer) *LLMAdjudicator { return &LLMAdjudicator{c: c} }
+
+// Adjudicate implements Adjudicator.
+func (a *LLMAdjudicator) Adjudicate(ctx context.Context, r Request) (Verdict, error) {
+ first, second := r.Base, r.Alt
+ if r.AltFirst {
+ first, second = r.Alt, r.Base
+ }
+ user := fmt.Sprintf("Text before the spot:\n%s\n\n[[ THE SPOT ]]\n\nText after the spot:\n%s\n\nReading 1: %s\nReading 2: %s\n",
+ r.Before, r.After, describe(first), describe(second))
+ reply, err := a.c.Complete(ctx, systemPrompt, user)
+ if err != nil {
+ return VerdictUnsure, fmt.Errorf("reconcile: adjudicate: %w", err)
+ }
+ m := answerRe.FindStringSubmatch(reply)
+ if m == nil {
+ return VerdictUnsure, nil
+ }
+ switch strings.ToUpper(m[1]) {
+ case "1":
+ if r.AltFirst {
+ return VerdictAlt, nil
+ }
+ return VerdictBase, nil
+ case "2":
+ if r.AltFirst {
+ return VerdictBase, nil
+ }
+ return VerdictAlt, nil
+ }
+ return VerdictUnsure, nil
+}
+
+func describe(reading string) string {
+ if strings.TrimSpace(reading) == "" {
+ return "(nothing: the words are simply absent)"
+ }
+ return reading
+}
+
+// OrderChecked asks the inner adjudicator twice with the reading order swapped and
+// keeps a verdict only when both agree. A model that flips with the order is showing
+// position bias, not judgement, so disagreement is VerdictUnsure. It doubles the time.
+type OrderChecked struct{ inner Adjudicator }
+
+// NewOrderChecked wraps an adjudicator.
+func NewOrderChecked(inner Adjudicator) *OrderChecked { return &OrderChecked{inner: inner} }
+
+// Adjudicate implements Adjudicator.
+func (o *OrderChecked) Adjudicate(ctx context.Context, r Request) (Verdict, error) {
+ r.AltFirst = false
+ v1, err := o.inner.Adjudicate(ctx, r)
+ if err != nil {
+ return VerdictUnsure, err
+ }
+ r.AltFirst = true
+ v2, err := o.inner.Adjudicate(ctx, r)
+ if err != nil {
+ return VerdictUnsure, err
+ }
+ if v1 == v2 {
+ return v1, nil
+ }
+ return VerdictUnsure, nil
+}
--
2.54.0
From b20558ffa61dcf936aa3bf3db0eb93dac9efdd9a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:19:00 +0200
Subject: [PATCH 090/299] test(reconcile): merging two transcripts, and failing
soft (red)
Identical texts and trivial disputes cost no model call; an alt verdict takes the
alternative's own words, base/unsure keep the base and only unsure is flagged;
insertions and deletions land in the right place; the reading order alternates; the
model gets the base's context. Three consecutive model errors stop the whole
reconciliation (base text, none of the partial changes, reason recorded) while a
success in between resets the breaker; a cancelled job is an error, not 'skipped'.
Spots carry the second they start at; the stored list is capped, the counts are not.
Red: Reconcile, Options, MaxContested and domain.Reconcile* are undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_test.go | 279 +++++++++++++++++++++++++++
1 file changed, 279 insertions(+)
create mode 100644 internal/reconcile/reconcile_test.go
diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go
new file mode 100644
index 0000000..d97a44f
--- /dev/null
+++ b/internal/reconcile/reconcile_test.go
@@ -0,0 +1,279 @@
+package reconcile_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// scripted is an Adjudicator that answers from a function and records every request.
+type scripted struct {
+ fn func(i int, r reconcile.Request) (reconcile.Verdict, error)
+ calls []reconcile.Request
+}
+
+func (s *scripted) Adjudicate(_ context.Context, r reconcile.Request) (reconcile.Verdict, error) {
+ i := len(s.calls)
+ s.calls = append(s.calls, r)
+ return s.fn(i, r)
+}
+
+func always(v reconcile.Verdict) *scripted {
+ return &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) { return v, nil }}
+}
+
+func tr(text string) domain.Transcription {
+ return domain.Transcription{Text: text, AudioSeconds: 100, Endpoint: "e"}
+}
+
+var opts = reconcile.Options{Window: 100, BreakAfter: 3, Reconciler: "iguana/gemma4-26b", BaseEngine: "iguana/pianissimo", AltEngine: "iguana/kb-whisper"}
+
+func merge(t *testing.T, base, alt domain.Transcription, adj reconcile.Adjudicator) domain.Transcription {
+ t.Helper()
+ out, err := reconcile.Reconcile(context.Background(), base, alt, adj, opts)
+ require.NoError(t, err)
+ return out
+}
+
+func TestReconcile_IdenticalTextsNeedNoModelAndComeBackUnchanged(t *testing.T) {
+ adj := always(reconcile.VerdictAlt)
+
+ out := merge(t, tr("Vi går igenom läget."), tr("vi går igenom läget"), adj)
+
+ assert.Equal(t, "Vi går igenom läget.", out.Text, "the base's own spelling and punctuation stand")
+ assert.Empty(t, adj.calls)
+ require.NotNil(t, out.Reconciliation)
+ assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
+ assert.Zero(t, out.Reconciliation.Disputes)
+}
+
+func TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel(t *testing.T) {
+ adj := always(reconcile.VerdictAlt)
+
+ out := merge(t, tr("i crossborder delen ska vi ju betala"), tr("i crossborderdelen ska vi betala"), adj)
+
+ assert.Empty(t, adj.calls, "a compound split and a filler particle are not worth a model call")
+ assert.Equal(t, "i crossborder delen ska vi ju betala", out.Text, "the base reading is kept")
+ assert.Equal(t, 2, out.Reconciliation.Disputes)
+ assert.Equal(t, 2, out.Reconciliation.AutoResolved)
+ assert.Zero(t, out.Reconciliation.Adjudicated)
+}
+
+func TestReconcile_AltVerdictReplacesTheSpanWithTheAlternativesOwnWords(t *testing.T) {
+ out := merge(t, tr("Totalt 1015 kronor. Det ska betalas."), tr("Totalt 1050 kronor. Det ska betalas."), always(reconcile.VerdictAlt))
+
+ assert.Equal(t, "Totalt 1050 kronor. Det ska betalas.", out.Text)
+ assert.Equal(t, 1, out.Reconciliation.Changed)
+ assert.Equal(t, 1, out.Reconciliation.Adjudicated)
+}
+
+func TestReconcile_BaseAndUnsureVerdictsKeepTheBaseButOnlyUnsureIsFlagged(t *testing.T) {
+ base := tr("Totalt 1015 kronor till SEB.")
+ alt := tr("Totalt 1050 kronor till SCB.")
+
+ keptBase := merge(t, base, alt, always(reconcile.VerdictBase))
+ unsure := merge(t, base, alt, always(reconcile.VerdictUnsure))
+
+ assert.Equal(t, "Totalt 1015 kronor till SEB.", keptBase.Text)
+ assert.Zero(t, keptBase.Reconciliation.Unsure)
+ assert.Empty(t, keptBase.Reconciliation.Contested, "a confident 'the base is right' is not a spot worth the reader's time")
+ assert.Equal(t, "Totalt 1015 kronor till SEB.", unsure.Text, "unsure keeps the base")
+ assert.Equal(t, 2, unsure.Reconciliation.Unsure)
+ require.Len(t, unsure.Reconciliation.Contested, 2)
+ assert.True(t, unsure.Reconciliation.Contested[0].Unsure)
+ assert.Equal(t, "base", unsure.Reconciliation.Contested[0].Used)
+}
+
+func TestReconcile_ChangedSpotsAreRecordedWithBothReadings(t *testing.T) {
+ out := merge(t, tr("det blir T+1 imorgon"), tr("det blir en T imorgon"), always(reconcile.VerdictAlt))
+
+ require.Len(t, out.Reconciliation.Contested, 1)
+ c := out.Reconciliation.Contested[0]
+ assert.Equal(t, "T+1", c.Base)
+ assert.Equal(t, "en T", c.Alt)
+ assert.Equal(t, "alt", c.Used)
+ assert.False(t, c.Unsure)
+}
+
+func TestReconcile_AWordOnlyTheAlternativeHeardIsInsertedWhereItBelongs(t *testing.T) {
+ out := merge(t, tr("Man får tillbaka ett förslag."), tr("Man får tillbaka ett coolt förslag."), always(reconcile.VerdictAlt))
+
+ assert.Equal(t, "Man får tillbaka ett coolt förslag.", out.Text)
+}
+
+func TestReconcile_AWordOnlyTheBaseHeardCanBeRemoved(t *testing.T) {
+ out := merge(t, tr("Man får tillbaka ett coolt förslag."), tr("Man får tillbaka ett förslag."), always(reconcile.VerdictAlt))
+
+ assert.Equal(t, "Man får tillbaka ett förslag.", out.Text)
+}
+
+func TestReconcile_SeveralDisputesAreAppliedIndependentlyAndInOrder(t *testing.T) {
+ adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
+ if r.Alt == "omvårdade" || r.Alt == "SCB" {
+ return reconcile.VerdictAlt, nil
+ }
+ return reconcile.VerdictBase, nil
+ }}
+
+ out := merge(t, tr("kunderna onboardade nu och sen betalas till SEB med 1050 kronor"),
+ tr("kunderna omvårdade nu och sen betalas till SCB med 2050 kronor"), adj)
+
+ assert.Equal(t, "kunderna omvårdade nu och sen betalas till SCB med 1050 kronor", out.Text)
+ assert.Equal(t, 3, out.Reconciliation.Disputes)
+ assert.Equal(t, 2, out.Reconciliation.Changed)
+}
+
+func TestReconcile_TheReadingOrderAlternatesFromOneDisputeToTheNext(t *testing.T) {
+ adj := always(reconcile.VerdictBase)
+
+ merge(t, tr("a x b y c z d"), tr("a p b q c r d"), adj)
+
+ require.Len(t, adj.calls, 3)
+ assert.False(t, adj.calls[0].AltFirst)
+ assert.True(t, adj.calls[1].AltFirst)
+ assert.False(t, adj.calls[2].AltFirst, "alternating averages out position bias across a transcript")
+}
+
+func TestReconcile_TheModelGetsTheContextWindowFromTheBase(t *testing.T) {
+ adj := always(reconcile.VerdictBase)
+ o := opts
+ o.Window = 2
+
+ _, err := reconcile.Reconcile(context.Background(), tr("ett två tre fyra fem sex sju åtta nio"), tr("ett två tre fyra FEMM sex sju åtta nio"), adj, o)
+
+ require.NoError(t, err)
+ require.Len(t, adj.calls, 1)
+ assert.Equal(t, "tre fyra", adj.calls[0].Before)
+ assert.Equal(t, "sex sju", adj.calls[0].After)
+ assert.Equal(t, "fem", adj.calls[0].Base)
+ assert.Equal(t, "FEMM", adj.calls[0].Alt)
+}
+
+func TestReconcile_OneModelErrorLeavesThatSpotUnsureAndCarriesOn(t *testing.T) {
+ boom := errors.New("timeout")
+ adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
+ if i == 0 {
+ return reconcile.VerdictUnsure, boom
+ }
+ return reconcile.VerdictAlt, nil
+ }}
+
+ out := merge(t, tr("a x b y c"), tr("a p b q c"), adj)
+
+ assert.Equal(t, "a x b q c", out.Text, "the first spot stayed as the base, the second was decided")
+ assert.Equal(t, 1, out.Reconciliation.Errors)
+ assert.Equal(t, 1, out.Reconciliation.Unsure)
+ assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
+}
+
+func TestReconcile_ConsecutiveModelErrorsStopTheWholeReconciliationInsteadOfWaitingOutEveryTimeout(t *testing.T) {
+ boom := errors.New("gateway down")
+ adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) { return reconcile.VerdictUnsure, boom }}
+ a, b := make([]string, 0, 20), make([]string, 0, 20)
+ for i := 0; i < 10; i++ {
+ a = append(a, fmt.Sprintf("w%d", i), "same")
+ b = append(b, fmt.Sprintf("v%d", i), "same")
+ }
+
+ out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), adj)
+
+ assert.Len(t, adj.calls, 3, "three consecutive failures and it stops asking")
+ assert.Equal(t, strings.Join(a, " "), out.Text, "the base text, with none of the partial changes")
+ assert.Equal(t, domain.ReconcileSkipped, out.Reconciliation.Status)
+ assert.Contains(t, out.Reconciliation.Reason, "gateway down")
+}
+
+func TestReconcile_ASuccessBetweenFailuresResetsTheBreaker(t *testing.T) {
+ boom := errors.New("blip")
+ adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
+ if i%3 == 2 {
+ return reconcile.VerdictBase, nil
+ }
+ return reconcile.VerdictUnsure, boom
+ }}
+ a, b := make([]string, 0, 20), make([]string, 0, 20)
+ for i := 0; i < 9; i++ {
+ a = append(a, fmt.Sprintf("w%d", i), "same")
+ b = append(b, fmt.Sprintf("v%d", i), "same")
+ }
+
+ out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), adj)
+
+ assert.Len(t, adj.calls, 9, "two failures then a success never reach three in a row")
+ assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
+ assert.Equal(t, 6, out.Reconciliation.Errors)
+}
+
+func TestReconcile_ACancelledJobIsAnErrorNotASkippedReconciliation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
+ cancel()
+ return reconcile.VerdictUnsure, ctx.Err()
+ }}
+
+ _, err := reconcile.Reconcile(ctx, tr("a x b"), tr("a y b"), adj, opts)
+
+ assert.ErrorIs(t, err, context.Canceled, "a shutdown must not be recorded as 'the model was unavailable'")
+}
+
+func TestReconcile_KeepsTheBaseSegmentsDurationAndNamesTheEngines(t *testing.T) {
+ base := tr("a x b")
+ base.Segments = []domain.Segment{{Start: 0, End: 4, Text: "a x b"}}
+ base.AudioSeconds = 3821.5
+
+ out := merge(t, base, tr("a y b"), always(reconcile.VerdictAlt))
+
+ assert.Equal(t, base.Segments, out.Segments, "the timed segments are the base engine's, untouched")
+ assert.InDelta(t, 3821.5, out.AudioSeconds, 1e-9)
+ assert.Equal(t, "iguana/pianissimo", out.Reconciliation.Base)
+ assert.Equal(t, "iguana/kb-whisper", out.Reconciliation.Alt)
+ assert.Equal(t, "iguana/gemma4-26b", out.Reconciliation.Reconciler)
+}
+
+func TestReconcile_ASpotCarriesTheSecondItStartsAt(t *testing.T) {
+ base := tr("ett två tre. fyra fem sex. sju åtta nio.")
+ base.Segments = []domain.Segment{
+ {Start: 0, End: 10, Text: "ett två tre."},
+ {Start: 10, End: 20, Text: "fyra fem sex."},
+ {Start: 20, End: 30, Text: "sju åtta nio."},
+ }
+ alt := tr("ett två tre. fyra femm sex. sju åtta nio.")
+
+ out := merge(t, base, alt, always(reconcile.VerdictUnsure))
+
+ require.Len(t, out.Reconciliation.Contested, 1)
+ assert.InDelta(t, 10.0, out.Reconciliation.Contested[0].Seconds, 1e-9, "'fem' is in the second segment, which starts at 10 s")
+}
+
+func TestReconcile_WithNoSegmentsASpotIsPlacedProportionally(t *testing.T) {
+ base := tr("a b c d e f g h i j")
+ base.AudioSeconds = 100
+ alt := tr("a b c d e f g h i J2")
+
+ out := merge(t, base, alt, always(reconcile.VerdictUnsure))
+
+ require.Len(t, out.Reconciliation.Contested, 1)
+ assert.InDelta(t, 90.0, out.Reconciliation.Contested[0].Seconds, 1e-9, "token 9 of 10 in 100 s")
+}
+
+func TestReconcile_ContestedSpotsAreCappedButTheCountsAreExact(t *testing.T) {
+ a, b := make([]string, 0, 600), make([]string, 0, 600)
+ for i := 0; i < 300; i++ {
+ a = append(a, fmt.Sprintf("w%d", i), "same", "same2")
+ b = append(b, fmt.Sprintf("v%d", i), "same", "same2")
+ }
+
+ out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), always(reconcile.VerdictUnsure))
+
+ assert.Equal(t, 300, out.Reconciliation.Unsure)
+ assert.Equal(t, 300, out.Reconciliation.Disputes)
+ assert.Len(t, out.Reconciliation.Contested, reconcile.MaxContested, "the stored list is bounded; the counts are not")
+}
--
2.54.0
From a1e16fa6580a9d0d83661a9cfeb8ee10b062265a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:19:22 +0200
Subject: [PATCH 091/299] test(reconcile): fixtures need two shared words
between disputes
Disputes separated by a single shared word are merged into one decision by design,
so several of my fixtures were secretly one dispute. Corrected before any
implementation existed; the rule is unchanged.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_test.go | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go
index d97a44f..c6b5308 100644
--- a/internal/reconcile/reconcile_test.go
+++ b/internal/reconcile/reconcile_test.go
@@ -123,10 +123,10 @@ func TestReconcile_SeveralDisputesAreAppliedIndependentlyAndInOrder(t *testing.T
return reconcile.VerdictBase, nil
}}
- out := merge(t, tr("kunderna onboardade nu och sen betalas till SEB med 1050 kronor"),
- tr("kunderna omvårdade nu och sen betalas till SCB med 2050 kronor"), adj)
+ out := merge(t, tr("kunderna onboardade nu och sen betalas till SEB via ombud med 1050 kronor"),
+ tr("kunderna omvårdade nu och sen betalas till SCB via ombud med 2050 kronor"), adj)
- assert.Equal(t, "kunderna omvårdade nu och sen betalas till SCB med 1050 kronor", out.Text)
+ assert.Equal(t, "kunderna omvårdade nu och sen betalas till SCB via ombud med 1050 kronor", out.Text)
assert.Equal(t, 3, out.Reconciliation.Disputes)
assert.Equal(t, 2, out.Reconciliation.Changed)
}
@@ -134,7 +134,7 @@ func TestReconcile_SeveralDisputesAreAppliedIndependentlyAndInOrder(t *testing.T
func TestReconcile_TheReadingOrderAlternatesFromOneDisputeToTheNext(t *testing.T) {
adj := always(reconcile.VerdictBase)
- merge(t, tr("a x b y c z d"), tr("a p b q c r d"), adj)
+ merge(t, tr("a x s1 s2 y t1 t2 z d"), tr("a p s1 s2 q t1 t2 r d"), adj)
require.Len(t, adj.calls, 3)
assert.False(t, adj.calls[0].AltFirst)
@@ -166,9 +166,9 @@ func TestReconcile_OneModelErrorLeavesThatSpotUnsureAndCarriesOn(t *testing.T) {
return reconcile.VerdictAlt, nil
}}
- out := merge(t, tr("a x b y c"), tr("a p b q c"), adj)
+ out := merge(t, tr("a x s1 s2 y c"), tr("a p s1 s2 q c"), adj)
- assert.Equal(t, "a x b q c", out.Text, "the first spot stayed as the base, the second was decided")
+ assert.Equal(t, "a x s1 s2 q c", out.Text, "the first spot stayed as the base, the second was decided")
assert.Equal(t, 1, out.Reconciliation.Errors)
assert.Equal(t, 1, out.Reconciliation.Unsure)
assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
@@ -179,8 +179,8 @@ func TestReconcile_ConsecutiveModelErrorsStopTheWholeReconciliationInsteadOfWait
adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) { return reconcile.VerdictUnsure, boom }}
a, b := make([]string, 0, 20), make([]string, 0, 20)
for i := 0; i < 10; i++ {
- a = append(a, fmt.Sprintf("w%d", i), "same")
- b = append(b, fmt.Sprintf("v%d", i), "same")
+ a = append(a, fmt.Sprintf("w%d", i), "s", "t")
+ b = append(b, fmt.Sprintf("v%d", i), "s", "t")
}
out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), adj)
@@ -201,8 +201,8 @@ func TestReconcile_ASuccessBetweenFailuresResetsTheBreaker(t *testing.T) {
}}
a, b := make([]string, 0, 20), make([]string, 0, 20)
for i := 0; i < 9; i++ {
- a = append(a, fmt.Sprintf("w%d", i), "same")
- b = append(b, fmt.Sprintf("v%d", i), "same")
+ a = append(a, fmt.Sprintf("w%d", i), "s", "t")
+ b = append(b, fmt.Sprintf("v%d", i), "s", "t")
}
out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), adj)
--
2.54.0
From 0fead2e102181a292b41ea5141c0baa3dbaafc3f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:19:54 +0200
Subject: [PATCH 092/299] feat(domain): Reconciliation -- how a merged
transcript came about (ADR-034)
Status (reconciled / single_engine / skipped), exact counts, and a bounded list of
contested spots (changed or unsure) with a position and both readings.
Co-Authored-By: Claude Sonnet 5.5
---
internal/domain/domain.go | 44 +++++++++++++++++++++++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index f570839..afceb7f 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -129,6 +129,50 @@ type Transcription struct {
Segments []Segment
Endpoint string
AudioSeconds float64
+ // Reconciliation is set when two engines transcribed the same audio and a local
+ // model chose between their disagreements (ADR-034). Nil for a single engine.
+ Reconciliation *Reconciliation
+}
+
+// ReconcileStatus says what became of an attempt to merge two transcripts.
+type ReconcileStatus string
+
+const (
+ // ReconcileDone: both engines produced text and the disagreements were resolved.
+ ReconcileDone ReconcileStatus = "reconciled"
+ // ReconcileSingle: only one engine produced text, so there was nothing to merge.
+ ReconcileSingle ReconcileStatus = "single_engine"
+ // ReconcileSkipped: both engines produced text but the model could not be used;
+ // the base text stands, unreconciled.
+ ReconcileSkipped ReconcileStatus = "skipped"
+)
+
+// ContestedSpot is a place the engines disagreed that a reader may want to check
+// against the audio: one the model changed, or one it was unsure about.
+type ContestedSpot struct {
+ Seconds float64 `json:"seconds"` // roughly where in the recording
+ Base string `json:"base"` // what the base engine heard
+ Alt string `json:"alt"` // what the other engine heard
+ Used string `json:"used"` // "base" or "alt": the reading now in the text
+ Unsure bool `json:"unsure"` // the model could not decide; the base was kept
+}
+
+// Reconciliation records how a merged transcript came about, so the page can say so
+// plainly and the user can check the uncertain spots. The counts are exact; Contested
+// is bounded.
+type Reconciliation struct {
+ Status ReconcileStatus `json:"status"`
+ Reason string `json:"reason,omitempty"` // why single_engine or skipped
+ Base string `json:"base"` // engine whose text is the base
+ Alt string `json:"alt"`
+ Reconciler string `json:"reconciler"`
+ Disputes int `json:"disputes"` // places the engines disagreed
+ AutoResolved int `json:"auto_resolved"` // trivial: compounds, filler words
+ Adjudicated int `json:"adjudicated"` // decided by the model
+ Changed int `json:"changed"` // the alternative's reading was used
+ Unsure int `json:"unsure"` // base kept, flagged
+ Errors int `json:"errors"` // model calls that failed
+ Contested []ContestedSpot `json:"contested,omitempty"`
}
// UploadStatus is the state of an uploaded-audio transcription job.
--
2.54.0
From 7f9e967ad0d5becba175ebb0b3054ed6808a79da Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:20:12 +0200
Subject: [PATCH 093/299] test(reconcile): a verdict returned with an error is
not used
Mutation-testing showed the forced-unsure line was untested because my fake
always returned unsure with its errors.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_test.go | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go
index c6b5308..ec85af8 100644
--- a/internal/reconcile/reconcile_test.go
+++ b/internal/reconcile/reconcile_test.go
@@ -277,3 +277,17 @@ func TestReconcile_ContestedSpotsAreCappedButTheCountsAreExact(t *testing.T) {
assert.Equal(t, 300, out.Reconciliation.Disputes)
assert.Len(t, out.Reconciliation.Contested, reconcile.MaxContested, "the stored list is bounded; the counts are not")
}
+
+func TestReconcile_AVerdictReturnedAlongsideAnErrorIsIgnored(t *testing.T) {
+ // An adjudicator that returns a verdict AND an error must not have the verdict used:
+ // the contract is that an error means no decision.
+ adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
+ return reconcile.VerdictAlt, errors.New("half a reply")
+ }}
+
+ out := merge(t, tr("a x b"), tr("a y b"), adj)
+
+ assert.Equal(t, "a x b", out.Text, "no decision means the base stands")
+ assert.Zero(t, out.Reconciliation.Changed)
+ assert.Equal(t, 1, out.Reconciliation.Unsure)
+}
--
2.54.0
From f25acebe4c0cfca8adda4eb2d412a820466681c0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:20:12 +0200
Subject: [PATCH 094/299] feat(reconcile): merge two transcripts with a model
choosing between disagreements
Base text stands except where the model picks the alternative's reading; trivial
disputes skip the model; the reading order alternates; the base's context goes to
the model. Fails soft: three consecutive model errors return the base text
unreconciled (none of the partial changes) with the reason; a verdict returned with
an error is ignored; a cancelled job is an error, not 'skipped'. Counts are exact,
the stored list of contested spots is capped at 200 and carries the second each
starts at. Mutation-checked on nine behaviours.
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile.go | 152 ++++++++++++++++++++++++++++++++
1 file changed, 152 insertions(+)
create mode 100644 internal/reconcile/reconcile.go
diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go
new file mode 100644
index 0000000..ba604a7
--- /dev/null
+++ b/internal/reconcile/reconcile.go
@@ -0,0 +1,152 @@
+package reconcile
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// MaxContested bounds the stored list of contested spots. The counts on the
+// Reconciliation are always exact; only this list is capped.
+const MaxContested = 200
+
+// Options configure Reconcile. Zero Window and BreakAfter take their defaults.
+type Options struct {
+ // Window is how many words of the base to show on each side of a dispute. 100 is
+ // what was measured (docs/research/local-model-adjudicator-sweep-2026-10-01.md).
+ Window int
+ // BreakAfter is how many consecutive model errors stop the reconciliation, so a
+ // model that is down costs three timeouts, not one per dispute.
+ BreakAfter int
+ // BaseEngine, AltEngine and Reconciler are recorded on the result.
+ BaseEngine, AltEngine, Reconciler string
+}
+
+// Reconcile merges two transcripts of the same speech. The base text stands except
+// where the model chooses the alternative's reading of a dispute; trivial disputes
+// (a compound split, a filler word) are resolved without the model. It never fails a
+// transcription: if the model cannot be used the base text comes back unreconciled
+// and the result says why. The only error it returns is a cancelled context, which
+// is the job stopping, not a verdict on the model.
+func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudicator, o Options) (domain.Transcription, error) {
+ window, breakAfter := o.Window, o.BreakAfter
+ if window <= 0 {
+ window = 100
+ }
+ if breakAfter <= 0 {
+ breakAfter = 3
+ }
+ bt, at := Tokenize(base.Text), Tokenize(alt.Text)
+ ds := FindDisputes(bt, at)
+ rec := &domain.Reconciliation{
+ Status: domain.ReconcileDone, Base: o.BaseEngine, Alt: o.AltEngine, Reconciler: o.Reconciler, Disputes: len(ds),
+ }
+ seconds := positioner(base, len(bt))
+
+ verdicts := make([]Verdict, len(ds))
+ consecutive := 0
+ for i, d := range ds {
+ if AutoResolve(d) {
+ rec.AutoResolved++
+ verdicts[i] = VerdictBase
+ continue
+ }
+ if err := ctx.Err(); err != nil {
+ return domain.Transcription{}, err
+ }
+ before, after := ContextAround(bt, d, window)
+ v, err := adj.Adjudicate(ctx, Request{Before: before, After: after, Base: d.Base, Alt: d.Alt, AltFirst: i%2 == 1})
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return domain.Transcription{}, ctxErr
+ }
+ rec.Errors++
+ consecutive++
+ if consecutive >= breakAfter {
+ return skipped(base, rec, fmt.Sprintf("the model failed %d times in a row: %v", consecutive, err)), nil
+ }
+ v = VerdictUnsure
+ } else {
+ consecutive = 0
+ rec.Adjudicated++
+ }
+ verdicts[i] = v
+ switch v {
+ case VerdictAlt:
+ rec.Changed++
+ addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "alt"})
+ case VerdictUnsure:
+ rec.Unsure++
+ addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "base", Unsure: true})
+ }
+ }
+
+ var parts []string
+ pos := 0
+ for i, d := range ds {
+ parts = appendTexts(parts, bt[pos:d.BaseStart])
+ if verdicts[i] == VerdictAlt {
+ parts = appendTexts(parts, at[d.AltStart:d.AltEnd])
+ } else {
+ parts = appendTexts(parts, bt[d.BaseStart:d.BaseEnd])
+ }
+ pos = d.BaseEnd
+ }
+ parts = appendTexts(parts, bt[pos:])
+
+ out := base
+ out.Text = strings.Join(parts, " ")
+ out.Reconciliation = rec
+ return out, nil
+}
+
+// skipped is the base text, none of the partial changes, and the reason.
+func skipped(base domain.Transcription, rec *domain.Reconciliation, reason string) domain.Transcription {
+ out := base
+ out.Reconciliation = &domain.Reconciliation{
+ Status: domain.ReconcileSkipped, Reason: reason, Base: rec.Base, Alt: rec.Alt, Reconciler: rec.Reconciler,
+ Disputes: rec.Disputes, AutoResolved: rec.AutoResolved, Errors: rec.Errors,
+ }
+ return out
+}
+
+func appendTexts(dst []string, ts []Token) []string {
+ for _, t := range ts {
+ dst = append(dst, t.Text)
+ }
+ return dst
+}
+
+// positioner maps a token index of the base to the second it starts at: through the
+// segments when there are any, proportionally otherwise.
+func positioner(base domain.Transcription, tokens int) func(int) float64 {
+ if len(base.Segments) == 0 {
+ return func(i int) float64 {
+ if tokens == 0 {
+ return 0
+ }
+ return float64(i) / float64(tokens) * base.AudioSeconds
+ }
+ }
+ starts := make([]int, len(base.Segments))
+ n := 0
+ for i, s := range base.Segments {
+ starts[i] = n
+ n += len(Tokenize(s.Text))
+ }
+ return func(i int) float64 {
+ k := 0
+ for k+1 < len(starts) && starts[k+1] <= i {
+ k++
+ }
+ return base.Segments[k].Start
+ }
+}
+
+func addContested(rec *domain.Reconciliation, c domain.ContestedSpot) {
+ if len(rec.Contested) < MaxContested {
+ rec.Contested = append(rec.Contested, c)
+ }
+}
--
2.54.0
From ad39b6154ce0e498ff88395f7a10732351e15dde Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:20:40 +0200
Subject: [PATCH 095/299] test(transcribe): two engines, then a reconciler --
and every way it can fail softly (red)
Both run in order (the fast one first) and are reconciled; identical texts cost no
model call; one engine down uses the other and says so, with the cloud fallback
untouched; only when BOTH local engines fail does the fallback run, and the row says
berget served it; both failing with no fallback reports both errors; a cancelled
job never reaches the cloud; a model that is down yields the base text unreconciled,
not a failed job.
Red: transcribe.Ensemble is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/ensemble_test.go | 166 ++++++++++++++++++
1 file changed, 166 insertions(+)
create mode 100644 internal/adapters/transcribe/ensemble_test.go
diff --git a/internal/adapters/transcribe/ensemble_test.go b/internal/adapters/transcribe/ensemble_test.go
new file mode 100644
index 0000000..e0735e8
--- /dev/null
+++ b/internal/adapters/transcribe/ensemble_test.go
@@ -0,0 +1,166 @@
+package transcribe_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// orderedBackend records, in a shared slice, when it was called.
+type orderedBackend struct {
+ name string
+ text string
+ err error
+ calls *[]string
+}
+
+func (o *orderedBackend) Name() string { return o.name }
+func (o *orderedBackend) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
+ *o.calls = append(*o.calls, o.name)
+ if o.err != nil {
+ return domain.Transcription{}, o.err
+ }
+ return domain.Transcription{Text: o.text, Endpoint: o.name, AudioSeconds: 10}, nil
+}
+
+type verdictAdj struct {
+ v reconcile.Verdict
+ err error
+ calls int
+}
+
+func (a *verdictAdj) Adjudicate(context.Context, reconcile.Request) (reconcile.Verdict, error) {
+ a.calls++
+ return a.v, a.err
+}
+
+var ensembleOpts = reconcile.Options{Reconciler: "iguana/gemma4-26b", BaseEngine: "iguana/pianissimo", AltEngine: "iguana/kb-whisper"}
+
+type rig struct {
+ e *transcribe.Ensemble
+ calls *[]string
+ adj *verdictAdj
+}
+
+func newEnsemble(baseText, altText string, baseErr, altErr error, withFallback bool, adj *verdictAdj) *rig {
+ calls := &[]string{}
+ base := &orderedBackend{name: "iguana/pianissimo", text: baseText, err: baseErr, calls: calls}
+ alt := &orderedBackend{name: "iguana/kb-whisper", text: altText, err: altErr, calls: calls}
+ var fb transcribe.Backend
+ if withFallback {
+ fb = &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
+ }
+ return &rig{e: transcribe.NewEnsemble(base, alt, fb, adj, ensembleOpts), calls: calls, adj: adj}
+}
+
+func TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled(t *testing.T) {
+ adj := &verdictAdj{v: reconcile.VerdictAlt}
+ r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, true, adj)
+
+ got, err := r.e.Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Equal(t, []string{"iguana/pianissimo", "iguana/kb-whisper"}, *r.calls,
+ "the 40-second engine runs first, so a failure there is known early; the fallback is not touched")
+ assert.Equal(t, "Totalt 1050 kronor idag.", got.Text)
+ assert.Equal(t, "iguana/pianissimo + iguana/kb-whisper, reconciled by iguana/gemma4-26b", got.Endpoint,
+ "every contributor is recorded: which engines touched the audio is a quality and a privacy fact")
+ require.NotNil(t, got.Reconciliation)
+ assert.Equal(t, domain.ReconcileDone, got.Reconciliation.Status)
+}
+
+func TestEnsemble_IdenticalTextsNeverCallTheModel(t *testing.T) {
+ adj := &verdictAdj{v: reconcile.VerdictAlt}
+ r := newEnsemble("Vi går igenom läget.", "Vi går igenom läget.", nil, nil, false, adj)
+
+ got, err := r.e.Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Zero(t, adj.calls)
+ assert.Equal(t, "Vi går igenom läget.", got.Text)
+}
+
+func TestEnsemble_WhenOneEngineFailsTheOtherIsUsedAndItIsSaidSo(t *testing.T) {
+ tests := []struct {
+ name string
+ baseErr error
+ altErr error
+ wantText string
+ wantEndpoint string
+ wantReasonHas string
+ }{
+ {"pianissimo down", errors.New("pianissimo wedged"), nil, "alt text", "iguana/kb-whisper", "iguana/pianissimo failed"},
+ {"kb-whisper down", nil, errors.New("metal oom"), "base text", "iguana/pianissimo", "iguana/kb-whisper failed"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ adj := &verdictAdj{v: reconcile.VerdictAlt}
+ r := newEnsemble("base text", "alt text", tt.baseErr, tt.altErr, true, adj)
+
+ got, err := r.e.Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantText, got.Text)
+ assert.Equal(t, tt.wantEndpoint, got.Endpoint)
+ assert.Zero(t, adj.calls, "there is nothing to reconcile")
+ require.NotNil(t, got.Reconciliation)
+ assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
+ assert.Contains(t, got.Reconciliation.Reason, tt.wantReasonHas)
+ assert.NotContains(t, *r.calls, "berget/kb-whisper", "one local engine working means audio does not leave the homelab")
+ })
+ }
+}
+
+func TestEnsemble_OnlyWhenBothLocalEnginesFailDoesTheCloudFallbackRun(t *testing.T) {
+ r := newEnsemble("", "", errors.New("a"), errors.New("b"), true, &verdictAdj{})
+
+ got, err := r.e.Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Equal(t, "fallback text", got.Text)
+ assert.Equal(t, "berget/kb-whisper", got.Endpoint, "the row must say berget served it: the audio left the homelab")
+ assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
+ assert.Contains(t, got.Reconciliation.Reason, "both local engines failed")
+}
+
+func TestEnsemble_BothFailingWithNoFallbackReportsBothErrors(t *testing.T) {
+ r := newEnsemble("", "", errors.New("pianissimo down"), errors.New("whisper down"), false, &verdictAdj{})
+
+ _, err := r.e.Transcribe(context.Background(), "/a.wav")
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "pianissimo down")
+ assert.Contains(t, err.Error(), "whisper down")
+}
+
+func TestEnsemble_ACancelledJobNeverReachesTheCloud(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ r := newEnsemble("", "", context.Canceled, context.Canceled, true, &verdictAdj{})
+
+ _, err := r.e.Transcribe(ctx, "/a.wav")
+
+ assert.ErrorIs(t, err, context.Canceled)
+ assert.NotContains(t, *r.calls, "berget/kb-whisper", "a cancelled job must not ship its audio to a cloud endpoint as a last act")
+}
+
+func TestEnsemble_AModelThatIsDownGivesTheBaseTextUnreconciledNotAFailedJob(t *testing.T) {
+ adj := &verdictAdj{err: errors.New("gateway down")}
+ // Four separate disagreements, each separated by two shared words, so the
+ // breaker trips after three consecutive failures.
+ r := newEnsemble("a1 s t b1 s t c1 s t d1", "a2 s t b2 s t c2 s t d2", nil, nil, false, adj)
+
+ got, err := r.e.Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Equal(t, "a1 s t b1 s t c1 s t d1", got.Text)
+ assert.Equal(t, domain.ReconcileSkipped, got.Reconciliation.Status)
+ assert.Equal(t, 3, adj.calls)
+}
--
2.54.0
From 8778376aa64d4e5082f1b23e2d6c2200c70dab08 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:21:17 +0200
Subject: [PATCH 096/299] test(transcribe): a shutdown in the middle of either
engine stops the job
Mutation-testing showed each of the two cancellation checks was redundant with the
other in my first test. Now a cancel during the first engine must not start the second
or the cloud, and a cancel during the second must not be recorded as a single-engine
success ('kb-whisper failed').
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/ensemble_test.go | 40 +++++++++++++++++++
1 file changed, 40 insertions(+)
diff --git a/internal/adapters/transcribe/ensemble_test.go b/internal/adapters/transcribe/ensemble_test.go
index e0735e8..f9feca4 100644
--- a/internal/adapters/transcribe/ensemble_test.go
+++ b/internal/adapters/transcribe/ensemble_test.go
@@ -164,3 +164,43 @@ func TestEnsemble_AModelThatIsDownGivesTheBaseTextUnreconciledNotAFailedJob(t *t
assert.Equal(t, domain.ReconcileSkipped, got.Reconciliation.Status)
assert.Equal(t, 3, adj.calls)
}
+
+// cancelling is a backend that cancels the job's context while it runs, the way a
+// shutdown arrives in the middle of a long transcription.
+type cancelling struct {
+ name string
+ cancel context.CancelFunc
+ calls *[]string
+}
+
+func (c *cancelling) Name() string { return c.name }
+func (c *cancelling) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
+ *c.calls = append(*c.calls, c.name)
+ c.cancel()
+ return domain.Transcription{}, context.Canceled
+}
+
+func TestEnsemble_AShutdownDuringTheFirstEngineStopsBeforeTheSecondOrTheCloud(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ calls := &[]string{}
+ base := &cancelling{name: "iguana/pianissimo", cancel: cancel, calls: calls}
+ alt := &orderedBackend{name: "iguana/kb-whisper", text: "alt", calls: calls}
+ fb := &orderedBackend{name: "berget/kb-whisper", text: "fb", calls: calls}
+
+ _, err := transcribe.NewEnsemble(base, alt, fb, &verdictAdj{}, ensembleOpts).Transcribe(ctx, "/a.wav")
+
+ assert.ErrorIs(t, err, context.Canceled)
+ assert.Equal(t, []string{"iguana/pianissimo"}, *calls, "no second engine and no cloud once the job is cancelled")
+}
+
+func TestEnsemble_AShutdownDuringTheSecondEngineIsAnErrorNotASingleEngineSuccess(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ calls := &[]string{}
+ base := &orderedBackend{name: "iguana/pianissimo", text: "base", calls: calls}
+ alt := &cancelling{name: "iguana/kb-whisper", cancel: cancel, calls: calls}
+
+ got, err := transcribe.NewEnsemble(base, alt, nil, &verdictAdj{}, ensembleOpts).Transcribe(ctx, "/a.wav")
+
+ assert.ErrorIs(t, err, context.Canceled, "a cancelled job must not be recorded as 'kb-whisper failed'")
+ assert.Empty(t, got.Text)
+}
--
2.54.0
From 34bc8dbdee7881c2c4ef1c213e699cc962922ef1 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:21:17 +0200
Subject: [PATCH 097/299] feat(transcribe): Ensemble -- two local engines, a
local reconciler, fail-soft at every step
Runs the fast engine then the other from one WAV and reconciles; one engine down
uses the other and says so; the model down gives the base text unreconciled; the
cloud fallback runs only when BOTH local engines fail and is recorded as such; a
cancelled job stops everything and never reaches the cloud. The endpoint lists every
contributor. Mutation-checked, including both cancellation points.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/ensemble.go | 77 ++++++++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 internal/adapters/transcribe/ensemble.go
diff --git a/internal/adapters/transcribe/ensemble.go b/internal/adapters/transcribe/ensemble.go
new file mode 100644
index 0000000..c71ff01
--- /dev/null
+++ b/internal/adapters/transcribe/ensemble.go
@@ -0,0 +1,77 @@
+package transcribe
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// Ensemble transcribes the same WAV with two engines, one after the other, and has a
+// local model reconcile their disagreements (ADR-034). It satisfies uploads.Transcriber,
+// so the worker neither knows nor cares that two engines were involved.
+//
+// It degrades in steps and never turns a recoverable problem into a failed job:
+// one engine down → the other's text, said so on the result; the model down → the
+// base text unreconciled; both engines down → the cloud fallback, if configured, and
+// recorded as such. A cancelled job stops everything and never reaches the cloud.
+type Ensemble struct {
+ base, alt Backend
+ fallback Backend // nil = no cloud fallback
+ adj reconcile.Adjudicator
+ opts reconcile.Options
+}
+
+// NewEnsemble builds an Ensemble. base is the engine whose text stands where the two
+// disagree and is the faster one, so it runs first; alt supplies the other reading.
+func NewEnsemble(base, alt, fallback Backend, adj reconcile.Adjudicator, opts reconcile.Options) *Ensemble {
+ return &Ensemble{base: base, alt: alt, fallback: fallback, adj: adj, opts: opts}
+}
+
+// Transcribe implements uploads.Transcriber.
+func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
+ b, berr := e.base.Transcribe(ctx, wavPath)
+ if err := ctx.Err(); err != nil {
+ return domain.Transcription{}, err
+ }
+ a, aerr := e.alt.Transcribe(ctx, wavPath)
+ if err := ctx.Err(); err != nil {
+ return domain.Transcription{}, err
+ }
+
+ switch {
+ case berr == nil && aerr == nil:
+ out, err := reconcile.Reconcile(ctx, b, a, e.adj, e.opts)
+ if err != nil {
+ return domain.Transcription{}, err
+ }
+ out.Endpoint = fmt.Sprintf("%s + %s, reconciled by %s", e.base.Name(), e.alt.Name(), e.opts.Reconciler)
+ return out, nil
+ case berr == nil:
+ return e.single(b, e.base.Name(), fmt.Sprintf("%s failed: %v", e.alt.Name(), aerr)), nil
+ case aerr == nil:
+ return e.single(a, e.alt.Name(), fmt.Sprintf("%s failed: %v", e.base.Name(), berr)), nil
+ }
+
+ both := errors.Join(fmt.Errorf("%s: %w", e.base.Name(), berr), fmt.Errorf("%s: %w", e.alt.Name(), aerr))
+ if e.fallback == nil {
+ return domain.Transcription{}, both
+ }
+ f, ferr := e.fallback.Transcribe(ctx, wavPath)
+ if ferr != nil {
+ return domain.Transcription{}, errors.Join(both, fmt.Errorf("%s: %w", e.fallback.Name(), ferr))
+ }
+ return e.single(f, e.fallback.Name(), "both local engines failed"), nil
+}
+
+// single is a transcript from one engine only: nothing to reconcile, and the result
+// says why, because a reader should know the second opinion was missing.
+func (e *Ensemble) single(t domain.Transcription, engine, reason string) domain.Transcription {
+ t.Endpoint = engine
+ t.Reconciliation = &domain.Reconciliation{
+ Status: domain.ReconcileSingle, Reason: reason, Base: e.opts.BaseEngine, Alt: e.opts.AltEngine, Reconciler: e.opts.Reconciler,
+ }
+ return t
+}
--
2.54.0
From 5bd41077afe6c1537831c202e592fea6a37f0cf6 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:21:36 +0200
Subject: [PATCH 098/299] test(store): the reconciliation survives the round
trip, and its absence stays absent (red)
Counts and contested spots round-trip; a single-engine transcript has NO
reconciliation (nil, not empty -- the page must not claim a second opinion); a
skipped reconciliation keeps its reason.
Red: Transcription.Reconciliation is stored nowhere and UploadedTranscript has none.
Co-Authored-By: Claude Sonnet 5.5
---
.../store/uploaded_transcripts_test.go | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
diff --git a/internal/adapters/store/uploaded_transcripts_test.go b/internal/adapters/store/uploaded_transcripts_test.go
index a313468..8ac9143 100644
--- a/internal/adapters/store/uploaded_transcripts_test.go
+++ b/internal/adapters/store/uploaded_transcripts_test.go
@@ -230,3 +230,57 @@ func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job")
}
}
+
+func TestUploadLifecycle_ReconciliationRoundTripsWithItsContestedSpots(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA)
+ ctx := context.Background()
+ id, err := s.CreateUpload(ctx, userA, "m", false)
+ require.NoError(t, err)
+ require.NoError(t, s.StartTranscribing(ctx, userA, id))
+ want := &domain.Reconciliation{
+ Status: domain.ReconcileDone, Base: "iguana/pianissimo", Alt: "iguana/kb-whisper", Reconciler: "iguana/gemma4-26b",
+ Disputes: 380, AutoResolved: 90, Adjudicated: 285, Changed: 120, Unsure: 5, Errors: 0,
+ Contested: []domain.ContestedSpot{
+ {Seconds: 2210.5, Base: "SEB", Alt: "SCB", Used: "alt"},
+ {Seconds: 3400, Base: "T+1", Alt: "en T", Used: "base", Unsure: true},
+ },
+ }
+
+ require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: want}))
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.Equal(t, want, got.Reconciliation)
+}
+
+func TestUploadLifecycle_ASingleEngineTranscriptHasNoReconciliation(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA)
+ ctx := context.Background()
+ id, err := s.CreateUpload(ctx, userA, "m", false)
+ require.NoError(t, err)
+ require.NoError(t, s.StartTranscribing(ctx, userA, id))
+ require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e"}))
+
+ got, err := s.GetUpload(ctx, userA, id)
+
+ require.NoError(t, err)
+ assert.Nil(t, got.Reconciliation, "no reconciliation is not an empty one: the page must not claim a second opinion")
+}
+
+func TestUploadLifecycle_ASkippedReconciliationKeepsItsReason(t *testing.T) {
+ s := newStore(t)
+ seedUsers(t, userA)
+ ctx := context.Background()
+ id, err := s.CreateUpload(ctx, userA, "m", false)
+ require.NoError(t, err)
+ require.NoError(t, s.StartTranscribing(ctx, userA, id))
+ rec := &domain.Reconciliation{Status: domain.ReconcileSkipped, Reason: "the model failed 3 times in a row: gateway down", Errors: 3}
+
+ require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: rec}))
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.Equal(t, rec, got.Reconciliation)
+}
--
2.54.0
From e838f6bb6ddca706f9569770e993f61330633b26 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:21:52 +0200
Subject: [PATCH 099/299] feat(store): migration 019 --
uploaded_transcripts.reconciliation
JSONB, NULL for a single engine so the page cannot claim a second opinion. On the
already FORCE'd, RLS-scoped table, so the exact-set isolation test is unaffected.
Up and down are one commit: golang-migrate pairs them.
Co-Authored-By: Claude Sonnet 5.5
---
.../019_uploaded_transcripts_reconciliation.down.sql | 1 +
.../019_uploaded_transcripts_reconciliation.up.sql | 12 ++++++++++++
2 files changed, 13 insertions(+)
create mode 100644 internal/adapters/store/migrations/019_uploaded_transcripts_reconciliation.down.sql
create mode 100644 internal/adapters/store/migrations/019_uploaded_transcripts_reconciliation.up.sql
diff --git a/internal/adapters/store/migrations/019_uploaded_transcripts_reconciliation.down.sql b/internal/adapters/store/migrations/019_uploaded_transcripts_reconciliation.down.sql
new file mode 100644
index 0000000..46eba75
--- /dev/null
+++ b/internal/adapters/store/migrations/019_uploaded_transcripts_reconciliation.down.sql
@@ -0,0 +1 @@
+ALTER TABLE uploaded_transcripts DROP COLUMN reconciliation;
diff --git a/internal/adapters/store/migrations/019_uploaded_transcripts_reconciliation.up.sql b/internal/adapters/store/migrations/019_uploaded_transcripts_reconciliation.up.sql
new file mode 100644
index 0000000..433870a
--- /dev/null
+++ b/internal/adapters/store/migrations/019_uploaded_transcripts_reconciliation.up.sql
@@ -0,0 +1,12 @@
+-- Migration 019: how a merged transcript came about (ADR-034).
+--
+-- When two engines transcribed the same audio and a local model chose between their
+-- disagreements, this records the status, the exact counts, and a bounded list of the
+-- contested spots (changed or unsure, each with a position and both readings) so the
+-- page can say what was done and the user can check the uncertain spots against the
+-- audio. NULL means a single engine: no second opinion, and the page must not claim one.
+--
+-- It lives on the already-FORCE'd, RLS-scoped uploaded_transcripts, so the exact-set
+-- isolation test is unaffected. The contested spots hold fragments of the private
+-- transcript, which is why they are here and nowhere else.
+ALTER TABLE uploaded_transcripts ADD COLUMN reconciliation JSONB;
--
2.54.0
From ae4f9fd775f1ab37360c3fba728ddd68fa2b23e8 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:22:09 +0200
Subject: [PATCH 100/299] feat(store): persist and read back the reconciliation
CompleteUpload writes it (SQL NULL when there was none, never the JSON text 'null':
the page claims a second opinion only if the column is non-NULL); GetUpload reads it,
List does not. Mutation-checked.
domain.go and the store are one commit: the store tests need the new field.
Co-Authored-By: Claude Sonnet 5.5
---
.../adapters/store/uploaded_transcripts.go | 25 +++++++++++++++----
internal/domain/domain.go | 9 ++++---
2 files changed, 26 insertions(+), 8 deletions(-)
diff --git a/internal/adapters/store/uploaded_transcripts.go b/internal/adapters/store/uploaded_transcripts.go
index 9e2cc81..c5820fe 100644
--- a/internal/adapters/store/uploaded_transcripts.go
+++ b/internal/adapters/store/uploaded_transcripts.go
@@ -52,12 +52,20 @@ func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.
if err != nil {
return fmt.Errorf("store: complete upload: marshal segments: %w", err)
}
+ // A nil Reconciliation must be SQL NULL, not the JSON text "null": the page decides
+ // whether to claim a second opinion by whether this column is NULL.
+ var rec []byte
+ if t.Reconciliation != nil {
+ if rec, err = json.Marshal(t.Reconciliation); err != nil {
+ return fmt.Errorf("store: complete upload: marshal reconciliation: %w", err)
+ }
+ }
return s.updateUpload(ctx, userID, id, "complete upload",
`UPDATE uploaded_transcripts
SET status = 'done', content = $3, segments = $4, endpoint = $5,
- audio_seconds = $6, error_kind = NULL, finished_at = NOW()
+ audio_seconds = $6, reconciliation = $7, error_kind = NULL, finished_at = NOW()
WHERE id = $1 AND user_id = $2`,
- t.Text, segs, t.Endpoint, t.AudioSeconds)
+ t.Text, segs, t.Endpoint, t.AudioSeconds, rec)
}
// FailUpload moves an upload to failed with a typed reason.
@@ -157,19 +165,19 @@ func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
}
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
- audio_seconds, error_kind, had_video, created_at, finished_at FROM uploaded_transcripts`
+ audio_seconds, error_kind, had_video, reconciliation, created_at, finished_at FROM uploaded_transcripts`
// scanUpload scans one uploadSelect row. withText controls whether the transcript
// body and segments are kept (GetUpload) or dropped (ListUploads).
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
var (
status, errKind, endpoint, content *string
- segments []byte
+ segments, reconciliation []byte
seconds *float64
finished *time.Time
)
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
- &seconds, &errKind, &u.HadVideo, &u.CreatedAt, &finished); err != nil {
+ &seconds, &errKind, &u.HadVideo, &reconciliation, &u.CreatedAt, &finished); err != nil {
return err
}
u.Status = domain.UploadStatus(deref(status))
@@ -184,6 +192,13 @@ func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error
if !withText {
return nil
}
+ if len(reconciliation) > 0 {
+ var r domain.Reconciliation
+ if err := json.Unmarshal(reconciliation, &r); err != nil {
+ return fmt.Errorf("unmarshal reconciliation: %w", err)
+ }
+ u.Reconciliation = &r
+ }
u.Content = deref(content)
if len(segments) > 0 {
if err := json.Unmarshal(segments, &u.Segments); err != nil {
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index afceb7f..2952d6d 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -214,7 +214,10 @@ type UploadedTranscript struct {
// HadVideo records that the uploaded file carried a video track. Only its audio
// was used and the video was discarded; the page says so. It is set at upload
// and never cleared (ADR-033).
- HadVideo bool
- CreatedAt time.Time
- FinishedAt time.Time // zero until the job reaches done or failed
+ HadVideo bool
+ // Reconciliation is how a merged transcript came about, or nil for a single
+ // engine (ADR-034). Set by Get, not by List.
+ Reconciliation *Reconciliation
+ CreatedAt time.Time
+ FinishedAt time.Time // zero until the job reaches done or failed
}
--
2.54.0
From d6ba11d288ad2d38f6545d1fe65dfbda03904e34 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:23:00 +0200
Subject: [PATCH 101/299] test(web): the page says what reconciliation did,
plainly, and never claims more (red)
Reconciled: who the two engines and the deciding model were, and the counts
(differences, settled automatically, decided by the model, uncertain). The contested
spots are listed with a minute mark, both readings and which was used, uncertain ones
marked, readings escaped, and a very long list shortened with its true count. A
single-engine transcript makes no claim of a second opinion; one-engine and
model-unavailable cases say why.
Red: the page renders none of this yet.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_handler_test.go | 105 +++++++++++++++++++++++++++
1 file changed, 105 insertions(+)
diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go
index a94400a..158eafd 100644
--- a/internal/web/uploads_handler_test.go
+++ b/internal/web/uploads_handler_test.go
@@ -3,6 +3,7 @@ package web_test
import (
"bytes"
"context"
+ "fmt"
"log/slog"
"mime/multipart"
"net/http"
@@ -355,3 +356,107 @@ func TestUploadPost_ARejectionIsLoggedWithItsReasonAndNeverTheFilenameOrTitle(t
assert.NotContains(t, logged, "Secret project notes", "the title is private content")
assert.NotContains(t, logged, "client-acme-roadmap", "the filename is private content")
}
+
+func setReconciliation(t *testing.T, id, json string) {
+ t.Helper()
+ _, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET reconciliation = $2::jsonb WHERE id = $1`, id, json)
+ require.NoError(t, err)
+}
+
+const reconciledJSON = `{"status":"reconciled","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b",
+ "disputes":380,"auto_resolved":90,"adjudicated":285,"changed":120,"unsure":5,"errors":0,
+ "contested":[
+ {"seconds":2210.5,"base":"SEB","alt":"SCB","used":"alt","unsure":false},
+ {"seconds":3400,"base":"T+1","alt":"en T","used":"base","unsure":true}]}`
+
+func TestUploadStatus_ReconciledSaysWhatWasDoneInPlainWords(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ setReconciliation(t, id, reconciledJSON)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "Reconciled from two transcripts")
+ assert.Contains(t, b, "iguana/pianissimo")
+ assert.Contains(t, b, "iguana/kb-whisper")
+ assert.Contains(t, b, "iguana/gemma4-26b", "who decided is part of the account")
+ assert.Contains(t, b, "380 differences")
+ assert.Contains(t, b, "90 settled automatically")
+ assert.Contains(t, b, "285 decided by the model")
+ assert.Contains(t, b, "5 uncertain")
+}
+
+func TestUploadStatus_ListsTheContestedSpotsWithAMinuteMarkBothReadingsAndWhichWasUsed(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ setReconciliation(t, id, reconciledJSON)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "~37 min", "2210.5 s")
+ assert.Contains(t, b, "SEB")
+ assert.Contains(t, b, "SCB")
+ assert.Contains(t, b, "~57 min", "3400 s")
+ assert.Contains(t, b, "T+1")
+ assert.Contains(t, b, "en T")
+ assert.Contains(t, b, "uncertain", "an unsure spot is marked so the reader knows to check it")
+}
+
+func TestUploadStatus_ASingleEngineTranscriptDoesNotClaimASecondOpinion(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+
+ assert.NotContains(t, body(t, do(t, app, get("/uploads/"+id+"/status"))), "Reconciled", "no reconciliation, no claim")
+}
+
+func TestUploadStatus_WhenOnlyOneEngineWorkedItSaysWhyTheSecondOpinionIsMissing(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ setReconciliation(t, id, `{"status":"single_engine","reason":"iguana/pianissimo failed: wedged","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b"}`)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "Only one engine")
+ assert.Contains(t, b, "iguana/pianissimo failed")
+ assert.NotContains(t, b, "Reconciled from two")
+}
+
+func TestUploadStatus_WhenTheModelWasUnavailableItSaysTheTextIsUnreconciled(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ setReconciliation(t, id, `{"status":"skipped","reason":"the model failed 3 times in a row: gateway down","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b","disputes":380,"errors":3}`)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "could not be reconciled")
+ assert.Contains(t, b, "iguana/pianissimo", "which engine's text is being shown")
+ assert.Contains(t, b, "gateway down")
+}
+
+func TestUploadStatus_ContestedReadingsAreEscaped(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"unsure":1,
+ "contested":[{"seconds":60,"base":"","alt":"ok","used":"base","unsure":true}]}`)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.NotContains(t, b, "", "a reading is words from a recording, never markup")
+ assert.Contains(t, b, "<script>")
+}
+
+func TestUploadStatus_AVeryLongListOfContestedSpotsIsShortenedWithItsTrueCount(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ var spots []string
+ for i := 0; i < 120; i++ {
+ spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"b%d","alt":"a%d","used":"base","unsure":true}`, i*10, i, i))
+ }
+ setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":400,"unsure":120,"contested":[`+strings.Join(spots, ",")+`]}`)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "b0", "the first spots are shown")
+ assert.NotContains(t, b, ">b119<", "the page does not render every one")
+ assert.Contains(t, b, "70 more", "120 stored, 50 shown")
+}
--
2.54.0
From 6fb7a5298013251dd3db331fffc9c0fbe0150de5 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:23:39 +0200
Subject: [PATCH 102/299] feat(web): say what reconciliation did, and never
claim a second opinion that was not there
Reconciled: the two engines, the deciding model and the exact counts; the contested
spots (changed or uncertain) listed with a minute mark and both readings, the first
50 shown and the rest counted from the exact totals. Single-engine and
model-unavailable results say why. A nil reconciliation renders nothing. Readings are
escaped. Mutation-checked: the cap, the minute rounding and the 'more' count.
uploads.go and the templ pair are one commit: the helpers and the template need each other.
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 25 +++
internal/web/uploads.templ | 47 +++++
internal/web/uploads_templ.go | 336 ++++++++++++++++++++++++++++++++--
3 files changed, 390 insertions(+), 18 deletions(-)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index 1f5e90c..6f57637 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -3,7 +3,9 @@ package web
import (
"context"
"errors"
+ "fmt"
"io"
+ "math"
"net/http"
"path/filepath"
"strings"
@@ -271,3 +273,26 @@ func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history
Subject: "recording",
}
}
+
+// maxSpotsShown is how many contested spots the page lists. The stored list holds up to
+// 200 and the counts are exact; a page of two hundred "check this" items is not a page
+// anyone reads, so the rest are counted, not listed.
+const maxSpotsShown = 50
+
+// minuteMark renders where in the recording a spot is, as the nearest minute.
+func minuteMark(seconds float64) string {
+ return fmt.Sprintf("~%d min", int(math.Round(seconds/60)))
+}
+
+// shownSpots is the part of the contested list the page renders.
+func shownSpots(r *domain.Reconciliation) []domain.ContestedSpot {
+ if len(r.Contested) > maxSpotsShown {
+ return r.Contested[:maxSpotsShown]
+ }
+ return r.Contested
+}
+
+// moreSpots is how many spots to check are not listed, from the exact counts.
+func moreSpots(r *domain.Reconciliation) int {
+ return max(0, r.Changed+r.Unsure-len(shownSpots(r)))
+}
diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ
index f4bf11b..81ec0f7 100644
--- a/internal/web/uploads.templ
+++ b/internal/web/uploads.templ
@@ -2,6 +2,7 @@ package web
import (
"fmt"
+ "strconv"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
@@ -65,6 +66,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by { u.Endpoint } .
+ @reconciliationNote(u.Reconciliation)
if chatEnabled {
Ask about this recording
}
@@ -123,3 +125,48 @@ templ videoNotice(hadVideo bool) {
}
}
+
+// reconciliationNote says what was done when two engines transcribed the audio
+// (ADR-034), and never claims a second opinion that was not there: nil means a single
+// engine and renders nothing. Spots the model changed or was unsure about are listed
+// with a minute mark and both readings so the user can check them against the audio.
+templ reconciliationNote(r *domain.Reconciliation) {
+ if r != nil {
+ switch r.Status {
+ case domain.ReconcileDone:
+
+
+ Reconciled from two transcripts ({ r.Base } and { r.Alt }):
+ { strconv.Itoa(r.Disputes) } differences,
+ { strconv.Itoa(r.AutoResolved) } settled automatically,
+ { strconv.Itoa(r.Adjudicated) } decided by the model ({ r.Reconciler }),
+ { strconv.Itoa(r.Unsure) } uncertain.
+
+ if len(r.Contested) > 0 {
+
+ Spots to check against the audio
+
+ for _, c := range shownSpots(r) {
+
+ { minuteMark(c.Seconds) }: { c.Base } / { c.Alt } —
+ if c.Unsure {
+ uncertain kept { c.Used }
+ } else {
+ changed used { c.Used }
+ }
+
+ }
+
+ if moreSpots(r) > 0 {
+ and { strconv.Itoa(moreSpots(r)) } more not listed
+ }
+
+ }
+
+ case domain.ReconcileSingle:
+ Only one engine produced this transcript ({ r.Reason }), so there was no second opinion to reconcile it with.
+ case domain.ReconcileSkipped:
+ Two transcripts were made but could not be reconciled ({ r.Reason }); the text shown is { r.Base }'s, unreconciled.
+ }
+ }
+}
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go
index 396b7f2..801ec56 100644
--- a/internal/web/uploads_templ.go
+++ b/internal/web/uploads_templ.go
@@ -5,11 +5,14 @@ package web
//lint:file-ignore SA4006 This context is only used if a nested component is present.
-import "github.com/a-h/templ"
-import templruntime "github.com/a-h/templ/runtime"
+import (
+ "github.com/a-h/templ"
+ templruntime "github.com/a-h/templ/runtime"
+)
import (
"fmt"
+ "strconv"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
@@ -57,7 +60,7 @@ func UploadsPage(v uploadsView) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 17, Col: 67}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 18, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -70,7 +73,7 @@ func UploadsPage(v uploadsView) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(v.Limits.MaxDuration))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 18, Col: 50}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 19, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -88,7 +91,7 @@ func UploadsPage(v uploadsView) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 22, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 23, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -116,7 +119,7 @@ func UploadsPage(v uploadsView) templ.Component {
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 35, Col: 51}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -129,7 +132,7 @@ func UploadsPage(v uploadsView) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 35, Col: 63}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -142,7 +145,7 @@ func UploadsPage(v uploadsView) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Status))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 45}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 37, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -213,7 +216,7 @@ func UploadPage(u domain.UploadedTranscript, chatEnabled bool) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 50, Col: 16}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 51, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -281,7 +284,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 95}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 66, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -294,7 +297,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 66, Col: 48}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 67, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -304,6 +307,10 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+ templ_7745c5c3_Err = reconciliationNote(u.Reconciliation).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
if chatEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Reconciled from two transcripts (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var26 string
+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 139, Col: 47}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " and ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.Alt)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 139, Col: 61}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "): ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var28 string
+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Disputes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 140, Col: 32}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " differences, ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var29 string
+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.AutoResolved))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 141, Col: 36}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " settled automatically, ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var30 string
+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Adjudicated))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 142, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " decided by the model (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var31 string
+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reconciler)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 142, Col: 74}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "), ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var32 string
+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Unsure))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 143, Col: 30}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " uncertain.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(r.Contested) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "Spots to check against the audio ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, c := range shownSpots(r) {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var33 string
+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(minuteMark(c.Seconds))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 33}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ": ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var34 string
+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 45}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " / ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var35 string
+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 57}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " — ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if c.Unsure {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "uncertain kept ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var36 string
+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 153, Col: 70}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "changed used ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var37 string
+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 155, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if moreSpots(r) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "and ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var38 string
+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 161, Col: 57}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " more not listed
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case domain.ReconcileSingle:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Only one engine produced this transcript (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var39 string
+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 167, Col: 88}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "), so there was no second opinion to reconcile it with.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case domain.ReconcileSkipped:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "Two transcripts were made but could not be reconciled (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var40 string
+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 101}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "); the text shown is ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var41 string
+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 132}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "'s, unreconciled.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ }
+ return nil
+ })
+}
+
var _ = templruntime.GeneratedTemplate
--
2.54.0
From 59f2893434d4c15602e39523ab8ccb2cf05923c8 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:24:09 +0200
Subject: [PATCH 103/299] docs(adr): ADR-034 names the settings for what they
are
TAPIR_RECONCILE_BASE_MODEL, not 'second model': the engine added to turn the
feature on is the one whose TEXT is the base, and the old name hid that. All three
models must be local aliases; the base must differ from the other engine. ADR is
still Proposed, so this is an in-place correction.
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/DECISIONS.md b/DECISIONS.md
index 97bf6d3..f63320c 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1601,10 +1601,13 @@ Mode on iguana is the unproven suspect).
plain words what was done — "reconciled from two transcripts: N differences, K uncertain" — and
lists the uncertain spots. A reconciled transcript is never presented as simply "the" transcript.
-7. **Configuration.** `TAPIR_TRANSCRIBE_SECOND_MODEL` (empty = off), `TAPIR_RECONCILE_MODEL`
- (required when a second engine is set), `TAPIR_RECONCILE_CHECK_ORDER` (off). The default job
- timeout rises from 30 to 60 minutes: ~4 min of kb-whisper and ~40 s of pianissimo per hour of audio,
- plus the adjudication.
+7. **Configuration.** `TAPIR_RECONCILE_BASE_MODEL` (empty = off; the engine whose text is the base,
+ e.g. `iguana/pianissimo` — `TAPIR_TRANSCRIBE_MODEL` stays the other engine, e.g. kb-whisper),
+ `TAPIR_RECONCILE_MODEL` (the adjudicator; required when a base engine is set),
+ `TAPIR_RECONCILE_CHECK_ORDER` (off). All three models must be local aliases (`koala/` or `iguana/`):
+ a cloud alias is a start-up error, and the base engine must differ from the other one. The default
+ job timeout rises from 30 to 60 minutes: ~4 min of kb-whisper and ~40 s of pianissimo per hour of
+ audio, plus the adjudication.
8. **Not decided here.** A cloud adjudicator (rejected: private content); tuning the prompt; making
`gemma4-31b` the default; a "reconciling" progress state (the page says "transcribing" for the whole
--
2.54.0
From ad071d44cb76477f5186280dae1d28f0c8352a4e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:24:09 +0200
Subject: [PATCH 104/299] test(config): reconciliation settings -- off by
default, local only, complete (red)
Off by default; a base engine needs an adjudicator; every model must be a local alias
(koala/ or iguana/): a cloud adjudicator is a start-up error because private meeting
content must not reach it; the base engine must differ from the other engine; default
job timeout 60m (two engines plus adjudication).
Red: the Config fields do not exist.
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads_test.go | 77 ++++++++++++++++++++++++++++++++-
1 file changed, 75 insertions(+), 2 deletions(-)
diff --git a/internal/config/uploads_test.go b/internal/config/uploads_test.go
index 3d120bf..858cdf1 100644
--- a/internal/config/uploads_test.go
+++ b/internal/config/uploads_test.go
@@ -15,6 +15,7 @@ func clearUploadEnv(t *testing.T) {
"TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT",
"TAPIR_UPLOAD_TMP_DIR", "TAPIR_FFMPEG_BIN", "TAPIR_FFPROBE_BIN",
"TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", "TAPIR_UPLOAD_CHAT_MODEL",
+ "TAPIR_RECONCILE_BASE_MODEL", "TAPIR_RECONCILE_MODEL", "TAPIR_RECONCILE_CHECK_ORDER",
} {
unset(t, k)
}
@@ -43,8 +44,11 @@ func TestLoad_UploadDefaults(t *testing.T) {
if c.TranscribeFallbackModel != "berget/kb-whisper" {
t.Errorf("TranscribeFallbackModel = %q", c.TranscribeFallbackModel)
}
- if c.TranscribeTimeout != 30*time.Minute {
- t.Errorf("TranscribeTimeout = %v, want 30m (a 90 min recording took ~7.5 min)", c.TranscribeTimeout)
+ if c.TranscribeTimeout != 60*time.Minute {
+ t.Errorf("TranscribeTimeout = %v, want 60m (ADR-034: two engines plus ~300 adjudications)", c.TranscribeTimeout)
+ }
+ if c.ReconcileBaseModel != "" || c.ReconcileModel != "" || c.ReconcileCheckOrder {
+ t.Errorf("reconciliation must be OFF by default: %q %q %v", c.ReconcileBaseModel, c.ReconcileModel, c.ReconcileCheckOrder)
}
if c.UploadTmpDir != os.TempDir() {
t.Errorf("UploadTmpDir = %q, want os.TempDir()", c.UploadTmpDir)
@@ -139,3 +143,72 @@ func TestLoad_UploadRejectsInvalidValues(t *testing.T) {
})
}
}
+
+func TestLoad_ReconcileSettingsAreParsed(t *testing.T) {
+ clearUploadEnv(t)
+ setEnv(t, map[string]string{
+ "TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo",
+ "TAPIR_RECONCILE_MODEL": "iguana/gemma4-31b",
+ "TAPIR_RECONCILE_CHECK_ORDER": "true",
+ })
+
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ if c.ReconcileBaseModel != "iguana/pianissimo" || c.ReconcileModel != "iguana/gemma4-31b" || !c.ReconcileCheckOrder {
+ t.Errorf("not applied: %+v", c.UploadConfig)
+ }
+}
+
+func TestLoad_ReconcileRefusesAnUnsafeOrIncompleteSetup(t *testing.T) {
+ tests := []struct {
+ name string
+ env map[string]string
+ }{
+ {"a base engine with no adjudicator", map[string]string{"TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo"}},
+ {"a cloud adjudicator: private meeting content must not reach it", map[string]string{
+ "TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "berget/mistral-small",
+ }},
+ {"a cloud adjudicator from another provider", map[string]string{
+ "TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "gemini/gemini-2.5-pro",
+ }},
+ {"an adjudicator alias with no host prefix", map[string]string{
+ "TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "gemma4-26b",
+ }},
+ {"a cloud base engine", map[string]string{
+ "TAPIR_RECONCILE_BASE_MODEL": "berget/kb-whisper", "TAPIR_RECONCILE_MODEL": "iguana/gemma4-26b",
+ }},
+ {"the same engine twice", map[string]string{
+ "TAPIR_RECONCILE_BASE_MODEL": "iguana/kb-whisper", "TAPIR_RECONCILE_MODEL": "iguana/gemma4-26b",
+ }},
+ {"a bad order-check flag", map[string]string{
+ "TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "iguana/gemma4-26b", "TAPIR_RECONCILE_CHECK_ORDER": "perhaps",
+ }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ clearUploadEnv(t)
+ setEnv(t, tt.env)
+
+ if _, err := Load(); err == nil {
+ t.Errorf("Load accepted %v", tt.env)
+ }
+ })
+ }
+}
+
+func TestLoad_AnAdjudicatorWithoutABaseEngineIsIgnoredNotAnError(t *testing.T) {
+ clearUploadEnv(t)
+ t.Setenv("TAPIR_RECONCILE_MODEL", "iguana/gemma4-26b")
+
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ if c.ReconcileBaseModel != "" {
+ t.Errorf("reconciliation must stay off without a base engine")
+ }
+}
--
2.54.0
From f25fdaf398dd6555301db42ed17590dedb8daf5c Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:24:34 +0200
Subject: [PATCH 105/299] test(config): a cloud alias that merely contains a
local host name is still cloud
Mutation-testing showed a substring match instead of a prefix match went unnoticed.
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads_test.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/internal/config/uploads_test.go b/internal/config/uploads_test.go
index 858cdf1..dceb532 100644
--- a/internal/config/uploads_test.go
+++ b/internal/config/uploads_test.go
@@ -177,6 +177,8 @@ func TestLoad_ReconcileRefusesAnUnsafeOrIncompleteSetup(t *testing.T) {
{"an adjudicator alias with no host prefix", map[string]string{
"TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "gemma4-26b",
}},
+ {"a cloud alias that merely contains a local host name", map[string]string{
+ "TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "berget/iguana/gemma4-26b"}},
{"a cloud base engine", map[string]string{
"TAPIR_RECONCILE_BASE_MODEL": "berget/kb-whisper", "TAPIR_RECONCILE_MODEL": "iguana/gemma4-26b",
}},
--
2.54.0
From 61fb50ce9910baf5eaaccf62f378ab917ea3b042 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:24:34 +0200
Subject: [PATCH 106/299] feat(config): two-engine reconciliation settings with
a local-only guard (ADR-034)
TAPIR_RECONCILE_BASE_MODEL turns it on and needs TAPIR_RECONCILE_MODEL; every model must
be a local alias (koala/ or iguana/, matched as a PREFIX): a cloud adjudicator is a
start-up error because private meeting content must not reach it; the two engines must
differ; default job timeout 60m. Off by default. Mutation-checked.
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads.go | 63 +++++++++++++++++++++++++++++++++++++-
1 file changed, 62 insertions(+), 1 deletion(-)
diff --git a/internal/config/uploads.go b/internal/config/uploads.go
index ac4942a..f428eba 100644
--- a/internal/config/uploads.go
+++ b/internal/config/uploads.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
+ "strings"
"time"
)
@@ -42,6 +43,16 @@ type UploadConfig struct {
// NOT the summarizer default: koala/phi4-mini has an 8,192-token context and
// returns HTTP 400 on an hour of speech (~13,000 tokens).
UploadChatModel string
+ // ReconcileBaseModel turns on two-engine transcription (ADR-034): the engine whose
+ // TEXT is the base of the merged transcript (e.g. iguana/pianissimo). TranscribeModel
+ // stays the other engine (e.g. kb-whisper). Empty = off.
+ ReconcileBaseModel string
+ // ReconcileModel is the LOCAL chat model that chooses between the two engines'
+ // disagreements. Required when ReconcileBaseModel is set.
+ ReconcileModel string
+ // ReconcileCheckOrder asks the model twice per dispute with the reading order
+ // swapped and keeps only agreement. It doubles the time; off by default.
+ ReconcileCheckOrder bool
}
const (
@@ -49,7 +60,7 @@ const (
defaultUploadMaxMinutes = 90
defaultTranscribeModel = "iguana/kb-whisper"
defaultTranscribeFallbackModel = "berget/kb-whisper"
- defaultTranscribeTimeout = 30 * time.Minute
+ defaultTranscribeTimeout = 60 * time.Minute
defaultMaxUploadedChars = 90000
defaultUploadChatModel = "iguana/gemma4-26b"
)
@@ -62,6 +73,8 @@ func loadUploadConfig() (UploadConfig, error) {
FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"),
FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"),
UploadChatModel: envOr("TAPIR_UPLOAD_CHAT_MODEL", defaultUploadChatModel),
+ ReconcileBaseModel: os.Getenv("TAPIR_RECONCILE_BASE_MODEL"),
+ ReconcileModel: os.Getenv("TAPIR_RECONCILE_MODEL"),
}
if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" {
@@ -93,6 +106,17 @@ func loadUploadConfig() (UploadConfig, error) {
}
u.TranscribeTimeout = timeout
+ if v := os.Getenv("TAPIR_RECONCILE_CHECK_ORDER"); v != "" {
+ on, err := strconv.ParseBool(v)
+ if err != nil {
+ return UploadConfig{}, fmt.Errorf("config: TAPIR_RECONCILE_CHECK_ORDER=%q: %w", v, err)
+ }
+ u.ReconcileCheckOrder = on
+ }
+ if err := u.validateReconcile(); err != nil {
+ return UploadConfig{}, err
+ }
+
chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars)
if err != nil {
return UploadConfig{}, err
@@ -116,3 +140,40 @@ func positiveInt(key string, fallback int) (int, error) {
}
return n, nil
}
+
+// localPrefixes are the host prefixes of the models that run on the homelab. Private
+// meeting content may be sent to these and to nothing else for reconciliation
+// (ADR-034): a cloud alias is refused at start-up rather than discovered later.
+var localPrefixes = []string{"koala/", "iguana/"}
+
+func isLocalModel(alias string) bool {
+ for _, p := range localPrefixes {
+ if strings.HasPrefix(alias, p) {
+ return true
+ }
+ }
+ return false
+}
+
+// validateReconcile checks the two-engine settings. They are inert while no base
+// engine is set; once one is, the setup must be complete and entirely local.
+func (u UploadConfig) validateReconcile() error {
+ if u.ReconcileBaseModel == "" {
+ return nil
+ }
+ if u.ReconcileModel == "" {
+ return fmt.Errorf("config: TAPIR_RECONCILE_BASE_MODEL is set but TAPIR_RECONCILE_MODEL (the adjudicator) is not")
+ }
+ for _, m := range []struct{ name, alias string }{
+ {"TAPIR_RECONCILE_BASE_MODEL", u.ReconcileBaseModel},
+ {"TAPIR_RECONCILE_MODEL", u.ReconcileModel},
+ } {
+ if !isLocalModel(m.alias) {
+ return fmt.Errorf("config: %s=%q is not a local model (koala/ or iguana/): private meeting content must not leave the homelab for reconciliation", m.name, m.alias)
+ }
+ }
+ if u.ReconcileBaseModel == u.TranscribeModel {
+ return fmt.Errorf("config: TAPIR_RECONCILE_BASE_MODEL and TAPIR_TRANSCRIBE_MODEL are both %q: two engines must differ", u.TranscribeModel)
+ }
+ return nil
+}
--
2.54.0
From 8110fe56abc2d07891f2917330fa69b07011dd49 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:24:38 +0200
Subject: [PATCH 107/299] style(config): gofumpt the new test rows
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads_test.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/internal/config/uploads_test.go b/internal/config/uploads_test.go
index dceb532..b4874aa 100644
--- a/internal/config/uploads_test.go
+++ b/internal/config/uploads_test.go
@@ -178,7 +178,8 @@ func TestLoad_ReconcileRefusesAnUnsafeOrIncompleteSetup(t *testing.T) {
"TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "gemma4-26b",
}},
{"a cloud alias that merely contains a local host name", map[string]string{
- "TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "berget/iguana/gemma4-26b"}},
+ "TAPIR_RECONCILE_BASE_MODEL": "iguana/pianissimo", "TAPIR_RECONCILE_MODEL": "berget/iguana/gemma4-26b",
+ }},
{"a cloud base engine", map[string]string{
"TAPIR_RECONCILE_BASE_MODEL": "berget/kb-whisper", "TAPIR_RECONCILE_MODEL": "iguana/gemma4-26b",
}},
--
2.54.0
From 78916753933dabb0f4353b3ed97dcdf5132a1b44 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:24:54 +0200
Subject: [PATCH 108/299] test(llm): a temperature option; zero is sent as
zero, not 'unset' (red)
The reconciler was measured at temperature 0 but the client hardcodes 0.2. Zero must
be sent explicitly, and the default must not change for existing callers.
Red: WithTemperature is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/llm/temperature_test.go | 57 +++++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 internal/adapters/llm/temperature_test.go
diff --git a/internal/adapters/llm/temperature_test.go b/internal/adapters/llm/temperature_test.go
new file mode 100644
index 0000000..7a227e7
--- /dev/null
+++ b/internal/adapters/llm/temperature_test.go
@@ -0,0 +1,57 @@
+package llm
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+// capturedTemperature runs one Complete against a fake gateway and returns the
+// "temperature" field the request carried, and whether it was present at all.
+func capturedTemperature(t *testing.T, opts ...Option) (float64, bool) {
+ t.Helper()
+ var body map[string]any
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &body)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "ok"}}}})
+ }))
+ defer srv.Close()
+
+ if _, err := New(srv.URL, "", "m", 5*time.Second, opts...).Complete(context.Background(), "s", "u"); err != nil {
+ t.Fatalf("Complete: %v", err)
+ }
+ v, ok := body["temperature"].(float64)
+ return v, ok
+}
+
+func TestClient_DefaultTemperatureIsUnchanged(t *testing.T) {
+ got, ok := capturedTemperature(t)
+
+ if !ok || got != 0.2 {
+ t.Fatalf("default temperature = %v (present %v), want 0.2: existing callers must not change behaviour", got, ok)
+ }
+}
+
+func TestClient_WithTemperatureZeroSendsExactlyZero(t *testing.T) {
+ // The reconciler's accuracy (92%/97%) was measured at temperature 0. Zero must be
+ // SENT, not treated as "unset" and replaced by the default.
+ got, ok := capturedTemperature(t, WithTemperature(0))
+
+ if !ok || got != 0 {
+ t.Fatalf("temperature = %v (present %v), want an explicit 0", got, ok)
+ }
+}
+
+func TestClient_WithTemperatureSendsTheGivenValue(t *testing.T) {
+ got, ok := capturedTemperature(t, WithTemperature(0.7))
+
+ if !ok || got != 0.7 {
+ t.Fatalf("temperature = %v (present %v), want 0.7", got, ok)
+ }
+}
--
2.54.0
From 505670c0e1c32e2e37515539f37d2a31b73fa6eb Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:25:10 +0200
Subject: [PATCH 109/299] feat(llm): WithTemperature, default unchanged
Zero is sent as zero (the field has no omitempty). The reconciler's measured accuracy
was at temperature 0, so it runs there; every other caller keeps 0.2.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/llm/client.go | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go
index f2fc1a6..5ced970 100644
--- a/internal/adapters/llm/client.go
+++ b/internal/adapters/llm/client.go
@@ -25,12 +25,16 @@ import (
// docs/homelab-integration.md.
const defaultMaxTokens = 8192
+// defaultTemperature is what every caller got before WithTemperature existed.
+const defaultTemperature = 0.2
+
// Client calls an OpenAI-compatible chat completions endpoint.
type Client struct {
baseURL string
apiKey string
model string
maxTokens int
+ temp float64
httpClient *http.Client
usageHook func(model string, prompt, completion int)
}
@@ -51,6 +55,12 @@ func WithMaxTokens(n int) Option {
}
}
+// WithTemperature overrides the sampling temperature. Zero IS a valid value and is sent
+// as zero: the reconciler's accuracy was measured at temperature 0 and must run there.
+func WithTemperature(t float64) Option {
+ return func(c *Client) { c.temp = t }
+}
+
// WithUsageHook registers a callback fired after a successful completion with the
// model and the prompt/completion token counts from the response usage block. It
// keeps this copied, stdlib-only package (ADR-004) decoupled from metrics: the
@@ -66,6 +76,7 @@ func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) *
apiKey: apiKey,
model: model,
maxTokens: defaultMaxTokens,
+ temp: defaultTemperature,
httpClient: &http.Client{Timeout: timeout},
}
for _, opt := range opts {
@@ -105,7 +116,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
{Role: "system", Content: system},
{Role: "user", Content: user},
},
- Temperature: 0.2,
+ Temperature: c.temp,
MaxTokens: c.maxTokens,
}
b, err := json.Marshal(body)
--
2.54.0
From b492a3144e4dab58dc286cf214a42e8420b2b9df Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:25:10 +0200
Subject: [PATCH 110/299] test(cmd): the base-engine setting turns on the
ensemble, and nothing else does (red)
Red: buildTranscriber is undefined.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_test.go | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/cmd/tapir/uploads_test.go b/cmd/tapir/uploads_test.go
index 9a55b62..f91e782 100644
--- a/cmd/tapir/uploads_test.go
+++ b/cmd/tapir/uploads_test.go
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web"
@@ -154,3 +155,33 @@ func TestWireUploads_NilIntakeLeavesTheAppUntouched(t *testing.T) {
assert.Nil(t, app.Uploads, "a nil *Intake must not become a non-nil interface that mounts the routes")
assert.Zero(t, app.UploadChatMaxChars)
}
+
+func TestBuildTranscriber_DefaultIsTheSingleEngineChainAsBefore(t *testing.T) {
+ cfg := uploadCfg(t, true)
+
+ tr := buildTranscriber(cfg)
+
+ assert.IsType(t, &transcribe.Chain{}, tr, "with no base engine configured nothing changes")
+}
+
+func TestBuildTranscriber_ABaseEngineTurnsOnTheEnsemble(t *testing.T) {
+ cfg := uploadCfg(t, true)
+ cfg.ReconcileBaseModel = "iguana/pianissimo"
+ cfg.ReconcileModel = "iguana/gemma4-26b"
+
+ tr := buildTranscriber(cfg)
+
+ assert.IsType(t, &transcribe.Ensemble{}, tr)
+}
+
+func TestBuildUploads_WiresTheEnsembleWhenConfigured(t *testing.T) {
+ cfg := uploadCfg(t, true)
+ cfg.ReconcileBaseModel = "iguana/pianissimo"
+ cfg.ReconcileModel = "iguana/gemma4-26b"
+
+ in, w, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
+
+ require.NoError(t, err)
+ assert.NotNil(t, in)
+ assert.NotNil(t, w)
+}
--
2.54.0
From 3d755fd57a3824ca01d76bad25675d9e400fd6aa Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:26:02 +0200
Subject: [PATCH 111/299] fix(transcribe): check (discard) the deferred Close
errors golangci-lint flags
Found because the project's lint step cannot fail: the Taskfile runs
'golangci-lint run ./... || echo "not installed -- skipping"', so a failing lint
prints that message and exits 0. CI's log showed these findings and still passed.
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/gateway.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/internal/adapters/transcribe/gateway.go b/internal/adapters/transcribe/gateway.go
index 33cf464..763fcc4 100644
--- a/internal/adapters/transcribe/gateway.go
+++ b/internal/adapters/transcribe/gateway.go
@@ -62,7 +62,7 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: open audio: %w", err)
}
- defer f.Close()
+ defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: stat audio: %w", err)
@@ -95,7 +95,7 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
}
- defer resp.Body.Close()
+ defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snip, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
return domain.Transcription{}, &StatusError{Code: resp.StatusCode, Snippet: strings.TrimSpace(string(snip))}
--
2.54.0
From 7338d9d60361ea15958aa844cb8b333014275c95 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:26:02 +0200
Subject: [PATCH 112/299] test(e2e): discard the deferred Close error
golangci-lint flags
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/e2e_upload_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cmd/tapir/e2e_upload_test.go b/cmd/tapir/e2e_upload_test.go
index 2459374..2432c3b 100644
--- a/cmd/tapir/e2e_upload_test.go
+++ b/cmd/tapir/e2e_upload_test.go
@@ -92,7 +92,7 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
// 1. Upload.
f, err := os.Open(audioPath)
require.NoError(t, err)
- defer f.Close()
+ defer func() { _ = f.Close() }()
pr, pw := io.Pipe()
mw := multipart.NewWriter(pw)
go func() {
--
2.54.0
From ed8ca5467431d9ee3c35e3caf4fb4e892c1dcb30 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:26:02 +0200
Subject: [PATCH 113/299] feat(cmd): buildTranscriber -- the base-engine
setting turns on the two-engine ensemble
Default is the single-engine chain exactly as before. With TAPIR_RECONCILE_BASE_MODEL set,
the base engine and the primary run on the same WAV and a LOCAL model (config has already
refused any non-local alias) reconciles them, at temperature 0 with a short reply -- the
conditions its accuracy was measured under. The cloud fallback is unchanged and runs
only when both local engines fail.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads.go | 50 +++++++++++++++++++++++++++++++++++---------
1 file changed, 40 insertions(+), 10 deletions(-)
diff --git a/cmd/tapir/uploads.go b/cmd/tapir/uploads.go
index 0da0d53..40dba37 100644
--- a/cmd/tapir/uploads.go
+++ b/cmd/tapir/uploads.go
@@ -7,10 +7,13 @@ import (
"net/http"
"os"
"path/filepath"
+ "time"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
+ "git.d-ma.be/mathias/tapir/internal/adapters/llm"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/config"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
"git.d-ma.be/mathias/tapir/internal/uploads"
"git.d-ma.be/mathias/tapir/internal/web"
)
@@ -52,16 +55,7 @@ func buildUploads(ctx context.Context, cfg config.Config, st uploadsStore, log *
log.Warn("uploads: marked interrupted by a restart (audio is not retained; they must be re-uploaded)", "count", n)
}
- // No overall client timeout: a 90 minute recording takes minutes. The deadline
- // is the worker's per-job context.
- client := &http.Client{}
- primary := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeModel, client)
- var fallback transcribe.Backend
- if cfg.TranscribeFallbackModel != "" && cfg.TranscribeFallbackModel != cfg.TranscribeModel {
- fallback = transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeFallbackModel, client)
- }
-
- worker := uploads.NewWorker(st, &audio.FFmpeg{Bin: cfg.FFmpegBin}, transcribe.NewChain(primary, fallback),
+ worker := uploads.NewWorker(st, &audio.FFmpeg{Bin: cfg.FFmpegBin}, buildTranscriber(cfg),
cfg.UploadTmpDir, cfg.TranscribeTimeout)
intake := uploads.NewIntake(st, &audio.FFprobe{Bin: cfg.FFprobeBin}, worker,
uploadLimits(cfg), cfg.UploadTmpDir)
@@ -98,3 +92,39 @@ func wireUploads(app *web.App, cfg config.Config, in *uploads.Intake) {
app.UploadChatMaxChars = cfg.MaxUploadedTranscriptChars
app.UploadChatModel = cfg.UploadChatModel
}
+
+// adjudicatorTimeout bounds one adjudication call. A call takes ~2 s on gemma4-26b and ~10 s
+// on 31b; this is the wait before an unresponsive model is counted as an error.
+const adjudicatorTimeout = 2 * time.Minute
+
+// buildTranscriber builds what the worker transcribes with. By default that is the
+// single-engine chain (the primary, then the optional cloud fallback). When
+// TAPIR_RECONCILE_BASE_MODEL is set it is the two-engine ensemble (ADR-034): the base
+// engine and the primary run on the same WAV and a LOCAL model reconciles them. Config
+// has already refused any non-local model, so nothing here can send private audio or
+// text to a cloud model for reconciliation; the cloud fallback is unchanged.
+func buildTranscriber(cfg config.Config) uploads.Transcriber {
+ // No overall client timeout: a 90 minute recording takes minutes. The deadline is
+ // the worker's per-job context.
+ client := &http.Client{}
+ primary := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeModel, client)
+ var fallback transcribe.Backend
+ if cfg.TranscribeFallbackModel != "" && cfg.TranscribeFallbackModel != cfg.TranscribeModel {
+ fallback = transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeFallbackModel, client)
+ }
+ if cfg.ReconcileBaseModel == "" {
+ return transcribe.NewChain(primary, fallback)
+ }
+
+ base := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.ReconcileBaseModel, client)
+ // Temperature 0 and a short reply: the adjudicator's accuracy was measured that way.
+ chat := llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.ReconcileModel, adjudicatorTimeout,
+ llm.WithTemperature(0), llm.WithMaxTokens(200))
+ var adj reconcile.Adjudicator = reconcile.NewLLMAdjudicator(chat)
+ if cfg.ReconcileCheckOrder {
+ adj = reconcile.NewOrderChecked(adj)
+ }
+ return transcribe.NewEnsemble(base, primary, fallback, adj, reconcile.Options{
+ BaseEngine: cfg.ReconcileBaseModel, AltEngine: cfg.TranscribeModel, Reconciler: cfg.ReconcileModel,
+ })
+}
--
2.54.0
From c3c9b1e48ab5f02acbf1429b9e13630bcedd6f5a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:27:22 +0200
Subject: [PATCH 114/299] test(e2e): two engines + local reconciler, live
TAPIR_E2E_RECONCILE=1 runs pianissimo + kb-whisper + gemma4-26b on the audio and
asserts the reconciliation is recorded, every contributor is named in the endpoint, and
the page says what was done. Run 2026-10-01 on 82 s of synthetic Swedish: 3 disputes, all
3 changed; against the known script 2 were right (virket, a missing 'procent') and 1 was
WRONG (Maria -> Vi replaced a correct base reading). A smoke test, not a measurement.
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/e2e_upload_test.go | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/cmd/tapir/e2e_upload_test.go b/cmd/tapir/e2e_upload_test.go
index 2432c3b..e4c2803 100644
--- a/cmd/tapir/e2e_upload_test.go
+++ b/cmd/tapir/e2e_upload_test.go
@@ -74,6 +74,11 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
cfg.GatewayKey = key
cfg.UploadsEnabled = true
cfg.UploadTmpDir = filepath.Join(t.TempDir(), "spool")
+ if os.Getenv("TAPIR_E2E_RECONCILE") == "1" {
+ // Two engines and a local model, as ADR-034 describes.
+ cfg.ReconcileBaseModel = envOrDefault("TAPIR_E2E_BASE_MODEL", "iguana/pianissimo")
+ cfg.ReconcileModel = envOrDefault("TAPIR_E2E_RECONCILE_MODEL", "iguana/gemma4-26b")
+ }
cfg.FFmpegBin = envOrDefault("TAPIR_E2E_FFMPEG", "ffmpeg")
cfg.FFprobeBin = envOrDefault("TAPIR_E2E_FFPROBE", "ffprobe")
@@ -134,6 +139,15 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
`SELECT status, endpoint, content, audio_seconds, had_video FROM uploaded_transcripts WHERE user_id = $1`, uid).
Scan(&status, &endpoint, &content, &secs, &hadVideo))
t.Logf("done: endpoint=%s audio_s=%.1f transcript_chars=%d had_video=%v", endpoint, secs, len(content), hadVideo)
+ if os.Getenv("TAPIR_E2E_RECONCILE") == "1" {
+ var recJSON string
+ require.NoError(t, raw.QueryRow(ctx,
+ `SELECT COALESCE(reconciliation::text, 'NULL') FROM uploaded_transcripts WHERE user_id = $1`, uid).Scan(&recJSON))
+ require.NotEqual(t, "NULL", recJSON, "a two-engine run must record its reconciliation")
+ t.Logf("reconciliation: %.600s", recJSON)
+ assert.Contains(t, endpoint, "reconciled by", "every contributor is named in the endpoint")
+ assert.Contains(t, page, "Reconciled from two transcripts")
+ }
if os.Getenv("TAPIR_E2E_HAD_VIDEO") == "1" {
// A Teams meeting with the camera off: a video file whose audio is what matters.
assert.True(t, hadVideo, "the file had a video track and the row must say so")
--
2.54.0
From 2497554683479126ead99a06a06cd3c79a831f27 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:27:22 +0200
Subject: [PATCH 115/299] docs(adr): ADR-034 records the first live smoke,
including the wrong call
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/DECISIONS.md b/DECISIONS.md
index f63320c..dfdf5c9 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1627,6 +1627,13 @@ Mode on iguana is the unproven suspect).
the wrong one (it was right 92% on a hand-picked set, ~97% for 31b).
- Reading the page takes more words: the reconciliation summary is new surface to keep honest.
+**First live smoke (2026-10-01, synthetic audio, 3 disputes):** all 3 were changed to the
+alternative's reading; against the known script 2 were right and **1 was wrong** (a name replaced by a
+pronoun, a correct base reading lost). That is one run of three, not a measurement, but it is the failure
+this design must expect: the reconciler can overwrite a correct reading, which is why changed spots, not
+only uncertain ones, are listed on the page, and why the decision below is a blind check on a real
+recording.
+
**Validation / resolution triggers.**
- **The test that decides it:** re-upload the same real recording after merge; the speaker judges, blind,
~30 disputes where the reconciled text differs from pianissimo alone. Keep the feature only if the
--
2.54.0
From 36a80ac4f52d78e8cc6ff4e6648092867bfb9584 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:27:22 +0200
Subject: [PATCH 116/299] docs(use-cases): scenarios for two-engine
reconciliation (ADR-034)
Co-Authored-By: Claude Sonnet 5.5
---
docs/use-cases/audio_upload.feature | 50 +++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/docs/use-cases/audio_upload.feature b/docs/use-cases/audio_upload.feature
index ba31cda..87c31cc 100644
--- a/docs/use-cases/audio_upload.feature
+++ b/docs/use-cases/audio_upload.feature
@@ -106,3 +106,53 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
Given my recording's transcript is longer than the caption default
When I ask a question about it
Then the transcript is sent with the upload budget, not the 18,000-character caption default
+
+ # ADR-034, two engines and a local reconciler (off unless TAPIR_RECONCILE_BASE_MODEL is set)
+
+ Scenario: Two engines transcribe the audio and their disagreements are reconciled
+ Given two-engine transcription is enabled
+ When a recording is transcribed
+ Then both engines run on the same audio, the fast one first
+ And a local model chooses between their disagreements
+ And the endpoint names every engine and the model that took part
+
+ Scenario: Trivial disagreements never reach the model
+ When the engines differ only by a compound written as one word or two, or by a filler word
+ Then the base reading is kept without a model call
+
+ Scenario: Differences close together are one decision
+ When two differences are separated by a single shared word
+ Then they are asked about as one dispute
+
+ Scenario: An unsure or changed reading is flagged for me
+ When the model changes a reading or cannot decide
+ Then the page lists the spot with a minute mark, both readings and which was used
+ And an unsure spot keeps the base reading and is marked uncertain
+
+ Scenario: The model cannot be a cloud model
+ When the reconciliation model or base engine is not a koala/ or iguana/ alias
+ Then the service refuses to start
+ And a cloud alias that merely contains a local host name is refused too
+
+ Scenario: One engine failing does not fail the upload
+ When one of the two engines fails
+ Then the other engine's transcript is used
+ And the page says only one engine produced it and why
+ And the cloud fallback is not used
+
+ Scenario: Only when both local engines fail does the cloud fallback run
+ When both local engines fail and a fallback is configured
+ Then the fallback transcribes it and the row records that it did
+
+ Scenario: A model that is down gives an unreconciled transcript, not a failed job
+ When the reconciliation model fails three times in a row
+ Then the base transcript is kept unreconciled with the reason
+ And the page says it could not be reconciled
+
+ Scenario: A cancelled job never reaches the cloud
+ When the job is cancelled during either engine
+ Then neither the second engine nor the cloud fallback is called
+
+ Scenario: A single-engine transcript claims no second opinion
+ When only one engine was configured
+ Then the page does not mention reconciliation
--
2.54.0
From 26cc00c1cc3bfee4062fcb23c3e7367d329927c9 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:27:37 +0200
Subject: [PATCH 117/299] test(acceptance): map the two-engine scenarios to
their covering tests
Co-Authored-By: Claude Sonnet 5.5
---
test/acceptance/scenario_coverage_test.go | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go
index a2c8ae4..651eacd 100644
--- a/test/acceptance/scenario_coverage_test.go
+++ b/test/acceptance/scenario_coverage_test.go
@@ -122,6 +122,18 @@ var scenarioCoverage = map[string]string{
"I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
"A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
+ // audio_upload.feature, ADR-034
+ "Two engines transcribe the audio and their disagreements are reconciled": "TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled",
+ "Trivial disagreements never reach the model": "TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel",
+ "Differences close together are one decision": "TestFindDisputes_TwoDifferencesSeparatedByOneSharedWordAreOneDispute",
+ "An unsure or changed reading is flagged for me": "TestUploadStatus_ListsTheContestedSpotsWithAMinuteMarkBothReadingsAndWhichWasUsed",
+ "The model cannot be a cloud model": "TestLoad_ReconcileRefusesAnUnsafeOrIncompleteSetup",
+ "One engine failing does not fail the upload": "TestEnsemble_WhenOneEngineFailsTheOtherIsUsedAndItIsSaidSo",
+ "Only when both local engines fail does the cloud fallback run": "TestEnsemble_OnlyWhenBothLocalEnginesFailDoesTheCloudFallbackRun",
+ "A model that is down gives an unreconciled transcript, not a failed job": "TestEnsemble_AModelThatIsDownGivesTheBaseTextUnreconciledNotAFailedJob",
+ "A cancelled job never reaches the cloud": "TestEnsemble_ACancelledJobNeverReachesTheCloud",
+ "A single-engine transcript claims no second opinion": "TestUploadStatus_ASingleEngineTranscriptDoesNotClaimASecondOpinion",
+
// summarize_new_video.feature
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",
"A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped",
--
2.54.0
From 8dbcb9a9eb9689b60a4387f77040d991f9828914 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:27:47 +0200
Subject: [PATCH 118/299] docs(data-model): reconciliation column
Co-Authored-By: Claude Sonnet 5.5
---
docs/data-model.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/data-model.md b/docs/data-model.md
index 2d448b2..f898de9 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -153,6 +153,7 @@ erDiagram
float audio_seconds
text error_kind "typed: interrupted | transcription_failed — never a provider string"
bool had_video "the file carried a video track; only its audio was used (migration 018)"
+ jsonb reconciliation "NULL for a single engine; else status, exact counts and a bounded list of contested spots (migration 019, ADR-034)"
timestamptz created_at
timestamptz finished_at
}
@@ -224,7 +225,7 @@ mechanism.
grounds that it holds only public caption content, and a recorded meeting is the opposite.
`user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing →
done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK
- constraints). `had_video` (migration 018) records that the file carried a video track — a Teams meeting with the camera off is still a video file — so the page can say only its audio was used and the video was discarded. `endpoint` records which backend served it — `berget/...` means the audio left the
+ constraints). `reconciliation` (migration 019, ADR-034) is NULL when one engine produced the text, so the page can never claim a second opinion that was not there; otherwise it holds the status (`reconciled` / `single_engine` / `skipped`), exact counts (differences, settled automatically, decided by the model, changed, uncertain, model errors) and up to 200 contested spots (each a position in seconds, both readings and which was used). The spots hold fragments of the private transcript, which is why they live in this RLS-scoped row. `had_video` (migration 018) records that the file carried a video track — a Teams meeting with the camera off is still a video file — so the page can say only its audio was used and the video was discarded. `endpoint` records which backend served it — `berget/...` means the audio left the
homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90
minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion.
- **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
--
2.54.0
From b9e0dfa1c925651ac455e15ec81f7b778cec9f21 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:27:47 +0200
Subject: [PATCH 119/299] docs(homelab): two-engine mode, its settings and why
every model must be local
Co-Authored-By: Claude Sonnet 5.5
---
docs/homelab-integration.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md
index 3a003b9..8181592 100644
--- a/docs/homelab-integration.md
+++ b/docs/homelab-integration.md
@@ -28,6 +28,14 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
`iguana/gemma4-26b`, which silently drops the head above it), `TAPIR_UPLOAD_CHAT_MODEL`
(`iguana/gemma4-26b`; `koala/phi4-mini`'s 8,192-token context 400s on an hour of speech). Needs `ffmpeg` + `ffprobe`
in the runtime image; Tapir's own ingress body limit for a 200 MB upload is unverified (infra).
+ **Two-engine mode (ADR-034), off unless `TAPIR_RECONCILE_BASE_MODEL` is set:** it runs
+ `iguana/pianissimo` (the base text) and `TAPIR_TRANSCRIBE_MODEL` (kb-whisper) on the same audio, then a
+ LOCAL chat model (`TAPIR_RECONCILE_MODEL`, e.g. `iguana/gemma4-26b`: 92% against a speaker's blind answers
+ at ~2 s a call; `iguana/gemma4-31b` 97% but ~5x slower) chooses between their disagreements.
+ `TAPIR_RECONCILE_CHECK_ORDER=true` asks twice per dispute with the order swapped (doubles the time).
+ Every model must start `koala/` or `iguana/`: a cloud alias is a start-up error. The default job timeout is
+ 60 minutes. `iguana/pianissimo` is KlangAI's Swedish ASR (a Parakeet/FastConformer on MLX, served by a small
+ FastAPI shell on iguana:8102) and speaks the same `/v1/audio/transcriptions` API.
- **Auth key:** treat as a secret via the SecretStore port; do not hardcode in committed code.
NOTE: the documented `sk-local-123` returned `401` on `30401/v1/models` (2026-06-02) — it is
stale or the gateway now enforces the `LITELLM_MASTER_KEY` (1Password `op://HomeLab/LITELLM_MASTER_KEY`).
--
2.54.0
From 7ae888a468dacc831063174f56775a9e16593c60 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Thu, 1 Oct 2026 23:27:47 +0200
Subject: [PATCH 120/299] docs(claude): two-engine reconciliation, and that
task check does not gate lint (#41)
Co-Authored-By: Claude Sonnet 5.5
---
CLAUDE.md | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 02c5454..2860dea 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -100,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–017),
+ 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
@@ -118,6 +118,11 @@ The repo is **green and shipping** — last tag `v0.15.0`. `task check` passes (
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.
+ **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.
--
2.54.0
From e3aa661cf27561609da3c563ac27b5a38c2fee7f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 07:13:10 +0200
Subject: [PATCH 121/299] docs(research): Low Power Mode confirmed as the cause
of slow gemma4-31b (22.3 vs 7.9 tok/s decode)
Co-Authored-By: Claude Sonnet 5.5
---
.../local-model-adjudicator-sweep-2026-10-01.md | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/docs/research/local-model-adjudicator-sweep-2026-10-01.md b/docs/research/local-model-adjudicator-sweep-2026-10-01.md
index d98abd6..d541171 100644
--- a/docs/research/local-model-adjudicator-sweep-2026-10-01.md
+++ b/docs/research/local-model-adjudicator-sweep-2026-10-01.md
@@ -70,6 +70,16 @@ on GPU), context length (32k vs 4k made no difference), and the **inference engi
of the same model measured 76 tok/s prefill and 8.6 decode, the same within noise. (Terse
output saves only ~3.6 s of ~9.3 s because prefill dominates.) Two unrelated engines at the same
speed points at the machine: ~140 GB/s effective memory bandwidth on a part rated for 800, and
-**iguana has macOS Low Power Mode on** (`pmset` `lowpowermode 1`). That is the leading suspect
-and is **not proven**: turning it off needs root and was not done. A first MLX benchmark was
+**iguana had macOS Low Power Mode on** (`pmset` `lowpowermode 1`). A first MLX benchmark was
wrong because `mlx_lm.server` caches prompts; the figures above are from the corrected run.
+
+**Confirmed 2026-10-02.** The owner turned Low Power Mode off (`lowpowermode 0`, read back over
+ssh) and `gemma4:31b` was re-measured with nothing else resident: **217 tok/s prefill, 22.3
+tok/s decode** (4 runs, spread 22.2-22.6), against 103 and 7.9 with it on, so 2.1x and 2.8x. The
+slowness was the power mode, not the engine or the model. Caveats on the comparison: the new run
+used a different, synthetic Swedish prompt (~700 words, the original items held real transcript
+text and were deleted) and the model answered in 26-43 tokens, not the ~200 of the first run, so
+it is a same-machine before/after, not a controlled A/B. `gemma4:26b` and the MLX figures were
+not re-measured. The 31B's 97% judge accuracy is therefore usable at roughly a third of the
+latency the sweep implied; the 26B-vs-31B choice in ADR-034 was made on the slow numbers and is
+reopened by this.
--
2.54.0
From 7f8e3677b38fe650b7bbcb7c2f5dcfc4c3931345 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 07:13:10 +0200
Subject: [PATCH 122/299] docs(adr-034): gemma4-31b slowness explained; reopen
the 26B-vs-31B reconciler choice
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/DECISIONS.md b/DECISIONS.md
index dfdf5c9..4171b02 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1554,8 +1554,10 @@ against the speaker's answers: **`gemma4-31b` 97% and `gemma4-26b` 92%** passed
**Unknowns, stated.** The adjudicator evidence is 30 items from one speaker and one domain, chosen by
Opus as decidable; the other ~350 disagreements (fillers, compounds, small words) have no ground
truth. Whether a *merged* transcript is better than the better single engine has not been measured.
-Reconcile time per hour is estimated, not measured. `gemma4-31b`'s slowness is unexplained (Low Power
-Mode on iguana is the unproven suspect).
+Reconcile time per hour is estimated, not measured. `gemma4-31b`'s slowness was Low Power Mode on
+iguana: with it off the 31B decodes at 22.3 tok/s, not 7.9 (2026-10-02, same-machine before/after on
+a different synthetic prompt; see the research note). The reconciler default `gemma4-26b` was chosen
+on the slow numbers and is reopened: 31B scored 97% against 92%.
**Decision.**
--
2.54.0
From 2e9d76dad07e06e26ffc0d8af840b6e2edc1786d Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:01:31 +0200
Subject: [PATCH 123/299] docs(research): first blind check of two-engine
reconcile on a real recording (aggregates only)
Co-Authored-By: Claude Sonnet 5.5
---
.../reconcile-blind-check-2026-10-02.md | 68 +++++++++++++++++++
1 file changed, 68 insertions(+)
create mode 100644 docs/research/reconcile-blind-check-2026-10-02.md
diff --git a/docs/research/reconcile-blind-check-2026-10-02.md b/docs/research/reconcile-blind-check-2026-10-02.md
new file mode 100644
index 0000000..e0b6e22
--- /dev/null
+++ b/docs/research/reconcile-blind-check-2026-10-02.md
@@ -0,0 +1,68 @@
+# Two-engine reconcile, first check on a real recording (tapir#33, ADR-034, 2026-10-02)
+
+Aggregate numbers only. This repo is public and the recording is private (#31 custody is open), so no
+transcript text, no disputed words and no per-item reading appears here.
+
+## What was run
+
+One real 63.6-minute Teams recording of one speaker dictating project notes (the video track, camera
+off, was discarded). Build: PR #39 head `7f8e367`. Everything local: `iguana/pianissimo` as base,
+`iguana/kb-whisper` as the second engine, `iguana/gemma4-31b` as reconciler (iguana Low Power Mode
+off), cloud fallbacks off, a throwaway Postgres.
+
+| Measure | Value |
+|---|---|
+| Job time, upload to saved | 21 min 49 s for 63.6 min of audio (20.6 min per hour of audio) |
+| Differences between the engines | 362 |
+| Settled automatically (compound splits, up to two filler words) | 80 |
+| Decided by the model | 282 |
+| Of those: alternative reading used / base kept / uncertain | 130 / 151 / 1 |
+| Model call errors | 0 |
+
+20.6 min per hour is inside the 25-minute resolution trigger in ADR-034, so no smaller adjudicator is
+needed on speed grounds. The earlier estimate was about 10 min per hour on gemma4-26b; it was not
+measured and this run used the 31B.
+
+## Blind check
+
+30 spots drawn at random (fixed seed) from the 89 replacement spots that could be placed in the base
+text, across the whole recording (0:44 to 60:57). The two readings were shown as x and y in random
+order, with the key kept apart from the judge. Judge: Claude Opus, text only, no audio, at the
+speaker's request ("I trust Opus"). The speaker did not judge these items themselves.
+
+| Verdict | Items |
+|---|---|
+| The reconciler's reading (the alternative) is right | 21 |
+| The base reading (pianissimo) is right, so the change made it worse | 1 |
+| Neither reading is right | 3 |
+| Cannot tell from text (compound, hyphen and number-format differences) | 5 |
+
+- Right of the items with a verdict: 21 of 22, 95% (95% interval 78% to 99%).
+- Right of all 30, counting "neither" and "cannot tell" as not right: 21 of 30, 70% (52% to 83%).
+- Right of the 25 that were not "cannot tell": 21 of 25, 84% (65% to 94%).
+- Net effect against the base on these 30: 21 better, 1 worse, 8 unchanged in quality.
+
+## Reading the result
+
+ADR-034's bar was "right at least 85% of the time". It did not say how ties count, and that was a
+flaw in the bar, not in the data. The literal reading (all 30) gives 70% and **does not meet** the bar;
+counting only decided items gives 95% and meets it. Neither is a hidden pass or a hidden failure; both
+are reported. What is not in doubt: on a random sample the reconciler improved the text far more often
+than it harmed it (21 to 1), and the 8 unchanged cases are mostly spellings the text cannot settle.
+
+## What this did not measure
+
+- **Better single engine.** The second ADR-034 condition, "no worse than the better single engine", is
+ **not answered**. Only changed spots are stored, not the 151 where the model kept pianissimo, so the
+ reconciler cannot be compared with kb-whisper alone. The 21-to-1 result is against pianissimo.
+- **The speaker as judge.** Opus judged from text. It cannot hear the audio, so it can be wrong where
+ the right word is audible but not inferable (items such as a mis-heard number or a name). Earlier,
+ Opus and the speaker agreed 30 of 30, but on items chosen as decidable; that does not transfer to a
+ random sample.
+- **Insertions and deletions.** 42 of the 131 stored spots were not sampled: 39 are an added or
+ removed word, which cannot be placed in the base text, and 3 are the uncertain spot or could not be
+ located. The 39 are mostly small words ("och", "det"), so this sample says little about them.
+- **One recording, one speaker, 30 items.** Wide intervals, and neighbouring spots (three pairs within
+ seconds of each other) are not independent.
+- **Segments keep the base text.** The saved timed segments hold pianissimo's wording, not the merged
+ text; only the stored full text is merged. Found while building this sample; not yet an issue.
--
2.54.0
From 1a89eb386830171f13b8a9a88916633db80d8922 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:01:31 +0200
Subject: [PATCH 124/299] docs(adr-034): record the first blind check; bar
ambiguity and the unanswered second condition
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/DECISIONS.md b/DECISIONS.md
index 4171b02..420e550 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1641,6 +1641,13 @@ recording.
~30 disputes where the reconciled text differs from pianissimo alone. Keep the feature only if the
reconciled reading is right at least 85% of the time **and** no worse than the better single engine.
If not, switch it off (config) and keep the single-engine path.
+ **First result, 2026-10-02, before merge** (`docs/research/reconcile-blind-check-2026-10-02.md`): 30
+ random replacement spots on the real 63.6-minute recording, judged blind by Opus from text (not by the
+ speaker, who delegated it): 21 right, 1 worse than pianissimo, 3 neither reading right, 5 undecidable.
+ That is 95% of decided items but 70% of all 30; the bar did not say how ties count, so it is **met on
+ one reading and missed on the other**, and the speaker has not ruled. The second condition, no worse
+ than the better single engine, is **not answered**: kept-base spots are not stored, so kb-whisper alone
+ cannot be compared. Left `Proposed`; the feature stays off by default.
- Reconcile time above 25 minutes per hour of audio: drop to a smaller adjudicator or skip more
disputes automatically.
- A different recording or speaker in which the engines agree far less (or far more): re-measure before
--
2.54.0
From 7e9f5d7ca5865f8817e22b44aee90aefcfb944e8 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:49:06 +0200
Subject: [PATCH 125/299] docs(adr): ADR-035 -- progress as real phases, the
same transitions in the log, every judged spot kept (#42 #43 #44)
Co-Authored-By: Claude Sonnet 5.5
---
DECISIONS.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 73 insertions(+), 1 deletion(-)
diff --git a/DECISIONS.md b/DECISIONS.md
index 420e550..553ceeb 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1596,7 +1596,8 @@ on the slow numbers and is reopened: 31B scored 97% against 92%.
timeout at a time. Both engines fail → ADR-033's fallback path.
6. **What is stored and shown.** `content` is the merged text. `segments` stay the base engine's timed
- segments, untouched: `content` is authoritative for chat, `segments` only for time. `endpoint`
+ segments, untouched (**amended by ADR-035: they now carry the merged words, timing stays the
+ base's**): `content` is authoritative for chat, `segments` only for time. `endpoint`
lists every contributor and the reconciler. A new `reconciliation` column (migration 019, same
RLS-scoped table) records the status, counts, and the **uncertain and changed spots with a minute
mark and both readings**, so the user can check them against the audio. The transcript page says in
@@ -1659,6 +1660,77 @@ unchanged.
---
+## ADR-035 — Show an upload's progress as real phases, log the same transitions, and keep every judged spot
+
+**Status:** Proposed (2026-10-02). Decider: Mathias (pending). Amends ADR-034 decision 6 (segments).
+Closes #42, #43 and #44.
+
+**Context (measured, not assumed).** The first real run (a 63.6-minute recording, 21 min 49 s) exposed
+three gaps. (1) The page said only `queued`/`transcribing`/`done`; during 22 minutes nothing told the
+user whether the job was working, hung or dead, and after a crash the row would say `transcribing`
+until the next start. (2) The server log had no line about the job between start-up and the final
+line, so an operator saw it only from outside (spool files, iguana CPU). (3) Evaluating the result
+showed that spots the model *kept* as the base are counted but not stored (so ADR-034's "no worse than
+the better single engine" cannot be measured after the fact, and the audio is not retained), insertions
+and deletions cannot be placed without context, and the saved `segments` still carry the base engine's
+wording while only `content` is merged.
+
+**Decision.**
+
+1. **Phases are data on the row.** A new `progress` JSONB column (migration 020, same RLS-scoped table)
+ holds an ordered list of steps `{key, label, state, started_at, finished_at, done, total}` with state
+ `pending|running|done|failed|skipped`. The plan is declared up front so the page shows what is coming:
+ received, preparing audio, then the transcriber's own steps (two-engine: engine A, engine B,
+ comparing, deciding n of N; single: transcribing), then saving.
+2. **One source for the page and the log.** An `internal/progress` Tracker is the only writer. Every
+ transition is saved to the row and logged by the same call, so the UI and the log cannot disagree.
+ Engines and the reconciler report through a context-carried `Reporter` (no-op when absent), so the
+ `Transcriber` interface does not change.
+3. **Alive is not the same as progressing, and the page says which it knows.** A heartbeat goroutine,
+ independent of any engine call, stamps `alive_at` every 10 s; `progress_at` moves only on a real
+ transition or advance. The page derives: *running*; *quiet* (the deciding step has not advanced for
+ 5 minutes, so the model is probably waiting or slow); *lost* (`alive_at` older than 45 s, so the
+ service stopped reporting and may have restarted or crashed). The two engine calls are single blocking
+ requests, so inside them the page shows elapsed time only: **no percentage is ever shown that does not
+ come from real counts.** Only the deciding step has a bar.
+4. **The page.** A server-rendered step list in tapir's existing tokens and mono font, swapped in place
+ by the existing HTMX poll (every 2 s while unfinished, none once done or failed). Finished steps
+ collapse to one line with their duration; the running step has a CSS-only spinner that respects
+ `prefers-reduced-motion`; a failed step and a lost service are visibly different from running.
+ No new JavaScript, framework or stylesheet.
+5. **Logs.** One INFO line per phase transition and engine call (upload id, endpoint, elapsed), the
+ reconcile counts, `adjudicated n/N` at most once a minute, a WARN when an engine soft-fails or the
+ breaker trips, and a "still in phase X" line every 5 minutes. **Ids, counts, durations, endpoints and
+ error kinds only: never transcript text, disputed words or the filename**, asserted by a test.
+6. **Every judged spot is stored.** `reconciliation.contested` keeps each model-decided spot with its
+ verdict (changed, kept base, uncertain) and a few words of left context, bounded at 2,000. The page
+ still lists only changed and uncertain spots. This makes ADR-034's second condition measurable on the
+ next real run.
+7. **Segments carry the merged words.** The reconciled words replace the base's in the saved `segments`;
+ timing stays the base's. If the segments cannot be aligned to the merged text (a word wrapped across
+ a segment edge), they stay the base's and the result says so in the log. This replaces ADR-034
+ decision 6's "segments stay untouched".
+
+**Alternatives rejected.** Server-sent events or a websocket: a long-lived connection through the
+ingress, with buffering to get wrong, for a page that changes every few seconds at best; polling is what
+the page already does. A percentage from the engine calls: there is no data behind it. A separate
+progress table: progress belongs to the row and inherits its RLS. Logging only through the UI: an
+operator has no page.
+
+**Consequences.** Positive: running, quiet, lost and failed are distinguishable by the user and the
+operator; the next blind check can answer ADR-034's second condition. Negative / watch: two more writes
+per transition and one per 10 s per running job (one job at a time, so negligible); a heartbeat proves
+the process is alive, not that an engine is making progress inside one request, which only the job
+timeout (60 min) bounds. The stored spot list grows to a few hundred small objects per hour of audio.
+
+**Validation.** A fake engine held open past the thresholds must change the page (quiet, lost); a log
+capture on a synthetic transcript must contain none of its words; a screenshot review of the running,
+quiet, lost, failed and done states against the existing pages (the visual claims tests cannot assert).
+Revisit if the heartbeat write ever shows in the database's load, or if a longer recording cap makes
+five-minute quiet detection too coarse.
+
+---
+
## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
--
2.54.0
From 7bf5f6b8b75e6407172b1506806b58fd42c36e4a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:49:44 +0200
Subject: [PATCH 126/299] test(progress): a tracker that shows the plan, times
each step, throttles counts and heartbeats on its own (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker_test.go | 295 ++++++++++++++++++++++++++++++
1 file changed, 295 insertions(+)
create mode 100644 internal/progress/tracker_test.go
diff --git a/internal/progress/tracker_test.go b/internal/progress/tracker_test.go
new file mode 100644
index 0000000..d88a88f
--- /dev/null
+++ b/internal/progress/tracker_test.go
@@ -0,0 +1,295 @@
+package progress_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "log/slog"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/progress"
+)
+
+// fakeSink records every saved snapshot and every heartbeat.
+type fakeSink struct {
+ mu sync.Mutex
+ saves [][]domain.ProgressStep
+ at []time.Time
+ beats int
+ err error
+}
+
+func (f *fakeSink) SaveProgress(_ context.Context, steps []domain.ProgressStep, at time.Time) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.saves = append(f.saves, append([]domain.ProgressStep(nil), steps...))
+ f.at = append(f.at, at)
+ return f.err
+}
+
+func (f *fakeSink) Beat(context.Context) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.beats++
+ return f.err
+}
+
+func (f *fakeSink) last(t *testing.T) []domain.ProgressStep {
+ t.Helper()
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ require.NotEmpty(t, f.saves, "nothing was saved")
+ return f.saves[len(f.saves)-1]
+}
+
+func (f *fakeSink) saveCount() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return len(f.saves)
+}
+
+func (f *fakeSink) beatCount() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.beats
+}
+
+// clock is a hand-wound clock so durations and throttling are exact.
+type clock struct{ t time.Time }
+
+func (c *clock) now() time.Time { return c.t }
+func (c *clock) tick(d time.Duration) { c.t = c.t.Add(d) }
+func newClock() *clock { return &clock{t: time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)} }
+
+func plan() []progress.Planned {
+ return []progress.Planned{{Key: "audio", Label: "Preparing audio"}, {Key: "decide", Label: "Deciding"}, {Key: "save", Label: "Saving"}}
+}
+
+func newTracker(t *testing.T) (*progress.Tracker, *fakeSink, *clock, *bytes.Buffer) {
+ t.Helper()
+ sink, clk, buf := &fakeSink{}, newClock(), &bytes.Buffer{}
+ log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ return progress.NewTracker(context.Background(), sink, log, plan(), progress.WithClock(clk.now), progress.WithMinSave(2*time.Second)), sink, clk, buf
+}
+
+func stateOf(steps []domain.ProgressStep, key string) domain.StepState {
+ for _, s := range steps {
+ if s.Key == key {
+ return s.State
+ }
+ }
+ return ""
+}
+
+func TestTracker_PlanIsShownUpFrontInOrderAllPending(t *testing.T) {
+ tr, sink, _, _ := newTracker(t)
+
+ tr.Start()
+
+ steps := sink.last(t)
+ require.Len(t, steps, 3)
+ require.Equal(t, []string{"audio", "decide", "save"}, []string{steps[0].Key, steps[1].Key, steps[2].Key})
+ for _, s := range steps {
+ require.Equal(t, domain.StepPending, s.State, s.Key)
+ }
+ require.Equal(t, "Preparing audio", steps[0].Label)
+}
+
+func TestTracker_BeginRunsAStepAndDoneRecordsItsDuration(t *testing.T) {
+ tr, sink, clk, _ := newTracker(t)
+ tr.Start()
+
+ tr.Begin("audio", 0)
+ require.Equal(t, domain.StepRunning, stateOf(sink.last(t), "audio"))
+ clk.tick(90 * time.Second)
+ tr.Done("audio")
+
+ s := sink.last(t)[0]
+ require.Equal(t, domain.StepDone, s.State)
+ require.Equal(t, 90*time.Second, s.FinishedAt.Sub(s.StartedAt), "the finished step must carry how long it took")
+}
+
+func TestTracker_AdvanceCountsAreSavedAndThrottled(t *testing.T) {
+ tr, sink, clk, _ := newTracker(t)
+ tr.Start()
+ tr.Begin("decide", 282)
+ base := sink.saveCount()
+
+ tr.Advance("decide", 1)
+ clk.tick(500 * time.Millisecond)
+ tr.Advance("decide", 2)
+ clk.tick(500 * time.Millisecond)
+ tr.Advance("decide", 3)
+
+ require.Equal(t, base+1, sink.saveCount(), "advances inside the minimum interval must not each hit the database")
+ clk.tick(2 * time.Second)
+ tr.Advance("decide", 4)
+ require.Equal(t, base+2, sink.saveCount())
+ got := sink.last(t)[1]
+ require.Equal(t, 4, got.Done)
+ require.Equal(t, 282, got.Total)
+}
+
+func TestTracker_DoneFlushesTheFinalCountEvenInsideTheThrottle(t *testing.T) {
+ tr, sink, _, _ := newTracker(t)
+ tr.Start()
+ tr.Begin("decide", 10)
+ tr.Advance("decide", 9)
+
+ tr.Advance("decide", 10)
+ tr.Done("decide")
+
+ got := sink.last(t)[1]
+ require.Equal(t, domain.StepDone, got.State)
+ require.Equal(t, 10, got.Done, "a finished step must show its full count, not the last throttled one")
+}
+
+func TestTracker_FinishSkipsWhatNeverRanAndClosesWhatDid(t *testing.T) {
+ tr, sink, _, _ := newTracker(t)
+ tr.Start()
+ tr.Begin("audio", 0)
+ tr.Done("audio")
+ tr.Begin("decide", 5)
+
+ tr.Finish()
+
+ steps := sink.last(t)
+ require.Equal(t, domain.StepDone, stateOf(steps, "audio"))
+ require.Equal(t, domain.StepDone, stateOf(steps, "decide"), "a step still running when the job completes is closed")
+ require.Equal(t, domain.StepSkipped, stateOf(steps, "save"), "a step that never ran is skipped, not left pending")
+}
+
+func TestTracker_FailMarksTheRunningStepFailedAndLeavesTheRestPending(t *testing.T) {
+ tr, sink, _, _ := newTracker(t)
+ tr.Start()
+ tr.Begin("audio", 0)
+ tr.Done("audio")
+ tr.Begin("decide", 5)
+
+ tr.Fail()
+
+ steps := sink.last(t)
+ require.Equal(t, domain.StepDone, stateOf(steps, "audio"))
+ require.Equal(t, domain.StepFailed, stateOf(steps, "decide"))
+ require.Equal(t, domain.StepPending, stateOf(steps, "save"), "what never started stays pending: it shows where the job stopped")
+}
+
+func TestTracker_AnUnknownKeyIsIgnored(t *testing.T) {
+ tr, sink, _, _ := newTracker(t)
+ tr.Start()
+ before := sink.saveCount()
+
+ require.NotPanics(t, func() {
+ tr.Begin("no-such-step", 0)
+ tr.Advance("no-such-step", 1)
+ tr.Done("no-such-step")
+ })
+ require.Equal(t, before, sink.saveCount())
+}
+
+func TestTracker_ASinkFailureIsLoggedAndNeverStopsTheJob(t *testing.T) {
+ tr, sink, _, buf := newTracker(t)
+ sink.err = errors.New("db down")
+
+ require.NotPanics(t, func() {
+ tr.Start()
+ tr.Begin("audio", 0)
+ tr.Done("audio")
+ })
+ require.Contains(t, buf.String(), "level=WARN", "a lost progress write is worth a WARN")
+ require.Contains(t, buf.String(), "db down")
+}
+
+func TestTracker_EveryTransitionIsLoggedByTheSameCall(t *testing.T) {
+ tr, _, clk, buf := newTracker(t)
+ tr.Start()
+ tr.Begin("audio", 0)
+ clk.tick(3 * time.Second)
+ tr.Done("audio")
+
+ out := buf.String()
+ require.Contains(t, out, "step=audio")
+ require.Contains(t, out, "started")
+ require.Contains(t, out, "finished")
+ require.Contains(t, out, "elapsed=3s")
+}
+
+func TestTracker_AdvanceIsLoggedAtMostOncePerMinute(t *testing.T) {
+ tr, _, clk, buf := newTracker(t)
+ tr.Start()
+ tr.Begin("decide", 100)
+
+ for i := 1; i <= 30; i++ {
+ clk.tick(1 * time.Second)
+ tr.Advance("decide", i)
+ }
+ require.LessOrEqual(t, strings.Count(buf.String(), "step progress"), 1,
+ "30 seconds of progress is at most one log line, not thirty")
+ clk.tick(61 * time.Second)
+ tr.Advance("decide", 31)
+ require.Equal(t, 2, strings.Count(buf.String(), "step progress"))
+ require.Contains(t, buf.String(), "done=31")
+ require.Contains(t, buf.String(), "total=100")
+}
+
+func TestTracker_HeartbeatBeatsOnItsOwnUntilStopped(t *testing.T) {
+ tr, sink, _, _ := newTracker(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+
+ go func() {
+ defer close(done)
+ tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
+ }()
+
+ require.Eventually(t, func() bool { return sink.beatCount() >= 3 }, 2*time.Second, 5*time.Millisecond,
+ "a heartbeat that does not depend on any engine call is the point")
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("Heartbeat did not stop when its context ended")
+ }
+}
+
+func TestTracker_HeartbeatSaysWhichStepIsStillRunningAndForHowLong(t *testing.T) {
+ tr, _, clk, buf := newTracker(t)
+ tr.Start()
+ tr.Begin("decide", 100)
+ clk.tick(7 * time.Minute)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go tr.Heartbeat(ctx, time.Hour, 5*time.Millisecond)
+
+ require.Eventually(t, func() bool { return strings.Contains(buf.String(), "still running") }, 2*time.Second, 5*time.Millisecond)
+ require.Contains(t, buf.String(), "step=decide")
+ require.Contains(t, buf.String(), "elapsed=7m0s")
+}
+
+func TestFrom_WithoutAReporterReturnsOneThatDoesNothing(t *testing.T) {
+ r := progress.From(context.Background())
+
+ require.NotNil(t, r)
+ require.NotPanics(t, func() {
+ r.Begin("x", 3)
+ r.Advance("x", 1)
+ r.Done("x")
+ })
+}
+
+func TestWithAndFrom_ACarriedReporterIsTheOneReturned(t *testing.T) {
+ tr, sink, _, _ := newTracker(t)
+ tr.Start()
+ ctx := progress.With(context.Background(), tr)
+
+ progress.From(ctx).Begin("audio", 0)
+
+ require.Equal(t, domain.StepRunning, stateOf(sink.last(t), "audio"))
+}
--
2.54.0
From c9ebe526cc41dbe67478332e850317f21fe4d986 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:50:39 +0200
Subject: [PATCH 127/299] feat(domain): progress steps and the two timestamps
that tell alive from progressing (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/domain/domain.go | 37 +++++++++++++++++++++++++++++++++++--
1 file changed, 35 insertions(+), 2 deletions(-)
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index 2952d6d..20fbd75 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -175,6 +175,32 @@ type Reconciliation struct {
Contested []ContestedSpot `json:"contested,omitempty"`
}
+// StepState is where one phase of an upload job stands (ADR-035).
+type StepState string
+
+const (
+ StepPending StepState = "pending"
+ StepRunning StepState = "running"
+ StepDone StepState = "done"
+ StepFailed StepState = "failed"
+ // StepSkipped is a step that never ran because the job finished without it, for
+ // example the second engine's step when the first engine's text was used alone.
+ StepSkipped StepState = "skipped"
+)
+
+// ProgressStep is one named phase of an upload job. Total is zero for a phase with no
+// count; only a phase whose work is really countable carries one (ADR-035: no
+// percentage that does not come from real counts).
+type ProgressStep struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ State StepState `json:"state"`
+ StartedAt time.Time `json:"started_at,omitzero"`
+ FinishedAt time.Time `json:"finished_at,omitzero"`
+ Done int `json:"done,omitempty"`
+ Total int `json:"total,omitempty"`
+}
+
// UploadStatus is the state of an uploaded-audio transcription job.
type UploadStatus string
@@ -218,6 +244,13 @@ type UploadedTranscript struct {
// Reconciliation is how a merged transcript came about, or nil for a single
// engine (ADR-034). Set by Get, not by List.
Reconciliation *Reconciliation
- CreatedAt time.Time
- FinishedAt time.Time // zero until the job reaches done or failed
+ // Progress is the job's phases, in order. Set by Get, not by List (ADR-035).
+ Progress []ProgressStep
+ // ProgressAt is when a phase last really changed or advanced; AliveAt is the
+ // heartbeat. They differ on purpose: alive says the service is still reporting,
+ // progress says work is moving (ADR-035). Both are zero until the job starts.
+ ProgressAt time.Time
+ AliveAt time.Time
+ CreatedAt time.Time
+ FinishedAt time.Time // zero until the job reaches done or failed
}
--
2.54.0
From 4bfe578a052742fbf1ad3a38d47ac9c357bcb0f7 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:50:39 +0200
Subject: [PATCH 128/299] feat(progress): Tracker -- one writer for the page
and the log, throttled counts, a heartbeat of its own (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker.go | 246 +++++++++++++++++++++++++++++++++++
1 file changed, 246 insertions(+)
create mode 100644 internal/progress/tracker.go
diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go
new file mode 100644
index 0000000..0c6e0d0
--- /dev/null
+++ b/internal/progress/tracker.go
@@ -0,0 +1,246 @@
+// Package progress tracks the phases of one upload job (ADR-035). A Tracker is the
+// only writer of a job's progress: every transition is saved to the job's row and
+// logged by the same call, so what the page shows and what the log says cannot
+// disagree. The engines and the reconciler report through a Reporter carried in the
+// context, so the Transcriber interface stays what it was.
+package progress
+
+import (
+ "context"
+ "log/slog"
+ "sync"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// Planned is one phase of a job, declared up front so the page can show what is coming.
+type Planned struct {
+ Key, Label string
+}
+
+// Reporter is what a long-running component reports to. Begin starts a phase (total
+// is the number of countable units, 0 when there is no honest count), Advance says how
+// many are done, Done closes the phase.
+type Reporter interface {
+ Begin(key string, total int)
+ Advance(key string, done int)
+ Done(key string)
+}
+
+// Sink is where a job's progress is persisted. Both calls are best effort: progress
+// is for people, so a failed write is logged and never stops the job.
+type Sink interface {
+ SaveProgress(ctx context.Context, steps []domain.ProgressStep, at time.Time) error
+ Beat(ctx context.Context) error
+}
+
+const (
+ defaultMinSave = 2 * time.Second
+ logAdvanceEach = time.Minute
+)
+
+// Option tunes a Tracker.
+type Option func(*Tracker)
+
+// WithClock fixes the clock, for tests.
+func WithClock(now func() time.Time) Option { return func(t *Tracker) { t.now = now } }
+
+// WithMinSave sets the least time between two saves caused by Advance alone. Begin,
+// Done, Fail and Finish always save.
+func WithMinSave(d time.Duration) Option { return func(t *Tracker) { t.minSave = d } }
+
+// Tracker implements Reporter for one job.
+type Tracker struct {
+ ctx context.Context
+ sink Sink
+ log *slog.Logger
+ now func() time.Time
+ minSave time.Duration
+
+ mu sync.Mutex
+ steps []domain.ProgressStep
+ lastAdvanceSave time.Time
+ lastAdvanceLog time.Time
+}
+
+// NewTracker plans a job's phases. ctx is used for the saves, so it should outlive the
+// job's own timeout: a job that timed out still gets its failure recorded.
+func NewTracker(ctx context.Context, sink Sink, log *slog.Logger, plan []Planned, opts ...Option) *Tracker {
+ t := &Tracker{ctx: ctx, sink: sink, log: log, now: time.Now, minSave: defaultMinSave}
+ for _, o := range opts {
+ o(t)
+ }
+ for _, p := range plan {
+ t.steps = append(t.steps, domain.ProgressStep{Key: p.Key, Label: p.Label, State: domain.StepPending})
+ }
+ return t
+}
+
+// Start saves the plan, every phase pending.
+func (t *Tracker) Start() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.log.Info("job started", "steps", len(t.steps))
+ t.save()
+}
+
+// Begin implements Reporter.
+func (t *Tracker) Begin(key string, total int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ s := t.find(key)
+ if s == nil {
+ return
+ }
+ s.State, s.StartedAt, s.FinishedAt, s.Done, s.Total = domain.StepRunning, t.now(), time.Time{}, 0, total
+ t.log.Info("step started", "step", key, "total", total)
+ t.save()
+}
+
+// Advance implements Reporter. The count is always kept; it is saved at most once per
+// minimum interval and logged at most once a minute, so a counted phase does not turn
+// into a write and a log line per unit.
+func (t *Tracker) Advance(key string, done int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ s := t.find(key)
+ if s == nil {
+ return
+ }
+ s.Done = done
+ now := t.now()
+ if t.lastAdvanceLog.IsZero() || now.Sub(t.lastAdvanceLog) >= logAdvanceEach {
+ t.lastAdvanceLog = now
+ t.log.Info("step progress", "step", key, "done", done, "total", s.Total)
+ }
+ if t.lastAdvanceSave.IsZero() || now.Sub(t.lastAdvanceSave) >= t.minSave {
+ t.lastAdvanceSave = now
+ t.save()
+ }
+}
+
+// Done implements Reporter.
+func (t *Tracker) Done(key string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ s := t.find(key)
+ if s == nil {
+ return
+ }
+ t.close(s, domain.StepDone)
+ t.log.Info("step finished", "step", key, "elapsed", s.FinishedAt.Sub(s.StartedAt))
+ t.save()
+}
+
+// Fail marks the phase that was running as failed. What never started stays pending,
+// which is what shows the user where the job stopped.
+func (t *Tracker) Fail() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ for i := range t.steps {
+ if t.steps[i].State == domain.StepRunning {
+ t.close(&t.steps[i], domain.StepFailed)
+ t.log.Warn("step failed", "step", t.steps[i].Key, "elapsed", t.steps[i].FinishedAt.Sub(t.steps[i].StartedAt))
+ }
+ }
+ t.save()
+}
+
+// Finish closes the job: a phase still running is done, a phase that never ran is
+// skipped.
+func (t *Tracker) Finish() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ for i := range t.steps {
+ switch t.steps[i].State {
+ case domain.StepRunning:
+ t.close(&t.steps[i], domain.StepDone)
+ case domain.StepPending:
+ t.steps[i].State = domain.StepSkipped
+ }
+ }
+ t.log.Info("job finished")
+ t.save()
+}
+
+// Heartbeat stamps the job's row every beatEvery and logs which phase is still running
+// every stillEvery, until ctx ends. It runs on its own, independent of any engine call:
+// a heartbeat that stopped means the process is gone, not that a request is slow.
+func (t *Tracker) Heartbeat(ctx context.Context, beatEvery, stillEvery time.Duration) {
+ beat, still := time.NewTicker(beatEvery), time.NewTicker(stillEvery)
+ defer beat.Stop()
+ defer still.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-beat.C:
+ if err := t.sink.Beat(ctx); err != nil && ctx.Err() == nil {
+ t.log.Warn("progress: heartbeat not saved", "err", err)
+ }
+ case <-still.C:
+ t.logStill()
+ }
+ }
+}
+
+func (t *Tracker) logStill() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ for _, s := range t.steps {
+ if s.State == domain.StepRunning {
+ t.log.Info("still running", "step", s.Key, "elapsed", t.now().Sub(s.StartedAt), "done", s.Done, "total", s.Total)
+ return
+ }
+ }
+}
+
+// close ends a phase. Callers hold t.mu.
+func (t *Tracker) close(s *domain.ProgressStep, state domain.StepState) {
+ s.State, s.FinishedAt = state, t.now()
+ if s.StartedAt.IsZero() {
+ s.StartedAt = s.FinishedAt
+ }
+}
+
+// find returns the phase with this key, or nil. Callers hold t.mu.
+func (t *Tracker) find(key string) *domain.ProgressStep {
+ for i := range t.steps {
+ if t.steps[i].Key == key {
+ return &t.steps[i]
+ }
+ }
+ return nil
+}
+
+// save persists a copy of the phases. Callers hold t.mu. A failed write is logged and
+// dropped: progress is cosmetic and must never fail the job it describes.
+func (t *Tracker) save() {
+ snapshot := append([]domain.ProgressStep(nil), t.steps...)
+ if err := t.sink.SaveProgress(t.ctx, snapshot, t.now()); err != nil && t.ctx.Err() == nil {
+ t.log.Warn("progress: not saved", "err", err)
+ }
+}
+
+type ctxKey struct{}
+
+// With returns a context that carries r.
+func With(ctx context.Context, r Reporter) context.Context {
+ return context.WithValue(ctx, ctxKey{}, r)
+}
+
+// From returns the Reporter carried by ctx, or one that does nothing, so a component
+// can always report without checking whether anyone is listening.
+func From(ctx context.Context) Reporter {
+ if r, ok := ctx.Value(ctxKey{}).(Reporter); ok {
+ return r
+ }
+ return noop{}
+}
+
+type noop struct{}
+
+func (noop) Begin(string, int) {}
+func (noop) Advance(string, int) {}
+func (noop) Done(string) {}
--
2.54.0
From 8ec43ad3934790836a0e38b9a75f5730271aece6 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:50:39 +0200
Subject: [PATCH 129/299] test(progress): read the log buffer safely while the
heartbeat writes to it
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker_test.go | 25 +++++++++++++++++++++----
1 file changed, 21 insertions(+), 4 deletions(-)
diff --git a/internal/progress/tracker_test.go b/internal/progress/tracker_test.go
index d88a88f..f7e4928 100644
--- a/internal/progress/tracker_test.go
+++ b/internal/progress/tracker_test.go
@@ -1,7 +1,6 @@
package progress_test
import (
- "bytes"
"context"
"errors"
"log/slog"
@@ -60,20 +59,38 @@ func (f *fakeSink) beatCount() int {
return f.beats
}
+// syncBuf is a log sink safe to read while the heartbeat goroutine is still writing.
+type syncBuf struct {
+ mu sync.Mutex
+ b strings.Builder
+}
+
+func (s *syncBuf) Write(p []byte) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.b.Write(p)
+}
+
+func (s *syncBuf) String() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.b.String()
+}
+
// clock is a hand-wound clock so durations and throttling are exact.
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func (c *clock) tick(d time.Duration) { c.t = c.t.Add(d) }
-func newClock() *clock { return &clock{t: time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)} }
+func newClock() *clock { return &clock{t: time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)} }
func plan() []progress.Planned {
return []progress.Planned{{Key: "audio", Label: "Preparing audio"}, {Key: "decide", Label: "Deciding"}, {Key: "save", Label: "Saving"}}
}
-func newTracker(t *testing.T) (*progress.Tracker, *fakeSink, *clock, *bytes.Buffer) {
+func newTracker(t *testing.T) (*progress.Tracker, *fakeSink, *clock, *syncBuf) {
t.Helper()
- sink, clk, buf := &fakeSink{}, newClock(), &bytes.Buffer{}
+ sink, clk, buf := &fakeSink{}, newClock(), &syncBuf{}
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
return progress.NewTracker(context.Background(), sink, log, plan(), progress.WithClock(clk.now), progress.WithMinSave(2*time.Second)), sink, clk, buf
}
--
2.54.0
From 256e5b1a66aa99925ed68b2bedcb91fab1da1451 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:51:04 +0200
Subject: [PATCH 130/299] test(store): progress and heartbeat on an upload row
-- round trip, alive is not progress, another user's write changes nothing
(red)
Co-Authored-By: Claude Sonnet 5.5
---
.../uploaded_transcripts_progress_test.go | 137 ++++++++++++++++++
1 file changed, 137 insertions(+)
create mode 100644 internal/adapters/store/uploaded_transcripts_progress_test.go
diff --git a/internal/adapters/store/uploaded_transcripts_progress_test.go b/internal/adapters/store/uploaded_transcripts_progress_test.go
new file mode 100644
index 0000000..3456df2
--- /dev/null
+++ b/internal/adapters/store/uploaded_transcripts_progress_test.go
@@ -0,0 +1,137 @@
+package store_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/store"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// Progress and heartbeat of an upload job (ADR-035, migration 020). Like the other
+// upload store tests these run as the superuser, so the cross-user cases prove the
+// statement's own user_id predicate; the database-enforced isolation is covered by the
+// RLS tests, and migration 020 adds columns to the already-FORCE'd table, not a table.
+
+func startedUpload(t *testing.T, s *store.Store) string {
+ t.Helper()
+ seedUsers(t, userA, userB)
+ id, err := s.CreateUpload(context.Background(), userA, "m", false)
+ require.NoError(t, err)
+ return id
+}
+
+func TestUploadProgress_AFreshUploadHasNoProgressAndNoHeartbeat(t *testing.T) {
+ s := newStore(t)
+ id := startedUpload(t, s)
+
+ got, err := s.GetUpload(context.Background(), userA, id)
+
+ require.NoError(t, err)
+ assert.Empty(t, got.Progress)
+ assert.True(t, got.ProgressAt.IsZero())
+ assert.True(t, got.AliveAt.IsZero())
+}
+
+func TestUploadProgress_StepsAndTheirTimesRoundTripInOrder(t *testing.T) {
+ s := newStore(t)
+ id := startedUpload(t, s)
+ ctx := context.Background()
+ at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
+ steps := []domain.ProgressStep{
+ {Key: "audio", Label: "Preparing audio", State: domain.StepDone, StartedAt: at, FinishedAt: at.Add(40 * time.Second)},
+ {Key: "decide", Label: "Deciding", State: domain.StepRunning, StartedAt: at.Add(time.Minute), Done: 12, Total: 282},
+ {Key: "save", Label: "Saving", State: domain.StepPending},
+ }
+
+ require.NoError(t, s.SaveProgress(ctx, userA, id, steps, at.Add(2*time.Minute)))
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ require.Len(t, got.Progress, 3)
+ assert.Equal(t, []string{"audio", "decide", "save"}, []string{got.Progress[0].Key, got.Progress[1].Key, got.Progress[2].Key})
+ assert.Equal(t, domain.StepRunning, got.Progress[1].State)
+ assert.Equal(t, 12, got.Progress[1].Done)
+ assert.Equal(t, 282, got.Progress[1].Total)
+ assert.True(t, got.Progress[0].FinishedAt.Equal(at.Add(40*time.Second)), "a step's finish time must survive the round trip")
+ assert.True(t, got.Progress[2].StartedAt.IsZero(), "a pending step has no start time, not a zero date")
+ assert.True(t, got.ProgressAt.Equal(at.Add(2*time.Minute)))
+}
+
+func TestUploadProgress_AHeartbeatMovesAliveAtAndNotProgressAt(t *testing.T) {
+ s := newStore(t)
+ id := startedUpload(t, s)
+ ctx := context.Background()
+ at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
+ require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "audio", Label: "a", State: domain.StepRunning}}, at))
+ before := time.Now().Add(-time.Second)
+
+ require.NoError(t, s.Heartbeat(ctx, userA, id))
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.True(t, got.AliveAt.After(before), "the heartbeat must stamp the row with now")
+ assert.True(t, got.ProgressAt.Equal(at), "alive is not progress: a heartbeat must not move progress_at")
+}
+
+func TestUploadProgress_ASecondSaveReplacesTheFirst(t *testing.T) {
+ s := newStore(t)
+ id := startedUpload(t, s)
+ ctx := context.Background()
+ at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
+ require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepRunning}}, at))
+
+ require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepDone}, {Key: "b", Label: "B", State: domain.StepRunning}}, at.Add(time.Minute)))
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ require.Len(t, got.Progress, 2)
+ assert.Equal(t, domain.StepDone, got.Progress[0].State)
+}
+
+func TestUploadProgress_ByAnotherUserChangesNothing(t *testing.T) {
+ s := newStore(t)
+ id := startedUpload(t, s)
+ ctx := context.Background()
+
+ errSave := s.SaveProgress(ctx, userB, id, []domain.ProgressStep{{Key: "x", Label: "X", State: domain.StepDone}}, time.Now())
+ errBeat := s.Heartbeat(ctx, userB, id)
+
+ assert.ErrorIs(t, errSave, store.ErrUploadNotFound)
+ assert.ErrorIs(t, errBeat, store.ErrUploadNotFound)
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ assert.Empty(t, got.Progress, "B's write must not have reached A's upload")
+ assert.True(t, got.AliveAt.IsZero())
+}
+
+func TestUploadProgress_ListDoesNotDragItAlong(t *testing.T) {
+ s := newStore(t)
+ id := startedUpload(t, s)
+ ctx := context.Background()
+ require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepRunning}}, time.Now()))
+
+ rows, err := s.ListUploads(ctx, userA)
+
+ require.NoError(t, err)
+ require.Len(t, rows, 1)
+ assert.Empty(t, rows[0].Progress, "the list view needs a status, not every row's phases")
+}
+
+func TestUploadProgress_SurvivesCompletion(t *testing.T) {
+ s := newStore(t)
+ id := startedUpload(t, s)
+ ctx := context.Background()
+ require.NoError(t, s.StartTranscribing(ctx, userA, id))
+ require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepDone}}, time.Now()))
+
+ require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e"}))
+
+ got, err := s.GetUpload(ctx, userA, id)
+ require.NoError(t, err)
+ require.Len(t, got.Progress, 1, "a finished upload keeps its record of what was done, and how long each step took")
+}
--
2.54.0
From 41ebba39637af4e7f5a6bd701ef6e579eb79a9ca Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:51:11 +0200
Subject: [PATCH 131/299] feat(store): migration 020 --
uploaded_transcripts.progress, progress_at, alive_at (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
.../020_uploaded_transcripts_progress.down.sql | 4 ++++
.../020_uploaded_transcripts_progress.up.sql | 15 +++++++++++++++
2 files changed, 19 insertions(+)
create mode 100644 internal/adapters/store/migrations/020_uploaded_transcripts_progress.down.sql
create mode 100644 internal/adapters/store/migrations/020_uploaded_transcripts_progress.up.sql
diff --git a/internal/adapters/store/migrations/020_uploaded_transcripts_progress.down.sql b/internal/adapters/store/migrations/020_uploaded_transcripts_progress.down.sql
new file mode 100644
index 0000000..f80bcd2
--- /dev/null
+++ b/internal/adapters/store/migrations/020_uploaded_transcripts_progress.down.sql
@@ -0,0 +1,4 @@
+ALTER TABLE uploaded_transcripts
+ DROP COLUMN alive_at,
+ DROP COLUMN progress_at,
+ DROP COLUMN progress;
diff --git a/internal/adapters/store/migrations/020_uploaded_transcripts_progress.up.sql b/internal/adapters/store/migrations/020_uploaded_transcripts_progress.up.sql
new file mode 100644
index 0000000..40d373e
--- /dev/null
+++ b/internal/adapters/store/migrations/020_uploaded_transcripts_progress.up.sql
@@ -0,0 +1,15 @@
+-- Migration 020: where an upload job is, and whether the service is still reporting (ADR-035).
+--
+-- progress the job's ordered phases as JSON (key, label, state, times, done/total).
+-- progress_at when a phase last REALLY changed or advanced.
+-- alive_at a heartbeat stamped every few seconds by a goroutine independent of any
+-- engine call. The two differ on purpose: alive says the service is still
+-- reporting, progress says work is moving, and a page that cannot tell a
+-- crashed service from a slow model is the failure this exists to end.
+--
+-- All three are NULL until the job starts. They sit on the already-FORCE'd, RLS-scoped
+-- uploaded_transcripts, so the exact-set isolation test is unaffected.
+ALTER TABLE uploaded_transcripts
+ ADD COLUMN progress JSONB,
+ ADD COLUMN progress_at TIMESTAMPTZ,
+ ADD COLUMN alive_at TIMESTAMPTZ;
--
2.54.0
From f8526d45492265ce63431ccebef26620a6bbccdf Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:51:39 +0200
Subject: [PATCH 132/299] feat(store): SaveProgress and Heartbeat; Get returns
the phases and both timestamps (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
.../adapters/store/uploaded_transcripts.go | 41 +++++++++++++++++--
1 file changed, 37 insertions(+), 4 deletions(-)
diff --git a/internal/adapters/store/uploaded_transcripts.go b/internal/adapters/store/uploaded_transcripts.go
index c5820fe..71d289b 100644
--- a/internal/adapters/store/uploaded_transcripts.go
+++ b/internal/adapters/store/uploaded_transcripts.go
@@ -68,6 +68,26 @@ func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.
t.Text, segs, t.Endpoint, t.AudioSeconds, rec)
}
+// SaveProgress records the job's phases and when something last really changed
+// (ADR-035). It replaces the previous snapshot: the Tracker holds the whole plan.
+func (s *Store) SaveProgress(ctx context.Context, userID, id string, steps []domain.ProgressStep, at time.Time) error {
+ b, err := json.Marshal(steps)
+ if err != nil {
+ return fmt.Errorf("store: save progress: marshal: %w", err)
+ }
+ return s.updateUpload(ctx, userID, id, "save progress",
+ `UPDATE uploaded_transcripts SET progress = $3, progress_at = $4
+ WHERE id = $1 AND user_id = $2`, b, at)
+}
+
+// Heartbeat stamps the upload as alive now. It does not touch progress_at: alive says
+// the service is still reporting, not that work is moving (ADR-035).
+func (s *Store) Heartbeat(ctx context.Context, userID, id string) error {
+ return s.updateUpload(ctx, userID, id, "heartbeat",
+ `UPDATE uploaded_transcripts SET alive_at = NOW()
+ WHERE id = $1 AND user_id = $2`)
+}
+
// FailUpload moves an upload to failed with a typed reason.
func (s *Store) FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error {
return s.updateUpload(ctx, userID, id, "fail upload",
@@ -165,19 +185,21 @@ func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
}
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
- audio_seconds, error_kind, had_video, reconciliation, created_at, finished_at FROM uploaded_transcripts`
+ audio_seconds, error_kind, had_video, reconciliation, progress, progress_at, alive_at,
+ created_at, finished_at FROM uploaded_transcripts`
// scanUpload scans one uploadSelect row. withText controls whether the transcript
// body and segments are kept (GetUpload) or dropped (ListUploads).
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
var (
status, errKind, endpoint, content *string
- segments, reconciliation []byte
+ segments, reconciliation, progress []byte
seconds *float64
- finished *time.Time
+ finished, progressAt, aliveAt *time.Time
)
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
- &seconds, &errKind, &u.HadVideo, &reconciliation, &u.CreatedAt, &finished); err != nil {
+ &seconds, &errKind, &u.HadVideo, &reconciliation, &progress, &progressAt, &aliveAt,
+ &u.CreatedAt, &finished); err != nil {
return err
}
u.Status = domain.UploadStatus(deref(status))
@@ -192,6 +214,17 @@ func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error
if !withText {
return nil
}
+ if len(progress) > 0 {
+ if err := json.Unmarshal(progress, &u.Progress); err != nil {
+ return fmt.Errorf("unmarshal progress: %w", err)
+ }
+ }
+ if progressAt != nil {
+ u.ProgressAt = *progressAt
+ }
+ if aliveAt != nil {
+ u.AliveAt = *aliveAt
+ }
if len(reconciliation) > 0 {
var r domain.Reconciliation
if err := json.Unmarshal(reconciliation, &r); err != nil {
--
2.54.0
From 3f66c3549790b0d39d1d54420b38dcb2e49fa059 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:52:20 +0200
Subject: [PATCH 133/299] test(uploads): the worker's store double records
progress saves and heartbeats
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker_test.go | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/internal/uploads/worker_test.go b/internal/uploads/worker_test.go
index da5235f..2454795 100644
--- a/internal/uploads/worker_test.go
+++ b/internal/uploads/worker_test.go
@@ -23,6 +23,8 @@ type wStore struct {
completed map[string]domain.Transcription
failed map[string]domain.UploadErrorKind
startErr error
+ saves [][]domain.ProgressStep
+ beats int
}
func newWStore() *wStore {
@@ -50,6 +52,35 @@ func (s *wStore) FailUpload(_ context.Context, _, id string, k domain.UploadErro
return nil
}
+func (s *wStore) SaveProgress(_ context.Context, _, _ string, steps []domain.ProgressStep, _ time.Time) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.saves = append(s.saves, append([]domain.ProgressStep(nil), steps...))
+ return nil
+}
+
+func (s *wStore) Heartbeat(context.Context, string, string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.beats++
+ return nil
+}
+
+func (s *wStore) lastSteps() []domain.ProgressStep {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if len(s.saves) == 0 {
+ return nil
+ }
+ return s.saves[len(s.saves)-1]
+}
+
+func (s *wStore) beatCount() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.beats
+}
+
func (s *wStore) snapshot() (map[string]domain.Transcription, map[string]domain.UploadErrorKind) {
s.mu.Lock()
defer s.mu.Unlock()
--
2.54.0
From eb4d4b67e6805a8013551d6e36cbecc124957d39 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:52:20 +0200
Subject: [PATCH 134/299] test(uploads): the worker reports real phases, beats
on its own, and logs them in order without the transcript (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker_progress_test.go | 164 +++++++++++++++++++++++
1 file changed, 164 insertions(+)
create mode 100644 internal/uploads/worker_progress_test.go
diff --git a/internal/uploads/worker_progress_test.go b/internal/uploads/worker_progress_test.go
new file mode 100644
index 0000000..24611ea
--- /dev/null
+++ b/internal/uploads/worker_progress_test.go
@@ -0,0 +1,164 @@
+package uploads_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "log/slog"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/progress"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+// Progress, heartbeat and logging of the worker (ADR-035, #42, #43).
+
+// plannedTr is a transcriber that declares its own phases and reports them, like the
+// two-engine Ensemble does.
+type plannedTr struct{ *fakeTranscriber }
+
+func (plannedTr) Plan() []progress.Planned {
+ return []progress.Planned{{Key: "engine_a", Label: "Transcribing with A"}, {Key: "engine_b", Label: "Transcribing with B"}}
+}
+
+func (p plannedTr) Transcribe(ctx context.Context, wav string) (domain.Transcription, error) {
+ r := progress.From(ctx)
+ r.Begin("engine_a", 0)
+ r.Done("engine_a")
+ r.Begin("engine_b", 0)
+ r.Done("engine_b")
+ return p.fakeTranscriber.Transcribe(ctx, wav)
+}
+
+func keysAndStates(steps []domain.ProgressStep) []string {
+ var out []string
+ for _, s := range steps {
+ out = append(out, s.Key+":"+string(s.State))
+ }
+ return out
+}
+
+// syncLog is a log sink safe to read while the worker's goroutines write.
+type syncLog struct {
+ mu sync.Mutex
+ b bytes.Buffer
+}
+
+func (l *syncLog) Write(p []byte) (int, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.Write(p)
+}
+
+func (l *syncLog) String() string {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.String()
+}
+
+func newProgressWorker(t *testing.T, r *wrig, tr uploads.Transcriber, opts ...uploads.WorkerOption) *uploads.Worker {
+ t.Helper()
+ return uploads.NewWorker(r.st, r.conv, tr, r.tmp, time.Minute, opts...)
+}
+
+func TestWorkerProgress_ASingleEngineJobShowsEveryPhaseDone(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.w.Process(context.Background(), r.spool(t, "a"))
+
+ assert.Equal(t, []string{"received:done", "audio:done", "transcribe:done", "save:done"}, keysAndStates(r.st.lastSteps()))
+}
+
+func TestWorkerProgress_ATranscriberThatPlansItsOwnPhasesGetsThemBetweenAudioAndSaving(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ w := newProgressWorker(t, r, plannedTr{r.tr})
+
+ w.Process(context.Background(), r.spool(t, "a"))
+
+ assert.Equal(t, []string{"received:done", "audio:done", "engine_a:done", "engine_b:done", "save:done"}, keysAndStates(r.st.lastSteps()))
+}
+
+func TestWorkerProgress_AFailureMarksWhereItStoppedAndLeavesTheRestPending(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(*wrig)
+ want []string
+ }{
+ {
+ "conversion fails", func(r *wrig) { r.conv.err = errors.New("moov atom not found") },
+ []string{"received:done", "audio:failed", "transcribe:pending", "save:pending"},
+ },
+ {
+ "every engine fails", func(r *wrig) { r.tr.err = errors.New("iguana down") },
+ []string{"received:done", "audio:done", "transcribe:failed", "save:pending"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ tt.setup(r)
+
+ r.w.Process(context.Background(), r.spool(t, "a"))
+
+ assert.Equal(t, tt.want, keysAndStates(r.st.lastSteps()))
+ })
+ }
+}
+
+func TestWorkerProgress_AShutdownIsNotRecordedAsAFailedStep(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.tr.block = make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() { defer close(done); r.w.Process(ctx, r.spool(t, "a")) }()
+ require.Eventually(t, func() bool {
+ return strings.Contains(strings.Join(keysAndStates(r.st.lastSteps()), ","), "transcribe:running")
+ }, 2*time.Second, time.Millisecond)
+
+ cancel()
+ <-done
+
+ assert.Contains(t, keysAndStates(r.st.lastSteps()), "transcribe:running",
+ "a shutdown leaves the row for the start-up sweep; it must not pose as a failure of the step")
+}
+
+func TestWorkerProgress_TheHeartbeatBeatsWhileAnEngineCallIsStuckAndStopsWhenTheJobEnds(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.tr.block = make(chan struct{})
+ w := newProgressWorker(t, r, r.tr, uploads.WithHeartbeat(5*time.Millisecond, time.Hour))
+ done := make(chan struct{})
+ go func() { defer close(done); w.Process(context.Background(), r.spool(t, "a")) }()
+
+ require.Eventually(t, func() bool { return r.st.beatCount() >= 3 }, 2*time.Second, 5*time.Millisecond,
+ "the page tells a slow engine from a dead service by this heartbeat, so it must not wait on the engine")
+ close(r.tr.block)
+ <-done
+ after := r.st.beatCount()
+ time.Sleep(40 * time.Millisecond)
+
+ assert.Equal(t, after, r.st.beatCount(), "no heartbeat may outlive its job")
+}
+
+func TestWorkerProgress_TheLogNamesEveryPhaseInOrderAndNeverTheTranscriptOrThePath(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ r.tr.result = domain.Transcription{Text: "zebrakaka secret meeting words", Endpoint: "iguana/kb-whisper", AudioSeconds: 3}
+ buf := &syncLog{}
+ w := newProgressWorker(t, r, r.tr, uploads.WithLogger(slog.New(slog.NewTextHandler(buf, nil))))
+
+ w.Process(context.Background(), r.spool(t, "a"))
+
+ out := buf.String()
+ audio := strings.Index(out, "step=audio")
+ transcribe := strings.Index(out, "step=transcribe")
+ save := strings.Index(out, "step=save")
+ require.True(t, audio >= 0 && transcribe > audio && save > transcribe, "phases must be logged in the order they ran:\n%s", out)
+ assert.Contains(t, out, "upload=a", "every line says which upload it is about")
+ assert.NotContains(t, out, "zebrakaka", "no transcript text in the log")
+ assert.NotContains(t, out, r.tmp, "no spool path, which carries the upload's identity, in the log")
+}
--
2.54.0
From 27a88a0a1fb84a8059eda4703ac255f5260877d2 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:53:10 +0200
Subject: [PATCH 135/299] feat(uploads): the worker reports real phases, beats
on its own, and logs each one (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker.go | 117 ++++++++++++++++++++++++++++++++++---
1 file changed, 109 insertions(+), 8 deletions(-)
diff --git a/internal/uploads/worker.go b/internal/uploads/worker.go
index 7b32ab0..334efd3 100644
--- a/internal/uploads/worker.go
+++ b/internal/uploads/worker.go
@@ -10,6 +10,7 @@ import (
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/progress"
)
// ErrStopped is returned by Submit once the worker has stopped.
@@ -26,13 +27,52 @@ type Transcriber interface {
Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error)
}
+// Planner is an optional extra on a Transcriber: it declares its own phases (for
+// example one per engine) so the page can show them. A Transcriber that does not
+// implement it gets a single "transcribing" phase from the worker. Whatever a Planner
+// declares it reports itself, through progress.From(ctx) (ADR-035).
+type Planner interface {
+ Plan() []progress.Planned
+}
+
// WorkerStore is the slice of the private transcript store the worker drives.
type WorkerStore interface {
StartTranscribing(ctx context.Context, userID, id string) error
CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
+ SaveProgress(ctx context.Context, userID, id string, steps []domain.ProgressStep, at time.Time) error
+ Heartbeat(ctx context.Context, userID, id string) error
}
+// WorkerOption tunes a Worker.
+type WorkerOption func(*Worker)
+
+// WithHeartbeat sets how often a running job stamps its row alive and how often it logs
+// which phase is still running. The defaults are 10 s and 5 min (ADR-035).
+func WithHeartbeat(beat, still time.Duration) WorkerOption {
+ return func(w *Worker) { w.beatEvery, w.stillEvery = beat, still }
+}
+
+// WithLogger replaces slog.Default() as the worker's logger.
+func WithLogger(l *slog.Logger) WorkerOption { return func(w *Worker) { w.log = l } }
+
+const (
+ defaultBeatEvery = 10 * time.Second
+ defaultStillEvery = 5 * time.Minute
+)
+
+// jobSink binds the store to one job so a progress.Tracker can persist through it.
+type jobSink struct {
+ st WorkerStore
+ userID, id string
+}
+
+func (s jobSink) SaveProgress(ctx context.Context, steps []domain.ProgressStep, at time.Time) error {
+ return s.st.SaveProgress(ctx, s.userID, s.id, steps, at)
+}
+
+func (s jobSink) Beat(ctx context.Context) error { return s.st.Heartbeat(ctx, s.userID, s.id) }
+
// Worker transcribes accepted uploads in-process, ONE AT A TIME (ADR-033). It is
// not a second deployable: it shares the web process's lifetime, which is why it
// inherits ADR-018's single-replica assumption — the queue is process-local. The
@@ -45,6 +85,9 @@ type Worker struct {
tmpDir string
timeout time.Duration
+ log *slog.Logger
+ beatEvery, stillEvery time.Duration
+
mu sync.Mutex
queue []Job
stopped bool
@@ -53,8 +96,15 @@ type Worker struct {
// NewWorker wires a Worker. timeout bounds each job (conversion + transcription);
// a job that outlives it is failed rather than left spinning.
-func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout time.Duration) *Worker {
- return &Worker{store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1)}
+func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout time.Duration, opts ...WorkerOption) *Worker {
+ w := &Worker{
+ store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1),
+ beatEvery: defaultBeatEvery, stillEvery: defaultStillEvery,
+ }
+ for _, o := range opts {
+ o(w)
+ }
+ return w
}
// Submit queues a job. It never blocks. It satisfies Submitter.
@@ -125,9 +175,17 @@ func (w *Worker) stop() {
// uploaded file and the converted WAV are deleted on every path — the audio is
// never retained (ADR-033). A failure is recorded with a TYPED kind; a shutdown
// mid-job is not a failure and leaves the row for the start-up sweep.
+//
+// Every phase is reported through one progress.Tracker, which saves it to the row for
+// the page and logs it, and a heartbeat stamps the row on its own while the job runs
+// (ADR-035). Nothing logged or saved here carries transcript text or the file's name.
func (w *Worker) Process(ctx context.Context, job Job) {
defer func() { _ = os.Remove(job.Path) }()
- log := slog.Default().With("upload", job.UploadID)
+ base := w.log
+ if base == nil {
+ base = slog.Default()
+ }
+ log := base.With("upload", job.UploadID)
start := time.Now()
if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
@@ -136,32 +194,75 @@ func (w *Worker) Process(ctx context.Context, job Job) {
return
}
+ plan := []progress.Planned{{Key: "received", Label: "Received"}, {Key: "audio", Label: "Preparing the audio"}}
+ planner, planned := w.tr.(Planner)
+ if planned {
+ plan = append(plan, planner.Plan()...)
+ } else {
+ plan = append(plan, progress.Planned{Key: "transcribe", Label: "Transcribing"})
+ }
+ plan = append(plan, progress.Planned{Key: "save", Label: "Saving the transcript"})
+ tr := progress.NewTracker(ctx, jobSink{st: w.store, userID: job.UserID, id: job.UploadID}, log, plan)
+ tr.Start()
+ tr.Begin("received", 0)
+ tr.Done("received")
+
+ hbCtx, stopBeat := context.WithCancel(ctx)
+ hbDone := make(chan struct{})
+ go func() {
+ defer close(hbDone)
+ tr.Heartbeat(hbCtx, w.beatEvery, w.stillEvery)
+ }()
+ defer func() { stopBeat(); <-hbDone }() // no heartbeat outlives its job
+
+ // abort records where the job stopped. A shutdown (the parent context ended) is not
+ // a failure: the row is left for the start-up sweep, so the step stays as it was.
+ abort := func() {
+ if ctx.Err() == nil {
+ tr.Fail()
+ }
+ w.fail(ctx, job, log)
+ }
+
wav, err := w.tempWAV()
if err != nil {
log.Error("upload: temp wav", "err", err)
- w.fail(ctx, job, log)
+ abort()
return
}
defer func() { _ = os.Remove(wav) }()
jobCtx, cancel := context.WithTimeout(ctx, w.timeout)
defer cancel()
+ tr.Begin("audio", 0)
if err := w.conv.ToWAV(jobCtx, job.Path, wav); err != nil {
log.Error("upload: convert", "err", err)
- w.fail(ctx, job, log)
+ abort()
return
}
- t, err := w.tr.Transcribe(jobCtx, wav)
+ tr.Done("audio")
+
+ if !planned {
+ tr.Begin("transcribe", 0)
+ }
+ t, err := w.tr.Transcribe(progress.With(jobCtx, tr), wav)
if err != nil {
log.Error("upload: transcribe", "err", err, "elapsed", time.Since(start).Round(time.Second))
- w.fail(ctx, job, log)
+ abort()
return
}
+ if !planned {
+ tr.Done("transcribe")
+ }
+
+ tr.Begin("save", 0)
if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
log.Error("upload: store transcript", "err", err)
- w.fail(ctx, job, log)
+ abort()
return
}
+ tr.Done("save")
+ tr.Finish()
log.Info("upload transcribed", "endpoint", t.Endpoint, "audio_s", t.AudioSeconds,
"segments", len(t.Segments), "elapsed", time.Since(start).Round(time.Second))
}
--
2.54.0
From 538ed93ccd403d0a3b868f9f807981b91fda47f4 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:53:10 +0200
Subject: [PATCH 136/299] test(uploads): a slow heartbeat write, so one is in
flight when the job ends
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker_test.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/internal/uploads/worker_test.go b/internal/uploads/worker_test.go
index 2454795..e8abbf7 100644
--- a/internal/uploads/worker_test.go
+++ b/internal/uploads/worker_test.go
@@ -25,6 +25,9 @@ type wStore struct {
startErr error
saves [][]domain.ProgressStep
beats int
+ // beatDelay makes each heartbeat write slow, so one is still in flight when a job
+ // ends -- which is what proves the worker waits for its heartbeat to finish.
+ beatDelay time.Duration
}
func newWStore() *wStore {
@@ -60,6 +63,7 @@ func (s *wStore) SaveProgress(_ context.Context, _, _ string, steps []domain.Pro
}
func (s *wStore) Heartbeat(context.Context, string, string) error {
+ time.Sleep(s.beatDelay)
s.mu.Lock()
defer s.mu.Unlock()
s.beats++
--
2.54.0
From 9393c27cdb709a31ce25703ffbbd98b5d2be9191 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:53:10 +0200
Subject: [PATCH 137/299] test(uploads): the worker waits for its heartbeat to
finish -- a mutation that skipped the wait survived until now
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker_progress_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/uploads/worker_progress_test.go b/internal/uploads/worker_progress_test.go
index 24611ea..b6e1cf1 100644
--- a/internal/uploads/worker_progress_test.go
+++ b/internal/uploads/worker_progress_test.go
@@ -131,6 +131,7 @@ func TestWorkerProgress_AShutdownIsNotRecordedAsAFailedStep(t *testing.T) {
func TestWorkerProgress_TheHeartbeatBeatsWhileAnEngineCallIsStuckAndStopsWhenTheJobEnds(t *testing.T) {
r := newWRig(t, time.Minute)
r.tr.block = make(chan struct{})
+ r.st.beatDelay = 20 * time.Millisecond
w := newProgressWorker(t, r, r.tr, uploads.WithHeartbeat(5*time.Millisecond, time.Hour))
done := make(chan struct{})
go func() { defer close(done); w.Process(context.Background(), r.spool(t, "a")) }()
--
2.54.0
From a6b8a2b744de0f30e3a2df89ba47c3b5c91df872 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:53:33 +0200
Subject: [PATCH 138/299] test(progress): a keyed soft failure that logs why, a
note that costs no write, and FailRunning for the job's own failure (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker_test.go | 36 +++++++++++++++++++++++++++++--
1 file changed, 34 insertions(+), 2 deletions(-)
diff --git a/internal/progress/tracker_test.go b/internal/progress/tracker_test.go
index f7e4928..cf78319 100644
--- a/internal/progress/tracker_test.go
+++ b/internal/progress/tracker_test.go
@@ -182,14 +182,14 @@ func TestTracker_FinishSkipsWhatNeverRanAndClosesWhatDid(t *testing.T) {
require.Equal(t, domain.StepSkipped, stateOf(steps, "save"), "a step that never ran is skipped, not left pending")
}
-func TestTracker_FailMarksTheRunningStepFailedAndLeavesTheRestPending(t *testing.T) {
+func TestTracker_FailRunningMarksTheRunningStepFailedAndLeavesTheRestPending(t *testing.T) {
tr, sink, _, _ := newTracker(t)
tr.Start()
tr.Begin("audio", 0)
tr.Done("audio")
tr.Begin("decide", 5)
- tr.Fail()
+ tr.FailRunning()
steps := sink.last(t)
require.Equal(t, domain.StepDone, stateOf(steps, "audio"))
@@ -197,6 +197,35 @@ func TestTracker_FailMarksTheRunningStepFailedAndLeavesTheRestPending(t *testing
require.Equal(t, domain.StepPending, stateOf(steps, "save"), "what never started stays pending: it shows where the job stopped")
}
+func TestTracker_AKeyedFailMarksOneStepFailedLogsWhyAndTheJobGoesOn(t *testing.T) {
+ tr, sink, _, buf := newTracker(t)
+ tr.Start()
+ tr.Begin("audio", 0)
+
+ tr.Fail("audio", errors.New("gateway said no"))
+ tr.Begin("decide", 5)
+
+ steps := sink.last(t)
+ require.Equal(t, domain.StepFailed, stateOf(steps, "audio"))
+ require.Equal(t, domain.StepRunning, stateOf(steps, "decide"), "a soft failure must not stop the next step from running")
+ require.Contains(t, buf.String(), "level=WARN")
+ require.Contains(t, buf.String(), "gateway said no")
+ require.Contains(t, buf.String(), "step=audio")
+}
+
+func TestTracker_NoteIsALogLineAndNeverASave(t *testing.T) {
+ tr, sink, _, buf := newTracker(t)
+ tr.Start()
+ before := sink.saveCount()
+
+ tr.Note("reconciled", "disputes", 362, "auto_resolved", 80)
+
+ require.Equal(t, before, sink.saveCount(), "a note is for the operator; it must not cost a database write")
+ require.Contains(t, buf.String(), "reconciled")
+ require.Contains(t, buf.String(), "disputes=362")
+ require.Contains(t, buf.String(), "auto_resolved=80")
+}
+
func TestTracker_AnUnknownKeyIsIgnored(t *testing.T) {
tr, sink, _, _ := newTracker(t)
tr.Start()
@@ -206,6 +235,7 @@ func TestTracker_AnUnknownKeyIsIgnored(t *testing.T) {
tr.Begin("no-such-step", 0)
tr.Advance("no-such-step", 1)
tr.Done("no-such-step")
+ tr.Fail("no-such-step", errors.New("x"))
})
require.Equal(t, before, sink.saveCount())
}
@@ -298,6 +328,8 @@ func TestFrom_WithoutAReporterReturnsOneThatDoesNothing(t *testing.T) {
r.Begin("x", 3)
r.Advance("x", 1)
r.Done("x")
+ r.Fail("x", errors.New("e"))
+ r.Note("n", "k", 1)
})
}
--
2.54.0
From 5f279d7e71e6e3021b52215150f2a70b3d7e8084 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:53:43 +0200
Subject: [PATCH 139/299] feat(progress): keyed Fail for a soft failure, Note
for the operator, FailRunning for the job's own (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker.go | 31 +++++++++++++++++++++++++++----
1 file changed, 27 insertions(+), 4 deletions(-)
diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go
index 0c6e0d0..f3e94c7 100644
--- a/internal/progress/tracker.go
+++ b/internal/progress/tracker.go
@@ -21,11 +21,15 @@ type Planned struct {
// Reporter is what a long-running component reports to. Begin starts a phase (total
// is the number of countable units, 0 when there is no honest count), Advance says how
-// many are done, Done closes the phase.
+// many are done, Done closes the phase. Fail closes a phase that did not work while
+// the job goes on (an engine that was down, the other one's text used instead) and
+// says why in the log. Note is a log line for the operator that costs no write.
type Reporter interface {
Begin(key string, total int)
Advance(key string, done int)
Done(key string)
+ Fail(key string, err error)
+ Note(msg string, kv ...any)
}
// Sink is where a job's progress is persisted. Both calls are best effort: progress
@@ -133,9 +137,26 @@ func (t *Tracker) Done(key string) {
t.save()
}
-// Fail marks the phase that was running as failed. What never started stays pending,
-// which is what shows the user where the job stopped.
-func (t *Tracker) Fail() {
+// Fail implements Reporter: one phase failed and the job carries on.
+func (t *Tracker) Fail(key string, err error) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ s := t.find(key)
+ if s == nil {
+ return
+ }
+ t.close(s, domain.StepFailed)
+ t.log.Warn("step failed", "step", key, "elapsed", s.FinishedAt.Sub(s.StartedAt), "err", err)
+ t.save()
+}
+
+// Note implements Reporter.
+func (t *Tracker) Note(msg string, kv ...any) { t.log.Info(msg, kv...) }
+
+// FailRunning marks the phase that was running as failed, because the whole job
+// failed. What never started stays pending, which is what shows the user where the
+// job stopped.
+func (t *Tracker) FailRunning() {
t.mu.Lock()
defer t.mu.Unlock()
for i := range t.steps {
@@ -244,3 +265,5 @@ type noop struct{}
func (noop) Begin(string, int) {}
func (noop) Advance(string, int) {}
func (noop) Done(string) {}
+func (noop) Fail(string, error) {}
+func (noop) Note(string, ...any) {}
--
2.54.0
From c6319b9bc61850644ee4540468959a8ab4b9ceeb Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:53:43 +0200
Subject: [PATCH 140/299] refactor(uploads): the worker's own failure is
FailRunning
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/uploads/worker.go b/internal/uploads/worker.go
index 334efd3..e414e44 100644
--- a/internal/uploads/worker.go
+++ b/internal/uploads/worker.go
@@ -219,7 +219,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
// a failure: the row is left for the start-up sweep, so the step stays as it was.
abort := func() {
if ctx.Err() == nil {
- tr.Fail()
+ tr.FailRunning()
}
w.fail(ctx, job, log)
}
--
2.54.0
From f064dec185f561a8303a7040c8f200a86d5f9281 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:54:07 +0200
Subject: [PATCH 141/299] test(transcribe): the ensemble declares and reports
its phases; a failed engine is shown failed, a shutdown is not (red)
Co-Authored-By: Claude Sonnet 5.5
---
.../transcribe/ensemble_progress_test.go | 162 ++++++++++++++++++
1 file changed, 162 insertions(+)
create mode 100644 internal/adapters/transcribe/ensemble_progress_test.go
diff --git a/internal/adapters/transcribe/ensemble_progress_test.go b/internal/adapters/transcribe/ensemble_progress_test.go
new file mode 100644
index 0000000..4c779cf
--- /dev/null
+++ b/internal/adapters/transcribe/ensemble_progress_test.go
@@ -0,0 +1,162 @@
+package transcribe_test
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/progress"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// What the Ensemble reports to the page and the log (ADR-035). The reporter here is a
+// recording double carried in the context, exactly as the worker's Tracker is.
+
+type recReporter struct {
+ mu sync.Mutex
+ events []string
+ notes []map[string]any
+}
+
+func (r *recReporter) add(e string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.events = append(r.events, e)
+}
+
+func (r *recReporter) Begin(key string, _ int) { r.add("begin:" + key) }
+func (r *recReporter) Advance(key string, _ int) { r.add("advance:" + key) }
+func (r *recReporter) Done(key string) { r.add("done:" + key) }
+func (r *recReporter) Fail(key string, _ error) { r.add("fail:" + key) }
+func (r *recReporter) Note(msg string, kv ...any) {
+ m := map[string]any{"msg": msg}
+ for i := 0; i+1 < len(kv); i += 2 {
+ m[kv[i].(string)] = kv[i+1]
+ }
+ r.mu.Lock()
+ r.notes = append(r.notes, m)
+ r.mu.Unlock()
+ r.add("note:" + msg)
+}
+
+func (r *recReporter) joined() string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return strings.Join(r.events, ",")
+}
+
+func TestEnsemblePlan_DeclaresBothEnginesAndTheReconcileStepsByName(t *testing.T) {
+ r := newEnsemble("a", "a", nil, nil, false, &verdictAdj{})
+
+ plan := r.e.Plan()
+
+ require.Len(t, plan, 4)
+ assert.Equal(t, []string{"engine_a", "engine_b", "compare", "decide"}, []string{plan[0].Key, plan[1].Key, plan[2].Key, plan[3].Key})
+ assert.Contains(t, plan[0].Label, "iguana/pianissimo", "the page says which engine is running")
+ assert.Contains(t, plan[1].Label, "iguana/kb-whisper")
+}
+
+func TestEnsemblePlan_TheCloudFallbackIsAStepOnlyWhenOneIsConfigured(t *testing.T) {
+ with := newEnsemble("a", "a", nil, nil, true, &verdictAdj{}).e.Plan()
+ without := newEnsemble("a", "a", nil, nil, false, &verdictAdj{}).e.Plan()
+
+ assert.Equal(t, "fallback", with[len(with)-1].Key)
+ assert.Len(t, without, 4, "no fallback configured: the page must not promise one")
+}
+
+func TestEnsembleProgress_BothEnginesAreReportedInTheOrderTheyRun(t *testing.T) {
+ rep := &recReporter{}
+ r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, false, &verdictAdj{v: reconcile.VerdictAlt})
+
+ _, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
+
+ require.NoError(t, err)
+ got := rep.joined()
+ a, aDone := strings.Index(got, "begin:engine_a"), strings.Index(got, "done:engine_a")
+ b, bDone := strings.Index(got, "begin:engine_b"), strings.Index(got, "done:engine_b")
+ require.True(t, a >= 0 && aDone > a && b > aDone && bDone > b, "events out of order: %s", got)
+ assert.NotContains(t, got, "fail:")
+}
+
+func TestEnsembleProgress_AnEngineThatFailsIsShownFailedAndTheOtherStillRuns(t *testing.T) {
+ rep := &recReporter{}
+ r := newEnsemble("text", "text", errors.New("pianissimo down"), nil, false, &verdictAdj{})
+
+ got, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
+
+ require.NoError(t, err, "one engine down is a degraded result, not a failed job")
+ assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
+ ev := rep.joined()
+ assert.Contains(t, ev, "fail:engine_a")
+ assert.Contains(t, ev, "done:engine_b")
+ assert.NotContains(t, ev, "begin:compare", "nothing to compare with one engine")
+}
+
+func TestEnsembleProgress_TheFallbackIsReportedOnlyWhenBothEnginesFailed(t *testing.T) {
+ rep := &recReporter{}
+ r := newEnsemble("t", "t", errors.New("a down"), errors.New("b down"), true, &verdictAdj{})
+
+ _, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
+
+ require.NoError(t, err)
+ ev := rep.joined()
+ assert.Contains(t, ev, "fail:engine_a")
+ assert.Contains(t, ev, "fail:engine_b")
+ assert.Contains(t, ev, "begin:fallback")
+ assert.Contains(t, ev, "done:fallback")
+
+ rep2 := &recReporter{}
+ ok := newEnsemble("x", "x", nil, nil, true, &verdictAdj{})
+ _, err = ok.e.Transcribe(progress.With(context.Background(), rep2), "/a.wav")
+ require.NoError(t, err)
+ assert.NotContains(t, rep2.joined(), "fallback", "a healthy run never touches the cloud step")
+}
+
+// cancellingBackend ends the job in the middle of its own call, like a shutdown.
+type cancellingBackend struct {
+ name string
+ cancel context.CancelFunc
+}
+
+func (c cancellingBackend) Name() string { return c.name }
+func (c cancellingBackend) Transcribe(ctx context.Context, _ string) (domain.Transcription, error) {
+ c.cancel()
+ return domain.Transcription{}, ctx.Err()
+}
+
+func TestEnsembleProgress_AShutdownMidEngineIsNotReportedAsAFailedEngine(t *testing.T) {
+ rep := &recReporter{}
+ ctx, cancel := context.WithCancel(progress.With(context.Background(), rep))
+ calls := &[]string{}
+ alt := &orderedBackend{name: "iguana/kb-whisper", text: "x", calls: calls}
+ e := transcribe.NewEnsemble(cancellingBackend{name: "iguana/pianissimo", cancel: cancel}, alt, nil, &verdictAdj{}, ensembleOpts)
+
+ _, err := e.Transcribe(ctx, "/a.wav")
+
+ require.ErrorIs(t, err, context.Canceled)
+ assert.NotContains(t, rep.joined(), "fail:", "a stopped job is not a broken engine: the page must not blame one")
+ assert.Empty(t, *calls, "and the second engine never starts")
+}
+
+func TestEnsembleProgress_TheReconcileCountsAreNotedForTheOperator(t *testing.T) {
+ rep := &recReporter{}
+ r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, false, &verdictAdj{v: reconcile.VerdictAlt})
+
+ _, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
+
+ require.NoError(t, err)
+ require.Len(t, rep.notes, 1)
+ n := rep.notes[0]
+ assert.Equal(t, "reconciled", n["msg"])
+ assert.Equal(t, 1, n["disputes"])
+ assert.Equal(t, 1, n["changed"])
+ assert.Equal(t, 0, n["unsure"])
+ assert.Equal(t, 0, n["errors"])
+}
--
2.54.0
From aefc8103f7fddd435954e4606977aaee554fdab0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:54:31 +0200
Subject: [PATCH 142/299] feat(transcribe): the ensemble declares and reports
its phases; a failed engine shows failed, a shutdown does not (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/ensemble.go | 44 +++++++++++++++++++++---
1 file changed, 40 insertions(+), 4 deletions(-)
diff --git a/internal/adapters/transcribe/ensemble.go b/internal/adapters/transcribe/ensemble.go
index c71ff01..c44b80b 100644
--- a/internal/adapters/transcribe/ensemble.go
+++ b/internal/adapters/transcribe/ensemble.go
@@ -6,6 +6,7 @@ import (
"fmt"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/progress"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
@@ -30,13 +31,30 @@ func NewEnsemble(base, alt, fallback Backend, adj reconcile.Adjudicator, opts re
return &Ensemble{base: base, alt: alt, fallback: fallback, adj: adj, opts: opts}
}
-// Transcribe implements uploads.Transcriber.
+// Plan implements uploads.Planner: the phases this ensemble reports, so the page can
+// show them before they run. The fallback is a phase only when one is configured.
+func (e *Ensemble) Plan() []progress.Planned {
+ plan := []progress.Planned{
+ {Key: "engine_a", Label: "Transcribing with " + e.base.Name()},
+ {Key: "engine_b", Label: "Transcribing with " + e.alt.Name()},
+ {Key: "compare", Label: "Comparing the two transcripts"},
+ {Key: "decide", Label: "Deciding the differences"},
+ }
+ if e.fallback != nil {
+ plan = append(plan, progress.Planned{Key: "fallback", Label: "Cloud fallback (" + e.fallback.Name() + ")"})
+ }
+ return plan
+}
+
+// Transcribe implements uploads.Transcriber. Each phase is reported through the
+// progress.Reporter in ctx (a no-op when nobody listens).
func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
- b, berr := e.base.Transcribe(ctx, wavPath)
+ rep := progress.From(ctx)
+ b, berr := e.run(ctx, rep, "engine_a", e.base, wavPath)
if err := ctx.Err(); err != nil {
return domain.Transcription{}, err
}
- a, aerr := e.alt.Transcribe(ctx, wavPath)
+ a, aerr := e.run(ctx, rep, "engine_b", e.alt, wavPath)
if err := ctx.Err(); err != nil {
return domain.Transcription{}, err
}
@@ -48,6 +66,10 @@ func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Trans
return domain.Transcription{}, err
}
out.Endpoint = fmt.Sprintf("%s + %s, reconciled by %s", e.base.Name(), e.alt.Name(), e.opts.Reconciler)
+ if rc := out.Reconciliation; rc != nil {
+ rep.Note("reconciled", "status", string(rc.Status), "disputes", rc.Disputes, "auto_resolved", rc.AutoResolved,
+ "adjudicated", rc.Adjudicated, "changed", rc.Changed, "unsure", rc.Unsure, "errors", rc.Errors)
+ }
return out, nil
case berr == nil:
return e.single(b, e.base.Name(), fmt.Sprintf("%s failed: %v", e.alt.Name(), aerr)), nil
@@ -59,13 +81,27 @@ func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Trans
if e.fallback == nil {
return domain.Transcription{}, both
}
- f, ferr := e.fallback.Transcribe(ctx, wavPath)
+ f, ferr := e.run(ctx, rep, "fallback", e.fallback, wavPath)
if ferr != nil {
return domain.Transcription{}, errors.Join(both, fmt.Errorf("%s: %w", e.fallback.Name(), ferr))
}
return e.single(f, e.fallback.Name(), "both local engines failed"), nil
}
+// run is one engine call, reported as a phase. A job that was stopped is not a broken
+// engine, so a cancelled context is never reported as a failed phase.
+func (e *Ensemble) run(ctx context.Context, rep progress.Reporter, key string, be Backend, wav string) (domain.Transcription, error) {
+ rep.Begin(key, 0)
+ t, err := be.Transcribe(ctx, wav)
+ switch {
+ case err == nil:
+ rep.Done(key)
+ case ctx.Err() == nil:
+ rep.Fail(key, err)
+ }
+ return t, err
+}
+
// single is a transcript from one engine only: nothing to reconcile, and the result
// says why, because a reader should know the second opinion was missing.
func (e *Ensemble) single(t domain.Transcription, engine, reason string) domain.Transcription {
--
2.54.0
From 0b7bf08c95e38eb33a828cdde0307f4c57ba62fd Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:55:43 +0200
Subject: [PATCH 143/299] test(reconcile): kept spots are stored, segments
carry the merged words, the cap is an option -- three old assertions changed
on purpose (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_test.go | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go
index ec85af8..4cf8875 100644
--- a/internal/reconcile/reconcile_test.go
+++ b/internal/reconcile/reconcile_test.go
@@ -84,7 +84,12 @@ func TestReconcile_BaseAndUnsureVerdictsKeepTheBaseButOnlyUnsureIsFlagged(t *tes
assert.Equal(t, "Totalt 1015 kronor till SEB.", keptBase.Text)
assert.Zero(t, keptBase.Reconciliation.Unsure)
- assert.Empty(t, keptBase.Reconciliation.Contested, "a confident 'the base is right' is not a spot worth the reader's time")
+ require.Len(t, keptBase.Reconciliation.Contested, 2,
+ "every judged spot is stored, kept ones too: without them the reconciler cannot be compared with either engine alone (ADR-035)")
+ for _, c := range keptBase.Reconciliation.Contested {
+ assert.Equal(t, "base", c.Used)
+ assert.False(t, c.Unsure, "a confident 'the base is right' is stored but is not an uncertain spot")
+ }
assert.Equal(t, "Totalt 1015 kronor till SEB.", unsure.Text, "unsure keeps the base")
assert.Equal(t, 2, unsure.Reconciliation.Unsure)
require.Len(t, unsure.Reconciliation.Contested, 2)
@@ -224,14 +229,16 @@ func TestReconcile_ACancelledJobIsAnErrorNotASkippedReconciliation(t *testing.T)
assert.ErrorIs(t, err, context.Canceled, "a shutdown must not be recorded as 'the model was unavailable'")
}
-func TestReconcile_KeepsTheBaseSegmentsDurationAndNamesTheEngines(t *testing.T) {
+func TestReconcile_KeepsTheBaseTimingAndDurationAndNamesTheEngines(t *testing.T) {
base := tr("a x b")
base.Segments = []domain.Segment{{Start: 0, End: 4, Text: "a x b"}}
base.AudioSeconds = 3821.5
out := merge(t, base, tr("a y b"), always(reconcile.VerdictAlt))
- assert.Equal(t, base.Segments, out.Segments, "the timed segments are the base engine's, untouched")
+ require.Len(t, out.Segments, 1)
+ assert.InDelta(t, 0.0, out.Segments[0].Start, 1e-9, "timing is the base engine's")
+ assert.InDelta(t, 4.0, out.Segments[0].End, 1e-9)
assert.InDelta(t, 3821.5, out.AudioSeconds, 1e-9)
assert.Equal(t, "iguana/pianissimo", out.Reconciliation.Base)
assert.Equal(t, "iguana/kb-whisper", out.Reconciliation.Alt)
@@ -264,18 +271,25 @@ func TestReconcile_WithNoSegmentsASpotIsPlacedProportionally(t *testing.T) {
assert.InDelta(t, 90.0, out.Reconciliation.Contested[0].Seconds, 1e-9, "token 9 of 10 in 100 s")
}
-func TestReconcile_ContestedSpotsAreCappedButTheCountsAreExact(t *testing.T) {
+func TestReconcile_JudgedSpotsAreCappedButTheCountsAreExact(t *testing.T) {
a, b := make([]string, 0, 600), make([]string, 0, 600)
for i := 0; i < 300; i++ {
a = append(a, fmt.Sprintf("w%d", i), "same", "same2")
b = append(b, fmt.Sprintf("v%d", i), "same", "same2")
}
+ capped := opts
+ capped.MaxContested = 50
- out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), always(reconcile.VerdictUnsure))
+ out, err := reconcile.Reconcile(context.Background(), tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), always(reconcile.VerdictUnsure), capped)
+ require.NoError(t, err)
assert.Equal(t, 300, out.Reconciliation.Unsure)
assert.Equal(t, 300, out.Reconciliation.Disputes)
- assert.Len(t, out.Reconciliation.Contested, reconcile.MaxContested, "the stored list is bounded; the counts are not")
+ assert.Len(t, out.Reconciliation.Contested, 50, "the stored list is bounded; the counts are not")
+}
+
+func TestReconcile_TheDefaultCapIsAFewThousandSoAWholeMeetingIsKept(t *testing.T) {
+ assert.Equal(t, 2000, reconcile.MaxContested, "a 90-minute recording has a few hundred judged spots; the cap is a guard, not a budget")
}
func TestReconcile_AVerdictReturnedAlongsideAnErrorIsIgnored(t *testing.T) {
--
2.54.0
From 520ab666f8ca02cfbf9933ee5e68e1751159c865 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:55:43 +0200
Subject: [PATCH 144/299] test(reconcile): the comparison and the deciding
phase report real counts, and a shutdown is neither done nor failed (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_progress_test.go | 116 ++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 internal/reconcile/reconcile_progress_test.go
diff --git a/internal/reconcile/reconcile_progress_test.go b/internal/reconcile/reconcile_progress_test.go
new file mode 100644
index 0000000..fdf8271
--- /dev/null
+++ b/internal/reconcile/reconcile_progress_test.go
@@ -0,0 +1,116 @@
+package reconcile_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/progress"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// What Reconcile reports while it works (ADR-035): the comparison, then the deciding
+// phase with the real number of model decisions as its total.
+
+type repRec struct {
+ mu sync.Mutex
+ events []string
+}
+
+func (r *repRec) add(e string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.events = append(r.events, e)
+}
+
+func (r *repRec) Begin(key string, total int) { r.add(fmt.Sprintf("begin:%s:%d", key, total)) }
+func (r *repRec) Advance(key string, done int) { r.add(fmt.Sprintf("advance:%s:%d", key, done)) }
+func (r *repRec) Done(key string) { r.add("done:" + key) }
+func (r *repRec) Fail(key string, _ error) { r.add("fail:" + key) }
+func (r *repRec) Note(string, ...any) {}
+func (r *repRec) joined() string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return strings.Join(r.events, ",")
+}
+func withRep(r *repRec) context.Context { return progress.With(context.Background(), r) }
+func reconcileWith(t *testing.T, ctx context.Context, base, alt string, adj reconcile.Adjudicator) error {
+ t.Helper()
+ _, err := reconcile.Reconcile(ctx, tr(base), tr(alt), adj, opts)
+ return err
+}
+
+// two compound/filler disputes the model is never asked about, then two it is.
+const (
+ progBase = "i crossborder delen ska vi ju betala totalt 1015 kronor till SEB idag"
+ progAlt = "i crossborderdelen ska vi betala totalt 1050 kronor till SCB idag"
+)
+
+func TestReconcileProgress_TheDecidingPhaseTotalIsTheModelDecisionsNotTheDisputes(t *testing.T) {
+ rep := &repRec{}
+
+ require.NoError(t, reconcileWith(t, withRep(rep), progBase, progAlt, always(reconcile.VerdictAlt)))
+
+ assert.Equal(t,
+ "begin:compare:0,done:compare,begin:decide:2,advance:decide:1,advance:decide:2,done:decide",
+ rep.joined(),
+ "4 disputes, 2 settled without the model: the bar counts the 2 the model decides, so it can reach its end")
+}
+
+func TestReconcileProgress_TextsThatAgreeStillClosePhasesSoNothingIsLeftRunning(t *testing.T) {
+ rep := &repRec{}
+
+ require.NoError(t, reconcileWith(t, withRep(rep), "samma text hela vägen", "samma text hela vägen", always(reconcile.VerdictAlt)))
+
+ assert.Equal(t, "begin:compare:0,done:compare,begin:decide:0,done:decide", rep.joined())
+}
+
+func TestReconcileProgress_AModelErrorStillCountsAsADecisionTaken(t *testing.T) {
+ rep := &repRec{}
+ adj := &scripted{fn: func(i int, _ reconcile.Request) (reconcile.Verdict, error) {
+ if i == 0 {
+ return reconcile.VerdictBase, errors.New("one timeout")
+ }
+ return reconcile.VerdictAlt, nil
+ }}
+
+ require.NoError(t, reconcileWith(t, withRep(rep), progBase, progAlt, adj))
+
+ assert.Contains(t, rep.joined(), "advance:decide:1,advance:decide:2", "a spot that errored was still handled; the bar must move past it")
+}
+
+func TestReconcileProgress_TheBreakerTrippingShowsTheDecidingPhaseFailedNotDone(t *testing.T) {
+ rep := &repRec{}
+ adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
+ return reconcile.VerdictBase, errors.New("model down")
+ }}
+ small := opts
+ small.BreakAfter = 2
+
+ _, err := reconcile.Reconcile(withRep(rep), tr(progBase), tr(progAlt), adj, small)
+
+ require.NoError(t, err, "a model that is down degrades the result; it does not fail the job")
+ assert.Contains(t, rep.joined(), "fail:decide")
+ assert.NotContains(t, rep.joined(), "done:decide")
+}
+
+func TestReconcileProgress_AShutdownIsNeitherDoneNorFailed(t *testing.T) {
+ rep := &repRec{}
+ ctx, cancel := context.WithCancel(withRep(rep))
+ adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
+ cancel()
+ return reconcile.VerdictAlt, nil
+ }}
+
+ err := reconcileWith(t, ctx, progBase, progAlt, adj)
+
+ require.ErrorIs(t, err, context.Canceled)
+ assert.NotContains(t, rep.joined(), "fail:decide")
+ assert.NotContains(t, rep.joined(), "done:decide")
+}
--
2.54.0
From 9297f728c2c9eb5543868687e5c3a2bb4e52041f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:55:43 +0200
Subject: [PATCH 145/299] test(reconcile): every judged spot with its left
context; segments rewritten per segment, or left alone when they do not line
up (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_spots_test.go | 120 +++++++++++++++++++++
1 file changed, 120 insertions(+)
create mode 100644 internal/reconcile/reconcile_spots_test.go
diff --git a/internal/reconcile/reconcile_spots_test.go b/internal/reconcile/reconcile_spots_test.go
new file mode 100644
index 0000000..a3eba9b
--- /dev/null
+++ b/internal/reconcile/reconcile_spots_test.go
@@ -0,0 +1,120 @@
+package reconcile_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// Every judged spot is stored with a few words of left context, and the saved segments
+// carry the merged words while keeping the base's timing (ADR-035 decisions 6 and 7).
+
+func TestReconcileSpots_AKeptSpotAndAChangedSpotAreBothStoredWithTheirVerdict(t *testing.T) {
+ adj := &scripted{fn: func(i int, _ reconcile.Request) (reconcile.Verdict, error) {
+ if i == 0 {
+ return reconcile.VerdictBase, nil
+ }
+ return reconcile.VerdictAlt, nil
+ }}
+
+ out := merge(t, tr(progBase), tr(progAlt), adj)
+
+ require.Len(t, out.Reconciliation.Contested, 2)
+ assert.Equal(t, "base", out.Reconciliation.Contested[0].Used)
+ assert.Equal(t, "1015", out.Reconciliation.Contested[0].Base)
+ assert.Equal(t, "alt", out.Reconciliation.Contested[1].Used)
+ assert.Equal(t, 1, out.Reconciliation.Changed, "the exact counts are unchanged by storing more")
+}
+
+func TestReconcileSpots_AutoResolvedDisputesAreNotStored(t *testing.T) {
+ out := merge(t, tr("i crossborder delen ska vi ju betala"), tr("i crossborderdelen ska vi betala"), always(reconcile.VerdictAlt))
+
+ assert.Empty(t, out.Reconciliation.Contested, "compounds and fillers were never judged; they are counted, not listed")
+}
+
+func TestReconcileSpots_ASpotCarriesTheWordsJustBeforeIt(t *testing.T) {
+ out := merge(t, tr("vi skickar in ett special meddelande till taxsystemet"), tr("vi skickar in ett CAMT meddelande till taxsystemet"), always(reconcile.VerdictAlt))
+
+ require.Len(t, out.Reconciliation.Contested, 1)
+ assert.Equal(t, "vi skickar in ett", out.Reconciliation.Contested[0].Before)
+}
+
+func TestReconcileSpots_TheContextIsAFewWordsNotTheWholeMeeting(t *testing.T) {
+ long := "ett två tre fyra fem sex sju åtta nio tio elva tolv"
+ out := merge(t, tr(long+" X slut"), tr(long+" Y slut"), always(reconcile.VerdictAlt))
+
+ require.Len(t, out.Reconciliation.Contested, 1)
+ assert.Equal(t, "fem sex sju åtta nio tio elva tolv", out.Reconciliation.Contested[0].Before,
+ "eight words: enough to find the place, small enough to stay out of the way")
+}
+
+func segmented(text string, segs ...domain.Segment) domain.Transcription {
+ t := tr(text)
+ t.Segments = segs
+ return t
+}
+
+func TestReconcileSegments_AChangedWordReplacesTheBaseWordInItsOwnSegmentOnly(t *testing.T) {
+ base := segmented("ett två tre. fyra fem sex. sju åtta nio.",
+ domain.Segment{Start: 0, End: 10, Text: "ett två tre."},
+ domain.Segment{Start: 10, End: 20, Text: "fyra fem sex."},
+ domain.Segment{Start: 20, End: 30, Text: "sju åtta nio."})
+
+ out := merge(t, base, tr("ett två tre. fyra femm sex. sju åtta nio."), always(reconcile.VerdictAlt))
+
+ require.Len(t, out.Segments, 3)
+ assert.Equal(t, "ett två tre.", out.Segments[0].Text, "an untouched segment keeps its exact text")
+ assert.Equal(t, "fyra femm sex.", out.Segments[1].Text)
+ assert.Equal(t, "sju åtta nio.", out.Segments[2].Text)
+ assert.InDelta(t, 10.0, out.Segments[1].Start, 1e-9, "timing stays the base's")
+ assert.InDelta(t, 20.0, out.Segments[1].End, 1e-9)
+}
+
+func TestReconcileSegments_AWordOnlyTheAlternativeHeardLandsInTheSegmentItBelongsTo(t *testing.T) {
+ base := segmented("a b c. d e f.", domain.Segment{Start: 0, End: 5, Text: "a b c."}, domain.Segment{Start: 5, End: 10, Text: "d e f."})
+
+ out := merge(t, base, tr("a b c. d NY e f."), always(reconcile.VerdictAlt))
+
+ assert.Equal(t, "a b c.", out.Segments[0].Text)
+ assert.Equal(t, "d NY e f.", out.Segments[1].Text)
+}
+
+func TestReconcileSegments_AWordOnlyTheBaseHeardIsRemovedFromItsSegment(t *testing.T) {
+ base := segmented("a b c. d X e f.", domain.Segment{Start: 0, End: 5, Text: "a b c."}, domain.Segment{Start: 5, End: 10, Text: "d X e f."})
+
+ out := merge(t, base, tr("a b c. d e f."), always(reconcile.VerdictAlt))
+
+ assert.Equal(t, "d e f.", out.Segments[1].Text)
+}
+
+func TestReconcileSegments_AKeptBaseLeavesEverySegmentAsItWas(t *testing.T) {
+ base := segmented("a x b", domain.Segment{Start: 0, End: 4, Text: "a x b"})
+
+ out := merge(t, base, tr("a y b"), always(reconcile.VerdictBase))
+
+ assert.Equal(t, base.Segments, out.Segments)
+}
+
+func TestReconcileSegments_TheCallersSegmentsAreNeverChangedInPlace(t *testing.T) {
+ segs := []domain.Segment{{Start: 0, End: 4, Text: "a x b"}}
+ base := segmented("a x b", segs...)
+
+ _ = merge(t, base, tr("a y b"), always(reconcile.VerdictAlt))
+
+ assert.Equal(t, "a x b", segs[0].Text, "Reconcile builds new segments; it does not edit the base transcription's")
+}
+
+func TestReconcileSegments_SegmentsThatDoNotLineUpWithTheTextAreLeftAsTheBases(t *testing.T) {
+ // A word wrapped across a segment edge makes the segments' word count differ from
+ // the joined text's. Guessing would put words in the wrong place, so leave them.
+ base := segmented("a x b c", domain.Segment{Start: 0, End: 4, Text: "a x"}, domain.Segment{Start: 4, End: 8, Text: "b c d"})
+
+ out := merge(t, base, tr("a y b c"), always(reconcile.VerdictAlt))
+
+ assert.Equal(t, base.Segments, out.Segments)
+ assert.Equal(t, "a y b c", out.Text, "the merged text is unaffected")
+}
--
2.54.0
From f74ad726e2a45fe40eed5801095b78eb59076e51 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:56:53 +0200
Subject: [PATCH 146/299] feat(domain): a judged spot carries the few words
before it (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/domain/domain.go | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index 20fbd75..92df9f7 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -147,14 +147,18 @@ const (
ReconcileSkipped ReconcileStatus = "skipped"
)
-// ContestedSpot is a place the engines disagreed that a reader may want to check
-// against the audio: one the model changed, or one it was unsure about.
+// ContestedSpot is a place the engines disagreed that the model judged: it changed it,
+// kept the base's reading, or could not decide. Every judged spot is stored, not only
+// the ones a reader should check, because the audio is not retained and the kept ones
+// are what a later evaluation needs (ADR-035). The page lists only the changed and
+// uncertain ones.
type ContestedSpot struct {
- Seconds float64 `json:"seconds"` // roughly where in the recording
- Base string `json:"base"` // what the base engine heard
- Alt string `json:"alt"` // what the other engine heard
- Used string `json:"used"` // "base" or "alt": the reading now in the text
- Unsure bool `json:"unsure"` // the model could not decide; the base was kept
+ Seconds float64 `json:"seconds"` // roughly where in the recording
+ Base string `json:"base"` // what the base engine heard
+ Alt string `json:"alt"` // what the other engine heard
+ Used string `json:"used"` // "base" or "alt": the reading now in the text
+ Unsure bool `json:"unsure"` // the model could not decide; the base was kept
+ Before string `json:"before,omitempty"` // the few words just before the spot, to find it by
}
// Reconciliation records how a merged transcript came about, so the page can say so
--
2.54.0
From 976aeb71c64b50fdfe1461b4e0493b5d39e5fc86 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:56:53 +0200
Subject: [PATCH 147/299] feat(reconcile): report the comparison and deciding
phases, store every judged spot, merge the words into the segments (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile.go | 147 +++++++++++++++++++++++++-------
1 file changed, 118 insertions(+), 29 deletions(-)
diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go
index ba604a7..f1e3956 100644
--- a/internal/reconcile/reconcile.go
+++ b/internal/reconcile/reconcile.go
@@ -6,11 +6,17 @@ import (
"strings"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/progress"
)
-// MaxContested bounds the stored list of contested spots. The counts on the
-// Reconciliation are always exact; only this list is capped.
-const MaxContested = 200
+// MaxContested is the default bound on the stored list of judged spots. A 90-minute
+// recording has a few hundred, so this is a guard against a runaway, not a budget. The
+// counts on the Reconciliation are always exact; only the list is capped.
+const MaxContested = 2000
+
+// beforeWords is how many words of left context a stored spot carries: enough to find
+// the place again, small enough to stay out of the way.
+const beforeWords = 8
// Options configure Reconcile. Zero Window and BreakAfter take their defaults.
type Options struct {
@@ -20,6 +26,8 @@ type Options struct {
// BreakAfter is how many consecutive model errors stop the reconciliation, so a
// model that is down costs three timeouts, not one per dispute.
BreakAfter int
+ // MaxContested caps the stored judged spots; zero takes the package default.
+ MaxContested int
// BaseEngine, AltEngine and Reconciler are recorded on the result.
BaseEngine, AltEngine, Reconciler string
}
@@ -31,22 +39,50 @@ type Options struct {
// and the result says why. The only error it returns is a cancelled context, which
// is the job stopping, not a verdict on the model.
func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudicator, o Options) (domain.Transcription, error) {
- window, breakAfter := o.Window, o.BreakAfter
+ window, breakAfter, maxSpots := o.Window, o.BreakAfter, o.MaxContested
if window <= 0 {
window = 100
}
if breakAfter <= 0 {
breakAfter = 3
}
+ if maxSpots <= 0 {
+ maxSpots = MaxContested
+ }
+ rep := progress.From(ctx)
+
+ rep.Begin("compare", 0)
bt, at := Tokenize(base.Text), Tokenize(alt.Text)
ds := FindDisputes(bt, at)
+ rep.Done("compare")
+
+ // The deciding phase counts what the MODEL decides: the trivial disputes are settled
+ // without it, so counting them would leave the bar short of its end.
+ deciding := 0
+ for _, d := range ds {
+ if !AutoResolve(d) {
+ deciding++
+ }
+ }
rec := &domain.Reconciliation{
Status: domain.ReconcileDone, Base: o.BaseEngine, Alt: o.AltEngine, Reconciler: o.Reconciler, Disputes: len(ds),
}
seconds := positioner(base, len(bt))
+ spot := func(d Dispute, used string, unsure bool) {
+ before, _ := ContextAround(bt, d, window)
+ if w := strings.Fields(before); len(w) > beforeWords {
+ before = strings.Join(w[len(w)-beforeWords:], " ")
+ }
+ if len(rec.Contested) < maxSpots {
+ rec.Contested = append(rec.Contested, domain.ContestedSpot{
+ Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: used, Unsure: unsure, Before: before,
+ })
+ }
+ }
+ rep.Begin("decide", deciding)
verdicts := make([]Verdict, len(ds))
- consecutive := 0
+ consecutive, handled := 0, 0
for i, d := range ds {
if AutoResolve(d) {
rec.AutoResolved++
@@ -65,7 +101,9 @@ func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudica
rec.Errors++
consecutive++
if consecutive >= breakAfter {
- return skipped(base, rec, fmt.Sprintf("the model failed %d times in a row: %v", consecutive, err)), nil
+ failure := fmt.Errorf("the model failed %d times in a row: %w", consecutive, err)
+ rep.Fail("decide", failure)
+ return skipped(base, rec, failure.Error()), nil
}
v = VerdictUnsure
} else {
@@ -76,30 +114,94 @@ func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudica
switch v {
case VerdictAlt:
rec.Changed++
- addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "alt"})
+ spot(d, "alt", false)
case VerdictUnsure:
rec.Unsure++
- addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "base", Unsure: true})
+ spot(d, "base", true)
+ default:
+ spot(d, "base", false)
}
+ handled++
+ rep.Advance("decide", handled)
+ }
+ rep.Done("decide")
+
+ out := base
+ out.Text, out.Segments = merge(base, bt, at, ds, verdicts)
+ out.Reconciliation = rec
+ return out, nil
+}
+
+// merge applies the verdicts: the base's words stand except where the alternative's
+// reading was chosen. It returns the merged text and the base's segments carrying the
+// merged words (timing is the base's). Segments are rewritten only when their words line
+// up with the text; a word wrapped across a segment edge makes them differ, and guessing
+// would put words in the wrong place, so then the base's segments are returned as they
+// were (ADR-035). The base's own segments are never edited in place.
+func merge(base domain.Transcription, bt, at []Token, ds []Dispute, verdicts []Verdict) (string, []domain.Segment) {
+ segOf, aligned := segmentIndex(base.Segments, len(bt))
+ var perSeg [][]string
+ var changed map[int]bool
+ if aligned {
+ perSeg, changed = make([][]string, len(base.Segments)), map[int]bool{}
}
var parts []string
+ keep := func(from, to int) {
+ for i := from; i < to; i++ {
+ parts = append(parts, bt[i].Text)
+ if aligned {
+ perSeg[segOf[i]] = append(perSeg[segOf[i]], bt[i].Text)
+ }
+ }
+ }
pos := 0
for i, d := range ds {
- parts = appendTexts(parts, bt[pos:d.BaseStart])
+ keep(pos, d.BaseStart)
if verdicts[i] == VerdictAlt {
- parts = appendTexts(parts, at[d.AltStart:d.AltEnd])
+ for _, t := range at[d.AltStart:d.AltEnd] {
+ parts = append(parts, t.Text)
+ }
+ if aligned {
+ anchor := min(d.BaseStart, len(bt)-1)
+ for _, t := range at[d.AltStart:d.AltEnd] {
+ perSeg[segOf[anchor]] = append(perSeg[segOf[anchor]], t.Text)
+ }
+ changed[segOf[anchor]] = true
+ for k := d.BaseStart; k < d.BaseEnd; k++ {
+ changed[segOf[k]] = true
+ }
+ }
} else {
- parts = appendTexts(parts, bt[d.BaseStart:d.BaseEnd])
+ keep(d.BaseStart, d.BaseEnd)
}
pos = d.BaseEnd
}
- parts = appendTexts(parts, bt[pos:])
+ keep(pos, len(bt))
- out := base
- out.Text = strings.Join(parts, " ")
- out.Reconciliation = rec
- return out, nil
+ if !aligned {
+ return strings.Join(parts, " "), base.Segments
+ }
+ segs := append([]domain.Segment(nil), base.Segments...)
+ for k := range changed {
+ segs[k].Text = strings.Join(perSeg[k], " ")
+ }
+ return strings.Join(parts, " "), segs
+}
+
+// segmentIndex maps each base token to the segment it came from. aligned is false when
+// there are no segments or their word count is not the text's.
+func segmentIndex(segs []domain.Segment, tokens int) (segOf []int, aligned bool) {
+ if len(segs) == 0 || tokens == 0 {
+ return nil, false
+ }
+ segOf = make([]int, 0, tokens)
+ for k, s := range segs {
+ for range Tokenize(s.Text) {
+ segOf = append(segOf, k)
+ }
+ }
+ return segOf, len(segOf) == tokens
}
// skipped is the base text, none of the partial changes, and the reason.
@@ -112,13 +214,6 @@ func skipped(base domain.Transcription, rec *domain.Reconciliation, reason strin
return out
}
-func appendTexts(dst []string, ts []Token) []string {
- for _, t := range ts {
- dst = append(dst, t.Text)
- }
- return dst
-}
-
// positioner maps a token index of the base to the second it starts at: through the
// segments when there are any, proportionally otherwise.
func positioner(base domain.Transcription, tokens int) func(int) float64 {
@@ -144,9 +239,3 @@ func positioner(base domain.Transcription, tokens int) func(int) float64 {
return base.Segments[k].Start
}
}
-
-func addContested(rec *domain.Reconciliation, c domain.ContestedSpot) {
- if len(rec.Contested) < MaxContested {
- rec.Contested = append(rec.Contested, c)
- }
-}
--
2.54.0
From a97598586c7fba61ede2cd6fa90eb6ca075443ca Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:56:53 +0200
Subject: [PATCH 148/299] test(reconcile): a span across a segment edge leaves
neither segment holding a replaced word -- a mutation that skipped this
survived
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_spots_test.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/internal/reconcile/reconcile_spots_test.go b/internal/reconcile/reconcile_spots_test.go
index a3eba9b..bf4e6a4 100644
--- a/internal/reconcile/reconcile_spots_test.go
+++ b/internal/reconcile/reconcile_spots_test.go
@@ -118,3 +118,13 @@ func TestReconcileSegments_SegmentsThatDoNotLineUpWithTheTextAreLeftAsTheBases(t
assert.Equal(t, base.Segments, out.Segments)
assert.Equal(t, "a y b c", out.Text, "the merged text is unaffected")
}
+
+func TestReconcileSegments_ASpanThatCrossesASegmentEdgeIsRemovedFromBothSegments(t *testing.T) {
+ base := segmented("a b c d", domain.Segment{Start: 0, End: 5, Text: "a b"}, domain.Segment{Start: 5, End: 10, Text: "c d"})
+
+ out := merge(t, base, tr("a X d"), always(reconcile.VerdictAlt))
+
+ require.Equal(t, "a X d", out.Text)
+ assert.Equal(t, "a X", out.Segments[0].Text)
+ assert.Equal(t, "d", out.Segments[1].Text, "'c' was replaced too: it must not linger in the second segment")
+}
--
2.54.0
From d0f2dcf838a97bf44185880ed5c6be330114c603 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 08:57:58 +0200
Subject: [PATCH 149/299] test(web): the page shows real phases, tells running
from quiet from lost, and never invents a percentage (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_progress_handler_test.go | 243 ++++++++++++++++++
1 file changed, 243 insertions(+)
create mode 100644 internal/web/uploads_progress_handler_test.go
diff --git a/internal/web/uploads_progress_handler_test.go b/internal/web/uploads_progress_handler_test.go
new file mode 100644
index 0000000..fcce617
--- /dev/null
+++ b/internal/web/uploads_progress_handler_test.go
@@ -0,0 +1,243 @@
+package web_test
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/web"
+)
+
+// The page's progress display (ADR-035, #42): real phases from the row, and the three
+// honest states the page can tell apart -- running, quiet (counted phase not advancing)
+// and lost (the service stopped reporting). Real router, real Postgres, a fixed clock.
+
+var progressNow = time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
+
+func fixedClock(app *web.App) { app.Now = func() time.Time { return progressNow } }
+
+// seedProgress writes the job's phases and the two timestamps onto an existing row.
+func seedProgress(t *testing.T, id string, steps []domain.ProgressStep, progressAt, aliveAt time.Time) {
+ t.Helper()
+ b, err := json.Marshal(steps)
+ require.NoError(t, err)
+ var alive any
+ if !aliveAt.IsZero() {
+ alive = aliveAt
+ }
+ _, err = rawPool(t).Exec(context.Background(),
+ `UPDATE uploaded_transcripts SET progress = $2, progress_at = $3, alive_at = $4 WHERE id = $1`,
+ id, b, progressAt, alive)
+ require.NoError(t, err)
+}
+
+func step(key, label string, state domain.StepState, startedAgo, ranFor time.Duration, done, total int) domain.ProgressStep {
+ s := domain.ProgressStep{Key: key, Label: label, State: state, Done: done, Total: total}
+ if state != domain.StepPending {
+ s.StartedAt = progressNow.Add(-startedAgo)
+ }
+ if state == domain.StepDone || state == domain.StepFailed {
+ s.FinishedAt = s.StartedAt.Add(ranFor)
+ }
+ return s
+}
+
+// twoEngineRun is a job in its second engine: three steps done, one running, three to come.
+func twoEngineRun() []domain.ProgressStep {
+ return []domain.ProgressStep{
+ step("received", "Received", domain.StepDone, 5*time.Minute, 0, 0, 0),
+ step("audio", "Preparing the audio", domain.StepDone, 5*time.Minute, 40*time.Second, 0, 0),
+ step("engine_a", "Transcribing with iguana/pianissimo", domain.StepDone, 4*time.Minute, 38*time.Second, 0, 0),
+ step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepRunning, 3*time.Minute+12*time.Second, 0, 0, 0),
+ step("compare", "Comparing the two transcripts", domain.StepPending, 0, 0, 0, 0),
+ step("decide", "Deciding the differences", domain.StepPending, 0, 0, 0, 0),
+ step("save", "Saving the transcript", domain.StepPending, 0, 0, 0, 0),
+ }
+}
+
+func statusBody(t *testing.T, app *web.App, id string) string {
+ t.Helper()
+ rec := do(t, app, get("/uploads/"+id+"/status"))
+ require.Equal(t, 200, rec.Code)
+ return body(t, rec)
+}
+
+func TestUploadProgress_ARunningJobShowsEveryPhaseInOrderWithTheRunningOneMarked(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ fixedClock(app)
+ id := seedUpload(t, userID, "m", "transcribing", "")
+ seedProgress(t, id, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
+
+ b := statusBody(t, app, id)
+
+ last := -1
+ for _, label := range []string{
+ "Received", "Preparing the audio", "Transcribing with iguana/pianissimo", "Transcribing with iguana/kb-whisper",
+ "Comparing the two transcripts", "Deciding the differences", "Saving the transcript",
+ } {
+ i := strings.Index(b, label)
+ require.Greater(t, i, last, "phase %q missing or out of order:\n%s", label, b)
+ last = i
+ }
+ assert.Equal(t, 3, strings.Count(b, "step-done"), "three finished phases collapse to a done line")
+ assert.Equal(t, 1, strings.Count(b, `aria-current="step"`), "exactly one phase is the current one")
+ assert.Contains(t, b, "3m 12s", "the running phase shows how long it has run")
+ assert.Contains(t, b, "38s", "a finished phase shows how long it took")
+ assert.Contains(t, b, `hx-trigger="every 2s"`, "an unfinished job re-asks every two seconds")
+}
+
+func TestUploadProgress_OnlyACountedPhaseGetsABarAndItShowsRealCounts(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ fixedClock(app)
+ id := seedUpload(t, userID, "m", "transcribing", "")
+ steps := twoEngineRun()
+ steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 3*time.Minute, 2*time.Minute, 0, 0)
+ steps[4] = step("compare", "Comparing the two transcripts", domain.StepDone, 1*time.Minute, time.Second, 0, 0)
+ steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 50*time.Second, 0, 142, 282)
+ seedProgress(t, id, steps, progressNow.Add(-5*time.Second), progressNow.Add(-3*time.Second))
+
+ b := statusBody(t, app, id)
+
+ assert.Contains(t, b, `
Date: Fri, 2 Oct 2026 09:00:23 +0200
Subject: [PATCH 150/299] feat(web): progress styles in tapir's own tokens -- a
mono step log, a CSS spinner that respects reduced motion (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/view.go | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/internal/web/view.go b/internal/web/view.go
index 32f808e..af81ffd 100644
--- a/internal/web/view.go
+++ b/internal/web/view.go
@@ -765,6 +765,30 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
.flash-error { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
+/* upload progress (ADR-035): a log of phases that fills in as the job runs */
+.steps { list-style: none; margin: var(--s3) 0; padding: 0; font-family: var(--mono); font-size: .86rem; }
+.step { display: flex; align-items: baseline; gap: var(--s2); padding: .2rem 0; color: var(--muted); flex-wrap: wrap; }
+.step-mark { flex: none; width: 1.1rem; text-align: center; }
+.step-label { flex: 1 1 auto; }
+.step-time { margin-left: auto; color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; }
+.step-done { color: var(--fg); }
+.step-done .step-mark { color: var(--mint); }
+.step-running { color: var(--fg); font-weight: 600; }
+.step-running .step-mark { color: var(--accent); }
+.step-failed { color: var(--err-fg); }
+.step-skipped, .step-pending { color: var(--muted); }
+.step-skipped { opacity: .7; }
+.step-pending { opacity: .75; }
+.step-bar { flex: 1 0 100%; margin: 0 0 0 1.6rem; height: .5rem; accent-color: var(--accent); }
+.step-count { flex: 1 0 100%; margin-left: 1.6rem; font-weight: 400; color: var(--muted); }
+.spinner { display: inline-block; width: .8rem; height: .8rem; box-sizing: border-box; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: tapir-spin 1s linear infinite; }
+@keyframes tapir-spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-color: var(--accent); } }
+.step-health { margin: var(--s2) 0; padding: var(--s2) var(--s3); border-radius: var(--radius); font-size: .9rem; border: 1px solid var(--line); }
+.step-health-lost { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
+.step-health-quiet { background: var(--accent-weak); color: var(--fg); border-color: var(--accent); }
+.what-was-done summary { cursor: pointer; color: var(--muted); }
+
/* htmx loading feedback */
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
--
2.54.0
From 89c9dd67051b1035e4f1de352c8d67d8867516a8 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:00:23 +0200
Subject: [PATCH 151/299] feat(web): tell running from quiet from lost; poll
every two seconds (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 103 ++++++++++++++++++++++++++++++++++++++--
1 file changed, 100 insertions(+), 3 deletions(-)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index 6f57637..af1a166 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -9,6 +9,7 @@ import (
"net/http"
"path/filepath"
"strings"
+ "time"
"unicode/utf8"
"github.com/a-h/templ"
@@ -32,7 +33,15 @@ const (
uploadOverhead = 1 << 20
maxTitleRunes = 200
// uploadPollEvery is how often an unfinished upload re-asks for its status.
- uploadPollEvery = "5s"
+ uploadPollEvery = "2s"
+ // serviceSilentAfter is how long a job may go without any signal (heartbeat or
+ // progress) before the page calls the service lost. The heartbeat is every 10 s, so
+ // 45 s is four missed beats (ADR-035).
+ serviceSilentAfter = 45 * time.Second
+ // countedQuietAfter is how long a COUNTED phase (the deciding one) may go without
+ // advancing before the page says it is quiet. Each decision has a two-minute timeout,
+ // so five minutes is more than two in a row. A phase with no count is never quiet.
+ countedQuietAfter = 5 * time.Minute
)
// uploadsView is what the /uploads page renders.
@@ -137,7 +146,7 @@ func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- a.render(w, r, UploadPage(*u, a.Chat != nil))
+ a.render(w, r, UploadPage(*u, a.Chat != nil, a.now()))
}
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
@@ -148,7 +157,7 @@ func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- a.render(w, r, uploadStatus(*u, a.Chat != nil))
+ a.render(w, r, uploadStatus(*u, a.Chat != nil, a.now()))
}
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
@@ -296,3 +305,91 @@ func shownSpots(r *domain.Reconciliation) []domain.ContestedSpot {
func moreSpots(r *domain.Reconciliation) int {
return max(0, r.Changed+r.Unsure-len(shownSpots(r)))
}
+
+// jobHealth is what the page can honestly say about a job it is still polling.
+type jobHealth string
+
+const (
+ // healthRunning: the service is reporting and work is moving, or there is nothing yet
+ // to say otherwise.
+ healthRunning jobHealth = "running"
+ // healthQuiet: the service is alive but a counted phase has not advanced for a while.
+ healthQuiet jobHealth = "quiet"
+ // healthLost: nothing has been heard from the service for too long. It may have
+ // restarted or crashed; the start-up sweep fails the row once it is back.
+ healthLost jobHealth = "lost"
+)
+
+// uploadHealth tells running from quiet from lost for an upload being transcribed (the
+// page calls it for no other state: a queued or finished job has no heartbeat to judge). The
+// two timestamps mean different things (ADR-035): alive_at is the heartbeat, which says
+// the service is still reporting; progress_at moves only when work really moved. A
+// single blocking engine call has no sub-progress, so only a counted phase can be quiet.
+// silent is how long since the signal that decided it.
+func uploadHealth(u domain.UploadedTranscript, now time.Time) (health jobHealth, silent time.Duration) {
+ last := u.AliveAt
+ if u.ProgressAt.After(last) {
+ last = u.ProgressAt
+ }
+ if last.IsZero() {
+ return healthRunning, 0
+ }
+ if d := now.Sub(last); d > serviceSilentAfter {
+ return healthLost, d
+ }
+ for _, s := range u.Progress {
+ if s.State == domain.StepRunning && s.Total > 0 && !u.ProgressAt.IsZero() {
+ if d := now.Sub(u.ProgressAt); d > countedQuietAfter {
+ return healthQuiet, d
+ }
+ }
+ }
+ return healthRunning, 0
+}
+
+// stepDuration renders how long a phase took or has run: "<1s", "42s", "3m 12s", "1h 02m".
+func stepDuration(d time.Duration) string {
+ switch {
+ case d < time.Second:
+ return "<1s"
+ case d < time.Minute:
+ return fmt.Sprintf("%ds", int(d.Seconds()))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
+ default:
+ return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60)
+ }
+}
+
+// stepTime is the time shown beside a phase: how long it has run, or how long it took.
+func stepTime(s domain.ProgressStep, now time.Time) string {
+ switch s.State {
+ case domain.StepRunning:
+ return stepDuration(now.Sub(s.StartedAt))
+ case domain.StepDone, domain.StepFailed:
+ return stepDuration(s.FinishedAt.Sub(s.StartedAt))
+ }
+ return ""
+}
+
+// stepMark is the glyph at the head of a phase line.
+func stepMark(s domain.ProgressStep) string {
+ switch s.State {
+ case domain.StepDone:
+ return "✓"
+ case domain.StepFailed:
+ return "✗"
+ case domain.StepSkipped:
+ return "–"
+ }
+ return "·"
+}
+
+// minutesAgo is a whole number of minutes for the health notes: "6 min", or "under a
+// minute" when it is less.
+func minutesAgo(d time.Duration) string {
+ if m := int(d.Minutes()); m >= 1 {
+ return fmt.Sprintf("%d min", m)
+ }
+ return "under a minute"
+}
--
2.54.0
From a7668b670ee0e9dc04383c1effa431aa5ceb3679 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:00:23 +0200
Subject: [PATCH 152/299] feat(web): the upload page shows the job's phases, a
bar only where the count is real, and says when the service is lost (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.templ | 77 +++++-
internal/web/uploads_templ.go | 456 ++++++++++++++++++++++++++++------
2 files changed, 457 insertions(+), 76 deletions(-)
diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ
index 81ec0f7..fdca943 100644
--- a/internal/web/uploads.templ
+++ b/internal/web/uploads.templ
@@ -45,11 +45,11 @@ templ UploadsPage(v uploadsView) {
}
// UploadPage is one upload's page: its title and the polling status region.
-templ UploadPage(u domain.UploadedTranscript, chatEnabled bool) {
+templ UploadPage(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
@Layout("Tapir — " + u.Title) {
{ u.Title }
- @uploadStatus(u, chatEnabled)
+ @uploadStatus(u, chatEnabled, now)
All recordings
}
@@ -57,7 +57,7 @@ templ UploadPage(u domain.UploadedTranscript, chatEnabled bool) {
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
// page re-asks; done and failed render without it, so nothing polls forever.
-templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
+templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
switch u.Status {
case domain.UploadDone:
@@ -67,6 +67,12 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
of audio by
{ u.Endpoint } .
@reconciliationNote(u.Reconciliation)
+ if len(u.Progress) > 0 {
+
+ What was done
+ @progressSteps(u.Progress, now, false)
+
+ }
if chatEnabled {
Ask about this recording
}
@@ -79,6 +85,12 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
@videoNotice(u.HadVideo)
{ uploadFailureMessage(u.ErrorKind) }
+ if len(u.Progress) > 0 {
+
+ Where it stopped
+ @progressSteps(u.Progress, now, false)
+
+ }
default:
@videoNotice(u.HadVideo)
if u.Status == domain.UploadTranscribing {
-
Transcribing… a long recording takes a few minutes.
+ {{ health, silent := uploadHealth(u, now) }}
+ if len(u.Progress) > 0 {
+ @progressSteps(u.Progress, now, health != healthLost)
+ @healthNote(health, silent)
+ } else {
+
Transcribing… a long recording takes a few minutes.
+ }
} else {
Queued — waiting for its turn to be transcribed.
}
@@ -170,3 +188,54 @@ templ reconciliationNote(r *domain.Reconciliation) {
}
}
}
+
+// progressSteps is the job's phases as a log that fills in as it runs: finished ones
+// collapse to a line with how long they took, the running one has a spinner and its
+// elapsed time, and what has not started is dimmed. live is false when the spinner would
+// be a lie (the service is lost, or the job is over), so the running phase shows a
+// warning mark instead. Only a counted phase gets a bar (ADR-035).
+templ progressSteps(steps []domain.ProgressStep, now time.Time, live bool) {
+
+ for _, s := range steps {
+
+ if s.State == domain.StepRunning && live {
+
+ } else if s.State == domain.StepRunning {
+ !
+ } else {
+ { stepMark(s) }
+ }
+ { s.Label }
+ if t := stepTime(s, now); t != "" {
+ { t }
+ }
+ if s.State == domain.StepRunning && s.Total > 0 {
+
+ { strconv.Itoa(s.Done) } of { strconv.Itoa(s.Total) }
+ }
+
+ }
+
+}
+
+// healthNote says so when the job is not simply running: the service stopped reporting,
+// or a counted phase has gone quiet. Running says nothing: the spinner does.
+templ healthNote(h jobHealth, silent time.Duration) {
+ switch h {
+ case healthLost:
+
+ The service stopped reporting { minutesAgo(silent) } ago. It may have restarted or crashed.
+ This page keeps checking; if nothing changes, upload the recording again.
+
+ case healthQuiet:
+
+ Nothing new for { minutesAgo(silent) }: the model may be slow. The service is still running,
+ and the job gives up on its own if it takes too long.
+
+ }
+}
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go
index 801ec56..c49ef2a 100644
--- a/internal/web/uploads_templ.go
+++ b/internal/web/uploads_templ.go
@@ -176,7 +176,7 @@ func UploadsPage(v uploadsView) templ.Component {
}
// UploadPage is one upload's page: its title and the polling status region.
-func UploadPage(u domain.UploadedTranscript, chatEnabled bool) templ.Component {
+func UploadPage(u domain.UploadedTranscript, chatEnabled bool, now time.Time) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -226,7 +226,7 @@ func UploadPage(u domain.UploadedTranscript, chatEnabled bool) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = uploadStatus(u, chatEnabled).Render(ctx, templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = uploadStatus(u, chatEnabled, now).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -246,7 +246,7 @@ func UploadPage(u domain.UploadedTranscript, chatEnabled bool) templ.Component {
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
// page re-asks; done and failed render without it, so nothing polls forever.
-func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component {
+func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -311,44 +311,58 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+ if len(u.Progress) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
What was done ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = progressSteps(u.Progress, now, false).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
if chatEnabled {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
Ask about this recording
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">Ask about this recording")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
Transcript ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Transcript ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 75, Col: 40}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 81, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.UploadFailed:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -356,51 +370,69 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 81, Col: 42}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 87, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(u.Progress) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
Where it stopped ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = progressSteps(u.Progress, now, false).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -409,17 +441,33 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
return templ_7745c5c3_Err
}
if u.Status == domain.UploadTranscribing {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
Transcribing… a long recording takes a few minutes.
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
+ health, silent := uploadHealth(u, now)
+ if len(u.Progress) > 0 {
+ templ_7745c5c3_Err = progressSteps(u.Progress, now, health != healthLost).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = healthNote(health, silent).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
Transcribing… a long recording takes a few minutes.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
Queued — waiting for its turn to be transcribed.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
Queued — waiting for its turn to be transcribed.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -463,20 +511,20 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 106, Col: 16}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 124, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " Ask about this recording Answers come only from this recording's transcript, which is private to you.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " Ask about this recording Answers come only from this recording's transcript, which is private to you.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -484,20 +532,20 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Back to the transcript
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">Back to the transcript
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -537,7 +585,7 @@ func videoNotice(hadVideo bool) templ.Component {
}
ctx = templ.ClearChildren(ctx)
if hadVideo {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -574,262 +622,262 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if r != nil {
switch r.Status {
case domain.ReconcileDone:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Reconciled from two transcripts (")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
Reconciled from two transcripts (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 139, Col: 47}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 157, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " and ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " and ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.Alt)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 139, Col: 61}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 157, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "): ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "): ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Disputes))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 140, Col: 32}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 158, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " differences, ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " differences, ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.AutoResolved))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 141, Col: 36}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 159, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " settled automatically, ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " settled automatically, ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Adjudicated))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 142, Col: 35}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 160, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " decided by the model (")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " decided by the model (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reconciler)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 142, Col: 74}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 160, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "), ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "), ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Unsure))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 143, Col: 30}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 161, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " uncertain.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " uncertain.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(r.Contested) > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
Spots to check against the audio ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "Spots to check against the audio ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, c := range shownSpots(r) {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(minuteMark(c.Seconds))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 33}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ": ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ": ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 45}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " / ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " / ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 151, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " — ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " — ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.Unsure {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "uncertain kept ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "uncertain kept ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 153, Col: 70}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 171, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "changed used ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "changed used ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 155, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 173, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if moreSpots(r) > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "and ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
and ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r)))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 161, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 179, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " more not listed
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " more not listed")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.ReconcileSingle:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Only one engine produced this transcript (")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
Only one engine produced this transcript (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 167, Col: 88}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 185, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "), so there was no second opinion to reconcile it with.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "), so there was no second opinion to reconcile it with.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.ReconcileSkipped:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "Two transcripts were made but could not be reconciled (")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
Two transcripts were made but could not be reconciled (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 101}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 187, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "); the text shown is ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "); the text shown is ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 132}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 187, Col: 132}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "'s, unreconciled.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "'s, unreconciled.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -839,4 +887,268 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
})
}
+// progressSteps is the job's phases as a log that fills in as it runs: finished ones
+// collapse to a line with how long they took, the running one has a spinner and its
+// elapsed time, and what has not started is dimmed. live is false when the spinner would
+// be a lie (the service is lost, or the job is over), so the running phase shows a
+// warning mark instead. Only a counted phase gets a bar (ADR-035).
+func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var42 == nil {
+ templ_7745c5c3_Var42 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, s := range steps {
+ templ_7745c5c3_Var43 := []any{"step step-" + string(s.State)}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var43...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if s.State == domain.StepRunning && live {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if s.State == domain.StepRunning {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "! ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var45 string
+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(stepMark(s))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 211, Col: 61}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var46 string
+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 213, Col: 38}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if t := stepTime(s, now); t != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var47 string
+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(t)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 215, Col: 32}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if s.State == domain.StepRunning && s.Total > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var50 string
+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Done))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 219, Col: 52}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " of ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var51 string
+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Total))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 219, Col: 81}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// healthNote says so when the job is not simply running: the service stopped reporting,
+// or a counted phase has gone quiet. Running says nothing: the spinner does.
+func healthNote(h jobHealth, silent time.Duration) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var52 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var52 == nil {
+ templ_7745c5c3_Var52 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ switch h {
+ case healthLost:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "The service stopped reporting ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var53 string
+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 232, Col: 54}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case healthQuiet:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "Nothing new for ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var54 string
+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 237, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
var _ = templruntime.GeneratedTemplate
--
2.54.0
From d62331b5f935491c9924ebd02a7ace0d89c09a31 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:00:23 +0200
Subject: [PATCH 153/299] test(web): the quiet note is capitalised as the page
writes it
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_progress_handler_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/web/uploads_progress_handler_test.go b/internal/web/uploads_progress_handler_test.go
index fcce617..aab67e2 100644
--- a/internal/web/uploads_progress_handler_test.go
+++ b/internal/web/uploads_progress_handler_test.go
@@ -159,7 +159,7 @@ func TestUploadProgress_ACountedPhaseThatStopsAdvancingGoesQuietNotLost(t *testi
b := statusBody(t, app, id)
assert.Contains(t, b, "step-health-quiet")
- assert.Contains(t, b, "nothing new for 6 min")
+ assert.Contains(t, b, "Nothing new for 6 min")
assert.NotContains(t, b, "stopped reporting", "the service is alive; it is the model that is slow")
}
--
2.54.0
From 6dfd53ac7105e4fa6933efd6ead0192892bbac0a Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:00:49 +0200
Subject: [PATCH 154/299] test(web): the page lists only changed and uncertain
spots, with the words before each, though every judged spot is stored (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_spots_handler_test.go | 72 ++++++++++++++++++++++
1 file changed, 72 insertions(+)
create mode 100644 internal/web/uploads_spots_handler_test.go
diff --git a/internal/web/uploads_spots_handler_test.go b/internal/web/uploads_spots_handler_test.go
new file mode 100644
index 0000000..fd2e44a
--- /dev/null
+++ b/internal/web/uploads_spots_handler_test.go
@@ -0,0 +1,72 @@
+package web_test
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// The stored judged spots include the ones the model kept as the base (ADR-035), for a
+// later evaluation. The page lists only the ones a reader should check: changed and
+// uncertain.
+
+func TestUploadStatus_OnlyChangedAndUncertainSpotsAreListedNotTheKeptOnes(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":3,"adjudicated":3,"changed":1,"unsure":1,
+ "contested":[
+ {"seconds":60,"base":"CHANGEDBASE","alt":"CHANGEDALT","used":"alt","unsure":false},
+ {"seconds":120,"base":"KEPTBASE","alt":"KEPTALT","used":"base","unsure":false},
+ {"seconds":180,"base":"UNSUREBASE","alt":"UNSUREALT","used":"base","unsure":true}]}`)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "CHANGEDBASE")
+ assert.Contains(t, b, "UNSUREALT")
+ assert.NotContains(t, b, "KEPTBASE", "a spot the model confidently kept is not one for the reader to check")
+ assert.NotContains(t, b, "KEPTALT")
+}
+
+func TestUploadStatus_TheMoreCountIgnoresKeptSpotsAndMatchesTheListedKind(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ var spots []string
+ for i := 0; i < 60; i++ {
+ spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"b%d","alt":"a%d","used":"alt","unsure":false}`, i*10, i, i))
+ spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"k%d","alt":"j%d","used":"base","unsure":false}`, i*10+5, i, i))
+ }
+ setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":400,"changed":60,"contested":[`+strings.Join(spots, ",")+`]}`)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "10 more", "60 changed, 50 shown: the 60 kept ones do not count")
+ assert.NotContains(t, b, ">k0<")
+}
+
+func TestUploadStatus_WhenNothingNeedsCheckingThereIsNoSpotsSection(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"adjudicated":1,
+ "contested":[{"seconds":60,"base":"KEPTBASE","alt":"KEPTALT","used":"base","unsure":false}]}`)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ require.Contains(t, b, "Reconciled from two transcripts")
+ assert.NotContains(t, b, "Spots to check", "an empty list would invite checking nothing")
+}
+
+func TestUploadStatus_ASpotShowsTheWordsJustBeforeIt(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
+ setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"changed":1,
+ "contested":[{"seconds":2640,"base":"special","alt":"CAMT","used":"alt","unsure":false,"before":"vi skickar in ett"}]}`)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, "vi skickar in ett", "the words before the spot are how a reader finds it in the audio")
+ assert.Contains(t, b, "special")
+ assert.Contains(t, b, "CAMT")
+}
--
2.54.0
From 8cb0b0a12ddd727f6061e3b1a6923608f06ea726 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:00:56 +0200
Subject: [PATCH 155/299] test(web): the kept-spot assertion looks for what the
page actually renders (red)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_spots_handler_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/web/uploads_spots_handler_test.go b/internal/web/uploads_spots_handler_test.go
index fd2e44a..c758016 100644
--- a/internal/web/uploads_spots_handler_test.go
+++ b/internal/web/uploads_spots_handler_test.go
@@ -43,7 +43,7 @@ func TestUploadStatus_TheMoreCountIgnoresKeptSpotsAndMatchesTheListedKind(t *tes
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "10 more", "60 changed, 50 shown: the 60 kept ones do not count")
- assert.NotContains(t, b, ">k0<")
+ assert.NotContains(t, b, "k0 / j0", "a kept spot is not rendered even when it is among the first stored")
}
func TestUploadStatus_WhenNothingNeedsCheckingThereIsNoSpotsSection(t *testing.T) {
--
2.54.0
From 24d1a99ed0880a8df673d5a23e795cdfba4a0ced Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:01:21 +0200
Subject: [PATCH 156/299] feat(web): list only the spots to check, in order and
capped (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 22 +++++++++++++++-------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index af1a166..24e84bd 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -283,9 +283,9 @@ func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history
}
}
-// maxSpotsShown is how many contested spots the page lists. The stored list holds up to
-// 200 and the counts are exact; a page of two hundred "check this" items is not a page
-// anyone reads, so the rest are counted, not listed.
+// maxSpotsShown is how many spots to check the page lists. The stored list holds every
+// judged spot and the counts are exact; a page of two hundred "check this" items is not a
+// page anyone reads, so the rest are counted, not listed.
const maxSpotsShown = 50
// minuteMark renders where in the recording a spot is, as the nearest minute.
@@ -293,12 +293,20 @@ func minuteMark(seconds float64) string {
return fmt.Sprintf("~%d min", int(math.Round(seconds/60)))
}
-// shownSpots is the part of the contested list the page renders.
+// shownSpots is the part of the stored list the page renders: the spots a reader should
+// check, changed or uncertain, in order, capped. A spot the model confidently kept is
+// stored (ADR-035) but is not one to check, so it is not listed.
func shownSpots(r *domain.Reconciliation) []domain.ContestedSpot {
- if len(r.Contested) > maxSpotsShown {
- return r.Contested[:maxSpotsShown]
+ var out []domain.ContestedSpot
+ for _, c := range r.Contested {
+ if c.Used == "alt" || c.Unsure {
+ out = append(out, c)
+ if len(out) == maxSpotsShown {
+ break
+ }
+ }
}
- return r.Contested
+ return out
}
// moreSpots is how many spots to check are not listed, from the exact counts.
--
2.54.0
From cd7595a04871624dcd8f51f8d44679281f306c2f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:01:21 +0200
Subject: [PATCH 157/299] feat(web): a spot shows the words before it, and an
empty list shows no section (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.templ | 8 +-
internal/web/uploads_templ.go | 317 ++++++++++++++++++----------------
2 files changed, 174 insertions(+), 151 deletions(-)
diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ
index fdca943..8abaebd 100644
--- a/internal/web/uploads.templ
+++ b/internal/web/uploads.templ
@@ -160,13 +160,17 @@ templ reconciliationNote(r *domain.Reconciliation) {
{ strconv.Itoa(r.Adjudicated) } decided by the model ({ r.Reconciler }),
{ strconv.Itoa(r.Unsure) } uncertain.
- if len(r.Contested) > 0 {
+ if len(shownSpots(r)) > 0 {
Spots to check against the audio
for _, c := range shownSpots(r) {
- { minuteMark(c.Seconds) }: { c.Base } / { c.Alt } —
+ { minuteMark(c.Seconds) }:
+ if c.Before != "" {
+ …{ c.Before }
+ }
+ { c.Base } / { c.Alt } —
if c.Unsure {
uncertain kept { c.Used }
} else {
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go
index c49ef2a..262b873 100644
--- a/internal/web/uploads_templ.go
+++ b/internal/web/uploads_templ.go
@@ -717,7 +717,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if len(r.Contested) > 0 {
+ if len(shownSpots(r)) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "Spots to check against the audio ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -740,144 +740,163 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var34 string
- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 45}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " / ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
+ if c.Before != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "…")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var34 string
+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Before)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 171, Col: 44}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
var templ_7745c5c3_Var35 string
- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt)
+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 173, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " — ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " / ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var36 string
+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 173, Col: 30}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " — ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.Unsure {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "uncertain kept ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var36 string
- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 171, Col: 70}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "changed used ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "uncertain kept ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 173, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 175, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "changed used ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var38 string
+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 177, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if moreSpots(r) > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "and ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
and ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var38 string
- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r)))
+ var templ_7745c5c3_Var39 string
+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r)))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 179, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 183, Col: 57}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " more not listed
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " more not listed")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.ReconcileSingle:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "Only one engine produced this transcript (")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var39 string
- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 185, Col: 88}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "), so there was no second opinion to reconcile it with.
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- case domain.ReconcileSkipped:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "Two transcripts were made but could not be reconciled (")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
Only one engine produced this transcript (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 187, Col: 101}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 189, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "); the text shown is ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "), so there was no second opinion to reconcile it with.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case domain.ReconcileSkipped:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Two transcripts were made but could not be reconciled (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 187, Col: 132}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 191, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "'s, unreconciled.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "); the text shown is ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var42 string
+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 191, Col: 132}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "'s, unreconciled.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -908,177 +927,177 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var42 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var42 == nil {
- templ_7745c5c3_Var42 = templ.NopComponent
+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var43 == nil {
+ templ_7745c5c3_Var43 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, s := range steps {
- templ_7745c5c3_Var43 := []any{"step step-" + string(s.State)}
- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var43...)
+ templ_7745c5c3_Var44 := []any{"step step-" + string(s.State)}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if s.State == domain.StepRunning && live {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if s.State == domain.StepRunning {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "! ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "! ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var45 string
- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(stepMark(s))
+ var templ_7745c5c3_Var46 string
+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(stepMark(s))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 211, Col: 61}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 215, Col: 61}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var46 string
- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
+ var templ_7745c5c3_Var47 string
+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 213, Col: 38}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 217, Col: 38}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if t := stepTime(s, now); t != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var47 string
- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(t)
+ var templ_7745c5c3_Var48 string
+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(t)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 215, Col: 32}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 219, Col: 32}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if s.State == domain.StepRunning && s.Total > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" max=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var50 string
- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Done))
+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.Itoa(s.Total))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 219, Col: 52}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 222, Col: 90}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " of ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var51 string
- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Total))
+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Done))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 219, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 223, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " of ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var52 string
+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Total))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 223, Col: 81}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1104,45 +1123,45 @@ func healthNote(h jobHealth, silent time.Duration) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var52 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var52 == nil {
- templ_7745c5c3_Var52 = templ.NopComponent
+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var53 == nil {
+ templ_7745c5c3_Var53 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch h {
case healthLost:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "The service stopped reporting ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var53 string
- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 232, Col: 54}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- case healthQuiet:
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "Nothing new for ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
The service stopped reporting ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 237, Col: 40}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 236, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case healthQuiet:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "Nothing new for ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var55 string
+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 241, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
--
2.54.0
From f74fbbc40eacecefb162fa7abecdabcc7f98a32f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:01:37 +0200
Subject: [PATCH 158/299] test(cmd): the uploads store double has the progress
methods the worker now needs
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_test.go | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/cmd/tapir/uploads_test.go b/cmd/tapir/uploads_test.go
index f91e782..894aaae 100644
--- a/cmd/tapir/uploads_test.go
+++ b/cmd/tapir/uploads_test.go
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -37,6 +38,12 @@ func (f *fakeUploadsStore) CompleteUpload(context.Context, string, string, domai
return nil
}
+func (f *fakeUploadsStore) SaveProgress(context.Context, string, string, []domain.ProgressStep, time.Time) error {
+ return nil
+}
+
+func (f *fakeUploadsStore) Heartbeat(context.Context, string, string) error { return nil }
+
func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) {
f.sweeps++
return 2, f.sweepErr
--
2.54.0
From c06d285a3a33467d72023c492caf1e3fae73d8fd Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:03:32 +0200
Subject: [PATCH 159/299] test(web): the lost job's running phase carries a
warning mark (red; found in the screenshot review)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_progress_handler_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/web/uploads_progress_handler_test.go b/internal/web/uploads_progress_handler_test.go
index aab67e2..a7d9305 100644
--- a/internal/web/uploads_progress_handler_test.go
+++ b/internal/web/uploads_progress_handler_test.go
@@ -145,6 +145,7 @@ func TestUploadProgress_AHeartbeatThatStoppedMeansTheServiceIsLostAndTheJobIsNot
assert.Contains(t, b, "stopped reporting")
assert.Contains(t, b, "3 min", "how long ago it was last heard from")
assert.NotContains(t, b, "spinner", "a lost job must not wear the running spinner")
+ assert.Contains(t, b, "step-mark-warn", "the running phase of a lost job carries a warning mark, in the error colour, not the accent")
assert.Contains(t, b, `hx-trigger="every`, "it keeps asking: a restarted service comes back, or the start-up sweep fails the row")
}
--
2.54.0
From 18d8050a2bfa170181afbf06bdce5eb5f981dd52 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:03:55 +0200
Subject: [PATCH 160/299] fix(web): the progress bar ends at the time column
and the lost mark is error-coloured (screenshot review)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/view.go | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/internal/web/view.go b/internal/web/view.go
index af81ffd..23a6e5b 100644
--- a/internal/web/view.go
+++ b/internal/web/view.go
@@ -779,9 +779,10 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.step-skipped, .step-pending { color: var(--muted); }
.step-skipped { opacity: .7; }
.step-pending { opacity: .75; }
-.step-bar { flex: 1 0 100%; margin: 0 0 0 1.6rem; height: .5rem; accent-color: var(--accent); }
-.step-count { flex: 1 0 100%; margin-left: 1.6rem; font-weight: 400; color: var(--muted); }
-.spinner { display: inline-block; width: .8rem; height: .8rem; box-sizing: border-box; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: tapir-spin 1s linear infinite; }
+.step-bar { flex: none; width: calc(100% - 1.6rem); margin: 0 0 0 1.6rem; height: .5rem; accent-color: var(--accent); }
+.step-count { flex: none; width: calc(100% - 1.6rem); margin-left: 1.6rem; font-weight: 400; color: var(--muted); }
+.step-running .step-mark-warn { color: var(--err-fg); }
+.spinner { display: inline-block; vertical-align: -.1em; width: .8rem; height: .8rem; box-sizing: border-box; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: tapir-spin 1s linear infinite; }
@keyframes tapir-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-color: var(--accent); } }
.step-health { margin: var(--s2) 0; padding: var(--s2) var(--s3); border-radius: var(--radius); font-size: .9rem; border: 1px solid var(--line); }
--
2.54.0
From 105777f1aa7e8e420604f22375dc7767652ba8c2 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:03:55 +0200
Subject: [PATCH 161/299] fix(web): the spinner sits in the same mark column as
the other glyphs; a lost job's running phase gets a warning mark (screenshot
review)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.templ | 4 ++--
internal/web/uploads_templ.go | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ
index 8abaebd..3ea43b9 100644
--- a/internal/web/uploads.templ
+++ b/internal/web/uploads.templ
@@ -208,9 +208,9 @@ templ progressSteps(steps []domain.ProgressStep, now time.Time, live bool) {
}
>
if s.State == domain.StepRunning && live {
-
+
} else if s.State == domain.StepRunning {
- !
+ !
} else {
{ stepMark(s) }
}
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go
index 262b873..9ddc345 100644
--- a/internal/web/uploads_templ.go
+++ b/internal/web/uploads_templ.go
@@ -970,12 +970,12 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
return templ_7745c5c3_Err
}
if s.State == domain.StepRunning && live {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if s.State == domain.StepRunning {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "! ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "! ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
--
2.54.0
From 02a065b826031f99442c2c1f9d4933b80c2834ef Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:03:55 +0200
Subject: [PATCH 162/299] test(web): opt-in renderer of every progress state
for a screenshot review (TAPIR_SHOT_DIR)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_shots_test.go | 77 ++++++++++++++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 internal/web/uploads_shots_test.go
diff --git a/internal/web/uploads_shots_test.go b/internal/web/uploads_shots_test.go
new file mode 100644
index 0000000..2a227d7
--- /dev/null
+++ b/internal/web/uploads_shots_test.go
@@ -0,0 +1,77 @@
+package web_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// TestRenderUploadProgressStatesForReview writes the upload page in each progress state
+// to $TAPIR_SHOT_DIR as standalone HTML (light and dark), for a screenshot review of the
+// visual claims tests cannot assert: that it looks like tapir and reads like a log of
+// phases (ADR-035, #42). Opt-in: it does nothing unless the variable is set. The text is
+// synthetic. Screenshot with any browser, for example:
+//
+// TAPIR_SHOT_DIR=/tmp/shots go test -run TestRenderUploadProgressStatesForReview ./internal/web
+// chromium --headless --screenshot=/tmp/shots/running.png --window-size=900,900 /tmp/shots/running-light.html
+func TestRenderUploadProgressStatesForReview(t *testing.T) {
+ dir := os.Getenv("TAPIR_SHOT_DIR")
+ if dir == "" {
+ t.Skip("set TAPIR_SHOT_DIR to render the progress states for a screenshot review")
+ }
+ require.NoError(t, os.MkdirAll(dir, 0o750))
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ fixedClock(app)
+
+ decide := twoEngineRun()
+ decide[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 9*time.Minute, 4*time.Minute, 0, 0)
+ decide[4] = step("compare", "Comparing the two transcripts", domain.StepDone, 5*time.Minute, time.Second, 0, 0)
+ decide[5] = step("decide", "Deciding the differences", domain.StepRunning, 4*time.Minute+30*time.Second, 0, 142, 282)
+
+ failed := twoEngineRun()
+ failed[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepFailed, 3*time.Minute, time.Minute, 0, 0)
+
+ done := []domain.ProgressStep{
+ step("received", "Received", domain.StepDone, 22*time.Minute, 0, 0, 0),
+ step("audio", "Preparing the audio", domain.StepDone, 22*time.Minute, 41*time.Second, 0, 0),
+ step("engine_a", "Transcribing with iguana/pianissimo", domain.StepDone, 21*time.Minute, 2*time.Minute+10*time.Second, 0, 0),
+ step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 19*time.Minute, 4*time.Minute+5*time.Second, 0, 0),
+ step("compare", "Comparing the two transcripts", domain.StepDone, 15*time.Minute, 2*time.Second, 0, 0),
+ step("decide", "Deciding the differences", domain.StepDone, 15*time.Minute, 14*time.Minute, 282, 282),
+ step("save", "Saving the transcript", domain.StepDone, time.Minute, time.Second, 0, 0),
+ }
+
+ cases := []struct {
+ name, status, content string
+ steps []domain.ProgressStep
+ progressAgo, aliveAgo time.Duration
+ }{
+ {"running", "transcribing", "", twoEngineRun(), 10 * time.Second, 4 * time.Second},
+ {"deciding", "transcribing", "", decide, 3 * time.Second, 3 * time.Second},
+ {"quiet", "transcribing", "", decide, 6 * time.Minute, 4 * time.Second},
+ {"lost", "transcribing", "", twoEngineRun(), 3 * time.Minute, 3 * time.Minute},
+ {"failed", "failed", "transcription_failed", failed, 2 * time.Minute, 2 * time.Minute},
+ {"done", "done", "Synthetic transcript text for the review.", done, time.Minute, time.Minute},
+ {"queued", "queued", "", nil, 0, 0},
+ }
+ for _, c := range cases {
+ id := seedUpload(t, userID, "Project notes (synthetic)", c.status, c.content)
+ if c.steps != nil {
+ seedProgress(t, id, c.steps, progressNow.Add(-c.progressAgo), progressNow.Add(-c.aliveAgo))
+ }
+ page := body(t, do(t, app, get("/uploads/"+id)))
+ for _, theme := range []string{"light", "dark"} {
+ html := page
+ if theme == "dark" {
+ html = strings.Replace(html, "
Date: Fri, 2 Oct 2026 09:04:19 +0200
Subject: [PATCH 163/299] docs(use-cases): scenarios for progress, the log and
the judged spots (ADR-035) -- the coverage gate is red until they are mapped
Co-Authored-By: Claude Sonnet 5.5
---
docs/use-cases/audio_upload.feature | 58 +++++++++++++++++++++++++++++
1 file changed, 58 insertions(+)
diff --git a/docs/use-cases/audio_upload.feature b/docs/use-cases/audio_upload.feature
index 87c31cc..16fcc83 100644
--- a/docs/use-cases/audio_upload.feature
+++ b/docs/use-cases/audio_upload.feature
@@ -156,3 +156,61 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
Scenario: A single-engine transcript claims no second opinion
When only one engine was configured
Then the page does not mention reconciliation
+
+ # ADR-035: progress, the log, and the judged spots
+
+ Scenario: I can see which phase my upload is in
+ Given a recording is being transcribed
+ When I open its page
+ Then it lists every phase in order, the finished ones with how long each took
+ And the running phase is marked, with how long it has run
+ And the page updates in place every two seconds until the job ends
+
+ Scenario: A bar appears only where the count is real
+ When the deciding phase has decided 142 of 282 differences
+ Then the page shows a bar with "142 of 282"
+ And an engine call, which is one request with nothing to count, never gets a bar or a percentage
+
+ Scenario: A slow engine call is not mistaken for a dead service
+ When an engine call has run for 25 minutes and the service is still reporting
+ Then the page shows the elapsed time and says nothing is wrong
+
+ Scenario: A service that stopped reporting is called lost
+ When nothing has been heard from the service for longer than the heartbeat allows
+ Then the page says the service stopped reporting and may have restarted or crashed
+ And the running phase carries a warning mark and no spinner
+ And the page keeps checking
+
+ Scenario: A model that stops advancing is called quiet
+ When the deciding phase has not advanced for five minutes and the service is still reporting
+ Then the page says nothing new has happened for that long and that the service is still running
+ And it does not say the service is lost
+
+ Scenario: A failed job shows where it stopped
+ When a job fails
+ Then the failed phase is marked and what never started stays pending
+ And the page stops checking
+
+ Scenario: A finished job keeps its record of what was done
+ When a job is done
+ Then the page keeps a collapsed list of the phases with how long each took
+
+ Scenario: The heartbeat runs on its own while an engine call is stuck
+ When an engine call does not return
+ Then the row is still stamped alive every few seconds
+ And no heartbeat outlives its job
+
+ Scenario: The log names every phase and never the transcript
+ When a job runs
+ Then the log has a line per phase in the order it ran, each naming the upload
+ And no line contains transcript text or the spool path
+
+ Scenario: Every judged spot is stored but only the ones to check are listed
+ When the model has changed, kept and been unsure about spots
+ Then every one is stored with its verdict and the words before it
+ And the page lists only the changed and uncertain ones, with the words before each
+
+ Scenario: The saved segments carry the merged words
+ When the model changes a word
+ Then the segment containing it carries the reconciled word
+ And the segment's timing is unchanged
--
2.54.0
From 6722433eb08dadb21f2b586343c8dd0b39bc6a52 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:04:23 +0200
Subject: [PATCH 164/299] test(acceptance): map the progress, log and
judged-spot scenarios to their covering tests
Co-Authored-By: Claude Sonnet 5.5
---
test/acceptance/scenario_coverage_test.go | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go
index 651eacd..682fa67 100644
--- a/test/acceptance/scenario_coverage_test.go
+++ b/test/acceptance/scenario_coverage_test.go
@@ -134,6 +134,19 @@ var scenarioCoverage = map[string]string{
"A cancelled job never reaches the cloud": "TestEnsemble_ACancelledJobNeverReachesTheCloud",
"A single-engine transcript claims no second opinion": "TestUploadStatus_ASingleEngineTranscriptDoesNotClaimASecondOpinion",
+ // audio_upload.feature, ADR-035
+ "I can see which phase my upload is in": "TestUploadProgress_ARunningJobShowsEveryPhaseInOrderWithTheRunningOneMarked",
+ "A bar appears only where the count is real": "TestUploadProgress_OnlyACountedPhaseGetsABarAndItShowsRealCounts",
+ "A slow engine call is not mistaken for a dead service": "TestUploadProgress_ALongEngineCallWithAFreshHeartbeatIsStillJustRunning",
+ "A service that stopped reporting is called lost": "TestUploadProgress_AHeartbeatThatStoppedMeansTheServiceIsLostAndTheJobIsNotCalledRunning",
+ "A model that stops advancing is called quiet": "TestUploadProgress_ACountedPhaseThatStopsAdvancingGoesQuietNotLost",
+ "A failed job shows where it stopped": "TestUploadProgress_AFailedJobShowsWhichPhaseFailedAndWhatNeverStarted",
+ "A finished job keeps its record of what was done": "TestUploadProgress_ADoneJobKeepsItsRecordOfWhatWasDoneAndHowLongEachTook",
+ "The heartbeat runs on its own while an engine call is stuck": "TestWorkerProgress_TheHeartbeatBeatsWhileAnEngineCallIsStuckAndStopsWhenTheJobEnds",
+ "The log names every phase and never the transcript": "TestWorkerProgress_TheLogNamesEveryPhaseInOrderAndNeverTheTranscriptOrThePath",
+ "Every judged spot is stored but only the ones to check are listed": "TestUploadStatus_OnlyChangedAndUncertainSpotsAreListedNotTheKeptOnes",
+ "The saved segments carry the merged words": "TestReconcileSegments_AChangedWordReplacesTheBaseWordInItsOwnSegmentOnly",
+
// summarize_new_video.feature
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",
"A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped",
--
2.54.0
From 0763e6238be114e7d53c14347e02659e79b01bc6 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:04:32 +0200
Subject: [PATCH 165/299] docs(data-model): progress, progress_at, alive_at,
and every judged spot stored (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
docs/data-model.md | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/docs/data-model.md b/docs/data-model.md
index f898de9..a644782 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -153,7 +153,10 @@ erDiagram
float audio_seconds
text error_kind "typed: interrupted | transcription_failed — never a provider string"
bool had_video "the file carried a video track; only its audio was used (migration 018)"
- jsonb reconciliation "NULL for a single engine; else status, exact counts and a bounded list of contested spots (migration 019, ADR-034)"
+ jsonb reconciliation "NULL for a single engine; else status, exact counts and a bounded list of every judged spot (migration 019, ADR-034; all spots stored, ADR-035)"
+ jsonb progress "the job's ordered phases: key, label, state, times, done/total; NULL until the job starts (migration 020, ADR-035)"
+ timestamptz progress_at "when a phase last really changed or advanced (migration 020)"
+ timestamptz alive_at "heartbeat from the running job, every ~10 s; not progress (migration 020)"
timestamptz created_at
timestamptz finished_at
}
@@ -225,7 +228,7 @@ mechanism.
grounds that it holds only public caption content, and a recorded meeting is the opposite.
`user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing →
done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK
- constraints). `reconciliation` (migration 019, ADR-034) is NULL when one engine produced the text, so the page can never claim a second opinion that was not there; otherwise it holds the status (`reconciled` / `single_engine` / `skipped`), exact counts (differences, settled automatically, decided by the model, changed, uncertain, model errors) and up to 200 contested spots (each a position in seconds, both readings and which was used). The spots hold fragments of the private transcript, which is why they live in this RLS-scoped row. `had_video` (migration 018) records that the file carried a video track — a Teams meeting with the camera off is still a video file — so the page can say only its audio was used and the video was discarded. `endpoint` records which backend served it — `berget/...` means the audio left the
+ constraints). `reconciliation` (migration 019, ADR-034) is NULL when one engine produced the text, so the page can never claim a second opinion that was not there; otherwise it holds the status (`reconciled` / `single_engine` / `skipped`), exact counts (differences, settled automatically, decided by the model, changed, uncertain, model errors) and up to 2,000 judged spots (ADR-035: every one the model decided, kept or changed or unsure, each a position in seconds, both readings, which was used, and the few words before it; the page lists only the changed and uncertain ones). `progress`, `progress_at` and `alive_at` (migration 020, ADR-035) let the page show the job's phases and tell a running job from a quiet one from a lost service: `alive_at` is a heartbeat from a goroutine independent of any engine call, `progress_at` moves only when work really moved; the saved `segments` carry the reconciled words with the base engine's timing. The spots hold fragments of the private transcript, which is why they live in this RLS-scoped row. `had_video` (migration 018) records that the file carried a video track — a Teams meeting with the camera off is still a video file — so the page can say only its audio was used and the video was discarded. `endpoint` records which backend served it — `berget/...` means the audio left the
homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90
minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion.
- **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
--
2.54.0
From c977618979e1f4007f035eac494af23a50fc0e3d Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:04:32 +0200
Subject: [PATCH 166/299] docs(claude): the progress tracker, the two
timestamps, and the log's privacy rule (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
CLAUDE.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2860dea..0ab0ffc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -121,6 +121,14 @@ The repo is **green and shipping** — last tag `v0.15.0`. `task check` passes (
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.**
+ `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
--
2.54.0
From 7029dd1d7f0bfc25c084819e6e70b69eefd1fef2 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 09:05:36 +0200
Subject: [PATCH 167/299] test(e2e): a finished job's phases are all closed and
it was reporting while it ran (ADR-035)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/e2e_upload_test.go | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/cmd/tapir/e2e_upload_test.go b/cmd/tapir/e2e_upload_test.go
index e4c2803..683e483 100644
--- a/cmd/tapir/e2e_upload_test.go
+++ b/cmd/tapir/e2e_upload_test.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "encoding/json"
"fmt"
"io"
"mime/multipart"
@@ -21,6 +22,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/config"
+ "git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web"
)
@@ -156,6 +158,23 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
assert.Equal(t, "done", status)
assert.NotEmpty(t, endpoint)
assert.NotEmpty(t, content)
+
+ // ADR-035: a finished job's phases are all closed, and it was reporting while it ran.
+ var progJSON string
+ var progressAt, aliveAt *time.Time
+ require.NoError(t, raw.QueryRow(ctx,
+ `SELECT COALESCE(progress::text, '[]'), progress_at, alive_at FROM uploaded_transcripts WHERE user_id = $1`, uid).
+ Scan(&progJSON, &progressAt, &aliveAt))
+ var steps []domain.ProgressStep
+ require.NoError(t, json.Unmarshal([]byte(progJSON), &steps))
+ require.NotEmpty(t, steps, "the job recorded no phases")
+ for _, st := range steps {
+ assert.Contains(t, []domain.StepState{domain.StepDone, domain.StepSkipped}, st.State,
+ "phase %q was left %q after the job finished", st.Key, st.State)
+ }
+ assert.Equal(t, "save", steps[len(steps)-1].Key)
+ assert.NotNil(t, progressAt, "progress_at must have been stamped")
+ t.Logf("phases: %d, heartbeat stamped: %v", len(steps), aliveAt != nil)
entries, err := os.ReadDir(cfg.UploadTmpDir)
require.NoError(t, err)
assert.Empty(t, entries, "the audio must be deleted once transcribed")
--
2.54.0
From 896ce6a9522b6ec2b648ac80db3764e06a689eeb Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:37:49 +0200
Subject: [PATCH 168/299] test(config): a strict shape for a local model alias,
and the upload chat model must be local (red; adversarial review F1, F8)
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads_local_test.go | 74 +++++++++++++++++++++++++++
1 file changed, 74 insertions(+)
create mode 100644 internal/config/uploads_local_test.go
diff --git a/internal/config/uploads_local_test.go b/internal/config/uploads_local_test.go
new file mode 100644
index 0000000..f7adccd
--- /dev/null
+++ b/internal/config/uploads_local_test.go
@@ -0,0 +1,74 @@
+package config
+
+import "testing"
+
+// What counts as a LOCAL model alias, and where that is demanded (ADR-034, ADR-035 review).
+// A private transcript may be sent to a local model and to nothing else; the check is a
+// guard against a misconfiguration or a crafted alias, so it must be strict about shape,
+// not just about a prefix.
+
+func TestIsLocalModel_AcceptsRealLocalAliasesAndNothingShapedLikeAnEscape(t *testing.T) {
+ tests := []struct {
+ alias string
+ want bool
+ }{
+ {"koala/phi4-mini", true},
+ {"iguana/gemma4-26b", true},
+ {"iguana/gemma4-31b-think", true},
+ {"koala/qwen36-35b-a3b", true},
+ {"iguana/kb-whisper", true},
+ {"iguana/hf.co-model_v1:q4", true},
+ {"", false},
+ {"koala/", false},
+ {"iguana/", false},
+ {"gemma4-26b", false},
+ {"berget/mistral-small", false},
+ {"berget/koala/x", false},
+ {"xkoala/x", false},
+ {"Koala/x", false},
+ {" koala/x", false},
+ {"koala/x ", false},
+ {"koala/x y", false},
+ {"koala/x\n", false},
+ {"koala/../berget/kb", false},
+ {"koala//berget/mistral-small", false},
+ {"koala/x/y", false},
+ {"koala/x?host=berget", false},
+ {"koala/x#frag", false},
+ {"koala/x@evil", false},
+ {"koalа/x", false}, // Cyrillic а
+ }
+ for _, tt := range tests {
+ t.Run(tt.alias, func(t *testing.T) {
+ if got := IsLocalModel(tt.alias); got != tt.want {
+ t.Errorf("IsLocalModel(%q) = %v, want %v", tt.alias, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestLoad_UploadChatModelMustBeLocalBecauseTheWholeTranscriptIsSentToIt(t *testing.T) {
+ for _, alias := range []string{"berget/mistral-small", "gemini/gemini-2.5-pro", "gemma4-26b", "koala/../berget/kb"} {
+ t.Run(alias, func(t *testing.T) {
+ clearUploadEnv(t)
+ setEnv(t, map[string]string{"TAPIR_UPLOAD_CHAT_MODEL": alias})
+
+ if _, err := Load(); err == nil {
+ t.Errorf("Load accepted TAPIR_UPLOAD_CHAT_MODEL=%q: a private transcript of up to 90,000 characters would be sent to it", alias)
+ }
+ })
+ }
+}
+
+func TestLoad_ALocalUploadChatModelIsAccepted(t *testing.T) {
+ clearUploadEnv(t)
+ setEnv(t, map[string]string{"TAPIR_UPLOAD_CHAT_MODEL": "iguana/gemma4-31b"})
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if cfg.UploadChatModel != "iguana/gemma4-31b" {
+ t.Errorf("UploadChatModel = %q", cfg.UploadChatModel)
+ }
+}
--
2.54.0
From 4b16e33715f601e3dbb4959edd93a1a9ed1d412d Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:37:49 +0200
Subject: [PATCH 169/299] test(cmd): the upload chat switcher never offers a
cloud model (red; adversarial review F1)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_chat_models_test.go | 46 +++++++++++++++++++++++++++
1 file changed, 46 insertions(+)
create mode 100644 cmd/tapir/uploads_chat_models_test.go
diff --git a/cmd/tapir/uploads_chat_models_test.go b/cmd/tapir/uploads_chat_models_test.go
new file mode 100644
index 0000000..eb64f93
--- /dev/null
+++ b/cmd/tapir/uploads_chat_models_test.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "reflect"
+ "testing"
+
+ "git.d-ma.be/mathias/tapir/internal/config"
+)
+
+// The chat over an uploaded recording sends the whole private transcript to the model
+// the user picks, so the switcher for it may offer local models only (ADR-035 review).
+
+func TestUploadChatModels_NeverOffersACloudModelButKeepsTheLocalOrder(t *testing.T) {
+ var cfg config.Config
+ cfg.SummarizerModel = "koala/phi4-mini"
+ cfg.FallbackModel = "iguana/gemma4-26b"
+ cfg.CloudFallbackModel = "berget/mistral-small"
+
+ got := uploadChatModels(cfg)
+
+ want := []string{"koala/phi4-mini", "iguana/gemma4-26b"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("uploadChatModels = %v, want %v: the cloud model that the video chat offers must not be offered for a private recording", got, want)
+ }
+}
+
+func TestUploadChatModels_ACloudPrimaryIsDroppedToo(t *testing.T) {
+ var cfg config.Config
+ cfg.SummarizerModel = "berget/mistral-small"
+ cfg.FallbackModel = "iguana/gemma4-26b"
+
+ got := uploadChatModels(cfg)
+
+ if !reflect.DeepEqual(got, []string{"iguana/gemma4-26b"}) {
+ t.Errorf("uploadChatModels = %v", got)
+ }
+}
+
+func TestUploadChatModels_NoLocalModelMeansNoneOfferedNotACloudDefault(t *testing.T) {
+ var cfg config.Config
+ cfg.SummarizerModel = "berget/mistral-small"
+
+ if got := uploadChatModels(cfg); len(got) != 0 {
+ t.Errorf("uploadChatModels = %v, want none", got)
+ }
+}
--
2.54.0
From 7fe746173d538d22f809b07a884d0ea8067cd668 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:37:49 +0200
Subject: [PATCH 170/299] test(web): the upload chat offers and accepts local
models only, and fails closed (red; adversarial review F1)
Co-Authored-By: Claude Sonnet 5.5
---
.../web/uploads_chat_local_handler_test.go | 73 +++++++++++++++++++
1 file changed, 73 insertions(+)
create mode 100644 internal/web/uploads_chat_local_handler_test.go
diff --git a/internal/web/uploads_chat_local_handler_test.go b/internal/web/uploads_chat_local_handler_test.go
new file mode 100644
index 0000000..d7b547a
--- /dev/null
+++ b/internal/web/uploads_chat_local_handler_test.go
@@ -0,0 +1,73 @@
+package web_test
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/chat"
+ "git.d-ma.be/mathias/tapir/internal/web"
+)
+
+// The whole private transcript goes to the model chosen in the form, so the upload chat
+// offers and accepts local models only, even when the shared chat service knows a cloud
+// one (ADR-035 review). The cloud model here is only a name; no network is involved.
+
+func newUploadChatAppWithCloud(t *testing.T) (*web.App, *fakeChatter) {
+ t.Helper()
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ fc := &fakeChatter{
+ models: []string{"koala/phi4-mini", "iguana/gemma4-26b", "berget/mistral-small"},
+ reply: chat.Reply{Answer: "Anders owns the timber order."},
+ }
+ app.Chat = fc
+ app.UploadChatMaxChars = 120000
+ app.UploadChatModels = []string{"koala/phi4-mini", "iguana/gemma4-26b"}
+ return app, fc
+}
+
+func TestUploadChatPage_DoesNotOfferACloudModelEvenThoughTheServiceKnowsOne(t *testing.T) {
+ app, _ := newUploadChatAppWithCloud(t)
+ id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/chat")))
+
+ assert.Contains(t, b, "iguana/gemma4-26b")
+ assert.NotContains(t, b, "berget/mistral-small", "the switcher must not list a model the transcript must not be sent to")
+}
+
+func TestUploadChatPost_AForgedCloudModelIsReplacedByALocalOneBeforeTheTranscriptIsSent(t *testing.T) {
+ app, fc := newUploadChatAppWithCloud(t)
+ id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
+
+ rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"Who owns timber?"}, "model": {"berget/mistral-small"}}))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, 1, fc.callCount)
+ assert.NotEqual(t, "berget/mistral-small", fc.gotReq.Model, "a cloud model reached the chat service with the private transcript")
+ assert.Contains(t, []string{"koala/phi4-mini", "iguana/gemma4-26b"}, fc.gotReq.Model)
+}
+
+func TestUploadChatPost_ALocalModelThatIsOfferedIsHonoured(t *testing.T) {
+ app, fc := newUploadChatAppWithCloud(t)
+ id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
+
+ rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"koala/phi4-mini"}}))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ assert.Equal(t, "koala/phi4-mini", fc.gotReq.Model)
+}
+
+func TestUploadChat_WithNoLocalModelConfiguredNothingIsSentAnywhere(t *testing.T) {
+ app, fc := newUploadChatAppWithCloud(t)
+ app.UploadChatModels = nil
+ id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
+
+ rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"berget/mistral-small"}}))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ assert.Zero(t, fc.callCount, "failing closed: no local model offered means no model is called")
+}
--
2.54.0
From a9a2a9270fac114b78b8582e3a439df40928c59f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:38:30 +0200
Subject: [PATCH 171/299] fix(config): a strict shape for a local model alias;
the upload chat model must be local (adversarial review F1, F8)
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads.go | 28 ++++++++++++++++------------
1 file changed, 16 insertions(+), 12 deletions(-)
diff --git a/internal/config/uploads.go b/internal/config/uploads.go
index f428eba..442ba26 100644
--- a/internal/config/uploads.go
+++ b/internal/config/uploads.go
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
+ "regexp"
"strconv"
"strings"
"time"
@@ -116,6 +117,9 @@ func loadUploadConfig() (UploadConfig, error) {
if err := u.validateReconcile(); err != nil {
return UploadConfig{}, err
}
+ if !IsLocalModel(u.UploadChatModel) {
+ return UploadConfig{}, fmt.Errorf("config: TAPIR_UPLOAD_CHAT_MODEL=%q is not a local model (koala/ or iguana/): the whole private transcript is sent to it", u.UploadChatModel)
+ }
chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars)
if err != nil {
@@ -141,18 +145,18 @@ func positiveInt(key string, fallback int) (int, error) {
return n, nil
}
-// localPrefixes are the host prefixes of the models that run on the homelab. Private
-// meeting content may be sent to these and to nothing else for reconciliation
-// (ADR-034): a cloud alias is refused at start-up rather than discovered later.
-var localPrefixes = []string{"koala/", "iguana/"}
+// localAlias is the shape of a model alias that runs on the homelab: a local host
+// prefix, then one plain name. A bare prefix check let through aliases that are shaped
+// like an escape (koala/../berget/x, koala//x, koala/x?host=berget): whether the
+// gateway would route them elsewhere is unknown, so they are refused (ADR-035 review).
+var localAlias = regexp.MustCompile(`^(koala|iguana)/[A-Za-z0-9][A-Za-z0-9._:-]*$`)
-func isLocalModel(alias string) bool {
- for _, p := range localPrefixes {
- if strings.HasPrefix(alias, p) {
- return true
- }
- }
- return false
+// IsLocalModel reports whether alias is a local model. Private meeting content may be
+// sent to these and to nothing else: reconciliation (ADR-034) and chat over an uploaded
+// recording (ADR-035) are both held to it, and a cloud alias is refused at start-up
+// rather than discovered later.
+func IsLocalModel(alias string) bool {
+ return localAlias.MatchString(alias) && !strings.Contains(alias, "..")
}
// validateReconcile checks the two-engine settings. They are inert while no base
@@ -168,7 +172,7 @@ func (u UploadConfig) validateReconcile() error {
{"TAPIR_RECONCILE_BASE_MODEL", u.ReconcileBaseModel},
{"TAPIR_RECONCILE_MODEL", u.ReconcileModel},
} {
- if !isLocalModel(m.alias) {
+ if !IsLocalModel(m.alias) {
return fmt.Errorf("config: %s=%q is not a local model (koala/ or iguana/): private meeting content must not leave the homelab for reconciliation", m.name, m.alias)
}
}
--
2.54.0
From 5ae9212869f9694b05e1f9cf3ae25c18df2cdb9f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:38:30 +0200
Subject: [PATCH 172/299] feat(cmd): uploadChatModels -- the chat switcher for
a private recording is local models only (review F1)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/processor.go | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/cmd/tapir/processor.go b/cmd/tapir/processor.go
index dc5b483..c815262 100644
--- a/cmd/tapir/processor.go
+++ b/cmd/tapir/processor.go
@@ -137,6 +137,20 @@ func chatModels(cfg config.Config) []string {
return models
}
+// uploadChatModels is the chat switcher for a PRIVATE uploaded recording: the same
+// ordered set as chatModels, local models only. The whole transcript (up to 90,000
+// characters) is sent to the one chosen, so a cloud model the video chat offers must not
+// be offered here; with none local, none is offered (ADR-035 review).
+func uploadChatModels(cfg config.Config) []string {
+ var local []string
+ for _, m := range chatModels(cfg) {
+ if config.IsLocalModel(m) {
+ local = append(local, m)
+ }
+ }
+ return local
+}
+
// buildChat wires the per-video chat service (ADR-027): a Completer factory over
// the SAME LiteLLM gateway the summarizer uses (a different alias per model, not a
// second client config) and the same transcript-truncation budget. It returns nil
--
2.54.0
From e121ef241d129d48502a96ca1dcca903b2fc6a3e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:38:30 +0200
Subject: [PATCH 173/299] fix(cmd): wire the local-only chat models into the
upload routes (review F1)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/cmd/tapir/uploads.go b/cmd/tapir/uploads.go
index 40dba37..fedc375 100644
--- a/cmd/tapir/uploads.go
+++ b/cmd/tapir/uploads.go
@@ -91,6 +91,7 @@ func wireUploads(app *web.App, cfg config.Config, in *uploads.Intake) {
app.UploadLimits = uploadLimits(cfg)
app.UploadChatMaxChars = cfg.MaxUploadedTranscriptChars
app.UploadChatModel = cfg.UploadChatModel
+ app.UploadChatModels = uploadChatModels(cfg)
}
// adjudicatorTimeout bounds one adjudication call. A call takes ~2 s on gemma4-26b and ~10 s
--
2.54.0
From 49335049823175e35ee7e8e9691413c888602a6b Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:38:30 +0200
Subject: [PATCH 174/299] feat(web): App.UploadChatModels -- the models upload
chat offers and accepts (review F1)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/handlers.go | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
index 3867181..bd81d03 100644
--- a/internal/web/handlers.go
+++ b/internal/web/handlers.go
@@ -122,6 +122,11 @@ type App struct {
// summarizer's (koala/phi4-mini, 8,192-token context) and cannot hold an hour of
// speech.
UploadChatModel string
+ // UploadChatModels is the set of models chat over an uploaded recording offers and
+ // accepts. The whole private transcript goes to the one chosen, so it must hold
+ // LOCAL models only, and the caller decides that. Nil or empty fails closed: no
+ // model is offered and none is called (ADR-035 review).
+ UploadChatModels []string
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
--
2.54.0
From ef815edded7b4afca61d6e4e39b2f351b8ee926b Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:38:30 +0200
Subject: [PATCH 175/299] fix(web): upload chat offers and accepts local models
only and fails closed; a forged cloud model is replaced before anything is
sent (review F1)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 37 ++++++++++++++++++++++++++++++++++---
1 file changed, 34 insertions(+), 3 deletions(-)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index 24e84bd..28601fe 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -220,7 +220,8 @@ func (a *App) handleUploadChat(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
return
}
- a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, a.Chat.DefaultModel(a.UploadChatModel), nil, false, "")))
+ model, _ := a.resolveUploadModel("")
+ a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, model, nil, false, "")))
}
// handleUploadChatMessage answers one question against the owner's uploaded
@@ -240,7 +241,13 @@ func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
- model := a.resolveModel(r.FormValue("model"), a.UploadChatModel)
+ model, ok := a.resolveUploadModel(r.FormValue("model"))
+ if !ok {
+ a.logger().Error("upload chat: no local model is configured; nothing was sent", "upload", u.ID)
+ view := a.uploadChatView(*u, "", nil, false, "No local model is available for chat about a recording.")
+ a.render(w, r, UploadChatPage(*u, view))
+ return
+ }
history := parseHistory(r.Form["hq"], r.Form["ha"])
question := strings.TrimSpace(r.FormValue("question"))
@@ -273,7 +280,7 @@ func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history
VideoID: u.ID,
Title: u.Title,
Available: true,
- Models: a.Chat.Models(),
+ Models: a.UploadChatModels,
Selected: model,
History: history,
Truncated: truncated,
@@ -401,3 +408,27 @@ func minutesAgo(d time.Duration) string {
}
return "under a minute"
}
+
+// resolveUploadModel picks the model for chat over an uploaded recording from the
+// offered LOCAL set only: the posted one when it is in the set, else the configured
+// default when it is, else the first. ok is false when no model is offered, in which
+// case nothing may be sent anywhere (the whole private transcript is the payload).
+func (a *App) resolveUploadModel(posted string) (model string, ok bool) {
+ offered := func(m string) bool {
+ for _, o := range a.UploadChatModels {
+ if o == m {
+ return true
+ }
+ }
+ return false
+ }
+ switch {
+ case posted != "" && offered(posted):
+ return posted, true
+ case a.UploadChatModel != "" && offered(a.UploadChatModel):
+ return a.UploadChatModel, true
+ case len(a.UploadChatModels) > 0:
+ return a.UploadChatModels[0], true
+ }
+ return "", false
+}
--
2.54.0
From 99ac5580e061137458c39974402c1642c21b453c Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:38:30 +0200
Subject: [PATCH 176/299] test(web): the upload chat helper says which models
are offered
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_chat_handler_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/web/uploads_chat_handler_test.go b/internal/web/uploads_chat_handler_test.go
index ff6f96f..ab0a125 100644
--- a/internal/web/uploads_chat_handler_test.go
+++ b/internal/web/uploads_chat_handler_test.go
@@ -31,6 +31,7 @@ func newUploadChatApp(t *testing.T) (*web.App, *fakeChatter) {
}
app.Chat = fc
app.UploadChatMaxChars = 120000
+ app.UploadChatModels = fc.models
return app, fc
}
--
2.54.0
From dc0c64ddeb3330ca3e6b00fcc91b99628dd52273 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:39:04 +0200
Subject: [PATCH 177/299] test(errkind): an error reduced to a safe kind, never
its text (red; adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/errkind/errkind_test.go | 64 ++++++++++++++++++++++++++++++++
1 file changed, 64 insertions(+)
create mode 100644 internal/errkind/errkind_test.go
diff --git a/internal/errkind/errkind_test.go b/internal/errkind/errkind_test.go
new file mode 100644
index 0000000..cba5ce5
--- /dev/null
+++ b/internal/errkind/errkind_test.go
@@ -0,0 +1,64 @@
+package errkind_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "git.d-ma.be/mathias/tapir/internal/errkind"
+)
+
+// Of reduces an error to a short kind that is safe to log, store and show: it never
+// carries the error's own text, because that text can hold a provider's response body, a
+// file path or a word from a private transcript (ADR-035 review F3, F5).
+
+type statusErr struct{ code int }
+
+func (e statusErr) Error() string {
+ return fmt.Sprintf("gateway said: %d and echoed the transcript", e.code)
+}
+func (e statusErr) HTTPStatus() int { return e.code }
+
+type timeoutNet struct{}
+
+func (timeoutNet) Error() string { return "i/o timeout to 10.0.0.1" }
+func (timeoutNet) Timeout() bool { return true }
+func (timeoutNet) Temporary() bool { return true }
+
+func TestOf_ReducesAnErrorToAKindAndNeverItsText(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want string
+ }{
+ {"nothing", nil, ""},
+ {"a deadline", context.DeadlineExceeded, "timeout"},
+ {"a wrapped deadline", fmt.Errorf("transcribe: %w", context.DeadlineExceeded), "timeout"},
+ {"a cancellation", fmt.Errorf("job: %w", context.Canceled), "canceled"},
+ {"an HTTP status", statusErr{code: 502}, "http_502"},
+ {"a wrapped HTTP status", fmt.Errorf("model: %w", statusErr{code: 400}), "http_400"},
+ {"a network timeout", timeoutNet{}, "timeout"},
+ {"a network failure", &net.OpError{Op: "dial", Err: errors.New("connection refused")}, "network"},
+ {"a url error wrapping a refusal", &url.Error{Op: "Post", URL: "http://gateway/secret-path", Err: errors.New("refused")}, "network"},
+ {"anything else", errors.New("moov atom not found in /spool/upload-555"), "error"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, errkind.Of(tt.err))
+ })
+ }
+}
+
+func TestOf_AKindCarriesNoWordFromTheError(t *testing.T) {
+ err := fmt.Errorf("convert /spool/upload-555: %w", errors.New("Anders ansvarar för virket"))
+
+ got := errkind.Of(err)
+
+ assert.NotContains(t, got, "Anders")
+ assert.NotContains(t, got, "spool")
+}
--
2.54.0
From d8eea66c4cad61e0d6fc647b4487e6e3b918eda7 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:39:10 +0200
Subject: [PATCH 178/299] feat(errkind): Of -- a kind from a fixed vocabulary,
never the error's text (review F3, F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/errkind/errkind.go | 42 +++++++++++++++++++++++++++++++++++++
1 file changed, 42 insertions(+)
create mode 100644 internal/errkind/errkind.go
diff --git a/internal/errkind/errkind.go b/internal/errkind/errkind.go
new file mode 100644
index 0000000..74e9099
--- /dev/null
+++ b/internal/errkind/errkind.go
@@ -0,0 +1,42 @@
+// Package errkind reduces an error to a short kind that is safe to log, store and show.
+//
+// An error's own text can hold a provider's response body (a gateway that echoes the
+// prompt), a file path (ffmpeg names the spool file), or a word from a private
+// transcript. None of that may reach a log line, a database column or a page about a
+// private recording (ADR-033: "typed, never a provider's string"; ADR-035). Of returns
+// only a fixed vocabulary, so it can never carry any.
+package errkind
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+)
+
+// HTTPStatuser is implemented by errors that are a non-2xx answer from a service.
+type HTTPStatuser interface{ HTTPStatus() int }
+
+// Of is the kind of err: "timeout", "canceled", "http_", "network", or "error".
+// It is empty for nil.
+func Of(err error) string {
+ if err == nil {
+ return ""
+ }
+ var hs HTTPStatuser
+ var ne net.Error
+ switch {
+ case errors.Is(err, context.DeadlineExceeded):
+ return "timeout"
+ case errors.Is(err, context.Canceled):
+ return "canceled"
+ case errors.As(err, &hs):
+ return fmt.Sprintf("http_%d", hs.HTTPStatus())
+ case errors.As(err, &ne):
+ if ne.Timeout() {
+ return "timeout"
+ }
+ return "network"
+ }
+ return "error"
+}
--
2.54.0
From 8c82d67b1272808ea15240df9c94097376e3b6c3 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:39:25 +0200
Subject: [PATCH 179/299] test(llm): a non-200 is an HTTP status error whose
kind carries no body, and the body is bounded (red; adversarial review F3)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/llm/status_error_test.go | 63 ++++++++++++++++++++++
1 file changed, 63 insertions(+)
create mode 100644 internal/adapters/llm/status_error_test.go
diff --git a/internal/adapters/llm/status_error_test.go b/internal/adapters/llm/status_error_test.go
new file mode 100644
index 0000000..8e9b68c
--- /dev/null
+++ b/internal/adapters/llm/status_error_test.go
@@ -0,0 +1,63 @@
+package llm
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/errkind"
+)
+
+// A non-200 from the gateway is an HTTP status error: its kind can be logged and stored
+// without its body, which can echo the prompt (adversarial review F3).
+
+func failingGateway(t *testing.T, status int, body string) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(body))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestClient_ANon200IsAnHTTPStatusErrorWhoseKindCarriesNoBody(t *testing.T) {
+ srv := failingGateway(t, http.StatusBadRequest, `{"error":"prompt was: Anders ansvarar för virket"}`)
+
+ _, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
+
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+ if got := errkind.Of(err); got != "http_400" {
+ t.Errorf("errkind.Of = %q, want http_400", got)
+ }
+ var he *HTTPError
+ if !errors.As(err, &he) || he.Code != http.StatusBadRequest {
+ t.Errorf("error is not an *HTTPError with the status code: %v", err)
+ }
+}
+
+func TestClient_TheErrorTextIsUnchangedForCallersThatLogIt(t *testing.T) {
+ srv := failingGateway(t, http.StatusBadGateway, "upstream down")
+
+ _, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
+
+ if err == nil || err.Error() != "LLM returned 502: upstream down" {
+ t.Errorf("error text = %v: callers that log it already rely on this form", err)
+ }
+}
+
+func TestClient_AnEnormousErrorBodyIsBoundedInTheErrorText(t *testing.T) {
+ srv := failingGateway(t, http.StatusInternalServerError, strings.Repeat("x", 100_000))
+
+ _, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
+
+ if err == nil || len(err.Error()) > 1024 {
+ t.Errorf("error text is %d bytes: a gateway's whole body must not become an error message", len(err.Error()))
+ }
+}
--
2.54.0
From 81f3d7ffc6e99e48cfb22e83598492e454d6a16d Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:39:25 +0200
Subject: [PATCH 180/299] test(transcribe): a StatusError exposes its code as a
kind, not its snippet (red; adversarial review F3)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/gateway_test.go | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/internal/adapters/transcribe/gateway_test.go b/internal/adapters/transcribe/gateway_test.go
index 6a8c221..99186d5 100644
--- a/internal/adapters/transcribe/gateway_test.go
+++ b/internal/adapters/transcribe/gateway_test.go
@@ -3,6 +3,7 @@ package transcribe_test
import (
"bytes"
"context"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
@@ -15,6 +16,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/errkind"
)
// The httptest server stands in for the LiteLLM gateway: it is the process
@@ -230,3 +232,9 @@ func TestGateway_WithNoSegmentsTheTextFieldIsUsedWithWhitespaceCollapsed(t *test
require.NoError(t, err)
assert.Equal(t, "Hej allihopa. Vi börjar.", got.Text)
}
+
+func TestStatusError_ExposesItsCodeAsAKindAndNotItsSnippet(t *testing.T) {
+ err := fmt.Errorf("transcribe: %w", &transcribe.StatusError{Code: 502, Snippet: "echoed: Anders ansvarar"})
+
+ assert.Equal(t, "http_502", errkind.Of(err))
+}
--
2.54.0
From 2608ad2f09c1429a4e4f4aef561249e07fd11779 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:39:38 +0200
Subject: [PATCH 181/299] feat(llm): HTTPError -- a status code to branch on, a
bounded body in the text (review F3)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/llm/client.go | 25 ++++++++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go
index 5ced970..33a3688 100644
--- a/internal/adapters/llm/client.go
+++ b/internal/adapters/llm/client.go
@@ -55,6 +55,29 @@ func WithMaxTokens(n int) Option {
}
}
+// maxErrorBody bounds how much of a failing response ends up in an error message.
+const maxErrorBody = 512
+
+// HTTPError is a non-200 answer from the gateway. Its text keeps the form callers that
+// log it already rely on, with the body bounded; its Code is what code may branch on and
+// what errkind turns into a kind. The body is provider text and can echo the prompt, so
+// nothing private may persist or display Error() (ADR-035 review F3).
+type HTTPError struct {
+ Code int
+ Body string
+}
+
+func (e *HTTPError) Error() string {
+ body := e.Body
+ if len(body) > maxErrorBody {
+ body = body[:maxErrorBody] + "…"
+ }
+ return fmt.Sprintf("LLM returned %d: %s", e.Code, body)
+}
+
+// HTTPStatus implements errkind.HTTPStatuser.
+func (e *HTTPError) HTTPStatus() int { return e.Code }
+
// WithTemperature overrides the sampling temperature. Zero IS a valid value and is sent
// as zero: the reconciler's accuracy was measured at temperature 0 and must run there.
func WithTemperature(t float64) Option {
@@ -166,7 +189,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, out)
+ return "", &HTTPError{Code: resp.StatusCode, Body: string(out)}
}
var cr chatResponse
--
2.54.0
From 2d1b08458b97c6f01870c0355b5ce7e7483047bc Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:39:38 +0200
Subject: [PATCH 182/299] feat(transcribe): StatusError.HTTPStatus -- its code,
never its snippet, becomes the kind (review F3)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/gateway.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/internal/adapters/transcribe/gateway.go b/internal/adapters/transcribe/gateway.go
index 763fcc4..106798a 100644
--- a/internal/adapters/transcribe/gateway.go
+++ b/internal/adapters/transcribe/gateway.go
@@ -31,6 +31,9 @@ type StatusError struct {
Snippet string
}
+// HTTPStatus implements errkind.HTTPStatuser: the code, never the snippet.
+func (e *StatusError) HTTPStatus() int { return e.Code }
+
func (e *StatusError) Error() string {
return fmt.Sprintf("transcribe: gateway returned HTTP %d: %s", e.Code, e.Snippet)
}
--
2.54.0
From f6c3f2af238bea9bc38818beb30b990b34ebb072 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:10 +0200
Subject: [PATCH 183/299] test(reconcile): the skipped reason carries a kind,
not the model's text (red; review F3)
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_reason_test.go | 33 +++++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 internal/reconcile/reconcile_reason_test.go
diff --git a/internal/reconcile/reconcile_reason_test.go b/internal/reconcile/reconcile_reason_test.go
new file mode 100644
index 0000000..d8ab642
--- /dev/null
+++ b/internal/reconcile/reconcile_reason_test.go
@@ -0,0 +1,33 @@
+package reconcile_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// The reason a reconciliation was skipped is stored and shown on a page about a private
+// recording, so it carries a kind, never the model's error text, which can echo the
+// prompt (adversarial review F3).
+
+func TestReconcile_TheBreakerReasonCarriesAKindNotTheModelsText(t *testing.T) {
+ adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
+ return reconcile.VerdictBase, errors.New("gateway echoed the prompt: zebrakaka secret words")
+ }}
+ small := opts
+ small.BreakAfter = 2
+
+ out, err := reconcile.Reconcile(context.Background(), tr(progBase), tr(progAlt), adj, small)
+
+ require.NoError(t, err)
+ require.Equal(t, domain.ReconcileSkipped, out.Reconciliation.Status)
+ assert.NotContains(t, out.Reconciliation.Reason, "zebrakaka", "provider text must not be stored or shown")
+ assert.Contains(t, out.Reconciliation.Reason, "2 times in a row", "the reason still says what happened")
+ assert.Contains(t, out.Reconciliation.Reason, "error", "and carries a kind")
+}
--
2.54.0
From 8f3300b3773d3263e08c4af41bddd4cf4f5043d2 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:10 +0200
Subject: [PATCH 184/299] test(transcribe): a failed engine's reason is a kind,
not its error text (red; review F3)
Co-Authored-By: Claude Sonnet 5.5
---
.../transcribe/ensemble_reason_test.go | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
create mode 100644 internal/adapters/transcribe/ensemble_reason_test.go
diff --git a/internal/adapters/transcribe/ensemble_reason_test.go b/internal/adapters/transcribe/ensemble_reason_test.go
new file mode 100644
index 0000000..4777bd7
--- /dev/null
+++ b/internal/adapters/transcribe/ensemble_reason_test.go
@@ -0,0 +1,36 @@
+package transcribe_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// When one engine is down the page says so and why; the "why" is a kind, not the
+// engine's error text, which can carry provider output (adversarial review F3).
+
+func TestEnsemble_AFailedEnginesReasonIsAKindNotItsErrorText(t *testing.T) {
+ r := newEnsemble("text", "text", nil, errors.New("kb-whisper said: zebrakaka secret words"), false, &verdictAdj{v: reconcile.VerdictAlt})
+
+ got, err := r.e.Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.NotContains(t, got.Reconciliation.Reason, "zebrakaka")
+ assert.Contains(t, got.Reconciliation.Reason, "iguana/kb-whisper failed", "which engine failed is still said")
+}
+
+func TestEnsemble_AnHTTPFailureShowsItsStatusCodeAsTheKind(t *testing.T) {
+ r := newEnsemble("text", "text", &transcribe.StatusError{Code: 502, Snippet: "zebrakaka"}, nil, false, &verdictAdj{})
+
+ got, err := r.e.Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Contains(t, got.Reconciliation.Reason, "http_502")
+ assert.NotContains(t, got.Reconciliation.Reason, "zebrakaka")
+}
--
2.54.0
From e9863c2e12e05d0995d3c87d27975a66b728d9fe Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:10 +0200
Subject: [PATCH 185/299] test(progress): a keyed failure logs its kind, not
the error's text (red; review F3)
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker_test.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/internal/progress/tracker_test.go b/internal/progress/tracker_test.go
index cf78319..b3c231b 100644
--- a/internal/progress/tracker_test.go
+++ b/internal/progress/tracker_test.go
@@ -197,7 +197,7 @@ func TestTracker_FailRunningMarksTheRunningStepFailedAndLeavesTheRestPending(t *
require.Equal(t, domain.StepPending, stateOf(steps, "save"), "what never started stays pending: it shows where the job stopped")
}
-func TestTracker_AKeyedFailMarksOneStepFailedLogsWhyAndTheJobGoesOn(t *testing.T) {
+func TestTracker_AKeyedFailMarksOneStepFailedLogsItsKindAndTheJobGoesOn(t *testing.T) {
tr, sink, _, buf := newTracker(t)
tr.Start()
tr.Begin("audio", 0)
@@ -209,7 +209,8 @@ func TestTracker_AKeyedFailMarksOneStepFailedLogsWhyAndTheJobGoesOn(t *testing.T
require.Equal(t, domain.StepFailed, stateOf(steps, "audio"))
require.Equal(t, domain.StepRunning, stateOf(steps, "decide"), "a soft failure must not stop the next step from running")
require.Contains(t, buf.String(), "level=WARN")
- require.Contains(t, buf.String(), "gateway said no")
+ require.NotContains(t, buf.String(), "gateway said no", "an error's text can carry provider output or a path: the log gets its kind")
+ require.Contains(t, buf.String(), "kind=error")
require.Contains(t, buf.String(), "step=audio")
}
--
2.54.0
From 166ff849c0db17952247b9407ab6f83ae8554650 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:10 +0200
Subject: [PATCH 186/299] test(uploads): a failure logs a kind, never the error
text or the spool path (red; review F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker_progress_test.go | 31 ++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/internal/uploads/worker_progress_test.go b/internal/uploads/worker_progress_test.go
index b6e1cf1..b3d8e68 100644
--- a/internal/uploads/worker_progress_test.go
+++ b/internal/uploads/worker_progress_test.go
@@ -163,3 +163,34 @@ func TestWorkerProgress_TheLogNamesEveryPhaseInOrderAndNeverTheTranscriptOrThePa
assert.NotContains(t, out, "zebrakaka", "no transcript text in the log")
assert.NotContains(t, out, r.tmp, "no spool path, which carries the upload's identity, in the log")
}
+
+func TestWorkerProgress_AFailureLogsAKindNeverTheErrorTextOrThePath(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(*wrig)
+ kind string
+ }{
+ {"conversion fails and names the spool file", func(r *wrig) {
+ r.conv.err = errors.New("Error opening input file " + r.tmp + "/upload-a: Invalid data zebrakaka")
+ }, "kind=error"},
+ {"every engine fails and the gateway echoed a word", func(r *wrig) {
+ r.tr.err = errors.New("gateway echoed: zebrakaka secret words")
+ }, "kind=error"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ tt.setup(r)
+ buf := &syncLog{}
+ w := newProgressWorker(t, r, r.tr, uploads.WithLogger(slog.New(slog.NewTextHandler(buf, nil))))
+
+ w.Process(context.Background(), r.spool(t, "a"))
+
+ out := buf.String()
+ assert.NotContains(t, out, "zebrakaka", "provider text or file contents in the log")
+ assert.NotContains(t, out, r.tmp, "the spool path in the log")
+ assert.Contains(t, out, tt.kind)
+ assert.Contains(t, out, "upload=a")
+ })
+ }
+}
--
2.54.0
From 31f5a74d2a87f02689b64f0d96b3c9678e95e823 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:10 +0200
Subject: [PATCH 187/299] test(web): the upload routes' error paths log a kind,
not the text (red; review F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_logging_handler_test.go | 72 ++++++++++++++++++++
1 file changed, 72 insertions(+)
create mode 100644 internal/web/uploads_logging_handler_test.go
diff --git a/internal/web/uploads_logging_handler_test.go b/internal/web/uploads_logging_handler_test.go
new file mode 100644
index 0000000..e29a33e
--- /dev/null
+++ b/internal/web/uploads_logging_handler_test.go
@@ -0,0 +1,72 @@
+package web_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Error paths of the upload routes log a kind, never the error's text, which can carry
+// a file name, a path, a database value or a provider's output (adversarial review F5).
+
+type lockedBuf struct {
+ mu sync.Mutex
+ b bytes.Buffer
+}
+
+func (l *lockedBuf) Write(p []byte) (int, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.Write(p)
+}
+
+func (l *lockedBuf) String() string {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.String()
+}
+
+type failingUploader struct{ err error }
+
+func (f failingUploader) Accept(context.Context, string, string, io.Reader) (string, error) {
+ return "", f.err
+}
+
+func TestUploadPost_AnUnexpectedFailureLogsAKindAndNotItsText(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ buf := &lockedBuf{}
+ app.Log = slog.New(slog.NewTextHandler(buf, nil))
+ app.Uploads = failingUploader{err: errors.New(`ffprobe: Error opening input file /spool/upload-555 for "zebrakaka-meeting.m4a"`)}
+
+ rec := do(t, app, uploadReq(t, "t", "zebrakaka-meeting.m4a", []byte("x")))
+
+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
+ out := buf.String()
+ assert.NotContains(t, out, "zebrakaka", "a file name is private content")
+ assert.NotContains(t, out, "/spool/", "a spool path carries the upload's identity")
+ assert.Contains(t, out, "kind=error")
+}
+
+func TestUploadChatPost_AModelFailureLogsAKindAndNotItsText(t *testing.T) {
+ app, fc := newUploadChatApp(t)
+ buf := &lockedBuf{}
+ app.Log = slog.New(slog.NewTextHandler(buf, nil))
+ fc.err = errors.New("LLM returned 400: the prompt was zebrakaka secret words")
+ id := seedUpload(t, userID, "m", "done", meetingText)
+
+ rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ out := buf.String()
+ assert.NotContains(t, out, "zebrakaka")
+ assert.Contains(t, out, "kind=error")
+}
--
2.54.0
From 03a122c9f53b7a1c473a6757a2b8618fccece28f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:55 +0200
Subject: [PATCH 188/299] fix(tracker.go): an error reaches the log, the row
and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/progress/tracker.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go
index f3e94c7..37678b7 100644
--- a/internal/progress/tracker.go
+++ b/internal/progress/tracker.go
@@ -12,6 +12,7 @@ import (
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/errkind"
)
// Planned is one phase of a job, declared up front so the page can show what is coming.
@@ -146,7 +147,7 @@ func (t *Tracker) Fail(key string, err error) {
return
}
t.close(s, domain.StepFailed)
- t.log.Warn("step failed", "step", key, "elapsed", s.FinishedAt.Sub(s.StartedAt), "err", err)
+ t.log.Warn("step failed", "step", key, "elapsed", s.FinishedAt.Sub(s.StartedAt), "kind", errkind.Of(err))
t.save()
}
--
2.54.0
From 16a8833f79e03354afc30c3f5eba3a954cb3590d Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:55 +0200
Subject: [PATCH 189/299] fix(reconcile.go): an error reaches the log, the row
and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go
index f1e3956..35048e1 100644
--- a/internal/reconcile/reconcile.go
+++ b/internal/reconcile/reconcile.go
@@ -6,6 +6,7 @@ import (
"strings"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/progress"
)
@@ -101,9 +102,8 @@ func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudica
rec.Errors++
consecutive++
if consecutive >= breakAfter {
- failure := fmt.Errorf("the model failed %d times in a row: %w", consecutive, err)
- rep.Fail("decide", failure)
- return skipped(base, rec, failure.Error()), nil
+ rep.Fail("decide", err)
+ return skipped(base, rec, fmt.Sprintf("the model failed %d times in a row (%s)", consecutive, errkind.Of(err))), nil
}
v = VerdictUnsure
} else {
--
2.54.0
From 0b35278cdf39ac76324f27d108b1e3904b0de786 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:55 +0200
Subject: [PATCH 190/299] fix(transcribe): an error reaches the log, the row
and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/ensemble.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/internal/adapters/transcribe/ensemble.go b/internal/adapters/transcribe/ensemble.go
index c44b80b..262e5fd 100644
--- a/internal/adapters/transcribe/ensemble.go
+++ b/internal/adapters/transcribe/ensemble.go
@@ -6,6 +6,7 @@ import (
"fmt"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/progress"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
@@ -72,9 +73,9 @@ func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Trans
}
return out, nil
case berr == nil:
- return e.single(b, e.base.Name(), fmt.Sprintf("%s failed: %v", e.alt.Name(), aerr)), nil
+ return e.single(b, e.base.Name(), fmt.Sprintf("%s failed (%s)", e.alt.Name(), errkind.Of(aerr))), nil
case aerr == nil:
- return e.single(a, e.alt.Name(), fmt.Sprintf("%s failed: %v", e.base.Name(), berr)), nil
+ return e.single(a, e.alt.Name(), fmt.Sprintf("%s failed (%s)", e.base.Name(), errkind.Of(berr))), nil
}
both := errors.Join(fmt.Errorf("%s: %w", e.base.Name(), berr), fmt.Errorf("%s: %w", e.alt.Name(), aerr))
--
2.54.0
From a2587eae9b5167c86fb27a6c5e7dc029f8f11179 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:55 +0200
Subject: [PATCH 191/299] fix(worker.go): an error reaches the log, the row and
the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/internal/uploads/worker.go b/internal/uploads/worker.go
index e414e44..eb6d78c 100644
--- a/internal/uploads/worker.go
+++ b/internal/uploads/worker.go
@@ -10,6 +10,7 @@ import (
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/progress"
)
@@ -236,7 +237,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
defer cancel()
tr.Begin("audio", 0)
if err := w.conv.ToWAV(jobCtx, job.Path, wav); err != nil {
- log.Error("upload: convert", "err", err)
+ log.Error("upload: convert", "kind", errkind.Of(err))
abort()
return
}
@@ -247,7 +248,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
}
t, err := w.tr.Transcribe(progress.With(jobCtx, tr), wav)
if err != nil {
- log.Error("upload: transcribe", "err", err, "elapsed", time.Since(start).Round(time.Second))
+ log.Error("upload: transcribe", "kind", errkind.Of(err), "elapsed", time.Since(start).Round(time.Second))
abort()
return
}
--
2.54.0
From fb79dbb0453834c3efa698d538a3ddf6c583d55b Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:55 +0200
Subject: [PATCH 192/299] fix(uploads.go): an error reaches the log, the row
and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index 28601fe..9b4d8e2 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -17,6 +17,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
@@ -134,7 +135,9 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
"This file is larger than the upload limit.")
default:
- a.serverError(w, r, "upload", err)
+ // Not err: it can carry the file's name, a spool path or a database value.
+ a.logger().Error("upload failed", "kind", errkind.Of(err))
+ http.Error(w, "internal error", http.StatusInternalServerError)
}
}
@@ -261,7 +264,7 @@ func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
MaxChars: a.UploadChatMaxChars,
})
if err != nil {
- a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "err", err)
+ a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "kind", errkind.Of(err))
view.Error = "That model couldn't answer just now. Try again, or switch models."
} else {
view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer})
--
2.54.0
From d16c2998d0920f8bb5f4fa52bbdf0ea6d085b473 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:40:55 +0200
Subject: [PATCH 193/299] test(reconcile): the stored reason is a kind -- the
old assertion encoded the leak the review found
Co-Authored-By: Claude Sonnet 5.5
---
internal/reconcile/reconcile_test.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go
index 4cf8875..cb1bb45 100644
--- a/internal/reconcile/reconcile_test.go
+++ b/internal/reconcile/reconcile_test.go
@@ -193,7 +193,8 @@ func TestReconcile_ConsecutiveModelErrorsStopTheWholeReconciliationInsteadOfWait
assert.Len(t, adj.calls, 3, "three consecutive failures and it stops asking")
assert.Equal(t, strings.Join(a, " "), out.Text, "the base text, with none of the partial changes")
assert.Equal(t, domain.ReconcileSkipped, out.Reconciliation.Status)
- assert.Contains(t, out.Reconciliation.Reason, "gateway down")
+ assert.Contains(t, out.Reconciliation.Reason, "3 times in a row")
+ assert.NotContains(t, out.Reconciliation.Reason, "gateway down", "the reason is shown on the page: a kind, not the model's own text")
}
func TestReconcile_ASuccessBetweenFailuresResetsTheBreaker(t *testing.T) {
--
2.54.0
From 21b337702ad7d48acd1d42c9e08da13b25641824 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:41:38 +0200
Subject: [PATCH 194/299] test: a per-engine deadline so a wedged primary
reaches the fallback (red; adversarial review F4)
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/config/uploads_test.go b/internal/config/uploads_test.go
index b4874aa..745bc99 100644
--- a/internal/config/uploads_test.go
+++ b/internal/config/uploads_test.go
@@ -12,7 +12,7 @@ func clearUploadEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"TAPIR_UPLOADS_ENABLED", "TAPIR_UPLOAD_MAX_MB", "TAPIR_UPLOAD_MAX_MINUTES",
- "TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT",
+ "TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT", "TAPIR_TRANSCRIBE_ENGINE_TIMEOUT",
"TAPIR_UPLOAD_TMP_DIR", "TAPIR_FFMPEG_BIN", "TAPIR_FFPROBE_BIN",
"TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", "TAPIR_UPLOAD_CHAT_MODEL",
"TAPIR_RECONCILE_BASE_MODEL", "TAPIR_RECONCILE_MODEL", "TAPIR_RECONCILE_CHECK_ORDER",
--
2.54.0
From 8d1b7d887fdfe67a46b788942b8c79b9875bd434 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:41:38 +0200
Subject: [PATCH 195/299] test: a per-engine deadline so a wedged primary
reaches the fallback (red; adversarial review F4)
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads_local_test.go | 29 ++++++++++++++++++++++++++-
1 file changed, 28 insertions(+), 1 deletion(-)
diff --git a/internal/config/uploads_local_test.go b/internal/config/uploads_local_test.go
index f7adccd..af6a0ba 100644
--- a/internal/config/uploads_local_test.go
+++ b/internal/config/uploads_local_test.go
@@ -1,6 +1,9 @@
package config
-import "testing"
+import (
+ "testing"
+ "time"
+)
// What counts as a LOCAL model alias, and where that is demanded (ADR-034, ADR-035 review).
// A private transcript may be sent to a local model and to nothing else; the check is a
@@ -72,3 +75,27 @@ func TestLoad_ALocalUploadChatModelIsAccepted(t *testing.T) {
t.Errorf("UploadChatModel = %q", cfg.UploadChatModel)
}
}
+
+func TestLoad_TheEngineTimeoutDefaultsToTwentyFiveMinutesAndMustBePositive(t *testing.T) {
+ clearUploadEnv(t)
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if cfg.TranscribeEngineTimeout != 25*time.Minute {
+ t.Errorf("TranscribeEngineTimeout = %v, want 25m: a 90-minute recording takes about 8 minutes on kb-whisper", cfg.TranscribeEngineTimeout)
+ }
+
+ setEnv(t, map[string]string{"TAPIR_TRANSCRIBE_ENGINE_TIMEOUT": "7m"})
+ cfg, err = Load()
+ if err != nil || cfg.TranscribeEngineTimeout != 7*time.Minute {
+ t.Errorf("override: %v, %v", cfg.TranscribeEngineTimeout, err)
+ }
+
+ for _, bad := range []string{"0", "-1m", "soon"} {
+ setEnv(t, map[string]string{"TAPIR_TRANSCRIBE_ENGINE_TIMEOUT": bad})
+ if _, err := Load(); err == nil {
+ t.Errorf("Load accepted TAPIR_TRANSCRIBE_ENGINE_TIMEOUT=%q", bad)
+ }
+ }
+}
--
2.54.0
From c8c7356f190679333a1e216c8f0f4737aee58d23 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:41:38 +0200
Subject: [PATCH 196/299] test: a per-engine deadline so a wedged primary
reaches the fallback (red; adversarial review F4)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/timeout_test.go | 104 +++++++++++++++++++
1 file changed, 104 insertions(+)
create mode 100644 internal/adapters/transcribe/timeout_test.go
diff --git a/internal/adapters/transcribe/timeout_test.go b/internal/adapters/transcribe/timeout_test.go
new file mode 100644
index 0000000..fed4489
--- /dev/null
+++ b/internal/adapters/transcribe/timeout_test.go
@@ -0,0 +1,104 @@
+package transcribe_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/errkind"
+ "git.d-ma.be/mathias/tapir/internal/reconcile"
+)
+
+// A wedged engine (iguana after a Metal OOM, infra#273) does not answer at all. With only
+// the job's deadline, the primary used up the whole job before any fallback could run, and
+// the chain then refused the fallback because the job context had expired. Each engine call
+// gets its own deadline (adversarial review F4).
+
+type hangingBackend struct{ name string }
+
+func (h hangingBackend) Name() string { return h.name }
+func (hangingBackend) Transcribe(ctx context.Context, _ string) (domain.Transcription, error) {
+ <-ctx.Done()
+ return domain.Transcription{}, ctx.Err()
+}
+
+func TestWithTimeout_AHungBackendFailsAtItsOwnDeadlineWhileTheJobIsStillAlive(t *testing.T) {
+ job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ b := transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, 40*time.Millisecond)
+
+ start := time.Now()
+ _, err := b.Transcribe(job, "/a.wav")
+
+ require.Error(t, err)
+ assert.Equal(t, "timeout", errkind.Of(err))
+ assert.Less(t, time.Since(start), 2*time.Second, "it must give up at its own deadline, not wait for the job's")
+ assert.NoError(t, job.Err(), "the job context is untouched, which is what lets a fallback run")
+ assert.Equal(t, "iguana/kb-whisper", b.Name(), "the wrapper is invisible in the recorded endpoint")
+}
+
+func TestWithTimeout_ASuccessPassesStraightThrough(t *testing.T) {
+ calls := &[]string{}
+ inner := &orderedBackend{name: "iguana/kb-whisper", text: "hej", calls: calls}
+
+ got, err := transcribe.WithTimeout(inner, time.Minute).Transcribe(context.Background(), "/a.wav")
+
+ require.NoError(t, err)
+ assert.Equal(t, "hej", got.Text)
+}
+
+func TestWithTimeout_ZeroMeansNoDeadlineOfItsOwn(t *testing.T) {
+ inner := &orderedBackend{name: "x", text: "hej", calls: &[]string{}}
+
+ assert.Same(t, inner, transcribe.WithTimeout(inner, 0), "a zero timeout must not wrap or change anything")
+}
+
+func TestChain_AHungPrimaryReachesTheFallbackWhenItHasItsOwnDeadline(t *testing.T) {
+ calls := &[]string{}
+ fb := &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
+ c := transcribe.NewChain(transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, 40*time.Millisecond), fb)
+ job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ got, err := c.Transcribe(job, "/a.wav")
+
+ require.NoError(t, err, "a wedged primary is exactly what the fallback exists for")
+ assert.Equal(t, "berget/kb-whisper", got.Endpoint)
+}
+
+func TestChain_AJobThatEndedWhileThePrimaryHungStillNeverReachesTheFallback(t *testing.T) {
+ calls := &[]string{}
+ fb := &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
+ c := transcribe.NewChain(transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, time.Minute), fb)
+ job, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
+ defer cancel()
+
+ _, err := c.Transcribe(job, "/a.wav")
+
+ require.Error(t, err)
+ assert.Empty(t, *calls, "the job's own deadline or a cancel must still stop the audio from reaching a cloud endpoint")
+}
+
+func TestEnsemble_AHungEngineIsASoftFailureAndTheOtherEnginesTextIsUsed(t *testing.T) {
+ calls := &[]string{}
+ alt := &orderedBackend{name: "iguana/kb-whisper", text: "text from the other engine", calls: calls}
+ e := transcribe.NewEnsemble(
+ transcribe.WithTimeout(hangingBackend{name: "iguana/pianissimo"}, 40*time.Millisecond), alt, nil,
+ &verdictAdj{v: reconcile.VerdictAlt}, ensembleOpts)
+ job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ got, err := e.Transcribe(job, "/a.wav")
+
+ require.NoError(t, err)
+ assert.Equal(t, "text from the other engine", got.Text)
+ assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
+ assert.Contains(t, got.Reconciliation.Reason, "timeout")
+ assert.False(t, errors.Is(err, context.DeadlineExceeded))
+}
--
2.54.0
From 81a60d5fd4ade71ef7db37566a52348f8989b157 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:41:38 +0200
Subject: [PATCH 197/299] test: a per-engine deadline so a wedged primary
reaches the fallback (red; adversarial review F4)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_timeout_test.go | 45 +++++++++++++++++++++++++++++++
1 file changed, 45 insertions(+)
create mode 100644 cmd/tapir/uploads_timeout_test.go
diff --git a/cmd/tapir/uploads_timeout_test.go b/cmd/tapir/uploads_timeout_test.go
new file mode 100644
index 0000000..f25d896
--- /dev/null
+++ b/cmd/tapir/uploads_timeout_test.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// The wiring of the per-engine deadline: a gateway whose primary model never answers
+// must still reach the fallback model inside the job's own deadline (adversarial review
+// F4). The fake gateway answers per model from the multipart `model` field.
+
+func TestBuildTranscriber_AWedgedPrimaryReachesTheFallbackBeforeTheJobDeadline(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseMultipartForm(1 << 20)
+ if r.FormValue("model") == "iguana/kb-whisper" {
+ <-r.Context().Done() // wedged: never answers
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"text":"hej","duration":1,"segments":[{"start":0,"end":1,"text":"hej"}]}`))
+ }))
+ defer srv.Close()
+ cfg := uploadCfg(t, true)
+ cfg.GatewayURL = srv.URL
+ cfg.TranscribeModel = "iguana/kb-whisper"
+ cfg.TranscribeFallbackModel = "berget/kb-whisper"
+ cfg.TranscribeEngineTimeout = 150 * time.Millisecond
+ wav := filepath.Join(t.TempDir(), "a.wav")
+ require.NoError(t, os.WriteFile(wav, []byte("RIFF....WAVEfmt "), 0o600))
+ job, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ got, err := buildTranscriber(cfg).Transcribe(job, wav)
+
+ require.NoError(t, err, "the wedged primary used the whole job and the fallback never ran")
+ assert.Equal(t, "berget/kb-whisper", got.Endpoint)
+}
--
2.54.0
From c8ec468e475bfa303d917829632c59a98d8fbbb0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:42:46 +0200
Subject: [PATCH 198/299] feat(transcribe): WithTimeout -- each engine call
gets its own deadline so a wedged engine fails while the job lives (review
F4)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/transcribe/timeout.go | 33 +++++++++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 internal/adapters/transcribe/timeout.go
diff --git a/internal/adapters/transcribe/timeout.go b/internal/adapters/transcribe/timeout.go
new file mode 100644
index 0000000..fee7b1b
--- /dev/null
+++ b/internal/adapters/transcribe/timeout.go
@@ -0,0 +1,33 @@
+package transcribe
+
+import (
+ "context"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+)
+
+// WithTimeout gives every call to b its own deadline. A wedged engine does not answer at
+// all (iguana after a Metal OOM, infra#273), and with only the job's deadline it used up
+// the whole job before a fallback could run -- and Chain then refuses the fallback because
+// the job context has expired. With its own deadline the call fails while the job is still
+// alive, which is exactly when the fallback, or the other engine, can take over.
+//
+// A zero or negative d returns b unchanged. The wrapper is invisible: Name is b's.
+func WithTimeout(b Backend, d time.Duration) Backend {
+ if d <= 0 {
+ return b
+ }
+ return timeoutBackend{Backend: b, d: d}
+}
+
+type timeoutBackend struct {
+ Backend
+ d time.Duration
+}
+
+func (t timeoutBackend) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
+ ctx, cancel := context.WithTimeout(ctx, t.d)
+ defer cancel()
+ return t.Backend.Transcribe(ctx, wavPath)
+}
--
2.54.0
From c9b7df9f95c86f19360ce195e7e308f10792ab4c Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:42:46 +0200
Subject: [PATCH 199/299] feat(config): TAPIR_TRANSCRIBE_ENGINE_TIMEOUT,
default 25 minutes (review F4)
Co-Authored-By: Claude Sonnet 5.5
---
internal/config/uploads.go | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/internal/config/uploads.go b/internal/config/uploads.go
index 442ba26..2785455 100644
--- a/internal/config/uploads.go
+++ b/internal/config/uploads.go
@@ -28,6 +28,10 @@ type UploadConfig struct {
// TranscribeTimeout bounds one job (conversion + transcription); a 90 minute
// recording took ~7.5 minutes on iguana.
TranscribeTimeout time.Duration
+ // TranscribeEngineTimeout bounds ONE engine call, so a wedged engine fails while the
+ // job is still alive and the fallback (or the other engine) can take over. The job
+ // timeout alone cannot do that (ADR-035 review).
+ TranscribeEngineTimeout time.Duration
// UploadTmpDir is where uploads are spooled and converted. Audio is deleted
// after transcription, so this holds at most the in-flight files.
UploadTmpDir string
@@ -62,6 +66,7 @@ const (
defaultTranscribeModel = "iguana/kb-whisper"
defaultTranscribeFallbackModel = "berget/kb-whisper"
defaultTranscribeTimeout = 60 * time.Minute
+ defaultTranscribeEngineTimeout = 25 * time.Minute
defaultMaxUploadedChars = 90000
defaultUploadChatModel = "iguana/gemma4-26b"
)
@@ -107,6 +112,15 @@ func loadUploadConfig() (UploadConfig, error) {
}
u.TranscribeTimeout = timeout
+ engineTimeout, err := durationOr("TAPIR_TRANSCRIBE_ENGINE_TIMEOUT", defaultTranscribeEngineTimeout)
+ if err != nil {
+ return UploadConfig{}, err
+ }
+ if engineTimeout <= 0 {
+ return UploadConfig{}, fmt.Errorf("config: TAPIR_TRANSCRIBE_ENGINE_TIMEOUT must be positive, got %v", engineTimeout)
+ }
+ u.TranscribeEngineTimeout = engineTimeout
+
if v := os.Getenv("TAPIR_RECONCILE_CHECK_ORDER"); v != "" {
on, err := strconv.ParseBool(v)
if err != nil {
--
2.54.0
From 7a468fdc0965b4dd7a433c0db1caff1a6f52a2f0 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:42:46 +0200
Subject: [PATCH 200/299] fix(cmd): every local engine call carries its own
deadline (review F4)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads.go | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/cmd/tapir/uploads.go b/cmd/tapir/uploads.go
index fedc375..13f7d02 100644
--- a/cmd/tapir/uploads.go
+++ b/cmd/tapir/uploads.go
@@ -108,7 +108,12 @@ func buildTranscriber(cfg config.Config) uploads.Transcriber {
// No overall client timeout: a 90 minute recording takes minutes. The deadline is
// the worker's per-job context.
client := &http.Client{}
- primary := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeModel, client)
+ // Each LOCAL engine call gets its own deadline: a wedged engine does not answer, and
+ // without one it used the whole job before the fallback could run.
+ local := func(model string) transcribe.Backend {
+ return transcribe.WithTimeout(transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, model, client), cfg.TranscribeEngineTimeout)
+ }
+ primary := local(cfg.TranscribeModel)
var fallback transcribe.Backend
if cfg.TranscribeFallbackModel != "" && cfg.TranscribeFallbackModel != cfg.TranscribeModel {
fallback = transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeFallbackModel, client)
@@ -117,7 +122,7 @@ func buildTranscriber(cfg config.Config) uploads.Transcriber {
return transcribe.NewChain(primary, fallback)
}
- base := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.ReconcileBaseModel, client)
+ base := local(cfg.ReconcileBaseModel)
// Temperature 0 and a short reply: the adjudicator's accuracy was measured that way.
chat := llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.ReconcileModel, adjudicatorTimeout,
llm.WithTemperature(0), llm.WithMaxTokens(200))
--
2.54.0
From 29b3b725d12aadf19836657a21e49ac6ca5e589f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:43:46 +0200
Subject: [PATCH 201/299] test: the duration limit is checked against what is
converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/ffmpeg_test.go | 32 ++++++++++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/internal/adapters/audio/ffmpeg_test.go b/internal/adapters/audio/ffmpeg_test.go
index 9a4a852..bb8c6c1 100644
--- a/internal/adapters/audio/ffmpeg_test.go
+++ b/internal/adapters/audio/ffmpeg_test.go
@@ -77,3 +77,35 @@ printf data > "$last"`)
assert.Less(t, pw, in, "input options must come BEFORE -i or ffmpeg applies them to the output")
assert.Less(t, fw, in)
}
+
+func TestFFmpegToWAV_CapsTheOutputLengthWhenAMaximumIsSet(t *testing.T) {
+ // The duration limit is a CLAIM in the file's header; a file that lies about it can
+ // convert to hours of audio (adversarial review F2: a 2 h file claiming 10 s became a
+ // 120-minute WAV). The cap is an OUTPUT option, after -i, with a minute of slack.
+ rec := filepath.Join(t.TempDir(), "argv")
+ bin := stub(t, `printf '%s\n' "$@" > `+rec+`
+for a; do last="$a"; done
+printf data > "$last"`)
+
+ err := (&audio.FFmpeg{Bin: bin, MaxSeconds: 5400}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))
+
+ require.NoError(t, err)
+ raw, err := os.ReadFile(rec)
+ require.NoError(t, err)
+ joined := " " + strings.Join(strings.Split(strings.TrimSpace(string(raw)), "\n"), " ") + " "
+ assert.Contains(t, joined, " -t 5460 ", "limit plus a minute of slack")
+ assert.Less(t, strings.Index(joined, " -i /spool/in "), strings.Index(joined, " -t 5460 "), "-t after -i is an output cap; before it, an input one")
+}
+
+func TestFFmpegToWAV_WithNoMaximumThereIsNoLengthCap(t *testing.T) {
+ rec := filepath.Join(t.TempDir(), "argv")
+ bin := stub(t, `printf '%s\n' "$@" > `+rec+`
+for a; do last="$a"; done
+printf data > "$last"`)
+
+ require.NoError(t, (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav")))
+
+ raw, err := os.ReadFile(rec)
+ require.NoError(t, err)
+ assert.NotContains(t, " "+strings.ReplaceAll(string(raw), "\n", " ")+" ", " -t ")
+}
--
2.54.0
From 52351c2cc18cd7438ad4a3182d9a0ef8aabaf268 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:43:46 +0200
Subject: [PATCH 202/299] test: the duration limit is checked against what is
converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker_duration_test.go | 116 +++++++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 internal/uploads/worker_duration_test.go
diff --git a/internal/uploads/worker_duration_test.go b/internal/uploads/worker_duration_test.go
new file mode 100644
index 0000000..a1c94d4
--- /dev/null
+++ b/internal/uploads/worker_duration_test.go
@@ -0,0 +1,116 @@
+package uploads_test
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "git.d-ma.be/mathias/tapir/internal/domain"
+ "git.d-ma.be/mathias/tapir/internal/uploads"
+)
+
+// The duration limit is a claim in the file's header. The worker measures what was
+// really converted and refuses both a recording that is longer than the limit (the
+// header lied low) and one that ends far earlier than the file claimed (it was truncated
+// and would otherwise yield a partial transcript with no warning) -- adversarial review F2.
+
+// sizedConverter writes a (sparse) 16 kHz mono PCM WAV of the given length, without
+// actually allocating it: 32,000 bytes per second plus a 44-byte header.
+type sizedConverter struct{ seconds float64 }
+
+func (c sizedConverter) ToWAV(_ context.Context, _, dst string) error {
+ f, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ return f.Truncate(44 + int64(c.seconds*32000))
+}
+
+func durationWorker(t *testing.T, r *wrig, wavSeconds float64, max time.Duration) *uploads.Worker {
+ t.Helper()
+ opts := []uploads.WorkerOption{}
+ if max > 0 {
+ opts = append(opts, uploads.WithMaxDuration(max))
+ }
+ return uploads.NewWorker(r.st, sizedConverter{seconds: wavSeconds}, r.tr, r.tmp, time.Minute, opts...)
+}
+
+func jobWith(r *wrig, t *testing.T, claimed time.Duration) uploads.Job {
+ j := r.spool(t, "a")
+ j.Duration = claimed
+ return j
+}
+
+func TestWorkerDuration_AConvertedLengthOverTheLimitFailsTheJobBeforeAnyEngineSeesIt(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ w := durationWorker(t, r, 100*60, 90*time.Minute)
+
+ w.Process(context.Background(), jobWith(r, t, 10*time.Second))
+
+ _, failed := r.st.snapshot()
+ assert.Equal(t, domain.UploadErrTooLong, failed["a"], "a header that claimed 10 s must not let 100 minutes through")
+ assert.Empty(t, r.tr.paths, "no engine may be sent audio that is over the limit")
+ assert.Empty(t, r.leftovers(t), "the audio is still deleted")
+}
+
+func TestWorkerDuration_JustOverTheLimitWithinTheSlackStillTranscribes(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ w := durationWorker(t, r, 90*60+20, 90*time.Minute)
+
+ w.Process(context.Background(), jobWith(r, t, 90*time.Minute))
+
+ completed, failed := r.st.snapshot()
+ assert.Empty(t, failed)
+ assert.Contains(t, completed, "a", "ffmpeg rounds: 20 s over a 90-minute limit is not a lie")
+}
+
+func TestWorkerDuration_AFileThatEndsFarEarlierThanItClaimedIsRefusedNotHalfTranscribed(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ w := durationWorker(t, r, 20*60, 90*time.Minute)
+
+ w.Process(context.Background(), jobWith(r, t, 60*time.Minute))
+
+ _, failed := r.st.snapshot()
+ assert.Equal(t, domain.UploadErrIncomplete, failed["a"], "a recording truncated at a third must not return a third of a transcript with no warning")
+ assert.Empty(t, r.tr.paths)
+}
+
+func TestWorkerDuration_AnEstimateThatIsMerelyALittleOffIsNotTruncation(t *testing.T) {
+ tests := []struct {
+ name string
+ claimed time.Duration
+ wav float64
+ }{
+ {"five percent short", 60 * time.Minute, 57 * 60},
+ {"under two minutes short of a long file", 60 * time.Minute, 59 * 60},
+ {"unknown claimed length", 0, 20 * 60},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ w := durationWorker(t, r, tt.wav, 90*time.Minute)
+
+ w.Process(context.Background(), jobWith(r, t, tt.claimed))
+
+ completed, failed := r.st.snapshot()
+ assert.Empty(t, failed)
+ assert.Contains(t, completed, "a")
+ })
+ }
+}
+
+func TestWorkerDuration_WithNoMaximumConfiguredNothingIsMeasured(t *testing.T) {
+ r := newWRig(t, time.Minute)
+ w := durationWorker(t, r, 100*60, 0)
+
+ w.Process(context.Background(), jobWith(r, t, 0))
+
+ completed, _ := r.st.snapshot()
+ assert.Contains(t, completed, "a")
+ require.NotNil(t, w)
+}
--
2.54.0
From 8f62d053ee33e18cee01b32285234feee6f3d053 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:43:46 +0200
Subject: [PATCH 203/299] test: the duration limit is checked against what is
converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/intake_test.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/internal/uploads/intake_test.go b/internal/uploads/intake_test.go
index fb2e39c..4fc8dbd 100644
--- a/internal/uploads/intake_test.go
+++ b/internal/uploads/intake_test.go
@@ -106,7 +106,8 @@ func TestAccept_ValidAudioIsStoredProbedQueuedAndSubmitted(t *testing.T) {
assert.Equal(t, audio, r.pr.seen, "the prober must see the complete uploaded bytes, on disk")
require.Len(t, r.sub.jobs, 1)
job := r.sub.jobs[0]
- assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path}, job)
+ assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path, Duration: 10 * time.Minute}, job,
+ "the job carries what the file CLAIMED, so the worker can compare it with what was converted")
assert.Equal(t, filepath.Dir(job.Path), r.tmp, "the temp file lives in the configured directory")
got, err := os.ReadFile(job.Path)
require.NoError(t, err)
--
2.54.0
From 5640d95f409bef1949f24f0063ae2ad7ae32cdb9 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:43:46 +0200
Subject: [PATCH 204/299] test: the duration limit is checked against what is
converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_handler_test.go | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go
index 158eafd..bc0ca3c 100644
--- a/internal/web/uploads_handler_test.go
+++ b/internal/web/uploads_handler_test.go
@@ -460,3 +460,21 @@ func TestUploadStatus_AVeryLongListOfContestedSpotsIsShortenedWithItsTrueCount(t
assert.NotContains(t, b, ">b119<", "the page does not render every one")
assert.Contains(t, b, "70 more", "120 stored, 50 shown")
}
+
+func TestUploadStatus_ALengthFailureIsExplainedInWordsAndNotAsAGenericError(t *testing.T) {
+ app, _ := newUploadApp(t, audioOK, 200<<20)
+ tests := []struct{ kind, want string }{
+ {"audio_too_long", "longer than the limit"},
+ {"audio_incomplete", "ends much earlier than"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.kind, func(t *testing.T) {
+ id := seedUpload(t, userID, "m-"+tt.kind, "failed", tt.kind)
+
+ b := body(t, do(t, app, get("/uploads/"+id+"/status")))
+
+ assert.Contains(t, b, tt.want)
+ assert.NotContains(t, b, "This upload failed. Please upload it again.")
+ })
+ }
+}
--
2.54.0
From 741eccb5759608c31d61b6afae5a5311b5f83b5e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:43:46 +0200
Subject: [PATCH 205/299] test: the duration limit is checked against what is
converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads_converter_test.go | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 cmd/tapir/uploads_converter_test.go
diff --git a/cmd/tapir/uploads_converter_test.go b/cmd/tapir/uploads_converter_test.go
new file mode 100644
index 0000000..a8bde82
--- /dev/null
+++ b/cmd/tapir/uploads_converter_test.go
@@ -0,0 +1,26 @@
+package main
+
+import (
+ "testing"
+ "time"
+
+ "git.d-ma.be/mathias/tapir/internal/config"
+)
+
+// The converter is told the configured maximum, so a file whose header lies about its
+// length cannot become hours of WAV (adversarial review F2).
+
+func TestNewConverter_IsCappedAtTheConfiguredMaximumDuration(t *testing.T) {
+ var cfg config.Config
+ cfg.FFmpegBin = "/usr/local/bin/ffmpeg"
+ cfg.UploadMaxDuration = 90 * time.Minute
+
+ c := newConverter(cfg)
+
+ if c.Bin != "/usr/local/bin/ffmpeg" {
+ t.Errorf("Bin = %q", c.Bin)
+ }
+ if c.MaxSeconds != 5400 {
+ t.Errorf("MaxSeconds = %v, want 5400", c.MaxSeconds)
+ }
+}
--
2.54.0
From ba39864b2bbdd3f61eecae0e30df734617311e21 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:45:25 +0200
Subject: [PATCH 206/299] feat(domain): UploadErrTooLong and
UploadErrIncomplete -- the two ways a file's claimed length can be wrong
(review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/domain/domain.go | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index 92df9f7..70daa42 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -226,6 +226,14 @@ const (
UploadErrInterrupted UploadErrorKind = "interrupted"
// UploadErrTranscriptionFailed: every configured transcription endpoint failed.
UploadErrTranscriptionFailed UploadErrorKind = "transcription_failed"
+ // UploadErrTooLong: the audio, once converted, is longer than the limit. The limit is
+ // first checked against the file's header, which a hostile or damaged file can lie
+ // about (ADR-035 review).
+ UploadErrTooLong UploadErrorKind = "audio_too_long"
+ // UploadErrIncomplete: the audio, once converted, ends far earlier than the file
+ // claimed: a truncated recording that would otherwise give a partial transcript with
+ // no warning.
+ UploadErrIncomplete UploadErrorKind = "audio_incomplete"
)
// UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is
--
2.54.0
From 12576aa129f3d9c105f7ee70b24d8f3128a10611 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:45:25 +0200
Subject: [PATCH 207/299] feat(audio): MaxSeconds caps the converted length, so
a header that lies cannot become hours of WAV (review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/adapters/audio/ffmpeg.go | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/internal/adapters/audio/ffmpeg.go b/internal/adapters/audio/ffmpeg.go
index 609cd50..a78908a 100644
--- a/internal/adapters/audio/ffmpeg.go
+++ b/internal/adapters/audio/ffmpeg.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
+ "strconv"
"strings"
)
@@ -17,8 +18,17 @@ import (
type FFmpeg struct {
// Bin is the ffmpeg executable ("ffmpeg" resolves via PATH).
Bin string
+ // MaxSeconds caps the converted length when positive. The duration limit is a claim
+ // in the file's header and can be lied about (a 2 h file claiming 10 s converted to a
+ // 120-minute WAV); this bounds what a lie can cost. A minute of slack is added so a
+ // file at the limit is never cut, and the worker measures the result.
+ MaxSeconds int
}
+// lengthSlackSeconds is added to MaxSeconds so ffmpeg's rounding never clips a file that
+// is really at the limit.
+const lengthSlackSeconds = 60
+
// ToWAV writes src, converted, to dst. Video streams are dropped (-vn): by now the
// file has already been probed as audio-only, but a cover-art stream must not end
// up in the WAV.
@@ -29,7 +39,12 @@ func (f *FFmpeg) ToWAV(ctx context.Context, src, dst string) error {
}
// Input restrictions go BEFORE -i: placed after it they would apply to the output.
args := append([]string{"-y", "-v", "error"}, inputRestrictions...)
- args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", dst)
+ args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le")
+ if f.MaxSeconds > 0 {
+ // After -i: an OUTPUT cap. Before it, it would limit what is read, not written.
+ args = append(args, "-t", strconv.Itoa(f.MaxSeconds+lengthSlackSeconds))
+ }
+ args = append(args, "-f", "wav", dst)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.WaitDelay = killGrace
if out, err := cmd.CombinedOutput(); err != nil {
--
2.54.0
From 132f46b0476969b63896143043a3eb275cc17f2f Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:45:25 +0200
Subject: [PATCH 208/299] feat(uploads): the job carries the duration the file
claimed (review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/intake.go | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/internal/uploads/intake.go b/internal/uploads/intake.go
index 56249b7..ebc8b19 100644
--- a/internal/uploads/intake.go
+++ b/internal/uploads/intake.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
+ "time"
"git.d-ma.be/mathias/tapir/internal/domain"
)
@@ -33,6 +34,9 @@ type Job struct {
UserID string
UploadID string
Path string
+ // Duration is what the file CLAIMED when probed. The worker compares it with what
+ // was really converted; zero means unknown (ADR-035 review).
+ Duration time.Duration
}
// Submitter hands accepted jobs to the in-process worker (ADR-033).
@@ -103,7 +107,7 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
if err != nil {
return "", fmt.Errorf("uploads: create: %w", err)
}
- if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path}); err != nil {
+ if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path, Duration: probe.Duration}); err != nil {
// A row that will never run must not sit queued: that is the stuck spinner.
if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrInterrupted); ferr != nil {
return "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr)
--
2.54.0
From 16cd246241680e0d168f6ead6af376a55b723e81 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:45:25 +0200
Subject: [PATCH 209/299] feat(uploads): the worker measures the converted
audio against the limit and the claim (review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/uploads/worker.go | 61 ++++++++++++++++++++++++++++++++------
1 file changed, 52 insertions(+), 9 deletions(-)
diff --git a/internal/uploads/worker.go b/internal/uploads/worker.go
index eb6d78c..6a49646 100644
--- a/internal/uploads/worker.go
+++ b/internal/uploads/worker.go
@@ -54,6 +54,12 @@ func WithHeartbeat(beat, still time.Duration) WorkerOption {
return func(w *Worker) { w.beatEvery, w.stillEvery = beat, still }
}
+// WithMaxDuration makes the worker measure the converted audio and fail a job whose real
+// length exceeds the limit, or falls far short of what the file claimed. Zero (the
+// default) measures nothing. The limit is first checked against the file's header, which
+// can lie (ADR-035 review).
+func WithMaxDuration(d time.Duration) WorkerOption { return func(w *Worker) { w.maxDuration = d } }
+
// WithLogger replaces slog.Default() as the worker's logger.
func WithLogger(l *slog.Logger) WorkerOption { return func(w *Worker) { w.log = l } }
@@ -88,6 +94,7 @@ type Worker struct {
log *slog.Logger
beatEvery, stillEvery time.Duration
+ maxDuration time.Duration
mu sync.Mutex
queue []Job
@@ -191,7 +198,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
log.Error("upload: start transcribing", "err", err)
- w.fail(ctx, job, log)
+ w.fail(ctx, job, log, domain.UploadErrTranscriptionFailed)
return
}
@@ -218,17 +225,17 @@ func (w *Worker) Process(ctx context.Context, job Job) {
// abort records where the job stopped. A shutdown (the parent context ended) is not
// a failure: the row is left for the start-up sweep, so the step stays as it was.
- abort := func() {
+ abort := func(kind domain.UploadErrorKind) {
if ctx.Err() == nil {
tr.FailRunning()
}
- w.fail(ctx, job, log)
+ w.fail(ctx, job, log, kind)
}
wav, err := w.tempWAV()
if err != nil {
log.Error("upload: temp wav", "err", err)
- abort()
+ abort(domain.UploadErrTranscriptionFailed)
return
}
defer func() { _ = os.Remove(wav) }()
@@ -238,7 +245,12 @@ func (w *Worker) Process(ctx context.Context, job Job) {
tr.Begin("audio", 0)
if err := w.conv.ToWAV(jobCtx, job.Path, wav); err != nil {
log.Error("upload: convert", "kind", errkind.Of(err))
- abort()
+ abort(domain.UploadErrTranscriptionFailed)
+ return
+ }
+ if kind, bad := w.audioLengthProblem(wav, job.Duration); bad {
+ abort(kind)
+ log.Error("upload: converted audio length rejected", "kind", string(kind))
return
}
tr.Done("audio")
@@ -249,7 +261,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
t, err := w.tr.Transcribe(progress.With(jobCtx, tr), wav)
if err != nil {
log.Error("upload: transcribe", "kind", errkind.Of(err), "elapsed", time.Since(start).Round(time.Second))
- abort()
+ abort(domain.UploadErrTranscriptionFailed)
return
}
if !planned {
@@ -259,7 +271,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
tr.Begin("save", 0)
if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
log.Error("upload: store transcript", "err", err)
- abort()
+ abort(domain.UploadErrTranscriptionFailed)
return
}
tr.Done("save")
@@ -270,11 +282,11 @@ func (w *Worker) Process(ctx context.Context, job Job) {
// fail records a transcription failure — unless the PARENT context ended, which
// is a shutdown, not a failure: the row is left for the start-up sweep.
-func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger) {
+func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger, kind domain.UploadErrorKind) {
if ctx.Err() != nil {
return
}
- if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, domain.UploadErrTranscriptionFailed); err != nil {
+ if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, kind); err != nil {
log.Error("upload: record failure", "err", err)
}
}
@@ -291,3 +303,34 @@ func (w *Worker) tempWAV() (string, error) {
}
return name, nil
}
+
+const (
+ // tooLongSlack is how far over the limit the converted audio may be before the claim
+ // in the file's header is called a lie: ffmpeg rounds.
+ tooLongSlack = 30 * time.Second
+ // A file counts as truncated when it converts to under 80% of the length it claimed
+ // AND more than two minutes short. Probe estimates for variable-bitrate files are
+ // imprecise; refusing real recordings would be worse than the problem.
+ incompleteBelow = 0.8
+ incompleteShortfall = 2 * time.Minute
+)
+
+// audioLengthProblem measures the converted WAV (16 kHz mono 16-bit PCM is 32,000 bytes a
+// second after a 44-byte header) against the limit and against what the file claimed.
+func (w *Worker) audioLengthProblem(wav string, claimed time.Duration) (domain.UploadErrorKind, bool) {
+ if w.maxDuration <= 0 {
+ return "", false
+ }
+ info, err := os.Stat(wav)
+ if err != nil {
+ return "", false
+ }
+ got := time.Duration(float64(max(info.Size()-44, 0)) / 32000 * float64(time.Second))
+ switch {
+ case got > w.maxDuration+tooLongSlack:
+ return domain.UploadErrTooLong, true
+ case float64(got) < incompleteBelow*float64(claimed) && claimed-got > incompleteShortfall:
+ return domain.UploadErrIncomplete, true
+ }
+ return "", false
+}
--
2.54.0
From 07050cf9b67ac9484c6be96311a9d9f6f4498e6e Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:45:25 +0200
Subject: [PATCH 210/299] feat(web): say in words why a recording was refused
for its real length (review F2)
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/internal/web/uploads.go b/internal/web/uploads.go
index 9b4d8e2..b139846 100644
--- a/internal/web/uploads.go
+++ b/internal/web/uploads.go
@@ -203,6 +203,10 @@ func uploadFailureMessage(k domain.UploadErrorKind) string {
switch k {
case domain.UploadErrInterrupted:
return "The server restarted before this recording was transcribed. Please upload it again."
+ case domain.UploadErrTooLong:
+ return "This recording is longer than the limit once its audio is read (the file's header said otherwise). Please upload a shorter recording."
+ case domain.UploadErrIncomplete:
+ return "This recording ends much earlier than the file says it should; it may be cut off or damaged. Please upload the complete recording."
case domain.UploadErrTranscriptionFailed:
return "Transcription failed on every available service. Please upload it again later."
default:
--
2.54.0
From 6b83d72655c7c135037f3db74fe8aeba1117ab51 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:45:25 +0200
Subject: [PATCH 211/299] fix(cmd): the converter is capped and the worker
measures, at the configured maximum (review F2)
Co-Authored-By: Claude Sonnet 5.5
---
cmd/tapir/uploads.go | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/cmd/tapir/uploads.go b/cmd/tapir/uploads.go
index 13f7d02..bda8a19 100644
--- a/cmd/tapir/uploads.go
+++ b/cmd/tapir/uploads.go
@@ -55,8 +55,8 @@ func buildUploads(ctx context.Context, cfg config.Config, st uploadsStore, log *
log.Warn("uploads: marked interrupted by a restart (audio is not retained; they must be re-uploaded)", "count", n)
}
- worker := uploads.NewWorker(st, &audio.FFmpeg{Bin: cfg.FFmpegBin}, buildTranscriber(cfg),
- cfg.UploadTmpDir, cfg.TranscribeTimeout)
+ worker := uploads.NewWorker(st, newConverter(cfg), buildTranscriber(cfg),
+ cfg.UploadTmpDir, cfg.TranscribeTimeout, uploads.WithMaxDuration(cfg.UploadMaxDuration))
intake := uploads.NewIntake(st, &audio.FFprobe{Bin: cfg.FFprobeBin}, worker,
uploadLimits(cfg), cfg.UploadTmpDir)
return intake, worker, nil
@@ -94,6 +94,12 @@ func wireUploads(app *web.App, cfg config.Config, in *uploads.Intake) {
app.UploadChatModels = uploadChatModels(cfg)
}
+// newConverter is the ffmpeg adapter capped at the configured maximum duration, so a file
+// whose header lies about its length cannot become hours of WAV (ADR-035 review).
+func newConverter(cfg config.Config) *audio.FFmpeg {
+ return &audio.FFmpeg{Bin: cfg.FFmpegBin, MaxSeconds: int(cfg.UploadMaxDuration.Seconds())}
+}
+
// adjudicatorTimeout bounds one adjudication call. A call takes ~2 s on gemma4-26b and ~10 s
// on 31b; this is the wait before an unresponsive model is counted as an error.
const adjudicatorTimeout = 2 * time.Minute
--
2.54.0
From dc100d1cd43d777ddd91e1b607d9157c5ff07ef5 Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:45:25 +0200
Subject: [PATCH 212/299] test(web): the job carries the claimed duration
Co-Authored-By: Claude Sonnet 5.5
---
internal/web/uploads_handler_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go
index bc0ca3c..82d19a9 100644
--- a/internal/web/uploads_handler_test.go
+++ b/internal/web/uploads_handler_test.go
@@ -132,7 +132,7 @@ func TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt(t *testing.T) {
assert.Equal(t, "Sprint review", title)
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
require.Len(t, sub.jobs, 1)
- assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path}, sub.jobs[0])
+ assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path, Duration: audioOK.Duration}, sub.jobs[0])
spooled, err := os.ReadFile(sub.jobs[0].Path)
require.NoError(t, err)
assert.Equal(t, "synthetic audio bytes", string(spooled))
--
2.54.0
From fdcdee74135ccd5189ced8e5d014c67e2dbe7b3c Mon Sep 17 00:00:00 2001
From: Mathias
Date: Fri, 2 Oct 2026 16:45:25 +0200
Subject: [PATCH 213/299] test(cmd): the uploads store double records the kind
a job failed with
Co-Authored-By: Claude Sonnet 5.5