Compare commits

..
40 Commits
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 1c5d85f5c6 feat(cmd): serve mounts audio upload when enabled (ADR-033)
Wires buildUploads into cmdServe: Intake and limits into the web App, the
worker on the server's ctx. Logs the two facts an operator must not miss: the
queue is process-local (single replica), and a configured fallback is a cloud
endpoint that private audio can leave the homelab through.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:56:22 +02:00
mathiasandClaude Sonnet 5.5 9b3175db09 feat(cmd): buildUploads -- spool, sweep, fail interrupted rows, wire the chain (ADR-033)
Returns nils when disabled (never a typed-nil Intake). When enabled: private
0700 spool dir, delete audio left by a crash, fail rows a restart left queued
or transcribing (the audio is not retained, so they cannot resume), then build
the iguana->berget chain, worker and intake. An empty
TAPIR_TRANSCRIBE_FALLBACK_MODEL keeps private audio on the homelab. Enabling
with no gateway fails at start-up, not at the first upload.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:56:21 +02:00
mathiasandClaude Sonnet 5.5 eb9357a5de test(cmd): start-up removes spooled audio left by a crash (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:56:11 +02:00
mathiasandClaude Sonnet 5.5 3e802adade test(cmd): buildUploads wiring -- off by default, sweep first, private spool (red)
Red: buildUploads is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:55:55 +02:00
mathiasandClaude Sonnet 5.5 b8798dbd5d feat(config): upload and transcription settings (ADR-033)
Embedded UploadConfig. Off by default until the image carries ffmpeg; 200 MB /
90 min; iguana primary with a berget fallback that an explicit empty
TAPIR_TRANSCRIBE_FALLBACK_MODEL disables (private audio then never leaves the
homelab); zero or negative limits fail at load instead of rejecting every
upload at runtime.

uploads.go and the two-line hook in config.go are one commit: the tests need both.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:55:32 +02:00
mathiasandClaude Sonnet 5.5 fb521f2b89 test(config): upload and transcription settings (red)
Off by default until the image has ffmpeg; 200 MB / 90 min (the longest length
measured); iguana primary, berget fallback that an explicit empty value
disables; zero or negative limits are refused rather than silently rejecting
every upload.

Red: the Config fields do not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:55:11 +02:00
mathiasandClaude Sonnet 5.5 a8dadb8ca4 feat(uploads): in-process transcription worker, one job at a time (ADR-033)
Queue -> StartTranscribing -> ffmpeg to 16 kHz WAV -> gateway chain ->
CompleteUpload, with a per-job timeout so a hung endpoint ends in failed, not
a spinner. The upload and the WAV are deleted on every path (audio is never
retained). A shutdown mid-job is not a failure: the row is left for the
start-up sweep. Cancellation wins over a non-empty queue, so queued jobs are
never started on shutdown; stop() deletes their audio and refuses new jobs.

Concurrency is 1 on purpose: two simultaneous long jobs on iguana were never
measured, and a Metal OOM there latches whisper (infra#273). The queue is
process-local, so ADR-018's single-replica assumption now has a second
dependent.

Mutation-checked, -race clean: cleanup, shutdown-vs-failure, timeout,
concurrency and stop-cleanup each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:54:52 +02:00
mathiasandClaude Sonnet 5.5 3cfc95640e test(uploads): a queued job at shutdown must never be started; timeout test fails fast
Mutation-testing the worker showed deleting stop()'s cleanup went unnoticed:
Process was incidentally deleting the audio of jobs it should never have
started. The stop test now asserts only the running job was converted (red on
the old code). The timeout test no longer hangs for the 10-minute go test
default when the timeout is removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:54:52 +02:00
mathiasandClaude Sonnet 5.5 52417e6b47 test(uploads): in-process transcription worker (red)
Success path deletes both the upload and the WAV; failures are typed and still
delete the audio; a shutdown mid-job leaves the row for the start-up sweep
(it is not a transcription failure); a hung endpoint is failed by the job
timeout, never an eternal spinner; strictly one job at a time (two long jobs on
iguana were never measured); stopping deletes queued audio and refuses new jobs.

Red: uploads.Worker is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:47:22 +02:00
mathiasandClaude Sonnet 5.5 4352446f36 feat(audio): ffmpeg adapter -- always transcode to 16 kHz mono PCM (ADR-033)
Replaces whisper-server --convert. A zero exit that wrote nothing is an error.
Also fixes the red test's stub, which wrote an empty file and so (correctly)
tripped the no-output check.

Checked against real ffmpeg on iguana with the same argv: AAC .m4a in,
pcm_s16le 16000 Hz mono WAV out, duration preserved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:46:34 +02:00
mathiasandClaude Sonnet 5.5 3bc33fc34c test(audio): ffmpeg transcode to 16 kHz mono PCM, always (red)
Red: audio.FFmpeg is undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:46:14 +02:00
mathiasandClaude Sonnet 5.5 2227cfff7e test(transcribe): opt-in live check against the real gateway
The httptest fake proves only what we THINK the gateway accepts. Skipped
unless TAPIR_LIVE_WAV and TAPIR_LIVE_GATEWAY_URL are set. Run once on
2026-10-01 with 11 min of synthetic Swedish (21 MB, above the 8-10 MB ingress
cap, via the NodePort): both iguana/kb-whisper (52.8 s) and berget/kb-whisper
(35.4 s) accepted the streamed, computed-Content-Length body and returned
usable verbose_json. Synthetic audio only; this repo is public.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:46:14 +02:00
mathiasandClaude Sonnet 5.5 cae423750c feat(transcribe): gateway client with an iguana->berget fallback chain (ADR-033)
Streams the WAV from disk with a computed Content-Length (no buffering, no
chunking), posts verbose_json with field `file` and language=sv, clamps the
final segment to the audio duration, and treats an empty transcript as a
failure. The chain falls back on ANY primary error, records the serving
endpoint, and never falls back once the context is cancelled.

gateway.go and chain.go are one commit: the chain's tests use the gateway's
StatusError and ErrEmptyTranscript, so neither compiles alone.

Mutation-checked: field name, clamp, cancel-fallback, content length and the
recorded endpoint each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:44:34 +02:00
mathiasandClaude Sonnet 5.5 bb285d8757 test(transcribe): gateway client and iguana->berget chain (red)
Gateway: field is `file`, no Expect header, a computed Content-Length (not
chunked, not buffered -- a 90 min WAV is ~170 MB), verbose_json normalised
with the last segment clamped to the audio duration, errors independent of
the body, an empty transcript is a failure. Chain: ANY primary failure falls
back and the row records the fallback endpoint; a cancelled job never falls
back to the cloud.

Red: package transcribe does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:43:51 +02:00
mathiasandClaude Sonnet 5.5 e12a5e7e89 feat(web): upload routes -- form, multipart intake, honest status (ADR-033)
GET/POST /uploads, GET /uploads/{id}, GET /uploads/{id}/status, mounted only
when an Uploader is wired. The multipart body is streamed to Intake, never
buffered. Rejections are synchronous: 422 (video, too long, unreadable) or 413
with the user-facing message and no row. Another user's id is a 404 that does
not leak the title. Unfinished states poll; done/failed render without
hx-trigger so nothing polls forever (ADR-025). A done transcript names the
endpoint that served it.

handlers.go, uploads.go and the templ pair are one commit: the App fields and
Store methods are what make the handlers compile.

Mutation-checked: dropping the 413 mapping, the not-found mapping, or letting
a done card poll each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:42:58 +02:00
mathiasandClaude Sonnet 5.5 1bc98813fc test(web): read the response body once; match the message's real case
Two defects in my red tests, found when going green: body() drains the
recorder so a second call read empty, and two assertions expected a different
case than the user-facing message. The assertions were not weakened.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:42:40 +02:00
mathiasandClaude Sonnet 5.5 e1ed6ea6f4 test(web): upload routes -- form, multipart POST, rejections, owner-only, honest status (red)
Real Router + real Intake + real Postgres; only ffprobe and the worker are
faked. Covers: the form states the limits up front; accepted audio queues a
row and redirects; video / too-long / too-large are rejected synchronously
(422/422/413) with a message and NO row; a non-owner gets 404 without the
title leaking; unfinished states keep polling and terminal states stop (no
stuck spinner); a done transcript names the endpoint that served it.

Red: App.Uploads / App.UploadLimits are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:41:42 +02:00
mathiasandClaude Sonnet 5.5 f84389028d feat(audio): ffprobe adapter implementing uploads.Prober (ADR-033)
Cover art is not video; non-zero exit is the file's fault (ErrUnreadable),
a missing binary / cancelled ctx / bad output is ours. WaitDelay bounds a
cancelled probe whose child holds the pipe open.

Checked against real ffprobe on iguana: JSON shape matches the fixtures and
/etc/hosts exits 1 with 'Invalid data found when processing input'. The unit
tests drive a stub process; ffprobe is not installed on koala.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:40:51 +02:00
mathiasandClaude Sonnet 5.5 0ab067c510 test(audio): ffprobe adapter -- cover art is not video, exit code vs missing binary (red)
Red: package audio does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:40:29 +02:00
mathiasandClaude Sonnet 5.5 1910d3dfb5 feat(uploads): Intake -- spool, probe, validate, queue; clean up on every failure (ADR-033)
Bounded read (cap+1 byte tells 'at the limit' from 'over'); a file that fails
validation creates no row; a prober fault is our error, not the user's bad
file; a row whose job cannot be submitted is failed, never left queued.

Mutation-checked: cap off-by-one, missing cleanup and deleting the handed-over
file each turn a test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:40:13 +02:00
mathiasandClaude Sonnet 5.5 b98e9df462 test(uploads): Accept -- size cap, probe verdicts, cleanup on every failure path (red)
Red: uploads.Job, Intake and ErrUnreadable are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:40:03 +02:00
mathiasandClaude Sonnet 5.5 e3e1f17c40 feat(uploads): validation policy for probed audio uploads (ADR-033)
Video is checked first so it is always refused as video; limits and reasons
are typed so callers and tests branch on Reason, not message text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:39:51 +02:00
mathiasandClaude Sonnet 5.5 f42b8e23fc test(uploads): validation policy -- video, no-audio, too-long, unreadable (red)
Probe-based, not extension-based: a Teams voice-only export is typically an
.mp4 with no video stream, indistinguishable from video by name (ADR-033).
Red: package uploads does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:39:20 +02:00
mathiasandClaude Sonnet 5.5 836a501576 feat(store): Store API for private uploaded transcripts (ADR-033)
Create/Start/Complete/Fail/Get/List plus the start-up sweep that fails
unfinished rows as interrupted (audio is not retained, so no resume). Every
call goes through withUser AND names user_id in its predicate, so isolation
does not rest on RLS alone and stays testable under the superuser test
connection; a non-owner gets ErrUploadNotFound, never a distinct forbidden.
List drops transcript text.

Mutation-checked: removing the user predicate from Get or from the UPDATEs
turns the cross-user tests red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:38:25 +02:00
mathiasandClaude Sonnet 5.5 c29c0a7127 feat(domain): types for private uploaded transcripts (ADR-033)
Segment, Transcription, UploadStatus, UploadErrorKind and UploadedTranscript.
Error kinds are typed, never a provider's string. UploadedTranscript is a
distinct type from the shared public-caption Transcript on purpose.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:37:39 +02:00
mathiasandClaude Sonnet 5.5 4b557209c8 test(store): Store API for uploaded transcripts (red)
Lifecycle queued -> transcribing -> done|failed, per-row serving endpoint,
segments JSONB round trip, owner-only get/write (not-found, not forbidden),
list without transcript text, and the start-up sweep that fails unfinished
rows as `interrupted` for every user (audio is not retained, so no resume).

Red: CreateUpload and friends are undefined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:37:30 +02:00
mathiasandClaude Sonnet 5.5 e36c4c1e0c feat(store): migration 017 -- private RLS-scoped uploaded_transcripts (ADR-033)
Separate table, not a user_id on the shared transcripts (ADR-021). ENABLE +
FORCE ROW LEVEL SECURITY with the migration-003 policy, user_id NOT NULL
ON DELETE CASCADE, typed error_kind, per-row serving endpoint, JSONB segments.
CHECK constraints: done => content, failed => error_kind.

Up and down are one commit: golang-migrate pairs them.

Mutation-checked: dropping FORCE, the policy, or the cascade each turn a test
red. FORCE is only observable structurally -- the app role is not the table
owner, so no behavioural test can see it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:36:50 +02:00
mathiasandClaude Sonnet 5.5 066ae6966a test(store): isolation tests for the private uploaded_transcripts table (red)
Drives RLS directly as the non-superuser app role: deny-all with no scope,
own-rows-only scoped reads, zero-row cross-user UPDATE/DELETE, rejected
cross-user INSERT, FORCE + NOT NULL user_id, cascade on DeleteUser. Also pins
the EXACT set of public tables with row security off, so a new table added
without RLS fails CI rather than passing it (ADR-033). The existing test only
named transcripts; #33's "the only non-RLS table" is false -- invitations,
user_identities and schema_migrations are off too.

Red: relation "uploaded_transcripts" does not exist. Set test passes today
and guards the migration that follows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:36:22 +02:00
mathiasandClaude Sonnet 5.5 38b3f1cf33 docs(adr): ADR-033 upload audio -> private Swedish transcript -> chat (#33)
Proposed, decider pending. Supersedes ADR-007's STT deferral for
user-supplied audio only; the YouTube audio-download rejection stands.
Settles #28 decision 1 as a separate RLS-scoped uploaded_transcripts table
and records the measured length ceiling, the ingress 413, and the 18k-char
chat truncation that #33 assumed did not exist. Two DECISION NEEDED items
(ffmpeg in the runtime image; berget fallback for private audio).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:35:04 +02:00
mathiasandClaude Sonnet 5.5 feb108def3 docs(research): measure the audio length ceiling before designing chunking (#33)
No transcription ceiling found up to 90 min: one 174 MB request through the
LiteLLM NodePort transcribed in 440 s. The first real failure is the public
gateway ingress rejecting bodies over ~8-10 MB with 413, not whisper or a
Metal OOM. Also records that srt arrives wrapped in a JSON envelope, that the
two backends differ in timestamp granularity, and that the existing chat path
truncates at 18k chars (~22 min of speech).

Synthetic Swedish TTS fixture only; no real transcript.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:33:34 +02:00
mathias 4bafd5f082 fix(web): views.templ imports the pre-rename gitea.d-ma.be host
CI / Build & Import (push) Successful in 11s
CI / Deploy via GitOps (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 19s
internal/web/views.templ:7 still imports
  gitea.d-ma.be/mathias/tapir/internal/adapters/store
while go.mod declares
  git.d-ma.be/mathias/tapir

Left behind by the gitea rename (infra ADR-0004). The old hostname still
resolves in DNS but no longer serves — HTTPS to it fails outright — so the
import is genuinely broken, not merely outdated.

Why nothing caught it: the COMMITTED internal/web/views_templ.go is correct,
so builds and CI use a good generated file and stay green. The rot is only in
the source. It surfaces the moment anyone runs `templ generate` — which
regenerates the broken import over the good output, and then the build fails
with

  no required module provides package gitea.d-ma.be/mathias/tapir/internal/adapters/store

Found exactly that way while evaluating this repo for the estate-wide lint
config: regenerating templ as part of the check turned a green repo red, and
the cause was the source, not the check.

Source fixed; regenerating now reproduces the committed generated file
byte-for-byte, so views_templ.go is unchanged here. Verified: go build and
go vet pass.
2026-09-05 14:44:39 +02:00
mathias e3e78e042b style: apply gofumpt
CI / Build & Import (push) Successful in 10s
CI / Lint / Test / Vet (push) Successful in 21s
CI / Deploy via GitOps (push) Successful in 4s
Estate-wide consolidation pass. The canonical .golangci.yml
(infra ci-templates/golangci.yml, infra#309 pattern) enables the gofumpt
formatter; golangci-lint v2 default set enables NO formatter, which is why
this drift accumulated silently -- the same hole that let two files rot in
dma-cli until they were found by hand (dma-cli#12).

Mechanical and deterministic: `gofumpt -w .`, nothing hand-edited. Verified
per repo before commit -- gofumpt -l now reports 0, gofmt -l reports 0, and
go build + go vet both pass.

Clears this repo to adopt the canonical lint config in a later wave.
2026-09-05 12:39:31 +02:00
mathiasandClaude Opus 5 6f1e0be09b ci(deploy): refuse to patch a manifest with an empty IMAGE_TAG (infra#311)
CI / Lint / Test / Vet (push) Successful in 44s
CI / Build & Import (push) Successful in 9s
CI / Deploy via GitOps (push) Successful in 5s
On 2026-08-25 a deploy job ran with an empty IMAGE_TAG, wrote
`image: localhost:5000/gitea-mcp:` into the infra repo and pushed it.
That is invalid YAML, so Flux failed the ENTIRE apps Kustomization --
every app stopped reconciling for about an hour, including three
unrelated deploys queued behind it.

This job's existing check could not catch it. It greps for
`image: <repo>:${IMAGE_TAG}` after the sed, and with IMAGE_TAG empty that
pattern matches the corrupted line itself, so the verification passes
because of the bug rather than despite it.

Asserting the input up front is the fix: an empty tag now fails the job
before anything is written, let alone committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 17:25:24 +02:00
mathiasandClaude Opus 5 d45348ad2e ci: call the shared build-image script instead of an inline copy (infra#309)
CI / Lint / Test / Vet (push) Successful in 27s
CI / Build & Import (push) Successful in 11s
CI / Deploy via GitOps (push) Successful in 5s
The buildctl/skopeo step here was one of nineteen near-identical copies
across the estate. That duplication is why moving them off rootless
buildah (infra#241) took eleven commits and then had to be rediscovered
in both repo templates and the gitea-ci skill.

The implementation now lives in .gitea/build-image.sh, byte-identical to
mathias/infra's ci-templates/build-image.sh and held there by a drift
gate. What used to be textual differences between copies -- labels, tags,
build-args, build target -- are environment inputs.

Behaviour is unchanged: same registry, same tags, same labels, same
buildctl-against-host-buildkitd path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 11:29:55 +02:00
mathiasandClaude Opus 5 1c2ad2990f ci: build via buildctl instead of rootless buildah (infra#241)
CI / Build & Import (push) Successful in 8s
CI / Deploy via GitOps (push) Successful in 1s
CI / Lint / Test / Vet (push) Successful in 34s
Main has been CI-red since 2026-08-13. The build job dies extracting a base
image layer inside the gitea-runner pod:

  ApplyLayer ... potentially insufficient UIDs or GIDs available in user
  namespace (requested 0:42 for /etc/gshadow)

Root cause is the runner pod's securityContext, not this repo: it drops ALL
capabilities and sets allowPrivilegeEscalation:false, so newuidmap/newgidmap
can't gain CAP_SETUID/CAP_SETGID and rootless buildah falls back to a single
non-remapped uid/gid mapping. Adding those capabilities was tried live in the
infra repo and reverted as a confirmed no-op (no_new_privs blocks the
setuid-exec gain regardless of the container's capability set).

buildctl talks to koala's host buildkitd over /run/buildkit/buildkitd.sock,
which the runner pod already mounts, so the privileged layer work happens in
buildkitd on the host and the pod's hardened posture is untouched. Same
migration agentsquad (infra#233) and segment-embedder (infra#243) already made.

Drops the buildah smoke test with it: `buildah from ${REF}` would pull the
image back into the pod's buildah store and hit the very wall this migration
avoids, failing the job for a reason unrelated to the image. The deploy job's
`Verify rollout` is the stronger check — it requires the real pod to reach
Ready in-cluster, rather than the entrypoint printing its own name for 5s.
Tag builds lose their only image-level check (deploy runs on main pushes only);
accepted, since a tag is always cut from a main commit that already deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-17 12:07:51 +02:00
mathiasandClaude Opus 5 489b4bb2a1 chore(spike): recover the /bygge toolchain from tmpfs (#28)
CI / Lint / Test / Vet (push) Successful in 37s
CI / Build & Import (push) Failing after 5s
CI / Deploy via GitOps (push) Skipped
analyze_srt.py, build_page.py and transcode.yaml produced the /bygge
prototype. They were sitting in a session scratchpad on tmpfs, one reboot
from gone, while spike issues #29-#31 were written as if they had to be
built from scratch.

Committed as found, defects documented rather than fixed: max_tokens=6000
truncates anything past ~6 minutes of audio, the page title is hardcoded,
and the schema still carries fields no consumer renders.

The four-check validator is the part worth keeping — the coverage-gap and
Swedish-number-grounding checks catch errors that timestamp and citation
checks structurally cannot.

Real transcripts and analyses stay out: this repo is public and the
recordings are a named person discussing a client's project. Only the
synthetic fixture is committed, which exercises all four checks with no
model call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-13 11:37:03 +02:00
mathias 21e7d6c74b fix(ci): smoke test hung 14min — buildah run doesn't die under plain timeout
CI / Lint / Test / Vet (push) Successful in 15s
CI / Build & Import (push) Successful in 20s
CI / Deploy via GitOps (push) Successful in 5s
Bare `/tapir` runs the long-running server, same as the old ctr-based smoke
test. ctr's --rm reliably force-killed it; a plain `timeout N buildah run`
does not — it only signals the wrapper, and the container process can
survive that and keep the log pipe open, hanging the whole job (observed
live: run 155, 14min before failure). Backgrounds the run and tears it down
with `buildah rm -f`, which forcibly kills regardless of wrapper state, and
captures output via a file instead of a blocking pipe.

Refs infra#132.
2026-07-26 06:17:18 +00:00
mathias 8814ba6673 ci(smoke): use buildah run instead of sudo k3s ctr for smoke test
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Failing after 13m42s
CI / Deploy via GitOps (push) Has been skipped
Same fix as cobalt-dingo — removes the sudo/host-containerd dependency so
this still works once the act_runner is containerized (infra#132).

Refs infra#132.
2026-07-26 05:57:37 +00:00
mathias 4b557a4325 ci(deploy): add Flux GitOps deploy job, mirroring cobalt-dingo's pattern
CI / Lint / Test / Vet (push) Successful in 27s
CI / Build & Import (push) Successful in 18s
CI / Deploy via GitOps (push) Successful in 5s
Fixes silent stale-deploy gap: CI built+pushed images but nothing bumped
k3s/apps/tapir/deployment.yaml, so merged features sat CI-green with zero
production effect (infra#111, infra#168). Flux native image-automation
can't scan localhost:5000 from inside k3s pods, so this patches the infra
repo directly via the existing INFRA_DEPLOY_KEY org secret (same key
cobalt-dingo and brain-gardener already use) on every push to main.

Refs infra#111.
2026-07-25 21:21:02 +00:00
mathiasandClaude Opus 4.8 72cb25111f test(store): address migrations by version, not step count (#8)
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 12s
The up/down migration tests stepped a hard-coded number of Steps(-N)/Steps(+N)
down from HEAD and back. The counts assumed a specific latest migration, so
adding one shifted every count by one and unrelated tests (010/011/014) went
red with confusing off-by-one symptoms — a papercut on every new migration.

Drive the schema to an exact version with m.Migrate(version) via two helpers
(headVersion, migrateTo). Each test now steps to just below its target by
version, asserts the down effect, steps up to the target, asserts the up
effect, then restores to the captured HEAD. A migration added on top changes
HEAD but shifts no count, so no test needs editing.

Verified by adding a throwaway migration 017 on top: all four tests stayed
green with zero edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QbdxXWxLefS5AwLN5eyze
2026-07-02 14:49:02 +02:00
48 changed files with 5782 additions and 68 deletions
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Build an OCI image with buildctl and push it to the local registry.
#
# CANONICAL COPY: mathias/infra ci-templates/build-image.sh (infra#309).
# Every repo carries a byte-identical copy at .gitea/build-image.sh, written by
# `task ci:sync` and held in place by the drift gate. DO NOT EDIT THE COPIES --
# edit here, re-sync, and the gate will tell you which repos still disagree.
#
# Why this exists: this step was hand-copied into a dozen repos, so migrating it
# off rootless buildah (infra#241) meant eleven near-identical commits, eleven
# verifications, and three separate rediscoveries of the same fix in the two
# repo templates and the gitea-ci skill. The differences between the copies were
# incidental -- a label here, a build-arg there -- never intentional. Those are
# inputs now, not forks of the implementation.
#
# Why buildctl and not buildah: the gitea-runner pod runs with
# `capabilities: drop [ALL]` and `allowPrivilegeEscalation: false`, which
# unconditionally blocks the setuid-root exec that rootless buildah's uid/gid
# remapping needs, and the pod has no OCI runtime at all. buildctl is a thin
# client -- it hands the build to the host's buildkitd over a socket, so the
# privileged layer work happens on the host and this pod's securityContext is
# not in the picture. buildah is absent from the runner image as of baked-v14
# and its absence is asserted at pod start (infra#308).
#
# Inputs, all via environment:
#
# IMAGE (required) image name, e.g. cobalt-dingo
# REGISTRY default localhost:5000
# SHA_TAG default `git rev-parse --short HEAD`
# VERSION_TAG optional extra tag, e.g. v1.2.3 on a tag build
# PUSH_LATEST default 1; set 0 to skip the :latest tag
# CONTEXT default . -- build context directory
# DOCKERFILE_DIR default $CONTEXT -- directory holding the Dockerfile
# DOCKERFILE_NAME optional, when the file is not named `Dockerfile`
# TARGET optional, multi-stage build target (--opt target=)
# BUILD_ARGS optional, space-separated K=V pairs
# BUILD_SECRETS optional, space-separated buildkit secret specs, each
# `id=NAME,src=PATH`. Pass the SPEC only -- the caller
# writes the secret to PATH itself. A secret VALUE must
# never be passed here: it would land in this script's
# argv and therefore in the CI log.
# EXTRA_LABELS optional, space-separated K=V pairs
# GIT_SHA optional, stamped as org.opencontainers.image.revision
# IMAGE_SOURCE optional, stamped as org.opencontainers.image.source
#
# Note on shell style below: every conditional is a full `if` block, never
# `[ -n "$X" ] && cmd`. Under `set -e` the && form exits the script whenever the
# test is false, which is a real bug this estate has already shipped once.
set -euo pipefail
if [ -z "${IMAGE:-}" ]; then
echo "build-image: IMAGE is required" >&2
exit 2
fi
REGISTRY="${REGISTRY:-localhost:5000}"
BUILDKIT_HOST="${BUILDKIT_HOST:-unix:///run/buildkit/buildkitd.sock}"
CONTEXT="${CONTEXT:-.}"
DOCKERFILE_DIR="${DOCKERFILE_DIR:-$CONTEXT}"
PUSH_LATEST="${PUSH_LATEST:-1}"
VERSION_TAG="${VERSION_TAG:-}"
if [ -z "${SHA_TAG:-}" ]; then
SHA_TAG="$(git rev-parse --short HEAD)"
fi
REF="${REGISTRY}/${IMAGE}:${SHA_TAG}"
TAR_FILE="$(mktemp)"
trap 'rm -f "$TAR_FILE"' EXIT
args=(
--frontend dockerfile.v0
--local "context=${CONTEXT}"
--local "dockerfile=${DOCKERFILE_DIR}"
)
if [ -n "${DOCKERFILE_NAME:-}" ]; then
args+=(--opt "filename=${DOCKERFILE_NAME}")
fi
if [ -n "${TARGET:-}" ]; then
args+=(--opt "target=${TARGET}")
fi
for spec in ${BUILD_SECRETS:-}; do
# Opaque id=NAME,src=PATH -- buildkit reads the file, nothing is echoed.
args+=(--secret "${spec}")
done
if [ -n "${GIT_SHA:-}" ]; then
args+=(--opt "label:org.opencontainers.image.revision=${GIT_SHA}")
fi
if [ -n "${IMAGE_SOURCE:-}" ]; then
args+=(--opt "label:org.opencontainers.image.source=${IMAGE_SOURCE}")
fi
for kv in ${BUILD_ARGS:-}; do
args+=(--opt "build-arg:${kv}")
done
for kv in ${EXTRA_LABELS:-}; do
args+=(--opt "label:${kv}")
done
echo "build-image: building ${REF}"
buildctl --addr "$BUILDKIT_HOST" build "${args[@]}" --output "type=oci,dest=${TAR_FILE}"
# buildctl has no registry-push equivalent to `buildah push`, so skopeo copies
# the OCI archive up -- once per tag, from the same archive, so every tag points
# at bit-identical content.
push_tag() {
echo "build-image: pushing ${1}"
skopeo copy --dest-tls-verify=false "oci-archive:${TAR_FILE}" "docker://${1}"
}
push_tag "$REF"
if [ "$PUSH_LATEST" = "1" ]; then
push_tag "${REGISTRY}/${IMAGE}:latest"
fi
if [ -n "$VERSION_TAG" ]; then
push_tag "${REGISTRY}/${IMAGE}:${VERSION_TAG}"
fi
echo "✓ build-image: ${REF} pushed"
+157 -27
View File
@@ -61,33 +61,163 @@ jobs:
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT" echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi fi
# infra#241: rootless buildah inside the gitea-runner pod can't remap the
# UID/GID range newuidmap/newgidmap need (the pod drops ALL capabilities
# and sets allowPrivilegeEscalation:false — both deliberate, it also holds
# CI secrets), so extracting a base-image layer that chowns outside the
# single available mapping fails with `ApplyLayer ... requested 0:42 for
# /etc/gshadow`. Main has been red on exactly this since 2026-08-13.
# buildctl instead talks to koala's host buildkitd over the socket the
# runner pod already mounts: the privileged extraction happens inside
# buildkitd on the host, never in this pod. Same migration agentsquad
# (infra#233) and segment-embedder (infra#243) already made — zero
# security tradeoff, unlike loosening the pod's capabilities.
#
# buildctl has no registry-push flag of its own: it writes an OCI archive
# and skopeo pushes that, which is also why every tag below is one copy of
# a single build rather than a rebuild.
- name: Build and push to local registry - name: Build and push to local registry
run: | # infra#309: implementation lives in .gitea/build-image.sh, a
REGISTRY="localhost:5000" # byte-identical copy of mathias/infra's ci-templates/build-image.sh.
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" # Do not edit it here -- edit the canonical file, run `task ci:sync`,
buildah build \ # and infra's drift gate names any repo still carrying an old copy.
--label "org.opencontainers.image.revision=${{ github.sha }}" \ env:
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \ SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
-t ${REF} \ VERSION_TAG: ${{ steps.meta.outputs.version-tag }}
-t ${REGISTRY}/${{ env.IMAGE }}:latest \ GIT_SHA: ${{ github.sha }}
. IMAGE_SOURCE: ${{ github.repositoryUrl }}
buildah push --tls-verify=false ${REF} run: bash .gitea/build-image.sh
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
buildah push --tls-verify=false \
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
echo "Image pushed to ${REF}"
- name: Smoke test # The buildah-based smoke test that used to sit here is gone with the
run: | # buildctl migration above: `buildah from ${REF}` would pull the image
REGISTRY="localhost:5000" # back into the pod's buildah store and hit the very uid/gid wall the
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" # migration exists to avoid — it would fail the job for a reason that has
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}" # nothing to do with the image being good. The `Verify rollout` step in
sudo k3s ctr images pull --plain-http ${REF} # the deploy job below is the stronger check anyway: it requires the real
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /tapir 2>&1 || true) # pod to reach Ready in-cluster, not just the entrypoint to print its own
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true # name for 5s. Tag builds lose their only image-level check (deploy is
echo "$OUTPUT" | grep -q "tapir" \ # main-push only) — accepted; a tag is always built from a main commit
&& echo "Smoke test passed" \ # that already deployed.
|| echo "Smoke test inconclusive: $OUTPUT"
# ── 3. Mirror to GitHub — skipped for now (SSH key rotation pending) ─ # ── 3. Deploy via infra repo + Flux ─────────────────────────────────────────
# Flux native image-automation can't scan localhost:5000 from inside k3s pods
# (mathias/infra k3s/flux/flux-system/image-automation.yaml) — this job
# mirrors cobalt-dingo's proven pattern instead: patch the infra repo's
# manifest directly on every push to main, then annotate Flux for a fast
# reconcile. Fixes infra#111 (image built+pushed but manifest bump was
# manual, so merged features silently didn't deploy).
deploy:
name: Deploy via GitOps
needs: build
runs-on: self-hosted
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: staging
steps:
- name: Update image tag in infra repo
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
set -euo pipefail
# infra#311: fail on an empty tag BEFORE patching anything. The
# grep verification further down CANNOT catch it -- with IMAGE_TAG
# empty its pattern matches the very corruption it exists to reject.
# The result is `image: <repo>:` in the manifest, which is invalid
# YAML, so Flux fails the ENTIRE apps Kustomization and every app
# stops reconciling, not just this one. That happened on 2026-08-25.
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
# INFRA_DEPLOY_KEY is a Gitea org secret (mathias org), already
# configured per docs/cd-pipeline.md in the infra repo — same key
# cobalt-dingo and brain-gardener use, no new secret needed.
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
chmod 600 ~/.ssh/id_infra
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes"
rm -rf /tmp/infra
git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra
cd /tmp/infra
DEPLOYMENT="k3s/apps/tapir/deployment.yaml"
# In-place update of the image tag. sed (not yq) so we don't
# depend on additional tooling on the runner — same as cobalt-dingo.
sed -i "s|image: localhost:5000/tapir:.*|image: localhost:5000/tapir:${IMAGE_TAG}|" "$DEPLOYMENT"
# Verify the patch took effect.
grep -q "localhost:5000/tapir:${IMAGE_TAG}" "$DEPLOYMENT" \
|| { echo "✗ image tag patch failed"; exit 1; }
if git diff --quiet "$DEPLOYMENT"; then
echo "ℹ image tag unchanged — skipping push"
else
git -c user.name="tapir CI" \
-c user.email="ci@tapir.local" \
commit -m "chore(deploy): tapir → ${IMAGE_TAG}" "$DEPLOYMENT"
git push origin main
echo "✓ pushed to infra repo"
fi
shred -u ~/.ssh/id_infra
- name: Trigger Flux reconcile (immediate)
run: |
# Without these annotations, Flux would still pick up the change
# within 30s (the apps Kustomization interval). The annotations
# cut latency to ~1s.
kubectl -n flux-system annotate gitrepository flux-system \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
kubectl -n flux-system annotate kustomization apps \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
- name: Wait for Flux to apply new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
# Poll the Deployment spec until it reflects the new tag.
# Bound to 60s so a stuck Flux doesn't hang CI.
EXPECTED="localhost:5000/tapir:${IMAGE_TAG}"
for i in $(seq 1 60); do
CURRENT=$(kubectl get deploy tapir -n tapir \
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
if [ "$CURRENT" = "$EXPECTED" ]; then
echo "✓ Flux applied new image after ${i}s"
break
fi
sleep 1
done
# Final assertion (in case the loop exited without matching).
kubectl get deploy tapir -n tapir \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
| grep -qx "$EXPECTED" \
|| { echo "✗ Flux did not apply new image within 60s"; exit 1; }
- name: Verify rollout
run: |
kubectl rollout status deployment/tapir \
--namespace tapir \
--timeout=120s \
|| {
echo "── pod status ──"
kubectl get pods -n tapir -o wide
echo "── events ──"
kubectl get events -n tapir --sort-by='.lastTimestamp' | tail -20
echo "── describe ──"
kubectl describe pods -n tapir -l app=tapir | tail -40
exit 1
}
- name: Confirm pod running new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
kubectl get pods -n tapir \
-l app=tapir \
--field-selector=status.phase=Running \
-o jsonpath='{.items[*].spec.containers[0].image}' \
| grep -q "localhost:5000/tapir:${IMAGE_TAG}" \
&& echo "✓ pod running new image" \
|| { echo "✗ pod image mismatch"; exit 1; }
# ── 4. Mirror to GitHub — skipped for now (SSH key rotation pending) ─
+11
View File
@@ -11,3 +11,14 @@
.env.* .env.*
!.env.example !.env.example
*.local *.local
# Spike media (#28): real recordings, their transcripts and derived analyses are
# private third-party content and this repo is public. Only synthetic fixtures
# are committed — see scripts/spike-media/README.md.
/scripts/spike-media/*.mov
/scripts/spike-media/*.mp4
/scripts/spike-media/*.wav
/scripts/spike-media/*.srt
/scripts/spike-media/*.json
/scripts/spike-media/*.html
!/scripts/spike-media/fixtures/
+191 -1
View File
@@ -123,7 +123,9 @@ secret surface (see VISION Stage 2); it gets the existing, vetted secret path.
## ADR-007 — Captions-first; audio-download + speech-to-text deferred ## ADR-007 — Captions-first; audio-download + speech-to-text deferred
**Status:** Accepted (2026-06-02). Acquisition mechanism superseded by ADR-010 (Data API **Status:** Accepted (2026-06-02). Acquisition mechanism superseded by ADR-010 (Data API
`captions.download` → player/timedtext baseUrl); captions-first stance and STT deferral stand. `captions.download` → player/timedtext baseUrl); captions-first stance and STT deferral stand
**for YouTube**. STT deferral superseded **for user-supplied audio only** by ADR-033; the
YouTube audio-download rejection is unchanged.
**Context.** YouTube's Data API does not expose transcripts. Options: official captions **Context.** YouTube's Data API does not expose transcripts. Options: official captions
(clean, limited coverage) vs. audio download + local Whisper (broad coverage, ToS-grey, (clean, limited coverage) vs. audio download + local Whisper (broad coverage, ToS-grey,
@@ -1314,6 +1316,191 @@ roll back. No schema, no structural change.
--- ---
## ADR-033 — Upload audio, get a private Swedish transcript, discuss it (user-supplied STT)
**Status:** Proposed (2026-10-01). **Decider: Mathias — pending.** Issue #33 (audio-only subset of
#28). **Supersedes ADR-007's speech-to-text deferral for user-supplied audio only.** The
YouTube audio-download rejection in ADR-007 **stands untouched**. Builds on ADR-012 (isolation),
ADR-018 (single replica), ADR-021 (**the constraint**), ADR-022 (endpoint chain), ADR-025
(honest status), ADR-027 (chat). ADR-014 item 4 ("Whisper reconsideration is a future ADR,
gated on caption data") is a different question — whether to transcribe YouTube videos with no
captions — and this ADR does not answer it. Measurements: `docs/research/audio-length-ceiling-2026-10-01.md`.
**Context.** The maintainer has a concrete need: a Swedish Teams voice-only export, 30–90
minutes, to upload and then *discuss* (what was decided, what is open, who owes what). Every
piece exists — a Whisper endpoint on the gateway (infra#274), chat-with-transcript (ADR-027) —
and nothing connects them.
ADR-007 deferred STT for three reasons: yt-dlp fragility, ToS-grey scraping, and GPU contention
with the JEPA PoC. Upload-STT shares **none** of them: the user owns the file, nothing is
scraped, there is no caption rate gate, and the contention moved from koala to iguana. What it
does share is landing Whisper in the codebase, which ADR-007 deferred — so a new ADR is required,
and ADR-017's process note records the one time a guardrail-reversing change shipped without one.
**The constraint that shapes everything.** ADR-021 made `transcripts` a shared, **non-RLS**
table on the explicit grounds that it holds only public caption content and nothing
user-identifying. A recorded internal meeting is the opposite: private third-party content owned
by one user. The existing isolation test asserts `transcripts` *is* non-RLS, so a naive reuse
would leak across tenants **and pass CI green**.
**What was measured, not argued** (research doc above):
- **No transcription ceiling up to 90 minutes.** One 174 MB request through the LiteLLM NodePort
took 440 s (~12× realtime). The first failure is the public ingress `llm-api.d-ma.be`
returning `413` above roughly 8–10 MB (~5 min of WAV) — not whisper, and not a Metal OOM.
The 8:20 failure recorded in #33 is consistent with that cap.
- **`response_format=srt` survives LiteLLM, wrapped** in a JSON envelope (`{"text": "<srt>"}`).
- **The two backends differ.** `iguana/kb-whisper` returns whole-second segments;
`berget/kb-whisper` returns sub-second times and a different `language` token.
- **The existing chat path truncates at 18,000 chars (~22 min of speech), keeping the head.**
#33 assumed chat "retrieves rather than generates" and so has no length ceiling. It does
not retrieve; it stuffs the transcript into the prompt and cuts the tail.
**Unknowns, stated.** Real-speech accuracy; compressed-input behaviour; concurrent long jobs on
iguana; whether the models chat offers can hold an hour of Swedish (~50 k chars); and the Metal
OOM, deliberately not provoked.
**Decision.**
1. **Audio only; transcript is the only thing kept.** An authenticated user uploads an audio
file; Tapir transcribes it and stores the transcript. The audio itself is deleted after
transcription (temp file, removed in a `defer`, swept at start-up) — no custody problem to
solve, and nothing to hand to #31. Video is rejected with a message naming #28. YouTube
audio-download stays rejected. No public/anonymous upload (ADR-008).
2. **Private store: a separate RLS-scoped table, `uploaded_transcripts`** (resolves #28's
decision 1). `user_id NOT NULL REFERENCES users ON DELETE CASCADE`, `ENABLE` + `FORCE ROW LEVEL
SECURITY`, the same `tapir.current_user_id` policy as every other user-owned table. It carries
the transcript text and its timestamped segments (re-transcribing 90 minutes costs ~7
minutes; discarding the timestamps is irreversible), `status`, the `endpoint` that served
it, `audio_seconds`, and a typed `error_kind`. **Not** a `user_id` column on `transcripts`
with a partial policy, even though that would reuse more code: ADR-021's exception is
precisely that *that table holds no private row*, the isolation test asserts it has no
`user_id`, and a partial policy would turn "single exception" into "single exception, with a
private lane inside it" — the shape under which a future query drops the policy clause and
leaks. Two tables keep both boundaries independently assertable.
3. **The isolation test asserts both boundaries and the set.** (a) User A cannot read, update or
delete user B's uploaded transcript; no GUC set → deny-all. (b) `transcripts` is still
non-RLS with no `user_id`. (c) **The set of public-schema tables with row security off is
asserted exactly** — so a new table added without RLS fails CI instead of passing it. (c) is
new; the existing test only checks the named tables.
4. **Validate by probing, reject clearly.** Format and duration cannot be read from a filename
or MIME type — a Teams "voice-only" recording is typically an `.mp4` container with no video
stream, which is indistinguishable from video by extension. The upload is probed
(`ffprobe`): accept any container with an audio stream and **no** video stream; reject video
with "video is #28"; reject unreadable files. Limits, all configurable, all rejected
synchronously with a specific message rather than by timeout: **200 MB** upload and
**90 minutes** duration. 90 minutes is *the longest length measured*, not a ceiling that was
found; raising it is a measurement, not an edit.
5. **Transcode server-side, always, to 16 kHz mono PCM WAV** — even when the input already is,
rather than assuming. whisper-server's `--convert` is rejected (its `--tmp-dir` is created at
process start and rots under macOS periodic cleanup). The transcoder sits behind a port; the
tests use a fake. **This puts `ffmpeg` and `ffprobe` in the runtime image**, which is
distroless-static today. **Provisional: copy static ffmpeg/ffprobe binaries into the existing
distroless image** (no shell, no package manager retained). This is a heavy new dependency
and is **the maintainer's call** — see Open items.
6. **Transcription adapter via the gateway, never the public hostname.** A `Transcriber` port;
the implementation calls `POST {TAPIR_GATEWAY_URL}/audio/transcriptions` where the gateway
URL is the in-cluster NodePort (`koala:30401/v1`). Primary `iguana/kb-whisper`, fallback
`berget/kb-whisper` (immune to iguana contention, which can wedge permanently — infra#273).
- **Fallback triggers on any primary failure** (transport error, timeout, non-2xx). It never
branches on the body: `{"error":"failed to process audio"}` has had at least five distinct
causes.
- **`response_format=verbose_json`**, normalised to one segment shape: times clamped to the
audio duration (the final segment can end past it), one language token. Not `srt`: the
envelope adds a parse step and iguana's segment timing is the same either way.
- **No chunking in v1.** Nothing failed to 90 minutes (measured). Chunk-and-stitch is the
answer *if* the cap is raised or smaller requests become necessary — see triggers.
- **The serving endpoint is recorded per row**, and surfaced, because the two backends
differ in timestamp granularity: which one served a transcript is a quality fact.
- **Berget is a cloud endpoint.** On fallback the audio of a private meeting leaves the
homelab. #33 asks for this fallback; it is configurable (`TAPIR_TRANSCRIBE_FALLBACK_MODEL`,
empty disables), recorded per row, and called out in Open items rather than defaulted
silently.
- Swedish only (`language=sv`). Language detection and routing (#28 decision 2) are not
touched.
- `-H 'Expect:'`, multipart field `file` — encoded in the adapter and its tests, not rediscovered.
7. **Jobs run in-process, one at a time (ADR-018 stands).** Transcription is minutes (~7.5 min
for 90) but a single goroutine behind a one-slot semaphore: two simultaneous 90-minute jobs on
iguana were not measured, so concurrency is 1 until they are. No worker, no new deployable,
no second replica. Honest status per ADR-025: `queued → transcribing → done | failed`, polled
by the same `/status` pattern, every terminal state a rendered card, never a stuck spinner.
Audio is not retained, so a restart mid-job **cannot resume**: at start-up any
`queued`/`transcribing` row is marked `failed` with `error_kind = interrupted`, and the user
re-uploads. The single-replica assumption gains a second dependent (the semaphore is
process-local) and is recorded here.
8. **Chat reuses `chat.Service`; it is not forked.** The uploaded-transcript chat handlers
look the row up through the RLS-scoped store, so a non-owner gets 404 by the same mechanism
as ADR-027's summary gate. ADR-027's decisions (ephemeral, switchable models, trust-the-model)
carry over unchanged. **One thing must differ:** the 18,000-char default cuts a one-hour
meeting to its first third, so uploaded transcripts take their own cap,
`TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS`. **Provisional default 120,000 chars (~2.4 h at
measured density)**, with the existing `Truncated` flag surfaced honestly. The default is a
guess until the chat slice measures what the offered models actually accept; it is not
evidence that they can.
9. **Not decided here.** Rich `AVSNITT`/`ALTERNATIV` analysis (gated on #30 — `analyze_srt.py`
hardcodes `max_tokens: 6000`); video, keyframes, synced playback (#28); language detection;
per-transcript deletion UX (account deletion cascades; see Open items); retention of audio.
**On the sequencing question #28 asks of itself** — *is this the thing that best serves the
Stage-0 gate, or the most interesting thing to build?* Recorded answer: it proceeds **ahead of
#32** (the demand test) as the maintainer's explicit, knowing call (#28, comment of 2026-10-01),
on a concrete need rather than speculation. It is not a return-nudge (ADR-020/027): it adds
nothing that prompts a user to come back, so it does not contaminate the unprompted-return signal
ADR-016 measures. The honest risk is the one ADR-016 names: it is larger than anything ADR-020
deferred. The scope cut — chat first, no rich analysis, no video — is what keeps it bounded.
**Consequences.**
- **Positive.** The user's request is met end to end with existing parts. ADR-021's exception stays
singular and now provably so. The measured ceiling removes chunking from v1. No audio is stored.
- **Negative / watch.**
- Tapir's runtime image gains `ffmpeg`: a larger image and a new CVE surface to patch.
- iguana becomes load-bearing for a user-facing feature (#28 decision 3; infra#273 is still the
dependency that matters) — a Metal OOM now means *every upload fails over to berget*, i.e.
private audio leaving the homelab, silently unless the per-row endpoint is surfaced.
- A 90-minute job holds the single slot for ~7.5 minutes; a second user's upload queues behind it.
- Chat over a long transcript is bounded by model context, not by anything in Tapir.
- Tapir's own ingress body limit for a 200 MB upload is **unverified** (it lives in `infra`).
**Validation / resolution triggers** (each observable by someone other than the author):
- *Chunking*: build it when the duration cap is raised above 90 min, or when a measured
request at the current cap fails with whisper-side (not ingress) errors.
- *Berget-first*: if more than a quarter of uploads in any week are served by the fallback,
swap the primary/fallback order and revisit infra#273.
- *Retrieval chat*: if the chat slice finds an offered model cannot answer a question whose
answer is in the last third of a 60-minute transcript, chunked retrieval replaces the larger cap.
- *Worker*: if a transcription job ever degrades the reading UI, or Tapir runs more than one
replica, ADR-018 is revisited.
- *The decision itself*: if the maintainer has not used the feature unprompted within the
ADR-016 window, it was the interesting thing, not the useful one — remove it.
**Open items this ADR creates.**
- **DECISION NEEDED — ffmpeg in the runtime image.** *Options:* (A) static ffmpeg/ffprobe copied
into distroless **(recommended — the only option that satisfies "accept m4a/mp3/opus/mp4,
validate duration, reject video"; pure-Go decoders cover wav/mp3 only)**; (B) accept WAV
only, convert client-side (fails "I just want to upload it"); (C) a transcode sidecar
(second deployable, ADR-018's reasoning against). *Blocks:* the Dockerfile slice, not the
tests, which use the port.
- **DECISION NEEDED — berget fallback for private audio.** *Options:* (A) enabled by default as
#33 specifies, with the serving endpoint shown **(recommended, matches the issue)**; (B)
disabled by default, a failed iguana job fails honestly and the user retries. *Blocks:*
the production default only; the adapter ships either way.
- Verify Tapir's ingress `proxy-body-size` against the 200 MB cap (`infra`).
- Per-transcript deletion is not built; account deletion cascades. A private-content store
without a delete button is a gap worth a ticket, not a silent omission.
**Reversibility.** Additive: one table (down-migration drops it), one adapter, one upload
surface, one image change. Removing the upload route removes the feature; `transcripts`, `videos`
and ADR-021 are untouched.
---
## Rejected alternatives ## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
@@ -1338,6 +1525,9 @@ maps to the ADR that settles it.
| Reverse the Dex-write invite flow (Google OIDC only) | Some intended Future-B users won't use Google; OIDC-only leaves them with no onboarding path — invite flow is load-bearing | ADR-017 | | Reverse the Dex-write invite flow (Google OIDC only) | Some intended Future-B users won't use Google; OIDC-only leaves them with no onboarding path — invite flow is load-bearing | ADR-017 |
| k8s CronJob for scheduled discovery (vs in-process) | At Future-B scale the in-process scheduler is simpler to deploy; CronJob's failure-isolation benefit was weighed and traded away knowingly (revisit if >1 replica or load grows) | ADR-018 | | k8s CronJob for scheduled discovery (vs in-process) | At Future-B scale the in-process scheduler is simpler to deploy; CronJob's failure-isolation benefit was weighed and traded away knowingly (revisit if >1 replica or load grows) | ADR-018 |
| Cached-transcript-first onboarding burst (instant, zero-fetch picks) | Live pilot DB: ~3% cross-user video overlap, 0 cached-and-unsummarized, a new user's newest-20 are 20/20 uncached — newest-first and cached-first are structurally incompatible. Empty lever at pilot scale | ADR-028 | | Cached-transcript-first onboarding burst (instant, zero-fetch picks) | Live pilot DB: ~3% cross-user video overlap, 0 cached-and-unsummarized, a new user's newest-20 are 20/20 uncached — newest-first and cached-first are structurally incompatible. Empty lever at pilot scale | ADR-028 |
| Uploaded transcripts as a `user_id` column + partial RLS policy on `transcripts` | Turns ADR-021's single exception into an exception with a private lane inside it; the isolation test asserts `transcripts` has no `user_id`. A separate RLS table keeps both boundaries independently assertable | ADR-033 |
| whisper-server `--convert` for server-side transcode | Its `--tmp-dir` is created at process start and rots under macOS periodic cleanup | ADR-033 |
| Calling the public `llm-api.d-ma.be` from in-cluster Tapir | Its ingress returns 413 above ~8–10 MB (~5 min of WAV); the NodePort has no such cap | ADR-033 |
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one — If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
not a silent reversal. not a silent reversal.
+21
View File
@@ -207,6 +207,27 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
log.Info("web chat enabled (stored-transcript only)", "models", chatModels(cfg)) log.Info("web chat enabled (stored-transcript only)", "models", chatModels(cfg))
} }
// Audio upload -> private transcript (ADR-033). Off unless TAPIR_UPLOADS_ENABLED:
// the image must carry ffmpeg first. The worker is IN-PROCESS and one job at a
// time, so it inherits the single-replica assumption below (its queue is
// process-local); it stops with the same ctx as the server.
intake, worker, err := buildUploads(ctx, cfg, st, log)
if err != nil {
return err
}
if intake != nil {
app.Uploads = intake
app.UploadLimits = uploadLimits(cfg)
go worker.Run(ctx)
log.Info("audio upload enabled", "model", cfg.TranscribeModel, "fallback", cfg.TranscribeFallbackModel,
"max_mb", cfg.UploadMaxBytes>>20, "max_minutes", int(cfg.UploadMaxDuration.Minutes()))
log.Warn("audio upload assumes a SINGLE replica: the transcription queue is process-local (ADR-033, ADR-018)")
if cfg.TranscribeFallbackModel != "" {
log.Warn("audio upload fallback is a CLOUD endpoint: if the primary fails, private audio leaves the homelab",
"fallback", cfg.TranscribeFallbackModel)
}
}
// User onboarding is handled by the IdP (Authentik invite flow), not Tapir — // User onboarding is handled by the IdP (Authentik invite flow), not Tapir —
// the Dex local-password provisioning path was removed (ADR-019). An // the Dex local-password provisioning path was removed (ADR-019). An
// authenticated subject with no Tapir user is routed to /register. // authenticated subject with no Tapir user is routed to /register.
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// uploadsStore is the store surface the upload wiring needs. *store.Store
// satisfies it.
type uploadsStore interface {
uploads.Store
uploads.WorkerStore
FailInterruptedUploads(ctx context.Context) (int, error)
}
// buildUploads wires audio upload and private transcription (ADR-033). It returns
// nils when uploads are disabled — never a typed-nil Intake, which would mount the
// /uploads routes over nothing. When enabled it, in order: prepares a private spool
// directory, deletes any audio a crashed run left in it, fails the rows such a run
// left queued or transcribing (the audio is not retained, so they can never
// resume), and only then builds the intake and the worker. The caller runs the
// worker. The fallback endpoint is a cloud service; an empty
// TAPIR_TRANSCRIBE_FALLBACK_MODEL keeps private audio on the homelab.
func buildUploads(ctx context.Context, cfg config.Config, st uploadsStore, log *slog.Logger) (*uploads.Intake, *uploads.Worker, error) {
if !cfg.UploadsEnabled {
return nil, nil, nil
}
if cfg.GatewayURL == "" {
return nil, nil, fmt.Errorf("uploads: TAPIR_UPLOADS_ENABLED is set but there is no TAPIR_GATEWAY_URL to transcribe through")
}
if err := os.MkdirAll(cfg.UploadTmpDir, 0o700); err != nil {
return nil, nil, fmt.Errorf("uploads: spool dir: %w", err)
}
removeSpooledAudio(cfg.UploadTmpDir, log)
n, err := st.FailInterruptedUploads(ctx)
if err != nil {
return nil, nil, fmt.Errorf("uploads: fail interrupted rows: %w", err)
}
if n > 0 {
log.Warn("uploads: marked interrupted by a restart (audio is not retained; they must be re-uploaded)", "count", n)
}
// No overall client timeout: a 90 minute recording takes minutes. The deadline
// is the worker's per-job context.
client := &http.Client{}
primary := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeModel, client)
var fallback transcribe.Backend
if cfg.TranscribeFallbackModel != "" && cfg.TranscribeFallbackModel != cfg.TranscribeModel {
fallback = transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeFallbackModel, client)
}
worker := uploads.NewWorker(st, &audio.FFmpeg{Bin: cfg.FFmpegBin}, transcribe.NewChain(primary, fallback),
cfg.UploadTmpDir, cfg.TranscribeTimeout)
intake := uploads.NewIntake(st, &audio.FFprobe{Bin: cfg.FFprobeBin}, worker,
uploadLimits(cfg), cfg.UploadTmpDir)
return intake, worker, nil
}
func uploadLimits(cfg config.Config) uploads.Limits {
return uploads.Limits{MaxBytes: cfg.UploadMaxBytes, MaxDuration: cfg.UploadMaxDuration}
}
// removeSpooledAudio deletes the spool files this process family creates
// (upload-*, wav-*) and nothing else.
func removeSpooledAudio(dir string, log *slog.Logger) {
for _, pat := range []string{"upload-*", "wav-*"} {
matches, _ := filepath.Glob(filepath.Join(dir, pat))
for _, m := range matches {
if err := os.Remove(m); err != nil {
log.Warn("uploads: remove leftover audio", "path", m, "err", err)
}
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// fakeUploadsStore satisfies the store surface buildUploads needs, and counts the
// start-up sweep.
type fakeUploadsStore struct {
sweeps int
sweepErr error
}
func (f *fakeUploadsStore) CreateUpload(context.Context, string, string) (string, error) {
return "id", nil
}
func (f *fakeUploadsStore) FailUpload(context.Context, string, string, domain.UploadErrorKind) error {
return nil
}
func (f *fakeUploadsStore) StartTranscribing(context.Context, string, string) error { return nil }
func (f *fakeUploadsStore) CompleteUpload(context.Context, string, string, domain.Transcription) error {
return nil
}
func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) {
f.sweeps++
return 2, f.sweepErr
}
func uploadCfg(t *testing.T, enabled bool) config.Config {
t.Helper()
var cfg config.Config
cfg.GatewayURL = "http://gateway.invalid/v1"
cfg.UploadsEnabled = enabled
cfg.UploadMaxBytes = 200 << 20
cfg.UploadTmpDir = filepath.Join(t.TempDir(), "spool")
cfg.TranscribeModel = "iguana/kb-whisper"
cfg.TranscribeFallbackModel = "berget/kb-whisper"
return cfg
}
func TestBuildUploads_DisabledMountsNothingAndSweepsNothing(t *testing.T) {
st := &fakeUploadsStore{}
in, w, err := buildUploads(context.Background(), uploadCfg(t, false), st, quietLog())
require.NoError(t, err)
assert.Nil(t, in, "a typed-nil Intake must never reach web.App.Uploads: it would mount the routes over nothing")
assert.Nil(t, w)
assert.Zero(t, st.sweeps)
}
func TestBuildUploads_EnabledFailsInterruptedRowsBeforeAnythingElseStarts(t *testing.T) {
st := &fakeUploadsStore{}
in, w, err := buildUploads(context.Background(), uploadCfg(t, true), st, quietLog())
require.NoError(t, err)
require.NotNil(t, in)
require.NotNil(t, w)
assert.Equal(t, 1, st.sweeps,
"audio is not retained, so rows left queued/transcribing by a restart can never resume: fail them at start-up, not never")
}
func TestBuildUploads_SweepFailureStopsStartup(t *testing.T) {
st := &fakeUploadsStore{sweepErr: errors.New("db down")}
_, _, err := buildUploads(context.Background(), uploadCfg(t, true), st, quietLog())
require.Error(t, err)
assert.Contains(t, err.Error(), "db down")
}
func TestBuildUploads_CreatesTheSpoolDirectoryPrivately(t *testing.T) {
cfg := uploadCfg(t, true)
_, _, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
require.NoError(t, err)
info, err := os.Stat(cfg.UploadTmpDir)
require.NoError(t, err)
assert.True(t, info.IsDir())
assert.Equal(t, os.FileMode(0o700), info.Mode().Perm(), "spooled audio is private third-party content")
}
func TestBuildUploads_EnabledWithoutAGatewayIsAnError(t *testing.T) {
cfg := uploadCfg(t, true)
cfg.GatewayURL = ""
_, _, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
assert.Error(t, err, "uploads on with nowhere to transcribe must fail loudly at start-up, not at the first upload")
}
func TestBuildUploads_RemovesAudioLeftBehindByACrash(t *testing.T) {
cfg := uploadCfg(t, true)
require.NoError(t, os.MkdirAll(cfg.UploadTmpDir, 0o700))
leftovers := []string{"upload-123", "wav-456"}
for _, n := range leftovers {
require.NoError(t, os.WriteFile(filepath.Join(cfg.UploadTmpDir, n), []byte("audio"), 0o600))
}
unrelated := filepath.Join(cfg.UploadTmpDir, "someone-elses-file")
require.NoError(t, os.WriteFile(unrelated, []byte("keep"), 0o600))
_, _, err := buildUploads(context.Background(), cfg, &fakeUploadsStore{}, quietLog())
require.NoError(t, err)
for _, n := range leftovers {
_, err := os.Stat(filepath.Join(cfg.UploadTmpDir, n))
assert.True(t, os.IsNotExist(err), "%s: audio is never retained, so a spooled file from a crashed run must not survive start-up", n)
}
_, err = os.Stat(unrelated)
assert.NoError(t, err, "only our own spool files are removed")
}
@@ -0,0 +1,110 @@
# Audio length ceiling — measured before designing chunking (tapir#33, 2026-10-01)
Question (#33, "the main technical risk"): at what recording length does Swedish
transcription through the LiteLLM gateway first fail, and with what failure mode?
Measured **before** any chunking design, with iguana not holding a large ollama model,
so the result is the ceiling and not the Metal OOM of infra#273.
## Answer
**No transcription ceiling was found at or below 90 minutes.** A single request carrying
90 minutes of 16 kHz mono PCM (174 MB) transcribed in one pass in 440 s. The first thing
that actually fails is **not whisper**: it is the public gateway ingress, which rejects the
upload with `413` at roughly 5.5 minutes of WAV.
| Route | First failure | Failure mode |
|---|---|---|
| `https://llm-api.d-ma.be` (openresty ingress) | between 8.0 MB and 10.5 MB body (~4–5.5 min of WAV) | `413 Request Entity Too Large`, HTML body from `openresty`, returned instantly |
| `http://koala:30401` (LiteLLM NodePort) → `iguana/kb-whisper` | **none up to 90 min / 174 MB** | — |
| `http://koala:30401` → `berget/kb-whisper` | **none up to 22 min / 42 MB** (largest tried) | — |
The ingress limit is the reason the 8:20 / 15 MB recording in #33 "failed repeatedly" and a
30-second slice succeeded. That failure was attributed to a Metal OOM; at least in the
size-dependent form described, a body-size cap explains it without one. (The OOM
mechanism in infra#273 is separately real; this run did not provoke it.)
The exact ingress cap was bracketed (8.0 MB passed, 10.5 MB failed), not read from the
ingress configuration, which lives in `infra`. "About 10 MB" is an inference.
## Method
- **Audio.** No known-good Swedish recording is available that is safe to use: the only real
one (`IMG_1233.wav`) is the private `/bygge` recording (#31 custody still open) and was
deliberately not touched. Instead: macOS `say -v Alva` (Swedish) on iguana, a 1,171-char
five-paragraph meeting script → 82.5 s of 16 kHz mono PCM WAV (`ffmpeg -ar 16000 -ac 1`).
Concatenated ×4, ×8, ×16, ×32, ×44, ×66 with Python `wave` (the issue's own recipe).
Nothing here is a real transcript; none of it is committed.
- **Request.** `POST /v1/audio/transcriptions`, multipart `file=`, `model=iguana/kb-whisper`,
`response_format=verbose_json`, `-H 'Expect:'` (per #33's traps), master key supplied only
through `op run` and never on argv.
- **Confound control.** `ollama /api/ps` on iguana was read before every step. Resident:
nothing, or `nomic-embed-text` (0.37 GB). Never a large model.
## Results (`iguana/kb-whisper`, NodePort)
| Audio | WAV size | HTTP | Wall | Realtime factor | Segments | Chars |
|---|---|---|---|---|---|---|
| 0:30 | 0.96 MB | 200 | 2.8 s | 11× | — | — |
| 2:30 / 3:20 / 4:10 | 4.8 / 6.4 / 8.0 MB | 200 (ingress) | 12.9 / 16.0 / 19.9 s | 12× | — | — |
| 5:30 (×4) | 10.5 MB | **413 ingress**, 200 NodePort in 27 s | 27 s | 12× | 121 | 4,557 |
| 11:00 (×8) | 21.1 MB | 200 | 53 s | 12× | 241 | 9,094 |
| 22:00 (×16) | 42.3 MB | 200 | 105 s | 13× | 482 | 18,201 |
| 44:01 (×32) | 84.5 MB | 200 | 209 s | 13× | 966 | 36,438 |
| 60:32 (×44) | 116.2 MB | 200 | 291 s | 12× | 1,332 | 50,145 |
| 90:48 (×66) | 174.3 MB | 200 | 440 s | 12× | 2,005 | 75,165 |
- Throughput is flat at ~12× realtime. 90 minutes costs ~7.5 minutes of wall time. This is
a minutes-long job, not a request/response one (see ADR-033 on ADR-018).
- Segments per source repetition stay at 30.1–30.4 from ×4 to ×66, and segment times are
monotonic with `last_end` within 0.2 s of `duration`. No drift and no repetition-loop
hallucination appeared across 90 minutes. This is a weak check — the input is a repeated
loop and synthetic voice — not evidence of accuracy.
- **Only `iguana/kb-whisper`'s `x4` run reported `last_end` 360.0 s against `duration`
330.2 s.** Whisper's final segment can end past the audio; clamp to `duration`.
## Findings that change the design
1. **Use the NodePort route, not the public hostname.** Tapir runs in-cluster beside the
gateway; `llm-api.d-ma.be` is the external door and has a body cap. #33 said "use the
gateway, never the host" and named the public URL; the gateway is right, that URL is not.
`TAPIR_GATEWAY_URL` already points at `koala:30401/v1`.
2. **`response_format=srt` survives the LiteLLM hop, but wrapped.** The response is JSON
`{"text": "<the SRT>", "usage": …}`, not a bare SRT body — a client that parses the body
as SRT gets a parse error on `{`. #33 question 2 is answered: it passes through, inside
an envelope.
3. **The two backends return different timestamp granularity.** `iguana/kb-whisper`
segments are **whole seconds** (`0→1`, `1→4`, `4→7`); `berget/kb-whisper` returns real
sub-second times (`0.051→1.192`) and a different `language` token (`swedish` vs `sv`).
`verbose_json` and the default JSON were byte-identical on iguana. An adapter that
falls back between the two must normalise both, and "which endpoint served this" is
a quality fact, not just a log line.
4. **The existing chat path cannot answer about most of a long recording.** At
828 chars/min (this fixture; real speech is plausibly a little denser),
`TAPIR_MAX_TRANSCRIPT_CHARS`'s default of **18,000** is ~21.7 minutes. A 60-minute
transcript is 50 k chars, a 90-minute one 75 k. `chat.Service` truncates by keeping
the head (`internal/adapters/chat/chat.go`), so questions about the last two-thirds of
a one-hour meeting get an answer from a transcript that does not contain it. #33 says
chat "retrieves rather than generates" and therefore has no ceiling; it **stuffs and
truncates**, and its ceiling is the model context. See ADR-033.
5. **Chunking is not needed for v1 on length.** Nothing failed up to 90 minutes. The
chunking-and-stitching recipe in #33 (offset timestamps, renumber cues, strip `\r`) is
not built. It stays the answer *if* a longer cap is wanted or the OOM mechanism forces
smaller requests — see ADR-033's resolution triggers.
## What this did not measure
- **Metal OOM under contention.** Deliberately avoided. A big resident ollama model
during a long transcription is still the known way to wedge iguana permanently.
- **Real speech.** TTS is clean, single-voice, no crosstalk or room noise. WER and
speaker overlap in a Teams export are untested. Speed is probably representative;
accuracy is not claimed.
- **Compressed input.** Only WAV was sent. Whether `m4a`/`mp3`/`opus` can reach whisper
without a server-side transcode is not established here (ADR-033 treats it as needing one).
- **Concurrency.** One request at a time. Two simultaneous 90-minute jobs on iguana were
not tried.
- **`berget/kb-whisper` beyond 22 min / 42 MB.** Enough to know the fallback is not
size-capped at meeting length for 22 minutes; not proven at 90.
- **Time-of-day / other tenants.** One afternoon, one run per length.
Scripts were throwaway shell/Python in a session scratchpad and are not committed; the
method above reproduces them.
+45
View File
@@ -0,0 +1,45 @@
package audio
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
)
// FFmpeg converts any audio container to the strict format whisper wants: 16 kHz
// mono signed 16-bit PCM WAV (ADR-033). It ALWAYS transcodes, even when the input
// already is that format — a Teams export often is, and the transcode is then
// nearly free; assuming it would be the bug. This replaces whisper-server's
// --convert, whose --tmp-dir is created at process start and rots under macOS
// periodic cleanup.
type FFmpeg struct {
// Bin is the ffmpeg executable ("ffmpeg" resolves via PATH).
Bin string
}
// ToWAV writes src, converted, to dst. Video streams are dropped (-vn): by now the
// file has already been probed as audio-only, but a cover-art stream must not end
// up in the WAV.
func (f *FFmpeg) ToWAV(ctx context.Context, src, dst string) error {
bin := f.Bin
if bin == "" {
bin = "ffmpeg"
}
cmd := exec.CommandContext(ctx, bin,
"-y", "-v", "error", "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", dst)
cmd.WaitDelay = killGrace
if out, err := cmd.CombinedOutput(); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return fmt.Errorf("audio: ffmpeg: %w", ctxErr)
}
return fmt.Errorf("audio: ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
}
// A zero exit that wrote nothing is still a failure: never hand whisper a
// missing file and let the error surface three layers later.
if info, err := os.Stat(dst); err != nil || info.Size() == 0 {
return fmt.Errorf("audio: ffmpeg wrote no output at %s", dst)
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package audio_test
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
)
func TestFFmpegToWAV_AsksForSixteenKilohertzMonoPCMAlways(t *testing.T) {
// Always transcode, even when the input already is 16 kHz mono: a Teams export
// often is, and the point is to not ASSUME (ADR-033). Records the argv.
rec := filepath.Join(t.TempDir(), "argv")
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
for a; do last="$a"; done
printf data > "$last"`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))
require.NoError(t, err)
raw, err := os.ReadFile(rec)
require.NoError(t, err)
argv := strings.Split(strings.TrimSpace(string(raw)), "\n")
joined := " " + strings.Join(argv, " ") + " "
for _, want := range []string{" -ac 1 ", " -ar 16000 ", " -c:a pcm_s16le ", " -vn ", " -i /spool/in "} {
assert.Contains(t, joined, want)
}
assert.True(t, strings.HasSuffix(strings.TrimSpace(joined), "out.wav"), "the output path is the last argument")
}
func TestFFmpegToWAV_NonzeroExitIsAnErrorCarryingStderr(t *testing.T) {
bin := stub(t, `echo "moov atom not found" >&2; exit 1`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/in", filepath.Join(t.TempDir(), "o.wav"))
require.Error(t, err)
assert.Contains(t, err.Error(), "moov atom not found")
}
func TestFFmpegToWAV_ZeroExitWithNoOutputIsAnError(t *testing.T) {
bin := stub(t, `exit 0`)
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/in", filepath.Join(t.TempDir(), "never-written.wav"))
require.Error(t, err)
}
func TestFFmpegToWAV_MissingBinaryIsAnError(t *testing.T) {
err := (&audio.FFmpeg{Bin: filepath.Join(t.TempDir(), "no-ffmpeg")}).ToWAV(context.Background(), "/in", "/out.wav")
require.Error(t, err)
assert.False(t, errors.Is(err, context.Canceled))
}
+95
View File
@@ -0,0 +1,95 @@
// Package audio holds the adapters that inspect and convert user-supplied audio
// with ffmpeg's tools (ADR-033). They sit behind small ports so the use case is
// tested with fakes and only this package knows a subprocess is involved.
package audio
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strconv"
"time"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// FFprobe implements uploads.Prober by running ffprobe on the spooled file.
type FFprobe struct {
// Bin is the ffprobe executable ("ffprobe" resolves via PATH).
Bin string
}
// killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close.
const killGrace = 2 * time.Second
// Probe runs ffprobe and classifies the outcome. A non-zero exit means ffprobe
// looked at the file and could not read it: that is the FILE's fault and returns
// uploads.ErrUnreadable. A missing binary, a cancelled context or unparsable
// output is OURS and is returned as an ordinary error, so it is never reported to
// the user as their file being bad.
func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error) {
bin := f.Bin
if bin == "" {
bin = "ffprobe"
}
cmd := exec.CommandContext(ctx, bin,
"-v", "error", "-print_format", "json", "-show_format", "-show_streams", path)
cmd.WaitDelay = killGrace
out, err := cmd.Output()
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr)
}
var exit *exec.ExitError
if errors.As(err, &exit) {
return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, exit.Stderr)
}
return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err)
}
p, err := ParseProbe(out)
if err != nil {
return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err)
}
return p, nil
}
// probeJSON is the subset of `ffprobe -print_format json` output that matters.
type probeJSON struct {
Streams []struct {
CodecType string `json:"codec_type"`
Disposition struct {
AttachedPic int `json:"attached_pic"`
} `json:"disposition"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
// ParseProbe turns ffprobe's JSON into a uploads.Probe. A video stream that is
// only attached cover art (an mp3/m4a with an album picture) is not video. A
// missing or "N/A" duration yields zero, which uploads.Validate rejects as
// unreadable rather than accepting an empty recording.
func ParseProbe(data []byte) (uploads.Probe, error) {
var j probeJSON
if err := json.Unmarshal(data, &j); err != nil {
return uploads.Probe{}, fmt.Errorf("decode: %w", err)
}
var p uploads.Probe
for _, s := range j.Streams {
switch s.CodecType {
case "audio":
p.HasAudio = true
case "video":
if s.Disposition.AttachedPic == 0 {
p.HasVideo = true
}
}
}
if secs, err := strconv.ParseFloat(j.Format.Duration, 64); err == nil && secs > 0 {
p.Duration = time.Duration(secs * float64(time.Second))
}
return p, nil
}
+129
View File
@@ -0,0 +1,129 @@
package audio_test
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
func TestParseProbe(t *testing.T) {
tests := []struct {
name string
json string
want uploads.Probe
}{
{
name: "audio-only m4a",
json: `{"streams":[{"codec_type":"audio","disposition":{"attached_pic":0}}],"format":{"duration":"2701.5"}}`,
want: uploads.Probe{Duration: 2701*time.Second + 500*time.Millisecond, HasAudio: true},
},
{
name: "mp4 with a real video stream",
json: `{"streams":[{"codec_type":"video","disposition":{"attached_pic":0}},{"codec_type":"audio"}],"format":{"duration":"240.0"}}`,
want: uploads.Probe{Duration: 240 * time.Second, HasAudio: true, HasVideo: true},
},
{
name: "mp3 whose only video stream is attached cover art is NOT video",
json: `{"streams":[{"codec_type":"audio"},{"codec_type":"video","disposition":{"attached_pic":1}}],"format":{"duration":"60"}}`,
want: uploads.Probe{Duration: 60 * time.Second, HasAudio: true},
},
{
name: "no streams at all",
json: `{"streams":[],"format":{"duration":"10"}}`,
want: uploads.Probe{Duration: 10 * time.Second},
},
{
name: "duration absent means zero, which Validate rejects as unreadable",
json: `{"streams":[{"codec_type":"audio"}],"format":{}}`,
want: uploads.Probe{HasAudio: true},
},
{
name: "duration reported as N/A means zero",
json: `{"streams":[{"codec_type":"audio"}],"format":{"duration":"N/A"}}`,
want: uploads.Probe{HasAudio: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := audio.ParseProbe([]byte(tt.json))
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestParseProbe_GarbageIsAnError(t *testing.T) {
_, err := audio.ParseProbe([]byte("not json"))
assert.Error(t, err)
}
// stub writes an executable shell script standing in for ffprobe. This is a fake
// at the process boundary: the real binary is not on every host (it is not on
// koala), and what is under test is how Probe drives and interprets a process.
func stub(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "ffprobe")
require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755))
return p
}
func TestFFprobeProbe_ParsesWhatTheProcessPrints(t *testing.T) {
bin := stub(t, `echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"90"}}'`)
got, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/ignored")
require.NoError(t, err)
assert.Equal(t, uploads.Probe{Duration: 90 * time.Second, HasAudio: true}, got)
}
func TestFFprobeProbe_PassesTheFilePathAsTheLastArgument(t *testing.T) {
out := filepath.Join(t.TempDir(), "args")
bin := stub(t, `for a; do last="$a"; done; printf '%s' "$last" > `+out+`
echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`)
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-123")
require.NoError(t, err)
got, err := os.ReadFile(out)
require.NoError(t, err)
assert.Equal(t, "/spool/upload-123", string(got))
}
func TestFFprobeProbe_NonzeroExitMeansTheFileIsUnreadable(t *testing.T) {
bin := stub(t, `echo "Invalid data found when processing input" >&2; exit 1`)
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/x")
assert.ErrorIs(t, err, uploads.ErrUnreadable)
}
func TestFFprobeProbe_MissingBinaryIsOurFaultNotAnUnreadableFile(t *testing.T) {
_, err := (&audio.FFprobe{Bin: filepath.Join(t.TempDir(), "no-such-ffprobe")}).Probe(context.Background(), "/x")
require.Error(t, err)
assert.False(t, errors.Is(err, uploads.ErrUnreadable),
"a missing ffprobe must not be reported to the user as their file being bad")
}
func TestFFprobeProbe_StopsWhenTheContextIsCancelled(t *testing.T) {
bin := stub(t, `sleep 30`)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
_, err := (&audio.FFprobe{Bin: bin}).Probe(ctx, "/x")
require.Error(t, err)
assert.Less(t, time.Since(start), 5*time.Second)
assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a timeout is not a verdict on the file")
}
+48 -37
View File
@@ -3,6 +3,7 @@ package store_test
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"os" "os"
"testing" "testing"
@@ -34,6 +35,30 @@ func fileMigrator(t *testing.T) *migrate.Migrate {
return m return m
} }
// headVersion reports the current (HEAD) schema version so a test can restore
// to it after stepping down, without hard-coding what HEAD is. Adding a
// migration on top changes HEAD but no test that uses this needs editing.
func headVersion(t *testing.T, m *migrate.Migrate) uint {
t.Helper()
v, dirty, err := m.Version()
require.NoError(t, err)
require.False(t, dirty, "schema must not be dirty")
return v
}
// migrateTo drives the schema to an exact version *by version number*, not by
// step count. This is the whole point of the migrate-test design: a migration
// added above the target does not shift any count here, so unrelated tests stay
// green (see issue #8). ErrNoChange (already at that version) is not a failure.
func migrateTo(t *testing.T, m *migrate.Migrate, version uint) {
t.Helper()
err := m.Migrate(version)
if errors.Is(err, migrate.ErrNoChange) {
return
}
require.NoError(t, err)
}
// loginEventsExists reports whether the login_events relation is present. // loginEventsExists reports whether the login_events relation is present.
func loginEventsExists(t *testing.T) bool { func loginEventsExists(t *testing.T) bool {
t.Helper() t.Helper()
@@ -53,25 +78,15 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
require.True(t, loginEventsExists(t), "login_events must exist at latest migration") require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
m := fileMigrator(t) m := fileMigrator(t)
// 011..016 sit above 010; step them down first so 010 is exercised in isolation. head := headVersion(t, m)
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, login_events intact")
require.True(t, loginEventsExists(t), "016 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, login_events intact")
require.True(t, loginEventsExists(t), "015 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 014 drops channel_title, login_events intact")
require.True(t, loginEventsExists(t), "014 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors, login_events intact")
require.True(t, loginEventsExists(t), "013 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 012 is a no-op, login_events intact")
require.True(t, loginEventsExists(t), "012 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 011 must not touch login_events")
require.True(t, loginEventsExists(t), "011 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 010 must drop login_events") migrateTo(t, m, 9) // just below 010 — everything above steps down
require.False(t, loginEventsExists(t), "login_events must be gone after the down migration") require.False(t, loginEventsExists(t), "login_events must be gone after the down migration")
require.NoError(t, m.Steps(7), "up must recreate 010 then re-apply 011..016") migrateTo(t, m, 10) // up 010
require.True(t, loginEventsExists(t), "login_events must be restored after the up migration") require.True(t, loginEventsExists(t), "login_events must be restored after the up migration")
migrateTo(t, m, head) // restore to HEAD for sibling tests
} }
// autoSummarizeDefault reads the users.auto_summarize column default as text // autoSummarizeDefault reads the users.auto_summarize column default as text
@@ -93,21 +108,15 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE") require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
m := fileMigrator(t) m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state") head := headVersion(t, m)
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts")
require.NoError(t, m.Steps(-1), "down 014 drops channel_title") migrateTo(t, m, 10) // just below 011 — reverts the column default
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors")
require.NoError(t, m.Steps(-1), "down 012 is a no-op")
require.NoError(t, m.Steps(-1), "down 011 reverts the column default")
require.Equal(t, "false", autoSummarizeDefault(t), "default is FALSE after the down migration") require.Equal(t, "false", autoSummarizeDefault(t), "default is FALSE after the down migration")
require.NoError(t, m.Steps(1), "up 011 re-applies the TRUE default") migrateTo(t, m, 11) // up 011 re-applies the TRUE default
require.Equal(t, "true", autoSummarizeDefault(t)) require.Equal(t, "true", autoSummarizeDefault(t))
require.NoError(t, m.Steps(1), "up 012 runs clean (no FORCE RLS on fresh schema)")
require.NoError(t, m.Steps(1), "up 013 creates channel_errors") migrateTo(t, m, head) // restore to HEAD for sibling tests
require.NoError(t, m.Steps(1), "up 014 recreates channel_title")
require.NoError(t, m.Steps(1), "up 015 reshapes transcripts to shared")
require.NoError(t, m.Steps(1), "up 016 recreates channel_caption_state (HEAD)")
} }
// channelTitleExists reports whether videos.channel_title is present. // channelTitleExists reports whether videos.channel_title is present.
@@ -127,17 +136,15 @@ func TestMigration014VideoChannelTitleUpDown(t *testing.T) {
require.True(t, channelTitleExists(t), "channel_title exists at latest migration") require.True(t, channelTitleExists(t), "channel_title exists at latest migration")
m := fileMigrator(t) m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, channel_title intact") head := headVersion(t, m)
require.True(t, channelTitleExists(t), "016 down leaves channel_title intact")
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, channel_title intact") migrateTo(t, m, 13) // just below 014 — drops channel_title
require.True(t, channelTitleExists(t), "015 down leaves channel_title intact")
require.NoError(t, m.Steps(-1), "down 014 must drop channel_title")
require.False(t, channelTitleExists(t), "channel_title must be gone after the down migration") require.False(t, channelTitleExists(t), "channel_title must be gone after the down migration")
require.NoError(t, m.Steps(1), "up 014 must recreate channel_title") migrateTo(t, m, 14) // up 014 recreates channel_title
require.True(t, channelTitleExists(t), "channel_title must be restored after the up migration") require.True(t, channelTitleExists(t), "channel_title must be restored after the up migration")
require.NoError(t, m.Steps(1), "up 015 restores the shared transcripts shape")
require.NoError(t, m.Steps(1), "up 016 recreates channel_caption_state (HEAD)") migrateTo(t, m, head) // restore to HEAD for sibling tests
} }
// TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any // TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any
@@ -148,7 +155,11 @@ func TestMigration012FixAutoSummarizeRLS(t *testing.T) {
// Round-trip: down 012, then up 012 — must be idempotent. // Round-trip: down 012, then up 012 — must be idempotent.
m := fileMigrator(t) m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 012 must not error") head := headVersion(t, m)
require.NoError(t, m.Steps(1), "up 012 must re-apply cleanly")
migrateTo(t, m, 11) // down 012 must not error
migrateTo(t, m, 12) // up 012 must re-apply cleanly
require.Equal(t, "true", autoSummarizeDefault(t), "default still TRUE after 012 re-applied") require.Equal(t, "true", autoSummarizeDefault(t), "default still TRUE after 012 re-applied")
migrateTo(t, m, head) // restore to HEAD for sibling tests
} }
@@ -0,0 +1,3 @@
-- Drops the private uploaded-transcript store and every transcript in it. There
-- is no revert of the data: the original audio was never kept (ADR-033).
DROP TABLE uploaded_transcripts;
@@ -0,0 +1,48 @@
-- Migration 017: uploaded_transcripts — PRIVATE transcripts of audio a user
-- uploaded themselves (ADR-033, tapir#33).
--
-- This is deliberately NOT the shared `transcripts` table (migration 015,
-- ADR-021). That table is non-RLS on the explicit grounds that it holds only
-- public caption content and nothing user-identifying. A recorded meeting is the
-- opposite: private third-party content owned by exactly one user. Reusing
-- `transcripts` — or adding a user_id to it with a partial policy — would put a
-- private row in the one table the isolation test asserts is shared, and would
-- pass CI green. A separate table keeps ADR-021's exception singular.
--
-- Row-level security follows migration 003 exactly: ENABLE + FORCE (the app
-- connects as the table owner, which bypasses RLS unless forced), and a policy
-- keyed off the per-request GUC tapir.current_user_id. With no GUC set the
-- predicate is NULL and the table is deny-all.
--
-- The original audio is never stored (ADR-033): this row is all that is kept.
-- segments holds the timestamped cues; re-transcribing 90 minutes costs ~7
-- minutes, so discarding the timestamps would be irreversible.
CREATE TABLE uploaded_transcripts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'transcribing', 'done', 'failed')),
content TEXT,
segments JSONB,
endpoint TEXT, -- which backend served it (iguana | berget); a quality fact
audio_seconds DOUBLE PRECISION,
error_kind TEXT, -- typed, never a provider's error string (ADR-033)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
CONSTRAINT uploaded_transcripts_done_has_content
CHECK (status <> 'done' OR content IS NOT NULL),
CONSTRAINT uploaded_transcripts_failed_has_error_kind
CHECK (status <> 'failed' OR error_kind IS NOT NULL)
);
CREATE INDEX idx_uploaded_transcripts_user ON uploaded_transcripts (user_id, created_at DESC);
ALTER TABLE uploaded_transcripts ENABLE ROW LEVEL SECURITY;
ALTER TABLE uploaded_transcripts FORCE ROW LEVEL SECURITY;
CREATE POLICY uploaded_transcripts_isolation ON uploaded_transcripts
FOR ALL
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
COMMENT ON TABLE uploaded_transcripts IS
'PRIVATE per-user transcripts of user-supplied audio. RLS-scoped (ADR-012). Never merge into the shared transcripts table (ADR-021, ADR-033).';
@@ -0,0 +1,199 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// ErrUploadNotFound is returned when an uploaded transcript does not exist OR is
// not the caller's. The two are deliberately indistinguishable: a non-owner learns
// nothing about whether someone else's upload exists (ADR-033).
var ErrUploadNotFound = errors.New("store: uploaded transcript not found")
// Uploaded transcripts are PRIVATE per-user content, stored in their own
// RLS-scoped table (migration 017) — never in the shared `transcripts` (ADR-021).
// Every method goes through withUser, so row-level security confines it to the
// caller's rows; each statement ALSO names user_id in its predicate, so the
// isolation does not rest on a single mechanism and stays testable under the
// superuser connection the unit tests use.
// CreateUpload records a new upload in the queued state and returns its id.
func (s *Store) CreateUpload(ctx context.Context, userID, title string) (string, error) {
var id string
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
`INSERT INTO uploaded_transcripts (user_id, title) VALUES ($1, $2) RETURNING id`,
userID, title).Scan(&id)
}); err != nil {
return "", fmt.Errorf("store: create upload: %w", err)
}
return id, nil
}
// StartTranscribing moves an upload to transcribing.
func (s *Store) StartTranscribing(ctx context.Context, userID, id string) error {
return s.updateUpload(ctx, userID, id, "start transcribing",
`UPDATE uploaded_transcripts SET status = 'transcribing'
WHERE id = $1 AND user_id = $2`)
}
// CompleteUpload stores the finished transcription and moves the upload to done.
func (s *Store) CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error {
segs, err := json.Marshal(t.Segments)
if err != nil {
return fmt.Errorf("store: complete upload: marshal segments: %w", err)
}
return s.updateUpload(ctx, userID, id, "complete upload",
`UPDATE uploaded_transcripts
SET status = 'done', content = $3, segments = $4, endpoint = $5,
audio_seconds = $6, error_kind = NULL, finished_at = NOW()
WHERE id = $1 AND user_id = $2`,
t.Text, segs, t.Endpoint, t.AudioSeconds)
}
// FailUpload moves an upload to failed with a typed reason.
func (s *Store) FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error {
return s.updateUpload(ctx, userID, id, "fail upload",
`UPDATE uploaded_transcripts
SET status = 'failed', error_kind = $3, finished_at = NOW()
WHERE id = $1 AND user_id = $2`,
string(kind))
}
// updateUpload runs one UPDATE keyed on ($1 = id, $2 = userID) plus extra args,
// and maps "no row touched" to ErrUploadNotFound — which is what a write to another
// user's upload looks like under RLS (the row is invisible, not forbidden).
func (s *Store) updateUpload(ctx context.Context, userID, id, op, sql string, args ...any) error {
all := append([]any{id, userID}, args...)
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx, sql, all...)
if err != nil {
return fmt.Errorf("store: %s: %w", op, err)
}
if ct.RowsAffected() == 0 {
return ErrUploadNotFound
}
return nil
})
}
// GetUpload returns one of the caller's uploads, including the transcript text.
func (s *Store) GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error) {
var u domain.UploadedTranscript
err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
row := tx.QueryRow(ctx, uploadSelect+` WHERE id = $1 AND user_id = $2`, id, userID)
return scanUpload(row, &u, true)
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUploadNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get upload: %w", err)
}
return &u, nil
}
// ListUploads returns the caller's uploads, newest first, WITHOUT transcript text
// or segments: a list view must not drag every transcript along.
func (s *Store) ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error) {
var out []domain.UploadedTranscript
err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, uploadSelect+` WHERE user_id = $1 ORDER BY created_at DESC, id`, userID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var u domain.UploadedTranscript
if err := scanUpload(rows, &u, false); err != nil {
return err
}
out = append(out, u)
}
return rows.Err()
})
if err != nil {
return nil, fmt.Errorf("store: list uploads: %w", err)
}
return out, nil
}
// FailInterruptedUploads is the start-up sweep. The audio of an upload is never
// retained (ADR-033), so a job that was queued or transcribing when the process
// stopped can never resume; leaving it would be a stuck spinner. Every such row,
// for every user, becomes failed/interrupted and the user re-uploads. It
// enumerates users from the un-RLS'd identity map and scopes each pass with
// withUser, exactly as scheduled discovery does (ADR-018). Returns rows failed.
func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
users, err := s.ListAllUsers(ctx)
if err != nil {
return 0, fmt.Errorf("store: fail interrupted uploads: %w", err)
}
total := 0
for _, u := range users {
err := s.withUser(ctx, u.UserID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`UPDATE uploaded_transcripts
SET status = 'failed', error_kind = $2, finished_at = NOW()
WHERE user_id = $1 AND status IN ('queued', 'transcribing')`,
u.UserID, string(domain.UploadErrInterrupted))
total += int(ct.RowsAffected())
return err
})
if err != nil {
return total, fmt.Errorf("store: fail interrupted uploads: user %s: %w", u.UserID, err)
}
}
return total, nil
}
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
audio_seconds, error_kind, created_at, finished_at FROM uploaded_transcripts`
// scanUpload scans one uploadSelect row. withText controls whether the transcript
// body and segments are kept (GetUpload) or dropped (ListUploads).
func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error {
var (
status, errKind, endpoint, content *string
segments []byte
seconds *float64
finished *time.Time
)
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
&seconds, &errKind, &u.CreatedAt, &finished); err != nil {
return err
}
u.Status = domain.UploadStatus(deref(status))
u.Endpoint = deref(endpoint)
u.ErrorKind = domain.UploadErrorKind(deref(errKind))
if seconds != nil {
u.AudioSeconds = *seconds
}
if finished != nil {
u.FinishedAt = *finished
}
if !withText {
return nil
}
u.Content = deref(content)
if len(segments) > 0 {
if err := json.Unmarshal(segments, &u.Segments); err != nil {
return fmt.Errorf("unmarshal segments: %w", err)
}
}
return nil
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
@@ -0,0 +1,196 @@
package store_test
import (
"context"
"sort"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
)
// Isolation proof for ADR-033: an uploaded transcript is PRIVATE content owned by
// one user, so it lives in its own RLS-scoped table — never in the shared,
// non-RLS `transcripts` (ADR-021). These tests drive the database directly (raw
// SQL as the non-superuser `app` role) so they observe row-level security itself,
// not any application-layer WHERE clause. The superuser pool is used only to seed
// and to read pg_catalog; it bypasses RLS and so must never be the pool whose
// answers the assertions rest on.
// seedUploadedTranscript inserts one finished upload for userID as the superuser
// (RLS bypassed so both users' rows land regardless of any GUC) and returns its id.
// The text is synthetic — this repo is public, and a real transcript must never
// appear in it (#33).
func seedUploadedTranscript(t *testing.T, super *pgxpool.Pool, userID, text string) string {
t.Helper()
ctx := context.Background()
_, err := super.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING`, userID)
require.NoError(t, err)
var id string
require.NoError(t, super.QueryRow(ctx,
`INSERT INTO uploaded_transcripts (user_id, title, status, content)
VALUES ($1, 'synthetic fixture', 'done', $2) RETURNING id`,
userID, text).Scan(&id))
return id
}
func TestUploadedTranscriptsRLS_ScopedReadsSeeOnlyOwnRows(t *testing.T) {
newStore(t)
super := rawPool(t)
resetDB(t, super)
seedUploadedTranscript(t, super, userA, "text owned by A")
seedUploadedTranscript(t, super, userB, "text owned by B")
app := appPool(t, super)
require.Equal(t, 0, scopedCount(t, app, "", "uploaded_transcripts"),
"no tapir.current_user_id must be deny-all: this proves RLS is live, not bypassed")
require.Equal(t, 1, scopedCount(t, app, userA, "uploaded_transcripts"),
"user A must see exactly its own upload")
require.Equal(t, 1, scopedCount(t, app, userB, "uploaded_transcripts"),
"user B must see exactly its own upload")
// Count alone cannot distinguish "A's row" from "B's row" — read the content.
var got string
ctx := context.Background()
tx, err := app.Begin(ctx)
require.NoError(t, err)
defer tx.Rollback(ctx) //nolint:errcheck
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, userA)
require.NoError(t, err)
require.NoError(t, tx.QueryRow(ctx, `SELECT content FROM uploaded_transcripts`).Scan(&got))
require.Equal(t, "text owned by A", got, "A's scoped read must return A's text, never B's")
}
func TestUploadedTranscriptsRLS_CrossUserWritesAffectZeroRows(t *testing.T) {
newStore(t)
super := rawPool(t)
resetDB(t, super)
seedUploadedTranscript(t, super, userA, "text owned by A")
bID := seedUploadedTranscript(t, super, userB, "text owned by B")
app := appPool(t, super)
require.Equal(t, int64(0), scopedRowsAffected(t, app, userA,
`UPDATE uploaded_transcripts SET content = 'hacked' WHERE id = $1`, bID),
"user A scoped UPDATE must touch zero of B's uploads")
require.Equal(t, int64(0), scopedRowsAffected(t, app, userA,
`DELETE FROM uploaded_transcripts WHERE id = $1`, bID),
"user A scoped DELETE must touch zero of B's uploads")
var content string
require.NoError(t, super.QueryRow(context.Background(),
`SELECT content FROM uploaded_transcripts WHERE id = $1`, bID).Scan(&content))
require.Equal(t, "text owned by B", content, "B's upload must survive A's writes unchanged")
}
func TestUploadedTranscriptsRLS_InsertForAnotherUserIsRejected(t *testing.T) {
newStore(t)
super := rawPool(t)
resetDB(t, super)
seedUploadedTranscript(t, super, userA, "text owned by A")
_, err := super.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1)`, userB)
require.NoError(t, err)
app := appPool(t, super)
ctx := context.Background()
tx, err := app.Begin(ctx)
require.NoError(t, err)
defer tx.Rollback(ctx) //nolint:errcheck
_, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, userA)
require.NoError(t, err)
// Scoped as A, plant a row that claims to belong to B. The policy's WITH CHECK
// (defaulting to USING under FOR ALL) must refuse it.
_, err = tx.Exec(ctx,
`INSERT INTO uploaded_transcripts (user_id, title, status, content)
VALUES ($1, 'planted', 'done', 'planted')`, userB)
require.Error(t, err, "a row scoped as A must not be insertable on behalf of B")
require.Contains(t, err.Error(), "row-level security")
}
func TestUploadedTranscripts_RowSecurityIsEnabledForcedAndKeyedByUser(t *testing.T) {
newStore(t)
super := rawPool(t)
ctx := context.Background()
var enabled, forced bool
require.NoError(t, super.QueryRow(ctx,
`SELECT relrowsecurity, relforcerowsecurity FROM pg_class
WHERE oid = 'public.uploaded_transcripts'::regclass`).Scan(&enabled, &forced))
require.True(t, enabled, "uploaded_transcripts must have row-level security enabled")
require.True(t, forced,
"FORCE is what binds the table-owner role tapir; without it the policy is dead in production")
var userIDNotNull bool
require.NoError(t, super.QueryRow(ctx,
`SELECT is_nullable = 'NO' FROM information_schema.columns
WHERE table_name = 'uploaded_transcripts' AND column_name = 'user_id'`).Scan(&userIDNotNull))
require.True(t, userIDNotNull, "an upload with no owner would match no policy and be unreachable")
}
func TestUploadedTranscripts_DeleteUserRemovesTheirUploads(t *testing.T) {
s := newStore(t)
super := rawPool(t)
resetDB(t, super)
seedUploadedTranscript(t, super, userA, "text owned by A")
bID := seedUploadedTranscript(t, super, userB, "text owned by B")
require.NoError(t, s.DeleteUser(context.Background(), userA))
var aRows, bRows int
ctx := context.Background()
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM uploaded_transcripts WHERE user_id = $1`, userA).Scan(&aRows))
require.NoError(t, super.QueryRow(ctx,
`SELECT count(*) FROM uploaded_transcripts WHERE id = $1`, bID).Scan(&bRows))
require.Equal(t, 0, aRows, "deleting a user must remove their private transcripts (ON DELETE CASCADE)")
require.Equal(t, 1, bRows, "deleting A must not touch B's transcripts")
}
// rowSecurityOffTables are the public-schema tables that deliberately have row
// security OFF. The list is exact on purpose: a NEW table added without RLS must
// fail this test and force a decision, instead of passing CI by omission — the
// failure mode ADR-033 names, where a naive reuse "leaks private content across
// tenants and passes CI green".
//
// - transcripts: shared public caption content, the single exception for CONTENT
// (ADR-021). It is the one that must never gain a private row.
// - user_identities: the Dex-subject -> user_id map, read at login before any
// user is known (migration 004). Holds no user content.
// - invitations: exists before the user does, so there is no user to scope by;
// the single-use token is the capability (migration 009).
// - schema_migrations: golang-migrate bookkeeping.
//
// #33 describes `transcripts` as "the only non-RLS table". That is true of content
// and false of tables; this test asserts the real set.
var rowSecurityOffTables = []string{
"invitations",
"schema_migrations",
"transcripts",
"user_identities",
}
func TestRowSecurityOffTablesAreExactlyTheKnownSet(t *testing.T) {
newStore(t)
super := rawPool(t)
rows, err := super.Query(context.Background(),
`SELECT c.relname FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND NOT c.relrowsecurity
ORDER BY c.relname`)
require.NoError(t, err)
defer rows.Close()
var got []string
for rows.Next() {
var name string
require.NoError(t, rows.Scan(&name))
got = append(got, name)
}
require.NoError(t, rows.Err())
want := append([]string{}, rowSecurityOffTables...)
sort.Strings(want)
require.Equal(t, want, got,
"every public table must have row security ON unless it is in rowSecurityOffTables; "+
"adding a table here is a decision that needs an ADR")
}
@@ -0,0 +1,188 @@
package store_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// These exercise the Store API over uploaded_transcripts. The test Store connects
// as the superuser, which BYPASSES row-level security — so the cross-user cases
// here prove the query's own user_id predicate (defence in depth), while the
// database-enforced guarantee is proven separately, as the non-superuser app
// role, in uploaded_transcripts_isolation_test.go. Text is synthetic; this repo is
// public and no real transcript may appear in it (#33).
func seedUsers(t *testing.T, ids ...string) {
t.Helper()
super := rawPool(t)
resetDB(t, super)
for _, id := range ids {
_, err := super.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1)`, id)
require.NoError(t, err)
}
}
func TestCreateUpload_StartsQueuedAndIsOwnedByTheCreator(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "Teams export")
require.NoError(t, err)
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, id, got.ID)
assert.Equal(t, userA, got.UserID)
assert.Equal(t, "Teams export", got.Title)
assert.Equal(t, domain.UploadQueued, got.Status)
assert.Empty(t, got.Content)
assert.True(t, got.FinishedAt.IsZero(), "an unfinished upload has no finish time")
}
func TestUploadLifecycle_CompletedKeepsTextSegmentsEndpointAndDuration(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m")
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
mid, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadTranscribing, mid.Status)
want := domain.Transcription{
Text: "God morgon allihopa. Vi går igenom läget.",
Segments: []domain.Segment{
{Start: 0.0, End: 1.5, Text: "God morgon allihopa."},
{Start: 1.5, End: 4.25, Text: "Vi går igenom läget."},
},
Endpoint: "iguana/kb-whisper",
AudioSeconds: 4.25,
}
require.NoError(t, s.CompleteUpload(ctx, userA, id, want))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadDone, got.Status)
assert.Equal(t, want.Text, got.Content)
assert.Equal(t, want.Segments, got.Segments, "segment times must survive the JSONB round trip")
assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is a quality fact and is recorded per row")
assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
assert.False(t, got.FinishedAt.IsZero())
assert.Empty(t, got.ErrorKind)
}
func TestUploadLifecycle_FailedRecordsTypedKindAndNoContent(t *testing.T) {
s := newStore(t)
seedUsers(t, userA)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "m")
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, id))
require.NoError(t, s.FailUpload(ctx, userA, id, domain.UploadErrTranscriptionFailed))
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadFailed, got.Status)
assert.Equal(t, domain.UploadErrTranscriptionFailed, got.ErrorKind)
assert.Empty(t, got.Content)
assert.False(t, got.FinishedAt.IsZero())
}
func TestGetUpload_AnotherUsersIDIsNotFound(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "A's meeting")
require.NoError(t, err)
_, err = s.GetUpload(ctx, userB, id)
assert.ErrorIs(t, err, store.ErrUploadNotFound,
"a non-owner must get not-found, not forbidden: existence is not disclosed")
}
func TestUploadWrites_ByAnotherUserChangeNothing(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
id, err := s.CreateUpload(ctx, userA, "A's meeting")
require.NoError(t, err)
assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound)
assert.ErrorIs(t, s.CompleteUpload(ctx, userB, id, domain.Transcription{Text: "planted"}), store.ErrUploadNotFound)
assert.ErrorIs(t, s.FailUpload(ctx, userB, id, domain.UploadErrTranscriptionFailed), store.ErrUploadNotFound)
got, err := s.GetUpload(ctx, userA, id)
require.NoError(t, err)
assert.Equal(t, domain.UploadQueued, got.Status, "B's writes must leave A's upload exactly as it was")
assert.Empty(t, got.Content)
}
func TestListUploads_ReturnsOnlyTheCallersNewestFirstWithoutTranscriptText(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
ctx := context.Background()
first, err := s.CreateUpload(ctx, userA, "first")
require.NoError(t, err)
second, err := s.CreateUpload(ctx, userA, "second")
require.NoError(t, err)
_, err = s.CreateUpload(ctx, userB, "B's")
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, first))
require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"}))
got, err := s.ListUploads(ctx, userA)
require.NoError(t, err)
require.Len(t, got, 2, "A must see only A's uploads")
assert.Equal(t, second, got[0].ID, "newest first")
assert.Equal(t, first, got[1].ID)
assert.Empty(t, got[1].Content, "a list row must not drag the whole transcript along")
assert.Equal(t, domain.UploadDone, got[1].Status)
}
func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T) {
s := newStore(t)
seedUsers(t, userA, userB)
super := rawPool(t)
ctx := context.Background()
for subject, uid := range map[string]string{"sub-a": userA, "sub-b": userB} {
_, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid)
require.NoError(t, err)
}
queued, err := s.CreateUpload(ctx, userA, "queued")
require.NoError(t, err)
running, err := s.CreateUpload(ctx, userB, "running")
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userB, running))
done, err := s.CreateUpload(ctx, userA, "done")
require.NoError(t, err)
require.NoError(t, s.StartTranscribing(ctx, userA, done))
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"}))
n, err := s.FailInterruptedUploads(ctx)
require.NoError(t, err)
assert.Equal(t, 2, n, "the queued and the transcribing row, across both users")
for _, c := range []struct{ user, id string }{{userA, queued}, {userB, running}} {
got, err := s.GetUpload(ctx, c.user, c.id)
require.NoError(t, err)
assert.Equal(t, domain.UploadFailed, got.Status)
assert.Equal(t, domain.UploadErrInterrupted, got.ErrorKind,
"audio is not retained, so a restart cannot resume: the honest state is failed/interrupted")
}
kept, err := s.GetUpload(ctx, userA, done)
require.NoError(t, err)
assert.Equal(t, domain.UploadDone, kept.Status, "a finished transcript must survive the sweep")
assert.Equal(t, "kept", kept.Content)
}
+53
View File
@@ -0,0 +1,53 @@
package transcribe
import (
"context"
"errors"
"fmt"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// Backend is one transcription endpoint. *Gateway satisfies it; Name is the value
// recorded as the serving endpoint.
type Backend interface {
Name() string
Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error)
}
// Chain tries a primary (local) backend and, if it fails for ANY reason, a
// fallback. The fallback exists because iguana can wedge permanently on a Metal
// OOM (infra#273) — which is also why a fallback is a data-egress decision: the
// berget endpoint is a cloud service, so the result records which one served it.
type Chain struct {
primary Backend
fallback Backend // nil disables the fallback
}
// NewChain builds a Chain. A nil fallback means a primary failure is the result.
func NewChain(primary, fallback Backend) *Chain {
return &Chain{primary: primary, fallback: fallback}
}
// Transcribe returns the first backend's success. It falls back on any primary
// error — transport, timeout, non-2xx, empty transcript — and never inspects a
// response body to decide. A cancelled context stops the chain: a job the user or
// shutdown cancelled must not ship its audio to a cloud endpoint as a last act.
func (c *Chain) Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error) {
t, err := c.primary.Transcribe(ctx, wavPath)
if err == nil {
t.Endpoint = c.primary.Name()
return t, nil
}
if c.fallback == nil || ctx.Err() != nil {
return domain.Transcription{}, fmt.Errorf("%s: %w", c.primary.Name(), err)
}
t, ferr := c.fallback.Transcribe(ctx, wavPath)
if ferr != nil {
return domain.Transcription{}, errors.Join(
fmt.Errorf("%s: %w", c.primary.Name(), err),
fmt.Errorf("%s: %w", c.fallback.Name(), ferr))
}
t.Endpoint = c.fallback.Name()
return t, nil
}
@@ -0,0 +1,99 @@
package transcribe_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/domain"
)
type fakeBackend struct {
name string
text string
err error
calls int
}
func (f *fakeBackend) Name() string { return f.name }
func (f *fakeBackend) Transcribe(_ context.Context, _ string) (domain.Transcription, error) {
f.calls++
if f.err != nil {
return domain.Transcription{}, f.err
}
return domain.Transcription{Text: f.text, Endpoint: f.name}, nil
}
func TestChain_PrimarySuccessNeverTouchesTheFallback(t *testing.T) {
primary := &fakeBackend{name: "iguana/kb-whisper", text: "primary text"}
fallback := &fakeBackend{name: "berget/kb-whisper", text: "fallback text"}
got, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, "primary text", got.Text)
assert.Equal(t, "iguana/kb-whisper", got.Endpoint)
assert.Zero(t, fallback.calls, "audio must not leave the homelab when the local endpoint worked")
}
func TestChain_AnyPrimaryFailureFallsBackAndRecordsTheFallbackEndpoint(t *testing.T) {
tests := []struct {
name string
err error
}{
{"server error", &transcribe.StatusError{Code: 500}},
{"gateway timeout", &transcribe.StatusError{Code: 504}},
{"client error", &transcribe.StatusError{Code: 400}},
{"transport failure", errors.New("dial tcp: connection refused")},
{"empty transcript", transcribe.ErrEmptyTranscript},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
primary := &fakeBackend{name: "iguana/kb-whisper", err: tt.err}
fallback := &fakeBackend{name: "berget/kb-whisper", text: "fallback text"}
got, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
require.NoError(t, err)
assert.Equal(t, "fallback text", got.Text)
assert.Equal(t, "berget/kb-whisper", got.Endpoint,
"the row must say berget served it: that means the audio left the homelab")
})
}
}
func TestChain_BothFailingReportsBothErrors(t *testing.T) {
primary := &fakeBackend{name: "iguana/kb-whisper", err: errors.New("iguana down")}
fallback := &fakeBackend{name: "berget/kb-whisper", err: errors.New("berget down")}
_, err := transcribe.NewChain(primary, fallback).Transcribe(context.Background(), "/a.wav")
require.Error(t, err)
assert.Contains(t, err.Error(), "iguana down")
assert.Contains(t, err.Error(), "berget down")
}
func TestChain_WithoutAFallbackAPrimaryFailureIsTheResult(t *testing.T) {
primary := &fakeBackend{name: "iguana/kb-whisper", err: errors.New("iguana down")}
_, err := transcribe.NewChain(primary, nil).Transcribe(context.Background(), "/a.wav")
require.Error(t, err)
assert.Contains(t, err.Error(), "iguana down")
}
func TestChain_ACancelledJobDoesNotFallBack(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
primary := &fakeBackend{name: "iguana/kb-whisper", err: context.Canceled}
fallback := &fakeBackend{name: "berget/kb-whisper", text: "x"}
_, err := transcribe.NewChain(primary, fallback).Transcribe(ctx, "/a.wav")
assert.ErrorIs(t, err, context.Canceled)
assert.Zero(t, fallback.calls, "a cancelled job must not ship the audio to a cloud endpoint")
}
+147
View File
@@ -0,0 +1,147 @@
// Package transcribe turns a WAV file into a timed Swedish transcript by calling
// the LiteLLM gateway's OpenAI-compatible /audio/transcriptions endpoint
// (ADR-033). A Gateway is one model alias; a Chain tries a local one and falls
// back to a second.
package transcribe
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strings"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// ErrEmptyTranscript is returned when the endpoint answered 200 but with no text.
// An empty transcript is not a success: chat over it could only answer "nothing".
var ErrEmptyTranscript = errors.New("transcribe: endpoint returned an empty transcript")
// StatusError is a non-2xx answer from the gateway. Nothing may branch on the
// body — {"error":"failed to process audio"} has had at least five distinct
// causes — so only the status code is exposed for logic; Snippet is for logs.
type StatusError struct {
Code int
Snippet string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("transcribe: gateway returned HTTP %d: %s", e.Code, e.Snippet)
}
// Gateway transcribes through one model alias on the gateway. baseURL is the
// gateway's /v1 root — the in-cluster NodePort, not the public hostname, whose
// ingress rejects bodies over ~8-10 MB with 413 (docs/research/audio-length-ceiling).
type Gateway struct {
baseURL string
key string
model string
client *http.Client
}
// NewGateway builds a Gateway. The client should carry no overall Timeout: a 90
// minute recording takes minutes, so the deadline belongs to the caller's context.
func NewGateway(baseURL, key, model string, client *http.Client) *Gateway {
return &Gateway{baseURL: strings.TrimRight(baseURL, "/"), key: key, model: model, client: client}
}
// Name is the model alias; it is what gets recorded as the serving endpoint.
func (g *Gateway) Name() string { return g.model }
// Transcribe uploads the WAV at path and returns the normalised transcription.
// The body is streamed from disk with a COMPUTED Content-Length: a 90 minute WAV
// is ~170 MB, so it is neither buffered in memory nor sent chunked.
func (g *Gateway) Transcribe(ctx context.Context, path string) (domain.Transcription, error) {
f, err := os.Open(path)
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: open audio: %w", err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: stat audio: %w", err)
}
var prefix bytes.Buffer
mw := multipart.NewWriter(&prefix)
for _, kv := range [][2]string{{"model", g.model}, {"response_format", "verbose_json"}, {"language", "sv"}} {
if err := mw.WriteField(kv[0], kv[1]); err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
}
}
// The multipart field is `file`; `audio_file` is rejected as "Invalid request".
if _, err := mw.CreateFormFile("file", "audio.wav"); err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
}
suffix := []byte("\r\n--" + mw.Boundary() + "--\r\n")
body := io.MultiReader(bytes.NewReader(prefix.Bytes()), f, bytes.NewReader(suffix))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.baseURL+"/audio/transcriptions", body)
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: build request: %w", err)
}
req.ContentLength = int64(prefix.Len()) + info.Size() + int64(len(suffix))
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+g.key)
// No "Expect: 100-continue": it trips the proxy for any body over 1 KB.
resp, err := g.client.Do(req)
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snip, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
return domain.Transcription{}, &StatusError{Code: resp.StatusCode, Snippet: strings.TrimSpace(string(snip))}
}
t, err := parseVerbose(resp.Body)
if err != nil {
return domain.Transcription{}, fmt.Errorf("transcribe: %s: %w", g.model, err)
}
t.Endpoint = g.model
return t, nil
}
// verboseJSON is the subset of the verbose_json response used. Both backends
// return it; they differ in segment granularity (iguana: whole seconds, berget:
// sub-second) and in the language token, neither of which matters here.
type verboseJSON struct {
Text string `json:"text"`
Duration float64 `json:"duration"`
Segments []struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
} `json:"segments"`
}
// parseVerbose normalises a verbose_json body. Whisper's final window can end past
// the audio (9.0 s for 4.25 s of audio was observed), so segment ends are clamped
// to the reported duration.
func parseVerbose(r io.Reader) (domain.Transcription, error) {
var v verboseJSON
if err := json.NewDecoder(r).Decode(&v); err != nil {
return domain.Transcription{}, fmt.Errorf("decode response: %w", err)
}
text := strings.TrimSpace(v.Text)
if text == "" {
return domain.Transcription{}, ErrEmptyTranscript
}
t := domain.Transcription{Text: text, AudioSeconds: v.Duration}
for _, s := range v.Segments {
seg := domain.Segment{Start: s.Start, End: s.End, Text: strings.TrimSpace(s.Text)}
if v.Duration > 0 {
seg.End = min(seg.End, v.Duration)
seg.Start = min(seg.Start, seg.End)
}
t.Segments = append(t.Segments, seg)
}
return t, nil
}
@@ -0,0 +1,174 @@
package transcribe_test
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// The httptest server stands in for the LiteLLM gateway: it is the process
// boundary, so a fake here is legitimate. The JSON it returns mirrors what the
// real gateway returned for iguana/kb-whisper and berget/kb-whisper on 2026-10-01
// (docs/research/audio-length-ceiling-2026-10-01.md). All text is synthetic.
const verboseJSON = `{
"text": " God morgon allihopa.\n Vi går igenom läget.\n",
"language": "swedish",
"duration": 4.25,
"segments": [
{"id": 0, "start": 0.0, "end": 1.0, "text": " God morgon allihopa."},
{"id": 1, "start": 1.0, "end": 9.0, "text": " Vi går igenom läget."}
]
}`
func wavFile(t *testing.T, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "a.wav")
require.NoError(t, os.WriteFile(p, []byte(content), 0o600))
return p
}
type seen struct {
method, path, auth, expect, contentType string
contentLength int64
transferEncoding []string
fields map[string]string
file string
fileField string
bodyLen int
}
// serve starts a gateway fake that records the request it received and replies
// with status and body.
func serve(t *testing.T, status int, reply string, got *seen) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got != nil {
got.method, got.path = r.Method, r.URL.Path
got.auth = r.Header.Get("Authorization")
got.expect = r.Header.Get("Expect")
got.contentType = r.Header.Get("Content-Type")
got.contentLength = r.ContentLength
got.transferEncoding = r.TransferEncoding
raw, _ := io.ReadAll(r.Body)
got.bodyLen = len(raw)
r.Body = io.NopCloser(bytes.NewReader(raw))
require.NoError(t, r.ParseMultipartForm(32<<20))
got.fields = map[string]string{}
for k, v := range r.MultipartForm.Value {
got.fields[k] = v[0]
}
for field, fhs := range r.MultipartForm.File {
got.fileField = field
f, _ := fhs[0].Open()
b, _ := io.ReadAll(f)
got.file = string(b)
}
}
w.WriteHeader(status)
_, _ = io.WriteString(w, reply)
}))
t.Cleanup(srv.Close)
return srv
}
func TestGateway_SendsTheRequestTheGatewayExpects(t *testing.T) {
var got seen
srv := serve(t, 200, verboseJSON, &got)
g := transcribe.NewGateway(srv.URL+"/v1", "secret-key", "iguana/kb-whisper", srv.Client())
_, err := g.Transcribe(context.Background(), wavFile(t, "RIFF-synthetic-pcm"))
require.NoError(t, err)
assert.Equal(t, http.MethodPost, got.method)
assert.Equal(t, "/v1/audio/transcriptions", got.path)
assert.Equal(t, "Bearer secret-key", got.auth)
assert.Equal(t, "file", got.fileField, "the multipart field is `file`; `audio_file` is rejected as 'Invalid request'")
assert.Equal(t, "RIFF-synthetic-pcm", got.file)
assert.Equal(t, "iguana/kb-whisper", got.fields["model"])
assert.Equal(t, "verbose_json", got.fields["response_format"])
assert.Equal(t, "sv", got.fields["language"])
assert.Empty(t, got.expect, "no Expect: 100-continue: it trips the proxy for any body over 1 KB")
}
func TestGateway_SendsAKnownContentLengthNotChunked(t *testing.T) {
var got seen
srv := serve(t, 200, verboseJSON, &got)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
_, err := g.Transcribe(context.Background(), wavFile(t, "0123456789"))
require.NoError(t, err)
assert.Empty(t, got.transferEncoding, "a 90-minute WAV is ~170 MB: stream it with a computed length, not chunked and not buffered")
assert.Equal(t, int64(got.bodyLen), got.contentLength)
}
func TestGateway_NormalisesTheVerboseJSONResponse(t *testing.T) {
srv := serve(t, 200, verboseJSON, nil)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client())
got, err := g.Transcribe(context.Background(), wavFile(t, "x"))
require.NoError(t, err)
assert.Equal(t, "God morgon allihopa.\n Vi går igenom läget.", got.Text)
assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is part of the result")
assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9)
assert.Equal(t, []domain.Segment{
{Start: 0, End: 1, Text: "God morgon allihopa."},
// The final segment ended at 9.0 s for 4.25 s of audio: whisper's last
// window can run past the end. Clamped to the audio duration.
{Start: 1, End: 4.25, Text: "Vi går igenom läget."},
}, got.Segments)
}
func TestGateway_ErrorsDoNotDependOnTheBody(t *testing.T) {
// {"error":"failed to process audio"} has had at least five distinct causes, so
// no code may branch on it. Two different bodies must be treated identically.
for _, reply := range []string{`{"error":"failed to process audio"}`, `{"error":"something else entirely"}`} {
srv := serve(t, 500, reply, nil)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
_, err := g.Transcribe(context.Background(), wavFile(t, "x"))
var se *transcribe.StatusError
require.ErrorAs(t, err, &se)
assert.Equal(t, 500, se.Code)
}
}
func TestGateway_AnEmptyTranscriptIsAFailureNotASuccess(t *testing.T) {
srv := serve(t, 200, `{"text":" ","language":"swedish","duration":3,"segments":[]}`, nil)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
_, err := g.Transcribe(context.Background(), wavFile(t, "x"))
assert.ErrorIs(t, err, transcribe.ErrEmptyTranscript)
}
func TestGateway_MalformedJSONIsAnError(t *testing.T) {
srv := serve(t, 200, `not json`, nil)
g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client())
_, err := g.Transcribe(context.Background(), wavFile(t, "x"))
assert.Error(t, err)
}
func TestGateway_MissingAudioFileIsAnError(t *testing.T) {
g := transcribe.NewGateway("http://127.0.0.1:1/v1", "k", "m", http.DefaultClient)
_, err := g.Transcribe(context.Background(), filepath.Join(t.TempDir(), "nope.wav"))
assert.Error(t, err)
}
+49
View File
@@ -0,0 +1,49 @@
package transcribe_test
import (
"context"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/transcribe"
)
// TestGateway_LiveGateway is an opt-in check against the REAL gateway, because
// the httptest fake above can only prove what we think the gateway accepts. It
// is skipped unless TAPIR_LIVE_WAV names a 16 kHz mono WAV and the gateway
// coordinates are set:
//
// TAPIR_LIVE_WAV=/path/a.wav TAPIR_LIVE_GATEWAY_URL=http://koala:30401/v1 \
// TAPIR_LIVE_MODEL=iguana/kb-whisper op run --env-file ~/.op-env -- \
// go test ./internal/adapters/transcribe -run LiveGateway -v
//
// Use SYNTHETIC audio only: this repo is public and the gateway may be a cloud
// endpoint (#33).
func TestGateway_LiveGateway(t *testing.T) {
wav := os.Getenv("TAPIR_LIVE_WAV")
url := os.Getenv("TAPIR_LIVE_GATEWAY_URL")
if wav == "" || url == "" {
t.Skip("set TAPIR_LIVE_WAV and TAPIR_LIVE_GATEWAY_URL to run against the real gateway")
}
model := os.Getenv("TAPIR_LIVE_MODEL")
if model == "" {
model = "iguana/kb-whisper"
}
key := os.Getenv("LITELLM_MASTER_KEY")
require.NotEmpty(t, key, "LITELLM_MASTER_KEY must be set (via op run)")
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Minute)
defer cancel()
got, err := transcribe.NewGateway(url, key, model, &http.Client{}).Transcribe(ctx, wav)
require.NoError(t, err)
assert.NotEmpty(t, got.Text)
assert.NotEmpty(t, got.Segments)
assert.Equal(t, model, got.Endpoint)
t.Logf("endpoint=%s audio_s=%.1f segments=%d chars=%d", got.Endpoint, got.AudioSeconds, len(got.Segments), len(got.Text))
}
+7
View File
@@ -20,6 +20,9 @@ import (
// Config is Tapir's fully-resolved runtime configuration. // Config is Tapir's fully-resolved runtime configuration.
type Config struct { type Config struct {
// UploadConfig is the audio-upload and private-transcription settings (ADR-033).
UploadConfig
// UserID is the Tapir user the run operates as. Stage 0 has exactly one. // UserID is the Tapir user the run operates as. Stage 0 has exactly one.
// It must be a UUID: it keys the UUID user_id/video_id columns (data-model). // It must be a UUID: it keys the UUID user_id/video_id columns (data-model).
UserID string UserID string
@@ -320,6 +323,10 @@ func Load() (Config, error) {
} }
c.OnboardMaxVideoSeconds = onboardMax c.OnboardMaxVideoSeconds = onboardMax
if c.UploadConfig, err = loadUploadConfig(); err != nil {
return Config{}, err
}
return c, nil return c, nil
} }
+110
View File
@@ -0,0 +1,110 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
)
// UploadConfig holds the settings for audio upload and private transcription
// (ADR-033). It is embedded in Config, so its fields read as c.UploadsEnabled etc.
type UploadConfig struct {
// UploadsEnabled mounts the /uploads routes and starts the worker. Off by
// default: the runtime image must contain ffmpeg and ffprobe first (ADR-033).
UploadsEnabled bool
// UploadMaxBytes caps one upload. UploadMaxDuration caps one recording; 90
// minutes is the longest length MEASURED, not a ceiling that was found, so
// raising it is a measurement, not an edit.
UploadMaxBytes int64
UploadMaxDuration time.Duration
// TranscribeModel is the primary gateway alias; TranscribeFallbackModel is the
// fallback, tried on ANY primary failure. The fallback is a cloud endpoint
// (berget): set it EMPTY to keep private audio on the homelab.
TranscribeModel string
TranscribeFallbackModel string
// TranscribeTimeout bounds one job (conversion + transcription); a 90 minute
// recording took ~7.5 minutes on iguana.
TranscribeTimeout time.Duration
// UploadTmpDir is where uploads are spooled and converted. Audio is deleted
// after transcription, so this holds at most the in-flight files.
UploadTmpDir string
FFmpegBin string
FFprobeBin string
// MaxUploadedTranscriptChars bounds the transcript sent to a chat model for an
// UPLOADED recording. The 18,000-char default for captions is ~22 minutes of
// speech; an hour of meeting is ~50,000 chars. A guess until measured against
// the models chat offers.
MaxUploadedTranscriptChars int
}
const (
defaultUploadMaxMB = 200
defaultUploadMaxMinutes = 90
defaultTranscribeModel = "iguana/kb-whisper"
defaultTranscribeFallbackModel = "berget/kb-whisper"
defaultTranscribeTimeout = 30 * time.Minute
defaultMaxUploadedChars = 120000
)
func loadUploadConfig() (UploadConfig, error) {
u := UploadConfig{
TranscribeModel: envOr("TAPIR_TRANSCRIBE_MODEL", defaultTranscribeModel),
TranscribeFallbackModel: lookupOr("TAPIR_TRANSCRIBE_FALLBACK_MODEL", defaultTranscribeFallbackModel),
UploadTmpDir: envOr("TAPIR_UPLOAD_TMP_DIR", os.TempDir()),
FFmpegBin: envOr("TAPIR_FFMPEG_BIN", "ffmpeg"),
FFprobeBin: envOr("TAPIR_FFPROBE_BIN", "ffprobe"),
}
if v := os.Getenv("TAPIR_UPLOADS_ENABLED"); v != "" {
on, err := strconv.ParseBool(v)
if err != nil {
return UploadConfig{}, fmt.Errorf("config: TAPIR_UPLOADS_ENABLED=%q: %w", v, err)
}
u.UploadsEnabled = on
}
mb, err := positiveInt("TAPIR_UPLOAD_MAX_MB", defaultUploadMaxMB)
if err != nil {
return UploadConfig{}, err
}
u.UploadMaxBytes = int64(mb) << 20
mins, err := positiveInt("TAPIR_UPLOAD_MAX_MINUTES", defaultUploadMaxMinutes)
if err != nil {
return UploadConfig{}, err
}
u.UploadMaxDuration = time.Duration(mins) * time.Minute
timeout, err := durationOr("TAPIR_TRANSCRIBE_TIMEOUT", defaultTranscribeTimeout)
if err != nil {
return UploadConfig{}, err
}
if timeout <= 0 {
return UploadConfig{}, fmt.Errorf("config: TAPIR_TRANSCRIBE_TIMEOUT must be positive, got %v", timeout)
}
u.TranscribeTimeout = timeout
chars, err := intOr("TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS", defaultMaxUploadedChars)
if err != nil {
return UploadConfig{}, err
}
if chars < 0 {
chars = 0
}
u.MaxUploadedTranscriptChars = chars
return u, nil
}
// positiveInt is intOr that refuses zero and negatives: a zero upload limit would
// reject every file, which is a misconfiguration to fail on, not to run with.
func positiveInt(key string, fallback int) (int, error) {
n, err := intOr(key, fallback)
if err != nil {
return 0, err
}
if n <= 0 {
return 0, fmt.Errorf("config: %s must be positive, got %d", key, n)
}
return n, nil
}
+123
View File
@@ -0,0 +1,123 @@
package config
import (
"os"
"testing"
"time"
)
// clearUploadEnv unsets every upload knob so a test observes the default, not the
// host environment.
func clearUploadEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"TAPIR_UPLOADS_ENABLED", "TAPIR_UPLOAD_MAX_MB", "TAPIR_UPLOAD_MAX_MINUTES",
"TAPIR_TRANSCRIBE_MODEL", "TAPIR_TRANSCRIBE_FALLBACK_MODEL", "TAPIR_TRANSCRIBE_TIMEOUT",
"TAPIR_UPLOAD_TMP_DIR", "TAPIR_FFMPEG_BIN", "TAPIR_FFPROBE_BIN",
"TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS",
} {
unset(t, k)
}
}
func TestLoad_UploadDefaults(t *testing.T) {
clearUploadEnv(t)
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.UploadsEnabled {
t.Error("uploads must be OFF by default: the runtime image needs ffmpeg first (ADR-033)")
}
if c.UploadMaxBytes != 200<<20 {
t.Errorf("UploadMaxBytes = %d, want 200 MB", c.UploadMaxBytes)
}
if c.UploadMaxDuration != 90*time.Minute {
t.Errorf("UploadMaxDuration = %v, want 90m (the longest length measured, not a found ceiling)", c.UploadMaxDuration)
}
if c.TranscribeModel != "iguana/kb-whisper" {
t.Errorf("TranscribeModel = %q", c.TranscribeModel)
}
if c.TranscribeFallbackModel != "berget/kb-whisper" {
t.Errorf("TranscribeFallbackModel = %q", c.TranscribeFallbackModel)
}
if c.TranscribeTimeout != 30*time.Minute {
t.Errorf("TranscribeTimeout = %v, want 30m (a 90 min recording took ~7.5 min)", c.TranscribeTimeout)
}
if c.UploadTmpDir != os.TempDir() {
t.Errorf("UploadTmpDir = %q, want os.TempDir()", c.UploadTmpDir)
}
if c.FFmpegBin != "ffmpeg" || c.FFprobeBin != "ffprobe" {
t.Errorf("bins = %q, %q", c.FFmpegBin, c.FFprobeBin)
}
if c.MaxUploadedTranscriptChars != 120000 {
t.Errorf("MaxUploadedTranscriptChars = %d, want 120000", c.MaxUploadedTranscriptChars)
}
}
func TestLoad_UploadValuesAreParsed(t *testing.T) {
clearUploadEnv(t)
setEnv(t, map[string]string{
"TAPIR_UPLOADS_ENABLED": "true",
"TAPIR_UPLOAD_MAX_MB": "50",
"TAPIR_UPLOAD_MAX_MINUTES": "30",
"TAPIR_TRANSCRIBE_MODEL": "koala/whisper",
"TAPIR_TRANSCRIBE_TIMEOUT": "10m",
"TAPIR_UPLOAD_TMP_DIR": "/var/spool/tapir",
"TAPIR_FFMPEG_BIN": "/opt/ffmpeg",
"TAPIR_FFPROBE_BIN": "/opt/ffprobe",
"TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS": "60000",
})
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !c.UploadsEnabled || c.UploadMaxBytes != 50<<20 || c.UploadMaxDuration != 30*time.Minute ||
c.TranscribeModel != "koala/whisper" || c.TranscribeTimeout != 10*time.Minute ||
c.UploadTmpDir != "/var/spool/tapir" || c.FFmpegBin != "/opt/ffmpeg" ||
c.FFprobeBin != "/opt/ffprobe" || c.MaxUploadedTranscriptChars != 60000 {
t.Errorf("values not applied: %+v", c)
}
}
func TestLoad_EmptyTranscribeFallbackDisablesIt(t *testing.T) {
clearUploadEnv(t)
t.Setenv("TAPIR_TRANSCRIBE_FALLBACK_MODEL", "")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.TranscribeFallbackModel != "" {
t.Errorf("TranscribeFallbackModel = %q: an explicit empty value must disable the cloud fallback, "+
"so private audio can be kept on the homelab", c.TranscribeFallbackModel)
}
}
func TestLoad_UploadRejectsInvalidValues(t *testing.T) {
tests := []struct{ key, val string }{
{"TAPIR_UPLOADS_ENABLED", "maybe"},
{"TAPIR_UPLOAD_MAX_MB", "lots"},
{"TAPIR_UPLOAD_MAX_MB", "0"},
{"TAPIR_UPLOAD_MAX_MB", "-5"},
{"TAPIR_UPLOAD_MAX_MINUTES", "0"},
{"TAPIR_UPLOAD_MAX_MINUTES", "forever"},
{"TAPIR_TRANSCRIBE_TIMEOUT", "soon"},
{"TAPIR_TRANSCRIBE_TIMEOUT", "0s"},
}
for _, tt := range tests {
t.Run(tt.key+"="+tt.val, func(t *testing.T) {
clearUploadEnv(t)
t.Setenv(tt.key, tt.val)
if _, err := Load(); err == nil {
t.Errorf("Load accepted %s=%q; a zero or negative limit would reject every upload", tt.key, tt.val)
}
})
}
}
+59
View File
@@ -111,3 +111,62 @@ type Summary struct {
FallbackUsed bool FallbackUsed bool
CreatedAt time.Time CreatedAt time.Time
} }
// Segment is one timed span of a transcription. Times are seconds from the start
// of the audio.
type Segment struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
}
// Transcription is the result of transcribing user-supplied audio (ADR-033):
// the full text, its timed segments, which backend served it, and how long the
// audio was. Endpoint is recorded because the backends differ in timestamp
// granularity — which one served a transcript is a quality fact.
type Transcription struct {
Text string
Segments []Segment
Endpoint string
AudioSeconds float64
}
// UploadStatus is the state of an uploaded-audio transcription job.
type UploadStatus string
const (
UploadQueued UploadStatus = "queued"
UploadTranscribing UploadStatus = "transcribing"
UploadDone UploadStatus = "done"
UploadFailed UploadStatus = "failed"
)
// UploadErrorKind is the typed reason an upload failed. It is deliberately NOT a
// provider's error string: the whisper server's "failed to process audio" has had
// at least five distinct causes, so nothing may branch on it (ADR-033).
type UploadErrorKind string
const (
// UploadErrInterrupted: the process restarted mid-job. The audio is not
// retained, so the job cannot resume; the user re-uploads.
UploadErrInterrupted UploadErrorKind = "interrupted"
// UploadErrTranscriptionFailed: every configured transcription endpoint failed.
UploadErrTranscriptionFailed UploadErrorKind = "transcription_failed"
)
// UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is
// never stored in, or merged with, the shared public-caption Transcript (ADR-021).
// Content and Segments are empty until Status is UploadDone.
type UploadedTranscript struct {
ID string
UserID string
Title string
Status UploadStatus
Content string
Segments []Segment
Endpoint string
AudioSeconds float64
ErrorKind UploadErrorKind
CreatedAt time.Time
FinishedAt time.Time // zero until the job reaches done or failed
}
+115
View File
@@ -0,0 +1,115 @@
package uploads
import (
"context"
"errors"
"fmt"
"io"
"os"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// ErrUnreadable is what a Prober returns when the FILE is the problem (not media,
// truncated, damaged), as opposed to the prober itself failing. Accept turns it
// into a user-facing rejection; any other prober error is our fault and surfaces
// as an internal error — a missing ffprobe must not read as "your file is bad".
var ErrUnreadable = errors.New("uploads: file is not readable media")
// Prober inspects a file on disk. The ffprobe adapter implements it.
type Prober interface {
Probe(ctx context.Context, path string) (Probe, error)
}
// Store is the slice of the private transcript store the intake needs.
type Store interface {
CreateUpload(ctx context.Context, userID, title string) (string, error)
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
}
// Job is one accepted upload awaiting transcription. Path is a temp file the
// receiver owns: it must delete it when finished (the audio is never retained).
type Job struct {
UserID string
UploadID string
Path string
}
// Submitter hands accepted jobs to the in-process worker (ADR-033).
type Submitter interface {
Submit(Job) error
}
// Intake validates an uploaded recording and, if it passes, queues it.
type Intake struct {
store Store
prober Prober
sub Submitter
limits Limits
tmpDir string
}
// NewIntake wires an Intake. tmpDir is where uploads are spooled; it must exist.
func NewIntake(s Store, p Prober, sub Submitter, l Limits, tmpDir string) *Intake {
return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir}
}
// Accept spools src to disk (bounded by MaxBytes), probes it, validates it, records
// a queued upload and submits the job. It returns the upload id. A file that fails
// validation yields a *RejectError and creates NO row — rejection is synchronous
// and leaves nothing behind. On success the temp file is handed to the job and the
// worker deletes it; on every failure path Accept deletes it itself.
func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reader) (string, error) {
f, err := os.CreateTemp(in.tmpDir, "upload-*")
if err != nil {
return "", fmt.Errorf("uploads: spool: %w", err)
}
path := f.Name()
handedOver := false
defer func() {
if !handedOver {
_ = os.Remove(path)
}
}()
// Read one byte past the cap so "exactly at the limit" and "over" are told apart.
n, err := io.Copy(f, io.LimitReader(src, in.limits.MaxBytes+1))
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return "", fmt.Errorf("uploads: receive: %w", err)
}
if n > in.limits.MaxBytes {
return "", &RejectError{Reason: ReasonTooLarge, Message: fmt.Sprintf(
"This file is larger than the %d MB limit.", in.limits.MaxBytes>>20)}
}
probe, err := in.prober.Probe(ctx, path)
if errors.Is(err, ErrUnreadable) {
return "", &RejectError{
Reason: ReasonUnreadable,
Message: "This file could not be read as audio; it may be damaged or not a recording.",
}
}
if err != nil {
return "", fmt.Errorf("uploads: probe: %w", err)
}
if err := Validate(probe, in.limits); err != nil {
return "", err
}
id, err := in.store.CreateUpload(ctx, userID, title)
if err != nil {
return "", fmt.Errorf("uploads: create: %w", err)
}
if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path}); err != nil {
// A row that will never run must not sit queued: that is the stuck spinner.
if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrInterrupted); ferr != nil {
return "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr)
}
return "", fmt.Errorf("uploads: submit: %w", err)
}
handedOver = true
return id, nil
}
+212
View File
@@ -0,0 +1,212 @@
package uploads_test
import (
"bytes"
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"testing/iotest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
type fakeStore struct {
created []string // titles
failed map[string]domain.UploadErrorKind
nextID string
}
func (f *fakeStore) CreateUpload(_ context.Context, _, title string) (string, error) {
f.created = append(f.created, title)
return f.nextID, nil
}
func (f *fakeStore) FailUpload(_ context.Context, _, id string, kind domain.UploadErrorKind) error {
if f.failed == nil {
f.failed = map[string]domain.UploadErrorKind{}
}
f.failed[id] = kind
return nil
}
type fakeProber struct {
probe uploads.Probe
err error
paths []string
seen []byte // content of the file at probe time
}
func (f *fakeProber) Probe(_ context.Context, path string) (uploads.Probe, error) {
f.paths = append(f.paths, path)
f.seen, _ = os.ReadFile(path)
return f.probe, f.err
}
type fakeSubmitter struct {
jobs []uploads.Job
err error
}
func (f *fakeSubmitter) Submit(j uploads.Job) error {
f.jobs = append(f.jobs, j)
return f.err
}
type rig struct {
in *uploads.Intake
st *fakeStore
pr *fakeProber
sub *fakeSubmitter
tmp string
}
func newRig(t *testing.T) *rig {
t.Helper()
r := &rig{
st: &fakeStore{nextID: "up-1"},
pr: &fakeProber{probe: uploads.Probe{Duration: 10 * time.Minute, HasAudio: true}},
sub: &fakeSubmitter{},
tmp: t.TempDir(),
}
r.in = uploads.NewIntake(r.st, r.pr, r.sub, uploads.Limits{MaxBytes: 1024, MaxDuration: 90 * time.Minute}, r.tmp)
return r
}
func (r *rig) tmpFiles(t *testing.T) []string {
t.Helper()
ents, err := os.ReadDir(r.tmp)
require.NoError(t, err)
var names []string
for _, e := range ents {
names = append(names, e.Name())
}
return names
}
func TestAccept_ValidAudioIsStoredProbedQueuedAndSubmitted(t *testing.T) {
r := newRig(t)
audio := bytes.Repeat([]byte("a"), 500)
id, err := r.in.Accept(context.Background(), "user-1", "Teams export", bytes.NewReader(audio))
require.NoError(t, err)
assert.Equal(t, "up-1", id)
assert.Equal(t, []string{"Teams export"}, r.st.created)
assert.Equal(t, audio, r.pr.seen, "the prober must see the complete uploaded bytes, on disk")
require.Len(t, r.sub.jobs, 1)
job := r.sub.jobs[0]
assert.Equal(t, uploads.Job{UserID: "user-1", UploadID: "up-1", Path: job.Path}, job)
assert.Equal(t, filepath.Dir(job.Path), r.tmp, "the temp file lives in the configured directory")
got, err := os.ReadFile(job.Path)
require.NoError(t, err)
assert.Equal(t, audio, got, "the handed-over file still exists: the worker, not Accept, deletes it")
}
func TestAccept_OversizeBodyIsRejectedAndLeavesNothingBehind(t *testing.T) {
r := newRig(t)
_, err := r.in.Accept(context.Background(), "user-1", "big", strings.NewReader(strings.Repeat("a", 1025)))
var rej *uploads.RejectError
require.True(t, errors.As(err, &rej))
assert.Equal(t, uploads.ReasonTooLarge, rej.Reason)
assert.Contains(t, rej.Message, "limit")
assert.Empty(t, r.pr.paths, "a file already over the size cap must not be probed")
assert.Empty(t, r.st.created, "a rejected upload creates no row")
assert.Empty(t, r.sub.jobs)
assert.Empty(t, r.tmpFiles(t), "the partial temp file must be removed")
}
func TestAccept_BodyExactlyAtTheLimitIsAccepted(t *testing.T) {
r := newRig(t)
_, err := r.in.Accept(context.Background(), "user-1", "edge", strings.NewReader(strings.Repeat("a", 1024)))
require.NoError(t, err)
}
func TestAccept_ProbeVerdictRejectsWithoutRowAndRemovesTheFile(t *testing.T) {
tests := []struct {
name string
probe uploads.Probe
reason uploads.Reason
}{
{"video", uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true}, uploads.ReasonVideo},
{"too long", uploads.Probe{Duration: 2 * time.Hour, HasAudio: true}, uploads.ReasonTooLong},
{"no audio", uploads.Probe{Duration: time.Minute}, uploads.ReasonNoAudio},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := newRig(t)
r.pr.probe = tt.probe
_, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
var rej *uploads.RejectError
require.True(t, errors.As(err, &rej))
assert.Equal(t, tt.reason, rej.Reason)
assert.Empty(t, r.st.created)
assert.Empty(t, r.sub.jobs)
assert.Empty(t, r.tmpFiles(t))
})
}
}
func TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError(t *testing.T) {
t.Run("ffprobe says the file is not media", func(t *testing.T) {
r := newRig(t)
r.pr.err = uploads.ErrUnreadable
_, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("not media"))
var rej *uploads.RejectError
require.True(t, errors.As(err, &rej), "the user's file is the problem: tell them")
assert.Equal(t, uploads.ReasonUnreadable, rej.Reason)
assert.Empty(t, r.tmpFiles(t))
})
t.Run("the prober itself broke", func(t *testing.T) {
r := newRig(t)
r.pr.err = errors.New("exec: ffprobe: executable file not found")
_, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
require.Error(t, err)
var rej *uploads.RejectError
assert.False(t, errors.As(err, &rej), "our fault must not be reported as the user's bad file")
assert.Empty(t, r.st.created)
assert.Empty(t, r.tmpFiles(t))
})
}
func TestAccept_ClientDisconnectMidUploadLeavesNothingBehind(t *testing.T) {
r := newRig(t)
body := io.MultiReader(strings.NewReader("partial"), iotest.ErrReader(errors.New("client went away")))
_, err := r.in.Accept(context.Background(), "user-1", "t", body)
require.Error(t, err)
assert.Empty(t, r.pr.paths)
assert.Empty(t, r.st.created)
assert.Empty(t, r.tmpFiles(t))
}
func TestAccept_SubmitFailureFailsTheRowHonestlyAndRemovesTheFile(t *testing.T) {
r := newRig(t)
r.sub.err = errors.New("worker stopped")
_, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
require.Error(t, err)
assert.Equal(t, domain.UploadErrInterrupted, r.st.failed["up-1"],
"a row that will never run must not sit queued: that is the stuck spinner ADR-025 forbids")
assert.Empty(t, r.tmpFiles(t))
}
+82
View File
@@ -0,0 +1,82 @@
// Package uploads is the use case behind "upload an audio recording" (ADR-033):
// validate the file by PROBING it, keep the transcript private, never retain the
// audio. It depends only on ports (a Prober, a store, a job Submitter) — the
// concrete ffprobe adapter, the web handler and the transcription worker are wired
// from outside.
package uploads
import (
"fmt"
"time"
)
// Limits bound what an upload may be. Both are rejected synchronously, with a
// specific message, rather than by a timeout (ADR-033).
type Limits struct {
MaxBytes int64
MaxDuration time.Duration
}
// Probe is what inspecting an uploaded file revealed. It is the input to Validate
// because a name or MIME type cannot say whether a container carries video: a
// Teams voice-only export is typically an .mp4 with no video stream.
type Probe struct {
Duration time.Duration
HasAudio bool
HasVideo bool
}
// Reason is the machine-checkable class of a rejection.
type Reason string
const (
ReasonVideo Reason = "video"
ReasonNoAudio Reason = "no_audio"
ReasonTooLong Reason = "too_long"
ReasonTooLarge Reason = "too_large"
ReasonUnreadable Reason = "unreadable"
)
// RejectError is a synchronous, user-presentable refusal. Message is written for
// the person who uploaded the file; Reason is for code and tests.
type RejectError struct {
Reason Reason
Message string
}
func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message }
// Validate decides whether a probed file may be transcribed. Video is checked
// first so a video file is always refused as video, whatever its length.
func Validate(p Probe, l Limits) error {
switch {
case p.HasVideo:
return &RejectError{Reason: ReasonVideo, Message: "This file contains a video track. " +
"Only audio recordings are supported for now; video upload is tracked in issue #28."}
case !p.HasAudio:
return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."}
case p.Duration <= 0:
return &RejectError{Reason: ReasonUnreadable, Message: "Could not read a length from this file; it may be damaged."}
case p.Duration > l.MaxDuration:
return &RejectError{Reason: ReasonTooLong, Message: fmt.Sprintf(
"This recording is %s long; the limit is %s.", FormatDuration(p.Duration), FormatDuration(l.MaxDuration))}
}
return nil
}
// FormatDuration renders a length for people: "1 h 42 min", "45 min", "30 s".
func FormatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := int(d / time.Hour)
m := int(d % time.Hour / time.Minute)
switch {
case h > 0 && m > 0:
return fmt.Sprintf("%d h %d min", h, m)
case h > 0:
return fmt.Sprintf("%d h", h)
case m > 0:
return fmt.Sprintf("%d min", m)
default:
return fmt.Sprintf("%d s", int(d/time.Second))
}
}
+95
View File
@@ -0,0 +1,95 @@
package uploads_test
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
var testLimits = uploads.Limits{MaxBytes: 200 << 20, MaxDuration: 90 * time.Minute}
func TestValidate(t *testing.T) {
tests := []struct {
name string
probe uploads.Probe
wantReason uploads.Reason // "" = accepted
wantInMsg []string
}{
{
name: "audio-only file within the duration limit is accepted",
probe: uploads.Probe{Duration: 45 * time.Minute, HasAudio: true},
},
{
name: "a recording exactly at the limit is accepted",
probe: uploads.Probe{Duration: 90 * time.Minute, HasAudio: true},
},
{
name: "a file with a video stream is rejected and names issue 28",
probe: uploads.Probe{Duration: 4 * time.Minute, HasAudio: true, HasVideo: true},
wantReason: uploads.ReasonVideo,
wantInMsg: []string{"video", "#28"},
},
{
name: "video with no audio is still rejected as video, not as missing audio",
probe: uploads.Probe{Duration: 4 * time.Minute, HasVideo: true},
wantReason: uploads.ReasonVideo,
wantInMsg: []string{"#28"},
},
{
name: "a file with no audio stream is rejected",
probe: uploads.Probe{Duration: 4 * time.Minute},
wantReason: uploads.ReasonNoAudio,
wantInMsg: []string{"no audio"},
},
{
name: "a recording over the limit is rejected, stating both lengths",
probe: uploads.Probe{Duration: 102 * time.Minute, HasAudio: true},
wantReason: uploads.ReasonTooLong,
wantInMsg: []string{"1 h 42 min", "1 h 30 min"},
},
{
name: "zero duration is unreadable, not accepted as an empty recording",
probe: uploads.Probe{HasAudio: true},
wantReason: uploads.ReasonUnreadable,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := uploads.Validate(tt.probe, testLimits)
if tt.wantReason == "" {
require.NoError(t, err)
return
}
var rej *uploads.RejectError
require.True(t, errors.As(err, &rej), "want a *RejectError, got %v", err)
assert.Equal(t, tt.wantReason, rej.Reason)
for _, s := range tt.wantInMsg {
assert.Contains(t, rej.Message, s)
}
})
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{90 * time.Minute, "1 h 30 min"},
{102 * time.Minute, "1 h 42 min"},
{45 * time.Minute, "45 min"},
{2 * time.Hour, "2 h"},
{30 * time.Second, "30 s"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
assert.Equal(t, tt.want, uploads.FormatDuration(tt.d))
})
}
}
+191
View File
@@ -0,0 +1,191 @@
package uploads
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"sync"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// ErrStopped is returned by Submit once the worker has stopped.
var ErrStopped = errors.New("uploads: worker stopped")
// Converter turns the uploaded file into the 16 kHz mono PCM WAV whisper wants.
type Converter interface {
ToWAV(ctx context.Context, src, dst string) error
}
// Transcriber turns a WAV into a timed transcription. The gateway chain
// (iguana primary, berget fallback) implements it.
type Transcriber interface {
Transcribe(ctx context.Context, wavPath string) (domain.Transcription, error)
}
// WorkerStore is the slice of the private transcript store the worker drives.
type WorkerStore interface {
StartTranscribing(ctx context.Context, userID, id string) error
CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
}
// Worker transcribes accepted uploads in-process, ONE AT A TIME (ADR-033). It is
// not a second deployable: it shares the web process's lifetime, which is why it
// inherits ADR-018's single-replica assumption — the queue is process-local. The
// concurrency of one is deliberate: two simultaneous 90-minute jobs on iguana were
// never measured, and a Metal OOM there latches the whisper backend (infra#273).
type Worker struct {
store WorkerStore
conv Converter
tr Transcriber
tmpDir string
timeout time.Duration
mu sync.Mutex
queue []Job
stopped bool
wake chan struct{}
}
// NewWorker wires a Worker. timeout bounds each job (conversion + transcription);
// a job that outlives it is failed rather than left spinning.
func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout time.Duration) *Worker {
return &Worker{store: s, conv: c, tr: t, tmpDir: tmpDir, timeout: timeout, wake: make(chan struct{}, 1)}
}
// Submit queues a job. It never blocks. It satisfies Submitter.
func (w *Worker) Submit(j Job) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.stopped {
return ErrStopped
}
w.queue = append(w.queue, j)
select {
case w.wake <- struct{}{}:
default:
}
return nil
}
// Run processes queued jobs until ctx ends, then deletes the audio of anything
// still queued and refuses new jobs. The rows of those jobs stay queued or
// transcribing and are failed as interrupted by the start-up sweep
// (store.FailInterruptedUploads): the audio is not retained, so they cannot resume.
func (w *Worker) Run(ctx context.Context) {
defer w.stop()
for {
job, ok := w.next(ctx)
if !ok {
return
}
w.Process(ctx, job)
}
}
// next blocks for the next job. Cancellation wins over a non-empty queue: a job
// still queued at shutdown must not be STARTED (it would begin a long conversion
// with an already-cancelled context); stop() deletes its audio instead.
func (w *Worker) next(ctx context.Context) (Job, bool) {
for {
if ctx.Err() != nil {
return Job{}, false
}
w.mu.Lock()
if len(w.queue) > 0 {
j := w.queue[0]
w.queue = w.queue[1:]
w.mu.Unlock()
return j, true
}
w.mu.Unlock()
select {
case <-w.wake:
case <-ctx.Done():
return Job{}, false
}
}
}
func (w *Worker) stop() {
w.mu.Lock()
defer w.mu.Unlock()
w.stopped = true
for _, j := range w.queue {
_ = os.Remove(j.Path)
}
w.queue = nil
}
// Process runs one job: row -> transcribing, convert, transcribe, store. The
// uploaded file and the converted WAV are deleted on every path — the audio is
// never retained (ADR-033). A failure is recorded with a TYPED kind; a shutdown
// mid-job is not a failure and leaves the row for the start-up sweep.
func (w *Worker) Process(ctx context.Context, job Job) {
defer func() { _ = os.Remove(job.Path) }()
log := slog.Default().With("upload", job.UploadID)
start := time.Now()
if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
log.Error("upload: start transcribing", "err", err)
w.fail(ctx, job, log)
return
}
wav, err := w.tempWAV()
if err != nil {
log.Error("upload: temp wav", "err", err)
w.fail(ctx, job, log)
return
}
defer func() { _ = os.Remove(wav) }()
jobCtx, cancel := context.WithTimeout(ctx, w.timeout)
defer cancel()
if err := w.conv.ToWAV(jobCtx, job.Path, wav); err != nil {
log.Error("upload: convert", "err", err)
w.fail(ctx, job, log)
return
}
t, err := w.tr.Transcribe(jobCtx, wav)
if err != nil {
log.Error("upload: transcribe", "err", err, "elapsed", time.Since(start).Round(time.Second))
w.fail(ctx, job, log)
return
}
if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
log.Error("upload: store transcript", "err", err)
w.fail(ctx, job, log)
return
}
log.Info("upload transcribed", "endpoint", t.Endpoint, "audio_s", t.AudioSeconds,
"segments", len(t.Segments), "elapsed", time.Since(start).Round(time.Second))
}
// fail records a transcription failure — unless the PARENT context ended, which
// is a shutdown, not a failure: the row is left for the start-up sweep.
func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger) {
if ctx.Err() != nil {
return
}
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, domain.UploadErrTranscriptionFailed); err != nil {
log.Error("upload: record failure", "err", err)
}
}
func (w *Worker) tempWAV() (string, error) {
f, err := os.CreateTemp(w.tmpDir, "wav-*")
if err != nil {
return "", fmt.Errorf("uploads: temp wav: %w", err)
}
name := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(name)
return "", fmt.Errorf("uploads: temp wav: %w", err)
}
return name, nil
}
+282
View File
@@ -0,0 +1,282 @@
package uploads_test
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
type wStore struct {
mu sync.Mutex
started []string
completed map[string]domain.Transcription
failed map[string]domain.UploadErrorKind
startErr error
}
func newWStore() *wStore {
return &wStore{completed: map[string]domain.Transcription{}, failed: map[string]domain.UploadErrorKind{}}
}
func (s *wStore) StartTranscribing(_ context.Context, _, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.started = append(s.started, id)
return s.startErr
}
func (s *wStore) CompleteUpload(_ context.Context, _, id string, t domain.Transcription) error {
s.mu.Lock()
defer s.mu.Unlock()
s.completed[id] = t
return nil
}
func (s *wStore) FailUpload(_ context.Context, _, id string, k domain.UploadErrorKind) error {
s.mu.Lock()
defer s.mu.Unlock()
s.failed[id] = k
return nil
}
func (s *wStore) snapshot() (map[string]domain.Transcription, map[string]domain.UploadErrorKind) {
s.mu.Lock()
defer s.mu.Unlock()
c := map[string]domain.Transcription{}
f := map[string]domain.UploadErrorKind{}
for k, v := range s.completed {
c[k] = v
}
for k, v := range s.failed {
f[k] = v
}
return c, f
}
type fakeConverter struct {
err error
srcs []string
dsts []string
}
func (c *fakeConverter) ToWAV(_ context.Context, src, dst string) error {
c.srcs = append(c.srcs, src)
c.dsts = append(c.dsts, dst)
if c.err != nil {
return c.err
}
return os.WriteFile(dst, []byte("wav"), 0o600)
}
type fakeTranscriber struct {
result domain.Transcription
err error
paths []string
// block, when non-nil, holds every call until it is closed or ctx ends.
block chan struct{}
running atomic.Int32
maxSeen atomic.Int32
}
func (f *fakeTranscriber) Transcribe(ctx context.Context, wav string) (domain.Transcription, error) {
f.paths = append(f.paths, wav)
n := f.running.Add(1)
defer f.running.Add(-1)
for {
m := f.maxSeen.Load()
if n <= m || f.maxSeen.CompareAndSwap(m, n) {
break
}
}
if f.block != nil {
select {
case <-f.block:
case <-ctx.Done():
return domain.Transcription{}, ctx.Err()
}
}
return f.result, f.err
}
type wrig struct {
w *uploads.Worker
st *wStore
conv *fakeConverter
tr *fakeTranscriber
tmp string
}
func newWRig(t *testing.T, timeout time.Duration) *wrig {
t.Helper()
r := &wrig{
st: newWStore(),
conv: &fakeConverter{},
tr: &fakeTranscriber{result: domain.Transcription{Text: "synthetic text", Endpoint: "iguana/kb-whisper", AudioSeconds: 3}},
tmp: t.TempDir(),
}
r.w = uploads.NewWorker(r.st, r.conv, r.tr, r.tmp, timeout)
return r
}
// spool writes a fake uploaded file (what Intake would hand over) and returns a Job.
func (r *wrig) spool(t *testing.T, id string) uploads.Job {
t.Helper()
p := filepath.Join(r.tmp, "upload-"+id)
require.NoError(t, os.WriteFile(p, []byte("audio"), 0o600))
return uploads.Job{UserID: "u1", UploadID: id, Path: p}
}
func (r *wrig) leftovers(t *testing.T) []string {
t.Helper()
ents, err := os.ReadDir(r.tmp)
require.NoError(t, err)
var n []string
for _, e := range ents {
n = append(n, e.Name())
}
return n
}
func TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio(t *testing.T) {
r := newWRig(t, time.Minute)
job := r.spool(t, "a")
r.w.Process(context.Background(), job)
c, f := r.st.snapshot()
assert.Equal(t, []string{"a"}, r.st.started)
assert.Equal(t, "synthetic text", c["a"].Text)
assert.Equal(t, "iguana/kb-whisper", c["a"].Endpoint)
assert.Empty(t, f)
require.Len(t, r.conv.srcs, 1)
assert.Equal(t, job.Path, r.conv.srcs[0], "the converter reads the uploaded file")
assert.Equal(t, r.conv.dsts, r.tr.paths, "whisper is sent the converted WAV, not the original")
assert.Equal(t, r.tmp, filepath.Dir(r.conv.dsts[0]))
assert.Empty(t, r.leftovers(t), "the audio is never retained: both the upload and the WAV are deleted")
}
func TestWorkerProcess_FailuresAreTypedAndStillDeleteTheAudio(t *testing.T) {
tests := []struct {
name string
setup func(*wrig)
}{
{"conversion fails", func(r *wrig) { r.conv.err = errors.New("moov atom not found") }},
{"every transcription endpoint fails", func(r *wrig) { r.tr.err = errors.New("iguana down; berget down") }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := newWRig(t, time.Minute)
tt.setup(r)
job := r.spool(t, "a")
r.w.Process(context.Background(), job)
c, f := r.st.snapshot()
assert.Empty(t, c)
assert.Equal(t, domain.UploadErrTranscriptionFailed, f["a"])
assert.Empty(t, r.leftovers(t))
})
}
t.Run("conversion failure never reaches whisper", func(t *testing.T) {
r := newWRig(t, time.Minute)
r.conv.err = errors.New("bad")
r.w.Process(context.Background(), r.spool(t, "a"))
assert.Empty(t, r.tr.paths)
})
}
func TestWorkerProcess_ShutdownMidJobLeavesTheRowForTheStartupSweep(t *testing.T) {
r := newWRig(t, time.Minute)
r.tr.block = make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
job := r.spool(t, "a")
go func() { r.w.Process(ctx, job); close(done) }()
require.Eventually(t, func() bool { return r.tr.running.Load() == 1 }, 2*time.Second, 5*time.Millisecond)
cancel()
<-done
_, f := r.st.snapshot()
assert.Empty(t, f, "a shutdown is not a transcription failure: the row stays for FailInterruptedUploads to mark interrupted")
assert.Empty(t, r.leftovers(t), "but the audio is still deleted")
}
func TestWorkerProcess_AJobThatOutlivesItsTimeoutIsFailedNotStuck(t *testing.T) {
r := newWRig(t, 50*time.Millisecond)
r.tr.block = make(chan struct{}) // never released
done := make(chan struct{})
go func() { r.w.Process(context.Background(), r.spool(t, "a")); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Process did not return: a hung endpoint was not bounded by the job timeout")
}
_, f := r.st.snapshot()
assert.Equal(t, domain.UploadErrTranscriptionFailed, f["a"], "a hung endpoint must end in failed, not an eternal spinner (ADR-025)")
assert.Empty(t, r.leftovers(t))
}
func TestWorkerProcess_StartFailureDropsTheJobAndDeletesTheAudio(t *testing.T) {
r := newWRig(t, time.Minute)
r.st.startErr = errors.New("db down")
r.w.Process(context.Background(), r.spool(t, "a"))
assert.Empty(t, r.tr.paths, "never transcribe a job whose row could not be moved to transcribing")
_, f := r.st.snapshot()
assert.Contains(t, f, "a")
assert.Empty(t, r.leftovers(t))
}
func TestWorkerRun_ProcessesOneJobAtATime(t *testing.T) {
r := newWRig(t, time.Minute)
r.tr.block = make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go r.w.Run(ctx)
for _, id := range []string{"a", "b", "c"} {
require.NoError(t, r.w.Submit(r.spool(t, id)))
}
require.Eventually(t, func() bool { return r.tr.running.Load() == 1 }, 2*time.Second, 5*time.Millisecond)
time.Sleep(50 * time.Millisecond) // give a (wrong) second job time to start
assert.Equal(t, int32(1), r.tr.running.Load(), "two simultaneous long jobs on iguana were never measured: concurrency is 1")
close(r.tr.block)
require.Eventually(t, func() bool { c, _ := r.st.snapshot(); return len(c) == 3 }, 3*time.Second, 5*time.Millisecond)
assert.Equal(t, int32(1), r.tr.maxSeen.Load())
}
func TestWorkerRun_StoppingDeletesQueuedAudioAndRefusesNewJobs(t *testing.T) {
r := newWRig(t, time.Minute)
r.tr.block = make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
stopped := make(chan struct{})
go func() { r.w.Run(ctx); close(stopped) }()
require.NoError(t, r.w.Submit(r.spool(t, "a")))
require.Eventually(t, func() bool { return r.tr.running.Load() == 1 }, 2*time.Second, 5*time.Millisecond)
require.NoError(t, r.w.Submit(r.spool(t, "b"))) // queued behind the running job
cancel()
<-stopped
assert.Len(t, r.conv.srcs, 1, "a job still queued at shutdown must never be started: only the running job was converted")
assert.Empty(t, r.leftovers(t), "audio of queued jobs is deleted on stop: the rows are swept to interrupted at next start")
assert.ErrorIs(t, r.w.Submit(r.spool(t, "c")), uploads.ErrStopped)
}
+21
View File
@@ -13,6 +13,7 @@ import (
"git.d-ma.be/mathias/tapir/internal/adapters/store" "git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
) )
// Store is the read/write surface the web handlers depend on — a narrow port over // Store is the read/write surface the web handlers depend on — a narrow port over
@@ -53,6 +54,12 @@ type Store interface {
// the most recent discovery pass, shown on the account page as a warning. // the most recent discovery pass, shown on the account page as a warning.
ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error) ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error)
// Uploaded-audio transcripts (ADR-033): the caller's own PRIVATE uploads,
// stored apart from the shared public captions. Both are user-scoped; a
// non-owner's id is ErrUploadNotFound.
GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error)
ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error)
// SetTranscriptStatus clears or updates a video's transcript backoff state. // SetTranscriptStatus clears or updates a video's transcript backoff state.
// Used by handleRetryNow to clear rate_limited_at before immediate processing. // Used by handleRetryNow to clear rate_limited_at before immediate processing.
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
@@ -100,6 +107,12 @@ type App struct {
// summary view shows no "dig deeper" affordance. It holds no caption-fetch // summary view shows no "dig deeper" affordance. It holds no caption-fetch
// dependency, so an enabled chat cannot reach YouTube or the rate gate. // dependency, so an enabled chat cannot reach YouTube or the rate gate.
Chat Chatter Chat Chatter
// Uploads, when non-nil, accepts audio recordings for private transcription
// (ADR-033). Nil = the /uploads routes are not mounted. UploadLimits are the
// same limits the Uploader enforces, shown on the form so they are stated up
// front rather than discovered by failing.
Uploads Uploader
UploadLimits uploads.Limits
// Processing tracks in-flight immediate summarizations so the status endpoint // Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use. // shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet Processing ProcessingSet
@@ -166,6 +179,14 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /v/{videoId}/chat", a.handleChat) app.HandleFunc("GET /v/{videoId}/chat", a.handleChat)
app.HandleFunc("POST /v/{videoId}/chat", a.handleChatMessage) app.HandleFunc("POST /v/{videoId}/chat", a.handleChatMessage)
} }
// Audio upload → private transcript (ADR-033). Mounted only when an Uploader is
// wired; every handler is user-scoped, so another user's upload is a 404.
if a.Uploads != nil {
app.HandleFunc("GET /uploads", a.handleUploadsPage)
app.HandleFunc("POST /uploads", a.handleUploadPost)
app.HandleFunc("GET /uploads/{id}", a.handleUploadDetail)
app.HandleFunc("GET /uploads/{id}/status", a.handleUploadStatus)
}
app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister) app.HandleFunc("POST /register", a.handleRegister)
+2 -1
View File
@@ -3,9 +3,10 @@ package web
import ( import (
"bytes" "bytes"
"context" "context"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"strings" "strings"
"testing" "testing"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
) )
func TestParseYouTubeVideoID(t *testing.T) { func TestParseYouTubeVideoID(t *testing.T) {
+191
View File
@@ -0,0 +1,191 @@
package web
import (
"context"
"errors"
"io"
"net/http"
"path/filepath"
"strings"
"unicode/utf8"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// Uploader accepts an uploaded recording for transcription (ADR-033).
// *uploads.Intake satisfies it. It is the whole of what the handlers know about
// validation, spooling and queueing.
type Uploader interface {
Accept(ctx context.Context, userID, title string, src io.Reader) (id string, err error)
}
const (
// uploadOverhead is headroom above the file-size cap for the multipart framing
// and the title field; the cap itself is enforced on the file part by Intake.
uploadOverhead = 1 << 20
maxTitleRunes = 200
// uploadPollEvery is how often an unfinished upload re-asks for its status.
uploadPollEvery = "5s"
)
// uploadsView is what the /uploads page renders.
type uploadsView struct {
Limits uploads.Limits
Rows []domain.UploadedTranscript
Error string // a synchronous rejection message, empty when none
}
// handleUploadsPage renders the upload form and the caller's own uploads.
func (a *App) handleUploadsPage(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
a.renderUploadsPage(w, r, userID, http.StatusOK, "")
}
func (a *App) renderUploadsPage(w http.ResponseWriter, r *http.Request, userID string, status int, msg string) {
rows, err := a.Store.ListUploads(r.Context(), userID)
if err != nil {
a.serverError(w, r, "list uploads", err)
return
}
a.renderStatus(w, r, status, UploadsPage(uploadsView{Limits: a.UploadLimits, Rows: rows, Error: msg}))
}
// handleUploadPost streams the multipart body: the optional title field, then the
// file part, which goes straight to Intake without being buffered in memory. A
// rejection is synchronous — a status code and a message on the upload page, no
// row, no timeout (ADR-033).
func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, a.UploadLimits.MaxBytes+uploadOverhead)
mr, err := r.MultipartReader()
if err != nil {
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
return
}
title := ""
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
a.uploadFailure(w, r, userID, err)
return
}
switch part.FormName() {
case "title":
b, _ := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
title = clipRunes(strings.TrimSpace(string(b)), maxTitleRunes)
case "file":
if title == "" {
title = titleFromFilename(part.FileName())
}
id, err := a.Uploads.Accept(r.Context(), userID, title, part)
if err != nil {
a.uploadFailure(w, r, userID, err)
return
}
http.Redirect(w, r, "/uploads/"+id, http.StatusSeeOther)
return
}
}
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
}
// uploadFailure maps an Accept (or multipart) error to a response: a validation
// rejection is the user's to fix (422, or 413 for size) and carries its own
// message; anything else is ours.
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
var rej *uploads.RejectError
var tooBig *http.MaxBytesError
switch {
case errors.As(err, &rej):
status := http.StatusUnprocessableEntity
if rej.Reason == uploads.ReasonTooLarge {
status = http.StatusRequestEntityTooLarge
}
a.renderUploadsPage(w, r, userID, status, rej.Message)
case errors.As(err, &tooBig):
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
"This file is larger than the upload limit.")
default:
a.serverError(w, r, "upload", err)
}
}
// handleUploadDetail renders one upload's page (GET /uploads/{id}). The row is
// read through the user-scoped store, so another user's id is a 404 — existence is
// not disclosed, and the title never appears in the response.
func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
u, ok := a.loadOwnedUpload(w, r)
if !ok {
return
}
a.render(w, r, UploadPage(*u))
}
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
// keep polling; terminal states render without hx-trigger so the page stops
// asking (ADR-025: never a stuck spinner).
func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
u, ok := a.loadOwnedUpload(w, r)
if !ok {
return
}
a.render(w, r, uploadStatus(*u))
}
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
userID, ok := a.currentUserID(w, r)
if !ok {
return nil, false
}
u, err := a.Store.GetUpload(r.Context(), userID, r.PathValue("id"))
if errors.Is(err, store.ErrUploadNotFound) {
http.NotFound(w, r)
return nil, false
}
if err != nil {
a.serverError(w, r, "get upload", err)
return nil, false
}
return u, true
}
// titleFromFilename is the default title: the file's name without its extension.
func titleFromFilename(name string) string {
base := filepath.Base(name)
t := strings.TrimSuffix(base, filepath.Ext(base))
if t == "" || t == "." {
return "Untitled recording"
}
return clipRunes(t, maxTitleRunes)
}
func clipRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n])
}
// uploadFailureMessage turns a typed failure kind into words for the user. It is
// keyed on OUR kinds, never on a provider's error string (ADR-033).
func uploadFailureMessage(k domain.UploadErrorKind) string {
switch k {
case domain.UploadErrInterrupted:
return "The server restarted before this recording was transcribed. Please upload it again."
case domain.UploadErrTranscriptionFailed:
return "Transcription failed on every available service. Please upload it again later."
default:
return "This upload failed. Please upload it again."
}
}
+91
View File
@@ -0,0 +1,91 @@
package web
import (
"fmt"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// UploadsPage is the upload form plus the caller's own uploads (ADR-033).
templ UploadsPage(v uploadsView) {
@Layout("Tapir — Upload a recording") {
<article class="account">
<h1>Upload a recording</h1>
<p class="muted">
Audio only. Up to { fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20) } and
{ uploads.FormatDuration(v.Limits.MaxDuration) }. The transcript is private to you;
the audio is deleted once it has been transcribed.
</p>
if v.Error != "" {
<div class="flash flash-error" role="alert">{ v.Error }</div>
}
<form method="post" action="/uploads" enctype="multipart/form-data">
<p><label>Title (optional) <input type="text" name="title" maxlength="200"/></label></p>
<p><input type="file" name="file" accept="audio/*,.m4a,.mp3,.wav,.opus,.ogg,.mp4" required/></p>
<p><button type="submit">Upload and transcribe</button></p>
</form>
if len(v.Rows) > 0 {
<section>
<h2>Your recordings</h2>
<ul>
for _, u := range v.Rows {
<li>
<a href={ templ.SafeURL("/uploads/" + u.ID) }>{ u.Title }</a>
<span class="chip">{ string(u.Status) }</span>
</li>
}
</ul>
</section>
}
</article>
}
}
// UploadPage is one upload's page: its title and the polling status region.
templ UploadPage(u domain.UploadedTranscript) {
@Layout("Tapir — " + u.Title) {
<article class="account">
<h1>{ u.Title }</h1>
@uploadStatus(u)
<p><a href="/uploads">All recordings</a></p>
</article>
}
}
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
// page re-asks; done and failed render without it, so nothing polls forever.
templ uploadStatus(u domain.UploadedTranscript) {
switch u.Status {
case domain.UploadDone:
<div class="upload-status upload-done" role="status">
<p>
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
of audio by <span class="chip">{ u.Endpoint }</span>.
</p>
<details open>
<summary>Transcript</summary>
<pre class="transcript">{ u.Content }</pre>
</details>
</div>
case domain.UploadFailed:
<div class="upload-status upload-failed flash flash-error" role="alert">
<p>{ uploadFailureMessage(u.ErrorKind) }</p>
</div>
default:
<div
class="upload-status upload-working"
role="status"
hx-get={ "/uploads/" + u.ID + "/status" }
hx-trigger={ "every " + uploadPollEvery }
hx-swap="outerHTML"
>
if u.Status == domain.UploadTranscribing {
<p>Transcribing… a long recording takes a few minutes.</p>
} else {
<p>Queued — waiting for its turn to be transcribed.</p>
}
</div>
}
}
+277
View File
@@ -0,0 +1,277 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/uploads"
"git.d-ma.be/mathias/tapir/internal/web"
)
// Upload route tests drive the real Router, the real Intake and the real Postgres
// store; only the process boundary (ffprobe) and the transcription worker are
// faked. All audio bytes and transcript text are synthetic — this repo is public
// and no real recording or transcript may appear in it (#33).
const otherUserID = "22222222-2222-2222-2222-222222222222"
type uploadProber struct{ probe uploads.Probe }
func (p uploadProber) Probe(context.Context, string) (uploads.Probe, error) { return p.probe, nil }
type uploadSubmitter struct{ jobs []uploads.Job }
func (s *uploadSubmitter) Submit(j uploads.Job) error { s.jobs = append(s.jobs, j); return nil }
func newUploadApp(t *testing.T, probe uploads.Probe, maxBytes int64) (*web.App, *uploadSubmitter) {
t.Helper()
app := newApp(t)
resetDB(t, rawPool(t))
sub := &uploadSubmitter{}
limits := uploads.Limits{MaxBytes: maxBytes, MaxDuration: 90 * time.Minute}
s := newStore(t)
app.Uploads = uploads.NewIntake(s, uploadProber{probe: probe}, sub, limits, t.TempDir())
app.UploadLimits = limits
return app, sub
}
var audioOK = uploads.Probe{Duration: 45 * time.Minute, HasAudio: true}
func uploadReq(t *testing.T, title, filename string, content []byte) *http.Request {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if title != "" {
require.NoError(t, mw.WriteField("title", title))
}
if filename != "" {
fw, err := mw.CreateFormFile("file", filename)
require.NoError(t, err)
_, err = fw.Write(content)
require.NoError(t, err)
}
require.NoError(t, mw.Close())
req := httptest.NewRequest(http.MethodPost, "/uploads", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
return req
}
func seedUpload(t *testing.T, owner, title, status, content string) string {
t.Helper()
p := rawPool(t)
_, err := p.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING`, owner)
require.NoError(t, err)
var id string
switch status {
case "done":
require.NoError(t, p.QueryRow(context.Background(),
`INSERT INTO uploaded_transcripts (user_id, title, status, content, endpoint, audio_seconds, finished_at)
VALUES ($1, $2, 'done', $3, 'berget/kb-whisper', 2700, NOW()) RETURNING id`,
owner, title, content).Scan(&id))
case "failed":
require.NoError(t, p.QueryRow(context.Background(),
`INSERT INTO uploaded_transcripts (user_id, title, status, error_kind, finished_at)
VALUES ($1, $2, 'failed', $3, NOW()) RETURNING id`,
owner, title, content).Scan(&id)) // content carries the error_kind here
default:
require.NoError(t, p.QueryRow(context.Background(),
`INSERT INTO uploaded_transcripts (user_id, title, status) VALUES ($1, $2, $3) RETURNING id`,
owner, title, status).Scan(&id))
}
return id
}
func get(path string) *http.Request { return httptest.NewRequest(http.MethodGet, path, nil) }
func TestUploadsPage_ShowsAMultipartFormWithAFileField(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, get("/uploads"))
require.Equal(t, http.StatusOK, rec.Code)
b := body(t, rec)
assert.Contains(t, b, `enctype="multipart/form-data"`)
assert.Contains(t, b, `name="file"`, "the multipart field is `file`")
assert.Contains(t, b, "200 MB", "the size limit is stated up front, not discovered by failing")
assert.Contains(t, b, "1 h 30 min", "the length limit is stated up front")
}
func TestUploadsPage_ListsOnlyTheCallersUploads(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
seedUpload(t, userID, "My weekly sync", "queued", "")
seedUpload(t, otherUserID, "Someone else's board meeting", "queued", "")
b := body(t, do(t, app, get("/uploads")))
assert.Contains(t, b, "My weekly sync")
assert.NotContains(t, b, "Someone else's board meeting")
}
func TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, "Sprint review", "recording.m4a", []byte("synthetic audio bytes")))
require.Equal(t, http.StatusSeeOther, rec.Code)
var id, status, title string
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT id, status, title FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&id, &status, &title))
assert.Equal(t, "queued", status)
assert.Equal(t, "Sprint review", title)
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
require.Len(t, sub.jobs, 1)
assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path}, sub.jobs[0])
spooled, err := os.ReadFile(sub.jobs[0].Path)
require.NoError(t, err)
assert.Equal(t, "synthetic audio bytes", string(spooled))
}
func TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, "", "Teams meeting 2026-09-30.mp4", []byte("x")))
require.Equal(t, http.StatusSeeOther, rec.Code)
var title string
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT title FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&title))
assert.Equal(t, "Teams meeting 2026-09-30", title)
}
func TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow(t *testing.T) {
tests := []struct {
name string
probe uploads.Probe
maxBytes int64
content string
wantStatus int
wantInBody []string
}{
{
name: "video names issue 28",
probe: uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true},
maxBytes: 1 << 20,
content: "x",
wantStatus: http.StatusUnprocessableEntity,
wantInBody: []string{"video", "#28"},
},
{
name: "too long states both lengths",
probe: uploads.Probe{Duration: 2 * time.Hour, HasAudio: true},
maxBytes: 1 << 20,
content: "x",
wantStatus: http.StatusUnprocessableEntity,
wantInBody: []string{"2 h", "1 h 30 min"},
},
{
name: "too large is 413",
probe: audioOK,
maxBytes: 4,
content: "more than four bytes",
wantStatus: http.StatusRequestEntityTooLarge,
wantInBody: []string{"limit"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app, sub := newUploadApp(t, tt.probe, tt.maxBytes)
rec := do(t, app, uploadReq(t, "t", "r.m4a", []byte(tt.content)))
assert.Equal(t, tt.wantStatus, rec.Code)
b := body(t, rec)
for _, s := range tt.wantInBody {
assert.Contains(t, b, s)
}
var n int
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT count(*) FROM uploaded_transcripts`).Scan(&n))
assert.Zero(t, n, "a rejected upload leaves no row")
assert.Empty(t, sub.jobs)
})
}
}
func TestUploadPost_WithNoFilePartIs400(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, "only a title", "", nil))
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, body(t, rec), "Choose an audio file")
}
func TestUploadRoutes_AreNotMountedWithoutAnUploader(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
assert.Equal(t, http.StatusNotFound, do(t, app, get("/uploads")).Code)
}
func TestUploadPage_OwnerSeesTitleStatusAndNonOwnerGetsNotFound(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
mine := seedUpload(t, userID, "Roadmap sync", "queued", "")
theirs := seedUpload(t, otherUserID, "Their private meeting", "queued", "")
own := do(t, app, get("/uploads/"+mine))
require.Equal(t, http.StatusOK, own.Code)
assert.Contains(t, body(t, own), "Roadmap sync")
for _, path := range []string{"/uploads/" + theirs, "/uploads/" + theirs + "/status"} {
rec := do(t, app, get(path))
assert.Equal(t, http.StatusNotFound, rec.Code, path)
assert.NotContains(t, body(t, rec), "Their private meeting", "a 404 must not leak the title")
}
}
func TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
tests := []struct {
name, status, content string
polls bool
wantInBody string
}{
{"queued", "queued", "", true, "Queued"},
{"transcribing", "transcribing", "", true, "ranscrib"},
{"done", "done", "Synthetic transcript text.", false, "Synthetic transcript text."},
{"failed interrupted", "failed", "interrupted", false, "upload it again"},
{"failed transcription", "failed", "transcription_failed", false, "Transcription failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id := seedUpload(t, userID, "m-"+tt.name, tt.status, tt.content)
rec := do(t, app, get("/uploads/"+id+"/status"))
require.Equal(t, http.StatusOK, rec.Code)
b := body(t, rec)
assert.Contains(t, b, tt.wantInBody)
if tt.polls {
assert.Contains(t, b, `hx-trigger="every`, "an unfinished upload must keep polling")
} else {
assert.NotContains(t, b, "hx-trigger", "a finished upload must not poll forever: no stuck spinner")
}
})
}
}
func TestUploadStatus_DoneNamesTheEndpointThatServedIt(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "berget/kb-whisper",
"which backend served a transcript is a quality fact, and berget means the audio left the homelab")
assert.True(t, strings.Contains(b, "45 min"), "the audio length is shown")
}
+381
View File
@@ -0,0 +1,381 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package web
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// UploadsPage is the upload form plus the caller's own uploads (ADR-033).
func UploadsPage(v uploadsView) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<article class=\"account\"><h1>Upload a recording</h1><p class=\"muted\">Audio only. Up to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 17, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " and ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(v.Limits.MaxDuration))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 18, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ". The transcript is private to you; the audio is deleted once it has been transcribed.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if v.Error != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"flash flash-error\" role=\"alert\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 22, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<form method=\"post\" action=\"/uploads\" enctype=\"multipart/form-data\"><p><label>Title (optional) <input type=\"text\" name=\"title\" maxlength=\"200\"></label></p><p><input type=\"file\" name=\"file\" accept=\"audio/*,.m4a,.mp3,.wav,.opus,.ogg,.mp4\" required></p><p><button type=\"submit\">Upload and transcribe</button></p></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(v.Rows) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<section><h2>Your recordings</h2><ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, u := range v.Rows {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<li><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 35, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 35, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</a> <span class=\"chip\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Status))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 36, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</ul></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Tapir — Upload a recording").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// UploadPage is one upload's page: its title and the polling status region.
func UploadPage(u domain.UploadedTranscript) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<article class=\"account\"><h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 50, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = uploadStatus(u).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<p><a href=\"/uploads\">All recordings</a></p></article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Tapir — "+u.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
// page re-asks; done and failed render without it, so nothing polls forever.
func uploadStatus(u domain.UploadedTranscript) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch u.Status {
case domain.UploadDone:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"upload-status upload-done\" role=\"status\"><p>Transcribed ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 64, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " of audio by <span class=\"chip\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span>.</p><details open><summary>Transcript</summary><pre class=\"transcript\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 69, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</pre></details></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.UploadFailed:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"upload-status upload-failed flash flash-error\" role=\"alert\"><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 74, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"upload-status upload-working\" role=\"status\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/" + u.ID + "/status")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 80, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-trigger=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("every " + uploadPollEvery)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 81, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if u.Status == domain.UploadTranscribing {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p>Transcribing… a long recording takes a few minutes.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p>Queued — waiting for its turn to be transcribed.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store" "git.d-ma.be/mathias/tapir/internal/adapters/store"
) )
// Layout is the shared HTML shell. HTMX drives the progressive interactions // Layout is the shared HTML shell. HTMX drives the progressive interactions
+2 -1
View File
@@ -14,7 +14,8 @@ import (
// page renders any GET path's full HTML (helper for theme/structure assertions). // page renders any GET path's full HTML (helper for theme/structure assertions).
func page(t *testing.T, app interface { func page(t *testing.T, app interface {
Router() http.Handler Router() http.Handler
}, path string) string { }, path string,
) string {
t.Helper() t.Helper()
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) app.Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+72
View File
@@ -0,0 +1,72 @@
# spike-media — the `/bygge` toolchain, recovered
Throwaway spike tooling for #28 (upload → transcript → takeaways). Committed
because it was found in `tmpfs` one reboot from gone, not because it is finished.
Nothing here is wired into tapir. No database, no HTTP handler, no ADR.
## What produced `/bygge`
The prototype at `tapir.d-ma.be/bygge` was **not** hand-built, which the epic and
the first round of spike issues both got wrong. It came from these three scripts:
```
IMG_1233.mov --transcode.yaml--> IMG_1233.web.mp4 (k3s Job, h264, +faststart)
IMG_1233.wav --whisper---------> IMG_1233.sv.srt (interactive — the one manual step)
IMG_1233.sv.srt --analyze_srt.py--> analys.json (berget/mistral-medium, temperature=0)
analys.json + video --build_page.py--> page HTML
```
The reference analysis was produced by **`berget/mistral-medium`** — a mid-tier
cloud model, not a frontier one. That matters when judging what a local model
has to clear: the bar is mistral-medium, and it should be re-run as the control
arm rather than excluded.
## The validator is the valuable part
`analyze_srt.py` grades its own output on four checks. Two of them catch classes
of error that timestamp- and citation-checking cannot:
| Check | Catches |
|---|---|
| citation exact / **partial** / absent | Paraphrase presented as a quote. Three tiers, because `alternativ` citations legitimately stitch non-contiguous passages — a strict substring test fails the reference output. |
| `TALET … FINNS INTE I CITATET` | A wrong number carried by a *verbatim* citation and a *real* timestamp. Converts Swedish number-words to digits first. It caught `45×230` where the transcript said `220`. |
| `TACKNINGSLUCKA` | Spans of the clip no section covers. **The recall term** — without it an empty analysis scores perfectly on every other check. |
| timestamp exists in the SRT | Fabricated seek targets. Near-solved: 74/75 on the reference, because the model copies the cue starts it was handed. |
## Known defects — read before running
- **`max_tokens: 6000` is too low.** The 4-minute reference analysis is ~4,700
output tokens, i.e. ~78% of budget. A 10-minute clip truncates for every model,
and truncation reads as a JSON-discipline failure if you are not counting
tokens. Record `finish_reason` and separate `truncated` from `malformed`.
- **`build_page.py` hardcodes the title** (`"Jonas – genomgång av tillbyggnad"`).
- **The schema carries fields nothing renders** — `projekt.sammanfattning`,
`projekt.skede`, `avsnitt.bildtid`, `avsnitt.bildmotiv`, `material[]`,
`oklart[]` — left over from an earlier still-frame/markdown output that
`build_page.py` replaced. They are paid for in output tokens on every run.
- **Speaker attribution is inferred, not diarized.** The output attributes
statements by name from an SRT with no speaker labels. All four checks above
are blind to a misattribution.
- **The web encode does not downscale.** The source is 720×1280; `-preset
veryfast -crf 24` on handheld motion is what makes it ~5 Mbps, not the
resolution. The lever is the preset.
## Fixtures
`fixtures/fake.sv.srt` + `fixtures/fake.analys.json` — a synthetic 7-cue Swedish
construction walkthrough with planted numbers. No real people, no real project.
It exercises the parser and all four validator checks **without a model call**,
which is what makes it usable in a test.
## Why the real artifacts are not here
**This repo is public.** The prototype's transcript and analysis are a verbatim
recording of a named person discussing a client's building project — private
third-party content, and exactly the class of data #28 calls out as the reason
uploaded transcripts cannot live in the shared `transcripts` table.
So `IMG_1233.sv.srt`, `payload.json` and the model-comparison runs stay out of
git here. That leaves `/bygge` without a versioned source of truth, which is a
real gap and a decision for #28: a private repo, an encrypted blob, or accepting
that the reference artifacts live outside version control.
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""Analysera en svensk SRT-transkription av ett byggprojekt.
Steg 1 av 2: producerar tidsstämplad, källbelagd JSON som steg 2
(ffmpeg-bildutdrag + markdown-montering) konsumerar.
Varje påstående måste bära sitt ordagranna citat och sin tidsstämpel.
Det gör hallucination synlig i stället för trolig, och ger samtidigt
bildtiderna gratis.
Körning:
export DMABE_LLMAPI_KEY=... # redan satt på koala
python3 analyze_srt.py IMG_1233.sv.srt > analys.json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, asdict
GATEWAY = os.environ.get("LITELLM_BASE", "http://koala:30401/v1")
DEFAULT_MODEL = "berget/mistral-medium"
# ---------------------------------------------------------------- SRT-parsning
TS = re.compile(
r"(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})"
)
@dataclass
class Cue:
idx: int
start: float # sekunder
end: float
text: str
@property
def start_hms(self) -> str:
return secs_to_hms(self.start)
def secs_to_hms(s: float) -> str:
h, rem = divmod(int(s), 3600)
m, sec = divmod(rem, 60)
return f"{h:02d}:{m:02d}:{sec:02d}"
def hms_to_secs(v: str) -> float:
"""Tål '00:01:23', '01:23', '83' och '00:01:23,450'."""
v = v.strip().replace(",", ".")
parts = v.split(":")
try:
nums = [float(p) for p in parts]
except ValueError as e:
raise ValueError(f"ogiltig tidsstämpel: {v!r}") from e
if len(nums) == 3:
return nums[0] * 3600 + nums[1] * 60 + nums[2]
if len(nums) == 2:
return nums[0] * 60 + nums[1]
if len(nums) == 1:
return nums[0]
raise ValueError(f"ogiltig tidsstämpel: {v!r}")
def parse_srt(raw: str) -> list[Cue]:
"""Blockbaserad parsning. Tål saknade index och CRLF."""
raw = raw.replace("\r\n", "\n").replace("", "")
cues: list[Cue] = []
for block in re.split(r"\n\s*\n", raw.strip()):
lines = [ln for ln in block.split("\n") if ln.strip()]
if not lines:
continue
m = None
text_from = 0
for i, ln in enumerate(lines[:2]):
m = TS.search(ln)
if m:
text_from = i + 1
break
if not m:
continue
g = [int(x) for x in m.groups()]
start = g[0] * 3600 + g[1] * 60 + g[2] + g[3] / 1000
end = g[4] * 3600 + g[5] * 60 + g[6] + g[7] / 1000
text = " ".join(lines[text_from:]).strip()
if text:
cues.append(Cue(len(cues) + 1, start, end, text))
return cues
def as_numbered_transcript(cues: list[Cue]) -> str:
"""Tidsstämplad text som modellen kan citera exakt ur."""
return "\n".join(f"[{c.start_hms}] {c.text}" for c in cues)
# ------------------------------------------------------------------ LLM-anrop
SYSTEM = """Du är byggteknisk analytiker. Du analyserar en transkription från ett \
platsbesök där Jonas beskriver ett PLANERAT byggprojekt för minst en annan person.
Detta är ett SAMTAL, inte en monolog. Korta inpass som "Hela vägen?", "Vilken höjd?" \
eller "Måste det bli det?" är motpartens frågor - de driver samtalet men är inte \
Jonas påståenden. Attribuera inte en fråga som ett beslut.
Det mesta som sägs är FÖRSLAG under diskussion, inte färdiga beslut. Signalord som \
"vi tänker", "alternativt", "kanske", "förmodligen", "eventuellt", "om möjligt", \
"troligast" och "beror på" markerar något ÖPPET. Blanda aldrig ihop ett övervägt \
alternativ med ett fattat beslut - den skillnaden är hela poängen med analysen.
ABSOLUTA KRAV:
- Rapportera ENDAST det som faktiskt sägs. Dra inga egna slutsatser, lägg inte till \
byggmoment, material eller mått som inte nämns explicit.
- Varje påstående MÅSTE bära ett ordagrant citat ur transkriptionen samt dess tidsstämpel.
- Mått revideras under samtalets gång ("nio meter" -> "9,40"). Återge alla varianter \
som nämns, med citat, i stället för att välja en.
- Är något oklart, ohörbart eller avbrutet: skriv det i fältet "oklart" i stället för \
att gissa.
- Svara ENDAST med giltig JSON. Ingen markdown, inga kodstaket, ingen förklarande text."""
USER_TMPL = """Dela in transkriptionen i tematiska avsnitt (typiskt 4-8 stycken) som \
följer hur Jonas rör sig genom projektet.
TACKNINGSKRAV: avsnitten maste tillsammans tacka HELA klippet, fran 00:00:00 till \
{slut}. Forsta avsnittet borjar 00:00:00, sista avsnittet slutar {slut}, och varje \
avsnitts "start" ar lika med foregaende avsnitts "slut". Lamna inga luckor - ocksa \
korta avslutande kommentarer (tidplan, kostnader, nasta steg) ska tillhora ett avsnitt.
Svara med exakt denna JSON-struktur:
{{
"projekt": {{
"sammanfattning": "2-3 meningar om vad projektet är",
"typ": "t.ex. nybyggnad villa / tillbyggnad / renovering - eller null om det inte framgår",
"skede": "vilket byggskede som visas - eller null"
}},
"avsnitt": [
{{
"rubrik": "kort beskrivande rubrik",
"start": "HH:MM:SS",
"slut": "HH:MM:SS",
"sammanfattning": "vad Jonas beskriver i detta avsnitt, 1-3 meningar",
"nyckelpunkter": [
{{"pastaende": "vad som sags", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}
],
"bildtid": "HH:MM:SS - ogonblicket dar det Jonas beskriver syns tydligast",
"bildmotiv": "vad som bor synas i bilden vid bildtid"
}}
],
"beslut": [{{"vad": "nagot som uttalas som bestamt", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"alternativ": [{{
"fraga": "vad som vags mot varandra",
"optioner": ["alternativ A", "alternativ B"],
"lutar_at": "det alternativ som forespraakas - eller null om oavgjort",
"citat": "ordagrant citat", "tid": "HH:MM:SS"
}}],
"oppna_fragor": [{{
"fraga": "det som inte ar avgjort",
"beror_pa": "vad avgorandet hanger pa - eller null",
"citat": "ordagrant citat", "tid": "HH:MM:SS"
}}],
"matt": [{{"vad": "matt/dimension", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"material": [{{"vad": "material/produkt", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"oklart": ["saker som ar ohorbara, avbrutna eller tvetydiga i transkriptionen"]
}}
Valj "bildtid" omsorgsfullt. I detta samtal pekar Jonas standigt pa saker med ord som \
"har", "hit", "den typ sa" - orden ar obegripliga utan bilden, sa bildtiden ska ligga \
dar foremalet han syftar pa syns, inte dar han byter amne. Tomma listor ar helt i sin \
ordning om inget relevant namns.
TRANSKRIPTION:
{transcript}"""
def call_llm(
model: str, transcript: str, slut: str, timeout: int = 300, retries: int = 2
) -> str:
key = os.environ.get("DMABE_LLMAPI_KEY") or os.environ.get("LITELLM_API_KEY")
if not key:
sys.exit("DMABE_LLMAPI_KEY (eller LITELLM_API_KEY) saknas i miljon.")
body = json.dumps(
{
"model": model,
"temperature": 0,
"max_tokens": 6000,
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": USER_TMPL.format(transcript=transcript, slut=slut)},
],
}
).encode()
# Gatewayen begransar burst (limit_req burst=5) och berget-anropet kan
# spika. TimeoutError arvs INTE av URLError, sa den maste fangas separat -
# annars blir felet en rasptrace efter flera minuters vantan.
last = ""
for attempt in range(retries + 1):
req = urllib.request.Request(
f"{GATEWAY}/chat/completions",
data=body,
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
payload = json.load(r)
break
except urllib.error.HTTPError as e:
detail = e.read()[:400].decode(errors="replace")
if e.code == 429 and attempt < retries:
last = f"HTTP 429 (rate limit)"
elif e.code >= 500 and attempt < retries:
last = f"HTTP {e.code}"
else:
sys.exit(f"gateway HTTP {e.code}: {detail}")
except (TimeoutError, urllib.error.URLError, OSError) as e:
reason = getattr(e, "reason", e)
if attempt < retries:
last = f"{type(e).__name__}: {reason}"
else:
sys.exit(
f"nadde inte gateway {GATEWAY} efter {retries + 1} forsok "
f"({type(e).__name__}: {reason}). Kontrollera att koala:30401 svarar."
)
backoff = 3 * (attempt + 1)
print(f"[!] {last} - forsok {attempt + 2}/{retries + 1} om {backoff}s", file=sys.stderr)
time.sleep(backoff)
else:
sys.exit(f"gav upp efter {retries + 1} forsok: {last}")
choice = payload["choices"][0]
content = (choice.get("message") or {}).get("content") or ""
if not content.strip():
# Kant lage for thinking-modeller: resonemanget ater hela budgeten.
sys.exit(
f"tomt svar (finish_reason={choice.get('finish_reason')}). "
"Valj en icke-resonerande modell for strukturerad utdata."
)
return content
def extract_json(s: str) -> dict:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
try:
return json.loads(s)
except json.JSONDecodeError:
# Sista utvag: forsta {...sista }
i, j = s.find("{"), s.rfind("}")
if i >= 0 and j > i:
return json.loads(s[i : j + 1])
raise
# -------------------------------------------------------------- Verifiering
def norm(s: str) -> str:
return re.sub(r"[^\wåäöÅÄÖ]+", " ", (s or "").lower()).strip()
# --- svenska talord -> siffror -------------------------------------------
# Byggmatt sags i klartext ("fyrtiofem ganger tvahundratjugo") men refereras
# i siffror. Utan denna oversattning kan ett felaktigt matt bara ett giltigt
# ordagrant citat och passera oupptackt - den farligaste feltypen i ett
# byggdokument.
_SW_UNITS = {
"noll": 0, "en": 1, "ett": 1, "två": 2, "tva": 2, "tre": 3, "fyra": 4,
"fem": 5, "sex": 6, "sju": 7, "åtta": 8, "atta": 8, "nio": 9, "tio": 10,
"elva": 11, "tolv": 12, "tretton": 13, "fjorton": 14, "femton": 15,
"sexton": 16, "sjutton": 17, "arton": 18, "nitton": 19,
}
_SW_TENS = {
"tjugo": 20, "trettio": 30, "fyrtio": 40, "femtio": 50, "sextio": 60,
"sjuttio": 70, "åttio": 80, "attio": 80, "nittio": 90,
}
_SW_ATOMS = {**_SW_UNITS, **_SW_TENS, "hundra": 100, "tusen": 1000}
# Langsta forst sa att "sexton" vinner over "sex", "attio" over "atta".
_SW_ORDER = sorted(_SW_ATOMS, key=len, reverse=True)
def _sw_word_to_int(word: str) -> int | None:
"""'tvåhundratjugo' -> 220. None om ordet inte ar ett rent talord."""
w = word.lower()
toks: list[str] = []
while w:
for atom in _SW_ORDER:
if w.startswith(atom):
toks.append(atom)
w = w[len(atom) :]
break
else:
return None
if not toks:
return None
total = current = 0
for t in toks:
v = _SW_ATOMS[t]
if t == "hundra":
current = (current or 1) * 100
elif t == "tusen":
total += (current or 1) * 1000
current = 0
else:
current += v
return total + current
def numbers_in(text: str) -> set[float]:
"""Alla tal i texten, som siffror och som svenska talord.
Svenska anvander decimalkomma ("9,40" = 9.4). Naiv \\d+-matchning skulle
lasa det som tva separata heltal 9 och 40 - och just har revideras matten
live i samtalet ("nio meter" -> "9,40"), sa den skillnaden ar betydelsebarande.
"""
found: set[float] = set()
for m in re.finditer(r"\d+(?:[.,]\d+)?", text or ""):
found.add(float(m.group().replace(",", ".")))
for w in re.findall(r"[a-zåäöA-ZÅÄÖ]+", text or ""):
# "en"/"ett" ar obestamd artikel vida oftare an raknetalet 1
# ("ett sedumtak"), sa fristaende traffar ger falsklarm. Sammansatta
# former ("etthundra") fangas anda av den giriga tokeniseringen.
if len(w) < 3 or w.lower() in {"en", "ett"}:
continue
v = _sw_word_to_int(w)
if v is not None and v > 0:
found.add(float(v))
return found
def fmt_num(v: float) -> str:
return str(int(v)) if v == int(v) else f"{v:g}"
def unsupported(claimed: set[float], supported: set[float]) -> list[float]:
"""Tal i pastaendet som inte styrks av citatet (med liten tolerans)."""
return sorted(
n for n in claimed if not any(abs(n - s) < 0.01 for s in supported)
)
def verify(doc: dict, cues: list[Cue], duration: float) -> list[str]:
"""Varje citat ska ga att aterfinna; varje tid ska ligga inom klippet.
Detta ar sjalva poangen med strukturen - ett pastaende utan verifierbar
kalla ar en hallucination, och den ska synas har och inte i dokumentet.
"""
warnings: list[str] = []
haystack = norm(" ".join(c.text for c in cues))
def check_quote(where: str, quote: str) -> None:
n = norm(quote)
if not n:
warnings.append(f"{where}: tomt citat")
return
if n in haystack:
return
# Delvis traff: minst 60 % av orden i foljd nagonstans
words = n.split()
if len(words) >= 4:
for size in (len(words), max(4, int(len(words) * 0.6))):
for i in range(0, len(words) - size + 1):
if " ".join(words[i : i + size]) in haystack:
warnings.append(f"{where}: citat matchar bara delvis: {quote!r}")
return
warnings.append(f"{where}: CITAT SAKNAS I TRANSKRIPTION: {quote!r}")
def check_time(where: str, t: str) -> None:
try:
v = hms_to_secs(t)
except ValueError:
warnings.append(f"{where}: ogiltig tid {t!r}")
return
if not (0 <= v <= duration + 1):
warnings.append(f"{where}: tid {t} utanfor klippet (0-{secs_to_hms(duration)})")
for i, sec in enumerate(doc.get("avsnitt") or [], 1):
tag = f"avsnitt {i} ({sec.get('rubrik','?')})"
for f in ("start", "slut", "bildtid"):
if sec.get(f):
check_time(f"{tag}.{f}", sec[f])
for j, kp in enumerate(sec.get("nyckelpunkter") or [], 1):
check_quote(f"{tag}.nyckelpunkt {j}", kp.get("citat", ""))
if kp.get("tid"):
check_time(f"{tag}.nyckelpunkt {j}.tid", kp["tid"])
def check_numbers(where: str, claim: str, quote: str) -> None:
"""Varje tal i pastaendet maste finnas i dess eget citat."""
claimed = numbers_in(claim)
if not claimed:
return
supported = numbers_in(quote)
for n in unsupported(claimed, supported):
stod = ", ".join(fmt_num(s) for s in sorted(supported)) or "inga tal"
warnings.append(
f"{where}: TALET {fmt_num(n)} FINNS INTE I CITATET "
f"(citat stoder {stod}): {claim!r}"
)
for i, sec in enumerate(doc.get("avsnitt") or [], 1):
tag = f"avsnitt {i} ({sec.get('rubrik','?')})"
for j, kp in enumerate(sec.get("nyckelpunkter") or [], 1):
check_numbers(f"{tag}.nyckelpunkt {j}", kp.get("pastaende", ""), kp.get("citat", ""))
for key in ("material", "matt", "beslut"):
for j, it in enumerate(doc.get(key) or [], 1):
check_quote(f"{key}[{j}]", it.get("citat", ""))
if it.get("tid"):
check_time(f"{key}[{j}].tid", it["tid"])
check_numbers(f"{key}[{j}]", it.get("vad", ""), it.get("citat", ""))
# Tackningskontroll: avsnitten ska tacka hela klippet utan hal, annars
# tappas material tyst (och inget bildutdrag gors for den luckan).
spans: list[tuple[float, float]] = []
for sec in doc.get("avsnitt") or []:
try:
spans.append((hms_to_secs(sec["start"]), hms_to_secs(sec["slut"])))
except (KeyError, ValueError):
continue
spans.sort()
cursor = 0.0
for s, e in spans:
if s - cursor > 2.0:
gap_text = " ".join(
c.text for c in cues if c.start >= cursor - 0.5 and c.end <= s + 0.5
)
warnings.append(
f"TACKNINGSLUCKA {secs_to_hms(cursor)}-{secs_to_hms(s)} "
f"({s - cursor:.0f}s utan avsnitt): {gap_text[:90]!r}"
)
cursor = max(cursor, e)
if duration - cursor > 2.0:
warnings.append(
f"TACKNINGSLUCKA {secs_to_hms(cursor)}-{secs_to_hms(duration)} "
f"({duration - cursor:.0f}s i slutet utan avsnitt)"
)
return warnings
# ------------------------------------------------------------------- main
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("srt")
ap.add_argument("--model", default=DEFAULT_MODEL)
ap.add_argument("--raw", action="store_true", help="skriv aven modellens rasvar till stderr")
args = ap.parse_args()
with open(args.srt, encoding="utf-8") as fh:
cues = parse_srt(fh.read())
if not cues:
sys.exit(f"inga cues hittades i {args.srt} - fel format?")
duration = cues[-1].end
print(
f"[i] {len(cues)} cues, langd {secs_to_hms(duration)}, modell {args.model}",
file=sys.stderr,
)
content = call_llm(args.model, as_numbered_transcript(cues), secs_to_hms(duration))
if args.raw:
print(content, file=sys.stderr)
try:
doc = extract_json(content)
except json.JSONDecodeError as e:
sys.exit(f"modellen returnerade ogiltig JSON: {e}\n---\n{content[:800]}")
warnings = verify(doc, cues, duration)
doc["_meta"] = {
"modell": args.model,
"kallfil": os.path.basename(args.srt),
"antal_cues": len(cues),
"langd": secs_to_hms(duration),
"varningar": warnings,
}
print(json.dumps(doc, ensure_ascii=False, indent=2))
if warnings:
print(f"\n[!] {len(warnings)} verifieringsvarningar:", file=sys.stderr)
for w in warnings:
print(f" - {w}", file=sys.stderr)
else:
print("[ok] alla citat aterfunna, alla tider inom klippet", file=sys.stderr)
if __name__ == "__main__":
main()
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""Bygg en fristaende HTML-sida: video + SRT + tidssynkad analys.
Sidan ar sjalvbarande sa nar som pa videofilen - transkription och analys
bakas in i HTML:en. Det gor att den fungerar aven over file:// (fetch mot
en lokal JSON blockeras av CORS).
python3 build_page.py payload.json IMG_1233.web.mp4 > index.html
"""
from __future__ import annotations
import html
import json
import sys
TEMPLATE = """<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
:root {
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
--accent:#2563eb; --accent-soft:#eff4ff;
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
}
@media (prefers-color-scheme: dark) {
:root {
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
--accent:#60a5fa; --accent-soft:#17233b;
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
}
}
:root[data-theme="dark"] {
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
--accent:#60a5fa; --accent-soft:#17233b;
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
}
:root[data-theme="light"] {
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
--accent:#2563eb; --accent-soft:#eff4ff;
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--ink);
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
}
header {
padding:14px 20px; border-bottom:1px solid var(--line); background:var(--panel);
display:flex; align-items:baseline; gap:14px; flex-wrap:wrap;
}
header h1 { margin:0; font-size:17px; font-weight:650; }
header .sub { color:var(--muted); font-size:13px; }
.wrap {
display:grid; grid-template-columns: minmax(0,1.35fr) minmax(320px,1fr);
gap:20px; padding:20px; align-items:start; max-width:1600px; margin:0 auto;
}
@media (max-width: 950px) { .wrap { grid-template-columns:1fr; } }
/* Videon star stilla; analysen ror sig bredvid. */
.videocol { position:sticky; top:20px; }
video { width:100%; border-radius:10px; background:#000; display:block; }
.nowbar {
margin-top:10px; padding:12px 14px; background:var(--panel);
border:1px solid var(--line); border-radius:10px; min-height:64px;
}
.nowbar .label { font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
.nowbar .text { margin-top:4px; font-size:16px; }
.tabs { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
.tab {
padding:6px 12px; border:1px solid var(--line); background:var(--panel);
border-radius:999px; cursor:pointer; font-size:13px; color:var(--ink);
}
.tab[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
.pane { display:none; }
.pane.active { display:block; }
.scroller {
max-height: calc(100vh - 190px); overflow-y:auto; padding-right:6px;
scroll-behavior:smooth;
}
.card {
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--line);
border-radius:8px; padding:11px 13px; margin-bottom:9px; cursor:pointer;
transition:border-color .15s, background .15s;
}
.card:hover { border-color:var(--accent); }
.card.on { background:var(--accent-soft); border-left-color:var(--accent); }
.card .k {
font-size:10px; text-transform:uppercase; letter-spacing:.07em;
font-weight:700; margin-bottom:5px;
}
.card .t { font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums; }
.card .cit { display:block; margin-top:6px; color:var(--muted); font-size:13px; font-style:italic; }
.k.beslut{color:var(--beslut)} .k.alt{color:var(--alt)}
.k.oppen{color:var(--oppen)} .k.matt{color:var(--matt)}
.card.beslut{border-left-color:var(--beslut)} .card.alt{border-left-color:var(--alt)}
.card.oppen{border-left-color:var(--oppen)} .card.matt{border-left-color:var(--matt)}
.opts { margin:6px 0 0; padding-left:18px; font-size:13px; }
.lutar { font-size:12px; color:var(--muted); margin-top:5px; }
.cue {
padding:5px 9px; border-radius:6px; cursor:pointer; display:flex; gap:10px;
font-size:14px; border-left:2px solid transparent;
}
.cue:hover { background:var(--accent-soft); }
.cue.on { background:var(--accent-soft); border-left-color:var(--accent); font-weight:600; }
.cue .ts {
color:var(--muted); font-variant-numeric:tabular-nums; font-size:12px;
flex:0 0 46px; padding-top:2px;
}
.sec-head {
margin:16px 0 7px; font-size:12px; font-weight:700; text-transform:uppercase;
letter-spacing:.06em; color:var(--muted); border-bottom:1px solid var(--line);
padding-bottom:5px;
}
.sec-head:first-child { margin-top:0; }
.warn {
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--alt);
border-radius:8px; padding:11px 13px; font-size:13px; color:var(--muted);
}
.warn b { color:var(--ink); }
.warn ul { margin:6px 0 0; padding-left:18px; }
</style>
</head>
<body>
<header>
<h1>__TITLE__</h1>
<span class="sub">__SUB__</span>
</header>
<div class="wrap">
<div class="videocol">
<video id="v" controls preload="metadata" src="__VIDEO__"></video>
<div class="nowbar">
<div class="label">Sägs nu</div>
<div class="text" id="now">—</div>
</div>
</div>
<div>
<div class="tabs" role="tablist">
<button class="tab" role="tab" aria-selected="true" data-pane="analys">Analys</button>
<button class="tab" role="tab" aria-selected="false" data-pane="text">Transkription</button>
<button class="tab" role="tab" aria-selected="false" data-pane="kvalitet">Kvalitet</button>
</div>
<div class="pane active" id="pane-analys"><div class="scroller" id="analys"></div></div>
<div class="pane" id="pane-text"><div class="scroller" id="text"></div></div>
<div class="pane" id="pane-kvalitet"><div class="scroller" id="kvalitet"></div></div>
</div>
</div>
<script>
const DATA = __DATA__;
const v = document.getElementById('v');
const hms = s => {
s = Math.max(0, Math.floor(s));
return String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0');
};
const toSec = t => {
if (t == null) return null;
const p = String(t).trim().split(':').map(Number);
if (p.some(isNaN)) return null;
return p.length === 3 ? p[0]*3600+p[1]*60+p[2] : p.length === 2 ? p[0]*60+p[1] : p[0];
};
const esc = s => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
/* Varje kort bar sin tid; ett klick soker dit. Analysen ar tidsforankrad
hela vagen ned, sa synkningen ar bara en sortering pa den tiden. */
const items = [];
const A = DATA.analys;
(A.avsnitt || []).forEach((s, i) => {
const t = toSec(s.start) ?? 0;
items.push({t, end: toSec(s.slut) ?? 1e9, kind:'avsnitt', html:
`<div class="k">Avsnitt ${i+1} · ${esc(s.start)}–${esc(s.slut)}</div>
<div style="font-weight:650;font-size:15px">${esc(s.rubrik)}</div>
<div style="margin-top:5px">${esc(s.sammanfattning)}</div>`});
(s.nyckelpunkter || []).forEach(k => {
const kt = toSec(k.tid); if (kt == null) return;
items.push({t:kt, end:kt+6, kind:'punkt', citat:k.citat, html:
`<div class="t">${esc(k.tid)}</div><div>${esc(k.pastaende)}</div>
<div class="cit">”${esc(k.citat)}”</div>`});
});
});
(A.beslut || []).forEach(b => {
const t = toSec(b.tid); if (t == null) return;
items.push({t, end:t+8, kind:'beslut', citat:b.citat, html:
`<div class="k beslut">Beslut</div><div class="t">${esc(b.tid)}</div>
<div style="font-weight:600">${esc(b.vad)}</div>
${b.skal ? `<div class="lutar">Skäl: ${esc(b.skal)}</div>` : ''}
<div class="cit">”${esc(b.citat)}”</div>`});
});
(A.alternativ || []).forEach(a => {
const t = toSec(a.tid); if (t == null) return;
items.push({t, end:t+8, kind:'alt', citat:a.citat, html:
`<div class="k alt">Alternativ</div><div class="t">${esc(a.tid)}</div>
<div style="font-weight:600">${esc(a.fraga)}</div>
<ul class="opts">${(a.optioner||[]).map(o=>`<li>${esc(o)}</li>`).join('')}</ul>
<div class="lutar">${a.lutar_at ? 'Lutar åt: <b>'+esc(a.lutar_at)+'</b>' : 'Oavgjort'}</div>
<div class="cit">”${esc(a.citat)}”</div>`});
});
(A.oppna_fragor || []).forEach(o => {
const t = toSec(o.tid); if (t == null) return;
items.push({t, end:t+8, kind:'oppen', citat:o.citat, html:
`<div class="k oppen">Öppen fråga</div><div class="t">${esc(o.tid)}</div>
<div style="font-weight:600">${esc(o.fraga)}</div>
${o.beror_pa ? `<div class="lutar">Beror på: ${esc(o.beror_pa)}</div>` : ''}
<div class="cit">”${esc(o.citat)}”</div>`});
});
(A.matt || []).forEach(m => {
const t = toSec(m.tid); if (t == null) return;
items.push({t, end:t+6, kind:'matt', citat:m.citat, html:
`<div class="k matt">Mått</div><div class="t">${esc(m.tid)}</div>
<div>${esc(m.vad)}</div><div class="cit">”${esc(m.citat)}”</div>`});
});
const RANK = {avsnitt:0, beslut:1, oppen:2, alt:3, matt:4, punkt:5};
/* Samma replik klassas ofta bade som nyckelpunkt och som matt/beslut.
Visa den mest specifika varianten en gang i stallet for tva nastan
identiska kort - annars later flodet som ett eko. */
const bestByQuote = new Map();
items.forEach(it => {
const key = (it.citat||'').trim().toLowerCase() + '@' + it.t;
if (!it.citat) return;
const prev = bestByQuote.get(key);
if (prev === undefined || RANK[it.kind] < RANK[prev.kind]) bestByQuote.set(key, it);
});
const kept = items.filter(it => {
if (!it.citat) return true;
const key = (it.citat||'').trim().toLowerCase() + '@' + it.t;
return bestByQuote.get(key) === it;
});
items.length = 0; items.push(...kept);
items.sort((a,b) => a.t - b.t || RANK[a.kind] - RANK[b.kind]);
const analysEl = document.getElementById('analys');
analysEl.innerHTML = items.map((it,i) =>
`<div class="card ${it.kind}" data-i="${i}" data-t="${it.t}">${it.html}</div>`).join('');
const textEl = document.getElementById('text');
textEl.innerHTML = DATA.cues.map((c,i) =>
`<div class="cue" data-ci="${i}" data-t="${c.t}">
<span class="ts">${hms(c.t)}</span><span>${esc(c.text)}</span></div>`).join('');
const warns = (A._meta && A._meta.varningar) || [];
document.getElementById('kvalitet').innerHTML = `
<div class="warn">
<b>Automatisk källkontroll</b><br>
Modell: ${esc((A._meta&&A._meta.modell)||'–')} · ${DATA.cues.length} repliker · längd ${esc((A._meta&&A._meta.langd)||'–')}<br><br>
Varje påstående kontrolleras mot sitt eget citat: citatet måste återfinnas
ordagrant i transkriptionen, tiden måste ligga inom klippet, och varje tal i
påståendet måste styrkas av citatet.
${warns.length
? `<br><br><b>${warns.length} varningar:</b><ul>${warns.map(w=>`<li>${esc(w)}</li>`).join('')}</ul>`
: '<br><br>Inga avvikelser.'}
</div>`;
/* Klick söker i videon - åt båda håll: kort och replik. */
document.addEventListener('click', e => {
const el = e.target.closest('.card, .cue');
if (!el) return;
v.currentTime = parseFloat(el.dataset.t) + 0.05;
v.play().catch(()=>{});
});
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.setAttribute('aria-selected','false'));
tab.setAttribute('aria-selected','true');
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
document.getElementById('pane-' + tab.dataset.pane).classList.add('active');
});
});
/* Auto-scroll bara nar anvandaren inte sjalv scrollar - annars slits vyn
ur handerna pa den som lasar. */
let userScrolled = 0;
document.querySelectorAll('.scroller').forEach(s =>
s.addEventListener('wheel', () => { userScrolled = Date.now(); }, {passive:true}));
let lastCard = -1, lastCue = -1;
function sync() {
const t = v.currentTime;
let ci = -1;
for (let i = 0; i < DATA.cues.length; i++) {
if (t >= DATA.cues[i].t - 0.15) ci = i; else break;
}
if (ci !== lastCue) {
document.querySelectorAll('.cue.on').forEach(e => e.classList.remove('on'));
if (ci >= 0) {
const el = textEl.querySelector(`[data-ci="${ci}"]`);
if (el) {
el.classList.add('on');
if (Date.now() - userScrolled > 3000) el.scrollIntoView({block:'center'});
}
document.getElementById('now').textContent = DATA.cues[ci].text;
}
lastCue = ci;
}
let ai = -1;
for (let i = 0; i < items.length; i++) {
if (t >= items[i].t - 0.15) ai = i; else break;
}
if (ai !== lastCard) {
document.querySelectorAll('.card.on').forEach(e => e.classList.remove('on'));
if (ai >= 0) {
const el = analysEl.querySelector(`[data-i="${ai}"]`);
if (el) {
el.classList.add('on');
if (Date.now() - userScrolled > 3000 &&
document.getElementById('pane-analys').classList.contains('active')) {
el.scrollIntoView({block:'center'});
}
}
}
lastCard = ai;
}
requestAnimationFrame(sync);
}
requestAnimationFrame(sync);
</script>
</body>
</html>
"""
def main() -> None:
payload_path, video = sys.argv[1], sys.argv[2]
payload = json.load(open(payload_path, encoding="utf-8"))
a = payload["analys"]
proj = a.get("projekt") or {}
sub = " · ".join(
x for x in [proj.get("typ"), (a.get("_meta") or {}).get("langd"), video] if x
)
out = (
TEMPLATE.replace("__TITLE__", html.escape("Jonas – genomgång av tillbyggnad"))
.replace("__SUB__", html.escape(sub))
.replace("__VIDEO__", html.escape(video))
.replace("__DATA__", json.dumps(payload, ensure_ascii=False))
)
sys.stdout.write(out)
if __name__ == "__main__":
main()
@@ -0,0 +1,173 @@
{
"projekt": {
"sammanfattning": "Projektet är en nybyggnad med grundläggning, betongplatta och limträstomme. Taket kommer att vara ett sedumtak.",
"typ": "nybyggnad",
"skede": "stomme"
},
"avsnitt": [
{
"rubrik": "Introduktion och grundläggning",
"start": "00:00:00",
"slut": "00:00:06",
"sammanfattning": "Jonas introducerar platsen och meddelar att grundläggningen är klar.",
"nyckelpunkter": [
{
"pastaende": "Grundläggningen är klar",
"citat": "Vi har precis blivit klara med grundläggningen.",
"tid": "00:00:03"
}
],
"bildtid": "00:00:03",
"bildmotiv": "tomten med färdig grundläggning"
},
{
"rubrik": "Dränering och grundvatten",
"start": "00:00:06",
"slut": "00:00:14",
"sammanfattning": "Jonas beskriver dräneringsarbetet som krävdes på grund av högt grundvatten.",
"nyckelpunkter": [
{
"pastaende": "Dränering utfördes på grund av högt grundvatten",
"citat": "Vi fick dränera ordentligt först, för grundvattnet stod väldigt högt här.",
"tid": "00:00:06"
}
],
"bildtid": "00:00:10",
"bildmotiv": "dräneringssystem eller markarbete"
},
{
"rubrik": "Betongplatta och isolering",
"start": "00:00:14",
"slut": "00:00:22",
"sammanfattning": "Jonas beskriver betongplattan och isoleringen under den.",
"nyckelpunkter": [
{
"pastaende": "Betongplatta på mark med cellplast under",
"citat": "det är en betongplatta på mark med tvåhundra millimeter cellplast under.",
"tid": "00:00:16"
}
],
"bildtid": "00:00:18",
"bildmotiv": "betongplattan med cellplast"
},
{
"rubrik": "Stomme och bjälklag",
"start": "00:00:22",
"slut": "00:00:31",
"sammanfattning": "Jonas beskriver stommen i limträ och bjälklagets dimensioner.",
"nyckelpunkter": [
{
"pastaende": "Stommen är i limträ",
"citat": "Här ser ni stommen, den är i limträ.",
"tid": "00:00:22"
},
{
"pastaende": "Pelarnas dimensioner",
"citat": "Pelarna är hundraåttio gånger hundraåttio.",
"tid": "00:00:25"
},
{
"pastaende": "Bjälklagets spännvidd och dimensioner",
"citat": "Bjälklaget spänner sex meter fritt, och vi har fyrtiofem gånger tvåhundratjugo som balkar.",
"tid": "00:00:31"
}
],
"bildtid": "00:00:28",
"bildmotiv": "limträstommen med pelare och bjälklag"
},
{
"rubrik": "Tak och tidplan",
"start": "00:00:40",
"slut": "00:00:48",
"sammanfattning": "Jonas nämner taktypen och förklarar att projektet är försenat på grund av leveransproblem.",
"nyckelpunkter": [
{
"pastaende": "Taket kommer att vara sedumtak",
"citat": "Taket blir sedumtak, det var byggherrens önskemål.",
"tid": "00:00:40"
},
{
"pastaende": "Sedumtaket ställer krav på bärigheten",
"citat": "Det ställer krav på bärigheten.",
"tid": "00:00:44"
},
{
"pastaende": "Projektet är två veckor efter tidplanen",
"citat": "Vi ligger ungefär två veckor efter tidplanen på grund av leveransproblem på limträet.",
"tid": "00:00:48"
}
],
"bildtid": "00:00:44",
"bildmotiv": "stommen med eventuella förberedelser för taket"
}
],
"material": [
{
"vad": "cellplast",
"citat": "med tvåhundra millimeter cellplast under",
"tid": "00:00:18"
},
{
"vad": "betongplatta",
"citat": "det är en betongplatta på mark",
"tid": "00:00:16"
},
{
"vad": "limträ",
"citat": "den är i limträ",
"tid": "00:00:22"
},
{
"vad": "sedumtak",
"citat": "Taket blir sedumtak",
"tid": "00:00:40"
}
],
"matt": [
{
"vad": "200 mm cellplast",
"citat": "tvåhundra millimeter cellplast",
"tid": "00:00:18"
},
{
"vad": "180x180 mm pelare",
"citat": "Pelarna är hundraåttio gånger hundraåttio",
"tid": "00:00:25"
},
{
"vad": "6 meter spännvidd för bjälklag",
"citat": "Bjälklaget spänner sex meter fritt",
"tid": "00:00:31"
},
{
"vad": "45x230 mm balkar",
"citat": "fyrtiofem gånger tvåhundratjugo som balkar",
"tid": "00:00:31"
}
],
"problem": [
{
"vad": "högt grundvatten",
"citat": "grundvattnet stod väldigt högt här",
"tid": "00:00:10"
},
{
"vad": "leveransproblem med limträ",
"citat": "leveransproblem på limträet",
"tid": "00:00:48"
},
{
"vad": "försenat projekt",
"citat": "Vi ligger ungefär två veckor efter tidplanen",
"tid": "00:00:48"
}
],
"oklart": [],
"_meta": {
"modell": "berget/mistral-medium",
"kallfil": "fake.sv.srt",
"antal_cues": 7,
"langd": "00:00:56",
"varningar": []
}
}
+27
View File
@@ -0,0 +1,27 @@
1
00:00:00,000 --> 00:00:06,500
Ja, då står vi här på tomten. Vi har precis blivit klara med grundläggningen.
2
00:00:06,500 --> 00:00:14,000
Vi fick dränera ordentligt först, för grundvattnet stod väldigt högt här.
3
00:00:14,000 --> 00:00:22,000
Sen göt vi plattan, det är en betongplatta på mark med tvåhundra millimeter cellplast under.
4
00:00:22,000 --> 00:00:31,000
Här ser ni stommen, den är i limträ. Pelarna är hundraåttio gånger hundraåttio.
5
00:00:31,000 --> 00:00:40,000
Bjälklaget spänner sex meter fritt, och vi har fyrtiofem gånger tvåhundratjugo som balkar.
6
00:00:40,000 --> 00:00:48,000
Taket blir sedumtak, det var byggherrens önskemål. Det ställer krav på bärigheten.
7
00:00:48,000 --> 00:00:56,000
Vi ligger ungefär två veckor efter tidplanen på grund av leveransproblem på limträet.
+51
View File
@@ -0,0 +1,51 @@
apiVersion: batch/v1
kind: Job
metadata:
name: jonas-transcode
namespace: default
spec:
ttlSecondsAfterFinished: 600
activeDeadlineSeconds: 1800
backoffLimit: 0
template:
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: koala
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: ffmpeg
image: mwader/static-ffmpeg:7.1
args:
- "-nostdin"
- "-i"
- "/work/IMG_1233.mov"
- "-c:v"
- "libx264"
- "-preset"
- "veryfast"
- "-crf"
- "24"
- "-vf"
- "scale='min(1280,iw)':-2"
- "-c:a"
- "aac"
- "-b:a"
- "128k"
- "-movflags"
- "+faststart"
- "-y"
- "/work/IMG_1233.web.mp4"
volumeMounts:
- name: work
mountPath: /work
resources:
limits: {cpu: "6", memory: "4Gi"}
volumes:
- name: work
hostPath:
path: /home/mathias/dev/AGENTS/agentsquad
type: Directory
@@ -25,9 +25,11 @@ type fakeSource struct {
func (f fakeSource) ListSubscriptions(ctx context.Context, userID string) ([]domain.Subscription, error) { func (f fakeSource) ListSubscriptions(ctx context.Context, userID string) ([]domain.Subscription, error) {
return nil, nil return nil, nil
} }
func (f fakeSource) NewVideos(ctx context.Context, sub domain.Subscription) ([]domain.Video, error) { func (f fakeSource) NewVideos(ctx context.Context, sub domain.Subscription) ([]domain.Video, error) {
return nil, nil return nil, nil
} }
func (f fakeSource) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) { func (f fakeSource) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) {
return f.transcript, nil return f.transcript, nil
} }
@@ -132,10 +134,12 @@ type watchSource struct {
func (s *watchSource) ListSubscriptions(ctx context.Context, userID string) ([]domain.Subscription, error) { func (s *watchSource) ListSubscriptions(ctx context.Context, userID string) ([]domain.Subscription, error) {
return s.subs, nil return s.subs, nil
} }
func (s *watchSource) NewVideos(ctx context.Context, sub domain.Subscription) ([]domain.Video, error) { func (s *watchSource) NewVideos(ctx context.Context, sub domain.Subscription) ([]domain.Video, error) {
s.newVideoHits++ s.newVideoHits++
return s.videosBySub[sub.ID], nil return s.videosBySub[sub.ID], nil
} }
func (s *watchSource) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) { func (s *watchSource) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) {
return s.transcripts[v.ID], nil return s.transcripts[v.ID], nil
} }