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:
+40
-3
@@ -18,7 +18,12 @@ only opaque references to them; the secret material lives in ESO/1Password (ADR-
|
||||
not LLM re-summarization but a rate-gated, reputation-risky caption fetch (ADR-010/014), which
|
||||
is paid per re-fetch regardless of user count — so persisting public caption content once and
|
||||
sharing it strictly beats the coupling it removes. Everything else each user owns is
|
||||
self-contained; `rls_test.go` proves transcripts is the single exception.
|
||||
self-contained; `rls_test.go` proves transcripts is the single exception for *content*.
|
||||
**Uploaded-audio transcripts are the opposite of public caption content** (private, owned by
|
||||
one user) and therefore live in their own RLS-scoped `uploaded_transcripts` table, never in
|
||||
`transcripts` (ADR-033). The isolation test now also pins the **exact set** of tables with row
|
||||
security off (`transcripts`, `user_identities`, `invitations`, `schema_migrations`), so a new
|
||||
table added without RLS fails CI.
|
||||
- **Secrets by reference only.** Tables hold a `secret_ref` (opaque string/UUID resolved via
|
||||
the `SecretStore` port), never tokens or keys.
|
||||
- **The brain sink is just a delivery target.** No brain-specific tables. Whether a summary
|
||||
@@ -41,6 +46,7 @@ erDiagram
|
||||
VIDEO ||--o| SUMMARY : "has at most one"
|
||||
SUMMARY ||--o{ SINK_DELIVERY : "delivered via"
|
||||
USER ||--o{ CHANNEL_ERROR : "reports unavailable channels"
|
||||
USER ||--o{ UPLOADED_TRANSCRIPT : "uploads (private, ADR-033)"
|
||||
|
||||
USER {
|
||||
uuid id PK
|
||||
@@ -136,12 +142,32 @@ erDiagram
|
||||
timestamptz first_seen
|
||||
timestamptz last_seen
|
||||
}
|
||||
UPLOADED_TRANSCRIPT {
|
||||
uuid id PK
|
||||
uuid user_id FK "-> USER, ON DELETE CASCADE, NOT NULL; RLS ENABLE+FORCE (migration 017)"
|
||||
text title
|
||||
text status "queued | transcribing | done | failed"
|
||||
text content "nullable until done; the transcript text"
|
||||
jsonb segments "nullable until done; [{start,end,text}] in seconds"
|
||||
text endpoint "which backend served it: iguana/... | berget/... (berget = audio left the homelab)"
|
||||
float audio_seconds
|
||||
text error_kind "typed: interrupted | transcription_failed — never a provider string"
|
||||
bool had_video "the file carried a video track; only its audio was used (migration 018)"
|
||||
jsonb reconciliation "NULL for a single engine; else status, exact counts and a bounded list of every judged spot (migration 019, ADR-034; all spots stored, ADR-035)"
|
||||
jsonb progress "the job's ordered phases: key, label, state, times, done/total; NULL until the job starts (migration 020, ADR-035)"
|
||||
timestamptz progress_at "when a phase last really changed or advanced (migration 020)"
|
||||
timestamptz alive_at "heartbeat from the running job, every ~10 s; not progress (migration 020)"
|
||||
timestamptz created_at
|
||||
timestamptz finished_at
|
||||
}
|
||||
```
|
||||
|
||||
`SUMMARY_ACTION` has `UNIQUE (user_id, video_id, action)`; `VIDEO_CONNECTION` has
|
||||
`UNIQUE (user_id, provider)` (one connection per provider — reconnect upserts in place).
|
||||
RLS (`ENABLE` + `FORCE`) is on **every solid user-owned table above** — `users`, `videos`,
|
||||
`transcripts`, `summaries`, `summary_actions`, `video_connections`, `channel_errors`.
|
||||
`summaries`, `summary_actions`, `video_connections`, `channel_errors`, `uploaded_transcripts`.
|
||||
(`transcripts` is deliberately **not** in this list: since ADR-021 it is shared public caption
|
||||
content with no `user_id`.)
|
||||
`sink_deliveries` is RLS'd via an `EXISTS` on its parent summary; `user_identities` is
|
||||
intentionally **not** RLS'd (auth plumbing). See the *Isolation invariant* section for the
|
||||
mechanism.
|
||||
@@ -197,6 +223,14 @@ mechanism.
|
||||
the most recent discovery pass. Upserted per scheduler pass (`last_seen` refreshed each run);
|
||||
surfaced on the account page as a warning. Cascades on user deletion. Primary key is
|
||||
`(user_id, channel_id)`. FORCE RLS'd.
|
||||
- **UPLOADED_TRANSCRIPTS** (migration 017, ADR-033) — the **private** transcript of audio a user
|
||||
uploaded. Deliberately a separate table from the shared `transcripts`: that one is non-RLS on the
|
||||
grounds that it holds only public caption content, and a recorded meeting is the opposite.
|
||||
`user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing →
|
||||
done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK
|
||||
constraints). `reconciliation` (migration 019, ADR-034) is NULL when one engine produced the text, so the page can never claim a second opinion that was not there; otherwise it holds the status (`reconciled` / `single_engine` / `skipped`), exact counts (differences, settled automatically, decided by the model, changed, uncertain, model errors) and up to 2,000 judged spots (ADR-035: every one the model decided, kept or changed or unsure, each a position in seconds, both readings, which was used, and the few words before it; the page lists only the changed and uncertain ones). `progress`, `progress_at` and `alive_at` (migration 020, ADR-035) let the page show the job's phases and tell a running job from a quiet one from a lost service: `alive_at` is a heartbeat from a goroutine independent of any engine call, `progress_at` moves only when work really moved; the saved `segments` carry the reconciled words with the base engine's timing. The spots hold fragments of the private transcript, which is why they live in this RLS-scoped row. `had_video` (migration 018) records that the file carried a video track — a Teams meeting with the camera off is still a video file — so the page can say only its audio was used and the video was discarded. `endpoint` records which backend served it — `berget/...` means the audio left the
|
||||
homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90
|
||||
minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion.
|
||||
- **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
|
||||
Stage-0 gate query (VISION §Stage 0: "returned and used in ≥2 distinct weeks").
|
||||
|
||||
@@ -209,7 +243,10 @@ enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the
|
||||
Enforcement is **Postgres Row-Level Security** (migration `003_rls.up.sql`):
|
||||
|
||||
- RLS is `ENABLE`d **and** `FORCE`d on every user-owned table — `users`, `videos`,
|
||||
`transcripts`, `summaries`, `summary_actions`, `video_connections`, `channel_errors`.
|
||||
`summaries`, `summary_actions`, `video_connections`, `channel_errors`, and (migration 017)
|
||||
`uploaded_transcripts`. `transcripts` is **not** one of them: ADR-021 made it shared public
|
||||
content, and migration 003's original per-user `transcripts` policy was dropped with that
|
||||
table in migration 015.
|
||||
`FORCE` is load-bearing: the app connects as the table **owner** (`tapir` role), and owners
|
||||
bypass RLS unless forced.
|
||||
- Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by
|
||||
|
||||
Reference in New Issue
Block a user