Skip to content

feat(api): let a recording carry its own words, name and measurements - #142

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/api-recording-detail
Aug 23, 2026
Merged

feat(api): let a recording carry its own words, name and measurements#142
TheMeinerLP merged 1 commit into
mainfrom
feat/api-recording-detail

Conversation

@TheMeinerLP

@TheMeinerLP TheMeinerLP commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Migration 0013 created every column this round needs and left them empty on purpose. Four of them are the recording page's: session.title, session.description, and transcription_job.sample_rate/channels/stored_bytes. Nothing wrote them and nothing read them. This is the API side of all four, plus the transcript endpoint §4 of the phase-2 spec asks for.

No console change. The shapes are at the bottom, because the UI is built against them next.

The transcript needed no storage, only a door

transcription_job.transcript has always been kept indefinitely — the retention sweep deletes the S3 object and nothing but an enqueue or a requeue clears the column. So GET /api/sessions/{id}/transcript adds no storage; what it adds is a way to read what is already there.

It calls sturnus.application.assembly.assemble. The same function, from the same rows, under the same guild's merge_gap_seconds and the same document_provider, localised to UTC exactly as _create_session_document localises it. A second merge implementation would have drifted from the published protocol, and the drift would have shown up as a console that disagrees with the document about where one speaker stopped and the next began.

Two things assemble needed around it — binding a provider onto the link repository, and turning merge_gap_seconds into a timedelta — moved out of worker.py and into assembly.py as BoundLinks and merge_gap_from. assemble has two callers now; anything both have to do to reach it belongs next to it rather than in one of them. merge_gap_from is also total where the worker's inline expression was not: docs/operations.md §4.1 tells operators they may edit guild_config with SQL, and a merge_gap_seconds of "half a minute" should merge blocks by the default rule, not answer 500 on a tab.

Authorisation is the same call, not the same rule written twice

Every other read port in sturnus.console.ports carries requested_by, because an authorisation a handler applies afterwards is one a handler can forget. TranscriptReader does not, and that is the requirement rather than a gap: the handler asks SessionReads.session_for first — literally the statement /api/sessions/{id} is served from, scoped by session_participant inside the SQL — and answers 404 when it comes back None. Expressing the participant rule a second time, as another WHERE, would have been a second place for the two answers to diverge.

This grants nothing new. The transcript is already inside the protocol document that gets posted to the channel and linked from the session's own row. It is the same words, to the same people, through a different door. What it is still not is searchable — see below.

A session whose audio is gone still has its transcript

That is intended and it is the point of the retention window being about the recording rather than the minutes. So this endpoint answers 200 for a session whose audio endpoints answer 404, and carries audio_available: false so the console can say which of the two happened instead of rendering an empty tab that reads as a bug.

There is a second way for the tab to be empty, and it means the opposite thing, so it gets its own field: pending_tracks is how many of the session's speakers have not been transcribed yet. Zero blocks with a pending track is a meeting still being decoded; zero blocks with none is a meeting nobody spoke in. Both are an empty list and only that number tells them apart.

A session still being recorded is answered rather than refused: its jobs are not enqueued until it closes and assemble cannot place words between a start and an end that does not exist, so ended_at is null, blocks is empty, and pending_tracks says how many are coming.

A title is not a tag, and the difference is who it belongs to

session_tag is keyed by its owner: two people label the same meeting differently and neither reads the other's words. session.title and session.description are one per session and shared by everybody who was in it.

A tag is how one person finds a thing again; a title is what the meeting was. "kunde" and "nochmal ansehen" are notes to self and would be noise — or an opinion published to colleagues — if everyone saw them. "Sprint 34 planning" is not a remark about the meeting, it is its name, and a name four attendees each had to type separately is four names for one thing.

It follows that a participant may overwrite what another participant wrote, and there is no history and no author recorded. That is the trade every shared document makes; the alternative is per-person titles, which is tags again spelled longer.

GET/PUT /api/sessions/{id}/name, participant-authorised, one endpoint for both fields because one form writes both and two endpoints would let a form save half of itself.

The bounds, and why these numbers

  • MAX_TITLE_CHARS = 200. A title is rendered in a heading, a list row and a browser tab, all of which truncate long before this. Two hundred is comfortably more than anybody types into a name field and short enough that no layout has to plan for it; a title needing more than a line is a description.
  • MAX_DESCRIPTION_CHARS = 4000. A few paragraphs — an agenda, the decisions, who is doing what. Deliberately far short of the transcript it sits next to: this field is context for the minutes, not a second copy of them, and an unbounded text column reachable by every participant of every session is a storage decision nobody made.

Otherwise it is stored as it was typed. sturnus.console.naming normalises almost nothing, and that is the contrast with sturnus.console.tags: a tag is lowercased and folded hard because two spellings filter differently and people report that as a tag disappearing, while a title is compared to nothing. Three things do happen and each survives the "store what you are given" rule:

  • Trimmed, and empty becomes null — an empty string and a null would be one fact told two ways.
  • A title collapses to one line (it is rendered where there is no second line); a description keeps its own line breaks, and \r\n from a <textarea> is stored as \n so two identical descriptions are not two different strings.
  • Control characters are refused, not stripped — the same argument tags.normalise makes: a stripped one produces stored text that differs from what was typed in a way nothing on the screen can show.

Search reaches titles, and still not transcripts

?q= now matches session.title and session.description alongside the channel name, participant display names and the reader's own tags. Everything it matches is already in the response this same person gets from /api/sessions, so it narrows what somebody can see rather than widening it — a title a colleague typed is on a meeting the searcher was already in and could already open.

