Move transcript correction into transcription, with per-talkgroup vocabulary and area context #36

Open
opened 2026-08-23 13:53:29 -04:00 by logan · 2 comments
Owner

The problem

Transcript correction already exists — but it is fused into the Gemini extraction step. internal/intelligence.py:69 asks the extraction model for transcript_corrected alongside scene, entity, location and severity extraction:

transcript_corrected: fix only clear STT/vocoder errors (e.g. "Several" → "10-4", misheard street names, garbled unit IDs)

Two consequences, both bad:

1. The correction arrives after the decisions that depend on it. The same model call that extracts units, locations and severity is the one emitting the correction — so extraction reasons over the raw transcript and hands back a fix afterwards. Entities are pulled from text already known to be wrong. Embeddings, incident type, severity and correlation are all computed upstream of the correction.

2. It only runs when correlation is on. Correction lives behind correlation_enabled. In the 2026-08-23 12:31–13:31 STT window correlation was deliberately off, so nothing was ever corrected. That is the normal state during cost-controlled development, which means the correction path is off exactly when transcripts are being evaluated.

Evidence from that window — 29 of 29 calls transcribed, and this one stored intact:

10-4. 10-11. 10-11. 10-12. 10-13. … 10-23. 10-23. 10-23.   (×40, 56 words)

Proposal

Move correction into transcription, as its own step.

audio → whisper → degenerate filter → CORRECTION PASS → write

The correction pass takes the raw transcript plus reference context and returns a corrected transcript. It is a reference lookup, not a transcription prior — the distinction that already prevents vocabulary from going into the Whisper prompt (transcription.py:21-27: an enumerated ten-code prompt made Whisper hallucinate ten-code runs). A corrector that sees the audio's transcript and a list of local names can fix sound-alikes without being able to invent a series.

No schema change is needed. transcript and transcript_corrected both already exist, and every consumer already reads transcript_corrected or transcript. intelligence.py then drops correction from its prompt and simply consumes the corrected text — a smaller prompt doing one job.

Keep preserve_transcript_correction: a human edit via PATCH /calls/{id}/transcript must still outrank the machine.

Reference data: set it, stop deriving it

The corrector needs to know what the local nouns are. Today the only area context is guessed from talkgroup names — vocabulary_learner.py:103:

area_hint = f"Talkgroups include: {', '.join(tg_names)}" if tg_names else "Unknown area"

For a single-municipality system that is thin; for a multi-county system it is close to useless. Vocabulary is also per-system only — there is no per-talkgroup vocabulary anywhere.

Wanted, on both the system and the talkgroup:

  • vocabulary terms (unit call signs, local jargon)
  • area context: county / municipality, major roads, landmark businesses, hospitals

Scope resolution: merge, with talkgroup ranked above system. The specific beats the general — a multi-county system whose talkgroup covers one municipality must not have that municipality's streets swamped by a county-wide list. A single-municipality system is then the degenerate case: populate the system level only and every talkgroup inherits it.

This needs UI on the systems page: today config.talkgroups[] holds only id and name.

Scope of this change

  • New: correction step in internal/transcription.py; per-talkgroup vocabulary + area fields on the system doc; systems-page editing for them.
  • Removed: the transcript_corrected responsibility in intelligence.py's EXTRACTION_PROMPT.
  • Kept: vocabulary_induction_loop. It is the only thing that discovers new terms from live traffic. Feed it the corrector's own edits — a corrector that changed "Cool Parts, Illinois" to a real street is a stronger learning signal than sampling raw transcripts, and it is the same shape as the existing learn_from_correction.

Open questions

  • Cost. One extra LLM call per transcribed call. Negligible at ~30 calls/hour on one node; the dominant per-call cost on a busy multi-system deployment. Cheap model, and possibly skip correction for transcripts under ~3 words, where there is nothing to correct and 9 of 29 calls in the sample window sat.
  • Flag. Correction belongs under stt_enabled rather than correlation_enabled, since it is part of producing a transcript. Worth confirming that is the intent before wiring it.
  • Does the corrector also reject? It will see degenerate output the existing filter missed. Letting it return "this is not speech" may be a better hallucination catch than tuning _RUN_RATIO / _MAX_PHRASE_REPEATS further — but a corrector that can delete a transcript needs care.

Not in scope

