Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it. - ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only). - ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set. - ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments. - Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check. - Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment. Closes #33, #34, #42, #43, #44. Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #39.
This commit is contained in:
+482
-1
@@ -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,480 @@ 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": "<srt>"}`).
|
||||
- **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. 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; 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.**
|
||||
|
||||
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
|
||||
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, 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. 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
|
||||
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, **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
|
||||
lists **local models only** (amended by ADR-035 after the adversarial review found it listed
|
||||
the cloud model too: the whole private transcript goes to the one chosen), 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;
|
||||
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, 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`).
|
||||
- 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
|
||||
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*: 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
|
||||
ADR-016 window, it was the interesting thing, not the useful one — remove it.
|
||||
|
||||
**Open items this ADR creates.**
|
||||
- **DECIDED (Mathias, 2026-10-02): option A.** Static ffmpeg and ffprobe 7.0.2 are copied into the
|
||||
distroless image, pinned by version and sha256 (Dockerfile in this PR; #34). Measured at that time:
|
||||
**about 160 MB** added, not the 70 MB first estimated (80 MB per binary); the build is **GPLv3**;
|
||||
the checksum proves the bytes reviewed, not the publisher; upstream's `releases/` URL moves on, so
|
||||
vendoring the verified tarball is the durable fix (not done). Uploads stay off until the deployment
|
||||
sets `TAPIR_UPLOADS_ENABLED`. *The question as it was put:* *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.
|
||||
- **DECIDED (Mathias, 2026-10-02): option A, enabled by default (#35).** Scope: the audio
|
||||
transcription fallback only. Reconciliation text and chat over a recording never go to a cloud
|
||||
model (ADR-034, ADR-035). *The question as it was put:* *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.
|
||||
|
||||
---
|
||||
|
||||
## 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 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.**
|
||||
|
||||
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 (**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
|
||||
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_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
|
||||
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.
|
||||
|
||||
**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
|
||||
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.
|
||||
**Decision (Mathias, 2026-10-02), on the first result below: keep it as an opt-in feature**, reading
|
||||
the bar as 95% of decided items, with the second condition to be answered on the next real run.
|
||||
For deployment the reconciler is `iguana/gemma4-31b` (97% against the speaker's answers, about 22
|
||||
minutes for the 63.6-minute recording with Low Power Mode off). A cloud reconciler was raised and is
|
||||
**not adopted**: it would send private transcript text to a cloud API on every upload; recorded as
|
||||
an evaluation question in #45.
|
||||
**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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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".
|
||||
|
||||
8. **Hardening from the adversarial review** (`skills:pr-gatekeeper`, head `7029dd1`: BLOCK; recorded on
|
||||
PR #39). Each finding was reproduced by a failing test first.
|
||||
- **Chat over a recording offers and accepts local models only** (it listed `berget/mistral-small`);
|
||||
`TAPIR_UPLOAD_CHAT_MODEL` must be local at start-up; with no local model, nothing is sent.
|
||||
- **A model alias counts as local only if it is one plain name after `koala/` or `iguana/`**
|
||||
(`koala/../berget/x`, `koala//x`, `koala/x?host=…` were accepted by a bare prefix check).
|
||||
- **An error reaches a log line, the row or the page as a kind** (`timeout`, `canceled`, `http_502`,
|
||||
`network`, `error`), never its text, which can hold a provider's response body, the spool path or
|
||||
a word from the transcript (`internal/errkind`). The stored reconcile reason and an engine's
|
||||
failure reason are kinds; the page no longer shows provider text.
|
||||
- **Every local engine call has its own deadline** (`TAPIR_TRANSCRIBE_ENGINE_TIMEOUT`, default 20
|
||||
minutes: two wedged engines in sequence then leave 20 of the job's 60 minutes for a fallback),
|
||||
so a wedged engine fails while the job lives and the fallback can run; the job's own
|
||||
deadline or a cancel still never reaches the cloud.
|
||||
- **The duration limit is checked against what is converted**, not the file's header: ffmpeg is
|
||||
capped at the limit plus a minute, a longer result fails the job (`audio_too_long`), and a result
|
||||
under 80% of the claimed length and more than two minutes short is **transcribed and noted** on
|
||||
the audio step ("the audio is 20 min long; the file claimed 1 h 19 min"), not refused. The second
|
||||
adversarial review showed a complete variable-bitrate MP3 whose header over-estimated its length
|
||||
by 4x was wrongly refused by the first version of this check; a note keeps a truncated file
|
||||
visible without turning away real recordings.
|
||||
- **The queue is bounded**, 20 waiting in total and 3 per user, and its room is checked before the
|
||||
upload is spooled (not after 200 MB and an ffprobe run); a full queue is a 429 with a typed reason,
|
||||
never a stuck row or a server error.
|
||||
- **The configured upload chat model is callable.** It is added to the shared chat service's model
|
||||
list when uploads are on and it is local, because the service re-maps a model it does not offer
|
||||
(a valid `iguana/gemma4-31b` was being silently swapped for `koala/phi4-mini`). The video chat's own switcher
|
||||
lists that local model too while uploads are on.
|
||||
- **A client that hangs up mid-upload is not an error**: the handler logs INFO and writes nothing
|
||||
(rendering the error page ran a database read on a cancelled context and logged a false ERROR). The
|
||||
400 "cut off" answer is keyed on a marker for errors from *reading the client's body*
|
||||
(`uploads.ErrClientRead`), not on `io.ErrUnexpectedEOF`, which is also what a database drop looks like.
|
||||
- **A note is not hidden**: a finished upload's record opens by default when any step carries a note.
|
||||
- **Bad input is a 4xx**: a non-UUID id is a 404, a malformed or cut-off multipart body is a 400
|
||||
whose text is not logged, and a title with control bytes, bidirectional controls or invalid UTF-8 is
|
||||
cleaned (emoji joiners, Swedish letters and variation selectors are kept), not a 500.
|
||||
- **The image was built and inspected** (buildah on koala, 2026-10-02): the first build FAILED
|
||||
(`tar: xz: Cannot exec`, the golang image has no xz); fixed, rebuilt at 180 MB, ffmpeg and ffprobe
|
||||
run as `nonroot` in distroless. CI does not build the image on a pull request
|
||||
(`if: github.event_name != 'pull_request'`), so the first CI build of it is the merge to main.
|
||||
Not done, filed as follow-ups: a vendored ffmpeg tarball, CSRF cookie review, ffprobe CPU and
|
||||
stream-count limits, mov external references, `alive_at` clock skew, a hung database blocking the
|
||||
tracker, an unbounded gateway response body, a user-facing notice for a recording that is partial but
|
||||
inside the tolerance.
|
||||
|
||||
**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
|
||||
@@ -1338,6 +1814,11 @@ 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 |
|
||||
| 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.
|
||||
|
||||
Reference in New Issue
Block a user