Compare commits

..
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 12d1365607 test(web): the 499 test cancels the client mid-Accept, as a real hang-up does
CI / Lint / Test / Vet (pull_request) Successful in 27s
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-02 17:47:13 +02:00
mathiasandClaude Sonnet 5.5 9d7afb2bcb fix(web): the title-part hang-up is handled like the rest, and an abandoned request is answered 499, not an implicit 200 (fourth review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:47:13 +02:00
mathiasandClaude Sonnet 5.5 1266e18038 fix(uploads): every worker log carries a kind, including store and temp-file errors (fourth review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:47:13 +02:00
mathiasandClaude Sonnet 5.5 51ee5d48e5 feat(errkind): a database error is reduced to its SQLSTATE, never its message, which can quote a transcript value (fourth review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:47:13 +02:00
mathiasandClaude Sonnet 5.5 cff5cd2f0a test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 fc8ac376fa test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 73fe203d62 test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 fbc3ee61c1 test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 e97d4400b0 test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 6e50a97b6d docs(adr): third review -- the hang-up path, the client-read marker, the note kept visible
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-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 815ee98d4c test(web): the title input that proves a bidi character is dropped, not replaced
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 067620d06a chore(web): regenerate uploads_templ.go
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 91e475807c fix(web): a finished upload's record opens when a step carries a note (third review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 6d044ba403 fix(web): a client that hangs up is logged at INFO and not answered; the 400 follows the marker, not io.ErrUnexpectedEOF; hasNote (third review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 632dbeba0e feat(uploads): ErrClientRead marks an error from reading the client's body (third review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 fb5dc9d461 test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 272a5db75f test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 1b0fc668bc test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 375da83c5e test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 af4e9c8632 test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 04a3bc261f test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 08ffc2ff62 test(web): Go escapes for the bidirectional characters, which staticcheck ST1018 requires -- task check does not gate lint (#41)
CI / Lint / Test / Vet (pull_request) Successful in 25s
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-02 17:23:50 +02:00
mathiasandClaude Sonnet 5.5 e70e56a531 test(web): the render tool shows a step note
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:23:01 +02:00
mathiasandClaude Sonnet 5.5 b46a0dd0c9 docs(adr): second review -- the note instead of a refusal, room before spooling, the callable chat model, and the image that was actually built (ADR-035 decision 8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 fe6c0fae3a test(acceptance): follow the renamed scenario
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 2a616c6ae1 docs(use-cases): a short recording is noted, not refused
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 bcec4f6e86 docs(homelab): engine timeout 20m
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 be20adc76b chore(fixtures): an obviously invented context phrase (second review F10 residual)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 1b97851a39 chore(fixtures): an obviously invented context phrase (second review F10 residual)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 b59b1a22dd test(web): joiners and variation selectors survive the title cleaning
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 22c0317c71 fix(web): a body cut off mid-file is a 400; bidirectional controls are stripped from a title (second review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 9a2f24b3c9 fix(uploads): refuse a full queue before spooling, probing or creating a row (second review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 3f5b9345a0 feat(uploads): HasRoom -- say before spooling whether a submit would be accepted (second review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 16db156406 fix(config): the engine timeout defaults to 20 minutes so two wedged engines leave the job time for a fallback (second review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 471425cd4c fix(cmd): the configured upload chat model is in the shared chat service's list, so it is no longer silently swapped (second review finding 2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:22:41 +02:00
mathiasandClaude Sonnet 5.5 8f79132fcb test: second review -- the upload chat model outside the chain, room checked before spooling, a cut-off body is a 400, bidi controls in titles, the ensemble's deadline wiring, an honest test name (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:38 +02:00
mathiasandClaude Sonnet 5.5 b1f9d5f4b7 test: second review -- the upload chat model outside the chain, room checked before spooling, a cut-off body is a 400, bidi controls in titles, the ensemble's deadline wiring, an honest test name (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:38 +02:00
mathiasandClaude Sonnet 5.5 5bd2e3d3d0 test: second review -- the upload chat model outside the chain, room checked before spooling, a cut-off body is a 400, bidi controls in titles, the ensemble's deadline wiring, an honest test name (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:38 +02:00
mathiasandClaude Sonnet 5.5 b8a6e2f6f5 test: second review -- the upload chat model outside the chain, room checked before spooling, a cut-off body is a 400, bidi controls in titles, the ensemble's deadline wiring, an honest test name (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:38 +02:00
mathiasandClaude Sonnet 5.5 335ad0cd85 test: second review -- the upload chat model outside the chain, room checked before spooling, a cut-off body is a 400, bidi controls in titles, the ensemble's deadline wiring, an honest test name (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:38 +02:00
mathiasandClaude Sonnet 5.5 59bc373068 test: second review -- the upload chat model outside the chain, room checked before spooling, a cut-off body is a 400, bidi controls in titles, the ensemble's deadline wiring, an honest test name (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:38 +02:00
mathiasandClaude Sonnet 5.5 d505f64c99 test: second review -- the upload chat model outside the chain, room checked before spooling, a cut-off body is a 400, bidi controls in titles, the ensemble's deadline wiring, an honest test name (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:38 +02:00
mathiasandClaude Sonnet 5.5 dbe37e5756 feat(web): style for a step note
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:02 +02:00
mathiasandClaude Sonnet 5.5 0962b6f639 chore(web): regenerate uploads_templ.go
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:02 +02:00
mathiasandClaude Sonnet 5.5 9314454ab1 feat(web): show a step's note under it, escaped (second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:02 +02:00
mathiasandClaude Sonnet 5.5 9c8ac0f9fb fix(web): no message for the removed incomplete kind (second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:02 +02:00
mathiasandClaude Sonnet 5.5 f7284ccdc9 fix(uploads): a file much shorter than it claimed is transcribed and noted, not refused; the tolerances are named (second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:02 +02:00
mathiasandClaude Sonnet 5.5 4943c80dbd feat(progress): Annotate -- a note on a step, saved at once (second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:02 +02:00
mathiasandClaude Sonnet 5.5 8eca8f721b feat(domain): ProgressStep.Note; UploadErrIncomplete removed -- a short file is noted, not refused (second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:20:02 +02:00
mathiasandClaude Sonnet 5.5 406c0232c1 test: a shortfall is noted on the audio step, not refused; the 80%, two-minute and 30-second tolerances are pinned (red; second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:19:32 +02:00
mathiasandClaude Sonnet 5.5 ac530de91a test: a shortfall is noted on the audio step, not refused; the 80%, two-minute and 30-second tolerances are pinned (red; second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:19:32 +02:00
mathiasandClaude Sonnet 5.5 a90107b73b test: a shortfall is noted on the audio step, not refused; the 80%, two-minute and 30-second tolerances are pinned (red; second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:19:32 +02:00
mathiasandClaude Sonnet 5.5 0ef83f2ccb test: a shortfall is noted on the audio step, not refused; the 80%, two-minute and 30-second tolerances are pinned (red; second review finding 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:19:32 +02:00
mathiasandClaude Sonnet 5.5 71cd50aa88 fix(docker): install xz in the ffmpeg stage -- the golang image has none; found by building the image, which CI does not do on a pull request (second review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:19:06 +02:00
mathiasandClaude Sonnet 5.5 aa54089230 docs(claude): errors are logged and shown as kinds, and what carries transcript text must be local (ADR-035)
CI / Lint / Test / Vet (pull_request) Successful in 25s
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-02 16:50:56 +02:00
mathiasandClaude Sonnet 5.5 8749ffe6a9 docs(homelab): the engine timeout, a local chat model, ffmpeg in the image, and 31B with Low Power Mode off
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:50:56 +02:00
mathiasandClaude Sonnet 5.5 67879f85eb test(acceptance): map the hardening scenarios, and follow the two tests renamed in the review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:50:43 +02:00
mathiasandClaude Sonnet 5.5 08b7654d06 docs(use-cases): scenarios for the review's hardening (ADR-035 decision 8) -- the coverage gate is red until mapped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:50:35 +02:00
mathiasandClaude Sonnet 5.5 f03b0213d0 docs(adr): record the owner's decisions on #34, #35 and ADR-034, and the adversarial review's hardening (ADR-035 decision 8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:50:22 +02:00
mathiasandClaude Sonnet 5.5 cb22832413 feat(docker): static ffmpeg and ffprobe in the distroless image, pinned by version and sha256 (#34 option A)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:50:02 +02:00
mathiasandClaude Sonnet 5.5 5d4324c2a2 chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 6596e83cd5 chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 bc90e3aec7 chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 1a57489d04 chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 ede6c0e5ea chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 5928925589 chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 e5bba3cd23 chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 2df8a7eb20 chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 6e7c772b31 chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 a2027c9f9b chore(fixtures): invented text in place of words that may come from the private recording; the research note no longer names the private file (adversarial review F10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:49:54 +02:00
mathiasandClaude Sonnet 5.5 e38ea3175d test: assertions the adversarial review found missing -- log lines, immediate saves, snapshot isolation, store and temp-file failures, bounded snippet, identical list (review F9)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:48:58 +02:00
mathiasandClaude Sonnet 5.5 cdb709b9e2 test: assertions the adversarial review found missing -- log lines, immediate saves, snapshot isolation, store and temp-file failures, bounded snippet, identical list (review F9)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:48:58 +02:00
mathiasandClaude Sonnet 5.5 0d8bf0f94c test: assertions the adversarial review found missing -- log lines, immediate saves, snapshot isolation, store and temp-file failures, bounded snippet, identical list (review F9)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:48:58 +02:00
mathiasandClaude Sonnet 5.5 5b3e550d9b test: assertions the adversarial review found missing -- log lines, immediate saves, snapshot isolation, store and temp-file failures, bounded snippet, identical list (review F9)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:48:58 +02:00
mathiasandClaude Sonnet 5.5 cb0ed8642a test: assertions the adversarial review found missing -- log lines, immediate saves, snapshot isolation, store and temp-file failures, bounded snippet, identical list (review F9)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:48:58 +02:00
mathiasandClaude Sonnet 5.5 b8a4778513 test(web): a malformed multipart header is a 400 whose text is not logged; the file-name case no longer demands a 303
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:47:49 +02:00
mathiasandClaude Sonnet 5.5 9b82c06d92 fix(web): 429 for a busy queue; a non-UUID id and a malformed or odd-byte title are 4xx or cleaned, never 500 (review F6, F7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:47:49 +02:00
mathiasandClaude Sonnet 5.5 93b4dbec1e feat(uploads): a full queue is a busy rejection, the row failed honestly (review F6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:47:49 +02:00
mathiasandClaude Sonnet 5.5 f9ad85cac4 feat(uploads): a bounded queue, in total and per user (review F6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:47:49 +02:00
mathiasandClaude Sonnet 5.5 a1d6754ae9 feat(uploads): ReasonBusy, a rejection that is the user's to retry (review F6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:47:49 +02:00
mathiasandClaude Sonnet 5.5 e4584fb2ac feat(domain): UploadErrBusy -- the queue was full (review F6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:47:49 +02:00
mathiasandClaude Sonnet 5.5 019ee636c8 test: a bounded queue answered as 'busy', and bad input answered with a 4xx (red; adversarial review F6, F7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:46:13 +02:00
mathiasandClaude Sonnet 5.5 0da24d42f4 test: a bounded queue answered as 'busy', and bad input answered with a 4xx (red; adversarial review F6, F7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:46:13 +02:00
mathiasandClaude Sonnet 5.5 2891ffabae test: a bounded queue answered as 'busy', and bad input answered with a 4xx (red; adversarial review F6, F7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:46:13 +02:00
mathiasandClaude Sonnet 5.5 aa0322095b test: a bounded queue answered as 'busy', and bad input answered with a 4xx (red; adversarial review F6, F7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:46:13 +02:00
mathiasandClaude Sonnet 5.5 a7e2cc43c0 test(cmd): the built worker refuses audio longer than the configured maximum (review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 fdcdee7413 test(cmd): the uploads store double records the kind a job failed with
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 dc100d1cd4 test(web): the job carries the claimed duration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 6b83d72655 fix(cmd): the converter is capped and the worker measures, at the configured maximum (review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 07050cf9b6 feat(web): say in words why a recording was refused for its real length (review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 16cd246241 feat(uploads): the worker measures the converted audio against the limit and the claim (review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 132f46b047 feat(uploads): the job carries the duration the file claimed (review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 12576aa129 feat(audio): MaxSeconds caps the converted length, so a header that lies cannot become hours of WAV (review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 ba39864b2b feat(domain): UploadErrTooLong and UploadErrIncomplete -- the two ways a file's claimed length can be wrong (review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:25 +02:00
mathiasandClaude Sonnet 5.5 741eccb575 test: the duration limit is checked against what is converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:43:46 +02:00
mathiasandClaude Sonnet 5.5 5640d95f40 test: the duration limit is checked against what is converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:43:46 +02:00
mathiasandClaude Sonnet 5.5 8f62d053ee test: the duration limit is checked against what is converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:43:46 +02:00
mathiasandClaude Sonnet 5.5 52351c2cc1 test: the duration limit is checked against what is converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:43:46 +02:00
mathiasandClaude Sonnet 5.5 29b3b725d1 test: the duration limit is checked against what is converted, not what the header claims (red; adversarial review F2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:43:46 +02:00
mathiasandClaude Sonnet 5.5 7a468fdc09 fix(cmd): every local engine call carries its own deadline (review F4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:42:46 +02:00
mathiasandClaude Sonnet 5.5 c9b7df9f95 feat(config): TAPIR_TRANSCRIBE_ENGINE_TIMEOUT, default 25 minutes (review F4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:42:46 +02:00
mathiasandClaude Sonnet 5.5 c8ec468e47 feat(transcribe): WithTimeout -- each engine call gets its own deadline so a wedged engine fails while the job lives (review F4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:42:46 +02:00
mathiasandClaude Sonnet 5.5 81a60d5fd4 test: a per-engine deadline so a wedged primary reaches the fallback (red; adversarial review F4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:41:38 +02:00
mathiasandClaude Sonnet 5.5 c8c7356f19 test: a per-engine deadline so a wedged primary reaches the fallback (red; adversarial review F4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:41:38 +02:00
mathiasandClaude Sonnet 5.5 8d1b7d887f test: a per-engine deadline so a wedged primary reaches the fallback (red; adversarial review F4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:41:38 +02:00
mathiasandClaude Sonnet 5.5 21b337702a test: a per-engine deadline so a wedged primary reaches the fallback (red; adversarial review F4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:41:38 +02:00
mathiasandClaude Sonnet 5.5 d16c2998d0 test(reconcile): the stored reason is a kind -- the old assertion encoded the leak the review found
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:55 +02:00
mathiasandClaude Sonnet 5.5 fb79dbb045 fix(uploads.go): an error reaches the log, the row and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:55 +02:00
mathiasandClaude Sonnet 5.5 a2587eae9b fix(worker.go): an error reaches the log, the row and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:55 +02:00
mathiasandClaude Sonnet 5.5 0b35278cdf fix(transcribe): an error reaches the log, the row and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:55 +02:00
mathiasandClaude Sonnet 5.5 16a8833f79 fix(reconcile.go): an error reaches the log, the row and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:55 +02:00
mathiasandClaude Sonnet 5.5 03a122c9f5 fix(tracker.go): an error reaches the log, the row and the page as a kind, never its text (adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:55 +02:00
mathiasandClaude Sonnet 5.5 31f5a74d2a test(web): the upload routes' error paths log a kind, not the text (red; review F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:10 +02:00
mathiasandClaude Sonnet 5.5 166ff849c0 test(uploads): a failure logs a kind, never the error text or the spool path (red; review F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:10 +02:00
mathiasandClaude Sonnet 5.5 e9863c2e12 test(progress): a keyed failure logs its kind, not the error's text (red; review F3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:10 +02:00
mathiasandClaude Sonnet 5.5 8f3300b377 test(transcribe): a failed engine's reason is a kind, not its error text (red; review F3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:10 +02:00
mathiasandClaude Sonnet 5.5 f6c3f2af23 test(reconcile): the skipped reason carries a kind, not the model's text (red; review F3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:40:10 +02:00
mathiasandClaude Sonnet 5.5 2d1b08458b feat(transcribe): StatusError.HTTPStatus -- its code, never its snippet, becomes the kind (review F3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:39:38 +02:00
mathiasandClaude Sonnet 5.5 2608ad2f09 feat(llm): HTTPError -- a status code to branch on, a bounded body in the text (review F3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:39:38 +02:00
mathiasandClaude Sonnet 5.5 81f3d7ffc6 test(transcribe): a StatusError exposes its code as a kind, not its snippet (red; adversarial review F3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:39:25 +02:00
mathiasandClaude Sonnet 5.5 8c82d67b12 test(llm): a non-200 is an HTTP status error whose kind carries no body, and the body is bounded (red; adversarial review F3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:39:25 +02:00
mathiasandClaude Sonnet 5.5 d8eea66c4c feat(errkind): Of -- a kind from a fixed vocabulary, never the error's text (review F3, F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:39:10 +02:00
mathiasandClaude Sonnet 5.5 dc0c64ddeb test(errkind): an error reduced to a safe kind, never its text (red; adversarial review F3, F5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:39:04 +02:00
mathiasandClaude Sonnet 5.5 99ac5580e0 test(web): the upload chat helper says which models are offered
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:38:30 +02:00
mathiasandClaude Sonnet 5.5 ef815edded fix(web): upload chat offers and accepts local models only and fails closed; a forged cloud model is replaced before anything is sent (review F1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:38:30 +02:00
mathiasandClaude Sonnet 5.5 4933504982 feat(web): App.UploadChatModels -- the models upload chat offers and accepts (review F1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:38:30 +02:00
mathiasandClaude Sonnet 5.5 e121ef241d fix(cmd): wire the local-only chat models into the upload routes (review F1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:38:30 +02:00
mathiasandClaude Sonnet 5.5 5ae9212869 feat(cmd): uploadChatModels -- the chat switcher for a private recording is local models only (review F1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:38:30 +02:00
mathiasandClaude Sonnet 5.5 a9a2a9270f fix(config): a strict shape for a local model alias; the upload chat model must be local (adversarial review F1, F8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:38:30 +02:00
mathiasandClaude Sonnet 5.5 7fe746173d test(web): the upload chat offers and accepts local models only, and fails closed (red; adversarial review F1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:37:49 +02:00
mathiasandClaude Sonnet 5.5 4b16e33715 test(cmd): the upload chat switcher never offers a cloud model (red; adversarial review F1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:37:49 +02:00
mathiasandClaude Sonnet 5.5 896ce6a952 test(config): a strict shape for a local model alias, and the upload chat model must be local (red; adversarial review F1, F8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:37:49 +02:00
mathiasandClaude Sonnet 5.5 7029dd1d7f test(e2e): a finished job's phases are all closed and it was reporting while it ran (ADR-035)
CI / Lint / Test / Vet (pull_request) Successful in 23s
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-02 09:05:36 +02:00
mathiasandClaude Sonnet 5.5 c977618979 docs(claude): the progress tracker, the two timestamps, and the log's privacy rule (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:04:32 +02:00
mathiasandClaude Sonnet 5.5 0763e6238b docs(data-model): progress, progress_at, alive_at, and every judged spot stored (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:04:32 +02:00
mathiasandClaude Sonnet 5.5 6722433eb0 test(acceptance): map the progress, log and judged-spot scenarios to their covering tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:04:23 +02:00
mathiasandClaude Sonnet 5.5 ac75971030 docs(use-cases): scenarios for progress, the log and the judged spots (ADR-035) -- the coverage gate is red until they are mapped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:04:19 +02:00
mathiasandClaude Sonnet 5.5 02a065b826 test(web): opt-in renderer of every progress state for a screenshot review (TAPIR_SHOT_DIR)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:03:55 +02:00
mathiasandClaude Sonnet 5.5 105777f1aa fix(web): the spinner sits in the same mark column as the other glyphs; a lost job's running phase gets a warning mark (screenshot review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:03:55 +02:00
mathiasandClaude Sonnet 5.5 18d8050a2b fix(web): the progress bar ends at the time column and the lost mark is error-coloured (screenshot review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:03:55 +02:00
mathiasandClaude Sonnet 5.5 c06d285a3a test(web): the lost job's running phase carries a warning mark (red; found in the screenshot review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:03:32 +02:00
mathiasandClaude Sonnet 5.5 f74fbbc40e test(cmd): the uploads store double has the progress methods the worker now needs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:01:37 +02:00
mathiasandClaude Sonnet 5.5 cd7595a048 feat(web): a spot shows the words before it, and an empty list shows no section (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:01:21 +02:00
mathiasandClaude Sonnet 5.5 24d1a99ed0 feat(web): list only the spots to check, in order and capped (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:01:21 +02:00
mathiasandClaude Sonnet 5.5 8cb0b0a12d test(web): the kept-spot assertion looks for what the page actually renders (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:00:56 +02:00
mathiasandClaude Sonnet 5.5 6dfd53ac71 test(web): the page lists only changed and uncertain spots, with the words before each, though every judged spot is stored (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:00:49 +02:00
mathiasandClaude Sonnet 5.5 d62331b5f9 test(web): the quiet note is capitalised as the page writes it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:00:23 +02:00
mathiasandClaude Sonnet 5.5 a7668b670e feat(web): the upload page shows the job's phases, a bar only where the count is real, and says when the service is lost (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:00:23 +02:00
mathiasandClaude Sonnet 5.5 89c9dd6705 feat(web): tell running from quiet from lost; poll every two seconds (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:00:23 +02:00
mathiasandClaude Sonnet 5.5 d1dcb131f2 feat(web): progress styles in tapir's own tokens -- a mono step log, a CSS spinner that respects reduced motion (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:00:23 +02:00
mathiasandClaude Sonnet 5.5 d0f2dcf838 test(web): the page shows real phases, tells running from quiet from lost, and never invents a percentage (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:57:58 +02:00
mathiasandClaude Sonnet 5.5 a97598586c test(reconcile): a span across a segment edge leaves neither segment holding a replaced word -- a mutation that skipped this survived
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:56:53 +02:00
mathiasandClaude Sonnet 5.5 976aeb71c6 feat(reconcile): report the comparison and deciding phases, store every judged spot, merge the words into the segments (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:56:53 +02:00
mathiasandClaude Sonnet 5.5 f74ad726e2 feat(domain): a judged spot carries the few words before it (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:56:53 +02:00
mathiasandClaude Sonnet 5.5 9297f728c2 test(reconcile): every judged spot with its left context; segments rewritten per segment, or left alone when they do not line up (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:55:43 +02:00
mathiasandClaude Sonnet 5.5 520ab666f8 test(reconcile): the comparison and the deciding phase report real counts, and a shutdown is neither done nor failed (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:55:43 +02:00
mathiasandClaude Sonnet 5.5 0b7bf08c95 test(reconcile): kept spots are stored, segments carry the merged words, the cap is an option -- three old assertions changed on purpose (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:55:43 +02:00
mathiasandClaude Sonnet 5.5 aefc8103f7 feat(transcribe): the ensemble declares and reports its phases; a failed engine shows failed, a shutdown does not (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:54:31 +02:00
mathiasandClaude Sonnet 5.5 f064dec185 test(transcribe): the ensemble declares and reports its phases; a failed engine is shown failed, a shutdown is not (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:54:07 +02:00
mathiasandClaude Sonnet 5.5 c6319b9bc6 refactor(uploads): the worker's own failure is FailRunning
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:53:43 +02:00
mathiasandClaude Sonnet 5.5 5f279d7e71 feat(progress): keyed Fail for a soft failure, Note for the operator, FailRunning for the job's own (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:53:43 +02:00
mathiasandClaude Sonnet 5.5 a6b8a2b744 test(progress): a keyed soft failure that logs why, a note that costs no write, and FailRunning for the job's own failure (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:53:33 +02:00
mathiasandClaude Sonnet 5.5 9393c27cdb test(uploads): the worker waits for its heartbeat to finish -- a mutation that skipped the wait survived until now
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:53:10 +02:00
mathiasandClaude Sonnet 5.5 538ed93ccd test(uploads): a slow heartbeat write, so one is in flight when the job ends
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:53:10 +02:00
mathiasandClaude Sonnet 5.5 27a88a0a1f feat(uploads): the worker reports real phases, beats on its own, and logs each one (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:53:10 +02:00
mathiasandClaude Sonnet 5.5 eb4d4b67e6 test(uploads): the worker reports real phases, beats on its own, and logs them in order without the transcript (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:52:20 +02:00
mathiasandClaude Sonnet 5.5 3f66c35497 test(uploads): the worker's store double records progress saves and heartbeats
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:52:20 +02:00
mathiasandClaude Sonnet 5.5 f8526d4549 feat(store): SaveProgress and Heartbeat; Get returns the phases and both timestamps (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:51:39 +02:00
mathiasandClaude Sonnet 5.5 41ebba3963 feat(store): migration 020 -- uploaded_transcripts.progress, progress_at, alive_at (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:51:11 +02:00
mathiasandClaude Sonnet 5.5 256e5b1a66 test(store): progress and heartbeat on an upload row -- round trip, alive is not progress, another user's write changes nothing (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:51:04 +02:00
mathiasandClaude Sonnet 5.5 8ec43ad393 test(progress): read the log buffer safely while the heartbeat writes to it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:50:39 +02:00
mathiasandClaude Sonnet 5.5 4bfe578a05 feat(progress): Tracker -- one writer for the page and the log, throttled counts, a heartbeat of its own (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:50:39 +02:00
mathiasandClaude Sonnet 5.5 c9ebe526cc feat(domain): progress steps and the two timestamps that tell alive from progressing (ADR-035)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:50:39 +02:00
mathiasandClaude Sonnet 5.5 7bf5f6b8b7 test(progress): a tracker that shows the plan, times each step, throttles counts and heartbeats on its own (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:49:44 +02:00
mathiasandClaude Sonnet 5.5 7e9f5d7ca5 docs(adr): ADR-035 -- progress as real phases, the same transitions in the log, every judged spot kept (#42 #43 #44)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:49:06 +02:00
mathiasandClaude Sonnet 5.5 1a89eb3868 docs(adr-034): record the first blind check; bar ambiguity and the unanswered second condition
CI / Lint / Test / Vet (pull_request) Successful in 23s
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-02 08:01:31 +02:00
mathiasandClaude Sonnet 5.5 2e9d76dad0 docs(research): first blind check of two-engine reconcile on a real recording (aggregates only)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 08:01:31 +02:00
mathiasandClaude Sonnet 5.5 7f8e3677b3 docs(adr-034): gemma4-31b slowness explained; reopen the 26B-vs-31B reconciler choice
CI / Lint / Test / Vet (pull_request) Successful in 22s
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-02 07:13:10 +02:00
mathiasandClaude Sonnet 5.5 e3aa661cf2 docs(research): Low Power Mode confirmed as the cause of slow gemma4-31b (22.3 vs 7.9 tok/s decode)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 07:13:10 +02:00
mathiasandClaude Sonnet 5.5 7ae888a468 docs(claude): two-engine reconciliation, and that task check does not gate lint (#41)
CI / Lint / Test / Vet (pull_request) Successful in 21s
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 23:27:47 +02:00
mathiasandClaude Sonnet 5.5 b9e0dfa1c9 docs(homelab): two-engine mode, its settings and why every model must be local
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:27:47 +02:00
mathiasandClaude Sonnet 5.5 8dbcb9a9eb docs(data-model): reconciliation column
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:27:47 +02:00
mathiasandClaude Sonnet 5.5 26cc00c1cc test(acceptance): map the two-engine scenarios to their covering tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:27:37 +02:00
mathiasandClaude Sonnet 5.5 36a80ac4f5 docs(use-cases): scenarios for two-engine reconciliation (ADR-034)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:27:22 +02:00
mathiasandClaude Sonnet 5.5 2497554683 docs(adr): ADR-034 records the first live smoke, including the wrong call
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:27:22 +02:00
mathiasandClaude Sonnet 5.5 c3c9b1e48a test(e2e): two engines + local reconciler, live
TAPIR_E2E_RECONCILE=1 runs pianissimo + kb-whisper + gemma4-26b on the audio and
asserts the reconciliation is recorded, every contributor is named in the endpoint, and
the page says what was done. Run 2026-10-01 on 82 s of synthetic Swedish: 3 disputes, all
3 changed; against the known script 2 were right (virket, a missing 'procent') and 1 was
WRONG (Maria -> Vi replaced a correct base reading). A smoke test, not a measurement.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:27:22 +02:00
mathiasandClaude Sonnet 5.5 ed8ca54674 feat(cmd): buildTranscriber -- the base-engine setting turns on the two-engine ensemble
Default is the single-engine chain exactly as before. With TAPIR_RECONCILE_BASE_MODEL set,
the base engine and the primary run on the same WAV and a LOCAL model (config has already
refused any non-local alias) reconciles them, at temperature 0 with a short reply -- the
conditions its accuracy was measured under. The cloud fallback is unchanged and runs
only when both local engines fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:26:02 +02:00
mathiasandClaude Sonnet 5.5 7338d9d603 test(e2e): discard the deferred Close error golangci-lint flags
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:26:02 +02:00
mathiasandClaude Sonnet 5.5 3d755fd57a fix(transcribe): check (discard) the deferred Close errors golangci-lint flags
Found because the project's lint step cannot fail: the Taskfile runs
'golangci-lint run ./... || echo "not installed -- skipping"', so a failing lint
prints that message and exits 0. CI's log showed these findings and still passed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:26:02 +02:00
mathiasandClaude Sonnet 5.5 b492a3144e test(cmd): the base-engine setting turns on the ensemble, and nothing else does (red)
Red: buildTranscriber is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:25:10 +02:00
mathiasandClaude Sonnet 5.5 505670c0e1 feat(llm): WithTemperature, default unchanged
Zero is sent as zero (the field has no omitempty). The reconciler's measured accuracy
was at temperature 0, so it runs there; every other caller keeps 0.2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:25:10 +02:00
mathiasandClaude Sonnet 5.5 7891675393 test(llm): a temperature option; zero is sent as zero, not 'unset' (red)
The reconciler was measured at temperature 0 but the client hardcodes 0.2. Zero must
be sent explicitly, and the default must not change for existing callers.

Red: WithTemperature is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:24:54 +02:00
mathiasandClaude Sonnet 5.5 8110fe56ab style(config): gofumpt the new test rows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:24:38 +02:00
mathiasandClaude Sonnet 5.5 61fb50ce99 feat(config): two-engine reconciliation settings with a local-only guard (ADR-034)
TAPIR_RECONCILE_BASE_MODEL turns it on and needs TAPIR_RECONCILE_MODEL; every model must
be a local alias (koala/ or iguana/, matched as a PREFIX): a cloud adjudicator is a
start-up error because private meeting content must not reach it; the two engines must
differ; default job timeout 60m. Off by default. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:24:34 +02:00
mathiasandClaude Sonnet 5.5 f25fdaf398 test(config): a cloud alias that merely contains a local host name is still cloud
Mutation-testing showed a substring match instead of a prefix match went unnoticed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:24:34 +02:00
mathiasandClaude Sonnet 5.5 ad071d44cb test(config): reconciliation settings -- off by default, local only, complete (red)
Off by default; a base engine needs an adjudicator; every model must be a local alias
(koala/ or iguana/): a cloud adjudicator is a start-up error because private meeting
content must not reach it; the base engine must differ from the other engine; default
job timeout 60m (two engines plus adjudication).

Red: the Config fields do not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:24:20 +02:00
mathiasandClaude Sonnet 5.5 59f2893434 docs(adr): ADR-034 names the settings for what they are
TAPIR_RECONCILE_BASE_MODEL, not 'second model': the engine added to turn the
feature on is the one whose TEXT is the base, and the old name hid that. All three
models must be local aliases; the base must differ from the other engine. ADR is
still Proposed, so this is an in-place correction.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:24:09 +02:00
mathiasandClaude Sonnet 5.5 6fb7a52980 feat(web): say what reconciliation did, and never claim a second opinion that was not there
CI / Lint / Test / Vet (pull_request) Successful in 21s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
Reconciled: the two engines, the deciding model and the exact counts; the contested
spots (changed or uncertain) listed with a minute mark and both readings, the first
50 shown and the rest counted from the exact totals. Single-engine and
model-unavailable results say why. A nil reconciliation renders nothing. Readings are
escaped. Mutation-checked: the cap, the minute rounding and the 'more' count.

uploads.go and the templ pair are one commit: the helpers and the template need each other.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:23:39 +02:00
mathiasandClaude Sonnet 5.5 d6ba11d288 test(web): the page says what reconciliation did, plainly, and never claims more (red)
Reconciled: who the two engines and the deciding model were, and the counts
(differences, settled automatically, decided by the model, uncertain). The contested
spots are listed with a minute mark, both readings and which was used, uncertain ones
marked, readings escaped, and a very long list shortened with its true count. A
single-engine transcript makes no claim of a second opinion; one-engine and
model-unavailable cases say why.

Red: the page renders none of this yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:23:00 +02:00
mathiasandClaude Sonnet 5.5 ae4f9fd775 feat(store): persist and read back the reconciliation
CompleteUpload writes it (SQL NULL when there was none, never the JSON text 'null':
the page claims a second opinion only if the column is non-NULL); GetUpload reads it,
List does not. Mutation-checked.

domain.go and the store are one commit: the store tests need the new field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:22:09 +02:00
mathiasandClaude Sonnet 5.5 e838f6bb6d feat(store): migration 019 -- uploaded_transcripts.reconciliation
JSONB, NULL for a single engine so the page cannot claim a second opinion. On the
already FORCE'd, RLS-scoped table, so the exact-set isolation test is unaffected.
Up and down are one commit: golang-migrate pairs them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:21:52 +02:00
mathiasandClaude Sonnet 5.5 5bd41077af test(store): the reconciliation survives the round trip, and its absence stays absent (red)
Counts and contested spots round-trip; a single-engine transcript has NO
reconciliation (nil, not empty -- the page must not claim a second opinion); a
skipped reconciliation keeps its reason.

Red: Transcription.Reconciliation is stored nowhere and UploadedTranscript has none.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:21:36 +02:00
mathiasandClaude Sonnet 5.5 34bc8dbdee feat(transcribe): Ensemble -- two local engines, a local reconciler, fail-soft at every step
Runs the fast engine then the other from one WAV and reconciles; one engine down
uses the other and says so; the model down gives the base text unreconciled; the
cloud fallback runs only when BOTH local engines fail and is recorded as such; a
cancelled job stops everything and never reaches the cloud. The endpoint lists every
contributor. Mutation-checked, including both cancellation points.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:21:17 +02:00
mathiasandClaude Sonnet 5.5 8778376aa6 test(transcribe): a shutdown in the middle of either engine stops the job
Mutation-testing showed each of the two cancellation checks was redundant with the
other in my first test. Now a cancel during the first engine must not start the second
or the cloud, and a cancel during the second must not be recorded as a single-engine
success ('kb-whisper failed').

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:21:17 +02:00
mathiasandClaude Sonnet 5.5 ad39b6154c test(transcribe): two engines, then a reconciler -- and every way it can fail softly (red)
Both run in order (the fast one first) and are reconciled; identical texts cost no
model call; one engine down uses the other and says so, with the cloud fallback
untouched; only when BOTH local engines fail does the fallback run, and the row says
berget served it; both failing with no fallback reports both errors; a cancelled
job never reaches the cloud; a model that is down yields the base text unreconciled,
not a failed job.

Red: transcribe.Ensemble is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 23:20:40 +02:00
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
90 changed files with 8840 additions and 209 deletions
+17 -1
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`,
timedtext/InnerTube acquisition per ADR-010), `summarizer` + `llm` (the copied AI router,
now a resilient endpoint chain — local primary → local fallback → external worst-case,
parse-failure-aware, ADR-004 + ADR-022), `store` (Postgres, golang-migrate migrations 001–017),
parse-failure-aware, ADR-004 + ADR-022), `store` (Postgres, golang-migrate migrations 001–019),
`secrets` (file-backed `SecretStore`). The brain HTTP sink (ADR-005) is the remaining
optional sink.
- Stage 1 is open (ADR-012): multi-user with **DB-enforced** isolation — Postgres RLS `FORCE`d
@@ -118,6 +118,22 @@ The repo is **green and shipping** — last tag `v0.15.0`. `task check` passes (
in `uploaded_transcripts`; the audio is never retained; chat reuses `chat.Service`. The
isolation test pins the **exact set** of non-RLS tables (`TestRowSecurityOffTablesAreExactlyTheKnownSet`).
The runtime image does not yet contain ffmpeg — that is a pending decision recorded in ADR-033.
Two-engine reconciliation (ADR-034, migration 019, off unless `TAPIR_RECONCILE_BASE_MODEL`):
`internal/reconcile` (word diff, disputes, a local-model adjudicator) and
`transcribe.Ensemble`; every model must be a local alias, enforced at start-up.
Progress and logging (ADR-035, migration 020): `internal/progress.Tracker` is the ONE writer of a
job's phases (saved to the row for the page, logged by the same call); engines and the reconciler
report through `progress.From(ctx)`, so `uploads.Transcriber` is unchanged, and a transcriber may
implement `uploads.Planner` to declare its own phases. The page tells running / quiet / lost from
two timestamps (`alive_at` heartbeat vs `progress_at`) and never shows a percentage that does not
come from real counts. **Nothing logged may carry transcript text, words from it, or the spool path.**
Errors reach a log, a row or a page as `errkind.Of(err)` (a kind such as `timeout` or `http_502`),
never `err.Error()`: error text can hold a provider's response body, the spool path or transcript
words (ADR-035 decision 8). Local-only for anything that carries transcript text: `config.IsLocalModel`.
`TAPIR_SHOT_DIR=… go test -run TestRenderUploadProgressStatesForReview ./internal/web` renders every
state for a screenshot review.
**Lint caveat:** `task check` does not fail on lint (#41: the Taskfile `|| echo` swallows
findings) — run `golangci-lint run ./...` yourself until that is fixed.
- `cmd/tapir` subcommands: `list`, `show`, `auth` (interactive host-side OAuth), `run` (batch
watch→summarize), `serve` (the HTMX+Templ web reader/writer under `internal/web`, a new
transport over the unchanged engine/ports — ADR-003). `tapir env` prints config.
+288 -15
View File
@@ -1363,11 +1363,16 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
**Decision.**
1. **Audio only; transcript is the only thing kept.** An authenticated user uploads an audio
file; Tapir transcribes it and stores the transcript. The audio itself is deleted after
transcription (temp file, removed in a `defer`, swept at start-up) — no custody problem to
solve, and nothing to hand to #31. Video is rejected with a message naming #28. YouTube
audio-download stays rejected. No public/anonymous upload (ADR-008).
1. **Audio is what is used; the transcript is the only thing kept.** An authenticated user
uploads a recording; Tapir transcribes its audio and stores the transcript. The audio itself
is deleted after transcription (temp file, removed in a `defer`, swept at start-up) — no
custody problem to solve, and nothing to hand to #31. **A file that also carries a video
track is accepted: its audio is extracted, the video is discarded, and the user is told so**
(see 4). *Amended 2026-10-01, the same day this ADR was drafted: the first draft rejected
video and named #28. The maintainer's first real recording — a Teams meeting with the camera
off — is still an `.mp4` with a video track, so rejecting video rejected the primary use
case.* This keeps no video and analyses none: playback, keyframes and what-is-shown analysis
remain #28. YouTube audio-download stays rejected. No public/anonymous upload (ADR-008).
2. **Private store: a separate RLS-scoped table, `uploaded_transcripts`** (resolves #28's
decision 1). `user_id NOT NULL REFERENCES users ON DELETE CASCADE`, `ENABLE` + `FORCE ROW LEVEL
@@ -1387,14 +1392,20 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
asserted exactly** — so a new table added without RLS fails CI instead of passing it. (c) is
new; the existing test only checks the named tables.
4. **Validate by probing, reject clearly.** Format and duration cannot be read from a filename
or MIME type — a Teams "voice-only" recording is typically an `.mp4` container with no video
stream, which is indistinguishable from video by extension. The upload is probed
(`ffprobe`): accept any container with an audio stream and **no** video stream; reject video
with "video is #28"; reject unreadable files. Limits, all configurable, all rejected
4. **Validate by probing, reject clearly, and say what was ignored.** Format, duration and
what a file contains cannot be read from a filename or MIME type — a Teams recording is an
`.mp4` whether or not the camera was on. The upload is probed (`ffprobe`): **accept any
container with an audio stream, whether or not it also has a video stream** (an mp3's cover
art counts as neither). Whether a real video track was present is recorded on the row
(`had_video`) and the transcript page says so plainly — *this file contained a video track;
only its audio was used and the video was discarded* — so the user is never left to wonder
what happened to the picture. Reject a file with **no** audio stream (including a screen
recording with no microphone) and an unreadable one. Limits, all configurable, all rejected
synchronously with a specific message rather than by timeout: **200 MB** upload and
**90 minutes** duration. 90 minutes is *the longest length measured*, not a ceiling that was
found; raising it is a measurement, not an edit.
found; raising it is a measurement, not an edit. The size cap now matters more: a video file
is much larger than its audio and is spooled whole before the audio is extracted, so a long
recording with screen sharing can exceed 200 MB while its audio is a few tens of MB.
5. **Transcode server-side, always, to 16 kHz mono PCM WAV** — even when the input already is,
rather than assuming. whisper-server's `--convert` is rejected (its `--tmp-dir` is created at
@@ -1450,8 +1461,9 @@ 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`,
whose 8,192-token context returns HTTP 400 on an hour of speech (~13,000 tokens). Chat over
an upload opens on `TAPIR_UPLOAD_CHAT_MODEL` (default `iguana/gemma4-26b`); the switcher
still lists every model, and picking `phi4-mini` on a long recording fails loudly and
inline. A per-model budget is not built.
lists **local models only** (amended by ADR-035 after the adversarial review found it listed
the cloud model too: the whole private transcript goes to the one chosen), and picking
`phi4-mini` on a long recording fails loudly and inline. A per-model budget is not built.
Past ~90,000 chars (≈100 minutes) no offered model can answer by stuffing; see triggers.
9. **Not decided here.** Rich `AVSNITT`/`ALTERNATIV` analysis (gated on #30 — `analyze_srt.py`
@@ -1483,6 +1495,9 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
whisper-server permanently. Chatting with a transcript can therefore load the model that
wedges the transcriber. Not solved here; the berget fallback limits the blast radius.
- Tapir's own ingress body limit for a 200 MB upload is **unverified** (it lives in `infra`).
- Accepting video means large files are spooled before their audio is extracted, and ffmpeg
demuxes (but, with `-vn`, does not decode) video it then throws away. A client-side audio
extraction would avoid the upload cost; it is not built.
**Validation / resolution triggers** (each observable by someone other than the author):
- *Chunking*: build it when the duration cap is raised above 90 min, or when a measured
@@ -1499,13 +1514,20 @@ 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.
**Open items this ADR creates.**
- **DECISION NEEDED — ffmpeg in the runtime image.** *Options:* (A) static ffmpeg/ffprobe copied
- **DECIDED (Mathias, 2026-10-02): option A.** Static ffmpeg and ffprobe 7.0.2 are copied into the
distroless image, pinned by version and sha256 (Dockerfile in this PR; #34). Measured at that time:
**about 160 MB** added, not the 70 MB first estimated (80 MB per binary); the build is **GPLv3**;
the checksum proves the bytes reviewed, not the publisher; upstream's `releases/` URL moves on, so
vendoring the verified tarball is the durable fix (not done). Uploads stay off until the deployment
sets `TAPIR_UPLOADS_ENABLED`. *The question as it was put:* *Options:* (A) static ffmpeg/ffprobe copied
into distroless **(recommended — the only option that satisfies "accept m4a/mp3/opus/mp4,
validate duration, reject video"; pure-Go decoders cover wav/mp3 only)**; (B) accept WAV
only, convert client-side (fails "I just want to upload it"); (C) a transcode sidecar
(second deployable, ADR-018's reasoning against). *Blocks:* the Dockerfile slice, not the
tests, which use the port.
- **DECISION NEEDED — berget fallback for private audio.** *Options:* (A) enabled by default as
- **DECIDED (Mathias, 2026-10-02): option A, enabled by default (#35).** Scope: the audio
transcription fallback only. Reconciliation text and chat over a recording never go to a cloud
model (ADR-034, ADR-035). *The question as it was put:* *Options:* (A) enabled by default as
#33 specifies, with the serving endpoint shown **(recommended, matches the issue)**; (B)
disabled by default, a failed iguana job fails honestly and the user retries. *Blocks:*
the production default only; the adapter ships either way.
@@ -1519,6 +1541,255 @@ and ADR-021 are untouched.
---
## ADR-034 — Transcribe with two engines and let a local model reconcile their disagreements
**Status:** Proposed (2026-10-01). **Decider: Mathias — pending.** Issue #40 (decision B) and #33.
Builds on ADR-033 (private upload transcripts), ADR-022 (endpoint chain, a pattern reused), ADR-018
(single replica), ADR-025 (honest status). Evidence:
`docs/research/asr-kb-whisper-vs-pianissimo-2026-10-01.md` and
`docs/research/local-model-adjudicator-sweep-2026-10-01.md`.
**Context.** On the first real one-hour recording `iguana/kb-whisper` and `iguana/pianissimo` were
a near tie on accuracy (the speaker's blind verdicts on the 30 meaning-changing disagreements:
pianissimo 17, kb-whisper 13) and they **err in different places**: pianissimo is better on terms and
numbers, kb-whisper on ordinary words; kb-whisper's errors garble, pianissimo's read as plausible.
Pianissimo is ~6x faster (40 s vs 4m08s for 63.7 min) and runs through the same adapter. Two engines
that disagree in ~380 places per hour, with a ~92% word agreement, is information a single-engine
pipeline throws away. Separately, eleven local models were scored on adjudicating those disagreements
against the speaker's answers: **`gemma4-31b` 97% and `gemma4-26b` 92%** passed the pre-set bar
(>= 80% accuracy, >= 85% order-swap consistency, <= 10% unparseable), nothing else did.
**Unknowns, stated.** The adjudicator evidence is 30 items from one speaker and one domain, chosen by
Opus as decidable; the other ~350 disagreements (fillers, compounds, small words) have no ground
truth. Whether a *merged* transcript is better than the better single engine has not been measured.
Reconcile time per hour is estimated, not measured. `gemma4-31b`'s slowness was Low Power Mode on
iguana: with it off the 31B decodes at 22.3 tok/s, not 7.9 (2026-10-02, same-machine before/after on
a different synthetic prompt; see the research note). The reconciler default `gemma4-26b` was chosen
on the slow numbers and is reopened: 31B scored 97% against 92%.
**Decision.**
1. **Two engines, one WAV, both local, one after the other.** Each upload is transcribed by
`iguana/pianissimo` and `iguana/kb-whisper` from the same converted WAV, sequentially (concurrency
stays 1, ADR-033). Each engine fails independently; the berget fallback (ADR-033) applies only when
**both** fail. Off by default: with no second engine configured nothing changes.
2. **Align by word, with pianissimo as the base text.** Both texts are tokenised (lower case, letters
and digits only) and diffed word by word (Myers); neighbouring differences separated by a single
equal word are merged into one **dispute**. The base is pianissimo: sentence-level prose with
punctuation and millisecond timestamps, where kb-whisper's output is wrapped fragments. **This
choice is about readability and timing, not accuracy — accuracy was a tie — so it is the cheaper
mistake, not a measured winner.** A replaced span takes the alternative's own words.
3. **Resolve the trivial ones without a model.** A dispute that is identical once spaces are removed
(a compound split or joined) keeps the base; so does a dispute that is only a short filler word
on one side. Everything else is adjudicated. (Opus's classification of 380 disputes found ~46
compound and ~26 punctuation-only residue, ~65 filler.)
4. **A local model adjudicates the rest, bounded.** Each dispute goes to a local chat model (default
`iguana/gemma4-26b`; `gemma4-31b` is configurable and scored higher but is ~5x slower) with ~100
words of context either side and the two readings, in the wording that was measured. The reading
order alternates by dispute so position bias averages out. The verdict is *base*, *alternative* or
*unsure*; **unsure keeps the base and is flagged**. An optional order-check mode asks twice with the
order swapped and treats disagreement as unsure; it is **off by default** because it doubles the time
and the measured consistency of the passing models is 93%.
- **The model must be local.** Its alias must begin `koala/` or `iguana/`; a cloud alias is a
start-up configuration error. Private meeting content never goes to a cloud model for this step.
- **Bounded impact.** The reconciler can only choose between two readings the engines already
produced; it cannot write new text. Speech that says "answer 2" can at worst flip one dispute.
5. **Reconciliation never fails a job.** One engine fails → its text is used, recorded as
single-engine. The adjudicator is down (three consecutive errors) → the base text is used
unreconciled, recorded as skipped with the reason, and the remaining disputes are not retried one
timeout at a time. Both engines fail → ADR-033's fallback path.
6. **What is stored and shown.** `content` is the merged text. `segments` stay the base engine's timed
segments, untouched (**amended by ADR-035: they now carry the merged words, timing stays the
base's**): `content` is authoritative for chat, `segments` only for time. `endpoint`
lists every contributor and the reconciler. A new `reconciliation` column (migration 019, same
RLS-scoped table) records the status, counts, and the **uncertain and changed spots with a minute
mark and both readings**, so the user can check them against the audio. The transcript page says in
plain words what was done — "reconciled from two transcripts: N differences, K uncertain" — and
lists the uncertain spots. A reconciled transcript is never presented as simply "the" transcript.
7. **Configuration.** `TAPIR_RECONCILE_BASE_MODEL` (empty = off; the engine whose text is the base,
e.g. `iguana/pianissimo` — `TAPIR_TRANSCRIBE_MODEL` stays the other engine, e.g. kb-whisper),
`TAPIR_RECONCILE_MODEL` (the adjudicator; required when a base engine is set),
`TAPIR_RECONCILE_CHECK_ORDER` (off). All three models must be local aliases (`koala/` or `iguana/`):
a cloud alias is a start-up error, and the base engine must differ from the other one. The default
job timeout rises from 30 to 60 minutes: ~4 min of kb-whisper and ~40 s of pianissimo per hour of
audio, plus the adjudication.
8. **Not decided here.** A cloud adjudicator (rejected: private content); tuning the prompt; making
`gemma4-31b` the default; a "reconciling" progress state (the page says "transcribing" for the whole
job); feeding the uncertain spots to chat; whether pianissimo should also be a middle tier in the
fallback chain (#40, option A).
**Consequences.**
- **Positive.** Uses the finding that the engines err differently; stays local end to end; degrades
to today's behaviour at every failure; and is honest on the page about what it did.
- **Negative / watch.**
- Job time grows: roughly 300 adjudicated disputes x ~2 s on `gemma4-26b` ~ 10 minutes per hour of
audio — an estimate, not a measurement; double with order-check on, ~5x with `gemma4-31b`.
- Two gemma-class calls sit on iguana beside whisper. During the sweep a 17-20 GB model was resident
beside whisper-server without wedging it, far below the 52 GB that did (infra#273); the hazard is
reduced, not removed.
- A merged transcript can be wrong where both engines are wrong, and where the adjudicator picks
the wrong one (it was right 92% on a hand-picked set, ~97% for 31b).
- Reading the page takes more words: the reconciliation summary is new surface to keep honest.
**First live smoke (2026-10-01, synthetic audio, 3 disputes):** all 3 were changed to the
alternative's reading; against the known script 2 were right and **1 was wrong** (a name replaced by a
pronoun, a correct base reading lost). That is one run of three, not a measurement, but it is the failure
this design must expect: the reconciler can overwrite a correct reading, which is why changed spots, not
only uncertain ones, are listed on the page, and why the decision below is a blind check on a real
recording.
**Validation / resolution triggers.**
- **The test that decides it:** re-upload the same real recording after merge; the speaker judges, blind,
~30 disputes where the reconciled text differs from pianissimo alone. Keep the feature only if the
reconciled reading is right at least 85% of the time **and** no worse than the better single engine.
If not, switch it off (config) and keep the single-engine path.
**Decision (Mathias, 2026-10-02), on the first result below: keep it as an opt-in feature**, reading
the bar as 95% of decided items, with the second condition to be answered on the next real run.
For deployment the reconciler is `iguana/gemma4-31b` (97% against the speaker's answers, about 22
minutes for the 63.6-minute recording with Low Power Mode off). A cloud reconciler was raised and is
**not adopted**: it would send private transcript text to a cloud API on every upload; recorded as
an evaluation question in #45.
**First result, 2026-10-02, before merge** (`docs/research/reconcile-blind-check-2026-10-02.md`): 30
random replacement spots on the real 63.6-minute recording, judged blind by Opus from text (not by the
speaker, who delegated it): 21 right, 1 worse than pianissimo, 3 neither reading right, 5 undecidable.
That is 95% of decided items but 70% of all 30; the bar did not say how ties count, so it is **met on
one reading and missed on the other**, and the speaker has not ruled. The second condition, no worse
than the better single engine, is **not answered**: kept-base spots are not stored, so kb-whisper alone
cannot be compared. Left `Proposed`; the feature stays off by default.
- Reconcile time above 25 minutes per hour of audio: drop to a smaller adjudicator or skip more
disputes automatically.
- A different recording or speaker in which the engines agree far less (or far more): re-measure before
trusting the 92%/97% figures.
**Reversibility.** Off by default and additive: the second engine, the reconciler and the column are
unused when the settings are empty; the down-migration drops the column; the single-engine path is
unchanged.
---
## ADR-035 — Show an upload's progress as real phases, log the same transitions, and keep every judged spot
**Status:** Proposed (2026-10-02). Decider: Mathias (pending). Amends ADR-034 decision 6 (segments).
Closes #42, #43 and #44.
**Context (measured, not assumed).** The first real run (a 63.6-minute recording, 21 min 49 s) exposed
three gaps. (1) The page said only `queued`/`transcribing`/`done`; during 22 minutes nothing told the
user whether the job was working, hung or dead, and after a crash the row would say `transcribing`
until the next start. (2) The server log had no line about the job between start-up and the final
line, so an operator saw it only from outside (spool files, iguana CPU). (3) Evaluating the result
showed that spots the model *kept* as the base are counted but not stored (so ADR-034's "no worse than
the better single engine" cannot be measured after the fact, and the audio is not retained), insertions
and deletions cannot be placed without context, and the saved `segments` still carry the base engine's
wording while only `content` is merged.
**Decision.**
1. **Phases are data on the row.** A new `progress` JSONB column (migration 020, same RLS-scoped table)
holds an ordered list of steps `{key, label, state, started_at, finished_at, done, total}` with state
`pending|running|done|failed|skipped`. The plan is declared up front so the page shows what is coming:
received, preparing audio, then the transcriber's own steps (two-engine: engine A, engine B,
comparing, deciding n of N; single: transcribing), then saving.
2. **One source for the page and the log.** An `internal/progress` Tracker is the only writer. Every
transition is saved to the row and logged by the same call, so the UI and the log cannot disagree.
Engines and the reconciler report through a context-carried `Reporter` (no-op when absent), so the
`Transcriber` interface does not change.
3. **Alive is not the same as progressing, and the page says which it knows.** A heartbeat goroutine,
independent of any engine call, stamps `alive_at` every 10 s; `progress_at` moves only on a real
transition or advance. The page derives: *running*; *quiet* (the deciding step has not advanced for
5 minutes, so the model is probably waiting or slow); *lost* (`alive_at` older than 45 s, so the
service stopped reporting and may have restarted or crashed). The two engine calls are single blocking
requests, so inside them the page shows elapsed time only: **no percentage is ever shown that does not
come from real counts.** Only the deciding step has a bar.
4. **The page.** A server-rendered step list in tapir's existing tokens and mono font, swapped in place
by the existing HTMX poll (every 2 s while unfinished, none once done or failed). Finished steps
collapse to one line with their duration; the running step has a CSS-only spinner that respects
`prefers-reduced-motion`; a failed step and a lost service are visibly different from running.
No new JavaScript, framework or stylesheet.
5. **Logs.** One INFO line per phase transition and engine call (upload id, endpoint, elapsed), the
reconcile counts, `adjudicated n/N` at most once a minute, a WARN when an engine soft-fails or the
breaker trips, and a "still in phase X" line every 5 minutes. **Ids, counts, durations, endpoints and
error kinds only: never transcript text, disputed words or the filename**, asserted by a test.
6. **Every judged spot is stored.** `reconciliation.contested` keeps each model-decided spot with its
verdict (changed, kept base, uncertain) and a few words of left context, bounded at 2,000. The page
still lists only changed and uncertain spots. This makes ADR-034's second condition measurable on the
next real run.
7. **Segments carry the merged words.** The reconciled words replace the base's in the saved `segments`;
timing stays the base's. If the segments cannot be aligned to the merged text (a word wrapped across
a segment edge), they stay the base's and the result says so in the log. This replaces ADR-034
decision 6's "segments stay untouched".
8. **Hardening from the adversarial review** (`skills:pr-gatekeeper`, head `7029dd1`: BLOCK; recorded on
PR #39). Each finding was reproduced by a failing test first.
- **Chat over a recording offers and accepts local models only** (it listed `berget/mistral-small`);
`TAPIR_UPLOAD_CHAT_MODEL` must be local at start-up; with no local model, nothing is sent.
- **A model alias counts as local only if it is one plain name after `koala/` or `iguana/`**
(`koala/../berget/x`, `koala//x`, `koala/x?host=…` were accepted by a bare prefix check).
- **An error reaches a log line, the row or the page as a kind** (`timeout`, `canceled`, `http_502`,
`network`, `error`), never its text, which can hold a provider's response body, the spool path or
a word from the transcript (`internal/errkind`). The stored reconcile reason and an engine's
failure reason are kinds; the page no longer shows provider text.
- **Every local engine call has its own deadline** (`TAPIR_TRANSCRIBE_ENGINE_TIMEOUT`, default 20
minutes: two wedged engines in sequence then leave 20 of the job's 60 minutes for a fallback),
so a wedged engine fails while the job lives and the fallback can run; the job's own
deadline or a cancel still never reaches the cloud.
- **The duration limit is checked against what is converted**, not the file's header: ffmpeg is
capped at the limit plus a minute, a longer result fails the job (`audio_too_long`), and a result
under 80% of the claimed length and more than two minutes short is **transcribed and noted** on
the audio step ("the audio is 20 min long; the file claimed 1 h 19 min"), not refused. The second
adversarial review showed a complete variable-bitrate MP3 whose header over-estimated its length
by 4x was wrongly refused by the first version of this check; a note keeps a truncated file
visible without turning away real recordings.
- **The queue is bounded**, 20 waiting in total and 3 per user, and its room is checked before the
upload is spooled (not after 200 MB and an ffprobe run); a full queue is a 429 with a typed reason,
never a stuck row or a server error.
- **The configured upload chat model is callable.** It is added to the shared chat service's model
list when uploads are on and it is local, because the service re-maps a model it does not offer
(a valid `iguana/gemma4-31b` was being silently swapped for `koala/phi4-mini`). The video chat's own switcher
lists that local model too while uploads are on.
- **A client that hangs up mid-upload is not an error**: the handler logs INFO and writes nothing
(rendering the error page ran a database read on a cancelled context and logged a false ERROR). The
400 "cut off" answer is keyed on a marker for errors from *reading the client's body*
(`uploads.ErrClientRead`), not on `io.ErrUnexpectedEOF`, which is also what a database drop looks like.
- **A note is not hidden**: a finished upload's record opens by default when any step carries a note.
- **Bad input is a 4xx**: a non-UUID id is a 404, a malformed or cut-off multipart body is a 400
whose text is not logged, and a title with control bytes, bidirectional controls or invalid UTF-8 is
cleaned (emoji joiners, Swedish letters and variation selectors are kept), not a 500.
- **The image was built and inspected** (buildah on koala, 2026-10-02): the first build FAILED
(`tar: xz: Cannot exec`, the golang image has no xz); fixed, rebuilt at 180 MB, ffmpeg and ffprobe
run as `nonroot` in distroless. CI does not build the image on a pull request
(`if: github.event_name != 'pull_request'`), so the first CI build of it is the merge to main.
Not done, filed as follow-ups: a vendored ffmpeg tarball, CSRF cookie review, ffprobe CPU and
stream-count limits, mov external references, `alive_at` clock skew, a hung database blocking the
tracker, an unbounded gateway response body, a user-facing notice for a recording that is partial but
inside the tolerance.
**Alternatives rejected.** Server-sent events or a websocket: a long-lived connection through the
ingress, with buffering to get wrong, for a page that changes every few seconds at best; polling is what
the page already does. A percentage from the engine calls: there is no data behind it. A separate
progress table: progress belongs to the row and inherits its RLS. Logging only through the UI: an
operator has no page.
**Consequences.** Positive: running, quiet, lost and failed are distinguishable by the user and the
operator; the next blind check can answer ADR-034's second condition. Negative / watch: two more writes
per transition and one per 10 s per running job (one job at a time, so negligible); a heartbeat proves
the process is alive, not that an engine is making progress inside one request, which only the job
timeout (60 min) bounds. The stored spot list grows to a few hundred small objects per hour of audio.
**Validation.** A fake engine held open past the thresholds must change the page (quiet, lost); a log
capture on a synthetic transcript must contain none of its words; a screenshot review of the running,
quiet, lost, failed and done states against the existing pages (the visual claims tests cannot assert).
Revisit if the heartbeat write ever shows in the database's load, or if a longer recording cap makes
five-minute quiet detection too coarse.
---
## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
@@ -1546,6 +1817,8 @@ maps to the ADR that settles it.
| Uploaded transcripts as a `user_id` column + partial RLS policy on `transcripts` | Turns ADR-021's single exception into an exception with a private lane inside it; the isolation test asserts `transcripts` has no `user_id`. A separate RLS table keeps both boundaries independently assertable | ADR-033 |
| whisper-server `--convert` for server-side transcode | Its `--tmp-dir` is created at process start and rots under macOS periodic cleanup | ADR-033 |
| Calling the public `llm-api.d-ma.be` from in-cluster Tapir | Its ingress returns 413 above ~8–10 MB (~5 min of WAV); the NodePort has no such cap | ADR-033 |
| A cloud model (e.g. Opus) as the adjudicator | It scored 30/30 but would send private meeting content to a cloud API; local gemma4-26b/31b passed the same bar | ADR-034 |
| Pick one engine globally (pianissimo only, or kb-whisper only) | A near tie on accuracy with different error profiles; choosing one discards the information in their disagreement | ADR-034 |
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
not a silent reversal.
+40
View File
@@ -16,12 +16,52 @@ COPY . .
# 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
# ── 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 ─────────────────────────────────────────────────────────────────
# distroless static + nonroot: no shell, no package manager, runs as uid 65532.
# ca-certificates are bundled, which the OIDC/HTTPS clients need.
FROM gcr.io/distroless/static-debian12:nonroot
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
USER nonroot:nonroot
+43 -4
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
@@ -21,6 +22,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web"
)
@@ -74,6 +76,11 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
cfg.GatewayKey = key
cfg.UploadsEnabled = true
cfg.UploadTmpDir = filepath.Join(t.TempDir(), "spool")
if os.Getenv("TAPIR_E2E_RECONCILE") == "1" {
// Two engines and a local model, as ADR-034 describes.
cfg.ReconcileBaseModel = envOrDefault("TAPIR_E2E_BASE_MODEL", "iguana/pianissimo")
cfg.ReconcileModel = envOrDefault("TAPIR_E2E_RECONCILE_MODEL", "iguana/gemma4-26b")
}
cfg.FFmpegBin = envOrDefault("TAPIR_E2E_FFMPEG", "ffmpeg")
cfg.FFprobeBin = envOrDefault("TAPIR_E2E_FFPROBE", "ffprobe")
@@ -92,7 +99,7 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
// 1. Upload.
f, err := os.Open(audioPath)
require.NoError(t, err)
defer f.Close()
defer func() { _ = f.Close() }()
pr, pw := io.Pipe()
mw := multipart.NewWriter(pw)
go func() {
@@ -129,13 +136,45 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
var status, endpoint, content string
var secs float64
var hadVideo bool
require.NoError(t, raw.QueryRow(ctx,
`SELECT status, endpoint, content, audio_seconds FROM uploaded_transcripts WHERE user_id = $1`, uid).
Scan(&status, &endpoint, &content, &secs))
t.Logf("done: endpoint=%s audio_s=%.1f transcript_chars=%d", endpoint, secs, len(content))
`SELECT status, endpoint, content, audio_seconds, had_video FROM uploaded_transcripts WHERE user_id = $1`, uid).
Scan(&status, &endpoint, &content, &secs, &hadVideo))
t.Logf("done: endpoint=%s audio_s=%.1f transcript_chars=%d had_video=%v", endpoint, secs, len(content), hadVideo)
if os.Getenv("TAPIR_E2E_RECONCILE") == "1" {
var recJSON string
require.NoError(t, raw.QueryRow(ctx,
`SELECT COALESCE(reconciliation::text, 'NULL') FROM uploaded_transcripts WHERE user_id = $1`, uid).Scan(&recJSON))
require.NotEqual(t, "NULL", recJSON, "a two-engine run must record its reconciliation")
t.Logf("reconciliation: %.600s", recJSON)
assert.Contains(t, endpoint, "reconciled by", "every contributor is named in the endpoint")
assert.Contains(t, page, "Reconciled from two transcripts")
}
if os.Getenv("TAPIR_E2E_HAD_VIDEO") == "1" {
// A Teams meeting with the camera off: a video file whose audio is what matters.
assert.True(t, hadVideo, "the file had a video track and the row must say so")
assert.Contains(t, page, "Only its audio was used", "and the user must be told")
}
assert.Equal(t, "done", status)
assert.NotEmpty(t, endpoint)
assert.NotEmpty(t, content)
// ADR-035: a finished job's phases are all closed, and it was reporting while it ran.
var progJSON string
var progressAt, aliveAt *time.Time
require.NoError(t, raw.QueryRow(ctx,
`SELECT COALESCE(progress::text, '[]'), progress_at, alive_at FROM uploaded_transcripts WHERE user_id = $1`, uid).
Scan(&progJSON, &progressAt, &aliveAt))
var steps []domain.ProgressStep
require.NoError(t, json.Unmarshal([]byte(progJSON), &steps))
require.NotEmpty(t, steps, "the job recorded no phases")
for _, st := range steps {
assert.Contains(t, []domain.StepState{domain.StepDone, domain.StepSkipped}, st.State,
"phase %q was left %q after the job finished", st.Key, st.State)
}
assert.Equal(t, "save", steps[len(steps)-1].Key)
assert.NotNil(t, progressAt, "progress_at must have been stamped")
t.Logf("phases: %d, heartbeat stamped: %v", len(steps), aliveAt != nil)
entries, err := os.ReadDir(cfg.UploadTmpDir)
require.NoError(t, err)
assert.Empty(t, entries, "the audio must be deleted once transcribed")
+21
View File
@@ -134,9 +134,30 @@ func chatModels(cfg config.Config) []string {
add(cfg.SummarizerModel)
add(cfg.FallbackModel)
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
}
// uploadChatModels is the chat switcher for a PRIVATE uploaded recording: the same
// ordered set as chatModels, local models only. The whole transcript (up to 90,000
// characters) is sent to the one chosen, so a cloud model the video chat offers must not
// be offered here; with none local, none is offered (ADR-035 review).
func uploadChatModels(cfg config.Config) []string {
var local []string
for _, m := range chatModels(cfg) {
if config.IsLocalModel(m) {
local = append(local, m)
}
}
return local
}
// buildChat wires the per-video chat service (ADR-027): a Completer factory over
// the SAME LiteLLM gateway the summarizer uses (a different alias per model, not a
// second client config) and the same transcript-truncation budget. It returns nil
+53 -11
View File
@@ -7,10 +7,13 @@ import (
"net/http"
"os"
"path/filepath"
"time"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
"git.d-ma.be/mathias/tapir/internal/adapters/llm"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/reconcile"
"git.d-ma.be/mathias/tapir/internal/uploads"
"git.d-ma.be/mathias/tapir/internal/web"
)
@@ -52,17 +55,8 @@ func buildUploads(ctx context.Context, cfg config.Config, st uploadsStore, log *
log.Warn("uploads: marked interrupted by a restart (audio is not retained; they must be re-uploaded)", "count", n)
}
// No overall client timeout: a 90 minute recording takes minutes. The deadline
// is the worker's per-job context.
client := &http.Client{}
primary := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeModel, client)
var fallback transcribe.Backend
if cfg.TranscribeFallbackModel != "" && cfg.TranscribeFallbackModel != cfg.TranscribeModel {
fallback = transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeFallbackModel, client)
}
worker := uploads.NewWorker(st, &audio.FFmpeg{Bin: cfg.FFmpegBin}, transcribe.NewChain(primary, fallback),
cfg.UploadTmpDir, cfg.TranscribeTimeout)
worker := uploads.NewWorker(st, newConverter(cfg), buildTranscriber(cfg),
cfg.UploadTmpDir, cfg.TranscribeTimeout, uploads.WithMaxDuration(cfg.UploadMaxDuration))
intake := uploads.NewIntake(st, &audio.FFprobe{Bin: cfg.FFprobeBin}, worker,
uploadLimits(cfg), cfg.UploadTmpDir)
return intake, worker, nil
@@ -97,4 +91,52 @@ func wireUploads(app *web.App, cfg config.Config, in *uploads.Intake) {
app.UploadLimits = uploadLimits(cfg)
app.UploadChatMaxChars = cfg.MaxUploadedTranscriptChars
app.UploadChatModel = cfg.UploadChatModel
app.UploadChatModels = uploadChatModels(cfg)
}
// 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
@@ -0,0 +1,85 @@
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
@@ -0,0 +1,26 @@
package main
import (
"testing"
"time"
"git.d-ma.be/mathias/tapir/internal/config"
)
// The converter is told the configured maximum, so a file whose header lies about its
// length cannot become hours of WAV (adversarial review F2).
func TestNewConverter_IsCappedAtTheConfiguredMaximumDuration(t *testing.T) {
var cfg config.Config
cfg.FFmpegBin = "/usr/local/bin/ffmpeg"
cfg.UploadMaxDuration = 90 * time.Minute
c := newConverter(cfg)
if c.Bin != "/usr/local/bin/ffmpeg" {
t.Errorf("Bin = %q", c.Bin)
}
if c.MaxSeconds != 5400 {
t.Errorf("MaxSeconds = %v, want 5400", c.MaxSeconds)
}
}
+44 -4
View File
@@ -6,10 +6,12 @@ import (
"os"
"path/filepath"
"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/config"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web"
@@ -18,15 +20,17 @@ import (
// fakeUploadsStore satisfies the store surface buildUploads needs, and counts the
// start-up sweep.
type fakeUploadsStore struct {
sweeps int
sweepErr error
sweeps int
sweepErr error
failedKinds []domain.UploadErrorKind
}
func (f *fakeUploadsStore) CreateUpload(context.Context, string, string) (string, error) {
func (f *fakeUploadsStore) CreateUpload(context.Context, string, string, bool) (string, error) {
return "id", nil
}
func (f *fakeUploadsStore) FailUpload(context.Context, string, string, domain.UploadErrorKind) error {
func (f *fakeUploadsStore) FailUpload(_ context.Context, _, _ string, k domain.UploadErrorKind) error {
f.failedKinds = append(f.failedKinds, k)
return nil
}
@@ -36,6 +40,12 @@ func (f *fakeUploadsStore) CompleteUpload(context.Context, string, string, domai
return nil
}
func (f *fakeUploadsStore) SaveProgress(context.Context, string, string, []domain.ProgressStep, time.Time) error {
return nil
}
func (f *fakeUploadsStore) Heartbeat(context.Context, string, string) error { return nil }
func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) {
f.sweeps++
return 2, f.sweepErr
@@ -154,3 +164,33 @@ func TestWireUploads_NilIntakeLeavesTheAppUntouched(t *testing.T) {
assert.Nil(t, app.Uploads, "a nil *Intake must not become a non-nil interface that mounts the routes")
assert.Zero(t, app.UploadChatMaxChars)
}
func TestBuildTranscriber_DefaultIsTheSingleEngineChainAsBefore(t *testing.T) {
cfg := uploadCfg(t, true)
tr := buildTranscriber(cfg)
assert.IsType(t, &transcribe.Chain{}, tr, "with no base engine configured nothing changes")
}
func TestBuildTranscriber_ABaseEngineTurnsOnTheEnsemble(t *testing.T) {
cfg := uploadCfg(t, true)
cfg.ReconcileBaseModel = "iguana/pianissimo"
cfg.ReconcileModel = "iguana/gemma4-26b"
tr := buildTranscriber(cfg)
assert.IsType(t, &transcribe.Ensemble{}, tr)
}
func TestBuildUploads_WiresTheEnsembleWhenConfigured(t *testing.T) {
cfg := uploadCfg(t, true)
cfg.ReconcileBaseModel = "iguana/pianissimo"
cfg.ReconcileModel = "iguana/gemma4-26b"
in, w, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
require.NoError(t, err)
assert.NotNil(t, in)
assert.NotNil(t, w)
}
+78
View File
@@ -0,0 +1,78 @@
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
@@ -0,0 +1,39 @@
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")
}
+6 -1
View File
@@ -152,6 +152,11 @@ erDiagram
text endpoint "which backend served it: iguana/... | berget/... (berget = audio left the homelab)"
float audio_seconds
text error_kind "typed: interrupted | transcription_failed — never a provider string"
bool had_video "the file carried a video track; only its audio was used (migration 018)"
jsonb reconciliation "NULL for a single engine; else status, exact counts and a bounded list of every judged spot (migration 019, ADR-034; all spots stored, ADR-035)"
jsonb progress "the job's ordered phases: key, label, state, times, done/total; NULL until the job starts (migration 020, ADR-035)"
timestamptz progress_at "when a phase last really changed or advanced (migration 020)"
timestamptz alive_at "heartbeat from the running job, every ~10 s; not progress (migration 020)"
timestamptz created_at
timestamptz finished_at
}
@@ -223,7 +228,7 @@ mechanism.
grounds that it holds only public caption content, and a recorded meeting is the opposite.
`user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing →
done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK
constraints). `endpoint` records which backend served it — `berget/...` means the audio left the
constraints). `reconciliation` (migration 019, ADR-034) is NULL when one engine produced the text, so the page can never claim a second opinion that was not there; otherwise it holds the status (`reconciled` / `single_engine` / `skipped`), exact counts (differences, settled automatically, decided by the model, changed, uncertain, model errors) and up to 2,000 judged spots (ADR-035: every one the model decided, kept or changed or unsure, each a position in seconds, both readings, which was used, and the few words before it; the page lists only the changed and uncertain ones). `progress`, `progress_at` and `alive_at` (migration 020, ADR-035) let the page show the job's phases and tell a running job from a quiet one from a lost service: `alive_at` is a heartbeat from a goroutine independent of any engine call, `progress_at` moves only when work really moved; the saved `segments` carry the reconciled words with the base engine's timing. The spots hold fragments of the private transcript, which is why they live in this RLS-scoped row. `had_video` (migration 018) records that the file carried a video track — a Teams meeting with the camera off is still a video file — so the page can say only its audio was used and the video was discarded. `endpoint` records which backend served it — `berget/...` means the audio left the
homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90
minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion.
- **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
+9 -3
View File
@@ -23,11 +23,17 @@ 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:
`TAPIR_UPLOADS_ENABLED` (off by default), `TAPIR_UPLOAD_MAX_MB` (200), `TAPIR_UPLOAD_MAX_MINUTES`
(90), `TAPIR_TRANSCRIBE_MODEL`, `TAPIR_TRANSCRIBE_FALLBACK_MODEL` (empty disables the cloud
fallback), `TAPIR_TRANSCRIBE_TIMEOUT` (30m), `TAPIR_UPLOAD_TMP_DIR`, `TAPIR_FFMPEG_BIN`,
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`,
`TAPIR_FFPROBE_BIN`, `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` (90000 — the measured ceiling of
`iguana/gemma4-26b`, which silently drops the head above it), `TAPIR_UPLOAD_CHAT_MODEL`
(`iguana/gemma4-26b`; `koala/phi4-mini`'s 8,192-token context 400s on an hour of speech). Needs `ffmpeg` + `ffprobe`
in the runtime image; Tapir's own ingress body limit for a 200 MB upload is unverified (infra).
(`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).
**Two-engine mode (ADR-034), off unless `TAPIR_RECONCILE_BASE_MODEL` is set:** it runs
`iguana/pianissimo` (the base text) and `TAPIR_TRANSCRIBE_MODEL` (kb-whisper) on the same audio, then a
LOCAL chat model (`TAPIR_RECONCILE_MODEL`) 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.
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`).
@@ -0,0 +1,81 @@
# kb-whisper vs pianissimo on one real hour of Swedish speech (tapir#33, 2026-10-01)
One recording, one speaker: a 63.7-minute Teams session of the maintainer dictating project
notes (domain-heavy, English loanwords and product names mixed into Swedish). **No transcript
text and no domain terms are recorded here**: this repo is public and the content is private.
Only aggregate numbers.
Backends, both through the LiteLLM gateway and Tapir's own adapter, unchanged:
`iguana/kb-whisper` (whisper.cpp, KB's Swedish fine-tune) and `iguana/pianissimo` (KlangAI
Swedish Parakeet/FastConformer-TDT, MLX 8-bit, served by a small FastAPI shell on iguana:8102).
## Result: a near tie on accuracy, a large difference in speed
| | kb-whisper | pianissimo |
|---|---|---|
| Wall time for 63.7 min | 4m08s (~15x realtime) | **40 s (~95x realtime)** |
| Segments | 1,302 (timed pieces, wrapped at ~60 chars) | 434 (sentences) |
| Words (normalised) | 6,606 | 6,510 |
| Punctuation | light, every segment ends in a full stop | heavier (138 vs 82 commas) |
| Repetition loops | none | none |
Word-level agreement between them: **92.4%** as first computed, **93.1%** after the segment
rejoin fix below. 380 aligned disagreement spans, median one word each.
**Blind A/B by the speaker** (the only ground truth available), on the 30 spans a model
(Claude Opus 5.5) had picked as changing meaning, shuffled and unlabelled: **pianissimo 17,
kb-whisper 13**, no "neither", no "can't tell".
| Category of the 30 | pianissimo | kb-whisper |
|---|---|---|
| Technical term / English loanword | 8 | 4 |
| Number / date / unit | 4 | 1 |
| Name / proper noun | 3 | 3 |
| Function word / small word | 1 | 2 |
| Other (everyday words) | 1 | 3 |
The reading: pianissimo is better on terminology and numbers, kb-whisper on ordinary Swedish
words; names are even. Their errors differ in kind: kb-whisper's tend to be garbled words, a
couple were wrong numbers; pianissimo's tend to be fluent substitutions that read as plausible
(a wrong institution, an invented person, a dropped sign), which a reader or an LLM is less
likely to catch.
Opus's own lean agreed with the speaker's blind answer on **30 of 30**. That is not a measure
of Opus over all 380 spans: it chose spans it could decide, and every one of the 30 carried a
lean. Its full classification of the 380 (counts only): function/small word 81, filler or
false start 65, technical term 58, compound split/join 46, other 39, name 28, casing or
punctuation residue 26, inflection 23, number/date/unit 10, content omission or insertion
(>= 3 words) 4.
## A bug this comparison found in Tapir (fixed)
kb-whisper's output is wrapped at ~60 characters and **a wrap can fall inside a word**; the
continuation segment has no leading space, the response's `text` field joins segments with
newlines, and a segment can be a lone `.`. Tapir stored that joined text, so chat received
broken words. Measured on real gateway output: 7% of segments (96/1,332) in a synthetic hour,
42/1,302 lines in the real one. berget's segments are whole sentences with no leading space, so
the two need different joins. `transcribe.joinSegments` now rebuilds the text from the segments.
Checked against the real transcript with pianissimo's vocabulary as an independent yardstick:
36 spans changed, 31 now entirely known words, words unknown to pianissimo 224 -> 177.
Five rejoined spans still contain an unknown word (a name or an unlucky glue): small residual
risk. Earlier tests used short sentences and could not see this.
## What this does not show
- **One recording, one speaker, one domain.** No claim about other voices, accents, crosstalk,
or room noise. 17 vs 13 is not a significant difference.
- **The other ~350 spans** were not judged by the speaker. They are mostly spelling, compounds,
fillers and small words; Opus's counts there are a model's judgement, not ground truth.
- Errors **both** models share in text they agree on are invisible to this method.
- Speed was measured once each, on an otherwise idle iguana; pianissimo's 40 s includes the
gateway hop and a 122 MB WAV upload.
- Whether pianissimo survives the infra#273 Metal-OOM contention the way whisper does not was
not tested; both run on iguana.
## What it suggests for Tapir (not decided, filed as an issue)
Pianissimo is a **second local backend on the same host, through the same adapter**. That
makes it a natural middle tier ahead of the cloud fallback (kb-whisper -> pianissimo ->
berget), so a wedged whisper no longer sends private audio off the homelab. At 40 s an hour it
is also cheap enough to run **both** on every upload and give chat both texts, or flag where
they disagree; the maintainer's blind answers show they err in different places.
@@ -29,7 +29,7 @@ ingress configuration, which lives in `infra`. "About 10 MB" is an inference.
## Method
- **Audio.** No known-good Swedish recording is available that is safe to use: the only real
one (`IMG_1233.wav`) is the private `/bygge` recording (#31 custody still open) and was
one is a private recording (#31 custody still open) and was
deliberately not touched. Instead: macOS `say -v Alva` (Swedish) on iguana, a 1,171-char
five-paragraph meeting script → 82.5 s of 16 kHz mono PCM WAV (`ffmpeg -ar 16000 -ac 1`).
Concatenated ×4, ×8, ×16, ×32, ×44, ×66 with Python `wave` (the issue's own recipe).
@@ -0,0 +1,85 @@
# Can a local model adjudicate two ASR transcripts? (tapir#33, 2026-10-01)
Question: when `iguana/kb-whisper` and `iguana/pianissimo` disagree about a word or phrase,
can a **local** model say which reading is right, well enough to merge the two transcripts?
Ground truth: the speaker's own blind answers on 30 disagreements of one real recording
(see `asr-kb-whisper-vs-pianissimo-2026-10-01.md`; no transcript text is recorded here,
this repo is public). Reference: Claude Opus 5.5 agreed with the speaker on all 30.
## Method
- **Items:** 30 disagreement spans that Opus had picked as changing meaning, the speaker's
verdict as truth (pianissimo right 17, kb-whisper right 13).
- **Prompt:** one fixed English instruction (two transcriptions of Swedish speech differ at
ONE marked spot; say which reading the speaker most likely said; reply `ANSWER: 1|2|UNSURE`
plus one sentence). Each item was asked in **both reading orders** and in **two contexts**:
narrow (+-8 words) and wide (+-100 words). 120 calls per model, temperature 0.
- **Models:** 11, all local (`koala/*`, `iguana/*`): nothing left the homelab. Run one iguana
model at a time, each unloaded afterwards, to avoid the memory pressure behind infra#273;
whisper was confirmed healthy afterwards.
- **Pass criteria, fixed before the run:** wide context, strict accuracy >= 80% (an
unparseable or "unsure" answer counts as wrong), order-swap consistency >= 85%,
unparseable <= 10%.
## Results (wide context, 60 calls per model)
| Model | Correct | 95% CI | Same pick when order swapped | Unparseable | s/call | Pass |
|---|---|---|---|---|---|---|
| `iguana/gemma4-31b` | 58 = 97% | 89-99% | 93% | 0 | 10.4 | **yes** |
| `iguana/gemma4-26b` | 55 = 92% | 82-96% | 93% | 0 | 2.0 | **yes** |
| `iguana/ornith-35b` | 51 = 85% | 74-92% | 77% | 0 | 2.5 | no (consistency) |
| `koala/phi4-14b` | 50 = 83% | 72-91% | 80% | 0 | 1.0 | no (consistency) |
| `koala/qwen36-35b-a3b` | 49 = 82% | 70-89% | 83% | 0 | 3.3 | no (consistency) |
| `iguana/granite4` | 47 = 78% | 66-87% | 83% | 0 | 4.6 | no |
| `iguana/qwen3-14b` | 44 = 73% | 61-83% | 87% | 0 | 4.4 | no (accuracy) |
| `iguana/gpt-oss-20b` | 40 = 67% | 54-77% | 100% | 17 | 11.2 | no (unparseable) |
| `koala/phi4-mini` | 37 = 62% | 49-73% | 24% | 1 | 0.5 | no |
| `iguana/gemma4-26b-think` | 23 = 38% | 27-51% | 100% | 37 | 15.3 | no (unparseable) |
| `koala/qwen35-9b-fast` | 0 | n/a | n/a | 60 | 7.2 | no (no output) |
Narrow context only (+-8 words) costs 6-12 points for the models that pass: gemma4-31b 92%,
gemma4-26b 83%, ornith-35b 80%. Context is not decoration: Opus also used evidence from
elsewhere in the recording (an amount stated earlier, a term that recurs).
Position bias: the passing models pick "Reading 1" 47-51% of the time. `phi4-mini` picks it 15%
and flips its answer when the order is swapped 76% of the time: it carries almost no signal.
## Reading it
- **Two models pass:** the gemma4 pair. Both clear the bar and neither shows position bias.
26b is ~5x faster; their intervals overlap, so 30 items cannot separate 92% from 97%.
- **The thinking models were not tested fairly.** `gpt-oss-20b` and `gemma4-26b-think` spend
the 600-token budget reasoning and often never reach the answer line. When gemma4-26b-think
did answer, it was right 23 of 23; gpt-oss-20b 93% of the answered. A larger budget would
make them eligible; at 15 s a call it would not make them practical.
## What this does not show
- **Thirty items, one recording, one speaker, one domain.** The 30 were chosen by Opus as
decidable, so they overstate how easy the typical disagreement is; ~350 others are mostly
spelling, compounds and fillers, which no ground truth covers.
- Opus agreeing 30/30 is not a measure of Opus over all 380 disagreements.
- Accuracy was measured at temperature 0 on one prompt; no prompt tuning was tried.
- The speaker's answers are the only truth; they are one person's recollection of what they said.
## Why gemma4-31b is slow, and what was ruled out
On iguana (M2 Ultra, 64 GB) the dense 31B runs at **103 tok/s prefill, 7.9 tok/s decode**; the
26B MoE at 641 and 39.6. Ruled out as the cause: memory pressure (no swap, 45% free, model 100%
on GPU), context length (32k vs 4k made no difference), and the **inference engine** — MLX 4-bit
of the same model measured 76 tok/s prefill and 8.6 decode, the same within noise. (Terse
output saves only ~3.6 s of ~9.3 s because prefill dominates.) Two unrelated engines at the same
speed points at the machine: ~140 GB/s effective memory bandwidth on a part rated for 800, and
**iguana had macOS Low Power Mode on** (`pmset` `lowpowermode 1`). A first MLX benchmark was
wrong because `mlx_lm.server` caches prompts; the figures above are from the corrected run.
**Confirmed 2026-10-02.** The owner turned Low Power Mode off (`lowpowermode 0`, read back over
ssh) and `gemma4:31b` was re-measured with nothing else resident: **217 tok/s prefill, 22.3
tok/s decode** (4 runs, spread 22.2-22.6), against 103 and 7.9 with it on, so 2.1x and 2.8x. The
slowness was the power mode, not the engine or the model. Caveats on the comparison: the new run
used a different, synthetic Swedish prompt (~700 words, the original items held real transcript
text and were deleted) and the model answered in 26-43 tokens, not the ~200 of the first run, so
it is a same-machine before/after, not a controlled A/B. `gemma4:26b` and the MLX figures were
not re-measured. The 31B's 97% judge accuracy is therefore usable at roughly a third of the
latency the sweep implied; the 26B-vs-31B choice in ADR-034 was made on the slow numbers and is
reopened by this.
@@ -0,0 +1,68 @@
# Two-engine reconcile, first check on a real recording (tapir#33, ADR-034, 2026-10-02)
Aggregate numbers only. This repo is public and the recording is private (#31 custody is open), so no
transcript text, no disputed words and no per-item reading appears here.
## What was run
One real 63.6-minute Teams recording of one speaker dictating project notes (the video track, camera
off, was discarded). Build: PR #39 head `7f8e367`. Everything local: `iguana/pianissimo` as base,
`iguana/kb-whisper` as the second engine, `iguana/gemma4-31b` as reconciler (iguana Low Power Mode
off), cloud fallbacks off, a throwaway Postgres.
| Measure | Value |
|---|---|
| Job time, upload to saved | 21 min 49 s for 63.6 min of audio (20.6 min per hour of audio) |
| Differences between the engines | 362 |
| Settled automatically (compound splits, up to two filler words) | 80 |
| Decided by the model | 282 |
| Of those: alternative reading used / base kept / uncertain | 130 / 151 / 1 |
| Model call errors | 0 |
20.6 min per hour is inside the 25-minute resolution trigger in ADR-034, so no smaller adjudicator is
needed on speed grounds. The earlier estimate was about 10 min per hour on gemma4-26b; it was not
measured and this run used the 31B.
## Blind check
30 spots drawn at random (fixed seed) from the 89 replacement spots that could be placed in the base
text, across the whole recording (0:44 to 60:57). The two readings were shown as x and y in random
order, with the key kept apart from the judge. Judge: Claude Opus, text only, no audio, at the
speaker's request ("I trust Opus"). The speaker did not judge these items themselves.
| Verdict | Items |
|---|---|
| The reconciler's reading (the alternative) is right | 21 |
| The base reading (pianissimo) is right, so the change made it worse | 1 |
| Neither reading is right | 3 |
| Cannot tell from text (compound, hyphen and number-format differences) | 5 |
- Right of the items with a verdict: 21 of 22, 95% (95% interval 78% to 99%).
- Right of all 30, counting "neither" and "cannot tell" as not right: 21 of 30, 70% (52% to 83%).
- Right of the 25 that were not "cannot tell": 21 of 25, 84% (65% to 94%).
- Net effect against the base on these 30: 21 better, 1 worse, 8 unchanged in quality.
## Reading the result
ADR-034's bar was "right at least 85% of the time". It did not say how ties count, and that was a
flaw in the bar, not in the data. The literal reading (all 30) gives 70% and **does not meet** the bar;
counting only decided items gives 95% and meets it. Neither is a hidden pass or a hidden failure; both
are reported. What is not in doubt: on a random sample the reconciler improved the text far more often
than it harmed it (21 to 1), and the 8 unchanged cases are mostly spellings the text cannot settle.
## What this did not measure
- **Better single engine.** The second ADR-034 condition, "no worse than the better single engine", is
**not answered**. Only changed spots are stored, not the 151 where the model kept pianissimo, so the
reconciler cannot be compared with kb-whisper alone. The 21-to-1 result is against pianissimo.
- **The speaker as judge.** Opus judged from text. It cannot hear the audio, so it can be wrong where
the right word is audible but not inferable (items such as a mis-heard number or a name). Earlier,
Opus and the speaker agreed 30 of 30, but on items chosen as decidable; that does not transfer to a
random sample.
- **Insertions and deletions.** 42 of the 131 stored spots were not sampled: 39 are an added or
removed word, which cannot be placed in the base text, and 3 are the uncertain spot or could not be
located. The 39 are mostly small words ("och", "det"), so this sample says little about them.
- **One recording, one speaker, 30 items.** Wide intervals, and neighbouring spots (three pairs within
seconds of each other) are not independent.
- **Segments keep the base text.** The saved timed segments hold pianissimo's wording, not the merged
text; only the stored full text is merged. Found while building this sample; not yet an issue.
+165 -8
View File
@@ -7,7 +7,9 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
# PRIVATE content, so it lives in its own RLS-scoped store and never in the
# shared public-caption `transcripts` table (ADR-021). Rejection is synchronous,
# the audio is never retained, and a failed job ends in a terminal state with a
# message — never a spinner (ADR-025). Audio only: video is #28.
# message — never a spinner (ADR-025). A file with a video track (a Teams meeting
# with the camera off is one) is accepted: only its audio is used and the user is
# told. Video playback, keyframes and analysis of what is shown remain #28.
Background:
Given I am a registered user with audio upload enabled
@@ -25,9 +27,15 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
When I upload an audio file and give no title
Then its title is the filename without its extension
Scenario: A file containing video is rejected, naming issue 28
When I upload a file that has a video track
Then it is rejected with a message that video is tracked in issue 28
Scenario: A file with a video track is accepted and I am told the video was ignored
When I upload a recording that also has a video track
Then it is accepted and queued like any other
And its page says only its audio was used and the video was discarded
And it says so while queued, while transcribing, when done and when failed
Scenario: A video file with no audio is rejected for having no audio
When I upload a video that has no audio track
Then it is rejected with a message that it has no audio
And nothing is recorded
Scenario: A recording over the length limit is rejected with both lengths
@@ -40,10 +48,9 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
Then it is rejected with 413 and a message about the limit
And nothing is recorded
Scenario: An audio-only MP4 is not mistaken for video
When I upload an mp4 container that has an audio stream and no video stream
Then it is accepted
And an mp3 whose only video stream is cover art is accepted too
Scenario: Cover art on an audio file is not a video track
When I upload an mp3 whose only video stream is attached cover art
Then it is treated as audio and no video notice is shown
Scenario: A file that is not media is rejected as unreadable
When I upload a file ffprobe cannot read
@@ -99,3 +106,153 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
Given my recording's transcript is longer than the caption default
When I ask a question about it
Then the transcript is sent with the upload budget, not the 18,000-character caption default
# ADR-034, two engines and a local reconciler (off unless TAPIR_RECONCILE_BASE_MODEL is set)
Scenario: Two engines transcribe the audio and their disagreements are reconciled
Given two-engine transcription is enabled
When a recording is transcribed
Then both engines run on the same audio, the fast one first
And a local model chooses between their disagreements
And the endpoint names every engine and the model that took part
Scenario: Trivial disagreements never reach the model
When the engines differ only by a compound written as one word or two, or by a filler word
Then the base reading is kept without a model call
Scenario: Differences close together are one decision
When two differences are separated by a single shared word
Then they are asked about as one dispute
Scenario: An unsure or changed reading is flagged for me
When the model changes a reading or cannot decide
Then the page lists the spot with a minute mark, both readings and which was used
And an unsure spot keeps the base reading and is marked uncertain
Scenario: The model cannot be a cloud model
When the reconciliation model or base engine is not a koala/ or iguana/ alias
Then the service refuses to start
And a cloud alias that merely contains a local host name is refused too
Scenario: One engine failing does not fail the upload
When one of the two engines fails
Then the other engine's transcript is used
And the page says only one engine produced it and why
And the cloud fallback is not used
Scenario: Only when both local engines fail does the cloud fallback run
When both local engines fail and a fallback is configured
Then the fallback transcribes it and the row records that it did
Scenario: A model that is down gives an unreconciled transcript, not a failed job
When the reconciliation model fails three times in a row
Then the base transcript is kept unreconciled with the reason
And the page says it could not be reconciled
Scenario: A cancelled job never reaches the cloud
When the job is cancelled during either engine
Then neither the second engine nor the cloud fallback is called
Scenario: A single-engine transcript claims no second opinion
When only one engine was configured
Then the page does not mention reconciliation
# 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 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
+19 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
@@ -17,8 +18,17 @@ import (
type FFmpeg struct {
// Bin is the ffmpeg executable ("ffmpeg" resolves via PATH).
Bin string
// MaxSeconds caps the converted length when positive. The duration limit is a claim
// in the file's header and can be lied about (a 2 h file claiming 10 s converted to a
// 120-minute WAV); this bounds what a lie can cost. A minute of slack is added so a
// file at the limit is never cut, and the worker measures the result.
MaxSeconds int
}
// lengthSlackSeconds is added to MaxSeconds so ffmpeg's rounding never clips a file that
// is really at the limit.
const lengthSlackSeconds = 60
// ToWAV writes src, converted, to dst. Video streams are dropped (-vn): by now the
// file has already been probed as audio-only, but a cover-art stream must not end
// up in the WAV.
@@ -27,8 +37,15 @@ func (f *FFmpeg) ToWAV(ctx context.Context, src, dst string) error {
if bin == "" {
bin = "ffmpeg"
}
cmd := exec.CommandContext(ctx, bin,
"-y", "-v", "error", "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", dst)
// Input restrictions go BEFORE -i: placed after it they would apply to the output.
args := append([]string{"-y", "-v", "error"}, inputRestrictions...)
args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le")
if f.MaxSeconds > 0 {
// After -i: an OUTPUT cap. Before it, it would limit what is read, not written.
args = append(args, "-t", strconv.Itoa(f.MaxSeconds+lengthSlackSeconds))
}
args = append(args, "-f", "wav", dst)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.WaitDelay = killGrace
if out, err := cmd.CombinedOutput(); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
+51
View File
@@ -58,3 +58,54 @@ func TestFFmpegToWAV_MissingBinaryIsAnError(t *testing.T) {
require.Error(t, err)
assert.False(t, errors.Is(err, context.Canceled))
}
func TestFFmpegToWAV_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) {
rec := filepath.Join(t.TempDir(), "argv")
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
for a; do last="$a"; done
printf data > "$last"`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "o.wav"))
require.NoError(t, err)
args := argvOf(t, rec)
pw, fw, in := indexOf(args, "-protocol_whitelist"), indexOf(args, "-format_whitelist"), indexOf(args, "-i")
require.GreaterOrEqual(t, pw, 0, "argv: %v", args)
require.GreaterOrEqual(t, fw, 0, "argv: %v", args)
assert.Equal(t, "file,pipe", args[pw+1])
assert.Equal(t, audio.AllowedDemuxers, args[fw+1])
assert.Less(t, pw, in, "input options must come BEFORE -i or ffmpeg applies them to the output")
assert.Less(t, fw, in)
}
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 ")
}
+16 -2
View File
@@ -21,6 +21,19 @@ type FFprobe struct {
Bin string
}
// AllowedDemuxers are the input formats an upload may be. The input is
// attacker-supplied, and a file's CONTENT can tell ffmpeg to open other files or
// URLs (HLS and concat playlists, image2 sequences, network demuxers). Recent
// ffmpeg refuses the ones tried (7.0.2: HLS without a .m3u8 name is not sniffed,
// concat rejects unsafe paths), but that is version luck; this list is not. It
// holds the containers real recordings use and none of the playlist or network
// formats. "mov" covers .mp4/.m4a/.3gp; "matroska" covers .webm.
const AllowedDemuxers = "wav,mov,mp4,m4a,3gp,mp3,ogg,matroska,webm,flac,aac,opus,aiff,amr,asf"
// inputRestrictions are ffmpeg/ffprobe INPUT options and must precede the input.
// Local files and pipes only: no network, no other protocols.
var inputRestrictions = []string{"-protocol_whitelist", "file,pipe", "-format_whitelist", AllowedDemuxers}
// killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close.
const killGrace = 2 * time.Second
@@ -34,8 +47,9 @@ func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error)
if bin == "" {
bin = "ffprobe"
}
cmd := exec.CommandContext(ctx, bin,
"-v", "error", "-print_format", "json", "-show_format", "-show_streams", path)
args := append([]string{"-v", "error"}, inputRestrictions...)
args = append(args, "-print_format", "json", "-show_format", "-show_streams", path)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.WaitDelay = killGrace
out, err := cmd.Output()
if err != nil {
+57
View File
@@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -127,3 +128,59 @@ func TestFFprobeProbe_StopsWhenTheContextIsCancelled(t *testing.T) {
assert.Less(t, time.Since(start), 5*time.Second)
assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a timeout is not a verdict on the file")
}
// argvStub records the arguments it was called with into a file and then behaves as
// a successful ffprobe, so a test can assert exactly what the process was asked.
func argvStub(t *testing.T) (bin, argvFile string) {
t.Helper()
argvFile = filepath.Join(t.TempDir(), "argv")
bin = stub(t, `printf '%s\n' "$@" > `+argvFile+`
echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`)
return bin, argvFile
}
func argvOf(t *testing.T, file string) []string {
t.Helper()
raw, err := os.ReadFile(file)
require.NoError(t, err)
return strings.Split(strings.TrimSpace(string(raw)), "\n")
}
func indexOf(args []string, want string) int {
for i, a := range args {
if a == want {
return i
}
}
return -1
}
func TestFFprobeProbe_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) {
// The input is attacker-supplied. ffmpeg can be told by a file's CONTENT to open
// other files or URLs (HLS and concat playlists); recent versions refuse by
// default, but that is version luck. Pin it: local files and pipes only, and
// only the demuxers real audio recordings use.
bin, argvFile := argvStub(t)
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1")
require.NoError(t, err)
args := argvOf(t, argvFile)
pw := indexOf(args, "-protocol_whitelist")
require.GreaterOrEqual(t, pw, 0, "argv: %v", args)
assert.Equal(t, "file,pipe", args[pw+1])
fw := indexOf(args, "-format_whitelist")
require.GreaterOrEqual(t, fw, 0, "argv: %v", args)
assert.Equal(t, audio.AllowedDemuxers, args[fw+1])
assert.Equal(t, "/spool/upload-1", args[len(args)-1], "the path stays the last argument")
}
func TestAllowedDemuxers_ExcludeThePlaylistFormats(t *testing.T) {
for _, bad := range []string{"concat", "hls", "applehttp", "dash", "rtsp", "sdp", "image2"} {
assert.NotContains(t, strings.Split(audio.AllowedDemuxers, ","), bad,
"%s can make ffmpeg open other files or URLs named inside the upload", bad)
}
for _, good := range []string{"wav", "mov", "mp4", "m4a", "mp3", "ogg", "matroska", "flac"} {
assert.Contains(t, strings.Split(audio.AllowedDemuxers, ","), good)
}
}
@@ -0,0 +1,32 @@
package audio_test
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// TestFFprobeLive_HostilePlaylistsAreRefusedAndRealAudioIsNot runs the REAL ffprobe
// (opt-in: TAPIR_LIVE_FFPROBE) on crafted concat/HLS files and on a real m4a.
func TestFFprobeLive_HostilePlaylistsAreRefusedAndRealAudioIsNot(t *testing.T) {
bin := os.Getenv("TAPIR_LIVE_FFPROBE")
if bin == "" {
t.Skip("set TAPIR_LIVE_FFPROBE (and TAPIR_LIVE_HOSTILE_DIR, TAPIR_LIVE_M4A) to run against the real ffprobe")
}
p := &audio.FFprobe{Bin: bin}
for _, name := range []string{"up-concat", "up-hls"} {
_, err := p.Probe(context.Background(), os.Getenv("TAPIR_LIVE_HOSTILE_DIR")+"/"+name)
assert.ErrorIs(t, err, uploads.ErrUnreadable, name)
}
got, err := p.Probe(context.Background(), os.Getenv("TAPIR_LIVE_M4A"))
require.NoError(t, err)
assert.True(t, got.HasAudio)
assert.False(t, got.HasVideo)
assert.InDelta(t, 82.5, got.Duration.Seconds(), 1)
}
+36 -2
View File
@@ -25,12 +25,16 @@ import (
// docs/homelab-integration.md.
const defaultMaxTokens = 8192
// defaultTemperature is what every caller got before WithTemperature existed.
const defaultTemperature = 0.2
// Client calls an OpenAI-compatible chat completions endpoint.
type Client struct {
baseURL string
apiKey string
model string
maxTokens int
temp float64
httpClient *http.Client
usageHook func(model string, prompt, completion int)
}
@@ -51,6 +55,35 @@ 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
// model and the prompt/completion token counts from the response usage block. It
// keeps this copied, stdlib-only package (ADR-004) decoupled from metrics: the
@@ -66,6 +99,7 @@ func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) *
apiKey: apiKey,
model: model,
maxTokens: defaultMaxTokens,
temp: defaultTemperature,
httpClient: &http.Client{Timeout: timeout},
}
for _, opt := range opts {
@@ -105,7 +139,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
{Role: "system", Content: system},
{Role: "user", Content: user},
},
Temperature: 0.2,
Temperature: c.temp,
MaxTokens: c.maxTokens,
}
b, err := json.Marshal(body)
@@ -155,7 +189,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, out)
return "", &HTTPError{Code: resp.StatusCode, Body: string(out)}
}
var cr chatResponse
@@ -0,0 +1,63 @@
package llm
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.d-ma.be/mathias/tapir/internal/errkind"
)
// A non-200 from the gateway is an HTTP status error: its kind can be logged and stored
// without its body, which can echo the prompt (adversarial review F3).
func failingGateway(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func TestClient_ANon200IsAnHTTPStatusErrorWhoseKindCarriesNoBody(t *testing.T) {
srv := failingGateway(t, http.StatusBadRequest, `{"error":"prompt was: Anders ansvarar för virket"}`)
_, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
if err == nil {
t.Fatal("expected an error")
}
if got := errkind.Of(err); got != "http_400" {
t.Errorf("errkind.Of = %q, want http_400", got)
}
var he *HTTPError
if !errors.As(err, &he) || he.Code != http.StatusBadRequest {
t.Errorf("error is not an *HTTPError with the status code: %v", err)
}
}
func TestClient_TheErrorTextIsUnchangedForCallersThatLogIt(t *testing.T) {
srv := failingGateway(t, http.StatusBadGateway, "upstream down")
_, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
if err == nil || err.Error() != "LLM returned 502: upstream down" {
t.Errorf("error text = %v: callers that log it already rely on this form", err)
}
}
func TestClient_AnEnormousErrorBodyIsBoundedInTheErrorText(t *testing.T) {
srv := failingGateway(t, http.StatusInternalServerError, strings.Repeat("x", 100_000))
_, err := New(srv.URL, "", "m", 5*time.Second).Complete(context.Background(), "s", "u")
if err == nil || len(err.Error()) > 1024 {
t.Errorf("error text is %d bytes: a gateway's whole body must not become an error message", len(err.Error()))
}
}
+57
View File
@@ -0,0 +1,57 @@
package llm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// capturedTemperature runs one Complete against a fake gateway and returns the
// "temperature" field the request carried, and whether it was present at all.
func capturedTemperature(t *testing.T, opts ...Option) (float64, bool) {
t.Helper()
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "ok"}}}})
}))
defer srv.Close()
if _, err := New(srv.URL, "", "m", 5*time.Second, opts...).Complete(context.Background(), "s", "u"); err != nil {
t.Fatalf("Complete: %v", err)
}
v, ok := body["temperature"].(float64)
return v, ok
}
func TestClient_DefaultTemperatureIsUnchanged(t *testing.T) {
got, ok := capturedTemperature(t)
if !ok || got != 0.2 {
t.Fatalf("default temperature = %v (present %v), want 0.2: existing callers must not change behaviour", got, ok)
}
}
func TestClient_WithTemperatureZeroSendsExactlyZero(t *testing.T) {
// The reconciler's accuracy (92%/97%) was measured at temperature 0. Zero must be
// SENT, not treated as "unset" and replaced by the default.
got, ok := capturedTemperature(t, WithTemperature(0))
if !ok || got != 0 {
t.Fatalf("temperature = %v (present %v), want an explicit 0", got, ok)
}
}
func TestClient_WithTemperatureSendsTheGivenValue(t *testing.T) {
got, ok := capturedTemperature(t, WithTemperature(0.7))
if !ok || got != 0.7 {
t.Fatalf("temperature = %v (present %v), want 0.7", got, ok)
}
}
@@ -0,0 +1 @@
ALTER TABLE uploaded_transcripts DROP COLUMN had_video;
@@ -0,0 +1,9 @@
-- Migration 018: record whether an uploaded file carried a video track (ADR-033,
-- amended 2026-10-01).
--
-- A Teams meeting with the camera off is still an .mp4 with a video track, so video
-- files are ACCEPTED: only the audio is extracted and the video is discarded. This
-- flag lets the transcript page say so — "this file contained a video track; only its
-- audio was used" — so the user is never left to wonder what happened to the picture.
-- It outlives the job: completing or failing an upload does not clear it.
ALTER TABLE uploaded_transcripts ADD COLUMN had_video BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1 @@
ALTER TABLE uploaded_transcripts DROP COLUMN reconciliation;
@@ -0,0 +1,12 @@
-- Migration 019: how a merged transcript came about (ADR-034).
--
-- When two engines transcribed the same audio and a local model chose between their
-- disagreements, this records the status, the exact counts, and a bounded list of the
-- contested spots (changed or unsure, each with a position and both readings) so the
-- page can say what was done and the user can check the uncertain spots against the
-- audio. NULL means a single engine: no second opinion, and the page must not claim one.
--
-- It lives on the already-FORCE'd, RLS-scoped uploaded_transcripts, so the exact-set
-- isolation test is unaffected. The contested spots hold fragments of the private
-- transcript, which is why they are here and nowhere else.
ALTER TABLE uploaded_transcripts ADD COLUMN reconciliation JSONB;
@@ -0,0 +1,4 @@
ALTER TABLE uploaded_transcripts
DROP COLUMN alive_at,
DROP COLUMN progress_at,
DROP COLUMN progress;
@@ -0,0 +1,15 @@
-- Migration 020: where an upload job is, and whether the service is still reporting (ADR-035).
--
-- progress the job's ordered phases as JSON (key, label, state, times, done/total).
-- progress_at when a phase last REALLY changed or advanced.
-- alive_at a heartbeat stamped every few seconds by a goroutine independent of any
-- engine call. The two differ on purpose: alive says the service is still
-- reporting, progress says work is moving, and a page that cannot tell a
-- crashed service from a slow model is the failure this exists to end.
--
-- All three are NULL until the job starts. They sit on the already-FORCE'd, RLS-scoped
-- uploaded_transcripts, so the exact-set isolation test is unaffected.
ALTER TABLE uploaded_transcripts
ADD COLUMN progress JSONB,
ADD COLUMN progress_at TIMESTAMPTZ,
ADD COLUMN alive_at TIMESTAMPTZ;
@@ -25,12 +25,14 @@ var ErrUploadNotFound = errors.New("store: uploaded transcript not found")
// superuser connection the unit tests use.
// CreateUpload records a new upload in the queued state and returns its id.
func (s *Store) CreateUpload(ctx context.Context, userID, title string) (string, error) {
// hadVideo records that the file carried a video track (only its audio is used);
// it is kept for the life of the row so the page can say so.
func (s *Store) CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error) {
var id string
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
`INSERT INTO uploaded_transcripts (user_id, title) VALUES ($1, $2) RETURNING id`,
userID, title).Scan(&id)
`INSERT INTO uploaded_transcripts (user_id, title, had_video) VALUES ($1, $2, $3) RETURNING id`,
userID, title, hadVideo).Scan(&id)
}); err != nil {
return "", fmt.Errorf("store: create upload: %w", err)
}
@@ -50,12 +52,40 @@ func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.
if err != nil {
return fmt.Errorf("store: complete upload: marshal segments: %w", err)
}
// A nil Reconciliation must be SQL NULL, not the JSON text "null": the page decides
// whether to claim a second opinion by whether this column is NULL.
var rec []byte
if t.Reconciliation != nil {
if rec, err = json.Marshal(t.Reconciliation); err != nil {
return fmt.Errorf("store: complete upload: marshal reconciliation: %w", err)
}
}
return s.updateUpload(ctx, userID, id, "complete upload",
`UPDATE uploaded_transcripts
SET status = 'done', content = $3, segments = $4, endpoint = $5,
audio_seconds = $6, error_kind = NULL, finished_at = NOW()
audio_seconds = $6, reconciliation = $7, error_kind = NULL, finished_at = NOW()
WHERE id = $1 AND user_id = $2`,
t.Text, segs, t.Endpoint, t.AudioSeconds)
t.Text, segs, t.Endpoint, t.AudioSeconds, rec)
}
// SaveProgress records the job's phases and when something last really changed
// (ADR-035). It replaces the previous snapshot: the Tracker holds the whole plan.
func (s *Store) SaveProgress(ctx context.Context, userID, id string, steps []domain.ProgressStep, at time.Time) error {
b, err := json.Marshal(steps)
if err != nil {
return fmt.Errorf("store: save progress: marshal: %w", err)
}
return s.updateUpload(ctx, userID, id, "save progress",
`UPDATE uploaded_transcripts SET progress = $3, progress_at = $4
WHERE id = $1 AND user_id = $2`, b, at)
}
// Heartbeat stamps the upload as alive now. It does not touch progress_at: alive says
// the service is still reporting, not that work is moving (ADR-035).
func (s *Store) Heartbeat(ctx context.Context, userID, id string) error {
return s.updateUpload(ctx, userID, id, "heartbeat",
`UPDATE uploaded_transcripts SET alive_at = NOW()
WHERE id = $1 AND user_id = $2`)
}
// FailUpload moves an upload to failed with a typed reason.
@@ -155,19 +185,21 @@ func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
}
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
audio_seconds, error_kind, created_at, finished_at FROM uploaded_transcripts`
audio_seconds, error_kind, had_video, reconciliation, progress, progress_at, alive_at,
created_at, finished_at FROM uploaded_transcripts`
// scanUpload scans one uploadSelect row. withText controls whether the transcript
// body and segments are kept (GetUpload) or dropped (ListUploads).
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
var (
status, errKind, endpoint, content *string
segments []byte
segments, reconciliation, progress []byte
seconds *float64
finished *time.Time
finished, progressAt, aliveAt *time.Time
)
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
&seconds, &errKind, &u.CreatedAt, &finished); err != nil {
&seconds, &errKind, &u.HadVideo, &reconciliation, &progress, &progressAt, &aliveAt,
&u.CreatedAt, &finished); err != nil {
return err
}
u.Status = domain.UploadStatus(deref(status))
@@ -182,6 +214,24 @@ func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error
if !withText {
return nil
}
if len(progress) > 0 {
if err := json.Unmarshal(progress, &u.Progress); err != nil {
return fmt.Errorf("unmarshal progress: %w", err)
}
}
if progressAt != nil {
u.ProgressAt = *progressAt
}
if aliveAt != nil {
u.AliveAt = *aliveAt
}
if len(reconciliation) > 0 {
var r domain.Reconciliation
if err := json.Unmarshal(reconciliation, &r); err != nil {
return fmt.Errorf("unmarshal reconciliation: %w", err)
}
u.Reconciliation = &r
}
u.Content = deref(content)
if len(segments) > 0 {
if err := json.Unmarshal(segments, &u.Segments); err != nil {
@@ -0,0 +1,137 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// Progress and heartbeat of an upload job (ADR-035, migration 020). Like the other
// upload store tests these run as the superuser, so the cross-user cases prove the
// statement's own user_id predicate; the database-enforced isolation is covered by the
// RLS tests, and migration 020 adds columns to the already-FORCE'd table, not a table.
func startedUpload(t *testing.T, s *store.Store) string {
t.Helper()
seedUsers(t, userA, userB)
id, err := s.CreateUpload(context.Background(), userA, "m", false)
require.NoError(t, err)
return id
}
func TestUploadProgress_AFreshUploadHasNoProgressAndNoHeartbeat(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
got, err := s.GetUpload(context.Background(), userA, id)
require.NoError(t, err)
assert.Empty(t, got.Progress)
assert.True(t, got.ProgressAt.IsZero())
assert.True(t, got.AliveAt.IsZero())
}
func TestUploadProgress_StepsAndTheirTimesRoundTripInOrder(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
steps := []domain.ProgressStep{
{Key: "audio", Label: "Preparing audio", State: domain.StepDone, StartedAt: at, FinishedAt: at.Add(40 * time.Second)},
{Key: "decide", Label: "Deciding", State: domain.StepRunning, StartedAt: at.Add(time.Minute), Done: 12, Total: 282},
{Key: "save", Label: "Saving", State: domain.StepPending},
}
require.NoError(t, s.SaveProgress(ctx, userA, id, steps, at.Add(2*time.Minute)))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
require.Len(t, got.Progress, 3)
assert.Equal(t, []string{"audio", "decide", "save"}, []string{got.Progress[0].Key, got.Progress[1].Key, got.Progress[2].Key})
assert.Equal(t, domain.StepRunning, got.Progress[1].State)
assert.Equal(t, 12, got.Progress[1].Done)
assert.Equal(t, 282, got.Progress[1].Total)
assert.True(t, got.Progress[0].FinishedAt.Equal(at.Add(40*time.Second)), "a step's finish time must survive the round trip")
assert.True(t, got.Progress[2].StartedAt.IsZero(), "a pending step has no start time, not a zero date")
assert.True(t, got.ProgressAt.Equal(at.Add(2*time.Minute)))
}
func TestUploadProgress_AHeartbeatMovesAliveAtAndNotProgressAt(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "audio", Label: "a", State: domain.StepRunning}}, at))
before := time.Now().Add(-time.Second)
require.NoError(t, s.Heartbeat(ctx, userA, id))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.True(t, got.AliveAt.After(before), "the heartbeat must stamp the row with now")
assert.True(t, got.ProgressAt.Equal(at), "alive is not progress: a heartbeat must not move progress_at")
}
func TestUploadProgress_ASecondSaveReplacesTheFirst(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC)
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepRunning}}, at))
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepDone}, {Key: "b", Label: "B", State: domain.StepRunning}}, at.Add(time.Minute)))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
require.Len(t, got.Progress, 2)
assert.Equal(t, domain.StepDone, got.Progress[0].State)
}
func TestUploadProgress_ByAnotherUserChangesNothing(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
errSave := s.SaveProgress(ctx, userB, id, []domain.ProgressStep{{Key: "x", Label: "X", State: domain.StepDone}}, time.Now())
errBeat := s.Heartbeat(ctx, userB, id)
assert.ErrorIs(t, errSave, store.ErrUploadNotFound)
assert.ErrorIs(t, errBeat, store.ErrUploadNotFound)
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Empty(t, got.Progress, "B's write must not have reached A's upload")
assert.True(t, got.AliveAt.IsZero())
}
func TestUploadProgress_ListDoesNotDragItAlong(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepRunning}}, time.Now()))
rows, err := s.ListUploads(ctx, userA)
require.NoError(t, err)
require.Len(t, rows, 1)
assert.Empty(t, rows[0].Progress, "the list view needs a status, not every row's phases")
}
func TestUploadProgress_SurvivesCompletion(t *testing.T) {
s := newStore(t)
id := startedUpload(t, s)
ctx := context.Background()
require.NoError(t, s.StartTranscribing(ctx, userA, id))
require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "a", Label: "A", State: domain.StepDone}}, time.Now()))
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e"}))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
require.Len(t, got.Progress, 1, "a finished upload keeps its record of what was done, and how long each step took")
}
@@ -33,7 +33,7 @@ func TestCreateUpload_StartsQueuedAndIsOwnedByTheCreator(t *testing.T) {
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "Teams export")
id, err := s.CreateUpload(ctx, userA, "Teams export", false)
require.NoError(t, err)
got, err := s.GetUpload(ctx, userA, id)
@@ -50,7 +50,7 @@ func TestUploadLifecycle_CompletedKeepsTextSegmentsEndpointAndDuration(t *testin
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m")
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
@@ -84,7 +84,7 @@ func TestUploadLifecycle_FailedRecordsTypedKindAndNoContent(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m")
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
@@ -102,7 +102,7 @@ func TestGetUpload_AnotherUsersIDIsNotFound(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "A's meeting")
id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
require.NoError(t, err)
_, err = s.GetUpload(ctx, userB, id)
@@ -115,7 +115,7 @@ func TestUploadWrites_ByAnotherUserChangeNothing(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "A's meeting")
id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
require.NoError(t, err)
assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound)
@@ -132,11 +132,11 @@ func TestListUploads_ReturnsOnlyTheCallersNewestFirstWithoutTranscriptText(t *te
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
first, err := s.CreateUpload(ctx, userA, "first")
first, err := s.CreateUpload(ctx, userA, "first", false)
require.NoError(t, err)
second, err := s.CreateUpload(ctx, userA, "second")
second, err := s.CreateUpload(ctx, userA, "second", false)
require.NoError(t, err)
_, err = s.CreateUpload(ctx, userB, "B's")
_, err = s.CreateUpload(ctx, userB, "B's", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, first))
require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"}))
@@ -160,12 +160,12 @@ func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T
_, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid)
require.NoError(t, err)
}
queued, err := s.CreateUpload(ctx, userA, "queued")
queued, err := s.CreateUpload(ctx, userA, "queued", false)
require.NoError(t, err)
running, err := s.CreateUpload(ctx, userB, "running")
running, err := s.CreateUpload(ctx, userB, "running", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userB, running))
done, err := s.CreateUpload(ctx, userA, "done")
done, err := s.CreateUpload(ctx, userA, "done", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, done))
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"}))
@@ -186,3 +186,101 @@ func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T
assert.Equal(t, domain.UploadDone, kept.Status, "a finished transcript must survive the sweep")
assert.Equal(t, "kept", kept.Content)
}
func TestCreateUpload_RecordsWhetherTheFileHadAVideoTrack(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
withVideo, err := s.CreateUpload(ctx, userA, "camera off", true)
require.NoError(t, err)
audioOnly, err := s.CreateUpload(ctx, userA, "voice only", false)
require.NoError(t, err)
gotVideo, err := s.GetUpload(ctx, userA, withVideo)
require.NoError(t, err)
gotAudio, err := s.GetUpload(ctx, userA, audioOnly)
require.NoError(t, err)
listed, err := s.ListUploads(ctx, userA)
require.NoError(t, err)
assert.True(t, gotVideo.HadVideo, "the page must be able to say the video was ignored")
assert.False(t, gotAudio.HadVideo)
byID := map[string]bool{}
for _, u := range listed {
byID[u.ID] = u.HadVideo
}
assert.Equal(t, map[string]bool{withVideo: true, audioOnly: false}, byID, "the list carries it too")
}
func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
done, err := s.CreateUpload(ctx, userA, "d", true)
require.NoError(t, err)
failed, err := s.CreateUpload(ctx, userA, "f", true)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, done))
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "x", Endpoint: "e"}))
require.NoError(t, s.FailUpload(ctx, userA, failed, domain.UploadErrTranscriptionFailed))
for _, id := range []string{done, failed} {
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job")
}
}
func TestUploadLifecycle_ReconciliationRoundTripsWithItsContestedSpots(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
want := &domain.Reconciliation{
Status: domain.ReconcileDone, Base: "iguana/pianissimo", Alt: "iguana/kb-whisper", Reconciler: "iguana/gemma4-26b",
Disputes: 380, AutoResolved: 90, Adjudicated: 285, Changed: 120, Unsure: 5, Errors: 0,
Contested: []domain.ContestedSpot{
{Seconds: 2210.5, Base: "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", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e"}))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Nil(t, got.Reconciliation, "no reconciliation is not an empty one: the page must not claim a second opinion")
}
func TestUploadLifecycle_ASkippedReconciliationKeepsItsReason(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m", false)
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
rec := &domain.Reconciliation{Status: domain.ReconcileSkipped, Reason: "the model failed 3 times in a row: gateway down", Errors: 3}
require.NoError(t, s.CompleteUpload(ctx, userA, id, domain.Transcription{Text: "x", Endpoint: "e", Reconciliation: rec}))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, rec, got.Reconciliation)
}
+114
View File
@@ -0,0 +1,114 @@
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 this ensemble reports, so the page can
// show them before they run. The fallback is a phase only when one is configured.
func (e *Ensemble) Plan() []progress.Planned {
plan := []progress.Planned{
{Key: "engine_a", Label: "Transcribing with " + e.base.Name()},
{Key: "engine_b", Label: "Transcribing with " + e.alt.Name()},
{Key: "compare", Label: "Comparing the two transcripts"},
{Key: "decide", Label: "Deciding the differences"},
}
if e.fallback != nil {
plan = append(plan, progress.Planned{Key: "fallback", Label: "Cloud fallback (" + e.fallback.Name() + ")"})
}
return plan
}
// Transcribe implements uploads.Transcriber. Each phase is reported through the
// progress.Reporter in ctx (a no-op when nobody listens).
func (e *Ensemble) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
rep := progress.From(ctx)
b, berr := e.run(ctx, rep, "engine_a", e.base, wavPath)
if err := ctx.Err(); err != nil {
return domain.Transcription{}, err
}
a, aerr := e.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
}
// 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
}
@@ -0,0 +1,162 @@
package transcribe_test
import (
"context"
"errors"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/progress"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// What the Ensemble reports to the page and the log (ADR-035). The reporter here is a
// recording double carried in the context, exactly as the worker's Tracker is.
type recReporter struct {
mu sync.Mutex
events []string
notes []map[string]any
}
func (r *recReporter) add(e string) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, e)
}
func (r *recReporter) Begin(key string, _ int) { r.add("begin:" + key) }
func (r *recReporter) Advance(key string, _ int) { r.add("advance:" + key) }
func (r *recReporter) Done(key string) { r.add("done:" + key) }
func (r *recReporter) Fail(key string, _ error) { r.add("fail:" + key) }
func (r *recReporter) Note(msg string, kv ...any) {
m := map[string]any{"msg": msg}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i].(string)] = kv[i+1]
}
r.mu.Lock()
r.notes = append(r.notes, m)
r.mu.Unlock()
r.add("note:" + msg)
}
func (r *recReporter) joined() string {
r.mu.Lock()
defer r.mu.Unlock()
return strings.Join(r.events, ",")
}
func TestEnsemblePlan_DeclaresBothEnginesAndTheReconcileStepsByName(t *testing.T) {
r := newEnsemble("a", "a", nil, nil, false, &verdictAdj{})
plan := r.e.Plan()
require.Len(t, plan, 4)
assert.Equal(t, []string{"engine_a", "engine_b", "compare", "decide"}, []string{plan[0].Key, plan[1].Key, plan[2].Key, plan[3].Key})
assert.Contains(t, plan[0].Label, "iguana/pianissimo", "the page says which engine is running")
assert.Contains(t, plan[1].Label, "iguana/kb-whisper")
}
func TestEnsemblePlan_TheCloudFallbackIsAStepOnlyWhenOneIsConfigured(t *testing.T) {
with := newEnsemble("a", "a", nil, nil, true, &verdictAdj{}).e.Plan()
without := newEnsemble("a", "a", nil, nil, false, &verdictAdj{}).e.Plan()
assert.Equal(t, "fallback", with[len(with)-1].Key)
assert.Len(t, without, 4, "no fallback configured: the page must not promise one")
}
func TestEnsembleProgress_BothEnginesAreReportedInTheOrderTheyRun(t *testing.T) {
rep := &recReporter{}
r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, false, &verdictAdj{v: reconcile.VerdictAlt})
_, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
require.NoError(t, err)
got := rep.joined()
a, aDone := strings.Index(got, "begin:engine_a"), strings.Index(got, "done:engine_a")
b, bDone := strings.Index(got, "begin:engine_b"), strings.Index(got, "done:engine_b")
require.True(t, a >= 0 && aDone > a && b > aDone && bDone > b, "events out of order: %s", got)
assert.NotContains(t, got, "fail:")
}
func TestEnsembleProgress_AnEngineThatFailsIsShownFailedAndTheOtherStillRuns(t *testing.T) {
rep := &recReporter{}
r := newEnsemble("text", "text", errors.New("pianissimo down"), nil, false, &verdictAdj{})
got, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
require.NoError(t, err, "one engine down is a degraded result, not a failed job")
assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
ev := rep.joined()
assert.Contains(t, ev, "fail:engine_a")
assert.Contains(t, ev, "done:engine_b")
assert.NotContains(t, ev, "begin:compare", "nothing to compare with one engine")
}
func TestEnsembleProgress_TheFallbackIsReportedOnlyWhenBothEnginesFailed(t *testing.T) {
rep := &recReporter{}
r := newEnsemble("t", "t", errors.New("a down"), errors.New("b down"), true, &verdictAdj{})
_, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
require.NoError(t, err)
ev := rep.joined()
assert.Contains(t, ev, "fail:engine_a")
assert.Contains(t, ev, "fail:engine_b")
assert.Contains(t, ev, "begin:fallback")
assert.Contains(t, ev, "done:fallback")
rep2 := &recReporter{}
ok := newEnsemble("x", "x", nil, nil, true, &verdictAdj{})
_, err = ok.e.Transcribe(progress.With(context.Background(), rep2), "/a.wav")
require.NoError(t, err)
assert.NotContains(t, rep2.joined(), "fallback", "a healthy run never touches the cloud step")
}
// cancellingBackend ends the job in the middle of its own call, like a shutdown.
type cancellingBackend struct {
name string
cancel context.CancelFunc
}
func (c cancellingBackend) Name() string { return c.name }
func (c cancellingBackend) Transcribe(ctx context.Context, _ string) (domain.Transcription, error) {
c.cancel()
return domain.Transcription{}, ctx.Err()
}
func TestEnsembleProgress_AShutdownMidEngineIsNotReportedAsAFailedEngine(t *testing.T) {
rep := &recReporter{}
ctx, cancel := context.WithCancel(progress.With(context.Background(), rep))
calls := &[]string{}
alt := &orderedBackend{name: "iguana/kb-whisper", text: "x", calls: calls}
e := transcribe.NewEnsemble(cancellingBackend{name: "iguana/pianissimo", cancel: cancel}, alt, nil, &verdictAdj{}, ensembleOpts)
_, err := e.Transcribe(ctx, "/a.wav")
require.ErrorIs(t, err, context.Canceled)
assert.NotContains(t, rep.joined(), "fail:", "a stopped job is not a broken engine: the page must not blame one")
assert.Empty(t, *calls, "and the second engine never starts")
}
func TestEnsembleProgress_TheReconcileCountsAreNotedForTheOperator(t *testing.T) {
rep := &recReporter{}
r := newEnsemble("Totalt 1015 kronor idag.", "Totalt 1050 kronor idag.", nil, nil, false, &verdictAdj{v: reconcile.VerdictAlt})
_, err := r.e.Transcribe(progress.With(context.Background(), rep), "/a.wav")
require.NoError(t, err)
require.Len(t, rep.notes, 1)
n := rep.notes[0]
assert.Equal(t, "reconciled", n["msg"])
assert.Equal(t, 1, n["disputes"])
assert.Equal(t, 1, n["changed"])
assert.Equal(t, 0, n["unsure"])
assert.Equal(t, 0, n["errors"])
}
@@ -0,0 +1,36 @@
package transcribe_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// When one engine is down the page says so and why; the "why" is a kind, not the
// engine's error text, which can carry provider output (adversarial review F3).
func TestEnsemble_AFailedEnginesReasonIsAKindNotItsErrorText(t *testing.T) {
r := newEnsemble("text", "text", nil, errors.New("kb-whisper said: zebrakaka secret words"), false, &verdictAdj{v: reconcile.VerdictAlt})
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.NotContains(t, got.Reconciliation.Reason, "zebrakaka")
assert.Contains(t, got.Reconciliation.Reason, "iguana/kb-whisper failed", "which engine failed is still said")
}
func TestEnsemble_AnHTTPFailureShowsItsStatusCodeAsTheKind(t *testing.T) {
r := newEnsemble("text", "text", &transcribe.StatusError{Code: 502, Snippet: "zebrakaka"}, nil, false, &verdictAdj{})
got, err := r.e.Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Contains(t, got.Reconciliation.Reason, "http_502")
assert.NotContains(t, got.Reconciliation.Reason, "zebrakaka")
}
@@ -0,0 +1,206 @@
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)
}
+47 -6
View File
@@ -31,6 +31,9 @@ type StatusError struct {
Snippet string
}
// HTTPStatus implements errkind.HTTPStatuser: the code, never the snippet.
func (e *StatusError) HTTPStatus() int { return e.Code }
func (e *StatusError) Error() string {
return fmt.Sprintf("transcribe: gateway returned HTTP %d: %s", e.Code, e.Snippet)
}
@@ -62,7 +65,7 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: open audio: %w", err)
}
defer f.Close()
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: stat audio: %w", err)
@@ -95,7 +98,7 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snip, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
return domain.Transcription{}, &StatusError{Code: resp.StatusCode, Snippet: strings.TrimSpace(string(snip))}
@@ -110,8 +113,9 @@ func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcrip
}
// verboseJSON is the subset of the verbose_json response used. Both backends
// return it; they differ in segment granularity (iguana: whole seconds, berget:
// sub-second) and in the language token, neither of which matters here.
// return it; they differ in segment granularity (iguana: whole seconds and
// sub-sentence pieces, berget: sub-second sentences), in the language token, and in
// whether a segment's text starts with a space.
type verboseJSON struct {
Text string `json:"text"`
Duration float64 `json:"duration"`
@@ -124,13 +128,21 @@ type verboseJSON struct {
// parseVerbose normalises a verbose_json body. Whisper's final window can end past
// the audio (9.0 s for 4.25 s of audio was observed), so segment ends are clamped
// to the reported duration.
// to the reported duration. Text is rebuilt from the segments, not taken from the
// response's `text` field: see joinSegments.
func parseVerbose(r io.Reader) (domain.Transcription, error) {
var v verboseJSON
if err := json.NewDecoder(r).Decode(&v); err != nil {
return domain.Transcription{}, fmt.Errorf("decode response: %w", err)
}
text := strings.TrimSpace(v.Text)
raw := make([]string, 0, len(v.Segments))
for _, s := range v.Segments {
raw = append(raw, s.Text)
}
text := joinSegments(raw)
if text == "" {
text = collapseSpace(v.Text)
}
if text == "" {
return domain.Transcription{}, ErrEmptyTranscript
}
@@ -145,3 +157,32 @@ func parseVerbose(r io.Reader) (domain.Transcription, error) {
}
return t, nil
}
// joinSegments rebuilds the running text from raw (untrimmed) segment texts.
//
// whisper.cpp wraps its output at ~60 characters and a wrap can land INSIDE a word
// ("nyt" | "t godkännande"): a segment that begins with a space starts a new word,
// one that does not continues the previous one, and the response's own `text`
// field joins them with newlines, which breaks the word. So when any segment
// carries a leading space the raw texts are concatenated as they are. Backends
// that return whole sentences with no leading space (berget) are joined with a
// space instead — concatenating those would glue sentences together.
func joinSegments(raw []string) string {
leadingSpace := false
for _, s := range raw {
if strings.HasPrefix(s, " ") {
leadingSpace = true
break
}
}
sep := " "
if leadingSpace {
sep = ""
}
return collapseSpace(strings.Join(raw, sep))
}
// collapseSpace turns every run of whitespace (including newlines) into one space.
func collapseSpace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
+84 -1
View File
@@ -3,11 +3,13 @@ package transcribe_test
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -15,6 +17,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
)
// The httptest server stands in for the LiteLLM gateway: it is the process
@@ -121,7 +124,7 @@ func TestGateway_NormalisesTheVerboseJSONResponse(t *testing.T) {
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
require.NoError(t, err)
assert.Equal(t, "God morgon allihopa.\n Vi går igenom läget.", got.Text)
assert.Equal(t, "God morgon allihopa. Vi går igenom läget.", got.Text)
assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is part of the result")
assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
assert.Equal(t, []domain.Segment{
@@ -172,3 +175,83 @@ func TestGateway_MissingAudioFileIsAnError(t *testing.T) {
assert.Error(t, err)
}
// whisper.cpp wraps its output at ~60 characters, and a wrap can land INSIDE a word:
// the continuation segment has no leading space, the `text` field joins segments
// with newlines, and a segment can be a lone ".". Measured on real gateway output
// 2026-10-01: 7% of a one-hour recording's segments were such fragments. Joining by
// newline hands the chat model broken words ("nyt\nt"), so Text is rebuilt from the
// segments: a segment that begins with a space starts a new word, one that does not
// continues the previous one.
func TestGateway_RejoinsWordsWhisperCppSplitAcrossSegments(t *testing.T) {
reply := `{
"text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen\n.\n Det kräver ett nyt\nt godkännande från beställaren.\n Framförallt på arbetspl\natsen.\n",
"language": "swedish", "duration": 20,
"segments": [
{"start": 0, "end": 4, "text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen"},
{"start": 4, "end": 5, "text": "."},
{"start": 5, "end": 8, "text": " Det kräver ett nyt"},
{"start": 8, "end": 11, "text": "t godkännande från beställaren."},
{"start": 11, "end": 14, "text": " Framförallt på arbetspl"},
{"start": 14, "end": 16, "text": "atsen."}
]}`
srv := serve(t, 200, reply, nil)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
require.NoError(t, err)
assert.Equal(t, "Vi ligger just nu 12 procent över den ursprungliga kalkylen. "+
"Det kräver ett nytt godkännande från beställaren. Framförallt på arbetsplatsen.", got.Text)
assert.Len(t, got.Segments, 6, "the timed segments are kept as the backend gave them (trimmed)")
assert.Equal(t, ".", got.Segments[1].Text)
}
func TestGateway_SentenceLevelSegmentsWithNoLeadingSpaceAreJoinedWithASpace(t *testing.T) {
// berget/kb-whisper returns whole sentences with no leading space; rebuilding by
// concatenation would glue them ("allihopa!Vi").
reply := `{"text": "God morgon allihopa!\nVi går igenom läget.", "language": "sv", "duration": 5,
"segments": [
{"start": 0.05, "end": 1.2, "text": "God morgon allihopa!"},
{"start": 1.6, "end": 4.2, "text": "Vi går igenom läget."}
]}`
srv := serve(t, 200, reply, nil)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "berget/kb-whisper", srv.Client())
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
require.NoError(t, err)
assert.Equal(t, "God morgon allihopa! Vi går igenom läget.", got.Text)
}
func TestGateway_WithNoSegmentsTheTextFieldIsUsedWithWhitespaceCollapsed(t *testing.T) {
srv := serve(t, 200, `{"text": " Hej allihopa.\n Vi börjar. ", "duration": 3, "segments": []}`, nil)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
require.NoError(t, err)
assert.Equal(t, "Hej allihopa. Vi börjar.", got.Text)
}
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")
}
+33
View File
@@ -0,0 +1,33 @@
package transcribe
import (
"context"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// WithTimeout gives every call to b its own deadline. A wedged engine does not answer at
// all (iguana after a Metal OOM, infra#273), and with only the job's deadline it used up
// the whole job before a fallback could run -- and Chain then refuses the fallback because
// the job context has expired. With its own deadline the call fails while the job is still
// alive, which is exactly when the fallback, or the other engine, can take over.
//
// A zero or negative d returns b unchanged. The wrapper is invisible: Name is b's.
func WithTimeout(b Backend, d time.Duration) Backend {
if d <= 0 {
return b
}
return timeoutBackend{Backend: b, d: d}
}
type timeoutBackend struct {
Backend
d time.Duration
}
func (t timeoutBackend) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
ctx, cancel := context.WithTimeout(ctx, t.d)
defer cancel()
return t.Backend.Transcribe(ctx, wavPath)
}
@@ -0,0 +1,104 @@
package transcribe_test
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// A wedged engine (iguana after a Metal OOM, infra#273) does not answer at all. With only
// the job's deadline, the primary used up the whole job before any fallback could run, and
// the chain then refused the fallback because the job context had expired. Each engine call
// gets its own deadline (adversarial review F4).
type hangingBackend struct{ name string }
func (h hangingBackend) Name() string { return h.name }
func (hangingBackend) Transcribe(ctx context.Context, _ string) (domain.Transcription, error) {
<-ctx.Done()
return domain.Transcription{}, ctx.Err()
}
func TestWithTimeout_AHungBackendFailsAtItsOwnDeadlineWhileTheJobIsStillAlive(t *testing.T) {
job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
b := transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, 40*time.Millisecond)
start := time.Now()
_, err := b.Transcribe(job, "/a.wav")
require.Error(t, err)
assert.Equal(t, "timeout", errkind.Of(err))
assert.Less(t, time.Since(start), 2*time.Second, "it must give up at its own deadline, not wait for the job's")
assert.NoError(t, job.Err(), "the job context is untouched, which is what lets a fallback run")
assert.Equal(t, "iguana/kb-whisper", b.Name(), "the wrapper is invisible in the recorded endpoint")
}
func TestWithTimeout_ASuccessPassesStraightThrough(t *testing.T) {
calls := &[]string{}
inner := &orderedBackend{name: "iguana/kb-whisper", text: "hej", calls: calls}
got, err := transcribe.WithTimeout(inner, time.Minute).Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, "hej", got.Text)
}
func TestWithTimeout_ZeroMeansNoDeadlineOfItsOwn(t *testing.T) {
inner := &orderedBackend{name: "x", text: "hej", calls: &[]string{}}
assert.Same(t, inner, transcribe.WithTimeout(inner, 0), "a zero timeout must not wrap or change anything")
}
func TestChain_AHungPrimaryReachesTheFallbackWhenItHasItsOwnDeadline(t *testing.T) {
calls := &[]string{}
fb := &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
c := transcribe.NewChain(transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, 40*time.Millisecond), fb)
job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := c.Transcribe(job, "/a.wav")
require.NoError(t, err, "a wedged primary is exactly what the fallback exists for")
assert.Equal(t, "berget/kb-whisper", got.Endpoint)
}
func TestChain_AJobThatEndedWhileThePrimaryHungStillNeverReachesTheFallback(t *testing.T) {
calls := &[]string{}
fb := &orderedBackend{name: "berget/kb-whisper", text: "fallback text", calls: calls}
c := transcribe.NewChain(transcribe.WithTimeout(hangingBackend{name: "iguana/kb-whisper"}, time.Minute), fb)
job, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
_, err := c.Transcribe(job, "/a.wav")
require.Error(t, err)
assert.Empty(t, *calls, "the job's own deadline or a cancel must still stop the audio from reaching a cloud endpoint")
}
func TestEnsemble_AHungEngineIsASoftFailureAndTheOtherEnginesTextIsUsed(t *testing.T) {
calls := &[]string{}
alt := &orderedBackend{name: "iguana/kb-whisper", text: "text from the other engine", calls: calls}
e := transcribe.NewEnsemble(
transcribe.WithTimeout(hangingBackend{name: "iguana/pianissimo"}, 40*time.Millisecond), alt, nil,
&verdictAdj{v: reconcile.VerdictAlt}, ensembleOpts)
job, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := e.Transcribe(job, "/a.wav")
require.NoError(t, err)
assert.Equal(t, "text from the other engine", got.Text)
assert.Equal(t, domain.ReconcileSingle, got.Reconciliation.Status)
assert.Contains(t, got.Reconciliation.Reason, "timeout")
assert.False(t, errors.Is(err, context.DeadlineExceeded))
}
+80 -1
View File
@@ -3,7 +3,9 @@ package config
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
)
@@ -26,6 +28,10 @@ type UploadConfig struct {
// TranscribeTimeout bounds one job (conversion + transcription); a 90 minute
// recording took ~7.5 minutes on iguana.
TranscribeTimeout time.Duration
// TranscribeEngineTimeout bounds ONE engine call, so a wedged engine fails while the
// job is still alive and the fallback (or the other engine) can take over. The job
// timeout alone cannot do that (ADR-035 review).
TranscribeEngineTimeout time.Duration
// UploadTmpDir is where uploads are spooled and converted. Audio is deleted
// after transcription, so this holds at most the in-flight files.
UploadTmpDir string
@@ -42,6 +48,16 @@ type UploadConfig struct {
// NOT the summarizer default: koala/phi4-mini has an 8,192-token context and
// returns HTTP 400 on an hour of speech (~13,000 tokens).
UploadChatModel string
// ReconcileBaseModel turns on two-engine transcription (ADR-034): the engine whose
// TEXT is the base of the merged transcript (e.g. iguana/pianissimo). TranscribeModel
// stays the other engine (e.g. kb-whisper). Empty = off.
ReconcileBaseModel string
// ReconcileModel is the LOCAL chat model that chooses between the two engines'
// disagreements. Required when ReconcileBaseModel is set.
ReconcileModel string
// ReconcileCheckOrder asks the model twice per dispute with the reading order
// swapped and keeps only agreement. It doubles the time; off by default.
ReconcileCheckOrder bool
}
const (
@@ -49,7 +65,8 @@ const (
defaultUploadMaxMinutes = 90
defaultTranscribeModel = "iguana/kb-whisper"
defaultTranscribeFallbackModel = "berget/kb-whisper"
defaultTranscribeTimeout = 30 * time.Minute
defaultTranscribeTimeout = 60 * time.Minute
defaultTranscribeEngineTimeout = 20 * time.Minute
defaultMaxUploadedChars = 90000
defaultUploadChatModel = "iguana/gemma4-26b"
)
@@ -62,6 +79,8 @@ func loadUploadConfig() (UploadConfig, error) {
FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"),
FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"),
UploadChatModel: envOr("TAPIR_UPLOAD_CHAT_MODEL", defaultUploadChatModel),
ReconcileBaseModel: os.Getenv("TAPIR_RECONCILE_BASE_MODEL"),
ReconcileModel: os.Getenv("TAPIR_RECONCILE_MODEL"),
}
if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" {
@@ -93,6 +112,29 @@ func loadUploadConfig() (UploadConfig, error) {
}
u.TranscribeTimeout = timeout
engineTimeout, err := durationOr("TAPIR_TRANSCRIBE_ENGINE_TIMEOUT", defaultTranscribeEngineTimeout)
if err != nil {
return UploadConfig{}, err
}
if engineTimeout <= 0 {
return UploadConfig{}, fmt.Errorf("config: TAPIR_TRANSCRIBE_ENGINE_TIMEOUT must be positive, got %v", engineTimeout)
}
u.TranscribeEngineTimeout = engineTimeout
if v := os.Getenv("TAPIR_RECONCILE_CHECK_ORDER"); v != "" {
on, err := strconv.ParseBool(v)
if err != nil {
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)
if err != nil {
return UploadConfig{}, err
@@ -116,3 +158,40 @@ func positiveInt(key string, fallback int) (int, error) {
}
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
@@ -0,0 +1,101 @@
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)
}
}
}
+79 -3
View File
@@ -12,9 +12,10 @@ func clearUploadEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"TAPIR_UPLOADS_ENABLED", "TAPIR_UPLOAD_MAX_MB", "TAPIR_UPLOAD_MAX_MINUTES",
"TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT",
"TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT", "TAPIR_TRANSCRIBE_ENGINE_TIMEOUT",
"TAPIR_UPLOAD_TMP_DIR", "TAPIR_FFMPEG_BIN", "TAPIR_FFPROBE_BIN",
"TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", "TAPIR_UPLOAD_CHAT_MODEL",
"TAPIR_RECONCILE_BASE_MODEL", "TAPIR_RECONCILE_MODEL", "TAPIR_RECONCILE_CHECK_ORDER",
} {
unset(t, k)
}
@@ -43,8 +44,11 @@ func TestLoad_UploadDefaults(t *testing.T) {
if c.TranscribeFallbackModel != "berget/kb-whisper" {
t.Errorf("TranscribeFallbackModel = %q", c.TranscribeFallbackModel)
}
if c.TranscribeTimeout != 30*time.Minute {
t.Errorf("TranscribeTimeout = %v, want 30m (a 90 min recording took ~7.5 min)", c.TranscribeTimeout)
if c.TranscribeTimeout != 60*time.Minute {
t.Errorf("TranscribeTimeout = %v, want 60m (ADR-034: two engines plus ~300 adjudications)", c.TranscribeTimeout)
}
if c.ReconcileBaseModel != "" || c.ReconcileModel != "" || c.ReconcileCheckOrder {
t.Errorf("reconciliation must be OFF by default: %q %q %v", c.ReconcileBaseModel, c.ReconcileModel, c.ReconcileCheckOrder)
}
if c.UploadTmpDir != os.TempDir() {
t.Errorf("UploadTmpDir = %q, want os.TempDir()", c.UploadTmpDir)
@@ -139,3 +143,75 @@ 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")
}
}
+101 -2
View File
@@ -129,6 +129,84 @@ type Transcription struct {
Segments []Segment
Endpoint string
AudioSeconds float64
// Reconciliation is set when two engines transcribed the same audio and a local
// model chose between their disagreements (ADR-034). Nil for a single engine.
Reconciliation *Reconciliation
}
// ReconcileStatus says what became of an attempt to merge two transcripts.
type ReconcileStatus string
const (
// ReconcileDone: both engines produced text and the disagreements were resolved.
ReconcileDone ReconcileStatus = "reconciled"
// ReconcileSingle: only one engine produced text, so there was nothing to merge.
ReconcileSingle ReconcileStatus = "single_engine"
// ReconcileSkipped: both engines produced text but the model could not be used;
// the base text stands, unreconciled.
ReconcileSkipped ReconcileStatus = "skipped"
)
// ContestedSpot is a place the engines disagreed that the model judged: it changed it,
// kept the base's reading, or could not decide. Every judged spot is stored, not only
// the ones a reader should check, because the audio is not retained and the kept ones
// are what a later evaluation needs (ADR-035). The page lists only the changed and
// uncertain ones.
type ContestedSpot struct {
Seconds float64 `json:"seconds"` // roughly where in the recording
Base string `json:"base"` // what the base engine heard
Alt string `json:"alt"` // what the other engine heard
Used string `json:"used"` // "base" or "alt": the reading now in the text
Unsure bool `json:"unsure"` // the model could not decide; the base was kept
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
// plainly and the user can check the uncertain spots. The counts are exact; Contested
// is bounded.
type Reconciliation struct {
Status ReconcileStatus `json:"status"`
Reason string `json:"reason,omitempty"` // why single_engine or skipped
Base string `json:"base"` // engine whose text is the base
Alt string `json:"alt"`
Reconciler string `json:"reconciler"`
Disputes int `json:"disputes"` // places the engines disagreed
AutoResolved int `json:"auto_resolved"` // trivial: compounds, filler words
Adjudicated int `json:"adjudicated"` // decided by the model
Changed int `json:"changed"` // the alternative's reading was used
Unsure int `json:"unsure"` // base kept, flagged
Errors int `json:"errors"` // model calls that failed
Contested []ContestedSpot `json:"contested,omitempty"`
}
// 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.
@@ -152,6 +230,13 @@ const (
UploadErrInterrupted UploadErrorKind = "interrupted"
// UploadErrTranscriptionFailed: every configured transcription endpoint failed.
UploadErrTranscriptionFailed UploadErrorKind = "transcription_failed"
// UploadErrTooLong: the audio, once converted, is longer than the limit. The limit is
// first checked against the file's header, which a hostile or damaged file can lie
// about (ADR-035 review).
UploadErrTooLong UploadErrorKind = "audio_too_long"
// 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
@@ -167,6 +252,20 @@ type UploadedTranscript struct {
Endpoint string
AudioSeconds float64
ErrorKind UploadErrorKind
CreatedAt time.Time
FinishedAt time.Time // zero until the job reaches done or failed
// HadVideo records that the uploaded file carried a video track. Only its audio
// was used and the video was discarded; the page says so. It is set at upload
// and never cleared (ADR-033).
HadVideo bool
// 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
FinishedAt time.Time // zero until the job reaches done or failed
}
+51
View File
@@ -0,0 +1,51 @@
// 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
@@ -0,0 +1,82 @@
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")
}
+284
View File
@@ -0,0 +1,284 @@
// 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) error
}
const (
defaultMinSave = 2 * time.Second
logAdvanceEach = time.Minute
)
// Option tunes a Tracker.
type Option func(*Tracker)
// WithClock fixes the clock, for tests.
func WithClock(now func() time.Time) Option { return func(t *Tracker) { t.now = now } }
// WithMinSave sets the least time between two saves caused by Advance alone. Begin,
// Done, Fail and Finish always save.
func WithMinSave(d time.Duration) Option { return func(t *Tracker) { t.minSave = d } }
// Tracker implements Reporter for one job.
type Tracker struct {
ctx context.Context
sink Sink
log *slog.Logger
now func() time.Time
minSave time.Duration
mu sync.Mutex
steps []domain.ProgressStep
lastAdvanceSave time.Time
lastAdvanceLog time.Time
}
// NewTracker plans a job's phases. ctx is used for the saves, so it should outlive the
// job's own timeout: a job that timed out still gets its failure recorded.
func NewTracker(ctx context.Context, sink Sink, log *slog.Logger, plan []Planned, opts ...Option) *Tracker {
t := &Tracker{ctx: ctx, sink: sink, log: log, now: time.Now, minSave: defaultMinSave}
for _, o := range opts {
o(t)
}
for _, p := range plan {
t.steps = append(t.steps, domain.ProgressStep{Key: p.Key, Label: p.Label, State: domain.StepPending})
}
return t
}
// Start saves the plan, every phase pending.
func (t *Tracker) Start() {
t.mu.Lock()
defer t.mu.Unlock()
t.log.Info("job started", "steps", len(t.steps))
t.save()
}
// Begin implements Reporter.
func (t *Tracker) Begin(key string, total int) {
t.mu.Lock()
defer t.mu.Unlock()
s := t.find(key)
if s == nil {
return
}
s.State, s.StartedAt, s.FinishedAt, s.Done, s.Total = domain.StepRunning, t.now(), time.Time{}, 0, total
t.log.Info("step started", "step", key, "total", total)
t.save()
}
// Advance implements Reporter. The count is always kept; it is saved at most once per
// minimum interval and logged at most once a minute, so a counted phase does not turn
// into a write and a log line per unit.
func (t *Tracker) Advance(key string, done int) {
t.mu.Lock()
defer t.mu.Unlock()
s := t.find(key)
if s == nil {
return
}
s.Done = done
now := t.now()
if t.lastAdvanceLog.IsZero() || now.Sub(t.lastAdvanceLog) >= logAdvanceEach {
t.lastAdvanceLog = now
t.log.Info("step progress", "step", key, "done", done, "total", s.Total)
}
if t.lastAdvanceSave.IsZero() || now.Sub(t.lastAdvanceSave) >= t.minSave {
t.lastAdvanceSave = now
t.save()
}
}
// Done implements Reporter.
func (t *Tracker) Done(key string) {
t.mu.Lock()
defer t.mu.Unlock()
s := t.find(key)
if s == nil {
return
}
t.close(s, domain.StepDone)
t.log.Info("step finished", "step", key, "elapsed", s.FinishedAt.Sub(s.StartedAt))
t.save()
}
// Fail 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:
if err := t.sink.Beat(ctx); err != nil && ctx.Err() == nil {
t.log.Warn("progress: heartbeat not saved", "err", err)
}
case <-still.C:
t.logStill()
}
}
}
func (t *Tracker) logStill() {
t.mu.Lock()
defer t.mu.Unlock()
for _, s := range t.steps {
if s.State == domain.StepRunning {
t.log.Info("still running", "step", s.Key, "elapsed", t.now().Sub(s.StartedAt), "done", s.Done, "total", s.Total)
return
}
}
}
// close ends a phase. Callers hold t.mu.
func (t *Tracker) close(s *domain.ProgressStep, state domain.StepState) {
s.State, s.FinishedAt = state, t.now()
if s.StartedAt.IsZero() {
s.StartedAt = s.FinishedAt
}
}
// find returns the phase with this key, or nil. Callers hold t.mu.
func (t *Tracker) find(key string) *domain.ProgressStep {
for i := range t.steps {
if t.steps[i].Key == key {
return &t.steps[i]
}
}
return nil
}
// save persists a copy of the phases. Callers hold t.mu. A failed write is logged and
// dropped: progress is cosmetic and must never fail the job it describes.
func (t *Tracker) save() {
snapshot := append([]domain.ProgressStep(nil), t.steps...)
if err := t.sink.SaveProgress(t.ctx, snapshot, t.now()); err != nil && t.ctx.Err() == nil {
t.log.Warn("progress: not saved", "err", err)
}
}
type ctxKey struct{}
// With returns a context that carries r.
func With(ctx context.Context, r Reporter) context.Context {
return context.WithValue(ctx, ctxKey{}, r)
}
// From returns the Reporter carried by ctx, or one that does nothing, so a component
// can always report without checking whether anyone is listening.
func From(ctx context.Context) Reporter {
if r, ok := ctx.Value(ctxKey{}).(Reporter); ok {
return r
}
return noop{}
}
type noop struct{}
func (noop) Begin(string, int) {}
func (noop) Advance(string, int) {}
func (noop) Done(string) {}
func (noop) Fail(string, error) {}
func (noop) Note(string, ...any) {}
+402
View File
@@ -0,0 +1,402 @@
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
err error
}
func (f *fakeSink) SaveProgress(_ context.Context, steps []domain.ProgressStep, at time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
f.saves = append(f.saves, append([]domain.ProgressStep(nil), steps...))
f.at = append(f.at, at)
return f.err
}
func (f *fakeSink) Beat(context.Context) error {
f.mu.Lock()
defer f.mu.Unlock()
f.beats++
return f.err
}
func (f *fakeSink) last(t *testing.T) []domain.ProgressStep {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
require.NotEmpty(t, f.saves, "nothing was saved")
return f.saves[len(f.saves)-1]
}
func (f *fakeSink) saveCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.saves)
}
func (f *fakeSink) beatCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.beats
}
// 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) 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") })
}
+119
View File
@@ -0,0 +1,119 @@
package reconcile
import (
"context"
"fmt"
"regexp"
"strings"
)
// Verdict is an adjudicator's decision about one dispute. The zero value is
// VerdictUnsure: with no decision the base reading stands and the spot is flagged.
type Verdict int
const (
VerdictUnsure Verdict = iota
VerdictBase
VerdictAlt
)
// Request is one dispute as the adjudicator sees it: the surrounding words, the two
// readings, and which of them is shown as "Reading 1". Alternating AltFirst between
// disputes averages out position bias.
type Request struct {
Before, After string
Base, Alt string
AltFirst bool
}
// Adjudicator decides which reading of a dispute the speaker most likely said.
type Adjudicator interface {
Adjudicate(ctx context.Context, r Request) (Verdict, error)
}
// Completer is the chat surface the LLM adjudicator needs. *llm.Client satisfies it.
type Completer interface {
Complete(ctx context.Context, system, user string) (string, error)
}
// systemPrompt is the wording that was MEASURED (docs/research/
// local-model-adjudicator-sweep-2026-10-01.md): gemma4-26b 92% and gemma4-31b 97% against
// the speaker's blind answers. Changing it invalidates those figures.
const systemPrompt = "You compare two automatic transcriptions of the same Swedish speech. A single person is dictating project notes " +
"(finance and payments jargon, with English terms mixed into Swedish). The two transcriptions differ at ONE marked spot. " +
"Decide which reading is what the speaker most likely actually said, using the surrounding text and ordinary Swedish. " +
"Reply with exactly one line, either 'ANSWER: 1', 'ANSWER: 2' or 'ANSWER: UNSURE', then one short sentence of reasoning."
var answerRe = regexp.MustCompile(`(?i)ANSWER:\s*(1|2|UNSURE)\b`)
// LLMAdjudicator asks a chat model. It never invents an answer: a reply it cannot read
// is VerdictUnsure, and a transport error is returned as an error so the caller can tell
// "the model is down" from "the model is unsure".
type LLMAdjudicator struct{ c Completer }
// NewLLMAdjudicator wraps a Completer.
func NewLLMAdjudicator(c Completer) *LLMAdjudicator { return &LLMAdjudicator{c: c} }
// Adjudicate implements Adjudicator.
func (a *LLMAdjudicator) Adjudicate(ctx context.Context, r Request) (Verdict, error) {
first, second := r.Base, r.Alt
if r.AltFirst {
first, second = r.Alt, r.Base
}
user := fmt.Sprintf("Text before the spot:\n%s\n\n[[ THE SPOT ]]\n\nText after the spot:\n%s\n\nReading 1: %s\nReading 2: %s\n",
r.Before, r.After, describe(first), describe(second))
reply, err := a.c.Complete(ctx, systemPrompt, user)
if err != nil {
return VerdictUnsure, fmt.Errorf("reconcile: adjudicate: %w", err)
}
m := answerRe.FindStringSubmatch(reply)
if m == nil {
return VerdictUnsure, nil
}
switch strings.ToUpper(m[1]) {
case "1":
if r.AltFirst {
return VerdictAlt, nil
}
return VerdictBase, nil
case "2":
if r.AltFirst {
return VerdictBase, nil
}
return VerdictAlt, nil
}
return VerdictUnsure, nil
}
func describe(reading string) string {
if strings.TrimSpace(reading) == "" {
return "(nothing: the words are simply absent)"
}
return reading
}
// OrderChecked asks the inner adjudicator twice with the reading order swapped and
// keeps a verdict only when both agree. A model that flips with the order is showing
// position bias, not judgement, so disagreement is VerdictUnsure. It doubles the time.
type OrderChecked struct{ inner Adjudicator }
// NewOrderChecked wraps an adjudicator.
func NewOrderChecked(inner Adjudicator) *OrderChecked { return &OrderChecked{inner: inner} }
// Adjudicate implements Adjudicator.
func (o *OrderChecked) Adjudicate(ctx context.Context, r Request) (Verdict, error) {
r.AltFirst = false
v1, err := o.inner.Adjudicate(ctx, r)
if err != nil {
return VerdictUnsure, err
}
r.AltFirst = true
v2, err := o.inner.Adjudicate(ctx, r)
if err != nil {
return VerdictUnsure, err
}
if v1 == v2 {
return v1, nil
}
return VerdictUnsure, nil
}
+147
View File
@@ -0,0 +1,147 @@
package reconcile_test
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
type fakeCompleter struct {
replies []string
err error
systems []string
users []string
}
func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string, error) {
f.systems = append(f.systems, system)
f.users = append(f.users, user)
if f.err != nil {
return "", f.err
}
i := len(f.users) - 1
if i >= len(f.replies) {
i = len(f.replies) - 1
}
return f.replies[i], nil
}
var req = reconcile.Request{Before: "grannarna ska då", After: "De ska kunna måla", Base: "målade", Alt: "mölade"}
func TestLLMAdjudicator_SendsTheMeasuredPromptWithTheReadingsInTheRequestedOrder(t *testing.T) {
fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
a := reconcile.NewLLMAdjudicator(fc)
_, err := a.Adjudicate(context.Background(), req)
require.NoError(t, err)
require.Len(t, fc.users, 1)
assert.Contains(t, fc.systems[0], "two automatic transcriptions of the same Swedish speech")
assert.Contains(t, fc.systems[0], "ANSWER: 1")
u := fc.users[0]
assert.Contains(t, u, "Text before the spot:\ngrannarna ska då")
assert.Contains(t, u, "[[ THE SPOT ]]")
assert.Contains(t, u, "Text after the spot:\nDe ska kunna måla")
assert.Contains(t, u, "Reading 1: målade\nReading 2: mölade", "base first when AltFirst is false")
}
func TestLLMAdjudicator_AltFirstSwapsWhichReadingIsNumberOne(t *testing.T) {
fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
r := req
r.AltFirst = true
_, err := reconcile.NewLLMAdjudicator(fc).Adjudicate(context.Background(), r)
require.NoError(t, err)
assert.Contains(t, fc.users[0], "Reading 1: mölade\nReading 2: målade")
}
func TestLLMAdjudicator_MapsTheAnswerBackToBaseOrAltWhicheverOrderWasShown(t *testing.T) {
tests := []struct {
name string
altFirst bool
reply string
want reconcile.Verdict
}{
{"1 with base first is base", false, "ANSWER: 1", reconcile.VerdictBase},
{"2 with base first is alt", false, "ANSWER: 2", reconcile.VerdictAlt},
{"1 with alt first is alt", true, "ANSWER: 1", reconcile.VerdictAlt},
{"2 with alt first is base", true, "ANSWER: 2", reconcile.VerdictBase},
{"unsure is unsure", false, "ANSWER: UNSURE", reconcile.VerdictUnsure},
{"case and trailing reasoning are tolerated", false, "answer: 2\nThe second reading fits the context.", reconcile.VerdictAlt},
{"reasoning before the answer line is tolerated", true, "Hmm.\nANSWER: 2", reconcile.VerdictBase},
{"no answer line is unsure, not a guess", false, "I think the first one.", reconcile.VerdictUnsure},
{"an empty reply is unsure", false, "", reconcile.VerdictUnsure},
{"an out of range number is unsure", false, "ANSWER: 3", reconcile.VerdictUnsure},
{"a number that merely starts with 1 is not 1", false, "ANSWER: 12", reconcile.VerdictUnsure},
{"a word that merely starts with 2 is not 2", false, "ANSWER: 2nd option", reconcile.VerdictUnsure},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := req
r.AltFirst = tt.altFirst
got, err := reconcile.NewLLMAdjudicator(&fakeCompleter{replies: []string{tt.reply}}).Adjudicate(context.Background(), r)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestLLMAdjudicator_AnEmptyReadingIsDescribedNotLeftBlank(t *testing.T) {
fc := &fakeCompleter{replies: []string{"ANSWER: 1"}}
r := req
r.Base = ""
_, err := reconcile.NewLLMAdjudicator(fc).Adjudicate(context.Background(), r)
require.NoError(t, err)
assert.Contains(t, fc.users[0], "Reading 1: (nothing: the words are simply absent)")
}
func TestLLMAdjudicator_ACompleterErrorIsReturnedNotTurnedIntoAVerdict(t *testing.T) {
boom := errors.New("gateway down")
_, err := reconcile.NewLLMAdjudicator(&fakeCompleter{err: boom}).Adjudicate(context.Background(), req)
assert.ErrorIs(t, err, boom, "the caller must be able to tell 'the model is down' from 'the model is unsure'")
}
func TestOrderChecked_AsksTwiceWithTheOrderSwappedAndKeepsOnlyAgreement(t *testing.T) {
tests := []struct {
name string
replies []string // for [base-first call, alt-first call]
want reconcile.Verdict
}{
{"both orders pick the base", []string{"ANSWER: 1", "ANSWER: 2"}, reconcile.VerdictBase},
{"both orders pick the alternative", []string{"ANSWER: 2", "ANSWER: 1"}, reconcile.VerdictAlt},
{"it flips with the order, so it is position bias, not a judgement", []string{"ANSWER: 1", "ANSWER: 1"}, reconcile.VerdictUnsure},
{"one order unsure is unsure", []string{"ANSWER: 1", "ANSWER: UNSURE"}, reconcile.VerdictUnsure},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fc := &fakeCompleter{replies: tt.replies}
got, err := reconcile.NewOrderChecked(reconcile.NewLLMAdjudicator(fc)).Adjudicate(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
require.Len(t, fc.users, 2)
assert.True(t, strings.Contains(fc.users[0], "Reading 1: målade"), "first call shows the base first")
assert.True(t, strings.Contains(fc.users[1], "Reading 1: mölade"), "second call shows the alternative first")
})
}
}
func TestOrderChecked_AnErrorOnEitherCallIsAnError(t *testing.T) {
_, err := reconcile.NewOrderChecked(reconcile.NewLLMAdjudicator(&fakeCompleter{err: errors.New("down")})).Adjudicate(context.Background(), req)
assert.Error(t, err)
}
+187
View File
@@ -0,0 +1,187 @@
package reconcile
// OpKind is the kind of one run in an edit script.
type OpKind int
const (
// Equal: A[AStart:AEnd] and B[BStart:BEnd] are the same words.
Equal OpKind = iota
// Replace: different words on both sides.
Replace
// Delete: words only in A.
Delete
// Insert: words only in B.
Insert
)
// Op is one run of the edit script. The ops of a Diff tile both inputs: each op starts
// where the previous one ended, in A and in B.
type Op struct {
Kind OpKind
AStart, AEnd int
BStart, BEnd int
}
// maxEdits bounds the Myers search. Memory grows with the square of the edit
// distance, so two texts that share almost nothing (one engine produced garbage)
// are reported as one Replace instead of spending gigabytes proving it. A real
// hour disagrees in roughly 400 places, i.e. about 800 edits, far below this.
const maxEdits = 3000
// Diff returns a minimal edit script from a to b (Myers, O(ND)). Adjacent deletions
// and insertions are reported as one Replace.
func Diff(a, b []string) []Op {
pre := 0
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
pre++
}
suf := 0
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
suf++
}
midA, midB := a[pre:len(a)-suf], b[pre:len(b)-suf]
steps, ok := myers(midA, midB)
if !ok {
steps = steps[:0]
for range midA {
steps = append(steps, stepDel)
}
for range midB {
steps = append(steps, stepIns)
}
}
var ops []Op
ai, bi := 0, 0
emit := func(k OpKind, da, db int) {
if da == 0 && db == 0 {
return
}
if k == Equal && len(ops) > 0 && ops[len(ops)-1].Kind == Equal {
ops[len(ops)-1].AEnd += da
ops[len(ops)-1].BEnd += db
} else {
ops = append(ops, Op{Kind: k, AStart: ai, AEnd: ai + da, BStart: bi, BEnd: bi + db})
}
ai += da
bi += db
}
emit(Equal, pre, pre)
for i := 0; i < len(steps); {
if steps[i] == stepEq {
j := i
for j < len(steps) && steps[j] == stepEq {
j++
}
emit(Equal, j-i, j-i)
i = j
continue
}
dels, inss := 0, 0
for i < len(steps) && steps[i] != stepEq {
if steps[i] == stepDel {
dels++
} else {
inss++
}
i++
}
switch {
case dels > 0 && inss > 0:
emit(Replace, dels, inss)
case dels > 0:
emit(Delete, dels, 0)
default:
emit(Insert, 0, inss)
}
}
emit(Equal, suf, suf)
return ops
}
type step int8
const (
stepEq step = iota
stepDel
stepIns
)
// myers returns one step per element of the edit script, in order. ok is false when
// the edit distance exceeds maxEdits.
func myers(a, b []string) ([]step, bool) {
n, m := len(a), len(b)
if n == 0 && m == 0 {
return nil, true
}
limit := min(n+m, maxEdits)
off := limit + 1
v := make([]int32, 2*limit+3)
var trace [][]int32 // trace[d] is v as it stood at the start of round d, for k in [-d, d]
for d := 0; d <= limit; d++ {
snap := make([]int32, 2*d+1)
for k := -d; k <= d; k++ {
snap[k+d] = v[off+k]
}
trace = append(trace, snap)
for k := -d; k <= d; k += 2 {
var x int
if k == -d || (k != d && v[off+k-1] < v[off+k+1]) {
x = int(v[off+k+1])
} else {
x = int(v[off+k-1]) + 1
}
y := x - k
for x < n && y < m && a[x] == b[y] {
x++
y++
}
v[off+k] = int32(x)
if x >= n && y >= m {
return backtrack(trace, n, m, d), true
}
}
}
return nil, false
}
func backtrack(trace [][]int32, n, m, d int) []step {
var rev []step
x, y := n, m
for ; d >= 0; d-- {
snap := trace[d]
at := func(k int) int { return int(snap[k+d]) }
k := x - y
var prevK int
if k == -d || (k != d && at(k-1) < at(k+1)) {
prevK = k + 1
} else {
prevK = k - 1
}
prevX := 0
if d > 0 {
prevX = at(prevK)
}
prevY := prevX - prevK
for x > prevX && y > prevY {
rev = append(rev, stepEq)
x--
y--
}
if d > 0 {
if x == prevX {
rev = append(rev, stepIns)
} else {
rev = append(rev, stepDel)
}
}
x, y = prevX, prevY
}
out := make([]step, len(rev))
for i, s := range rev {
out[len(rev)-1-i] = s
}
return out
}
+139
View File
@@ -0,0 +1,139 @@
package reconcile_test
import (
"math/rand"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
func words(s string) []string { return strings.Fields(s) }
// apply rebuilds b from a using the ops, copying equal runs from a and the
// replacement/insertion runs from b. If Diff is right this must equal b.
func apply(a, b []string, ops []reconcile.Op) []string {
var out []string
for _, o := range ops {
switch o.Kind {
case reconcile.Equal:
out = append(out, a[o.AStart:o.AEnd]...)
default:
out = append(out, b[o.BStart:o.BEnd]...)
}
}
return out
}
func TestDiff_Cases(t *testing.T) {
tests := []struct {
name string
a, b string
want []reconcile.Op
}{
{"identical", "a b c", "a b c", []reconcile.Op{{Kind: reconcile.Equal, AStart: 0, AEnd: 3, BStart: 0, BEnd: 3}}},
{"both empty", "", "", nil},
{"one word replaced", "a b c", "a x c", []reconcile.Op{
{Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
{Kind: reconcile.Replace, AStart: 1, AEnd: 2, BStart: 1, BEnd: 2},
{Kind: reconcile.Equal, AStart: 2, AEnd: 3, BStart: 2, BEnd: 3},
}},
{"word only in b", "a c", "a b c", []reconcile.Op{
{Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
{Kind: reconcile.Insert, AStart: 1, AEnd: 1, BStart: 1, BEnd: 2},
{Kind: reconcile.Equal, AStart: 1, AEnd: 2, BStart: 2, BEnd: 3},
}},
{"word only in a", "a b c", "a c", []reconcile.Op{
{Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
{Kind: reconcile.Delete, AStart: 1, AEnd: 2, BStart: 1, BEnd: 1},
{Kind: reconcile.Equal, AStart: 2, AEnd: 3, BStart: 1, BEnd: 2},
}},
{"nothing in common", "a b", "x y z", []reconcile.Op{
{Kind: reconcile.Replace, AStart: 0, AEnd: 2, BStart: 0, BEnd: 3},
}},
{"a is empty", "", "x y", []reconcile.Op{{Kind: reconcile.Insert, AStart: 0, AEnd: 0, BStart: 0, BEnd: 2}}},
{"b is empty", "x y", "", []reconcile.Op{{Kind: reconcile.Delete, AStart: 0, AEnd: 2, BStart: 0, BEnd: 0}}},
{"adjacent delete and insert read as one replace", "a b c d", "a x y d", []reconcile.Op{
{Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1},
{Kind: reconcile.Replace, AStart: 1, AEnd: 3, BStart: 1, BEnd: 3},
{Kind: reconcile.Equal, AStart: 3, AEnd: 4, BStart: 3, BEnd: 4},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, reconcile.Diff(words(tt.a), words(tt.b)))
})
}
}
// lcsLen is a plain dynamic-programming reference. Myers must find an edit script
// whose equal runs add up to exactly this: a diff that is merely valid but not
// minimal would turn real agreement into disputes the adjudicator is then asked about.
func lcsLen(a, b []string) int {
prev := make([]int, len(b)+1)
for i := 1; i <= len(a); i++ {
cur := make([]int, len(b)+1)
for j := 1; j <= len(b); j++ {
if a[i-1] == b[j-1] {
cur[j] = prev[j-1] + 1
} else {
cur[j] = max(prev[j], cur[j-1])
}
}
prev = cur
}
return prev[len(b)]
}
func TestDiff_RebuildsBAndKeepsEveryCommonWordOnRandomInputs(t *testing.T) {
rng := rand.New(rand.NewSource(7))
alphabet := []string{"a", "b", "c", "d", "e", "f"}
gen := func(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = alphabet[rng.Intn(len(alphabet))]
}
return out
}
for i := 0; i < 400; i++ {
a, b := gen(rng.Intn(25)), gen(rng.Intn(25))
ops := reconcile.Diff(a, b)
require.Equal(t, append([]string{}, b...), append([]string{}, apply(a, b, ops)...), "a=%v b=%v ops=%v", a, b, ops)
common := 0
prevA, prevB := 0, 0
for _, o := range ops {
require.Equal(t, prevA, o.AStart, "ops must tile a without gaps: %v", ops)
require.Equal(t, prevB, o.BStart, "ops must tile b without gaps: %v", ops)
prevA, prevB = o.AEnd, o.BEnd
if o.Kind == reconcile.Equal {
common += o.AEnd - o.AStart
require.Equal(t, a[o.AStart:o.AEnd], b[o.BStart:o.BEnd], "an Equal op must really be equal")
}
}
require.Equal(t, len(a), prevA)
require.Equal(t, len(b), prevB)
require.Equal(t, lcsLen(a, b), common, "the diff must be minimal: a=%v b=%v", a, b)
}
}
func TestDiff_HandlesAnHoursWorthOfWordsFast(t *testing.T) {
rng := rand.New(rand.NewSource(3))
a := make([]string, 6500)
for i := range a {
a[i] = string(rune('a'+rng.Intn(26))) + string(rune('a'+rng.Intn(26)))
}
b := append([]string{}, a...)
for i := 0; i < 380; i++ { // ~380 disputes, as measured on a real hour
b[rng.Intn(len(b))] = "zz" + string(rune('a'+rng.Intn(26)))
}
ops := reconcile.Diff(a, b)
assert.NotEmpty(t, ops)
assert.Equal(t, append([]string{}, b...), append([]string{}, apply(a, b, ops)...))
}
+100
View File
@@ -0,0 +1,100 @@
package reconcile
import "strings"
// Dispute is one place where the two transcripts disagree. BaseStart:BaseEnd and
// AltStart:AltEnd are token ranges into the two Tokenize results; an empty range
// means that engine heard nothing there. Base and Alt are the two readings as each
// engine wrote them, so a replaced span takes the alternative's own spelling.
type Dispute struct {
BaseStart, BaseEnd int
AltStart, AltEnd int
Base, Alt string
}
// FindDisputes aligns base against alt word by word and returns the disagreements.
// Differences separated by a single shared word are merged into one dispute: for a
// reader that is one decision, and asking about "b" and "d" separately in "b c d"
// hides that "c" sits between them. Two shared words keep disputes apart.
func FindDisputes(base, alt []Token) []Dispute {
a, b := keysOf(base), keysOf(alt)
ops := Diff(a, b)
var out []Dispute
for i := 0; i < len(ops); i++ {
if ops[i].Kind == Equal {
continue
}
cur := ops[i]
for i+2 < len(ops) && ops[i+1].Kind == Equal && ops[i+1].AEnd-ops[i+1].AStart == 1 && ops[i+2].Kind != Equal {
cur.AEnd, cur.BEnd = ops[i+2].AEnd, ops[i+2].BEnd
i += 2
}
out = append(out, Dispute{
BaseStart: cur.AStart, BaseEnd: cur.AEnd,
AltStart: cur.BStart, AltEnd: cur.BEnd,
Base: join(base[cur.AStart:cur.AEnd]),
Alt: join(alt[cur.BStart:cur.BEnd]),
})
}
return out
}
// fillers are Swedish discourse particles. A dispute that is only one or two of them
// on one side changes no meaning, so it is resolved without asking a model. The list
// is deliberately short and leaves out words that can be an answer ("nej") or that
// carry content.
var fillers = map[string]bool{
"ju": true, "då": true, "liksom": true, "alltså": true, "så": true, "ja": true,
"eh": true, "ehm": true, "öh": true, "mm": true, "hm": true, "typ": true, "va": true, "asså": true,
}
// maxFillerRun is how many filler words may be waved through together.
const maxFillerRun = 2
// AutoResolve reports whether a dispute needs no model, in which case the base
// reading is kept: either the two sides are the same letters once spaces are removed
// (a compound written as one word or two), or one side is empty and the other is a
// short run of filler words.
func AutoResolve(d Dispute) bool {
baseKeys, altKeys := keysOfText(d.Base), keysOfText(d.Alt)
if strings.Join(baseKeys, "") == strings.Join(altKeys, "") {
return true
}
return (len(baseKeys) == 0 && onlyFillers(altKeys)) || (len(altKeys) == 0 && onlyFillers(baseKeys))
}
func onlyFillers(keys []string) bool {
if len(keys) == 0 || len(keys) > maxFillerRun {
return false
}
for _, k := range keys {
if !fillers[k] {
return false
}
}
return true
}
// ContextAround returns up to window words of the base before and after the dispute,
// as the base wrote them.
func ContextAround(base []Token, d Dispute, window int) (before, after string) {
return join(base[max(0, d.BaseStart-window):d.BaseStart]), join(base[d.BaseEnd:min(len(base), d.BaseEnd+window)])
}
func keysOf(ts []Token) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Key
}
return out
}
func keysOfText(s string) []string { return keysOf(Tokenize(s)) }
func join(ts []Token) string {
parts := make([]string, len(ts))
for i, t := range ts {
parts[i] = t.Text
}
return strings.Join(parts, " ")
}
+131
View File
@@ -0,0 +1,131 @@
package reconcile_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
func disputes(base, alt string) []reconcile.Dispute {
return reconcile.FindDisputes(reconcile.Tokenize(base), reconcile.Tokenize(alt))
}
func TestFindDisputes_NoneWhenTheTextsAgreeWordForWord(t *testing.T) {
assert.Empty(t, disputes("Vi går igenom läget.", "vi går igenom läget"),
"casing and punctuation are not disagreements")
}
func TestFindDisputes_OneReplacedWordIsOneDisputeCarryingBothReadings(t *testing.T) {
got := disputes("Vi ska målade grannarna.", "Vi ska mölade grannarna.")
require.Len(t, got, 1)
d := got[0]
assert.Equal(t, "målade", d.Base)
assert.Equal(t, "mölade", d.Alt)
assert.Equal(t, 2, d.BaseStart)
assert.Equal(t, 3, d.BaseEnd)
assert.Equal(t, 2, d.AltStart)
assert.Equal(t, 3, d.AltEnd)
}
func TestFindDisputes_ReadingsKeepTheirOwnSpellingAndPunctuation(t *testing.T) {
got := disputes("Det blir A+2, alltså.", "Det blir A 2, alltså.")
require.Len(t, got, 1)
assert.Equal(t, "A+2,", got[0].Base)
assert.Equal(t, "A 2,", got[0].Alt, "the alternative's words replace the span as the engine wrote them")
}
func TestFindDisputes_AWordOnlyOneEngineHeardHasAnEmptyOtherSide(t *testing.T) {
got := disputes("Man får tillbaka ett förslag.", "Man får tillbaka ett coolt förslag.")
require.Len(t, got, 1)
assert.Equal(t, "", got[0].Base)
assert.Equal(t, "coolt", got[0].Alt)
assert.Equal(t, got[0].BaseStart, got[0].BaseEnd, "an insertion has an empty base range at its position")
assert.Equal(t, 4, got[0].BaseStart)
}
func TestFindDisputes_TwoDifferencesSeparatedByOneSharedWordAreOneDispute(t *testing.T) {
// "x ... y" around a single agreed word is one decision for the reader, not two.
got := disputes("a b c d e", "a x c y e")
require.Len(t, got, 1)
assert.Equal(t, "b c d", got[0].Base)
assert.Equal(t, "x c y", got[0].Alt)
}
func TestFindDisputes_TwoSharedWordsKeepTheDisputesApart(t *testing.T) {
got := disputes("a b c d e f", "a x c d y f")
require.Len(t, got, 2)
assert.Equal(t, "b", got[0].Base)
assert.Equal(t, "e", got[1].Base)
}
func TestFindDisputes_ChainsOfCloseDifferencesMergeIntoOne(t *testing.T) {
got := disputes("a b c d e f g", "a x c y e z g")
require.Len(t, got, 1)
assert.Equal(t, "b c d e f", got[0].Base)
}
func TestFindDisputes_LeavesIndicesThatAddressBothTokenSlices(t *testing.T) {
base := reconcile.Tokenize("Hej Anders och Maria")
alt := reconcile.Tokenize("Hej Sanders och Maria")
got := reconcile.FindDisputes(base, alt)
require.Len(t, got, 1)
assert.Equal(t, "Anders", base[got[0].BaseStart].Text)
assert.Equal(t, "Sanders", alt[got[0].AltStart].Text)
}
func TestAutoResolve(t *testing.T) {
tests := []struct {
name string
base, alt string
resolved bool
}{
{"a compound written as two words or one", "i takrenns delen", "i takrennsdelen", true},
{"a compound with a hyphen", "takrenns-delen", "takrenns delen", true},
{"a filler only the alternative heard", "vi ska ju betala", "vi ska betala", true},
{"a filler only the base heard", "vi ska betala", "vi ska ju betala", true},
{"two fillers in a row", "vi ska ja ju betala", "vi ska betala", true},
{"a different word is for the model", "vi ska målade", "vi ska mölade", false},
{"a different number is for the model", "totalt 1050 kronor", "totalt 2050 kronor", false},
{"a content word only one side heard is for the model", "ett förslag", "ett coolt förslag", false},
{"three filler words are too many to wave through", "vi ska ju då liksom betala", "vi ska betala", false},
{"a filler swapped for a content word is for the model", "vi ska ju betala", "vi ska nu betala", false},
{"nej is an answer, not a filler", "men nej det går inte", "men det går inte", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := disputes(tt.base, tt.alt)
require.Len(t, got, 1, "the fixture must be exactly one dispute")
assert.Equal(t, tt.resolved, reconcile.AutoResolve(got[0]))
})
}
}
func TestContextAround_TakesWordsFromTheBaseOnBothSidesAndStopsAtTheEdges(t *testing.T) {
base := reconcile.Tokenize("ett två tre fyra fem sex sju åtta nio tio")
d := reconcile.Dispute{BaseStart: 4, BaseEnd: 6} // "fem sex"
before, after := reconcile.ContextAround(base, d, 3)
assert.Equal(t, "två tre fyra", before)
assert.Equal(t, "sju åtta nio", after)
b2, a2 := reconcile.ContextAround(base, reconcile.Dispute{BaseStart: 0, BaseEnd: 1}, 3)
assert.Equal(t, "", b2)
assert.Equal(t, "två tre fyra", a2)
b3, a3 := reconcile.ContextAround(base, reconcile.Dispute{BaseStart: 9, BaseEnd: 10}, 3)
assert.Equal(t, "sju åtta nio", b3)
assert.Equal(t, "", a3)
}
+241
View File
@@ -0,0 +1,241 @@
package reconcile
import (
"context"
"fmt"
"strings"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/progress"
)
// MaxContested is the default bound on the stored list of judged spots. A 90-minute
// recording has a few hundred, so this is a guard against a runaway, not a budget. The
// counts on the Reconciliation are always exact; only the list is capped.
const MaxContested = 2000
// beforeWords is how many words of left context a stored spot carries: enough to find
// the place again, small enough to stay out of the way.
const beforeWords = 8
// Options configure Reconcile. Zero Window and BreakAfter take their defaults.
type Options struct {
// Window is how many words of the base to show on each side of a dispute. 100 is
// what was measured (docs/research/local-model-adjudicator-sweep-2026-10-01.md).
Window int
// BreakAfter is how many consecutive model errors stop the reconciliation, so a
// model that is down costs three timeouts, not one per dispute.
BreakAfter int
// MaxContested caps the stored judged spots; zero takes the package default.
MaxContested int
// BaseEngine, AltEngine and Reconciler are recorded on the result.
BaseEngine, AltEngine, Reconciler string
}
// Reconcile merges two transcripts of the same speech. The base text stands except
// where the model chooses the alternative's reading of a dispute; trivial disputes
// (a compound split, a filler word) are resolved without the model. It never fails a
// transcription: if the model cannot be used the base text comes back unreconciled
// and the result says why. The only error it returns is a cancelled context, which
// is the job stopping, not a verdict on the model.
func Reconcile(ctx context.Context, base, alt domain.Transcription, adj Adjudicator, o Options) (domain.Transcription, error) {
window, breakAfter, maxSpots := o.Window, o.BreakAfter, o.MaxContested
if window <= 0 {
window = 100
}
if breakAfter <= 0 {
breakAfter = 3
}
if maxSpots <= 0 {
maxSpots = MaxContested
}
rep := progress.From(ctx)
rep.Begin("compare", 0)
bt, at := Tokenize(base.Text), Tokenize(alt.Text)
ds := FindDisputes(bt, at)
rep.Done("compare")
// The deciding phase counts what the MODEL decides: the trivial disputes are settled
// without it, so counting them would leave the bar short of its end.
deciding := 0
for _, d := range ds {
if !AutoResolve(d) {
deciding++
}
}
rec := &domain.Reconciliation{
Status: domain.ReconcileDone, Base: o.BaseEngine, Alt: o.AltEngine, Reconciler: o.Reconciler, Disputes: len(ds),
}
seconds := positioner(base, len(bt))
spot := func(d Dispute, used string, unsure bool) {
before, _ := ContextAround(bt, d, window)
if w := strings.Fields(before); len(w) > beforeWords {
before = strings.Join(w[len(w)-beforeWords:], " ")
}
if len(rec.Contested) < maxSpots {
rec.Contested = append(rec.Contested, domain.ContestedSpot{
Seconds: seconds(d.BaseStart), Base: d.Base, Alt: d.Alt, Used: used, Unsure: unsure, Before: before,
})
}
}
rep.Begin("decide", deciding)
verdicts := make([]Verdict, len(ds))
consecutive, handled := 0, 0
for i, d := range ds {
if AutoResolve(d) {
rec.AutoResolved++
verdicts[i] = VerdictBase
continue
}
if err := ctx.Err(); err != nil {
return domain.Transcription{}, err
}
before, after := ContextAround(bt, d, window)
v, err := adj.Adjudicate(ctx, Request{Before: before, After: after, Base: d.Base, Alt: d.Alt, AltFirst: i%2 == 1})
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return domain.Transcription{}, ctxErr
}
rec.Errors++
consecutive++
if consecutive >= breakAfter {
rep.Fail("decide", err)
return skipped(base, rec, fmt.Sprintf("the model failed %d times in a row (%s)", consecutive, errkind.Of(err))), nil
}
v = VerdictUnsure
} else {
consecutive = 0
rec.Adjudicated++
}
verdicts[i] = v
switch v {
case VerdictAlt:
rec.Changed++
spot(d, "alt", false)
case VerdictUnsure:
rec.Unsure++
spot(d, "base", true)
default:
spot(d, "base", false)
}
handled++
rep.Advance("decide", handled)
}
rep.Done("decide")
out := base
out.Text, out.Segments = merge(base, bt, at, ds, verdicts)
out.Reconciliation = rec
return out, nil
}
// merge applies the verdicts: the base's words stand except where the alternative's
// reading was chosen. It returns the merged text and the base's segments carrying the
// merged words (timing is the base's). Segments are rewritten only when their words line
// up with the text; a word wrapped across a segment edge makes them differ, and guessing
// would put words in the wrong place, so then the base's segments are returned as they
// were (ADR-035). The base's own segments are never edited in place.
func merge(base domain.Transcription, bt, at []Token, ds []Dispute, verdicts []Verdict) (string, []domain.Segment) {
segOf, aligned := segmentIndex(base.Segments, len(bt))
var perSeg [][]string
var changed map[int]bool
if aligned {
perSeg, changed = make([][]string, len(base.Segments)), map[int]bool{}
}
var parts []string
keep := func(from, to int) {
for i := from; i < to; i++ {
parts = append(parts, bt[i].Text)
if aligned {
perSeg[segOf[i]] = append(perSeg[segOf[i]], bt[i].Text)
}
}
}
pos := 0
for i, d := range ds {
keep(pos, d.BaseStart)
if verdicts[i] == VerdictAlt {
for _, t := range at[d.AltStart:d.AltEnd] {
parts = append(parts, t.Text)
}
if aligned {
anchor := min(d.BaseStart, len(bt)-1)
for _, t := range at[d.AltStart:d.AltEnd] {
perSeg[segOf[anchor]] = append(perSeg[segOf[anchor]], t.Text)
}
changed[segOf[anchor]] = true
for k := d.BaseStart; k < d.BaseEnd; k++ {
changed[segOf[k]] = true
}
}
} else {
keep(d.BaseStart, d.BaseEnd)
}
pos = d.BaseEnd
}
keep(pos, len(bt))
if !aligned {
return strings.Join(parts, " "), base.Segments
}
segs := append([]domain.Segment(nil), base.Segments...)
for k := range changed {
segs[k].Text = strings.Join(perSeg[k], " ")
}
return strings.Join(parts, " "), segs
}
// segmentIndex maps each base token to the segment it came from. aligned is false when
// there are no segments or their word count is not the text's.
func segmentIndex(segs []domain.Segment, tokens int) (segOf []int, aligned bool) {
if len(segs) == 0 || tokens == 0 {
return nil, false
}
segOf = make([]int, 0, tokens)
for k, s := range segs {
for range Tokenize(s.Text) {
segOf = append(segOf, k)
}
}
return segOf, len(segOf) == tokens
}
// skipped is the base text, none of the partial changes, and the reason.
func skipped(base domain.Transcription, rec *domain.Reconciliation, reason string) domain.Transcription {
out := base
out.Reconciliation = &domain.Reconciliation{
Status: domain.ReconcileSkipped, Reason: reason, Base: rec.Base, Alt: rec.Alt, Reconciler: rec.Reconciler,
Disputes: rec.Disputes, AutoResolved: rec.AutoResolved, Errors: rec.Errors,
}
return out
}
// positioner maps a token index of the base to the second it starts at: through the
// segments when there are any, proportionally otherwise.
func positioner(base domain.Transcription, tokens int) func(int) float64 {
if len(base.Segments) == 0 {
return func(i int) float64 {
if tokens == 0 {
return 0
}
return float64(i) / float64(tokens) * base.AudioSeconds
}
}
starts := make([]int, len(base.Segments))
n := 0
for i, s := range base.Segments {
starts[i] = n
n += len(Tokenize(s.Text))
}
return func(i int) float64 {
k := 0
for k+1 < len(starts) && starts[k+1] <= i {
k++
}
return base.Segments[k].Start
}
}
@@ -0,0 +1,116 @@
package reconcile_test
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/progress"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// What Reconcile reports while it works (ADR-035): the comparison, then the deciding
// phase with the real number of model decisions as its total.
type repRec struct {
mu sync.Mutex
events []string
}
func (r *repRec) add(e string) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, e)
}
func (r *repRec) Begin(key string, total int) { r.add(fmt.Sprintf("begin:%s:%d", key, total)) }
func (r *repRec) Advance(key string, done int) { r.add(fmt.Sprintf("advance:%s:%d", key, done)) }
func (r *repRec) Done(key string) { r.add("done:" + key) }
func (r *repRec) Fail(key string, _ error) { r.add("fail:" + key) }
func (r *repRec) Note(string, ...any) {}
func (r *repRec) joined() string {
r.mu.Lock()
defer r.mu.Unlock()
return strings.Join(r.events, ",")
}
func withRep(r *repRec) context.Context { return progress.With(context.Background(), r) }
func reconcileWith(t *testing.T, ctx context.Context, base, alt string, adj reconcile.Adjudicator) error {
t.Helper()
_, err := reconcile.Reconcile(ctx, tr(base), tr(alt), adj, opts)
return err
}
// two compound/filler disputes the model is never asked about, then two it is.
const (
progBase = "i 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")
}
@@ -0,0 +1,33 @@
package reconcile_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// The reason a reconciliation was skipped is stored and shown on a page about a private
// recording, so it carries a kind, never the model's error text, which can echo the
// prompt (adversarial review F3).
func TestReconcile_TheBreakerReasonCarriesAKindNotTheModelsText(t *testing.T) {
adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
return reconcile.VerdictBase, errors.New("gateway echoed the prompt: zebrakaka secret words")
}}
small := opts
small.BreakAfter = 2
out, err := reconcile.Reconcile(context.Background(), tr(progBase), tr(progAlt), adj, small)
require.NoError(t, err)
require.Equal(t, domain.ReconcileSkipped, out.Reconciliation.Status)
assert.NotContains(t, out.Reconciliation.Reason, "zebrakaka", "provider text must not be stored or shown")
assert.Contains(t, out.Reconciliation.Reason, "2 times in a row", "the reason still says what happened")
assert.Contains(t, out.Reconciliation.Reason, "error", "and carries a kind")
}
+130
View File
@@ -0,0 +1,130 @@
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")
}
+308
View File
@@ -0,0 +1,308 @@
package reconcile_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// scripted is an Adjudicator that answers from a function and records every request.
type scripted struct {
fn func(i int, r reconcile.Request) (reconcile.Verdict, error)
calls []reconcile.Request
}
func (s *scripted) Adjudicate(_ context.Context, r reconcile.Request) (reconcile.Verdict, error) {
i := len(s.calls)
s.calls = append(s.calls, r)
return s.fn(i, r)
}
func always(v reconcile.Verdict) *scripted {
return &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) { return v, nil }}
}
func tr(text string) domain.Transcription {
return domain.Transcription{Text: text, AudioSeconds: 100, Endpoint: "e"}
}
var opts = reconcile.Options{Window: 100, BreakAfter: 3, Reconciler: "iguana/gemma4-26b", BaseEngine: "iguana/pianissimo", AltEngine: "iguana/kb-whisper"}
func merge(t *testing.T, base, alt domain.Transcription, adj reconcile.Adjudicator) domain.Transcription {
t.Helper()
out, err := reconcile.Reconcile(context.Background(), base, alt, adj, opts)
require.NoError(t, err)
return out
}
func TestReconcile_IdenticalTextsNeedNoModelAndComeBackUnchanged(t *testing.T) {
adj := always(reconcile.VerdictAlt)
out := merge(t, tr("Vi går igenom läget."), tr("vi går igenom läget"), adj)
assert.Equal(t, "Vi går igenom läget.", out.Text, "the base's own spelling and punctuation stand")
assert.Empty(t, adj.calls)
require.NotNil(t, out.Reconciliation)
assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
assert.Zero(t, out.Reconciliation.Disputes)
}
func TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel(t *testing.T) {
adj := always(reconcile.VerdictAlt)
out := merge(t, tr("i takrenns delen ska vi ju betala"), tr("i takrennsdelen ska vi betala"), adj)
assert.Empty(t, adj.calls, "a compound split and a filler particle are not worth a model call")
assert.Equal(t, "i takrenns delen ska vi ju betala", out.Text, "the base reading is kept")
assert.Equal(t, 2, out.Reconciliation.Disputes)
assert.Equal(t, 2, out.Reconciliation.AutoResolved)
assert.Zero(t, out.Reconciliation.Adjudicated)
}
func TestReconcile_AltVerdictReplacesTheSpanWithTheAlternativesOwnWords(t *testing.T) {
out := merge(t, tr("Totalt 1015 kronor. Det ska betalas."), tr("Totalt 1050 kronor. Det ska betalas."), always(reconcile.VerdictAlt))
assert.Equal(t, "Totalt 1050 kronor. Det ska betalas.", out.Text)
assert.Equal(t, 1, out.Reconciliation.Changed)
assert.Equal(t, 1, out.Reconciliation.Adjudicated)
}
func TestReconcile_BaseAndUnsureVerdictsKeepTheBaseButOnlyUnsureIsFlagged(t *testing.T) {
base := tr("Totalt 1015 kronor till Axel.")
alt := tr("Totalt 1050 kronor till Alex.")
keptBase := merge(t, base, alt, always(reconcile.VerdictBase))
unsure := merge(t, base, alt, always(reconcile.VerdictUnsure))
assert.Equal(t, "Totalt 1015 kronor till Axel.", keptBase.Text)
assert.Zero(t, keptBase.Reconciliation.Unsure)
require.Len(t, keptBase.Reconciliation.Contested, 2,
"every judged spot is stored, kept ones too: without them the reconciler cannot be compared with either engine alone (ADR-035)")
for _, c := range keptBase.Reconciliation.Contested {
assert.Equal(t, "base", c.Used)
assert.False(t, c.Unsure, "a confident 'the base is right' is stored but is not an uncertain spot")
}
assert.Equal(t, "Totalt 1015 kronor till Axel.", unsure.Text, "unsure keeps the base")
assert.Equal(t, 2, unsure.Reconciliation.Unsure)
require.Len(t, unsure.Reconciliation.Contested, 2)
assert.True(t, unsure.Reconciliation.Contested[0].Unsure)
assert.Equal(t, "base", unsure.Reconciliation.Contested[0].Used)
}
func TestReconcile_ChangedSpotsAreRecordedWithBothReadings(t *testing.T) {
out := merge(t, tr("det blir A+2 imorgon"), tr("det blir en A imorgon"), always(reconcile.VerdictAlt))
require.Len(t, out.Reconciliation.Contested, 1)
c := out.Reconciliation.Contested[0]
assert.Equal(t, "A+2", c.Base)
assert.Equal(t, "en A", c.Alt)
assert.Equal(t, "alt", c.Used)
assert.False(t, c.Unsure)
}
func TestReconcile_AWordOnlyTheAlternativeHeardIsInsertedWhereItBelongs(t *testing.T) {
out := merge(t, tr("Man får tillbaka ett förslag."), tr("Man får tillbaka ett coolt förslag."), always(reconcile.VerdictAlt))
assert.Equal(t, "Man får tillbaka ett coolt förslag.", out.Text)
}
func TestReconcile_AWordOnlyTheBaseHeardCanBeRemoved(t *testing.T) {
out := merge(t, tr("Man får tillbaka ett coolt förslag."), tr("Man får tillbaka ett förslag."), always(reconcile.VerdictAlt))
assert.Equal(t, "Man får tillbaka ett förslag.", out.Text)
}
func TestReconcile_SeveralDisputesAreAppliedIndependentlyAndInOrder(t *testing.T) {
adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
if r.Alt == "mölade" || r.Alt == "Alex" {
return reconcile.VerdictAlt, nil
}
return reconcile.VerdictBase, nil
}}
out := merge(t, tr("kunderna målade nu och sen betalas till Axel via ombud med 1050 kronor"),
tr("kunderna mölade nu och sen betalas till Alex 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, 3, out.Reconciliation.Disputes)
assert.Equal(t, 2, out.Reconciliation.Changed)
}
func TestReconcile_TheReadingOrderAlternatesFromOneDisputeToTheNext(t *testing.T) {
adj := always(reconcile.VerdictBase)
merge(t, tr("a x s1 s2 y t1 t2 z d"), tr("a p s1 s2 q t1 t2 r d"), adj)
require.Len(t, adj.calls, 3)
assert.False(t, adj.calls[0].AltFirst)
assert.True(t, adj.calls[1].AltFirst)
assert.False(t, adj.calls[2].AltFirst, "alternating averages out position bias across a transcript")
}
func TestReconcile_TheModelGetsTheContextWindowFromTheBase(t *testing.T) {
adj := always(reconcile.VerdictBase)
o := opts
o.Window = 2
_, err := reconcile.Reconcile(context.Background(), tr("ett två tre fyra fem sex sju åtta nio"), tr("ett två tre fyra FEMM sex sju åtta nio"), adj, o)
require.NoError(t, err)
require.Len(t, adj.calls, 1)
assert.Equal(t, "tre fyra", adj.calls[0].Before)
assert.Equal(t, "sex sju", adj.calls[0].After)
assert.Equal(t, "fem", adj.calls[0].Base)
assert.Equal(t, "FEMM", adj.calls[0].Alt)
}
func TestReconcile_OneModelErrorLeavesThatSpotUnsureAndCarriesOn(t *testing.T) {
boom := errors.New("timeout")
adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
if i == 0 {
return reconcile.VerdictUnsure, boom
}
return reconcile.VerdictAlt, nil
}}
out := merge(t, tr("a x s1 s2 y c"), tr("a p s1 s2 q c"), adj)
assert.Equal(t, "a x s1 s2 q c", out.Text, "the first spot stayed as the base, the second was decided")
assert.Equal(t, 1, out.Reconciliation.Errors)
assert.Equal(t, 1, out.Reconciliation.Unsure)
assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
}
func TestReconcile_ConsecutiveModelErrorsStopTheWholeReconciliationInsteadOfWaitingOutEveryTimeout(t *testing.T) {
boom := errors.New("gateway down")
adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) { return reconcile.VerdictUnsure, boom }}
a, b := make([]string, 0, 20), make([]string, 0, 20)
for i := 0; i < 10; i++ {
a = append(a, fmt.Sprintf("w%d", i), "s", "t")
b = append(b, fmt.Sprintf("v%d", i), "s", "t")
}
out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), adj)
assert.Len(t, adj.calls, 3, "three consecutive failures and it stops asking")
assert.Equal(t, strings.Join(a, " "), out.Text, "the base text, with none of the partial changes")
assert.Equal(t, domain.ReconcileSkipped, out.Reconciliation.Status)
assert.Contains(t, out.Reconciliation.Reason, "3 times in a row")
assert.NotContains(t, out.Reconciliation.Reason, "gateway down", "the reason is shown on the page: a kind, not the model's own text")
}
func TestReconcile_ASuccessBetweenFailuresResetsTheBreaker(t *testing.T) {
boom := errors.New("blip")
adj := &scripted{fn: func(i int, r reconcile.Request) (reconcile.Verdict, error) {
if i%3 == 2 {
return reconcile.VerdictBase, nil
}
return reconcile.VerdictUnsure, boom
}}
a, b := make([]string, 0, 20), make([]string, 0, 20)
for i := 0; i < 9; i++ {
a = append(a, fmt.Sprintf("w%d", i), "s", "t")
b = append(b, fmt.Sprintf("v%d", i), "s", "t")
}
out := merge(t, tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), adj)
assert.Len(t, adj.calls, 9, "two failures then a success never reach three in a row")
assert.Equal(t, domain.ReconcileDone, out.Reconciliation.Status)
assert.Equal(t, 6, out.Reconciliation.Errors)
}
func TestReconcile_ACancelledJobIsAnErrorNotASkippedReconciliation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
cancel()
return reconcile.VerdictUnsure, ctx.Err()
}}
_, err := reconcile.Reconcile(ctx, tr("a x b"), tr("a y b"), adj, opts)
assert.ErrorIs(t, err, context.Canceled, "a shutdown must not be recorded as 'the model was unavailable'")
}
func TestReconcile_KeepsTheBaseTimingAndDurationAndNamesTheEngines(t *testing.T) {
base := tr("a x b")
base.Segments = []domain.Segment{{Start: 0, End: 4, Text: "a x b"}}
base.AudioSeconds = 3821.5
out := merge(t, base, tr("a y b"), always(reconcile.VerdictAlt))
require.Len(t, out.Segments, 1)
assert.InDelta(t, 0.0, out.Segments[0].Start, 1e-9, "timing is the base engine's")
assert.InDelta(t, 4.0, out.Segments[0].End, 1e-9)
assert.InDelta(t, 3821.5, out.AudioSeconds, 1e-9)
assert.Equal(t, "iguana/pianissimo", out.Reconciliation.Base)
assert.Equal(t, "iguana/kb-whisper", out.Reconciliation.Alt)
assert.Equal(t, "iguana/gemma4-26b", out.Reconciliation.Reconciler)
}
func TestReconcile_ASpotCarriesTheSecondItStartsAt(t *testing.T) {
base := tr("ett två tre. fyra fem sex. sju åtta nio.")
base.Segments = []domain.Segment{
{Start: 0, End: 10, Text: "ett två tre."},
{Start: 10, End: 20, Text: "fyra fem sex."},
{Start: 20, End: 30, Text: "sju åtta nio."},
}
alt := tr("ett två tre. fyra femm sex. sju åtta nio.")
out := merge(t, base, alt, always(reconcile.VerdictUnsure))
require.Len(t, out.Reconciliation.Contested, 1)
assert.InDelta(t, 10.0, out.Reconciliation.Contested[0].Seconds, 1e-9, "'fem' is in the second segment, which starts at 10 s")
}
func TestReconcile_WithNoSegmentsASpotIsPlacedProportionally(t *testing.T) {
base := tr("a b c d e f g h i j")
base.AudioSeconds = 100
alt := tr("a b c d e f g h i J2")
out := merge(t, base, alt, always(reconcile.VerdictUnsure))
require.Len(t, out.Reconciliation.Contested, 1)
assert.InDelta(t, 90.0, out.Reconciliation.Contested[0].Seconds, 1e-9, "token 9 of 10 in 100 s")
}
func TestReconcile_JudgedSpotsAreCappedButTheCountsAreExact(t *testing.T) {
a, b := make([]string, 0, 600), make([]string, 0, 600)
for i := 0; i < 300; i++ {
a = append(a, fmt.Sprintf("w%d", i), "same", "same2")
b = append(b, fmt.Sprintf("v%d", i), "same", "same2")
}
capped := opts
capped.MaxContested = 50
out, err := reconcile.Reconcile(context.Background(), tr(strings.Join(a, " ")), tr(strings.Join(b, " ")), always(reconcile.VerdictUnsure), capped)
require.NoError(t, err)
assert.Equal(t, 300, out.Reconciliation.Unsure)
assert.Equal(t, 300, out.Reconciliation.Disputes)
assert.Len(t, out.Reconciliation.Contested, 50, "the stored list is bounded; the counts are not")
}
func TestReconcile_TheDefaultCapIsAFewThousandSoAWholeMeetingIsKept(t *testing.T) {
assert.Equal(t, 2000, reconcile.MaxContested, "a 90-minute recording has a few hundred judged spots; the cap is a guard, not a budget")
}
func TestReconcile_AVerdictReturnedAlongsideAnErrorIsIgnored(t *testing.T) {
// An adjudicator that returns a verdict AND an error must not have the verdict used:
// the contract is that an error means no decision.
adj := &scripted{fn: func(int, reconcile.Request) (reconcile.Verdict, error) {
return reconcile.VerdictAlt, errors.New("half a reply")
}}
out := merge(t, tr("a x b"), tr("a y b"), adj)
assert.Equal(t, "a x b", out.Text, "no decision means the base stands")
assert.Zero(t, out.Reconciliation.Changed)
assert.Equal(t, 1, out.Reconciliation.Unsure)
}
+57
View File
@@ -0,0 +1,57 @@
// Package reconcile merges two transcripts of the same speech: it finds where they
// disagree word by word, resolves the trivial disagreements itself, and asks a LOCAL
// model to choose between the two readings of the rest (ADR-034). It depends on
// nothing outside the standard library and the domain; the model, the engines and
// the store are wired in from outside.
package reconcile
import (
"strings"
"unicode"
)
// Token is one word of a transcript. Text is the word as written, with its casing
// and punctuation, so the merged transcript reads naturally; Key is what two engines
// are compared on: lower case, letters and digits only.
type Token struct {
Text string
Key string
}
// Tokenize splits s on whitespace. A "word" that has no letter or digit (a dash, a
// lone full stop) is not something two engines can disagree about, so it is joined
// to the neighbouring word instead of becoming a token of its own.
func Tokenize(s string) []Token {
var out []Token
pending := ""
for _, w := range strings.Fields(s) {
k := key(w)
if k == "" {
if len(out) > 0 {
out[len(out)-1].Text += " " + w
} else if pending == "" {
pending = w
} else {
pending += " " + w
}
continue
}
text := w
if pending != "" {
text = pending + " " + w
pending = ""
}
out = append(out, Token{Text: text, Key: k})
}
return out
}
func key(w string) string {
var b strings.Builder
for _, r := range w {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
}
}
return b.String()
}
+66
View File
@@ -0,0 +1,66 @@
package reconcile_test
import (
"testing"
"github.com/stretchr/testify/assert"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
func texts(ts []reconcile.Token) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Text
}
return out
}
func keys(ts []reconcile.Token) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Key
}
return out
}
func TestTokenize_KeepsTheWordAsWrittenAndKeysItWithoutCaseOrPunctuation(t *testing.T) {
got := reconcile.Tokenize("Hej, allihopa! Vi går igenom ÅÄÖ.")
assert.Equal(t, []string{"Hej,", "allihopa!", "Vi", "går", "igenom", "ÅÄÖ."}, texts(got),
"the original spelling and punctuation are kept for the merged text")
assert.Equal(t, []string{"hej", "allihopa", "vi", "går", "igenom", "åäö"}, keys(got),
"the key is lower case with punctuation removed, Swedish letters lowered correctly")
}
func TestTokenize_KeysIgnoreHyphensAndPlusSoOnlyWordsDecideEquality(t *testing.T) {
got := reconcile.Tokenize("end-to-end A+2 5 000 kr")
assert.Equal(t, []string{"endtoend", "a2", "5", "000", "kr"}, keys(got))
}
func TestTokenize_APunctuationOnlyWordJoinsThePreviousWordInsteadOfBecomingOne(t *testing.T) {
got := reconcile.Tokenize("Vi går – sen tillbaka . Slut")
assert.Equal(t, []string{"Vi", "går –", "sen", "tillbaka .", "Slut"}, texts(got),
"a dash or a lone full stop is not a word the engines can disagree about")
assert.Equal(t, []string{"vi", "går", "sen", "tillbaka", "slut"}, keys(got))
}
func TestTokenize_LeadingPunctuationOnlyWordJoinsTheNextWord(t *testing.T) {
got := reconcile.Tokenize("— Hej då")
assert.Equal(t, []string{"— Hej", "då"}, texts(got))
}
func TestTokenize_NewlinesAndRunsOfSpaceAreJustSeparators(t *testing.T) {
got := reconcile.Tokenize(" Hej\n allihopa \t Vi ")
assert.Equal(t, []string{"Hej", "allihopa", "Vi"}, texts(got))
}
func TestTokenize_EmptyAndPunctuationOnlyInputGiveNoTokens(t *testing.T) {
assert.Empty(t, reconcile.Tokenize(""))
assert.Empty(t, reconcile.Tokenize(" \n "))
assert.Empty(t, reconcile.Tokenize("— . !"))
}
+50 -4
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
)
@@ -23,7 +24,7 @@ type Prober interface {
// Store is the slice of the private transcript store the intake needs.
type Store interface {
CreateUpload(ctx context.Context, userID, title string) (string, error)
CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error)
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
}
@@ -33,6 +34,9 @@ type Job struct {
UserID string
UploadID string
Path string
// Duration is what the file CLAIMED when probed. The worker compares it with what
// was really converted; zero means unknown (ADR-035 review).
Duration time.Duration
}
// Submitter hands accepted jobs to the in-process worker (ADR-033).
@@ -54,12 +58,42 @@ 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}
}
// 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
// a queued upload and submits the job. It returns the upload id. A file that fails
// validation yields a *RejectError and creates NO row — rejection is synchronous
// and leaves nothing behind. On success the temp file is handed to the job and the
// worker deletes it; on every failure path Accept deletes it itself.
func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reader) (string, error) {
// 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}
}
f, err := os.CreateTemp(in.tmpDir, "upload-*")
if err != nil {
return "", fmt.Errorf("uploads: spool: %w", err)
@@ -73,11 +107,15 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
}()
// Read one byte past the cap so "exactly at the limit" and "over" are told apart.
n, err := io.Copy(f, io.LimitReader(src, in.limits.MaxBytes+1))
cr := &clientReader{r: io.LimitReader(src, in.limits.MaxBytes+1)}
n, err := io.Copy(f, cr)
if cerr := f.Close(); err == nil {
err = cerr
}
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)
}
if n > in.limits.MaxBytes {
@@ -99,11 +137,19 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
return "", err
}
id, err := in.store.CreateUpload(ctx, userID, title)
id, err := in.store.CreateUpload(ctx, userID, title, probe.HasVideo)
if err != nil {
return "", fmt.Errorf("uploads: create: %w", err)
}
if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path}); err != nil {
if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path, Duration: probe.Duration}); err != nil {
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.
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)
+96
View File
@@ -0,0 +1,96 @@
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", 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", 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", 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", &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", strings.NewReader("data"))
require.Error(t, err)
assert.NotErrorIs(t, err, uploads.ErrClientRead)
}
+34 -6
View File
@@ -20,13 +20,15 @@ import (
)
type fakeStore struct {
created []string // titles
failed map[string]domain.UploadErrorKind
nextID string
created []string // titles
hadVideo []bool
failed map[string]domain.UploadErrorKind
nextID string
}
func (f *fakeStore) CreateUpload(_ context.Context, _, title string) (string, error) {
func (f *fakeStore) CreateUpload(_ context.Context, _, title string, hadVideo bool) (string, error) {
f.created = append(f.created, title)
f.hadVideo = append(f.hadVideo, hadVideo)
return f.nextID, nil
}
@@ -104,7 +106,8 @@ func TestAccept_ValidAudioIsStoredProbedQueuedAndSubmitted(t *testing.T) {
assert.Equal(t, audio, r.pr.seen, "the prober must see the complete uploaded bytes, on disk")
require.Len(t, r.sub.jobs, 1)
job := r.sub.jobs[0]
assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path}, job)
assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path, Duration: 10 * time.Minute}, job,
"the job carries what the file CLAIMED, so the worker can compare it with what was converted")
assert.Equal(t, filepath.Dir(job.Path), r.tmp, "the temp file lives in the configured directory")
got, err := os.ReadFile(job.Path)
require.NoError(t, err)
@@ -140,7 +143,7 @@ func TestAccept_ProbeVerdictRejectsWithoutRowAndRemovesTheFile(t *testing.T) {
probe uploads.Probe
reason uploads.Reason
}{
{"video", uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true}, uploads.ReasonVideo},
{"video with no audio", uploads.Probe{Duration: time.Minute, HasVideo: true}, uploads.ReasonNoAudio},
{"too long", uploads.Probe{Duration: 2 * time.Hour, HasAudio: true}, uploads.ReasonTooLong},
{"no audio", uploads.Probe{Duration: time.Minute}, uploads.ReasonNoAudio},
}
@@ -210,3 +213,28 @@ func TestAccept_SubmitFailureFailsTheRowHonestlyAndRemovesTheFile(t *testing.T)
"a row that will never run must not sit queued: that is the stuck spinner ADR-025 forbids")
assert.Empty(t, r.tmpFiles(t))
}
func TestAccept_AFileWithAVideoTrackIsAcceptedAndRecordsThatItHadOne(t *testing.T) {
tests := []struct {
name string
probe uploads.Probe
wantVideo bool
}{
{"audio only", uploads.Probe{Duration: 10 * time.Minute, HasAudio: true}, false},
{"audio with a video track (a Teams meeting with the camera off)", uploads.Probe{Duration: 10 * time.Minute, HasAudio: true, HasVideo: true}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := newRig(t)
r.pr.probe = tt.probe
id, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
require.NoError(t, err)
assert.Equal(t, "up-1", id)
assert.Equal(t, []bool{tt.wantVideo}, r.st.hadVideo,
"the row must say whether a video track was present, so the page can say it was ignored")
assert.Len(t, r.sub.jobs, 1, "it is queued like any other recording")
})
}
}
+9 -8
View File
@@ -18,8 +18,8 @@ type Limits struct {
}
// Probe is what inspecting an uploaded file revealed. It is the input to Validate
// because a name or MIME type cannot say whether a container carries video: a
// Teams voice-only export is typically an .mp4 with no video stream.
// because a name or MIME type cannot say what a container holds: a Teams recording
// is an .mp4 whether or not the camera was on.
type Probe struct {
Duration time.Duration
HasAudio bool
@@ -30,11 +30,12 @@ type Probe struct {
type Reason string
const (
ReasonVideo Reason = "video"
ReasonNoAudio Reason = "no_audio"
ReasonTooLong Reason = "too_long"
ReasonTooLarge Reason = "too_large"
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
@@ -46,13 +47,13 @@ type RejectError struct {
func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message }
// Validate decides whether a probed file may be transcribed. Video is checked
// first so a video file is always refused as video, whatever its length.
// Validate decides whether a probed file may be transcribed. A video track is NOT
// a reason to refuse: a Teams meeting with the camera off is still an .mp4 with a
// video track, so rejecting video would reject the primary use case. Only the audio
// is used (Probe.HasVideo is recorded so the user can be told the video was
// ignored). What IS refused: no audio at all, an unreadable length, too long.
func Validate(p Probe, l Limits) error {
switch {
case p.HasVideo:
return &RejectError{Reason: ReasonVideo, Message: "This file contains a video track. " +
"Only audio recordings are supported for now; video upload is tracked in issue #28."}
case !p.HasAudio:
return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."}
case p.Duration <= 0:
+5 -7
View File
@@ -29,16 +29,14 @@ func TestValidate(t *testing.T) {
probe: uploads.Probe{Duration: 90 * time.Minute, HasAudio: true},
},
{
name: "a file with a video stream is rejected and names issue 28",
probe: uploads.Probe{Duration: 4 * time.Minute, HasAudio: true, HasVideo: true},
wantReason: uploads.ReasonVideo,
wantInMsg: []string{"video", "#28"},
name: "a recording that also has a video stream is accepted: the audio is what is used",
probe: uploads.Probe{Duration: 4 * time.Minute, HasAudio: true, HasVideo: true},
},
{
name: "video with no audio is still rejected as video, not as missing audio",
name: "video with no audio is rejected as having no audio, and says so",
probe: uploads.Probe{Duration: 4 * time.Minute, HasVideo: true},
wantReason: uploads.ReasonVideo,
wantInMsg: []string{"#28"},
wantReason: uploads.ReasonNoAudio,
wantInMsg: []string{"no audio"},
},
{
name: "a file with no audio stream is rejected",
+222 -17
View File
@@ -10,11 +10,18 @@ import (
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/progress"
)
// ErrStopped is returned by Submit once the worker has 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.
type Converter interface {
ToWAV(ctx context.Context, src, dst string) error
@@ -26,13 +33,66 @@ type Transcriber interface {
Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error)
}
// Planner is an optional extra on a Transcriber: it declares its own phases (for
// example one per engine) so the page can show them. A Transcriber that does not
// implement it gets a single "transcribing" phase from the worker. Whatever a Planner
// declares it reports itself, through progress.From(ctx) (ADR-035).
type Planner interface {
Plan() []progress.Planned
}
// WorkerStore is the slice of the private transcript store the worker drives.
type WorkerStore interface {
StartTranscribing(ctx context.Context, userID, id string) error
CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
SaveProgress(ctx context.Context, userID, id string, steps []domain.ProgressStep, at time.Time) error
Heartbeat(ctx context.Context, userID, id string) error
}
// WorkerOption tunes a Worker.
type WorkerOption func(*Worker)
// WithHeartbeat sets how often a running job stamps its row alive and how often it logs
// which phase is still running. The defaults are 10 s and 5 min (ADR-035).
func WithHeartbeat(beat, still time.Duration) WorkerOption {
return func(w *Worker) { w.beatEvery, w.stillEvery = beat, still }
}
// 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) error { return s.st.Heartbeat(ctx, s.userID, s.id) }
// Worker transcribes accepted uploads in-process, ONE AT A TIME (ADR-033). It is
// not a second deployable: it shares the web process's lifetime, which is why it
// inherits ADR-018's single-replica assumption — the queue is process-local. The
@@ -45,6 +105,11 @@ type Worker struct {
tmpDir string
timeout time.Duration
log *slog.Logger
beatEvery, stillEvery time.Duration
maxDuration time.Duration
maxQueued, maxPerUser int
mu sync.Mutex
queue []Job
stopped bool
@@ -53,8 +118,34 @@ type Worker struct {
// NewWorker wires a Worker. timeout bounds each job (conversion + transcription);
// a job that outlives it is failed rather than left spinning.
func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout time.Duration) *Worker {
return &Worker{store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1)}
func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout time.Duration, opts ...WorkerOption) *Worker {
w := &Worker{
store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1),
beatEvery: defaultBeatEvery, stillEvery: defaultStillEvery,
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.
@@ -64,6 +155,18 @@ func (w *Worker) Submit(j Job) error {
if w.stopped {
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)
select {
case w.wake <- struct{}{}:
@@ -125,55 +228,118 @@ func (w *Worker) stop() {
// uploaded file and the converted WAV are deleted on every path — the audio is
// never retained (ADR-033). A failure is recorded with a TYPED kind; a shutdown
// mid-job is not a failure and leaves the row for the start-up sweep.
//
// Every phase is reported through one progress.Tracker, which saves it to the row for
// the page and logs it, and a heartbeat stamps the row on its own while the job runs
// (ADR-035). Nothing logged or saved here carries transcript text or the file's name.
func (w *Worker) Process(ctx context.Context, job Job) {
defer func() { _ = os.Remove(job.Path) }()
log := slog.Default().With("upload", job.UploadID)
base := w.log
if base == nil {
base = slog.Default()
}
log := base.With("upload", job.UploadID)
start := time.Now()
if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
log.Error("upload: start transcribing", "err", err)
w.fail(ctx, job, log)
log.Error("upload: start transcribing", "kind", errkind.Of(err))
w.fail(ctx, job, log, domain.UploadErrTranscriptionFailed)
return
}
plan := []progress.Planned{{Key: "received", Label: "Received"}, {Key: "audio", Label: "Preparing the audio"}}
planner, planned := w.tr.(Planner)
if planned {
plan = append(plan, planner.Plan()...)
} else {
plan = append(plan, progress.Planned{Key: "transcribe", Label: "Transcribing"})
}
plan = append(plan, progress.Planned{Key: "save", Label: "Saving the transcript"})
tr := progress.NewTracker(ctx, jobSink{st: w.store, userID: job.UserID, id: job.UploadID}, log, plan)
tr.Start()
tr.Begin("received", 0)
tr.Done("received")
hbCtx, stopBeat := context.WithCancel(ctx)
hbDone := make(chan struct{})
go func() {
defer close(hbDone)
tr.Heartbeat(hbCtx, w.beatEvery, w.stillEvery)
}()
defer func() { stopBeat(); <-hbDone }() // no heartbeat outlives its job
// abort records where the job stopped. A shutdown (the parent context ended) is not
// a failure: the row is left for the start-up sweep, so the step stays as it was.
abort := func(kind domain.UploadErrorKind) {
if ctx.Err() == nil {
tr.FailRunning()
}
w.fail(ctx, job, log, kind)
}
wav, err := w.tempWAV()
if err != nil {
log.Error("upload: temp wav", "err", err)
w.fail(ctx, job, log)
log.Error("upload: temp wav", "kind", errkind.Of(err))
abort(domain.UploadErrTranscriptionFailed)
return
}
defer func() { _ = os.Remove(wav) }()
jobCtx, cancel := context.WithTimeout(ctx, w.timeout)
defer cancel()
tr.Begin("audio", 0)
if err := w.conv.ToWAV(jobCtx, job.Path, wav); err != nil {
log.Error("upload: convert", "err", err)
w.fail(ctx, job, log)
log.Error("upload: convert", "kind", errkind.Of(err))
abort(domain.UploadErrTranscriptionFailed)
return
}
t, err := w.tr.Transcribe(jobCtx, wav)
got, tooLong := w.audioLengthProblem(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(progress.With(jobCtx, tr), wav)
if err != nil {
log.Error("upload: transcribe", "err", err, "elapsed", time.Since(start).Round(time.Second))
w.fail(ctx, job, log)
log.Error("upload: transcribe", "kind", errkind.Of(err), "elapsed", time.Since(start).Round(time.Second))
abort(domain.UploadErrTranscriptionFailed)
return
}
if !planned {
tr.Done("transcribe")
}
tr.Begin("save", 0)
if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
log.Error("upload: store transcript", "err", err)
w.fail(ctx, job, log)
log.Error("upload: store transcript", "kind", errkind.Of(err))
abort(domain.UploadErrTranscriptionFailed)
return
}
tr.Done("save")
tr.Finish()
log.Info("upload transcribed", "endpoint", t.Endpoint, "audio_s", t.AudioSeconds,
"segments", len(t.Segments), "elapsed", time.Since(start).Round(time.Second))
}
// fail records a transcription failure — unless the PARENT context ended, which
// is a shutdown, not a failure: the row is left for the start-up sweep.
func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger) {
func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger, kind domain.UploadErrorKind) {
if ctx.Err() != nil {
return
}
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, domain.UploadErrTranscriptionFailed); err != nil {
log.Error("upload: record failure", "err", err)
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, kind); err != nil {
log.Error("upload: record failure", "kind", errkind.Of(err))
}
}
@@ -189,3 +355,42 @@ func (w *Worker) tempWAV() (string, error) {
}
return name, nil
}
const (
// tooLongSlack is how far over the limit the converted audio may be before the claim
// in the file's header is called a lie: ffmpeg rounds.
tooLongSlack = 30 * time.Second
// A file counts as truncated when it converts to under 80% of the length it claimed
// AND more than two minutes short. Probe estimates for variable-bitrate files are
// imprecise; refusing real recordings would be worse than the problem.
incompleteBelow = 0.8
incompleteShortfall = 2 * time.Minute
)
// audioLengthProblem measures the converted WAV (16 kHz mono 16-bit PCM is 32,000 bytes a
// second after a 44-byte header) against the limit. 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
@@ -0,0 +1,187 @@
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)
}
+215
View File
@@ -0,0 +1,215 @@
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")
}
+108
View File
@@ -0,0 +1,108 @@
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")
}
+52
View File
@@ -23,6 +23,13 @@ type wStore struct {
completed map[string]domain.Transcription
failed map[string]domain.UploadErrorKind
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 {
@@ -39,6 +46,9 @@ func (s *wStore) StartTranscribing(_ context.Context, _, id string) error {
func (s *wStore) CompleteUpload(_ context.Context, _, id string, t domain.Transcription) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.completeErr != nil {
return s.completeErr
}
s.completed[id] = t
return nil
}
@@ -50,6 +60,36 @@ func (s *wStore) FailUpload(_ context.Context, _, id string, k domain.UploadErro
return nil
}
func (s *wStore) SaveProgress(_ context.Context, _, _ string, steps []domain.ProgressStep, _ time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
s.saves = append(s.saves, append([]domain.ProgressStep(nil), steps...))
return nil
}
func (s *wStore) Heartbeat(context.Context, string, string) error {
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) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -173,6 +213,10 @@ func TestWorkerProcess_FailuresAreTypedAndStillDeleteTheAudio(t *testing.T) {
}{
{"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") }},
{"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 {
t.Run(tt.name, func(t *testing.T) {
@@ -280,3 +324,11 @@ 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.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" }
+5
View File
@@ -122,6 +122,11 @@ type App struct {
// summarizer's (koala/phi4-mini, 8,192-token context) and cannot hold an hour of
// speech.
UploadChatModel string
// UploadChatModels is the set of models chat over an uploaded recording offers and
// accepts. The whole private transcript goes to the one chosen, so it must hold
// LOCAL models only, and the caller decides that. Nil or empty fails closed: no
// model is offered and none is called (ADR-035 review).
UploadChatModels []string
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
+276 -14
View File
@@ -3,10 +3,15 @@ package web
import (
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/a-h/templ"
@@ -14,6 +19,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/errkind"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
@@ -30,7 +36,15 @@ const (
uploadOverhead = 1 << 20
maxTitleRunes = 200
// uploadPollEvery is how often an unfinished upload re-asks for its status.
uploadPollEvery = "5s"
uploadPollEvery = "2s"
// serviceSilentAfter is how long a job may go without any signal (heartbeat or
// progress) before the page calls the service lost. The heartbeat is every 10 s, so
// 45 s is four missed beats (ADR-035).
serviceSilentAfter = 45 * time.Second
// countedQuietAfter is how long a COUNTED phase (the deciding one) may go without
// advancing before the page says it is quiet. Each decision has a two-minute timeout,
// so five minutes is more than two in a row. A phase with no count is never quiet.
countedQuietAfter = 5 * time.Minute
)
// uploadsView is what the /uploads page renders.
@@ -80,13 +94,32 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
break
}
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)
return
}
switch part.FormName() {
case "title":
b, _ := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
title = clipRunes(strings.TrimSpace(string(b)), maxTitleRunes)
b, err := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
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
}
title = cleanTitle(string(b))
case "file":
if title == "" {
title = titleFromFilename(part.FileName())
@@ -103,24 +136,54 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
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
// rejection is the user's to fix (422, or 413 for size) and carries its own
// message; anything else is ours.
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
if a.abandoned(w, r) {
return
}
var rej *uploads.RejectError
var tooBig *http.MaxBytesError
switch {
case errors.As(err, &rej):
// Logged by reason only: the filename and title are private content.
a.logger().Info("upload rejected", "reason", string(rej.Reason))
status := http.StatusUnprocessableEntity
if rej.Reason == uploads.ReasonTooLarge {
switch rej.Reason {
case uploads.ReasonTooLarge:
status = http.StatusRequestEntityTooLarge
case uploads.ReasonBusy:
status = http.StatusTooManyRequests
}
a.renderUploadsPage(w, r, userID, status, rej.Message)
case errors.As(err, &tooBig):
a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
"This file is larger than the upload limit.")
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:
a.serverError(w, r, "upload", err)
// Not err: it can carry the file's name, a spool path or a database value.
a.logger().Error("upload failed", "kind", errkind.Of(err))
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
@@ -132,7 +195,7 @@ func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
a.render(w, r, UploadPage(*u, a.Chat != nil))
a.render(w, r, UploadPage(*u, a.Chat != nil, a.now()))
}
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
@@ -143,7 +206,7 @@ func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
a.render(w, r, uploadStatus(*u, a.Chat != nil))
a.render(w, r, uploadStatus(*u, a.Chat != nil, a.now()))
}
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
@@ -151,7 +214,14 @@ func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.U
if !ok {
return nil, false
}
u, err := a.Store.GetUpload(r.Context(), userID, r.PathValue("id"))
id := 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) {
http.NotFound(w, r)
return nil, false
@@ -166,11 +236,49 @@ func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.U
// titleFromFilename is the default title: the file's name without its extension.
func titleFromFilename(name string) string {
base := filepath.Base(name)
t := strings.TrimSuffix(base, filepath.Ext(base))
t := cleanTitle(strings.TrimSuffix(base, filepath.Ext(base)))
if t == "" || t == "." {
return "Untitled recording"
}
return clipRunes(t, maxTitleRunes)
return t
}
// 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 {
@@ -186,6 +294,10 @@ func uploadFailureMessage(k domain.UploadErrorKind) string {
switch k {
case domain.UploadErrInterrupted:
return "The server restarted before this recording was transcribed. Please upload it again."
case domain.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:
return "Transcription failed on every available service. Please upload it again later."
default:
@@ -206,7 +318,8 @@ func (a *App) handleUploadChat(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
return
}
a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, a.Chat.DefaultModel(a.UploadChatModel), nil, false, "")))
model, _ := a.resolveUploadModel("")
a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, model, nil, false, "")))
}
// handleUploadChatMessage answers one question against the owner's uploaded
@@ -226,7 +339,13 @@ func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
model := a.resolveModel(r.FormValue("model"), a.UploadChatModel)
model, ok := a.resolveUploadModel(r.FormValue("model"))
if !ok {
a.logger().Error("upload chat: no local model is configured; nothing was sent", "upload", u.ID)
view := a.uploadChatView(*u, "", nil, false, "No local model is available for chat about a recording.")
a.render(w, r, UploadChatPage(*u, view))
return
}
history := parseHistory(r.Form["hq"], r.Form["ha"])
question := strings.TrimSpace(r.FormValue("question"))
@@ -240,7 +359,7 @@ func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
MaxChars: a.UploadChatMaxChars,
})
if err != nil {
a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "err", err)
a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "kind", errkind.Of(err))
view.Error = "That model couldn't answer just now. Try again, or switch models."
} else {
view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer})
@@ -259,7 +378,7 @@ func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history
VideoID: u.ID,
Title: u.Title,
Available: true,
Models: a.Chat.Models(),
Models: a.UploadChatModels,
Selected: model,
History: history,
Truncated: truncated,
@@ -268,3 +387,146 @@ func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history
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
}
+142 -4
View File
@@ -2,6 +2,7 @@ package web
import (
"fmt"
"strconv"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
@@ -44,11 +45,11 @@ templ UploadsPage(v uploadsView) {
}
// UploadPage is one upload's page: its title and the polling status region.
templ UploadPage(u domain.UploadedTranscript, chatEnabled bool) {
templ UploadPage(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
@Layout("Tapir — " + u.Title) {
<article class="account">
<h1>{ u.Title }</h1>
@uploadStatus(u, chatEnabled)
@uploadStatus(u, chatEnabled, now)
<p><a href="/uploads">All recordings</a></p>
</article>
}
@@ -56,14 +57,22 @@ templ UploadPage(u domain.UploadedTranscript, chatEnabled bool) {
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
// page re-asks; done and failed render without it, so nothing polls forever.
templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
switch u.Status {
case domain.UploadDone:
<div class="upload-status upload-done" role="status">
@videoNotice(u.HadVideo)
<p>
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by <span class="chip">{ u.Endpoint }</span>.
</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 {
<p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p>
}
@@ -74,7 +83,14 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
</div>
case domain.UploadFailed:
<div class="upload-status upload-failed flash flash-error" role="alert">
@videoNotice(u.HadVideo)
<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>
}
</div>
default:
<div
@@ -84,8 +100,15 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
hx-trigger={ "every " + uploadPollEvery }
hx-swap="outerHTML"
>
@videoNotice(u.HadVideo)
if u.Status == domain.UploadTranscribing {
<p>Transcribing… a long recording takes a few minutes.</p>
{{ 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>
}
} else {
<p>Queued — waiting for its turn to be transcribed.</p>
}
@@ -108,3 +131,118 @@ templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
</article>
}
}
// videoNotice tells the user a video track was present and what became of it. A
// Teams meeting with the camera off is still a video file; it is accepted, only the
// audio is transcribed, and the picture is thrown away — so say so, in every state,
// instead of leaving the user to wonder (ADR-033).
templ videoNotice(hadVideo bool) {
if hadVideo {
<p class="muted video-notice">
This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.
</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>
}
}
@@ -0,0 +1,93 @@
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
@@ -0,0 +1,31 @@
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,6 +31,7 @@ func newUploadChatApp(t *testing.T) (*web.App, *fakeChatter) {
}
app.Chat = fc
app.UploadChatMaxChars = 120000
app.UploadChatModels = fc.models
return app, fc
}
@@ -0,0 +1,73 @@
package web_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/web"
)
// The whole private transcript goes to the model chosen in the form, so the upload chat
// offers and accepts local models only, even when the shared chat service knows a cloud
// one (ADR-035 review). The cloud model here is only a name; no network is involved.
func newUploadChatAppWithCloud(t *testing.T) (*web.App, *fakeChatter) {
t.Helper()
app, _ := newUploadApp(t, audioOK, 200<<20)
fc := &fakeChatter{
models: []string{"koala/phi4-mini", "iguana/gemma4-26b", "berget/mistral-small"},
reply: chat.Reply{Answer: "Anders owns the timber order."},
}
app.Chat = fc
app.UploadChatMaxChars = 120000
app.UploadChatModels = []string{"koala/phi4-mini", "iguana/gemma4-26b"}
return app, fc
}
func TestUploadChatPage_DoesNotOfferACloudModelEvenThoughTheServiceKnowsOne(t *testing.T) {
app, _ := newUploadChatAppWithCloud(t)
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
b := body(t, do(t, app, get("/uploads/"+id+"/chat")))
assert.Contains(t, b, "iguana/gemma4-26b")
assert.NotContains(t, b, "berget/mistral-small", "the switcher must not list a model the transcript must not be sent to")
}
func TestUploadChatPost_AForgedCloudModelIsReplacedByALocalOneBeforeTheTranscriptIsSent(t *testing.T) {
app, fc := newUploadChatAppWithCloud(t)
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"Who owns timber?"}, "model": {"berget/mistral-small"}}))
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, fc.callCount)
assert.NotEqual(t, "berget/mistral-small", fc.gotReq.Model, "a cloud model reached the chat service with the private transcript")
assert.Contains(t, []string{"koala/phi4-mini", "iguana/gemma4-26b"}, fc.gotReq.Model)
}
func TestUploadChatPost_ALocalModelThatIsOfferedIsHonoured(t *testing.T) {
app, fc := newUploadChatAppWithCloud(t)
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"koala/phi4-mini"}}))
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "koala/phi4-mini", fc.gotReq.Model)
}
func TestUploadChat_WithNoLocalModelConfiguredNothingIsSentAnywhere(t *testing.T) {
app, fc := newUploadChatAppWithCloud(t)
app.UploadChatModels = nil
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"berget/mistral-small"}}))
require.Equal(t, http.StatusOK, rec.Code)
assert.Zero(t, fc.callCount, "failing closed: no local model offered means no model is called")
}
@@ -0,0 +1,124 @@
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"
)
// 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, 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")
}
+206 -4
View File
@@ -3,6 +3,8 @@ package web_test
import (
"bytes"
"context"
"fmt"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -130,7 +132,7 @@ func TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt(t *testing.T) {
assert.Equal(t, "Sprint review", title)
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
require.Len(t, sub.jobs, 1)
assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path}, sub.jobs[0])
assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path, Duration: audioOK.Duration}, sub.jobs[0])
spooled, err := os.ReadFile(sub.jobs[0].Path)
require.NoError(t, err)
assert.Equal(t, "synthetic audio bytes", string(spooled))
@@ -158,12 +160,12 @@ func TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow(t *testing.T) {
wantInBody []string
}{
{
name: "video names issue 28",
probe: uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true},
name: "video with no audio is rejected for having no audio",
probe: uploads.Probe{Duration: time.Minute, HasVideo: true},
maxBytes: 1 << 20,
content: "x",
wantStatus: http.StatusUnprocessableEntity,
wantInBody: []string{"video", "#28"},
wantInBody: []string{"no audio"},
},
{
name: "too long states both lengths",
@@ -275,3 +277,203 @@ func TestUploadStatus_DoneNamesTheEndpointThatServedIt(t *testing.T) {
"which backend served a transcript is a quality fact, and berget means the audio left the homelab")
assert.True(t, strings.Contains(b, "45 min"), "the audio length is shown")
}
func markHadVideo(t *testing.T, id string) {
t.Helper()
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET had_video = TRUE WHERE id = $1`, id)
require.NoError(t, err)
}
func TestUploadPost_AFileWithAVideoTrackIsAcceptedAndTheRowRemembersIt(t *testing.T) {
// A Teams meeting with the camera off is still an .mp4 with a video track.
app, sub := newUploadApp(t, uploads.Probe{Duration: 62 * time.Minute, HasAudio: true, HasVideo: true}, 200<<20)
rec := do(t, app, uploadReq(t, "Project notes", "Teams meeting.mp4", []byte("synthetic bytes")))
require.Equal(t, http.StatusSeeOther, rec.Code, "it must be accepted, not rejected as video")
var hadVideo bool
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
assert.True(t, hadVideo)
assert.Len(t, sub.jobs, 1)
}
func TestUploadPost_AudioOnlyIsNotMarkedAsHavingHadVideo(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
do(t, app, uploadReq(t, "Voice memo", "memo.m4a", []byte("x")))
var hadVideo bool
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
assert.False(t, hadVideo)
}
func TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
states := []struct{ status, content string }{
{"queued", ""}, {"transcribing", ""}, {"done", "Synthetic transcript."}, {"failed", "interrupted"},
}
for _, st := range states {
t.Run(st.status, func(t *testing.T) {
withVideo := seedUpload(t, userID, "camera off "+st.status, st.status, st.content)
markHadVideo(t, withVideo)
audioOnly := seedUpload(t, userID, "voice only "+st.status, st.status, st.content)
notice := body(t, do(t, app, get("/uploads/"+withVideo+"/status")))
plain := body(t, do(t, app, get("/uploads/"+audioOnly+"/status")))
assert.Contains(t, notice, "video track")
assert.Contains(t, notice, "Only its audio was used", "say what happened, not just that something did")
assert.Contains(t, notice, "discarded")
assert.NotContains(t, plain, "video track", "no notice when there was no video")
})
}
}
func TestUploadPage_AlsoCarriesTheVideoNotice(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "camera off", "done", "Synthetic transcript.")
markHadVideo(t, id)
assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "Only its audio was used")
}
func TestUploadPost_ARejectionIsLoggedWithItsReasonAndNeverTheFilenameOrTitle(t *testing.T) {
// Rejections are synchronous and leave no row, so without a log line an operator
// cannot tell "the user never retried" from "the page refused it". The reason is
// operational; the filename and title are the user's private content.
var buf bytes.Buffer
app, _ := newUploadApp(t, uploads.Probe{Duration: time.Minute, HasVideo: true}, 1<<20)
app.Log = slog.New(slog.NewTextHandler(&buf, nil))
rec := do(t, app, uploadReq(t, "Secret project notes", "client-acme-roadmap.mp4", []byte("x")))
require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
logged := buf.String()
assert.Contains(t, logged, "upload rejected")
assert.Contains(t, logged, "reason=no_audio")
assert.NotContains(t, logged, "Secret project notes", "the title is private content")
assert.NotContains(t, logged, "client-acme-roadmap", "the filename is private content")
}
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.")
})
}
}
@@ -0,0 +1,72 @@
package web_test
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Error paths of the upload routes log a kind, never the error's text, which can carry
// a file name, a path, a database value or a provider's output (adversarial review F5).
type lockedBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (l *lockedBuf) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.Write(p)
}
func (l *lockedBuf) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.b.String()
}
type failingUploader struct{ err error }
func (f failingUploader) Accept(context.Context, string, string, io.Reader) (string, error) {
return "", f.err
}
func TestUploadPost_AnUnexpectedFailureLogsAKindAndNotItsText(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
app.Uploads = failingUploader{err: errors.New(`ffprobe: Error opening input file /spool/upload-555 for "zebrakaka-meeting.m4a"`)}
rec := do(t, app, uploadReq(t, "t", "zebrakaka-meeting.m4a", []byte("x")))
assert.Equal(t, http.StatusInternalServerError, rec.Code)
out := buf.String()
assert.NotContains(t, out, "zebrakaka", "a file name is private content")
assert.NotContains(t, out, "/spool/", "a spool path carries the upload's identity")
assert.Contains(t, out, "kind=error")
}
func TestUploadChatPost_AModelFailureLogsAKindAndNotItsText(t *testing.T) {
app, fc := newUploadChatApp(t)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
fc.err = errors.New("LLM returned 400: the prompt was zebrakaka secret words")
id := seedUpload(t, userID, "m", "done", meetingText)
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
require.Equal(t, http.StatusOK, rec.Code)
out := buf.String()
assert.NotContains(t, out, "zebrakaka")
assert.Contains(t, out, "kind=error")
}
@@ -0,0 +1,287 @@
package web_test
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web"
)
// The page's progress display (ADR-035, #42): real phases from the row, and the three
// honest states the page can tell apart -- running, quiet (counted phase not advancing)
// and lost (the service stopped reporting). Real router, real Postgres, a fixed clock.
var progressNow = time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
func fixedClock(app *web.App) { app.Now = func() time.Time { return progressNow } }
// seedProgress writes the job's phases and the two timestamps onto an existing row.
func seedProgress(t *testing.T, id string, steps []domain.ProgressStep, progressAt, aliveAt time.Time) {
t.Helper()
b, err := json.Marshal(steps)
require.NoError(t, err)
var alive any
if !aliveAt.IsZero() {
alive = aliveAt
}
_, err = rawPool(t).Exec(context.Background(),
`UPDATE uploaded_transcripts SET progress = $2, progress_at = $3, alive_at = $4 WHERE id = $1`,
id, b, progressAt, alive)
require.NoError(t, err)
}
func step(key, label string, state domain.StepState, startedAgo, ranFor time.Duration, done, total int) domain.ProgressStep {
s := domain.ProgressStep{Key: key, Label: label, State: state, Done: done, Total: total}
if state != domain.StepPending {
s.StartedAt = progressNow.Add(-startedAgo)
}
if state == domain.StepDone || state == domain.StepFailed {
s.FinishedAt = s.StartedAt.Add(ranFor)
}
return s
}
// twoEngineRun is a job in its second engine: three steps done, one running, three to come.
func twoEngineRun() []domain.ProgressStep {
return []domain.ProgressStep{
step("received", "Received", domain.StepDone, 5*time.Minute, 0, 0, 0),
step("audio", "Preparing the audio", domain.StepDone, 5*time.Minute, 40*time.Second, 0, 0),
step("engine_a", "Transcribing with iguana/pianissimo", domain.StepDone, 4*time.Minute, 38*time.Second, 0, 0),
step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepRunning, 3*time.Minute+12*time.Second, 0, 0, 0),
step("compare", "Comparing the two transcripts", domain.StepPending, 0, 0, 0, 0),
step("decide", "Deciding the differences", domain.StepPending, 0, 0, 0, 0),
step("save", "Saving the transcript", domain.StepPending, 0, 0, 0, 0),
}
}
func statusBody(t *testing.T, app *web.App, id string) string {
t.Helper()
rec := do(t, app, get("/uploads/"+id+"/status"))
require.Equal(t, 200, rec.Code)
return body(t, rec)
}
func TestUploadProgress_ARunningJobShowsEveryPhaseInOrderWithTheRunningOneMarked(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
seedProgress(t, id, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
b := statusBody(t, app, id)
last := -1
for _, label := range []string{
"Received", "Preparing the audio", "Transcribing with iguana/pianissimo", "Transcribing with iguana/kb-whisper",
"Comparing the two transcripts", "Deciding the differences", "Saving the transcript",
} {
i := strings.Index(b, label)
require.Greater(t, i, last, "phase %q missing or out of order:\n%s", label, b)
last = i
}
assert.Equal(t, 3, strings.Count(b, "step-done"), "three finished phases collapse to a done line")
assert.Equal(t, 1, strings.Count(b, `aria-current="step"`), "exactly one phase is the current one")
assert.Contains(t, b, "3m 12s", "the running phase shows how long it has run")
assert.Contains(t, b, "38s", "a finished phase shows how long it took")
assert.Contains(t, b, `hx-trigger="every 2s"`, "an unfinished job re-asks every two seconds")
}
func TestUploadProgress_OnlyACountedPhaseGetsABarAndItShowsRealCounts(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
steps := twoEngineRun()
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 3*time.Minute, 2*time.Minute, 0, 0)
steps[4] = step("compare", "Comparing the two transcripts", domain.StepDone, 1*time.Minute, time.Second, 0, 0)
steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 50*time.Second, 0, 142, 282)
seedProgress(t, id, steps, progressNow.Add(-5*time.Second), progressNow.Add(-3*time.Second))
b := statusBody(t, app, id)
assert.Contains(t, b, `<progress`)
assert.Contains(t, b, `value="142"`)
assert.Contains(t, b, `max="282"`)
assert.Contains(t, b, "142 of 282")
// ...and a phase with no honest count never gets one: no percentage out of thin air.
app2, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app2)
id2 := seedUpload(t, userID, "m2", "transcribing", "")
seedProgress(t, id2, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
assert.NotContains(t, statusBody(t, app2, id2), "<progress", "an engine call is one request: there is nothing to count")
}
func TestUploadProgress_ALongEngineCallWithAFreshHeartbeatIsStillJustRunning(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
steps := twoEngineRun()
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepRunning, 25*time.Minute, 0, 0, 0)
seedProgress(t, id, steps, progressNow.Add(-25*time.Minute), progressNow.Add(-3*time.Second))
b := statusBody(t, app, id)
assert.Contains(t, b, "25m 00s")
assert.NotContains(t, b, "step-health-lost")
assert.NotContains(t, b, "step-health-quiet",
"a single blocking request has no sub-progress to go quiet about; the heartbeat says the service is alive")
}
func TestUploadProgress_AHeartbeatThatStoppedMeansTheServiceIsLostAndTheJobIsNotCalledRunning(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
seedProgress(t, id, twoEngineRun(), progressNow.Add(-3*time.Minute), progressNow.Add(-3*time.Minute))
b := statusBody(t, app, id)
assert.Contains(t, b, "step-health-lost")
assert.Contains(t, b, "stopped reporting")
assert.Contains(t, b, "3 min", "how long ago it was last heard from")
assert.NotContains(t, b, "spinner", "a lost job must not wear the running spinner")
assert.Contains(t, b, "step-mark-warn", "the running phase of a lost job carries a warning mark, in the error colour, not the accent")
assert.Contains(t, b, `hx-trigger="every`, "it keeps asking: a restarted service comes back, or the start-up sweep fails the row")
}
func TestUploadProgress_ACountedPhaseThatStopsAdvancingGoesQuietNotLost(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
steps := twoEngineRun()
steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 20*time.Minute, 0, 142, 282)
seedProgress(t, id, steps, progressNow.Add(-6*time.Minute), progressNow.Add(-3*time.Second))
b := statusBody(t, app, id)
assert.Contains(t, b, "step-health-quiet")
assert.Contains(t, b, "Nothing new for 6 min")
assert.NotContains(t, b, "stopped reporting", "the service is alive; it is the model that is slow")
}
func TestUploadProgress_BeforeTheFirstHeartbeatAJobIsRunningUntilItHasBeenSilentTooLong(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
fresh := seedUpload(t, userID, "fresh", "transcribing", "")
seedProgress(t, fresh, twoEngineRun(), progressNow.Add(-5*time.Second), time.Time{})
silent := seedUpload(t, userID, "silent", "transcribing", "")
seedProgress(t, silent, twoEngineRun(), progressNow.Add(-2*time.Minute), time.Time{})
assert.NotContains(t, statusBody(t, app, fresh), "step-health-lost", "the first beat has not been due yet")
assert.Contains(t, statusBody(t, app, silent), "step-health-lost", "no beat and no progress for two minutes is a lost service")
}
func TestUploadProgress_AQueuedJobShowsNoPhasesAndNoLostWarning(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "queued", "")
b := statusBody(t, app, id)
assert.Contains(t, b, "Queued")
assert.NotContains(t, b, `class="steps"`)
assert.NotContains(t, b, "step-health")
assert.Contains(t, b, `hx-trigger="every 2s"`)
}
func TestUploadProgress_AFailedJobShowsWhichPhaseFailedAndWhatNeverStarted(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "failed", "transcription_failed")
steps := twoEngineRun()
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepFailed, 3*time.Minute, time.Minute, 0, 0)
seedProgress(t, id, steps, progressNow.Add(-2*time.Minute), progressNow.Add(-2*time.Minute))
b := statusBody(t, app, id)
assert.Contains(t, b, "Transcription failed")
assert.Contains(t, b, "step-failed")
assert.Contains(t, b, "step-pending", "what never started says where the job stopped")
assert.NotContains(t, b, "hx-trigger", "a failed job stops polling")
assert.NotContains(t, b, "step-health", "a finished job is not 'lost'")
}
func TestUploadProgress_ADoneJobKeepsItsRecordOfWhatWasDoneAndHowLongEachTook(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
steps := []domain.ProgressStep{
step("received", "Received", domain.StepDone, 20*time.Minute, 0, 0, 0),
step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0),
step("decide", "Deciding the differences", domain.StepDone, 15*time.Minute, 9*time.Minute+30*time.Second, 282, 282),
step("save", "Saving the transcript", domain.StepSkipped, 0, 0, 0, 0),
}
seedProgress(t, id, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
b := statusBody(t, app, id)
assert.Contains(t, b, "Synthetic transcript text.")
assert.Contains(t, b, "What was done")
assert.Contains(t, b, "9m 30s")
assert.Contains(t, b, "step-skipped")
assert.NotContains(t, b, "hx-trigger", "a finished upload must not poll forever")
assert.NotContains(t, b, "step-health", "a finished job is never 'lost' however old its heartbeat")
assert.NotContains(t, b, "spinner")
}
// stepsBlock is the progress list of a page: from its opening tag to its closing one.
func stepsBlock(t *testing.T, html string) string {
t.Helper()
start := strings.Index(html, `<ol class="steps"`)
require.GreaterOrEqual(t, start, 0, "no progress list in:\n%s", html)
end := strings.Index(html[start:], "</ol>")
require.GreaterOrEqual(t, end, 0)
return html[start : start+end+len("</ol>")]
}
func TestUploadProgress_TheFullPageCarriesTheVerySameProgressListAsTheFragment(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
seedProgress(t, id, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
full := body(t, do(t, app, get("/uploads/"+id)))
fragment := statusBody(t, app, id)
assert.Equal(t, stepsBlock(t, fragment), stepsBlock(t, full),
"the page a user opens and the fragment HTMX swaps in must show the same list, or the display jumps on the first poll")
}
func TestUploadProgress_AStepNoteIsShownDirectlyUnderItsStepAndEscaped(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
steps := twoEngineRun()
steps[1].Note = "The audio is 20 min long; the file claimed 1 h 19 min. <b>cut off</b>?"
seedProgress(t, id, steps, progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
b := statusBody(t, app, id)
audio := strings.Index(b, "Preparing the audio")
note := strings.Index(b, "The audio is 20 min long")
next := strings.Index(b, "Transcribing with iguana/pianissimo")
require.True(t, audio >= 0 && note > audio && next > note, "the note must sit between its own step and the next one:\n%s", b)
assert.Contains(t, b, "step-note")
assert.NotContains(t, b, "<b>cut off</b>", "a note is words, never markup")
}
func TestUploadProgress_ADonePageShowsItsRecordOpenWhenAStepHasANote(t *testing.T) {
// A truncated recording must not look like a clean one: the note that replaced the
// refusal is the only warning, so it cannot sit inside a collapsed list (third review).
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
noted := seedUpload(t, userID, "noted", "done", "Synthetic transcript text.")
steps := []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}
steps[0].Note = "The audio is 20 min long; the file claimed 1 h 19 min."
seedProgress(t, noted, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
clean := seedUpload(t, userID, "clean", "done", "Synthetic transcript text.")
seedProgress(t, clean, []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
assert.Contains(t, statusBody(t, app, noted), `<details class="what-was-done" open>`)
assert.NotContains(t, statusBody(t, app, clean), `<details class="what-was-done" open>`, "a clean record stays collapsed")
}
+81
View File
@@ -0,0 +1,81 @@
package web_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// TestRenderUploadProgressStatesForReview writes the upload page in each progress state
// to $TAPIR_SHOT_DIR as standalone HTML (light and dark), for a screenshot review of the
// visual claims tests cannot assert: that it looks like tapir and reads like a log of
// phases (ADR-035, #42). Opt-in: it does nothing unless the variable is set. The text is
// synthetic. Screenshot with any browser, for example:
//
// TAPIR_SHOT_DIR=/tmp/shots go test -run TestRenderUploadProgressStatesForReview ./internal/web
// chromium --headless --screenshot=/tmp/shots/running.png --window-size=900,900 /tmp/shots/running-light.html
func TestRenderUploadProgressStatesForReview(t *testing.T) {
dir := os.Getenv("TAPIR_SHOT_DIR")
if dir == "" {
t.Skip("set TAPIR_SHOT_DIR to render the progress states for a screenshot review")
}
require.NoError(t, os.MkdirAll(dir, 0o750))
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
decide := twoEngineRun()
decide[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 9*time.Minute, 4*time.Minute, 0, 0)
decide[4] = step("compare", "Comparing the two transcripts", domain.StepDone, 5*time.Minute, time.Second, 0, 0)
decide[5] = step("decide", "Deciding the differences", domain.StepRunning, 4*time.Minute+30*time.Second, 0, 142, 282)
failed := twoEngineRun()
failed[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepFailed, 3*time.Minute, time.Minute, 0, 0)
done := []domain.ProgressStep{
step("received", "Received", domain.StepDone, 22*time.Minute, 0, 0, 0),
step("audio", "Preparing the audio", domain.StepDone, 22*time.Minute, 41*time.Second, 0, 0),
step("engine_a", "Transcribing with iguana/pianissimo", domain.StepDone, 21*time.Minute, 2*time.Minute+10*time.Second, 0, 0),
step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 19*time.Minute, 4*time.Minute+5*time.Second, 0, 0),
step("compare", "Comparing the two transcripts", domain.StepDone, 15*time.Minute, 2*time.Second, 0, 0),
step("decide", "Deciding the differences", domain.StepDone, 15*time.Minute, 14*time.Minute, 282, 282),
step("save", "Saving the transcript", domain.StepDone, time.Minute, time.Second, 0, 0),
}
noted := twoEngineRun()
noted[1].Note = "The audio is 20 min long; the file claimed 1 h 19 min. If the recording was cut off, upload the complete one."
cases := []struct {
name, status, content string
steps []domain.ProgressStep
progressAgo, aliveAgo time.Duration
}{
{"running", "transcribing", "", twoEngineRun(), 10 * time.Second, 4 * time.Second},
{"deciding", "transcribing", "", decide, 3 * time.Second, 3 * time.Second},
{"quiet", "transcribing", "", decide, 6 * time.Minute, 4 * time.Second},
{"lost", "transcribing", "", twoEngineRun(), 3 * time.Minute, 3 * time.Minute},
{"failed", "failed", "transcription_failed", failed, 2 * time.Minute, 2 * time.Minute},
{"done", "done", "Synthetic transcript text for the review.", done, time.Minute, time.Minute},
{"noted", "transcribing", "", noted, 10 * time.Second, 4 * time.Second},
{"queued", "queued", "", nil, 0, 0},
}
for _, c := range cases {
id := seedUpload(t, userID, "Project notes (synthetic)", c.status, c.content)
if c.steps != nil {
seedProgress(t, id, c.steps, progressNow.Add(-c.progressAgo), progressNow.Add(-c.aliveAgo))
}
page := body(t, do(t, app, get("/uploads/"+id)))
for _, theme := range []string{"light", "dark"} {
html := page
if theme == "dark" {
html = strings.Replace(html, "<html", `<html data-theme="dark"`, 1)
}
require.NoError(t, os.WriteFile(filepath.Join(dir, c.name+"-"+theme+".html"), []byte(html), 0o600))
}
}
}
@@ -0,0 +1,72 @@
package web_test
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The stored judged spots include the ones the model kept as the base (ADR-035), for a
// later evaluation. The page lists only the ones a reader should check: changed and
// uncertain.
func TestUploadStatus_OnlyChangedAndUncertainSpotsAreListedNotTheKeptOnes(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":3,"adjudicated":3,"changed":1,"unsure":1,
"contested":[
{"seconds":60,"base":"CHANGEDBASE","alt":"CHANGEDALT","used":"alt","unsure":false},
{"seconds":120,"base":"KEPTBASE","alt":"KEPTALT","used":"base","unsure":false},
{"seconds":180,"base":"UNSUREBASE","alt":"UNSUREALT","used":"base","unsure":true}]}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "CHANGEDBASE")
assert.Contains(t, b, "UNSUREALT")
assert.NotContains(t, b, "KEPTBASE", "a spot the model confidently kept is not one for the reader to check")
assert.NotContains(t, b, "KEPTALT")
}
func TestUploadStatus_TheMoreCountIgnoresKeptSpotsAndMatchesTheListedKind(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
var spots []string
for i := 0; i < 60; i++ {
spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"b%d","alt":"a%d","used":"alt","unsure":false}`, i*10, i, i))
spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"k%d","alt":"j%d","used":"base","unsure":false}`, i*10+5, i, i))
}
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":400,"changed":60,"contested":[`+strings.Join(spots, ",")+`]}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "10 more", "60 changed, 50 shown: the 60 kept ones do not count")
assert.NotContains(t, b, "k0 / j0", "a kept spot is not rendered even when it is among the first stored")
}
func TestUploadStatus_WhenNothingNeedsCheckingThereIsNoSpotsSection(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"adjudicated":1,
"contested":[{"seconds":60,"base":"KEPTBASE","alt":"KEPTALT","used":"base","unsure":false}]}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
require.Contains(t, b, "Reconciled from two transcripts")
assert.NotContains(t, b, "Spots to check", "an empty list would invite checking nothing")
}
func TestUploadStatus_ASpotShowsTheWordsJustBeforeIt(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"changed":1,
"contested":[{"seconds":2640,"base":"vanligt","alt":"KORT","used":"alt","unsure":false,"before":"grannen målar ett"}]}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "grannen målar ett", "the words before the spot are how a reader finds it in the audio")
assert.Contains(t, b, "vanligt")
assert.Contains(t, b, "KORT")
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -765,6 +765,32 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
.flash-error { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
/* upload progress (ADR-035): a log of phases that fills in as the job runs */
.steps { list-style: none; margin: var(--s3) 0; padding: 0; font-family: var(--mono); font-size: .86rem; }
.step { display: flex; align-items: baseline; gap: var(--s2); padding: .2rem 0; color: var(--muted); flex-wrap: wrap; }
.step-mark { flex: none; width: 1.1rem; text-align: center; }
.step-label { flex: 1 1 auto; }
.step-time { margin-left: auto; color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; }
.step-done { color: var(--fg); }
.step-done .step-mark { color: var(--mint); }
.step-running { color: var(--fg); font-weight: 600; }
.step-running .step-mark { color: var(--accent); }
.step-failed { color: var(--err-fg); }
.step-skipped, .step-pending { color: var(--muted); }
.step-skipped { opacity: .7; }
.step-pending { opacity: .75; }
.step-bar { flex: none; width: calc(100% - 1.6rem); margin: 0 0 0 1.6rem; height: .5rem; accent-color: var(--accent); }
.step-note { flex: none; width: calc(100% - 1.6rem); margin-left: 1.6rem; font-weight: 400; color: var(--muted); font-family: system-ui, -apple-system, sans-serif; font-size: .82rem; }
.step-count { flex: none; width: calc(100% - 1.6rem); margin-left: 1.6rem; font-weight: 400; color: var(--muted); }
.step-running .step-mark-warn { color: var(--err-fg); }
.spinner { display: inline-block; vertical-align: -.1em; width: .8rem; height: .8rem; box-sizing: border-box; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: tapir-spin 1s linear infinite; }
@keyframes tapir-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-color: var(--accent); } }
.step-health { margin: var(--s2) 0; padding: var(--s2) var(--s3); border-radius: var(--radius); font-size: .9rem; border: 1px solid var(--line); }
.step-health-lost { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
.step-health-quiet { background: var(--accent-weak); color: var(--fg); border-color: var(--accent); }
.what-was-done summary { cursor: pointer; color: var(--muted); }
/* htmx loading feedback */
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
+53 -18
View File
@@ -102,24 +102,59 @@ var scenarioCoverage = map[string]string{
"Chat is reachable only from my own summary view": "TestChatOnlyReachableForOwnVideo",
// audio_upload.feature (ADR-033, #33)
"The upload form states the limits up front": "TestUploadsPage_ShowsAMultipartFormWithAFileField",
"An accepted recording is queued and I am taken to its page": "TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt",
"A recording with no title is named after its file": "TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension",
"A file containing video is rejected, naming issue 28": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
"A recording over the length limit is rejected with both lengths": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
"A file over the size limit is rejected as too large": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
"An audio-only MP4 is not mistaken for video": "TestParseProbe",
"A file that is not media is rejected as unreadable": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
"A broken ffprobe is reported as our fault, not as a bad file": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
"A recording becomes a transcript and the audio is deleted": "TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio",
"The endpoint that served a transcript is recorded and shown": "TestUploadStatus_DoneNamesTheEndpointThatServedIt",
"The local endpoint is tried first and the fallback only on failure": "TestChain_AnyPrimaryFailureFallsBackAndRecordsTheFallbackEndpoint",
"A failed transcription ends in a message, never a spinner": "TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop",
"A restart fails the jobs it interrupted": "TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser",
"Only one recording is transcribed at a time": "TestWorkerRun_ProcessesOneJobAtATime",
"My uploads are invisible to other users": "TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled",
"I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
"A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
"The upload form states the limits up front": "TestUploadsPage_ShowsAMultipartFormWithAFileField",
"An accepted recording is queued and I am taken to its page": "TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt",
"A recording with no title is named after its file": "TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension",
"A file with a video track is accepted and I am told the video was ignored": "TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas",
"A video file with no audio is rejected for having no audio": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
"A recording over the length limit is rejected with both lengths": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
"A file over the size limit is rejected as too large": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
"Cover art on an audio file is not a video track": "TestParseProbe",
"A file that is not media is rejected as unreadable": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
"A broken ffprobe is reported as our fault, not as a bad file": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
"A recording becomes a transcript and the audio is deleted": "TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio",
"The endpoint that served a transcript is recorded and shown": "TestUploadStatus_DoneNamesTheEndpointThatServedIt",
"The local endpoint is tried first and the fallback only on failure": "TestChain_AnyPrimaryFailureFallsBackAndRecordsTheFallbackEndpoint",
"A failed transcription ends in a message, never a spinner": "TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop",
"A restart fails the jobs it interrupted": "TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser",
"Only one recording is transcribed at a time": "TestWorkerRun_ProcessesOneJobAtATime",
"My uploads are invisible to other users": "TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled",
"I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget",
"A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap",
// audio_upload.feature, ADR-034
"Two engines transcribe the audio and their disagreements are reconciled": "TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled",
"Trivial disagreements never reach the model": "TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel",
"Differences close together are one decision": "TestFindDisputes_TwoDifferencesSeparatedByOneSharedWordAreOneDispute",
"An unsure or changed reading is flagged for me": "TestUploadStatus_ListsTheContestedSpotsWithAMinuteMarkBothReadingsAndWhichWasUsed",
"The model cannot be a cloud model": "TestLoad_ReconcileRefusesAnUnsafeOrIncompleteSetup",
"One engine failing does not fail the upload": "TestEnsemble_WhenOneEngineFailsTheOtherIsUsedAndItIsSaidSo",
"Only when both local engines fail does the cloud fallback run": "TestEnsemble_OnlyWhenBothLocalEnginesFailDoesTheCloudFallbackRun",
"A model that is down gives an unreconciled transcript, not a failed job": "TestEnsemble_AModelThatIsDownGivesTheBaseTextUnreconciledNotAFailedJob",
"A cancelled job never reaches the cloud": "TestEnsemble_ACancelledJobNeverReachesTheCloud",
"A single-engine transcript claims no second opinion": "TestUploadStatus_ASingleEngineTranscriptDoesNotClaimASecondOpinion",
// audio_upload.feature, ADR-035
"I can see which phase my upload is in": "TestUploadProgress_ARunningJobShowsEveryPhaseInOrderWithTheRunningOneMarked",
"A bar appears only where the count is real": "TestUploadProgress_OnlyACountedPhaseGetsABarAndItShowsRealCounts",
"A slow engine call is not mistaken for a dead service": "TestUploadProgress_ALongEngineCallWithAFreshHeartbeatIsStillJustRunning",
"A service that stopped reporting is called lost": "TestUploadProgress_AHeartbeatThatStoppedMeansTheServiceIsLostAndTheJobIsNotCalledRunning",
"A model that stops advancing is called quiet": "TestUploadProgress_ACountedPhaseThatStopsAdvancingGoesQuietNotLost",
"A failed job shows where it stopped": "TestUploadProgress_AFailedJobShowsWhichPhaseFailedAndWhatNeverStarted",
"A finished job keeps its record of what was done": "TestUploadProgress_ADoneJobKeepsItsRecordOfWhatWasDoneAndHowLongEachTook",
"The heartbeat runs on its own while an engine call is stuck": "TestWorkerProgress_TheHeartbeatBeatsWhileAnEngineCallIsStuckAndStopsWhenTheJobEnds",
"The log names every phase and never the transcript": "TestWorkerProgress_OnASuccessfulJobTheLogNamesEveryPhaseInOrderAndNeverTheTranscriptOrThePath",
"Every judged spot is stored but only the ones to check are listed": "TestUploadStatus_OnlyChangedAndUncertainSpotsAreListedNotTheKeptOnes",
"Chat about a recording is offered with local models only": "TestUploadChatPage_DoesNotOfferACloudModelEvenThoughTheServiceKnowsOne",
"A forged cloud model is never sent the transcript": "TestUploadChatPost_AForgedCloudModelIsReplacedByALocalOneBeforeTheTranscriptIsSent",
"A wedged engine does not use up the whole job": "TestChain_AHungPrimaryReachesTheFallbackWhenItHasItsOwnDeadline",
"A file that lies about its length is refused": "TestWorkerDuration_AConvertedLengthOverTheLimitFailsTheJobBeforeAnyEngineSeesIt",
"A recording much shorter than its file claimed is transcribed and noted": "TestWorkerDuration_AFileThatEndsFarEarlierThanItClaimedIsStillTranscribedAndSaysSo",
"A full queue is a retry, not an error": "TestAccept_AFullQueueIsABusyRejectionNotAServerErrorAndLeavesNothingBehind",
"One user cannot fill the queue for everyone": "TestWorkerSubmit_OneUserCannotFillTheQueueForEveryoneElse",
"Errors are shown as kinds, never their text": "TestReconcile_TheBreakerReasonCarriesAKindNotTheModelsText",
"Input that cannot be right is a client error": "TestUploadRoutes_ANonUUIDIdIs404NotA500",
"The saved segments carry the merged words": "TestReconcileSegments_AChangedWordReplacesTheBaseWordInItsOwnSegmentOnly",
// summarize_new_video.feature
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",