Whether _is_degenerate failed to fire on the 16:54 call above is a separate question and still open — the filter returns True for that exact stored string when run locally, so either it did not run or it ran against different text. c2-core restarted mid-window, so the logs for that call are gone. Settle it by reprocessing that call, not here.

## The problem Transcript correction already exists — but it is fused into the Gemini **extraction** step. `internal/intelligence.py:69` asks the extraction model for `transcript_corrected` alongside scene, entity, location and severity extraction: > `transcript_corrected: fix only clear STT/vocoder errors (e.g. "Several" → "10-4", misheard street names, garbled unit IDs)` Two consequences, both bad: **1. The correction arrives after the decisions that depend on it.** The same model call that extracts units, locations and severity is the one emitting the correction — so extraction reasons over the *raw* transcript and hands back a fix afterwards. Entities are pulled from text already known to be wrong. Embeddings, incident type, severity and correlation are all computed upstream of the correction. **2. It only runs when correlation is on.** Correction lives behind `correlation_enabled`. In the 2026-08-23 12:31–13:31 STT window correlation was deliberately off, so nothing was ever corrected. That is the normal state during cost-controlled development, which means the correction path is off exactly when transcripts are being evaluated. Evidence from that window — 29 of 29 calls transcribed, and this one stored intact: ``` 10-4. 10-11. 10-11. 10-12. 10-13. … 10-23. 10-23. 10-23. (×40, 56 words) ``` ## Proposal Move correction into transcription, as its own step. ``` audio → whisper → degenerate filter → CORRECTION PASS → write ``` The correction pass takes the raw transcript plus reference context and returns a corrected transcript. It is a *reference* lookup, not a transcription prior — the distinction that already prevents vocabulary from going into the Whisper prompt (`transcription.py:21-27`: an enumerated ten-code prompt made Whisper hallucinate ten-code runs). A corrector that sees the audio's transcript and a list of local names can fix sound-alikes without being able to invent a series. **No schema change is needed.** `transcript` and `transcript_corrected` both already exist, and every consumer already reads `transcript_corrected or transcript`. `intelligence.py` then drops correction from its prompt and simply consumes the corrected text — a smaller prompt doing one job. Keep `preserve_transcript_correction`: a human edit via `PATCH /calls/{id}/transcript` must still outrank the machine. ## Reference data: set it, stop deriving it The corrector needs to know what the local nouns are. Today the only area context is *guessed from talkgroup names* — `vocabulary_learner.py:103`: ```python area_hint = f"Talkgroups include: {', '.join(tg_names)}" if tg_names else "Unknown area" ``` For a single-municipality system that is thin; for a multi-county system it is close to useless. Vocabulary is also **per-system only** — there is no per-talkgroup vocabulary anywhere. Wanted, on both the system and the talkgroup: - vocabulary terms (unit call signs, local jargon) - area context: county / municipality, major roads, landmark businesses, hospitals **Scope resolution: merge, with talkgroup ranked above system.** The specific beats the general — a multi-county system whose talkgroup covers one municipality must not have that municipality's streets swamped by a county-wide list. A single-municipality system is then the degenerate case: populate the system level only and every talkgroup inherits it. This needs UI on the systems page: today `config.talkgroups[]` holds only id and name. ## Scope of this change - **New**: correction step in `internal/transcription.py`; per-talkgroup vocabulary + area fields on the system doc; systems-page editing for them. - **Removed**: the `transcript_corrected` responsibility in `intelligence.py`'s `EXTRACTION_PROMPT`. - **Kept**: `vocabulary_induction_loop`. It is the only thing that discovers new terms from live traffic. Feed it the corrector's own edits — a corrector that changed "Cool Parts, Illinois" to a real street is a stronger learning signal than sampling raw transcripts, and it is the same shape as the existing `learn_from_correction`. ## Open questions - **Cost.** One extra LLM call per transcribed call. Negligible at ~30 calls/hour on one node; the dominant per-call cost on a busy multi-system deployment. Cheap model, and possibly skip correction for transcripts under ~3 words, where there is nothing to correct and 9 of 29 calls in the sample window sat. - **Flag.** Correction belongs under `stt_enabled` rather than `correlation_enabled`, since it is part of producing a transcript. Worth confirming that is the intent before wiring it. - **Does the corrector also reject?** It will see degenerate output the existing filter missed. Letting it return "this is not speech" may be a better hallucination catch than tuning `_RUN_RATIO` / `_MAX_PHRASE_REPEATS` further — but a corrector that can delete a transcript needs care. ## Not in scope Whether `_is_degenerate` failed to fire on the 16:54 call above is a separate question and still open — the filter returns True for that exact stored string when run locally, so either it did not run or it ran against different text. c2-core restarted mid-window, so the logs for that call are gone. Settle it by reprocessing that call, not here.
Author
Owner

