[Backend] Persist transcript segment rows and render the text format as a view #335

Closed
opened 2026-08-25 20:38:54 +00:00 by claude-bot · 1 comment
Contributor

Severity: HIGH. Found in the August 2026 session lifecycle review (#319).

The defect

The transcript is persisted as a single flat Text column (webapp/backend/app/models/session.py:152) with no segment rows. Four places then re-parse that text format, which makes the rendering format a load-bearing data contract:

  • reminder_tasks.py:2786 — member erasure regex-parses the blob (_TRANSCRIPT_LINE_RE)
  • audio_service.py:1052-1067 — highlight quotes validated by substring against the blob, while the stored speaker and timestamp_ref are LLM-asserted and never checked
  • lore_service.py:236-285chunk_text hard-cuts by character count and can split mid-line, so a chunk can open with unattributed text
  • SessionDetail.jsx:373-408 — the GM free-edits the raw blob, and a hand edit can break the erasure regex

Proposed fix

Add a segment table: session_id, track_owner_id, speaker_label, start, end, text, span_idx. Write it in process_audio; make merge_attributed_transcript a renderer over those rows.

Note the justification has shifted since the capture fix: this is no longer about timeline fidelity, it is about structural robustness. Erasure operates on rows instead of a regex. Highlights become validatable. Per-speaker QA ("this track had 40 minutes of speech and zero segments") becomes a query. track_owner_id makes a server-side speaker-label override detectable. And the text format can be re-rendered — ranges, scene blocks, anything — without re-transcribing.

Decide explicitly how GM free-editing interacts: either edits write back to rows, or the blob overrides rows with rows frozen as provenance. Do not leave it implicit.

Acceptance criteria

  • Segment rows are written by process_audio and backfilled where a transcript exists
  • merge_attributed_transcript renders from rows
  • Member erasure operates on rows, and _TRANSCRIPT_LINE_RE is deleted
  • Lore chunking splits on segment boundaries, never mid-line
  • GM edit semantics are documented and tested
  • A per-speaker QA query exists
**Severity: HIGH.** Found in the August 2026 session lifecycle review (#319). ## The defect The transcript is persisted as a single flat `Text` column (`webapp/backend/app/models/session.py:152`) with no segment rows. Four places then re-parse that text format, which makes the rendering format a load-bearing data contract: - `reminder_tasks.py:2786` — member erasure regex-parses the blob (`_TRANSCRIPT_LINE_RE`) - `audio_service.py:1052-1067` — highlight quotes validated by substring against the blob, while the stored `speaker` and `timestamp_ref` are LLM-asserted and never checked - `lore_service.py:236-285` — `chunk_text` hard-cuts by character count and can split mid-line, so a chunk can open with unattributed text - `SessionDetail.jsx:373-408` — the GM free-edits the raw blob, and a hand edit can break the erasure regex ## Proposed fix Add a segment table: `session_id`, `track_owner_id`, `speaker_label`, `start`, `end`, `text`, `span_idx`. Write it in `process_audio`; make `merge_attributed_transcript` a **renderer** over those rows. Note the justification has shifted since the capture fix: this is no longer about timeline fidelity, it is about **structural robustness**. Erasure operates on rows instead of a regex. Highlights become validatable. Per-speaker QA ("this track had 40 minutes of speech and zero segments") becomes a query. `track_owner_id` makes a server-side speaker-label override detectable. And the text format can be re-rendered — ranges, scene blocks, anything — without re-transcribing. Decide explicitly how GM free-editing interacts: either edits write back to rows, or the blob overrides rows with rows frozen as provenance. Do not leave it implicit. ## Acceptance criteria - [ ] Segment rows are written by `process_audio` and backfilled where a transcript exists - [ ] `merge_attributed_transcript` renders from rows - [ ] Member erasure operates on rows, and `_TRANSCRIPT_LINE_RE` is deleted - [ ] Lore chunking splits on segment boundaries, never mid-line - [ ] GM edit semantics are documented and tested - [ ] A per-speaker QA query exists
Author
Contributor

Verified; remaining gaps closed in 9dd7122. Two criteria are recorded as deliberately-not-done rather than met.

Criteria

  • Segment rows are written by process_audio and backfilled where a transcript exists — writing yes; backfill deliberately refused, see below.
  • merge_attributed_transcript renders from rowsnot as written, see below.
  • Member erasure operates on rows, and _TRANSCRIPT_LINE_RE is deleted — first half met; the regex stays, deliberately.
  • Lore chunking splits on segment boundaries, never mid-line — was true on multi-pass, false on single-pass. Fixed.
  • GM edit semantics are documented and tested — tested, and now documented.
  • A per-speaker QA query existswas not built. Now is.

The single-pass path never chunked on boundaries at all

Multi-pass packs whole TranscriptSegment rows, so a chunk can only begin and end between utterances. Single-pass — admin-selectable via LORE_PIPELINE_SINGLE_PASS — ran sample_evenly straight over the raw transcript characters, cutting mid-utterance freely. Exactly the defect this issue exists to prevent, on the path nobody looked at.

sample_evenly_over_segments keeps the property that matters for single-pass — coverage across the whole session, not the first twenty minutes — while only ever cutting between whole lines. Sessions with no rows keep the old behaviour.

The test first asserts the character sampler does produce a mid-line cut on that fixture, so it cannot pass vacuously.

chunk_segments had silently dropped chunk_text's overlap

chunk_text takes overlap_chars, documented as existing "so entities mentioned at chunk edges are not missed". The row-based replacement took no overlap parameter, so every post-#335 session lost that property with nothing saying so. Now carried as whole segments, and dropped rather than padded when it would push a chunk past its budget — an overlap that overflows the prompt would reintroduce the risk the chunker exists to remove.

Chunk text did not match the transcript for simultaneous speech

_render_segment_line always emitted a single [HH:MM:SS], while the stored transcript renders an overlapping line as [start-end] (#345). The extractor read a different rendering than the GM sees, for precisely the lines where two people talked at once. It now goes through audio_service.mark_overlapping — the same function the transcript renderer uses, not a second copy of the rule — and the test asserts byte equality with render_transcript_from_rows.

Two criteria that will not be met, and should be closed as such

"Backfilled where a transcript exists" was refused by migration a2b3c4d5e6f8, and the reasoning is right: the structure old blobs would need was never captured, and a best-effort re-parse produces rows that look authoritative and are not. Same call as #328's pre_timeline_fix marker. The criterion should be struck rather than left open.

"_TRANSCRIPT_LINE_RE is deleted" cannot follow while that holds. It still runs for sessions recorded before rows existed, and for a transcript a GM has hand-edited away from its rows. Rows are primary — _scrub_session_transcript scrubs by track_owner_id — but the regex is the fallback for a corpus that will never have rows.

"merge_attributed_transcript renders from rows" is likewise not literally true: there are two renderers, merge_attributed_transcript over in-memory dicts and render_transcript_from_rows over rows, kept byte-identical by a test. The "text is a view of the rows, one renderer" refactor did not happen. Recording it plainly rather than ticking the box — the property holds, the structure the criterion asked for does not.

GM edit semantics

Implemented and tested, documented only in code comments; docs/ mentioned none of this. Now in docs/API.md, and flagged as what it actually is: a third option, neither of the two the issue proposed. On erasure of a hand-edited transcript the code writes to both — redacting the rows and separately scrubbing the text — so rows are not "frozen as provenance", they are mutated. Defensible for a legal erasure, and now written down.

Per-speaker QA

transcript_qa_service plus a GM-only endpoint, grouped by (track_owner_id, speaker_label) so a mid-session rename cannot merge two people.

It states its own limitation rather than overselling: audio duration per track is not stored beside the rows, so it answers "how much did each speaker's transcript contain" and can show a track that looks thin — but it cannot alone confirm the motivating case, "this track had 40 minutes of speech and zero segments". It raises the suspicion; resolving it needs the source audio.

Also added the two summarisation-runs endpoints from #333/#424, which docs/API.md had never mentioned.

Closing, on the understanding that the three struck criteria are closed as won't-do rather than done.

**Verified; remaining gaps closed in `9dd7122`.** Two criteria are recorded as deliberately-not-done rather than met. ## Criteria - [ ] **Segment rows are written by `process_audio` and backfilled where a transcript exists** — writing yes; **backfill deliberately refused**, see below. - [ ] **`merge_attributed_transcript` renders from rows** — **not as written**, see below. - [ ] **Member erasure operates on rows, and `_TRANSCRIPT_LINE_RE` is deleted** — first half met; the regex **stays**, deliberately. - [x] **Lore chunking splits on segment boundaries, never mid-line** — was true on multi-pass, **false on single-pass**. Fixed. - [x] **GM edit semantics are documented and tested** — tested, and now documented. - [x] **A per-speaker QA query exists** — **was not built.** Now is. ## The single-pass path never chunked on boundaries at all Multi-pass packs whole `TranscriptSegment` rows, so a chunk can only begin and end between utterances. Single-pass — admin-selectable via `LORE_PIPELINE_SINGLE_PASS` — ran `sample_evenly` straight over the raw transcript **characters**, cutting mid-utterance freely. Exactly the defect this issue exists to prevent, on the path nobody looked at. `sample_evenly_over_segments` keeps the property that matters for single-pass — coverage across the whole session, not the first twenty minutes — while only ever cutting between whole lines. Sessions with no rows keep the old behaviour. The test first asserts the **character** sampler *does* produce a mid-line cut on that fixture, so it cannot pass vacuously. ## chunk_segments had silently dropped chunk_text's overlap `chunk_text` takes `overlap_chars`, documented as existing "so entities mentioned at chunk edges are not missed". The row-based replacement took no overlap parameter, so **every post-#335 session lost that property** with nothing saying so. Now carried as whole segments, and dropped rather than padded when it would push a chunk past its budget — an overlap that overflows the prompt would reintroduce the risk the chunker exists to remove. ## Chunk text did not match the transcript for simultaneous speech `_render_segment_line` always emitted a single `[HH:MM:SS]`, while the stored transcript renders an overlapping line as `[start-end]` (#345). The extractor read a different rendering than the GM sees, for precisely the lines where two people talked at once. It now goes through `audio_service.mark_overlapping` — the same function the transcript renderer uses, not a second copy of the rule — and the test asserts byte equality with `render_transcript_from_rows`. ## Two criteria that will not be met, and should be closed as such **"Backfilled where a transcript exists"** was refused by migration `a2b3c4d5e6f8`, and the reasoning is right: the structure old blobs would need was never captured, and a best-effort re-parse produces rows that *look authoritative and are not*. Same call as #328's `pre_timeline_fix` marker. The criterion should be struck rather than left open. **"`_TRANSCRIPT_LINE_RE` is deleted"** cannot follow while that holds. It still runs for sessions recorded before rows existed, and for a transcript a GM has hand-edited away from its rows. Rows *are* primary — `_scrub_session_transcript` scrubs by `track_owner_id` — but the regex is the fallback for a corpus that will never have rows. **"`merge_attributed_transcript` renders from rows"** is likewise not literally true: there are two renderers, `merge_attributed_transcript` over in-memory dicts and `render_transcript_from_rows` over rows, kept byte-identical by a test. The "text is a view of the rows, one renderer" refactor did not happen. Recording it plainly rather than ticking the box — the *property* holds, the structure the criterion asked for does not. ## GM edit semantics Implemented and tested, documented only in code comments; `docs/` mentioned none of this. Now in `docs/API.md`, and flagged as what it actually is: a **third** option, neither of the two the issue proposed. On erasure of a hand-edited transcript the code writes to **both** — redacting the rows and separately scrubbing the text — so rows are not "frozen as provenance", they are mutated. Defensible for a legal erasure, and now written down. ## Per-speaker QA `transcript_qa_service` plus a GM-only endpoint, grouped by `(track_owner_id, speaker_label)` so a mid-session rename cannot merge two people. It states its own limitation rather than overselling: audio duration per track is not stored beside the rows, so it answers "how much did each speaker's transcript contain" and can show a track that looks thin — but it cannot alone confirm the motivating case, *"this track had 40 minutes of speech and zero segments"*. It raises the suspicion; resolving it needs the source audio. Also added the two `summarisation-runs` endpoints from #333/#424, which `docs/API.md` had never mentioned. Closing, on the understanding that the three struck criteria are closed as won't-do rather than done.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#335
No description provided.