feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) (#39)
CI / Deploy via GitOps (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 23s

Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it.

- ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only).
- ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set.
- ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments.
- Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check.
- Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge.

Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment.

Closes #33, #34, #42, #43, #44.

Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #39.
This commit is contained in:
2026-10-03 14:13:37 +00:00
committed by mathias
parent 4bafd5f082
commit 4a2067063d
104 changed files with 14214 additions and 311 deletions
+41
View File
@@ -13,6 +13,7 @@ import (
"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"
)
// 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.
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.
// Used by handleRetryNow to clear rate_limited_at before immediate processing.
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
@@ -100,6 +107,26 @@ type App struct {
// summary view shows no "dig deeper" affordance. It holds no caption-fetch
// dependency, so an enabled chat cannot reach YouTube or the rate gate.
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
// UploadChatMaxChars is the transcript budget for chat over an uploaded
// recording. The caption default (18,000 chars, ~22 minutes of speech) would
// keep only the first third of an hour-long meeting.
UploadChatMaxChars int
// UploadChatModel is the model chat over an uploaded recording opens with. Empty
// means the Chatter's own default. It exists because that default is the
// summarizer's (koala/phi4-mini, 8,192-token context) and cannot hold an hour of
// speech.
UploadChatModel string
// UploadChatModels is the set of models chat over an uploaded recording offers and
// accepts. The whole private transcript goes to the one chosen, so it must hold
// LOCAL models only, and the caller decides that. Nil or empty fails closed: no
// model is offered and none is called (ADR-035 review).
UploadChatModels []string
// Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet
@@ -166,6 +193,20 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /v/{videoId}/chat", a.handleChat)
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)
// Chat over the uploaded transcript reuses the ADR-027 Chatter; mounted only
// when one is wired.
if a.Chat != nil {
app.HandleFunc("GET /uploads/{id}/chat", a.handleUploadChat)
app.HandleFunc("POST /uploads/{id}/chat", a.handleUploadChatMessage)
}
}
app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister)