Compare commits

..
94 Commits
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 f25acebe4c feat(reconcile): merge two transcripts with a model choosing between disagreements
CI / Lint / Test / Vet (pull_request) Successful in 20s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
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 <noreply@anthropic.com>
2026-10-01 23:20:12 +02:00
mathiasandClaude Sonnet 5.5 7f9e967ad0 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 <noreply@anthropic.com>
2026-10-01 23:20:12 +02:00
mathiasandClaude Sonnet 5.5 0fead2e102 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 <noreply@anthropic.com>
2026-10-01 23:19:54 +02:00
mathiasandClaude Sonnet 5.5 a1e16fa658 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 <noreply@anthropic.com>
2026-10-01 23:19:22 +02:00
mathiasandClaude Sonnet 5.5 b20558ffa6 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 <noreply@anthropic.com>
2026-10-01 23:19:00 +02:00
mathiasandClaude Sonnet 5.5 5e54c9beff 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 <noreply@anthropic.com>
2026-10-01 23:18:11 +02:00
mathiasandClaude Sonnet 5.5 2bfe84ee64 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 <noreply@anthropic.com>
2026-10-01 23:18:11 +02:00
mathiasandClaude Sonnet 5.5 481321a75c 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 <noreply@anthropic.com>
2026-10-01 23:17:42 +02:00
mathiasandClaude Sonnet 5.5 2600e6c45a 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 <noreply@anthropic.com>
2026-10-01 23:17:21 +02:00
mathiasandClaude Sonnet 5.5 4e43327792 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 <noreply@anthropic.com>
2026-10-01 23:16:55 +02:00
mathiasandClaude Sonnet 5.5 92a39da45c 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 <noreply@anthropic.com>
2026-10-01 23:16:31 +02:00
mathiasandClaude Sonnet 5.5 aa5607b002 feat(reconcile): tokenise for word-level comparison
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:16:31 +02:00
mathiasandClaude Sonnet 5.5 984d92e3a9 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 <noreply@anthropic.com>
2026-10-01 23:15:44 +02:00
mathiasandClaude Sonnet 5.5 00fd884c6c 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 <noreply@anthropic.com>
2026-10-01 23:15:44 +02:00
mathiasandClaude Sonnet 5.5 de9871c9c3 docs(adr): ADR-034 transcribe with two engines, reconcile with a local model
CI / Lint / Test / Vet (pull_request) Successful in 29s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
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 <noreply@anthropic.com>
2026-10-01 23:15:12 +02:00
mathiasandClaude Sonnet 5.5 e182a6b07d 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 <noreply@anthropic.com>
2026-10-01 23:14:27 +02:00
mathiasandClaude Sonnet 5.5 ebc6cc3ff8 docs(research): kb-whisper vs pianissimo on one real hour of Swedish speech
CI / Lint / Test / Vet (pull_request) Successful in 22s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
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 <noreply@anthropic.com>
2026-10-01 19:45:56 +02:00
mathiasandClaude Sonnet 5.5 d11afb5ff1 fix(transcribe): rebuild the transcript from segments so split words are whole
CI / Lint / Test / Vet (pull_request) Successful in 22s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
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 <noreply@anthropic.com>
2026-10-01 19:35:14 +02:00
mathiasandClaude Sonnet 5.5 500e11962b 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 <noreply@anthropic.com>
2026-10-01 19:34:54 +02:00
mathiasandClaude Sonnet 5.5 206fc2f5ca feat(web): log upload rejections by reason only
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 19:28:10 +02:00
mathiasandClaude Sonnet 5.5 5368d5f8c2 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 <noreply@anthropic.com>
2026-10-01 19:28:06 +02:00
mathiasandClaude Sonnet 5.5 365dff5c85 docs(data-model): had_video
CI / Lint / Test / Vet (pull_request) Successful in 26s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 18:43:55 +02:00
mathiasandClaude Sonnet 5.5 adaaa9918f test(acceptance): map the video-track scenarios to their covering tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 18:43:55 +02:00
mathiasandClaude Sonnet 5.5 364a28eecd docs(use-cases): a video track is accepted and the user is told; no-audio is rejected
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 18:43:55 +02:00
mathiasandClaude Sonnet 5.5 1fa7ca3a8c 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 <noreply@anthropic.com>
2026-10-01 18:43:48 +02:00
mathiasandClaude Sonnet 5.5 cca0a7461b 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 <noreply@anthropic.com>
2026-10-01 18:43:14 +02:00
mathiasandClaude Sonnet 5.5 5175496809 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 <noreply@anthropic.com>
2026-10-01 18:43:14 +02:00
mathiasandClaude Sonnet 5.5 f102199097 feat(uploads): Intake passes whether the file had a video track to the store
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 18:43:00 +02:00
mathiasandClaude Sonnet 5.5 01eab1ab6f 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 <noreply@anthropic.com>
2026-10-01 18:43:00 +02:00
mathiasandClaude Sonnet 5.5 85cf89c86c feat(store): CreateUpload records had_video; Get/List return it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 18:42:50 +02:00
mathiasandClaude Sonnet 5.5 603d02e504 feat(domain): UploadedTranscript.HadVideo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 18:42:40 +02:00
mathiasandClaude Sonnet 5.5 3c4d2880f3 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 <noreply@anthropic.com>
2026-10-01 18:42:33 +02:00
mathiasandClaude Sonnet 5.5 1157854441 test(cmd): fake store follows the CreateUpload signature (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 18:42:33 +02:00
mathiasandClaude Sonnet 5.5 7aec2538be 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 <noreply@anthropic.com>
2026-10-01 18:42:33 +02:00
mathiasandClaude Sonnet 5.5 eb55c7d9d1 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 <noreply@anthropic.com>
2026-10-01 18:42:13 +02:00
mathiasandClaude Sonnet 5.5 ab62cdaf1c 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 <noreply@anthropic.com>
2026-10-01 18:42:13 +02:00
mathiasandClaude Sonnet 5.5 6cbb7b2df0 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 <noreply@anthropic.com>
2026-10-01 18:42:04 +02:00
mathiasandClaude Sonnet 5.5 1e192e53ef 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 <noreply@anthropic.com>
2026-10-01 18:41:49 +02:00
mathiasandClaude Sonnet 5.5 6ebb4e49e9 test(audio): opt-in live ffprobe check on hostile playlists and real audio
CI / Lint / Test / Vet (pull_request) Successful in 40s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
Skipped unless TAPIR_LIVE_FFPROBE is set. Synthetic/crafted files only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:14:29 +02:00
mathiasandClaude Sonnet 5.5 291a360a83 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 <noreply@anthropic.com>
2026-10-01 17:14:29 +02:00
mathiasandClaude Sonnet 5.5 4a0c51d877 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 <noreply@anthropic.com>
2026-10-01 17:13:54 +02:00
mathiasandClaude Sonnet 5.5 1c726a810e docs(homelab): upload chat budget is 90000 and the chat model is gemma4-26b
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:08:33 +02:00
mathiasandClaude Sonnet 5.5 77fe6954e5 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 <noreply@anthropic.com>
2026-10-01 17:08:28 +02:00
mathiasandClaude Sonnet 5.5 4d3e2a471a 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 <noreply@anthropic.com>
2026-10-01 17:08:13 +02:00
mathiasandClaude Sonnet 5.5 548bfaee85 test(e2e): use the same wireUploads as serve
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:07:41 +02:00
mathiasandClaude Sonnet 5.5 ad2e3e0e8e 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 <noreply@anthropic.com>
2026-10-01 17:07:41 +02:00
mathiasandClaude Sonnet 5.5 e6738bcd3e feat(cmd): wireUploads -- limits, chat budget and chat model into the web App
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:07:40 +02:00
mathiasandClaude Sonnet 5.5 3bb4e30f4c 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 <noreply@anthropic.com>
2026-10-01 17:07:33 +02:00
mathiasandClaude Sonnet 5.5 6c39ab86cf 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 <noreply@anthropic.com>
2026-10-01 17:07:33 +02:00
mathiasandClaude Sonnet 5.5 4b6ac2a213 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 <noreply@anthropic.com>
2026-10-01 17:07:20 +02:00
mathiasandClaude Sonnet 5.5 af66419a44 feat(config): measured chat budget (90,000) and upload chat model (gemma4-26b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:07:20 +02:00
mathiasandClaude Sonnet 5.5 4b09dc8f36 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 <noreply@anthropic.com>
2026-10-01 17:07:09 +02:00
mathiasandClaude Sonnet 5.5 8f16ab671a 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 <noreply@anthropic.com>
2026-10-01 17:02:11 +02:00
mathiasandClaude Sonnet 5.5 39aeda7c69 docs(homelab): speech-to-text endpoints, the ingress 413, and the upload knobs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:02:05 +02:00
mathiasandClaude Sonnet 5.5 0fc25a24ed 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 <noreply@anthropic.com>
2026-10-01 17:02:05 +02:00
mathiasandClaude Sonnet 5.5 07375f75de test(acceptance): map audio_upload.feature scenarios to their covering tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:01:38 +02:00
mathiasandClaude Sonnet 5.5 f9d6df9f84 docs(use-cases): audio_upload.feature -- scenarios for #33 (ADR-033)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:01:20 +02:00
mathiasandClaude Sonnet 5.5 3464cdf876 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 <noreply@anthropic.com>
2026-10-01 17:01:06 +02:00
mathiasandClaude Sonnet 5.5 4fdb212cef 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 <noreply@anthropic.com>
2026-10-01 16:58:25 +02:00
mathiasandClaude Sonnet 5.5 a39f58b730 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 <noreply@anthropic.com>
2026-10-01 16:58:25 +02:00
mathiasandClaude Sonnet 5.5 10ca45fc0a 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 <noreply@anthropic.com>
2026-10-01 16:57:47 +02:00
mathiasandClaude Sonnet 5.5 c72d366935 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 <noreply@anthropic.com>
2026-10-01 16:57:01 +02:00
mathiasandClaude Sonnet 5.5 ec29c5e761 style(chat): gofumpt the new test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:57:01 +02:00
mathiasandClaude Sonnet 5.5 867afefb08 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 <noreply@anthropic.com>
2026-10-01 16:56:51 +02:00
mathiasandClaude Sonnet 5.5 1c5d85f5c6 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 <noreply@anthropic.com>
2026-10-01 16:56:22 +02:00
mathiasandClaude Sonnet 5.5 9b3175db09 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 <noreply@anthropic.com>
2026-10-01 16:56:21 +02:00
mathiasandClaude Sonnet 5.5 eb9357a5de test(cmd): start-up removes spooled audio left by a crash (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:56:11 +02:00
mathiasandClaude Sonnet 5.5 3e802adade test(cmd): buildUploads wiring -- off by default, sweep first, private spool (red)
Red: buildUploads is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:55:55 +02:00
mathiasandClaude Sonnet 5.5 b8798dbd5d 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 <noreply@anthropic.com>
2026-10-01 16:55:32 +02:00
mathiasandClaude Sonnet 5.5 fb521f2b89 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 <noreply@anthropic.com>
2026-10-01 16:55:11 +02:00
mathiasandClaude Sonnet 5.5 a8dadb8ca4 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 <noreply@anthropic.com>
2026-10-01 16:54:52 +02:00
mathiasandClaude Sonnet 5.5 3cfc95640e 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 <noreply@anthropic.com>
2026-10-01 16:54:52 +02:00
mathiasandClaude Sonnet 5.5 52417e6b47 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 <noreply@anthropic.com>
2026-10-01 16:47:22 +02:00
mathiasandClaude Sonnet 5.5 4352446f36 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 <noreply@anthropic.com>
2026-10-01 16:46:34 +02:00
mathiasandClaude Sonnet 5.5 3bc33fc34c test(audio): ffmpeg transcode to 16 kHz mono PCM, always (red)
Red: audio.FFmpeg is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:46:14 +02:00
mathiasandClaude Sonnet 5.5 2227cfff7e 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 <noreply@anthropic.com>
2026-10-01 16:46:14 +02:00
mathiasandClaude Sonnet 5.5 cae423750c 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 <noreply@anthropic.com>
2026-10-01 16:44:34 +02:00
mathiasandClaude Sonnet 5.5 bb285d8757 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 <noreply@anthropic.com>
2026-10-01 16:43:51 +02:00
mathiasandClaude Sonnet 5.5 e12a5e7e89 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 <noreply@anthropic.com>
2026-10-01 16:42:58 +02:00
mathiasandClaude Sonnet 5.5 1bc98813fc 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 <noreply@anthropic.com>
2026-10-01 16:42:40 +02:00
mathiasandClaude Sonnet 5.5 e1ed6ea6f4 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 <noreply@anthropic.com>
2026-10-01 16:41:42 +02:00
mathiasandClaude Sonnet 5.5 f84389028d 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 <noreply@anthropic.com>
2026-10-01 16:40:51 +02:00
mathiasandClaude Sonnet 5.5 0ab067c510 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 <noreply@anthropic.com>
2026-10-01 16:40:29 +02:00
mathiasandClaude Sonnet 5.5 1910d3dfb5 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 <noreply@anthropic.com>
2026-10-01 16:40:13 +02:00
mathiasandClaude Sonnet 5.5 b98e9df462 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 <noreply@anthropic.com>
2026-10-01 16:40:03 +02:00
mathiasandClaude Sonnet 5.5 e3e1f17c40 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 <noreply@anthropic.com>
2026-10-01 16:39:51 +02:00
mathiasandClaude Sonnet 5.5 f42b8e23fc 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 <noreply@anthropic.com>
2026-10-01 16:39:20 +02:00
mathiasandClaude Sonnet 5.5 836a501576 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 <noreply@anthropic.com>
2026-10-01 16:38:25 +02:00
mathiasandClaude Sonnet 5.5 c29c0a7127 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 <noreply@anthropic.com>
2026-10-01 16:37:39 +02:00
mathiasandClaude Sonnet 5.5 4b557209c8 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 <noreply@anthropic.com>
2026-10-01 16:37:30 +02:00
mathiasandClaude Sonnet 5.5 e36c4c1e0c 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 <noreply@anthropic.com>
2026-10-01 16:36:50 +02:00
mathiasandClaude Sonnet 5.5 066ae6966a 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 <noreply@anthropic.com>
2026-10-01 16:36:22 +02:00
mathiasandClaude Sonnet 5.5 38b3f1cf33 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 <noreply@anthropic.com>
2026-10-01 16:35:04 +02:00
mathiasandClaude Sonnet 5.5 feb108def3 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 <noreply@anthropic.com>
2026-10-01 16:33:34 +02:00
111 changed files with 714 additions and 10091 deletions
+1 -17
View File
@@ -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`, - Adapters present under `internal/adapters/`: `youtube` (captions-first `VideoSource`,
timedtext/InnerTube acquisition per ADR-010), `summarizer` + `llm` (the copied AI router, timedtext/InnerTube acquisition per ADR-010), `summarizer` + `llm` (the copied AI router,
now a resilient endpoint chain — local primary → local fallback → external worst-case, 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–019), 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 `secrets` (file-backed `SecretStore`). The brain HTTP sink (ADR-005) is the remaining
optional sink. optional sink.
- Stage 1 is open (ADR-012): multi-user with **DB-enforced** isolation — Postgres RLS `FORCE`d - Stage 1 is open (ADR-012): multi-user with **DB-enforced** isolation — Postgres RLS `FORCE`d
@@ -118,22 +118,6 @@ 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 in `uploaded_transcripts`; the audio is never retained; chat reuses `chat.Service`. The
isolation test pins the **exact set** of non-RLS tables (`TestRowSecurityOffTablesAreExactlyTheKnownSet`). 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. The runtime image does not yet contain ffmpeg — that is a pending decision recorded in ADR-033.
Two-engine reconciliation (ADR-034, migration 019, off unless `TAPIR_RECONCILE_BASE_MODEL`):
`internal/reconcile` (word diff, disputes, a local-model adjudicator) and
`transcribe.Ensemble`; every model must be a local alias, enforced at start-up.
Progress and logging (ADR-035, migration 020): `internal/progress.Tracker` is the ONE writer of a
job's phases (saved to the row for the page, logged by the same call); engines and the reconciler
report through `progress.From(ctx)`, so `uploads.Transcriber` is unchanged, and a transcriber may
implement `uploads.Planner` to declare its own phases. The page tells running / quiet / lost from
two timestamps (`alive_at` heartbeat vs `progress_at`) and never shows a percentage that does not
come from real counts. **Nothing logged may carry transcript text, words from it, or the spool path.**
Errors reach a log, a row or a page as `errkind.Of(err)` (a kind such as `timeout` or `http_502`),
never `err.Error()`: error text can hold a provider's response body, the spool path or transcript
words (ADR-035 decision 8). Local-only for anything that carries transcript text: `config.IsLocalModel`.
`TAPIR_SHOT_DIR=… go test -run TestRenderUploadProgressStatesForReview ./internal/web` renders every
state for a screenshot review.
**Lint caveat:** `task check` does not fail on lint (#41: the Taskfile `|| echo` swallows
findings) — run `golangci-lint run ./...` yourself until that is fixed.
- `cmd/tapir` subcommands: `list`, `show`, `auth` (interactive host-side OAuth), `run` (batch - `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 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. transport over the unchanged engine/ports — ADR-003). `tapir env` prints config.
+15 -199
View File
@@ -1434,12 +1434,7 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
empty disables), recorded per row, and called out in Open items rather than defaulted empty disables), recorded per row, and called out in Open items rather than defaulted
silently. silently.
- Swedish only (`language=sv`). Language detection and routing (#28 decision 2) are not - Swedish only (`language=sv`). Language detection and routing (#28 decision 2) are not
touched. **Amended 2026-10-04 (#61, interim):** the language is now a per-upload choice on touched.
the form, `sv` (preselected, and what an upload that names none gets) or `auto` ("Other":
`language` is omitted so the engine detects it), a closed set held by the form, the request
code and a CHECK constraint on `uploaded_transcripts.language` (migration 021). Automatic
detection and a general-purpose engine for other languages are still not built (#61); an
`auto` upload goes to the same configured engine, which is not tuned for it.
- `-H 'Expect:'`, multipart field `file` — encoded in the adapter and its tests, not rediscovered. - `-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 7. **Jobs run in-process, one at a time (ADR-018 stands).** Transcription is minutes (~7.5 min
@@ -1452,25 +1447,6 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
re-uploads. The single-replica assumption gains a second dependent (the semaphore is re-uploads. The single-replica assumption gains a second dependent (the semaphore is
process-local) and is recorded here. process-local) and is recorded here.
*Amended 2026-10-03 (#50, #47).* The process now handles SIGTERM (what Kubernetes sends on
every deploy; only SIGINT was handled, so shutdown code never ran). On shutdown the running job
is recorded `interrupted` at once and the process waits, bounded to 10 s each, for in-flight
requests and for that write; the start-up sweep stays the backstop for a kill. **Known
limitation, deliberately not built: a deploy still loses a job in flight** (finishing it needs
a drain of up to the job time, a ~330 s termination grace and `strategy: Recreate`, i.e.
downtime on every deploy; a 90-minute file takes ~4.5 min and a deploy lands on a running job
perhaps one time in five at current volume). Jobs still queued at shutdown stay `queued` until
the next start's sweep. The user re-uploads, with an honest message.
*Receive limits (#47).* Request bodies are received at most 4 at a time, 2 per user, answered
as busy beyond that. Together with the 20-job queue and the running job that bounds the spool
at about (4 + 20 + 1) x 200 MiB = 5 GiB of the 6 Gi `emptyDir` (infra): raising any of those or
`TAPIR_UPLOAD_MAX_BYTES` must revisit that sum (pinned by `TestNewIntake_Defaults...`, not
enforced against infra). **A slow-drip body holds its slot without a time bound**: the server
sets `ReadHeaderTimeout` only, because a `ReadTimeout` would cut legitimate 200 MiB uploads on
slow links; two slow bodies take a user's two slots and two such users make everyone's upload
busy. It needs a valid session; recorded as a limitation with a follow-up issue.
8. **Chat reuses `chat.Service`; it is not forked.** The uploaded-transcript chat handlers 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 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, as ADR-027's summary gate, **before any model is called**. ADR-027's decisions (ephemeral,
@@ -1485,9 +1461,8 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
- **The opening model.** The offered list leads with the summarizer default `koala/phi4-mini`, - **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 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 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 still lists every model, and picking `phi4-mini` on a long recording fails loudly and
the cloud model too: the whole private transcript goes to the one chosen), and picking inline. A per-model budget is not built.
`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. 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` 9. **Not decided here.** Rich `AVSNITT`/`ALTERNATIV` analysis (gated on #30 — `analyze_srt.py`
@@ -1538,32 +1513,19 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
ADR-016 window, it was the interesting thing, not the useful one — remove it. ADR-016 window, it was the interesting thing, not the useful one — remove it.
**Open items this ADR creates.** **Open items this ADR creates.**
- **DECIDED (Mathias, 2026-10-02): option A.** Static ffmpeg and ffprobe 7.0.2 are copied into the - **DECISION NEEDED — ffmpeg in the runtime image.** *Options:* (A) static ffmpeg/ffprobe copied
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, 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 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 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 (second deployable, ADR-018's reasoning against). *Blocks:* the Dockerfile slice, not the
tests, which use the port. tests, which use the port.
- **DECIDED (Mathias, 2026-10-02): option A, enabled by default (#35).** Scope: the audio - **DECISION NEEDED — berget fallback for private audio.** *Options:* (A) enabled by default as
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) #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:* disabled by default, a failed iguana job fails honestly and the user retries. *Blocks:*
the production default only; the adapter ships either way. the production default only; the adapter ships either way.
- ~~Verify Tapir's ingress `proxy-body-size` against the 200 MB cap (`infra`).~~ **Resolved 2026-10-03:** real unauthenticated bodies of 2, 30, 120 and 210 MiB all pass NPM (TLS, piguard) and Traefik to tapir (a 302 to login); there is no body limit in that range. Uploads were enabled in production the same day, local engines only (infra#520). - Verify Tapir's ingress `proxy-body-size` against the 200 MB cap (`infra`).
- ~~Per-transcript deletion is not built; account deletion cascades.~~ **Built 2026-10-03 - Per-transcript deletion is not built; account deletion cascades. A private-content store
(#37):** `POST /uploads/{id}/delete`, owner-scoped (a non-owner gets 404 by the same RLS without a delete button is a gap worth a ticket, not a silent omission.
mechanism), confirm-then-delete on the recording's page. A recording that is **queued or
transcribing is refused (409) until it ends**, because the worker would otherwise write to a
row that is gone; a job whose service was lost stays undeletable until the next start's sweep
marks it failed. The refusal reflects the row's state when the delete was attempted: a
recording that finishes in the same instant still gets a 409 once, and a retry deletes it.
The home list now links to `/uploads` when uploads are mounted.
**Reversibility.** Additive: one table (down-migration drops it), one adapter, one upload **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` surface, one image change. Removing the upload route removes the feature; `transcripts`, `videos`
@@ -1592,10 +1554,8 @@ 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 **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 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. 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 Reconcile time per hour is estimated, not measured. `gemma4-31b`'s slowness is unexplained (Low Power
iguana: with it off the 31B decodes at 22.3 tok/s, not 7.9 (2026-10-02, same-machine before/after on Mode on iguana is the unproven suspect).
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.** **Decision.**
@@ -1634,26 +1594,18 @@ on the slow numbers and is reopened: 31B scored 97% against 92%.
timeout at a time. Both engines fail → ADR-033's fallback path. 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 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 segments, untouched: `content` is authoritative for chat, `segments` only for time. `endpoint`
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 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 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 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 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. 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, 7. **Configuration.** `TAPIR_TRANSCRIBE_SECOND_MODEL` (empty = off), `TAPIR_RECONCILE_MODEL`
e.g. `iguana/pianissimo` — `TAPIR_TRANSCRIBE_MODEL` stays the other engine, e.g. kb-whisper), (required when a second engine is set), `TAPIR_RECONCILE_CHECK_ORDER` (off). The default job
`TAPIR_RECONCILE_MODEL` (the adjudicator; required when a base engine is set), timeout rises from 30 to 60 minutes: ~4 min of kb-whisper and ~40 s of pianissimo per hour of audio,
`TAPIR_RECONCILE_CHECK_ORDER` (off). All three models must be local aliases (`koala/` or `iguana/`): plus the adjudication.
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.
**Amended 2026-10-04 (#61, interim):** reconcile is for Swedish. A recording whose language is
`auto` is never sent to the base engine (it is Swedish-only) and is not reconciled: the other
engine transcribes alone, then the cloud fallback if that fails and the job is live, and the
page does not claim a second opinion.
8. **Not decided here.** A cloud adjudicator (rejected: private content); tuning the prompt; making 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 `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 job); feeding the uncertain spots to chat; whether pianissimo should also be a middle tier in the
@@ -1672,31 +1624,11 @@ on the slow numbers and is reopened: 31B scored 97% against 92%.
the wrong one (it was right 92% on a hand-picked set, ~97% for 31b). 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. - 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.** **Validation / resolution triggers.**
- **The test that decides it:** re-upload the same real recording after merge; the speaker judges, blind, - **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 ~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. 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. 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 - Reconcile time above 25 minutes per hour of audio: drop to a smaller adjudicator or skip more
disputes automatically. disputes automatically.
- A different recording or speaker in which the engines agree far less (or far more): re-measure before - A different recording or speaker in which the engines agree far less (or far more): re-measure before
@@ -1708,122 +1640,6 @@ 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 ## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
-40
View File
@@ -16,52 +16,12 @@ COPY . .
# no libc. Trim symbols/DWARF to shrink the layer. # no libc. Trim symbols/DWARF to shrink the layer.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/tapir ./cmd/tapir RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/tapir ./cmd/tapir
# ── ffmpeg ──────────────────────────────────────────────────────────────────
# Audio upload probes files with ffprobe and converts them with ffmpeg (ADR-033,
# #34 option A). Static builds, so they run in the distroless image below.
#
# Pinned by version AND sha256: a changed upstream file fails the build instead of
# shipping. The checksum proves these are the bytes that were reviewed (2026-10-02;
# the publisher's md5 matched too). It does NOT prove authenticity: the publisher
# signs nothing we can check. amd64 only (the cluster is one amd64 node).
#
# These builds are GPLv3 (--enable-gpl --enable-version3). The service is private and
# the image is not distributed; the licence text is copied into the image anyway.
#
# The release URL moves on when upstream releases a newer version, so the pinned
# version is tried in releases/ and then old-releases/. If neither has it the build
# fails; vendoring the verified tarball is the durable fix.
FROM golang:1.26 AS ffmpeg
ARG FFMPEG_VERSION=7.0.2
ARG FFMPEG_SHA256=abda8d77ce8309141f83ab8edf0596834087c52467f6badf376a6a2a4c87cf67
# xz is not in the golang image (found by building it: `tar: xz: Cannot exec`).
RUN set -eu; \
apt-get update; \
apt-get install -y --no-install-recommends xz-utils; \
rm -rf /var/lib/apt/lists/*; \
mkdir -p /out; \
for dir in releases old-releases; do \
curl -fsSL -o /out/ffmpeg.tar.xz "https://johnvansickle.com/ffmpeg/${dir}/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz" && break; \
done; \
echo "${FFMPEG_SHA256} /out/ffmpeg.tar.xz" | sha256sum -c -; \
tar -xJf /out/ffmpeg.tar.xz -C /out --strip-components=1 --wildcards '*/ffmpeg' '*/ffprobe' '*/GPLv3.txt'; \
rm /out/ffmpeg.tar.xz
# ── runtime ───────────────────────────────────────────────────────────────── # ── runtime ─────────────────────────────────────────────────────────────────
# distroless static + nonroot: no shell, no package manager, runs as uid 65532. # distroless static + nonroot: no shell, no package manager, runs as uid 65532.
# ca-certificates are bundled, which the OIDC/HTTPS clients need. # ca-certificates are bundled, which the OIDC/HTTPS clients need.
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/tapir /tapir COPY --from=build /out/tapir /tapir
COPY --from=ffmpeg /out/ffmpeg /out/ffprobe /usr/local/bin/
COPY --from=ffmpeg /out/GPLv3.txt /usr/share/doc/ffmpeg/GPLv3.txt
# Where the service finds them. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set
# (a deployment decision, not an image one).
ENV TAPIR_FFMPEG_BIN=/usr/local/bin/ffmpeg \
TAPIR_FFPROBE_BIN=/usr/local/bin/ffprobe
EXPOSE 8080 EXPOSE 8080
USER nonroot:nonroot USER nonroot:nonroot
+1 -1
View File
@@ -34,7 +34,7 @@ tasks:
lint: lint:
desc: golangci-lint (installed by CI; skipped locally if absent). desc: golangci-lint (installed by CI; skipped locally if absent).
cmds: cmds:
- 'if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run ./...; else echo "golangci-lint not installed — skipping (CI installs it)"; fi' - 'command -v golangci-lint >/dev/null 2>&1 && golangci-lint run ./... || echo "golangci-lint not installed — skipping (CI installs it)"'
test: test:
desc: Run all tests. -p 1 serializes packages so the embedded-postgres tests desc: Run all tests. -p 1 serializes packages so the embedded-postgres tests
+1 -34
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
@@ -22,7 +21,6 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/store" "git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/config" "git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web" "git.d-ma.be/mathias/tapir/internal/web"
) )
@@ -76,11 +74,6 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
cfg.GatewayKey = key cfg.GatewayKey = key
cfg.UploadsEnabled = true cfg.UploadsEnabled = true
cfg.UploadTmpDir = filepath.Join(t.TempDir(), "spool") 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.FFmpegBin = envOrDefault("TAPIR_E2E_FFMPEG", "ffmpeg")
cfg.FFprobeBin = envOrDefault("TAPIR_E2E_FFPROBE", "ffprobe") cfg.FFprobeBin = envOrDefault("TAPIR_E2E_FFPROBE", "ffprobe")
@@ -99,7 +92,7 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
// 1. Upload. // 1. Upload.
f, err := os.Open(audioPath) f, err := os.Open(audioPath)
require.NoError(t, err) require.NoError(t, err)
defer func() { _ = f.Close() }() defer f.Close()
pr, pw := io.Pipe() pr, pw := io.Pipe()
mw := multipart.NewWriter(pw) mw := multipart.NewWriter(pw)
go func() { go func() {
@@ -141,15 +134,6 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
`SELECT status, endpoint, content, audio_seconds, had_video FROM uploaded_transcripts WHERE user_id = $1`, uid). `SELECT status, endpoint, content, audio_seconds, had_video FROM uploaded_transcripts WHERE user_id = $1`, uid).
Scan(&status, &endpoint, &content, &secs, &hadVideo)) 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) 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" { if os.Getenv("TAPIR_E2E_HAD_VIDEO") == "1" {
// A Teams meeting with the camera off: a video file whose audio is what matters. // 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.True(t, hadVideo, "the file had a video track and the row must say so")
@@ -158,23 +142,6 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
assert.Equal(t, "done", status) assert.Equal(t, "done", status)
assert.NotEmpty(t, endpoint) assert.NotEmpty(t, endpoint)
assert.NotEmpty(t, content) 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) entries, err := os.ReadDir(cfg.UploadTmpDir)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, entries, "the audio must be deleted once transcribed") assert.Empty(t, entries, "the audio must be deleted once transcribed")
+14 -15
View File
@@ -17,9 +17,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal"
"sync" "sync"
"time" "time"
@@ -41,7 +41,7 @@ func main() {
} }
log := slog.New(slog.NewTextHandler(os.Stderr, nil)) log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, stop := shutdownContext(context.Background()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
var err error var err error
@@ -215,11 +215,9 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
if err != nil { if err != nil {
return err return err
} }
var workerDone chan struct{} // nil without uploads: nothing to wait for at shutdown
wireUploads(app, cfg, intake) wireUploads(app, cfg, intake)
if intake != nil { if intake != nil {
workerDone = make(chan struct{}) go worker.Run(ctx)
go func() { worker.Run(ctx); close(workerDone) }()
log.Info("audio upload enabled", "model", cfg.TranscribeModel, "fallback", cfg.TranscribeFallbackModel, log.Info("audio upload enabled", "model", cfg.TranscribeModel, "fallback", cfg.TranscribeFallbackModel,
"max_mb", cfg.UploadMaxBytes>>20, "max_minutes", int(cfg.UploadMaxDuration.Minutes())) "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)") log.Warn("audio upload assumes a SINGLE replica: the transcription queue is process-local (ADR-033, ADR-018)")
@@ -352,19 +350,20 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
}() }()
} }
// Graceful shutdown on signal: stop accepting, drain in-flight requests, and let the // Graceful shutdown on signal: stop accepting, drain in-flight requests.
// worker record the job it was running as interrupted (#50). go func() {
var extra []*http.Server <-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
if metricsSrv != nil { if metricsSrv != nil {
extra = append(extra, metricsSrv) _ = metricsSrv.Shutdown(shutdownCtx)
}
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return fmt.Errorf("serve: listen: %w", err)
} }
}()
log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID) log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID)
if err := serveHTTP(ctx, srv, ln, extra, workerDone, shutdownGrace); err != nil { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err return fmt.Errorf("serve: %w", err)
} }
return nil return nil
} }
-21
View File
@@ -134,30 +134,9 @@ func chatModels(cfg config.Config) []string {
add(cfg.SummarizerModel) add(cfg.SummarizerModel)
add(cfg.FallbackModel) add(cfg.FallbackModel)
add(cfg.CloudFallbackModel) add(cfg.CloudFallbackModel)
// The model chat over an uploaded recording opens on must be one the shared service can
// call: it re-maps a model it does not offer, which silently swapped a configured 31B for
// phi4-mini, whose context cannot hold an hour of speech. Local only, and only when
// uploads are on, so the video chat is unchanged otherwise (second adversarial review).
if cfg.UploadsEnabled && config.IsLocalModel(cfg.UploadChatModel) {
add(cfg.UploadChatModel)
}
return models 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 // 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 // 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 // second client config) and the same transcript-truncation budget. It returns nil
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// shutdownGrace bounds each wait at shutdown (in-flight requests, then the worker). Two of
// them stay inside Kubernetes' default 30 s termination grace.
const shutdownGrace = 10 * time.Second
// shutdownContext is cancelled by SIGINT (a terminal) or SIGTERM (what Kubernetes sends
// on every deploy). Handling only SIGINT meant a deploy killed the process before any
// shutdown code ran (#50).
func shutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
}
// serveHTTP serves srv on ln until it fails or ctx ends, then shuts every server down and
// waits for in-flight requests and for workerDone (nil: no worker) to finish, each for at
// most grace. It must not return earlier: Serve returns http.ErrServerClosed as soon as
// Shutdown STARTS, and returning from main kills the drain and the worker's last write.
func serveHTTP(ctx context.Context, srv *http.Server, ln net.Listener, extra []*http.Server, workerDone <-chan struct{}, grace time.Duration) error {
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
select {
case err := <-errc:
if !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
return nil
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
defer cancel()
for _, s := range append([]*http.Server{srv}, extra...) {
_ = s.Shutdown(shutdownCtx)
}
if workerDone != nil {
select {
case <-workerDone:
case <-time.After(grace):
}
}
return nil
}
-124
View File
@@ -1,124 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"sync"
"syscall"
"testing"
"time"
)
// Kubernetes stops a pod with SIGTERM; only SIGINT was handled, so a deploy killed the
// process without running any shutdown code (#50).
func TestShutdownContext_IsCancelledBySIGTERMAndSIGINT(t *testing.T) {
for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGINT} {
t.Run(sig.String(), func(t *testing.T) {
ctx, stop := shutdownContext(context.Background())
defer stop()
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatalf("send %v: %v", sig, err)
}
select {
case <-ctx.Done():
case <-time.After(2 * time.Second):
t.Fatalf("context not cancelled by %v", sig)
}
})
}
}
// serveHTTP must not return while a request is in flight or the worker is still recording
// its job: Serve returns http.ErrServerClosed as soon as Shutdown STARTS, and returning
// from it let the process exit before either finished (#50). Each half is tested with the
// other already satisfied, so neither can hold the call open on the other's behalf.
func pendingFor(t *testing.T, returned <-chan error, why string) {
t.Helper()
select {
case err := <-returned:
t.Fatalf("serveHTTP returned (%v) %s", err, why)
case <-time.After(150 * time.Millisecond):
}
}
func TestServeHTTP_DrainsAnInFlightRequestBeforeReturning(t *testing.T) {
release := make(chan struct{})
entered := make(chan struct{})
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
close(entered)
<-release
_, _ = w.Write([]byte("ok"))
}))
srv.Start()
defer srv.Close()
var once sync.Once
releaseHandler := func() { once.Do(func() { close(release) }) }
defer releaseHandler() // runs before srv.Close: a failing test must not hang on the blocked handler
ctx, cancel := context.WithCancel(context.Background())
workerDone := make(chan struct{})
close(workerDone) // the worker is already done: only the request can hold serveHTTP open
returned := make(chan error, 1)
go func() { returned <- serveHTTP(ctx, srv.Config, srv.Listener, nil, workerDone, 5*time.Second) }()
go func() { _, _ = http.Get(srv.URL) }()
<-entered
cancel()
pendingFor(t, returned, "while a request was still in flight")
releaseHandler()
select {
case err := <-returned:
if err != nil {
t.Fatalf("serveHTTP: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("serveHTTP did not return once the request finished")
}
}
func TestServeHTTP_WaitsForTheWorkerBeforeReturning(t *testing.T) {
srv := httptest.NewUnstartedServer(http.NotFoundHandler())
srv.Start()
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
workerDone := make(chan struct{})
returned := make(chan error, 1)
go func() { returned <- serveHTTP(ctx, srv.Config, srv.Listener, nil, workerDone, 5*time.Second) }() // no request in flight
cancel()
pendingFor(t, returned, "before the worker finished")
close(workerDone)
select {
case err := <-returned:
if err != nil {
t.Fatalf("serveHTTP: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("serveHTTP did not return once the worker finished")
}
}
func TestServeHTTP_GivesUpOnAWorkerThatNeverFinishesAfterTheGrace(t *testing.T) {
srv := httptest.NewUnstartedServer(http.NotFoundHandler())
srv.Start()
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
returned := make(chan error, 1)
go func() {
returned <- serveHTTP(ctx, srv.Config, srv.Listener, nil, make(chan struct{}), 200*time.Millisecond)
}()
cancel()
select {
case <-returned:
case <-time.After(3 * time.Second):
t.Fatal("a stuck worker must not hold the process past the grace period")
}
}
+11 -53
View File
@@ -7,13 +7,10 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"time"
"git.d-ma.be/mathias/tapir/internal/adapters/audio" "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/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/config" "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/uploads"
"git.d-ma.be/mathias/tapir/internal/web" "git.d-ma.be/mathias/tapir/internal/web"
) )
@@ -55,8 +52,17 @@ 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) log.Warn("uploads: marked interrupted by a restart (audio is not retained; they must be re-uploaded)", "count", n)
} }
worker := uploads.NewWorker(st, newConverter(cfg), buildTranscriber(cfg), // No overall client timeout: a 90 minute recording takes minutes. The deadline
cfg.UploadTmpDir, cfg.TranscribeTimeout, uploads.WithMaxDuration(cfg.UploadMaxDuration)) // 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, intake := uploads.NewIntake(st, &audio.FFprobe{Bin: cfg.FFprobeBin}, worker,
uploadLimits(cfg), cfg.UploadTmpDir) uploadLimits(cfg), cfg.UploadTmpDir)
return intake, worker, nil return intake, worker, nil
@@ -91,52 +97,4 @@ func wireUploads(app *web.App, cfg config.Config, in *uploads.Intake) {
app.UploadLimits = uploadLimits(cfg) app.UploadLimits = uploadLimits(cfg)
app.UploadChatMaxChars = cfg.MaxUploadedTranscriptChars app.UploadChatMaxChars = cfg.MaxUploadedTranscriptChars
app.UploadChatModel = cfg.UploadChatModel app.UploadChatModel = cfg.UploadChatModel
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
// 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{}
// 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)
}
if cfg.ReconcileBaseModel == "" {
return transcribe.NewChain(primary, fallback)
}
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))
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,
})
} }
-85
View File
@@ -1,85 +0,0 @@
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)
}
}
// A valid local TAPIR_UPLOAD_CHAT_MODEL that is not in the video chat's chain used to be
// accepted by config and then silently ignored: the shared chat service re-maps a model it
// does not offer, so the user got koala/phi4-mini, whose 8,192-token context cannot hold an
// hour of speech (second adversarial review).
func TestChatModels_AnUploadChatModelOutsideTheChainIsOfferedWhenUploadsAreOn(t *testing.T) {
var cfg config.Config
cfg.SummarizerModel = "koala/phi4-mini"
cfg.FallbackModel = "iguana/gemma4-26b"
cfg.UploadsEnabled = true
cfg.UploadChatModel = "iguana/gemma4-31b"
service := chatModels(cfg)
upload := uploadChatModels(cfg)
if !reflect.DeepEqual(service, []string{"koala/phi4-mini", "iguana/gemma4-26b", "iguana/gemma4-31b"}) {
t.Errorf("chatModels = %v: the shared service must be able to call the configured upload model", service)
}
if !reflect.DeepEqual(upload, service) {
t.Errorf("uploadChatModels = %v, want %v", upload, service)
}
}
func TestChatModels_NothingIsAddedWhenUploadsAreOffOrTheModelIsNotLocal(t *testing.T) {
var cfg config.Config
cfg.SummarizerModel = "koala/phi4-mini"
cfg.UploadChatModel = "iguana/gemma4-31b"
if got := chatModels(cfg); !reflect.DeepEqual(got, []string{"koala/phi4-mini"}) {
t.Errorf("uploads off: chatModels = %v, want the video chain unchanged", got)
}
cfg.UploadsEnabled = true
cfg.UploadChatModel = "berget/mistral-small"
if got := chatModels(cfg); !reflect.DeepEqual(got, []string{"koala/phi4-mini"}) {
t.Errorf("a cloud upload chat model was added to the service: %v", got)
}
}
-26
View File
@@ -1,26 +0,0 @@
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 -42
View File
@@ -6,12 +6,10 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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/config"
"git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web" "git.d-ma.be/mathias/tapir/internal/web"
@@ -22,15 +20,13 @@ import (
type fakeUploadsStore struct { type fakeUploadsStore struct {
sweeps int sweeps int
sweepErr error sweepErr error
failedKinds []domain.UploadErrorKind
} }
func (f *fakeUploadsStore) CreateUpload(context.Context, string, string, domain.Language, bool) (string, error) { func (f *fakeUploadsStore) CreateUpload(context.Context, string, string, bool) (string, error) {
return "id", nil return "id", nil
} }
func (f *fakeUploadsStore) FailUpload(_ context.Context, _, _ string, k domain.UploadErrorKind) error { func (f *fakeUploadsStore) FailUpload(context.Context, string, string, domain.UploadErrorKind) error {
f.failedKinds = append(f.failedKinds, k)
return nil return nil
} }
@@ -40,12 +36,6 @@ func (f *fakeUploadsStore) CompleteUpload(context.Context, string, string, domai
return nil 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, time.Time) error { return nil }
func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) { func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) {
f.sweeps++ f.sweeps++
return 2, f.sweepErr return 2, f.sweepErr
@@ -164,33 +154,3 @@ 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.Nil(t, app.Uploads, "a nil *Intake must not become a non-nil interface that mounts the routes")
assert.Zero(t, app.UploadChatMaxChars) 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)
}
-78
View File
@@ -1,78 +0,0 @@
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)
}
func TestBuildTranscriber_AWedgedBaseEngineIsASoftFailureInTheEnsemble(t *testing.T) {
// The same deadline must guard the ensemble's BASE engine, not only the chain's primary
// (second adversarial review: the wiring was tested for the chain alone).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseMultipartForm(1 << 20)
if r.FormValue("model") == "iguana/pianissimo" {
<-r.Context().Done()
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.ReconcileBaseModel = "iguana/pianissimo"
cfg.ReconcileModel = "iguana/gemma4-31b"
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, "a wedged base engine must not use up the job")
assert.Equal(t, "hej", got.Text)
require.NotNil(t, got.Reconciliation)
assert.Equal(t, "single_engine", string(got.Reconciliation.Status))
assert.Contains(t, got.Reconciliation.Reason, "timeout")
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"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 worker buildUploads returns enforces the configured maximum on what was really
// converted (adversarial review F2). A stub ffmpeg writes a sparse 100-minute WAV however
// short the file claimed to be; the gateway is never reached because the job fails first.
func TestBuildUploads_TheWorkerRefusesAudioLongerThanTheConfiguredMaximum(t *testing.T) {
dir := t.TempDir()
stub := filepath.Join(dir, "ffmpeg")
require.NoError(t, os.WriteFile(stub, []byte("#!/bin/sh\nfor a; do last=\"$a\"; done\ntruncate -s 192000044 \"$last\"\n"), 0o700))
cfg := uploadCfg(t, true)
cfg.FFmpegBin = stub
cfg.UploadMaxDuration = 90 * time.Minute
cfg.TranscribeTimeout = time.Minute
st := &fakeUploadsStore{}
_, w, err := buildUploads(context.Background(), cfg, st, quietLog())
require.NoError(t, err)
src := filepath.Join(cfg.UploadTmpDir, "upload-x")
require.NoError(t, os.WriteFile(src, []byte("audio"), 0o600))
w.Process(context.Background(), uploads.Job{UserID: "u", UploadID: "id", Path: src, Duration: 10 * time.Second})
assert.Equal(t, []domain.UploadErrorKind{domain.UploadErrTooLong}, st.failedKinds,
"a file that claimed 10 s but converted to 100 minutes must fail, not be sent to an engine")
}
+1 -5
View File
@@ -153,10 +153,6 @@ erDiagram
float audio_seconds float audio_seconds
text error_kind "typed: interrupted | transcription_failed — never a provider string" 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)" 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 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 created_at
timestamptz finished_at timestamptz finished_at
} }
@@ -228,7 +224,7 @@ mechanism.
grounds that it holds only public caption content, and a recorded meeting is the opposite. 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 → `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 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 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 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 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. 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 - **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
+4 -11
View File
@@ -14,8 +14,7 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
not LiteLLM. Use `30401` for off-cluster LiteLLM. `confirm` resolved. not LiteLLM. Use `30401` for off-cluster LiteLLM. `confirm` resolved.
- **Speech-to-text (ADR-033):** the same gateway serves `POST /v1/audio/transcriptions` for - **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 `iguana/kb-whisper` (primary, local) and `berget/kb-whisper` (fallback, EU cloud — private audio
leaves the homelab if it is used: when the primary fails **or returns an empty transcript**, leaves the homelab if it is used). **Call it via the NodePort / in-cluster address above, NOT
e.g. a silent or music-only file; the transcript page then says so, #52). **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 `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 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 request (measured 2026-10-01, `docs/research/audio-length-ceiling-2026-10-01.md`). Send multipart
@@ -24,17 +23,11 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
`{"error":"failed to process audio"}` string — it has had at least five causes. Tapir's knobs: `{"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` `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 (90), `TAPIR_TRANSCRIBE_MODEL`, `TAPIR_TRANSCRIBE_FALLBACK_MODEL` (empty disables the cloud
fallback), `TAPIR_TRANSCRIBE_TIMEOUT` (60m, the whole job), `TAPIR_TRANSCRIBE_ENGINE_TIMEOUT` (20m, one engine call: a wedged engine then fails while the job lives and the fallback can run), `TAPIR_UPLOAD_TMP_DIR`, `TAPIR_FFMPEG_BIN`, fallback), `TAPIR_TRANSCRIBE_TIMEOUT` (30m), `TAPIR_UPLOAD_TMP_DIR`, `TAPIR_FFMPEG_BIN`,
`TAPIR_FFPROBE_BIN`, `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` (90000 — the measured ceiling of `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`, which silently drops the head above it), `TAPIR_UPLOAD_CHAT_MODEL`
(`iguana/gemma4-26b`; must be a local `koala/` or `iguana/` alias, since the whole private transcript is sent to it; `koala/phi4-mini`'s 8,192-token context 400s on an hour of speech). `ffmpeg` + `ffprobe` are in the runtime image (static, pinned by sha256; #34 decided A on 2026-10-02); Tapir's own ingress body limit for a 200 MB upload is unverified (infra). (`iguana/gemma4-26b`; `koala/phi4-mini`'s 8,192-token context 400s on an hour of speech). Needs `ffmpeg` + `ffprobe`
**Two-engine mode (ADR-034), off unless `TAPIR_RECONCILE_BASE_MODEL` is set:** it runs in the runtime image; Tapir's own ingress body limit for a 200 MB upload is unverified (infra).
`iguana/pianissimo` (the base text) and `TAPIR_TRANSCRIBE_MODEL` (kb-whisper) on the same audio, then a
LOCAL chat model (`TAPIR_RECONCILE_MODEL`) chooses between their disagreements. **Deployment choice (2026-10-02): `iguana/gemma4-31b`** (97% against a speaker's blind answers; 92% for `iguana/gemma4-26b`). With iguana's macOS **Low Power Mode off** the 31B decodes at 22 tok/s and reconciled a 63.6-minute recording in 21 min 49 s end to end; with it on it ran at 8 tok/s. Check `pmset -g | grep lowpowermode` before blaming a model or an engine for slowness.
`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. - **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 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`). stale or the gateway now enforces the `LITELLM_MASTER_KEY` (1Password `op://HomeLab/LITELLM_MASTER_KEY`).
@@ -29,7 +29,7 @@ ingress configuration, which lives in `infra`. "About 10 MB" is an inference.
## Method ## Method
- **Audio.** No known-good Swedish recording is available that is safe to use: the only real - **Audio.** No known-good Swedish recording is available that is safe to use: the only real
one is a private recording (#31 custody still open) and was 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 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`). 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). Concatenated ×4, ×8, ×16, ×32, ×44, ×66 with Python `wave` (the issue's own recipe).
@@ -70,16 +70,6 @@ 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 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 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 speed points at the machine: ~140 GB/s effective memory bandwidth on a part rated for 800, and
**iguana had macOS Low Power Mode on** (`pmset` `lowpowermode 1`). A first MLX benchmark was **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. 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.
@@ -1,68 +0,0 @@
# 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.
@@ -1,69 +0,0 @@
# ADR-034 second blind check: protocol (ready to run; tapir#33, D9, D10)
This repo is public and the recordings are private, so this file holds the method only. The
sheet, the key and the filled-in verdicts stay outside the repo, and the trial data is deleted
after the check. Results go back here as aggregate numbers, as in
`reconcile-blind-check-2026-10-02.md`.
## What the check decides
ADR-034 (two engines, a local model reconciles their disagreements) is `Proposed` and off in
production. Two conditions were set for keeping it. The first run answered the first one on one
reading only and did not answer the second:
1. **The reconciled reading is right at least 85% of the time.** Open flaw in the bar: it did not
say how ties count. The first run gave 95% (decided items only) or 70% (all 30), so it was met on
one reading and missed on the other.
2. **It is no worse than the better single engine.** Not answered: before ADR-035 the spots the
model *kept* as the base were not stored, so kb-whisper alone could not be compared.
ADR-035 now stores every judged spot (`reconciliation.contested`: both readings, which one was
used, unsure or not, a few words of left context, a time). That makes condition 2 measurable.
## Rules to fix BEFORE looking at any result (the owner confirms or changes them)
| Rule | Proposed |
|---|---|
| Who judges | The speaker, hearing the audio where the text cannot settle it (D9). Not Opus: it cannot hear, and its agreement with the speaker was only shown on items picked as decidable. |
| Sample | 30 spots drawn at random with a fixed seed, from ALL judged spots (changed and kept-base alike), not only the changed ones. Insertions and deletions that cannot be placed are listed separately and not sampled. |
| Verdict per spot | `x`, `y` (the two readings, shown in random order, key kept apart), `neither`, or `cannot tell`. |
| Condition 1 | Reconciled is right on at least 85% of the spots where the judge could tell (`cannot tell` excluded, reported, and `neither` counts as not right). Also report all-30. Pass needs the decided-only figure at 85% or more AND the all-30 figure above 70%. |
| Condition 2 | Over the same spots, the reconciled text is right at least as often as the better single engine (base engine alone is right where the base reading is right; the other engine alone where the alt reading is right). Ties favour the single engine. |
| If it fails | Switch reconcile off by configuration; the single-engine path is unchanged (ADR-034 reversibility). |
Why a fixed all-30 floor: the literal 70% last time sat well under the bar, and "decided only" can
hide a model that mostly lands on spelling differences.
## What a run needs (D10: the owner is present)
- The same real recording as before (about 60 minutes). Only the owner has it.
- A **throwaway** tapir instance with its own Postgres, not the production pod, so production's
key pin and deployment are untouched. Settings: `TAPIR_RECONCILE_BASE_MODEL=iguana/pianissimo`,
`TAPIR_TRANSCRIBE_MODEL=iguana/kb-whisper`, `TAPIR_RECONCILE_MODEL=iguana/gemma4-31b`, cloud
fallbacks empty. Job time was 21 min 49 s per 63.6 min last run.
- iguana with Low Power Mode off and gemma4-31b able to load (the production-iguana model load
that needs a quiet window; D6).
## Steps
1. Start the throwaway instance, upload the recording, wait for `done` (about 22 minutes).
2. Read `reconciliation.contested` from the throwaway database. From it build, **outside the repo**:
`sheet` (spot number, time, left context, reading x, reading y, random order) and `key` (which
of x/y is the base, which is the other engine, which one the reconciler used).
3. The speaker fills in the sheet (about 20 to 30 minutes for 30 spots, audio open at the times).
The agent does not see the key until the sheet is returned.
4. Score with the rules above. Write the aggregate numbers into a dated note next to this one,
and amend ADR-034's validation section (kept, or switched off, and the tie rule used).
5. Delete the throwaway instance, its database and the sheet, as last time.
6. Only if the check passes and the owner wants it in production: enabling reconcile there is
the separate D10 step (it needs `iguana/pianissimo` and the reconciler added to the tapir key
pin in the infra repo plus the env vars on the deployment, `risk/medium`, owner present).
## Open and owner-only
- Confirm or change the rules table above before the run.
- The **tool that builds the sheet and key** is the one piece not in the repo (last time it was a
one-off script). It can be built test-first on request; until then the agent builds it ad hoc
outside the repo.
- A Berget adjudicator (D8, tapir#45) is a different question with its own measurement and is
not part of this check: this check judges the local setup that ADR-034 describes.
@@ -1,115 +0,0 @@
# Sprint: audio upload follow-ups (Sweep Sprint, 2026-10)
Method: Sweep Sprint (phase 1 parallel read-only research per issue: **done**; phase 2 sequential
execution, one issue per commit: **v0.31.1 and v0.32.0 shipped 2026-10-03/04, the owner-held items
below remain open**). Closing oath below.
Milestones: [v0.31.1 - security and robustness](https://git.d-ma.be/mathias/tapir/milestone/10),
[v0.32.0 - audio upload follow-ups](https://git.d-ma.be/mathias/tapir/milestone/11).
Context: v0.31.0 shipped audio upload (ADR-033/034/035) and is live in production behind auth at
`/uploads`, with the Berget cloud fallback on for audio (infra#522). A real 63.7 min upload
transcribed in 2m53s with no errors. The phase-1 investigation found two pre-existing or new
weaknesses an authenticated user can reach, so the first release is a hotfix.
## Oath (closing gates, every clause must hold; blocked = descoped and tracked, never "done")
| # | Clause | Evidence |
|---|--------|----------|
| SG1 | Every issue in the sprint is closed with a linked commit, or moved out with a stated reason | issue list of both milestones |
| SG2 | Each fix has a test that was seen red on main first, named after the mechanism it observes | red/green output in the issue |
| SG3 | `task check` AND `golangci-lint run ./...` exit 0 (checked by `$?`, not by grep); `task check` does not gate lint until #41 | gate output |
| SG4 | Control code (#48, #49, the upload cap in #47) has a recorded adversarial review verified by execution, verdict in the issue | gatekeeper verdict comment |
| SG5 | Docs current in the same commit: ADR-033/035 text, feature file, use-case coverage test | diff |
| SG6 | Each release tagged (v0.31.1, v0.32.0), pushed, deployed, and observed live (pod running the new image, no restart loop, a real upload still works) | `kubectl`/log + one upload |
| SG7 | Learnings written to the brain; deferred work filed as issues; decisions that are the owner's recorded as DECISION NEEDED | brain ids + issue numbers |
## Work order
### v0.31.1 (security and robustness; hotfix, do first)
1. **#41 lint gate.** `task check` swallows lint findings (`|| echo`). Make the gate fail on findings,
fix what it surfaces. First, so every later gate means something. (Decision D1.)
2. **#48 cross-origin POST protection.** `http.NewCrossOriginProtection()` around `Router()`.
Red: foreign-origin POST to `/account/delete`, `/uploads`, `/uploads/{id}/chat` is refused.
3. **#49 ffprobe/ffmpeg limits.** `-max_streams 32` + narrower probe output; probe stdout cap;
probe deadline. Three commits.
4. **#47 concurrent-upload cap (remaining item).** Bound the spool: reservation per accepted upload
against the 6Gi emptyDir so concurrent uploads cannot evict the pod.
5. **#50 SIGTERM interim.** Handle SIGTERM; record the interrupted job at once; fix the false
comment. Full drain recorded as a limitation. (Decision D2.)
Release v0.31.1: one adversarial review round over 2-5, then tag, push, observe.
### v0.32.0 (follow-ups)
6. **#52** label a cloud-served transcript in plain words; document the empty-transcript trigger.
7. **#53** bound the LLM client's response body read.
8. **#51** tracker: save timeout, then app-clock heartbeat.
9. **#37** nav link to uploads and per-recording delete (queued/transcribing: refuse until it
ends). (Decision D3.)
10. **#36** chat default model after measurement. (Decision D4.)
11. **#38** measurements (Berget length limit, large models on production iguana). (D5, D6.)
12. **#46** remainder of the hardening list after the items above are split out.
13. **#45** evaluate Berget as the reconciler: a question, not work. (D8.)
## DECISION NEEDED (owner's call; recommendation first)
| # | Question | Recommendation | Blocks |
|---|----------|----------------|--------|
| D1 | #41: should any linter be allowed to opt out of the gate? | No opt-out; fix findings | #41 |
| D2 | #50: full SIGTERM drain, or record as a limitation and ship the interim fix? | Limitation + interim; a drain costs 4-6 h and downtime on every deploy at current volume | #50 |
| D3 | #37: delete of a queued/transcribing recording | Refuse until it ends | #37 |
| D4 | #36: make a 31B model the chat default? | Only after measurement, conditional | #36 |
| D5 | #38: Berget length measurement needs a throwaway key | Yes, throwaway key | #38 |
| D6 | #38: load large models on production iguana | Yes, in a quiet window with the owner present | #38 |
| D7 | #46: where the ffmpeg tarball lives | Do nothing now; file an issue | none |
| D8 | #45: evaluate Berget as the reconciler | **Decided 2026-10-04 by the owner: yes, Berget may be used, and each user must be able to keep a recording on local models only** (reverses the recommendation). Opt-out first: #60; #45 stays as the model measurement | #60, #45 |
| D9 | Who judges ADR-034's second blind check | The owner; do not reuse the one-off Opus authorisation. Protocol: `docs/research/reconcile-second-check-protocol.md` | ADR-034 |
| D10 | When to enable reconcile in production | Needs `iguana/pianissimo` in the key pin and env vars; owner present | ADR-034 |
## Dropped or reduced, with reasons
- **#46 item 1 (`alis`/`dref` external reference):** checked, not a problem. Under the application's
flags ffmpeg skips the external track and writes a 78-byte WAV.
- **#46 item 2 / #47 item 2:** merged into #49.
- **#47 item 3 (full drain):** reduced to #50 plus a recorded limitation.
- **#46 `Beat` taking the tracker mutex:** incorrect in #46; only `logStill` does. Corrected in #51.
## Human-present items (do not run unattended)
- Production iguana model loads (#38, D6).
- Enabling reconcile in production (D10).
- Any key change or `apply` in infra (infra#523, infra#524 are open and not part of this sprint).
## Release plan
SemVer: v0.31.1 patch (fixes, no new behaviour); v0.32.0 minor (nav link, delete, cloud labelling).
Every push to tapir main redeploys the pod and fails any job in flight: push when no upload is
running (check the pod log first).
## Status (2026-10-04)
**Shipped and deployed:** v0.31.1 (PR #55, merge e037c6b) and v0.32.0 (PR #58, merge 85f4cc3; pod
tapir-597dbfccc-ng78q 1/1, 0 restarts, image 85f4cc3). Each went through an adversarial review
round by agent with the verdicts recorded on the PR (v0.31.1: BLOCK then APPROVE; v0.32.0: BLOCK,
BLOCK, APPROVE, APPROVE; every block was a real defect, fixed test-first).
**Live evidence:** a foreign-origin `POST` to `/account/delete` and to `/uploads/{id}/delete` is
refused (403) on production, a same-origin one reaches the login redirect (302).
**Not observed live (needs an authenticated session, so it is the owner's look; closed on word or screenshot):**
- #52, cloud side: a transcript served by the Berget fallback names `berget`; a reconciled all-local
one shows no note. The local side was observed on 2026-10-04 (a new clip served by iguana: chip
`iguana/…`, no cloud note). The cloud side is carried as an acceptance item in #60.
**Observed live since:** #37 (owner, 2026-10-04: link, confirm-then-delete, no control while transcribing; closed). #50: at the 2026-10-04 rollout the old pod went `Terminating` then `Completed`
(not `Error`), 0 restarts; #50 closed. No job was in flight, so a drained job is still unobserved
(a recorded limitation, D2).
**Still open (owner's call, milestone 11):** #36, #38, #45 (now the Berget adjudicator measurement), #60 (per-recording local-only choice, 4 sub-decisions), #46 remainder, #56 (slow-drip bodies hold
a receive slot), #57 (shutdown logging, second signal). Decisions D4-D6, D9, D10 above are unchanged; D8 is decided (above).
**Deferred and filed from the reviews:** #56, #57, #59 (start-up exits when the identity provider's OIDC
discovery returns 503 (seen once at 19:40 on 2026-10-03; the pod restarted itself and recovered)).
-185
View File
@@ -106,188 +106,3 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
Given my recording's transcript is longer than the caption default Given my recording's transcript is longer than the caption default
When I ask a question about it When I ask a question about it
Then the transcript is sent with the upload budget, not the 18,000-character caption default 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
# 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
# ADR-035 decision 8: hardening from the adversarial review
Scenario: Chat about a recording is offered with local models only
When I open the chat for my recording
Then the model list holds only models that run on the homelab
And a cloud model the shared chat service knows is not in it
Scenario: A forged cloud model is never sent the transcript
When a chat request names a cloud model
Then a local model answers instead, and nothing was sent to the cloud one
Scenario: A wedged engine does not use up the whole job
When the primary engine never answers
Then it fails at its own deadline while the job is still alive
And the fallback runs, unless the job itself was cancelled or timed out
Scenario: A file that lies about its length is refused
When a file claims to be short but converts to more than the limit
Then the job fails with a plain explanation and no engine receives the audio
Scenario: A recording much shorter than its file claimed is transcribed and noted
When a file converts to under 80 percent of the length it claimed and more than two minutes short
Then it is transcribed anyway and its audio step says how long the audio really is
And a complete recording whose header over-estimated its length is not refused
Scenario: A transcript made by the cloud fallback says the audio left the homelab
When the transcript was produced by an endpoint that is not local
Then its page says in plain words that the audio left the homelab and names the provider
And a locally transcribed recording carries no such note
Scenario: I can delete a finished recording
When I confirm deleting a done or failed recording
Then the recording and its transcript are gone and I am back on my list
And another user's recording is a 404 and is untouched
Scenario: A recording still being transcribed cannot be deleted
When I try to delete a recording that is queued or transcribing
Then I am told it is still being transcribed and nothing is removed
Scenario: The home list links to my recordings when uploads are on
Given audio upload is enabled
Then the home list links to the recordings page
Scenario: A full queue is a retry, not an error
When too many recordings are already waiting
Then the upload is answered as busy, its row is failed honestly and its audio is deleted
Scenario: One user cannot fill the queue for everyone
When one user has several recordings waiting
Then further uploads from that user are refused and other users are not affected
Scenario: Errors are shown as kinds, never their text
When a model or an engine fails
Then the log, the stored reason and the page carry a kind such as timeout or http_502
And never the provider's response, a file path or a word from the transcript
Scenario: Input that cannot be right is a client error
When the id is not an id, or a header or title holds control bytes
Then the answer is a 4xx or the title is cleaned, never a server error
Scenario: I choose the language of a recording
When I upload a recording and choose Swedish or Other on the form
Then the recording is transcribed as that language and its page says which
Scenario: Swedish stays the default
When an upload names no language
Then it is transcribed as Swedish, as before the choice existed
Scenario: A language outside the list is refused
When an upload names a language the form does not offer
Then it is answered 400 before anything is stored or sent to an engine
Scenario: A recording in another language never reaches the Swedish-only engine
When I choose Other for a recording
Then the language is left for the engine to detect and the Swedish-only base engine and the reconcile are not used
And the engine that does run is the configured one, which is not tuned for other languages
+2 -2
View File
@@ -33,9 +33,9 @@ require (
github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.39.0 // indirect golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+4 -4
View File
@@ -113,12 +113,12 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+3 -22
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
) )
@@ -18,17 +17,8 @@ import (
type FFmpeg struct { type FFmpeg struct {
// Bin is the ffmpeg executable ("ffmpeg" resolves via PATH). // Bin is the ffmpeg executable ("ffmpeg" resolves via PATH).
Bin string 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 // 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 // file has already been probed as audio-only, but a cover-art stream must not end
// up in the WAV. // up in the WAV.
@@ -39,23 +29,14 @@ 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. // Input restrictions go BEFORE -i: placed after it they would apply to the output.
args := append([]string{"-y", "-v", "error"}, inputRestrictions...) args := append([]string{"-y", "-v", "error"}, inputRestrictions...)
args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le") args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", dst)
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 := exec.CommandContext(ctx, bin, args...)
cmd.WaitDelay = killGrace cmd.WaitDelay = killGrace
// Only a bounded head of ffmpeg's output is kept: a hostile file made it print ~0.4 if out, err := cmd.CombinedOutput(); err != nil {
// bytes per input byte, all of which CombinedOutput would buffer and copy (#49).
out := &headWriter{max: maxStderr}
cmd.Stdout, cmd.Stderr = out, out
if err := cmd.Run(); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil { if ctxErr := ctx.Err(); ctxErr != nil {
return fmt.Errorf("audio: ffmpeg: %w", ctxErr) return fmt.Errorf("audio: ffmpeg: %w", ctxErr)
} }
return fmt.Errorf("audio: ffmpeg: %w: %s", err, strings.TrimSpace(out.buf.String())) 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 // A zero exit that wrote nothing is still a failure: never hand whisper a
// missing file and let the error surface three layers later. // missing file and let the error surface three layers later.
-61
View File
@@ -77,64 +77,3 @@ printf data > "$last"`)
assert.Less(t, pw, in, "input options must come BEFORE -i or ffmpeg applies them to the output") assert.Less(t, pw, in, "input options must come BEFORE -i or ffmpeg applies them to the output")
assert.Less(t, fw, in) assert.Less(t, fw, in)
} }
func TestFFmpegToWAV_PassesAnOutputLengthCapAfterTheInputWhenAMaximumIsSet(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_PassesNoLengthCapWhenNoMaximumIsSet(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 ")
}
func TestFFmpegToWAV_CapsTheStreamCountBeforeTheInput(t *testing.T) {
// ffmpeg also allocated ~570 MB opening the 900-stream file (#49).
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)
ms, in := indexOf(args, "-max_streams"), indexOf(args, "-i")
require.GreaterOrEqual(t, ms, 0, "argv: %v", args)
assert.Equal(t, "32", args[ms+1])
assert.Less(t, ms, in, "an input option placed after -i is applied to the output")
}
func TestFFmpegToWAV_ABoundedPartOfAFloodingStderrIsKept(t *testing.T) {
// A hostile mp3 made ffmpeg print ~0.4 bytes of stderr per input byte (83 MB for a
// 209 MB file), all of it buffered, then copied twice into the error (#49 review).
bin := stub(t, `head -c 5000000 /dev/zero | tr '\0' 'e' >&2; exit 1`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "o.wav"))
require.Error(t, err)
assert.Less(t, len(err.Error()), 16*1024, "the error carries a bounded head of stderr, not all of it")
assert.Contains(t, err.Error(), "eeee", "and still carries some of it")
}
+9 -85
View File
@@ -4,7 +4,6 @@
package audio package audio
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -20,51 +19,6 @@ import (
type FFprobe struct { type FFprobe struct {
// Bin is the ffprobe executable ("ffprobe" resolves via PATH). // Bin is the ffprobe executable ("ffprobe" resolves via PATH).
Bin string Bin string
// MaxOutput bounds how much of ffprobe's stdout is read; zero means DefaultMaxOutput.
MaxOutput int
// Timeout bounds one probe; zero means DefaultProbeTimeout. A probe is milliseconds of
// work on a real file, so one that runs this long is the FILE's doing (#49).
Timeout time.Duration
}
// DefaultProbeTimeout is the probe deadline when FFprobe.Timeout is unset.
const DefaultProbeTimeout = 45 * time.Second
// DefaultMaxOutput is far above the narrowed probe output of a legitimate file (a few
// hundred bytes) and far below what a hostile one can make ffprobe print.
const DefaultMaxOutput = 1 << 20
// maxStderr is how much of ffprobe's stderr is kept for the error message.
const maxStderr = 4096
// errOutputCap is the write error that stops ffprobe once it prints past the cap.
var errOutputCap = errors.New("audio: ffprobe output exceeds the cap")
// capWriter collects up to max bytes and then refuses further writes, which closes the
// pipe and ends the process instead of buffering whatever it prints.
type capWriter struct {
buf bytes.Buffer
max int
}
func (w *capWriter) Write(p []byte) (int, error) {
if w.buf.Len()+len(p) > w.max {
return 0, errOutputCap
}
return w.buf.Write(p)
}
// headWriter keeps the first max bytes and silently drops the rest.
type headWriter struct {
buf bytes.Buffer
max int
}
func (w *headWriter) Write(p []byte) (int, error) {
if room := w.max - w.buf.Len(); room > 0 {
w.buf.Write(p[:min(room, len(p))])
}
return len(p), nil
} }
// AllowedDemuxers are the input formats an upload may be. The input is // AllowedDemuxers are the input formats an upload may be. The input is
@@ -76,22 +30,9 @@ func (w *headWriter) Write(p []byte) (int, error) {
// formats. "mov" covers .mp4/.m4a/.3gp; "matroska" covers .webm. // 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" const AllowedDemuxers = "wav,mov,mp4,m4a,3gp,mp3,ogg,matroska,webm,flac,aac,opus,aiff,amr,asf"
// MaxStreams bounds how many streams a file may declare. A 1.3 MB file declaring 900 of
// them cost ~570 MB of RSS in each of ffprobe and ffmpeg (#49), against a 512 Mi pod;
// real recordings have a handful.
const MaxStreams = "32"
// inputRestrictions are ffmpeg/ffprobe INPUT options and must precede the input. // inputRestrictions are ffmpeg/ffprobe INPUT options and must precede the input.
// Local files and pipes only: no network, no other protocols, and a bounded stream count. // Local files and pipes only: no network, no other protocols.
var inputRestrictions = []string{ var inputRestrictions = []string{"-protocol_whitelist", "file,pipe", "-format_whitelist", AllowedDemuxers}
"-protocol_whitelist", "file,pipe",
"-format_whitelist", AllowedDemuxers,
"-max_streams", MaxStreams,
}
// probeEntries is exactly what ParseProbe reads. -show_format/-show_streams print every
// tag too, and a 120 MB WAV made 491 MB of JSON that Output() buffers.
const probeEntries = "format=duration:stream=codec_type:stream_disposition=attached_pic"
// killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close. // killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close.
const killGrace = 2 * time.Second const killGrace = 2 * time.Second
@@ -107,38 +48,21 @@ func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error)
bin = "ffprobe" bin = "ffprobe"
} }
args := append([]string{"-v", "error"}, inputRestrictions...) args := append([]string{"-v", "error"}, inputRestrictions...)
args = append(args, "-print_format", "json", "-show_entries", probeEntries, path) args = append(args, "-print_format", "json", "-show_format", "-show_streams", path)
timeout := f.Timeout cmd := exec.CommandContext(ctx, bin, args...)
if timeout <= 0 {
timeout = DefaultProbeTimeout
}
probeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(probeCtx, bin, args...)
cmd.WaitDelay = killGrace cmd.WaitDelay = killGrace
maxOut := f.MaxOutput out, err := cmd.Output()
if maxOut <= 0 { if err != nil {
maxOut = DefaultMaxOutput if ctxErr := ctx.Err(); ctxErr != nil {
}
stdout, stderr := &capWriter{max: maxOut}, &headWriter{max: maxStderr}
cmd.Stdout, cmd.Stderr = stdout, stderr
if err := cmd.Run(); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil { // the caller gave up: not a verdict on the file
return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr) return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr)
} }
if probeCtx.Err() != nil { // our own deadline passed while the caller was still waiting
return uploads.Probe{}, fmt.Errorf("%w: ffprobe did not finish within %s", uploads.ErrUnreadable, timeout)
}
if errors.Is(err, errOutputCap) {
return uploads.Probe{}, fmt.Errorf("%w: ffprobe printed more than %d bytes", uploads.ErrUnreadable, maxOut)
}
var exit *exec.ExitError var exit *exec.ExitError
if errors.As(err, &exit) { if errors.As(err, &exit) {
return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, stderr.buf.String()) return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, exit.Stderr)
} }
return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err) return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err)
} }
p, err := ParseProbe(stdout.buf.Bytes()) p, err := ParseProbe(out)
if err != nil { if err != nil {
return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err) return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err)
} }
-75
View File
@@ -184,78 +184,3 @@ func TestAllowedDemuxers_ExcludeThePlaylistFormats(t *testing.T) {
assert.Contains(t, strings.Split(audio.AllowedDemuxers, ","), good) assert.Contains(t, strings.Split(audio.AllowedDemuxers, ","), good)
} }
} }
func TestFFprobeProbe_CapsTheStreamCountBeforeTheInput(t *testing.T) {
// A 1.3 MB file declaring 900 streams cost ~570 MB of RSS in ffprobe (#49) against
// a 512 Mi pod. -max_streams is an input option: it must precede the path.
bin, argvFile := argvStub(t)
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1")
require.NoError(t, err)
args := argvOf(t, argvFile)
ms := indexOf(args, "-max_streams")
require.GreaterOrEqual(t, ms, 0, "argv: %v", args)
assert.Equal(t, "32", args[ms+1])
assert.Less(t, ms, len(args)-1, "must come before the path, which stays last")
}
func TestFFprobeProbe_AsksOnlyForTheFieldsParseProbeReads(t *testing.T) {
// -show_format/-show_streams print every tag: a 120 MB WAV produced 491 MB of JSON
// that cmd.Output() buffers in Go (#49).
bin, argvFile := argvStub(t)
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1")
require.NoError(t, err)
args := argvOf(t, argvFile)
assert.Equal(t, -1, indexOf(args, "-show_format"), "argv: %v", args)
assert.Equal(t, -1, indexOf(args, "-show_streams"), "argv: %v", args)
se := indexOf(args, "-show_entries")
require.GreaterOrEqual(t, se, 0, "argv: %v", args)
assert.Equal(t, "format=duration:stream=codec_type:stream_disposition=attached_pic", args[se+1])
}
func TestFFprobeProbe_OutputPastTheCapIsUnreadableNotBuffered(t *testing.T) {
// ffprobe's stdout is attacker-influenced (tags, 491 MB observed). Past the cap the
// probe is stopped and the file is reported as unreadable (#49).
bin := stub(t, `head -c 200000 /dev/zero | tr '\0' 'a'`)
_, err := (&audio.FFprobe{Bin: bin, MaxOutput: 4096}).Probe(context.Background(), "/x")
assert.ErrorIs(t, err, uploads.ErrUnreadable)
}
func TestFFprobeProbe_OutputWithinTheCapIsParsed(t *testing.T) {
bin := stub(t, `echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"90"}}'`)
got, err := (&audio.FFprobe{Bin: bin, MaxOutput: 4096}).Probe(context.Background(), "/x")
require.NoError(t, err)
assert.True(t, got.HasAudio)
}
func TestFFprobeProbe_AHungProbeTimesOutOnItsOwnDeadlineAsUnreadableNotAsOurFault(t *testing.T) {
// Accept runs the probe on the request context, which has no deadline of its own;
// a probe that never returns would pin a request (#49). `exec` so the sleep is the
// process itself and not a child holding the pipe open.
bin := stub(t, `exec sleep 30`)
start := time.Now()
_, err := (&audio.FFprobe{Bin: bin, Timeout: 200 * time.Millisecond}).Probe(context.Background(), "/x")
assert.ErrorIs(t, err, uploads.ErrUnreadable)
assert.Less(t, time.Since(start), 3*time.Second)
}
func TestFFprobeProbe_ParentCancellationIsStillNotUnreadableWhenATimeoutIsSet(t *testing.T) {
bin := stub(t, `exec sleep 30`)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := (&audio.FFprobe{Bin: bin, Timeout: time.Minute}).Probe(ctx, "/x")
require.Error(t, err)
assert.False(t, errors.Is(err, uploads.ErrUnreadable), "the caller giving up is not a verdict on the file")
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
-156
View File
@@ -1,156 +0,0 @@
package llm
// Observes how much of a gateway response the client will read. The body is read before
// the status is looked at, so a misbehaving gateway or proxy that streams forever was
// bounded only by the client's overall timeout (#53).
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// endlessGateway answers with the given status and streams filler until the client hangs
// up; wrote reports how many bytes it managed to send.
func endlessGateway(t *testing.T, status int, wrote *atomic.Int64) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
chunk := []byte(strings.Repeat("a", 4096))
for {
n, err := w.Write(chunk)
wrote.Add(int64(n))
if err != nil {
return
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestClient_ASuccessBodyLargerThanTheCapIsAnErrorAndTheClientStopsReading(t *testing.T) {
var wrote atomic.Int64
srv := endlessGateway(t, http.StatusOK, &wrote)
c := New(srv.URL, "", "m", 30*time.Second, WithMaxResponseBytes(8192))
start := time.Now()
_, err := c.Complete(context.Background(), "s", "u")
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("want a response-too-large error, got %v", err)
}
if time.Since(start) > 5*time.Second {
t.Fatalf("the client kept reading: took %s", time.Since(start))
}
// Hanging up lets the server's writes fail; allow what the transport had already buffered.
time.Sleep(100 * time.Millisecond)
if got := wrote.Load(); got > 8192+1<<20 {
t.Fatalf("server managed to send %d bytes after a cap of 8192", got)
}
}
func TestClient_AFailingBodyIsReadOnlyUpToTheErrorBound(t *testing.T) {
var wrote atomic.Int64
srv := endlessGateway(t, http.StatusBadGateway, &wrote)
c := New(srv.URL, "", "m", 30*time.Second)
start := time.Now()
_, err := c.Complete(context.Background(), "s", "u")
he, ok := err.(*HTTPError)
if !ok {
t.Fatalf("want *HTTPError, got %T: %v", err, err)
}
if he.Code != http.StatusBadGateway {
t.Fatalf("code %d", he.Code)
}
if len(he.Body) > maxErrorBody+1 {
t.Fatalf("kept %d bytes of an error body, bound is %d", len(he.Body), maxErrorBody)
}
if time.Since(start) > 5*time.Second {
t.Fatalf("the client kept reading an endless error body: took %s", time.Since(start))
}
}
// sizedGateway answers 200 with a valid completion padded with trailing whitespace to
// exactly size bytes, so the boundary of the cap can be probed to the byte.
func sizedGateway(t *testing.T, size int) *httptest.Server {
t.Helper()
const base = `{"choices":[{"message":{"content":"ok"}}]}`
payload := base + strings.Repeat(" ", size-len(base))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(payload))
}))
t.Cleanup(srv.Close)
return srv
}
func TestClient_ABodyOfExactlyTheCapIsReadAndOneByteMoreIsRefused(t *testing.T) {
const capBytes = 4096
for _, tt := range []struct {
name string
size int
wantErr bool
}{
{"one under", capBytes - 1, false},
{"exactly the cap", capBytes, false},
{"one over", capBytes + 1, true},
} {
t.Run(tt.name, func(t *testing.T) {
c := New(sizedGateway(t, tt.size).URL, "", "m", 5*time.Second, WithMaxResponseBytes(capBytes))
got, err := c.Complete(context.Background(), "s", "u")
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("want a response-too-large error, got %q, %v", got, err)
}
return
}
if err != nil || got != "ok" {
t.Fatalf("want ok, got %q, %v", got, err)
}
})
}
}
// New is what production calls: no option. The default is therefore the live bound.
func TestClient_TheDefaultCapIsEightMiBWhenNoOptionIsGiven(t *testing.T) {
for _, tt := range []struct {
name string
size int
wantErr bool
}{
{"exactly 8 MiB", 8 << 20, false},
{"8 MiB plus one", 8<<20 + 1, true},
} {
t.Run(tt.name, func(t *testing.T) {
c := New(sizedGateway(t, tt.size).URL, "", "m", 10*time.Second)
_, err := c.Complete(context.Background(), "s", "u")
if (err != nil) != tt.wantErr {
t.Fatalf("size %d: err = %v, wantErr %v", tt.size, err, tt.wantErr)
}
})
}
}
func TestClient_ANonPositiveCapOptionKeepsTheDefault(t *testing.T) {
for _, n := range []int64{0, -1} {
c := New(sizedGateway(t, 1024).URL, "", "m", 5*time.Second, WithMaxResponseBytes(n))
if got, err := c.Complete(context.Background(), "s", "u"); err != nil || got != "ok" {
t.Fatalf("WithMaxResponseBytes(%d): want ok from the default cap, got %q, %v", n, got, err)
}
}
}
+4 -60
View File
@@ -25,33 +25,14 @@ import (
// docs/homelab-integration.md. // docs/homelab-integration.md.
const defaultMaxTokens = 8192 const defaultMaxTokens = 8192
// defaultTemperature is what every caller got before WithTemperature existed.
const defaultTemperature = 0.2
// Client calls an OpenAI-compatible chat completions endpoint. // Client calls an OpenAI-compatible chat completions endpoint.
type Client struct { type Client struct {
baseURL string baseURL string
apiKey string apiKey string
model string model string
maxTokens int maxTokens int
temp float64
httpClient *http.Client httpClient *http.Client
usageHook func(model string, prompt, completion int) usageHook func(model string, prompt, completion int)
maxBody int64
}
// defaultMaxResponseBytes bounds a success body. A completion is a few KB of text; this
// leaves room for a long one and none for a gateway that streams forever (#53).
const defaultMaxResponseBytes = 8 << 20
// WithMaxResponseBytes overrides the bound on a success response body. A non-positive n is
// ignored (keeps the default).
func WithMaxResponseBytes(n int64) Option {
return func(c *Client) {
if n > 0 {
c.maxBody = n
}
}
} }
// Option configures a Client at construction. Variadic so the existing 4-arg // Option configures a Client at construction. Variadic so the existing 4-arg
@@ -70,35 +51,6 @@ 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 {
return func(c *Client) { c.temp = t }
}
// WithUsageHook registers a callback fired after a successful completion with the // WithUsageHook registers a callback fired after a successful completion with the
// model and the prompt/completion token counts from the response usage block. It // 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 // keeps this copied, stdlib-only package (ADR-004) decoupled from metrics: the
@@ -114,9 +66,7 @@ func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) *
apiKey: apiKey, apiKey: apiKey,
model: model, model: model,
maxTokens: defaultMaxTokens, maxTokens: defaultMaxTokens,
temp: defaultTemperature,
httpClient: &http.Client{Timeout: timeout}, httpClient: &http.Client{Timeout: timeout},
maxBody: defaultMaxResponseBytes,
} }
for _, opt := range opts { for _, opt := range opts {
opt(c) opt(c)
@@ -155,7 +105,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
{Role: "system", Content: system}, {Role: "system", Content: system},
{Role: "user", Content: user}, {Role: "user", Content: user},
}, },
Temperature: c.temp, Temperature: 0.2,
MaxTokens: c.maxTokens, MaxTokens: c.maxTokens,
} }
b, err := json.Marshal(body) b, err := json.Marshal(body)
@@ -200,18 +150,12 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
} }
defer resp.Body.Close() //nolint:errcheck defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK { out, err := io.ReadAll(resp.Body)
// Only the bounded head is ever used (HTTPError.Error truncates to it), so read no more.
// A read error here only shortens a diagnostic; the status code below is what callers act on.
head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
return "", &HTTPError{Code: resp.StatusCode, Body: string(head)}
}
out, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBody+1))
if err != nil { if err != nil {
return "", fmt.Errorf("read response: %w", err) return "", fmt.Errorf("read response: %w", err)
} }
if int64(len(out)) > c.maxBody { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("LLM response exceeds %d bytes", c.maxBody) return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, out)
} }
var cr chatResponse var cr chatResponse
@@ -1,63 +0,0 @@
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()))
}
}
-57
View File
@@ -1,57 +0,0 @@
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)
}
}
@@ -1 +0,0 @@
ALTER TABLE uploaded_transcripts DROP COLUMN reconciliation;
@@ -1,12 +0,0 @@
-- 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;
@@ -1,4 +0,0 @@
ALTER TABLE uploaded_transcripts
DROP COLUMN alive_at,
DROP COLUMN progress_at,
DROP COLUMN progress;
@@ -1,15 +0,0 @@
-- 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;
@@ -1,2 +0,0 @@
ALTER TABLE uploaded_transcripts
DROP COLUMN language;
@@ -1,13 +0,0 @@
-- Migration 021: the language an upload is transcribed as (tapir#61).
--
-- 'sv' Swedish: the engines are sent language=sv. Every row before this migration was
-- transcribed that way (the request hard-coded it), so the default is true history.
-- 'auto' Other: the engine detects the language and the Swedish-only engine is not used.
--
-- A closed set held by the table as well as by the form and the request code, so a second
-- writer or a bug cannot store a value the engines are never meant to receive. The column
-- sits on the already-FORCE'd, RLS-scoped uploaded_transcripts, so the exact-set isolation
-- test is unaffected.
ALTER TABLE uploaded_transcripts
ADD COLUMN language TEXT NOT NULL DEFAULT 'sv'
CONSTRAINT uploaded_transcripts_language_check CHECK (language IN ('sv', 'auto'));
+13 -100
View File
@@ -17,10 +17,6 @@ import (
// nothing about whether someone else's upload exists (ADR-033). // nothing about whether someone else's upload exists (ADR-033).
var ErrUploadNotFound = errors.New("store: uploaded transcript not found") var ErrUploadNotFound = errors.New("store: uploaded transcript not found")
// ErrUploadInProgress is returned by DeleteUpload for an upload that is still queued or
// transcribing: the worker would write its result to a row that no longer exists.
var ErrUploadInProgress = errors.New("store: uploaded transcript is still being processed")
// Uploaded transcripts are PRIVATE per-user content, stored in their own // Uploaded transcripts are PRIVATE per-user content, stored in their own
// RLS-scoped table (migration 017) — never in the shared `transcripts` (ADR-021). // 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 // Every method goes through withUser, so row-level security confines it to the
@@ -31,12 +27,12 @@ var ErrUploadInProgress = errors.New("store: uploaded transcript is still being
// CreateUpload records a new upload in the queued state and returns its id. // CreateUpload records a new upload in the queued state and returns its id.
// hadVideo records that the file carried a video track (only its audio is used); // 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. // 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, lang domain.Language, hadVideo bool) (string, error) { func (s *Store) CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error) {
var id string var id string
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, return tx.QueryRow(ctx,
`INSERT INTO uploaded_transcripts (user_id, title, language, had_video) VALUES ($1, $2, $3, $4) RETURNING id`, `INSERT INTO uploaded_transcripts (user_id, title, had_video) VALUES ($1, $2, $3) RETURNING id`,
userID, title, string(lang), hadVideo).Scan(&id) userID, title, hadVideo).Scan(&id)
}); err != nil { }); err != nil {
return "", fmt.Errorf("store: create upload: %w", err) return "", fmt.Errorf("store: create upload: %w", err)
} }
@@ -56,84 +52,23 @@ func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.
if err != nil { if err != nil {
return fmt.Errorf("store: complete upload: marshal segments: %w", err) 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", return s.updateUpload(ctx, userID, id, "complete upload",
`UPDATE uploaded_transcripts `UPDATE uploaded_transcripts
SET status = 'done', content = $3, segments = $4, endpoint = $5, SET status = 'done', content = $3, segments = $4, endpoint = $5,
audio_seconds = $6, reconciliation = $7, error_kind = NULL, finished_at = NOW() audio_seconds = $6, error_kind = NULL, finished_at = NOW()
WHERE id = $1 AND user_id = $2`, WHERE id = $1 AND user_id = $2`,
t.Text, segs, t.Endpoint, t.AudioSeconds, rec) t.Text, segs, t.Endpoint, t.AudioSeconds)
} }
// SaveProgress records the job's phases and when something last really changed // FailUpload moves an upload to failed with a typed reason.
// (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 at the given time. It does not touch progress_at:
// alive says the service is still reporting, not that work is moving (ADR-035). The time
// comes from the application clock, the one progress_at and the page's health check use,
// so a database clock that disagrees cannot make a healthy job look lost (#51).
func (s *Store) Heartbeat(ctx context.Context, userID, id string, at time.Time) error {
return s.updateUpload(ctx, userID, id, "heartbeat",
`UPDATE uploaded_transcripts SET alive_at = $3
WHERE id = $1 AND user_id = $2`, at)
}
// FailUpload moves a queued or transcribing upload to failed with a typed reason. A
// finished upload is left as it was: a shutdown can record a job as failed after its
// transcript was already committed (the commit lands, the caller sees a cancelled
// context), and that must not hide the text. Such a call touches no row and returns
// ErrUploadNotFound.
func (s *Store) FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error { func (s *Store) FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error {
return s.updateUpload(ctx, userID, id, "fail upload", return s.updateUpload(ctx, userID, id, "fail upload",
`UPDATE uploaded_transcripts `UPDATE uploaded_transcripts
SET status = 'failed', error_kind = $3, finished_at = NOW() SET status = 'failed', error_kind = $3, finished_at = NOW()
WHERE id = $1 AND user_id = $2 AND status IN ('queued', 'transcribing')`, WHERE id = $1 AND user_id = $2`,
string(kind)) string(kind))
} }
// DeleteUpload removes one of the caller's finished (done or failed) uploads. An upload
// still queued or transcribing is refused with ErrUploadInProgress and left untouched; one
// that is not the caller's is ErrUploadNotFound, as for every other write (#37).
func (s *Store) DeleteUpload(ctx context.Context, userID, id string) error {
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`DELETE FROM uploaded_transcripts
WHERE id = $1 AND user_id = $2 AND status IN ('done', 'failed')`, id, userID)
if err != nil {
return fmt.Errorf("store: delete upload: %w", err)
}
if ct.RowsAffected() > 0 {
return nil
}
var exists bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM uploaded_transcripts WHERE id = $1 AND user_id = $2)`,
id, userID).Scan(&exists); err != nil {
return fmt.Errorf("store: delete upload: %w", err)
}
if exists {
return ErrUploadInProgress
}
return ErrUploadNotFound
})
}
// updateUpload runs one UPDATE keyed on ($1 = id, $2 = userID) plus extra args, // 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 // 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). // user's upload looks like under RLS (the row is invisible, not forbidden).
@@ -221,26 +156,22 @@ func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
return total, nil return total, nil
} }
const uploadSelect = `SELECT id, user_id, title, language, status, content, segments, endpoint, const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
audio_seconds, error_kind, had_video, reconciliation, progress, progress_at, alive_at, audio_seconds, error_kind, had_video, created_at, finished_at FROM uploaded_transcripts`
created_at, finished_at FROM uploaded_transcripts`
// scanUpload scans one uploadSelect row. withText controls whether the transcript // scanUpload scans one uploadSelect row. withText controls whether the transcript
// body and segments are kept (GetUpload) or dropped (ListUploads). // body and segments are kept (GetUpload) or dropped (ListUploads).
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error { func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
var ( var (
status, errKind, endpoint, content *string status, errKind, endpoint, content *string
segments, reconciliation, progress []byte segments []byte
seconds *float64 seconds *float64
finished, progressAt, aliveAt *time.Time finished *time.Time
) )
var language string if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &language, &status, &content, &segments, &endpoint, &seconds, &errKind, &u.HadVideo, &u.CreatedAt, &finished); err != nil {
&seconds, &errKind, &u.HadVideo, &reconciliation, &progress, &progressAt, &aliveAt,
&u.CreatedAt, &finished); err != nil {
return err return err
} }
u.Language = domain.Language(language)
u.Status = domain.UploadStatus(deref(status)) u.Status = domain.UploadStatus(deref(status))
u.Endpoint = deref(endpoint) u.Endpoint = deref(endpoint)
u.ErrorKind = domain.UploadErrorKind(deref(errKind)) u.ErrorKind = domain.UploadErrorKind(deref(errKind))
@@ -253,24 +184,6 @@ func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error
if !withText { if !withText {
return nil 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 {
return fmt.Errorf("unmarshal reconciliation: %w", err)
}
u.Reconciliation = &r
}
u.Content = deref(content) u.Content = deref(content)
if len(segments) > 0 { if len(segments) > 0 {
if err := json.Unmarshal(segments, &u.Segments); err != nil { if err := json.Unmarshal(segments, &u.Segments); err != nil {
@@ -1,68 +0,0 @@
package store_test
// Deleting one recording (#37). A finished or failed upload can be removed by its owner;
// one that is queued or transcribing is refused until it ends, because the worker would
// otherwise write its result to a row that is gone (D3 in the sprint plan).
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"
)
func TestDeleteUpload_RemovesAFinishedOrFailedUploadOfTheOwner(t *testing.T) {
for name, finish := range map[string]func(*store.Store, string) error{
"done": func(s *store.Store, id string) error {
return s.CompleteUpload(context.Background(), userA, id, domain.Transcription{Text: "synthetic", Endpoint: "iguana/kb-whisper"})
},
"failed": func(s *store.Store, id string) error {
return s.FailUpload(context.Background(), userA, id, domain.UploadErrTranscriptionFailed)
},
} {
t.Run(name, func(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
require.NoError(t, s.StartTranscribing(context.Background(), userA, id))
require.NoError(t, finish(s, id))
require.NoError(t, s.DeleteUpload(context.Background(), userA, id))
_, err := s.GetUpload(context.Background(), userA, id)
assert.ErrorIs(t, err, store.ErrUploadNotFound)
})
}
}
func TestDeleteUpload_RefusesAnUploadThatIsStillQueuedOrTranscribingAndKeepsTheRow(t *testing.T) {
s := newStore(t)
ctx := context.Background()
queued := startedUpload(t, s)
running, err := s.CreateUpload(ctx, userA, "running", domain.LangSwedish, false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, running))
for _, id := range []string{queued, running} {
assert.ErrorIs(t, s.DeleteUpload(ctx, userA, id), store.ErrUploadInProgress)
_, err := s.GetUpload(ctx, userA, id)
assert.NoError(t, err, "a refused delete must leave the row")
}
}
func TestDeleteUpload_AnotherUsersUploadIsNotFoundAndSurvives(t *testing.T) {
s := newStore(t)
ctx := context.Background()
id := startedUpload(t, s)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
require.NoError(t, s.FailUpload(ctx, userA, id, domain.UploadErrTranscriptionFailed))
assert.ErrorIs(t, s.DeleteUpload(ctx, userB, id), store.ErrUploadNotFound,
"existence is not disclosed to a non-owner")
_, err := s.GetUpload(ctx, userA, id)
assert.NoError(t, err, "B's delete must not have removed A's row")
}
@@ -1,47 +0,0 @@
package store_test
// A shutdown can record a job as failed after its transcript was already committed (the
// commit lands, the caller sees a cancelled context). FailUpload must not be able to turn a
// finished row into a failed one and hide the text (#50 review).
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
)
func TestFailUpload_LeavesAFinishedUploadAsItWas(t *testing.T) {
s := newStore(t)
ctx := context.Background()
id := startedUpload(t, s)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "synthetic text", Endpoint: "iguana/kb-whisper"}))
_ = s.FailUpload(ctx, userA, id, domain.UploadErrInterrupted)
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadDone, got.Status)
assert.Equal(t, "synthetic text", got.Content)
assert.Empty(t, got.ErrorKind)
}
func TestFailUpload_StillFailsAQueuedOrTranscribingUpload(t *testing.T) {
s := newStore(t)
ctx := context.Background()
queued := startedUpload(t, s)
running, err := s.CreateUpload(ctx, userA, "r", domain.LangSwedish, false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, running))
for _, id := range []string{queued, running} {
require.NoError(t, s.FailUpload(ctx, userA, id, domain.UploadErrBusy))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadFailed, got.Status)
}
}
@@ -1,137 +0,0 @@
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", domain.LangSwedish, 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))
beatAt := at.Add(90 * time.Second) // deliberately not the database's NOW()
require.NoError(t, s.Heartbeat(ctx, userA, id, beatAt))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.True(t, got.AliveAt.Equal(beatAt), "the heartbeat is stamped with the application's clock, the same one progress_at uses, not the database's NOW() (#51)")
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, time.Now())
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")
}
@@ -33,7 +33,7 @@ func TestCreateUpload_StartsQueuedAndIsOwnedByTheCreator(t *testing.T) {
seedUsers(t, userA) seedUsers(t, userA)
ctx := context.Background() ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "Teams export", domain.LangSwedish, false) id, err := s.CreateUpload(ctx, userA, "Teams export", false)
require.NoError(t, err) require.NoError(t, err)
got, err := s.GetUpload(ctx, userA, id) got, err := s.GetUpload(ctx, userA, id)
@@ -50,7 +50,7 @@ func TestUploadLifecycle_CompletedKeepsTextSegmentsEndpointAndDuration(t *testin
s := newStore(t) s := newStore(t)
seedUsers(t, userA) seedUsers(t, userA)
ctx := context.Background() ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", domain.LangSwedish, false) id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id)) require.NoError(t, s.StartTranscribing(ctx, userA, id))
@@ -84,7 +84,7 @@ func TestUploadLifecycle_FailedRecordsTypedKindAndNoContent(t *testing.T) {
s := newStore(t) s := newStore(t)
seedUsers(t, userA) seedUsers(t, userA)
ctx := context.Background() ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", domain.LangSwedish, false) id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id)) require.NoError(t, s.StartTranscribing(ctx, userA, id))
@@ -102,7 +102,7 @@ func TestGetUpload_AnotherUsersIDIsNotFound(t *testing.T) {
s := newStore(t) s := newStore(t)
seedUsers(t, userA, userB) seedUsers(t, userA, userB)
ctx := context.Background() ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "A's meeting", domain.LangSwedish, false) id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
require.NoError(t, err) require.NoError(t, err)
_, err = s.GetUpload(ctx, userB, id) _, err = s.GetUpload(ctx, userB, id)
@@ -115,7 +115,7 @@ func TestUploadWrites_ByAnotherUserChangeNothing(t *testing.T) {
s := newStore(t) s := newStore(t)
seedUsers(t, userA, userB) seedUsers(t, userA, userB)
ctx := context.Background() ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "A's meeting", domain.LangSwedish, false) id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
require.NoError(t, err) require.NoError(t, err)
assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound) assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound)
@@ -132,11 +132,11 @@ func TestListUploads_ReturnsOnlyTheCallersNewestFirstWithoutTranscriptText(t *te
s := newStore(t) s := newStore(t)
seedUsers(t, userA, userB) seedUsers(t, userA, userB)
ctx := context.Background() ctx := context.Background()
first, err := s.CreateUpload(ctx, userA, "first", domain.LangSwedish, false) first, err := s.CreateUpload(ctx, userA, "first", false)
require.NoError(t, err) require.NoError(t, err)
second, err := s.CreateUpload(ctx, userA, "second", domain.LangSwedish, false) second, err := s.CreateUpload(ctx, userA, "second", false)
require.NoError(t, err) require.NoError(t, err)
_, err = s.CreateUpload(ctx, userB, "B's", domain.LangSwedish, false) _, err = s.CreateUpload(ctx, userB, "B's", false)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, first)) require.NoError(t, s.StartTranscribing(ctx, userA, first))
require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"})) 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) _, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid)
require.NoError(t, err) require.NoError(t, err)
} }
queued, err := s.CreateUpload(ctx, userA, "queued", domain.LangSwedish, false) queued, err := s.CreateUpload(ctx, userA, "queued", false)
require.NoError(t, err) require.NoError(t, err)
running, err := s.CreateUpload(ctx, userB, "running", domain.LangSwedish, false) running, err := s.CreateUpload(ctx, userB, "running", false)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userB, running)) require.NoError(t, s.StartTranscribing(ctx, userB, running))
done, err := s.CreateUpload(ctx, userA, "done", domain.LangSwedish, false) done, err := s.CreateUpload(ctx, userA, "done", false)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, done)) require.NoError(t, s.StartTranscribing(ctx, userA, done))
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"})) require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"}))
@@ -191,9 +191,9 @@ func TestCreateUpload_RecordsWhetherTheFileHadAVideoTrack(t *testing.T) {
s := newStore(t) s := newStore(t)
seedUsers(t, userA) seedUsers(t, userA)
ctx := context.Background() ctx := context.Background()
withVideo, err := s.CreateUpload(ctx, userA, "camera off", domain.LangSwedish, true) withVideo, err := s.CreateUpload(ctx, userA, "camera off", true)
require.NoError(t, err) require.NoError(t, err)
audioOnly, err := s.CreateUpload(ctx, userA, "voice only", domain.LangSwedish, false) audioOnly, err := s.CreateUpload(ctx, userA, "voice only", false)
require.NoError(t, err) require.NoError(t, err)
gotVideo, err := s.GetUpload(ctx, userA, withVideo) gotVideo, err := s.GetUpload(ctx, userA, withVideo)
@@ -216,9 +216,9 @@ func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
s := newStore(t) s := newStore(t)
seedUsers(t, userA) seedUsers(t, userA)
ctx := context.Background() ctx := context.Background()
done, err := s.CreateUpload(ctx, userA, "d", domain.LangSwedish, true) done, err := s.CreateUpload(ctx, userA, "d", true)
require.NoError(t, err) require.NoError(t, err)
failed, err := s.CreateUpload(ctx, userA, "f", domain.LangSwedish, true) failed, err := s.CreateUpload(ctx, userA, "f", true)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, done)) 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.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "x", Endpoint: "e"}))
@@ -230,109 +230,3 @@ func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job") 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", domain.LangSwedish, 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: "Axel", Alt: "Alex", Used: "alt"},
{Seconds: 3400, Base: "A+2", Alt: "en A", 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", domain.LangSwedish, 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", domain.LangSwedish, 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)
}
func TestCreateUpload_RecordsTheLanguageTheUserChoseAndTheListCarriesIt(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
swedish, err := s.CreateUpload(ctx, userA, "sv", domain.LangSwedish, false)
require.NoError(t, err)
auto, err := s.CreateUpload(ctx, userA, "auto", domain.LangAuto, false)
require.NoError(t, err)
gotSv, err := s.GetUpload(ctx, userA, swedish)
require.NoError(t, err)
gotAuto, err := s.GetUpload(ctx, userA, auto)
require.NoError(t, err)
listed, err := s.ListUploads(ctx, userA)
require.NoError(t, err)
assert.Equal(t, domain.LangSwedish, gotSv.Language)
assert.Equal(t, domain.LangAuto, gotAuto.Language)
byID := map[string]domain.Language{}
for _, u := range listed {
byID[u.ID] = u.Language
}
assert.Equal(t, map[string]domain.Language{swedish: domain.LangSwedish, auto: domain.LangAuto}, byID)
}
// The closed set is also held by the table, so a bug or a second writer cannot store a
// value the engines are never meant to receive (tapir#61).
func TestUploadedTranscripts_TheTableRefusesALanguageOutsideTheClosedSet(t *testing.T) {
newStore(t)
seedUsers(t, userA)
_, err := rawPool(t).Exec(context.Background(),
`INSERT INTO uploaded_transcripts (user_id, title, language) VALUES ($1, 'x', 'en')`, userA)
require.Error(t, err)
assert.Contains(t, err.Error(), "language", "the CHECK constraint names the column")
}
// A row from before the column existed was transcribed as Swedish; the default says so.
func TestUploadedTranscripts_ARowInsertedWithoutALanguageIsSwedish(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
var id string
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`INSERT INTO uploaded_transcripts (user_id, title) VALUES ($1, 'old') RETURNING id`, userA).Scan(&id))
got, err := s.GetUpload(context.Background(), userA, id)
require.NoError(t, err)
assert.Equal(t, domain.LangSwedish, got.Language)
}
-149
View File
@@ -1,149 +0,0 @@
package transcribe
import (
"context"
"errors"
"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"
)
// 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}
}
// Plan implements uploads.Planner: the phases of a Swedish job, the two-engine one. The
// fallback is a phase only when one is configured.
func (e *Ensemble) Plan() []progress.Planned { return e.PlanFor(domain.LangSwedish) }
// PlanFor implements uploads.LanguagePlanner. A recording that is not Swedish never runs the
// base engine, the comparison or the decisions (transcribeOther), so the page lists only
// what can run: the other engine, then the fallback if one is configured.
func (e *Ensemble) PlanFor(lang domain.Language) []progress.Planned {
var plan []progress.Planned
if lang == domain.LangSwedish || lang == "" {
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"},
}
} else {
plan = []progress.Planned{{Key: "engine_b", Label: "Transcribing with " + e.alt.Name()}}
}
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) {
rep := progress.From(ctx)
if domain.LanguageFrom(ctx) != domain.LangSwedish {
return e.transcribeOther(ctx, rep, wavPath)
}
b, berr := e.run(ctx, rep, "engine_a", e.base, wavPath)
if err := ctx.Err(); err != nil {
return domain.Transcription{}, err
}
a, aerr := e.run(ctx, rep, "engine_b", e.alt, 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)
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 (%s)", e.alt.Name(), errkind.Of(aerr))), nil
case aerr == 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))
if e.fallback == nil {
return domain.Transcription{}, both
}
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
}
// transcribeOther handles a recording that is not Swedish (tapir#61). The base engine is
// Swedish-only, so it never receives the audio, and with one reading there is nothing to
// reconcile: the other engine transcribes alone, then the cloud fallback if it fails and the
// job is still live. The phases that did not run are shown as skipped by the tracker.
func (e *Ensemble) transcribeOther(ctx context.Context, rep progress.Reporter, wavPath string) (domain.Transcription, error) {
t, err := e.run(ctx, rep, "engine_b", e.alt, wavPath)
if err == nil {
t.Endpoint = e.alt.Name()
return t, nil
}
if e.fallback == nil || ctx.Err() != nil {
return domain.Transcription{}, fmt.Errorf("%s: %w", e.alt.Name(), err)
}
f, ferr := e.run(ctx, rep, "fallback", e.fallback, wavPath)
if ferr != nil {
return domain.Transcription{}, errors.Join(
fmt.Errorf("%s: %w", e.alt.Name(), err), fmt.Errorf("%s: %w", e.fallback.Name(), ferr))
}
f.Endpoint = e.fallback.Name()
return f, 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 {
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
}
@@ -1,184 +0,0 @@
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"])
}
// An auto-language upload never runs the base engine, the comparison or the decisions, so the
// page must not list them as coming (tapir#61, PR #62 review).
func TestEnsemblePlanFor_AnAutoLanguageListsOnlyThePhasesThatCanRun(t *testing.T) {
with := newEnsemble("a", "a", nil, nil, true, &verdictAdj{}).e
without := newEnsemble("a", "a", nil, nil, false, &verdictAdj{}).e
keys := func(p []progress.Planned) []string {
var k []string
for _, s := range p {
k = append(k, s.Key)
}
return k
}
assert.Equal(t, []string{"engine_b", "fallback"}, keys(with.PlanFor(domain.LangAuto)))
assert.Equal(t, []string{"engine_b"}, keys(without.PlanFor(domain.LangAuto)), "no fallback configured: none promised")
assert.Equal(t, with.Plan(), with.PlanFor(domain.LangSwedish), "Swedish is the plan it always was")
assert.Contains(t, with.PlanFor(domain.LangAuto)[0].Label, "iguana/kb-whisper")
assert.Equal(t, with.Plan(), with.PlanFor(""), "a job queued before the choice existed is Swedish")
assert.Equal(t, with.PlanFor(domain.LangAuto), with.PlanFor(domain.Language("xx")),
"an unknown language is planned as it is run: not the Swedish phases")
}
@@ -1,36 +0,0 @@
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")
}
@@ -1,255 +0,0 @@
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)
}
// 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)
}
// The base engine is Swedish-only. A recording that is not Swedish must never reach it, and
// there is no second opinion to reconcile: the other engine transcribes alone (tapir#61).
func autoCtx() context.Context { return domain.WithLanguage(context.Background(), domain.LangAuto) }
func TestEnsemble_AnAutoLanguageUploadNeverReachesTheBaseEngineAndIsNotReconciled(t *testing.T) {
adj := &verdictAdj{v: reconcile.VerdictAlt}
r := newEnsemble("base text", "alt text", nil, nil, true, adj)
got, err := r.e.Transcribe(autoCtx(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, []string{"iguana/kb-whisper"}, *r.calls, "only the other engine is called")
assert.Zero(t, adj.calls, "nothing is adjudicated")
assert.Equal(t, "alt text", got.Text)
assert.Equal(t, "iguana/kb-whisper", got.Endpoint)
assert.Nil(t, got.Reconciliation, "reconciliation does not apply, so none is recorded")
}
func TestEnsemble_AnAutoLanguageUploadFallsBackWhenItsEngineFailsAndStillSkipsTheBaseEngine(t *testing.T) {
r := newEnsemble("base text", "alt text", nil, errors.New("engine down"), true, &verdictAdj{})
got, err := r.e.Transcribe(autoCtx(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, []string{"iguana/kb-whisper", "berget/kb-whisper"}, *r.calls)
assert.Equal(t, "berget/kb-whisper", got.Endpoint)
}
func TestEnsemble_AnAutoLanguageUploadWithNoFallbackFailsWithoutTouchingTheBaseEngine(t *testing.T) {
r := newEnsemble("base text", "alt text", nil, errors.New("engine down"), false, &verdictAdj{})
_, err := r.e.Transcribe(autoCtx(), "/a.wav")
require.Error(t, err)
assert.Equal(t, []string{"iguana/kb-whisper"}, *r.calls)
}
func TestEnsemble_ACancelledAutoLanguageJobDoesNotReachTheFallback(t *testing.T) {
r := newEnsemble("base text", "alt text", nil, errors.New("engine down"), true, &verdictAdj{})
ctx, cancel := context.WithCancel(autoCtx())
cancel()
_, err := r.e.Transcribe(ctx, "/a.wav")
require.Error(t, err)
assert.NotContains(t, *r.calls, "berget/kb-whisper", "a stopped job must not ship its audio to a cloud endpoint")
}
+3 -17
View File
@@ -31,9 +31,6 @@ type StatusError struct {
Snippet string Snippet string
} }
// HTTPStatus implements errkind.HTTPStatuser: the code, never the snippet.
func (e *StatusError) HTTPStatus() int { return e.Code }
func (e *StatusError) Error() string { func (e *StatusError) Error() string {
return fmt.Sprintf("transcribe: gateway returned HTTP %d: %s", e.Code, e.Snippet) return fmt.Sprintf("transcribe: gateway returned HTTP %d: %s", e.Code, e.Snippet)
} }
@@ -65,26 +62,15 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
if err != nil { if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: open audio: %w", err) return domain.Transcription{}, fmt.Errorf("transcribe: open audio: %w", err)
} }
defer func() { _ = f.Close() }() defer f.Close()
info, err := f.Stat() info, err := f.Stat()
if err != nil { if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: stat audio: %w", err) return domain.Transcription{}, fmt.Errorf("transcribe: stat audio: %w", err)
} }
// The language is one request field and a closed set: sv is sent, auto sends nothing so
// the engine detects it, and anything else never leaves this process (tapir#61).
fields := [][2]string{{"model", g.model}, {"response_format", "verbose_json"}}
switch lang := domain.LanguageFrom(ctx); lang {
case domain.LangSwedish:
fields = append(fields, [2]string{"language", string(lang)})
case domain.LangAuto:
default:
return domain.Transcription{}, fmt.Errorf("transcribe: language %q is not one tapir sends", lang)
}
var prefix bytes.Buffer var prefix bytes.Buffer
mw := multipart.NewWriter(&prefix) mw := multipart.NewWriter(&prefix)
for _, kv := range fields { for _, kv := range [][2]string{{"model", g.model}, {"response_format", "verbose_json"}, {"language", "sv"}} {
if err := mw.WriteField(kv[0], kv[1]); err != nil { if err := mw.WriteField(kv[0], kv[1]); err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err) return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
} }
@@ -109,7 +95,7 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
if err != nil { if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err) return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
} }
defer func() { _ = resp.Body.Close() }() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 { if resp.StatusCode < 200 || resp.StatusCode > 299 {
snip, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) snip, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
return domain.Transcription{}, &StatusError{Code: resp.StatusCode, Snippet: strings.TrimSpace(string(snip))} return domain.Transcription{}, &StatusError{Code: resp.StatusCode, Snippet: strings.TrimSpace(string(snip))}
@@ -3,13 +3,11 @@ package transcribe_test
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -17,7 +15,6 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe" "git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/domain" "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 // The httptest server stands in for the LiteLLM gateway: it is the process
@@ -233,70 +230,3 @@ func TestGateway_WithNoSegmentsTheTextFieldIsUsedWithWhitespaceCollapsed(t *test
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "Hej allihopa. Vi börjar.", got.Text) 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))
}
func TestGateway_AnErrorBodyIsBoundedToTwoHundredBytesInTheStatusError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(strings.Repeat("x", 100_000)))
}))
defer srv.Close()
wav := filepath.Join(t.TempDir(), "a.wav")
require.NoError(t, os.WriteFile(wav, []byte("RIFF....WAVEfmt "), 0o600))
_, err := transcribe.NewGateway(srv.URL, "", "iguana/kb-whisper", srv.Client()).Transcribe(context.Background(), wav)
var se *transcribe.StatusError
require.ErrorAs(t, err, &se)
assert.LessOrEqual(t, len(se.Snippet), 200, "a gateway's whole body must not become part of an error")
}
// The language travels on the context (tapir#61); the request field is the only place it
// takes effect, so these observe the request the gateway fake received.
func TestGateway_SendsLanguageSvWhenTheContextCarriesNoneAndForSwedish(t *testing.T) {
for name, ctx := range map[string]context.Context{
"no language in the context": context.Background(),
"swedish": domain.WithLanguage(context.Background(), domain.LangSwedish),
} {
t.Run(name, func(t *testing.T) {
var got seen
srv := serve(t, 200, verboseJSON, &got)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
_, err := g.Transcribe(ctx, wavFile(t, "RIFF-synthetic-pcm"))
require.NoError(t, err)
assert.Equal(t, "sv", got.fields["language"])
})
}
}
func TestGateway_OmitsTheLanguageFieldForAnAutoContextSoTheEngineDetectsIt(t *testing.T) {
var got seen
srv := serve(t, 200, verboseJSON, &got)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
_, err := g.Transcribe(domain.WithLanguage(context.Background(), domain.LangAuto), wavFile(t, "RIFF-synthetic-pcm"))
require.NoError(t, err)
_, present := got.fields["language"]
assert.False(t, present, "an auto upload must not be forced to Swedish")
assert.Equal(t, "iguana/kb-whisper", got.fields["model"], "the rest of the request is unchanged")
}
func TestGateway_ALanguageOutsideTheClosedSetIsRefusedBeforeAnythingIsSent(t *testing.T) {
var got seen
srv := serve(t, 200, verboseJSON, &got)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
_, err := g.Transcribe(domain.WithLanguage(context.Background(), domain.Language("en")), wavFile(t, "RIFF-synthetic-pcm"))
require.Error(t, err)
assert.Empty(t, got.method, "no request may reach the engine with a language the closed set does not hold")
}
-33
View File
@@ -1,33 +0,0 @@
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)
}
@@ -1,146 +0,0 @@
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))
}
// WithTimeout sits in front of both local engines in production (cmd/tapir/uploads.go). It
// must hand the engine the job's context, not a fresh one: the language and the
// cancellation both travel on it (tapir#61).
func TestWithTimeout_TheEngineStillReceivesTheLanguageOfTheJob(t *testing.T) {
for name, tt := range map[string]struct {
ctx context.Context
wantField bool
}{
"auto leaves the field out": {domain.WithLanguage(context.Background(), domain.LangAuto), false},
"swedish sends sv": {domain.WithLanguage(context.Background(), domain.LangSwedish), true},
} {
t.Run(name, func(t *testing.T) {
var got seen
srv := serve(t, 200, verboseJSON, &got)
b := transcribe.WithTimeout(transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client()), time.Minute)
_, err := b.Transcribe(tt.ctx, wavFile(t, "RIFF-synthetic-pcm"))
require.NoError(t, err)
lang, present := got.fields["language"]
assert.Equal(t, tt.wantField, present)
if tt.wantField {
assert.Equal(t, "sv", lang)
}
})
}
}
func TestWithTimeout_ACancelledJobIsNotRestartedByTheWrapper(t *testing.T) {
var got seen
srv := serve(t, 200, verboseJSON, &got)
b := transcribe.WithTimeout(transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client()), time.Minute)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := b.Transcribe(ctx, wavFile(t, "RIFF-synthetic-pcm"))
require.Error(t, err)
assert.Empty(t, got.method, "a cancelled job must not send its audio anywhere through the wrapper")
}
+1 -80
View File
@@ -3,9 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"regexp"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -28,10 +26,6 @@ type UploadConfig struct {
// TranscribeTimeout bounds one job (conversion + transcription); a 90 minute // TranscribeTimeout bounds one job (conversion + transcription); a 90 minute
// recording took ~7.5 minutes on iguana. // recording took ~7.5 minutes on iguana.
TranscribeTimeout time.Duration 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 // UploadTmpDir is where uploads are spooled and converted. Audio is deleted
// after transcription, so this holds at most the in-flight files. // after transcription, so this holds at most the in-flight files.
UploadTmpDir string UploadTmpDir string
@@ -48,16 +42,6 @@ type UploadConfig struct {
// NOT the summarizer default: koala/phi4-mini has an 8,192-token context and // 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). // returns HTTP 400 on an hour of speech (~13,000 tokens).
UploadChatModel string 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 ( const (
@@ -65,8 +49,7 @@ const (
defaultUploadMaxMinutes = 90 defaultUploadMaxMinutes = 90
defaultTranscribeModel = "iguana/kb-whisper" defaultTranscribeModel = "iguana/kb-whisper"
defaultTranscribeFallbackModel = "berget/kb-whisper" defaultTranscribeFallbackModel = "berget/kb-whisper"
defaultTranscribeTimeout = 60 * time.Minute defaultTranscribeTimeout = 30 * time.Minute
defaultTranscribeEngineTimeout = 20 * time.Minute
defaultMaxUploadedChars = 90000 defaultMaxUploadedChars = 90000
defaultUploadChatModel = "iguana/gemma4-26b" defaultUploadChatModel = "iguana/gemma4-26b"
) )
@@ -79,8 +62,6 @@ func loadUploadConfig() (UploadConfig, error) {
FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"), FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"),
FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"), FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"),
UploadChatModel: envOr("TAPIR_UPLOAD_CHAT_MODEL", defaultUploadChatModel), 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 != "" { if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" {
@@ -112,29 +93,6 @@ func loadUploadConfig() (UploadConfig, error) {
} }
u.TranscribeTimeout = timeout 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 {
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
}
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) chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars)
if err != nil { if err != nil {
return UploadConfig{}, err return UploadConfig{}, err
@@ -158,40 +116,3 @@ func positiveInt(key string, fallback int) (int, error) {
} }
return n, nil return n, nil
} }
// 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._:-]*$`)
// 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
// 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
}
-101
View File
@@ -1,101 +0,0 @@
package config
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
// 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)
}
}
func TestLoad_TheEngineTimeoutDefaultsToTwentyMinutesAndMustBePositive(t *testing.T) {
clearUploadEnv(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.TranscribeEngineTimeout != 20*time.Minute {
t.Errorf("TranscribeEngineTimeout = %v, want 20m: a 90-minute recording takes about 8 minutes on kb-whisper, and two wedged engines in sequence must leave the job time to reach a fallback", 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)
}
}
}
+3 -79
View File
@@ -12,10 +12,9 @@ func clearUploadEnv(t *testing.T) {
t.Helper() t.Helper()
for _, k := range []string{ for _, k := range []string{
"TAPIR_UPLOADS_ENABLED", "TAPIR_UPLOAD_MAX_MB", "TAPIR_UPLOAD_MAX_MINUTES", "TAPIR_UPLOADS_ENABLED", "TAPIR_UPLOAD_MAX_MB", "TAPIR_UPLOAD_MAX_MINUTES",
"TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT", "TAPIR_TRANSCRIBE_ENGINE_TIMEOUT", "TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT",
"TAPIR_UPLOAD_TMP_DIR", "TAPIR_FFMPEG_BIN", "TAPIR_FFPROBE_BIN", "TAPIR_UPLOAD_TMP_DIR", "TAPIR_FFMPEG_BIN", "TAPIR_FFPROBE_BIN",
"TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", "TAPIR_UPLOAD_CHAT_MODEL", "TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", "TAPIR_UPLOAD_CHAT_MODEL",
"TAPIR_RECONCILE_BASE_MODEL", "TAPIR_RECONCILE_MODEL", "TAPIR_RECONCILE_CHECK_ORDER",
} { } {
unset(t, k) unset(t, k)
} }
@@ -44,11 +43,8 @@ func TestLoad_UploadDefaults(t *testing.T) {
if c.TranscribeFallbackModel != "berget/kb-whisper" { if c.TranscribeFallbackModel != "berget/kb-whisper" {
t.Errorf("TranscribeFallbackModel = %q", c.TranscribeFallbackModel) t.Errorf("TranscribeFallbackModel = %q", c.TranscribeFallbackModel)
} }
if c.TranscribeTimeout != 60*time.Minute { if c.TranscribeTimeout != 30*time.Minute {
t.Errorf("TranscribeTimeout = %v, want 60m (ADR-034: two engines plus ~300 adjudications)", c.TranscribeTimeout) t.Errorf("TranscribeTimeout = %v, want 30m (a 90 min recording took ~7.5 min)", 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() { if c.UploadTmpDir != os.TempDir() {
t.Errorf("UploadTmpDir = %q, want os.TempDir()", c.UploadTmpDir) t.Errorf("UploadTmpDir = %q, want os.TempDir()", c.UploadTmpDir)
@@ -143,75 +139,3 @@ 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 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",
}},
{"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 -56
View File
@@ -147,18 +147,14 @@ const (
ReconcileSkipped ReconcileStatus = "skipped" ReconcileSkipped ReconcileStatus = "skipped"
) )
// ContestedSpot is a place the engines disagreed that the model judged: it changed it, // ContestedSpot is a place the engines disagreed that a reader may want to check
// kept the base's reading, or could not decide. Every judged spot is stored, not only // against the audio: one the model changed, or one it was unsure about.
// 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 { type ContestedSpot struct {
Seconds float64 `json:"seconds"` // roughly where in the recording Seconds float64 `json:"seconds"` // roughly where in the recording
Base string `json:"base"` // what the base engine heard Base string `json:"base"` // what the base engine heard
Alt string `json:"alt"` // what the other engine heard Alt string `json:"alt"` // what the other engine heard
Used string `json:"used"` // "base" or "alt": the reading now in the text 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 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 // Reconciliation records how a merged transcript came about, so the page can say so
@@ -179,36 +175,6 @@ type Reconciliation struct {
Contested []ContestedSpot `json:"contested,omitempty"` 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"`
// Note is a short plain remark about the phase that the reader should see even though
// it did not fail, for example that the audio is much shorter than the file claimed.
// Durations and counts only: never transcript text, a name or a path.
Note string `json:"note,omitempty"`
}
// UploadStatus is the state of an uploaded-audio transcription job. // UploadStatus is the state of an uploaded-audio transcription job.
type UploadStatus string type UploadStatus string
@@ -230,13 +196,6 @@ const (
UploadErrInterrupted UploadErrorKind = "interrupted" UploadErrInterrupted UploadErrorKind = "interrupted"
// UploadErrTranscriptionFailed: every configured transcription endpoint failed. // UploadErrTranscriptionFailed: every configured transcription endpoint failed.
UploadErrTranscriptionFailed UploadErrorKind = "transcription_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"
// UploadErrBusy: too many recordings were already waiting, so this one was not
// queued. The user's to retry later (ADR-035 review).
UploadErrBusy UploadErrorKind = "queue_full"
) )
// UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is // UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is
@@ -246,9 +205,6 @@ type UploadedTranscript struct {
ID string ID string
UserID string UserID string
Title string Title string
// Language is what the upload was (or will be) transcribed as: sv, or auto for
// "Other" (tapir#61).
Language Language
Status UploadStatus Status UploadStatus
Content string Content string
Segments []Segment Segments []Segment
@@ -259,16 +215,6 @@ type UploadedTranscript struct {
// was used and the video was discarded; the page says so. It is set at upload // was used and the video was discarded; the page says so. It is set at upload
// and never cleared (ADR-033). // and never cleared (ADR-033).
HadVideo bool 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
// 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 CreatedAt time.Time
FinishedAt time.Time // zero until the job reaches done or failed FinishedAt time.Time // zero until the job reaches done or failed
} }
-47
View File
@@ -1,47 +0,0 @@
package domain
import "context"
// Language is the spoken language an upload is transcribed as. It is chosen by the user on
// the upload form and reaches the engines as one request field, so it is a closed set,
// never free text (tapir#61).
type Language string
const (
// LangSwedish sends language=sv: the Swedish engines' own language, and what every
// upload was transcribed as before the choice existed.
LangSwedish Language = "sv"
// LangAuto sends no language, so the engine detects it. It is what "Other" on the form
// means: the Swedish-only engines are not used for it.
LangAuto Language = "auto"
)
// ParseLanguage reads a form value. An empty value is Swedish (a client that predates the
// field); anything that is not exactly a known value is refused rather than normalised.
func ParseLanguage(s string) (Language, bool) {
switch Language(s) {
case "", LangSwedish:
return LangSwedish, true
case LangAuto:
return LangAuto, true
}
return "", false
}
type languageKey struct{}
// WithLanguage returns a context that carries the language of the job it serves. The
// Transcriber interface does not change: engines read it with LanguageFrom, the same way
// they read the progress Reporter (ADR-035).
func WithLanguage(ctx context.Context, l Language) context.Context {
return context.WithValue(ctx, languageKey{}, l)
}
// LanguageFrom returns the language carried by ctx, or Swedish when it carries none, so a
// caller that sets nothing behaves as it always did.
func LanguageFrom(ctx context.Context) Language {
if l, ok := ctx.Value(languageKey{}).(Language); ok && l != "" {
return l
}
return LangSwedish
}
-41
View File
@@ -1,41 +0,0 @@
package domain_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"git.d-ma.be/mathias/tapir/internal/domain"
)
func TestParseLanguage_KnownValuesAndEmptyAreAcceptedEverythingElseIsNot(t *testing.T) {
tests := []struct {
in string
want domain.Language
wantOK bool
}{
{"sv", domain.LangSwedish, true},
{"auto", domain.LangAuto, true},
{"", domain.LangSwedish, true}, // a client that sends no field keeps today's behaviour
{"SV", "", false}, // exact match: the value reaches a request field
{" sv", "", false},
{"sv\n", "", false},
{"en", "", false}, // a specific other language is not offered yet
{"sv,en", "", false},
{"../sv", "", false},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
got, ok := domain.ParseLanguage(tt.in)
assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.want, got)
})
}
}
func TestLanguageFrom_IsSwedishWhenTheContextCarriesNoneAndOtherwiseWhatWasSet(t *testing.T) {
assert.Equal(t, domain.LangSwedish, domain.LanguageFrom(context.Background()))
ctx := domain.WithLanguage(context.Background(), domain.LangAuto)
assert.Equal(t, domain.LangAuto, domain.LanguageFrom(ctx))
}
-51
View File
@@ -1,51 +0,0 @@
// 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 }
// SQLStater is implemented by database errors that carry a SQLSTATE (pgx's PgError does).
// The five-character code is a fixed vocabulary; the error's message is not: Postgres quotes
// the offending value, which for a transcript row is transcript text.
type SQLStater interface{ SQLState() string }
// Of is the kind of err: "timeout", "canceled", "http_<code>", "sql_<state>", "network",
// or "error".
// It is empty for nil.
func Of(err error) string {
if err == nil {
return ""
}
var hs HTTPStatuser
var ss SQLStater
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, &ss) && ss.SQLState() != "":
return "sql_" + ss.SQLState()
case errors.As(err, &ne):
if ne.Timeout() {
return "timeout"
}
return "network"
}
return "error"
}
-82
View File
@@ -1,82 +0,0 @@
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))
})
}
}
type sqlErr struct{}
func (sqlErr) Error() string {
return `ERROR: invalid input syntax for type json: "Anders ansvarar för virket"`
}
func (sqlErr) SQLState() string { return "22P02" }
func TestOf_ADatabaseErrorIsReducedToItsSQLStateNeverItsMessage(t *testing.T) {
// A Postgres error can quote the offending value, which for a transcript row is
// transcript text. The five-character SQLSTATE is a fixed vocabulary (fourth review).
err := fmt.Errorf("store: complete upload: %w", sqlErr{})
got := errkind.Of(err)
assert.Equal(t, "sql_22P02", got)
assert.NotContains(t, got, "Anders")
}
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")
}
-300
View File
@@ -1,300 +0,0 @@
// 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"
"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.
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. 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
// 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, at time.Time) error
}
const (
defaultMinSave = 2 * time.Second
defaultSaveTimeout = 5 * 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 } }
// WithSaveTimeout bounds one save or heartbeat write. Progress is cosmetic, so a database
// that stops answering costs a dropped write rather than an unbounded stall. It is still a
// delay: saves run under the tracker's lock, which Begin and Advance (called by the engines)
// need, so each transition can wait up to this long, and a hung database slows a job by
// that much per call, out of the job's own time budget (#51). A dropped final save is not
// retried; the row keeps its last saved step.
func WithSaveTimeout(d time.Duration) Option { return func(t *Tracker) { t.saveTimeout = 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
saveTimeout 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, saveTimeout: defaultSaveTimeout}
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 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), "kind", errkind.Of(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 {
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()
}
// Annotate attaches a short plain note to a phase and saves it at once. It is for a
// remark the reader should see although nothing failed. Durations and counts only: a note
// is shown on a page about a private recording.
func (t *Tracker) Annotate(key, note string) {
t.mu.Lock()
defer t.mu.Unlock()
s := t.find(key)
if s == nil {
return
}
s.Note = note
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:
beatCtx, cancel := context.WithTimeout(ctx, t.saveTimeout)
err := t.sink.Beat(beatCtx, t.now())
cancel()
if 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...)
ctx, cancel := context.WithTimeout(t.ctx, t.saveTimeout)
defer cancel()
if err := t.sink.SaveProgress(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) {}
func (noop) Fail(string, error) {}
func (noop) Note(string, ...any) {}
-154
View File
@@ -1,154 +0,0 @@
package progress_test
// Observes what a database that stops answering does to the job it reports on (#51):
// progress is cosmetic, so a save that never returns must not stall the engine calls that
// report through the tracker, nor the heartbeat that says the process is alive.
import (
"bytes"
"context"
"log/slog"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/progress"
)
// stallSink never answers until its context ends, like a database connection that hung.
type stallSink struct{ beats atomic.Int32 }
func (s *stallSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _ time.Time) error {
<-ctx.Done()
return ctx.Err()
}
func (s *stallSink) Beat(ctx context.Context, _ time.Time) error {
s.beats.Add(1)
<-ctx.Done()
return ctx.Err()
}
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()
}
func stallTracker(sink progress.Sink, buf *lockedBuf) *progress.Tracker {
log := slog.New(slog.NewTextHandler(buf, nil))
return progress.NewTracker(context.Background(), sink, log,
[]progress.Planned{{Key: "a", Label: "A"}}, progress.WithSaveTimeout(50*time.Millisecond))
}
func TestTracker_AHungSaveDoesNotBlockTheReporterPastItsTimeout(t *testing.T) {
buf := &lockedBuf{}
tr := stallTracker(&stallSink{}, buf)
done := make(chan struct{})
go func() { tr.Begin("a", 0); tr.Advance("a", 1); tr.Done("a"); close(done) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the reporter was blocked by a save that never returned")
}
require.Contains(t, buf.String(), "progress: not saved", "a dropped save is logged")
}
func TestTracker_AHungBeatDoesNotStopTheHeartbeat(t *testing.T) {
sink := &stallSink{}
tr := stallTracker(sink, &lockedBuf{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
require.Eventually(t, func() bool { return sink.beats.Load() >= 3 }, 3*time.Second, 5*time.Millisecond,
"each beat is abandoned at its timeout and the next one is tried")
}
func TestTracker_TheStillRunningLineIsNotHeldUpBehindAHungSave(t *testing.T) {
buf := &lockedBuf{}
tr := stallTracker(&stallSink{}, buf)
go tr.Begin("a", 0) // holds the tracker's lock inside a save that hangs until its timeout
time.Sleep(10 * time.Millisecond)
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") }, 3*time.Second, 5*time.Millisecond)
}
// deadlineSink records how long each write was given, and answers at once.
type deadlineSink struct {
mu sync.Mutex
saves []time.Duration
beats []time.Duration
noDLOK bool
}
func (d *deadlineSink) record(ctx context.Context, into *[]time.Duration) {
d.mu.Lock()
defer d.mu.Unlock()
dl, ok := ctx.Deadline()
if !ok {
d.noDLOK = true
return
}
*into = append(*into, time.Until(dl))
}
func (d *deadlineSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _ time.Time) error {
d.record(ctx, &d.saves)
return nil
}
func (d *deadlineSink) Beat(ctx context.Context, _ time.Time) error {
d.record(ctx, &d.beats)
return nil
}
// The production value is the one the worker gets: it builds a Tracker without options.
// A zero or tiny default would drop every write, a huge one would bound nothing.
func TestTracker_TheDefaultSaveAndBeatDeadlineIsAFewSecondsAway(t *testing.T) {
sink := &deadlineSink{}
tr := progress.NewTracker(context.Background(), sink, slog.New(slog.NewTextHandler(&lockedBuf{}, nil)),
[]progress.Planned{{Key: "a", Label: "A"}})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
tr.Begin("a", 0)
require.Eventually(t, func() bool {
sink.mu.Lock()
defer sink.mu.Unlock()
return len(sink.beats) > 0
}, 3*time.Second, 5*time.Millisecond)
sink.mu.Lock()
defer sink.mu.Unlock()
require.False(t, sink.noDLOK, "a write was given no deadline at all")
require.NotEmpty(t, sink.saves)
for _, d := range append(append([]time.Duration{}, sink.saves...), sink.beats...) {
require.Greater(t, d, time.Second, "the deadline is already (nearly) spent when the write starts")
require.LessOrEqual(t, d, 5*time.Second, "the default bound is 5 s")
}
}
-421
View File
@@ -1,421 +0,0 @@
package progress_test
import (
"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
beatAt []time.Time
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, at time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
f.beats++
f.beatAt = append(f.beatAt, at)
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
}
// 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 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, *syncBuf) {
t.Helper()
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
}
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_FailRunningMarksTheRunningStepFailedAndLeavesTheRestPending(t *testing.T) {
tr, sink, _, _ := newTracker(t)
tr.Start()
tr.Begin("audio", 0)
tr.Done("audio")
tr.Begin("decide", 5)
tr.FailRunning()
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_AKeyedFailMarksOneStepFailedLogsItsKindAndTheJobGoesOn(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.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")
}
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()
before := sink.saveCount()
require.NotPanics(t, func() {
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())
}
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")
tr.Begin("decide", 5)
clk.tick(2 * time.Second)
tr.FailRunning()
tr.Finish()
out := buf.String()
for _, want := range []string{
`msg="job started"`,
`msg="step started" step=audio`,
`msg="step finished" step=audio elapsed=3s`,
`msg="step started" step=decide total=5`,
`msg="step failed" step=decide elapsed=2s`,
`msg="job finished"`,
} {
require.Contains(t, out, want, "a transition with no log line is invisible to the operator")
}
}
func TestTracker_AKeyedFailIsSavedAtOnceNotOnTheNextTransition(t *testing.T) {
tr, sink, _, _ := newTracker(t)
tr.Start()
tr.Begin("audio", 0)
before := sink.saveCount()
tr.Fail("audio", errors.New("down"))
require.Equal(t, before+1, sink.saveCount(), "a soft-failed phase must show as failed on the page now")
require.Equal(t, domain.StepFailed, stateOf(sink.last(t), "audio"))
}
// retainingSink keeps the very slice it is handed, like a sink that queues it for later.
type retainingSink struct{ got [][]domain.ProgressStep }
func (r *retainingSink) SaveProgress(_ context.Context, steps []domain.ProgressStep, _ time.Time) error {
r.got = append(r.got, steps)
return nil
}
func (r *retainingSink) Beat(context.Context, time.Time) error { return nil }
func TestTracker_ASnapshotHandedToTheSinkIsNotChangedByLaterTransitions(t *testing.T) {
sink := &retainingSink{}
tr := progress.NewTracker(context.Background(), sink, slog.New(slog.NewTextHandler(&syncBuf{}, nil)), plan(),
progress.WithClock(newClock().now))
tr.Start()
first := sink.got[0]
tr.Begin("audio", 0)
tr.Done("audio")
require.Equal(t, domain.StepPending, first[0].State, "a sink that keeps a snapshot must not see it change under it")
}
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")
r.Fail("x", errors.New("e"))
r.Note("n", "k", 1)
})
}
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"))
}
func TestTracker_AnnotateAttachesANoteToAStepAndSavesItAtOnce(t *testing.T) {
tr, sink, _, _ := newTracker(t)
tr.Start()
tr.Begin("audio", 0)
before := sink.saveCount()
tr.Annotate("audio", "20 min of the 79 the file claimed")
require.Equal(t, before+1, sink.saveCount())
require.Equal(t, "20 min of the 79 the file claimed", sink.last(t)[0].Note)
require.NotPanics(t, func() { tr.Annotate("no-such-step", "x") })
}
func TestTracker_BeatIsStampedWithTheTrackersClock(t *testing.T) {
// alive_at used the database's NOW() while the page compared it with the application
// clock; a skewed database clock showed a healthy job as lost (#51).
tr, sink, clk, _ := newTracker(t)
clk.tick(42 * time.Second)
want := clk.now()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour)
require.Eventually(t, func() bool { return sink.beatCount() >= 1 }, 2*time.Second, 5*time.Millisecond)
sink.mu.Lock()
defer sink.mu.Unlock()
require.True(t, sink.beatAt[0].Equal(want), "beat stamped %v, the tracker's clock says %v", sink.beatAt[0], want)
}
+7 -7
View File
@@ -32,7 +32,7 @@ func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string
return f.replies[i], nil return f.replies[i], nil
} }
var req = reconcile.Request{Before: "grannarna ska då", After: "De ska kunna måla", Base: "målade", Alt: "mölade"} var req = reconcile.Request{Before: "kunderna ska då", After: "De ska kunna betala", Base: "onboardade", Alt: "omvårdade"}
func TestLLMAdjudicator_SendsTheMeasuredPromptWithTheReadingsInTheRequestedOrder(t *testing.T) { func TestLLMAdjudicator_SendsTheMeasuredPromptWithTheReadingsInTheRequestedOrder(t *testing.T) {
fc := &fakeCompleter{replies: []string{"ANSWER: 1"}} fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
@@ -45,10 +45,10 @@ func TestLLMAdjudicator_SendsTheMeasuredPromptWithTheReadingsInTheRequestedOrder
assert.Contains(t, fc.systems[0], "two automatic transcriptions of the same Swedish speech") assert.Contains(t, fc.systems[0], "two automatic transcriptions of the same Swedish speech")
assert.Contains(t, fc.systems[0], "ANSWER: 1") assert.Contains(t, fc.systems[0], "ANSWER: 1")
u := fc.users[0] u := fc.users[0]
assert.Contains(t, u, "Text before the spot:\ngrannarna ska då") assert.Contains(t, u, "Text before the spot:\nkunderna ska då")
assert.Contains(t, u, "[[ THE SPOT ]]") assert.Contains(t, u, "[[ THE SPOT ]]")
assert.Contains(t, u, "Text after the spot:\nDe ska kunna måla") assert.Contains(t, u, "Text after the spot:\nDe ska kunna betala")
assert.Contains(t, u, "Reading 1: målade\nReading 2: mölade", "base first when AltFirst is false") assert.Contains(t, u, "Reading 1: onboardade\nReading 2: omvårdade", "base first when AltFirst is false")
} }
func TestLLMAdjudicator_AltFirstSwapsWhichReadingIsNumberOne(t *testing.T) { func TestLLMAdjudicator_AltFirstSwapsWhichReadingIsNumberOne(t *testing.T) {
@@ -59,7 +59,7 @@ func TestLLMAdjudicator_AltFirstSwapsWhichReadingIsNumberOne(t *testing.T) {
_, err := reconcile.NewLLMAdjudicator(fc).Adjudicate(context.Background(), r) _, err := reconcile.NewLLMAdjudicator(fc).Adjudicate(context.Background(), r)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, fc.users[0], "Reading 1: mölade\nReading 2: målade") assert.Contains(t, fc.users[0], "Reading 1: omvårdade\nReading 2: onboardade")
} }
func TestLLMAdjudicator_MapsTheAnswerBackToBaseOrAltWhicheverOrderWasShown(t *testing.T) { func TestLLMAdjudicator_MapsTheAnswerBackToBaseOrAltWhicheverOrderWasShown(t *testing.T) {
@@ -134,8 +134,8 @@ func TestOrderChecked_AsksTwiceWithTheOrderSwappedAndKeepsOnlyAgreement(t *testi
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, tt.want, got) assert.Equal(t, tt.want, got)
require.Len(t, fc.users, 2) require.Len(t, fc.users, 2)
assert.True(t, strings.Contains(fc.users[0], "Reading 1: målade"), "first call shows the base first") 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: mölade"), "second call shows the alternative first") assert.True(t, strings.Contains(fc.users[1], "Reading 1: omvårdade"), "second call shows the alternative first")
}) })
} }
} }
+9 -9
View File
@@ -19,12 +19,12 @@ func TestFindDisputes_NoneWhenTheTextsAgreeWordForWord(t *testing.T) {
} }
func TestFindDisputes_OneReplacedWordIsOneDisputeCarryingBothReadings(t *testing.T) { func TestFindDisputes_OneReplacedWordIsOneDisputeCarryingBothReadings(t *testing.T) {
got := disputes("Vi ska målade grannarna.", "Vi ska mölade grannarna.") got := disputes("Vi ska onboardade kunderna.", "Vi ska omvårdade kunderna.")
require.Len(t, got, 1) require.Len(t, got, 1)
d := got[0] d := got[0]
assert.Equal(t, "målade", d.Base) assert.Equal(t, "onboardade", d.Base)
assert.Equal(t, "mölade", d.Alt) assert.Equal(t, "omvårdade", d.Alt)
assert.Equal(t, 2, d.BaseStart) assert.Equal(t, 2, d.BaseStart)
assert.Equal(t, 3, d.BaseEnd) assert.Equal(t, 3, d.BaseEnd)
assert.Equal(t, 2, d.AltStart) assert.Equal(t, 2, d.AltStart)
@@ -32,11 +32,11 @@ func TestFindDisputes_OneReplacedWordIsOneDisputeCarryingBothReadings(t *testing
} }
func TestFindDisputes_ReadingsKeepTheirOwnSpellingAndPunctuation(t *testing.T) { func TestFindDisputes_ReadingsKeepTheirOwnSpellingAndPunctuation(t *testing.T) {
got := disputes("Det blir A+2, alltså.", "Det blir A 2, alltså.") got := disputes("Det blir T+1, alltså.", "Det blir T 1, alltså.")
require.Len(t, got, 1) require.Len(t, got, 1)
assert.Equal(t, "A+2,", got[0].Base) assert.Equal(t, "T+1,", got[0].Base)
assert.Equal(t, "A 2,", got[0].Alt, "the alternative's words replace the span as the engine wrote them") 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) { func TestFindDisputes_AWordOnlyOneEngineHeardHasAnEmptyOtherSide(t *testing.T) {
@@ -90,12 +90,12 @@ func TestAutoResolve(t *testing.T) {
base, alt string base, alt string
resolved bool resolved bool
}{ }{
{"a compound written as two words or one", "i takrenns delen", "i takrennsdelen", true}, {"a compound written as two words or one", "i crossborder delen", "i crossborderdelen", true},
{"a compound with a hyphen", "takrenns-delen", "takrenns delen", 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 alternative heard", "vi ska ju betala", "vi ska betala", true},
{"a filler only the base heard", "vi ska betala", "vi ska ju 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}, {"two fillers in a row", "vi ska ja ju betala", "vi ska betala", true},
{"a different word is for the model", "vi ska målade", "vi ska mölade", false}, {"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 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}, {"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}, {"three filler words are too many to wave through", "vi ska ju då liksom betala", "vi ska betala", false},
+29 -118
View File
@@ -6,18 +6,11 @@ import (
"strings" "strings"
"git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/progress"
) )
// MaxContested is the default bound on the stored list of judged spots. A 90-minute // MaxContested bounds the stored list of contested spots. The counts on the
// recording has a few hundred, so this is a guard against a runaway, not a budget. The // Reconciliation are always exact; only this list is capped.
// counts on the Reconciliation are always exact; only the list is capped. const MaxContested = 200
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. // Options configure Reconcile. Zero Window and BreakAfter take their defaults.
type Options struct { type Options struct {
@@ -27,8 +20,6 @@ type Options struct {
// BreakAfter is how many consecutive model errors stop the reconciliation, so a // BreakAfter is how many consecutive model errors stop the reconciliation, so a
// model that is down costs three timeouts, not one per dispute. // model that is down costs three timeouts, not one per dispute.
BreakAfter int 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 and Reconciler are recorded on the result.
BaseEngine, AltEngine, Reconciler string BaseEngine, AltEngine, Reconciler string
} }
@@ -40,50 +31,22 @@ type Options struct {
// and the result says why. The only error it returns is a cancelled context, which // 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. // 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) { func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudicator, o Options) (domain.Transcription, error) {
window, breakAfter, maxSpots := o.Window, o.BreakAfter, o.MaxContested window, breakAfter := o.Window, o.BreakAfter
if window <= 0 { if window <= 0 {
window = 100 window = 100
} }
if breakAfter <= 0 { if breakAfter <= 0 {
breakAfter = 3 breakAfter = 3
} }
if maxSpots <= 0 {
maxSpots = MaxContested
}
rep := progress.From(ctx)
rep.Begin("compare", 0)
bt, at := Tokenize(base.Text), Tokenize(alt.Text) bt, at := Tokenize(base.Text), Tokenize(alt.Text)
ds := FindDisputes(bt, at) 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{ rec := &domain.Reconciliation{
Status: domain.ReconcileDone, Base: o.BaseEngine, Alt: o.AltEngine, Reconciler: o.Reconciler, Disputes: len(ds), Status: domain.ReconcileDone, Base: o.BaseEngine, Alt: o.AltEngine, Reconciler: o.Reconciler, Disputes: len(ds),
} }
seconds := positioner(base, len(bt)) 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)) verdicts := make([]Verdict, len(ds))
consecutive, handled := 0, 0 consecutive := 0
for i, d := range ds { for i, d := range ds {
if AutoResolve(d) { if AutoResolve(d) {
rec.AutoResolved++ rec.AutoResolved++
@@ -102,8 +65,7 @@ func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudica
rec.Errors++ rec.Errors++
consecutive++ consecutive++
if consecutive >= breakAfter { if consecutive >= breakAfter {
rep.Fail("decide", err) return skipped(base, rec, fmt.Sprintf("the model failed %d times in a row: %v", consecutive, err)), nil
return skipped(base, rec, fmt.Sprintf("the model failed %d times in a row (%s)", consecutive, errkind.Of(err))), nil
} }
v = VerdictUnsure v = VerdictUnsure
} else { } else {
@@ -114,94 +76,30 @@ func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudica
switch v { switch v {
case VerdictAlt: case VerdictAlt:
rec.Changed++ rec.Changed++
spot(d, "alt", false) addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "alt"})
case VerdictUnsure: case VerdictUnsure:
rec.Unsure++ rec.Unsure++
spot(d, "base", true) addContested(rec, domain.ContestedSpot{Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: "base", Unsure: 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 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 pos := 0
for i, d := range ds { for i, d := range ds {
keep(pos, d.BaseStart) parts = appendTexts(parts, bt[pos:d.BaseStart])
if verdicts[i] == VerdictAlt { if verdicts[i] == VerdictAlt {
for _, t := range at[d.AltStart:d.AltEnd] { parts = appendTexts(parts, 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 { } else {
keep(d.BaseStart, d.BaseEnd) parts = appendTexts(parts, bt[d.BaseStart:d.BaseEnd])
} }
pos = d.BaseEnd pos = d.BaseEnd
} }
keep(pos, len(bt)) parts = appendTexts(parts, bt[pos:])
if !aligned { out := base
return strings.Join(parts, " "), base.Segments out.Text = strings.Join(parts, " ")
} out.Reconciliation = rec
segs := append([]domain.Segment(nil), base.Segments...) return out, nil
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. // skipped is the base text, none of the partial changes, and the reason.
@@ -214,6 +112,13 @@ func skipped(base domain.Transcription, rec *domain.Reconciliation, reason strin
return out 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 // positioner maps a token index of the base to the second it starts at: through the
// segments when there are any, proportionally otherwise. // segments when there are any, proportionally otherwise.
func positioner(base domain.Transcription, tokens int) func(int) float64 { func positioner(base domain.Transcription, tokens int) func(int) float64 {
@@ -239,3 +144,9 @@ func positioner(base domain.Transcription, tokens int) func(int) float64 {
return base.Segments[k].Start return base.Segments[k].Start
} }
} }
func addContested(rec *domain.Reconciliation, c domain.ContestedSpot) {
if len(rec.Contested) < MaxContested {
rec.Contested = append(rec.Contested, c)
}
}
@@ -1,116 +0,0 @@
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 takrenns delen ska vi ju betala totalt 1015 kronor till Axel idag"
progAlt = "i takrennsdelen ska vi betala totalt 1050 kronor till Alex 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")
}
@@ -1,33 +0,0 @@
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")
}
-130
View File
@@ -1,130 +0,0 @@
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 takrenns delen ska vi ju betala"), tr("i takrennsdelen 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("grannen målar ett vanligt staket mot gatan"), tr("grannen målar ett KORT staket mot gatan"), always(reconcile.VerdictAlt))
require.Len(t, out.Reconciliation.Contested, 1)
assert.Equal(t, "grannen målar 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")
}
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")
}
+20 -35
View File
@@ -58,10 +58,10 @@ func TestReconcile_IdenticalTextsNeedNoModelAndComeBackUnchanged(t *testing.T) {
func TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel(t *testing.T) { func TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel(t *testing.T) {
adj := always(reconcile.VerdictAlt) adj := always(reconcile.VerdictAlt)
out := merge(t, tr("i takrenns delen ska vi ju betala"), tr("i takrennsdelen ska vi betala"), adj) 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.Empty(t, adj.calls, "a compound split and a filler particle are not worth a model call")
assert.Equal(t, "i takrenns delen ska vi ju betala", out.Text, "the base reading is kept") 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.Disputes)
assert.Equal(t, 2, out.Reconciliation.AutoResolved) assert.Equal(t, 2, out.Reconciliation.AutoResolved)
assert.Zero(t, out.Reconciliation.Adjudicated) assert.Zero(t, out.Reconciliation.Adjudicated)
@@ -76,21 +76,16 @@ func TestReconcile_AltVerdictReplacesTheSpanWithTheAlternativesOwnWords(t *testi
} }
func TestReconcile_BaseAndUnsureVerdictsKeepTheBaseButOnlyUnsureIsFlagged(t *testing.T) { func TestReconcile_BaseAndUnsureVerdictsKeepTheBaseButOnlyUnsureIsFlagged(t *testing.T) {
base := tr("Totalt 1015 kronor till Axel.") base := tr("Totalt 1015 kronor till SEB.")
alt := tr("Totalt 1050 kronor till Alex.") alt := tr("Totalt 1050 kronor till SCB.")
keptBase := merge(t, base, alt, always(reconcile.VerdictBase)) keptBase := merge(t, base, alt, always(reconcile.VerdictBase))
unsure := merge(t, base, alt, always(reconcile.VerdictUnsure)) unsure := merge(t, base, alt, always(reconcile.VerdictUnsure))
assert.Equal(t, "Totalt 1015 kronor till Axel.", keptBase.Text) assert.Equal(t, "Totalt 1015 kronor till SEB.", keptBase.Text)
assert.Zero(t, keptBase.Reconciliation.Unsure) assert.Zero(t, keptBase.Reconciliation.Unsure)
require.Len(t, keptBase.Reconciliation.Contested, 2, assert.Empty(t, keptBase.Reconciliation.Contested, "a confident 'the base is right' is not a spot worth the reader's time")
"every judged spot is stored, kept ones too: without them the reconciler cannot be compared with either engine alone (ADR-035)") assert.Equal(t, "Totalt 1015 kronor till SEB.", unsure.Text, "unsure keeps the base")
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 Axel.", unsure.Text, "unsure keeps the base")
assert.Equal(t, 2, unsure.Reconciliation.Unsure) assert.Equal(t, 2, unsure.Reconciliation.Unsure)
require.Len(t, unsure.Reconciliation.Contested, 2) require.Len(t, unsure.Reconciliation.Contested, 2)
assert.True(t, unsure.Reconciliation.Contested[0].Unsure) assert.True(t, unsure.Reconciliation.Contested[0].Unsure)
@@ -98,12 +93,12 @@ func TestReconcile_BaseAndUnsureVerdictsKeepTheBaseButOnlyUnsureIsFlagged(t *tes
} }
func TestReconcile_ChangedSpotsAreRecordedWithBothReadings(t *testing.T) { func TestReconcile_ChangedSpotsAreRecordedWithBothReadings(t *testing.T) {
out := merge(t, tr("det blir A+2 imorgon"), tr("det blir en A imorgon"), always(reconcile.VerdictAlt)) 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) require.Len(t, out.Reconciliation.Contested, 1)
c := out.Reconciliation.Contested[0] c := out.Reconciliation.Contested[0]
assert.Equal(t, "A+2", c.Base) assert.Equal(t, "T+1", c.Base)
assert.Equal(t, "en A", c.Alt) assert.Equal(t, "en T", c.Alt)
assert.Equal(t, "alt", c.Used) assert.Equal(t, "alt", c.Used)
assert.False(t, c.Unsure) assert.False(t, c.Unsure)
} }
@@ -122,16 +117,16 @@ func TestReconcile_AWordOnlyTheBaseHeardCanBeRemoved(t *testing.T) {
func TestReconcile_SeveralDisputesAreAppliedIndependentlyAndInOrder(t *testing.T) { func TestReconcile_SeveralDisputesAreAppliedIndependentlyAndInOrder(t *testing.T) {
adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) { adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
if r.Alt == "mölade" || r.Alt == "Alex" { if r.Alt == "omvårdade" || r.Alt == "SCB" {
return reconcile.VerdictAlt, nil return reconcile.VerdictAlt, nil
} }
return reconcile.VerdictBase, nil return reconcile.VerdictBase, nil
}} }}
out := merge(t, tr("kunderna målade nu och sen betalas till Axel via ombud med 1050 kronor"), out := merge(t, tr("kunderna onboardade nu och sen betalas till SEB via ombud med 1050 kronor"),
tr("kunderna mölade nu och sen betalas till Alex via ombud med 2050 kronor"), adj) tr("kunderna omvårdade nu och sen betalas till SCB via ombud med 2050 kronor"), adj)
assert.Equal(t, "kunderna mölade nu och sen betalas till Alex via ombud 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, 3, out.Reconciliation.Disputes)
assert.Equal(t, 2, out.Reconciliation.Changed) assert.Equal(t, 2, out.Reconciliation.Changed)
} }
@@ -193,8 +188,7 @@ func TestReconcile_ConsecutiveModelErrorsStopTheWholeReconciliationInsteadOfWait
assert.Len(t, adj.calls, 3, "three consecutive failures and it stops asking") 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, strings.Join(a, " "), out.Text, "the base text, with none of the partial changes")
assert.Equal(t, domain.ReconcileSkipped, out.Reconciliation.Status) assert.Equal(t, domain.ReconcileSkipped, out.Reconciliation.Status)
assert.Contains(t, out.Reconciliation.Reason, "3 times in a row") assert.Contains(t, out.Reconciliation.Reason, "gateway down")
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) { func TestReconcile_ASuccessBetweenFailuresResetsTheBreaker(t *testing.T) {
@@ -230,16 +224,14 @@ func TestReconcile_ACancelledJobIsAnErrorNotASkippedReconciliation(t *testing.T)
assert.ErrorIs(t, err, context.Canceled, "a shutdown must not be recorded as 'the model was unavailable'") assert.ErrorIs(t, err, context.Canceled, "a shutdown must not be recorded as 'the model was unavailable'")
} }
func TestReconcile_KeepsTheBaseTimingAndDurationAndNamesTheEngines(t *testing.T) { func TestReconcile_KeepsTheBaseSegmentsDurationAndNamesTheEngines(t *testing.T) {
base := tr("a x b") base := tr("a x b")
base.Segments = []domain.Segment{{Start: 0, End: 4, Text: "a x b"}} base.Segments = []domain.Segment{{Start: 0, End: 4, Text: "a x b"}}
base.AudioSeconds = 3821.5 base.AudioSeconds = 3821.5
out := merge(t, base, tr("a y b"), always(reconcile.VerdictAlt)) out := merge(t, base, tr("a y b"), always(reconcile.VerdictAlt))
require.Len(t, out.Segments, 1) assert.Equal(t, base.Segments, out.Segments, "the timed segments are the base engine's, untouched")
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.InDelta(t, 3821.5, out.AudioSeconds, 1e-9)
assert.Equal(t, "iguana/pianissimo", out.Reconciliation.Base) assert.Equal(t, "iguana/pianissimo", out.Reconciliation.Base)
assert.Equal(t, "iguana/kb-whisper", out.Reconciliation.Alt) assert.Equal(t, "iguana/kb-whisper", out.Reconciliation.Alt)
@@ -272,25 +264,18 @@ 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") assert.InDelta(t, 90.0, out.Reconciliation.Contested[0].Seconds, 1e-9, "token 9 of 10 in 100 s")
} }
func TestReconcile_JudgedSpotsAreCappedButTheCountsAreExact(t *testing.T) { func TestReconcile_ContestedSpotsAreCappedButTheCountsAreExact(t *testing.T) {
a, b := make([]string, 0, 600), make([]string, 0, 600) a, b := make([]string, 0, 600), make([]string, 0, 600)
for i := 0; i < 300; i++ { for i := 0; i < 300; i++ {
a = append(a, fmt.Sprintf("w%d", i), "same", "same2") a = append(a, fmt.Sprintf("w%d", i), "same", "same2")
b = append(b, fmt.Sprintf("v%d", i), "same", "same2") b = append(b, fmt.Sprintf("v%d", i), "same", "same2")
} }
capped := opts
capped.MaxContested = 50
out, err := reconcile.Reconcile(context.Background(), tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), always(reconcile.VerdictUnsure), capped) out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), always(reconcile.VerdictUnsure))
require.NoError(t, err)
assert.Equal(t, 300, out.Reconciliation.Unsure) assert.Equal(t, 300, out.Reconciliation.Unsure)
assert.Equal(t, 300, out.Reconciliation.Disputes) assert.Equal(t, 300, out.Reconciliation.Disputes)
assert.Len(t, out.Reconciliation.Contested, 50, "the stored list is bounded; the counts are not") assert.Len(t, out.Reconciliation.Contested, reconcile.MaxContested, "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) { func TestReconcile_AVerdictReturnedAlongsideAnErrorIsIgnored(t *testing.T) {
+2 -2
View File
@@ -34,9 +34,9 @@ func TestTokenize_KeepsTheWordAsWrittenAndKeysItWithoutCaseOrPunctuation(t *test
} }
func TestTokenize_KeysIgnoreHyphensAndPlusSoOnlyWordsDecideEquality(t *testing.T) { func TestTokenize_KeysIgnoreHyphensAndPlusSoOnlyWordsDecideEquality(t *testing.T) {
got := reconcile.Tokenize("end-to-end A+2 5 000 kr") got := reconcile.Tokenize("end-to-end T+1 5 000 kr")
assert.Equal(t, []string{"endtoend", "a2", "5", "000", "kr"}, keys(got)) assert.Equal(t, []string{"endtoend", "t1", "5", "000", "kr"}, keys(got))
} }
func TestTokenize_APunctuationOnlyWordJoinsThePreviousWordInsteadOfBecomingOne(t *testing.T) { func TestTokenize_APunctuationOnlyWordJoinsThePreviousWordInsteadOfBecomingOne(t *testing.T) {
+5 -107
View File
@@ -6,8 +6,6 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"sync"
"time"
"git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/domain"
) )
@@ -25,7 +23,7 @@ type Prober interface {
// Store is the slice of the private transcript store the intake needs. // Store is the slice of the private transcript store the intake needs.
type Store interface { type Store interface {
CreateUpload(ctx context.Context, userID, title string, lang domain.Language, hadVideo bool) (string, error) CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error)
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
} }
@@ -35,12 +33,6 @@ type Job struct {
UserID string UserID string
UploadID string UploadID string
Path string Path string
// Language is what the user chose for this recording; empty means Swedish (a job
// queued before the choice existed).
Language domain.Language
// 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). // Submitter hands accepted jobs to the in-process worker (ADR-033).
@@ -55,53 +47,6 @@ type Intake struct {
sub Submitter sub Submitter
limits Limits limits Limits
tmpDir string tmpDir string
// MaxReceives and MaxReceivesPerUser bound how many request bodies Accept reads at
// once; zero means the defaults. Without them N parallel POSTs each spool up to
// MaxBytes while the queue still looks empty, and the spool volume fills (#47).
MaxReceives int
MaxReceivesPerUser int
mu sync.Mutex
receives int
byUser map[string]int
}
const (
defaultMaxReceives = 4
defaultMaxReceivesPerUser = 2
)
// acquireReceive reserves a receive slot for userID, or reports there is none. Every
// successful call must be paired with releaseReceive.
func (in *Intake) acquireReceive(userID string) bool {
maxAll, maxUser := in.MaxReceives, in.MaxReceivesPerUser
if maxAll <= 0 {
maxAll = defaultMaxReceives
}
if maxUser <= 0 {
maxUser = defaultMaxReceivesPerUser
}
in.mu.Lock()
defer in.mu.Unlock()
if in.receives >= maxAll || in.byUser[userID] >= maxUser {
return false
}
if in.byUser == nil {
in.byUser = map[string]int{}
}
in.receives++
in.byUser[userID]++
return true
}
func (in *Intake) releaseReceive(userID string) {
in.mu.Lock()
defer in.mu.Unlock()
in.receives--
if in.byUser[userID]--; in.byUser[userID] <= 0 {
delete(in.byUser, userID)
}
} }
// NewIntake wires an Intake. tmpDir is where uploads are spooled; it must exist. // NewIntake wires an Intake. tmpDir is where uploads are spooled; it must exist.
@@ -109,47 +54,12 @@ func NewIntake(s Store, p Prober, sub Submitter, l Limits, tmpDir string) *Intak
return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir} return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir}
} }
// ErrClientRead marks an error that came from READING the client's body: a connection that
// dropped, a body cut short. It is the sender's to retry, unlike a spool, probe or database
// fault, which is ours. It is a marker on the error, not a sentinel that others may wrap:
// io.ErrUnexpectedEOF alone is also what a database drop looks like.
var ErrClientRead = errors.New("uploads: client body read failed")
// clientReader remembers the last non-EOF error its source returned, so Accept can tell
// a failure reading the client from a failure writing the spool file.
type clientReader struct {
r io.Reader
err error
}
func (c *clientReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
if err != nil && !errors.Is(err, io.EOF) {
c.err = err
}
return n, err
}
// busyMessage is what a user is told when the queue is full.
const busyMessage = "Too many recordings are waiting to be transcribed. Please try again in a few minutes."
// Accept spools src to disk (bounded by MaxBytes), probes it, validates it, records // 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 // 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 // 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 // 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. // worker deletes it; on every failure path Accept deletes it itself.
func (in *Intake) Accept(ctx context.Context, userID, title string, lang domain.Language, src io.Reader) (string, error) { func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reader) (string, error) {
// Refuse before spooling when the queue has no room, if the submitter can say so: a full
// queue otherwise cost up to the upload cap on disk, an ffprobe run and a failed row for
// every refused attempt.
if rc, ok := in.sub.(interface{ HasRoom(userID string) bool }); ok && !rc.HasRoom(userID) {
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
}
// HasRoom cannot see bodies still being received; bound those separately.
if !in.acquireReceive(userID) {
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
}
defer in.releaseReceive(userID)
f, err := os.CreateTemp(in.tmpDir, "upload-*") f, err := os.CreateTemp(in.tmpDir, "upload-*")
if err != nil { if err != nil {
return "", fmt.Errorf("uploads: spool: %w", err) return "", fmt.Errorf("uploads: spool: %w", err)
@@ -163,15 +73,11 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, lang domain.
}() }()
// Read one byte past the cap so "exactly at the limit" and "over" are told apart. // Read one byte past the cap so "exactly at the limit" and "over" are told apart.
cr := &clientReader{r: io.LimitReader(src, in.limits.MaxBytes+1)} n, err := io.Copy(f, io.LimitReader(src, in.limits.MaxBytes+1))
n, err := io.Copy(f, cr)
if cerr := f.Close(); err == nil { if cerr := f.Close(); err == nil {
err = cerr err = cerr
} }
if err != nil { if err != nil {
if cr.err != nil && errors.Is(err, cr.err) {
return "", fmt.Errorf("%w: %w", ErrClientRead, err)
}
return "", fmt.Errorf("uploads: receive: %w", err) return "", fmt.Errorf("uploads: receive: %w", err)
} }
if n > in.limits.MaxBytes { if n > in.limits.MaxBytes {
@@ -193,19 +99,11 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, lang domain.
return "", err return "", err
} }
id, err := in.store.CreateUpload(ctx, userID, title, lang, probe.HasVideo) id, err := in.store.CreateUpload(ctx, userID, title, probe.HasVideo)
if err != nil { if err != nil {
return "", fmt.Errorf("uploads: create: %w", err) return "", fmt.Errorf("uploads: create: %w", err)
} }
if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path, Language: lang, Duration: probe.Duration}); err != nil { if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path}); err != nil {
if errors.Is(err, ErrQueueFull) {
// The user's to retry, not a server fault: refuse it as such, and do not leave
// the row queued -- that is the stuck spinner.
if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrBusy); ferr != nil {
return "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr)
}
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
}
// A row that will never run must not sit queued: that is the stuck spinner. // 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 { 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 (and failing the row: %v)", err, ferr)
-96
View File
@@ -1,96 +0,0 @@
package uploads_test
import (
"context"
"errors"
"fmt"
"io"
"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/uploads"
)
// A full queue is the user's to retry, not a server fault: a typed rejection, the row
// failed honestly, the audio removed (adversarial review F6).
func TestAccept_AFullQueueIsABusyRejectionNotAServerErrorAndLeavesNothingBehind(t *testing.T) {
r := newRig(t)
r.sub.err = uploads.ErrQueueFull
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data"))
var rej *uploads.RejectError
require.True(t, errors.As(err, &rej), "a full queue must be a rejection the web layer can answer with 429: %v", err)
assert.Equal(t, uploads.ReasonBusy, rej.Reason)
assert.Contains(t, rej.Message, "waiting")
assert.Equal(t, domain.UploadErrBusy, r.st.failed["up-1"], "the row must not sit queued: that is the stuck spinner")
assert.Empty(t, r.tmpFiles(t), "the audio is deleted")
}
// roomSubmitter is a Submitter that can say, before anything is spooled, that there is no room.
type roomSubmitter struct {
fakeSubmitter
room bool
}
func (r *roomSubmitter) HasRoom(string) bool { return r.room }
func TestAccept_ANoRoomQueueIsRefusedBeforeAnythingIsSpooledProbedOrStored(t *testing.T) {
// Checking only at Submit meant 200 MB was spooled and ffprobe run for every refused
// attempt, and a failed row was left behind each time (second adversarial review).
r := newRig(t)
full := &roomSubmitter{room: false}
in := uploads.NewIntake(r.st, r.pr, full, uploads.Limits{MaxBytes: 1024, MaxDuration: 90 * time.Minute}, r.tmp)
_, err := in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data"))
var rej *uploads.RejectError
require.True(t, errors.As(err, &rej))
assert.Equal(t, uploads.ReasonBusy, rej.Reason)
assert.Empty(t, r.st.created, "no row for an upload that was never accepted")
assert.Empty(t, r.pr.paths, "ffprobe was never run on it")
assert.Empty(t, r.tmpFiles(t), "nothing was spooled")
assert.Empty(t, full.jobs)
}
func TestAccept_ASubmitterThatCannotSayStillWorksAsBefore(t *testing.T) {
r := newRig(t)
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data"))
require.NoError(t, err, "a Submitter without HasRoom is accepted: the check at Submit remains")
}
// brokenBody is a client body that fails mid-read, like a connection that dropped.
type brokenBody struct{ n int }
func (b *brokenBody) Read(p []byte) (int, error) {
if b.n > 0 {
b.n = 0
return copy(p, "partial audio"), nil
}
return 0, io.ErrUnexpectedEOF
}
func TestAccept_AnErrorReadingTheClientsBodyIsMarkedAsTheClientsAndAWrappedSubmitErrorIsNot(t *testing.T) {
r := newRig(t)
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, &brokenBody{n: 1})
require.Error(t, err)
assert.ErrorIs(t, err, uploads.ErrClientRead, "a dropped client body is the sender's to retry")
assert.Empty(t, r.tmpFiles(t))
// A fault that is ours must NOT carry the marker, even if it wraps the same sentinel.
r2 := newRig(t)
r2.sub.err = fmt.Errorf("db: %w", io.ErrUnexpectedEOF)
_, err = r2.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data"))
require.Error(t, err)
assert.NotErrorIs(t, err, uploads.ErrClientRead)
}
-132
View File
@@ -1,132 +0,0 @@
package uploads_test
// Observes how many request bodies Accept will read at once (#47). HasRoom and the
// per-user queue cap act before and after the receive; N parallel POSTs all pass
// HasRoom while the queue is still empty, so the receive itself is bounded.
import (
"context"
"errors"
"io"
"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/uploads"
)
// slowReceive starts an Accept whose body stays open until the returned writer is closed,
// and waits until it holds a spool file, which proves it is inside the receive.
type slowReceive struct {
w *io.PipeWriter
done chan error
finished bool
}
func startSlowReceive(t *testing.T, r *rig, in *uploads.Intake, user string, wantFiles int) *slowReceive {
t.Helper()
pr, pw := io.Pipe()
s := &slowReceive{w: pw, done: make(chan error, 1)}
go func() {
_, err := in.Accept(context.Background(), user, "t", domain.LangSwedish, pr)
s.done <- err
}()
require.Eventually(t, func() bool { return len(r.tmpFiles(t)) == wantFiles }, 2*time.Second, 5*time.Millisecond,
"the receive for %s should have started", user)
// An abandoned receive ends in a read error, so it never reaches the rig's fakes, which
// are not safe for concurrent use. A receive that finish() already ended is a no-op.
t.Cleanup(func() {
if s.finished {
return
}
_ = pw.CloseWithError(errors.New("test over"))
select {
case <-s.done:
case <-time.After(2 * time.Second):
}
})
return s
}
func (s *slowReceive) finish(t *testing.T) error {
t.Helper()
_, _ = s.w.Write([]byte("data"))
require.NoError(t, s.w.Close())
s.finished = true
select {
case err := <-s.done:
return err
case <-time.After(2 * time.Second):
t.Fatal("Accept did not return after its body ended")
return nil
}
}
func requireBusy(t *testing.T, err error) {
t.Helper()
var rej *uploads.RejectError
require.True(t, errors.As(err, &rej), "want a busy rejection, got %v", err)
assert.Equal(t, uploads.ReasonBusy, rej.Reason)
}
func TestAccept_APerUserLimitOnConcurrentReceivesRefusesTheNextBodyBeforeSpoolingIt(t *testing.T) {
r := newRig(t)
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
in.MaxReceivesPerUser = 2
in.MaxReceives = 10
startSlowReceive(t, r, in, "user-1", 1)
startSlowReceive(t, r, in, "user-1", 2)
_, err := in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data"))
requireBusy(t, err)
assert.Len(t, r.tmpFiles(t), 2, "the refused body was never spooled")
// Another user is not blocked by user-1's two receives.
other := startSlowReceive(t, r, in, "user-2", 3)
require.NoError(t, other.finish(t))
}
func TestAccept_AnOverallLimitOnConcurrentReceivesRefusesTheNextUser(t *testing.T) {
r := newRig(t)
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
in.MaxReceivesPerUser = 5
in.MaxReceives = 3
for i, u := range []string{"a", "b", "c"} {
startSlowReceive(t, r, in, u, i+1)
}
_, err := in.Accept(context.Background(), "d", "t", domain.LangSwedish, strings.NewReader("data"))
requireBusy(t, err)
assert.Len(t, r.tmpFiles(t), 3)
}
func TestAccept_AFinishedReceiveFreesItsSlot(t *testing.T) {
r := newRig(t)
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
in.MaxReceivesPerUser = 1
in.MaxReceives = 5
first := startSlowReceive(t, r, in, "user-1", 1)
require.NoError(t, first.finish(t))
_, err := in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data"))
require.NoError(t, err, "the slot is released when Accept returns")
}
func TestAccept_AFailedReceiveFreesItsSlot(t *testing.T) {
r := newRig(t)
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
in.MaxReceivesPerUser = 1
in.MaxReceives = 5
_, err := in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, &brokenBody{n: 1})
require.ErrorIs(t, err, uploads.ErrClientRead)
_, err = in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data"))
require.NoError(t, err, "a dropped client must not leak a slot")
}
-32
View File
@@ -1,32 +0,0 @@
package uploads_test
// The limits production runs on are the DEFAULTS: nothing outside tests sets
// MaxReceives or MaxReceivesPerUser. Pinned here with the literal numbers, so changing a
// default is a visible decision (#47 review). With 4 receives, 20 queued and 1 running at
// the 200 MiB cap, the spool needs ~5 GiB of the 6 Gi emptyDir (infra); raising any of
// them or TAPIR_UPLOAD_MAX_BYTES must revisit that arithmetic.
import (
"context"
"strings"
"testing"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
func TestNewIntake_DefaultsAllowTwoReceivesPerUserAndFourOverall(t *testing.T) {
r := newRig(t)
in := uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: time.Hour}, r.tmp)
startSlowReceive(t, r, in, "a", 1)
startSlowReceive(t, r, in, "a", 2)
_, err := in.Accept(context.Background(), "a", "t", domain.LangSwedish, strings.NewReader("x"))
requireBusy(t, err) // a third body from one user
startSlowReceive(t, r, in, "b", 3)
startSlowReceive(t, r, in, "b", 4)
_, err = in.Accept(context.Background(), "c", "t", domain.LangSwedish, strings.NewReader("x"))
requireBusy(t, err) // a fifth body overall
}
+11 -25
View File
@@ -21,15 +21,13 @@ import (
type fakeStore struct { type fakeStore struct {
created []string // titles created []string // titles
langs []domain.Language
hadVideo []bool hadVideo []bool
failed map[string]domain.UploadErrorKind failed map[string]domain.UploadErrorKind
nextID string nextID string
} }
func (f *fakeStore) CreateUpload(_ context.Context, _, title string, lang domain.Language, hadVideo bool) (string, error) { func (f *fakeStore) CreateUpload(_ context.Context, _, title string, hadVideo bool) (string, error) {
f.created = append(f.created, title) f.created = append(f.created, title)
f.langs = append(f.langs, lang)
f.hadVideo = append(f.hadVideo, hadVideo) f.hadVideo = append(f.hadVideo, hadVideo)
return f.nextID, nil return f.nextID, nil
} }
@@ -100,7 +98,7 @@ func TestAccept_ValidAudioIsStoredProbedQueuedAndSubmitted(t *testing.T) {
r := newRig(t) r := newRig(t)
audio := bytes.Repeat([]byte("a"), 500) audio := bytes.Repeat([]byte("a"), 500)
id, err := r.in.Accept(context.Background(), "user-1", "Teams export", domain.LangSwedish, bytes.NewReader(audio)) id, err := r.in.Accept(context.Background(), "user-1", "Teams export", bytes.NewReader(audio))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "up-1", id) assert.Equal(t, "up-1", id)
@@ -108,8 +106,7 @@ func TestAccept_ValidAudioIsStoredProbedQueuedAndSubmitted(t *testing.T) {
assert.Equal(t, audio, r.pr.seen, "the prober must see the complete uploaded bytes, on disk") assert.Equal(t, audio, r.pr.seen, "the prober must see the complete uploaded bytes, on disk")
require.Len(t, r.sub.jobs, 1) require.Len(t, r.sub.jobs, 1)
job := r.sub.jobs[0] job := r.sub.jobs[0]
assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path, Duration: 10 * time.Minute, Language: domain.LangSwedish}, job, assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path}, 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") assert.Equal(t, filepath.Dir(job.Path), r.tmp, "the temp file lives in the configured directory")
got, err := os.ReadFile(job.Path) got, err := os.ReadFile(job.Path)
require.NoError(t, err) require.NoError(t, err)
@@ -119,7 +116,7 @@ func TestAccept_ValidAudioIsStoredProbedQueuedAndSubmitted(t *testing.T) {
func TestAccept_OversizeBodyIsRejectedAndLeavesNothingBehind(t *testing.T) { func TestAccept_OversizeBodyIsRejectedAndLeavesNothingBehind(t *testing.T) {
r := newRig(t) r := newRig(t)
_, err := r.in.Accept(context.Background(), "user-1", "big", domain.LangSwedish, strings.NewReader(strings.Repeat("a", 1025))) _, err := r.in.Accept(context.Background(), "user-1", "big", strings.NewReader(strings.Repeat("a", 1025)))
var rej *uploads.RejectError var rej *uploads.RejectError
require.True(t, errors.As(err, &rej)) require.True(t, errors.As(err, &rej))
@@ -134,7 +131,7 @@ func TestAccept_OversizeBodyIsRejectedAndLeavesNothingBehind(t *testing.T) {
func TestAccept_BodyExactlyAtTheLimitIsAccepted(t *testing.T) { func TestAccept_BodyExactlyAtTheLimitIsAccepted(t *testing.T) {
r := newRig(t) r := newRig(t)
_, err := r.in.Accept(context.Background(), "user-1", "edge", domain.LangSwedish, strings.NewReader(strings.Repeat("a", 1024))) _, err := r.in.Accept(context.Background(), "user-1", "edge", strings.NewReader(strings.Repeat("a", 1024)))
require.NoError(t, err) require.NoError(t, err)
} }
@@ -154,7 +151,7 @@ func TestAccept_ProbeVerdictRejectsWithoutRowAndRemovesTheFile(t *testing.T) {
r := newRig(t) r := newRig(t)
r.pr.probe = tt.probe r.pr.probe = tt.probe
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data")) _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
var rej *uploads.RejectError var rej *uploads.RejectError
require.True(t, errors.As(err, &rej)) require.True(t, errors.As(err, &rej))
@@ -171,7 +168,7 @@ func TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError(t *testing.T)
r := newRig(t) r := newRig(t)
r.pr.err = uploads.ErrUnreadable r.pr.err = uploads.ErrUnreadable
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("not media")) _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("not media"))
var rej *uploads.RejectError var rej *uploads.RejectError
require.True(t, errors.As(err, &rej), "the user's file is the problem: tell them") require.True(t, errors.As(err, &rej), "the user's file is the problem: tell them")
@@ -182,7 +179,7 @@ func TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError(t *testing.T)
r := newRig(t) r := newRig(t)
r.pr.err = errors.New("exec: ffprobe: executable file not found") r.pr.err = errors.New("exec: ffprobe: executable file not found")
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data")) _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
require.Error(t, err) require.Error(t, err)
var rej *uploads.RejectError var rej *uploads.RejectError
@@ -196,7 +193,7 @@ func TestAccept_ClientDisconnectMidUploadLeavesNothingBehind(t *testing.T) {
r := newRig(t) r := newRig(t)
body := io.MultiReader(strings.NewReader("partial"), iotest.ErrReader(errors.New("client went away"))) body := io.MultiReader(strings.NewReader("partial"), iotest.ErrReader(errors.New("client went away")))
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, body) _, err := r.in.Accept(context.Background(), "user-1", "t", body)
require.Error(t, err) require.Error(t, err)
assert.Empty(t, r.pr.paths) assert.Empty(t, r.pr.paths)
@@ -208,7 +205,7 @@ func TestAccept_SubmitFailureFailsTheRowHonestlyAndRemovesTheFile(t *testing.T)
r := newRig(t) r := newRig(t)
r.sub.err = errors.New("worker stopped") r.sub.err = errors.New("worker stopped")
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data")) _, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
require.Error(t, err) require.Error(t, err)
assert.Equal(t, domain.UploadErrInterrupted, r.st.failed["up-1"], assert.Equal(t, domain.UploadErrInterrupted, r.st.failed["up-1"],
@@ -230,7 +227,7 @@ func TestAccept_AFileWithAVideoTrackIsAcceptedAndRecordsThatItHadOne(t *testing.
r := newRig(t) r := newRig(t)
r.pr.probe = tt.probe r.pr.probe = tt.probe
id, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangSwedish, strings.NewReader("data")) id, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "up-1", id) assert.Equal(t, "up-1", id)
@@ -240,14 +237,3 @@ func TestAccept_AFileWithAVideoTrackIsAcceptedAndRecordsThatItHadOne(t *testing.
}) })
} }
} }
func TestAccept_TheChosenLanguageIsStoredAndHandedToTheJob(t *testing.T) {
r := newRig(t)
_, err := r.in.Accept(context.Background(), "user-1", "t", domain.LangAuto, strings.NewReader("data"))
require.NoError(t, err)
assert.Equal(t, []domain.Language{domain.LangAuto}, r.st.langs, "the row records what the user chose")
require.Len(t, r.sub.jobs, 1)
assert.Equal(t, domain.LangAuto, r.sub.jobs[0].Language, "the worker is told, so the engines are")
}
-48
View File
@@ -1,48 +0,0 @@
package uploads
// Observes what jobSink hands the store: the heartbeat's timestamp is the one the tracker
// stamped (its application clock), not a second reading taken here (#51).
import (
"context"
"testing"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
)
type atStore struct{ beatAt, saveAt time.Time }
func (*atStore) StartTranscribing(context.Context, string, string) error { return nil }
func (*atStore) CompleteUpload(context.Context, string, string, domain.Transcription) error {
return nil
}
func (*atStore) FailUpload(context.Context, string, string, domain.UploadErrorKind) error { return nil }
func (s *atStore) SaveProgress(_ context.Context, _, _ string, _ []domain.ProgressStep, at time.Time) error {
s.saveAt = at
return nil
}
func (s *atStore) Heartbeat(_ context.Context, _, _ string, at time.Time) error {
s.beatAt = at
return nil
}
func TestJobSink_ForwardsTheTrackersTimestampToTheStoreUnchanged(t *testing.T) {
st := &atStore{}
sink := jobSink{st: st, userID: "u", id: "i"}
stamp := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
if err := sink.Beat(context.Background(), stamp); err != nil {
t.Fatal(err)
}
if err := sink.SaveProgress(context.Background(), nil, stamp); err != nil {
t.Fatal(err)
}
if !st.beatAt.Equal(stamp) {
t.Errorf("heartbeat reached the store stamped %v, want the tracker's %v", st.beatAt, stamp)
}
if !st.saveAt.Equal(stamp) {
t.Errorf("progress save reached the store stamped %v, want the tracker's %v", st.saveAt, stamp)
}
}
-2
View File
@@ -34,8 +34,6 @@ const (
ReasonTooLong Reason = "too_long" ReasonTooLong Reason = "too_long"
ReasonTooLarge Reason = "too_large" ReasonTooLarge Reason = "too_large"
ReasonUnreadable Reason = "unreadable" ReasonUnreadable Reason = "unreadable"
// ReasonBusy: the queue is full. Not about the file: the user retries later.
ReasonBusy Reason = "busy"
) )
// RejectError is a synchronous, user-presentable refusal. Message is written for // RejectError is a synchronous, user-presentable refusal. Message is written for
+24 -253
View File
@@ -10,18 +10,11 @@ import (
"time" "time"
"git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/progress"
) )
// ErrStopped is returned by Submit once the worker has stopped. // ErrStopped is returned by Submit once the worker has stopped.
var ErrStopped = errors.New("uploads: worker stopped") var ErrStopped = errors.New("uploads: worker stopped")
// ErrQueueFull is returned by Submit when the queue, or one user's share of it, is at
// its cap. Every queued job holds up to the upload cap on disk until its turn, so an
// unbounded queue is a disk-filling lever (ADR-035 review).
var ErrQueueFull = errors.New("uploads: queue full")
// Converter turns the uploaded file into the 16 kHz mono PCM WAV whisper wants. // Converter turns the uploaded file into the 16 kHz mono PCM WAV whisper wants.
type Converter interface { type Converter interface {
ToWAV(ctx context.Context, src, dst string) error ToWAV(ctx context.Context, src, dst string) error
@@ -33,73 +26,11 @@ type Transcriber interface {
Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error)
} }
// LanguagePlanner is a Planner whose phases depend on the language of the job: a recording
// that is not Swedish skips the second engine and the reconcile, and the page should not list
// them as coming (tapir#61). The worker prefers it over Planner.
type LanguagePlanner interface {
PlanFor(lang domain.Language) []progress.Planned
}
// 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. // WorkerStore is the slice of the private transcript store the worker drives.
type WorkerStore interface { type WorkerStore interface {
StartTranscribing(ctx context.Context, userID, id string) error StartTranscribing(ctx context.Context, userID, id string) error
CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) 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, at time.Time) 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 }
}
// WithQueueLimits bounds how many jobs may WAIT: in total, and per user. A job being
// processed is not waiting. The defaults are 20 and 3 (one instance, one job at a time).
func WithQueueLimits(total, perUser int) WorkerOption {
return func(w *Worker) { w.maxQueued, w.maxPerUser = total, perUser }
}
// 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 } }
const (
defaultMaxQueued = 20
defaultMaxPerUser = 3
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, at time.Time) error {
return s.st.Heartbeat(ctx, s.userID, s.id, at)
} }
// Worker transcribes accepted uploads in-process, ONE AT A TIME (ADR-033). It is // Worker transcribes accepted uploads in-process, ONE AT A TIME (ADR-033). It is
@@ -114,11 +45,6 @@ type Worker struct {
tmpDir string tmpDir string
timeout time.Duration timeout time.Duration
log *slog.Logger
beatEvery, stillEvery time.Duration
maxDuration time.Duration
maxQueued, maxPerUser int
mu sync.Mutex mu sync.Mutex
queue []Job queue []Job
stopped bool stopped bool
@@ -127,34 +53,8 @@ type Worker struct {
// NewWorker wires a Worker. timeout bounds each job (conversion + transcription); // NewWorker wires a Worker. timeout bounds each job (conversion + transcription);
// a job that outlives it is failed rather than left spinning. // a job that outlives it is failed rather than left spinning.
func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout time.Duration, opts ...WorkerOption) *Worker { func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout time.Duration) *Worker {
w := &Worker{ return &Worker{store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1)}
store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1),
beatEvery: defaultBeatEvery, stillEvery: defaultStillEvery,
maxQueued: defaultMaxQueued, maxPerUser: defaultMaxPerUser,
}
for _, o := range opts {
o(w)
}
return w
}
// HasRoom reports whether a job from this user would be accepted right now. It lets the
// intake refuse BEFORE spooling up to the upload cap and running ffprobe; Submit still
// enforces the same limits atomically, so a race costs at worst one refused submit.
func (w *Worker) HasRoom(userID string) bool {
w.mu.Lock()
defer w.mu.Unlock()
if w.stopped || len(w.queue) >= w.maxQueued {
return false
}
mine := 0
for _, q := range w.queue {
if q.UserID == userID {
mine++
}
}
return mine < w.maxPerUser
} }
// Submit queues a job. It never blocks. It satisfies Submitter. // Submit queues a job. It never blocks. It satisfies Submitter.
@@ -164,18 +64,6 @@ func (w *Worker) Submit(j Job) error {
if w.stopped { if w.stopped {
return ErrStopped return ErrStopped
} }
if len(w.queue) >= w.maxQueued {
return ErrQueueFull
}
mine := 0
for _, q := range w.queue {
if q.UserID == j.UserID {
mine++
}
}
if mine >= w.maxPerUser {
return ErrQueueFull
}
w.queue = append(w.queue, j) w.queue = append(w.queue, j)
select { select {
case w.wake <- struct{}{}: case w.wake <- struct{}{}:
@@ -185,10 +73,9 @@ func (w *Worker) Submit(j Job) error {
} }
// Run processes queued jobs until ctx ends, then deletes the audio of anything // Run processes queued jobs until ctx ends, then deletes the audio of anything
// still queued and refuses new jobs. The job running at shutdown is recorded interrupted // still queued and refuses new jobs. The rows of those jobs stay queued or
// by Process; the rows of jobs still queued stay queued and are failed as interrupted by // transcribing and are failed as interrupted by the start-up sweep
// the start-up sweep (store.FailInterruptedUploads): the audio is not retained, so they // (store.FailInterruptedUploads): the audio is not retained, so they cannot resume.
// cannot resume.
func (w *Worker) Run(ctx context.Context) { func (w *Worker) Run(ctx context.Context) {
defer w.stop() defer w.stop()
for { for {
@@ -237,133 +124,56 @@ func (w *Worker) stop() {
// Process runs one job: row -> transcribing, convert, transcribe, store. The // Process runs one job: row -> transcribing, convert, transcribe, store. The
// uploaded file and the converted WAV are deleted on every path — the audio is // 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 // never retained (ADR-033). A failure is recorded with a TYPED kind; a shutdown
// mid-job is recorded as interrupted at once (see fail). // 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) { func (w *Worker) Process(ctx context.Context, job Job) {
defer func() { _ = os.Remove(job.Path) }() defer func() { _ = os.Remove(job.Path) }()
base := w.log log := slog.Default().With("upload", job.UploadID)
if base == nil {
base = slog.Default()
}
log := base.With("upload", job.UploadID)
start := time.Now() start := time.Now()
if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil { if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
log.Error("upload: start transcribing", "kind", errkind.Of(err)) log.Error("upload: start transcribing", "err", err)
w.fail(ctx, job, log, domain.UploadErrTranscriptionFailed) w.fail(ctx, job, log)
return return
} }
plan := []progress.Planned{{Key: "received", Label: "Received"}, {Key: "audio", Label: "Preparing the audio"}}
lang := job.Language
if lang == "" {
lang = domain.LangSwedish // queued before the choice existed
}
planner, planned := w.tr.(Planner)
if lp, ok := w.tr.(LanguagePlanner); ok {
plan = append(plan, lp.PlanFor(lang)...)
} else 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 of the step: it stays as it was, and fail records the row as interrupted.
abort := func(kind domain.UploadErrorKind) {
if ctx.Err() == nil {
tr.FailRunning()
}
w.fail(ctx, job, log, kind)
}
wav, err := w.tempWAV() wav, err := w.tempWAV()
if err != nil { if err != nil {
log.Error("upload: temp wav", "kind", errkind.Of(err)) log.Error("upload: temp wav", "err", err)
abort(domain.UploadErrTranscriptionFailed) w.fail(ctx, job, log)
return return
} }
defer func() { _ = os.Remove(wav) }() defer func() { _ = os.Remove(wav) }()
jobCtx, cancel := context.WithTimeout(ctx, w.timeout) jobCtx, cancel := context.WithTimeout(ctx, w.timeout)
defer cancel() defer cancel()
tr.Begin("audio", 0)
if err := w.conv.ToWAV(jobCtx, job.Path, wav); err != nil { if err := w.conv.ToWAV(jobCtx, job.Path, wav); err != nil {
log.Error("upload: convert", "kind", errkind.Of(err)) log.Error("upload: convert", "err", err)
abort(domain.UploadErrTranscriptionFailed) w.fail(ctx, job, log)
return return
} }
got, tooLong := w.audioLengthProblem(wav) t, err := w.tr.Transcribe(jobCtx, wav)
if tooLong {
abort(domain.UploadErrTooLong)
log.Error("upload: converted audio is over the limit", "audio_s", int(got.Seconds()), "limit_s", int(w.maxDuration.Seconds()))
return
}
if w.maxDuration > 0 && job.Duration > 0 {
if note := shortfallNote(got, job.Duration); note != "" {
log.Warn("upload: audio is much shorter than the file claimed", "audio_s", int(got.Seconds()), "claimed_s", int(job.Duration.Seconds()))
tr.Annotate("audio", note)
}
}
tr.Done("audio")
if !planned {
tr.Begin("transcribe", 0)
}
t, err := w.tr.Transcribe(domain.WithLanguage(progress.With(jobCtx, tr), lang), wav)
if err != nil { if err != nil {
log.Error("upload: transcribe", "kind", errkind.Of(err), "elapsed", time.Since(start).Round(time.Second)) log.Error("upload: transcribe", "err", err, "elapsed", time.Since(start).Round(time.Second))
abort(domain.UploadErrTranscriptionFailed) w.fail(ctx, job, log)
return return
} }
if !planned {
tr.Done("transcribe")
}
tr.Begin("save", 0)
if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil { if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
log.Error("upload: store transcript", "kind", errkind.Of(err)) log.Error("upload: store transcript", "err", err)
abort(domain.UploadErrTranscriptionFailed) w.fail(ctx, job, log)
return return
} }
tr.Done("save")
tr.Finish()
log.Info("upload transcribed", "endpoint", t.Endpoint, "audio_s", t.AudioSeconds, log.Info("upload transcribed", "endpoint", t.Endpoint, "audio_s", t.AudioSeconds,
"segments", len(t.Segments), "elapsed", time.Since(start).Round(time.Second)) "segments", len(t.Segments), "elapsed", time.Since(start).Round(time.Second))
} }
// interruptedWriteTimeout bounds the one write a dying process makes to record its job. // fail records a transcription failure — unless the PARENT context ended, which
const interruptedWriteTimeout = 3 * time.Second // 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) {
// fail records a transcription failure. When the PARENT context ended the process is
// shutting down: the job is recorded interrupted at once, on a short context of its own,
// because the process may exit before any other pod's start-up sweep would do it (#50).
// The sweep remains the backstop for a process that is killed outright.
func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger, kind domain.UploadErrorKind) {
if ctx.Err() != nil { if ctx.Err() != nil {
kind = domain.UploadErrInterrupted return
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), interruptedWriteTimeout)
defer cancel()
} }
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, kind); err != nil { if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, domain.UploadErrTranscriptionFailed); err != nil {
log.Error("upload: record failure", "kind", errkind.Of(err)) log.Error("upload: record failure", "err", err)
} }
} }
@@ -379,42 +189,3 @@ func (w *Worker) tempWAV() (string, error) {
} }
return name, nil 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. It returns the real length and whether
// that is over the limit by more than the tolerance.
func (w *Worker) audioLengthProblem(wav string) (got time.Duration, tooLong bool) {
if w.maxDuration <= 0 {
return 0, false
}
info, err := os.Stat(wav)
if err != nil {
return 0, false
}
got = time.Duration(float64(max(info.Size()-44, 0)) / 32000 * float64(time.Second))
return got, got > w.maxDuration+tooLongSlack
}
// shortfallNote is a remark for the audio step when the converted audio is much shorter
// than the file claimed, or "" when it is not. It is a NOTE and never a failure: a
// container header can over-estimate a complete file (a variable-bitrate MP3 without a Xing
// header claimed 79 minutes for 20 real ones), so refusing would turn away real
// recordings, while saying nothing about a truncated one would hide a partial transcript.
func shortfallNote(got, claimed time.Duration) string {
if float64(got) >= incompleteBelow*float64(claimed) || claimed-got <= incompleteShortfall {
return ""
}
return fmt.Sprintf("The audio is %s long; the file claimed %s. If the recording was cut off, upload the complete one.",
FormatDuration(got), FormatDuration(claimed))
}
-187
View File
@@ -1,187 +0,0 @@
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 audioStepNote(r *wrig) string {
for _, st := range r.st.lastSteps() {
if st.Key == "audio" {
return st.Note
}
}
return ""
}
func TestWorkerDuration_AFileThatEndsFarEarlierThanItClaimedIsStillTranscribedAndSaysSo(t *testing.T) {
// A container header can over-estimate a complete file (VBR MP3 without a Xing header
// claimed 79 minutes for 20 real ones). Refusing it was wrong; staying silent about a
// truncated one is wrong too. So it is transcribed, and the audio step carries a note.
r := newWRig(t, time.Minute)
w := durationWorker(t, r, 20*60, 90*time.Minute)
w.Process(context.Background(), jobWith(r, t, 79*time.Minute))
completed, failed := r.st.snapshot()
assert.Empty(t, failed, "a complete recording with a bad header estimate must not be refused")
assert.Contains(t, completed, "a")
note := audioStepNote(r)
assert.Contains(t, note, "20 min", "what was really transcribed")
assert.Contains(t, note, "1 h 19 min", "what the file claimed")
assert.Contains(t, note, "cut off", "and what to do if that is not a surprise")
}
func TestWorkerDuration_TheShortfallNoteIsPinnedAtEightyPercentAndTwoMinutes(t *testing.T) {
tests := []struct {
name string
claimed time.Duration
wav float64
noted bool
}{
{"79 percent of an hour", 60 * time.Minute, 0.79 * 3600, true},
{"exactly 80 percent of an hour", 60 * time.Minute, 2880, false},
{"one second under 80 percent of an hour", 60 * time.Minute, 2879, true},
{"81 percent of an hour", 60 * time.Minute, 0.81 * 3600, false},
{"exactly two minutes short and under 80 percent", 6 * time.Minute, 4 * 60, false},
{"two minutes and a second short and under 80 percent", 6 * time.Minute, 4*60 - 1, true},
{"unknown claimed length", 0, 20 * 60, false},
}
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))
assert.Equal(t, tt.noted, audioStepNote(r) != "")
completed, _ := r.st.snapshot()
assert.Contains(t, completed, "a", "a note never stops the job")
})
}
}
func TestWorkerDuration_TheTooLongToleranceIsExactlyThirtySeconds(t *testing.T) {
tests := []struct {
name string
wav float64
fail bool
}{
{"at the limit plus 30 s", 90*60 + 30, false},
{"at the limit plus 31 s", 90*60 + 31, true},
}
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, 90*time.Minute))
_, failed := r.st.snapshot()
if tt.fail {
assert.Equal(t, domain.UploadErrTooLong, failed["a"])
} else {
assert.Empty(t, failed)
}
})
}
}
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)
}
-259
View File
@@ -1,259 +0,0 @@
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{})
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")) }()
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_OnASuccessfulJobTheLogNamesEveryPhaseInOrderAndNeverTheTranscriptOrThePath(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")
assert.Contains(t, out, "upload transcribed", "the job's own summary line")
assert.Contains(t, out, "endpoint=iguana/kb-whisper", "which endpoint served it is a quality fact")
}
func TestWorkerProgress_AFailureLogsAKindNeverTheErrorTextOrThePath(t *testing.T) {
tests := []struct {
name string
setup func(*wrig)
line 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")
}, "upload: convert"},
{"every engine fails and the gateway echoed a word", func(r *wrig) {
r.tr.err = errors.New("gateway echoed: zebrakaka secret words")
}, "upload: transcribe"},
}
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.line, "the failure must be logged at all, not only scrubbed")
assert.Contains(t, out, "kind=error")
assert.Contains(t, out, "upload=a")
})
}
}
func TestWorkerProgress_AStoreFailureLogsItsSQLStateNeverTheValueItQuotes(t *testing.T) {
// A Postgres error can quote the offending value; for a transcript row that is the
// transcript (fourth adversarial review). The log gets the SQLSTATE.
r := newWRig(t, time.Minute)
r.st.completeErr = dbErr{}
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")
assert.Contains(t, out, "upload: store transcript")
assert.Contains(t, out, "kind=sql_22P02")
}
// langPlannedTr plans per language, like the Ensemble: an auto job has no base engine.
type langPlannedTr struct{ *fakeTranscriber }
func (langPlannedTr) Plan() []progress.Planned { return plannedTr{}.Plan() }
func (langPlannedTr) PlanFor(l domain.Language) []progress.Planned {
if l == domain.LangAuto {
return []progress.Planned{{Key: "engine_b", Label: "Transcribing with B"}}
}
return plannedTr{}.Plan()
}
func (p langPlannedTr) Transcribe(ctx context.Context, wav string) (domain.Transcription, error) {
r := progress.From(ctx)
if domain.LanguageFrom(ctx) != domain.LangAuto {
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 TestWorkerProgress_AJobGetsThePlanOfItsLanguage(t *testing.T) {
for name, tt := range map[string]struct {
lang domain.Language
want []string
}{
"auto": {domain.LangAuto, []string{"received:done", "audio:done", "engine_b:done", "save:done"}},
"swedish": {domain.LangSwedish, []string{"received:done", "audio:done", "engine_a:done", "engine_b:done", "save:done"}},
"no language (queued before the choice existed)": {"", []string{"received:done", "audio:done", "engine_a:done", "engine_b:done", "save:done"}},
} {
t.Run(name, func(t *testing.T) {
r := newWRig(t, time.Minute)
w := newProgressWorker(t, r, langPlannedTr{r.tr})
job := r.spool(t, "a")
job.Language = tt.lang
w.Process(context.Background(), job)
assert.Equal(t, tt.want, keysAndStates(r.st.lastSteps()))
})
}
}
-108
View File
@@ -1,108 +0,0 @@
package uploads_test
import (
"context"
"errors"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// The queue was append-only: 100,000 submits with the worker idle all succeeded, and each
// queued job holds up to the upload cap on disk until its turn (adversarial review F6).
func queueWorker(t *testing.T, total, perUser int) *uploads.Worker {
t.Helper()
r := newWRig(t, time.Minute)
return uploads.NewWorker(r.st, r.conv, r.tr, r.tmp, time.Minute, uploads.WithQueueLimits(total, perUser))
}
func submit(w *uploads.Worker, user string, n int) error {
return w.Submit(uploads.Job{UserID: user, UploadID: user + "-" + strconv.Itoa(n), Path: "/nonexistent"})
}
func TestWorkerSubmit_RefusesBeyondTheTotalQueueCap(t *testing.T) {
w := queueWorker(t, 3, 10)
for i := 0; i < 3; i++ {
require.NoError(t, submit(w, "u"+strconv.Itoa(i), i))
}
err := submit(w, "u9", 9)
assert.ErrorIs(t, err, uploads.ErrQueueFull)
}
func TestWorkerSubmit_OneUserCannotFillTheQueueForEveryoneElse(t *testing.T) {
w := queueWorker(t, 100, 2)
require.NoError(t, submit(w, "alice", 1))
require.NoError(t, submit(w, "alice", 2))
errAlice := submit(w, "alice", 3)
errBob := submit(w, "bob", 1)
assert.True(t, errors.Is(errAlice, uploads.ErrQueueFull), "a third waiting upload from one user is refused")
assert.NoError(t, errBob, "another user is not affected by alice's queue")
}
func TestWorkerSubmit_ThereIsADefaultCapSoANeglectedWorkerCannotBeFilledForever(t *testing.T) {
r := newWRig(t, time.Minute)
w := uploads.NewWorker(r.st, r.conv, r.tr, r.tmp, time.Minute)
refused := 0
for i := 0; i < 1000; i++ {
if err := submit(w, "u"+strconv.Itoa(i), i); errors.Is(err, uploads.ErrQueueFull) {
refused++
}
}
assert.Positive(t, refused, "with the defaults, 1,000 submits to an idle worker must not all be accepted")
}
func TestWorkerSubmit_AJobThatHasLeftTheQueueFreesItsPlace(t *testing.T) {
r := newWRig(t, time.Minute)
w := uploads.NewWorker(r.st, r.conv, r.tr, r.tmp, time.Minute, uploads.WithQueueLimits(1, 1))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { defer close(done); w.Run(ctx) }()
defer func() { cancel(); <-done }()
job := r.spool(t, "first")
require.NoError(t, w.Submit(job))
require.Eventually(t, func() bool { c, _ := r.st.snapshot(); _, ok := c["first"]; return ok },
5*time.Second, 5*time.Millisecond, "the first job never completed")
assert.NoError(t, w.Submit(r.spool(t, "second")), "the cap counts jobs waiting, not jobs ever submitted")
}
func TestWorkerHasRoom_SaysWhetherASubmitWouldBeAccepted(t *testing.T) {
w := queueWorker(t, 3, 2)
assert.True(t, w.HasRoom("alice"))
require.NoError(t, submit(w, "alice", 1))
require.NoError(t, submit(w, "alice", 2))
assert.False(t, w.HasRoom("alice"), "alice is at her share")
assert.True(t, w.HasRoom("bob"), "bob is not")
require.NoError(t, submit(w, "bob", 1))
assert.False(t, w.HasRoom("carol"), "the queue as a whole is at its cap")
}
func TestWorkerHasRoom_AgreesWithSubmitAtEveryStepAndIsFalseOnceStopped(t *testing.T) {
w := queueWorker(t, 2, 1)
for i, user := range []string{"a", "a", "b", "c"} {
room := w.HasRoom(user)
err := submit(w, user, i)
assert.Equal(t, room, err == nil, "step %d (%s): HasRoom=%v but Submit returned %v", i, user, room, err)
}
r := newWRig(t, time.Minute)
stopped := uploads.NewWorker(r.st, r.conv, r.tr, r.tmp, time.Minute)
ctx, cancel := context.WithCancel(context.Background())
cancel()
stopped.Run(ctx)
assert.False(t, stopped.HasRoom("a"), "a worker that has stopped has no room")
}
+4 -84
View File
@@ -23,13 +23,6 @@ type wStore struct {
completed map[string]domain.Transcription completed map[string]domain.Transcription
failed map[string]domain.UploadErrorKind failed map[string]domain.UploadErrorKind
startErr error startErr error
// completeErr makes CompleteUpload fail, as a database outage would.
completeErr 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 { func newWStore() *wStore {
@@ -46,53 +39,17 @@ func (s *wStore) StartTranscribing(_ context.Context, _, id string) error {
func (s *wStore) CompleteUpload(_ context.Context, _, id string, t domain.Transcription) error { func (s *wStore) CompleteUpload(_ context.Context, _, id string, t domain.Transcription) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.completeErr != nil {
return s.completeErr
}
s.completed[id] = t s.completed[id] = t
return nil return nil
} }
func (s *wStore) FailUpload(ctx context.Context, _, id string, k domain.UploadErrorKind) error { func (s *wStore) FailUpload(_ context.Context, _, id string, k domain.UploadErrorKind) error {
if err := ctx.Err(); err != nil { // like the real store: a cancelled context cannot write
return err
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.failed[id] = k s.failed[id] = k
return nil 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, time.Time) error {
time.Sleep(s.beatDelay)
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) { func (s *wStore) snapshot() (map[string]domain.Transcription, map[string]domain.UploadErrorKind) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -126,7 +83,6 @@ type fakeTranscriber struct {
result domain.Transcription result domain.Transcription
err error err error
paths []string paths []string
langs []domain.Language // what each call's context carried
// block, when non-nil, holds every call until it is closed or ctx ends. // block, when non-nil, holds every call until it is closed or ctx ends.
block chan struct{} block chan struct{}
running atomic.Int32 running atomic.Int32
@@ -135,7 +91,6 @@ type fakeTranscriber struct {
func (f *fakeTranscriber) Transcribe(ctx context.Context, wav string) (domain.Transcription, error) { func (f *fakeTranscriber) Transcribe(ctx context.Context, wav string) (domain.Transcription, error) {
f.paths = append(f.paths, wav) f.paths = append(f.paths, wav)
f.langs = append(f.langs, domain.LanguageFrom(ctx))
n := f.running.Add(1) n := f.running.Add(1)
defer f.running.Add(-1) defer f.running.Add(-1)
for { for {
@@ -218,10 +173,6 @@ func TestWorkerProcess_FailuresAreTypedAndStillDeleteTheAudio(t *testing.T) {
}{ }{
{"conversion fails", func(r *wrig) { r.conv.err = errors.New("moov atom not found") }}, {"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") }}, {"every transcription endpoint fails", func(r *wrig) { r.tr.err = errors.New("iguana down; berget down") }},
{"storing the finished transcript fails", func(r *wrig) { r.st.completeErr = errors.New("db down") }},
{"the temporary WAV cannot be created", func(r *wrig) {
r.w = uploads.NewWorker(r.st, r.conv, r.tr, filepath.Join(r.tmp, "missing"), time.Minute)
}},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -247,9 +198,7 @@ func TestWorkerProcess_FailuresAreTypedAndStillDeleteTheAudio(t *testing.T) {
}) })
} }
func TestWorkerProcess_ShutdownMidJobRecordsTheRowInterruptedAtOnce(t *testing.T) { func TestWorkerProcess_ShutdownMidJobLeavesTheRowForTheStartupSweep(t *testing.T) {
// SIGTERM on a deploy used to leave the row "transcribing" until the NEXT pod's
// start-up sweep; the process may exit before any sweep runs (#50).
r := newWRig(t, time.Minute) r := newWRig(t, time.Minute)
r.tr.block = make(chan struct{}) r.tr.block = make(chan struct{})
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -262,8 +211,8 @@ func TestWorkerProcess_ShutdownMidJobRecordsTheRowInterruptedAtOnce(t *testing.T
<-done <-done
_, f := r.st.snapshot() _, f := r.st.snapshot()
assert.Equal(t, domain.UploadErrInterrupted, f["a"], "the row is recorded interrupted by the dying process, not left for a sweep") 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), "and the audio is still deleted") assert.Empty(t, r.leftovers(t), "but the audio is still deleted")
} }
func TestWorkerProcess_AJobThatOutlivesItsTimeoutIsFailedNotStuck(t *testing.T) { func TestWorkerProcess_AJobThatOutlivesItsTimeoutIsFailedNotStuck(t *testing.T) {
@@ -331,32 +280,3 @@ func TestWorkerRun_StoppingDeletesQueuedAudioAndRefusesNewJobs(t *testing.T) {
assert.Empty(t, r.leftovers(t), "audio of queued jobs is deleted on stop: the rows are swept to interrupted at next start") 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) assert.ErrorIs(t, r.w.Submit(r.spool(t, "c")), uploads.ErrStopped)
} }
// dbErr is an error shaped like pgx's: a message that quotes a value, and an SQLSTATE.
type dbErr struct{}
func (dbErr) Error() string {
return `ERROR: invalid input syntax for type json: "zebrakaka secret words"`
}
func (dbErr) SQLState() string { return "22P02" }
func TestWorker_TheTranscriberSeesTheLanguageOfTheJobOnItsContext(t *testing.T) {
for name, tt := range map[string]struct {
job domain.Language
want domain.Language
}{
"auto": {domain.LangAuto, domain.LangAuto},
"swedish": {domain.LangSwedish, domain.LangSwedish},
"a job with no language": {"", domain.LangSwedish}, // queued before the field existed
} {
t.Run(name, func(t *testing.T) {
r := newWRig(t, time.Minute)
job := r.spool(t, "a")
job.Language = tt.job
r.w.Process(context.Background(), job)
assert.Equal(t, []domain.Language{tt.want}, r.tr.langs)
})
}
}
-151
View File
@@ -1,151 +0,0 @@
package web_test
// Observes the cross-origin gate around Router (#48): SameSite=Lax does not stop a POST
// from a sibling *.d-ma.be origin, so the router itself must refuse a non-safe request
// whose Origin or Sec-Fetch-Site says it did not come from this site's own pages.
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// foreign is a header set a browser sends for a form post made by another page.
var foreign = map[string]map[string]string{
"sibling subdomain, Sec-Fetch-Site": {"Origin": "https://evil.d-ma.be", "Sec-Fetch-Site": "same-site"},
"other site, Sec-Fetch-Site": {"Origin": "https://evil.example", "Sec-Fetch-Site": "cross-site"},
"sibling subdomain, Origin only": {"Origin": "https://evil.d-ma.be"},
"opaque origin": {"Origin": "null"},
}
// own is a header set for the app's own pages (or a non-browser client): must pass.
var own = map[string]map[string]string{
"same-origin Sec-Fetch-Site": {"Sec-Fetch-Site": "same-origin", "Origin": "http://example.com"},
"matching Origin only": {"Origin": "http://example.com"},
"no browser headers": {},
}
func withHeaders(req *http.Request, h map[string]string) *http.Request {
for k, v := range h {
req.Header.Set(k, v)
}
return req
}
func userCount(t *testing.T) int {
t.Helper()
var n int
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT count(*) FROM users WHERE id = $1`, userID).Scan(&n))
return n
}
func TestCrossOrigin_ARefusedDeleteAccountPostLeavesTheAccountInPlace(t *testing.T) {
for name, h := range foreign {
t.Run(name, func(t *testing.T) {
app, _ := newAccountApp(t)
resetDB(t, rawPool(t))
seedConnection(t, app, "youtube", "ada@channel", "ref")
rec := do(t, app, withHeaders(httptest.NewRequest(http.MethodPost, "/account/delete", nil), h))
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, 1, userCount(t), "the account must survive a refused post")
})
}
}
func TestCrossOrigin_ASameOriginOrHeaderlessDeleteAccountPostIsServed(t *testing.T) {
for name, h := range own {
t.Run(name, func(t *testing.T) {
app, _ := newAccountApp(t)
resetDB(t, rawPool(t))
seedConnection(t, app, "youtube", "ada@channel", "ref")
rec := do(t, app, withHeaders(httptest.NewRequest(http.MethodPost, "/account/delete", nil), h))
assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, "/auth/logout", rec.Header().Get("Location"))
})
}
}
func TestCrossOrigin_ARefusedUploadPostSubmitsNoJob(t *testing.T) {
for name, h := range foreign {
t.Run(name, func(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, withHeaders(uploadReq(t, "m", "m.wav", []byte("RIFF....WAVE")), h))
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Empty(t, sub.jobs, "a refused post must not enqueue transcription")
})
}
}
func TestCrossOrigin_ARefusedUploadChatPostCallsNoModel(t *testing.T) {
for name, h := range foreign {
t.Run(name, func(t *testing.T) {
app, fc := newUploadChatApp(t)
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
req := chatPost("/uploads/"+id+"/chat", url.Values{"message": {"hej"}})
rec := do(t, app, withHeaders(req, h))
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Zero(t, fc.callCount)
})
}
}
func TestCrossOrigin_AForeignPostIsRefusedOnEachListedStateChangingPath(t *testing.T) {
routes := []string{
"/account/disconnect/youtube",
"/account/summarize-mode",
"/register",
"/paste",
"/v/abc/action",
"/v/abc/summarize",
"/v/abc/retry-now",
"/v/abc/chat",
}
for _, path := range routes {
t.Run(path, func(t *testing.T) {
app := newApp(t)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := do(t, app, withHeaders(req, foreign["sibling subdomain, Sec-Fetch-Site"]))
assert.Equal(t, http.StatusForbidden, rec.Code)
})
}
}
func TestCrossOrigin_ARefusedRecordingDeletePostLeavesTheRecordingInPlace(t *testing.T) {
for name, h := range foreign {
t.Run(name, func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
rec := do(t, app, withHeaders(httptest.NewRequest(http.MethodPost, "/uploads/"+id+"/delete", nil), h))
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.True(t, uploadExists(t, id), "a refused post must not delete the recording")
})
}
}
// A login redirect comes back to /auth/callback as a cross-site GET navigation, and
// health probes carry no headers: safe methods must never be refused on origin grounds.
func TestCrossOrigin_ACrossSiteGETIsNotRefused(t *testing.T) {
app := newApp(t)
for _, path := range []string{"/healthz", "/welcome"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Sec-Fetch-Site", "cross-site")
assert.Equal(t, http.StatusOK, do(t, app, req).Code, path)
}
}
+2 -16
View File
@@ -59,9 +59,6 @@ type Store interface {
// non-owner's id is ErrUploadNotFound. // non-owner's id is ErrUploadNotFound.
GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error) GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error)
ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error) ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error)
// DeleteUpload removes a finished upload; ErrUploadInProgress while it is still being
// processed, ErrUploadNotFound for a non-owner.
DeleteUpload(ctx context.Context, userID, id string) error
// SetTranscriptStatus clears or updates a video's transcript backoff state. // SetTranscriptStatus clears or updates a video's transcript backoff state.
// Used by handleRetryNow to clear rate_limited_at before immediate processing. // Used by handleRetryNow to clear rate_limited_at before immediate processing.
@@ -125,11 +122,6 @@ type App struct {
// summarizer's (koala/phi4-mini, 8,192-token context) and cannot hold an hour of // summarizer's (koala/phi4-mini, 8,192-token context) and cannot hold an hour of
// speech. // speech.
UploadChatModel string 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 // Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use. // shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet Processing ProcessingSet
@@ -203,7 +195,6 @@ func (a *App) Router() http.Handler {
app.HandleFunc("POST /uploads", a.handleUploadPost) app.HandleFunc("POST /uploads", a.handleUploadPost)
app.HandleFunc("GET /uploads/{id}", a.handleUploadDetail) app.HandleFunc("GET /uploads/{id}", a.handleUploadDetail)
app.HandleFunc("GET /uploads/{id}/status", a.handleUploadStatus) app.HandleFunc("GET /uploads/{id}/status", a.handleUploadStatus)
app.HandleFunc("POST /uploads/{id}/delete", a.handleUploadDelete)
// Chat over the uploaded transcript reuses the ADR-027 Chatter; mounted only // Chat over the uploaded transcript reuses the ADR-027 Chatter; mounted only
// when one is wired. // when one is wired.
if a.Chat != nil { if a.Chat != nil {
@@ -233,12 +224,7 @@ func (a *App) Router() http.Handler {
// resolved user_id. /register lives inside the auth guard but is exempt from // resolved user_id. /register lives inside the auth guard but is exempt from
// the registration gate (you must be able to reach it before you have a user). // the registration gate (you must be able to reach it before you have a user).
root.Handle("/", a.Auth.Middleware(a.registrationGate(app))) root.Handle("/", a.Auth.Middleware(a.registrationGate(app)))
return root
// The session cookie is SameSite=Lax, which still travels on a POST made from a
// sibling *.d-ma.be page (same site, different origin). Refuse any non-safe request
// whose Origin or Sec-Fetch-Site says it did not come from this origin (#48). It wraps
// everything, so a route added later is covered without remembering to ask.
return http.NewCrossOriginProtection().Handler(root)
} }
// handleWelcome renders the public landing page (/welcome). It is mounted outside // handleWelcome renders the public landing page (/welcome). It is mounted outside
@@ -313,7 +299,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
a.render(w, r, summaryList(buckets, hasConnected, autoSummarize)) a.render(w, r, summaryList(buckets, hasConnected, autoSummarize))
return return
} }
a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected, channels, autoSummarize, a.Uploads != nil)) a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected, channels, autoSummarize))
} }
// handleDetail renders one summary in full (highlights, takeaways, action group). // handleDetail renders one summary in full (highlights, takeaways, action group).
-61
View File
@@ -1,61 +0,0 @@
package oidc_test
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/web/oidc"
)
func testConfig(issuer string) oidc.Config {
return oidc.Config{
Issuer: issuer,
ClientID: testClientID,
ClientSecret: "test-client-secret",
RedirectURL: "http://tapir.test/auth/callback",
SessionSecret: "test-session-secret-please-change",
}
}
// #59: a discovery 503 at start-up is retried instead of failing New.
func TestNewRetriesDiscoveryAfter503UntilIssuerAnswers(t *testing.T) {
f := newFakeIssuer(t)
f.unavailableFor = 2
auth, err := oidc.New(context.Background(), testConfig(f.server.URL),
oidc.WithDiscoveryRetry(5, time.Millisecond))
require.NoError(t, err)
require.NotNil(t, auth)
require.Equal(t, int32(3), f.discoveryHits.Load())
}
func TestNewReturnsDiscoveryErrorAfterAttemptsExhausted(t *testing.T) {
f := newFakeIssuer(t)
f.unavailableFor = 1000
_, err := oidc.New(context.Background(), testConfig(f.server.URL),
oidc.WithDiscoveryRetry(3, time.Millisecond))
require.ErrorContains(t, err, "503")
require.Equal(t, int32(3), f.discoveryHits.Load())
}
func TestNewStopsDiscoveryBackoffWhenContextCancelled(t *testing.T) {
f := newFakeIssuer(t)
f.unavailableFor = 1000
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
_, err := oidc.New(ctx, testConfig(f.server.URL),
oidc.WithDiscoveryRetry(5, time.Hour))
require.True(t, errors.Is(err, context.DeadlineExceeded), "err = %v", err)
require.Less(t, time.Since(start), 5*time.Second)
require.Equal(t, int32(1), f.discoveryHits.Load())
}
+16 -66
View File
@@ -57,10 +57,6 @@ const (
pendingTTL = 10 * time.Minute pendingTTL = 10 * time.Minute
sessionCookie = "tapir_session" sessionCookie = "tapir_session"
loginPath = "/auth/login" loginPath = "/auth/login"
// Discovery retry (#59): 5 tries, 2+4+8+16 s back-off ≈ 30 s, then fail.
defaultDiscoveryAttempts = 5
defaultDiscoveryDelay = 2 * time.Second
) )
// DexAuth implements web.Auth against Dex. // DexAuth implements web.Auth against Dex.
@@ -74,9 +70,6 @@ type DexAuth struct {
sessionTTL time.Duration sessionTTL time.Duration
now func() time.Time now func() time.Time
insecure bool // tests only: drop the Secure attribute so cookies flow over http insecure bool // tests only: drop the Secure attribute so cookies flow over http
discoveryAttempts int
discoveryDelay time.Duration
} }
var _ web.Auth = (*DexAuth)(nil) var _ web.Auth = (*DexAuth)(nil)
@@ -103,55 +96,14 @@ func WithSessionTTL(ttl time.Duration) Option {
} }
} }
// WithDiscoveryRetry overrides the discovery attempt count and the initial
// back-off delay (doubled after each failed attempt).
func WithDiscoveryRetry(attempts int, delay time.Duration) Option {
return func(d *DexAuth) {
if attempts > 0 {
d.discoveryAttempts = attempts
}
if delay > 0 {
d.discoveryDelay = delay
}
}
}
// WithInsecureCookies drops the Secure cookie attribute. TEST ONLY — it lets // WithInsecureCookies drops the Secure cookie attribute. TEST ONLY — it lets
// the session cookie travel over plain http; never use it in production. // the session cookie travel over plain http; never use it in production.
func WithInsecureCookies() Option { func WithInsecureCookies() Option {
return func(d *DexAuth) { d.insecure = true } return func(d *DexAuth) { d.insecure = true }
} }
// discover runs OIDC discovery up to attempts times, doubling delay between // New discovers the issuer and returns a configured DexAuth. ctx bounds the
// tries (#59: an issuer briefly unavailable behind its proxy answered 503 and // discovery request only.
// serve exited). A cancelled ctx ends the back-off immediately with ctx's error.
func discover(ctx context.Context, issuer string, attempts int, delay time.Duration) (*oidc.Provider, error) {
var lastErr error
for i := range attempts {
if i > 0 {
t := time.NewTimer(delay)
select {
case <-ctx.Done():
t.Stop()
return nil, fmt.Errorf("after %d attempts (last: %v): %w", i, lastErr, ctx.Err())
case <-t.C:
}
delay *= 2
}
p, err := oidc.NewProvider(ctx, issuer)
if err == nil {
return p, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, fmt.Errorf("after %d attempts: %w", i+1, ctx.Err())
}
}
return nil, fmt.Errorf("after %d attempts: %w", attempts, lastErr)
}
// New discovers the issuer and returns a configured DexAuth. ctx bounds discovery,
// including its retry back-off.
func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) { func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
for name, val := range map[string]string{ for name, val := range map[string]string{
"issuer": cfg.Issuer, "issuer": cfg.Issuer,
@@ -165,30 +117,28 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
} }
} }
d := &DexAuth{ provider, err := oidc.NewProvider(ctx, cfg.Issuer)
cfg: cfg,
pending: newPendingStore(),
secret: []byte(cfg.SessionSecret),
sessionTTL: defaultSessionTTL,
now: time.Now,
discoveryAttempts: defaultDiscoveryAttempts,
discoveryDelay: defaultDiscoveryDelay,
}
for _, o := range opts {
o(d)
}
provider, err := discover(ctx, cfg.Issuer, d.discoveryAttempts, d.discoveryDelay)
if err != nil { if err != nil {
return nil, fmt.Errorf("oidc: discover issuer: %w", err) return nil, fmt.Errorf("oidc: discover issuer: %w", err)
} }
d.verifier = provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
d.oauth = &oauth2.Config{ d := &DexAuth{
cfg: cfg,
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
oauth: &oauth2.Config{
ClientID: cfg.ClientID, ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret, ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(), Endpoint: provider.Endpoint(),
RedirectURL: cfg.RedirectURL, RedirectURL: cfg.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
pending: newPendingStore(),
secret: []byte(cfg.SessionSecret),
sessionTTL: defaultSessionTTL,
now: time.Now,
}
for _, o := range opts {
o(d)
} }
return d, nil return d, nil
} }
-10
View File
@@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -35,11 +34,6 @@ type fakeIssuer struct {
sub string sub string
email string email string
nonce string nonce string
// discoveryHits counts requests to the discovery document; the first
// unavailableFor of them answer 503 (issuer briefly down behind its proxy).
discoveryHits atomic.Int32
unavailableFor int32
} }
const testKID = "test-key" const testKID = "test-key"
@@ -53,10 +47,6 @@ func newFakeIssuer(t *testing.T) *fakeIssuer {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
if f.discoveryHits.Add(1) <= f.unavailableFor {
http.Error(w, "no available server", http.StatusServiceUnavailable)
return
}
writeJSON(t, w, map[string]any{ writeJSON(t, w, map[string]any{
"issuer": f.server.URL, "issuer": f.server.URL,
"authorization_endpoint": f.server.URL + "/authorize", "authorization_endpoint": f.server.URL + "/authorize",
+1 -1
View File
@@ -66,7 +66,7 @@ func TestParseYouTubeVideoID(t *testing.T) {
func TestListPageShowsPasteFormOnlyWhenConnected(t *testing.T) { func TestListPageShowsPasteFormOnlyWhenConnected(t *testing.T) {
render := func(connected bool) string { render := func(connected bool) string {
var buf bytes.Buffer var buf bytes.Buffer
if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected, nil, true, false).Render(context.Background(), &buf); err != nil { if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected, nil, true).Render(context.Background(), &buf); err != nil {
t.Fatalf("render: %v", err) t.Fatalf("render: %v", err)
} }
return buf.String() return buf.String()
+16 -361
View File
@@ -3,24 +3,17 @@ package web
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"math"
"net/http" "net/http"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time"
"unicode"
"unicode/utf8" "unicode/utf8"
"github.com/a-h/templ" "github.com/a-h/templ"
"git.d-ma.be/mathias/tapir/internal/adapters/chat" "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/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/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/uploads" "git.d-ma.be/mathias/tapir/internal/uploads"
) )
@@ -28,7 +21,7 @@ import (
// *uploads.Intake satisfies it. It is the whole of what the handlers know about // *uploads.Intake satisfies it. It is the whole of what the handlers know about
// validation, spooling and queueing. // validation, spooling and queueing.
type Uploader interface { type Uploader interface {
Accept(ctx context.Context, userID, title string, lang domain.Language, src io.Reader) (id string, err error) Accept(ctx context.Context, userID, title string, src io.Reader) (id string, err error)
} }
const ( const (
@@ -36,18 +29,8 @@ const (
// and the title field; the cap itself is enforced on the file part by Intake. // and the title field; the cap itself is enforced on the file part by Intake.
uploadOverhead = 1 << 20 uploadOverhead = 1 << 20
maxTitleRunes = 200 maxTitleRunes = 200
// maxLanguageBytes bounds what is read of the language field; a valid value is at most four bytes, so a longer one fails the parse.
maxLanguageBytes = 16
// uploadPollEvery is how often an unfinished upload re-asks for its status. // uploadPollEvery is how often an unfinished upload re-asks for its status.
uploadPollEvery = "2s" uploadPollEvery = "5s"
// 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. // uploadsView is what the /uploads page renders.
@@ -91,62 +74,24 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
return return
} }
title := "" title := ""
lang := domain.LangSwedish
for { for {
part, err := mr.NextPart() part, err := mr.NextPart()
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
break break
} }
if err != nil { if err != nil {
var tooBig *http.MaxBytesError
if a.abandoned(w, r) {
return
}
if !errors.As(err, &tooBig) {
// A malformed multipart body (a header with a control byte, a truncated
// part) is the sender's to fix: a 400, not a server error, and the error
// text is never logged: it quotes the headers, which carry the file name.
a.logger().Info("upload rejected", "reason", "malformed")
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
return
}
a.uploadFailure(w, r, userID, err) a.uploadFailure(w, r, userID, err)
return return
} }
switch part.FormName() { switch part.FormName() {
case "title": case "title":
b, err := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax)) b, _ := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
if err != nil { title = clipRunes(strings.TrimSpace(string(b)), maxTitleRunes)
if a.abandoned(w, r) {
return
}
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
return
}
title = cleanTitle(string(b))
case "language":
// A closed set read before the file: anything else is refused before the file
// is spooled, so a crafted value never reaches an engine (tapir#61).
b, err := io.ReadAll(io.LimitReader(part, maxLanguageBytes+1))
if err != nil {
if a.abandoned(w, r) {
return
}
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
return
}
l, ok := domain.ParseLanguage(string(b))
if !ok {
a.logger().Info("upload rejected", "reason", "language")
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose a language from the list.")
return
}
lang = l
case "file": case "file":
if title == "" { if title == "" {
title = titleFromFilename(part.FileName()) title = titleFromFilename(part.FileName())
} }
id, err := a.Uploads.Accept(r.Context(), userID, title, lang, part) id, err := a.Uploads.Accept(r.Context(), userID, title, part)
if err != nil { if err != nil {
a.uploadFailure(w, r, userID, err) a.uploadFailure(w, r, userID, err)
return return
@@ -158,27 +103,10 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.") a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
} }
// abandoned reports, logs at INFO and answers 499, because the client has already gone: its request
// context is cancelled, so there is nobody to answer and rendering a page would run its
// database read on a dead context and log a false ERROR. A hang-up mid-upload is routine.
func (a *App) abandoned(w http.ResponseWriter, r *http.Request) bool {
if r.Context().Err() == nil {
return false
}
a.logger().Info("upload abandoned", "reason", "client_gone")
// 499 (the "client closed request" convention): a client that half-closed after sending
// can still read, and an unwritten status would reach it as a false, empty 200.
w.WriteHeader(499)
return true
}
// uploadFailure maps an Accept (or multipart) error to a response: a validation // 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 // rejection is the user's to fix (422, or 413 for size) and carries its own
// message; anything else is ours. // message; anything else is ours.
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) { func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
if a.abandoned(w, r) {
return
}
var rej *uploads.RejectError var rej *uploads.RejectError
var tooBig *http.MaxBytesError var tooBig *http.MaxBytesError
switch { switch {
@@ -186,26 +114,16 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin
// Logged by reason only: the filename and title are private content. // Logged by reason only: the filename and title are private content.
a.logger().Info("upload rejected", "reason", string(rej.Reason)) a.logger().Info("upload rejected", "reason", string(rej.Reason))
status := http.StatusUnprocessableEntity status := http.StatusUnprocessableEntity
switch rej.Reason { if rej.Reason == uploads.ReasonTooLarge {
case uploads.ReasonTooLarge:
status = http.StatusRequestEntityTooLarge status = http.StatusRequestEntityTooLarge
case uploads.ReasonBusy:
status = http.StatusTooManyRequests
} }
a.renderUploadsPage(w, r, userID, status, rej.Message) a.renderUploadsPage(w, r, userID, status, rej.Message)
case errors.As(err, &tooBig): case errors.As(err, &tooBig):
a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge)) a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge, a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
"This file is larger than the upload limit.") "This file is larger than the upload limit.")
case errors.Is(err, uploads.ErrClientRead):
// An error reading the CLIENT's body: theirs to retry, not a server fault. Only that
// marker counts: io.ErrUnexpectedEOF alone is also what a database drop looks like.
a.logger().Info("upload rejected", "reason", "truncated")
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "The upload was cut off before it finished. Please try again.")
default: default:
// Not err: it can carry the file's name, a spool path or a database value. a.serverError(w, r, "upload", err)
a.logger().Error("upload failed", "kind", errkind.Of(err))
http.Error(w, "internal error", http.StatusInternalServerError)
} }
} }
@@ -217,7 +135,7 @@ func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
a.render(w, r, UploadPage(*u, a.Chat != nil, a.now())) a.render(w, r, UploadPage(*u, a.Chat != nil))
} }
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states // handleUploadStatus renders the status fragment HTMX polls. Unfinished states
@@ -228,33 +146,7 @@ func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
a.render(w, r, uploadStatus(*u, a.Chat != nil, a.now())) a.render(w, r, uploadStatus(*u, a.Chat != nil))
}
// handleUploadDelete removes one of the caller's finished recordings and returns to the
// list. Another user's id is a 404 like every other upload route; a recording still being
// transcribed is refused (409) rather than deleted under the worker (#37).
func (a *App) handleUploadDelete(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
id := r.PathValue("id")
if !uuidShape.MatchString(id) {
http.NotFound(w, r)
return
}
switch err := a.Store.DeleteUpload(r.Context(), userID, id); {
case err == nil:
http.Redirect(w, r, "/uploads", http.StatusSeeOther)
case errors.Is(err, store.ErrUploadNotFound):
http.NotFound(w, r)
case errors.Is(err, store.ErrUploadInProgress):
a.renderUploadsPage(w, r, userID, http.StatusConflict,
"This recording is still being transcribed. You can delete it once it has finished.")
default:
a.serverError(w, r, "delete upload", err)
}
} }
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) { func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
@@ -262,14 +154,7 @@ func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.U
if !ok { if !ok {
return nil, false return nil, false
} }
id := r.PathValue("id") u, err := a.Store.GetUpload(r.Context(), userID, r.PathValue("id"))
if !uuidShape.MatchString(id) {
// Not an id at all: the same 404 as an unknown or foreign one, never a database
// error that surfaces as a 500 (and no oracle about which ids exist).
http.NotFound(w, r)
return nil, false
}
u, err := a.Store.GetUpload(r.Context(), userID, id)
if errors.Is(err, store.ErrUploadNotFound) { if errors.Is(err, store.ErrUploadNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return nil, false return nil, false
@@ -284,87 +169,11 @@ func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.U
// titleFromFilename is the default title: the file's name without its extension. // titleFromFilename is the default title: the file's name without its extension.
func titleFromFilename(name string) string { func titleFromFilename(name string) string {
base := filepath.Base(name) base := filepath.Base(name)
t := cleanTitle(strings.TrimSuffix(base, filepath.Ext(base))) t := strings.TrimSuffix(base, filepath.Ext(base))
if t == "" || t == "." { if t == "" || t == "." {
return "Untitled recording" return "Untitled recording"
} }
return t return clipRunes(t, maxTitleRunes)
}
// cloudProvider names the cloud service that produced a transcript, or false when it was
// served locally (or by an unknown endpoint). "Local" is config.IsLocalModel: the same
// definition the start-up validation uses, not a second string match here (#52).
//
// The endpoint is one alias, except for the two-engine path (ADR-034), which writes
// "<base> + <alt>, reconciled by <model>" (ensemble.go). That shape is taken apart exactly
// and each alias is judged whole, so a composite of local models is local, and anything
// else (a cloud alias, one with no provider prefix, one with a space or a connector word in
// it) is not. A string that is not that shape is one alias.
func cloudProvider(endpoint string) (string, bool) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", false
}
aliases := []string{endpoint}
if engines, reconciler, ok := strings.Cut(endpoint, ", reconciled by "); ok {
aliases = append(strings.Split(engines, " + "), reconciler)
}
for _, a := range aliases {
if a = strings.TrimSpace(a); !config.IsLocalModel(a) {
provider, _, _ := strings.Cut(a, "/")
if provider = strings.TrimSpace(provider); provider == "" {
provider = a
}
return provider, true
}
}
return "", false
}
// languageLabel is how the page names the language a recording was transcribed as.
func languageLabel(l domain.Language) string {
if l == domain.LangAuto {
return "Other (detected by the engine)"
}
return "Swedish"
}
// hasNote reports whether any phase carries a note. A note is the only warning for a
// recording that is shorter than its file claimed, so a record that has one is shown open.
func hasNote(steps []domain.ProgressStep) bool {
for _, s := range steps {
if s.Note != "" {
return true
}
}
return false
}
// uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404.
var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// isBidiControl reports the invisible characters that change text direction. Emoji joiners
// (ZWJ, ZWNJ) and variation selectors are not among them and are kept.
func isBidiControl(r rune) bool {
return r >= 0x202A && r <= 0x202E || r >= 0x2066 && r <= 0x2069 || r == 0x200E || r == 0x200F || r == 0x061C
}
// cleanTitle makes a title safe to store and show: invalid UTF-8 dropped, control
// characters (NUL, newlines, escapes) turned into spaces, runs of space collapsed, trimmed
// and clipped. A title is cosmetic; bad bytes in it must never fail an upload, and Postgres
// refuses NUL in text.
func cleanTitle(s string) string {
s = strings.ToValidUTF8(s, "")
s = strings.Map(func(r rune) rune {
switch {
case isBidiControl(r):
return -1 // dropped: U+202E would reverse the rest of the title on its owner's page
case unicode.IsControl(r):
return ' '
}
return r
}, s)
return clipRunes(strings.Join(strings.Fields(s), " "), maxTitleRunes)
} }
func clipRunes(s string, n int) string { func clipRunes(s string, n int) string {
@@ -380,10 +189,6 @@ func uploadFailureMessage(k domain.UploadErrorKind) string {
switch k { switch k {
case domain.UploadErrInterrupted: case domain.UploadErrInterrupted:
return "The server restarted before this recording was transcribed. Please upload it again." return "The server restarted before this recording was transcribed. Please upload it again."
case domain.UploadErrBusy:
return "Too many recordings were waiting to be transcribed when this one was uploaded. Please upload it again in a few minutes."
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.UploadErrTranscriptionFailed: case domain.UploadErrTranscriptionFailed:
return "Transcription failed on every available service. Please upload it again later." return "Transcription failed on every available service. Please upload it again later."
default: default:
@@ -404,8 +209,7 @@ func (a *App) handleUploadChat(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther) http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
return return
} }
model, _ := a.resolveUploadModel("") a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, a.Chat.DefaultModel(a.UploadChatModel), nil, false, "")))
a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, model, nil, false, "")))
} }
// handleUploadChatMessage answers one question against the owner's uploaded // handleUploadChatMessage answers one question against the owner's uploaded
@@ -425,13 +229,7 @@ func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad form", http.StatusBadRequest) http.Error(w, "bad form", http.StatusBadRequest)
return return
} }
model, ok := a.resolveUploadModel(r.FormValue("model")) model := a.resolveModel(r.FormValue("model"), a.UploadChatModel)
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"]) history := parseHistory(r.Form["hq"], r.Form["ha"])
question := strings.TrimSpace(r.FormValue("question")) question := strings.TrimSpace(r.FormValue("question"))
@@ -445,7 +243,7 @@ func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
MaxChars: a.UploadChatMaxChars, MaxChars: a.UploadChatMaxChars,
}) })
if err != nil { if err != nil {
a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "kind", errkind.Of(err)) 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." view.Error = "That model couldn't answer just now. Try again, or switch models."
} else { } else {
view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer}) view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer})
@@ -464,7 +262,7 @@ func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history
VideoID: u.ID, VideoID: u.ID,
Title: u.Title, Title: u.Title,
Available: true, Available: true,
Models: a.UploadChatModels, Models: a.Chat.Models(),
Selected: model, Selected: model,
History: history, History: history,
Truncated: truncated, Truncated: truncated,
@@ -473,146 +271,3 @@ func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history
Subject: "recording", Subject: "recording",
} }
} }
// 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.
func minuteMark(seconds float64) string {
return fmt.Sprintf("~%d min", int(math.Round(seconds/60)))
}
// 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 {
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 out
}
// 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)))
}
// 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"
}
// 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
}
+3 -157
View File
@@ -2,7 +2,6 @@ package web
import ( import (
"fmt" "fmt"
"strconv"
"time" "time"
"git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/domain"
@@ -24,14 +23,6 @@ templ UploadsPage(v uploadsView) {
} }
<form method="post" action="/uploads" enctype="multipart/form-data"> <form method="post" action="/uploads" enctype="multipart/form-data">
<p><label>Title (optional) <input type="text" name="title" maxlength="200"/></label></p> <p><label>Title (optional) <input type="text" name="title" maxlength="200"/></label></p>
<p>
<label>Language
<select name="language">
<option value="sv" selected>Swedish</option>
<option value="auto">Other (detected by the engine)</option>
</select>
</label>
</p>
<p><input type="file" name="file" accept="audio/*,.m4a,.mp3,.wav,.opus,.ogg,.mp4" required/></p> <p><input type="file" name="file" accept="audio/*,.m4a,.mp3,.wav,.opus,.ogg,.mp4" required/></p>
<p><button type="submit">Upload and transcribe</button></p> <p><button type="submit">Upload and transcribe</button></p>
</form> </form>
@@ -53,12 +44,11 @@ templ UploadsPage(v uploadsView) {
} }
// UploadPage is one upload's page: its title and the polling status region. // UploadPage is one upload's page: its title and the polling status region.
templ UploadPage(u domain.UploadedTranscript, chatEnabled bool, now time.Time) { templ UploadPage(u domain.UploadedTranscript, chatEnabled bool) {
@Layout("Tapir — " + u.Title) { @Layout("Tapir — " + u.Title) {
<article class="account"> <article class="account">
<h1>{ u.Title }</h1> <h1>{ u.Title }</h1>
<p class="muted">Language: { languageLabel(u.Language) }</p> @uploadStatus(u, chatEnabled)
@uploadStatus(u, chatEnabled, now)
<p><a href="/uploads">All recordings</a></p> <p><a href="/uploads">All recordings</a></p>
</article> </article>
} }
@@ -66,7 +56,7 @@ templ UploadPage(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the // 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. // page re-asks; done and failed render without it, so nothing polls forever.
templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) { templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
switch u.Status { switch u.Status {
case domain.UploadDone: case domain.UploadDone:
<div class="upload-status upload-done" role="status"> <div class="upload-status upload-done" role="status">
@@ -75,19 +65,6 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) } Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by <span class="chip">{ u.Endpoint }</span>. of audio by <span class="chip">{ u.Endpoint }</span>.
</p> </p>
if provider, cloud := cloudProvider(u.Endpoint); cloud {
<p class="cloud-note">
This recording's audio left the homelab: it was transcribed by a cloud service ({ provider })
because the local engine could not.
</p>
}
@reconciliationNote(u.Reconciliation)
if len(u.Progress) > 0 {
<details class="what-was-done" if hasNote(u.Progress) { open }>
<summary>What was done</summary>
@progressSteps(u.Progress, now, false)
</details>
}
if chatEnabled { if chatEnabled {
<p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p> <p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p>
} }
@@ -95,19 +72,11 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
<summary>Transcript</summary> <summary>Transcript</summary>
<pre class="transcript">{ u.Content }</pre> <pre class="transcript">{ u.Content }</pre>
</details> </details>
@deleteRecording(u.ID)
</div> </div>
case domain.UploadFailed: case domain.UploadFailed:
<div class="upload-status upload-failed flash flash-error" role="alert"> <div class="upload-status upload-failed flash flash-error" role="alert">
@videoNotice(u.HadVideo) @videoNotice(u.HadVideo)
<p>{ uploadFailureMessage(u.ErrorKind) }</p> <p>{ uploadFailureMessage(u.ErrorKind) }</p>
if len(u.Progress) > 0 {
<details class="what-was-done" open>
<summary>Where it stopped</summary>
@progressSteps(u.Progress, now, false)
</details>
}
@deleteRecording(u.ID)
</div> </div>
default: default:
<div <div
@@ -119,13 +88,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
> >
@videoNotice(u.HadVideo) @videoNotice(u.HadVideo)
if u.Status == domain.UploadTranscribing { if u.Status == domain.UploadTranscribing {
{{ health, silent := uploadHealth(u, now) }}
if len(u.Progress) > 0 {
@progressSteps(u.Progress, now, health != healthLost)
@healthNote(health, silent)
} else {
<p>Transcribing… a long recording takes a few minutes.</p> <p>Transcribing… a long recording takes a few minutes.</p>
}
} else { } else {
<p>Queued — waiting for its turn to be transcribed.</p> <p>Queued — waiting for its turn to be transcribed.</p>
} }
@@ -133,20 +96,6 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
} }
} }
// deleteRecording is the confirm-then-delete control, shown only once a recording has
// finished (done or failed): deleting one that is still being processed is refused.
templ deleteRecording(id string) {
<details class="confirm-delete">
<summary class="btn-danger">Delete this recording…</summary>
<div class="confirm-body">
<p>This permanently deletes the recording and its transcript. Are you sure?</p>
<form method="post" action={ templ.SafeURL("/uploads/" + id + "/delete") }>
<button type="submit" class="btn-danger">Yes, permanently delete it</button>
</form>
</div>
</details>
}
// UploadChatPage is the no-JS full page for chatting with an uploaded transcript: // 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. // the same chat panel as a video's (ADR-027), scoped to this recording.
templ UploadChatPage(u domain.UploadedTranscript, v chatView) { templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
@@ -174,106 +123,3 @@ templ videoNotice(hadVideo bool) {
</p> </p>
} }
} }
// 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:
<div class="reconciliation">
<p>
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.
</p>
if len(shownSpots(r)) > 0 {
<details>
<summary>Spots to check against the audio</summary>
<ul>
for _, c := range shownSpots(r) {
<li>
{ minuteMark(c.Seconds) }:
if c.Before != "" {
<span class="muted">…{ c.Before }</span>
}
{ c.Base } / { c.Alt } —
if c.Unsure {
<span class="chip chip-warn">uncertain</span> kept { c.Used }
} else {
<span class="chip">changed</span> used { c.Used }
}
</li>
}
</ul>
if moreSpots(r) > 0 {
<p class="muted">and { strconv.Itoa(moreSpots(r)) } more not listed</p>
}
</details>
}
</div>
case domain.ReconcileSingle:
<p class="muted reconciliation">Only one engine produced this transcript ({ r.Reason }), so there was no second opinion to reconcile it with.</p>
case domain.ReconcileSkipped:
<p class="muted reconciliation">Two transcripts were made but could not be reconciled ({ r.Reason }); the text shown is { r.Base }'s, unreconciled.</p>
}
}
}
// 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) {
<ol class="steps" aria-label="Progress">
for _, s := range steps {
<li
class={ "step step-" + string(s.State) }
if s.State == domain.StepRunning {
aria-current="step"
}
>
if s.State == domain.StepRunning && live {
<span class="step-mark" aria-hidden="true"><span class="spinner"></span></span>
} else if s.State == domain.StepRunning {
<span class="step-mark step-mark-warn" aria-hidden="true">!</span>
} else {
<span class="step-mark" aria-hidden="true">{ stepMark(s) }</span>
}
<span class="step-label">{ s.Label }</span>
if t := stepTime(s, now); t != "" {
<span class="step-time">{ t }</span>
}
if s.Note != "" {
<span class="step-note">{ s.Note }</span>
}
if s.State == domain.StepRunning && s.Total > 0 {
<progress class="step-bar" value={ strconv.Itoa(s.Done) } max={ strconv.Itoa(s.Total) }></progress>
<span class="step-count">{ strconv.Itoa(s.Done) } of { strconv.Itoa(s.Total) }</span>
}
</li>
}
</ol>
}
// 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:
<p class="step-health step-health-lost" role="alert">
The service stopped reporting { minutesAgo(silent) } ago. It may have restarted or crashed.
This page keeps checking; if nothing changes, upload the recording again.
</p>
case healthQuiet:
<p class="step-health step-health-quiet">
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.
</p>
}
}
@@ -1,93 +0,0 @@
package web_test
import (
"context"
"fmt"
"log/slog"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Input that cannot be right is the caller's to fix: a 4xx, never a 500 (adversarial
// review F7). No cross-tenant oracle either way: unknown and foreign ids look the same.
func TestUploadRoutes_ANonUUIDIdIs404NotA500(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
for _, path := range []string{"/uploads/not-a-uuid", "/uploads/not-a-uuid/status", "/uploads/not-a-uuid/chat", "/uploads/%00/status", "/uploads/1/status"} {
t.Run(path, func(t *testing.T) {
rec := do(t, app, get(path))
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}
}
func TestUploadPost_ATitleWithControlCharactersOrInvalidUTF8IsCleanedNotA500(t *testing.T) {
tests := []struct{ name, title, filename string }{
{"a NUL in the title", "Sprint\x00review", "a.m4a"},
{"invalid UTF-8 in the title", "Sprint \xff\xfe review", "a.m4a"},
{"a newline in the title", "Sprint\nreview", "a.m4a"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, tt.title, tt.filename, []byte("synthetic audio bytes")))
require.Equal(t, http.StatusSeeOther, rec.Code, "bad bytes in a cosmetic title must not fail the upload: %s", body(t, rec))
require.Len(t, sub.jobs, 1)
var title string
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT title FROM uploaded_transcripts WHERE id = $1`, sub.jobs[0].UploadID).Scan(&title))
assert.NotContains(t, title, "\x00")
assert.NotContains(t, title, "\n")
assert.True(t, len(title) > 0, "a title is still shown")
})
}
}
func TestUploadPost_AMalformedMultipartHeaderIsA400AndItsTextIsNotLogged(t *testing.T) {
// A control byte in the part's file name makes the multipart body malformed. It is the
// sender's to fix, and the parser's error quotes the header, which carries the name.
app, sub := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
rec := do(t, app, uploadReq(t, "", "zebrakaka\x00weekly.m4a", []byte("x")))
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Empty(t, sub.jobs)
assert.NotContains(t, buf.String(), "zebrakaka", "a file name is private content")
}
func TestUploadPost_EveryBidirectionalControlCharacterIsStrippedFromATitle(t *testing.T) {
// U+202E reverses the text that follows it, so a title could render reversed on its
// owner's own page (second adversarial review). Every one of the twelve is pinned.
controls := []rune{0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069, 0x200E, 0x200F, 0x061C}
for _, c := range controls {
t.Run(fmt.Sprintf("U+%04X", c), func(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, "Sprint"+string(c)+"review", "a.m4a", []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 id = $1`, sub.jobs[0].UploadID).Scan(&title))
assert.Equal(t, "Sprintreview", title, "dropped, not replaced by a space: a space would leave a visible gap")
})
}
}
func TestUploadPost_SwedishLettersAndEmojiSurviveTheTitleCleaning(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
// Includes a ZWJ sequence (woman + ZWJ + laptop) and a ZWNJ: joiners are not direction controls.
rec := do(t, app, uploadReq(t, "Möte åäö 🎙️ 👩\u200d💻 a\u200cb", "a.m4a", []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 id = $1`, sub.jobs[0].UploadID).Scan(&title))
assert.Equal(t, "Möte åäö 🎙️ 👩\u200d💻 a\u200cb", title, "joiners and variation selectors belong to the text")
}
-31
View File
@@ -1,31 +0,0 @@
package web_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
func TestUploadPost_ABusyRejectionIs429WithItsMessage(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
app.Uploads = failingUploader{err: &uploads.RejectError{Reason: uploads.ReasonBusy, Message: "Too many recordings are waiting. Try again in a few minutes."}}
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")))
require.Equal(t, http.StatusTooManyRequests, rec.Code, "the user's to retry: not a 500, not a 422")
assert.Contains(t, body(t, rec), "Too many recordings are waiting")
}
func TestUploadStatus_ABusyFailureIsExplainedInWords(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "failed", "queue_full")
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "waiting")
assert.NotContains(t, b, "This upload failed. Please upload it again.")
}
@@ -31,7 +31,6 @@ func newUploadChatApp(t *testing.T) (*web.App, *fakeChatter) {
} }
app.Chat = fc app.Chat = fc
app.UploadChatMaxChars = 120000 app.UploadChatMaxChars = 120000
app.UploadChatModels = fc.models
return app, fc return app, fc
} }
@@ -1,73 +0,0 @@
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")
}
-140
View File
@@ -1,140 +0,0 @@
package web_test
// A transcript produced by the cloud fallback means this recording's audio left the
// homelab, which the reader is told in plain words (#52). "Local" is config.IsLocalModel:
// one definition, not a second string match in the view.
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setEndpoint(t *testing.T, id, endpoint string) {
t.Helper()
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET endpoint = $2 WHERE id = $1`, id, endpoint)
require.NoError(t, err)
}
func TestUploadStatus_ACloudServedTranscriptSaysTheAudioLeftTheHomelab(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
setEndpoint(t, id, "berget/kb-whisper")
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "left the homelab")
assert.Contains(t, b, "cloud service (berget)")
}
func TestUploadStatus_ALocallyServedTranscriptCarriesNoCloudNote(t *testing.T) {
for _, endpoint := range []string{"iguana/kb-whisper", "koala/whisper", ""} {
t.Run(endpoint, func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
setEndpoint(t, id, endpoint)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.NotContains(t, b, "left the homelab")
})
}
}
func TestUploadStatus_AnEndpointThatOnlyLooksLocalIsStillNamedAsCloud(t *testing.T) {
// The prefix alone must not decide it: IsLocalModel refuses path tricks.
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
setEndpoint(t, id, "iguana/../berget/kb-whisper")
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "left the homelab")
}
// The two-engine path (ADR-034) writes one composite endpoint string. Every name in it is
// a local model, so the audio never left the homelab and the note must not say it did.
func TestUploadStatus_AReconciledAllLocalEndpointCarriesNoCloudNote(t *testing.T) {
for _, endpoint := range []string{
"iguana/kb-whisper + koala/whisper, reconciled by iguana/gemma4-26b",
"iguana/kb-whisper + koala/whisper, reconciled by iguana/gemma4-26b\n",
} {
t.Run(endpoint, func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
setEndpoint(t, id, endpoint)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.NotContains(t, b, "left the homelab")
})
}
}
func TestUploadStatus_AReconciledEndpointNamingACloudEngineStillNamesTheCloudProvider(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
setEndpoint(t, id, "iguana/kb-whisper + berget/kb-whisper, reconciled by iguana/gemma4-26b")
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "cloud service (berget)")
}
// An alias without a provider prefix is not a local model, wherever it sits in the
// composite: it must never be dropped from the judgement (PR #58 re-review).
func TestUploadStatus_AReconciledEndpointWithAnUnprefixedAliasIsNotCalledLocal(t *testing.T) {
for _, endpoint := range []string{
"iguana/kb-whisper + whisper-1, reconciled by iguana/gemma4-26b",
"iguana/kb-whisper + whisper 1, reconciled by iguana/gemma4-26b",
"iguana/kb-whisper + berget / x, reconciled by iguana/gemma4-26b",
} {
t.Run(endpoint, func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
setEndpoint(t, id, endpoint)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "left the homelab")
assert.NotContains(t, b, "cloud service ()", "the note names a provider")
})
}
}
// "by", "reconciled" and "+" are connectors only inside the composite format. As a whole
// endpoint, or as an alias within the composite, they are model names and are judged.
func TestUploadStatus_AConnectorWordUsedAsAModelNameIsStillJudged(t *testing.T) {
for _, endpoint := range []string{
"by",
"reconciled",
"+",
"iguana/kb-whisper + by, reconciled by iguana/gemma4-26b",
"iguana/kb-whisper + reconciled, reconciled by iguana/gemma4-26b",
"iguana/kb-whisper + +, reconciled by iguana/gemma4-26b",
"/x", // no provider at all: the note must still name something
} {
t.Run(endpoint, func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
setEndpoint(t, id, endpoint)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "left the homelab")
assert.NotContains(t, b, "cloud service ()")
})
}
}
func TestUploadStatus_AReconcilerThatIsNotLocalNamesTheCloudProvider(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
setEndpoint(t, id, "iguana/kb-whisper + koala/whisper, reconciled by berget/mistral-large")
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "cloud service (berget)")
}
@@ -1,85 +0,0 @@
package web_test
// Deleting one recording (#37). Owner-scoped like every other upload route: another user's
// id is a 404 and the row survives; an upload still being processed is refused.
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func deleteReq(id string) *http.Request {
return httptest.NewRequest(http.MethodPost, "/uploads/"+id+"/delete", nil)
}
func uploadExists(t *testing.T, id string) bool {
t.Helper()
var n int
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT count(*) FROM uploaded_transcripts WHERE id = $1`, id).Scan(&n))
return n == 1
}
func TestUploadDelete_AnOwnerRemovesAFinishedOrFailedRecordingAndLandsOnTheList(t *testing.T) {
for _, status := range []string{"done", "failed"} {
t.Run(status, func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "Roadmap sync", status, "transcription_failed")
rec := do(t, app, deleteReq(id))
assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, "/uploads", rec.Header().Get("Location"))
assert.False(t, uploadExists(t, id))
})
}
}
func TestUploadDelete_AnotherUsersRecordingIs404AndSurvives(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
theirs := seedUpload(t, otherUserID, "Their private meeting", "done", meetingText)
rec := do(t, app, deleteReq(theirs))
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.True(t, uploadExists(t, theirs), "a non-owner's delete must not remove the row")
}
func TestUploadDelete_ARecordingStillBeingTranscribedIsRefusedWith409AndKept(t *testing.T) {
for _, status := range []string{"queued", "transcribing"} {
t.Run(status, func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", status, "")
rec := do(t, app, deleteReq(id))
assert.Equal(t, http.StatusConflict, rec.Code)
assert.Contains(t, body(t, rec), "still being transcribed")
assert.True(t, uploadExists(t, id))
})
}
}
func TestUploadDelete_ANonIDIsA404NotAServerError(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
assert.Equal(t, http.StatusNotFound, do(t, app, deleteReq("not-a-uuid")).Code)
}
func TestUploadPage_OffersDeleteOnlyForAFinishedOrFailedRecording(t *testing.T) {
for status, want := range map[string]bool{"done": true, "failed": true, "queued": false, "transcribing": false} {
t.Run(status, func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", status, "transcription_failed")
b := body(t, do(t, app, get("/uploads/"+id)))
assert.Equal(t, want, strings.Contains(b, `action="/uploads/`+id+`/delete"`))
})
}
}
@@ -1,126 +0,0 @@
package web_test
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// A client that hangs up in the middle of an upload is routine, not a server fault. The
// in-process recorder cannot see it (its context never cancels), so this talks to a real
// listener (third adversarial review: over a socket every such case logged an ERROR and
// half-close returned a 500, because the error page was rendered on a cancelled context).
// body positions at which the client can hang up: inside the title field, inside the file
// part's own headers, and mid-file. Every one must be handled alike (fourth adversarial
// review: the title branch had no guard and still logged an ERROR).
const (
inTitle = "in the title"
inHeaders = "in the file part's headers"
midFile = "mid-file"
)
func partialUpload(t *testing.T, addr, where string, hangUp func(*net.TCPConn)) {
t.Helper()
c, err := net.Dial("tcp", addr)
require.NoError(t, err)
tc := c.(*net.TCPConn)
const boundary = "xBOUNDARYx"
var body string
switch where {
case inTitle:
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nhalf a tit"
case inHeaders:
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filena"
default:
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.m4a\"\r\nContent-Type: audio/mp4\r\n\r\n" + strings.Repeat("a", 4000)
}
_, err = fmt.Fprintf(tc, "POST /uploads HTTP/1.1\r\nHost: x\r\nContent-Type: multipart/form-data; boundary=%s\r\nContent-Length: 200000\r\n\r\n%s", boundary, body)
require.NoError(t, err)
time.Sleep(50 * time.Millisecond) // let the server start reading
hangUp(tc)
}
func TestUploadPost_AClientThatHangsUpAnywhereInTheBodyIsLoggedAtInfoAndNeverAsAnError(t *testing.T) {
hangUps := map[string]func(*net.TCPConn){
"a half close": func(c *net.TCPConn) { _ = c.CloseWrite(); time.Sleep(100 * time.Millisecond); _ = c.Close() },
"a full close": func(c *net.TCPConn) { _ = c.Close() },
"a reset (RST)": func(c *net.TCPConn) { _ = c.SetLinger(0); _ = c.Close() },
}
for _, where := range []string{inTitle, inHeaders, midFile} {
for name, hangUp := range hangUps {
t.Run(where+", "+name, func(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
srv := httptest.NewServer(app.Router())
defer srv.Close()
partialUpload(t, strings.TrimPrefix(srv.URL, "http://"), where, hangUp)
require.Eventually(t, func() bool {
out := buf.String()
return strings.Contains(out, "upload abandoned") || strings.Contains(out, "upload rejected") || strings.Contains(out, "level=ERROR")
}, 3*time.Second, 20*time.Millisecond, "the server never finished handling the aborted upload")
time.Sleep(100 * time.Millisecond)
out := buf.String()
assert.NotContains(t, out, "level=ERROR", "a client hanging up is not a server fault:\n%s", out)
assert.Empty(t, sub.jobs)
})
}
}
}
// goneMidAccept is an Uploader during whose Accept the client disappears: the request's
// context is cancelled, then Accept fails the way a read from a dead connection does.
type goneMidAccept struct{ cancel context.CancelFunc }
func (g goneMidAccept) Accept(context.Context, string, string, domain.Language, io.Reader) (string, error) {
g.cancel()
return "", fmt.Errorf("receive: %w", io.ErrClosedPipe)
}
func TestUploadPost_AnAbandonedRequestGetsA499NotAnImplicitEmpty200(t *testing.T) {
// If the context is cancelled but the connection can still be written to (a client that
// half-closed after sending), a missing status becomes 200 with an empty body: a false
// success (fourth adversarial review).
app, _ := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app.Uploads = goneMidAccept{cancel: cancel}
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")).WithContext(ctx))
assert.Equal(t, 499, rec.Code, "never an implicit 200 for an upload that was not accepted")
assert.Contains(t, buf.String(), "upload abandoned")
assert.NotContains(t, buf.String(), "level=ERROR")
}
func TestUploadPost_ADatabaseDropThatLooksLikeAnEOFIsStillAServerError(t *testing.T) {
// pgx turns a connection that drops mid-message into io.ErrUnexpectedEOF. Mapping that
// sentinel to "your upload was cut off" (a 400) would hide a real fault: only an error
// from reading the CLIENT's body is theirs (third adversarial review).
app, _ := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
app.Uploads = failingUploader{err: fmt.Errorf("failed to receive message: %w", io.ErrUnexpectedEOF)}
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")))
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Contains(t, buf.String(), "level=ERROR")
}
+1 -124
View File
@@ -3,7 +3,6 @@ package web_test
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -16,7 +15,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads" "git.d-ma.be/mathias/tapir/internal/uploads"
"git.d-ma.be/mathias/tapir/internal/web" "git.d-ma.be/mathias/tapir/internal/web"
) )
@@ -133,7 +131,7 @@ func TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt(t *testing.T) {
assert.Equal(t, "Sprint review", title) assert.Equal(t, "Sprint review", title)
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location")) assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
require.Len(t, sub.jobs, 1) require.Len(t, sub.jobs, 1)
assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path, Language: domain.LangSwedish, Duration: audioOK.Duration}, sub.jobs[0]) 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) spooled, err := os.ReadFile(sub.jobs[0].Path)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "synthetic audio bytes", string(spooled)) assert.Equal(t, "synthetic audio bytes", string(spooled))
@@ -357,124 +355,3 @@ func TestUploadPost_ARejectionIsLoggedWithItsReasonAndNeverTheFilenameOrTitle(t
assert.NotContains(t, logged, "Secret project notes", "the title is private content") assert.NotContains(t, logged, "Secret project notes", "the title is private content")
assert.NotContains(t, logged, "client-acme-roadmap", "the filename 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":"Axel","alt":"Alex","used":"alt","unsure":false},
{"seconds":3400,"base":"A+2","alt":"en A","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, "Axel")
assert.Contains(t, b, "Alex")
assert.Contains(t, b, "~57 min", "3400 s")
assert.Contains(t, b, "A+2")
assert.Contains(t, b, "en A")
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":"<script>alert(1)</script>","alt":"ok","used":"base","unsure":true}]}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.NotContains(t, b, "<script>alert(1)</script>", "a reading is words from a recording, never markup")
assert.Contains(t, b, "&lt;script&gt;")
}
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")
}
func TestUploadStatus_ALengthFailureIsExplainedInWordsAndNotAsAGenericError(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
tests := []struct{ kind, want string }{
{"audio_too_long", "longer than the limit"},
}
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.")
})
}
}
@@ -1,151 +0,0 @@
package web_test
// The language of a recording is chosen on the upload form (tapir#61). These drive the real
// Router, Intake and Postgres store; the submitter is the process boundary and records the
// job the worker would receive.
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// langReq builds an upload whose parts come in the order given: "title", "language" and
// "file" (the file part always carries the same synthetic bytes).
func langReq(t *testing.T, order []string, language string) *http.Request {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for _, part := range order {
switch part {
case "title":
require.NoError(t, mw.WriteField("title", "m"))
case "language":
require.NoError(t, mw.WriteField("language", language))
case "file":
fw, err := mw.CreateFormFile("file", "m.wav")
require.NoError(t, err)
_, err = fw.Write([]byte("RIFF....WAVE"))
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 rowLanguage(t *testing.T, location string) domain.Language {
t.Helper()
id := strings.TrimPrefix(location, "/uploads/")
got, err := newStore(t).GetUpload(context.Background(), userID, id)
require.NoError(t, err)
return got.Language
}
func TestUploadPost_TheLanguageFieldReachesTheJobAndTheRow(t *testing.T) {
for _, tt := range []struct {
name string
language string
want domain.Language
}{
{"other", "auto", domain.LangAuto},
{"swedish", "sv", domain.LangSwedish},
} {
t.Run(tt.name, func(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, langReq(t, []string{"title", "language", "file"}, tt.language))
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Len(t, sub.jobs, 1)
assert.Equal(t, tt.want, sub.jobs[0].Language)
assert.Equal(t, tt.want, rowLanguage(t, rec.Header().Get("Location")))
})
}
}
func TestUploadPost_NoLanguageFieldIsSwedishAsBefore(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, langReq(t, []string{"title", "file"}, ""))
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Len(t, sub.jobs, 1)
assert.Equal(t, domain.LangSwedish, sub.jobs[0].Language)
assert.Equal(t, domain.LangSwedish, rowLanguage(t, rec.Header().Get("Location")))
}
// A part after the file is never read (the file part ends the stream), so a client that
// orders its fields wrongly gets Swedish, the old behaviour, not a guess.
func TestUploadPost_ALanguageFieldAfterTheFileIsNotReadAndTheUploadIsSwedish(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, langReq(t, []string{"title", "file", "language"}, "auto"))
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Len(t, sub.jobs, 1)
assert.Equal(t, domain.LangSwedish, sub.jobs[0].Language)
}
func TestUploadPost_ALanguageOutsideTheClosedSetIsRefusedBeforeAnythingIsSpooledOrStored(t *testing.T) {
for _, bad := range []string{"en", "SV", " sv", "sv,auto", "../auto", strings.Repeat("a", 5000)} {
t.Run(bad[:min(len(bad), 12)], func(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, langReq(t, []string{"title", "language", "file"}, bad))
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Empty(t, sub.jobs, "a refused post must not enqueue transcription")
rows, err := newStore(t).ListUploads(context.Background(), userID)
require.NoError(t, err)
assert.Empty(t, rows, "and must leave no row")
assert.Contains(t, body(t, rec), "Choose a language from the list.")
})
}
}
func TestUploadPage_TheFormOffersSwedishPreselectedAndOtherBeforeTheFilePart(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
b := body(t, do(t, app, get("/uploads")))
sel := strings.Index(b, `name="language"`)
file := strings.Index(b, `name="file"`)
require.True(t, sel >= 0, "the form has a language field")
assert.True(t, sel < file, "the field must come before the file part: the stream ends at the file")
assert.Regexp(t, `<option value="sv" selected`, b, "Swedish is preselected: today's behaviour")
assert.Contains(t, b, `<option value="auto"`)
}
func TestUploadPage_NamesTheLanguageInEveryState(t *testing.T) {
for _, status := range []string{"queued", "transcribing", "done", "failed"} {
for _, tt := range []struct {
language domain.Language
want string
}{
{domain.LangSwedish, "Swedish"},
{domain.LangAuto, "Other (detected by the engine)"},
} {
t.Run(status+"/"+string(tt.language), func(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", status, "Synthetic transcript text.")
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET language = $2 WHERE id = $1`, id, string(tt.language))
require.NoError(t, err)
b := body(t, do(t, app, get("/uploads/"+id)))
assert.Contains(t, b, "Language: "+tt.want)
})
}
}
}

Some files were not shown because too many files have changed in this diff Show More