From 6531bb6994a89aaad05e0f4dcfac8d2bfeb34a34 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 15:29:37 +0200 Subject: [PATCH] feat(api): let a recording carry its own words, name and measurements 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. --- src/sturnus/application/assembly.py | 76 +++++ src/sturnus/application/worker.py | 119 ++++--- src/sturnus/console/adapters.py | 185 ++++++++++- src/sturnus/console/app.py | 9 +- src/sturnus/console/filters.py | 39 ++- src/sturnus/console/naming.py | 168 ++++++++++ src/sturnus/console/ports.py | 68 +++- src/sturnus/console/queries.py | 71 +++- src/sturnus/console/routes_recording.py | 236 ++++++++++++++ src/sturnus/console/statistics.py | 202 ++++++++++++ src/sturnus/domain/measurements.py | 52 +++ src/sturnus/entrypoints/api.py | 9 + src/sturnus/infrastructure/db/queue.py | 22 +- src/sturnus/infrastructure/traced.py | 12 +- tests/application/test_assembly.py | 79 ++++- tests/application/test_worker.py | 174 +++++++++- tests/console/conftest.py | 70 +++- tests/console/test_adapters.py | 305 ++++++++++++++++++ tests/console/test_naming.py | 163 ++++++++++ tests/console/test_queries.py | 151 +++++++++ tests/console/test_recording_routes.py | 411 ++++++++++++++++++++++++ tests/console/test_statistics.py | 175 ++++++++++ tests/domain/test_measurements.py | 51 ++- tests/infrastructure/test_queue.py | 81 ++++- tests/infrastructure/test_telemetry.py | 5 +- 25 files changed, 2840 insertions(+), 93 deletions(-) create mode 100644 src/sturnus/console/naming.py create mode 100644 src/sturnus/console/routes_recording.py create mode 100644 tests/console/test_naming.py create mode 100644 tests/console/test_recording_routes.py diff --git a/src/sturnus/application/assembly.py b/src/sturnus/application/assembly.py index c4a0c05..7cafaa7 100644 --- a/src/sturnus/application/assembly.py +++ b/src/sturnus/application/assembly.py @@ -31,6 +31,15 @@ `sturnus.application.worker`) and `JobRepository.transcripts_for`, which reads it back, can agree on the format without either layer reaching into the other. + +`BoundLinks` and `merge_gap_from` are here for the same reason and are +newer: `assemble` has two callers now, not one. The worker builds the +published protocol with it, and the console serves +`GET /api/sessions/{id}/transcript` with it -- and the two must produce +the same reading of the same meeting. Anything either caller has to do +*around* `assemble` to get there therefore belongs next to `assemble` +rather than in one of them, because a second copy is a second place for +the console to disagree with the document. """ from __future__ import annotations @@ -168,3 +177,70 @@ async def assemble( merge_gap, recorded=recorded, ) + + +class LinkRepository(Protocol): + """Where a speaker's external identity is read from, keyed by provider. + + Unlike `LinkReader` above, `provider` is a parameter of + `external_identity` rather than something fixed at construction: one + process serves every guild, and which provider's account-link mapping + applies is itself per-guild configuration (Spec 11's + `document_provider`) that cannot be resolved until a session's guild + is known. `BoundLinks` below adapts one resolved provider back down to + the narrower `LinkReader` shape `assemble` actually calls. + """ + + async def external_identity( + self, discord_user_id: int, provider: str + ) -> tuple[str, str] | None: ... + + +class BoundLinks: + """A `LinkReader` for one provider, over a repository that serves many. + + `assemble` asks "who is this person elsewhere" and has no business + knowing that the answer depends on a guild's `document_provider`; the + repository cannot answer without one. This binds the two, and it lives + here rather than in either caller because both the worker writing a + protocol and the console serving `/api/sessions/{id}/transcript` need + the same binding -- and a second copy of it is a second place for the + console to resolve a speaker's identity differently from the document + that was published about the same meeting. + """ + + def __init__(self, links: LinkRepository, provider: str) -> None: + self._links = links + self._provider = provider + + async def external_identity(self, discord_user_id: int) -> tuple[str, str] | None: + return await self._links.external_identity(discord_user_id, self._provider) + + +def merge_gap_from(configured: str | None) -> timedelta: + """A guild's `merge_gap_seconds` as a `timedelta`, always. + + Total rather than strict, for the reason `sturnus.infrastructure.db. + queue._parallel_track_limit` and `sturnus.application.worker. + _configured_language` are both total: `ConfigStore.set` refuses a + non-integer, but `docs/operations.md` section 4.1 tells operators they + may edit `guild_config` with SQL, and that write never sees the + validation. An unusable value falls back to `DEFAULT_MERGE_GAP` -- + blocks merged by the default rule are a smaller loss than a protocol + that is never written and a transcript tab that answers 500. + + One function rather than one expression in the worker and another in + the console adapter, because the two must agree: how long a pause may + be before a speaker's blocks split decides where the paragraph breaks + fall, and a console showing different paragraphs from the published + document is a console nobody trusts. + """ + if configured is None: + return DEFAULT_MERGE_GAP + try: + seconds = int(configured.strip()) + except (AttributeError, ValueError): + return DEFAULT_MERGE_GAP + if seconds < 0: + return DEFAULT_MERGE_GAP + return timedelta(seconds=seconds) diff --git a/src/sturnus/application/worker.py b/src/sturnus/application/worker.py index 1643edb..e674523 100644 --- a/src/sturnus/application/worker.py +++ b/src/sturnus/application/worker.py @@ -79,10 +79,10 @@ therefore its guild -- is in hand. The first two are read in `process_one` itself, just before the engine is called; the last three inside `_create_session_document`. None of them is read once at process start, -because one worker serves every guild. `_BoundLinks` -adapts one call's resolved provider back down to the plain `LinkReader` -shape `assemble` itself calls, so `assemble` stays ignorant of -configuration entirely. +because one worker serves every guild. `sturnus.application.assembly. +BoundLinks` adapts one call's resolved provider back down to the plain +`LinkReader` shape `assemble` itself calls, so `assemble` stays ignorant +of configuration entirely. """ from __future__ import annotations @@ -92,12 +92,20 @@ import shutil import time import uuid -from datetime import UTC, datetime, timedelta, tzinfo +import wave +from datetime import UTC, datetime, tzinfo from pathlib import Path from typing import Protocol, cast from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from sturnus.application.assembly import JobReader, assemble, serialize_transcript +from sturnus.application.assembly import ( + BoundLinks, + JobReader, + LinkRepository, + assemble, + merge_gap_from, + serialize_transcript, +) from sturnus.application.documents import ( ChannelRef, CreatedDocument, @@ -107,8 +115,7 @@ ) from sturnus.application.transcription import TranscriptionEngine from sturnus.domain import settings as domain_settings -from sturnus.domain.measurements import JobMeasurements -from sturnus.domain.transcript import DEFAULT_MERGE_GAP +from sturnus.domain.measurements import JobMeasurements, RecordedAudio from sturnus.observability.events import Event, log_event, log_exception log = logging.getLogger(__name__) @@ -141,6 +148,13 @@ class Queue(Protocol): taken over by another one -- see `sturnus.infrastructure.db.queue. JobQueue.complete`. `process_one` always has one, so it always passes one. + + `audio` travels with the transcript rather than through a write of its + own, because it is read off the same two files in the same breath and + is fenced by the same lease: a worker that has lost its job must not + stamp the row with a size it measured for a copy nobody is waiting + for. `None` when the header could not be read, which leaves the + columns null -- see `_recorded_audio`. """ async def claim(self) -> object | None: ... @@ -152,6 +166,7 @@ async def complete( measurements: JobMeasurements | None = None, *, lease: datetime | None = None, + audio: RecordedAudio | None = None, ) -> bool: ... #: Returns whether the job is now **dead** -- out of attempts, so this @@ -193,39 +208,6 @@ class ConfigReader(Protocol): async def get(self, guild_id: int, key: str) -> str | None: ... -class LinkRepository(Protocol): - """Where a speaker's external identity is read from, keyed by provider. - - Unlike `sturnus.application.assembly.LinkReader`, `provider` is a - parameter of `external_identity` here rather than fixed once at - construction: the worker serves every guild from one process, and - which provider's account-link mapping applies is itself per-guild - configuration (Spec 11's `document_provider`) that cannot be resolved - until a session's guild is known. `_BoundLinks` below adapts one - resolved provider back down to the narrower `LinkReader` shape - `assemble` actually calls. - """ - - async def external_identity( - self, discord_user_id: int, provider: str - ) -> tuple[str, str] | None: ... - - -class _BoundLinks: - """Adapts `LinkRepository` to `sturnus.application.assembly.LinkReader` - for one already-resolved provider, so `assemble` -- which knows - nothing about per-guild configuration -- can keep calling - `external_identity` with just a Discord user id. - """ - - def __init__(self, links: LinkRepository, provider: str) -> None: - self._links = links - self._provider = provider - - async def external_identity(self, discord_user_id: int) -> tuple[str, str] | None: - return await self._links.external_identity(discord_user_id, self._provider) - - class SessionStore(Protocol): """The session-scoped bookkeeping this job needs. @@ -329,6 +311,40 @@ def _configured_language(configured: str | None) -> str | None: return named +def _recorded_audio(plaintext: Path, stored: Path) -> RecordedAudio | None: + """What this track is, read off the two files the job already has. + + The one moment in the system where both exist at once: the encrypted + object has just been downloaded and the plaintext WAV has just been + decrypted out of it, and both are deleted a few lines later. Every + later reader -- the spectrogram, a metadata tab -- would otherwise pay + a ranged GET and a chunk decrypt to walk the same RIFF header, plus a + second round trip to ask S3 how big the object is. + + Read with `wave` rather than by walking the chunk list as + `sturnus.console.spectrogram.parse_track_format` does, because the + file is on local disk here and the standard library is already the + writer: `sturnus.infrastructure.audio.SpeakerWriter` produced this + file through `wave`. The streaming reader exists because a console + request has no file, not because two parsers were wanted. + + `None` rather than an exception for anything unreadable. This is + metadata about a recording whose transcript is the point, and failing + a job -- and eventually killing it after `max_attempts` -- over a + header nobody can parse would trade the words for the file size. A + null column says "nobody could look", which is the truth. + """ + try: + with wave.open(str(plaintext), "rb") as track: + return RecordedAudio( + sample_rate=track.getframerate(), + channels=track.getnchannels(), + stored_bytes=stored.stat().st_size, + ) + except (OSError, wave.Error, ValueError, EOFError): + return None + + async def _guild_timezone(config: ConfigReader, guild: int) -> tzinfo: """The timezone the protocol's times are written in (Spec 11). @@ -388,7 +404,7 @@ async def _create_session_document( administrator has not configured this yet" -- unlike a rejected token or a deleted collection, this can and does resolve itself. - `document_provider`: which provider's account-link mapping a - speaker's external identity is read from, via `_BoundLinks`. + speaker's external identity is read from, via `BoundLinks`. - `merge_gap_seconds`: how long a pause may be before one speaker's blocks split, forwarded to `assemble`. """ @@ -407,15 +423,10 @@ async def _create_session_document( # key always resolves to a value. assert provider is not None - merge_gap_value = await config.get(guild, domain_settings.MERGE_GAP_SECONDS) - merge_gap = ( - timedelta(seconds=int(merge_gap_value)) - if merge_gap_value is not None - else DEFAULT_MERGE_GAP - ) + merge_gap = merge_gap_from(await config.get(guild, domain_settings.MERGE_GAP_SECONDS)) transcript = await assemble( - session_id, sessions, jobs, _BoundLinks(links, provider), UTC, merge_gap + session_id, sessions, jobs, BoundLinks(links, provider), UTC, merge_gap ) # `assemble` works in UTC deliberately -- ordering and merging must not @@ -640,8 +651,16 @@ async def process_one( # not rare on a long track; what must not happen is two workers # each storing a transcript for it and each reporting the # session's last job, which creates the protocol twice. + # Read before the `finally` below removes both files, and + # written in the same call as the transcript so one lease + # fences both. See `_recorded_audio` for why an unreadable + # header leaves the columns null rather than failing the job. is_last = await queue.complete( - job.id, serialize_transcript(result), result.measurements, lease=job.claimed_at + job.id, + serialize_transcript(result), + result.measurements, + lease=job.claimed_at, + audio=_recorded_audio(wav_path, encrypted_path), ) except Exception as exc: # Everything other than the transcription failure already diff --git a/src/sturnus/console/adapters.py b/src/sturnus/console/adapters.py index 7f2c05e..afbaccf 100644 --- a/src/sturnus/console/adapters.py +++ b/src/sturnus/console/adapters.py @@ -24,11 +24,12 @@ from datetime import UTC, datetime, timedelta, tzinfo from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from sqlalchemy import Row, case, delete, distinct, func, or_, select +from sqlalchemy import Row, case, delete, distinct, func, or_, select, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.sql import Subquery +from sturnus.application.assembly import BoundLinks, assemble, merge_gap_from from sturnus.application.collection_mirror import MirroredCollection from sturnus.application.directory_mirror import ( MirroredChannel, @@ -60,6 +61,7 @@ Track, ) from sturnus.console.reporting import RecordedSession +from sturnus.console.statistics import SessionName, SessionTranscript from sturnus.domain import settings from sturnus.domain.consent import ( ConsentRecord, @@ -82,8 +84,13 @@ TranscriptionJob, ) from sturnus.infrastructure.db.models import Session as SessionRow -from sturnus.infrastructure.db.queue import DEFAULT_LEASE_SECONDS -from sturnus.infrastructure.db.repositories import AccountLinkRepository, ConsentRepository +from sturnus.infrastructure.db.queue import DEFAULT_LEASE_SECONDS, TERMINAL_STATUSES +from sturnus.infrastructure.db.repositories import ( + AccountLinkRepository, + ConsentRepository, + JobRepository, + SessionRepository, +) from sturnus.infrastructure.db.requeue import ( ActiveSession, SessionView, @@ -432,6 +439,178 @@ async def replace( return tuple(sorted(wanted)) +class ConsoleSessionNaming: + """Names one session, if the person asking was in it. + + The same shape and the same rule as `ConsoleTagWriter` above, and + deliberately not the same class: a tag is one person's private label + and a title is the session's shared name, so the two write different + tables under different keys and only their authorisation is common + (see `sturnus.console.naming`). + + The authorisation is the first statement and there is no path past + it. A session that does not exist and one this person was not in both + answer `None` -- the same 404, for the same reason the audio endpoint + gives it: a 403 would confirm that a meeting exists to somebody just + established as having no part in it. + + **A blind write, on purpose.** The update sets both columns to what + it was handed, without reading what was there first. Two participants + renaming a meeting in the same minute is a last-writer-wins, which is + what a shared name is; the alternative is a version token on a field + two people edit twice a year, and a conflict dialogue nobody would + know what to do with. + """ + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def rename( + self, session_id: int, *, by: int, title: str | None, description: str | None + ) -> SessionName | None: + """The name afterwards, or `None` for a session not theirs.""" + async with self._session_factory() as db: + was_there = await db.scalar( + select(SessionParticipant.id) + .where( + SessionParticipant.session_id == session_id, + SessionParticipant.discord_user_id == by, + ) + .limit(1) + ) + if was_there is None: + return None + await db.execute( + update(SessionRow) + .where(SessionRow.id == session_id) + .values(title=title, description=description) + ) + await db.commit() + # What was stored, which is what was passed in: `naming` already + # trimmed it at the edge and this method changes nothing further, + # so re-reading the row would be a round trip to be told what this + # call just said. + return SessionName(title=title, description=description) + + +class ConsoleTranscripts: + """A session's assembled transcript, built the way the protocol was. + + `assemble` is called rather than reimplemented, and that is the whole + design of this class: the worker builds the published document with + the same function, from the same rows, under the same guild's + `merge_gap_seconds` and `document_provider`. A second merge here + would be a console that quietly disagrees with the document about + where one speaker stopped and the next began. + + Localised to UTC, exactly as `_create_session_document` assembles in + UTC: ordering and merging must not depend on an offset, and the times + go out as ISO 8601 with theirs attached for the console to render in + the viewer's own zone (the argument `sturnus.console.statistics. + calendar_year` makes about a person who meets across several guilds). + + **This class does not authorise anything, and must not be reached + without something that did.** See `sturnus.console.ports. + TranscriptReader` for why the participant rule is the caller's + `SessionReads.session_for` and not a second `WHERE` here. + """ + + def __init__( + self, + session_factory: async_sessionmaker[AsyncSession], + config: SettingsStore, + ) -> None: + self._session_factory = session_factory + self._config = config + # The three readers `assemble` declares as protocols, built once + # rather than per request: each is a thin wrapper over the same + # factory, and naming them here is what shows at a glance that + # this class reads exactly the rows the worker's document does. + self._sessions = SessionRepository(session_factory) + self._jobs = JobRepository(session_factory) + self._links = AccountLinkRepository(session_factory) + + async def transcript_of(self, session_id: int) -> SessionTranscript | None: + async with self._session_factory() as db: + bounds = ( + await db.execute( + select(SessionRow.guild_id, SessionRow.started_at, SessionRow.ended_at).where( + SessionRow.id == session_id + ) + ) + ).first() + if bounds is None: + return None + # One statement for both counts. `audio_deleted_at` is per + # job, so "the audio is gone" is a property of the whole + # session only when every one of its tracks has been swept -- + # and a session with no tracks at all has nothing to play + # either, which is the same answer. + tallies = ( + await db.execute( + select( + func.count() + .filter(TranscriptionJob.audio_deleted_at.is_(None)) + .label("with_audio"), + func.count() + # `TERMINAL_STATUSES` is the queue's own + # answer to "this job is over": `done` wrote + # a transcript and `dead` never will. + # Everything else is a speaker the tab is + # still waiting for. + .filter(TranscriptionJob.status.not_in(TERMINAL_STATUSES)) + .label("pending"), + ).where(TranscriptionJob.session_id == session_id) + ) + ).one() + + started_at, ended_at = bounds.started_at, bounds.ended_at + audio_available = tallies.with_audio > 0 + if ended_at is None: + # A session still being recorded has no transcript to + # assemble: its jobs are not enqueued until it closes, and + # `assemble` cannot place words between a start and an end + # that does not exist yet. Answered rather than refused, so + # the tab can say "still recording" instead of 404. + return SessionTranscript( + session_id=session_id, + started_at=started_at, + ended_at=None, + audio_available=audio_available, + pending_tracks=tallies.pending, + participants=(), + blocks=(), + ) + + stored = await self._config.snapshot(bounds.guild_id) + # Which provider's account-link mapping names a speaker outside + # Discord. The same key `_create_session_document` reads, so the + # transcript tab attributes a block to the same person the + # published document does. `snapshot` layers `DEFAULTS` over the + # stored rows and this key has one, so the fallback is only ever + # reached by a `SettingsStore` that answers with nothing. + provider = ( + stored.get(settings.DOCUMENT_PROVIDER) or settings.DEFAULTS[settings.DOCUMENT_PROVIDER] + ) + transcript = await assemble( + session_id, + self._sessions, + self._jobs, + BoundLinks(self._links, provider), + UTC, + merge_gap_from(stored.get(settings.MERGE_GAP_SECONDS)), + ) + return SessionTranscript( + session_id=session_id, + started_at=transcript.session_started_at, + ended_at=transcript.session_ended_at, + audio_available=audio_available, + pending_tracks=tallies.pending, + participants=transcript.participants, + blocks=transcript.blocks, + ) + + class ConsoleQueueControl: """Adapts the shared re-queue machinery to the console's `QueueControl`. diff --git a/src/sturnus/console/app.py b/src/sturnus/console/app.py index 2ce3350..05e1764 100644 --- a/src/sturnus/console/app.py +++ b/src/sturnus/console/app.py @@ -26,7 +26,7 @@ from aiohttp import web -from sturnus.console import routes_settings, routes_tags +from sturnus.console import routes_recording, routes_settings, routes_tags from sturnus.console.audio import AudioDelivery from sturnus.console.auth import ( ConsoleAuth, @@ -47,10 +47,12 @@ ProfileDirectory, QueueControl, QueueOverview, + SessionNaming, SessionReads, SettingsStore, StateStore, TagWriter, + TranscriptReader, ) from sturnus.console.routes_audio import AUDIO_DELIVERY from sturnus.console.routes_audio import register as register_audio @@ -279,6 +281,8 @@ def build_api( prefs: PreferenceDirectory, names: GuildNames, collections: CollectionNames, + transcripts: TranscriptReader, + naming: SessionNaming, ) -> web.Application: """Builds the application, with every collaborator injected. @@ -313,6 +317,8 @@ def build_api( app[PREFERENCES] = prefs app[GUILD_NAMES] = names app[COLLECTION_NAMES] = collections + app[routes_recording.TRANSCRIPTS] = transcripts + app[routes_recording.SESSION_NAMING] = naming app.add_routes( [ web.get("/healthz", healthz), @@ -332,4 +338,5 @@ def build_api( register_directory(app) routes_settings.register(app) routes_tags.register(app) + routes_recording.register(app) return app diff --git a/src/sturnus/console/filters.py b/src/sturnus/console/filters.py index 191557d..c8783ce 100644 --- a/src/sturnus/console/filters.py +++ b/src/sturnus/console/filters.py @@ -6,8 +6,9 @@ ## What is searched, and what deliberately is not -**Metadata only: the channel, the date, who was there, and the reader's -own tags.** Not the transcripts, and not the protocols. +**Metadata only: the channel, the date, who was there, what somebody +named the meeting, and the reader's own tags.** Not the transcripts, and +not the protocols. That is a decision about other people's speech and not a limitation of the implementation. Everybody in a recorded session consented to being @@ -19,10 +20,20 @@ the room agreed to when they clicked yes. Everything this module *does* search is already on the page. A session's -channel, its times, the names of everybody who was in it and the labels -this reader wrote are all in the response `/api/sessions` has always -returned to this same person. Filtering by them narrows what somebody can -already see; it does not widen it. +channel, its times, the names of everybody who was in it, its title and +description, and the labels this reader wrote are all in the response +`/api/sessions` returns to this same person. Filtering by them narrows +what somebody can already see; it does not widen it. + +A title and a description are the one searchable thing here that somebody +*typed about the meeting* rather than something the system observed, and +they are searchable for exactly that reason: a person writes "Sprint 34 +planning" on a recording so that they can find it again by typing +"sprint". They are also shared, unlike a tag, so a search over them can +find a meeting a colleague named -- which is a meeting the searcher was +already in and could already open. The console still says on screen that +search does not reach transcripts, because that remains true and is the +part people assume otherwise. If content search is wanted later, the shape it must have is fixed by Section 3.3 of the console design and by nothing in this file: the @@ -53,9 +64,12 @@ from sturnus.console.tags import InvalidTag, normalise #: The longest search text accepted. Not a storage limit -- it is what -#: keeps a `LIKE` pattern from being megabytes long, and a hundred -#: characters is already longer than any channel name, display name or -#: tag it could match. +#: keeps a `LIKE` pattern from being megabytes long. A hundred characters +#: is longer than any channel name, display name or tag it could match, +#: and shorter than a title or a description, which it is matched +#: *within*: nobody searches by pasting a whole paragraph, and a pattern +#: long enough to be one is a pattern the planner has to walk every row +#: for. MAX_QUERY_CHARS = 100 #: How many tags one request may filter by at once. They are combined with @@ -78,9 +92,10 @@ class SessionFilter: than being spelled as a wildcard somebody has to remember to write. """ - #: Free text, matched against the channel name, the display names of - #: everybody who was in the session, and this reader's own tags. - #: Never against a transcript; see the module docstring. + #: Free text, matched against the channel name, the session's title + #: and description, the display names of everybody who was in it, and + #: this reader's own tags. Never against a transcript; see the module + #: docstring. text: str | None #: Tags the recording must carry, all of them. AND rather than OR #: because a second chip is somebody narrowing a list -- selecting diff --git a/src/sturnus/console/naming.py b/src/sturnus/console/naming.py new file mode 100644 index 0000000..2f1108a --- /dev/null +++ b/src/sturnus/console/naming.py @@ -0,0 +1,168 @@ +"""What a meeting is called, and what somebody wrote down about it. + +A tag and a title look like the same feature and are not, and the +difference is who they belong to. `session_tag` is keyed by its owner: +two people label the same meeting differently and neither sees the +other's words, because a label is a private remark about a conversation +other people were also in (see `sturnus.console.tags`). `session.title` +and `session.description` are one per session and are shared by everybody +who was in it. + +That asymmetry is deliberate. **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 worse, an opinion published to +colleagues -- if everyone saw them. "Sprint 34 planning" is not a remark +about the meeting, it is the meeting's name, and a name that four +attendees each had to type separately is four names for one thing. So a +title is written once, by whoever gets there first, and anybody who was +in the room may correct it. + +It follows that a participant may overwrite what another participant +wrote, and there is no history. That is the same trade every shared +document makes, and the alternative -- per-person titles -- is just tags +again, spelled longer. + +Everything here is pure, for the same reason `sturnus.console.tags` and +`sturnus.console.statistics` are: what a title may be is a rule, and a +rule that can only be exercised through a database is a rule nobody +exercises. + +## What is *not* done to the text + +Almost nothing, which is the point. A title is prose somebody typed and +not a slug: no lowercasing, no case folding, no punctuation stripping, no +de-duplication against anything. `sturnus.console.tags` normalises hard +because two spellings of one tag filter differently and people report +that as a tag disappearing; nothing about a title is compared to anything, +so there is nothing for a normalisation to protect and everything for it +to spoil. + +Three things are done, and each has a reason that survives the "store +what you are given" rule: + +- **Trimmed, and empty becomes null.** An empty string and a null would + be the same fact told two ways -- "nobody has named this" -- and a + column holding both is a column every reader has to check twice. +- **A title is collapsed to one line.** It is rendered in a heading, in a + list row and in a browser tab, none of which has a second line, so a + newline in it is a paste accident rather than a decision. A + description keeps its own line breaks: it is a paragraph and paragraphs + have them. +- **Control characters are refused rather than stripped.** Stripping one + produces text that differs from what was typed in a way nothing on the + screen can show, which is the same argument + `sturnus.console.tags.normalise` makes. +""" + +from __future__ import annotations + +import re +import unicodedata + +#: The longest a title may be. A title names a meeting -- "Sprint 34 +#: planning", "Kunde OneLiteFeather, Kickoff" -- and is rendered in a +#: heading, a list row and a browser tab, all of which truncate long +#: before this. Two hundred characters is comfortably more than anybody +#: types into a name field and short enough that no layout has to plan +#: for it; a title that needs more than a line is a description. +MAX_TITLE_CHARS = 200 + +#: The longest a description may be. A few paragraphs: enough for an +#: agenda, the decisions taken and who is doing what, which is what +#: people actually write under a recording. 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. +MAX_DESCRIPTION_CHARS = 4000 + +#: Any run of whitespace, collapsed to one space in a title. `\s` under +#: `re.UNICODE` (the default for `str` patterns) covers the non-breaking +#: space and the ideographic space too, both of which arrive from a paste +#: and neither of which is visible in an input field. +_WHITESPACE = re.compile(r"\s+") + +#: The one control character a description may contain. A newline is what +#: makes prose prose; everything else in Unicode's `C` category -- a NUL, +#: a bidirectional override, a stray form feed -- is refused. +_NEWLINE = "\n" + + +class InvalidName(ValueError): + """A title or description that cannot be stored, free of the text itself. + + The reason is a fixed string on purpose, exactly as + `sturnus.console.tags.InvalidTag`'s is. It travels into an HTTP + response body, and the rule this API holds throughout is that no user + input is reflected into one. + """ + + +def normalise_title(raw: str | None) -> str | None: + """One line naming a meeting, or `None` for a meeting nobody has named. + + NFC first, so a composed and a decomposed `ü` are stored as one + string rather than as whichever the keyboard that typed them + produced; then every run of whitespace becomes one space and the ends + are trimmed. Case is left exactly as it was typed -- unlike a tag, + which is lowercased because it is compared, a title is only ever + displayed. + + Empty after trimming is `None` rather than `""`: clearing the field is + how somebody un-names a meeting, and the absence of a title has one + spelling in the database. + """ + if raw is None: + return None + collapsed = _WHITESPACE.sub(" ", unicodedata.normalize("NFC", raw)).strip() + if not collapsed: + return None + # After the collapse, not before it: a tab and a newline are both + # control characters and both mean "somebody pasted this", which is an + # ordinary title once the whitespace is one space. What is left at + # this point is a character that renders as nothing or reverses the + # text around it. + _refuse_control_characters(collapsed, "a title", allow_newlines=False) + if len(collapsed) > MAX_TITLE_CHARS: + raise InvalidName(f"a title may be at most {MAX_TITLE_CHARS} characters") + return collapsed + + +def normalise_description(raw: str | None) -> str | None: + """What somebody wrote about a meeting, or `None` for nothing written. + + Trimmed at the ends and otherwise stored as it arrived: the line + breaks between paragraphs are the shape of the text and collapsing + them would turn an agenda into a run-on sentence. NFC for the same + reason a title gets it, and for nothing further -- this is prose. + + Carriage returns are dropped rather than kept. A browser submits + `\\r\\n` from a `