Commit Graph
242 Commits
Author SHA1 Message Date
Logan CusanoandClaude Sonnet 5 3ae0bb2d5b intelligence: run the chatter classifier before the too-short skip, not after (#127)
82% of the classifier's backtest flags were <=5-word transcripts that already exit at skip_reason=transcript_too_short before the classifier ever ran, so shadow mode was on track to observe roughly a fifth of the real catch rate. Compute the verdict once, ahead of that check, and fold it into whichever doc_set already runs (no extra Firestore write). Also add a chatter_classifier_flagged/reason tally spanning both linked calls AND orphans in admin.py's summary block -- the target population is non-events, which land as orphans or single-call incidents, so linked alone undercounts it the same way corr_gate_veto would have without the #126 fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-13 12:42:44 -04:00
Logan CusanoandClaude Sonnet 5 05ddec8284 intelligence: shadow-mode upstream dispatch-vs-chatter classifier (server-26#127)
Three live measurement windows and two consensus-layer fixes (#125, #126)
converged on one decision (CORRELATION_REVIEW_0907b.md, _0912.md): stop
iterating the correlator's consensus layer, the actual lever is upstream —
a classifier in scene extraction that recognizes radio housekeeping (roll
call, bare 10-4/10-8/98 acknowledgements, unit check-ins) before it ever
becomes a scene for the correlator to judge.

Adds app/internal/chatter_classifier.py: a pure classify_chatter(transcript)
function recognizing two shapes drawn from hand-labeled examples in the
review docs, cross-referenced against the real dumps — not invented regexes.
Deliberately conservative: anything that doesn't cleanly reduce to a known
shape returns (False, None) and the existing pipeline runs unchanged.

SHADOW MODE ONLY. intelligence.extract_scenes computes the verdict next to
the existing _is_garbage_transcript / transcript_too_short gates and writes
chatter_classifier_verdict / chatter_classifier_reason onto the call doc,
but does not skip extraction. admin.py's correlation-debug _call_summary
surfaces both fields, same pattern as corr_gate_veto (#115/#126), so the
next live window can measure the real-world false-positive rate before
anything is wired to actually skip extraction. TODO(server-26#127) marks
the call site.

Backtest against all three existing dumps (1002 calls): 154 flagged, 0
false positives (no flagged call carries tags, coords, non-routine severity,
or matches any review-doc-named dangerous-to-drop transcript — the major
extinguishing-fire call, geocoded calls, pursuit updates, the Pelham Station
subject check, the property-retrieval call, all individually verified).

tests/test_chatter_classifier.py: real transcripts from the dumps/review
docs in both directions. Sandboxed pytest 332 -> 364, green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-12 23:59:53 -04:00
logan 11c98daed0 Merge pull request 'correlator: shrink the same-talkgroup escape hatch from 2h to a few minutes (#115)' (#126) from fix/115-escape-hatch-window into main
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy to VM (push) Failing after 1m24s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-12 04:47:49 -04:00
Logan CusanoandClaude Sonnet 5 83beb2bf35 admin: surface corr_gate_veto on the correlation-debug endpoint (#115)
corr_gate_veto was written to corr_debug but the admin endpoint's whitelist (_call_summary + the summary tally) never surfaced it, so the last commit's whole point -- measuring window #4 instead of guessing -- would have produced nothing to read. Add it to both. Also softened the docstring's remaining overclaim: whether the active-only ctx[recent] limitation explains the 2/24 window-3 misses is unanswered, not confirmed -- read corr_gate_veto next window instead of asserting a guess again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-12 04:47:15 -04:00
Logan CusanoandClaude Sonnet 5 598054746a correlator: mirror the dispatch/tactical idle split in the escape hatch, record the gate-veto reason (#115)
Review of #126 found: (1) the escape hatch applied tg_dispatch_thin_idle_minutes (5 min) unconditionally, but incident_correlator's own fast/thin path only uses that on dispatch channels and 15 min on tactical ones via _is_dispatch_channel -- mirrored the same selection here, plus a config.py note flagging the second consumer. (2) the docstring claimed a 'confirmed explanation' for 2 window-3 gate misses that was actually wrong (self-contradictory in its own text); replaced the guess with corr_gate_veto, written into corr_debug on every llm=orphan/rules=new disagreement that escalates, so window #4 can see *why* each one escaped instead of reconstructing it from the raw dump.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-12 04:42:14 -04:00
Logan CusanoandClaude Sonnet 5 400b74b519 correlator: shrink the same-talkgroup escape hatch from 2h to a few minutes (#115)
_recent_incident_on_same_talkgroup previously treated ANY same-talkgroup
incident within the 2-hour correlation_window_hours lookback as 'recent',
which disabled the whole LLM-orphan consensus gate on busy dispatch
channels: window #3 (CORRELATION_REVIEW_0912.md) measured 0/24 gate fires
against the exact target shape (rules=new, llm=orphan, tiebreak=new), with
22/24 explained by a same-talkgroup incident existing somewhere in the
prior 2h — nearly guaranteed on channels producing 3-13 incidents/2h.

Now the escape hatch only counts an incident as recent within
settings.tg_dispatch_thin_idle_minutes (5 min), reusing the same recency
bound the fast/thin path already uses for the 'dispatch, thin ack 10-30s
later' case this hatch exists for, instead of inventing a new constant.

Investigated the 2 unexplained misses (no same-tg incident found even by
a naive full-collection timestamp scan): confirmed ctx["recent"] is built
from status=="active" incidents with over-capacity incidents dropped
(_build_context / _drop_capped), not a full collection scan — an incident
that has auto-resolved or hit incident_max_calls/incident_max_duration
within the window is invisible to this check even though it is
chronologically recent. This does not explain the 2 misses (a same-tg
incident was absent by both checks there, so some other
_call_is_substanceless condition must be responsible), but it is a real
gap in the check as written. Documented in the docstring with a
TODO(server-26#115); fixing it needs a new, non-active-filtered Firestore
query, out of scope for this pass.

Tests: added a regression test proving an incident inside the old 2h
window but outside the new 5-minute window now correctly gates (fails on
main, passes here), plus a test proving a truly recent (<5min) same-tg
incident still escapes the gate as intended. Sandboxed pytest: 327 -> 329
passed (2 new tests), all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-12 04:28:07 -04:00
logan 07ff9ba193 Merge pull request 'correlator: gate LLM-orphan against rules-new instead of escalating to tiebreak (#115)' (#125) from fix/115-consensus-orphan-gate into main
Build & Deploy / Build & push images (push) Successful in 4m11s
Build & Deploy / Deploy to VM (push) Successful in 2m11s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-11 23:18:12 -04:00
Logan CusanoandClaude Sonnet 5 15a9d10666 correlator: keep the tiebreak for typed / reassignment calls in the orphan gate (#115)
_call_is_substanceless mirrored has_event_substance but not the creation gate's type-resolved short-circuit, so a routine-severity fire/medical call with no coords/tags/vehicles — or a reassignment (unit pulled to a new job) — could be gated to orphan where rules would open an incident. Bail out of the gate on incident_type or reassignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 23:57:20 -04:00
Logan CusanoandClaude Sonnet 5 dd426572fc correlator: fix consensus orphan-gate to test call substance, not empty corr_debug (#115)
The gate added in ca1d8fb checked rules_decision["corr_debug"] for a positive
signal, but that dict is empty at preview time for action=="new" (corr_path is
written at apply time). The check was always False, so the gate fired on real
events — replayed against corr_dump_9-7_pm.json it dropped ~36 linked calls
including a major "extinguishing fire", a moderate fire-alarm, geocoded calls
and pursuit updates.

Gate now runs against ctx (fully populated at preview time). It fires ONLY when
the call is substanceless: routine severity, no vehicle/geocode/tag, and no
incident already running on the same talkgroup. Any of those escalates to the
tiebreak instead. The substance predicate (has_event_substance) is factored out
of incident_correlator's creation gate and shared, so the two cannot diverge.

recorrelation_sweep: a call the gate parked gets a longer link-only retry budget
(10 vs 3) — the gate fires before any incident for the job exists, so the
substantive call that justifies linking can land after the standard ~6 min.
Still create_if_new=False.

incident_correlator location path: evaluate every in-radius candidate and link
the nearest that carries corroboration, instead of the first in an unsorted
`recent`. A unit-overlap location link is now tagged "location_unit_overlap" so
it stops merging into the fast path's bucket in the admin fit-signal histogram.

tests/test_consensus_gate.py: replaced the corr_debug-signal cases with ctx
substance cases (severity, coords, tags, vehicles, same-tg incident); added a
nearest-wins location test; the two location guard tests now assert they reach
the new guard. Full drb-c2-core suite 322 -> 325.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 23:50:41 -04:00
Logan CusanoandClaude Sonnet 5 ca1d8fbdae correlator: gate LLM-orphan against rules-new instead of escalating to tiebreak (#115)
CORRELATION_REVIEW_0907b.md measured that radio housekeeping (unit
check-ins, roll call, 10-8/10-98 clearings) is being promoted to
incidents. Every case reads corr_llm_action=orphan, corr_rules_action=new,
corr_consensus=tiebreak -> new: the cheap LLM correctly reads "not an
incident", the rules engine says `new` only because there is no incident to
link to, and the smart tiebreaker then sides with rules ~21/21. Reframing
the tiebreaker prompt (#116) did nothing. The fix is a consensus-logic gate,
not another prompt.

Fix 1 (routers/upload.py) - LLM-orphan gate in _correlate_with_consensus:
when the cheap LLM says `orphan` and the rules engine says `new` with NO
positive event signal, resolve to `orphan` and skip the tiebreak call
entirely. "No positive signal" = the rules corr_debug carries neither a
positive corr_path (unit-continuity / location / fast/disambig / fast/single)
nor a positive corr_fit_signal (unit_overlap / location_proximity). When it
does carry one, the existing escalation-to-tiebreak is kept so a genuine
event the LLM misreads as orphan still gets the second look. The resolved
outcome records corr_consensus="llm_orphan_gate" (greppable, distinct from
"tiebreak") and keeps corr_llm_reasoning / corr_rules_action /
corr_llm_action populated.

Fix 2 (incident_correlator.py) - tighten corr_path=location: the location
path linked on a bare sub-location_proximity_km (0.5 km) distance with no
unit or content check, which stitched a vehicle lockout to a station-restroom
slip and merged two different churches an hour apart. A location link now
requires unit overlap with the candidate OR a distance under a tighter bar
(_LOCATION_TIGHT_PROXIMITY_KM = 0.2 km). Pursuit incidents keep their
movement-speed-validated wide radius. A surviving location link now also
writes corr_fit_signal (unit_overlap | location_proximity), consistent with
Fix 1's positive-signal set.

Tests: new tests/test_consensus_gate.py (13 cases) - the gate resolves to
orphan without calling tiebreak on a no-signal disagreement; a unit_overlap /
location_proximity / unit-continuity / fast-disambig rules signal still
escalates; llm=link vs rules=new still escalates; the location path drops a
shared-area candidate with neither unit overlap nor tight proximity, links on
unit overlap, and links on tight proximity alone. Full c2-core suite
309 -> 322 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 23:32:52 -04:00
logan 7f4d684966 Merge pull request 'ci: deploy Firestore rules + indexes on every push to main (#51)' (#124) from fix/51-ci-firestore-deploy into main
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Successful in 3m49s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #124
2026-09-07 23:22:38 -04:00
Logan CusanoandClaude Sonnet 5 bd04bdbd69 firestore: declare DESC indexes for the orderBy(desc) queries (#33/#51)
The old //direction note ('ASC serves orderBy desc') was wrong for these query shapes and left useCalls/useIncidents/useAlerts and search_calls throwing FAILED_PRECONDITION. Declare calls/incidents/alert_events (…, DESC) to match the live DB (indexes created via gcloud 2026-09-08). Drop the misleading 'delete these duplicates' drift note.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 23:20:48 -04:00
Logan CusanoandClaude Sonnet 5 775244bbde ci: deploy Firestore rules + indexes on every push to main (#51)
The deploy job SSHes to the VM (which runs as the project service account) but never touched Firestore, so rules and composite indexes regressed silently after every fix. Add a firebase-tools deploy right after `git pull`, additive for indexes, warn-not-fail on error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 19:44:57 -04:00
logan bc3251e8df Merge pull request 'frontend: #109 punch-list P2 — RulesTab effect, node recent-calls window' (#123) from fix/109-punchlist-p2 into main
Build & Deploy / Build & push images (push) Successful in 5m45s
Build & Deploy / Deploy to VM (push) Successful in 2m23s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 19:06:34 -04:00
logan 7a5bd5dbbb Merge pull request 'map: remove stray clock, unstack incident rail, OSM tile fallback (#118)' (#122) from fix/118-map-overlays into main
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Canceled after 0s
Build & Deploy / Build & push images (push) Canceled after 1m31s
2026-09-07 19:06:30 -04:00
logan 629bd1c340 Merge pull request 'firestore: declare the alert_events composite index (#51)' (#121) from fix/51-alert-events-index into main
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Canceled after 0s
Build & Deploy / Build & push images (push) Canceled after 1m28s
2026-09-07 19:06:28 -04:00
logan cea094d66b Merge pull request 'c2-core: fix CORS so the browser can call the REST API (#110)' (#120) from fix/110-c2-core-cors into main
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Canceled after 0s
Build & Deploy / Build & push images (push) Canceled after 1m31s
2026-09-07 19:06:25 -04:00
logan 01c146e21e Merge pull request 'ci: bake NEXT_PUBLIC_MAP_TILE_URL into the frontend build (#117)' (#119) from fix/117-map-tile-build-arg into main
Build & Deploy / Build & push images (push) Failing after 14s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-07 19:06:21 -04:00
Logan CusanoandClaude Sonnet 5 8a0412b529 firestore: add the alert_events composite index (#51)
collectionGroup alert_events (acknowledged, org_id, triggered_at desc) — the index the /watch Triggered Alerts tab and the site-wide useUnacknowledgedAlerts hook require. Still needs a manual deploy; no automation exists (#51).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:54:10 -04:00
Logan CusanoandClaude Sonnet 5 52edbf105c map: remove stray clock, unstack the incident card from the zoom controls, OSM tile fallback (#118)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:53:14 -04:00
Logan CusanoandClaude Sonnet 5 77f1d2f93f frontend: #109 punch-list P2 — effect-guard RulesTab, pending node card modal, org save, node recent-calls filter
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:52:39 -04:00
Logan CusanoandClaude Sonnet 5 d60fef67ad c2-core: add CORS middleware so the browser can call the REST API (#110)
The Archive page's GET /calls/search failed its CORS preflight (OPTIONS -> 405, no Access-Control-* headers). Allow the app origin(s) explicitly for the standard methods and the authorization/content-type headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:52:38 -04:00
Logan CusanoandClaude Sonnet 5 fe643924c7 ci: bake NEXT_PUBLIC_MAP_TILE_URL into the frontend build (#117)
The map override var was added to MapView.tsx but never passed as a build-arg, so prod still shipped the dead Carto tile URL. Point it at OSM raster tiles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:48:42 -04:00
logan bccb3e0316 correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (#116)
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Successful in 2m25s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 16:59:46 -04:00
Logan CusanoandClaude Sonnet 5 1a631d65d0 correlator: address #116 review — call talkgroup id in the prompt, sort candidates
drb-correlation-review: ship, with two bounds the low-bar link rule needs.

1. _call_block emitted only the talkgroup NAME while _inc_summary emits
   numeric tg ids, so the "same talkgroup" precondition in _RULES was
   unevaluable and the low link bar applied unconditionally. _call_block now
   prints "Talkgroup: <name> (id <n>)".
2. ctx["recent"] is an unordered Firestore slice with no order_by; a busy 2h
   window (~40 active incidents) showed the model an arbitrary half of the
   candidates. _prompt_incidents() sorts by updated_at desc before the [:20]
   cap — also makes each row's idle: field monotonic.

+2 tests. Full c2-core suite green (sandboxed venv).

Review follow-ups (not blockers): _parse_response demotes an unresolvable
link to orphan (drops the call) rather than falling back to rules — now on
rising link volume; the 45% tiebreak escalation rate / smart-model cost is
untouched; _ROAD_RE swallows leading tokens so "10 Parker Street" still
won't road-overlap "Parker St".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:59:29 -04:00
Logan CusanoandClaude Sonnet 5 3a944f35c1 correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (server-26#115)
The 2026-09-07 measurement window (CORRELATION_REVIEW_0907.md) showed the
consensus tiebreaker was the dominant over-split driver: it ran on 45% of
calls and resolved link/orphan disagreements as "new" ~24/25 of the time,
shattering one Mohegan Park car-alarm job into 9 incidents and opening ~7
incidents from radio checks / roll calls.

Two causes, two fixes:

1. `_inc_summary` gave the model `id|type|loc|units|tags|idle` — no title,
   no talkgroup. It literally could not see that two "car alarms, Mohegan
   Park Ave/Avenue" incidents on TG 9560 were the same. Now includes the
   incident title (the strongest same-event signal) and talkgroup.

2. `_RULES` told the model "orphan when in doubt — conservative is always
   correct". For a system that over-splits, that is backwards: a wrong link
   is cheap, a duplicate incident is the failure. Rewritten to: prefer link
   for a plausible same-talkgroup continuation (low bar), reserve "new" for a
   genuinely different event, and explicitly "orphan" non-incidents (radio
   checks, roll call, 10-8/10-98, mileage logs).

Plus `_extract_road_ids` now canonicalises street-type synonyms
(Avenue→ave, Street→st, Road→rd, ...), so "Mohegan Park Avenue" and
"Mohegan Park Ave" share a road id — that one difference was splitting the
car-alarm incident.

+tests/test_correlator_115.py. Full c2-core suite green (sandboxed venv).
Bigger levers deferred to follow-ups: the consensus escalation itself (should
a cheap-LLM "orphan" ever reach a tiebreak?), a first-class road-overlap fit
signal in _call_fits_incident, geocode coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:53:03 -04:00
logan 0712e7a437 correlator: LLM tier reads the scene transcript, not the whole call (#112)
Build & Deploy / Build & push images (push) Successful in 4m1s
Build & Deploy / Deploy to VM (push) Successful in 2m2s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 04:40:34 -04:00
logan a739fa64f0 frontend: safe fixes from the #109 punch-list (#113)
Build & Deploy / Build & push images (push) Successful in 4m5s
Build & Deploy / Deploy to VM (push) Successful in 2m33s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 00:13:35 -04:00
Logan CusanoandClaude Sonnet 5 7189ba03e4 correlator: address #102 review — 0-based segment labels, never-empty slice
drb-correlation-review on the prior commit flagged two ways the per-scene
transcript could silently fall back to the whole-call text:

1. _build_transcript_block numbered transmissions "1." while the prompt says
   "0-based indices" — a model echoing the labels it saw returned 1-based
   indices, shifting every scene's slice by one. Labels are now "0." to match
   the documented contract (also fixes the same latent skew in
   _build_scene_embed_text / #80).
2. An empty join (bad / out-of-range / non-int indices) hit
   `transcript or call_doc.get(...)` in _build_context and fell back to the
   whole-call transcript — re-opening the leak exactly when indices are wrong.
   The slice now falls back to this call's own whole transcript *before*
   _build_context sees it, so it is never "". Non-int and negative indices
   are rejected rather than raising.

Slice logic extracted to `_scene_transcript_text` with a dedicated test file
(4 cases: subset, corrected-wins, no-indices fallback, bad-indices fallback).
Call-doc fallback kept (sweep / no-scene path) per the review. Also restored
the `-> ` spacing lost in the prior commit's kwarg edit.

Full c2-core suite green: 300 passed (sandboxed venv). Still DO NOT MERGE
until the measurement window closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:12:56 -04:00
Logan CusanoandClaude Sonnet 5 d67b2057e6 frontend: safe fixes from the #109 punch-list
- CallSpineEntry.tsx: drop the dead `hasAudio` prop + the early `return null`
  that sat between hooks in InlinePlayer (React #310 risk). Parent already
  gates the mount on audio presence.
- NodeCard.tsx + nodes/page.tsx: pending-node card no longer double-fires.
  NodeCard gains `linkToDetail` (default true); the pending branch passes
  false so the wrapping onClick (open config modal) isn't swallowed by the
  inner <Link> navigation. List view unchanged.
- trips/page.tsx: TripCard badge now buckets on end_date >= today, matching
  the list's own upcoming/past split — an in-progress trip no longer shows a
  "Past" badge under "Upcoming".
- trips/page.tsx, NodeConfigModal.tsx, nodes/[id]/page.tsx: tall modals get
  `p-4` on the overlay + `max-h-[90vh] overflow-y-auto` on the panel so they
  don't clip on short viewports (incidents' CreateModal pattern).
- lib/types.ts: IncidentRecord.units / vehicles are optional now, matching
  Firestore (older docs omit them); incidents/[id] gains a `?? []` guard.

Untypechecked (no node/npm locally). next build in deploy.yml gates it.
Full list of remaining items in server-26 #109.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:07:54 -04:00
Logan CusanoandClaude Sonnet 5 ef1e3d7f9d correlator: LLM tier reads the scene's transcript, not the whole call (server-26#102)
The last leg of the #80/#95 scene-context leak. llm_correlator._call_block
read call_doc's whole-call transcript for every scene, so on a multi-scene
call every scene's cheap-tier and tiebreaker decision was made against text
that also contained the other scenes.

- intelligence.py: each processed[] scene now carries its own "transcript" —
  transcript_corrected, else this scene's segments joined, else (single scene)
  the whole transcript.
- _build_context / preview_correlation / correlate_call: take a `transcript`
  param; _build_context resolves ctx["scene_transcript"] from it, falling
  back to the call doc (sweep, single-scene, tests) — the fallback is kept
  here, unlike embedding/severity, because a scene always has real text.
- upload.py: both scene loops pass scene["transcript"].
- llm_correlator._call_block: reads ctx["scene_transcript"] (call-doc
  fallback retained for test-built ctx).
- recorrelation_sweep: passes the call doc's text explicitly.
- +1 regression test. Full c2-core suite green (296 passed, sandboxed venv).

NOT for merge until the running correlation measurement window closes and its
dump is analysed — deploying a correlator change mid-window would mix old and
new behaviour in the sample.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:06:37 -04:00
logan c1c3e89e1d frontend: fix map stacking + honest infra error states (#108)
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m3s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-06 23:49:19 -04:00
Logan CusanoandClaude Sonnet 5 968134f8ee frontend: fix map stacking + honest infra error states
From a live review of drb.cusano.net.

MapView.tsx / globals.css:
- The Leaflet map painted above the sticky Nav (z-40) and modal overlays, so
  on Live the account dropdown opened *behind* the map. Pin .leaflet-container
  to its own stacking context (position:relative; z-index:0) — keeps Leaflet's
  internal pane order, drops the whole map below app chrome. The map's own
  overlay UI (legend, rail, clock, fit-all) is outside .leaflet-container and
  unaffected. Chosen over raising Nav's z-index, which would float the sticky
  header over modal backdrops on ~7 pages.
- Basemap: the "Dark" tile URL is already CARTO's keyless dark raster (so a
  prod "API KEY REQUIRED" watermark is a stale build or CARTO rate-limiting
  the origin, not this code). Add NEXT_PUBLIC_MAP_TILE_URL as a build-time
  override so a keyed style drops in without a code change; add the OSM
  attribution the keyless CARTO tiles require.

incidents/page.tsx, alerts/page.tsx:
- Both dumped raw Firestore "requires an index / PERMISSION_DENIED" strings
  (with a console.firebase URL) straight into the UI when the composite
  indexes aren't deployed (server-26 #13/#51). Collapse those known infra
  failures to a plain sentence; any other error passes through verbatim so a
  real bug still shows. alerts also now surfaces the events-query error at
  all — it was swallowed, showing a false "No alerts triggered yet." on a
  public-safety screen.

onboarding/page.tsx: stale comment (/dashboard -> "/").

Untypechecked (no node/npm locally); presentational only — one string
helper, one added error branch, a CSS rule, two tile-URL constants, a
comment. next build in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:43:22 -04:00
logan b430cf32f2 Merge pull request 'frontend: install command uses the node id from the mint form (node-26#4)' (#107) from feat/mint-panel-nodeid into main
Build & Deploy / Build & push images (push) Successful in 5m51s
Build & Deploy / Deploy to VM (push) Successful in 1m42s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #107
2026-09-06 20:15:10 -04:00
Logan CusanoandClaude Sonnet 5 93fa3a6054 frontend: install command uses the node id from the mint form (node-26#4)
The mint panel's copy command hard-coded --node-id node-XXX. Now the label
just entered (the operator types the node id there — placeholder relabeled
"Node ID, e.g. node-003") is captured on mint and interpolated into the
command: spaces → dashes, non [A-Za-z0-9_-] stripped (install.sh's rule),
falling back to node-XXX only if that yields nothing. The "edit node-XXX"
hint now only shows in the fallback case.

Not typechecked (no node/npm here); one useState<string|null>, one derived
string, a JSX conditional. `next build` in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:13:23 -04:00
logan de03f5bcaf Merge pull request 'frontend: mint panel shows the full one-shot install command (node-26#4)' (#106) from feat/mint-panel-install-command into main
Build & Deploy / Build & push images (push) Successful in 5m6s
Build & Deploy / Deploy to VM (push) Successful in 1m40s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #106
2026-09-06 19:31:34 -04:00
Logan CusanoandClaude Sonnet 5 0651bfe07a frontend: mint panel shows the full one-shot install command (node-26#4)
After a node enrollment token is minted, the panel now renders the
paste-ready `curl -fsSL .../install.sh | sudo bash -s -- --token <minted>
--node-id node-XXX --c2-url <derived> --mqtt-broker <derived>` line with a
Copy button, alongside the bare token (also kept, also now copyable).

- c2-url from NEXT_PUBLIC_C2_URL (same var lib/c2api.ts reads), fallback
  https://api.example.net
- mqtt-broker derived as mqtt.<api-host minus leading api.> — a DNS
  assumption; the panel text tells the operator to check it
- node id is a node-XXX placeholder; the panel collects none

Pairs with node-26's install.sh (feat/one-shot-install). The raw/tag/v1/
URL resolves once v1 is re-cut at that PR's merge.

NOT typechecked here (no node/npm in this environment); plain React, two
useState booleans + one computed string, reviewed by eye. `next build` in
the deploy workflow will catch a real type error before it ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 19:15:55 -04:00
logan c4656a9607 Merge pull request 'correlator: judge each scene on its own embedding + severity (#80, #95)' (#105) from fix/scene-context-leak-80-95 into main
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy to VM (push) Successful in 2m8s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #105
2026-09-06 17:49:23 -04:00
Logan CusanoandClaude Sonnet 5 a9d1d2475a correlator: judge each scene on its own embedding + severity (server-26#80, #95)
intelligence.py writes only the primary scene's embedding and severity to
calls/{id}. _build_context read them back off the call doc, so every
non-primary scene of a multi-scene call was correlated against scene 1's
semantic vector and severity rung: a scene about a different event scored
on the embedding path against the wrong incident, and could inherit a
minor/moderate/major severity it never had, clearing the creation gate on
borrowed weight. Same defect and same fix as the #87 coords leak.

- _build_context / preview_correlation / correlate_call: take embedding and
  severity as params; drop the call_doc.get() fallbacks. A scene that
  passes none has none, and is judged thin on its own signal.
- upload.py: both scene loops pass scene["embedding"] / scene["severity"];
  _correlate_with_consensus forwards them. The no-scene unclassified branch
  passes neither (correct: no scene, judged thin).
- recorrelation_sweep: passes the call doc's stored values explicitly
  (whole-call re-link, link-only, so a borrowed severity cannot create).
- intelligence.py: SCENE DETECTION prompt tightened toward one scene
  (server-26#5, partial) - MULTIPLE only for genuinely separate events,
  "when unsure, one scene", plus a not-a-new-scene list.
- test_incident_identity.py: +2 regression tests mirroring the #87 test.

Full c2-core suite green (295 passed). #5 prompt change is unmeasured -
needs a scoped correlation-only window. Known remaining legs, tracked
separately: llm_correlator._call_block still reads the whole-call
transcript per scene; content-divergence veto skips on a None embedding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:01:59 -04:00
Logan CusanoandClaude Opus 5 85393bdb26 ci: retrigger deploy after registry token expired mid-build
Build & Deploy / Build & push images (push) Successful in 6m20s
Build & Deploy / Deploy to VM (push) Successful in 1m59s
Build & Deploy / Report a failed deploy (push) Skipped
Run 570 (8b6c170) pushed c2-core successfully, then failed on
discord-bot with "failed to authorize: failed to fetch oauth token:
unauthorized" ~20s later, using the same credential. That is a
short-lived registry token expiring mid-run, not an invalid one.

Build job failure skipped "Deploy to VM", so 8b6c170 -- which closes
the viewer-triggerable OpenAI spend on incident summarize
(server-26#81) -- never reached production. Prod stayed on b722223
with the spend leak open.

No code change. This commit exists only to re-run the pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:49:05 -04:00
Logan CusanoandClaude Opus 5 8b6c170265 Close viewer-triggerable OpenAI spend on incident summarize (server-26#81)
Build & Deploy / Build & push images (push) Failing after 1m58s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 2s
POST /incidents/{id}/summarize was gated by require_service_or_firebase_token,
which accepts any authenticated Firebase user including role "viewer". That
route spends OpenAI credits via the background summarizer. The call-side
equivalent was already moved to require_admin_token; this brings the incident
side in line with it.

The frontend's two "summarize now" buttons on the incident detail page are
already gated behind isAdmin, so this backend change matches existing UI
behavior exactly and does not break any viewer/operator surface — it only
closes direct-API access for non-admins.

Swept every other route in incidents.py: list/get are reads with no spend and
correctly stay open to any signed-in user; create/update/delete/link/unlink
were already require_admin_token. No other sibling route needed changing.

Adds test_incident_summarize_auth.py pinning the dependency wiring directly
(the convention used in test_admin_feature_flags.py), so a future revert back
to the weak dependency fails a test immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:45:08 -04:00
Logan CusanoandClaude Opus 5 b7222230bd frontend: label machine-generated output and unbuilt entitlements (Gate A)
Build & Deploy / Build & push images (push) Successful in 4m25s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
Build & Deploy / Report a failed deploy (push) Skipped
Gate A (BUSINESS_MODEL.md, board minutes #42, dated to today by minutes #79
decision 14) blocks putting a price or an unbuilt entitlement claim on a
surface a reader can see, and requires that unverified machine assertions be
labelled as such on the same screen as the assertion.

The pricing leg was already met — /pricing and both homepage CTAs stopped
quoting the invented catalog. Condition A2 was not: a search of the whole
frontend for a "machine-generated" or "unverified" qualifier returned zero
hits. Every transcript, summary, title, location, unit list and vehicle list
is pipeline output that no human reviews, and entity-name accuracy in those
transcripts has never been measured (server-26#48) — yet all of it was
rendered to the reader as plain fact. Unqualified machine assertions about
real incidents and real people is the exposure Gate A exists to stop.

A2 — one reusable element, components/ui/MachineOutputNotice.tsx, rendered on
the same screen as the output (a footnote elsewhere does not satisfy A1's
"same screen" standard). Three variants for three shapes of surface, all
saying the same thing; the "popup" variant uses fixed grays because a Leaflet
popup is stock-white in both themes. Covered:

  - incident detail: under the summary (covers summary, title, location,
    units on scene/cleared, vehicles, tags) and above the call spine
  - incident list: above the timeline groups
  - Archive (/calls): above the transcript rows
  - node detail: above the Recent Calls table
  - Watch//alerts: above the events table, whose Snippet column is transcript
    text and whose keyword match was made against it
  - Live map: the desktop incident rail, pinned above the scroll area so it
    cannot be scrolled off the screen it qualifies; the mobile drawer; the
    incident marker popup; the incident-path stop popup
  - /systems: the source-call transcript preview
  - /features: the two marketing sections that describe the AI pipeline

A1 — components/ui/UnbuiltMarker.tsx marks a claim unbuilt inline:

  - /faq: the retention answer promised 7/90/365-day windows. There is no TTL
    and no deletion sweep anywhere in the product (server-26#44), so the
    answer now states plainly that nothing is deleted automatically and marks
    per-plan retention as not yet available.
  - /settings/billing: the plan cards' claims — custom retention, SSO/SAML,
    uptime SLA, data residency — are marked not-yet-available next to the plan
    that makes them.

Labelling only. No retention, SSO, SLA or residency was built; no billing,
Stripe or checkout code was touched (Gate B still bars charging anyone); no
price was added anywhere; no Python was touched. Both themes verified against
the light-mode !important overrides in globals.css, which are untouched.

tsc --noEmit clean.

Refs: server-26#46, server-26#44, server-26#48

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 02:47:40 -04:00
Logan Cusano bdb57ae75a correlator: stop non-primary scenes inheriting the call doc's pin (#87)
Build & Deploy / Build & push images (push) Successful in 4m30s
Build & Deploy / Deploy to VM (push) Successful in 1m47s
Build & Deploy / Report a failed deploy (push) Skipped
_build_context fell back to call_doc.get("location_coords") whenever a
scene passed no coordinates of its own. One radio call can be split
into several scenes, but only the primary scene's geocode is ever
written to the call doc — so every non-primary scene silently
inherited the primary scene's pin. That fabricated location_proximity,
the strongest accept signal the correlator has, for a scene that had
no location at all, and drove it into the primary scene's incident on
a pin it never had.

Drop the fallback: coords = location_coords. A scene with no location
is now correctly judged thin, cannot win the location path, cannot
supply call_coords to _call_fits_incident, and cannot seed
_find_cross_system_parent.

recorrelation_sweep.py, the only other caller of correlate_call, was
verified to already pass both location and location_coords explicitly
from the call doc, so the fallback there was a no-op and this change
is behavior-preserving for that path.

Adds test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin
to test_incident_identity.py, pinning ctx["coords"] is None and
ctx["is_thin_call"] is True when location=None but the call doc
carries a location_coords.

Ref: server-26#87
2026-08-31 02:45:31 -04:00
Logan CusanoandClaude Opus 5 29c2fb11b9 Ignore drb-telegram-bot/ — out of scope, not a deployed service
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
Build & Deploy / Report a failed deploy (push) Skipped
Scaffolding for a service that does not run and is not in compose. It has sat
untracked across four unattended runs, each of which had to decide again
whether to commit or delete someone else's work. Declaring it out of scope
ends that. server-26#56.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:28:29 -04:00
Logan CusanoandClaude Opus 5 865b5b4317 Close the /admin/features side-door that needed a container shell to flip AI spend
Build & Deploy / Build & push images (push) Successful in 4m15s
Build & Deploy / Deploy to VM (push) Successful in 1m56s
Build & Deploy / Report a failed deploy (push) Skipped
Board minutes #62 Decision 2 (server-26#64), due 2026-08-31. CTO draft #60
finding 1 and CISO draft #61 finding 3 reached this independently.

GET/PUT /admin/features accepted only a Firebase admin token, so the unattended
runbook had no headless path and SSHed into the c2-core container to write
config/ai_features with the admin SDK. Moving a platform-wide AI cost switch
required a full container shell, and set_flags() wrote no audit entry either
way, so a flag flip was unattributable however it happened.

- New agent_service_key (AGENT_SERVICE_KEY), deliberately separate from the
  Discord bot's service_key. Sharing one key would collapse two principals into
  a single unattributable identity in every log line, and the bot has no
  business flipping AI flags regardless.
- require_agent_key_or_admin accepts the agent key or a Firebase admin, and
  rejects the Discord key. The "key is configured" guard is load-bearing:
  compare_digest("", "") is a match, so a deployment that never set the key
  would otherwise accept an empty credential.
- set_flags() writes an audit_log entry with before/after values and the actor,
  wrapped so an audit failure cannot lose the flag write or 500 the route.
- Cascade helper sets the global doc and every system carrying an ai_flags
  override in one call. A global False already beats everything, but a system
  False beats a global True, so turning AI *on* could half-apply and leave a
  radio system hot after shutoff. It scans for the override rather than
  hardcoding the two known system IDs, so a new system cannot silently defeat
  it.
- cascade defaults to False. PUT /systems/{id}/ai-flags and the AiFlagsPanel
  toggle mean a per-system override is deliberate operator intent; cascading by
  default would erase it on any unrelated global flip. The runbook opts in.

Issue items 5 and 6 (retiring the SSH path from drb-worksession.md) are NOT
done here and the runbook is untouched. The credential does not exist in
production yet, so the SSH path is still the only one that works; retiring it
now would break the next unattended run. Owner activation is recorded on #64.

Tests 273 -> 289.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:52:00 -04:00
Logan Cusano 0635de8dac Stop alert webhooks putting raw transcripts in a third-party channel
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy to VM (push) Successful in 1m44s
Build & Deploy / Report a failed deploy (push) Skipped
Alert dispatch attached a 200-character raw transcript snippet to the
alert_events document and POSTed the same text to the org's Discord
webhook, with no redaction of any kind. Board minutes #42 ratified that
person names are suppressed on every surface until E&O is bound, and a
Discord channel is the least recoverable surface there is: once the text
lands we do not own it, cannot unsend it, and cannot audit who read it.

Raw transcript text now requires two independent gates, both closed by
default:

  1. alert_transcript_snippet_enabled -- an operator switch in config,
     set from the environment.
  2. alert_snippet_opt_in on the org document -- the customer's own
     explicit consent.

Gate 1 is not redundant. The frontend reads and writes Firestore directly
from the browser, so the org flag alone would let an org owner opt
themselves into receiving person names lifted from live public-safety
traffic. Capability is the operator's to grant; consent is the org's.

The gate fails closed on a Firestore error and on a call with no org
(a pre-tenancy node that has not been backfilled) -- a less informative
alert is cheap, an unrecallable disclosure is not. Alerting itself is
unchanged: the webhook still fires and still names the rule, the
talkgroup and the matched keywords.

This does not wait on the Gate B3 redactor (#43, 2026-09-30). The
snippet was a convenience field and needed no redactor to withhold.

Tests assert the person name in a sample transcript does not appear in
either the outbound payload or the Firestore write, in every combination
of the two gates.

Closes server-26#85. Refs #42, #43, #48.
2026-08-29 02:42:31 -04:00
Logan CusanoandClaude Opus 5 3df427f914 Scope Gate B3 so the owner can stop being blocked on it (server-26#43)
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Successful in 1m47s
Build & Deploy / Report a failed deploy (push) Skipped
Board minutes #62 decision 6d bars showing live data to a prospect until #43 is
scoped. The conversation count is 0 of 12 with a hard checkpoint on 2026-09-05,
so the scoping document is worth more this week than the implementation, which
is not due until 2026-09-30.

Corrects a premise in #43: the extraction prompt carries no person-name entity
field, so "entities are already extracted" does not hold. Redaction has to work
on raw free text, and that is most of the estimate.

Redaction is specified at write time rather than read time, because the frontend
reads Firestore directly and rules cannot mask a field -- redacting only in the
API would leave the raw document readable in the browser.

EMS exclusion needs a per-talkgroup flag. ai_flags is per-system, and real
systems carry EMS alongside police and fire.

Refs server-26#43, #42, #62, #66, #85.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 03:09:41 -04:00
Logan CusanoandClaude Opus 5 e30d594eea Stop a back-dated call from silently disabling every recency gate (server-26#74)
Build & Deploy / Build & push images (push) Successful in 4m11s
Build & Deploy / Deploy to VM (push) Successful in 1m44s
Build & Deploy / Report a failed deploy (push) Skipped
_call_fits_incident measured incident idle with the signed helper while every
other recency gate in the file uses the unsigned one. On the re-correlation
sweep, `now` is the call's own started_at, which can precede the incident's
last activity, so the value went negative.

Negative idle made `idle_min >= 15` false, which meant the content-divergence
veto never ran and unit overlap was accepted unconditionally -- on a shared
dispatch backbone that is the feedback loop that lets one incident absorb a
whole talkgroup. It also made `idle_min < 20.0` true at any back-dating, so a
tactical channel returned tactical_default for every swept orphan out to the
90-minute bound.

One variable feeds all four gates in the function, so this is a one-line change
at the source. The signed value is untouched where it belongs: callers still
compute corr_incident_idle_min themselves, so debug output keeps its meaning.

Direction is toward more splitting, on the sweep path only, which is the point
-- the bug was suppressing an over-merge veto. Forward-dated calls and anything
inside the thresholds behave exactly as before.

Two tests added alongside the existing idle-gate cases; both fail on the old
line and pass on the new one. 266 pass, 0 fail.

Refs server-26#74, #5, #80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 02:51:07 -04:00
Logan CusanoandClaude Opus 5 187b8c1500 Declare and create the calls(system_id, started_at) index dedup needs
Dedup was failing on essentially every inbound call in production. dedup.py
queries system_id == X with a started_at range; that composite index was
neither declared in firestore.indexes.json nor present in the live c2-server
database, so the query returned FAILED_PRECONDITION, dedup swallowed it as a
warning, and every duplicate check degraded to "not a duplicate".

While AI is off that only cost duplicate call documents. With a window open it
would have paid Whisper and Gemini twice for every double-heard transmission,
and fed Gate B5's cost measurement a figure that is wrong for a reason
unrelated to the pipeline being measured. Two documents for one transmission is
also the exact input shape that produces a spurious second incident.

The index is created on c2-server and building. This declares it in source so
the file and the live database agree.

Refs server-26#84, #33, #45.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 02:43:21 -04:00
Logan Cusano d18e4f0743 Make "AI is off" true, and stop the transcript PATCH from destroying calls
Build & Deploy / Build & push images (push) Successful in 4m2s
Build & Deploy / Deploy to VM (push) Successful in 1m53s
Build & Deploy / Report a failed deploy (push) Skipped
config/ai_features was not the switch it was documented to be. Three paths
spent money with it off, and one path read it wrong, so per-system opt-outs
did not opt anything out.

- Correlation in the ingest pipeline tested the raw global flag instead of the
  per-system resolution. With a system opted out, extraction was skipped but
  the no-scenes fallback still correlated the call with empty tags, taking the
  thin/recency path and attaching it to whatever incident was most recent on
  that system. The opt-out did not disable correlation, it disabled good
  correlation and left the worst kind running. (#75)

- Transcript correction ran on every transcribed call gated only by an env var,
  spending Gemini tokens and a Places lookup per proposed location. An
  "STT-only" window was never STT-only and its cost could not be attributed.
  Now behind transcript_correction_enabled. (#76)

- _run_extraction_pipeline and the vocabulary learner, both reachable from
  PATCH /calls/{id}/transcript, checked no flags at all. (#76, #81)

The flag resolver now lives in feature_flags.resolve_flags() rather than as a
local helper in upload.py. Three copies of that logic is how #75 happened.

PATCH /calls/{id}/transcript now refuses with 409 when correlation is off.
That route wipes tags, severity, location, units, embedding and unlinks the
call from every incident before queueing re-extraction. Gating extraction
alone would have made it destructive-only in the standing flags-off
configuration: the call left blank and orphaned forever, with the route still
answering 200. The wipe and the rebuild are one transaction in intent, so it
refuses before the first write.

Also: the summarizer's stale-incident sweep is no longer behind
summaries_enabled. It is pure Firestore with no model call in it, and gating
it meant nothing auto-resolved while AI was off - so every incident stayed
active forever and the candidate set every correlation reads kept growing.

transcript_correction_enabled is documented as NOT a pure cost lever. The
corrector is also the noise gate that sets not_speech; with it off, recogniser
noise reaches extraction as a real transcript, comes back thin, and
auto-attaches. Never open an evaluation window with correction off and
correlation on.

14 tests added covering flag precedence, both pipeline paths, the 409, the
correction gate and the summarizer no-op. Suite: 264 passed.

Refs #75, #76, #81, #45.
2026-08-27 02:49:09 -04:00