Reopening. The correction pass shipped in 58efdbd and works, but the reference-data shape it shipped is wrong in three ways that only surfaced when we tried to describe a real multi-county system. The pass itself, the retry, the talkgroup-over-system ranking and the removal from EXTRACTION_PROMPT all stand.

What's wrong with the shipped shape

No state. area_context is {municipality, county, roads[], landmarks[]}. "Ossining" is not a place — there are Ossinings, Springfields and Fairviews everywhere. Without a state the corrector can anchor to the wrong half of the country and be confidently wrong, which is the exact failure mode this feature exists to stop.

roads[] / landmarks[] cannot hold what radio traffic actually references. Intersections, schools, housing developments, rail stations, hospitals, and local nicknames ("the flats", "the north end") do not fit two lists — and a bare term is half the information anyway. 11-X-ray is useless on its own; 11-X-ray — MTA PD patrol unit is what lets a corrector recognise it. This also collapses the overlap with vocabulary, which was the other objection: a term list with meanings is the vocabulary, better shaped.

The two scopes have different shapes. System level goes through a pydantic body; the talkgroup copy rides inside config.talkgroups[] and is validated by nothing but the frontend.

Revised shape — identical at both scopes, every field nullable

area_context: {
  municipality?, county?, state?,
  center?, radius_km?, resolved_from?, resolved_at?,   # backend-written, never sent by the client
  local_knowledge?: [ { term, meaning } ]
}

Talkgroup wins where set, system fills the gaps, absent everywhere is legal.

Nullability is the mechanism, not a convenience. System level is only meaningful when it is true of every talkgroup on that system. White Plains PD — it is, so fill it once and every talkgroup inherits. A statewide Colorado system — it is not, so leave it null and fill per talkgroup. Which level an operator fills is their declaration of how homogeneous the system is, and that is what lets one schema serve both without a system_type flag.

Anchor rule: no anchor is better than a useless one. If municipality/county/state resolve to a radius too wide to discriminate — a whole state — write no center/radius_km at all. A statewide anchor would validate any location inside it, so the geocode sanity check would rubber-stamp everything while appearing to work. Absent anchor must mean "skip the check", never "accept anything".

Ownership

The derived geo fields go in area_context, not in a server-owned sibling key as first proposed. The problem that suggestion was dodging — PUT /systems/{id} taking the whole config blob from the frontend and overwriting anything the server computed — is the same defect as the ten_codes wipe fixed in 58efdbd, and the fix is the same: the backend merges its own fields rather than taking dictation from the client. The frontend does not decide what is in a system document.

Work

  • area_context gains state and local_knowledge[{term, meaning}]; roads[]/landmarks[] go.
  • One shape, one validator, both scopes — the talkgroup copy stops being unvalidated JSON inside config.
  • PUT /systems/{id} merges backend-owned area_context subfields instead of accepting the client's config wholesale.
  • Geocode the anchor on write (when the municipality/county/state actually changed — resolved_from is what makes that decidable), not on read. It changes when someone edits a town name, not every five minutes.
  • The systems-page UI follows the same shape at both levels.

Maps-based verification and the induction loop re-pointing are their own issue — this one is the schema and the pass.

