Commit Graph
512 Commits
Author SHA1 Message Date
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
mathiasandClaude Sonnet 5.5 1c726a810e docs(homelab): upload chat budget is 90000 and the chat model is gemma4-26b
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:08:33 +02:00
mathiasandClaude Sonnet 5.5 77fe6954e5 docs(adr): ADR-033 decision 8 -- chat budget and model from measurement
The proposed 120,000-char budget was a guess; measured, gemma4-26b silently
loses the head at that size and phi4-mini (first in the offered list) 400s on
an hour of speech. Budget is now 90,000, uploads open on gemma4-26b, the
retrieval trigger is concrete, and the iguana contention hazard is recorded.
ADR-033 is still Proposed, so this is an in-place correction, not a supersession.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:08:28 +02:00
mathiasandClaude Sonnet 5.5 4d3e2a471a docs(research): chat over a long transcript -- what the offered models really hold
phi4-mini 400s at ~13k tokens; gemma4-26b is correct to 90,000 chars and
silently loses the HEAD at 120,000 (no error, no truncation flag). The
end-needle test alone passed at 120k -- only a head-needle exposed it.
Records the default-model trap and the iguana contention hazard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:08:13 +02:00
mathiasandClaude Sonnet 5.5 548bfaee85 test(e2e): use the same wireUploads as serve
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:07:41 +02:00
mathiasandClaude Sonnet 5.5 ad2e3e0e8e fix(cmd): serve now actually sets the upload chat budget and model
cmdServe assigned Uploads and UploadLimits but not UploadChatMaxChars, so chat
over a long recording would have used the 18,000-char caption default. It now
calls wireUploads, which is tested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:07:41 +02:00
mathiasandClaude Sonnet 5.5 e6738bcd3e feat(cmd): wireUploads -- limits, chat budget and chat model into the web App
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:07:40 +02:00
mathiasandClaude Sonnet 5.5 3bb4e30f4c test(cmd): wireUploads sets the budget and model, and never a typed-nil Intake (red)
I wired Uploads and UploadLimits in cmdServe but not UploadChatMaxChars: chat
over a long recording would have silently used the 18,000-char caption
default. A helper that main and the e2e test both call makes that a tested fact.

Red: wireUploads is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:07:33 +02:00