The Live map was held behind useCalls(500), measured at ~9.2s to first map
paint on prod; it now mounts as soon as the saved view is known (nodes are
awaited only when there is no view). The legend painted over the expanded
Leaflet layers list (z-1001 vs the map's z-0 stacking context): it is now a
<details> that hides while the list is open and starts folded on short
viewports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tokens in globals.css, Tailwind gray/indigo scales remapped so the ~350
legacy raw-class uses follow the palette, light-mode override sheet moved
off slate, map accent hexes updated. Severity hues retuned for AA.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replay usage key gemini_usage -> llm_usage (backend + ReplayTab), re-price the
correction/correlation estimates for gpt-4o-mini, no temperature for o-series
or gpt-5 models, and refresh comments still naming Gemini.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One funded account (OpenAI) now covers extraction, embeddings, summaries,
the ambiguous-scene correlation call and transcript correction; Groq stays
optional for STT. New app/internal/llm.py picks the provider from the model
name, so setting either model back to a gemini-* ID needs no code change.
Usage accounting for replay runs now covers both providers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve upload.py (no-scene fallback uses the new _correlate with #298's
non_event_chatter reason) and chatter_classifier docstring (gate built, off by
default). Fold intelligence._llm_bool into incident_correlator.parse_llm_bool (#88).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No confidence field exists anywhere in scene/incident data, so the alerter
derives one: match weight (talkgroup 1.0, extracted tag 0.8, raw transcript
0.6) scaled down only when stt_confidence is under ALERT_STT_REFERENCE (0.3),
zero for not_speech. Below ALERT_MIN_CONFIDENCE, or high severity (term list,
or a major incident) without ALERT_CORROBORATION_MIN_CALLS calls or
ALERT_HIGH_SEVERITY_MIN_CONFIDENCE, the match is written to alert_holds
(no Firestore rule, admin-only GET /alerts/held) and logged instead of pushed.
A needs_corroboration hold is released when a later call brings its incident
to the call minimum; a rule pushed by that call supersedes its hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EXTRACTION_MODEL / EMBEDDING_MODEL replace the gpt-4o-mini and
text-embedding-3-small literals. gate_chatter (classify_chatter with roll_call
narrowed to whole-transcript roll calls) skips correction, extraction,
embedding and geocoding for housekeeping, writes skip_reason=non_event_chatter
and still runs the free clearance/plate backstops; upload.py's no-scene
fallback admits it like transcript_too_short. Ships OFF (CHATTER_GATE_ENABLED
=false): a gated call has no units, so it takes the no-fit-check thin attach,
and #127 requires a clean live shadow window first.
In-process TTL cache (24h, negatives 1h, 5000 entries, LRU) in front of the
Geocoding API, keyed by address+bounds; holds coordinates only, inside the
Maps 30-day lat/lng caching limit; errors and key/quota statuses not cached.
#104: the no-scene fallback passes the call doc's severity.
#88: resolved/reassignment parsed by _llm_bool, not bool().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
STT_PROVIDER=auto picks Groq whisper-large-v3-turbo only when GROQ_API_KEY is
set (else OpenAI, unchanged); compat covers any OpenAI-compatible server via
STT_BASE_URL/STT_API_KEY. A failed primary falls back to OpenAI STT_MODEL on
the same downloaded audio; the fallback reports to its own ai_health tier so
a dead primary stays red. The non-OpenAI provider gets a token-budgeted
glossary prompt (area places, local_knowledge, vocabulary; never ten-codes),
guarded by a narrow prompt-echo check. A per-30s-window zlib compression
check (Whisper's 2.4) adds a noise gate that survives correction being off
(#83, partial). Call docs record stt_provider/model/audio_seconds/confidence.
The sweep hold-back counts the fallback attempt. gate_chatter added to
chatter_classifier (used by transcription to skip correction when
CHATTER_GATE_ENABLED). GROQ_API_KEY templated with default ''.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
transcript_correction now also discards a correction that drops or changes a
bare number the recogniser heard (the live "7" -> "ShotSpotter" case) or
grows the transcript past max(4 words, 25%). place_verifier.verify() derives
an anchor from the talkgroup's own name plus the receiving node's state when
no area is configured; no state means no anchor, and a configured place
without an anchor keeps the load-bearing skip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The edge node now spools and retries uploads for up to 72h.
#301: when a call's started_at is more than late_upload_anchor_minutes
behind the real clock, extraction+correlation run under clock.pin(started_at)
(the replay mechanism), so recency gates and every timestamp written are on
the call's timeline. On-time uploads are unchanged. Also applies to
/calls/{id}/reprocess, which shares _run_intelligence_pipeline.
#302: an upload for a call_id whose audio is already stored returns the
stored URL without rewriting GCS or re-running Whisper/extraction/
correlation. Not atomic: two concurrent retries can both pass the check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules engine now commits every confident decision alone. _run_decision
marks a decision ambiguous (new_with_candidates, unfit_tg_candidate,
unverified_unit_link) and only then does upload._correlate make ONE
llm_correlator.decide call; a usable answer is committed, otherwise the rules
decision stands. Removed: _correlate_with_consensus, llm_correlator.tiebreak /
decisions_agree / tiebreak prompt, corr_smart_model, the correlation_smart
health tier, the LLM-orphan gate (_call_is_substanceless, effectively dead:
it could only fire on minor-severity untyped calls) and its sweep budget.
corr_consensus is now rules_only | llm | rules_fallback | rules_guard, with
corr_band naming why; admin debug and replay metrics read the new values.
Section fixes in the same pass:
- #115: no tiebreaker left to manufacture incidents; an LLM orphan of a
rules "new" is honoured only for untyped, substanceless, routine/minor calls.
- #86: divergence veto bar moved to config (embedding_divergence_threshold);
the band between it and the match bar is ambiguous, not a silent merge.
- #103: a unit link with no embedding is "unit_overlap_unverified" ->
ambiguous, instead of skipping the veto and linking as verified.
- #189: unit-only and LLM links refused once an incident is open 90 min;
unit links on incidents open >30 min are ambiguous; LLM links distance-
guarded (incident_max_spread_km) and only to shown candidates.
- #190: severity steps down on an explicit stand-down phrase; new
severity_peak keeps the monotonic max.
- #88: parse_llm_bool fails closed on "false"; scene closures honoured only
on confident links or the incident the scene opened.
- #27: sweep budget is the scan window (wall clock), not 3 attempts.
- LLM prompt: "Reserve new" bullet no longer contradicts the #189 EXCEPT.
- updated_at never moves backward on a back-dated link.
Unmeasured: no live AI or replay (credits unfunded); band frequency, F1 and
thresholds are unverified. Must be re-scored on both answer keys in Replay
before merge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checkin now carries disk_free_bytes/disk_total_bytes. Stored on the node doc
(not overwritten by older nodes that don't send them). Node page shows a Disk
row; node cards warn amber under 15% free, red under 5% or 2 GB. A full disk
stopped node-002 recording for 17 days while it showed Online.
Frontend unverified locally (no Node, #207); CI build typechecks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Incident detail "← Incidents" used router.back(), so arriving from Live
or a shared link it went to the map / out of the app. Now a Link.
- Resolved incidents showed "0m elapsed" (manual resolve sets no
resolved_at, updated_at can predate the last call): end is now the
latest of resolved_at, updated_at and the newest call.
- Admin: the max-width toggle wrapped the tab bar, so it reflowed on every
tab switch and left the cursor over e.g. "Close calls". Body only now.
- STT Eval sat on "Loading…" forever when a scanned page had zero matches
but a next_cursor — the refill effect didn't depend on cursor.
- Trip event blocks used dark-only bg-indigo-950/60: unreadable in light
mode. Theme tokens now.
- Reassign modal said "connected for the first time" on configured nodes.
- Dark-mode placeholders dimmed + italic; they read as filled-in values.
Frontend unverified locally (no Node, #207); CI build typechecks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Token text-ink-muted is #8C98AE in dark mode, ~2.9:1 on the always-white
popups (node, incident, path-stop, AIS, 511 camera). Scoped override to
#5B6577 (~6:1). Found in tonight's regression review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UI gate only — no route auth changes. Node page: override Ack/Force
Revert, Configure/Reassign and Restart OP25 are admin-only; non-admins on
an unconfigured node get a "an admin needs to assign it" note. /nodes
"Needs Configuration" cards open the config modal only for admins, and
link to the node page otherwise. /systems: New/Edit/Duplicate/Delete and
the AI-flags, area-context, talkgroup-pending and vocabulary panels are
admin-only; operators get a read-only list. (Delete's missing catch was
fixed in #290.)
Widening any of these routes to org owners/operators is a separate,
security-reviewed backend change.
Frontend unverified: no Node on the authoring box (#207).
Closes#222Closes#283
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#286: request() now throws C2ApiError — message is FastAPI's `detail` or a
plain sentence for the status (401/403/404/429/5xx), with toString() so the
many String(e) call sites read cleanly. status/body stay on the error; the
Discord parsers rebuild the legacy "C2 API error N: body" text via
legacyErrorText(), so their invite/code parsing is unchanged.
#284: Resolve, Regenerate summary, alert-rule toggle/delete/load,
Acknowledge, trip deletes, system delete and Restart OP25 now surface
failures (ErrorBanner where the page has one, alert() where the page
already uses it). Rules tab no longer says "No alert rules configured"
when the load failed. Confirms added for Resolve (no reopen path exists),
rule delete and trip delete. Reopen deliberately not built — manual
resolve doesn't set resolved_at, so reopening is a lifecycle change.
Frontend unverified: no Node on the authoring box (#207).
Closes#286
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UI_REDESIGN chunk 4 was never finished: 39 app/component files still used
raw Tailwind greys, kept readable in light mode only by the !important
override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the
light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both.
Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2,
500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised,
border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder.
Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700,
text-white and marketing pages left alone. text-[9px] -> text-[10px].
Frontend unverified: no Node on the authoring box (#207).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "Dark" base layer was bright OSM in prod; invert the raster via
.drb-tiles-dark so dark-theme overlays sit on a dark ground.
- Rail and mobile incident cards are opaque, not glass over the map.
- Dark --ink-muted #77839A -> #8C98AE, --sev-major #D03B3B -> #F05252
(both failed AA as small text on surface/raised).
- Card borders used `color + "55"` on CSS var() colours, which is
invalid CSS — no border rendered. Now color-mix.
- 10px rail labels -> 11px.
Frontend unverified: no Node on the authoring box (#207).
Closes#136
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- #279: cache incident/fan divIcons by their visual inputs and React.memo
MapView, so LiveView's 1s clock no longer rebuilds icons / calls setIcon
and the #238 pulse ring completes its cycle.
- #275: IncidentPanel shows "resolved", "lasted X" frozen at resolved_at,
and labels live cameras/context as current, not incident-time.
- #268: rail header (desktop + mobile drawer) reads "N of M · filtered"
via a new filteredFrom prop while the wall filter is on.
- #278: while camera tiles are docked bottom-centre, the weather status
chip and radar badge stack under the tiles inside the dock instead of
being covered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- NEW lane: Gate A notice pinned below the rows (it scrolled off with >3
entries, same rule as the rail, #46); rows at text-xs for the wall.
- Mobile chip moved off MapView's centred "Live" chip, which it covered.
- Keyword cues re-resolve to the incident once correlation links the call,
so the lane row is clickable and the pin pulses.
- Enabling notifications sends one confirmation notification; a browser
that grants permission but throws on construct (mobile Chrome) now shows
"unsupported" instead of a switched-on control that never delivers.
- useActiveIncidentsState: includeMetadataChanges so `loaded` flips on a
metadata-only server sync (quiet org / offline start) instead of waiting
for the first incident change and swallowing it into the baseline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 1 h loop set MapView state every 600 ms, re-rendering the whole map
component and rebuilding (setIcon) every marker icon on each tick. The
frame now lives in a small external store that only the radar frames and
the valid-time badge subscribe to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Weather Warnings overlay (layer key `wx`, saved in the view hash): NWS alert
polygons coloured by event (NWS hazard-map colours), outline dash by kind,
popup with event/headline/expiry/office and National Weather Service credit.
Polls GET /weather/alerts every 2 min while on and visible, refetches on pan.
A status chip always says what the layer shows; feed errors use the 511
FeedProblem wording; zone-only alerts are listed, never dropped.
- Radar: IEM nexrad-n0r -> n0q. Valid-time badge from IEM's n0q_0.json,
STALE past 15 min or when the time can't be read; dBZ legend; optional 1 h
loop (nexrad-n0q-900913-m05m..m55m), off by default. Still refreshed in
place, never remounted.
- liveWall: sampleRadarDbz reads the n0q tile with IEM's published 255-colour
ramp (verified against live tiles); pointInPolygon/alertsAt, wxStyle,
wantsWind helpers.
- Wall incident panel: "inside <warning>" chips, wind arrow + speed/gust +
station/obs time on fire/hazmat incidents; region Feeds gains an NWS row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lib/attention.ts: pure snapshot diff (Major opened, severity escalated,
pursuit path gained a stop, priority keyword in a new transcript) with a
silent baseline on page open; calls baseline separately on their first
non-empty snapshot. Diffed over all active incidents so widening a filter
is never "new"; emitted, laned and pulsed only for the FILTERED view.
- components/live/NewLane.tsx: "NEW · last 15 min" overlay above the rail
(desktop) / compact chip (mobile), Gate A notice included.
- components/live/AttentionControls.tsx: opt-in Web Audio tone (enabling
click unlocks audio) and Web Notifications while the tab is hidden, body
carries the machine-output notice; prefs + keywords in localStorage.
- LiveView wires it and passes pulseIncidentIds to MapView (2 min pulse).
- useIncidents: useActiveIncidentsState exposes a loaded flag (first
server snapshot) so the baseline never sees an empty pre-load list.
- MachineOutputNotice exports MACHINE_OUTPUT_NOTICE_TEXT (same wording).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /incidents/[id] page kept its own stop numbering (every geocoded call,
1..N) and path length, while its MapView now numbers only movement
incidents' non-ghosted stops. A fire with 3 geocoded calls showed stops
1-3 on the spine and unnumbered dots on the map. Use the shared
pathStops()/stopNumbers(), show 'km path' only for movement incidents,
and surface the 'may be merged' flag in the header.
Legend: a plain pin is also what an older (pre-#236) pin with no recorded
precision looks like; say so instead of claiming 'Street address'.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useIncident reports loading until its answer belongs to the current id
(the render before the effect read the previous id's loading=false), and
clears a stale doc on a denied read. LiveView holds the last selected doc
for the same id while the by-id lookup is pending, so a watched incident
resolving no longer deselects (fly to region) and reselects (fly back).
Follow-up for the panel's 'active'/elapsed copy on resolved incidents: #275.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Backend: _geocode_location now returns {lat, lng, location_type}, so the
precision rides onto the call doc and, unchanged via _resolve_location_pair,
onto the incident pin. Display-only: every correlator read indexes lat/lng.
Frontend: pins styled by precision (rooftop / interpolated ring /
GEOMETRIC_CENTER dashed uncertainty circle labelled with the spoken place /
unverified pre-#23 pins ghosted). liveWall.pathStops() is shared by the map
and the call spine: only police/accident incidents with a pursuit tag draw a
numbered path; others get unnumbered call dots. Segments over >10 min gaps or
talkgroup changes are dashed, stops implying >8 km/min are ghosted off the
path, paths >20 km get a "may be merged" badge. New optional MapView prop
pulseIncidentIds (wired by #238).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LiveView: the selected incident merges useCallsByIncident (org_id +
incident_ids CONTAINS, existing index, no orderBy/limit) into the calls fed
to IncidentPanel and MapView, so a long incident keeps its full path and
spine past the 500-call window. Stale rows from the previous selection are
re-checked against the current id before merging.
- #inc= not in the active set is fetched with useIncident: a resolved one is
selected, drawn on the map and shown under a "Resolved HH:MM" banner; a
missing or other-org id shows "Incident not found". Auto-tour stays on
active incidents.
- TimeScrubber: new loadedSince prop (oldest call when the window came back
full). The axis before it is hatched and labelled "not loaded" instead of
reading as a quiet stretch.
- useCalls returns hasMore (full page, counted before the duplicate_of
filter); useIncident resets loading on a new id so a lookup doesn't flash
"not found".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot
/help and the server-limit refusal no longer imply the DRB server bot is
required; a radio bot alone is enough.
- Admin overview: per-server main_bot_present, radio_bots, linked_via,
directory_source; Servers tab gets a Bots column.
- discord_web docstring: states the reversed "never calls Discord" rule
(plan finding 5) and the optional server bot.
- Server bot directory cog: re-push (which drops C2's cached permission
answers) on overwrite changes, role permission changes and role deletes.
- tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set
preferred, OAuth link, picker, start, reconciler) and full setup (server
bot + 4 radio bots: placement, shared channel, every refusal explained),
against a modelled Discord over httpx.MockTransport.
- storage.download_audio removed: no caller on main since #233.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- useOutagesIn kept the previous incident's answer while fetching the next
one (IncidentPanel isn't keyed), so a newly selected incident briefly read
'none reported here'. Drop the old answer when the target changes.
- OutageLayer built a fresh divIcon per marker every render; MapView
re-renders on live updates, so Leaflet rebuilt every marker's DOM. Memoize.
- Popup now carries the provider's coverage note (ODIN: non-reporting
utilities, e.g. Con Ed/O&R, show nothing even during an outage).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Storm/Traffic preset tag terms now match at the start of a word, so
"tree" no longer pulls "street-racing"/"street-fight" into Storm mode.
- FilterBar search compared the trimmed hash q against the untrimmed
typed text, so pausing after "main " reset the box to "main" and the
next word ran on ("mainst"). Compare trimmed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lib/useOutages.ts: GET /outages for the map view (pan/zoom debounced) or a
fixed bbox; polls every 3 min while visible, refetches when a hidden tab
returns.
- components/map/OutageLayer.tsx: county markers at the label point sized by
meters out, stale-only counties dimmed/dashed; popup with count, % served
when known, since/ETR, per-utility counts, SourceCredit + fetch time, and
"county centre, not the outage location". An always-on status chip says
what the layer shows: loading, N counties, none reported in view (with the
source's coverage note), no source enabled (admin-only link to
/admin#outages), no licensed source covers this area, or feed error.
- MapView: "Power Outages" overlay, off by default, plus a legend entry while
it is on. Not hash-persisted yet: LAYER_NAMES lives in lib/liveWall.ts,
which #235 owns this batch.
- lib/outageMatch.ts (pure): county data matches by county bbox and says
"county has N out" ("in or near" when two bboxes hold the point); point
data matches within 500 m; stale rows never count as live; flags
wires-down/transformer/manhole/pole/tree-on-wires incidents.
- WallPanels: OUTAGE lines under the incident's situational context (hazard
chip, source credit, fetch time) and an OUTAGE row in the region Feeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lib/liveFilter.ts (pure): WallFilter {types, sevFloor, systems, talkgroups,
ageH, tags, q, preset}, applyFilter over loaded incidents + calls, parse /
serialise to the f.* hash keys #234 reserved. Presets: Fire (fire + EMS
assists heard on a fire channel; Radar on), Storm (weather/wires/tree/flood
tags; Radar on), Traffic (accident / MVA tags; Traffic Events on).
components/live/FilterBar.tsx: one wrapping row under the wall bar; less-used
controls fold behind a toggle below md; read-only summary in kiosk.
LiveView applies the filter once and feeds the filtered lists to MapView
(pins, paths, rail), RegionPanel, TranscriptTicker, TimeScrubber and the
auto-tour; KPIs count the filtered set and say "filtered". Presets turn
layers on through the saved-view layer state, not a MapView edit. Pipeline
health stays unfiltered. liveWall.ts keeps commas literal in f.* values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>