Reopening. The correction pass shipped in `58efdbd` and works, but the reference-data **shape** it shipped is wrong in three ways that only surfaced when we tried to describe a real multi-county system. The pass itself, the retry, the talkgroup-over-system ranking and the removal from `EXTRACTION_PROMPT` all stand. ## What's wrong with the shipped shape **No state.** `area_context` is `{municipality, county, roads[], landmarks[]}`. "Ossining" is not a place — there are Ossinings, Springfields and Fairviews everywhere. Without a state the corrector can anchor to the wrong half of the country and be confidently wrong, which is the exact failure mode this feature exists to stop. **`roads[]` / `landmarks[]` cannot hold what radio traffic actually references.** Intersections, schools, housing developments, rail stations, hospitals, and local nicknames ("the flats", "the north end") do not fit two lists — and a bare term is half the information anyway. `11-X-ray` is useless on its own; `11-X-ray — MTA PD patrol unit` is what lets a corrector recognise it. This also collapses the overlap with `vocabulary`, which was the other objection: a term list with meanings **is** the vocabulary, better shaped. **The two scopes have different shapes.** System level goes through a pydantic body; the talkgroup copy rides inside `config.talkgroups[]` and is validated by nothing but the frontend. ## Revised shape — identical at both scopes, every field nullable ``` area_context: { municipality?, county?, state?, center?, radius_km?, resolved_from?, resolved_at?, # backend-written, never sent by the client local_knowledge?: [ { term, meaning } ] } ``` Talkgroup wins where set, system fills the gaps, absent everywhere is legal. **Nullability is the mechanism, not a convenience.** System level is only meaningful when it is true of *every* talkgroup on that system. White Plains PD — it is, so fill it once and every talkgroup inherits. A statewide Colorado system — it is not, so leave it null and fill per talkgroup. Which level an operator fills *is* their declaration of how homogeneous the system is, and that is what lets one schema serve both without a `system_type` flag. **Anchor rule: no anchor is better than a useless one.** If municipality/county/state resolve to a radius too wide to discriminate — a whole state — write no `center`/`radius_km` at all. A statewide anchor would validate any location inside it, so the geocode sanity check would rubber-stamp everything while appearing to work. Absent anchor must mean "skip the check", never "accept anything". ## Ownership The derived geo fields go **in `area_context`**, not in a server-owned sibling key as first proposed. The problem that suggestion was dodging — `PUT /systems/{id}` taking the whole `config` blob from the frontend and overwriting anything the server computed — is the same defect as the `ten_codes` wipe fixed in `58efdbd`, and the fix is the same: the backend merges its own fields rather than taking dictation from the client. The frontend does not decide what is in a system document. ## Work - `area_context` gains `state` and `local_knowledge[{term, meaning}]`; `roads[]`/`landmarks[]` go. - One shape, one validator, both scopes — the talkgroup copy stops being unvalidated JSON inside `config`. - `PUT /systems/{id}` merges backend-owned `area_context` subfields instead of accepting the client's `config` wholesale. - Geocode the anchor on write (when the municipality/county/state actually changed — `resolved_from` is what makes that decidable), not on read. It changes when someone edits a town name, not every five minutes. - The systems-page UI follows the same shape at both levels. Maps-based verification and the induction loop re-pointing are their own issue — this one is the schema and the pass.
Author
Owner

Flagging while researching a separate STT-quality issue: this reads as already implemented, not just proposed. drb-c2-core/app/internal/transcript_correction.py today is exactly this shape — a standalone pass between transcription and everything else (correct(), called from transcription.py, not fused into intelligence.py's extraction prompt), with per-talkgroup vocabulary/area_context merged talkgroup-first (resolve_context()), and vocabulary_learner.py still doing induction. intelligence.py's extraction prompt no longer appears to own transcript_corrected.

Not verifying the remaining open question myself (systems-page UI for editing per-talkgroup vocabulary/area fields — didn't check the frontend) or the flag placement (stt_enabled vs correlation_enabled — #83 suggests it's still coupled to correlation, worth checking against this issue's "Flag" open question specifically). If someone confirms both of those, this is closable; leaving open rather than closing on an incomplete check.

Flagging while researching a separate STT-quality issue: this reads as already implemented, not just proposed. `drb-c2-core/app/internal/transcript_correction.py` today is exactly this shape — a standalone pass between transcription and everything else (`correct()`, called from `transcription.py`, not fused into `intelligence.py`'s extraction prompt), with per-talkgroup vocabulary/area_context merged talkgroup-first (`resolve_context()`), and `vocabulary_learner.py` still doing induction. `intelligence.py`'s extraction prompt no longer appears to own `transcript_corrected`. Not verifying the remaining open question myself (systems-page UI for editing per-talkgroup vocabulary/area fields — didn't check the frontend) or the flag placement (`stt_enabled` vs `correlation_enabled` — #83 suggests it's still coupled to correlation, worth checking against this issue's "Flag" open question specifically). If someone confirms both of those, this is closable; leaving open rather than closing on an incomplete check.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#36