It still does not reach a transcript, and the console says so on screen. A search index over spoken words makes "did anybody ever mention X" a question the system answers, which is a use of a colleague's voice nobody agreed to when they consented to being recorded. sturnus.console.filters argues this at length and the argument is unchanged.

No index, deliberately. ILIKE '%…%' over free text is answered by a GIN trigram index and by no btree, and a trigram index needs CREATE EXTENSION pg_trgm — a privileged statement, in a migration the worker runs in-process at startup, on a deployment whose database role may not be permitted to create extensions. A deployment that cannot come up is worse than a scan, and the scan here is small: the statement has already narrowed to one person's sessions before any pattern is evaluated.

Follow-up, if it is ever wanted: create the extension and the GIN index in a migration that is not on the worker's startup path, where failing to create it is a feature that will not switch on rather than a system that will not start. Nothing in this PR needs it and nothing here should be read as deferring a problem — it is a scan over rows already restricted to one participant.

The file describes itself, once, where both copies exist

sturnus.console.spectrogram.parse_track_format walks the RIFF header live on every request — a ranged GET and a chunk decrypt to answer "how many channels" — and S3AudioStore.size is a second round trip for the object size.

The worker holds the encrypted object and the plaintext WAV it decrypted out of it, and deletes both a few lines later. _recorded_audio reads them there, with wave (the standard library wrote the file; the streaming parser exists because a console request has no file, not because two parsers were wanted), and the value travels to JobQueue.complete alongside the transcript so one lease fences both — a worker that has lost its job must not stamp the row with a size it measured for a copy nobody is waiting for.

A header nothing can parse produces None and leaves the columns null rather than failing the job. Failing — and eventually killing after max_attempts — over a header would trade the words for the file size.

Nullable, no backfill, exactly as audio_seconds was in 0007, and null is not zero. A row predating the columns has audio that may already be deleted, so there is nothing to read them from, and nought hertz would be a claim about a recording nobody looked at. sturnus.console.statistics already insists on that distinction and this follows it.

stored_bytes is the stored object — the encrypted one, the number S3AudioStore.size is asked for — because the question it answers is what keeping this recording costs.

The shapes, since the frontend will code against them

GET /api/sessions/{id}/transcriptCache-Control: private, no-store

{
  "session_id": "4711",
  "started_at": "2026-08-21T12:00:00+00:00",
  "ended_at": "2026-08-21T13:00:00+00:00",
  "audio_available": false,
  "pending_tracks": 0,
  "participants": [ {
    "discord_user_id": "100",
    "display_name": "anna",
    "external_user_id": "c9a1b2e3-…",
    "external_display_name": "Anna A."
  } ],
  "blocks": [ {
    "discord_user_id": "100",
    "display_name": "anna",
    "started_at": "2026-08-21T12:03:11+00:00",
    "text": "wir sind uns einig"
  } ]
}

audio_available: false with a non-empty blocks is the retention case and is not an error state — render the words and say the recording is gone. ended_at: null is a session still being recorded; blocks is then empty and pending_tracks says how many speakers are still to come. 404 for a session that does not exist and for one you were not in, indistinguishably.

GET/PUT /api/sessions/{id}/name

{ "title": "Sprint 34 planning", "description": "what we decided" }

Both members are optional on PUT and absent means null: it is a replace, so a client sending only a title clears the description. 400 with a fixed reason for text over the bounds, for a control character, and for a member that is not text or null; the reason never repeats what was sent. 404 for a session you were not in. The response is the stored pair, which may differ from what was submitted by trimming.

GET /api/sessions and GET /api/sessions/{id} — additive only:

{
  "title": "Sprint 34 planning",
  "description": "what we decided",
  "tracks": [ {
    "discord_user_id": "100",
    "display_name": "anna",
    "audio_seconds": 3600.0,
    "speech_seconds": 240.5,
    "segment_count": 42,
    "sample_rate": 16000,
    "channels": 1,
    "stored_bytes": 1048576
  } ]
}

The three new track members are numbers, not strings — unlike a snowflake, stored_bytes would have to reach nine petabytes before a JSON number lost a digit. All three are null for a job finished before migration 0013, and null must not be rendered as 0.

Checks

2259 passed on this branch, of which 112 are new; mypy, ruff check and ruff format all clean. Rebased onto main at a6e09fb.

Migration 0013 added the columns and nothing read or wrote them. Four
things the recording page needs now exist behind the API:

- `GET /api/sessions/{id}/transcript`, assembled by the very function the
  worker builds the published protocol with, under the same guild's
  `merge_gap_seconds` and `document_provider`. Authorised by calling
  `SessionReads.session_for` -- the same scoped statement the session's
  own metadata endpoint is served from, not a second copy of the rule.
- `GET`/`PUT /api/sessions/{id}/name`, participant-authorised. Titles and
  descriptions are shared, one per session, unlike a tag; the asymmetry
  is deliberate and `sturnus.console.naming` says why.
- Search reaches titles and descriptions, and still does not reach a
  transcript. No trigram index: it would need `CREATE EXTENSION pg_trgm`
  inside the migration the worker runs at startup.
- The worker writes `sample_rate`, `channels` and `stored_bytes` while it
  still holds both copies of a track on disk, and the session's tracks
  serve them -- so a metadata tab costs no S3 round trip. Nullable with
  no backfill, and null is not zero.

A session whose audio retention has expired still has its transcript.
The response says `audio_available: false` rather than looking broken.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant