45 Commits
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 b7cdb4452f Releases: tag each healthy deploy vMAJOR.MINOR.PATCH; /health and node checkin report versions (node-26#49)
Build & Deploy / Build & push images (push) Successful in 4m35s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 1m34s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
Build & Deploy / Tag release (push) Successful in 5s
VERSION holds MAJOR.MINOR (1.1). The build job works out the next patch
(scripts/next-version.sh, integer count) and stamps it into c2-core as
DRB_VERSION, which /health returns. The new "Tag release" job tags the
commit only after the deploy job succeeds, so a failed deploy's number is
reused. Node checkins carry `version`, stored on the node doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:34:51 -05:00
Logan CusanoandClaude Opus 5.5 5cdde85d55 Calls: store every radio heard on a call; call_end writes srcaddr (server-26#304)
Build & Deploy / Build & push images (push) Successful in 4m36s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m32s
Build & Deploy / Report a failed deploy (push) Skipped
P1 of the radio-ID plan. The node now reports `radios`, one
{radio, start, end} per transmission, on /upload and on call_end. Both go
through app/internal/radios.py into `radios` + `radio_ids` on the call doc.
/upload writes them before the pipeline starts, because call_end arrives
after it.

call_end also writes srcaddr now. Only call_start used to, and the node
usually learns the talker after call_start, so most calls kept None and the
correlator's srcaddr match rarely fired. /upload fills srcaddr from the
latest radio when the doc has none.

Older nodes send neither field; nothing is blanked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:03:08 -05:00
logan cbf45ff004 Merge pull request 'Frontend: finish Iris — accent-role blue/sky to indigo' (#306) from feat/palette-ui-pass-3 into main
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m48s
Build & Deploy / Report a failed deploy (push) Skipped
2026-11-08 22:54:05 -05:00
Logan CusanoandClaude Opus 5.5 cf232e313f frontend: move accent-role blue/sky to Iris (popup links, play glyph, admin info banners)
Category colours (police tag, trips) stay as-is: they're semantic, not accent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:53:40 -05:00
logan 92c9b245f6 Merge pull request 'Frontend: Iris palette + map load/legend fixes' (#305) from feat/palette-ui-pass-2 into main
Build & Deploy / Build & push images (push) Successful in 4m21s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 26s
Build & Deploy / Deploy to VM (push) Successful in 1m32s
Build & Deploy / Report a failed deploy (push) Skipped
Merge PR #305: Iris palette + map load/legend fixes
2026-11-08 21:50:48 -05:00
Logan CusanoandClaude Opus 5.5 cc41d1badb map: mount without waiting on the 500-call window; legend collapsible, hidden under open layers list
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>
2026-11-08 21:42:02 -05:00
Logan CusanoandClaude Opus 5.5 df837d7d81 frontend: Iris palette — graphite neutrals + indigo-violet accent
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>
2026-11-08 21:41:55 -05:00
Logan CusanoandClaude Opus 5.5 4fab959ddb AI providers: review follow-ups to 0060ccd
Build & Deploy / Build & push images (push) Successful in 4m59s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 2m48s
Build & Deploy / Report a failed deploy (push) Skipped
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>
2026-11-08 01:27:16 -05:00
Logan CusanoandClaude Opus 5.5 0060ccde75 AI providers: correlation decision and transcript correction move to OpenAI gpt-4o-mini
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>
2026-11-08 01:25:21 -05:00
Logan CusanoandClaude Opus 5.5 3e9ca4be06 Merge feat/297-cheaper-ai into feat/299-correlation-simplify
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>
2026-10-27 20:31:54 -04:00
Logan CusanoandClaude Opus 5.5 90a3c27a24 Docs: document the STT provider, extraction and alert-gate env vars (server-26#297, server-26#298, server-26#300)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:29:10 -04:00
Logan CusanoandClaude Opus 5.5 ff0a89e9f1 Alerts: confidence gate before any push, held alerts, release on corroboration (server-26#300)
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>
2026-10-27 20:29:01 -04:00
Logan CusanoandClaude Opus 5.5 240bbee5a4 Extraction: model in config, chatter gate, geocode cache (server-26#298, server-26#104, server-26#88)
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>
2026-10-27 20:28:46 -04:00
Logan CusanoandClaude Opus 5.5 d485eae41a Transcription: config-driven OpenAI-compatible STT with OpenAI fallback (server-26#297, server-26#83)
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>
2026-10-27 20:28:30 -04:00
Logan CusanoandClaude Opus 5.5 7ecfca7648 Correction: reject lost numbers and growth; place verifier anchors from the talkgroup name (server-26#162, server-26#160)
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>
2026-10-27 20:28:29 -04:00
Logan CusanoandClaude Opus 5.5 641028699f features page: describe rules-first correlation, not the removed consensus tiebreak (server-26#299)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:27:34 -04:00
Logan CusanoandClaude Opus 5.5 ec35d80580 upload: correlate late uploads on the call's own clock; make /upload idempotent on call_id (server-26#301, server-26#302)
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>
2026-10-27 20:26:48 -04:00
Logan CusanoandClaude Opus 5.5 3246fc97b0 correlation: rules decide, one cheap LLM call only when ambiguous; drop the consensus tiebreak (server-26#299)
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>
2026-10-27 20:26:38 -04:00
logan f4d78aba77 Merge pull request 'Nodes: store and show host disk free, warn when low (node-26#41)' (#295) from feat/41-node-disk into main
Build & Deploy / Build & push images (push) Successful in 4m43s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m43s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-26 02:28:10 -04:00
Logan CusanoandClaude Opus 5.5 268470b6c0 nodes: store and show host disk free; warn when low (node-26#41)
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>
2026-10-26 02:24:10 -04:00
logan 4a93390967 Merge pull request 'Frontend: fixes from the 10-25 Chrome sweep' (#294) from fix/site-sweep-1025 into main
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 2m26s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 23:13:55 -04:00
Logan CusanoandClaude Opus 5.5 94898d1af1 fix(frontend): issues from the 10-25 Chrome sweep
- 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>
2026-10-25 23:12:07 -04:00
logan 33056e2269 Merge pull request 'Frontend: hide admin-only node/system controls from non-admins (#222, #283) + popup contrast' (#291) from fix/222-283-admin-gating into main
Build & Deploy / Build & push images (push) Successful in 4m18s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m27s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 22:17:26 -04:00
Logan CusanoandClaude Opus 5.5 e8084357c3 fix(map): keep muted popup text AA on Leaflet's white popups
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>
2026-10-25 22:09:57 -04:00
Logan CusanoandClaude Opus 5.5 9702b90d40 fix(frontend): hide admin-only node and system controls from non-admins (#222, #283)
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 #222
Closes #283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:07:22 -04:00
logan 606b0a103c Merge pull request 'Frontend: readable API errors, no silent action failures (#284, #286)' (#290) from fix/284-286-error-feedback into main
Build & Deploy / Build & push images (push) Successful in 4m16s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m24s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 21:39:15 -04:00
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#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>
2026-10-25 21:38:13 -04:00
logan 6d667e81c9 Merge pull request 'Frontend: finish the token migration — raw greys to AA theme tokens' (#282) from fix/readability-tokens into main
Build & Deploy / Build & push images (push) Successful in 4m21s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m36s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 21:34:38 -04:00
Logan CusanoandClaude Opus 5.5 bf206334e3 fix(frontend): finish the token migration — raw grey classes -> theme tokens
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>
2026-10-25 21:29:13 -04:00
logan 3968b49b80 Merge pull request 'Map: readable incident rail — dark basemap, opaque cards, AA colours (#136)' (#281) from fix/136-map-contrast into main
Build & Deploy / Build & push images (push) Successful in 4m54s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 3m17s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 21:03:22 -04:00
Logan CusanoandClaude Opus 5.5 8099871c68 fix(map): readable incident rail — dark basemap, opaque cards, AA colours (#136)
- "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>
2026-10-25 21:03:08 -04:00
logan 49166789f4 Merge pull request 'Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock (#279 #275 #268 #278)' (#280) from fix/map-followups into main
Build & Deploy / Build & push images (push) Successful in 4m16s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m46s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 04:02:32 -04:00
Logan CusanoandClaude Opus 5.5 9698d463ad Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock
- #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>
2026-10-05 04:01:20 -04:00
logan 3c5496acb1 Merge pull request 'Live wall attention cues: NEW lane, pin pulse, opt-in tone and notifications (#238)' (#276) from feat/238-attention-cues into main
Build & Deploy / Build & push images (push) Successful in 4m42s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 2m55s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:53:41 -04:00
logan cde86a8ff9 Merge pull request 'Weather map layers: NWS warning polygons, n0q radar with valid time/legend/loop, wind at incident (#237)' (#277) from feat/237-weather-map into main
Build & Deploy / Build & push images (push) Successful in 4m44s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 4s
Build & Deploy / Deploy to VM (push) Successful in 10s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:53:34 -04:00
Logan CusanoandClaude Opus 5.5 292c4fffd9 Attention cues review fixes (#238)
- 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>
2026-10-05 03:52:02 -04:00
Logan CusanoandClaude Opus 5.5 da55114eef Radar loop: tick the frame outside MapView state (#237)
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>
2026-10-05 03:51:07 -04:00
Logan CusanoandClaude Opus 5.5 1a84bf46d7 Weather map layers: NWS warning polygons, n0q radar with valid time/legend/loop, wind at incident (#237)
- 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>
2026-10-05 03:46:05 -04:00
Logan CusanoandClaude Opus 5.5 eb0faf2aea Live wall attention cues: NEW lane, pin pulse, opt-in tone and notifications (#238)
- 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>
2026-10-05 03:45:05 -04:00
logan 1d6e79ba25 Merge pull request 'Live wall: selected incident loads its own calls, resolved deep links, scrubber marks unloaded time (#239)' (#272) from fix/239-call-window into main
Build & Deploy / Build & push images (push) Successful in 4m18s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 26s
Build & Deploy / Deploy to VM (push) Successful in 1m28s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 03:33:27 -04:00
logan 0d613019e0 Merge pull request 'Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236)' (#274) from feat/236-honest-pins into main
Build & Deploy / Build & push images (push) Failing after 15s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Deploy Firestore rules & indexes (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 2s
2026-10-05 03:33:20 -04:00
Logan CusanoandClaude Opus 5.5 e1bd3feac5 Incident page spine: number stops from pathStops, not its own index (#236)
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>
2026-10-05 03:32:36 -04:00
Logan CusanoandClaude Opus 5.5 7a0e86decf Live wall review fixes: no not-found flash or map whiplash when the selected incident resolves (#239)
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>
2026-10-05 03:29:58 -04:00
Logan CusanoandClaude Opus 5.5 6bf072973c Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236)
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>
2026-10-05 03:28:00 -04:00
Logan CusanoandClaude Opus 5.5 a267fdf982 Live wall: selected incident loads its own calls; resolved deep links; scrubber marks unloaded time (#239)
- 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>
2026-10-05 03:25:13 -04:00
108 changed files with 7461 additions and 2343 deletions
+42
View File
@@ -12,9 +12,27 @@ jobs:
build: build:
name: Build & push images name: Build & push images
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
# node-26#49: this build's release, vMAJOR.MINOR.PATCH. Tagged only once
# the deploy is healthy (job "tag" below), so a failed deploy's number is
# reused by the next push.
- name: Work out the release version
id: version
run: |
git fetch --force --tags origin
TAG="$(bash scripts/next-version.sh)"
if [ -z "$TAG" ]; then
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k3,3n | tail -1)"
fi
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "release ${TAG}"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -33,6 +51,7 @@ jobs:
push: true push: true
build-args: | build-args: |
GIT_SHA=${{ gitea.sha }} GIT_SHA=${{ gitea.sha }}
DRB_VERSION=${{ steps.version.outputs.version }}
tags: | tags: |
${{ env.REGISTRY }}/c2-core:latest ${{ env.REGISTRY }}/c2-core:latest
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }} ${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
@@ -459,3 +478,26 @@ jobs:
PY PY
curl -sS -X POST -H "Content-Type: application/json" \ curl -sS -X POST -H "Content-Type: application/json" \
--data @/tmp/payload.json "$WEBHOOK" || echo "notification POST failed" --data @/tmp/payload.json "$WEBHOOK" || echo "notification POST failed"
# node-26#49: a healthy deploy is a release. Tag it vMAJOR.MINOR.PATCH with
# the number the build stamped into /health.
tag:
name: Tag release
needs: [build, deploy]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Tag this commit
env:
VERSION: ${{ needs.build.outputs.version }}
run: |
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "no release version from build ('$VERSION')"; exit 1; }
git fetch --force --tags origin
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
echo "v$VERSION already exists"; exit 0
fi
git tag "v$VERSION"
git push origin "refs/tags/v$VERSION"
echo "released v$VERSION"
+1
View File
@@ -0,0 +1 @@
1.1
+45 -1
View File
@@ -36,8 +36,52 @@ NWS_USER_AGENT=DRB (you@example.com)
# Comma list of NWS state/marine area codes served # Comma list of NWS state/marine area codes served
NWS_AREA=NY NWS_AREA=NY
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries # OpenAI — transcription (whisper-1, and the STT fallback), extraction, embeddings
OPENAI_API_KEY= OPENAI_API_KEY=
# Since 2026-11-08 OpenAI also serves the correlation decision and transcript
# correction (CORR_CHEAP_MODEL / TRANSCRIPT_CORRECTION_MODEL, default gpt-4o-mini).
# GEMINI_API_KEY is only needed if you point either of those at a gemini-* model.
# OpenAI transcription model: whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
STT_MODEL=whisper-1
# STT provider (server-26#297). Any OpenAI-compatible /audio/transcriptions API.
# auto (default): groq if GROQ_API_KEY is set, compat if STT_BASE_URL is set,
# otherwise openai — so leaving these blank keeps today's OpenAI behaviour.
STT_PROVIDER=auto
# Groq: whisper-large-v3-turbo, $0.04/audio-hour (min 10s billed per request).
GROQ_API_KEY=
# Model on the non-OpenAI provider.
STT_COMPAT_MODEL=whisper-large-v3-turbo
# Override the endpoint/key (blank = provider default: https://api.groq.com/openai/v1
# and GROQ_API_KEY). Set both for a self-hosted OpenAI-compatible server.
STT_BASE_URL=
STT_API_KEY=
# Retry on OpenAI STT_MODEL when the provider above fails (needs OPENAI_API_KEY).
STT_FALLBACK_TO_OPENAI=true
# Area places / local names / vocabulary in the STT prompt (non-OpenAI provider only).
STT_VOCAB_PROMPT_ENABLED=true
# Estimated prompt token budget (Whisper keeps only the last 224).
STT_PROMPT_MAX_TOKENS=200
# Scene extraction / embeddings models (server-26#298)
EXTRACTION_MODEL=gpt-4o-mini
EMBEDDING_MODEL=text-embedding-3-small
# Skip correction + extraction for radio housekeeping (roll call, bare 10-4s).
# Default false = shadow mode (verdict recorded, nothing skipped). Turn on only
# after a shadow window shows no real events flagged (server-26#127).
CHATTER_GATE_ENABLED=false
# In-process geocode cache (Maps terms allow caching lat/lng <= 30 days)
GEOCODE_CACHE_TTL_SECONDS=86400
GEOCODE_CACHE_NEGATIVE_TTL_SECONDS=3600
GEOCODE_CACHE_MAX_ENTRIES=5000
# Push-alert confidence gate (server-26#300). Below the floor -> held (admin-only
# GET /alerts/held). High-severity matches also need N calls on the incident or
# the high confidence bar.
ALERT_MIN_CONFIDENCE=0.5
ALERT_HIGH_SEVERITY_MIN_CONFIDENCE=0.85
ALERT_CORROBORATION_MIN_CALLS=2
ALERT_STT_REFERENCE=0.3
SUMMARY_INTERVAL_MINUTES=15 SUMMARY_INTERVAL_MINUTES=15
CORRELATION_WINDOW_HOURS=4 CORRELATION_WINDOW_HOURS=4
EMBEDDING_SIMILARITY_THRESHOLD=0.82 EMBEDDING_SIMILARITY_THRESHOLD=0.82
+3
View File
@@ -13,5 +13,8 @@ COPY tests/ ./tests/
# -- exactly how production served two-day-old code for two days. # -- exactly how production served two-day-old code for two days.
ARG GIT_SHA=unknown ARG GIT_SHA=unknown
ENV GIT_SHA=$GIT_SHA ENV GIT_SHA=$GIT_SHA
# The release tag this build becomes (node-26#49), e.g. 1.1.4.
ARG DRB_VERSION=untagged
ENV DRB_VERSION=$DRB_VERSION
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+124 -19
View File
@@ -28,8 +28,39 @@ class Settings(BaseSettings):
# OpenAI (STT + intelligence) # OpenAI (STT + intelligence)
openai_api_key: Optional[str] = None openai_api_key: Optional[str] = None
# The OpenAI transcription model. Used when OpenAI is the STT provider AND
# as the fallback when another provider fails (server-26#297).
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
# ── STT provider (server-26#297) ──────────────────────────────────────────
# Any OpenAI-compatible /audio/transcriptions endpoint. Groq serves
# whisper-large-v3-turbo at $0.04/audio-hour against whisper-1's $0.36, same
# API (min billed length 10s per request, prompt capped at 224 tokens --
# console.groq.com/docs/speech-to-text, checked 2026-10-27).
# auto -- groq when GROQ_API_KEY is set, compat when STT_BASE_URL is
# set, else openai. The default, so a deploy without either
# behaves exactly as before.
# openai -- OpenAI only (stt_model).
# groq -- Groq (stt_compat_model), OpenAI fallback.
# compat -- any other OpenAI-compatible server: stt_base_url + stt_api_key
# + stt_compat_model (e.g. a self-hosted faster-whisper, #296).
stt_provider: str = "auto"
stt_base_url: Optional[str] = None # None -> the provider's own default
stt_api_key: Optional[str] = None # None -> GROQ_API_KEY / OPENAI_API_KEY
groq_api_key: Optional[str] = None
stt_compat_model: str = "whisper-large-v3-turbo" # model on a non-OpenAI provider
# Retry a failed non-OpenAI request once on OpenAI stt_model. Needs
# OPENAI_API_KEY; without it a primary failure just means no transcript.
stt_fallback_to_openai: bool = True
# Domain vocabulary in the STT prompt (area places, local_knowledge terms,
# vocabulary). Applied to the non-OpenAI provider only: whisper-1 echoed
# vocabulary into silence before (see transcription.py), so the OpenAI
# path -- including the fallback -- keeps today's fixed prompt.
stt_vocab_prompt_enabled: bool = True
# Whisper keeps only the LAST 224 prompt tokens and silently drops the
# rest. The prompt is built against this estimated budget, with headroom.
stt_prompt_max_tokens: int = 200
# AI request timeouts (server-26#153). Without these the OpenAI SDK waits # AI request timeouts (server-26#153). Without these the OpenAI SDK waits
# up to 600s per attempt and retries twice, and the google-genai client # up to 600s per attempt and retries twice, and the google-genai client
# waits forever, so a hung provider call had no upper bound at all — and # waits forever, so a hung provider call had no upper bound at all — and
@@ -48,8 +79,31 @@ class Settings(BaseSettings):
# google-genai does not retry unless told to, so this is the whole bound. # google-genai does not retry unless told to, so this is the whole bound.
gemini_timeout_seconds: float = 60.0 gemini_timeout_seconds: float = 60.0
# Scene extraction + embeddings (server-26#298). Were literals in
# intelligence.py; any OpenAI chat model that supports JSON mode works.
extraction_model: str = "gpt-4o-mini"
embedding_model: str = "text-embedding-3-small"
# Skip correction + extraction (+ embedding, geocoding) for transcripts
# chatter_classifier.gate_chatter calls radio housekeeping. OFF by default:
# a gated call carries no units, so it is thin and takes the no-fit-check
# thin attach (mechanism A) instead of the fit-tested path or the #115
# orphan gate, and server-26#127 set "0 false positives in a live shadow
# window" as the bar for going live. Read chatter_classifier_verdict off a
# shadow window first, then set CHATTER_GATE_ENABLED=true.
chatter_gate_enabled: bool = False
# Google Maps (geocoding) # Google Maps (geocoding)
google_maps_api_key: Optional[str] = None google_maps_api_key: Optional[str] = None
# In-process cache of Geocoding API answers (server-26#298). Dispatch
# repeats the same address on every follow-up call of a job, and the
# place verifier re-geocodes the same nouns. In-process rather than
# Firestore: a Firestore read costs about what it saves, adds latency on
# every miss, and would persist Maps content -- the Maps terms allow
# caching lat/lng for at most 30 days, and an in-memory TTL can never
# outlive that. Lost on restart, which only costs a few repeat lookups.
geocode_cache_ttl_seconds: int = 24 * 60 * 60
geocode_cache_negative_ttl_seconds: int = 60 * 60 # ZERO_RESULTS etc.
geocode_cache_max_entries: int = 5000
# 511NY developer key (server-26#183/#198). Issued with the NYSDOT Developer # 511NY developer key (server-26#183/#198). Issued with the NYSDOT Developer
# Access Agreement grant; the keyless legacy API is gone since the 511x cutover. # Access Agreement grant; the keyless legacy API is gone since the 511x cutover.
ny511_api_key: Optional[str] = None ny511_api_key: Optional[str] = None
@@ -68,27 +122,23 @@ class Settings(BaseSettings):
# NWS alerts cache TTL; the issue asks 60-120 s (floored at 30 s in code). # NWS alerts cache TTL; the issue asks 60-120 s (floored at 30 s in code).
weather_alerts_ttl_s: int = 90 weather_alerts_ttl_s: int = 90
# Gemini (intelligence extraction, embeddings, incident summaries) # Gemini: only used if CORR_CHEAP_MODEL / TRANSCRIPT_CORRECTION_MODEL is set
# to a gemini-* ID (app/internal/llm.py). OpenAI serves both by default.
gemini_api_key: Optional[str] = None gemini_api_key: Optional[str] = None
# Correlation consensus models # Correlation LLM (server-26#299). The rules engine decides every confident
# corr_cheap_model — first-pass LLM correlator (runs on every call) # case; this model is called at most ONCE per scene, and only when the rules
# corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree) # flag the decision as ambiguous (incident_correlator._ambiguity). There is
# Both IDs below were retired by Google and returned 404 on every call from # no second "smart" tier any more: the cheap/smart consensus tiebreak ran on
# some point before 2026-08-18 until they were corrected. Because a failed # ~15% of scenes and resolved nearly every disagreement as "new" (#115).
# LLM call falls back to the rules decision, nothing broke loudly -- the # A model ID was once retired by its provider and 404'd on every call for
# entire LLM tier and the consensus tiebreak were simply dead in production # days; a failed LLM call falls back to the rules decision, so nothing broke
# while correlation behaviour was being tuned against rules-only output. # loudly. Verify the ID against the provider's model list before changing.
# Verify against https://ai.google.dev/gemini-api/docs/models before changing. corr_cheap_model: str = "gpt-4o-mini" # OpenAI since 2026-11-08 (was gemini-3.6-flash); a gemini-* ID still works
corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down)
# gemini-2.5-pro was closed to new projects by 2026-09 (every tiebreak 404'd
# in the first replay run, server-26#170); Google lists no stable Pro model,
# so the smart tier is the newest stable Flash instead.
corr_smart_model: str = "gemini-3.8-flash" # was gemini-2.5-pro, gemini-1.5-pro
# Transcript correction (server-26#36). Runs inside transcription, once per # Transcript correction (server-26#36). Runs inside transcription, once per
# transcribed call above MIN_WORDS_FOR_CORRECTION, so it is priced like STT # transcribed call above MIN_WORDS_FOR_CORRECTION, so it is priced like STT
# rather than like the correlation tier — cheap model on purpose. # rather than like the correlation tier — cheap model on purpose.
transcript_correction_enabled: bool = True transcript_correction_enabled: bool = True
transcript_correction_model: str = "gemini-3.6-flash" transcript_correction_model: str = "gpt-4o-mini" # OpenAI since 2026-11-08; a gemini-* ID still works
# Retry Whisper once when its output is degenerate. The same clip produced a # Retry Whisper once when its output is degenerate. The same clip produced a
# 56-word ten-code counting run on one attempt and real speech on the next # 56-word ten-code counting run on one attempt and real speech on the next
# (2026-08-23, call e49ea32c), so a hallucination is a coin-flip rather than # (2026-08-23, call e49ea32c), so a hallucination is a coin-flip rather than
@@ -109,6 +159,30 @@ class Settings(BaseSettings):
# else's PII. Both gates must be open before any snippet leaves. # else's PII. Both gates must be open before any snippet leaves.
alert_transcript_snippet_enabled: bool = False alert_transcript_snippet_enabled: bool = False
place_verify_max_per_call: int = 3 place_verify_max_per_call: int = 3
# ── Alert confidence gate (server-26#300) ─────────────────────────────────
# Nothing in the pipeline emits a confidence, so the alerter derives one:
# how the rule matched (talkgroup 1.0 / extracted tag 0.8 / raw transcript
# text only 0.5 -- the CrimeRadar "fire alarm" -> "firearm" failure was a
# transcript match) scaled down when Whisper's own per-token probability
# (calls.stt_confidence) is below alert_stt_reference. Below the floor ->
# held, never pushed. High-severity matches also need the incident to
# have alert_corroboration_min_calls calls, or a confidence at or above
# alert_high_severity_min_confidence; held ones are released when a
# later call corroborates the incident. Starting values, not measured.
alert_min_confidence: float = 0.5
alert_high_severity_min_confidence: float = 0.85
alert_corroboration_min_calls: int = 2
# Low on purpose: exp(avg_logprob) on vocoded radio audio is often well
# under 0.5 for speech that is right, and Groq/OpenAI logprobs are not on
# a shared scale. Only clearly garbled audio should pull the score down.
alert_stt_reference: float = 0.3
alert_high_severity_terms: list[str] = [
"shots-fired", "shots fired", "shooting", "shot", "gun", "firearm", "weapon",
"stabbing", "stabbed", "officer-down", "officer down", "10-13",
"officer-needs-assistance", "active-shooter", "hostage", "barricade",
"explosion", "structure-fire", "structure fire", "working-fire",
"entrapment", "cardiac-arrest", "vehicle-pursuit", "pursuit",
]
# How close a candidate has to sound before it may rewrite a transcript. # How close a candidate has to sound before it may rewrite a transcript.
# Below this, Places Text Search will confidently hand back the nearest # Below this, Places Text Search will confidently hand back the nearest
# business for any garbage string. # business for any garbage string.
@@ -122,6 +196,39 @@ class Settings(BaseSettings):
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
embedding_no_location_threshold: float = 0.97 # slow-path: match without location (very high bar) embedding_no_location_threshold: float = 0.97 # slow-path: match without location (very high bar)
embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units
# server-26#86: the content-divergence veto on a unit-overlap link. This is
# a DIVERGENCE bar, not a match bar, so it sits deliberately below
# embedding_similarity_threshold: two calls about different jobs on one
# dispatch channel still share ten-codes, unit IDs and address phrasing, so
# "not a match" (< 0.93) is not evidence of "a different job". Below this
# bar the link is vetoed; between the two bars the link is neither
# confirmed nor refused, so it is AMBIGUOUS and goes to the LLM (#299)
# instead of silently merging. 0.82 is the historical literal, unmeasured
# against real score distributions.
embedding_divergence_threshold: float = 0.82
# A unit-overlap link is only a confident one while the incident is fresh:
# idle under this many minutes (the old hard-coded 15 of the divergence
# veto) AND open under unit_link_confident_max_span_minutes. Past either,
# shared units alone are mechanism B (CORRELATION_REVIEW_0820.md) — patrol
# units work job after job on one channel — so the link is ambiguous.
unit_link_fresh_idle_minutes: int = 15
unit_link_confident_max_span_minutes: int = 30
# server-26#189: an incident open this long accepts no more WEAK links
# (unit overlap without location/content agreement, or an LLM link) — only
# vehicle, location or embedding evidence, until the hard
# incident_max_duration_minutes cap. The 09-27 "Rollover Entrapment"
# sink absorbed unrelated Ch 1 jobs for 2h34m this way. Thin replies are
# exempt: they never refresh updated_at, so they cannot keep a sink alive.
incident_weak_link_max_span_minutes: int = 90
# server-26#189: an LLM link may not pull a call geocoded farther than
# this from a non-pursuit incident's verified pin (the same 4x
# location_proximity_km the slow path accepts). The LLM tier had no
# distance check at all, which is how one incident drew a 37 km path.
incident_max_spread_km: float = 2.0
# server-26#301: an upload arriving more than this long after the call
# started is correlated as of its started_at (clock pin), not now. Well
# above normal upload + STT lag so on-time calls are never back-dated.
late_upload_anchor_minutes: int = 10
location_proximity_km: float = 0.5 # radius for location-proximity matching location_proximity_km: float = 0.5 # radius for location-proximity matching
geocode_max_km: float = 40.0 # reject geocode results farther than this from the node geocode_max_km: float = 40.0 # reject geocode results farther than this from the node
incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls (major / unknown severity) incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls (major / unknown severity)
@@ -147,9 +254,7 @@ class Settings(BaseSettings):
# at 9.7 min. Every correct thin attach in that dump was <= 3.4 min idle # at 9.7 min. Every correct thin attach in that dump was <= 3.4 min idle
# and every wrong one was >= 8.2, so 5 separates them with room on both # and every wrong one was >= 8.2, so 5 separates them with room on both
# sides. Genuine back-and-forth is handled by the 30-second tier-1 path # sides. Genuine back-and-forth is handled by the 30-second tier-1 path
# above this. Also the escape hatch in routers/upload.py's LLM-orphan gate # above this.
# (_recent_incident_on_same_talkgroup, server-26#115) — check both call
# sites before retuning this.
tg_dispatch_thin_idle_minutes: int = 5 tg_dispatch_thin_idle_minutes: int = 5
# ── Hard caps: an incident past either of these stops accepting calls ────── # ── Hard caps: an incident past either of these stops accepting calls ──────
+18
View File
@@ -34,6 +34,24 @@ def openai_client(timeout: Optional[float] = None):
) )
def stt_client(api_key: Optional[str], base_url: Optional[str] = None):
"""
Synchronous client for an OpenAI-compatible transcription endpoint
(server-26#297) -- OpenAI itself, Groq, or a self-hosted server. Same
timeout/retry limits as every other client here, with STT's own timeout.
"""
from openai import OpenAI
kwargs = {}
if base_url:
kwargs["base_url"] = base_url
return OpenAI(
api_key=api_key,
timeout=settings.stt_timeout_seconds,
max_retries=settings.openai_max_retries,
**kwargs,
)
def async_openai_client(timeout: Optional[float] = None): def async_openai_client(timeout: Optional[float] = None):
"""Async OpenAI client, same limits as openai_client().""" """Async OpenAI client, same limits as openai_client()."""
from openai import AsyncOpenAI from openai import AsyncOpenAI
+1 -1
View File
@@ -32,7 +32,7 @@ from typing import Optional
from app.internal.logger import logger from app.internal.logger import logger
from app.config import settings from app.config import settings
TIERS = ("transcription", "correlation_cheap", "correlation_smart", "extraction") TIERS = ("transcription", "correlation_cheap", "extraction")
# Consecutive failures a TRANSIENT condition must reach before it alerts. # Consecutive failures a TRANSIENT condition must reach before it alerts.
# Permanent conditions skip this entirely and alert on failure #1. # Permanent conditions skip this entirely and alert on failure #1.
+208 -1
View File
@@ -2,9 +2,19 @@
Alert dispatch engine. Alert dispatch engine.
Loads enabled alert rules from Firestore and checks each one against the call's Loads enabled alert rules from Firestore and checks each one against the call's
talkgroup ID, tags, and transcript. On a match: talkgroup ID, tags, and transcript. On a match that passes the confidence
gate (server-26#300, `_assess`):
1. Creates an AlertEvent document in Firestore. 1. Creates an AlertEvent document in Firestore.
2. Optionally POSTs a Discord webhook message if the rule has one configured. 2. Optionally POSTs a Discord webhook message if the rule has one configured.
A match that fails the gate is written to `alert_holds` instead -- never
pushed, readable by admins (GET /alerts/held) -- and a hold that was waiting
only on corroboration is released when a later call brings its incident to
alert_corroboration_min_calls calls.
WHY A GATE: CrimeRadar pushed "shots fired at an elementary school" after STT
heard "fire alarm" as "firearm". A keyword hit in raw transcript text is the
weakest evidence this pipeline has, and a push alert is the one surface that
cannot be taken back.
Raw transcript text is withheld from both by default -- see _snippet_allowed Raw transcript text is withheld from both by default -- see _snippet_allowed
and server-26#85. and server-26#85.
@@ -54,6 +64,8 @@ async def check_and_dispatch(
# Loop-invariant: every rule here belongs to the same org, so the opt-in is # Loop-invariant: every rule here belongs to the same org, so the opt-in is
# resolved once rather than per match. # resolved once rather than per match.
snippet_allowed = await _snippet_allowed(org_id) snippet_allowed = await _snippet_allowed(org_id)
evidence = await _evidence(call_doc or {})
pushed_rules: set[str] = set()
for rule in rules: for rule in rules:
matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript) matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript)
@@ -63,6 +75,12 @@ async def check_and_dispatch(
alert_id = str(uuid.uuid4()) alert_id = str(uuid.uuid4())
snippet = _snippet(transcript) if snippet_allowed else None snippet = _snippet(transcript) if snippet_allowed else None
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
verdict = _assess(matched_keywords, tags, evidence)
if not verdict["push"]:
await _hold(alert_id, rule, org_id, call_id, node_id, talkgroup_id,
talkgroup_name, matched_keywords, snippet, now, verdict, evidence)
continue
pushed_rules.add(rule.get("rule_id", ""))
event = { event = {
"alert_id": alert_id, "alert_id": alert_id,
"org_id": org_id, "org_id": org_id,
@@ -76,6 +94,9 @@ async def check_and_dispatch(
"transcript_snippet": snippet, "transcript_snippet": snippet,
"triggered_at": now, "triggered_at": now,
"acknowledged": False, "acknowledged": False,
"confidence": verdict["confidence"],
"high_severity": verdict["high_severity"],
"corroborating_calls": evidence["max_calls"],
} }
try: try:
@@ -92,6 +113,192 @@ async def check_and_dispatch(
if webhook_url: if webhook_url:
await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet) await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet)
await _release_corroborated(rules, evidence, call_id, pushed_rules)
# ── Confidence gate (server-26#300) ─────────────────────────────────────────
# How a rule matched, as evidence. Talkgroup activity is metadata, not
# recognised speech, so it cannot be misheard.
_W_TALKGROUP = 1.0
_W_TAG = 0.8 # the extraction model put it in tags
_W_TRANSCRIPT = 0.6 # substring of raw STT text only (above the 0.5 default floor)
def _norm_term(term: str) -> str:
return (term or "").strip().lower().replace("-", " ")
def _is_high_severity_term(term: str) -> bool:
wanted = {_norm_term(t) for t in settings.alert_high_severity_terms}
return _norm_term(term) in wanted
async def _evidence(call_doc: dict) -> dict:
"""What the call doc and its incidents say about how much to trust it."""
incident_ids = list(call_doc.get("incident_ids") or [])
max_calls = 0
max_severity = call_doc.get("severity")
severity_rank = {"routine": 0, "minor": 1, "moderate": 2, "major": 3}
for iid in incident_ids:
try:
inc = await fstore.doc_get("incidents", iid) or {}
except Exception as e:
logger.warning(f"Alerter: could not read incident {iid}: {e}")
continue
max_calls = max(max_calls, len(inc.get("call_ids") or []))
if severity_rank.get(inc.get("severity"), -1) > severity_rank.get(max_severity, -1):
max_severity = inc.get("severity")
stt = call_doc.get("stt_confidence")
return {
"incident_ids": incident_ids,
"max_calls": max_calls,
"severity": max_severity,
"stt_confidence": stt if isinstance(stt, (int, float)) else None,
"not_speech": bool(call_doc.get("transcript_not_speech")),
}
def _assess(matched: list[str], tags: list[str], evidence: dict) -> dict:
"""
Push or hold, and why. Pure, so the gate is testable without Firestore.
confidence = match weight x STT factor. The STT factor is 1.0 unless
Whisper's own mean token probability is under alert_stt_reference, and
1.0 when there is none (a json-only model) rather than guessing.
"""
content = [m for m in matched if not m.startswith("talkgroup:")]
if not content:
weight = _W_TALKGROUP
elif any(m in (tags or []) for m in content):
weight = _W_TAG
else:
weight = _W_TRANSCRIPT
stt = evidence.get("stt_confidence")
stt_factor = 1.0
if content and stt is not None and settings.alert_stt_reference > 0:
stt_factor = min(1.0, max(0.0, stt / settings.alert_stt_reference))
if content and evidence.get("not_speech"):
stt_factor = 0.0 # the corrector called this recogniser noise
confidence = round(weight * stt_factor, 3)
high = bool(content) and (
any(_is_high_severity_term(m) for m in content)
or any(_is_high_severity_term(t) for t in (tags or []))
or evidence.get("severity") == "major"
)
corroborated = evidence.get("max_calls", 0) >= settings.alert_corroboration_min_calls
if confidence < settings.alert_min_confidence:
return {"push": False, "confidence": confidence, "high_severity": high,
"hold_reason": "low_confidence"}
if high and not corroborated and confidence < settings.alert_high_severity_min_confidence:
return {"push": False, "confidence": confidence, "high_severity": high,
"hold_reason": "needs_corroboration"}
return {"push": True, "confidence": confidence, "high_severity": high, "hold_reason": None}
async def _hold(alert_id, rule, org_id, call_id, node_id, talkgroup_id, talkgroup_name,
matched_keywords, snippet, now, verdict, evidence) -> None:
"""Record a gated match where admins can see it, and push nothing."""
hold = {
"alert_id": alert_id,
"org_id": org_id,
"rule_id": rule.get("rule_id", ""),
"rule_name": rule.get("name", ""),
"call_id": call_id,
"node_id": node_id,
"talkgroup_id": talkgroup_id,
"talkgroup_name": talkgroup_name or "",
"matched_keywords": matched_keywords,
"transcript_snippet": snippet,
"triggered_at": now,
"status": "held",
"hold_reason": verdict["hold_reason"],
"confidence": verdict["confidence"],
"high_severity": verdict["high_severity"],
# One incident to wait on: the first the call linked to. A call that
# linked to none can never be corroborated, so it stays held.
"incident_id": (evidence["incident_ids"] or [None])[0],
"corroborating_calls": evidence["max_calls"],
}
try:
await fstore.doc_set("alert_holds", alert_id, hold, merge=False)
except Exception as e:
logger.warning(f"Alerter: could not save held alert: {e}")
return
logger.info(
f"Alert HELD ({verdict['hold_reason']}, confidence={verdict['confidence']}, "
f"high_severity={verdict['high_severity']}, calls={evidence['max_calls']}): "
f"rule='{rule.get('name')}' call={call_id} keywords={matched_keywords}"
)
async def _release_corroborated(rules: list[dict], evidence: dict, call_id: str,
pushed_rules: set[str]) -> None:
"""
Push holds that were waiting only on corroboration once their incident
has enough calls. A low-confidence hold is never released this way: a
second call on the incident says the event is real, not that the first
call was heard right. A rule this call already pushed for supersedes its
hold instead of alerting twice.
"""
if evidence["max_calls"] < settings.alert_corroboration_min_calls:
return
by_id = {r.get("rule_id", ""): r for r in rules}
for iid in evidence["incident_ids"]:
try:
holds = await fstore.collection_list("alert_holds", incident_id=iid, status="held")
except Exception as e:
logger.warning(f"Alerter: could not read holds for incident {iid}: {e}")
continue
for hold in holds:
if hold.get("hold_reason") != "needs_corroboration":
continue
rule = by_id.get(hold.get("rule_id", ""))
now = datetime.now(timezone.utc).isoformat()
if rule is None or hold.get("rule_id", "") in pushed_rules:
status = "superseded" if rule is not None else "rule_gone"
await _set_hold_status(hold, status, call_id, now)
continue
event = {k: hold.get(k) for k in (
"alert_id", "org_id", "rule_id", "rule_name", "call_id", "node_id",
"talkgroup_id", "talkgroup_name", "matched_keywords",
"transcript_snippet", "confidence", "high_severity",
)}
event.update({
"triggered_at": now,
"acknowledged": False,
"released_from_hold": True,
"corroborated_by_call_id": call_id,
"corroborating_calls": evidence["max_calls"],
})
try:
await fstore.doc_set("alert_events", hold["alert_id"], event, merge=False)
except Exception as e:
logger.warning(f"Alerter: could not release held alert {hold.get('alert_id')}: {e}")
continue
await _set_hold_status(hold, "released", call_id, now)
pushed_rules.add(hold.get("rule_id", ""))
logger.info(
f"Alert released from hold: rule='{hold.get('rule_name')}' incident={iid} "
f"corroborated by call {call_id} ({evidence['max_calls']} calls)"
)
webhook_url = rule.get("discord_webhook")
if webhook_url:
await _post_webhook(webhook_url, rule.get("name", ""), hold.get("talkgroup_name"),
hold.get("matched_keywords") or [], hold.get("transcript_snippet"))
async def _set_hold_status(hold: dict, status: str, call_id: str, now: str) -> None:
try:
await fstore.doc_update("alert_holds", hold["alert_id"], {
"status": status, "resolved_at": now, "resolved_by_call_id": call_id,
})
except Exception as e:
logger.warning(f"Alerter: could not update hold {hold.get('alert_id')}: {e}")
async def _snippet_allowed(org_id: Optional[str]) -> bool: async def _snippet_allowed(org_id: Optional[str]) -> bool:
""" """
+45 -7
View File
@@ -14,13 +14,18 @@ This module is that classifier. It is a PURE function of the transcript text —
no Firestore, no LLM call, no side effects — so it is cheap to run on every no Firestore, no LLM call, no side effects — so it is cheap to run on every
transcript and cheap to test against real dumps offline. transcript and cheap to test against real dumps offline.
SHADOW MODE ONLY. As of this module's introduction, nothing skips scene A COST GATE SINCE server-26#298, through `gate_chatter`, not
extraction based on this verdict. `intelligence.extract_scenes` calls `classify_chatter` — built but OFF by default (`chatter_gate_enabled`) until a
`classify_chatter` purely to record the verdict on the call doc shadow window shows gated calls don't over-merge. `classify_chatter` is still
(`chatter_classifier_verdict` / `chatter_classifier_reason`) so it becomes the shadow verdict recorded on every call doc (`chatter_classifier_verdict` /
observable in the next `/admin` correlation-debug dump, exactly like `chatter_classifier_reason`), so the `/admin` tallies keep measuring the same
`corr_gate_veto` (server-26#115 / PR #126). See the TODO at that call site for thing. The gate is stricter on the one shape the #127 review flagged as a live
what has to be true before this flips live. false-positive risk: `roll_call` matches "roll call" anywhere, so "Delayed for
roll call, then respond to a structure fire at 12 Main Street" is a roll_call
verdict. The gate only skips a roll call whose remaining words are callsigns
and roll-call phrasing. When enabled, gated transcripts skip transcript
correction and scene extraction (and with it embedding and geocoding); the free
rule paths (clearance parse, self-initiated/plate backstop) still run.
Precision over recall, deliberately. A false positive here — flagging a REAL Precision over recall, deliberately. A false positive here — flagging a REAL
event as chatter — would, once live, silently mean that event never gets a event as chatter — would, once live, silently mean that event never gets a
@@ -133,3 +138,36 @@ def classify_chatter(transcript: Optional[str]) -> tuple[bool, Optional[str]]:
return False, None return False, None
return True, "bare_acknowledgement" return True, "bare_acknowledgement"
# Words a roll call is made of, beyond the filler/designator/phonetic sets.
_ROLL_CALL_WORDS = frozenset({
"roll", "call", "all", "cars", "car", "patrol", "patrols", "troop", "staff",
"concludes", "time", "came", "because", "volunteer", "nyc", "kl", "henry",
"here", "present", "s", "m", "it",
})
# A roll call may name a place or two ("Post 4, Ossining"); more than this many
# leftover words means something else is being said in the same transmission.
_ROLL_CALL_MAX_LEFTOVER = 2
def gate_chatter(transcript: Optional[str]) -> tuple[bool, Optional[str]]:
"""
Whether to skip paid AI for this transcript (server-26#298).
`classify_chatter`'s verdict, with roll_call narrowed: the transcript must
be roll-call content end to end, not a roll call mentioned on the way to
something real. Precision over recall still — a false positive here means
a real event never gets a scene.
"""
is_chatter, reason = classify_chatter(transcript)
if not is_chatter:
return False, None
if reason == "roll_call":
leftover = [
t for t in _tokenize(transcript or "")
if not _is_filler_token(t) and t not in _ROLL_CALL_WORDS
]
if len(leftover) > _ROLL_CALL_MAX_LEFTOVER:
return False, None
return True, reason
+2 -21
View File
@@ -16,7 +16,6 @@ what it actually spent instead of an estimate.
""" """
import json import json
import threading import threading
from contextvars import ContextVar
from typing import Optional from typing import Optional
from app.config import settings from app.config import settings
@@ -27,18 +26,6 @@ _client_lock = threading.Lock()
# Models that rejected a thinking level: retried without one from then on. # Models that rejected a thinking level: retried without one from then on.
_no_thinking_level: set[str] = set() _no_thinking_level: set[str] = set()
_usage_sink: ContextVar[Optional[dict]] = ContextVar("drb_gemini_usage", default=None)
def collect_usage(sink: Optional[dict]):
"""Route token counts for the current context into `sink` (a replay run). Returns a reset token."""
return _usage_sink.set(sink)
def reset_usage(token) -> None:
_usage_sink.reset(token)
def _get_client(): def _get_client():
global _client global _client
with _client_lock: with _client_lock:
@@ -71,14 +58,8 @@ def _record(purpose: str, model: str, usage) -> None:
prompt = getattr(usage, "prompt_token_count", None) or 0 prompt = getattr(usage, "prompt_token_count", None) or 0
output = getattr(usage, "candidates_token_count", None) or 0 output = getattr(usage, "candidates_token_count", None) or 0
thoughts = getattr(usage, "thoughts_token_count", None) or 0 thoughts = getattr(usage, "thoughts_token_count", None) or 0
logger.info(f"gemini usage {purpose} {model}: in={prompt} out={output} thinking={thoughts}") from app.internal.llm import record_usage
sink = _usage_sink.get() record_usage(purpose, model, prompt, output, thoughts)
if sink is not None:
row = sink.setdefault(f"{purpose}:{model}", {"calls": 0, "in": 0, "out": 0, "thinking": 0})
row["calls"] += 1
row["in"] += prompt
row["out"] += output
row["thinking"] += thoughts
def generate_json(model: str, prompt: str, *, purpose: str, def generate_json(model: str, prompt: str, *, purpose: str,
+346 -151
View File
@@ -43,6 +43,13 @@ Matching priority (in order):
Calls with no incident_type skip new-incident creation but still run paths 1–3, Calls with no incident_type skip new-incident creation but still run paths 1–3,
so unclassified calls (short transport end, "en route", etc.) can link to an so unclassified calls (short transport end, "en route", etc.) can link to an
existing incident via talkgroup match. existing incident via talkgroup match.
Decision flow (server-26#299): the rules engine decides every confident case on
its own. When it flags its decision as ambiguous (`_ambiguity`: opening an
incident while live candidates exist, orphaning an unfit same-talkgroup
follow-up, or linking on unverified shared units), routers/upload.py makes ONE
cheap LLM call (llm_correlator.decide) and commits its answer instead. There is
no second LLM tier and no consensus vote.
""" """
import math import math
import re import re
@@ -65,26 +72,65 @@ _SEVERITY_RANK = {"routine": 0, "minor": 1, "moderate": 2, "major": 3}
def _max_severity(current: Optional[str], new: Optional[str]) -> str: def _max_severity(current: Optional[str], new: Optional[str]) -> str:
""" """
Highest of two severities on the four-level ladder — the merge rule used Highest of two severities on the four-level ladder. A malformed or
every time a call attaches to an existing incident. unrecognized value from either side ranks as "routine" so it can never
suppress a real escalation.
Severity is monotonic by design: it only ever rises, never falls, as more
calls link. An incident briefly assessed "major" genuinely was major at
that moment; a later call that sounds calmer ("units clear", dispatcher
moving on) is evidence the SITUATION is winding down, not that the earlier
read was wrong. That's what `status`/`resolved_at` are for — resolution
retires an incident, it doesn't retroactively erase how serious it was.
Every severity-driven surface (worst-first incident rail, "Major only"
filter, map colour) exists to make sure a major event is never missed;
downgrading severity mid-incident would silently defeat that on the exact
incidents it exists to protect. A malformed/unrecognized value from either
side ranks as "routine" so it can never suppress a real escalation.
""" """
current = current if current in _SEVERITY_RANK else "routine" current = current if current in _SEVERITY_RANK else "routine"
new = new if new in _SEVERITY_RANK else "routine" new = new if new in _SEVERITY_RANK else "routine"
return current if _SEVERITY_RANK[current] >= _SEVERITY_RANK[new] else new return current if _SEVERITY_RANK[current] >= _SEVERITY_RANK[new] else new
# server-26#190 — explicit stand-down / unfounded dispositions. Deliberately a
# short list of phrases that only ever mean "there is less here than was
# reported"; a calmer-SOUNDING follow-up ("Engine 5 on scene", "copy") is not
# one, because extraction judges each transmission on its own words and a
# working fire's routine status traffic would otherwise talk it down to
# routine. Left out: "disregard"/"cancel" ("disregard my last" is a radio
# correction), "all clear" ("primary search all clear" on a working fire) and
# "code 4" (scene secure, not "nothing happened").
_STAND_DOWN_RE = re.compile(
r"\b(?:unfounded|false alarm|no (?:fire|flames|smoke showing)|nothing (?:showing|found|evident)"
r"|gone on arrival|g\.?o\.?a\.?|stand(?:ing)? down|stood down"
r"|fire(?:'s| is)? out)\b",
re.IGNORECASE,
)
def _is_stand_down(transcript: Optional[str]) -> bool:
return bool(transcript and _STAND_DOWN_RE.search(transcript))
def _severity_after_link(
inc: dict, call_severity: Optional[str], transcript: Optional[str],
) -> dict:
"""
The incident's severity fields after one more call links (server-26#190).
`severity` is the CURRENT read and drives the live surfaces (worst-first
rail, map colour, severity-scaled quiet timer). It rises immediately on
any worse call, and falls only on an explicit stand-down disposition
("they lit a fireplace... only smoke showing, no flames") — then to that
call's own severity. It used to only ever ratchet up, which left a false
Major on the wall for the full 90-minute Major quiet timer after units
reported nothing there.
`severity_peak` keeps the old monotonic meaning (the worst this incident
was ever judged), so history and any "was this ever major" question still
have an answer that later chatter cannot erase.
"""
current = inc.get("severity") if inc.get("severity") in _SEVERITY_RANK else "routine"
peak = _max_severity(inc.get("severity_peak"), current)
new = call_severity if call_severity in _SEVERITY_RANK else "routine"
out = {"severity_peak": _max_severity(peak, new)}
if _SEVERITY_RANK[new] < _SEVERITY_RANK[current] and _is_stand_down(transcript):
out["severity"] = new
out["severity_deescalated_from"] = current
else:
out["severity"] = _max_severity(current, new)
return out
# Maximum plausible ground speed for a moving incident (pursuit/transport). # Maximum plausible ground speed for a moving incident (pursuit/transport).
# ~3 miles/min ≈ 180 mph — well above real pursuit speeds, but generous enough # ~3 miles/min ≈ 180 mph — well above real pursuit speeds, but generous enough
# to tolerate GPS drift and call-timing jitter. Anything faster is a bad geocode # to tolerate GPS drift and call-timing jitter. Anything faster is a bad geocode
@@ -278,16 +324,88 @@ def has_event_substance(ctx: dict) -> bool:
a vehicle, a geocode, or a tag. a vehicle, a geocode, or a tag.
This is the substance half of the incident-creation gate (see This is the substance half of the incident-creation gate (see
`_run_decision`, "Severity, not type, decides..."), factored out so the `resolve_new_incident_type`). `call_units` and `location` are deliberately
consensus LLM-orphan gate in routers/upload.py mirrors it exactly and can excluded — radio protocol puts a unit ID and a place name in almost every
never drop a call the creation gate would have opened. `call_units` and transmission, so counting them as substance makes the check trivially true.
`location` are deliberately excluded — radio protocol puts a unit ID and a
place name in almost every transmission, so counting them as substance
makes the check trivially true.
""" """
return bool(ctx.get("call_vehicles") or ctx.get("coords") or ctx.get("tags")) return bool(ctx.get("call_vehicles") or ctx.get("coords") or ctx.get("tags"))
def resolve_new_incident_type(ctx: dict) -> Optional[str]:
"""
The type a NEW incident opened by this call gets, or None when the call is
not incident-worthy and must orphan instead. The one creation gate, shared
by the rules engine and the LLM tier (llm_correlator._parse_response), so
an LLM "new" can neither open what the rules gate refuses nor be demoted
to orphan for lacking a type the rules gate would have defaulted.
Severity, not type, decides whether a call is incident-worthy. Requiring a
concrete incident_type meant a channel whose traffic never classifies —
transit/rail administration, records lookups, prisoner transports — could
never open a SECOND incident: every later call funnelled into whichever
incident was created first (2026-08-16: TG 9048, 28 calls / 49 min)
alongside 30+ permanent orphans. So anything the extractor judged a real
event (minor+), or that carries substance (`has_event_substance`), opens
an incident under the neutral "other" type. `call_units` and `location`
are NOT substance — the first version of this gate counted them, turned
"11-Victor, 72 at Holland Station" into its own incident, and left 37 of
50 incidents open, one call each.
"""
resolved = ctx.get("incident_type") or _infer_type_from_tags(ctx.get("tags") or [])
if resolved:
return resolved
if ctx.get("call_severity") in ("minor", "moderate", "major") or has_event_substance(ctx):
return "other"
return None
def is_clearance_only(ctx: dict) -> bool:
"""
"45-9, I'm clear." carries one fact: which unit is done. Only the rules
engine's unit match can say which incident that is; an LLM link here would
apply the clear to whatever incident it picked, so such a call is never
sent to the LLM tier.
"""
units = ctx.get("call_units") or []
cleared = ctx.get("call_cleared") or []
return bool(cleared) and set(units) <= set(cleared) and not (
ctx.get("tags") or ctx.get("location") or ctx.get("call_vehicles") or ctx.get("incident_type")
)
_LLM_TRUE = frozenset({"true", "yes", "1"})
_LLM_FALSE = frozenset({"false", "no", "0", "", "none", "null"})
def parse_llm_bool(value) -> bool:
"""
A yes/no field from an LLM, failing CLOSED (server-26#88).
`bool(value)` reads the JSON *string* "false" as True, so a model that
quoted its booleans would resolve whatever incident the scene linked into.
Only a real bool, or an exact true-ish token, counts as True; anything
else — "false", "maybe", a number, a dict — is False. For `resolved` and
`reassignment` a missed True costs a stale open incident or one missed
unit release; a false True closes a live incident or blanks a call's units.
Public because the extraction boundary (intelligence.py) is where it
belongs; routers/upload.py applies it too, so a value that reaches the
scene loop un-coerced is still validated there.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
token = value.strip().lower()
if token in _LLM_TRUE:
return True
if token not in _LLM_FALSE:
logger.warning(f"parse_llm_bool: unrecognised value {value!r} read as False")
return False
if value is not None:
logger.warning(f"parse_llm_bool: non-boolean {type(value).__name__} {value!r} read as False")
return False
def _infer_type_from_tags(tags: list[str]) -> Optional[str]: def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
"""Return an incident type inferred from tags, or None if ambiguous.""" """Return an incident type inferred from tags, or None if ambiguous."""
for tag in tags: for tag in tags:
@@ -483,7 +601,7 @@ def _resolve_incident_title(
of strictly higher severity. Rationale in the commit message; in short, an of strictly higher severity. Rationale in the commit message; in short, an
incident's identity is the event that opened it, and the one situation incident's identity is the event that opened it, and the one situation
where the header must change is the one where things got worse. This where the header must change is the one where things got worse. This
mirrors `_max_severity`: monotonic, never walked back by later chatter. mirrors `severity_peak`: monotonic, never walked back by later chatter.
Two non-renames are still allowed, because neither replaces an event name: Two non-renames are still allowed, because neither replaces an event name:
• filling in a placeholder title on an incident that opened on a call • filling in a placeholder title on an incident that opened on a call
@@ -656,6 +774,43 @@ def _incident_at_capacity(inc: dict, now: datetime) -> Optional[str]:
return None return None
def _weak_link_capped(inc: dict, now: datetime) -> bool:
"""server-26#189 — past this span an incident takes only strong evidence."""
return _incident_span_minutes(inc, now) >= settings.incident_weak_link_max_span_minutes
def llm_candidates(ctx: dict) -> list[dict]:
"""
The incidents the LLM tier may see and link into: the rules engine's
`recent` pool minus anything at the hard caps or past the weak-link span
cap (server-26#189). Every LLM link is weak evidence — a model's reading
of two transcripts — so an incident that has been open long enough to
have become a catch-all is not offered to it at all.
"""
now = ctx["now"]
return [
inc for inc in ctx.get("recent") or []
if not _incident_at_capacity(inc, now) and not _weak_link_capped(inc, now)
]
def llm_link_allowed(inc: dict, ctx: dict) -> bool:
"""
Distance guard on an LLM-chosen link (server-26#189). When both the call
and a non-pursuit incident are geocoded and farther apart than
`incident_max_spread_km`, the model's link is refused: every rules path
already has a location check of its own, and the LLM tier had none.
"""
coords = ctx.get("coords")
pin = _verified_pin(inc)
if not coords or not pin:
return True
if set(inc.get("tags") or []) & _PURSUIT_TAGS:
return True
dist = _haversine_km(coords["lat"], coords["lng"], pin["lat"], pin["lng"])
return dist <= settings.incident_max_spread_km
def _drop_capped(incidents: list[dict], now: datetime) -> list[dict]: def _drop_capped(incidents: list[dict], now: datetime) -> list[dict]:
"""Remove over-cap incidents from a candidate pool (see _incident_at_capacity).""" """Remove over-cap incidents from a candidate pool (see _incident_at_capacity)."""
kept: list[dict] = [] kept: list[dict] = []
@@ -979,7 +1134,7 @@ def _run_decision(ctx: dict) -> dict:
# Hard caps, enforced regardless of which path would have matched. Normally # Hard caps, enforced regardless of which path would have matched. Normally
# a no-op because _build_context already filtered these out; re-applied here # a no-op because _build_context already filtered these out; re-applied here
# so the guarantee holds for any caller that assembles a ctx directly # so the guarantee holds for any caller that assembles a ctx directly
# (tests, the LLM tiebreak path) rather than through _build_context. # (tests) rather than through _build_context.
_capped_ids = { _capped_ids = {
inc.get("incident_id") inc.get("incident_id")
for inc in (list(ctx["all_active"]) + list(ctx["recent"])) for inc in (list(ctx["all_active"]) + list(ctx["recent"]))
@@ -1013,6 +1168,10 @@ def _run_decision(ctx: dict) -> dict:
matched_incident: Optional[dict] = None matched_incident: Optional[dict] = None
corr_debug: dict = {} corr_debug: dict = {}
# Why the fast path refused a same-talkgroup candidate, when it did. A
# "no_signal" refusal is the one the rules cannot settle: a substantive
# call on a channel with a live incident and no evidence either way.
tg_reject: Optional[str] = None
# ── 1. Fast path: talkgroup match with recency gate ────────────────────── # ── 1. Fast path: talkgroup match with recency gate ──────────────────────
# #
@@ -1168,13 +1327,14 @@ def _run_decision(ctx: dict) -> dict:
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1), "corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
"corr_fit_signal": fit_signal, "corr_fit_signal": fit_signal,
} }
if fit_signal == "unit_overlap" and call_units: if fit_signal.startswith("unit_") and call_units:
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units")) corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
logger.info( logger.info(
f"Correlator fast-path: call {call_id} → {candidate['incident_id']} " f"Correlator fast-path: call {call_id} → {candidate['incident_id']} "
f"(signal={fit_signal})" f"(signal={fit_signal})"
) )
else: else:
tg_reject = fit_signal
logger.info( logger.info(
f"Correlator fast-path skipped: call {call_id} — {fit_signal} " f"Correlator fast-path skipped: call {call_id} — {fit_signal} "
f"from {candidate['incident_id']}; will attempt new incident" f"from {candidate['incident_id']}; will attempt new incident"
@@ -1207,13 +1367,14 @@ def _run_decision(ctx: dict) -> dict:
"corr_candidates": len(tg_recent), "corr_candidates": len(tg_recent),
"corr_fit_signal": fit_signal, "corr_fit_signal": fit_signal,
} }
if fit_signal == "unit_overlap" and call_units: if fit_signal.startswith("unit_") and call_units:
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units")) corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
logger.info( logger.info(
f"Correlator fast-path (disambig {len(tg_recent)} candidates): " f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
f"call {call_id} → {candidate['incident_id']} (signal={fit_signal})" f"call {call_id} → {candidate['incident_id']} (signal={fit_signal})"
) )
else: else:
tg_reject = fit_signal
logger.info( logger.info(
f"Correlator fast-path disambig: no candidate fits call {call_id} " f"Correlator fast-path disambig: no candidate fits call {call_id} "
f"across {len(tg_recent)} incidents ({fit_signal}) — will attempt new incident" f"across {len(tg_recent)} incidents ({fit_signal}) — will attempt new incident"
@@ -1249,28 +1410,27 @@ def _run_decision(ctx: dict) -> dict:
and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "") and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "")
for inc in all_active for inc in all_active
) )
# Location conflict check: same unit at a clearly different location # Same unit-overlap judgement as the fast path (_unit_fit): a
# means the officer has moved to a new scene (e.g. dispatch sent # location conflict means the officer has moved to a new scene, a
# them to a second call without an explicit reassignment signal). # sink past the weak-link span cap takes no unit-only links
location_conflict = False # (server-26#189), and an old incident with no location or content
if coords and not reassigned_away: # agreement is linked but flagged unverified, i.e. ambiguous.
inc_coords_uc = best_unit_inc.get("location_coords") uc_fit, uc_signal = (False, "reassigned_away")
if inc_coords_uc: if not reassigned_away:
uc_dist = _haversine_km( uc_fit, uc_signal = _unit_fit(
coords["lat"], coords["lng"], best_unit_inc, coords, call_embedding, now,
inc_coords_uc["lat"], inc_coords_uc["lng"], settings.location_proximity_km,
) )
if uc_dist > settings.location_proximity_km: if not uc_fit:
location_conflict = True
logger.info( logger.info(
f"Correlator unit-continuity skipped: call {call_id} — " f"Correlator unit-continuity skipped: call {call_id} vs "
f"unit match but location conflict ({uc_dist:.2f}km) with " f"{best_unit_inc['incident_id']} — {uc_signal}"
f"{best_unit_inc['incident_id']}"
) )
if not reassigned_away and not location_conflict: else:
matched_incident = best_unit_inc matched_incident = best_unit_inc
corr_debug = { corr_debug = {
"corr_path": "unit-continuity", "corr_path": "unit-continuity",
"corr_fit_signal": uc_signal,
"corr_incident_idle_min": round(_incident_idle_minutes(best_unit_inc, now), 1), "corr_incident_idle_min": round(_incident_idle_minutes(best_unit_inc, now), 1),
# Unlike this file's other two paths (fast/single, fast/disambig), # Unlike this file's other two paths (fast/single, fast/disambig),
# a match here is unit-driven by construction (call_unit_set & # a match here is unit-driven by construction (call_unit_set &
@@ -1445,63 +1605,66 @@ def _run_decision(ctx: dict) -> dict:
# ── Decision output ─────────────────────────────────────────────────────── # ── Decision output ───────────────────────────────────────────────────────
if matched_incident: if matched_incident:
return { action, resolved_type = "link", incident_type
"action": "link", elif not create_if_new:
action, resolved_type = "orphan", None
else:
resolved_type = resolve_new_incident_type(ctx)
action = "new" if resolved_type else "orphan"
if resolved_type and resolved_type != incident_type:
logger.info(
f"Correlator: call {call_id} has no incident_type — opening a "
f"{resolved_type!r} incident (severity={call_severity}, tags={tags})"
)
decision = {
"action": action,
"matched_incident": matched_incident, "matched_incident": matched_incident,
"incident_type": incident_type,
"corr_debug": corr_debug,
}
if not create_if_new:
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
# Attempt type inference from tags before giving up on creation
resolved_type = incident_type
if not resolved_type and tags:
resolved_type = _infer_type_from_tags(tags)
if resolved_type:
logger.info(
f"Correlator: inferred incident_type={resolved_type!r} from tags {tags} for call {call_id}"
)
# Severity, not type, decides whether a call is incident-worthy.
#
# Requiring a concrete incident_type here meant a channel whose traffic never
# classifies — transit/rail administration, records lookups, prisoner
# transports — could never open a SECOND incident. Every later call on that
# talkgroup then funnelled into whichever incident happened to be created
# first, producing hour-long incidents made of unrelated transmissions
# (2026-08-16: TG 9048, 28 calls / 49 min) alongside 30+ permanent orphans.
#
# Anything the extractor judged a real event, or that carries any concrete
# content, now opens an incident under the neutral "other" type. Only
# content-free routine traffic is still left for the thin path to attach.
#
# `call_units` and `location` are NOT substance. Radio protocol puts a unit
# ID in essentially every transmission and a place name in most of them, so
# including them made has_substance true almost always and the severity check
# dead code — the first version of this gate turned "11-Victor, 72 at Holland
# Station" into its own incident and left 37 of 50 incidents open, one call
# each. A vehicle, a geocode, or a tag means the extractor found something
# beyond who was speaking and where they stood.
if not resolved_type:
has_substance = has_event_substance(ctx)
if call_severity in ("minor", "moderate", "major") or has_substance:
resolved_type = "other"
logger.info(
f"Correlator: call {call_id} has no incident_type — opening an 'other' "
f"incident (severity={call_severity}, substance={has_substance})"
)
if not resolved_type:
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
return {
"action": "new",
"matched_incident": None,
"incident_type": resolved_type, "incident_type": resolved_type,
"corr_debug": corr_debug, "corr_debug": corr_debug,
} }
decision["ambiguous"] = _ambiguity(decision, tg_reject, ctx)
return decision
def _ambiguity(decision: dict, tg_reject: Optional[str], ctx: dict) -> Optional[str]:
"""
Why the rules decision is NOT confident enough to commit alone, or None
when it is (server-26#299). Only an ambiguous decision is sent to the LLM
tier, once; every other decision is the rules engine's alone.
Ambiguous:
• "new_with_candidates" — the rules would OPEN an incident while live,
LLM-eligible incidents exist. Opening is the expensive mistake: this
system over-splits, and rules-only correlation opened ~1.8x the
incidents per hour of LLM-assisted (#170, 2026-10-04 control window)
— duplicates of a running job and typed radio housekeeping.
• "unfit_tg_candidate" — the rules would ORPHAN a substantive call that
has a live incident on its own talkgroup but no evidence for or
against it (fit test "no_signal"): an unfit follow-up.
• "unverified_unit_link" — the rules would LINK on shared units alone
into an incident that is no longer fresh, with no location or content
agreement (mechanism B; #103: the content veto has nothing to measure
when the scene has no embedding). Between the divergence and match
embedding bars (#86) a link lands here too.
Never ambiguous: thin calls (nothing to reason about), clearance-only
calls (only the unit match can place a 10-8), link-only sweeps
(create_if_new=False — rules-only by design), or when the LLM would have
no candidates to choose from.
"""
if not ctx.get("create_if_new", True) or ctx.get("is_thin_call"):
return None
if is_clearance_only(ctx) or not llm_candidates(ctx):
return None
action = decision["action"]
if action == "new":
return "new_with_candidates"
if action == "orphan" and tg_reject == "no_signal":
return "unfit_tg_candidate"
if action == "link" and (decision["corr_debug"] or {}).get("corr_fit_signal") == "unit_overlap_unverified":
return "unverified_unit_link"
return None
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -1514,7 +1677,7 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]:
server-26#96: every scene of a multi-scene call reaches this function server-26#96: every scene of a multi-scene call reaches this function
independently (upload.py's ``for scene_index, scene in enumerate(scenes):`` independently (upload.py's ``for scene_index, scene in enumerate(scenes):``
loop → _correlate_with_consensus → preview_correlation/apply_correlation), loop → _correlate → preview_correlation/apply_correlation),
and every scene writes to the SAME call doc. Writing corr_debug only at and every scene writes to the SAME call doc. Writing corr_debug only at
the flat top level meant scene 2's write silently clobbered scene 1's the flat top level meant scene 2's write silently clobbered scene 1's
corr_path/corr_consensus/etc — the call doc ended up describing a splice corr_path/corr_consensus/etc — the call doc ended up describing a splice
@@ -1553,18 +1716,14 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]:
"incident_id": incident_id, "incident_id": incident_id,
"corr_debug": corr_debug, "corr_debug": corr_debug,
# server-26#139: this scene's OWN extracted incident_type/ # server-26#139: this scene's OWN extracted incident_type/
# severity, as read by _call_is_substanceless's ctx at # severity, as read from ctx at decision time — not the call
# decision time — not the call doc's flat top-level field, # doc's flat top-level field, which is last-scene-wins
# which is last-scene-wins (server-26#96) and was the reason # (server-26#96).
# #138's "type" veto couldn't be told apart from cross-scene
# contamination without re-guessing from a live dump.
# NOTE: unlike incident_type, call_severity is already # NOTE: unlike incident_type, call_severity is already
# coerced to "routine" when extraction emitted nothing # coerced to "routine" when extraction emitted nothing
# (ctx build: `severity or "routine"`) — a scene reading # (ctx build: `severity or "routine"`) — a scene reading
# "routine" here doesn't distinguish "extraction said # "routine" here doesn't distinguish "extraction said
# routine" from "extraction said nothing". Don't split a # routine" from "extraction said nothing".
# severity veto the way #138 splits the type veto without
# accounting for that.
"incident_type": ctx.get("incident_type"), "incident_type": ctx.get("incident_type"),
"severity": ctx.get("call_severity"), "severity": ctx.get("call_severity"),
} }
@@ -1613,6 +1772,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
talkgroup_name=talkgroup_name, incident_type=incident_type, talkgroup_name=talkgroup_name, incident_type=incident_type,
cleared_units=call_cleared, refresh_activity=not thin_link, cleared_units=call_cleared, refresh_activity=not thin_link,
call_severity=call_severity, call_srcaddr=call_srcaddr, call_severity=call_severity, call_srcaddr=call_srcaddr,
call_transcript=ctx.get("scene_transcript"),
) )
return matched_incident["incident_id"] return matched_incident["incident_id"]
@@ -1798,6 +1958,65 @@ def _disambiguate(
return best return best
def _unit_fit(
inc: dict,
call_coords: Optional[dict],
call_embedding: Optional[list],
now: Optional[datetime],
proximity_km: float,
) -> tuple[bool, str]:
"""
Judge a link whose evidence is a shared unit. Same officer usually means
same call — but on a shared dispatch channel a patrol unit works job after
job, so shared units alone grow chains (mechanism B,
CORRELATION_REVIEW_0820.md). In order:
• call and incident both geocoded: far apart → (False,
"unit_loc_conflict"), the officer moved to a new scene; close →
(True, "unit_location_match").
• embeddings agree at the match bar → (True, "unit_content_match").
• incident past the weak-link span cap (#189) → (False,
"weak_link_span_cap"): a long-running incident stops taking
unit-only evidence.
• incident fresh (idle < unit_link_fresh_idle_minutes) and young (open
< unit_link_confident_max_span_minutes) → (True, "unit_overlap"). No
content veto here: an update on the same scene often shares little
vocabulary with the dispatch and does not restate the address.
• not fresh, embeddings below the DIVERGENCE bar (#86) → (False,
"content_divergence").
• anything else → (True, "unit_overlap_unverified"): the rules keep the
link but cannot vouch for it, so the decision is ambiguous and goes
to the LLM tier. This is where a scene with no embedding lands
(#103) — it used to skip the veto and link as if verified, which
removed the only brake on the unit loop for ~93% of calls.
"""
inc_coords = inc.get("location_coords")
if call_coords and inc_coords:
dist_km = _haversine_km(
call_coords["lat"], call_coords["lng"],
inc_coords["lat"], inc_coords["lng"],
)
return (False, "unit_loc_conflict") if dist_km > proximity_km else (True, "unit_location_match")
sim: Optional[float] = None
inc_emb = inc.get("embedding")
if call_embedding and inc_emb:
sim = _cosine_similarity(call_embedding, inc_emb)
if sim >= settings.embedding_similarity_threshold:
return True, "unit_content_match"
if now is None:
return True, "unit_overlap_unverified"
if _weak_link_capped(inc, now):
return False, "weak_link_span_cap"
fresh = _idle_gate_minutes(inc, now) < settings.unit_link_fresh_idle_minutes
if fresh and _incident_span_minutes(inc, now) < settings.unit_link_confident_max_span_minutes:
return True, "unit_overlap"
if not fresh and sim is not None and sim < settings.embedding_divergence_threshold:
return False, "content_divergence"
return True, "unit_overlap_unverified"
def _call_fits_incident( def _call_fits_incident(
inc: dict, inc: dict,
call_units: list[str], call_units: list[str],
@@ -1812,18 +2031,14 @@ def _call_fits_incident(
Return (fits, signal) — fits is True when this call plausibly belongs to Return (fits, signal) — fits is True when this call plausibly belongs to
the incident; signal names the specific evidence that drove the decision. the incident; signal names the specific evidence that drove the decision.
fits=True signals: "unit_overlap" | "vehicle_overlap" | "location_proximity" fits=True signals: see `_unit_fit` for the unit_* family | "vehicle_overlap"
| "location_proximity"
fits=False signals: "unit_loc_conflict" | "content_divergence" fits=False signals: "unit_loc_conflict" | "content_divergence"
| "location_conflict" | "no_signal" | "weak_link_span_cap" | "location_conflict" | "no_signal"
Evaluation order: Evaluation order:
1. Unit overlap. Same officer = same call. Also runs a location-conflict 1. Unit overlap — judged by `_unit_fit`, which also decides whether the
guard: geocoded on both sides and clearly different → the officer has link is confident or only "unit_overlap_unverified" (ambiguous).
moved to a new scene, false positive. No geocode on the call (or on
the incident, with a call geocode) AND the incident is old (≥ 15 min)
→ content divergence as a location proxy (embedding similarity < 0.82
→ different scene). Skipped for recent incidents (< 15 min) — an
update without re-stating the address is normal.
2. Vehicle overlap → True. 2. Vehicle overlap → True.
3. Location proximity. Both geocoded and close → True; far apart with no 3. Location proximity. Both geocoded and close → True; far apart with no
other positive signal → False. other positive signal → False.
@@ -1850,44 +2065,9 @@ def _call_fits_incident(
inc_units = _unit_keys(inc.get("units")) inc_units = _unit_keys(inc.get("units"))
matched_units = _matching_units(call_units, inc.get("units")) matched_units = _matching_units(call_units, inc.get("units"))
if matched_units: if matched_units:
if call_coords: fit, signal = _unit_fit(inc, call_coords, call_embedding, now, proximity_km)
# Hard location conflict: geocoded on both sides and clearly different. logger.info(f" fits[{inc_id}]: unit_overlap matched={matched_units} → {signal}")
inc_coords_u = inc.get("location_coords") return fit, signal
if inc_coords_u:
dist_km = _haversine_km(
call_coords["lat"], call_coords["lng"],
inc_coords_u["lat"], inc_coords_u["lng"],
)
if dist_km > proximity_km:
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict")
return False, "unit_loc_conflict"
elif call_embedding and idle_min >= 15:
# Call has geocode but incident doesn't — fall back to content
# divergence as a location proxy. Without this, stale incidents
# that never geocoded absorb unrelated calls purely on unit
# overlap (e.g. a patrol officer working a second scene 70 min
# after the original call).
inc_emb_u = inc.get("embedding")
if inc_emb_u:
sim = _cosine_similarity(call_embedding, inc_emb_u)
if sim < 0.82:
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence (has_call_coords/no_inc_coords) sim={sim:.3f} → content_divergence")
return False, "content_divergence"
elif call_embedding and idle_min >= 15:
# No geocode available AND old incident: use content divergence as a
# location-proxy veto. After 15+ minutes an officer at a completely
# different scene will be discussing clearly different content.
# Skip this for recent incidents — an officer updating on the same
# scene without re-stating the address is normal and their update
# won't share much vocabulary with the original dispatch.
inc_emb_u = inc.get("embedding")
if inc_emb_u:
sim = _cosine_similarity(call_embedding, inc_emb_u)
if sim < 0.82:
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence sim={sim:.3f} → content_divergence")
return False, "content_divergence"
logger.info(f" fits[{inc_id}]: unit_overlap matched={matched_units} → unit_overlap")
return True, "unit_overlap"
# ── 2. Vehicle overlap ──────────────────────────────────────────────────── # ── 2. Vehicle overlap ────────────────────────────────────────────────────
inc_vehicles = set(inc.get("vehicles") or []) inc_vehicles = set(inc.get("vehicles") or [])
@@ -2047,6 +2227,7 @@ async def _update_incident(
refresh_activity: bool = True, refresh_activity: bool = True,
call_severity: Optional[str] = None, call_severity: Optional[str] = None,
call_srcaddr: Optional[str] = None, call_srcaddr: Optional[str] = None,
call_transcript: Optional[str] = None,
) -> None: ) -> None:
incident_id = inc["incident_id"] incident_id = inc["incident_id"]
@@ -2109,7 +2290,9 @@ async def _update_incident(
"units_cleared": units_cleared, "units_cleared": units_cleared,
"location_mentions": location_mentions, "location_mentions": location_mentions,
"summary_stale": True, "summary_stale": True,
"severity": _max_severity(inc.get("severity"), call_severity), # Current severity can step DOWN on an explicit stand-down; the peak
# never does (server-26#190, _severity_after_link).
**_severity_after_link(inc, call_severity, call_transcript),
# Always all three, always together — writing one without the others is # Always all three, always together — writing one without the others is
# what let the label and the pin drift apart (server-26#23). # what let the label and the pin drift apart (server-26#23).
**location_fields, **location_fields,
@@ -2123,7 +2306,18 @@ async def _update_incident(
# acknowledging. The incident now ages from its last SUBSTANTIVE call, and # acknowledging. The incident now ages from its last SUBSTANTIVE call, and
# thin traffic rides along without extending its life. # thin traffic rides along without extending its life.
if refresh_activity: if refresh_activity:
updates["updated_at"] = _floor_at_started_at(inc, now).isoformat() # Never move updated_at BACKWARD: a back-dated `now` (sweep, or a late
# upload's clock pin, server-26#301) would make a live incident look
# idle, failing on-time calls' recency gates and timer-closing it early.
when = _floor_at_started_at(inc, now)
try:
prev = datetime.fromisoformat(str(inc.get("updated_at") or "").replace("Z", "+00:00"))
if prev.tzinfo is None:
prev = prev.replace(tzinfo=timezone.utc)
when = max(when, prev)
except ValueError:
pass
updates["updated_at"] = when.isoformat()
if is_new_call: # a second scene of the same call is not a second call if is_new_call: # a second scene of the same call is not a second call
updates["substantive_call_count"] = ( updates["substantive_call_count"] = (
inc.get("substantive_call_count") inc.get("substantive_call_count")
@@ -2233,6 +2427,7 @@ async def _create_incident(
"vehicles": call_vehicles, "vehicles": call_vehicles,
"srcaddrs": [call_srcaddr] if call_srcaddr else [], "srcaddrs": [call_srcaddr] if call_srcaddr else [],
"severity": call_severity, "severity": call_severity,
"severity_peak": call_severity,
"summary": None, "summary": None,
"summary_stale": True, "summary_stale": True,
"summary_last_run": None, "summary_last_run": None,
+138 -43
View File
@@ -1,7 +1,8 @@
""" """
GPT-4o-mini intelligence extraction from call transcripts. Intelligence extraction from call transcripts.
Sends the transcript to GPT-4o-mini with a structured prompt that detects Sends the transcript to the extraction model (settings.extraction_model,
gpt-4o-mini by default) with a structured prompt that detects
whether the recording contains one or multiple distinct scenes (back-to-back whether the recording contains one or multiple distinct scenes (back-to-back
dispatch conversations on a busy channel). Returns a list of scene dicts — dispatch conversations on a busy channel). Returns a list of scene dicts —
one per detected incident. Most calls produce a single scene. one per detected incident. Most calls produce a single scene.
@@ -12,12 +13,14 @@ import asyncio
import json import json
import math import math
import re import re
from collections import OrderedDict
from typing import Optional from typing import Optional
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import ai_health from app.internal import ai_health
from app.internal import area_context from app.internal import area_context
from app.internal.chatter_classifier import classify_chatter from app.internal import incident_correlator
from app.internal.chatter_classifier import classify_chatter, gate_chatter
# Location validity is defined once, by the module that owns the incident's # Location validity is defined once, by the module that owns the incident's
# location/pin invariant. incident_correlator does not import this module, so # location/pin invariant. incident_correlator does not import this module, so
# this is not a cycle. # this is not a cycle.
@@ -218,11 +221,12 @@ async def extract_scenes(
pass pass
return [] return []
# server-26#127 — SHADOW MODE ONLY. Computes whether this transcript looks # server-26#127 — the shadow verdict. Computes whether this transcript looks
# like non-event radio housekeeping (roll call, bare 10-4/10-8/98 # like non-event radio housekeeping (roll call, bare 10-4/10-8/98
# acknowledgements, unit check-ins) and records the verdict on the call # acknowledgements, unit check-ins) and records it on the call doc. The
# doc, but does NOT skip extraction anywhere below — every path runs # live cost gate (server-26#298, gate_chatter below) is a stricter view of
# exactly as it did before this landed. Deliberately ahead of the ≤5-word # the same classifier; this verdict is recorded unchanged so the /admin
# tallies keep measuring the same thing. Deliberately ahead of the ≤5-word
# skip: most bare acknowledgements ARE ≤5 words, and the first pass of # skip: most bare acknowledgements ARE ≤5 words, and the first pass of
# this feature put the classifier after that return, so it never saw the # this feature put the classifier after that return, so it never saw the
# bulk of its own target population — a review backtest against three # bulk of its own target population — a review backtest against three
@@ -231,13 +235,9 @@ async def extract_scenes(
# roughly a fifth of the real catch rate. Computing it once, here, and # roughly a fifth of the real catch rate. Computing it once, here, and
# folding the result into whichever skip/continue path runs below fixes # folding the result into whichever skip/continue path runs below fixes
# that without adding a second Firestore write. # that without adding a second Firestore write.
# TODO(server-26#127): flip this from shadow to live (skip extraction and # Residual risk of the live gate, unchanged from the #127 review: a
# write skip_reason="non_event_chatter" instead of just recording the # digit-hyphen street address ("72-Holland") alone in a transmission reads
# verdict) once a live shadow-mode window confirms 0 false positives on # as a unit ID. CHATTER_GATE_ENABLED=false restores shadow mode.
# real production traffic — pay particular attention to whole-transcript
# vs contains-anywhere matching for "roll call" and to digit-hyphen street
# addresses (e.g. "72-Holland"), both flagged as classifier risks that the
# dump backtest could not surface on its own.
chatter_is_chatter, chatter_reason = classify_chatter(transcript) chatter_is_chatter, chatter_reason = classify_chatter(transcript)
# Transcripts with ≤5 words carry no extractable intelligence — GPT hallucinates # Transcripts with ≤5 words carry no extractable intelligence — GPT hallucinates
@@ -277,6 +277,40 @@ async def extract_scenes(
"tags": tags, "incident_type": typ, "severity": sev}] "tags": tags, "incident_type": typ, "severity": sev}]
return [] return []
# server-26#298 — the gate is live. Radio housekeeping has no incident
# content for the extraction model to find, so it no longer pays for an
# extraction, an embedding per scene and a geocode to come back as a thin
# routine scene. gate_chatter is classify_chatter with roll_call narrowed
# to whole-transcript roll calls (the #127 review's false-positive shape).
# The free rule paths still run, exactly as on the <=5-word path above.
from app.config import settings
gated, gate_reason = gate_chatter(transcript) if settings.chatter_gate_enabled else (False, None)
if gated:
logger.info(
f"Intelligence: call {call_id} — non-event chatter ({gate_reason}), skipping extraction"
)
cleared_unit = _short_clearance_unit(transcript)
tags, typ, sev = _self_initiated_backstop(transcript, [], None, "routine", talkgroup_name)
updates = {
"skip_reason": "non_event_chatter",
"severity": sev if tags else "routine",
"chatter_classifier_verdict": chatter_is_chatter,
"chatter_classifier_reason": chatter_reason,
}
if cleared_unit:
updates["units"] = [cleared_unit]
updates["cleared_units"] = [cleared_unit]
try:
await fstore.doc_set("calls", call_id, updates)
except Exception:
pass
if cleared_unit:
return [_clearance_scene(transcript, cleared_unit)]
if tags:
return [{**_clearance_scene(transcript, ""), "units": [], "cleared_units": [],
"tags": tags, "incident_type": typ, "severity": sev}]
return []
try: try:
await fstore.doc_set("calls", call_id, { await fstore.doc_set("calls", call_id, {
"chatter_classifier_verdict": chatter_is_chatter, "chatter_classifier_verdict": chatter_is_chatter,
@@ -294,13 +328,14 @@ async def extract_scenes(
except Exception as e: except Exception as e:
text = str(e) text = str(e)
kind = ai_health.classify(text) kind = ai_health.classify(text)
logger.warning(f"GPT-4o-mini extraction failed for call {call_id}: {text}") from app.config import settings as _s
logger.warning(f"Extraction ({_s.extraction_model}) failed for call {call_id}: {text}")
await ai_health.report_degraded( await ai_health.report_degraded(
"extraction", "openai", "gpt-4o-mini", "extraction", "openai", _s.extraction_model,
{"billing": "the OpenAI account is out of credit", {"billing": "the OpenAI account is out of credit",
"dead_model": "model is unavailable"}.get(kind, f"extraction failed: {text[:200]}"), "dead_model": "model is unavailable"}.get(kind, f"extraction failed: {text[:200]}"),
{"billing": "Top up OpenAI billing", {"billing": "Top up OpenAI billing",
"dead_model": "Update the extraction model in intelligence.py"}.get(kind, "Usually transient"), "dead_model": "Update EXTRACTION_MODEL"}.get(kind, "Usually transient"),
permanent=kind != "transient", permanent=kind != "transient",
) )
return [] return []
@@ -364,8 +399,10 @@ async def extract_scenes(
severity: str = scene.get("severity") or "routine" severity: str = scene.get("severity") or "routine"
if severity == "unknown": if severity == "unknown":
severity = "routine" severity = "routine"
resolved: bool = bool(scene.get("resolved", False)) # server-26#88: bool("false") is True, which let a model answering
reassignment: bool = bool(scene.get("reassignment", False)) # with a JSON string resolve an incident it merely linked into.
resolved: bool = incident_correlator.parse_llm_bool(scene.get("resolved"))
reassignment: bool = incident_correlator.parse_llm_bool(scene.get("reassignment"))
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
segment_indices: Optional[list] = scene.get("segment_indices") segment_indices: Optional[list] = scene.get("segment_indices")
@@ -690,8 +727,15 @@ async def _geocode_location(
""" """
Geocode using Google Maps Geocoding API, biased toward the channel's area. Geocode using Google Maps Geocoding API, biased toward the channel's area.
Returns {"lat": float, "lng": float}, or None if geocoding fails or the Returns {"lat": float, "lng": float, "location_type": str}, or None if
result lands outside the area this channel covers. geocoding fails or the result lands outside the area this channel covers.
server-26#236: `location_type` (ROOFTOP / RANGE_INTERPOLATED /
GEOMETRIC_CENTER) rides along with the coordinates so the map can show how
precise a pin is — a route centroid must not draw the same teardrop as a
rooftop. It is display-only: every consumer of these coords reads only
"lat"/"lng", and the dict is stored as-is on the call doc and, through
incident_correlator._resolve_location_pair, on the incident pin.
THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This
used to reject anything more than geocode_max_km (40km) from the receiving used to reject anything more than geocode_max_km (40km) from the receiving
@@ -722,7 +766,6 @@ async def _geocode_location(
touch the anchor path at all — an anchor's own radius is always authoritative touch the anchor path at all — an anchor's own radius is always authoritative
when one has been resolved. when one has been resolved.
""" """
import httpx
from app.config import settings from app.config import settings
if not settings.google_maps_api_key: if not settings.google_maps_api_key:
@@ -748,23 +791,10 @@ async def _geocode_location(
f"{ref_lat - delta},{ref_lon - delta}" f"{ref_lat - delta},{ref_lon - delta}"
f"|{ref_lat + delta},{ref_lon + delta}" f"|{ref_lat + delta},{ref_lon + delta}"
) )
params = {
"address": location_str,
"bounds": bounds,
"region": "us",
"key": settings.google_maps_api_key,
}
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: result = await _geocode_lookup(location_str, bounds)
r = await client.get( if result is None:
"https://maps.googleapis.com/maps/api/geocode/json",
params=params,
)
r.raise_for_status()
data = r.json()
if data.get("status") != "OK" or not data.get("results"):
return None return None
result = data["results"][0]
location_type = result.get("geometry", {}).get("location_type", "") location_type = result.get("geometry", {}).get("location_type", "")
# Reject only APPROXIMATE — a region/city boundary centroid, which is # Reject only APPROXIMATE — a region/city boundary centroid, which is
# what an ungeocodable string degrades to and is genuinely useless. # what an ungeocodable string degrades to and is genuinely useless.
@@ -801,7 +831,7 @@ async def _geocode_location(
f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — " f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — "
f"accepted anyway: the query names its own region, not the node's" f"accepted anyway: the query names its own region, not the node's"
) )
coords = {"lat": lat, "lng": lng} coords = {"lat": lat, "lng": lng, "location_type": location_type}
logger.info( logger.info(
f"Geocoded '{location_str}' → {coords} " f"Geocoded '{location_str}' → {coords} "
f"({dist_km:.1f}km from {ref_label}) [{location_type}]" f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
@@ -812,6 +842,71 @@ async def _geocode_location(
return None return None
# server-26#298: geocode answers, keyed by the exact request (address +
# bounds). Only the first result's geometry is kept — the minimum the checks
# in _geocode_location read — so the cache holds coordinates, not Maps
# content. The distance/precision checks still run per call on a hit, so a
# cached answer is judged against whatever anchor or node asks.
_geocode_cache: "OrderedDict[tuple[str, str], tuple[float, Optional[dict]]]" = OrderedDict()
geocode_cache_stats = {"hits": 0, "misses": 0}
def clear_geocode_cache() -> None:
_geocode_cache.clear()
geocode_cache_stats.update(hits=0, misses=0)
async def _geocode_lookup(address: str, bounds: str) -> Optional[dict]:
"""
The Geocoding API's first result for `address` inside `bounds`, or None
when it has none. Cached in process (settings.geocode_cache_*). A network
error or non-200 raises and is NOT cached — the next call retries.
"""
import time
import httpx
from app.config import settings
key = (address.strip().lower(), bounds)
now = time.monotonic()
hit = _geocode_cache.get(key)
if hit is not None:
expires, value = hit
if expires > now:
_geocode_cache.move_to_end(key)
geocode_cache_stats["hits"] += 1
return value
_geocode_cache.pop(key, None)
geocode_cache_stats["misses"] += 1
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(
"https://maps.googleapis.com/maps/api/geocode/json",
params={"address": address, "bounds": bounds, "region": "us",
"key": settings.google_maps_api_key},
)
r.raise_for_status()
data = r.json()
status = data.get("status")
value: Optional[dict] = None
if status == "OK" and data.get("results"):
geometry = data["results"][0].get("geometry") or {}
value = {"geometry": {"location": geometry.get("location") or {},
"location_type": geometry.get("location_type", "")}}
ttl = settings.geocode_cache_ttl_seconds
elif status == "ZERO_RESULTS":
ttl = settings.geocode_cache_negative_ttl_seconds
else:
# OVER_QUERY_LIMIT / REQUEST_DENIED / UNKNOWN_ERROR: a key or quota
# problem, not an answer about this address.
return None
if ttl > 0 and settings.geocode_cache_max_entries > 0:
_geocode_cache[key] = (now + ttl, value)
while len(_geocode_cache) > settings.geocode_cache_max_entries:
_geocode_cache.popitem(last=False)
return value
def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]: def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
""" """
Extract the municipality name from a talkgroup name. Extract the municipality name from a talkgroup name.
@@ -936,7 +1031,7 @@ def _sync_extract(
ten_codes: Optional[dict[str, str]] = None, ten_codes: Optional[dict[str, str]] = None,
unit_format_hint: Optional[str] = None, unit_format_hint: Optional[str] = None,
) -> list[dict]: ) -> list[dict]:
"""Call GPT-4o-mini and return a list of scene dicts.""" """Call the extraction model and return a list of scene dicts."""
from app.config import settings from app.config import settings
from app.internal.ai_clients import openai_client from app.internal.ai_clients import openai_client
@@ -958,7 +1053,7 @@ def _sync_extract(
try: try:
client = openai_client() client = openai_client()
response = client.chat.completions.create( response = client.chat.completions.create(
model="gpt-4o-mini", model=settings.extraction_model,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}, response_format={"type": "json_object"},
) )
@@ -973,7 +1068,7 @@ def _sync_extract(
return [raw] return [raw]
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.warning(f"GPT-4o-mini returned non-JSON: {e}") logger.warning(f"Extraction model returned non-JSON: {e}")
return [] return []
# Any other exception is the API call itself failing (no credit, rate # Any other exception is the API call itself failing (no credit, rate
# limit, outage) and propagates to extract_scenes, which reports it to # limit, outage) and propagates to extract_scenes, which reports it to
@@ -983,7 +1078,7 @@ def _sync_extract(
def _sync_embed(text: str) -> Optional[list[float]]: def _sync_embed(text: str) -> Optional[list[float]]:
"""Generate a text-embedding-3-small vector for semantic similarity.""" """Generate an embedding (settings.embedding_model) for semantic similarity."""
from app.config import settings from app.config import settings
from app.internal.ai_clients import openai_client from app.internal.ai_clients import openai_client
@@ -992,7 +1087,7 @@ def _sync_embed(text: str) -> Optional[list[float]]:
try: try:
client = openai_client() client = openai_client()
result = client.embeddings.create(model="text-embedding-3-small", input=text) result = client.embeddings.create(model=settings.embedding_model, input=text)
return result.data[0].embedding return result.data[0].embedding
except Exception as e: except Exception as e:
logger.warning(f"Embedding generation failed: {e}") logger.warning(f"Embedding generation failed: {e}")
+96
View File
@@ -0,0 +1,96 @@
"""
One entry point for the two small JSON-mode LLM jobs: the correlation decision
on ambiguous scenes and transcript correction.
The provider follows the model name: a `gemini-*` model goes through
app/internal/gemini.py, anything else is an OpenAI chat model. Since the
2026-11-08 provider consolidation both defaults are OpenAI (gpt-4o-mini), so
one funded account (OpenAI) covers extraction, embeddings, summaries and these
two jobs; Groq stays optional for transcription. Pointing either model setting
back at a gemini-* ID needs no code change.
Token counts from both providers land in the same usage sink, so a replay run
(app/internal/replay.py) reports what it spent whichever provider served it.
"""
import json
from contextvars import ContextVar
from typing import Optional
from app.config import settings
from app.internal.logger import logger
_usage_sink: ContextVar[Optional[dict]] = ContextVar("drb_llm_usage", default=None)
def collect_usage(sink: Optional[dict]):
"""Route token counts for the current context into `sink` (a replay run). Returns a reset token."""
return _usage_sink.set(sink)
def reset_usage(token) -> None:
_usage_sink.reset(token)
def record_usage(purpose: str, model: str, prompt: int, output: int, thinking: int = 0) -> None:
logger.info(f"llm usage {purpose} {model}: in={prompt} out={output} thinking={thinking}")
sink = _usage_sink.get()
if sink is not None:
row = sink.setdefault(f"{purpose}:{model}", {"calls": 0, "in": 0, "out": 0, "thinking": 0})
row["calls"] += 1
row["in"] += prompt
row["out"] += output
row["thinking"] += thinking
def provider_for(model: str) -> str:
return "gemini" if (model or "").startswith("gemini") else "openai"
def available(model: str) -> bool:
"""Whether the provider serving `model` has a key configured."""
key = settings.gemini_api_key if provider_for(model) == "gemini" else settings.openai_api_key
return bool(key)
def timeout_seconds(model: str) -> float:
if provider_for(model) == "gemini":
return settings.gemini_timeout_seconds
return (1 + max(0, settings.openai_max_retries)) * settings.openai_timeout_seconds
def billing_fix(model: str) -> str:
if provider_for(model) == "gemini":
return "Top up billing at https://ai.studio/projects"
return "Top up billing at https://platform.openai.com/settings/organization/billing"
def generate_json(model: str, prompt: str, *, purpose: str,
thinking_level: Optional[str] = "minimal") -> dict:
"""
Synchronous (run it via asyncio.to_thread). Returns the parsed JSON body.
Raises on API failure so callers' ai_health classification (billing /
dead model / transient) is unchanged. `thinking_level` only applies to
Gemini 3.x; OpenAI's gpt-4o family has no such setting.
"""
if provider_for(model) == "gemini":
from app.internal import gemini
return gemini.generate_json(model, prompt, purpose=purpose, thinking_level=thinking_level)
from app.internal.ai_clients import openai_client
# JSON mode requires the word "JSON" in the prompt; both prompts say
# "Return JSON" / "Respond with JSON only".
kwargs = {}
# Reasoning models (o-series, gpt-5) reject a temperature; chat models get 0.
if not model.startswith(("o1", "o3", "o4", "gpt-5")):
kwargs["temperature"] = 0
resp = openai_client().chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
**kwargs,
)
usage = getattr(resp, "usage", None)
record_usage(purpose, model,
getattr(usage, "prompt_tokens", 0) or 0,
getattr(usage, "completion_tokens", 0) or 0)
return json.loads(resp.choices[0].message.content or "{}")
+74 -151
View File
@@ -1,23 +1,23 @@
""" """
LLM-based incident correlator using Gemini. LLM incident correlator (settings.corr_cheap_model, via app/internal/llm.py).
Two functions are exposed: One function is exposed:
decide(call_id, ctx) — cheap first-pass (corr_cheap_model) decide(call_id, ctx) — called by routers/upload.py ONLY when the rules
tiebreak(rules_decision, llm_decision, ctx) — smart tiebreaker (corr_smart_model) engine marked its decision ambiguous
(incident_correlator._ambiguity), at most once per
scene. server-26#299 removed the second "smart"
tiebreak tier and the cheap/smart consensus vote.
Both return a decision dict compatible with _run_decision() in incident_correlator: It returns a decision dict compatible with incident_correlator._apply_decision:
{"action": "link"|"new"|"orphan", {"action": "link"|"new"|"orphan",
"matched_incident": dict|None, "matched_incident": dict|None,
"incident_type": str|None, "incident_type": str|None,
"corr_debug": dict, "corr_debug": dict,
"reasoning": str} ← extra field for logging/tiebreak comparison "reasoning": str}
decide() is skipped for thin calls (no content to reason about) and when or None — no API key, nothing to choose from, an answer that fails
GEMINI_API_KEY is not set — in those cases returns None so the caller knows validation, or any LLM failure — and the caller then commits the rules
to fall back to the rules decision. decision unchanged, so the pipeline never stalls on the LLM.
Error handling: any Gemini failure returns None from decide() and the
rules_decision from tiebreak() so the pipeline never stalls.
""" """
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -155,8 +155,9 @@ call that reports a new event, below.):
same one — especially once that incident has been open a long time (see same one — especially once that incident has been open a long time (see
open:/calls:). open:/calls:).
- Reserve "new" for a call that clearly describes a DIFFERENT event from every - Reserve "new" for a call that clearly describes a DIFFERENT event from every
recent incident — a different place, different units, and a different subject, recent incident — a different place or subject, not merely a different
not merely a different transmission about the same job. transmission about the same job. Shared units alone do not make it the same
event (see EXCEPT above).
- "orphan" a call that is not an incident at all: radio checks, roll call, - "orphan" a call that is not an incident at all: radio checks, roll call,
a unit marking on/off duty or 10-8/10-98, mileage/log entries, a bare a unit marking on/off duty or 10-8/10-98, mileage/log entries, a bare
acknowledgement. Do not open a "new" incident for these. acknowledgement. Do not open a "new" incident for these.
@@ -166,103 +167,75 @@ call that reports a new event, below.):
""" """
def _build_decide_prompt(ctx: dict) -> str: def _build_decide_prompt(ctx: dict, candidates: list[dict]) -> str:
now = ctx["now"] now = ctx["now"]
recent = ctx["recent"] inc_block = "\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(candidates))
inc_block = (
"\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
if recent else "(none)"
)
return ( return (
"You are an incident correlator for a public safety radio monitoring system.\n\n" "You are an incident correlator for a public safety radio monitoring system.\n\n"
"A new radio call has arrived. Decide whether it belongs to an existing active incident, " "A new radio call has arrived. Decide whether it belongs to an existing active incident, "
"represents a new incident, or should be orphaned (not enough information).\n\n" "represents a new incident, or should be orphaned (not an incident at all).\n\n"
f"NEW CALL:\n{_call_block(ctx)}\n\n" f"NEW CALL:\n{_call_block(ctx)}\n\n"
f"ACTIVE INCIDENTS ({len(recent)} recent):\n{inc_block}\n" f"ACTIVE INCIDENTS ({len(candidates)} recent):\n{inc_block}\n"
f"{_RULES}\n"
f"Respond with JSON only (no markdown):\n{_SCHEMA}"
)
def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict) -> str:
now = ctx["now"]
recent = ctx["recent"]
inc_block = (
"\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
if recent else "(none)"
)
def _fmt(d: dict, name: str) -> str:
action = d.get("action", "?")
inc = d.get("matched_incident")
inc_id = inc["incident_id"] if inc else (d.get("incident_id") or "null")
reason = d.get("reasoning") or (d.get("corr_debug") or {}).get("corr_fit_signal") or "—"
return f" {name}: action={action}, incident_id={inc_id}, reasoning={reason!r}"
return (
"You are a senior incident correlator for a public safety radio monitoring system.\n\n"
"Two correlation engines disagree. You must make the final decision.\n\n"
f"NEW CALL:\n{_call_block(ctx)}\n\n"
f"ACTIVE INCIDENTS ({len(recent)} recent):\n{inc_block}\n\n"
"DISAGREEMENT:\n"
f"{_fmt(rules_decision, 'Rules engine')}\n"
f"{_fmt(llm_decision, 'LLM correlator')}\n"
f"{_RULES}\n" f"{_RULES}\n"
f"Respond with JSON only (no markdown):\n{_SCHEMA}" f"Respond with JSON only (no markdown):\n{_SCHEMA}"
) )
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Gemini API call (sync, runs in thread pool) # LLM call (sync, runs in thread pool)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _sync_gemini(model_name: str, prompt: str) -> dict: def _sync_gemini(model_name: str, prompt: str) -> dict:
from app.internal import gemini from app.internal import llm
return gemini.generate_json(model_name, prompt, purpose="correlation") return llm.generate_json(model_name, prompt, purpose="correlation")
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Decision parsing # Decision parsing
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _parse_response(raw: dict, ctx: dict) -> dict: def _parse_response(raw: dict, ctx: dict, candidates: list[dict]) -> Optional[dict]:
""" """
Convert raw Gemini JSON output to a decision dict compatible with _run_decision(). Convert the model's raw JSON to a decision dict, or None when the answer is
Resolves incident_id → full incident doc from ctx["all_active"]. unusable and the rules decision should stand.
Handles type inference for "new" actions the same way as the rules engine.
"""
from app.internal.incident_correlator import _infer_type_from_tags # same-package import
action = raw.get("action", "orphan") A "link" must name one of the candidates the model was shown — not any
active incident: the shown list is the one with the #189 weak-link cap
applied — and pass the distance guard (incident_correlator.
llm_link_allowed). A "new" takes its type from the same creation gate the
rules use, so it is refused (orphan) exactly when the rules would refuse
it. An unknown action is unusable, not an orphan: the old "default to
orphan" silently dropped calls on a malformed reply.
"""
from app.internal.incident_correlator import llm_link_allowed, resolve_new_incident_type
action = raw.get("action")
reasoning = raw.get("reasoning", "") reasoning = raw.get("reasoning", "")
if action not in ("link", "new", "orphan"): if action not in ("link", "new", "orphan"):
action = "orphan" logger.warning(f"LLM correlator: unusable action {action!r} — keeping the rules decision")
return None
matched_incident: Optional[dict] = None matched_incident: Optional[dict] = None
incident_type: Optional[str] = None
if action == "link": if action == "link":
inc_id = raw.get("incident_id") inc_id = raw.get("incident_id")
if inc_id: matched_incident = next((i for i in candidates if i.get("incident_id") == inc_id), None)
matched_incident = next(
(i for i in ctx["all_active"] if i.get("incident_id") == inc_id),
None,
)
if not matched_incident: if not matched_incident:
logger.warning( logger.warning(
f"LLM correlator: incident_id={inc_id!r} not in active incidents — orphaning" f"LLM correlator: incident_id={inc_id!r} is not a shown candidate — keeping the rules decision"
) )
action = "orphan" return None
if not llm_link_allowed(matched_incident, ctx):
incident_type: Optional[str] = None logger.info(
if action in ("link", "new"): f"LLM correlator: link to {inc_id} refused — call is geocoded beyond "
f"incident_max_spread_km of its pin (server-26#189)"
)
return None
incident_type = ctx["incident_type"] incident_type = ctx["incident_type"]
elif action == "new":
incident_type = resolve_new_incident_type(ctx)
if not incident_type: if not incident_type:
incident_type = _infer_type_from_tags(ctx["tags"])
if action == "new" and not incident_type:
# Can't create an incident without a type — demote to orphan
action = "orphan" action = "orphan"
matched_incident = None
return { return {
"action": action, "action": action,
@@ -279,54 +252,35 @@ def _parse_response(raw: dict, ctx: dict) -> dict:
async def decide(call_id: str, ctx: dict) -> Optional[dict]: async def decide(call_id: str, ctx: dict) -> Optional[dict]:
""" """
Run the cheap LLM correlator (corr_cheap_model) on the call. One cheap-model correlation decision for an ambiguous scene. Returns None
(keep the rules decision) when the model's provider has no API key, there
Returns a decision dict or None if: is no candidate to show, the answer fails validation, or the call fails.
- GEMINI_API_KEY is not configured
- the call is thin (content-free — no value from LLM)
- there are no recent active incidents to reason about
- Gemini fails
Callers should treat None as "fall back to rules decision".
""" """
if not settings.gemini_api_key: from app.internal.incident_correlator import llm_candidates
from app.internal import llm
if not llm.available(settings.corr_cheap_model):
return None return None
candidates = llm_candidates(ctx)
if ctx["is_thin_call"]: if not candidates:
return None # thin calls have no transcript/units/coords to reason about
if _is_clearance_only(ctx):
# "45-9, I'm clear." carries one fact: which unit is done. Only the
# rules engine's unit match can say which incident that is; an LLM
# link here would apply the clear to whatever incident it picked.
return None return None
if not ctx["recent"]:
return None # no incidents to correlate against — rules handles new-only
try: try:
prompt = _build_decide_prompt(ctx) prompt = _build_decide_prompt(ctx, candidates)
raw = await asyncio.to_thread(_sync_gemini, settings.corr_cheap_model, prompt) raw = await asyncio.to_thread(_sync_gemini, settings.corr_cheap_model, prompt)
decision = _parse_response(raw, ctx) except Exception as e:
await _log_llm_failure("LLM correlator", "correlation_cheap", call_id, settings.corr_cheap_model, e)
return None
await ai_health.report_healthy("correlation_cheap")
decision = _parse_response(raw if isinstance(raw, dict) else {}, ctx, candidates)
if decision:
_id = (decision["matched_incident"] or {}).get("incident_id", "null") _id = (decision["matched_incident"] or {}).get("incident_id", "null")
logger.info( logger.info(
f"LLM correlator ({settings.corr_cheap_model}): call {call_id} → " f"LLM correlator ({settings.corr_cheap_model}): call {call_id} → "
f"action={decision['action']} incident={_id} " f"action={decision['action']} incident={_id} "
f"reasoning={decision['reasoning']!r}" f"reasoning={decision['reasoning']!r}"
) )
await ai_health.report_healthy("correlation_cheap")
return decision return decision
except Exception as e:
await _log_llm_failure("LLM correlator", "correlation_cheap", call_id, settings.corr_cheap_model, e)
return None
def _is_clearance_only(ctx: dict) -> bool:
units = ctx.get("call_units") or []
cleared = ctx.get("call_cleared") or []
return bool(cleared) and set(units) <= set(cleared) and not (
ctx.get("tags") or ctx.get("location") or ctx.get("call_vehicles") or ctx.get("incident_type")
)
_dead_models: set[str] = set() _dead_models: set[str] = set()
@@ -350,7 +304,7 @@ async def _log_llm_failure(where: str, tier: str, call_id: str, model: str, exc:
if kind == "dead_model": if kind == "dead_model":
await _log_tier_down(where, tier, model, "model is unavailable", await _log_tier_down(where, tier, model, "model is unavailable",
"Update CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py", text) "Update CORR_CHEAP_MODEL in config.py", text)
return return
# A depleted balance reads as 429, the same status as an ordinary rate limit, # A depleted balance reads as 429, the same status as an ordinary rate limit,
@@ -358,20 +312,23 @@ async def _log_llm_failure(where: str, tier: str, call_id: str, model: str, exc:
# dead account never does. ai_health.classify() keeps a burst of rate limits # dead account never does. ai_health.classify() keeps a burst of rate limits
# at WARNING while an empty account escalates like a bad model ID. # at WARNING while an empty account escalates like a bad model ID.
if kind == "billing": if kind == "billing":
await _log_tier_down(where, tier, model, "the Gemini account is out of credit", from app.internal import llm
"Top up billing at https://ai.studio/projects", text) await _log_tier_down(where, tier, model, f"the {llm.provider_for(model)} account is out of credit",
llm.billing_fix(model), text)
return return
logger.warning(f"{where} failed for call {call_id}: {text}") logger.warning(f"{where} failed for call {call_id}: {text}")
from app.internal import llm
await ai_health.report_degraded( await ai_health.report_degraded(
tier, "gemini", model, "transient API error", tier, llm.provider_for(model), model, "transient API error",
"no action needed unless this persists", permanent=False, "no action needed unless this persists", permanent=False,
) )
async def _log_tier_down(where: str, tier: str, model: str, problem: str, fix: str, text: str) -> None: async def _log_tier_down(where: str, tier: str, model: str, problem: str, fix: str, text: str) -> None:
"""ERROR once per model, not once per call — this runs at radio-traffic volume.""" """ERROR once per model, not once per call — this runs at radio-traffic volume."""
await ai_health.report_degraded(tier, "gemini", model, problem, fix, permanent=True) from app.internal import llm
await ai_health.report_degraded(tier, llm.provider_for(model), model, problem, fix, permanent=True)
if model in _dead_models: if model in _dead_models:
return return
_dead_models.add(model) _dead_models.add(model)
@@ -379,37 +336,3 @@ async def _log_tier_down(where: str, tier: str, model: str, problem: str, fix: s
f"{where}: {problem} ({model!r}) -- the LLM correlation tier is DISABLED " f"{where}: {problem} ({model!r}) -- the LLM correlation tier is DISABLED "
f"and every call is falling back to rules-only. {fix}. API said: {text}" f"and every call is falling back to rules-only. {fix}. API said: {text}"
) )
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
"""
Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree.
Falls back to rules_decision on any error.
"""
call_id = ctx["call_id"]
try:
prompt = _build_tiebreak_prompt(rules_decision, llm_decision, ctx)
raw = await asyncio.to_thread(_sync_gemini, settings.corr_smart_model, prompt)
decision = _parse_response(raw, ctx)
_id = (decision["matched_incident"] or {}).get("incident_id", "null")
logger.info(
f"LLM tiebreak ({settings.corr_smart_model}): call {call_id} → "
f"action={decision['action']} incident={_id} "
f"reasoning={decision['reasoning']!r}"
)
await ai_health.report_healthy("correlation_smart")
return decision
except Exception as e:
await _log_llm_failure("LLM tiebreak", "correlation_smart", call_id, settings.corr_smart_model, e)
return rules_decision
def decisions_agree(rules: dict, llm: dict) -> bool:
"""True if both decisions agree on action and (when action=="link") on the target incident."""
if rules["action"] != llm["action"]:
return False
if rules["action"] == "link":
r_id = (rules.get("matched_incident") or {}).get("incident_id")
l_id = (llm.get("matched_incident") or {}).get("incident_id")
return r_id == l_id
return True
+14 -1
View File
@@ -6,6 +6,7 @@ import paho.mqtt.client as mqtt
from app.config import settings from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import radios
from app.internal import talkgroups from app.internal import talkgroups
from app.internal.tenancy import FOUNDING_ORG_ID from app.internal.tenancy import FOUNDING_ORG_ID
@@ -128,6 +129,9 @@ class MQTTHandler:
"sdr_devices": payload.get("sdr_devices"), "sdr_devices": payload.get("sdr_devices"),
"op25_sdr_serial": payload.get("op25_sdr_serial"), "op25_sdr_serial": payload.get("op25_sdr_serial"),
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1 "sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
"disk_free_bytes": payload.get("disk_free_bytes"),
"disk_total_bytes": payload.get("disk_total_bytes"),
"version": payload.get("version"), # installed release (node-26#49)
"enforce_override_timeout": payload.get("enforce_override_timeout", True), "enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False, "is_overridden": False,
"override_system_id": None, "override_system_id": None,
@@ -161,8 +165,11 @@ class MQTTHandler:
# radio_channels (node-26#24): a conventional node's channels and # radio_channels (node-26#24): a conventional node's channels and
# whether each is encrypted — the one decode failure the owner has # whether each is encrypted — the one decode failure the owner has
# to hear about from C2, since nothing on the node can fix it. # to hear about from C2, since nothing on the node can fix it.
# disk_* (node-26#41): free/total bytes of the host disk the node
# records to — a full disk silently stopped node-002 for 17 days.
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running", for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels"): "sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels",
"disk_free_bytes", "disk_total_bytes", "version"):
if key in payload: if key in payload:
updates[key] = payload[key] updates[key] = payload[key]
if "sdr_count" in payload: if "sdr_count" in payload:
@@ -302,6 +309,12 @@ class MQTTHandler:
updates["org_id"] = node["org_id"] updates["org_id"] = node["org_id"]
if payload.get("audio_url"): if payload.get("audio_url"):
updates["audio_url"] = payload["audio_url"] updates["audio_url"] = payload["audio_url"]
# The node usually learns the talker after call_start went out; only
# call_start used to write srcaddr, so most calls kept None
# (server-26#304).
if payload.get("srcaddr") is not None:
updates["srcaddr"] = payload["srcaddr"]
updates.update(radios.call_fields(payload.get("radios")))
await fstore.doc_set("calls", call_id, updates) await fstore.doc_set("calls", call_id, updates)
logger.info(f"Call end: {call_id}") logger.info(f"Call end: {call_id}")
+57 -4
View File
@@ -23,7 +23,8 @@ permanently improves the reference data for that channel.
NO ANCHOR MEANS SKIP, NOT ACCEPT. An anchor too wide to discriminate is not NO ANCHOR MEANS SKIP, NOT ACCEPT. An anchor too wide to discriminate is not
stored at all (see `area_context`), and without one this module returns stored at all (see `area_context`), and without one this module returns
immediately. A statewide radius would confirm anything inside it, which is worse immediately. A channel nobody described may still get one from its own
talkgroup name plus the node's state (server-26#160, `_talkgroup_anchor`). A statewide radius would confirm anything inside it, which is worse
than not checking — it looks like verification and is not. than not checking — it looks like verification and is not.
THE FREE TIER RUNS FIRST. A sound-alike among the terms the operator already THE FREE TIER RUNS FIRST. A sound-alike among the terms the operator already
@@ -169,6 +170,50 @@ def _known_soundalike(heard: str, area: dict) -> Optional[dict]:
return best return best
async def _talkgroup_anchor(
talkgroup_name: Optional[str], node_id: Optional[str],
) -> tuple[Optional[dict], str]:
"""
An anchor derived from the talkgroup's own name plus the receiving node's
state, or (None, ""). Same width rule as an operator anchor
(area_context.resolve_anchor drops anything wider than
area_anchor_max_radius_km, and caches per place for the process).
A STATE IS REQUIRED. "Springfield PD" alone geocodes to whichever
Springfield Google prefers, and an anchor in the wrong half of the
country would let Maps rewrite real local streets into sound-alikes from
there (the failure server-26#36 added `state` to prevent). The node's
reverse-geocoded state is coarse enough to be right even when the node
sits far from the area the channel covers.
"""
from app.internal import firestore as fstore
from app.internal.intelligence import _get_node_state, _municipality_from_tg
muni = _municipality_from_tg(talkgroup_name)
if not muni or not node_id:
return None, ""
node = await fstore.doc_get_cached("nodes", node_id) or {}
lat, lon = node.get("lat"), node.get("lon")
if lat is None or lon is None:
return None, ""
state = await _get_node_state(node_id, lat, lon)
if not state:
return None, ""
place = {"municipality": muni, "state": state}
resolved = await area_context.resolve_anchor(place)
if not resolved:
return None, ""
try:
anchor = {
"lat": float(resolved["center"]["lat"]),
"lng": float(resolved["center"]["lng"]),
"radius_km": float(resolved["radius_km"]),
}
except (KeyError, TypeError, ValueError):
return None, ""
return anchor, f"{muni}, {state}"
# -- Public -------------------------------------------------------------------- # -- Public --------------------------------------------------------------------
def _substitute(text: str, swaps: list[tuple[str, str]]) -> str: def _substitute(text: str, swaps: list[tuple[str, str]]) -> str:
@@ -186,6 +231,8 @@ async def verify(
tg_area: Optional[dict], tg_area: Optional[dict],
system_id: Optional[str] = None, system_id: Optional[str] = None,
talkgroup_id: Optional[Any] = None, talkgroup_id: Optional[Any] = None,
talkgroup_name: Optional[str] = None,
node_id: Optional[str] = None,
) -> tuple[Optional[str], Optional[list[dict]]]: ) -> tuple[Optional[str], Optional[list[dict]]]:
""" """
Check the corrector's location nouns against the talkgroup's anchor. Check the corrector's location nouns against the talkgroup's anchor.
@@ -197,12 +244,18 @@ async def verify(
if not settings.place_verification_enabled or not locations: if not settings.place_verification_enabled or not locations:
return None, None return None, None
area = area_context.effective(system_area, tg_area)
anchor = area_context.anchor_for(system_area, tg_area) anchor = area_context.anchor_for(system_area, tg_area)
suffix = _place_suffix(area)
if not anchor and not area_context.has_place(area):
# server-26#160: nobody described this channel, but its own name may
# (e.g. "New York City - NYPD Citywide 2 Patch") — the same signal
# #159 trusted in intelligence.py's geocoder. Only when an operator set
# NOTHING: a place that is set but has no anchor was too wide or is
# stale, and that skip stays load-bearing.
anchor, suffix = await _talkgroup_anchor(talkgroup_name, node_id)
if not anchor: if not anchor:
return None, None # load-bearing: no anchor means skip, never accept return None, None # load-bearing: no anchor means skip, never accept
area = area_context.effective(system_area, tg_area)
suffix = _place_suffix(area)
swaps: list[tuple[str, str]] = [] swaps: list[tuple[str, str]] = []
proposals: list[dict] = [] proposals: list[dict] = []
+64
View File
@@ -0,0 +1,64 @@
"""
Every radio heard on a call, as the edge node reports it (server-26#304).
The node sends `radios`: a list of {radio, start, end}, one entry per
transmission, offsets in seconds into the uploaded audio file, so a talker's
wall-clock time is audio_start_epoch + offset (the same mapping the node uses
for the audio itself). It arrives twice — on
/upload (as a JSON string form field, because the pipeline starts there) and
on the MQTT call_end — and both go through `call_fields` so the call doc gets
the same shape either way.
`radio_ids` is the distinct IDs in first-heard order, kept alongside so a
correlator or query can ask "was radio X on this call" without walking the list.
"""
import json
from typing import Any, Dict, List, Optional
from app.internal.logger import logger
# A 600 s recording polled every 0.5 s cannot honestly hold more than this.
MAX_TRANSMISSIONS = 200
def _num(value: Any) -> Optional[float]:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number >= 0 else None
def parse(value: Any) -> List[Dict[str, Any]]:
"""The node's radio list, validated. Accepts a list or its JSON string; junk entries are dropped."""
if value is None or value == "":
return []
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
logger.warning(f"radios: not JSON, ignored ({value[:80]!r})")
return []
if not isinstance(value, list):
return []
out: List[Dict[str, Any]] = []
for item in value[:MAX_TRANSMISSIONS]:
if not isinstance(item, dict):
continue
try:
radio = int(item.get("radio"))
except (TypeError, ValueError):
continue
start, end = _num(item.get("start")), _num(item.get("end"))
if radio <= 0 or start is None or end is None:
continue
out.append({"radio": radio, "start": start, "end": max(start, end)})
return out
def call_fields(value: Any) -> Dict[str, Any]:
"""Call-doc fields for a radio list; empty when the node sent none (older nodes, analog)."""
radios = parse(value)
if not radios:
return {}
return {"radios": radios, "radio_ids": list(dict.fromkeys(r["radio"] for r in radios))}
+36 -32
View File
@@ -65,14 +65,18 @@ def pipeline_worst_case_seconds(n_scenes: int = _BOUND_SCENES) -> float:
_run_intelligence_pipeline.""" _run_intelligence_pipeline."""
openai_attempts = 1 + max(0, settings.openai_max_retries) openai_attempts = 1 + max(0, settings.openai_max_retries)
stt_rounds = 2 if settings.stt_retry_on_degenerate else 1 stt_rounds = 2 if settings.stt_retry_on_degenerate else 1
stt = stt_rounds * openai_attempts * settings.stt_timeout_seconds # A failed primary STT provider falls back to OpenAI (server-26#297), so
# Transcript correction (inside transcribe_call). gemini.generate_json only # the worst case is every configured target timing out in turn.
# re-issues a request when the model rejects a thinking level — a fast from app.internal.transcription import stt_targets
# 400, not a timeout — and google-genai itself does not retry. stt_providers = max(1, len(stt_targets()))
correction = settings.gemini_timeout_seconds stt = stt_providers * stt_rounds * openai_attempts * settings.stt_timeout_seconds
# Transcript correction and the correlation decision go through
# llm.generate_json; the bound depends on which provider serves the model.
from app.internal import llm
correction = llm.timeout_seconds(settings.transcript_correction_model)
extraction = openai_attempts * settings.openai_timeout_seconds extraction = openai_attempts * settings.openai_timeout_seconds
embeddings = n_scenes * openai_attempts * settings.openai_timeout_seconds embeddings = n_scenes * openai_attempts * settings.openai_timeout_seconds
correlation = 2 * settings.gemini_timeout_seconds # decide + tiebreak, scene 0 correlation = llm.timeout_seconds(settings.corr_cheap_model) # at most one LLM call, scene 0 (#299)
return stt + correction + extraction + embeddings + correlation + _NON_AI_SLACK_SECONDS return stt + correction + extraction + embeddings + correlation + _NON_AI_SLACK_SECONDS
@@ -80,24 +84,28 @@ def _derive_min_minutes() -> int:
return max(_FLOOR_MINUTES, math.ceil(pipeline_worst_case_seconds() / 60)) return max(_FLOOR_MINUTES, math.ceil(pipeline_worst_case_seconds() / 60))
# 21 with the defaults (stt 120s, openai 60s x2 attempts, gemini 60s, 3 scenes). # 22 with the defaults (stt 120s, openai 60s x2 attempts for every call, 3 scenes).
MIN_MINUTES_SINCE_PIPELINE_START = _derive_min_minutes() MIN_MINUTES_SINCE_PIPELINE_START = _derive_min_minutes()
# Standard link-only retry budget before a call is tombstoned corr_path="unlinked".
MAX_SWEEP_ATTEMPTS = 3
# server-26#115 — a call the consensus LLM-orphan gate parked (llm=orphan vs
# rules=new, no substance) gets a longer budget. The gate fires before any
# incident for the job may exist, so the substantive call that would justify
# linking can land well after the standard ~6 min. Still link-only: a genuinely
# thin call must not mint an incident, and the rules creation gate would re-orphan
# it anyway.
GATED_ORPHAN_SWEEP_ATTEMPTS = 10
def _last_chance(call: dict, now: datetime) -> bool:
"""
server-26#27 — the retry budget is wall-clock, not an attempt count.
def _max_sweep_attempts(call: dict) -> int: The old budget was 3 attempts, which with the #131 hold-back expired ~19
if call.get("corr_consensus") == "llm_orphan_gate": minutes after the pipeline started, while the incident the orphan belongs
return GATED_ORPHAN_SWEEP_ATTEMPTS to may be opened later than that (`f321b047` was tombstoned three minutes
return MAX_SWEEP_ATTEMPTS after its own incident was created). An orphan is now retried on every
pass for as long as the scan query can see it, and tombstoned
corr_path="unlinked" only on its LAST visible pass: the one after which
its ended_at falls out of `recorrelation_scan_minutes`. A call with no
parseable ended_at gets no further retries.
"""
ended = _parse_dt(call.get("ended_at"))
if ended is None:
return True
remaining = timedelta(minutes=settings.recorrelation_scan_minutes) - (now - ended)
return remaining <= timedelta(minutes=settings.summary_interval_minutes)
async def recorrelation_loop() -> None: async def recorrelation_loop() -> None:
@@ -149,9 +157,8 @@ async def _run_sweep_pass() -> None:
("status", "==", "ended"), ("status", "==", "ended"),
("ended_at", ">=", cutoff), ("ended_at", ">=", cutoff),
]) ])
# corr_path="unlinked" is written after the attempt budget is exhausted. # corr_path="unlinked" is the tombstone written on an orphan's last
# Allows a few retries so a welfare-check call can link to an escalation # visible pass (_last_chance); the scan window itself is the budget.
# incident that is created a few minutes later, without sweeping 30× forever.
orphans = [ orphans = [
c for c in recent_ended c for c in recent_ended
if not c.get("incident_ids") and not c.get("incident_id") if not c.get("incident_ids") and not c.get("incident_id")
@@ -163,7 +170,6 @@ async def _run_sweep_pass() -> None:
# the thin path minutes later and attached to whatever was most recent — # the thin path minutes later and attached to whatever was most recent —
# a second route into the over-merge the thin fix above addresses. # a second route into the over-merge the thin fix above addresses.
and not c.get("skip_reason") and not c.get("skip_reason")
and c.get("corr_sweep_count", 0) < _max_sweep_attempts(c)
and not _pipeline_likely_still_running(c, now) and not _pipeline_likely_still_running(c, now)
] ]
@@ -173,14 +179,14 @@ async def _run_sweep_pass() -> None:
logger.info(f"Re-correlation sweep: {len(orphans)} orphaned call(s) to check") logger.info(f"Re-correlation sweep: {len(orphans)} orphaned call(s) to check")
linked = 0 linked = 0
for call in orphans: for call in orphans:
if await _recorrelate_orphan(call): if await _recorrelate_orphan(call, now):
linked += 1 linked += 1
if linked: if linked:
logger.info(f"Re-correlation sweep: linked {linked}/{len(orphans)} orphaned call(s)") logger.info(f"Re-correlation sweep: linked {linked}/{len(orphans)} orphaned call(s)")
async def _recorrelate_orphan(call: dict) -> bool: async def _recorrelate_orphan(call: dict, now: datetime) -> bool:
""" """
Attempt to link a single orphaned call to an existing incident. Attempt to link a single orphaned call to an existing incident.
Returns True if a match was found and the call was linked. Returns True if a match was found and the call was linked.
@@ -223,12 +229,10 @@ async def _recorrelate_orphan(call: dict) -> bool:
) )
return True return True
# Increment the attempt counter. Once the budget is reached the orphan filter # The attempt count is kept for the admin debug view only; the budget is
# above will stop picking this call up, and we write corr_path="unlinked" as # the scan window (_last_chance), after which the call is tombstoned.
# a permanent tombstone. update: dict = {"corr_sweep_count": call.get("corr_sweep_count", 0) + 1}
attempts = call.get("corr_sweep_count", 0) + 1 if _last_chance(call, now):
update: dict = {"corr_sweep_count": attempts}
if attempts >= _max_sweep_attempts(call):
update["corr_path"] = "unlinked" update["corr_path"] = "unlinked"
await fstore.doc_set("calls", call_id, update) await fstore.doc_set("calls", call_id, update)
return False return False
+16 -7
View File
@@ -58,9 +58,10 @@ PREFETCH = 6
# Estimates, not a bill — nothing in DRB reads a real invoice (server-26#45). # Estimates, not a bill — nothing in DRB reads a real invoice (server-26#45).
USD_WHISPER_PER_MIN = 0.006 USD_WHISPER_PER_MIN = 0.006
USD_PER_EXTRACTION = 0.0005 # gpt-4o-mini scene extraction + embedding USD_PER_EXTRACTION = 0.0005 # gpt-4o-mini scene extraction + embedding
USD_PER_CORRECTION = 0.0003 # Gemini flash transcript correction USD_PER_CORRECTION = 0.0004 # gpt-4o-mini transcript correction (~1.5k in / 300 out tokens)
USD_PER_GEOCODE = 0.005 # Google geocode, roughly one per located scene USD_PER_GEOCODE = 0.005 # Google geocode, roughly one per located scene
USD_PER_LLM_CORRELATE = 0.0005 # Gemini flash consensus decision USD_PER_LLM_CORRELATE = 0.0004 # one gpt-4o-mini correlation decision (~2k in / 100 out; ambiguous scenes only)
_LLM_CONSENSUS = ("llm", "rules_guard", "agreed", "tiebreak", "llm_orphan_gate")
# Fields the pipeline writes onto a call doc. Stripped when a call is copied # Fields the pipeline writes onto a call doc. Stripped when a call is copied
# into the sandbox so the replay recomputes them instead of inheriting the # into the sandbox so the replay recomputes them instead of inheriting the
@@ -73,6 +74,9 @@ _DERIVED = {
"vehicles", "cleared_units", "severity", "incident_type", "type", "vehicles", "cleared_units", "severity", "incident_type", "type",
"embedding", "skip_reason", "intelligence_started_at", "reassignment", "embedding", "skip_reason", "intelligence_started_at", "reassignment",
"resolved", "has_updates", "audio_url", "resolved", "has_updates", "audio_url",
# server-26#297: which STT produced the transcript, and its confidence —
# a re-transcription that comes back empty must not keep the live values.
"stt_provider", "stt_model", "stt_audio_seconds", "stt_confidence",
} }
_DERIVED_PREFIXES = ("corr_", "chatter_classifier_", "eval_") _DERIVED_PREFIXES = ("corr_", "chatter_classifier_", "eval_")
_TRANSCRIPT_FIELDS = ( _TRANSCRIPT_FIELDS = (
@@ -406,6 +410,7 @@ async def _prepare(call: dict, mode: str, source_scenes: dict[str, dict]) -> dic
transcript, segments = await transcription.transcribe_call( transcript, segments = await transcription.transcribe_call(
call_id, call["audio_gcs_uri"], talkgroup_name, call_id, call["audio_gcs_uri"], talkgroup_name,
system_id=call.get("system_id"), talkgroup_id=call.get("talkgroup_id"), system_id=call.get("system_id"), talkgroup_id=call.get("talkgroup_id"),
node_id=call.get("node_id"),
) )
else: else:
transcript, segments = _stored_input(call) transcript, segments = _stored_input(call)
@@ -469,9 +474,9 @@ async def _run(run_id: str, org_id: str, calls: list[dict], mode: str,
fl_token = force_flags(_flags_for(mode)) fl_token = force_flags(_flags_for(mode))
ai_failures: list = [] ai_failures: list = []
ai_token = ai_health.collect_sandbox_failures(ai_failures) ai_token = ai_health.collect_sandbox_failures(ai_failures)
from app.internal import gemini from app.internal import llm
usage: dict = {} usage: dict = {}
usage_token = gemini.collect_usage(usage) usage_token = llm.collect_usage(usage)
try: try:
sem = asyncio.Semaphore(PREFETCH) sem = asyncio.Semaphore(PREFETCH)
@@ -564,7 +569,7 @@ async def _run(run_id: str, org_id: str, calls: list[dict], mode: str,
metrics = compute_metrics(incidents, sb_calls) metrics = compute_metrics(incidents, sb_calls)
metrics["est_cost_usd"] = _running_cost(progress, metrics, mode) metrics["est_cost_usd"] = _running_cost(progress, metrics, mode)
metrics["ai_failures"] = dict(Counter(f"{f['tier']}: {f['problem']}" for f in ai_failures)) metrics["ai_failures"] = dict(Counter(f"{f['tier']}: {f['problem']}" for f in ai_failures))
metrics["gemini_usage"] = usage metrics["llm_usage"] = usage
except Exception as e: except Exception as e:
status = "failed" status = "failed"
errors.append(f"run: {type(e).__name__}: {e}"[:300]) errors.append(f"run: {type(e).__name__}: {e}"[:300])
@@ -572,7 +577,7 @@ async def _run(run_id: str, org_id: str, calls: list[dict], mode: str,
logger.error(f"Replay {run_id} failed: {e}") logger.error(f"Replay {run_id} failed: {e}")
finally: finally:
ai_health._sandbox_failures.reset(ai_token) ai_health._sandbox_failures.reset(ai_token)
gemini.reset_usage(usage_token) llm.reset_usage(usage_token)
unforce_flags(fl_token) unforce_flags(fl_token)
fstore.exit_sandbox(sb_token) fstore.exit_sandbox(sb_token)
_cancel.discard(run_id) _cancel.discard(run_id)
@@ -636,7 +641,11 @@ def compute_metrics(incidents: list[dict], calls: list[dict]) -> dict:
corr_path[r.get("corr_path") or "none"] += 1 corr_path[r.get("corr_path") or "none"] += 1
consensus[r.get("corr_consensus") or "none"] += 1 consensus[r.get("corr_consensus") or "none"] += 1
linked = sum(1 for c in calls if c.get("incident_ids")) linked = sum(1 for c in calls if c.get("incident_ids"))
llm = sum(n for k, n in consensus.items() if k not in ("none", "rules_only")) # Scene decisions that made an LLM call (server-26#299: at most one each).
# "rules_fallback" is excluded — the call failed or was unusable, so it
# was mostly not billed. "agreed"/"tiebreak" are pre-#299 runs' values
# (a tiebreak was two calls, so old runs under-count by their tiebreaks).
llm = sum(n for k, n in consensus.items() if k in _LLM_CONSENSUS)
return { return {
"calls": len(calls), "calls": len(calls),
"calls_linked": linked, "calls_linked": linked,
@@ -116,11 +116,37 @@ def _dedupe(items: list[str]) -> list[str]:
# mishearing this pass exists to fix (server-26#162). # mishearing this pass exists to fix (server-26#162).
_CODE_TOKEN_RE = re.compile(r"\b\d{1,3}(?:-\d{1,3})+\b") _CODE_TOKEN_RE = re.compile(r"\b\d{1,3}(?:-\d{1,3})+\b")
# Bare numbers (the rest of server-26#162): the same call had a bare "7"
# turned into "ShotSpotter". A unit number, a post, a house number or a count
# the recogniser DID hear must survive, in order. A number may still be
# added, because a sound-alike can legitimately be one ("back to Rose" ->
# "back to Route 9"). "4th" is not bare (it is a word).
_BARE_NUMBER_RE = re.compile(r"(?<![\w-])\d+(?![\w-])")
# "NEVER add information" is the other prompt rule nothing checked. A
# correction substitutes sound-alikes, so it may shift the word count a little
# ("Holmes Street and 4th and Rowe" -> "Home Street and Forest Ave"); growing
# by more than this is the model writing, not correcting.
_MAX_ADDED_WORDS = 4
_MAX_GROWTH_RATIO = 0.25
def _code_tokens(text: str) -> list[str]: def _code_tokens(text: str) -> list[str]:
return _CODE_TOKEN_RE.findall(text or "") return _CODE_TOKEN_RE.findall(text or "")
def _lost_numbers(original: str, corrected: str) -> bool:
"""True unless every bare number of `original` is still in `corrected`, in order."""
remaining = iter(_BARE_NUMBER_RE.findall(corrected or ""))
return not all(any(n == m for m in remaining) for n in _BARE_NUMBER_RE.findall(original or ""))
def _grew(original: str, corrected: str) -> bool:
n_orig = len((original or "").split())
n_new = len((corrected or "").split())
return n_new - n_orig > max(_MAX_ADDED_WORDS, int(n_orig * _MAX_GROWTH_RATIO))
def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict: def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
"""The config.talkgroups[] entry for this talkgroup, or {}.""" """The config.talkgroups[] entry for this talkgroup, or {}."""
if talkgroup_id is None: if talkgroup_id is None:
@@ -227,11 +253,11 @@ def build_context_block(context: dict, talkgroup_name: Optional[str]) -> str:
def _sync_gemini(model_name: str, prompt: str) -> dict: def _sync_gemini(model_name: str, prompt: str) -> dict:
from app.internal import gemini from app.internal import llm
# Correction rewrites text against vocabulary; keep a little reasoning # Correction rewrites text against vocabulary; keep a little reasoning
# ("low") rather than the correlator's "minimal" until a replay shows # ("low") rather than the correlator's "minimal" until a replay shows
# minimal doesn't hurt it. # minimal doesn't hurt it.
return gemini.generate_json(model_name, prompt, purpose="correction", thinking_level="low") return llm.generate_json(model_name, prompt, purpose="correction", thinking_level="low")
async def correct( async def correct(
@@ -241,6 +267,7 @@ async def correct(
system_id: Optional[str] = None, system_id: Optional[str] = None,
talkgroup_id: Optional[int] = None, talkgroup_id: Optional[int] = None,
talkgroup_name: Optional[str] = None, talkgroup_name: Optional[str] = None,
node_id: Optional[str] = None,
) -> tuple[Optional[str], Optional[list[dict]], bool]: ) -> tuple[Optional[str], Optional[list[dict]], bool]:
""" """
Second-opinion pass over a transcript. Second-opinion pass over a transcript.
@@ -255,7 +282,8 @@ async def correct(
so a correction that only fixed the joined transcript would never reach the so a correction that only fixed the joined transcript would never reach the
model on exactly the multi-transmission calls that carry the most content. model on exactly the multi-transmission calls that carry the most content.
""" """
if not settings.gemini_api_key or not settings.transcript_correction_enabled: from app.internal import llm
if not llm.available(settings.transcript_correction_model) or not settings.transcript_correction_enabled:
return None, None, False return None, None, False
if len((text or "").split()) < MIN_WORDS_FOR_CORRECTION: if len((text or "").split()) < MIN_WORDS_FOR_CORRECTION:
@@ -330,6 +358,8 @@ async def correct(
context.get("tg_area"), context.get("tg_area"),
system_id=system_id, system_id=system_id,
talkgroup_id=talkgroup_id, talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
node_id=node_id,
) )
except Exception as e: except Exception as e:
logger.warning(f"Place verification failed for call {call_id}: {e}") logger.warning(f"Place verification failed for call {call_id}: {e}")
@@ -354,6 +384,18 @@ async def correct(
f"discarding the joined correction" f"discarding the joined correction"
) )
corrected = None corrected = None
if corrected is not None and _lost_numbers(text, corrected):
logger.warning(
f"Transcript correction for call {call_id} dropped or changed a number — "
f"discarding the joined correction"
)
corrected = None
if corrected is not None and _grew(text, corrected):
logger.warning(
f"Transcript correction for call {call_id} grew the transcript "
f"({len(text.split())} -> {len(corrected.split())} words) — discarding the joined correction"
)
corrected = None
if corrected_segments is not None: if corrected_segments is not None:
for seg, orig in zip(corrected_segments, segments or []): for seg, orig in zip(corrected_segments, segments or []):
if _code_tokens(seg["text"]) != _code_tokens(orig.get("text", "")): if _code_tokens(seg["text"]) != _code_tokens(orig.get("text", "")):
@@ -363,6 +405,13 @@ async def correct(
) )
corrected_segments = None corrected_segments = None
break break
if _lost_numbers(orig.get("text", ""), seg["text"]) or _grew(orig.get("text", ""), seg["text"]):
logger.warning(
f"Transcript correction for call {call_id} grew a segment or dropped "
f"a number in one — discarding segment corrections"
)
corrected_segments = None
break
if corrected or corrected_segments or not_speech: if corrected or corrected_segments or not_speech:
changed = raw.get("changed") or [] changed = raw.get("changed") or []
+445 -105
View File
@@ -1,18 +1,25 @@
""" """
Speech-to-text transcription for recorded calls using OpenAI Whisper. Speech-to-text transcription for recorded calls.
Audio is downloaded from GCS then sent to the Whisper API. Falls back to Audio is downloaded from GCS once, then sent to an OpenAI-compatible
returning None on any failure so the intelligence pipeline can still run. /audio/transcriptions endpoint: OpenAI whisper-1 by default, or a cheaper
provider (Groq whisper-large-v3-turbo, a self-hosted server) when configured,
with OpenAI as the fallback when that provider fails (server-26#297). Returns
None on any failure so the intelligence pipeline can still run.
""" """
import asyncio import asyncio
import math
import re import re
import tempfile import tempfile
import os import os
from dataclasses import dataclass
from typing import Optional from typing import Optional
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import ai_health from app.internal import ai_health
from app.internal import transcript_correction from app.internal import transcript_correction
from app.internal import area_context
from app.internal.chatter_classifier import gate_chatter
from app.config import settings from app.config import settings
# Whisper treats `prompt` as preceding transcript text, not instructions. # Whisper treats `prompt` as preceding transcript text, not instructions.
@@ -43,6 +50,19 @@ _RUN_RATIO = 0.7 # share of consecutive pairs that must step by +1
_MIN_SEGMENTS_FOR_REPEAT = 6 # segments needed before repetition is considered _MIN_SEGMENTS_FOR_REPEAT = 6 # segments needed before repetition is considered
_UNIQUE_RATIO = 0.25 # unique/total segment texts at or below this is degenerate _UNIQUE_RATIO = 0.25 # unique/total segment texts at or below this is degenerate
_MAX_PHRASE_REPEATS = 8 # identical consecutive phrase repeats allowed in one blob _MAX_PHRASE_REPEATS = 8 # identical consecutive phrase repeats allowed in one blob
# Whisper's own failure test (openai/whisper transcribe.py, compression_ratio_
# threshold=2.4): text that gzips this well is a loop, whatever its shape. The
# two checks above only know two loop shapes; this catches the rest (two
# phrases alternating, a list re-emitted) without a model call, so a noise
# gate survives with transcript correction switched off (server-26#83). Only
# on longer text -- a short string has too little to compress to judge.
_MAX_COMPRESSION_RATIO = 2.4
_MIN_CHARS_FOR_COMPRESSION = 200
# Prompt echo (server-26#297): on silence Whisper can emit its own prompt.
# With a vocabulary glossary in the prompt that reads as a list of local
# street names. See _prompt_echo for why this is kept narrow.
_ECHO_MIN_TERMS = 3
_ECHO_WORD_RATIO = 0.6
def _ten_code_run(text: str) -> bool: def _ten_code_run(text: str) -> bool:
@@ -77,7 +97,74 @@ def _phrase_loop(text: str) -> bool:
return False return False
def _is_degenerate(text: str, segments: list[dict]) -> bool: def _compression_ratio(text: str) -> float:
import zlib
raw = text.encode("utf-8")
return len(raw) / max(1, len(zlib.compress(raw)))
def _prompt_echo(text: str, glossary: list[str]) -> bool:
"""
True if the transcript is the prompt's own glossary read back.
Deliberately narrow (review of server-26#297): a real location readback
("Main Street, Croton Avenue, Route 9") names glossary terms too, and
that is where map pins come from. So either the lead-in itself came back
("Local names: ..."), or nearly the whole glossary did, in prompt order,
with little else around it.
"""
if not text or not glossary:
return False
low = text.lower()
if "local names" in low:
return True
if len(glossary) < _ECHO_MIN_TERMS:
return False
positions = []
for t in glossary:
m = re.search(rf"\b{re.escape(t.lower())}\b", low)
if m:
positions.append(m.start())
if len(positions) < max(_ECHO_MIN_TERMS, math.ceil(0.8 * len(glossary))):
return False
if positions != sorted(positions):
return False
words = re.findall(r"[\w'-]+", low)
term_words = {w for t in glossary for w in re.findall(r"[\w'-]+", t.lower())}
return bool(words) and sum(1 for w in words if w in term_words) / len(words) >= _ECHO_WORD_RATIO
def _compression_loop(text: str, segments: list[dict]) -> bool:
"""
Whisper applies its 2.4 threshold per 30s decode window, and zlib's ratio
grows with length, so judging a whole 5-minute call at once would drop
busy real traffic. Judge ~30s windows of segments (or ~500-char chunks of
a segment-less transcript) and call it a loop only when at least half of
the windows long enough to judge are loops.
"""
windows: list[str] = []
if segments:
cur, start = [], None
for s in segments:
st = float(s.get("start") or 0.0)
if start is None:
start = st
if st - start > 30.0 and cur:
windows.append(" ".join(cur))
cur, start = [], st
cur.append(s.get("text") or "")
if cur:
windows.append(" ".join(cur))
else:
windows = [text[i:i + 500] for i in range(0, len(text), 500)]
judged = [w for w in windows if len(w) >= _MIN_CHARS_FOR_COMPRESSION]
if not judged:
return False
loops = sum(1 for w in judged if _compression_ratio(w) > _MAX_COMPRESSION_RATIO)
return loops * 2 >= len(judged)
def _is_degenerate(text: str, segments: list[dict], glossary: Optional[list[str]] = None) -> bool:
"""True if a transcript looks like Whisper output rather than radio traffic. """True if a transcript looks like Whisper output rather than radio traffic.
Applied AFTER the per-segment no_speech_prob filter, which does not catch Applied AFTER the per-segment no_speech_prob filter, which does not catch
@@ -88,6 +175,10 @@ def _is_degenerate(text: str, segments: list[dict]) -> bool:
return False return False
if _ten_code_run(text) or _phrase_loop(text): if _ten_code_run(text) or _phrase_loop(text):
return True return True
if _compression_loop(text, segments):
return True
if _prompt_echo(text, glossary or []):
return True
# Near-identical segments repeated across the whole recording. # Near-identical segments repeated across the whole recording.
if len(segments) >= _MIN_SEGMENTS_FOR_REPEAT: if len(segments) >= _MIN_SEGMENTS_FOR_REPEAT:
normalised = {s["text"].strip().lower() for s in segments} normalised = {s["text"].strip().lower() for s in segments}
@@ -96,7 +187,155 @@ def _is_degenerate(text: str, segments: list[dict]) -> bool:
return False return False
async def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
# ── Providers (server-26#297) ───────────────────────────────────────────────
_GROQ_BASE_URL = "https://api.groq.com/openai/v1"
@dataclass(frozen=True)
class SttTarget:
"""One OpenAI-compatible transcription endpoint to try."""
provider: str # "openai" | "groq" | "compat"
model: str
api_key: Optional[str]
base_url: Optional[str] # None -> the OpenAI SDK default
vocab_prompt: bool # whether the domain-vocabulary prompt is sent
tier: str # ai_health tier this target reports to
def stt_targets() -> list[SttTarget]:
"""
The endpoints to try, in order: the configured provider, then OpenAI as
fallback. Empty when nothing usable is configured.
`stt_provider="auto"` resolves to Groq only when GROQ_API_KEY is set, so a
deploy that has not provisioned the key yet keeps transcribing on OpenAI
exactly as before.
"""
provider = (settings.stt_provider or "auto").strip().lower()
if provider == "auto":
if settings.groq_api_key:
provider = "groq"
elif settings.stt_base_url:
provider = "compat" # an explicit endpoint is a compat server
else:
provider = "openai"
openai_target = SttTarget(
provider="openai", model=settings.stt_model, api_key=settings.openai_api_key,
base_url=None, vocab_prompt=False, tier="transcription",
)
if provider not in ("groq", "compat"):
if provider != "openai":
logger.warning(f"Unknown STT_PROVIDER {settings.stt_provider!r} — using OpenAI")
return [openai_target] if settings.openai_api_key else []
if provider == "groq":
key = settings.stt_api_key or settings.groq_api_key
base = settings.stt_base_url or _GROQ_BASE_URL
else:
# A self-hosted server may not check the key, but the SDK insists on one.
key = settings.stt_api_key or "unused"
base = settings.stt_base_url
targets: list[SttTarget] = []
if key and base:
targets.append(SttTarget(
provider=provider, model=settings.stt_compat_model, api_key=key, base_url=base,
vocab_prompt=settings.stt_vocab_prompt_enabled, tier="transcription",
))
else:
logger.warning(f"STT provider {provider!r} has no API key/base URL — using OpenAI only")
if settings.openai_api_key and (not targets or settings.stt_fallback_to_openai):
tier = "transcription_fallback" if targets else "transcription"
targets.append(SttTarget(**{**openai_target.__dict__, "tier": tier}))
return targets
# ── Prompt (server-26#297) ──────────────────────────────────────────────────
_CODE_SHAPED = re.compile(r"^\s*\d{1,3}(?:-\d{1,3})+\s*$")
_MAX_TERM_CHARS = 40
def _estimate_tokens(text: str) -> int:
"""
Conservative token estimate for Whisper's GPT-2 tokenizer, without the
tokenizer: one per punctuation mark and per digit, ~4 letters per token
for words. Over-counts real English slightly, which is the safe side —
Whisper drops prompt tokens past 224 from the FRONT, silently.
"""
n = 0
for piece in re.findall(r"[A-Za-z]+|\d|[^\sA-Za-z\d]", text or ""):
n += max(1, math.ceil(len(piece) / 4)) if piece[0].isalpha() else 1
return n
def _glossary_terms(context: Optional[dict]) -> list[str]:
"""
Local names worth spelling for the recogniser, most specific first:
the talkgroup/system local_knowledge terms, then vocabulary.
Never ten-codes. An enumerated run of codes in the prompt is what made
Whisper count "10-4. 10-5. 10-6." over silence (see _WHISPER_PROMPT), so
the ten_codes reference and any code-shaped vocabulary term stay out.
"""
if not context:
return []
area = context.get("area") or {}
terms = [e.get("term") or "" for e in (area.get("local_knowledge") or []) if isinstance(e, dict)]
terms += [t for t in (context.get("vocabulary") or []) if isinstance(t, str)]
out: list[str] = []
seen: set[str] = set()
for t in terms:
t = t.strip()
key = t.lower()
if not t or key in seen or len(t) > _MAX_TERM_CHARS or _CODE_SHAPED.match(t):
continue
seen.add(key)
out.append(t)
return out
def build_stt_prompt(
talkgroup_name: Optional[str], context: Optional[dict] = None,
) -> tuple[str, list[str]]:
"""
The STT prompt, and the glossary terms that made it in.
Without `context` this is exactly the prompt OpenAI has always been sent.
With it, the channel's area and local names go in front of the fixed
radio-speech priming, cut to settings.stt_prompt_max_tokens. A glossary
is how Whisper's prompting guide says to fix the spelling of proper
nouns; the priming text stays last because it is the part that sets style.
"""
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
if not context:
return tg_prefix + _WHISPER_PROMPT, []
area = context.get("area") or {}
place = ", ".join(str(area[f]) for f in area_context.PLACE_FIELDS if area.get(f))
head = tg_prefix + (f"{place}. " if place else "")
budget = settings.stt_prompt_max_tokens - _estimate_tokens(head + _WHISPER_PROMPT)
chosen: list[str] = []
for term in _glossary_terms(context):
trial = "Local names: " + ", ".join(chosen + [term]) + ". "
if _estimate_tokens(trial) > budget:
continue # a shorter later term may still fit
chosen.append(term)
glossary = ("Local names: " + ", ".join(chosen) + ". ") if chosen else ""
prompt = head + glossary + _WHISPER_PROMPT
if _estimate_tokens(prompt) > settings.stt_prompt_max_tokens:
return tg_prefix + _WHISPER_PROMPT, []
return prompt, chosen
# ── Failure reporting ───────────────────────────────────────────────────────
async def _log_transcribe_failure(call_id: str, exc: Exception, target: SttTarget) -> None:
""" """
Log a transcription failure, escalating a permanent condition to ERROR Log a transcription failure, escalating a permanent condition to ERROR
once (via app.internal.ai_health, which also drives the /health/ai once (via app.internal.ai_health, which also drives the /health/ai
@@ -115,47 +354,71 @@ async def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
""" """
text = str(exc) text = str(exc)
kind = ai_health.classify(text) kind = ai_health.classify(text)
who = f"{target.provider} ({target.model})"
billing_url = {
"openai": "https://platform.openai.com/settings/organization/billing",
"groq": "https://console.groq.com/settings/billing",
}.get(target.provider, "the provider's billing page")
if kind == "billing": if kind == "billing":
problem = "the OpenAI account cannot be billed" problem = f"the {target.provider} account cannot be billed"
fix = "top up at https://platform.openai.com/settings/organization/billing" fix = f"top up at {billing_url}"
logger.error( logger.error(
"Transcription: the OpenAI account cannot be billed -- EVERY call is " f"Transcription: the {target.provider} account cannot be billed -- calls on this "
"now stored with no transcript, so extraction, correlation and " f"provider get no transcript (OpenAI fallback, if configured, is carrying them). "
"incidents are all dead downstream. Top up at " f"Top up at {billing_url}. API said: {text}"
f"https://platform.openai.com/settings/organization/billing. API said: {text}"
) )
await ai_health.report_degraded( await ai_health.report_degraded(
"transcription", "openai", settings.stt_model, problem, fix, permanent=True target.tier, target.provider, target.model, problem, fix, permanent=True
) )
return return
if kind == "dead_model": if kind == "dead_model":
problem = "the STT model is unavailable" problem = "the STT model is unavailable"
fix = "update STT_MODEL in config.py" fix = "update STT_MODEL / STT_COMPAT_MODEL"
logger.error( logger.error(
f"Transcription: the configured model ({settings.stt_model!r}) is unavailable " f"Transcription: the configured model {who} is unavailable -- calls on this provider "
"-- EVERY call is now stored with no transcript, so extraction, correlation " f"get no transcript. Update STT_MODEL / STT_COMPAT_MODEL. API said: {text}"
f"and incidents are all dead downstream. Update STT_MODEL in config.py. API said: {text}"
) )
await ai_health.report_degraded( await ai_health.report_degraded(
"transcription", "openai", settings.stt_model, problem, fix, permanent=True target.tier, target.provider, target.model, problem, fix, permanent=True
) )
return return
logger.warning(f"Transcription failed for call {call_id}: {text}") logger.warning(f"Transcription via {who} failed for call {call_id}: {text}")
await ai_health.report_degraded( await ai_health.report_degraded(
"transcription", "openai", settings.stt_model, target.tier, target.provider, target.model,
"transient API error", "no action needed unless this persists", permanent=False, "transient API error", "no action needed unless this persists", permanent=False,
) )
# ── Pipeline entry point ────────────────────────────────────────────────────
async def _transcribe_with(
call_id: str, tmp_path: str, target: SttTarget, prompt: str, glossary: list[str],
) -> dict:
"""One target, with the one retry on degenerate output. Raises on API failure."""
result = await asyncio.to_thread(_sync_transcribe_file, tmp_path, target, prompt, glossary)
# A hallucination is a coin-flip, not a property of the clip: call
# e49ea32c produced a 56-word ten-code counting run on one attempt and
# ordinary speech on the next, same audio and temperature=0. Discarding
# on the first bad roll threw away a recoverable transcript, so spend
# one more request before giving up.
if result["degenerate"] and settings.stt_retry_on_degenerate:
logger.info(f"Retrying transcription for call {call_id} after degenerate output")
result = await asyncio.to_thread(_sync_transcribe_file, tmp_path, target, prompt, glossary)
if result["degenerate"]:
logger.warning(f"Transcription for call {call_id} was degenerate twice — giving up")
return result
async def transcribe_call( async def transcribe_call(
call_id: str, call_id: str,
gcs_uri: str, gcs_uri: str,
talkgroup_name: Optional[str] = None, talkgroup_name: Optional[str] = None,
system_id: Optional[str] = None, system_id: Optional[str] = None,
talkgroup_id: Optional[int] = None, talkgroup_id: Optional[int] = None,
node_id: Optional[str] = None,
) -> tuple[Optional[str], list[dict]]: ) -> tuple[Optional[str], list[dict]]:
""" """
Transcribe audio at the given GCS URI and store the result in Firestore. Transcribe audio at the given GCS URI and store the result in Firestore.
@@ -167,36 +430,78 @@ async def transcribe_call(
if not gcs_uri or not gcs_uri.startswith("gs://"): if not gcs_uri or not gcs_uri.startswith("gs://"):
return None, [] return None, []
targets = stt_targets()
if not targets:
logger.warning("No STT provider configured (OPENAI_API_KEY / GROQ_API_KEY unset) — transcription disabled.")
return None, []
context: Optional[dict] = None
if any(t.vocab_prompt for t in targets):
try: try:
transcript, segments, degenerate = await asyncio.to_thread( context = await transcript_correction.resolve_context(system_id, talkgroup_id)
_sync_transcribe, gcs_uri, talkgroup_name
)
# A hallucination is a coin-flip, not a property of the clip: call
# e49ea32c produced a 56-word ten-code counting run on one attempt and
# ordinary speech on the next, same audio and temperature=0. Discarding
# on the first bad roll threw away a recoverable transcript, so spend
# one more request before giving up.
if degenerate and settings.stt_retry_on_degenerate:
logger.info(f"Retrying transcription for call {call_id} after degenerate output")
transcript, segments, degenerate = await asyncio.to_thread(
_sync_transcribe, gcs_uri, talkgroup_name
)
if degenerate:
logger.warning(
f"Transcription for call {call_id} was degenerate twice — giving up"
)
except Exception as e: except Exception as e:
await _log_transcribe_failure(call_id, e) logger.warning(f"Could not resolve STT vocabulary for call {call_id}: {e}")
try:
tmp_path = await asyncio.to_thread(_download, gcs_uri)
except Exception as e:
# Not a provider failure: classify() would read a GCS "not found" as a
# retired model and page someone to change STT_MODEL.
logger.warning(f"Could not download audio for call {call_id} ({gcs_uri}): {e}")
return None, []
result: Optional[dict] = None
used: Optional[SttTarget] = None
try:
for i, target in enumerate(targets):
prompt, glossary = build_stt_prompt(talkgroup_name, context if target.vocab_prompt else None)
try:
result = await _transcribe_with(call_id, tmp_path, target, prompt, glossary)
except Exception as e:
await _log_transcribe_failure(call_id, e, target)
if i + 1 < len(targets):
logger.warning(
f"Transcription for call {call_id}: {target.provider} failed, "
f"falling back to {targets[i + 1].provider} ({targets[i + 1].model})"
)
continue
used = target
break
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
if used is None or result is None:
return None, [] return None, []
# No exception means the provider call itself succeeded (this also # No exception means the provider call itself succeeded (this also
# covers transcripts discarded as degenerate/hallucinated output — # covers transcripts discarded as degenerate/hallucinated output —
# that's a filtering decision, not a provider failure), so the # that's a filtering decision, not a provider failure), so that tier is
# transcription tier is healthy and any prior degradation clears. # healthy and any prior degradation clears. A fallback success clears
await ai_health.report_healthy("transcription") # only the fallback tier: the primary is still broken and must stay red.
await ai_health.report_healthy(used.tier)
transcript: Optional[str] = result["text"]
segments: list[dict] = result["segments"]
if transcript: if transcript:
updates: dict = {"transcript": transcript} updates: dict = {
"transcript": transcript,
# Which endpoint produced this text, and how much audio it billed,
# so cost (server-26#45) and an A/B of providers can be read off
# the call docs instead of guessed.
"stt_provider": used.provider,
"stt_model": used.model,
}
if result.get("duration") is not None:
updates["stt_audio_seconds"] = result["duration"]
if result.get("confidence") is not None:
# Mean per-token probability, exp(avg_logprob) weighted by segment
# length. The only STT-side confidence there is; the alert gate
# reads it (server-26#300).
updates["stt_confidence"] = result["confidence"]
if segments: if segments:
updates["segments"] = segments updates["segments"] = segments
@@ -212,17 +517,30 @@ async def transcribe_call(
from app.internal.feature_flags import resolve_flags from app.internal.feature_flags import resolve_flags
_, _ai_flag = await resolve_flags(system_id) _, _ai_flag = await resolve_flags(system_id)
# server-26#298: radio housekeeping (roll call, a bare "Post 4, 10-8,
# copy") has no proper noun to correct, and extraction skips it too
# (intelligence.extract_scenes), so a correction pass here is pure
# spend. Same pure classifier both places, so they cannot disagree.
is_chatter, chatter_reason = (
gate_chatter(transcript) if settings.chatter_gate_enabled else (False, None)
)
corrected, corrected_segments, not_speech = (None, None, False) corrected, corrected_segments, not_speech = (None, None, False)
if _ai_flag("transcript_correction_enabled"): if not _ai_flag("transcript_correction_enabled"):
logger.info(
f"Transcript correction disabled — saving raw transcript for call {call_id}"
)
elif is_chatter:
logger.info(
f"Transcript correction skipped for call {call_id}: chatter ({chatter_reason})"
)
else:
corrected, corrected_segments, not_speech = await transcript_correction.correct( corrected, corrected_segments, not_speech = await transcript_correction.correct(
call_id, transcript, segments, call_id, transcript, segments,
system_id=system_id, system_id=system_id,
talkgroup_id=talkgroup_id, talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name, talkgroup_name=talkgroup_name,
) node_id=node_id,
else:
logger.info(
f"Transcript correction disabled — saving raw transcript for call {call_id}"
) )
if corrected_segments: if corrected_segments:
# Raw stays as evidence; the corrected copy is what extraction reads. # Raw stays as evidence; the corrected copy is what extraction reads.
@@ -241,7 +559,7 @@ async def transcribe_call(
try: try:
await fstore.doc_set("calls", call_id, updates) await fstore.doc_set("calls", call_id, updates)
logger.info( logger.info(
f"Transcript saved for call {call_id} " f"Transcript saved for call {call_id} via {used.provider}/{used.model} "
f"({len(transcript)} chars, {len(segments)} segment(s)" f"({len(transcript)} chars, {len(segments)} segment(s)"
f"{', corrected' if corrected and not not_speech else ''})" f"{', corrected' if corrected and not not_speech else ''})"
) )
@@ -259,27 +577,10 @@ async def transcribe_call(
return transcript, segments return transcript, segments
def _sync_transcribe( def _download(gcs_uri: str) -> str:
gcs_uri: str, """Download the recording to a temp file once; the caller deletes it."""
talkgroup_name: Optional[str] = None,
) -> tuple[Optional[str], list[dict], bool]:
"""Download audio from GCS and transcribe with OpenAI Whisper.
Third element is True when output was DISCARDED as degenerate, which the
caller distinguishes from ordinary silence so it can retry — the same clip
can hallucinate on one attempt and transcribe on the next.
"""
from google.cloud import storage as gcs from google.cloud import storage as gcs
from google.oauth2 import service_account from google.oauth2 import service_account
from app.config import settings
from app.internal.ai_clients import openai_client
if not settings.openai_api_key:
logger.warning("OPENAI_API_KEY not set — transcription disabled.")
# Tuple, not a bare None: the caller unpacks two values, so returning
# None here raised a TypeError that surfaced as a misleading
# "Transcription failed" instead of the real missing-key warning.
return None, [], False
without_scheme = gcs_uri[len("gs://"):] without_scheme = gcs_uri[len("gs://"):]
bucket_name, blob_path = without_scheme.split("/", 1) bucket_name, blob_path = without_scheme.split("/", 1)
@@ -293,34 +594,56 @@ def _sync_transcribe(
else: else:
gcs_client = gcs.Client() gcs_client = gcs.Client()
bucket = gcs_client.bucket(bucket_name) blob = gcs_client.bucket(bucket_name).blob(blob_path)
blob = bucket.blob(blob_path)
suffix = os.path.splitext(blob_path)[1] or ".mp3" suffix = os.path.splitext(blob_path)[1] or ".mp3"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
blob.download_to_filename(tmp_path) blob.download_to_filename(tmp_path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
return tmp_path
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
# Vocabulary is intentionally excluded from the Whisper prompt.
# whisper-1 treats the prompt as a transcription prior and echoes
# vocabulary terms into noise/silence, polluting downstream extraction.
# Vocabulary context is applied in the GPT extraction step instead,
# where it is used as reference rather than a transcription prior.
prompt = tg_prefix + _WHISPER_PROMPT
# Only whisper-1 supports verbose_json (per-segment timestamps + no_speech_prob). def _segment_confidence(raw_segments: list) -> Optional[float]:
# gpt-4o-transcribe and gpt-4o-mini-transcribe only support json/text. """exp(avg_logprob) per segment, weighted by segment length; None if absent."""
use_verbose = settings.stt_model == "whisper-1" total, weight = 0.0, 0.0
for s in raw_segments:
lp = getattr(s, "avg_logprob", None)
if lp is None:
continue
w = max(0.01, float(getattr(s, "end", 0) or 0) - float(getattr(s, "start", 0) or 0))
total += math.exp(float(lp)) * w
weight += w
return round(total / weight, 3) if weight else None
# Own timeout, not openai_timeout_seconds: this request uploads the
# whole recording (server-26#153). def _sync_transcribe_file(
stt_client = openai_client(timeout=settings.stt_timeout_seconds) tmp_path: str, target: SttTarget, prompt: str, glossary: Optional[list[str]] = None,
) -> dict:
"""Transcribe one downloaded file on one target.
Returns {"text", "segments", "degenerate", "duration", "confidence"}.
`degenerate` is True when output was DISCARDED as degenerate, which the
caller distinguishes from ordinary silence so it can retry — the same clip
can hallucinate on one attempt and transcribe on the next. Raises on any
API failure, so the caller can fall back to the next target.
"""
from app.internal.ai_clients import stt_client
# verbose_json (per-segment timestamps + no_speech_prob) is a Whisper
# feature: whisper-1 on OpenAI, every whisper-* model on Groq. OpenAI's
# gpt-4o-transcribe / gpt-4o-mini-transcribe only support json/text.
use_verbose = target.model.startswith("whisper")
client = stt_client(target.api_key, target.base_url)
with open(tmp_path, "rb") as f: with open(tmp_path, "rb") as f:
response = stt_client.audio.transcriptions.create( response = client.audio.transcriptions.create(
model=settings.stt_model, model=target.model,
file=f, file=f,
language="en", language="en",
prompt=prompt, prompt=prompt,
@@ -338,32 +661,49 @@ def _sync_transcribe(
# start before the first PTT press. Whisper sometimes hallucinates filler # start before the first PTT press. Whisper sometimes hallucinates filler
# words or codes over this silence. Detected via no_speech_prob > 0.8 # words or codes over this silence. Detected via no_speech_prob > 0.8
# (Whisper's own confidence that a segment contains no real speech). # (Whisper's own confidence that a segment contains no real speech).
audio_duration: float = getattr(response, "duration", None) or float("inf") duration = getattr(response, "duration", None)
audio_duration: float = duration or float("inf")
kept = [
s for s in (response.segments or [])
if (_seg(s, "text") or "").strip()
and (_seg(s, "start") or 0.0) < audio_duration
and (_seg(s, "no_speech_prob") or 0.0) < 0.8
]
segments = [ segments = [
{"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text.strip()} {"start": round(_seg(s, "start") or 0.0, 2), "end": round(_seg(s, "end") or 0.0, 2),
for s in (response.segments or []) "text": _seg(s, "text").strip()}
if s.text.strip() for s in kept
and s.start < audio_duration
and getattr(s, "no_speech_prob", 0.0) < 0.8
] ]
# Reconstruct text from non-hallucinated segments only so the two stay # Reconstruct text from non-hallucinated segments only so the two stay
# in sync. If every segment was filtered, text becomes None which prevents # in sync. If every segment was filtered, text becomes None which prevents
# the intelligence pipeline from running on hallucinated content. # the intelligence pipeline from running on hallucinated content.
text = " ".join(s["text"] for s in segments) or None text = " ".join(s["text"] for s in segments) or None
if _is_degenerate(text or "", segments): base = {"duration": duration, "confidence": _segment_confidence([_SegView(s) for s in kept])}
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}") if _is_degenerate(text or "", segments, glossary):
return None, [], True logger.info(f"Discarded hallucinated transcript from {target.provider}: {(text or '')[:80]!r}")
return text, segments, False return {"text": None, "segments": [], "degenerate": True, **base}
else: return {"text": text, "segments": segments, "degenerate": False, **base}
# json format returns just {"text": "..."} — no segments or timestamps. # json format returns just {"text": "..."} — no segments or timestamps.
# Intelligence extraction falls back to treating the whole transcript as one block. # Intelligence extraction falls back to treating the whole transcript as one block.
text = (response.text or "").strip() or None text = (response.text or "").strip() or None
if _is_degenerate(text or "", []): if _is_degenerate(text or "", [], glossary):
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}") logger.info(f"Discarded hallucinated transcript from {target.provider}: {(text or '')[:80]!r}")
return None, [], True return {"text": None, "segments": [], "degenerate": True, "duration": None, "confidence": None}
return text, [], False return {"text": text, "segments": [], "degenerate": False, "duration": None, "confidence": None}
finally:
try:
os.unlink(tmp_path) def _seg(s, field: str):
except OSError: """Segment field from an SDK object or a plain dict (OpenAI-compatible
pass servers do not all come back through the same pydantic model)."""
if isinstance(s, dict):
return s.get(field)
return getattr(s, field, None)
class _SegView:
"""Attribute view over a segment, for _segment_confidence."""
def __init__(self, s):
self.avg_logprob = _seg(s, "avg_logprob")
self.start = _seg(s, "start")
self.end = _seg(s, "end")
+2
View File
@@ -187,6 +187,7 @@ app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signe
# build stamp baked in by the Dockerfile, not configuration anyone sets or # build stamp baked in by the Dockerfile, not configuration anyone sets or
# tunes, and keeping it out of Settings avoids implying it can be changed. # tunes, and keeping it out of Settings avoids implying it can be changed.
_GIT_SHA = os.getenv("GIT_SHA", "unknown") _GIT_SHA = os.getenv("GIT_SHA", "unknown")
_VERSION = os.getenv("DRB_VERSION", "untagged")
@app.get("/health") @app.get("/health")
@@ -197,6 +198,7 @@ async def health():
# CI asserts this equals the commit it just deployed. Without it a # CI asserts this equals the commit it just deployed. Without it a
# deploy can "succeed" while the previous container is still serving. # deploy can "succeed" while the previous container is still serving.
"git_sha": _GIT_SHA, "git_sha": _GIT_SHA,
"version": _VERSION,
} }
+16 -18
View File
@@ -109,9 +109,9 @@ async def debug_correlation(
"scene_index": scene_index, "scene_index": scene_index,
"transcript": scene.get("transcript"), "transcript": scene.get("transcript"),
"incident_id": scene.get("incident_id"), "incident_id": scene.get("incident_id"),
# server-26#139: this scene's OWN incident_type/severity, as seen # server-26#139: this scene's OWN incident_type/severity at decision
# by _call_is_substanceless at decision time — not the call doc's # time — not the call doc's flat top-level field, which is
# flat top-level field, which is last-scene-wins (server-26#96). # last-scene-wins (server-26#96).
"incident_type": scene.get("incident_type"), "incident_type": scene.get("incident_type"),
"severity": scene.get("severity"), "severity": scene.get("severity"),
"corr_path": corr_debug.get("corr_path"), "corr_path": corr_debug.get("corr_path"),
@@ -126,7 +126,7 @@ async def debug_correlation(
"corr_llm_reasoning": corr_debug.get("corr_llm_reasoning"), "corr_llm_reasoning": corr_debug.get("corr_llm_reasoning"),
"corr_llm_action": corr_debug.get("corr_llm_action"), "corr_llm_action": corr_debug.get("corr_llm_action"),
"corr_rules_action": corr_debug.get("corr_rules_action"), "corr_rules_action": corr_debug.get("corr_rules_action"),
"corr_gate_veto": corr_debug.get("corr_gate_veto"), "corr_band": corr_debug.get("corr_band"),
} }
def _call_summary(call: dict) -> dict: def _call_summary(call: dict) -> dict:
@@ -173,20 +173,18 @@ async def debug_correlation(
"corr_matched_units": call.get("corr_matched_units"), "corr_matched_units": call.get("corr_matched_units"),
"corr_sweep_count": call.get("corr_sweep_count"), "corr_sweep_count": call.get("corr_sweep_count"),
"skip_reason": call.get("skip_reason"), "skip_reason": call.get("skip_reason"),
# LLM consensus tier fields — written by upload.py's # LLM tier fields — written by upload.py's _correlate /
# _correlate_with_consensus / llm_correlator.py, but previously # llm_correlator.py, but previously dropped here, making it
# dropped here, making it impossible to tell from this endpoint # impossible to tell from this endpoint whether the LLM
# whether the LLM correlation tier is actually running (server-26#24). # correlation tier is actually running (server-26#24).
"corr_consensus": call.get("corr_consensus"), "corr_consensus": call.get("corr_consensus"),
"corr_llm_reasoning": call.get("corr_llm_reasoning"), "corr_llm_reasoning": call.get("corr_llm_reasoning"),
"corr_llm_action": call.get("corr_llm_action"), "corr_llm_action": call.get("corr_llm_action"),
"corr_rules_action": call.get("corr_rules_action"), "corr_rules_action": call.get("corr_rules_action"),
# server-26#115 — why an llm=orphan/rules=new disagreement escalated # server-26#299 — why the rules marked this decision ambiguous and
# to tiebreak instead of being gated (see upload.py's # sent it to the LLM (incident_correlator._ambiguity). Absent on a
# _call_is_substanceless). Present only on that disagreement shape; # confident rules decision.
# written here specifically so a live measurement window can read "corr_band": call.get("corr_band"),
# the reason instead of reconstructing it by hand from the dump.
"corr_gate_veto": call.get("corr_gate_veto"),
# server-26#127 — shadow-mode upstream chatter classifier verdict. # server-26#127 — shadow-mode upstream chatter classifier verdict.
# Written by intelligence.extract_scenes on every transcript that # Written by intelligence.extract_scenes on every transcript that
# reaches real scene extraction (not on garbage/too-short skips). # reaches real scene extraction (not on garbage/too-short skips).
@@ -298,7 +296,8 @@ async def debug_correlation(
orphans_by_tg[tg_key]["count"] += 1 orphans_by_tg[tg_key]["count"] += 1
if not o.get("incident_type") and not o.get("tags"): if not o.get("incident_type") and not o.get("tags"):
orphans_by_tg[tg_key]["no_type_count"] += 1 orphans_by_tg[tg_key]["no_type_count"] += 1
if (o.get("corr_sweep_count") or 0) >= 3: # Tombstoned by the sweep's wall-clock budget (server-26#27).
if o.get("corr_path") == "unlinked":
orphans_by_tg[tg_key]["sweep_exhausted_count"] += 1 orphans_by_tg[tg_key]["sweep_exhausted_count"] += 1
# ── Summary ─────────────────────────────────────────────────────────────── # ── Summary ───────────────────────────────────────────────────────────────
@@ -374,9 +373,8 @@ async def debug_correlation(
"corr_fit_signal": _tally(e.get("corr_fit_signal") for e in scene_entries), "corr_fit_signal": _tally(e.get("corr_fit_signal") for e in scene_entries),
"corr_consensus": _tally(e.get("corr_consensus") for e in scene_entries), "corr_consensus": _tally(e.get("corr_consensus") for e in scene_entries),
"corr_llm_action": _tally(e.get("corr_llm_action") for e in scene_entries), "corr_llm_action": _tally(e.get("corr_llm_action") for e in scene_entries),
# server-26#115 — this IS the number the escape-hatch fix exists to # server-26#299 — how often, and why, the LLM tier was consulted.
# produce: why each llm=orphan/rules=new call escaped the gate. "corr_band": _tally(e.get("corr_band") for e in scene_entries),
"corr_gate_veto": _tally(e.get("corr_gate_veto") for e in scene_entries),
# server-26#127 — shadow-mode chatter classifier. The target # server-26#127 — shadow-mode chatter classifier. The target
# population is non-events, which land as orphans or single-call # population is non-events, which land as orphans or single-call
# incidents, NOT as a slice of every linked call -- tally `orphans` # incidents, NOT as a slice of every linked call -- tally `orphans`
+21
View File
@@ -28,6 +28,27 @@ async def list_alerts(
return await fstore.collection_list("alert_events", **filters) return await fstore.collection_list("alert_events", **filters)
@router.get("/alerts/held")
async def list_held_alerts(
status: Optional[str] = Query("held", description="held | released | superseded | rule_gone; empty = all"),
org_id: Optional[str] = Query(None, description="Platform-admin only — omit for every org."),
_: dict = Depends(require_admin_token),
):
"""
Rule matches the confidence gate kept from being pushed (server-26#300).
Admin-only: a held match is by definition one we do not trust enough to
show, so it does not belong on any customer surface (and alert_holds has
no Firestore rule, so the browser cannot read it directly either).
"""
filters = {}
if status:
filters["status"] = status
if org_id:
filters["org_id"] = org_id
holds = await fstore.collection_list("alert_holds", **filters)
return sorted(holds, key=lambda h: h.get("triggered_at") or "", reverse=True)
@router.post("/alerts/{alert_id}/acknowledge") @router.post("/alerts/{alert_id}/acknowledge")
async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)): async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
doc = await fstore.doc_get("alert_events", alert_id) doc = await fstore.doc_get("alert_events", alert_id)
+249 -239
View File
@@ -1,9 +1,11 @@
import secrets import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio from app.internal.storage import upload_audio
from app.internal import dedup from app.internal import dedup
from app.internal import radios
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import clock from app.internal import clock
from app.internal.logger import logger from app.internal.logger import logger
@@ -23,6 +25,7 @@ async def upload_call_audio(
talkgroup_id: Optional[int] = Form(None), talkgroup_id: Optional[int] = Form(None),
talkgroup_name: Optional[str] = Form(None), talkgroup_name: Optional[str] = Form(None),
system_id: Optional[str] = Form(None), system_id: Optional[str] = Form(None),
radios_json: Optional[str] = Form(None, alias="radios"),
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
): ):
""" """
@@ -66,6 +69,24 @@ async def upload_call_audio(
if len(data) > settings.upload_max_bytes: if len(data) > settings.upload_max_bytes:
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).") raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
# server-26#302: the edge node retries uploads (spooled for up to 72h), and
# a retry arrives whenever the node lost the response to an upload C2 had
# already stored. call_id is the idempotency key: audio already stored
# means this upload was already processed, so
# re-running Whisper/extraction/correlation would double the AI spend and
# double-link the call (the server-26#131 shape). Not atomic: two retries
# racing inside one request's lifetime can both pass this check.
# Keyed on stored audio only: the pipeline marker alone would refuse a
# retry forever (and hand the node a null URL to keep retrying) after a
# pipeline crash; such a call is recovered by the sweep or a reprocess.
existing = await fstore.doc_get("calls", call_id) or {}
if existing.get("audio_gcs_uri"):
logger.info(
f"Upload for call {call_id} from {node_id} is a retry of one already "
f"processed — returning the stored audio, pipeline not re-run"
)
return {"url": existing.get("audio_gcs_uri"), "already_uploaded": True}
gcs_uri = await upload_audio(data, file.filename or "", call_id=call_id) gcs_uri = await upload_audio(data, file.filename or "", call_id=call_id)
if gcs_uri: if gcs_uri:
@@ -82,6 +103,14 @@ async def upload_call_audio(
updates = {"audio_gcs_uri": gcs_uri} updates = {"audio_gcs_uri": gcs_uri}
if node and node.get("org_id"): if node and node.get("org_id"):
updates["org_id"] = node["org_id"] updates["org_id"] = node["org_id"]
# Every radio heard on the call (server-26#304). Written here, not
# only on call_end, because the pipeline below starts before the
# node publishes call_end.
radio_fields = radios.call_fields(radios_json)
updates.update(radio_fields)
if radio_fields and existing.get("srcaddr") is None:
# Same meaning call_end gives it: the latest talker.
updates["srcaddr"] = radio_fields["radios"][-1]["radio"]
await fstore.doc_set("calls", call_id, updates) await fstore.doc_set("calls", call_id, updates)
except Exception as e: except Exception as e:
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}") logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
@@ -112,99 +141,56 @@ async def upload_call_audio(
return {"url": gcs_uri} return {"url": gcs_uri}
# server-26#115 — the consensus LLM-orphan gate only fires when the call is # New-event signals: an LLM "orphan" may not drop a call carrying one when the
# genuinely substanceless. The earlier version tested `rules_decision["corr_debug"]` # rules would open an incident for it (server-26#299). A missed real event is
# for a "positive signal", but corr_debug is EMPTY at preview time for # worse than one extra incident, and the sweep is link-only, so a dropped event
# action=="new" (corr_path:"new" is written at APPLY time), so that test was # never comes back. These are the old #115 gate's vetoes, which existed because
# always False and the gate dropped real events — a major "extinguishing fire", # a looser gate dropped typed and geocoded fire calls.
# geocoded calls, pursuit updates. The substance test now runs against `ctx`, _LLM_ORPHAN_VETO_SEVERITIES = ("moderate", "major")
# which is fully populated at preview time.
def _recent_incident_on_same_talkgroup(ctx: dict) -> bool: def _llm_orphan_vetoed(ctx: dict) -> bool:
from app.internal.incident_correlator import has_event_substance
return bool(
ctx.get("reassignment") or ctx.get("incident_type")
or ctx.get("call_severity") in _LLM_ORPHAN_VETO_SEVERITIES
or has_event_substance(ctx)
)
def _merge_llm_decision(rules: dict, llm: dict, ctx: dict) -> tuple[dict, str]:
""" """
True when a recent incident is running on this call's own system + The decision to commit for an ambiguous scene, and the corr_consensus
talkgroup, within `settings.tg_dispatch_thin_idle_minutes` (5 min) — value that says who made it: "llm", or "rules_guard" when the LLM's
applied uniformly regardless of the talkgroup's name (server-26#134). orphan was refused for a call with a strong new-event signal.
Covers "unit dispatched, thin ack 10-30s later": the ack has no
substance of its own but plainly belongs to the job just opened.
Reads ctx["recent"] (the rules engine's own candidate list — no extra
Firestore read). That list is status=="active" incidents only, so an
already-resolved or capacity-capped same-talkgroup incident won't be
seen here even if chronologically recent (server-26#115, unresolved —
would need a dedicated non-status-filtered query).
Whether this limitation explains the 2/24 unexplained gate misses in the
window #3 measurement is UNANSWERED, not confirmed either way — a prior
pass here claimed a "confirmed explanation" for both that turned out to
be self-contradictory. Read `corr_gate_veto` (written to corr_debug on
every escalation of this exact disagreement shape — see the caller) in
the next measurement window instead of guessing from the raw dump again.
# TODO(server-26#115): add a talkgroup-scoped incident lookup (any
# status, no capacity filter) if a future measurement window pins a real
# gate miss on a resolved/capped same-talkgroup incident.
""" """
from app.internal.incident_correlator import _idle_gate_minutes if llm["action"] == "orphan" and rules["action"] == "new" and _llm_orphan_vetoed(ctx):
return rules, "rules_guard"
tg_id = ctx.get("talkgroup_id") if (
system_id = ctx.get("system_id") llm["action"] == rules["action"] == "link"
if tg_id is None or not system_id: and llm["matched_incident"]["incident_id"] == rules["matched_incident"]["incident_id"]
return False ):
tg_str = str(tg_id) # The LLM confirmed the rules' own link: keep the rules' path/fit
now = ctx.get("now") or clock.now() # signal so corr_path histograms stay comparable across runs.
idle_limit = settings.tg_dispatch_thin_idle_minutes return rules, "llm"
for inc in ctx.get("recent") or []: final = {
if system_id not in (inc.get("system_ids") or []): "action": llm["action"],
continue "matched_incident": llm["matched_incident"],
if tg_str not in (inc.get("talkgroup_ids") or []): "incident_type": llm["incident_type"],
continue "corr_debug": dict(llm.get("corr_debug") or {}),
if _idle_gate_minutes(inc, now) <= idle_limit: }
return True if llm["action"] == "link":
return False # The rules' corr_path/fit signal described a different judgement
# (possibly a different incident); keep it under corr_rules_* only.
final["corr_debug"]["corr_path"] = "llm"
elif llm["action"] == "orphan" and rules["action"] == "link":
# Tombstone: the rules-only sweep would otherwise re-commit the very
# unverified unit link the LLM just refused.
final["corr_debug"]["corr_path"] = "llm_orphan"
return final, "llm"
def _call_is_substanceless(ctx: dict) -> tuple[bool, Optional[str]]: async def _correlate(
"""
True when the call carries nothing that marks it as a real event:
• no resolved incident_type and not a reassignment, AND
• severity is not moderate/major, AND
• no vehicle, geocode or tag (incident_correlator.has_event_substance —
the same predicate the incident-creation gate uses), AND
• no recent incident already running on the same talkgroup.
Only then may the LLM-orphan gate drop the call without a tiebreak.
Returns (substanceless, veto_reason). veto_reason names whichever
condition kept the tiebreak alive ("type" | "reassignment" | "severity" |
"substance" | "recent_tg"), or None when the call is substanceless. The
caller writes this into corr_debug on the escalation path so a live
measurement window can see *why* each llm=orphan/rules=new call escaped
the gate instead of inferring it after the fact from the raw dump —
exactly the guesswork that produced a wrong "confirmed explanation" for
2 window-#3 misses on the first pass of this fix.
"""
from app.internal import incident_correlator
# The incident-creation gate skips the has_event_substance check entirely
# when a type resolved (incident_correlator._run_decision ~:1397), so a
# typed call — fire/medical/etc. — opens an incident on substance we do not
# re-check here. reassignment=True is dispatch pulling a unit onto a NEW
# job (units are blanked at :296 for exactly that reason): the strongest
# new-incident signal in the pipeline. Either one means "keep the tiebreak".
if ctx.get("incident_type"):
return False, "type"
if ctx.get("reassignment"):
return False, "reassignment"
if (ctx.get("call_severity") or "routine") in ("moderate", "major"):
return False, "severity"
if incident_correlator.has_event_substance(ctx):
return False, "substance"
if _recent_incident_on_same_talkgroup(ctx):
return False, "recent_tg"
return True, None
async def _correlate_with_consensus(
call_id: str, call_id: str,
node_id: str, node_id: str,
system_id: Optional[str], system_id: Optional[str],
@@ -222,19 +208,22 @@ async def _correlate_with_consensus(
severity: Optional[str] = None, severity: Optional[str] = None,
transcript: Optional[str] = None, transcript: Optional[str] = None,
scene_index: int = 0, scene_index: int = 0,
) -> Optional[str]: ) -> tuple[Optional[str], dict]:
""" """
Consensus correlator: runs the rules engine and the cheap LLM in sequence. Correlate one scene (server-26#299): the rules engine decides; only when
If they agree the rules decision is committed directly. it marks its decision ambiguous is ONE cheap LLM call made, and a usable
If they disagree a smarter tiebreaker LLM makes the final call. LLM answer is committed instead. Returns (incident_id, committed decision).
Falls back to rules-only when GEMINI_API_KEY is absent, the call is corr_consensus on the call doc says who decided:
content-free (thin), or any LLM call fails. "rules_only" — confident rules decision, no LLM call
"llm" — ambiguous, the LLM's answer was committed
"rules_fallback" — ambiguous, LLM unavailable/failed/unusable → rules
"rules_guard" — ambiguous, LLM said orphan on a call with a strong
new-event signal → rules "new" kept
and corr_band names why it was ambiguous.
``scene_index`` (server-26#96) — which scene of the call this is, from the ``scene_index`` (server-26#96) — which scene of the call this is, so the
caller's ``enumerate(scenes)`` loop. Threaded through so the call doc's call doc's per-scene ``scenes`` map records this scene's own decision.
per-scene ``scenes`` map records this scene's own corr_debug/transcript
instead of colliding with every other scene's write on the flat fields.
""" """
from app.internal import incident_correlator, llm_correlator from app.internal import incident_correlator, llm_correlator
@@ -248,67 +237,118 @@ async def _correlate_with_consensus(
scene_index=scene_index, scene_index=scene_index,
) )
ctx = preview["ctx"] ctx = preview["ctx"]
rules_decision = preview["decision"] rules = preview["decision"]
band = rules.get("ambiguous")
llm_decision = await llm_correlator.decide(call_id, ctx) if not band:
rules["corr_debug"]["corr_consensus"] = "rules_only"
return await incident_correlator.apply_correlation(preview), rules
if llm_decision is None: llm = await llm_correlator.decide(call_id, ctx)
# LLM unavailable, skipped (thin call), or errored — rules wins. if llm is None:
rules_decision["corr_debug"]["corr_consensus"] = "rules_only" final, consensus = rules, "rules_fallback"
return await incident_correlator.apply_correlation(preview) else:
final, consensus = _merge_llm_decision(rules, llm, ctx)
if llm_correlator.decisions_agree(rules_decision, llm_decision): final["corr_debug"].update({
rules_decision["corr_debug"]["corr_consensus"] = "agreed" "corr_consensus": consensus,
rules_decision["corr_debug"]["corr_llm_reasoning"] = llm_decision.get("reasoning", "") "corr_band": band,
return await incident_correlator.apply_correlation(preview) "corr_rules_action": rules["action"],
# server-26#115 — LLM-orphan gate.
# When the cheap LLM says `orphan`, the rules engine says `new`, and the call
# is genuinely substanceless (routine severity, no vehicle/geocode/tag, and
# no incident already running on this talkgroup), resolve to `orphan` and DO
# NOT pay for the smart tiebreaker. A bare rules `new` there means only
# "nothing to link to" — trivially true for radio housekeeping (check-ins,
# roll call, 10-8/10-98) — and the tiebreaker rubber-stamped it ~21/21 of the
# time on exactly this disagreement (CORRELATION_REVIEW_0907b.md). Any real
# signal (severity, coords, tags, a live same-talkgroup incident) still
# escalates, so an event the LLM misreads as orphan is not lost.
is_orphan_vs_new = llm_decision["action"] == "orphan" and rules_decision["action"] == "new"
substanceless, gate_veto_reason = _call_is_substanceless(ctx) if is_orphan_vs_new else (False, None)
if is_orphan_vs_new and substanceless:
logger.info(
f"Consensus gate for call {call_id}: llm=orphan vs rules=new and call "
f"is substanceless — resolving orphan, skipping tiebreak"
)
gated = {
"action": "orphan",
"matched_incident": None,
"incident_type": None,
"corr_debug": dict(rules_decision.get("corr_debug") or {}),
}
gated["corr_debug"].update({
"corr_consensus": "llm_orphan_gate",
"corr_rules_action": rules_decision["action"],
"corr_llm_action": llm_decision["action"],
"corr_llm_reasoning": llm_decision.get("reasoning", ""),
}) })
return await incident_correlator.apply_correlation({"decision": gated, "ctx": ctx}) if llm is not None:
final["corr_debug"]["corr_llm_action"] = llm["action"]
# Disagree — escalate to the smarter tiebreaker. final["corr_debug"]["corr_llm_reasoning"] = llm.get("reasoning", "")
logger.info( logger.info(
f"Consensus disagreement for call {call_id}: " f"Correlation for call {call_id} scene {scene_index}: ambiguous ({band}) — "
f"rules={rules_decision['action']} vs llm={llm_decision['action']} — tiebreak" f"rules={rules['action']} llm={llm['action'] if llm else 'none'} → {consensus}"
) )
final = await llm_correlator.tiebreak(rules_decision, llm_decision, ctx) return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx}), final
final["corr_debug"]["corr_consensus"] = "tiebreak"
final["corr_debug"]["corr_rules_action"] = rules_decision["action"]
final["corr_debug"]["corr_llm_action"] = llm_decision["action"] def _may_resolve(decision: dict) -> bool:
if is_orphan_vs_new: """
# server-26#115 — record *why* the llm=orphan/rules=new gate stood server-26#88: honour a scene's `resolved` flag only on a link the
# down instead of leaving a future measurement window to guess it correlator was confident about, or on the incident this very scene opened.
# from the raw dump (which produced a wrong "confirmed explanation" A scene that attached by thin recency, or whose placement was ambiguous
# for 2/24 misses the first time around). (an LLM pick, or a rules fallback in the ambiguous band), has no business
final["corr_debug"]["corr_gate_veto"] = gate_veto_reason closing an incident it may not belong to — that closed live incidents
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx}) while the event was still on the air.
"""
if decision.get("action") == "new":
return True
debug = decision.get("corr_debug") or {}
return (
decision.get("action") == "link"
and debug.get("corr_consensus") == "rules_only"
and debug.get("corr_path") != "fast/thin"
)
async def _correlate_scenes(
call_id: str,
node_id: str,
system_id: Optional[str],
talkgroup_id: Optional[int],
talkgroup_name: Optional[str],
scenes: list[dict],
) -> tuple[list[str], list[str]]:
"""
Correlate each scene of one call independently — a single recording can
carry several events on a busy channel — and apply each scene's LLM
closure. Returns (incident_ids, merged tags). Shared by the live pipeline,
replay (via _extract_and_correlate) and the transcript-PATCH path.
"""
from app.internal import incident_correlator
incident_ids: list[str] = []
all_tags: list[str] = []
for scene_index, scene in enumerate(scenes):
all_tags.extend(scene["tags"])
# When dispatch is pulling a unit to a NEW call (reassignment), suppress
# unit overlap so the new scene doesn't chain into the unit's previous
# incident. parse_llm_bool (#88): a quoted "false" is not True.
is_reassignment = incident_correlator.parse_llm_bool(scene.get("reassignment"))
corr_units = [] if is_reassignment else scene.get("units")
incident_id, decision = await _correlate(
call_id=call_id,
node_id=node_id,
system_id=system_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
tags=scene["tags"],
incident_type=scene["incident_type"],
location=scene["location"],
location_coords=scene["location_coords"],
units=corr_units,
vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
transcript=scene.get("transcript"),
scene_index=scene_index,
)
if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id)
if incident_id and incident_correlator.parse_llm_bool(scene.get("resolved")):
if not _may_resolve(decision):
logger.info(
f"Ignoring closure from call {call_id} scene {scene_index}: link into "
f"{incident_id} was not confident (server-26#88)"
)
continue
await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": clock.now().isoformat(),
"resolved_via": "llm_closure",
# One transmission read as "it's over" ("transport complete")
# closed the whole 09-22 bridge MVA at 14:44 and its next 56
# calls opened a second incident (server-26#170). Inferred from
# a single call, so provisional, like a timer close.
"reopenable": True,
})
await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
return incident_ids, all_tags
async def _resolve_flags(system_id: Optional[str]): async def _resolve_flags(system_id: Optional[str]):
@@ -335,7 +375,7 @@ async def _run_extraction_pipeline(
preserve_transcript_correction: bool = False, preserve_transcript_correction: bool = False,
) -> None: ) -> None:
"""Run steps 2-4 of the intelligence pipeline using an existing transcript.""" """Run steps 2-4 of the intelligence pipeline using an existing transcript."""
from app.internal import intelligence, incident_correlator, alerter from app.internal import intelligence, alerter
flags, _flag = await _resolve_flags(system_id) flags, _flag = await _resolve_flags(system_id)
@@ -353,45 +393,9 @@ async def _run_extraction_pipeline(
) )
# Step 3: Correlate each scene to an incident independently. # Step 3: Correlate each scene to an incident independently.
# server-26#96: scene_index is threaded through so each scene's incident_ids, all_tags = await _correlate_scenes(
# corr_debug/transcript lands in its own entry of the call doc's call_id, node_id, system_id, talkgroup_id, talkgroup_name, scenes,
# `scenes` map instead of clobbering every other scene's write.
for scene_index, scene in enumerate(scenes):
all_tags.extend(scene["tags"])
# When dispatch is pulling a unit to a NEW call (reassignment), suppress unit
# overlap so the new scene doesn't chain into the unit's previous incident.
is_reassignment = bool(scene.get("reassignment"))
corr_units = [] if is_reassignment else scene.get("units")
incident_id = await _correlate_with_consensus(
call_id=call_id,
node_id=node_id,
system_id=system_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
tags=scene["tags"],
incident_type=scene["incident_type"],
location=scene["location"],
location_coords=scene["location_coords"],
units=corr_units,
vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
transcript=scene.get("transcript"),
scene_index=scene_index,
) )
if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id)
if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": clock.now().isoformat(),
"resolved_via": "llm_closure",
"reopenable": True, # provisional, see _extract_and_correlate
})
await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
else: else:
scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}" scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}"
logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id} (reprocess)") logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id} (reprocess)")
@@ -430,7 +434,7 @@ async def _extract_and_correlate(
copy of it that can drift. Caller owns the correlation feature-flag check copy of it that can drift. Caller owns the correlation feature-flag check
and alerting. and alerting.
""" """
from app.internal import intelligence, incident_correlator from app.internal import intelligence
# Step 2: Scene detection + intelligence extraction # Step 2: Scene detection + intelligence extraction
if scenes is None: if scenes is None:
@@ -443,50 +447,9 @@ async def _extract_and_correlate(
) )
# Step 3: Correlate each scene independently. # Step 3: Correlate each scene independently.
# A single recording can produce multiple incidents on a busy channel. incident_ids, all_tags = await _correlate_scenes(
incident_ids: list[str] = [] call_id, node_id, system_id, talkgroup_id, talkgroup_name, scenes,
all_tags: list[str] = []
# server-26#96: scene_index is threaded through so each scene's
# corr_debug/transcript lands in its own entry of the call doc's
# `scenes` map instead of clobbering every other scene's write.
for scene_index, scene in enumerate(scenes):
all_tags.extend(scene["tags"])
is_reassignment = bool(scene.get("reassignment"))
corr_units = [] if is_reassignment else scene.get("units")
incident_id = await _correlate_with_consensus(
call_id=call_id,
node_id=node_id,
system_id=system_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
tags=scene["tags"],
incident_type=scene["incident_type"],
location=scene["location"],
location_coords=scene["location_coords"],
units=corr_units,
vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
transcript=scene.get("transcript"),
scene_index=scene_index,
) )
if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id)
if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": clock.now().isoformat(),
"resolved_via": "llm_closure",
# One transmission read as "it's over" ("transport complete")
# closed the whole 09-22 bridge MVA at 14:44 and its next 56
# calls opened a second incident (server-26#170). Inferred from
# a single call, so provisional, like a timer close.
"reopenable": True,
})
await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
# Correlator also runs for calls with no scenes (unclassified) to attempt # Correlator also runs for calls with no scenes (unclassified) to attempt
# talkgroup-based linking even when no transcript could be produced. # talkgroup-based linking even when no transcript could be produced.
@@ -501,8 +464,11 @@ async def _extract_and_correlate(
if not scenes: if not scenes:
_call_doc = await fstore.doc_get("calls", call_id) _call_doc = await fstore.doc_get("calls", call_id)
skip_reason = (_call_doc or {}).get("skip_reason") skip_reason = (_call_doc or {}).get("skip_reason")
if not skip_reason or skip_reason == "transcript_too_short": # non_event_chatter (server-26#298) is the same population as
incident_id = await _correlate_with_consensus( # transcript_too_short, just longer: housekeeping that skipped the
# paid extraction but may still be a same-talkgroup follow-up.
if not skip_reason or skip_reason in ("transcript_too_short", "non_event_chatter"):
incident_id, _ = await _correlate(
call_id=call_id, call_id=call_id,
node_id=node_id, node_id=node_id,
system_id=system_id, system_id=system_id,
@@ -512,6 +478,11 @@ async def _extract_and_correlate(
incident_type=None, incident_type=None,
location=None, location=None,
location_coords=None, location_coords=None,
# server-26#104: the call doc's own severity, as
# recorrelation_sweep passes it. None resolved to "routine",
# which turned a reprocessed not-thin call into a thin one
# that attaches with no fit check.
severity=(_call_doc or {}).get("severity"),
) )
if incident_id: if incident_id:
incident_ids.append(incident_id) incident_ids.append(incident_id)
@@ -522,6 +493,33 @@ async def _extract_and_correlate(
return incident_ids, all_tags, scenes return incident_ids, all_tags, scenes
def _late_upload_anchor(call_doc: Optional[dict]) -> Optional[datetime]:
"""
The call's own started_at when its upload is late (server-26#301), else
None. "Late" is `late_upload_anchor_minutes` behind the real clock: an
on-time call keeps being judged against now exactly as before, so the live
path's recency windows are unchanged; only a spooled/retried upload is
back-dated. A started_at in the future or unparseable is ignored.
"""
raw = (call_doc or {}).get("started_at")
if not raw:
return None
if isinstance(raw, datetime): # mqtt_handler stores a native timestamp
started = raw
else:
try:
started = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except ValueError:
return None
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
lag = clock.now() - started
if lag > timedelta(minutes=settings.late_upload_anchor_minutes):
logger.info(f"Late upload: call started {lag} ago — correlating as of {started.isoformat()}")
return started
return None
async def _run_intelligence_pipeline( async def _run_intelligence_pipeline(
call_id: str, call_id: str,
node_id: str, node_id: str,
@@ -586,7 +584,7 @@ async def _run_intelligence_pipeline(
if _flag("stt_enabled"): if _flag("stt_enabled"):
transcript, segments = await transcription.transcribe_call( transcript, segments = await transcription.transcribe_call(
call_id, gcs_uri, talkgroup_name, call_id, gcs_uri, talkgroup_name,
system_id=system_id, talkgroup_id=talkgroup_id, system_id=system_id, talkgroup_id=talkgroup_id, node_id=node_id,
) )
else: else:
scope = "globally" if not flags["stt_enabled"] else f"system {system_id}" scope = "globally" if not flags["stt_enabled"] else f"system {system_id}"
@@ -596,6 +594,15 @@ async def _run_intelligence_pipeline(
incident_ids: list[str] = [] incident_ids: list[str] = []
all_tags: list[str] = [] all_tags: list[str] = []
if _flag("correlation_enabled"): if _flag("correlation_enabled"):
# server-26#301: a late upload (the node spools and retries for up to
# 72h) is correlated as of when the call happened, not as of now — the
# same clock pin replay uses, so every recency gate and every
# started_at/updated_at/resolved_at it writes is on the call's own
# timeline. Without it a call hours late linked into whatever incident
# was live on its talkgroup now, or opened one at the wrong time.
anchor = _late_upload_anchor(_call_doc)
tok = clock.pin(anchor) if anchor else None
try:
incident_ids, all_tags, _ = await _extract_and_correlate( incident_ids, all_tags, _ = await _extract_and_correlate(
call_id=call_id, call_id=call_id,
node_id=node_id, node_id=node_id,
@@ -605,6 +612,9 @@ async def _run_intelligence_pipeline(
transcript=transcript, transcript=transcript,
segments=segments, segments=segments,
) )
finally:
if tok is not None:
clock.unpin(tok)
else: else:
scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}" scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}"
logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id}") logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id}")
+10
View File
@@ -96,3 +96,13 @@ def _no_real_discord(monkeypatch):
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(_offline)) monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(_offline))
yield yield
discord_rest.reset() discord_rest.reset()
@pytest.fixture(autouse=True)
def _fresh_geocode_cache():
"""The geocode cache (server-26#298) is process-wide; a test that stubs
Maps must never be answered by a previous test's stub."""
from app.internal import intelligence
intelligence.clear_geocode_cache()
yield
intelligence.clear_geocode_cache()
+9 -3
View File
@@ -100,7 +100,7 @@ async def _run_ingest(global_correlation, system_ai_flags):
) )
with g, sysdoc, \ with g, sysdoc, \
patch.object(upload, "fstore") as fs, \ patch.object(upload, "fstore") as fs, \
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr, \ patch.object(upload, "_correlate", AsyncMock(return_value=(None, {}))) as corr, \
patch("app.internal.transcription.transcribe_call", patch("app.internal.transcription.transcribe_call",
AsyncMock(return_value=("units respond to main street", []))), \ AsyncMock(return_value=("units respond to main street", []))), \
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])) as scenes, \ patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])) as scenes, \
@@ -219,15 +219,21 @@ async def test_transcript_correction_is_skipped_when_its_flag_is_off():
patch.object(transcription, "fstore") as fs, \ patch.object(transcription, "fstore") as fs, \
patch.object(transcription, "ai_health") as health, \ patch.object(transcription, "ai_health") as health, \
patch.object(transcription, "transcript_correction") as tc, \ patch.object(transcription, "transcript_correction") as tc, \
patch("asyncio.to_thread", AsyncMock(return_value=("units respond", [], False))): patch.object(transcription.settings, "openai_api_key", "k"), \
patch.object(transcription.settings, "groq_api_key", None), \
patch.object(transcription, "_download", lambda uri: "/nonexistent.mp3"), \
patch.object(transcription, "_sync_transcribe_file", lambda *a, **k: {
"text": "units respond to the fire on Main Street", "segments": [],
"degenerate": False, "duration": 3.0, "confidence": None}):
fs.doc_set = AsyncMock() fs.doc_set = AsyncMock()
health.report_healthy = AsyncMock() health.report_healthy = AsyncMock()
health.report_failure = AsyncMock() health.report_failure = AsyncMock()
tc.correct = AsyncMock() tc.correct = AsyncMock()
await transcription.transcribe_call( text, _ = await transcription.transcribe_call(
"call-1", "gs://bucket/call-1.mp3", "PD Dispatch", system_id="sys-1" "call-1", "gs://bucket/call-1.mp3", "PD Dispatch", system_id="sys-1"
) )
assert text == "units respond to the fire on Main Street", "the transcript must still be produced"
tc.correct.assert_not_awaited() tc.correct.assert_not_awaited()
+3 -3
View File
@@ -136,15 +136,15 @@ async def test_transient_failure_alerts_once_threshold_crossed():
async def test_recovery_alerts_once_after_an_alerted_episode(): async def test_recovery_alerts_once_after_an_alerted_episode():
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook: with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
await ai_health.report_degraded( await ai_health.report_degraded(
"correlation_smart", "gemini", "gemini-1.5-pro", "correlation_cheap", "gemini", "gemini-3.6-flash",
"the Gemini account is out of credit", "top up billing", permanent=True, "the Gemini account is out of credit", "top up billing", permanent=True,
) )
webhook.reset_mock() webhook.reset_mock()
await ai_health.report_healthy("correlation_smart") await ai_health.report_healthy("correlation_cheap")
webhook.assert_awaited_once() webhook.assert_awaited_once()
state = ai_health.snapshot()["correlation_smart"] state = ai_health.snapshot()["correlation_cheap"]
assert state["degraded"] is False assert state["degraded"] is False
assert state["alerted"] is False assert state["alerted"] is False
assert state["consecutive_failures"] == 0 assert state["consecutive_failures"] == 0
+9 -20
View File
@@ -153,8 +153,10 @@ def test_timeout_messages_classify_as_transient():
def _corr_ctx(): def _corr_ctx():
from datetime import datetime, timezone
return {"call_id": "c1", "is_thin_call": False, "recent": [{"incident_id": "i1"}], return {"call_id": "c1", "is_thin_call": False, "recent": [{"incident_id": "i1"}],
"all_active": [], "call_units": ["12"], "call_cleared": [], "tags": ["mva"]} "all_active": [], "call_units": ["12"], "call_cleared": [], "tags": ["mva"],
"now": datetime.now(timezone.utc)}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -162,35 +164,22 @@ async def test_gemini_timeout_in_decide_falls_back_to_rules():
def hang(model, prompt): def hang(model, prompt):
raise httpx.ReadTimeout("The read operation timed out") raise httpx.ReadTimeout("The read operation timed out")
with patch.object(settings, "gemini_api_key", "test-key"), \ with patch.object(settings, "openai_api_key", "test-key"), \
patch.object(llm_correlator, "_build_decide_prompt", lambda ctx: "p"), \ patch.object(llm_correlator, "_build_decide_prompt", lambda ctx, cands: "p"), \
patch.object(llm_correlator, "_sync_gemini", hang): patch.object(llm_correlator, "_sync_gemini", hang):
assert await llm_correlator.decide("c1", _corr_ctx()) is None # None == rules_only assert await llm_correlator.decide("c1", _corr_ctx()) is None # None == keep the rules decision
state = ai_health.snapshot()["correlation_cheap"] state = ai_health.snapshot()["correlation_cheap"]
assert state["consecutive_failures"] == 1 assert state["consecutive_failures"] == 1
assert state["permanent"] is False assert state["permanent"] is False
@pytest.mark.asyncio
async def test_gemini_timeout_in_tiebreak_keeps_the_rules_decision():
def hang(model, prompt):
raise httpx.ReadTimeout("The read operation timed out")
rules = {"action": "new", "matched_incident": None, "corr_debug": {}}
with patch.object(llm_correlator, "_build_tiebreak_prompt", lambda r, l, c: "p"), \
patch.object(llm_correlator, "_sync_gemini", hang):
out = await llm_correlator.tiebreak(rules, {"action": "orphan"}, _corr_ctx())
assert out is rules
assert ai_health.snapshot()["correlation_smart"]["permanent"] is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_gemini_timeout_in_transcript_correction_keeps_raw_text(): async def test_gemini_timeout_in_transcript_correction_keeps_raw_text():
def hang(model, prompt): def hang(model, prompt):
raise httpx.ReadTimeout("The read operation timed out") raise httpx.ReadTimeout("The read operation timed out")
text = "engine 5 respond to a structure fire at 12 main street smoke showing" text = "engine 5 respond to a structure fire at 12 main street smoke showing"
with patch.object(settings, "gemini_api_key", "test-key"), \ with patch.object(settings, "openai_api_key", "test-key"), \
patch.object(transcript_correction, "resolve_context", AsyncMock(return_value={})), \ patch.object(transcript_correction, "resolve_context", AsyncMock(return_value={})), \
patch.object(transcript_correction, "_sync_gemini", hang): patch.object(transcript_correction, "_sync_gemini", hang):
assert await transcript_correction.correct("c1", text) == (None, None, False) assert await transcript_correction.correct("c1", text) == (None, None, False)
@@ -233,8 +222,8 @@ def test_worst_case_bound_follows_the_settings():
patch.object(settings, "gemini_timeout_seconds", 20.0), \ patch.object(settings, "gemini_timeout_seconds", 20.0), \
patch.object(settings, "stt_retry_on_degenerate", True): patch.object(settings, "stt_retry_on_degenerate", True):
bound = recorrelation_sweep.pipeline_worst_case_seconds(n_scenes=2) bound = recorrelation_sweep.pipeline_worst_case_seconds(n_scenes=2)
# stt 2x2x100 + correction 20 + extraction 2x10 + embeds 2x2x10 + decide/tiebreak 2x20 + slack # stt 2x2x100 + correction 20 + extraction 2x10 + embeds 2x2x10 + one correlation call 20 + slack
assert bound == 400 + 20 + 20 + 40 + 40 + recorrelation_sweep._NON_AI_SLACK_SECONDS assert bound == 400 + 20 + 20 + 40 + 20 + recorrelation_sweep._NON_AI_SLACK_SECONDS
def test_hold_back_covers_the_worst_case_and_never_drops_below_old_floor(): def test_hold_back_covers_the_worst_case_and_never_drops_below_old_floor():
+168
View File
@@ -0,0 +1,168 @@
"""
server-26#300 — confidence gate before any push alert.
CrimeRadar pushed "shots fired at an elementary school" after STT heard "fire
alarm" as "firearm". A single uncorroborated transcript hit on a
high-severity term must be held, not pushed; a corroborated incident releases
it; low-confidence matches never push.
"""
import pytest
from unittest.mock import AsyncMock, patch
from app.internal import alerter
from tests.fake_firestore import FakeFirestore
def _ev(**over):
base = {"incident_ids": [], "max_calls": 0, "severity": None,
"stt_confidence": None, "not_speech": False}
base.update(over)
return base
# ── the pure decision ───────────────────────────────────────────────────────
def test_talkgroup_only_rule_always_pushes():
v = alerter._assess(["talkgroup:9048"], [], _ev(stt_confidence=0.1))
assert v["push"] and v["confidence"] == 1.0
def test_transcript_only_firearm_single_call_is_held():
v = alerter._assess(["firearm"], [], _ev())
assert not v["push"] and v["hold_reason"] == "needs_corroboration"
assert v["high_severity"] is True
def test_tagged_shots_fired_still_needs_corroboration():
v = alerter._assess(["shots-fired"], ["shots-fired"], _ev(max_calls=1))
assert not v["push"] and v["confidence"] == 0.8
def test_corroborated_high_severity_pushes():
v = alerter._assess(["shots-fired"], ["shots-fired"], _ev(max_calls=2))
assert v["push"]
def test_major_incident_makes_any_content_match_high_severity():
v = alerter._assess(["school"], [], _ev(severity="major"))
assert v["high_severity"] and not v["push"]
def test_ordinary_tag_match_pushes():
v = alerter._assess(["mva"], ["mva"], _ev(max_calls=1))
assert v["push"] and not v["high_severity"]
def test_low_stt_confidence_holds_even_ordinary_matches():
v = alerter._assess(["mva"], ["mva"], _ev(stt_confidence=0.1))
assert not v["push"] and v["hold_reason"] == "low_confidence"
assert v["confidence"] == round(0.8 * 0.1 / 0.3, 3)
def test_typical_radio_logprob_does_not_hold_a_transcript_match():
"""exp(avg_logprob) of ~0.4 is normal on vocoded audio (review of #300)."""
v = alerter._assess(["mva"], [], _ev(stt_confidence=0.4))
assert v["push"]
def test_not_speech_never_pushes_content():
v = alerter._assess(["mva"], ["mva"], _ev(not_speech=True))
assert not v["push"]
# ── end to end through Firestore ────────────────────────────────────────────
RULE = {"rule_id": "r1", "name": "Weapons", "enabled": True, "org_id": "org-1",
"keywords": ["firearm", "shots-fired"], "discord_webhook": "https://discord.example/wh"}
@pytest.fixture
def fs(monkeypatch):
fake = FakeFirestore.install(monkeypatch)
fake.put("alert_rules", "r1", dict(RULE))
return fake
def _posts():
posted = []
class _Client:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, json=None, **k):
posted.append(json)
return posted, patch("httpx.AsyncClient", lambda *a, **k: _Client())
async def _dispatch(call_id, tags, transcript):
await alerter.check_and_dispatch(call_id=call_id, node_id="n1", talkgroup_id=1,
talkgroup_name="PD Dispatch", tags=tags, transcript=transcript)
@pytest.mark.asyncio
async def test_crimeradar_case_is_held_then_released_by_corroboration(fs):
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1"], "severity": "moderate"})
posted, http = _posts()
with http:
await _dispatch("c1", [], "report of a firearm at the elementary school")
assert posted == [], "a single uncorroborated firearm hit must not push"
assert fs.data.get("alert_events", {}) == {}
(hold,) = fs.data["alert_holds"].values()
assert hold["status"] == "held" and hold["hold_reason"] == "needs_corroboration"
assert hold["incident_id"] == "inc-1"
# A second call joins the incident; it does not itself mention a firearm.
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"], "severity": "moderate"})
with http:
await _dispatch("c2", [], "units on scene, stand by")
assert len(posted) == 1 and "Weapons" in posted[0]["content"]
(event,) = fs.data["alert_events"].values()
assert event["released_from_hold"] is True and event["corroborated_by_call_id"] == "c2"
assert fs.get("alert_holds", hold["alert_id"])["status"] == "released"
@pytest.mark.asyncio
async def test_low_confidence_hold_is_not_released_by_corroboration(fs):
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"], "stt_confidence": 0.1})
fs.put("incidents", "inc-1", {"call_ids": ["c1"]})
posted, http = _posts()
with http:
await _dispatch("c1", [], "firearm")
(hold,) = fs.data["alert_holds"].values()
assert hold["hold_reason"] == "low_confidence"
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"]})
with http:
await _dispatch("c2", [], "units on scene")
assert posted == []
assert fs.get("alert_holds", hold["alert_id"])["status"] == "held"
@pytest.mark.asyncio
async def test_rule_pushed_now_supersedes_its_hold_instead_of_alerting_twice(fs):
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1"]})
posted, http = _posts()
with http:
await _dispatch("c1", ["shots-fired"], "shots fired")
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"]})
with http:
await _dispatch("c2", ["shots-fired"], "more shots fired")
assert len(posted) == 1
(hold,) = fs.data["alert_holds"].values()
assert hold["status"] == "superseded"
@pytest.mark.asyncio
async def test_held_alerts_are_listed_for_admins(fs):
from app.routers import alerts as alerts_router
fs.put("alert_holds", "h1", {"alert_id": "h1", "status": "held", "triggered_at": "2026-10-27T01:00:00"})
fs.put("alert_holds", "h2", {"alert_id": "h2", "status": "released", "triggered_at": "2026-10-27T02:00:00"})
out = await alerts_router.list_held_alerts(status="held", org_id=None, _={})
assert [h["alert_id"] for h in out] == ["h1"]
+194
View File
@@ -0,0 +1,194 @@
"""
server-26#298 — the chatter pre-filter gates every paid call.
Radio housekeeping (roll call, a bare "Post 4, 10-8, 10-4") used to go through
transcript correction, scene extraction, an embedding per scene and a geocode,
only to come back as a thin routine scene. Now it pays for none of them; the
free rule paths (clearance parse, self-initiated/plate backstop) still run.
"""
from unittest.mock import AsyncMock, patch
import pytest
from app.config import settings
from app.internal import intelligence, transcription
from app.internal.chatter_classifier import gate_chatter
@pytest.fixture(autouse=True)
def _gate_on():
"""The gate ships OFF (shadow) until a live window clears it (#127);
these tests exercise it switched on."""
with patch.object(settings, "chatter_gate_enabled", True):
yield
class _NoPaidCalls:
"""Fail the test if any paid extraction/embedding/geocode call happens."""
def __enter__(self):
def _boom(*a, **k):
raise AssertionError("a paid AI call was made for chatter")
async def _aboom(*a, **k):
raise AssertionError("a paid geocode was made for chatter")
self.ps = [
patch.object(intelligence, "_sync_extract", _boom),
patch.object(intelligence, "_sync_embed", _boom),
patch.object(intelligence, "_geocode_lookup", _aboom),
]
for p in self.ps:
p.start()
return self
def __exit__(self, *a):
for p in self.ps:
p.stop()
async def _extract(transcript, talkgroup_name="PD Dispatch"):
saved: dict = {}
async def _doc_set(coll, doc_id, data, merge=True):
saved.update(data)
with patch.object(intelligence.fstore, "doc_get_cached", AsyncMock(return_value=None)), \
patch.object(intelligence.fstore, "doc_set", _doc_set):
scenes = await intelligence.extract_scenes("call-1", transcript, talkgroup_name)
return scenes, saved
@pytest.mark.asyncio
async def test_bare_acknowledgement_over_five_words_skips_extraction():
with _NoPaidCalls():
scenes, saved = await _extract("10-4, 10-4 Central, 98. 10-4, 98.")
assert scenes == []
assert saved["skip_reason"] == "non_event_chatter"
assert saved["severity"] == "routine"
assert saved["chatter_classifier_verdict"] is True
@pytest.mark.asyncio
async def test_whole_roll_call_skips_extraction():
roll = ("Car 100, roll call. Henry 1. Henry 1. Sam 1. Sam 1. 45 Baker. 45 Baker. "
"11 Adam. Adam. 11 Baker. Baker. Staff 1. 1. Staff 2.")
with _NoPaidCalls():
scenes, saved = await _extract(roll)
assert scenes == [] and saved["skip_reason"] == "non_event_chatter"
@pytest.mark.asyncio
async def test_roll_call_mentioned_on_the_way_to_a_real_job_still_extracts():
"""The #127 review's false-positive shape must still reach the model."""
text = "Delayed for roll call, then respond to a structure fire at 12 Main Street"
assert gate_chatter(text) == (False, None)
with patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
await _extract(text)
ext.assert_called_once()
@pytest.mark.asyncio
async def test_plate_read_chatter_still_becomes_a_stop_for_free():
text = "Lincoln Charlie Robert 7-4-0-7, Lincoln Charlie Robert 7-4-0-7."
with _NoPaidCalls():
scenes, saved = await _extract(text)
assert len(scenes) == 1 and scenes[0]["tags"] == ["traffic-stop"]
assert saved["severity"] == "minor"
@pytest.mark.asyncio
async def test_gate_off_restores_shadow_mode():
with patch.object(settings, "chatter_gate_enabled", False), \
patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
_, saved = await _extract("10-4, 10-4 Central, 98. 10-4, 98.")
ext.assert_called_once()
assert saved["chatter_classifier_verdict"] is True, "the shadow verdict is still recorded"
@pytest.mark.asyncio
async def test_real_event_is_not_gated():
with patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
await _extract("Shots fired, officer down, requesting backup immediately at the school.")
ext.assert_called_once()
@pytest.mark.asyncio
async def test_transcription_skips_correction_for_chatter():
flags = {"transcript_correction_enabled": True}
with patch.object(settings, "openai_api_key", "k"), \
patch.object(settings, "groq_api_key", None), \
patch.object(transcription, "_download", lambda uri: "/nonexistent.flac"), \
patch.object(transcription, "_sync_transcribe_file", lambda *a, **k: {
"text": "Post 4, 10-8. 10-4. Copy, 98.", "segments": [],
"degenerate": False, "duration": 2.0, "confidence": None}), \
patch.object(transcription.fstore, "doc_set", AsyncMock()), \
patch.object(transcription, "ai_health") as health, \
patch.object(transcription.transcript_correction, "correct", AsyncMock()) as correct, \
patch("app.internal.feature_flags.resolve_flags",
AsyncMock(return_value=(flags, lambda k: flags.get(k, True)))):
health.report_healthy = AsyncMock()
text, _ = await transcription.transcribe_call("call-1", "gs://b/c.flac", "PD Dispatch")
assert text == "Post 4, 10-8. 10-4. Copy, 98."
correct.assert_not_awaited()
@pytest.mark.asyncio
async def test_chatter_still_reaches_the_free_thin_correlation_path():
"""non_event_chatter joins transcript_too_short in upload.py's no-scene
fallback (rules-only for a thin call — no LLM)."""
from app.routers import upload
with patch.object(upload, "fstore") as fs, \
patch.object(upload, "_correlate", AsyncMock(return_value=(None, None))) as corr:
fs.doc_get = AsyncMock(return_value={"skip_reason": "non_event_chatter", "severity": "routine"})
fs.doc_set = AsyncMock()
await upload._extract_and_correlate(
call_id="c1", node_id="n1", system_id="s1", talkgroup_id=1,
talkgroup_name="PD Dispatch", transcript="10-4 Central, 98.", scenes=[],
)
corr.assert_awaited_once()
@pytest.mark.asyncio
async def test_no_scene_fallback_passes_the_call_doc_severity():
"""server-26#104: a reprocessed call whose doc says 'major' must not be
re-correlated as 'routine' (thin, no fit check)."""
from app.routers import upload
with patch.object(upload, "fstore") as fs, \
patch.object(upload, "_correlate", AsyncMock(return_value=(None, None))) as corr:
fs.doc_get = AsyncMock(return_value={"severity": "major"})
fs.doc_set = AsyncMock()
await upload._extract_and_correlate(
call_id="c1", node_id="n1", system_id="s1", talkgroup_id=1,
talkgroup_name="PD Dispatch", transcript="whatever", scenes=[],
)
assert corr.await_args.kwargs["severity"] == "major"
@pytest.mark.asyncio
async def test_gate_off_still_corrects_chatter():
"""With the gate off, transcription must not skip correction either."""
flags = {"transcript_correction_enabled": True}
with patch.object(settings, "chatter_gate_enabled", False), \
patch.object(settings, "openai_api_key", "k"), \
patch.object(settings, "groq_api_key", None), \
patch.object(transcription, "_download", lambda uri: "/nonexistent.flac"), \
patch.object(transcription, "_sync_transcribe_file", lambda *a, **k: {
"text": "Post 4, 10-8. 10-4. Copy, 98.", "segments": [],
"degenerate": False, "duration": 2.0, "confidence": None}), \
patch.object(transcription.fstore, "doc_set", AsyncMock()), \
patch.object(transcription, "ai_health") as health, \
patch.object(transcription.transcript_correction, "correct",
AsyncMock(return_value=(None, None, False))) as correct, \
patch("app.internal.feature_flags.resolve_flags",
AsyncMock(return_value=(flags, lambda k: flags.get(k, True)))):
health.report_healthy = AsyncMock()
await transcription.transcribe_call("call-1", "gs://b/c.flac", "PD Dispatch")
correct.assert_awaited_once()
def test_gate_ships_off():
from app.config import Settings
assert Settings.model_fields["chatter_gate_enabled"].default is False
+3 -4
View File
@@ -53,12 +53,11 @@ def test_a_unit_never_on_the_incident_cannot_close_it():
def test_clearance_only_call_skips_the_llm(): def test_clearance_only_call_skips_the_llm():
from app.internal import llm_correlator
ctx = {"call_units": ["45-9"], "call_cleared": ["45-9"], "tags": [], "location": None, ctx = {"call_units": ["45-9"], "call_cleared": ["45-9"], "tags": [], "location": None,
"call_vehicles": [], "incident_type": None} "call_vehicles": [], "incident_type": None}
assert llm_correlator._is_clearance_only(ctx) assert ic.is_clearance_only(ctx)
assert not llm_correlator._is_clearance_only({**ctx, "tags": ["mva"]}) assert not ic.is_clearance_only({**ctx, "tags": ["mva"]})
assert not llm_correlator._is_clearance_only({**ctx, "call_cleared": []}) assert not ic.is_clearance_only({**ctx, "call_cleared": []})
def test_only_numbered_units_hold_an_incident_open(): def test_only_numbered_units_hold_an_incident_open():
-430
View File
@@ -1,430 +0,0 @@
"""
server-26#115 — two consensus-quality fixes.
Fix 1 (routers/upload.py): when the cheap LLM says `orphan`, the rules engine
says `new`, and the call is genuinely SUBSTANCELESS (routine severity, no
vehicle/geocode/tag, and no incident already running on the same talkgroup),
resolve to `orphan` and DO NOT pay for the smart tiebreaker. Radio housekeeping
(unit check-ins, roll call, 10-8/10-98) was being promoted to incidents because
the tiebreaker rubber-stamped the rules `new` ~21/21 of the time
(CORRELATION_REVIEW_0907b.md).
The substance test runs against `ctx` (fully populated at preview time), NOT
against `rules_decision["corr_debug"]` — that dict is EMPTY at preview time for
action=="new" (corr_path:"new" is written at APPLY time), so the first version of
this gate fired on real events (a `major` "extinguishing fire", geocoded calls,
pursuit updates).
Fix 2 (incident_correlator.py): the `location` correlation path linked on a bare
sub-`location_proximity_km` (0.5 km) distance alone, taking whichever incident
came first in an unsorted `recent`. In a dense village two unrelated events
routinely geocode that close. A `location` link now needs unit overlap with the
candidate OR a distance under a tighter bar, and picks the NEAREST qualifying
candidate. A unit-overlap location link is tagged `location_unit_overlap` so it
does not merge into the fast path's bucket in the admin fit-signal histogram.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from app.routers import upload
from app.internal.incident_correlator import _run_decision, has_event_substance
NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
# ─────────────────────────────────────────────────────────────────────────────
# Fix 1 — the LLM-orphan gate in _correlate_with_consensus
# ─────────────────────────────────────────────────────────────────────────────
def _preview(action, corr_debug=None, ctx=None):
base_ctx = {"call_id": "call-1"}
if ctx:
base_ctx.update(ctx)
return {
"decision": {
"action": action,
"matched_incident": None,
"incident_type": "other" if action == "new" else None,
"corr_debug": {} if corr_debug is None else dict(corr_debug),
},
"ctx": base_ctx,
}
def _llm(action, reasoning="—"):
md = {"incident_id": "inc-1"} if action == "link" else None
return {"action": action, "matched_incident": md, "reasoning": reasoning}
async def _run_consensus(preview, llm_decision):
tiebreak_result = {
"action": "new", "matched_incident": None, "incident_type": "other",
"corr_debug": {}, "reasoning": "tb",
}
with patch("app.internal.incident_correlator.preview_correlation",
new=AsyncMock(return_value=preview)), \
patch("app.internal.incident_correlator.apply_correlation",
new=AsyncMock(return_value="incident-x")) as m_apply, \
patch("app.internal.llm_correlator.decide",
new=AsyncMock(return_value=llm_decision)), \
patch("app.internal.llm_correlator.tiebreak",
new=AsyncMock(return_value=tiebreak_result)) as m_tiebreak:
await upload._correlate_with_consensus(
call_id="call-1", node_id="n1", system_id="sys-1",
talkgroup_id=9048, talkgroup_name="Dispatch", tags=[],
incident_type=None, location=None, location_coords=None,
)
return m_apply, m_tiebreak
async def test_substanceless_no_recent_same_tg_incident_gates_without_tiebreak():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}), _llm("orphan", "unit check-in, not an incident"),
)
m_tiebreak.assert_not_called()
m_apply.assert_called_once()
gated = m_apply.call_args[0][0]["decision"]
assert gated["action"] == "orphan"
dbg = gated["corr_debug"]
assert dbg["corr_consensus"] == "llm_orphan_gate"
assert dbg["corr_consensus"] != "tiebreak"
assert dbg["corr_rules_action"] == "new"
assert dbg["corr_llm_action"] == "orphan"
assert dbg["corr_llm_reasoning"] == "unit check-in, not an incident"
@pytest.mark.parametrize("severity", ["moderate", "major"])
async def test_moderate_or_major_severity_is_not_gated(severity):
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"call_severity": severity}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_routine_severity_alone_still_gates():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"call_severity": "routine"}), _llm("orphan"),
)
m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
async def test_call_with_coords_is_not_gated():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"coords": {"lat": 41.15, "lng": -73.86}}),
_llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_call_with_tags_is_not_gated():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"tags": ["structure-fire"]}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_call_with_vehicles_is_not_gated():
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"call_vehicles": ["red sedan"]}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_call_with_resolved_incident_type_is_not_gated():
# The creation gate skips has_event_substance when a type resolved, so a
# typed call (fire/medical/…) opens an incident on substance the gate does
# not re-check — it must keep the tiebreak, not be dropped.
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"incident_type": "fire"}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_reassignment_call_is_not_gated():
# reassignment=True is dispatch pulling a unit onto a NEW job (units are
# blanked for exactly that reason) — the strongest new-incident signal.
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx={"reassignment": True}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_recent_incident_on_same_talkgroup_is_not_gated():
ctx = {
"system_id": "sys-1",
"talkgroup_id": 9048,
"talkgroup_name": "Dispatch",
"now": NOW,
"recent": [{
"incident_id": "inc-live",
"system_ids": ["sys-1"],
"talkgroup_ids": ["9048"],
"updated_at": (NOW - timedelta(minutes=1)).isoformat(),
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_called_once()
# server-26#115 window #3 (CORRELATION_REVIEW_0912.md): the escape hatch used
# to treat ANY same-talkgroup incident inside the 2h correlation_window_hours
# as "recent", which on a busy dispatch channel (3-13 incidents/2h) was
# satisfied almost unconditionally — the gate fired 0/24 times against its own
# target shape. It now only counts an incident as recent within
# settings.tg_dispatch_thin_idle_minutes (5 min), applied uniformly regardless
# of the talkgroup's name (owner correction, 2026-09-13 — see
# test_channel_name_does_not_affect_the_window below for why the dichotomy
# this originally had with incident_correlator's fast/thin idle selection was
# removed here).
async def test_recent_same_tg_incident_inside_new_short_window_still_escapes_gate():
ctx = {
"system_id": "sys-1",
"talkgroup_id": 9048,
"talkgroup_name": "Dispatch",
"now": NOW,
"recent": [{
"incident_id": "inc-live",
"system_ids": ["sys-1"],
"talkgroup_ids": ["9048"],
# 3 min ago — inside tg_dispatch_thin_idle_minutes (5).
"updated_at": (NOW - timedelta(minutes=3)).isoformat(),
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_called_once()
async def test_recent_same_tg_incident_older_than_short_window_now_gates():
# Regression test for the fix: 8 minutes is past the 5-minute bound but
# still inside the OLD 2-hour correlation_window_hours lookback. Before
# the fix this escaped the gate on any channel; after the fix it gates.
ctx = {
"system_id": "sys-1",
"talkgroup_id": 9048,
"talkgroup_name": "Dispatch",
"now": NOW,
"recent": [{
"incident_id": "inc-stale",
"system_ids": ["sys-1"],
"talkgroup_ids": ["9048"],
"updated_at": (NOW - timedelta(minutes=8)).isoformat(),
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
async def test_channel_name_does_not_affect_the_window():
# Owner correction, 2026-09-13 (direct scanning experience): a talkgroup
# named "tac"/"tactical" only sees materially different traffic during a
# real incident, and that's rare -- the bulk of traffic on any monitored
# channel, including high-risk stops and pursuits, runs on the main
# channel regardless of what it's named. An earlier version of this used
# a longer 15-minute window on anything not literally named "dispatch"/
# "patched"/"primary" (mirroring incident_correlator's fast/thin idle
# selection); that meant a busy single-channel department not literally
# named "dispatch" silently got the more permissive window and could
# reproduce #115's original bug. Same 8-minute age as the dispatch-named
# test above, but on a channel named "Tac 3" -- must gate identically,
# not escape into a longer window just because of the name.
ctx = {
"system_id": "sys-1",
"talkgroup_id": 383,
"talkgroup_name": "Tac 3",
"now": NOW,
"recent": [{
"incident_id": "inc-tac",
"system_ids": ["sys-1"],
"talkgroup_ids": ["383"],
"updated_at": (NOW - timedelta(minutes=8)).isoformat(),
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
async def test_gate_veto_reason_is_recorded_on_the_escalation_path():
# server-26#115: a live measurement window must be able to see *why* an
# llm=orphan/rules=new call escaped the gate without guessing from the raw
# dump (which produced a wrong "confirmed explanation" for 2 window-#3
# misses the first time). corr_gate_veto names the surviving condition.
ctx = {"call_severity": "major"}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_called_once()
final = m_apply.call_args[0][0]["decision"]
assert final["corr_debug"]["corr_gate_veto"] == "severity"
async def test_gate_veto_reason_is_absent_when_the_disagreement_is_not_orphan_vs_new():
# corr_gate_veto is only meaningful for the llm=orphan/rules=new shape the
# gate targets — it must not appear (or be misleadingly None-vs-absent) on
# an unrelated disagreement shape.
m_apply, m_tiebreak = await _run_consensus(
_preview("link", {}), _llm("orphan"),
)
m_tiebreak.assert_called_once()
final = m_apply.call_args[0][0]["decision"]
assert "corr_gate_veto" not in final["corr_debug"]
async def test_recent_incident_on_a_different_talkgroup_still_gates():
ctx = {
"system_id": "sys-1",
"talkgroup_id": 9048,
"recent": [{
"incident_id": "inc-other",
"system_ids": ["sys-1"],
"talkgroup_ids": ["1200"],
}],
}
m_apply, m_tiebreak = await _run_consensus(
_preview("new", {}, ctx=ctx), _llm("orphan"),
)
m_tiebreak.assert_not_called()
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
async def test_llm_link_vs_rules_new_still_escalates():
m_apply, m_tiebreak = await _run_consensus(_preview("new", {}), _llm("link", "same job"))
m_tiebreak.assert_called_once()
async def test_llm_orphan_vs_rules_link_still_escalates():
# Not the gate condition (gate needs rules=="new"); must fall through.
m_apply, m_tiebreak = await _run_consensus(_preview("link", {}), _llm("orphan"))
m_tiebreak.assert_called_once()
def test_has_event_substance_predicate():
assert has_event_substance({"coords": {"lat": 1, "lng": 2}})
assert has_event_substance({"tags": ["fire"]})
assert has_event_substance({"call_vehicles": ["sedan"]})
assert not has_event_substance({})
assert not has_event_substance({"coords": None, "tags": [], "call_vehicles": []})
# units and location are NOT substance — nearly every transmission has them.
assert not has_event_substance({"call_units": ["7-Adam"], "location": "Main St"})
# ─────────────────────────────────────────────────────────────────────────────
# Fix 2 — tighten corr_path=location
# ─────────────────────────────────────────────────────────────────────────────
CALL_COORDS = {"lat": 41.150000, "lng": -73.860000}
# ~0.39 km north of the call — inside location_proximity_km (0.5) but well
# outside the tight bar (_LOCATION_TIGHT_PROXIMITY_KM, 0.2).
FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000}
# ~0.13 km north of the call — inside the tight bar.
NEAR_INC_COORDS = {"lat": 41.151200, "lng": -73.860000}
# ~0.28 km north — inside the 0.5 radius, outside the 0.2 tight bar; used as a
# second candidate that must lose the nearest-wins sort to NEAR_INC_COORDS.
MID_INC_COORDS = {"lat": 41.152500, "lng": -73.860000}
def _inc(incident_id, coords, units):
return {
"incident_id": incident_id,
"system_ids": ["sys-1"],
"talkgroup_ids": ["100"], # different TGID → fast path is a no-op
"location_coords": coords,
"units": units,
"tags": [],
"type": "police",
"updated_at": (NOW - timedelta(minutes=6)).isoformat(),
"started_at": (NOW - timedelta(minutes=20)).isoformat(),
"status": "active",
"call_ids": ["c0"],
}
def _loc_ctx(*, incidents, call_units):
return {
"call_id": "call-loc",
"all_active": list(incidents),
"recent": list(incidents),
"call_doc": {},
"call_embedding": None,
"call_units": call_units,
"call_vehicles": [],
"call_cleared": [],
"call_severity": "routine",
"coords": CALL_COORDS,
"is_thin_call": False,
"now": NOW,
"system_id": "sys-1",
"talkgroup_id": 999, # not in inc.talkgroup_ids
"talkgroup_name": "Tactical",
"tags": [],
"incident_type": "police",
"location": "Main St",
"location_coords": CALL_COORDS,
"reassignment": True, # suppress the unit-continuity path
"create_if_new": True,
}
def test_location_path_in_radius_but_no_unit_overlap_no_tight_proximity_does_not_link(caplog):
ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
with caplog.at_level("INFO", logger="drb-c2-core"):
decision = _run_decision(ctx)
# Reaches, and is rejected by, the new guard (not an earlier path).
assert "location-path skipped" in caplog.text
assert decision["action"] != "link"
assert (decision.get("corr_debug") or {}).get("corr_path") != "location"
def test_location_path_links_on_unit_overlap_with_distinct_fit_signal():
ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["5-Adam"])],
call_units=["5-Adam"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location"
# NOT "unit_overlap" — that value belongs to the fast path's histogram bucket.
assert decision["corr_debug"]["corr_fit_signal"] == "location_unit_overlap"
def test_location_path_links_on_tight_proximity_without_unit_overlap():
ctx = _loc_ctx(
incidents=[_inc("inc-loc", NEAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location"
assert decision["corr_debug"]["corr_fit_signal"] == "location_proximity"
def test_location_path_picks_nearest_in_radius_candidate():
# `recent` order puts the farther tight-proximity incident first; the guard
# must still select the nearest one.
ctx = _loc_ctx(
incidents=[
_inc("inc-mid", MID_INC_COORDS, ["3-Boy"]), # ~0.28 km, tight-fail
_inc("inc-near", NEAR_INC_COORDS, ["3-Boy"]), # ~0.13 km, tight-pass
],
call_units=["3-Boy"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["matched_incident"]["incident_id"] == "inc-near"
assert decision["corr_debug"]["corr_path"] == "location"
@@ -0,0 +1,432 @@
"""
server-26#299 — rules decide confident outcomes; ONE cheap LLM call only in the
ambiguous band; no cheap/smart consensus tiebreak.
Also pins the section fixes that ride on the new shape:
#86 divergence veto bar is a setting, and the band between it and the match
bar is ambiguous rather than a silent merge
#103 a scene with no embedding is "unverified", not verified
#88 LLM yes/no fields fail closed; a closure only counts on a confident link
#189 weak links refused past the span cap; LLM links distance-guarded
#190 severity steps down on an explicit stand-down, the peak never does
#27 sweep budget is wall-clock
#301 late uploads are correlated on the call's own clock
#302 a retried upload does not re-run the pipeline
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from app.config import settings
from app.internal import incident_correlator as ic
from app.internal import llm_correlator, recorrelation_sweep
from app.routers import upload
NOW = datetime(2026, 10, 27, 15, 0, 0, tzinfo=timezone.utc)
def _inc(incident_id="inc-1", idle=1.0, span=None, **kw) -> dict:
updated = NOW - timedelta(minutes=idle)
started = NOW - timedelta(minutes=span if span is not None else idle)
inc = {
"incident_id": incident_id, "system_ids": ["sys-1"], "talkgroup_ids": ["383"],
"updated_at": updated.isoformat(), "started_at": started.isoformat(),
"status": "active", "call_ids": ["seed"], "type": "police",
}
inc.update(kw)
return inc
def _ctx(incidents=(), **kw) -> dict:
incidents = list(incidents)
base = {
"call_id": "call-1", "all_active": incidents, "recent": incidents,
"call_doc": {}, "call_embedding": None, "call_units": [], "call_vehicles": [],
"call_cleared": [], "call_severity": "routine", "coords": None,
"is_thin_call": False, "now": NOW, "system_id": "sys-1", "talkgroup_id": 383,
"talkgroup_name": "Ch 1", "tags": [], "incident_type": None, "location": None,
"location_coords": None, "reassignment": False, "create_if_new": True,
}
base.update(kw)
return base
def _vec(sim: float) -> tuple[list, list]:
"""Two unit vectors with cosine similarity `sim`."""
import math
return [1.0, 0.0], [sim, math.sqrt(max(0.0, 1 - sim * sim))]
# ── The band ────────────────────────────────────────────────────────────────
def test_fresh_unit_link_is_confident():
d = ic._run_decision(_ctx([_inc(idle=2, units=["6-Adam"])], call_units=["6-Adam"]))
assert d["action"] == "link"
assert d["corr_debug"]["corr_fit_signal"] == "unit_overlap"
assert d["ambiguous"] is None
def test_stale_unit_link_without_embedding_is_ambiguous_not_verified():
# #103: no embedding used to skip the veto and link as if verified.
d = ic._run_decision(_ctx([_inc(idle=20, units=["6-Adam"])], call_units=["6-Adam"]))
assert d["action"] == "link"
assert d["corr_debug"]["corr_fit_signal"] == "unit_overlap_unverified"
assert d["ambiguous"] == "unverified_unit_link"
def test_young_but_long_running_unit_link_is_ambiguous():
# #189: busy channel keeps idle low; age is what reveals a sink forming.
inc = _inc(idle=1, span=settings.unit_link_confident_max_span_minutes + 5, units=["6-Adam"])
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"]))
assert d["ambiguous"] == "unverified_unit_link"
@pytest.mark.parametrize("sim,expected", [
(0.80, ("orphan", "content_divergence")), # below the divergence bar → veto
(0.88, ("link", "unit_overlap_unverified")), # between the bars → ambiguous (#86)
(0.95, ("link", "unit_content_match")), # at the match bar → confident
])
def test_divergence_band_between_the_two_thresholds(sim, expected):
call_emb, inc_emb = _vec(sim)
inc = _inc(idle=20, units=["6-Adam"], embedding=inc_emb)
fit, signal = ic._call_fits_incident(
inc, ["6-Adam"], [], None, settings.location_proximity_km,
call_embedding=call_emb, now=NOW,
)
assert (("link" if fit else "orphan"), signal) == expected
def test_divergence_bar_is_the_setting_not_a_literal():
call_emb, inc_emb = _vec(0.88)
inc = _inc(idle=20, units=["6-Adam"], embedding=inc_emb)
with patch.object(settings, "embedding_divergence_threshold", 0.90):
fit, signal = ic._call_fits_incident(
inc, ["6-Adam"], [], None, settings.location_proximity_km,
call_embedding=call_emb, now=NOW,
)
assert (fit, signal) == (False, "content_divergence")
def test_new_with_live_candidates_is_ambiguous_without_none():
d = ic._run_decision(_ctx([_inc(talkgroup_ids=["999"])], incident_type="police"))
assert (d["action"], d["ambiguous"]) == ("new", "new_with_candidates")
d = ic._run_decision(_ctx([], incident_type="police"))
assert (d["action"], d["ambiguous"]) == ("new", None)
def test_orphan_with_unfit_same_tg_candidate_is_ambiguous():
# Substantive (a location) but untyped, live incident on its talkgroup, no evidence.
d = ic._run_decision(_ctx([_inc(idle=2)], location="Mohegan Park Ave"))
assert (d["action"], d["ambiguous"]) == ("orphan", "unfit_tg_candidate")
def test_thin_sweep_and_clearance_calls_are_never_ambiguous():
inc = _inc(idle=0.2)
assert ic._run_decision(_ctx([inc], is_thin_call=True))["ambiguous"] is None
assert ic._run_decision(_ctx([inc], incident_type="police", create_if_new=False))["ambiguous"] is None
stale = _inc(idle=20, units=["45-9"])
d = ic._run_decision(_ctx([stale], call_units=["45-9"], call_cleared=["45-9"]))
assert d["action"] == "link" and d["ambiguous"] is None
# ── #189: caps on absorption ────────────────────────────────────────────────
def test_weak_link_span_cap_refuses_unit_only_links():
inc = _inc(idle=1, span=settings.incident_weak_link_max_span_minutes + 1, units=["6-Adam"])
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"], incident_type="accident"))
assert d["action"] == "new"
assert ic.llm_candidates(_ctx([inc])) == []
def test_weak_link_span_cap_also_binds_unit_continuity():
inc = _inc(idle=1, span=settings.incident_weak_link_max_span_minutes + 1,
units=["6-Adam"], talkgroup_ids=["999"])
d = ic._run_decision(_ctx([inc], call_units=["6-Adam"]))
assert d["action"] == "orphan"
def test_llm_link_distance_guard():
pin = {"lat": 41.0, "lng": -73.8}
inc = _inc(location="Main St", location_coords=pin, location_coords_source="Main St")
near = _ctx(coords={"lat": 41.005, "lng": -73.8})
far = _ctx(coords={"lat": 41.2, "lng": -73.8}) # ~22 km
assert ic.llm_link_allowed(inc, near)
assert not ic.llm_link_allowed(inc, far)
assert ic.llm_link_allowed({**inc, "tags": ["vehicle-pursuit"]}, far)
# ── llm_correlator: one call, validated ─────────────────────────────────────
def test_parse_rejects_links_to_unshown_incidents_and_bad_actions():
shown = [_inc("inc-1")]
ctx = _ctx(shown)
assert llm_correlator._parse_response({"action": "link", "incident_id": "nope"}, ctx, shown) is None
assert llm_correlator._parse_response({"action": "maybe"}, ctx, shown) is None
ok = llm_correlator._parse_response({"action": "link", "incident_id": "inc-1"}, ctx, shown)
assert ok["action"] == "link" and ok["matched_incident"]["incident_id"] == "inc-1"
def test_parse_new_uses_the_rules_creation_gate():
ctx = _ctx(call_severity="minor")
assert llm_correlator._parse_response({"action": "new"}, ctx, [])["incident_type"] == "other"
out = llm_correlator._parse_response({"action": "new"}, _ctx(), [])
assert out["action"] == "orphan"
async def _correlate_with(rules_decision, llm_return, ctx=None):
ctx = ctx or _ctx([_inc()])
preview = {"decision": rules_decision, "ctx": ctx}
with patch("app.internal.incident_correlator.preview_correlation", AsyncMock(return_value=preview)), \
patch("app.internal.incident_correlator.apply_correlation", AsyncMock(return_value="inc-x")) as apply, \
patch("app.internal.llm_correlator.decide", AsyncMock(return_value=llm_return)) as decide:
_, final = await upload._correlate(
call_id="call-1", node_id="n", system_id="sys-1", talkgroup_id=383,
talkgroup_name="Ch 1", tags=[], incident_type=None, location=None,
location_coords=None,
)
return final, decide, apply
def _rules(action, ambiguous=None, inc=None):
return {"action": action, "matched_incident": inc, "incident_type": "police" if action == "new" else None,
"corr_debug": {}, "ambiguous": ambiguous}
def _llm(action, inc=None):
return {"action": action, "matched_incident": inc, "incident_type": "police" if action == "new" else None,
"corr_debug": {"corr_llm_reasoning": "r"}, "reasoning": "r"}
@pytest.mark.asyncio
async def test_confident_rules_decision_never_calls_the_llm():
final, decide, _ = await _correlate_with(_rules("new"), _llm("orphan"))
decide.assert_not_awaited()
assert final["corr_debug"]["corr_consensus"] == "rules_only"
@pytest.mark.asyncio
async def test_ambiguous_decision_makes_exactly_one_llm_call_and_commits_it():
inc = _inc("inc-2")
final, decide, apply = await _correlate_with(_rules("new", "new_with_candidates"), _llm("link", inc))
decide.assert_awaited_once()
committed = apply.await_args.args[0]["decision"]
assert committed["action"] == "link" and committed["matched_incident"]["incident_id"] == "inc-2"
dbg = final["corr_debug"]
assert dbg["corr_consensus"] == "llm" and dbg["corr_path"] == "llm"
assert dbg["corr_band"] == "new_with_candidates"
assert (dbg["corr_rules_action"], dbg["corr_llm_action"]) == ("new", "link")
@pytest.mark.asyncio
async def test_llm_unavailable_keeps_the_rules_decision():
rules = _rules("new", "new_with_candidates")
final, decide, _ = await _correlate_with(rules, None)
assert final is rules and final["corr_debug"]["corr_consensus"] == "rules_fallback"
@pytest.mark.asyncio
async def test_llm_orphan_cannot_drop_a_major_or_reassignment_call():
final, _, _ = await _correlate_with(
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()], call_severity="major"))
assert final["action"] == "new" and final["corr_debug"]["corr_consensus"] == "rules_guard"
final, _, _ = await _correlate_with(
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()]))
assert final["action"] == "orphan" and final["corr_debug"]["corr_consensus"] == "llm"
@pytest.mark.asyncio
@pytest.mark.parametrize("extra", [{"incident_type": "fire"}, {"tags": ["structure-fire"]},
{"coords": {"lat": 41.0, "lng": -73.8}}, {"reassignment": True}])
async def test_llm_orphan_cannot_drop_a_typed_or_substantive_call(extra):
final, _, _ = await _correlate_with(
_rules("new", "new_with_candidates"), _llm("orphan"), _ctx([_inc()], **extra))
assert final["action"] == "new" and final["corr_debug"]["corr_consensus"] == "rules_guard"
@pytest.mark.asyncio
async def test_llm_orphan_over_a_rules_link_is_tombstoned_for_the_sweep():
inc = _inc()
rules = _rules("link", "unverified_unit_link", inc)
final, _, _ = await _correlate_with(rules, _llm("orphan"))
assert final["action"] == "orphan" and final["corr_debug"]["corr_path"] == "llm_orphan"
@pytest.mark.asyncio
async def test_back_dated_link_never_moves_updated_at_backward():
inc = _inc(idle=1, span=10)
with patch.object(ic, "fstore") as fs:
fs.doc_set = AsyncMock()
await ic._update_incident(inc, "late-call", 383, "sys-1", [], None, None, ["6-Adam"], [], None,
NOW - timedelta(hours=3))
assert fs.doc_set.await_args.args[2]["updated_at"] == inc["updated_at"]
@pytest.mark.asyncio
async def test_llm_confirming_the_rules_link_keeps_the_rules_path():
inc = _inc()
rules = _rules("link", "unverified_unit_link", inc)
rules["corr_debug"] = {"corr_path": "fast/single", "corr_fit_signal": "unit_overlap_unverified"}
final, _, _ = await _correlate_with(rules, _llm("link", inc))
assert final["corr_debug"]["corr_path"] == "fast/single"
assert final["corr_debug"]["corr_consensus"] == "llm"
def test_no_tiebreak_tier_remains():
assert not hasattr(llm_correlator, "tiebreak")
assert not hasattr(settings, "corr_smart_model")
assert not hasattr(upload, "_correlate_with_consensus")
# ── #88 ─────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("value,expected", [
(True, True), (False, False), ("true", True), ("True", True), ("yes", True),
("false", False), ("False", False), ("no", False), ("", False), (None, False),
("maybe", False), (1, False), ({"x": 1}, False),
])
def test_parse_llm_bool_fails_closed(value, expected):
assert ic.parse_llm_bool(value) is expected
def test_closure_only_honoured_on_a_confident_link_or_own_new_incident():
assert upload._may_resolve({"action": "new", "corr_debug": {"corr_consensus": "llm"}})
assert upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "rules_only",
"corr_path": "fast/single"}})
assert not upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "rules_only",
"corr_path": "fast/thin"}})
assert not upload._may_resolve({"action": "link", "corr_debug": {"corr_consensus": "llm",
"corr_path": "llm"}})
assert not upload._may_resolve({"action": "orphan", "corr_debug": {}})
@pytest.mark.asyncio
async def test_quoted_false_does_not_resolve_or_blank_units():
scene = {"tags": [], "incident_type": "police", "location": None, "location_coords": None,
"units": ["6-Adam"], "resolved": "false", "reassignment": "false"}
corr = AsyncMock(return_value=("inc-1", {"action": "new", "corr_debug": {}}))
with patch.object(upload, "_correlate", corr), patch.object(upload, "fstore") as fs:
fs.doc_set = AsyncMock()
await upload._correlate_scenes("call-1", "n", "sys-1", 383, "Ch 1", [scene])
assert corr.await_args.kwargs["units"] == ["6-Adam"]
assert corr.await_args.kwargs["reassignment"] is False
fs.doc_set.assert_not_awaited()
# ── #190 ────────────────────────────────────────────────────────────────────
def test_severity_escalates_immediately_and_keeps_its_peak():
out = ic._severity_after_link({"severity": "minor"}, "major", "shots fired")
assert out["severity"] == "major" and out["severity_peak"] == "major"
def test_calmer_followup_without_stand_down_does_not_deescalate():
out = ic._severity_after_link({"severity": "major"}, "routine", "Engine 5 on scene, 10-4")
assert out["severity"] == "major" and "severity_deescalated_from" not in out
def test_explicit_stand_down_deescalates_but_peak_stays():
inc = {"severity": "major"}
out = ic._severity_after_link(inc, "minor", "Only smoke showing, no flames.")
assert out == {"severity": "minor", "severity_peak": "major", "severity_deescalated_from": "major"}
later = ic._severity_after_link({**inc, **out}, "routine", "copy")
assert later["severity"] == "minor" and later["severity_peak"] == "major"
@pytest.mark.parametrize("text", [
"call was unfounded", "false alarm, resetting the panel", "GOA, nothing found",
"Code 4, you can stand down", "fire is out",
])
def test_stand_down_phrases(text):
assert ic._is_stand_down(text)
@pytest.mark.parametrize("text", ["disregard my last", "code 40", "flames showing",
"primary search all clear", "Code 4", None])
def test_not_stand_down_phrases(text):
assert not ic._is_stand_down(text)
# ── #27 ─────────────────────────────────────────────────────────────────────
def test_sweep_tombstones_only_on_the_last_visible_pass():
scan = settings.recorrelation_scan_minutes
young = {"ended_at": (NOW - timedelta(minutes=20)).isoformat()}
last = {"ended_at": (NOW - timedelta(minutes=scan - 1)).isoformat()}
assert not recorrelation_sweep._last_chance(young, NOW)
assert recorrelation_sweep._last_chance(last, NOW)
assert recorrelation_sweep._last_chance({}, NOW)
@pytest.mark.asyncio
async def test_sweep_keeps_retrying_past_the_old_three_attempt_budget():
call = {"call_id": "c", "started_at": (NOW - timedelta(minutes=30)).isoformat(),
"ended_at": (NOW - timedelta(minutes=30)).isoformat(), "corr_sweep_count": 5}
with patch("app.internal.incident_correlator.correlate_call", AsyncMock(return_value=None)), \
patch.object(recorrelation_sweep, "fstore") as fs:
fs.doc_set = AsyncMock()
assert await recorrelation_sweep._recorrelate_orphan(call, NOW) is False
assert fs.doc_set.await_args.args[2] == {"corr_sweep_count": 6}
# ── #301 / #302 ─────────────────────────────────────────────────────────────
def test_late_upload_is_anchored_to_started_at_and_on_time_is_not():
with patch("app.internal.clock.now", return_value=NOW):
late = NOW - timedelta(hours=3)
assert upload._late_upload_anchor({"started_at": late.isoformat()}) == late
assert upload._late_upload_anchor({"started_at": late}) == late # native timestamp
assert upload._late_upload_anchor({"started_at": (NOW - timedelta(minutes=1)).isoformat()}) is None
assert upload._late_upload_anchor({}) is None
@pytest.mark.asyncio
async def test_late_upload_correlates_on_the_calls_own_clock():
from app.internal import clock
seen = {}
started = NOW - timedelta(hours=3)
async def fake_extract(**kw):
seen["now"] = clock.now()
return [], [], []
flags = ({"stt_enabled": False, "correlation_enabled": True}, lambda k: k == "correlation_enabled")
with patch.object(upload, "fstore") as fs, \
patch.object(upload, "_resolve_flags", AsyncMock(return_value=flags)), \
patch.object(upload, "_extract_and_correlate", fake_extract), \
patch("app.internal.talkgroups.resolve", AsyncMock(return_value="Ch 1")), \
patch("app.internal.alerter.check_and_dispatch", AsyncMock()):
fs.doc_get = AsyncMock(return_value={"started_at": started.isoformat(), "talkgroup_name": "Ch 1"})
fs.doc_set = AsyncMock()
await upload._run_intelligence_pipeline("call-1", "n", "sys-1", 383, "Ch 1", None)
assert seen["now"] == started
assert clock.now() != started # pin released
@pytest.mark.asyncio
async def test_retried_upload_returns_stored_audio_without_rerunning_the_pipeline():
from fastapi import BackgroundTasks
from fastapi.security import HTTPAuthorizationCredentials
docs = {
("node_keys", "n"): {"api_key": "k"},
("nodes", "n"): {"approval_status": "approved"},
("calls", "call-1"): {"audio_gcs_uri": "gs://b/call-1.mp3"},
}
class _File:
filename = "a.mp3"
async def read(self):
return b"audio"
bt = BackgroundTasks()
with patch.object(upload, "fstore") as fs, patch.object(upload, "upload_audio", AsyncMock()) as up:
fs.doc_get = AsyncMock(side_effect=lambda coll, doc_id: docs.get((coll, doc_id)))
out = await upload.upload_call_audio(
background_tasks=bt, file=_File(), call_id="call-1", node_id="n",
credentials=HTTPAuthorizationCredentials(scheme="Bearer", credentials="k"),
)
assert out == {"url": "gs://b/call-1.mp3", "already_uploaded": True}
up.assert_not_awaited()
assert bt.tasks == []
@@ -0,0 +1,141 @@
"""
server-26#115 — location-path corroboration, plus the shared substance predicate.
(The consensus LLM-orphan gate that used to be tested here was removed with the
cheap/smart tiebreak in server-26#299; its replacement is tested in
test_correlation_decision_flow.py.)
The `location` correlation path linked on a bare sub-`location_proximity_km`
(0.5 km) distance alone, taking whichever incident came first in an unsorted
`recent`. In a dense village two unrelated events routinely geocode that close.
A `location` link now needs unit overlap with the candidate OR a distance under a
tighter bar, and picks the NEAREST qualifying candidate. A unit-overlap location
link is tagged `location_unit_overlap` so it does not merge into the fast path's
bucket in the admin fit-signal histogram.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.internal.incident_correlator import _run_decision, has_event_substance
NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
def test_has_event_substance_predicate():
assert has_event_substance({"coords": {"lat": 1, "lng": 2}})
assert has_event_substance({"tags": ["fire"]})
assert has_event_substance({"call_vehicles": ["sedan"]})
assert not has_event_substance({})
assert not has_event_substance({"coords": None, "tags": [], "call_vehicles": []})
# units and location are NOT substance — nearly every transmission has them.
assert not has_event_substance({"call_units": ["7-Adam"], "location": "Main St"})
# ─────────────────────────────────────────────────────────────────────────────
# Fix 2 — tighten corr_path=location
# ─────────────────────────────────────────────────────────────────────────────
CALL_COORDS = {"lat": 41.150000, "lng": -73.860000}
# ~0.39 km north of the call — inside location_proximity_km (0.5) but well
# outside the tight bar (_LOCATION_TIGHT_PROXIMITY_KM, 0.2).
FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000}
# ~0.13 km north of the call — inside the tight bar.
NEAR_INC_COORDS = {"lat": 41.151200, "lng": -73.860000}
# ~0.28 km north — inside the 0.5 radius, outside the 0.2 tight bar; used as a
# second candidate that must lose the nearest-wins sort to NEAR_INC_COORDS.
MID_INC_COORDS = {"lat": 41.152500, "lng": -73.860000}
def _inc(incident_id, coords, units):
return {
"incident_id": incident_id,
"system_ids": ["sys-1"],
"talkgroup_ids": ["100"], # different TGID → fast path is a no-op
"location_coords": coords,
"units": units,
"tags": [],
"type": "police",
"updated_at": (NOW - timedelta(minutes=6)).isoformat(),
"started_at": (NOW - timedelta(minutes=20)).isoformat(),
"status": "active",
"call_ids": ["c0"],
}
def _loc_ctx(*, incidents, call_units):
return {
"call_id": "call-loc",
"all_active": list(incidents),
"recent": list(incidents),
"call_doc": {},
"call_embedding": None,
"call_units": call_units,
"call_vehicles": [],
"call_cleared": [],
"call_severity": "routine",
"coords": CALL_COORDS,
"is_thin_call": False,
"now": NOW,
"system_id": "sys-1",
"talkgroup_id": 999, # not in inc.talkgroup_ids
"talkgroup_name": "Tactical",
"tags": [],
"incident_type": "police",
"location": "Main St",
"location_coords": CALL_COORDS,
"reassignment": True, # suppress the unit-continuity path
"create_if_new": True,
}
def test_location_path_in_radius_but_no_unit_overlap_no_tight_proximity_does_not_link(caplog):
ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
with caplog.at_level("INFO", logger="drb-c2-core"):
decision = _run_decision(ctx)
# Reaches, and is rejected by, the new guard (not an earlier path).
assert "location-path skipped" in caplog.text
assert decision["action"] != "link"
assert (decision.get("corr_debug") or {}).get("corr_path") != "location"
def test_location_path_links_on_unit_overlap_with_distinct_fit_signal():
ctx = _loc_ctx(
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["5-Adam"])],
call_units=["5-Adam"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location"
# NOT "unit_overlap" — that value belongs to the fast path's histogram bucket.
assert decision["corr_debug"]["corr_fit_signal"] == "location_unit_overlap"
def test_location_path_links_on_tight_proximity_without_unit_overlap():
ctx = _loc_ctx(
incidents=[_inc("inc-loc", NEAR_INC_COORDS, ["7-Adam"])],
call_units=["3-Boy"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["corr_debug"]["corr_path"] == "location"
assert decision["corr_debug"]["corr_fit_signal"] == "location_proximity"
def test_location_path_picks_nearest_in_radius_candidate():
# `recent` order puts the farther tight-proximity incident first; the guard
# must still select the nearest one.
ctx = _loc_ctx(
incidents=[
_inc("inc-mid", MID_INC_COORDS, ["3-Boy"]), # ~0.28 km, tight-fail
_inc("inc-near", NEAR_INC_COORDS, ["3-Boy"]), # ~0.13 km, tight-pass
],
call_units=["3-Boy"],
)
decision = _run_decision(ctx)
assert decision["action"] == "link"
assert decision["matched_incident"]["incident_id"] == "inc-near"
assert decision["corr_debug"]["corr_path"] == "location"
@@ -332,13 +332,18 @@ def test_duration_cap_blocks_the_thin_path_too():
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan" assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan"
def test_incident_just_under_the_duration_cap_still_accepts_calls(): def test_incident_just_under_the_duration_cap_still_accepts_strong_evidence():
# Past the weak-link span cap (server-26#189) shared units alone no longer
# link, but strong evidence (a shared vehicle) still does until the hard cap.
inc = _long_running(settings.incident_max_duration_minutes - 10) inc = _long_running(settings.incident_max_duration_minutes - 10)
inc["units"] = ["6-Adam"] inc["units"] = ["6-Adam"]
inc["vehicles"] = ["red sedan"]
decision = _run_decision(_ctx( decision = _run_decision(_ctx(
all_active=[inc], recent=[inc], is_thin_call=False, call_units=["6-Adam"], all_active=[inc], recent=[inc], is_thin_call=False,
call_units=["7-Boy"], call_vehicles=["red sedan"],
)) ))
assert decision["action"] == "link" assert decision["action"] == "link"
assert decision["corr_debug"]["corr_fit_signal"] == "vehicle_overlap"
def test_call_count_cap_forces_a_new_incident(): def test_call_count_cap_forces_a_new_incident():
+3 -2
View File
@@ -34,12 +34,13 @@ def test_minimal_thinking_by_default_and_usage_lands_in_the_sink():
models = _FakeModels() models = _FakeModels()
a, b = _patched(models) a, b = _patched(models)
sink = {} sink = {}
tok = gemini.collect_usage(sink) from app.internal import llm
tok = llm.collect_usage(sink)
try: try:
with a, b: with a, b:
assert gemini.generate_json("m1", "p", purpose="correlation") == {"action": "link"} assert gemini.generate_json("m1", "p", purpose="correlation") == {"action": "link"}
finally: finally:
gemini.reset_usage(tok) llm.reset_usage(tok)
assert models.configs == [{"thinking_level": "minimal"}] assert models.configs == [{"thinking_level": "minimal"}]
assert sink == {"correlation:m1": {"calls": 1, "in": 1200, "out": 30, "thinking": 0}} assert sink == {"correlation:m1": {"calls": 1, "in": 1200, "out": 30, "thinking": 0}}
+162
View File
@@ -0,0 +1,162 @@
"""
server-26#298 — geocode result cache in front of the Google Geocoding API,
and server-26#160 — place verification gets an anchor from the talkgroup's
own name when nobody configured one.
"""
from unittest.mock import AsyncMock, patch
import pytest
import app.internal.intelligence as intel
from app.config import settings
from app.internal import place_verifier as pv
ANCHOR = {"lat": 41.16, "lng": -73.86, "radius_km": 10.0}
class _Counter:
def __init__(self, payload=None, exc=None):
self.calls = 0
self.payload = payload
self.exc = exc
def patch(self):
outer = self
class _Resp:
def raise_for_status(self):
if outer.exc:
raise outer.exc
def json(self):
return outer.payload
class _Client:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def get(self, *a, **k):
outer.calls += 1
return _Resp()
return patch("httpx.AsyncClient", lambda *a, **k: _Client())
def _ok(lat=41.161, lng=-73.861):
return {"status": "OK", "results": [{"geometry": {
"location": {"lat": lat, "lng": lng}, "location_type": "ROOFTOP"}}]}
@pytest.fixture(autouse=True)
def _key():
with patch.object(settings, "google_maps_api_key", "test-key"):
yield
@pytest.mark.asyncio
async def test_repeat_address_is_one_request():
c = _Counter(_ok())
with c.patch():
a = await intel._geocode_location("12 Main Street, Ossining", anchor=ANCHOR)
b = await intel._geocode_location("12 main street, ossining ", anchor=ANCHOR)
assert a == b == {"lat": 41.161, "lng": -73.861, "location_type": "ROOFTOP"}
assert c.calls == 1
assert intel.geocode_cache_stats == {"hits": 1, "misses": 1}
@pytest.mark.asyncio
async def test_cached_answer_is_still_judged_against_the_caller_anchor():
c = _Counter(_ok())
with c.patch():
assert await intel._geocode_location("12 Main Street", anchor=ANCHOR)
far = {"lat": 0.0, "lng": 0.0, "radius_km": 10.0}
# Different bounds -> different key, so a separate request — never a
# cached answer leaking past a different area's distance check.
assert await intel._geocode_location("12 Main Street", anchor=far) is None
assert c.calls == 2
@pytest.mark.asyncio
async def test_zero_results_is_cached_but_errors_are_not():
c = _Counter({"status": "ZERO_RESULTS", "results": []})
with c.patch():
await intel._geocode_location("Cool Parts, Illinois", anchor=ANCHOR)
await intel._geocode_location("Cool Parts, Illinois", anchor=ANCHOR)
assert c.calls == 1
denied = _Counter({"status": "REQUEST_DENIED"})
with denied.patch():
await intel._geocode_location("Snowden Avenue", anchor=ANCHOR)
await intel._geocode_location("Snowden Avenue", anchor=ANCHOR)
assert denied.calls == 2, "a key/quota failure is not an answer about the address"
boom = _Counter(_ok(), exc=RuntimeError("503"))
with boom.patch():
await intel._geocode_location("Croton Point", anchor=ANCHOR)
await intel._geocode_location("Croton Point", anchor=ANCHOR)
assert boom.calls == 2
@pytest.mark.asyncio
async def test_ttl_expiry_and_size_bound():
c = _Counter(_ok())
with c.patch(), patch.object(settings, "geocode_cache_ttl_seconds", 0):
await intel._geocode_location("12 Main Street", anchor=ANCHOR)
await intel._geocode_location("12 Main Street", anchor=ANCHOR)
assert c.calls == 2, "TTL 0 disables the cache"
c = _Counter(_ok())
with c.patch(), patch.object(settings, "geocode_cache_max_entries", 2):
for street in ("A Street", "B Street", "C Street"):
await intel._geocode_location(street, anchor=ANCHOR)
assert len(intel._geocode_cache) == 2
# ── server-26#160 ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_unconfigured_channel_gets_an_anchor_from_its_talkgroup_name():
resolved = {"center": {"lat": 40.71, "lng": -74.0}, "radius_km": 25.0}
seen = {}
async def _geo(query, anchor):
seen["query"], seen["anchor"] = query, anchor
return {"lat": 40.7, "lng": -74.0}
with patch.object(pv.settings, "place_verification_enabled", True), \
patch.object(intel.fstore, "doc_get_cached", AsyncMock(return_value={"lat": 41.2, "lon": -73.8})), \
patch.object(intel, "_get_node_state", AsyncMock(return_value="New York")), \
patch.object(pv.area_context, "resolve_anchor", AsyncMock(return_value=resolved)) as res, \
patch.object(pv, "_geocode_in_anchor", _geo):
await pv.verify("c1", "Respond to Lexington Avenue", None, ["Lexington Avenue"],
{}, {}, talkgroup_name="New York City PD", node_id="node-1")
res.assert_awaited_once_with({"municipality": "New York City", "state": "New York"})
assert seen["anchor"] == {"lat": 40.71, "lng": -74.0, "radius_km": 25.0}
assert seen["query"] == "Lexington Avenue, New York City, New York"
@pytest.mark.asyncio
async def test_no_state_means_no_derived_anchor():
"""'Springfield PD' alone could anchor anywhere — skip, never guess."""
with patch.object(pv.settings, "place_verification_enabled", True), \
patch.object(intel.fstore, "doc_get_cached", AsyncMock(return_value={"lat": 41.2, "lon": -73.8})), \
patch.object(intel, "_get_node_state", AsyncMock(return_value="")), \
patch.object(pv, "_geocode_in_anchor") as geo:
out = await pv.verify("c1", "Respond to Main Street", None, ["Main Street"],
{}, {}, talkgroup_name="Springfield PD", node_id="node-1")
assert out == (None, None)
geo.assert_not_called()
@pytest.mark.asyncio
async def test_configured_place_without_anchor_still_skips():
"""A place that is set but too wide (no anchor stored) keeps the
load-bearing skip; the talkgroup-name fallback is only for unset areas."""
with patch.object(pv.settings, "place_verification_enabled", True), \
patch.object(pv, "_talkgroup_anchor", AsyncMock()) as derived, \
patch.object(pv, "_geocode_in_anchor") as geo:
out = await pv.verify("c1", "Main Street", None, ["Main Street"],
{"state": "Colorado"}, {}, talkgroup_name="Denver PD", node_id="n")
assert out == (None, None)
derived.assert_not_awaited()
geo.assert_not_called()
@@ -0,0 +1,56 @@
"""
server-26#236: geocode precision must travel with the coordinates.
`_geocode_location` already judged a result by Google's `location_type` but
returned only {lat, lng}, so a GEOMETRIC_CENTER route centroid and a ROOFTOP
address drew the same pin. The type is now part of the returned dict, which
is stored as-is on the call doc and carried unchanged onto the incident pin by
`incident_correlator._resolve_location_pair`.
Display-only: every correlator read of location_coords indexes ["lat"] and
["lng"] directly, so the extra key cannot change a correlation decision.
"""
from unittest.mock import patch
import pytest
import app.internal.intelligence as intel
from app.config import settings
from tests.test_geocode_named_region import _maps_result
@pytest.fixture(autouse=True)
def _api_key():
with patch.object(settings, "google_maps_api_key", "test-key"):
yield
ANCHOR = {"lat": 41.16, "lng": -73.86, "radius_km": 10.0}
@pytest.mark.asyncio
@pytest.mark.parametrize("location_type", ["ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"])
async def test_accepted_location_type_is_returned_with_the_coords(location_type):
with _maps_result(41.161, -73.861, location_type=location_type):
coords = await intel._geocode_location("1 Main Street, Ossining", anchor=ANCHOR)
assert coords == {"lat": 41.161, "lng": -73.861, "location_type": location_type}
@pytest.mark.asyncio
@pytest.mark.parametrize("location_type", ["APPROXIMATE", "", "SOMETHING_NEW"])
async def test_rejected_location_type_still_returns_none(location_type):
with _maps_result(41.161, -73.861, location_type=location_type):
coords = await intel._geocode_location("Ossining", anchor=ANCHOR)
assert coords is None
def test_location_type_survives_onto_the_incident_pin():
"""`_resolve_location_pair` stores the call's coords dict as-is, so the
precision reaches the incident without any correlator change."""
from app.internal.incident_correlator import _resolve_location_pair
pin = {"lat": 41.161, "lng": -73.861, "location_type": "GEOMETRIC_CENTER"}
fields = _resolve_location_pair({}, "Route 9 and Main Street", pin)
assert fields["location_coords"] == pin
assert fields["location_coords_source"] == "Route 9 and Main Street"
@@ -69,7 +69,7 @@ async def test_named_region_geocode_accepted_beyond_node_distance():
node_lat=NODE_LAT, node_lon=NODE_LON, node_lat=NODE_LAT, node_lon=NODE_LON,
trust_named_region=True, trust_named_region=True,
) )
assert coords == {"lat": FAR_LAT, "lng": FAR_LNG} assert coords == {"lat": FAR_LAT, "lng": FAR_LNG, "location_type": "ROOFTOP"}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -110,7 +110,7 @@ async def test_named_region_geocode_within_node_distance_is_unaffected():
node_lat=NODE_LAT, node_lon=NODE_LON, node_lat=NODE_LAT, node_lon=NODE_LON,
trust_named_region=True, trust_named_region=True,
) )
assert coords == {"lat": near_lat, "lng": near_lng} assert coords == {"lat": near_lat, "lng": near_lng, "location_type": "ROOFTOP"}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -0,0 +1,14 @@
"""server-26#88 — resolved/reassignment are parsed, not bool()-coerced."""
import pytest
from app.internal.incident_correlator import parse_llm_bool as _llm_bool
@pytest.mark.parametrize("value,expected", [
(True, True), (False, False),
("true", True), ("TRUE", True), (" Yes ", True), ("yes", True),
("false", False), ("False", False), ("no", False), ("NO", False),
("", False), ("maybe", False), (None, False), (1, False), (0, False), ([], False),
])
def test_llm_bool(value, expected):
assert _llm_bool(value) is expected
+48
View File
@@ -0,0 +1,48 @@
"""app/internal/llm.py: provider follows the model name (2026-11-08 consolidation)."""
from types import SimpleNamespace
from unittest.mock import patch
from app.config import settings
from app.internal import llm
def test_provider_follows_model_name():
assert llm.provider_for("gpt-4o-mini") == "openai"
assert llm.provider_for("gemini-3.6-flash") == "gemini"
def test_available_checks_the_right_key():
with patch.object(settings, "openai_api_key", "k"), patch.object(settings, "gemini_api_key", None):
assert llm.available("gpt-4o-mini") and not llm.available("gemini-3.6-flash")
def test_defaults_are_openai():
assert llm.provider_for(settings.corr_cheap_model) == "openai"
assert llm.provider_for(settings.transcript_correction_model) == "openai"
def test_openai_path_parses_json_and_records_usage():
resp = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content='{"action": "new"}'))],
usage=SimpleNamespace(prompt_tokens=120, completion_tokens=9),
)
calls = {}
class FakeClient:
def __init__(self):
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create))
def create(self, **kw):
calls.update(kw)
return resp
sink = {}
tok = llm.collect_usage(sink)
try:
with patch("app.internal.ai_clients.openai_client", FakeClient):
out = llm.generate_json("gpt-4o-mini", "Return JSON", purpose="correlation")
finally:
llm.reset_usage(tok)
assert out == {"action": "new"}
assert calls["response_format"] == {"type": "json_object"}
assert sink["correlation:gpt-4o-mini"] == {"calls": 1, "in": 120, "out": 9, "thinking": 0}
+16
View File
@@ -118,6 +118,22 @@ async def test_checkin_updates_existing_configured_node(handler):
assert "last_seen" in updates assert "last_seen" in updates
@pytest.mark.asyncio
async def test_checkin_stores_disk_free_and_total(handler):
# node-26#41: a full disk stopped node-002 recording while it showed Online.
existing = {"node_id": "node-01", "status": "online", "configured": True}
with patch("app.internal.mqtt_handler.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(return_value=existing)
mock_fstore.doc_update = AsyncMock()
await handler._handle_checkin("node-01", {"disk_free_bytes": 5, "disk_total_bytes": 100})
updates = mock_fstore.doc_update.call_args[0][2]
assert updates["disk_free_bytes"] == 5 and updates["disk_total_bytes"] == 100
# An older node that doesn't report disk must not wipe the fields.
await handler._handle_checkin("node-01", {"name": "x"})
assert "disk_free_bytes" not in mock_fstore.doc_update.call_args[0][2]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_checkin_does_not_promote_unconfigured_to_online(handler): async def test_checkin_does_not_promote_unconfigured_to_online(handler):
existing = { existing = {
+100
View File
@@ -0,0 +1,100 @@
"""
Every radio on a call reaches the call doc (server-26#304) — from /upload,
which starts the pipeline, and from call_end, which used to drop srcaddr.
"""
import json
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.internal import radios
from app.internal.mqtt_handler import MQTTHandler
from app.main import app
from app.routers import upload
LIST = [{"radio": 555, "start": 0.0, "end": 1.0}, {"radio": 777, "start": 2.0, "end": 2.5},
{"radio": 555, "start": 3.5, "end": 4.0}]
def test_parse_accepts_json_and_drops_junk():
raw = json.dumps(LIST + [{"radio": 0, "start": 1, "end": 2}, {"radio": "x"}, "junk",
{"radio": 9, "start": -1, "end": 2}, {"radio": 8, "start": 5, "end": 4}])
assert radios.parse(raw) == LIST + [{"radio": 8, "start": 5.0, "end": 5.0}]
assert radios.parse("not json") == []
assert radios.parse({"radio": 1}) == []
assert radios.parse(None) == []
def test_call_fields_keeps_distinct_ids_in_first_heard_order():
assert radios.call_fields(LIST)["radio_ids"] == [555, 777]
assert radios.call_fields([]) == {}
@pytest.mark.asyncio
async def test_call_end_writes_srcaddr_and_radios():
"""call_start used to be the only writer of srcaddr; the talker is usually learned later."""
handler = MQTTHandler()
with patch("app.internal.mqtt_handler.fstore") as fs:
fs.doc_get_cached = AsyncMock(return_value=None)
fs.doc_set = AsyncMock()
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": 555, "radios": LIST})
updates = fs.doc_set.call_args[0][2]
assert updates["srcaddr"] == 555
assert updates["radios"] == LIST
assert updates["radio_ids"] == [555, 777]
@pytest.mark.asyncio
async def test_call_end_without_radios_leaves_them_alone():
"""An older node sends neither field; a merge write must not blank what /upload stored."""
handler = MQTTHandler()
with patch("app.internal.mqtt_handler.fstore") as fs:
fs.doc_get_cached = AsyncMock(return_value=None)
fs.doc_set = AsyncMock()
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": None})
updates = fs.doc_set.call_args[0][2]
assert "srcaddr" not in updates and "radios" not in updates
def _upload(existing_call, form_radios):
store = {
"node_keys": {"api_key": "k"},
"nodes": {"approval_status": "approved", "org_id": "org"},
"calls": existing_call,
}
writes = []
async def doc_get(c, i):
return store.get(c)
async def doc_set(c, i, data, merge=True):
writes.append((c, data))
data = {"call_id": "c1", "node_id": "n1"}
if form_radios is not None:
data["radios"] = form_radios
with patch.object(upload.fstore, "doc_get", doc_get), \
patch.object(upload.fstore, "doc_set", doc_set), \
patch.object(upload, "upload_audio", AsyncMock(return_value="gs://b/c1.mp3")), \
patch.object(upload.dedup, "find_duplicate_of", AsyncMock(return_value="other")):
resp = TestClient(app).post(
"/upload", data=data,
files={"file": ("a.mp3", b"\x00\x01", "audio/mpeg")},
headers={"Authorization": "Bearer k"},
)
assert resp.status_code == 200
return writes[0][1]
def test_upload_stores_the_radio_list_before_the_pipeline_runs():
first = _upload({}, json.dumps(LIST))
assert first["radios"] == LIST
assert first["radio_ids"] == [555, 777]
# call_start had no talker yet: take the latest one, as call_end would.
assert first["srcaddr"] == 555
def test_upload_keeps_an_existing_srcaddr_and_tolerates_no_list():
assert "srcaddr" not in _upload({"srcaddr": 999}, json.dumps(LIST))
assert "radios" not in _upload({}, None)
+279
View File
@@ -0,0 +1,279 @@
"""
server-26#297 — config-driven STT provider with OpenAI as the fallback.
The default must stay OpenAI until a Groq key exists (so prod does not break
before the key is provisioned), a failing primary must fall back to whisper-1
without losing the call, and the vocabulary prompt must never carry a
ten-code series (the hallucination documented in transcription.py).
"""
from unittest.mock import AsyncMock, patch
import pytest
from app.internal import transcription as tr
from app.config import settings
def _settings(**over):
base = dict(stt_provider="auto", groq_api_key=None, openai_api_key="sk-openai",
stt_api_key=None, stt_base_url=None, stt_fallback_to_openai=True,
stt_model="whisper-1", stt_compat_model="whisper-large-v3-turbo")
base.update(over)
return [patch.object(settings, k, v) for k, v in base.items()]
class _patched:
def __init__(self, **over):
self.ps = _settings(**over)
def __enter__(self):
for p in self.ps:
p.start()
def __exit__(self, *a):
for p in self.ps:
p.stop()
# ── provider resolution ─────────────────────────────────────────────────────
def test_default_without_groq_key_is_openai_only():
with _patched():
targets = tr.stt_targets()
assert [(t.provider, t.model, t.tier) for t in targets] == [("openai", "whisper-1", "transcription")]
assert targets[0].vocab_prompt is False, "the OpenAI path keeps today's fixed prompt"
def test_groq_key_makes_groq_primary_with_openai_fallback():
with _patched(groq_api_key="gsk"):
targets = tr.stt_targets()
assert [(t.provider, t.model) for t in targets] == [
("groq", "whisper-large-v3-turbo"), ("openai", "whisper-1")]
assert targets[0].base_url == "https://api.groq.com/openai/v1"
assert targets[0].api_key == "gsk"
assert targets[1].tier == "transcription_fallback"
def test_fallback_can_be_switched_off():
with _patched(groq_api_key="gsk", stt_fallback_to_openai=False):
assert [t.provider for t in tr.stt_targets()] == ["groq"]
def test_no_openai_key_means_no_fallback():
with _patched(groq_api_key="gsk", openai_api_key=None):
assert [t.provider for t in tr.stt_targets()] == ["groq"]
def test_compat_server_from_base_url():
with _patched(stt_base_url="http://whisper.local/v1", stt_compat_model="large-v3"):
targets = tr.stt_targets()
assert targets[0].provider == "compat" and targets[0].base_url == "http://whisper.local/v1"
assert targets[0].model == "large-v3"
def test_explicit_groq_without_key_degrades_to_openai():
with _patched(stt_provider="groq"):
assert [t.provider for t in tr.stt_targets()] == ["openai"]
def test_nothing_configured_is_empty():
with _patched(openai_api_key=None):
assert tr.stt_targets() == []
# ── fallback in transcribe_call ─────────────────────────────────────────────
def _ok(text="Engine 5 responding to a structure fire on Main Street"):
return {"text": text, "segments": [{"start": 0.0, "end": 3.0, "text": text}],
"degenerate": False, "duration": 3.0, "confidence": 0.81}
async def _transcribe(sync_impl, **over):
saved: dict = {}
async def _doc_set(coll, doc_id, data, merge=True):
saved.update(data)
flags = {"transcript_correction_enabled": False}
with _patched(**over), \
patch.object(tr, "_download", lambda uri: "/nonexistent.flac"), \
patch.object(tr, "_sync_transcribe_file", sync_impl), \
patch.object(tr.transcript_correction, "resolve_context", AsyncMock(return_value={})), \
patch.object(tr.fstore, "doc_set", _doc_set), \
patch.object(tr, "ai_health") as health, \
patch("app.internal.feature_flags.resolve_flags",
AsyncMock(return_value=(flags, lambda k: flags.get(k, True)))):
health.report_healthy = AsyncMock()
health.report_degraded = AsyncMock()
health.classify = lambda text: "transient"
result = await tr.transcribe_call("call-1", "gs://b/call-1.flac", "PD Dispatch", system_id="sys-1")
return result, saved, health
@pytest.mark.asyncio
async def test_primary_failure_falls_back_to_openai():
seen = []
def sync(path, target, prompt, glossary=None):
seen.append(target.provider)
if target.provider == "groq":
raise RuntimeError("503 Service Unavailable")
return _ok()
(text, segments), saved, health = await _transcribe(sync, groq_api_key="gsk")
assert seen == ["groq", "openai"]
assert text.startswith("Engine 5")
assert saved["stt_provider"] == "openai" and saved["stt_model"] == "whisper-1"
# The primary stays red; only the fallback tier is marked healthy.
assert health.report_degraded.await_args.args[:3] == ("transcription", "groq", "whisper-large-v3-turbo")
health.report_healthy.assert_awaited_once_with("transcription_fallback")
@pytest.mark.asyncio
async def test_primary_success_records_provider_and_cost_inputs():
(text, _), saved, health = await _transcribe(lambda *a, **k: _ok(), groq_api_key="gsk")
assert saved["stt_provider"] == "groq"
assert saved["stt_model"] == "whisper-large-v3-turbo"
assert saved["stt_audio_seconds"] == 3.0
assert saved["stt_confidence"] == 0.81
health.report_healthy.assert_awaited_once_with("transcription")
@pytest.mark.asyncio
async def test_every_target_failing_returns_nothing():
def sync(*a, **k):
raise RuntimeError("boom")
(text, segments), saved, health = await _transcribe(sync, groq_api_key="gsk")
assert (text, segments) == (None, [])
assert health.report_degraded.await_count == 2
health.report_healthy.assert_not_awaited()
@pytest.mark.asyncio
async def test_default_config_never_calls_groq():
seen = []
def sync(path, target, prompt, glossary=None):
seen.append((target.provider, target.base_url))
return _ok()
await _transcribe(sync)
assert seen == [("openai", None)]
# ── prompt ──────────────────────────────────────────────────────────────────
CONTEXT = {
"area": {"municipality": "Ossining", "county": "Westchester", "state": "New York",
"local_knowledge": [{"term": "Snowden Avenue"}, {"term": "Sing Sing", "meaning": "prison"}]},
"vocabulary": ["10-13", "5-David", "Croton Point", "Snowden Avenue"],
"ten_codes": {"10-4": "ack", "10-13": "officer needs assistance"},
}
def test_no_context_is_the_legacy_prompt():
prompt, glossary = tr.build_stt_prompt("PD Dispatch", None)
assert prompt == "Talkgroup: PD Dispatch. " + tr._WHISPER_PROMPT
assert glossary == []
def test_vocabulary_prompt_carries_places_but_never_ten_codes():
prompt, glossary = tr.build_stt_prompt("PD Dispatch", CONTEXT)
assert "Ossining, Westchester, New York" in prompt
assert glossary == ["Snowden Avenue", "Sing Sing", "5-David", "Croton Point"]
assert "10-13" not in prompt, "a code-shaped vocabulary term must stay out"
assert "officer needs assistance" not in prompt
assert prompt.endswith(tr._WHISPER_PROMPT)
def test_prompt_stays_inside_the_token_budget():
big = {"area": {}, "vocabulary": [f"Placename{i} Boulevard" for i in range(200)]}
with patch.object(settings, "stt_prompt_max_tokens", 200):
prompt, glossary = tr.build_stt_prompt("PD Dispatch", big)
assert tr._estimate_tokens(prompt) <= 200
assert 0 < len(glossary) < 200
def test_token_estimate_is_conservative_for_radio_text():
# GPT-2 tokenises this at ~25 tokens; the estimate must not be lower.
assert tr._estimate_tokens("Engine 5 responding to a structure fire on Main Street, 10-4.") >= 20
# ── degenerate output the vocabulary prompt could add ───────────────────────
def test_prompt_echo_is_discarded():
glossary = ["Snowden Avenue", "Sing Sing", "Croton Point", "Main Street"]
assert tr._is_degenerate("Snowden Avenue, Sing Sing, Croton Point, Main Street.", [], glossary)
assert tr._is_degenerate("Local names: Snowden Avenue, Sing Sing.", [], glossary)
def test_one_real_street_in_a_sentence_is_not_an_echo():
glossary = ["Snowden Avenue", "Sing Sing", "Croton Point"]
real = "Car 4 respond to Snowden Avenue for a report of a disabled vehicle."
assert not tr._is_degenerate(real, [], glossary)
def test_compression_ratio_catches_an_alternating_loop():
loop = "Dispatch, copy. Unit 4, go ahead. " * 12
assert not tr._phrase_loop(loop), "the old checks miss this shape"
assert tr._is_degenerate(loop, [])
def test_real_long_traffic_passes_the_compression_check():
real = (
"Headquarters to 5 David, respond to 12 Snowden Avenue for a report of a "
"two-car accident with injuries, caller states one vehicle is overturned. "
"5 David copies, en route from Route 9. Be advised EMS has been notified "
"and Ossining Fire is responding as well. 5 David on scene, two vehicles, "
"one patient complaining of neck pain, requesting a tow for one."
)
assert len(real) >= tr._MIN_CHARS_FOR_COMPRESSION
assert not tr._is_degenerate(real, [])
def test_sweep_hold_back_counts_the_fallback_attempt():
from app.internal import recorrelation_sweep
with _patched():
single = recorrelation_sweep.pipeline_worst_case_seconds()
with _patched(groq_api_key="gsk"):
double = recorrelation_sweep.pipeline_worst_case_seconds()
stt_one = (2 if settings.stt_retry_on_degenerate else 1) * (1 + settings.openai_max_retries) \
* settings.stt_timeout_seconds
assert double - single == stt_one
assert double / 60 < settings.recorrelation_scan_minutes, "the sweep must still be able to recover the call"
def test_location_readback_is_not_an_echo():
"""Review of #297: intersection/address readbacks are where pins come from."""
glossary = ["Route 9", "Croton Avenue", "North Highland Place", "Main Street", "Ossining"]
for real in ("Route 9 and Croton Avenue, North Highland Place.",
"Main Street, Croton Avenue, Route 9, Ossining."):
assert not tr._is_degenerate(real, [], glossary), real
def test_long_busy_call_is_judged_per_window():
seg_text = "Engine 5 responding, Ladder 2 responding, Medic 1 en route to Main Street. "
segments = [{"start": float(i * 5), "end": float(i * 5 + 4), "text": seg_text} for i in range(60)]
text = " ".join(s["text"] for s in segments)
# One repeated real update for 5 minutes still loops within every window,
# so this is degenerate — but a varied call with one looping window out of three is not.
assert tr._is_degenerate(text, segments)
real = [
"Headquarters to 5 David, respond to 12 Snowden Avenue for a two-car accident.",
"5 David copies, en route from Route 9, ETA four minutes.",
"Be advised EMS has been notified and Ossining Fire is responding as well.",
"5 David on scene, two vehicles, one patient complaining of neck pain.",
"Requesting a tow for the blue sedan, it is blocking the northbound lane.",
"Copy, tow is being contacted, any further units needed at this time?",
"Negative, traffic is backing up toward Croton Point, send one for control.",
"6 Henry, can you clear the larceny and assist 5 David on Snowden?",
"6 Henry clearing, show me en route, coming down from North Highland.",
"Medic 1 on location, patient is being packaged for transport to Phelps.",
"Fire is standing by for fluids, no entrapment, both drivers out of the cars.",
"Received, all units be advised the roadway is now closed southbound.",
]
loop = "Dispatch, copy. Unit 4, go ahead. " * 12
varied = [{"start": float(i * 5), "end": float(i * 5 + 4), "text": t} for i, t in enumerate(real)]
varied.append({"start": 70.0, "end": 98.0, "text": loop}) # its own window
assert not tr._is_degenerate(" ".join(s["text"] for s in varied), varied)
@@ -57,7 +57,7 @@ def _api_key():
The dev venv has no GEMINI_API_KEY, and correct() returns early without one The dev venv has no GEMINI_API_KEY, and correct() returns early without one
— which would make every assertion below pass for the wrong reason. — which would make every assertion below pass for the wrong reason.
""" """
with patch.object(tc.settings, "gemini_api_key", "test-key"): with patch.object(tc.settings, "openai_api_key", "test-key"):
yield yield
@@ -260,7 +260,10 @@ async def test_segment_code_change_discards_segments_only():
"segments": ["Headquarters, 10-13.", "Show it out to Ossining.", "360 north, back to Route 9."], "segments": ["Headquarters, 10-13.", "Show it out to Ossining.", "360 north, back to Route 9."],
} }
with _system(), _gemini(payload): with _system(), _gemini(payload):
text, segs, _ = await tc.correct("c1", "x y z w", SEGS, system_id="sys-1", talkgroup_id=9048) # A realistic joined original (code-free, like the correction): a
# placeholder like "x y z w" now trips the server-26#162 growth guard.
text, segs, _ = await tc.correct("c1", "Shout out to Optum, back to Rose.", SEGS,
system_id="sys-1", talkgroup_id=9048)
assert segs is None assert segs is None
assert text == "Show it out to Ossining, back to Route 9." assert text == "Show it out to Ossining, back to Route 9."
@@ -279,3 +282,30 @@ async def test_reference_data_reaches_the_prompt():
assert "Sing Sing — state prison" in p, "a term without its meaning is half the information" assert "Sing Sing — state prison" in p, "a term without its meaning is half the information"
assert "Ossining, Westchester, New York" in p, "state must reach the prompt (server-26#36)" assert "Ossining, Westchester, New York" in p, "state must reach the prompt (server-26#36)"
assert "10-13=officer needs assistance" in p assert "10-13=officer needs assistance" in p
# ── server-26#162, the rest: bare numbers and invented words ────────────────
@pytest.mark.asyncio
async def test_a_bare_number_turned_into_a_word_is_rejected():
"""The live case: a bare "7" came back as "ShotSpotter"."""
payload = {"corrected": "Car ShotSpotter on Home Street and Forest Ave."}
with _system(), _gemini(payload):
text, _, _ = await tc.correct("c1", "Car 7 on Holmes Street and 4th and Rowe.", None, system_id="sys-1")
assert text is None
@pytest.mark.asyncio
async def test_a_place_fix_that_keeps_its_numbers_still_passes():
payload = {"corrected": "Car 7 on Home Street and Forest Ave."}
with _system(), _gemini(payload):
text, _, _ = await tc.correct("c1", "Car 7 on Holmes Street and 4th and Rowe.", None, system_id="sys-1")
assert text == "Car 7 on Home Street and Forest Ave."
@pytest.mark.asyncio
async def test_a_correction_that_writes_new_sentences_is_rejected():
payload = {"corrected": "Car 7 on Main Street. Suspect is armed and fled northbound in a red sedan."}
with _system(), _gemini(payload):
text, _, _ = await tc.correct("c1", "Car 7 on Mane Street.", None, system_id="sys-1")
assert text is None
@@ -29,7 +29,7 @@ async def _run_ingest(skip_reason):
patch("app.internal.firestore.doc_get_cached", patch("app.internal.firestore.doc_get_cached",
AsyncMock(return_value={"system_id": "sys-1", "ai_flags": {}})), \ AsyncMock(return_value={"system_id": "sys-1", "ai_flags": {}})), \
patch.object(upload, "fstore") as fs, \ patch.object(upload, "fstore") as fs, \
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr, \ patch.object(upload, "_correlate", AsyncMock(return_value=(None, {}))) as corr, \
patch("app.internal.transcription.transcribe_call", patch("app.internal.transcription.transcribe_call",
AsyncMock(return_value=("10-8", []))), \ AsyncMock(return_value=("10-8", []))), \
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])), \ patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])), \
+24
View File
@@ -0,0 +1,24 @@
"""node-26#49 — releases are visible: /health reports the server's, checkin carries the node's."""
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.internal.mqtt_handler import MQTTHandler
from app.main import app
def test_health_reports_the_release():
with patch("app.main._VERSION", "1.1.4"):
body = TestClient(app).get("/health").json()
assert body["version"] == "1.1.4"
assert "git_sha" in body
@pytest.mark.asyncio
async def test_checkin_stores_the_node_release():
with patch("app.internal.mqtt_handler.fstore") as fs:
fs.doc_get = AsyncMock(return_value=None)
fs.doc_set = AsyncMock()
await MQTTHandler()._handle_checkin("n1", {"name": "Pi", "version": "1.1.10"})
assert fs.doc_set.call_args[0][2]["version"] == "1.1.10"
+118 -111
View File
@@ -85,7 +85,7 @@ function fmtDatetime(iso: string | null | undefined) {
const ROLE_COLORS: Record<UserRole, string> = { const ROLE_COLORS: Record<UserRole, string> = {
admin: "bg-indigo-900 text-indigo-300", admin: "bg-indigo-900 text-indigo-300",
operator: "bg-green-900 text-green-300", operator: "bg-green-900 text-green-300",
viewer: "bg-gray-800 text-gray-400", viewer: "bg-raised text-ink-2",
}; };
function RoleBadge({ role }: { role: UserRole }) { function RoleBadge({ role }: { role: UserRole }) {
@@ -136,14 +136,14 @@ function FeaturesTab() {
</div> </div>
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <div className="bg-surface border border-line rounded-xl divide-y divide-line">
{FLAG_META.map(({ key, label, description }) => ( {FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4"> <div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p> <p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p> <p className="text-ink-muted text-xs mt-0.5 leading-snug">{description}</p>
</div> </div>
<Toggle <Toggle
enabled={flags?.[key] ?? true} enabled={flags?.[key] ?? true}
@@ -238,30 +238,30 @@ function CorrelationDebugTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls. Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
Copy the JSON and paste it to Claude to diagnose correlation problems. Copy the JSON and paste it to Claude to diagnose correlation problems.
</p> </p>
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Incidents (limit)</label> <label className="text-xs text-ink-2 block mb-1">Incidents (limit)</label>
<input <input
type="number" type="number"
min={1} max={100} min={1} max={100}
value={limit} value={limit}
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))} onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-24 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Orphan scan window (hours)</label> <label className="text-xs text-ink-2 block mb-1">Orphan scan window (hours)</label>
<input <input
type="number" type="number"
min={1} max={168} min={1} max={168}
value={orphanHours} value={orphanHours}
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))} onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<button <button
@@ -281,12 +281,12 @@ function CorrelationDebugTab() {
{meta && ( {meta && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at} {meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
</p> </p>
<button <button
onClick={handleCopy} onClick={handleCopy}
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-gray-700 bg-gray-900 text-gray-300 hover:text-white transition-colors" className="text-xs font-mono px-3 py-1.5 rounded-lg border border-line-strong bg-surface text-ink-2 hover:text-white transition-colors"
> >
{copied ? "Copied!" : "Copy JSON"} {copied ? "Copied!" : "Copy JSON"}
</button> </button>
@@ -295,22 +295,22 @@ function CorrelationDebugTab() {
{meta && Object.keys(pathCounts).length > 0 && ( {meta && Object.keys(pathCounts).length > 0 && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">corr_path distribution</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">corr_path distribution</p>
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => ( {Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
<div key={path} className="flex justify-between text-xs font-mono"> <div key={path} className="flex justify-between text-xs font-mono">
<span className="text-gray-300">{path}</span> <span className="text-ink-2">{path}</span>
<span className="text-indigo-400">{n}</span> <span className="text-indigo-400">{n}</span>
</div> </div>
))} ))}
</div> </div>
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">fit_signal distribution</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">fit_signal distribution</p>
{Object.keys(signalCounts).length === 0 {Object.keys(signalCounts).length === 0
? <p className="text-xs text-gray-600 font-mono">No signal data yet — deploy correlator update first</p> ? <p className="text-xs text-ink-muted font-mono">No signal data yet — deploy correlator update first</p>
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => ( : Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
<div key={sig} className="flex justify-between text-xs font-mono"> <div key={sig} className="flex justify-between text-xs font-mono">
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-gray-300"}>{sig}</span> <span className={sig === "time_fallback" ? "text-yellow-400" : "text-ink-2"}>{sig}</span>
<span className="text-indigo-400">{n}</span> <span className="text-indigo-400">{n}</span>
</div> </div>
)) ))
@@ -320,13 +320,13 @@ function CorrelationDebugTab() {
)} )}
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && ( {meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1"> <div className="bg-surface border border-line rounded-xl p-4 space-y-1">
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">orphans by talkgroup</p> <p className="text-xs text-ink-2 font-mono font-semibold mb-2">orphans by talkgroup</p>
{meta.orphans_by_talkgroup.map((tg) => ( {meta.orphans_by_talkgroup.map((tg) => (
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono"> <div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
<span className="text-gray-300">{tg.talkgroup_name} <span className="text-gray-600">({tg.talkgroup_id})</span></span> <span className="text-ink-2">{tg.talkgroup_name} <span className="text-ink-muted">({tg.talkgroup_id})</span></span>
<span className="text-gray-400"> <span className="text-ink-2">
{tg.count} total · <span className="text-gray-500">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span> {tg.count} total · <span className="text-ink-muted">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
</span> </span>
</div> </div>
))} ))}
@@ -336,7 +336,7 @@ function CorrelationDebugTab() {
{json && ( {json && (
<pre <pre
ref={preRef} ref={preRef}
className="bg-gray-950 border border-gray-800 rounded-xl p-4 text-xs text-gray-300 font-mono overflow-auto max-h-[60vh] whitespace-pre" className="bg-page border border-line rounded-xl p-4 text-xs text-ink-2 font-mono overflow-auto max-h-[60vh] whitespace-pre"
> >
{json} {json}
</pre> </pre>
@@ -455,13 +455,13 @@ function UserDetailPanel({
const isSelf = user.uid === currentUid; const isSelf = user.uid === currentUid;
return ( return (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-5 space-y-5 font-mono">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-white font-semibold">{detail.email}</p> <p className="text-white font-semibold">{detail.email}</p>
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p> <p className="text-xs text-ink-muted mt-0.5">{detail.uid}</p>
</div> </div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button> <button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors text-xl leading-none">×</button>
</div> </div>
{error && ( {error && (
@@ -472,21 +472,21 @@ function UserDetailPanel({
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Display Name</label> <label className="text-xs text-ink-2 block mb-1">Display Name</label>
<input <input
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={(e) => setEditName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Full name" placeholder="Full name"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={editRole} value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)} onChange={(e) => setEditRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -496,13 +496,13 @@ function UserDetailPanel({
{editRole === "operator" && ( {editRole === "operator" && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span> Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
</label> </label>
<input <input
value={editNodes} value={editNodes}
onChange={(e) => setEditNodes(e.target.value)} onChange={(e) => setEditNodes(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456" placeholder="node-abc123, node-def456"
/> />
</div> </div>
@@ -517,10 +517,10 @@ function UserDetailPanel({
</button> </button>
</div> </div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs"> <div className="border-t border-line pt-4 space-y-2 text-xs">
<div className="flex justify-between items-center gap-3"> <div className="flex justify-between items-center gap-3">
<span className="text-gray-500">Organization</span> <span className="text-ink-muted">Organization</span>
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}> <span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-ink-2" : "text-yellow-400"}`}>
{!detail.org_id {!detail.org_id
? "None: sees no data" ? "None: sees no data"
: detail.org_id === myOrgId : detail.org_id === myOrgId
@@ -538,34 +538,34 @@ function UserDetailPanel({
</button> </button>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Status</span> <span className="text-ink-muted">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}> <span className={detail.disabled ? "text-red-400" : "text-green-400"}>
{detail.disabled ? "Disabled" : "Active"} {detail.disabled ? "Disabled" : "Active"}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Discord</span> <span className="text-ink-muted">Discord</span>
<span className="text-gray-300"> <span className="text-ink-2">
{detail.discord_linked {detail.discord_linked
? `@${detail.discord_username ?? detail.discord_user_id}` ? `@${detail.discord_username ?? detail.discord_user_id}`
: "Not linked"} : "Not linked"}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Created</span> <span className="text-ink-muted">Created</span>
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span> <span className="text-ink-2">{fmtDate(detail.creation_time)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500">Last sign-in</span> <span className="text-ink-muted">Last sign-in</span>
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span> <span className="text-ink-2">{fmtDate(detail.last_sign_in)}</span>
</div> </div>
</div> </div>
{(detail.sessions?.length ?? 0) > 0 && ( {(detail.sessions?.length ?? 0) > 0 && (
<div className="border-t border-gray-800 pt-4"> <div className="border-t border-line pt-4">
<button <button
onClick={() => setShowSessions((v) => !v)} onClick={() => setShowSessions((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink transition-colors flex items-center gap-1"
> >
<span>{showSessions ? "▲" : "▼"}</span> <span>{showSessions ? "▲" : "▼"}</span>
<span>Login history ({detail.sessions?.length} recent)</span> <span>Login history ({detail.sessions?.length} recent)</span>
@@ -573,9 +573,9 @@ function UserDetailPanel({
{showSessions && ( {showSessions && (
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto"> <div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
{detail.sessions?.map((s) => ( {detail.sessions?.map((s) => (
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4"> <div key={s.session_id} className="text-xs text-ink-2 flex justify-between gap-4">
<span>{fmtDatetime(s.timestamp)}</span> <span>{fmtDatetime(s.timestamp)}</span>
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span> <span className="text-ink-muted truncate">{s.ip ?? "—"}</span>
</div> </div>
))} ))}
</div> </div>
@@ -583,7 +583,7 @@ function UserDetailPanel({
</div> </div>
)} )}
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap"> <div className="border-t border-line pt-4 flex gap-4 flex-wrap">
{!isSelf ? ( {!isSelf ? (
<> <>
<button <button
@@ -602,7 +602,7 @@ function UserDetailPanel({
</button> </button>
</> </>
) : ( ) : (
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p> <p className="text-xs text-ink-muted">Cannot disable or delete your own account.</p>
)} )}
</div> </div>
</div> </div>
@@ -667,19 +667,19 @@ function CreateUserModal({
if (inviteLink) { if (inviteLink) {
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
<h2 className="text-white font-semibold">User Created</h2> <h2 className="text-white font-semibold">User Created</h2>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Share this one-time invite link with the new user so they can set their password. Share this one-time invite link with the new user so they can set their password.
It expires after use. It expires after use.
</p> </p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p> <p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={copyLink} onClick={copyLink}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
{copied ? "Copied!" : "Copy link"} {copied ? "Copied!" : "Copy link"}
</button> </button>
@@ -697,37 +697,37 @@ function CreateUserModal({
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 font-mono">
<h2 className="text-white font-semibold mb-4">Create User</h2> <h2 className="text-white font-semibold mb-4">Create User</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="user@example.com" placeholder="user@example.com"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Display Name <span className="text-gray-600">(optional)</span> Display Name <span className="text-ink-muted">(optional)</span>
</label> </label>
<input <input
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Jane Smith" placeholder="Jane Smith"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={role} value={role}
onChange={(e) => setRole(e.target.value as UserRole)} onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -736,14 +736,14 @@ function CreateUserModal({
</div> </div>
{role === "operator" && ( {role === "operator" && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span> Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
</label> </label>
<input <input
value={nodeIds} value={nodeIds}
onChange={(e) => setNodeIds(e.target.value)} onChange={(e) => setNodeIds(e.target.value)}
required required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456" placeholder="node-abc123, node-def456"
/> />
</div> </div>
@@ -760,7 +760,7 @@ function CreateUserModal({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -820,7 +820,7 @@ function UsersTab({ currentUid }: { currentUid: string }) {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p> <p className="text-xs text-ink-muted font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors" className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
@@ -836,14 +836,14 @@ function UsersTab({ currentUid }: { currentUid: string }) {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No users found.</p> <p className="text-ink-muted text-sm font-mono">No users found.</p>
) : ( ) : (
<div className="border border-gray-800 rounded-xl overflow-hidden"> <div className="border border-line rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider"> <tr className="bg-surface text-ink-muted uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Email</th> <th className="px-4 py-2.5 text-left">Email</th>
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th> <th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
<th className="px-4 py-2.5 text-left">Role</th> <th className="px-4 py-2.5 text-left">Role</th>
@@ -857,17 +857,17 @@ function UsersTab({ currentUid }: { currentUid: string }) {
{users.map((u) => ( {users.map((u) => (
<tr <tr
key={u.uid} key={u.uid}
className={`border-t border-gray-800 transition-colors ${ className={`border-t border-line transition-colors ${
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60" selectedUid === u.uid ? "bg-raised/60" : "hover:bg-raised/60"
}`} }`}
> >
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td> <td className="px-4 py-2.5 text-ink">{u.email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td> <td className="px-4 py-2.5 text-ink-2 hidden lg:table-cell">{u.display_name ?? "—"}</td>
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td> <td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell"> <td className="px-4 py-2.5 text-ink-muted hidden sm:table-cell">
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"} {u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
</td> </td>
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td> <td className="px-4 py-2.5 text-ink-muted hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
{u.disabled {u.disabled
? <span className="text-red-500">Disabled</span> ? <span className="text-red-500">Disabled</span>
@@ -952,15 +952,15 @@ function AuditLogTab() {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p> <p className="text-ink-muted text-sm font-mono">No audit entries yet.</p>
) : ( ) : (
<> <>
<div className="border border-gray-800 rounded-xl overflow-hidden"> <div className="border border-line rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider"> <tr className="bg-surface text-ink-muted uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Time</th> <th className="px-4 py-2.5 text-left">Time</th>
<th className="px-4 py-2.5 text-left">Action</th> <th className="px-4 py-2.5 text-left">Action</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th> <th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
@@ -970,12 +970,12 @@ function AuditLogTab() {
</thead> </thead>
<tbody> <tbody>
{entries.map((e) => ( {entries.map((e) => (
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40"> <tr key={e.log_id} className="border-t border-line hover:bg-raised/40">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td> <td className="px-4 py-2.5 text-ink-muted whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td> <td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td> <td className="px-4 py-2.5 text-ink-2 hidden sm:table-cell">{e.actor_email}</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td> <td className="px-4 py-2.5 text-ink-2 hidden md:table-cell">{e.target_email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate"> <td className="px-4 py-2.5 text-ink-muted max-w-xs truncate">
{Object.keys(e.details).length > 0 {Object.keys(e.details).length > 0
? Object.entries(e.details) ? Object.entries(e.details)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`) .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
@@ -1029,26 +1029,26 @@ function StaleCallsTab() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event. Finds calls stuck in <span className="text-ink-2">active</span> status because a node rebooted before sending an end-call event.
Preview first, then close. Preview first, then close.
</p> </p>
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label> <label className="text-xs text-ink-2 block mb-1">Older than (minutes)</label>
<input <input
type="number" type="number"
min={1} max={1440} min={1} max={1440}
value={minutes} value={minutes}
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))} onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<button <button
onClick={() => run(true)} onClick={() => run(true)}
disabled={loading} disabled={loading}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors" className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
> >
{loading ? "Working…" : "Preview"} {loading ? "Working…" : "Preview"}
</button> </button>
@@ -1068,18 +1068,18 @@ function StaleCallsTab() {
)} )}
{result && ( {result && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2"> <div className="bg-surface border border-line rounded-xl p-4 space-y-2">
<p className="text-sm font-mono text-white"> <p className="text-sm font-mono text-white">
{result.dry_run ? "Preview: " : "Closed: "} {result.dry_run ? "Preview: " : "Closed: "}
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}> <span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
{result.count} stale call{result.count !== 1 ? "s" : ""} {result.count} stale call{result.count !== 1 ? "s" : ""}
</span> </span>
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>} {result.count === 0 && <span className="text-ink-muted"> — nothing to clear</span>}
</p> </p>
{result.call_ids.length > 0 && ( {result.call_ids.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-0.5"> <div className="max-h-40 overflow-y-auto space-y-0.5">
{result.call_ids.map((id) => ( {result.call_ids.map((id) => (
<p key={id} className="text-xs font-mono text-gray-400">{id}</p> <p key={id} className="text-xs font-mono text-ink-2">{id}</p>
))} ))}
</div> </div>
)} )}
@@ -1104,17 +1104,17 @@ function fmtPct(x: number | null | undefined): string {
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) { function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2"> <div className="bg-surface border border-line rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
<div> <div>
<p className="text-xs text-gray-500 font-mono">Calls verified</p> <p className="text-xs text-ink-muted font-mono">Calls verified</p>
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p> <p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 font-mono">Raw WER (whisper-1)</p> <p className="text-xs text-ink-muted font-mono">Raw WER (whisper-1)</p>
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p> <p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 font-mono">Corrected WER (shipped)</p> <p className="text-xs text-ink-muted font-mono">Corrected WER (shipped)</p>
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p> <p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
</div> </div>
{stats && stats.eval_count > 0 && stats.eval_count < 20 && ( {stats && stats.eval_count > 0 && stats.eval_count < 20 && (
@@ -1169,8 +1169,10 @@ function SttEvalTab() {
// scanning", not "done", so this fires again on its own. // scanning", not "done", so this fires again on its own.
useEffect(() => { useEffect(() => {
if (queue.length === 0 && !exhausted) loadBatch(); if (queue.length === 0 && !exhausted) loadBatch();
// `cursor` is a dep so an empty page that advanced the cursor scans the
// next one; without it the tab sat on "Loading…" forever.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue.length, exhausted]); }, [queue.length, exhausted, cursor]);
useEffect(() => { useEffect(() => {
setDraft(current ? (current.transcript_corrected || current.transcript || "") : ""); setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
@@ -1197,7 +1199,7 @@ function SttEvalTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Listen to the audio, correct the transcript below until it matches what was actually said, then save. Listen to the audio, correct the transcript below until it matches what was actually said, then save.
This never touches the call&apos;s real transcript or re-runs anything — it only records ground truth This never touches the call&apos;s real transcript or re-runs anything — it only records ground truth
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off. for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
@@ -1212,8 +1214,8 @@ function SttEvalTab() {
)} )}
{current ? ( {current ? (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3"> <div className="bg-surface border border-line rounded-xl p-4 space-y-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-gray-400"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-ink-2">
<span>{new Date(current.started_at).toLocaleString()}</span> <span>{new Date(current.started_at).toLocaleString()}</span>
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span> <span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
</div> </div>
@@ -1222,18 +1224,18 @@ function SttEvalTab() {
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
<audio controls src={current.audio_url} className="w-full h-9" /> <audio controls src={current.audio_url} className="w-full h-9" />
) : ( ) : (
<p className="text-xs text-gray-500 italic">No audio on this call — skip it.</p> <p className="text-xs text-ink-muted italic">No audio on this call — skip it.</p>
)} )}
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Machine transcript (pre-filled) — correct it into what was actually said Machine transcript (pre-filled) — correct it into what was actually said
</label> </label>
<textarea <textarea
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
rows={4} rows={4}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
@@ -1248,15 +1250,15 @@ function SttEvalTab() {
<button <button
onClick={skip} onClick={skip}
disabled={saving} disabled={saving}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors" className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
> >
Skip Skip
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4"> <div className="bg-surface border border-line rounded-xl p-4">
<p className="text-sm font-mono text-gray-400"> <p className="text-sm font-mono text-ink-2">
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"} {loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
</p> </p>
</div> </div>
@@ -1309,16 +1311,19 @@ export default function AdminPage() {
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages"; const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
return ( return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}> // The width toggle wraps the tab body only. It used to wrap the tab bar
// too, so the bar reflowed on every switch and left the cursor over
// whatever landed under it (e.g. Calls -> "Close calls").
<div className="space-y-6">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1> <h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{TAB_LABELS.map(({ key, label }) => ( {TAB_LABELS.map(({ key, label }) => (
<button <button
key={key} key={key}
onClick={() => setTab(key)} onClick={() => setTab(key)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300" tab === key ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{label} {label}
@@ -1326,6 +1331,7 @@ export default function AdminPage() {
))} ))}
</div> </div>
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
{tab === "features" && <FeaturesTab />} {tab === "features" && <FeaturesTab />}
{tab === "cameras" && <CameraProvidersTab />} {tab === "cameras" && <CameraProvidersTab />}
{tab === "outages" && <OutageProvidersTab />} {tab === "outages" && <OutageProvidersTab />}
@@ -1336,5 +1342,6 @@ export default function AdminPage() {
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />} {tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />} {tab === "audit" && <AuditLogTab />}
</div> </div>
</div>
); );
} }
+55 -37
View File
@@ -5,6 +5,8 @@ import { useAuth } from "@/components/AuthProvider";
import { useAlerts } from "@/lib/useAlerts"; import { useAlerts } from "@/lib/useAlerts";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import { ErrorBanner } from "@/components/ui/EmptyState";
import type { AlertRule } from "@/lib/types"; import type { AlertRule } from "@/lib/types";
function fmtTime(iso: string) { function fmtTime(iso: string) {
@@ -20,6 +22,10 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const [webhook, setWebhook] = useState(""); const [webhook, setWebhook] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Load and toggle/delete failures (server-26#284) — separate from the create
// form's own `error`, and it keeps the list from claiming "no rules" when
// the fetch actually failed.
const [listError, setListError] = useState<string | null>(null);
async function load() { async function load() {
if (loaded) return; if (loaded) return;
@@ -27,8 +33,9 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const data = await c2api.getAlertRules() as AlertRule[]; const data = await c2api.getAlertRules() as AlertRule[];
setRules(data); setRules(data);
setLoaded(true); setLoaded(true);
setListError(null);
} catch (e) { } catch (e) {
console.error(e); setListError(`Couldn't load alert rules: ${errorMessage(e)}`);
} }
} }
@@ -50,8 +57,8 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const created = await c2api.createAlertRule(body) as AlertRule; const created = await c2api.createAlertRule(body) as AlertRule;
setRules((prev) => [created, ...prev]); setRules((prev) => [created, ...prev]);
setName(""); setKeywords(""); setTgIds(""); setWebhook(""); setName(""); setKeywords(""); setTgIds(""); setWebhook("");
} catch { } catch (e) {
setError("Failed to create rule."); setError(`Failed to create rule: ${errorMessage(e)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -61,52 +68,55 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
try { try {
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled }); await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r)); setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r));
} catch (e) { console.error(e); } setListError(null);
} catch (e) { setListError(`Couldn't update "${rule.name}": ${errorMessage(e)}`); }
} }
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!window.confirm("Delete this alert rule?")) return;
try { try {
await c2api.deleteAlertRule(id); await c2api.deleteAlertRule(id);
setRules((prev) => prev.filter((r) => r.rule_id !== id)); setRules((prev) => prev.filter((r) => r.rule_id !== id));
} catch (e) { console.error(e); } setListError(null);
} catch (e) { setListError(`Couldn't delete the rule: ${errorMessage(e)}`); }
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{isAdmin && ( {isAdmin && (
<form onSubmit={handleCreate} className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4"> <form onSubmit={handleCreate} className="bg-surface border border-line rounded-xl p-5 space-y-4">
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3> <h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Structure Fire" placeholder="e.g. Structure Fire"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Keywords (comma-separated)</label> <label className="text-xs text-ink-2 block mb-1">Keywords (comma-separated)</label>
<input <input
value={keywords} onChange={(e) => setKeywords(e.target.value)} value={keywords} onChange={(e) => setKeywords(e.target.value)}
placeholder="fire, smoke, structure" placeholder="fire, smoke, structure"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Talkgroup IDs (comma-separated)</label> <label className="text-xs text-ink-2 block mb-1">Talkgroup IDs (comma-separated)</label>
<input <input
value={tgIds} onChange={(e) => setTgIds(e.target.value)} value={tgIds} onChange={(e) => setTgIds(e.target.value)}
placeholder="9048, 9600" placeholder="9048, 9600"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Discord Webhook URL (optional)</label> <label className="text-xs text-ink-2 block mb-1">Discord Webhook URL (optional)</label>
<input <input
value={webhook} onChange={(e) => setWebhook(e.target.value)} value={webhook} onChange={(e) => setWebhook(e.target.value)}
placeholder="https://discord.com/api/webhooks/…" placeholder="https://discord.com/api/webhooks/…"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
</div> </div>
@@ -120,13 +130,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
</form> </form>
)} )}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> {listError && <ErrorBanner message={listError} />}
{rules.length === 0 ? (
<p className="text-gray-600 text-sm font-mono p-4">No alert rules configured.</p> <div className="bg-surface border border-line rounded-xl overflow-hidden">
{!loaded ? (
<p className="text-ink-muted text-sm font-mono p-4">{listError ? "Rules unavailable." : "Loading…"}</p>
) : rules.length === 0 ? (
<p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
) : ( ) : (
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase"> <tr className="border-b border-line text-xs text-ink-muted uppercase">
<th className="px-4 py-3">Name</th> <th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Keywords</th> <th className="px-4 py-3">Keywords</th>
<th className="px-4 py-3">Talkgroups</th> <th className="px-4 py-3">Talkgroups</th>
@@ -137,11 +151,11 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
</thead> </thead>
<tbody> <tbody>
{rules.map((rule) => ( {rules.map((rule) => (
<tr key={rule.rule_id} className="border-b border-gray-800"> <tr key={rule.rule_id} className="border-b border-line">
<td className="px-4 py-3 text-white text-sm">{rule.name}</td> <td className="px-4 py-3 text-white text-sm">{rule.name}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
<td className="px-4 py-3 text-gray-400 text-xs"> <td className="px-4 py-3 text-ink-2 text-xs">
{rule.discord_webhook ? ( {rule.discord_webhook ? (
<span className="text-green-400">configured</span> <span className="text-green-400">configured</span>
) : "—"} ) : "—"}
@@ -153,13 +167,13 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${ className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
rule.enabled rule.enabled
? "bg-green-900 text-green-300 hover:bg-green-800" ? "bg-green-900 text-green-300 hover:bg-green-800"
: "bg-gray-800 text-gray-400 hover:bg-gray-700" : "bg-raised text-ink-2 hover:bg-gray-700"
}`} }`}
> >
{rule.enabled ? "enabled" : "disabled"} {rule.enabled ? "enabled" : "disabled"}
</button> </button>
) : ( ) : (
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-gray-500"}`}> <span className={`text-xs ${rule.enabled ? "text-green-400" : "text-ink-muted"}`}>
{rule.enabled ? "enabled" : "disabled"} {rule.enabled ? "enabled" : "disabled"}
</span> </span>
)} )}
@@ -188,11 +202,13 @@ export default function AlertsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { alerts, loading, error } = useAlerts(); const { alerts, loading, error } = useAlerts();
const [tab, setTab] = useState<"events" | "rules">("events"); const [tab, setTab] = useState<"events" | "rules">("events");
const [ackError, setAckError] = useState<string | null>(null);
async function handleAcknowledge(id: string) { async function handleAcknowledge(id: string) {
setAckError(null);
try { try {
await c2api.acknowledgeAlert(id); await c2api.acknowledgeAlert(id);
} catch (e) { console.error(e); } } catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); }
} }
const unacked = alerts.filter((a) => !a.acknowledged); const unacked = alerts.filter((a) => !a.acknowledged);
@@ -209,13 +225,13 @@ export default function AlertsPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <div className="flex gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => ( {(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
<button <button
key={t} key={t}
onClick={() => setTab(t as "events" | "rules")} onClick={() => setTab(t as "events" | "rules")}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300" tab === t ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{t === "events" ? "Triggered Alerts" : "Rules"} {t === "events" ? "Triggered Alerts" : "Rules"}
@@ -223,9 +239,11 @@ export default function AlertsPage() {
))} ))}
</div> </div>
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
{tab === "events" && ( {tab === "events" && (
loading ? ( loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : error ? ( ) : error ? (
<p className="text-red-400 text-sm font-mono"> <p className="text-red-400 text-sm font-mono">
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error) {/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
@@ -233,7 +251,7 @@ export default function AlertsPage() {
: `Couldn't load alerts: ${error}`} : `Couldn't load alerts: ${error}`}
</p> </p>
) : alerts.length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p> <p className="text-ink-muted text-sm font-mono">No alerts triggered yet.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text, {/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
@@ -242,10 +260,10 @@ export default function AlertsPage() {
variant="inline" variant="inline"
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words." detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
/> />
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="bg-surface border border-line rounded-xl overflow-hidden">
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase"> <tr className="border-b border-line text-xs text-ink-muted uppercase">
<th className="px-4 py-3">Rule</th> <th className="px-4 py-3">Rule</th>
<th className="px-4 py-3">Talkgroup</th> <th className="px-4 py-3">Talkgroup</th>
<th className="px-4 py-3">Matched</th> <th className="px-4 py-3">Matched</th>
@@ -258,10 +276,10 @@ export default function AlertsPage() {
{alerts.map((alert) => ( {alerts.map((alert) => (
<tr <tr
key={alert.alert_id} key={alert.alert_id}
className={`border-b border-gray-800 ${alert.acknowledged ? "opacity-50" : ""}`} className={`border-b border-line ${alert.acknowledged ? "opacity-50" : ""}`}
> >
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td> <td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono"> <td className="px-4 py-3 text-ink-2 text-xs font-mono">
{alert.talkgroup_name || alert.talkgroup_id || "—"} {alert.talkgroup_name || alert.talkgroup_id || "—"}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -273,17 +291,17 @@ export default function AlertsPage() {
))} ))}
</div> </div>
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono max-w-xs truncate"> <td className="px-4 py-3 text-ink-2 text-xs font-mono max-w-xs truncate">
{alert.transcript_snippet || "—"} {alert.transcript_snippet || "—"}
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(alert.triggered_at)}</td> <td className="px-4 py-3 text-ink-2 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{alert.acknowledged ? ( {alert.acknowledged ? (
<span className="text-xs text-gray-500">acked</span> <span className="text-xs text-ink-muted">acked</span>
) : ( ) : (
<button <button
onClick={() => handleAcknowledge(alert.alert_id)} onClick={() => handleAcknowledge(alert.alert_id)}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors" className="text-xs bg-raised hover:bg-gray-700 text-ink-2 px-2 py-1 rounded transition-colors"
> >
Acknowledge Acknowledge
</button> </button>
+4 -4
View File
@@ -68,7 +68,7 @@ function ChevronIcon({ open }: { open: boolean }) {
<svg <svg
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" strokeLinecap="round" strokeLinejoin="round"
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} className={`text-ink-muted shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
> >
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
@@ -83,10 +83,10 @@ export default function FaqPage() {
<div className="text-center"> <div className="text-center">
<Badge tone="brand">FAQ</Badge> <Badge tone="brand">FAQ</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1> <h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
<p className="text-gray-400 mt-4">Can&apos;t find what you&apos;re looking for? Sign in and reach out from your account.</p> <p className="text-ink-2 mt-4">Can&apos;t find what you&apos;re looking for? Sign in and reach out from your account.</p>
</div> </div>
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800"> <div className="mt-12 divide-y divide-line border-t border-b border-line">
{FAQS.map((item, i) => { {FAQS.map((item, i) => {
const open = openIndex === i; const open = openIndex === i;
return ( return (
@@ -100,7 +100,7 @@ export default function FaqPage() {
<ChevronIcon open={open} /> <ChevronIcon open={open} />
</button> </button>
{open && ( {open && (
<div className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div> <div className="text-ink-2 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
)} )}
</div> </div>
); );
+5 -5
View File
@@ -17,7 +17,7 @@ const SECTIONS: {
body: body:
"The correlation engine groups related transmissions — across talkgroups and even across nodes — into a single incident. A pursuit renders as a path through every checkin point heard while it moved; a structure fire or accident renders as a pin at the location dispatch gave.", "The correlation engine groups related transmissions — across talkgroups and even across nodes — into a single incident. A pursuit renders as a path through every checkin point heard while it moved; a structure fire or accident renders as a pin at the location dispatch gave.",
points: [ points: [
"Hybrid rule + LLM correlation with a cheap/smart consensus tiebreak", "Rules decide clear-cut matches; an LLM weighs in only on the ambiguous ones",
"Distance, timing, shared units, and talkgroup signals all feed the match", "Distance, timing, shared units, and talkgroup signals all feed the match",
"Every call keeps its correlation debug trail for admins to audit", "Every call keeps its correlation debug trail for admins to audit",
], ],
@@ -76,7 +76,7 @@ export default function FeaturesPage() {
<div className="max-w-2xl"> <div className="max-w-2xl">
<Badge tone="brand">Features</Badge> <Badge tone="brand">Features</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1> <h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
<p className="text-gray-400 mt-4 leading-relaxed"> <p className="text-ink-2 mt-4 leading-relaxed">
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription, DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
correlation, mapping, and a live relay — end to end. correlation, mapping, and a live relay — end to end.
</p> </p>
@@ -88,12 +88,12 @@ export default function FeaturesPage() {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p> <p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2> <h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p> <p className="text-ink-2 mt-3 leading-relaxed">{s.body}</p>
</div> </div>
<Card padding="lg" className="lg:col-span-3"> <Card padding="lg" className="lg:col-span-3">
<ul className="space-y-3"> <ul className="space-y-3">
{s.points.map((p) => ( {s.points.map((p) => (
<li key={p} className="flex items-start gap-3 text-sm text-gray-300"> <li key={p} className="flex items-start gap-3 text-sm text-ink-2">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" /> <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
{p} {p}
</li> </li>
@@ -107,7 +107,7 @@ export default function FeaturesPage() {
))} ))}
</div> </div>
<div className="text-center mt-20 pt-16 border-t border-gray-800"> <div className="text-center mt-20 pt-16 border-t border-line">
<h2 className="text-display-sm text-white">See it running on your own traffic</h2> <h2 className="text-display-sm text-white">See it running on your own traffic</h2>
<div className="mt-6"> <div className="mt-6">
<LinkButton href="/login" size="lg">Get started</LinkButton> <LinkButton href="/login" size="lg">Get started</LinkButton>
+98 -66
View File
@@ -4,6 +4,8 @@
@import 'leaflet/dist/leaflet.css'; @import 'leaflet/dist/leaflet.css';
/* Palette: "Iris" (2026-11) — graphite neutrals + one indigo-violet accent.
* Tailwind's gray/indigo scales are remapped to match in tailwind.config.ts. */
/* ── Design tokens ──────────────────────────────────────────────────────────── /* ── Design tokens ────────────────────────────────────────────────────────────
* Single source of truth for surface, ink and encoding colour. `:root` is the * Single source of truth for surface, ink and encoding colour. `:root` is the
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"]) * LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
@@ -16,43 +18,43 @@
* Do not add a hue here for a category; add a glyph. * Do not add a hue here for a category; add a glyph.
*/ */
:root { :root {
--page: #F4F6F9; --page: #F7F7F8;
--surface: #FFFFFF; --surface: #FFFFFF;
--raised: #F7F9FB; --raised: #F2F2F5;
--line: rgba(11,17,27,.11); --line: rgba(24,24,27,.10);
--line-strong: rgba(11,17,27,.22); --line-strong: rgba(24,24,27,.20);
--ink: #101620; --ink: #18181B;
--ink-2: #46536A; --ink-2: #52525B;
--ink-muted: #6B788C; --ink-muted: #6B6B76;
--accent: #2A78D6; --accent: #5E6AD2;
--sev-moderate: #B07800; --sev-moderate: #B54708;
--sev-major: #C0281F; --sev-major: #D92D20;
--map-bg: #E8ECF1; --map-bg: #ECECF0;
--map-block: #DFE4EB; --map-block: #E1E1E8;
--map-road: #FFFFFF; --map-road: #FFFFFF;
--map-water: #D3E2EE; --map-water: #DADDF2;
} }
.dark { .dark {
--page: #0B0E13; --page: #08090A;
--surface: #141922; --surface: #111214;
--raised: #1B2230; --raised: #1A1B1F;
--line: rgba(255,255,255,.09); --line: rgba(255,255,255,.08);
--line-strong: rgba(255,255,255,.17); --line-strong: rgba(255,255,255,.16);
--ink: #E8EBF0; --ink: #EDEDEF;
--ink-2: #A5B0C2; --ink-2: #A9A9B3;
--ink-muted: #77839A; --ink-muted: #8B8B96; /* 5.3:1 on raised — holds AA at 10-12px (#136) */
--accent: #3987E5; --accent: #8B93F0;
--sev-moderate: #C98500; --sev-moderate: #FDB022;
--sev-major: #D03B3B; --sev-major: #F97066; /* 6.6:1 as text on surface (#136) */
--map-bg: #0E131B; --map-bg: #0D0E11;
--map-block: #161C26; --map-block: #16171B;
--map-road: #232C3A; --map-road: #26272D;
--map-water: #12202E; --map-water: #141729;
} }
/* ── Base ─────────────────────────────────────────────────────────────────── */ /* ── Base ─────────────────────────────────────────────────────────────────── */
@@ -69,7 +71,7 @@ html, body {
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below /* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
* readable. Lift both to gray-500 (~4:1). */ * readable. Lift both to gray-500 (~4:1). */
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; } .dark .text-gray-600, .dark .text-gray-700 { color: #7A7A85; }
/* ── Light mode overrides ─────────────────────────────────────────────────── */ /* ── Light mode overrides ─────────────────────────────────────────────────── */
/* /*
@@ -80,34 +82,34 @@ html, body {
/* Structural backgrounds */ /* Structural backgrounds */
html:not(.dark), html:not(.dark),
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; } html:not(.dark) body { background-color: #f2f2f5; color: #18181b; }
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; } html:not(.dark) .bg-gray-950 { background-color: #f2f2f5 !important; }
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; } html:not(.dark) .bg-gray-950\/95 { background-color: rgba(242,242,245,0.95) !important; }
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; } html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; } html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; } html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; } html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; } html:not(.dark) .bg-gray-800 { background-color: #f2f2f5 !important; }
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; } html:not(.dark) .bg-gray-800\/60 { background-color: rgba(228,228,231,0.70) !important; }
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; } html:not(.dark) .bg-gray-800\/40 { background-color: rgba(242,242,245,0.60) !important; }
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; } html:not(.dark) .bg-gray-800\/30 { background-color: rgba(242,242,245,0.50) !important; }
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; } html:not(.dark) .bg-gray-700 { background-color: #e4e4e7 !important; }
/* Borders */ /* Borders */
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; } html:not(.dark) .border-gray-800 { border-color: #e4e4e7 !important; }
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; } html:not(.dark) .border-gray-700 { border-color: #d4d4d8 !important; }
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; } html:not(.dark) .border-gray-600 { border-color: #a1a1aa !important; }
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; } html:not(.dark) .border-gray-800\/60 { border-color: #e4e4e7 !important; }
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; } html:not(.dark) .divide-gray-800 > * + * { border-color: #e4e4e7 !important; }
/* Text */ /* Text */
html:not(.dark) .text-white { color: #0f172a !important; } html:not(.dark) .text-white { color: #18181b !important; }
html:not(.dark) .text-gray-100 { color: #1e293b !important; } html:not(.dark) .text-gray-100 { color: #27272a !important; }
html:not(.dark) .text-gray-200 { color: #1e293b !important; } html:not(.dark) .text-gray-200 { color: #27272a !important; }
html:not(.dark) .text-gray-300 { color: #334155 !important; } html:not(.dark) .text-gray-300 { color: #3f3f46 !important; }
html:not(.dark) .text-gray-400 { color: #475569 !important; } html:not(.dark) .text-gray-400 { color: #52525b !important; }
html:not(.dark) .text-gray-500 { color: #64748b !important; } html:not(.dark) .text-gray-500 { color: #6b6b76 !important; }
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; } html:not(.dark) .text-gray-600 { color: #a1a1aa !important; }
/* …except on saturated fills (primary/danger/success buttons), where the fill /* …except on saturated fills (primary/danger/success buttons), where the fill
* stays dark in both themes — remapping to navy made their labels unreadable. */ * stays dark in both themes — remapping to navy made their labels unreadable. */
@@ -116,15 +118,15 @@ html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; } .bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
/* Hover states */ /* Hover states */
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; } html:not(.dark) .hover\:bg-gray-900:hover { background-color: #fafafa !important; }
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; } html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; } html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f2f2f5 !important; }
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; } html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e4e4e7 !important; }
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; } html:not(.dark) .active\:bg-gray-800:active { background-color: #f2f2f5 !important; }
/* Hover text */ /* Hover text */
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; } html:not(.dark) .hover\:text-gray-300:hover { color: #3f3f46 !important; }
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; } html:not(.dark) .hover\:text-gray-200:hover { color: #27272a !important; }
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */ /* ── Accent badge palette (dark → light) ─────────────────────────────────── */
@@ -162,11 +164,11 @@ html:not(.dark) .text-green-400 { color: #16a34a !important; }
html:not(.dark) .border-green-800 { border-color: #86efac !important; } html:not(.dark) .border-green-800 { border-color: #86efac !important; }
/* Unconfigured / Info */ /* Unconfigured / Info */
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; } html:not(.dark) .bg-indigo-950 { background-color: #f0f1fd !important; }
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; } html:not(.dark) .bg-indigo-900 { background-color: #f0f1fd !important; }
html:not(.dark) .text-indigo-300 { color: #4338ca !important; } html:not(.dark) .text-indigo-300 { color: #4e58b8 !important; }
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; } html:not(.dark) .text-indigo-400 { color: #5e6ad2 !important; }
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; } html:not(.dark) .border-indigo-800 { border-color: #c8ccf6 !important; }
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */ /* ── Pulsing ring for recording nodes ────────────────────────────────────── */
@keyframes pulse-ring { @keyframes pulse-ring {
@@ -191,6 +193,28 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
z-index: 0; z-index: 0;
} }
/* The legend floats above the map's stacking context, so it would cover the
* expanded layers list (both live in the right-hand column). Hide it while the
* list is open. */
.relative:has(.leaflet-control-layers-expanded) > .drb-legend {
visibility: hidden;
}
/* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme
* overlay cards sat on a bright, busy map. Inverting the raster keeps the
* keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving
* the overlays the dark ground their palette was chosen for. */
.leaflet-tile-pane .drb-tiles-dark {
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9) saturate(0.6);
}
/* Leaflet popups stay white in both themes (their body text is a fixed
* gray-900), so the theme's muted ink — light grey in dark mode, ~2.9:1 on
* white — is pinned to a grey that holds AA on white (~6:1). */
.leaflet-popup-content .text-ink-muted {
color: #5F5F69;
}
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip /* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
* padding and let the tile's own theme colours show, so the tile is no bigger * padding and let the tile's own theme colours show, so the tile is no bigger
* than the video it holds. */ * than the video it holds. */
@@ -204,14 +228,22 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
} }
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
/* Placeholders must read as hints, not values: Tailwind's default #9ca3af on
* the dark surface looked like filled-in text (e.g. /systems Local Area showed
* "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */
.dark input::placeholder,
.dark textarea::placeholder {
color: #6E6E78;
font-style: italic;
}
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
html:not(.dark) textarea { html:not(.dark) textarea {
color: #0f172a; color: #18181b;
} }
html:not(.dark) input::placeholder, html:not(.dark) input::placeholder,
html:not(.dark) textarea::placeholder { html:not(.dark) textarea::placeholder {
color: #94a3b8; color: #a1a1aa;
} }
/* ── Marketing/product surface additions (2026-08 overhaul) ───────────────── /* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
@@ -222,8 +254,8 @@ html:not(.dark) textarea::placeholder {
*/ */
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */ /* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; } html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(94,106,210,0.08) !important; }
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; } html:not(.dark) .border-indigo-600\/40 { border-color: rgba(94,106,210,0.35) !important; }
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; } html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; } html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; } html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
@@ -232,10 +264,10 @@ html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !imp
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */ /* Marketing hero background — subtle radial glow, brand-neutral in both themes */
.marketing-hero-bg { .marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%); background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.25), transparent 60%);
} }
html:not(.dark) .marketing-hero-bg { html:not(.dark) .marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%); background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.12), transparent 60%);
} }
/* Skeleton loading shimmer */ /* Skeleton loading shimmer */
+39 -47
View File
@@ -2,7 +2,8 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import Link from "next/link";
import { useParams } from "next/navigation";
import { useIncident } from "@/lib/useIncidents"; import { useIncident } from "@/lib/useIncidents";
import { useCallsByIncident } from "@/lib/useCalls"; import { useCallsByIncident } from "@/lib/useCalls";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
@@ -13,25 +14,17 @@ import { SeverityMark } from "@/components/marks/SeverityMark";
import { isKnownSeverity } from "@/lib/severity"; import { isKnownSeverity } from "@/lib/severity";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import type { CallRecord } from "@/lib/types"; import { ErrorBanner } from "@/components/ui/EmptyState";
import { errorMessage } from "@/lib/c2error";
import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const EARLIER_PAGE_SIZE = 8; const EARLIER_PAGE_SIZE = 8;
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { function elapsedLabel(startedAt: string, active: boolean, endedAt: string): string {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
const start = new Date(startedAt).getTime(); const start = new Date(startedAt).getTime();
const end = active ? Date.now() : new Date(updatedAt).getTime(); const end = active ? Date.now() : new Date(endedAt).getTime();
const mins = Math.max(0, Math.round((end - start) / 60000)); const mins = Math.max(0, Math.round((end - start) / 60000));
if (mins < 60) return `${mins}m`; if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60); const hrs = Math.floor(mins / 60);
@@ -41,7 +34,6 @@ function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): st
export default function IncidentDetailPage() { export default function IncidentDetailPage() {
const params = useParams(); const params = useParams();
const id = params.id as string; const id = params.id as string;
const router = useRouter();
const { incident, loading } = useIncident(id); const { incident, loading } = useIncident(id);
const { calls, loading: callsLoading } = useCallsByIncident(id); const { calls, loading: callsLoading } = useCallsByIncident(id);
@@ -49,31 +41,15 @@ export default function IncidentDetailPage() {
const [summarizing, setSummarizing] = useState(false); const [summarizing, setSummarizing] = useState(false);
const [resolving, setResolving] = useState(false); const [resolving, setResolving] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE); const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
// Same ordering/filtering MapView's IncidentPathLayer uses, so the stop // Stop numbers and path length come from the same liveWall.pathStops() the
// number shown on a spine entry matches the number on its map marker. // map's IncidentPathLayer draws from (server-26#236), so the spine numbers
const geocodedCalls = useMemo( // exactly what the map numbers: only a movement incident (police/accident
() => // with a pursuit tag) is numbered, and an impossible-speed stop never is.
calls const path = useMemo(() => (incident ? pathStops(incident, calls) : null), [incident, calls]);
.filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords) const stopNumberByCallId = useMemo(() => (path ? stopNumbers(path) : new Map<string, number>()), [path]);
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at)),
[calls]
);
const stopNumberByCallId = useMemo(() => {
const m = new Map<string, number>();
geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1));
return m;
}, [geocodedCalls]);
const pathLengthKm = useMemo(() => {
let total = 0;
for (let i = 1; i < geocodedCalls.length; i++) {
total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!);
}
return total;
}, [geocodedCalls]);
const newestFirst = useMemo( const newestFirst = useMemo(
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), () => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
@@ -81,16 +57,20 @@ export default function IncidentDetailPage() {
); );
async function handleResolve() { async function handleResolve() {
// No reopen path exists (server-26#284), so resolving is final from the UI.
if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return;
setResolving(true); setResolving(true);
setActionError(null);
try { await c2api.updateIncident(id, { status: "resolved" }); } try { await c2api.updateIncident(id, { status: "resolved" }); }
catch (e) { console.error(e); } catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
finally { setResolving(false); } finally { setResolving(false); }
} }
async function handleSummarize() { async function handleSummarize() {
setSummarizing(true); setSummarizing(true);
setActionError(null);
try { await c2api.summarizeIncident(id); } try { await c2api.summarizeIncident(id); }
catch (e) { console.error(e); } catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); }
finally { setSummarizing(false); } finally { setSummarizing(false); }
} }
@@ -102,18 +82,23 @@ export default function IncidentDetailPage() {
const unitsCleared = incident.units_cleared ?? []; const unitsCleared = incident.units_cleared ?? [];
const vehicles = incident.vehicles ?? []; const vehicles = incident.vehicles ?? [];
const active = incident.status === "active"; const active = incident.status === "active";
// A manual resolve doesn't set resolved_at and updated_at can predate the
// last call, which showed "0m elapsed" on an 11-call incident — so a
// resolved incident runs to the latest of those and its newest call.
const resolvedEnd = [incident.resolved_at, incident.updated_at, newestFirst[0]?.ended_at, newestFirst[0]?.started_at]
.filter((t): t is string => !!t)
.reduce((a, b) => (b > a ? b : a), incident.started_at);
const visible = newestFirst.slice(0, earlierShown); const visible = newestFirst.slice(0, earlierShown);
const remaining = newestFirst.length - visible.length; const remaining = newestFirst.length - visible.length;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<button {/* A real link, not router.back(): arriving from Live or a shared URL,
onClick={() => router.back()} "back" went to the map or out of the app. */}
className="text-xs text-ink-muted hover:text-ink-2 transition-colors" <Link href="/incidents" className="text-xs text-ink-muted hover:text-ink-2 transition-colors">
>
← Incidents ← Incidents
</button> </Link>
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */} {/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
@@ -129,8 +114,13 @@ export default function IncidentDetailPage() {
{incident.title ?? "Incident"} {incident.title ?? "Incident"}
</h1> </h1>
<p className="text-xs text-ink-muted font-mono"> <p className="text-xs text-ink-muted font-mono">
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed {elapsedLabel(incident.started_at, active, resolvedEnd)} elapsed
{pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path</>} {path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
{path?.mayBeMerged && (
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
{" · "}may be merged
</span>
)}
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""} {" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</p> </p>
</div> </div>
@@ -148,6 +138,8 @@ export default function IncidentDetailPage() {
)} )}
</div> </div>
{actionError && <ErrorBanner message={actionError} />}
{displayTags.length > 0 && ( {displayTags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{displayTags.map((t) => ( {displayTags.map((t) => (
+8 -1
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
import { useIncidents } from "@/lib/useIncidents"; import { useIncidents } from "@/lib/useIncidents";
import { useActiveCalls } from "@/lib/useCalls"; import { useActiveCalls } from "@/lib/useCalls";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { IncidentRecord } from "@/lib/types"; import type { IncidentRecord } from "@/lib/types";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@@ -204,6 +205,7 @@ export default function IncidentsPage() {
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo); const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all"); const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent"); const [sortMode, setSortMode] = useState<SortMode>("recent");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any"); const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
@@ -257,8 +259,11 @@ export default function IncidentsPage() {
}, [filtered]); }, [filtered]);
async function handleResolve(id: string) { async function handleResolve(id: string) {
// No reopen path exists (server-26#284), so resolving is final from the UI.
if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return;
setActionError(null);
try { await c2api.updateIncident(id, { status: "resolved" }); } try { await c2api.updateIncident(id, { status: "resolved" }); }
catch (e) { console.error(e); } catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
} }
return ( return (
@@ -273,6 +278,8 @@ export default function IncidentsPage() {
chips are pipeline output, so the notice rides with the list. */} chips are pipeline output, so the notice rides with the list. */}
<MachineOutputNotice variant="inline" /> <MachineOutputNotice variant="inline" />
{actionError && <ErrorBanner message={actionError} />}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{SEVERITY_FILTERS.map(({ key, label }) => ( {SEVERITY_FILTERS.map(({ key, label }) => (
+7 -7
View File
@@ -87,30 +87,30 @@ export default function LoginPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<h1 className="text-white text-lg font-bold">Sign in</h1> <h1 className="text-white text-lg font-bold">Sign in</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Password</label> <label className="text-xs text-ink-2 block mb-1">Password</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
autoComplete="current-password" autoComplete="current-password"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{error && ( {error && (
@@ -127,7 +127,7 @@ export default function LoginPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span> <span className="text-xs text-ink-muted">or</span>
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
</div> </div>
@@ -146,7 +146,7 @@ export default function LoginPage() {
Continue with Google Continue with Google
</button> </button>
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-ink-muted">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link> <Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
</p> </p>
+44 -17
View File
@@ -15,6 +15,8 @@ import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import { diskState, DISK_TEXT_CLASS } from "@/lib/nodeDisk";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
function ApprovalBadge({ status }: { status: string | null }) { function ApprovalBadge({ status }: { status: string | null }) {
@@ -54,10 +56,14 @@ export default function NodeDetailPage() {
return unsub; return unsub;
}, [id]); }, [id]);
async function sendCommand(action: string) { async function sendCommand(action: string, label: string) {
setSending(true); setSending(true);
try { try {
await c2api.sendCommand(id, { action }); await c2api.sendCommand(id, { action });
alert(`${label} sent to the node.`);
} catch (e) {
// Was try/finally only: an unhandled rejection and no feedback (server-26#284).
alert(`Couldn't send ${label.toLowerCase()}: ${errorMessage(e)}`);
} finally { } finally {
setSending(false); setSending(false);
} }
@@ -125,7 +131,7 @@ export default function NodeDetailPage() {
} }
if (!node) { if (!node) {
return <p className="text-gray-500 font-mono text-sm">Loading node…</p>; return <p className="text-ink-muted font-mono text-sm">Loading node…</p>;
} }
const system = systemMap[node.assigned_system_id ?? ""]; const system = systemMap[node.assigned_system_id ?? ""];
@@ -135,7 +141,7 @@ export default function NodeDetailPage() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-xl font-bold text-white font-mono">{node.name}</h1> <h1 className="text-xl font-bold text-white font-mono">{node.name}</h1>
<p className="text-gray-500 text-sm font-mono">{node.node_id}</p> <p className="text-ink-muted text-sm font-mono">{node.node_id}</p>
</div> </div>
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </div>
@@ -147,7 +153,7 @@ export default function NodeDetailPage() {
<span className="text-base">⚠</span> <span className="text-base">⚠</span>
<span>Local System Override Active</span> <span>Local System Override Active</span>
</div> </div>
<p className="text-gray-400 text-xs leading-relaxed"> <p className="text-ink-2 text-xs leading-relaxed">
This node is operating on a local system override. This node is operating on a local system override.
{node.override_timeout_at ? ( {node.override_timeout_at ? (
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</> <> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
@@ -155,7 +161,8 @@ export default function NodeDetailPage() {
<> No timeout is currently enforced (permanent override).</> <> No timeout is currently enforced (permanent override).</>
)} )}
</p> </p>
<div className="flex gap-2"> {/* Override routes are require_service_key_or_admin (server-26#222). */}
{isAdmin && <div className="flex gap-2">
{node.override_timeout_at && ( {node.override_timeout_at && (
<button <button
onClick={async () => { onClick={async () => {
@@ -184,12 +191,12 @@ export default function NodeDetailPage() {
> >
Force Revert Config Force Revert Config
</button> </button>
</div> </div>}
</div> </div>
)} )}
{/* Info */} {/* Info */}
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
{[ {[
["System", system?.name ?? "Unassigned"], ["System", system?.name ?? "Unassigned"],
["Location", `${node.lat}, ${node.lon}`], ["Location", `${node.lat}, ${node.lon}`],
@@ -208,12 +215,23 @@ export default function NodeDetailPage() {
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []), ...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
].map(([label, value]) => ( ].map(([label, value]) => (
<div key={label} className="flex justify-between px-4 py-2.5"> <div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">{label}</span> <span className="text-ink-muted">{label}</span>
<span className="text-gray-200">{value}</span> <span className="text-ink">{value}</span>
</div> </div>
))} ))}
{(() => {
const disk = diskState(node);
return (
<div className="flex justify-between px-4 py-2.5"> <div className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">Approval</span> <span className="text-ink-muted">Disk</span>
<span className={disk ? DISK_TEXT_CLASS[disk.level] : "text-ink-muted"}>
{disk ? `${disk.level !== "ok" ? "⚠ " : ""}${disk.label}` : "not reported (node software too old)"}
</span>
</div>
);
})()}
<div className="flex justify-between px-4 py-2.5">
<span className="text-ink-muted">Approval</span>
<ApprovalBadge status={node.approval_status ?? null} /> <ApprovalBadge status={node.approval_status ?? null} />
</div> </div>
</div> </div>
@@ -232,7 +250,7 @@ export default function NodeDetailPage() {
<button <button
onClick={handleReject} onClick={handleReject}
disabled={approving} disabled={approving}
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-gray-300 rounded-lg text-sm font-mono transition-colors" className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-ink-2 rounded-lg text-sm font-mono transition-colors"
> >
Reject Reject
</button> </button>
@@ -240,25 +258,34 @@ export default function NodeDetailPage() {
)} )}
{/* Actions */} {/* Actions */}
{/* Configure and Restart are admin-only on the server (server-26#222);
non-admins get a note instead of a button that 403s. */}
{!isAdmin && !node.configured && (
<p className="text-sm text-ink-2">This node isn&apos;t configured yet. An admin needs to assign it a radio system.</p>
)}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{isAdmin && (
<button <button
onClick={() => setShowConfig(true)} onClick={() => setShowConfig(true)}
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors" className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
> >
{node.configured ? "Reassign System" : "Configure"} {node.configured ? "Reassign System" : "Configure"}
</button> </button>
)}
{isAdmin && (
<button <button
disabled={sending} disabled={sending}
onClick={() => sendCommand("op25_restart")} onClick={() => sendCommand("op25_restart", "Restart OP25")}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
> >
Restart OP25 Restart OP25
</button> </button>
)}
{isAdmin && ( {isAdmin && (
<button <button
disabled={sending} disabled={sending}
onClick={handleRequestUpdate} onClick={handleRequestUpdate}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
> >
Update Node Software Update Node Software
</button> </button>
@@ -283,16 +310,16 @@ export default function NodeDetailPage() {
{/* Recent calls */} {/* Recent calls */}
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Recent Calls</h2>
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */} {/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />} {nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
{nodeCalls.length === 0 ? ( {nodeCalls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p> <p className="text-ink-muted text-sm font-mono">No calls recorded from this node.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
<th className="px-4 py-2 text-left">Time</th> <th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th> <th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th> <th className="px-4 py-2 text-left">System</th>
+10 -4
View File
@@ -65,7 +65,7 @@ export default function NodesPage() {
<button <button
onClick={handleUpdateAll} onClick={handleUpdateAll}
disabled={updatingAll} disabled={updatingAll}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50" className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
> >
{updatingAll ? "Requesting…" : "Update All Nodes"} {updatingAll ? "Requesting…" : "Update All Nodes"}
</button> </button>
@@ -79,22 +79,28 @@ export default function NodesPage() {
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pending.map((n) => ( {pending.map((n) => (
// Configuring is admin-only on the server (server-26#222); for
// everyone else the card links to the node page instead.
isAdmin ? (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer"> <div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} /> <NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
</div> </div>
) : (
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
)
))} ))}
</div> </div>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider"> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider">
All Nodes ({nodes.length}) All Nodes ({nodes.length})
</h2> </h2>
{loading ? ( {loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p> <p className="text-ink-muted text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((n) => ( {nodes.map((n) => (
+4 -4
View File
@@ -55,17 +55,17 @@ export default function OnboardingPage() {
return ( return (
<div className="max-w-sm mx-auto pt-16"> <div className="max-w-sm mx-auto pt-16">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<div> <div>
<h1 className="text-white text-lg font-bold">Set up your organization</h1> <h1 className="text-white text-lg font-bold">Set up your organization</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed"> <p className="text-ink-2 text-xs mt-2 leading-relaxed">
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later. One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label> <label className="text-xs text-ink-2 block mb-1">Organization name</label>
<input <input
type="text" type="text"
value={orgName} value={orgName}
@@ -73,7 +73,7 @@ export default function OnboardingPage() {
required required
autoFocus autoFocus
placeholder="e.g. Riverside County Scanner" placeholder="e.g. Riverside County Scanner"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
+9 -9
View File
@@ -51,7 +51,7 @@ function MarketingHomePage() {
<h1 className="text-display-sm md:text-display mt-5 text-white"> <h1 className="text-display-sm md:text-display mt-5 text-white">
See what&apos;s happening on the radio, as an incident — not a wall of calls. See what&apos;s happening on the radio, as an incident — not a wall of calls.
</h1> </h1>
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed"> <p className="text-ink-2 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed, DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
and correlated into incidents you can watch on a map or scrub back through in history — with a Discord and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
bot to relay the audio live to your team. bot to relay the audio live to your team.
@@ -68,7 +68,7 @@ function MarketingHomePage() {
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="max-w-2xl mb-10"> <div className="max-w-2xl mb-10">
<h2 className="text-display-sm text-white">The unit of value is the incident</h2> <h2 className="text-display-sm text-white">The unit of value is the incident</h2>
<p className="text-gray-400 mt-3"> <p className="text-ink-2 mt-3">
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
about — and let you click into any one of them for the full picture. about — and let you click into any one of them for the full picture.
</p> </p>
@@ -77,14 +77,14 @@ function MarketingHomePage() {
{CAPABILITIES.map((c) => ( {CAPABILITIES.map((c) => (
<Card key={c.title} padding="lg" hover> <Card key={c.title} padding="lg" hover>
<h3 className="text-white font-semibold">{c.title}</h3> <h3 className="text-white font-semibold">{c.title}</h3>
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p> <p className="text-ink-2 text-sm mt-2 leading-relaxed">{c.body}</p>
</Card> </Card>
))} ))}
</div> </div>
</section> </section>
{/* How it works */} {/* How it works */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<h2 className="text-display-sm text-white mb-10">How it works</h2> <h2 className="text-display-sm text-white mb-10">How it works</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
@@ -92,7 +92,7 @@ function MarketingHomePage() {
<div key={s.n}> <div key={s.n}>
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p> <p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
<h3 className="text-white font-semibold mt-2">{s.title}</h3> <h3 className="text-white font-semibold mt-2">{s.title}</h3>
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p> <p className="text-ink-2 text-sm mt-2 leading-relaxed">{s.body}</p>
</div> </div>
))} ))}
</div> </div>
@@ -100,12 +100,12 @@ function MarketingHomePage() {
</section> </section>
{/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */} {/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
<h2 className="text-display-sm text-white">Pricing is in development</h2> <h2 className="text-display-sm text-white">Pricing is in development</h2>
<p className="text-gray-400 mt-2 max-w-xl"> <p className="text-ink-2 mt-2 max-w-xl">
We would rather talk to you about what you need than post a number we would have to We would rather talk to you about what you need than post a number we would have to
walk back. Tell us about your coverage area and we will figure it out together. walk back. Tell us about your coverage area and we will figure it out together.
</p> </p>
@@ -118,10 +118,10 @@ function MarketingHomePage() {
</section> </section>
{/* Final CTA */} {/* Final CTA */}
<section className="border-t border-gray-800"> <section className="border-t border-line">
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
<h2 className="text-display-sm text-white">Bring your first node online</h2> <h2 className="text-display-sm text-white">Bring your first node online</h2>
<p className="text-gray-400 mt-3 max-w-xl mx-auto"> <p className="text-ink-2 mt-3 max-w-xl mx-auto">
Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call. Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call.
</p> </p>
<div className="mt-8"> <div className="mt-8">
+3 -3
View File
@@ -15,7 +15,7 @@ export default function PricingPage() {
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20"> <div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="text-center max-w-2xl mx-auto"> <div className="text-center max-w-2xl mx-auto">
<h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1> <h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1>
<p className="text-gray-400 mt-4"> <p className="text-ink-2 mt-4">
We are still working out what a fair price looks like for the people who actually use this. We are still working out what a fair price looks like for the people who actually use this.
Rather than post a number we would have to walk back, we would rather talk to you about what Rather than post a number we would have to walk back, we would rather talk to you about what
you need and what it is worth. you need and what it is worth.
@@ -24,7 +24,7 @@ export default function PricingPage() {
<Card padding="lg" className="mt-12 max-w-2xl mx-auto"> <Card padding="lg" className="mt-12 max-w-2xl mx-auto">
<h2 className="text-white text-lg font-bold">What you get today</h2> <h2 className="text-white text-lg font-bold">What you get today</h2>
<p className="text-gray-400 text-sm mt-2 leading-relaxed"> <p className="text-ink-2 text-sm mt-2 leading-relaxed">
The full incident pipeline — transcription, correlation into incidents, mapping, and the The full incident pipeline — transcription, correlation into incidents, mapping, and the
Discord relay. Node counts, seats, and history length are set per account while we work out Discord relay. Node counts, seats, and history length are set per account while we work out
the plan structure. the plan structure.
@@ -35,7 +35,7 @@ export default function PricingPage() {
</Card> </Card>
<div className="text-center mt-16"> <div className="text-center mt-16">
<p className="text-gray-400"> <p className="text-ink-2">
Questions?{" "} Questions?{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
{" "}or{" "} {" "}or{" "}
+3 -3
View File
@@ -63,18 +63,18 @@ export default function PrivacyPage() {
</div> </div>
<h1 className="text-display-sm text-white">Privacy Policy</h1> <h1 className="text-display-sm text-white">Privacy Policy</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p> <p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8"> <div className="mt-10 space-y-8">
{SECTIONS.map((s) => ( {SECTIONS.map((s) => (
<section key={s.heading}> <section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2> <h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p> <p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section> </section>
))} ))}
</div> </div>
<p className="text-gray-600 text-xs font-mono mt-16"> <p className="text-ink-muted text-xs font-mono mt-16">
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "} Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>. <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
</p> </p>
+16 -16
View File
@@ -92,13 +92,13 @@ export default function ProfilePage() {
<div> <div>
<h1 className="text-white text-xl font-bold">{displayName}</h1> <h1 className="text-white text-xl font-bold">{displayName}</h1>
{user.displayName && user.email && ( {user.displayName && user.email && (
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p> <p className="text-ink-2 text-sm mt-0.5">{user.email}</p>
)} )}
{role && ( {role && (
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${ <span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
role === "admin" ? "bg-indigo-900 text-indigo-300" : role === "admin" ? "bg-indigo-900 text-indigo-300" :
role === "operator" ? "bg-green-900 text-green-300" : role === "operator" ? "bg-green-900 text-green-300" :
"bg-gray-800 text-gray-400" "bg-raised text-ink-2"
}`}> }`}>
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} {role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
</span> </span>
@@ -107,9 +107,9 @@ export default function ProfilePage() {
</div> </div>
{/* Firebase account */} {/* Firebase account */}
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <section className="bg-surface border border-line rounded-xl divide-y divide-line">
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-2">Account</p>
<div className="space-y-2"> <div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} /> <Row label="Email" value={user.email ?? "—"} />
<Row label="UID" value={user.uid} mono truncate /> <Row label="UID" value={user.uid} mono truncate />
@@ -125,12 +125,12 @@ export default function ProfilePage() {
</section> </section>
{/* Discord linking */} {/* Discord linking */}
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <section className="bg-surface border border-line rounded-xl overflow-hidden">
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-3">Discord</p>
{linkLoading ? ( {linkLoading ? (
<p className="text-gray-500 text-sm">Loading…</p> <p className="text-ink-muted text-sm">Loading…</p>
) : linkStatus?.linked ? ( ) : linkStatus?.linked ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -156,21 +156,21 @@ export default function ProfilePage() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-gray-400"> <p className="text-sm text-ink-2">
Link your Discord account to access private trips from both the web and Discord. Link your Discord account to access private trips from both the web and Discord.
</p> </p>
{code ? ( {code ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3"> <div className="bg-raised rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span> <span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
</div> </div>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes. Run <span className="font-mono text-ink">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
</p> </p>
<button <button
onClick={generateCode} onClick={generateCode}
disabled={generating} disabled={generating}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50" className="text-xs text-ink-muted hover:text-ink transition-colors disabled:opacity-50"
> >
Generate new code Generate new code
</button> </button>
@@ -190,9 +190,9 @@ export default function ProfilePage() {
</section> </section>
{/* Sign out */} {/* Sign out */}
<section className="bg-gray-900 border border-gray-800 rounded-xl"> <section className="bg-surface border border-line rounded-xl">
<div className="px-4 py-3 flex items-center justify-between"> <div className="px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-400">Sign out of this device</p> <p className="text-sm text-ink-2">Sign out of this device</p>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
className="text-sm text-red-500 hover:text-red-400 transition-colors" className="text-sm text-red-500 hover:text-red-400 transition-colors"
@@ -213,8 +213,8 @@ function Row({ label, value, mono = false, truncate = false }: {
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<span className="text-xs text-gray-500 shrink-0">{label}</span> <span className="text-xs text-ink-muted shrink-0">{label}</span>
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}> <span className={`text-sm text-ink text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
{value} {value}
</span> </span>
</div> </div>
+13 -13
View File
@@ -39,11 +39,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-lg space-y-4"> <Card padding="lg" className="w-full max-w-lg space-y-4">
<h2 className="text-white font-semibold">Key created</h2> <h2 className="text-white font-semibold">Key created</h2>
<p className="text-xs text-gray-400"> <p className="text-xs text-ink-2">
Copy this key now — it won&apos;t be shown again. This is a sample key from the demo module in{" "} Copy this key now — it won&apos;t be shown again. This is a sample key from the demo module in{" "}
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn&apos;t authenticate against anything. <code className="text-ink-2">lib/apiKeys.ts</code>; it doesn&apos;t authenticate against anything.
</p> </p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p> <p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -61,11 +61,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
<h2 className="text-white font-semibold mb-4">New API key</h2> <h2 className="text-white font-semibold mb-4">New API key</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Label</label> <label className="text-xs text-ink-2 block mb-1">Label</label>
<input <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Ops dashboard integration" placeholder="e.g. Ops dashboard integration"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -96,9 +96,9 @@ export default function ApiKeysSettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4"> <div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p> <p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
<p className="text-gray-400 text-xs mt-1 leading-relaxed"> <p className="text-ink-2 text-xs mt-1 leading-relaxed">
Organization API keys aren&apos;t backed by a real endpoint yet — this screen runs against an in-memory Organization API keys aren&apos;t backed by a real endpoint yet — this screen runs against an in-memory
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to demo module (<code className="text-ink-2">lib/apiKeys.ts</code>) so the flow can be reviewed end to
end. See that file for the exact backend routes a real integration needs. end. See that file for the exact backend routes a real integration needs.
</p> </p>
</div> </div>
@@ -108,7 +108,7 @@ export default function ApiKeysSettingsPage() {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p> <p className="text-sm text-ink-muted">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button> <Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
</div> </div>
@@ -118,7 +118,7 @@ export default function ApiKeysSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Label</th> <th className="px-4 py-3 text-left">Label</th>
<th className="px-4 py-3 text-left">Key</th> <th className="px-4 py-3 text-left">Key</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
@@ -129,11 +129,11 @@ export default function ApiKeysSettingsPage() {
</thead> </thead>
<tbody> <tbody>
{keys.map((k) => ( {keys.map((k) => (
<tr key={k.key_id} className="border-b border-gray-800 last:border-0"> <tr key={k.key_id} className="border-b border-line last:border-0">
<td className="px-4 py-3 text-white">{k.name}</td> <td className="px-4 py-3 text-white">{k.name}</td>
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td> <td className="px-4 py-3 text-ink-muted font-mono text-xs">{k.key_prefix}…</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>} {k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
</td> </td>
+12 -12
View File
@@ -54,12 +54,12 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
return ( return (
<div> <div>
<div className="flex items-baseline justify-between mb-1.5"> <div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs text-gray-400 font-mono">{label}</span> <span className="text-xs text-ink-2 font-mono">{label}</span>
<span className="text-xs font-mono text-gray-300"> <span className="text-xs font-mono text-ink-2">
{used} / {limit === "unlimited" ? "∞" : limit} {used} / {limit === "unlimited" ? "∞" : limit}
</span> </span>
</div> </div>
<div className="h-2 rounded-full bg-gray-800 overflow-hidden"> <div className="h-2 rounded-full bg-raised overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`} className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }} style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
@@ -142,7 +142,7 @@ export default function BillingSettingsPage() {
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
<p className="text-xs text-gray-600 font-mono"> <p className="text-xs text-ink-muted font-mono">
Demo data — this page isn&apos;t connected to a live payment processor. See lib/billing.ts for the integration plan. Demo data — this page isn&apos;t connected to a live payment processor. See lib/billing.ts for the integration plan.
</p> </p>
@@ -164,7 +164,7 @@ export default function BillingSettingsPage() {
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} /> <UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} /> <UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
</div> </div>
<div className="mt-5 pt-5 border-t border-gray-800"> <div className="mt-5 pt-5 border-t border-line">
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}> <Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
{portalBusy ? "Opening…" : "Manage payment method & invoices"} {portalBusy ? "Opening…" : "Manage payment method & invoices"}
</Button> </Button>
@@ -179,10 +179,10 @@ export default function BillingSettingsPage() {
return ( return (
<div <div
key={p.id} key={p.id}
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`} className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-line"}`}
> >
<p className="text-white font-semibold text-sm">{p.name}</p> <p className="text-white font-semibold text-sm">{p.name}</p>
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p> <p className="text-ink-muted text-xs mt-1 flex-1">{p.tagline}</p>
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && ( {(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => ( {(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
@@ -212,17 +212,17 @@ export default function BillingSettingsPage() {
<Card> <Card>
<CardHeader title="Invoice history" /> <CardHeader title="Invoice history" />
{invoices.length === 0 ? ( {invoices.length === 0 ? (
<p className="text-gray-600 text-sm">No invoices yet.</p> <p className="text-ink-muted text-sm">No invoices yet.</p>
) : ( ) : (
<div className="divide-y divide-gray-800"> <div className="divide-y divide-line">
{invoices.map((inv) => ( {invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between gap-4 py-3"> <div key={inv.id} className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-gray-200 text-sm">{inv.description}</p> <p className="text-ink text-sm">{inv.description}</p>
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p> <p className="text-ink-muted text-xs font-mono">{fmtDate(inv.date)}</p>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span> <span className="text-ink-2 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge> <Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
</div> </div>
</div> </div>
+3 -3
View File
@@ -43,15 +43,15 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
description="Organization profile, team access, node ownership, API keys, and billing." description="Organization profile, team access, node ownership, API keys, and billing."
/> />
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit max-w-full overflow-x-auto">
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => ( {[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
<Link <Link
key={t.href} key={t.href}
href={t.href} href={t.href}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
pathname === t.href || pathname.startsWith(t.href + "/") pathname === t.href || pathname.startsWith(t.href + "/")
? "bg-gray-800 text-white" ? "bg-raised text-white"
: "text-gray-500 hover:text-gray-300" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{t.label} {t.label}
+13 -13
View File
@@ -46,8 +46,8 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<Card padding="lg" className="w-full max-w-md space-y-4"> <Card padding="lg" className="w-full max-w-md space-y-4">
<h2 className="text-white font-semibold">Member invited</h2> <h2 className="text-white font-semibold">Member invited</h2>
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p> <p className="text-xs text-ink-2">Share this one-time invite link so they can set their password. It expires after use.</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3"> <div className="bg-raised border border-line-strong rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p> <p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div> </div>
<Button onClick={onClose} fullWidth>Done</Button> <Button onClick={onClose} fullWidth>Done</Button>
@@ -62,18 +62,18 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
<h2 className="text-white font-semibold mb-4">Invite a member</h2> <h2 className="text-white font-semibold mb-4">Invite a member</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" required value={email} onChange={(e) => setEmail(e.target.value)} type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="teammate@example.com" placeholder="teammate@example.com"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Role</label> <label className="text-xs text-ink-2 block mb-1">Role</label>
<select <select
value={role} onChange={(e) => setRole(e.target.value as UserRole)} value={role} onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
<option value="admin">Admin — full access</option> <option value="admin">Admin — full access</option>
<option value="operator">Operator — owns nodes</option> <option value="operator">Operator — owns nodes</option>
@@ -130,7 +130,7 @@ export default function MembersSettingsPage() {
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-gray-500"> <p className="text-sm text-ink-muted">
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`} {loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
</p> </p>
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button> <Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
@@ -144,7 +144,7 @@ export default function MembersSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Member</th> <th className="px-4 py-3 text-left">Member</th>
<th className="px-4 py-3 text-left">Role</th> <th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
@@ -155,10 +155,10 @@ export default function MembersSettingsPage() {
{loading {loading
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />) ? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
: users.map((u) => ( : users.map((u) => (
<tr key={u.uid} className="border-b border-gray-800 last:border-0"> <tr key={u.uid} className="border-b border-line last:border-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="text-white">{u.display_name || u.email}</p> <p className="text-white">{u.display_name || u.email}</p>
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>} {u.display_name && <p className="text-ink-muted text-xs">{u.email}</p>}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{u.uid === user?.uid ? ( {u.uid === user?.uid ? (
@@ -168,7 +168,7 @@ export default function MembersSettingsPage() {
value={u.role} value={u.role}
disabled={savingUid === u.uid} disabled={savingUid === u.uid}
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)} onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50" className="bg-raised border border-line-strong rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
> >
<option value="admin">Admin</option> <option value="admin">Admin</option>
<option value="operator">Operator</option> <option value="operator">Operator</option>
@@ -176,7 +176,7 @@ export default function MembersSettingsPage() {
</select> </select>
)} )}
</td> </td>
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell"> <td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">
{u.role === "operator" ? u.owned_node_ids.length : "—"} {u.role === "operator" ? u.owned_node_ids.length : "—"}
</td> </td>
<td className="px-4 py-3 hidden md:table-cell"> <td className="px-4 py-3 hidden md:table-cell">
@@ -189,7 +189,7 @@ export default function MembersSettingsPage() {
</Card> </Card>
)} )}
<p className="text-xs text-gray-600 font-mono"> <p className="text-xs text-ink-muted font-mono">
Need to disable or delete a member? Use the full user admin panel under Admin → Users. Need to disable or delete a member? Use the full user admin panel under Admin → Users.
</p> </p>
</div> </div>
+14 -14
View File
@@ -114,7 +114,7 @@ function EnrollmentTokensPanel() {
</p> </p>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p> <p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-surface rounded px-2 py-1.5">{justMinted}</p>
<button <button
type="button" type="button"
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => { onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
@@ -133,7 +133,7 @@ function EnrollmentTokensPanel() {
: <>(check the broker host)</>}: : <>(check the broker host)</>}:
</p> </p>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre> <pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-surface rounded px-2 py-1.5">{installCmd}</pre>
<button <button
type="button" type="button"
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => { onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
@@ -163,7 +163,7 @@ function EnrollmentTokensPanel() {
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="Node ID, e.g. node-003" placeholder="Node ID, e.g. node-003"
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="flex-1 min-w-[12rem] bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<Button type="submit" size="sm" disabled={minting || !label.trim()}> <Button type="submit" size="sm" disabled={minting || !label.trim()}>
{minting ? "Minting…" : "New token"} {minting ? "Minting…" : "New token"}
@@ -176,11 +176,11 @@ function EnrollmentTokensPanel() {
<SkeletonRow cols={1} /> <SkeletonRow cols={1} />
</div> </div>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p> <p className="text-ink-muted text-xs">No enrollment tokens yet.</p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
<th className="py-2 text-left">Label</th> <th className="py-2 text-left">Label</th>
<th className="py-2 text-left hidden sm:table-cell">Created</th> <th className="py-2 text-left hidden sm:table-cell">Created</th>
<th className="py-2 text-left">Status</th> <th className="py-2 text-left">Status</th>
@@ -189,14 +189,14 @@ function EnrollmentTokensPanel() {
</thead> </thead>
<tbody> <tbody>
{tokens.map((t) => ( {tokens.map((t) => (
<tr key={t.token_id} className="border-b border-gray-800 last:border-0"> <tr key={t.token_id} className="border-b border-line last:border-0">
<td className="py-2 text-white">{t.label}</td> <td className="py-2 text-white">{t.label}</td>
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell"> <td className="py-2 text-ink-muted text-xs hidden sm:table-cell">
{new Date(t.created_at).toLocaleDateString()} {new Date(t.created_at).toLocaleDateString()}
</td> </td>
<td className="py-2 text-xs"> <td className="py-2 text-xs">
{t.revoked ? ( {t.revoked ? (
<span className="text-gray-600">Revoked</span> <span className="text-ink-muted">Revoked</span>
) : ( ) : (
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span> <span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
)} )}
@@ -283,7 +283,7 @@ export default function NodeOwnershipSettingsPage() {
<EnrollmentTokensPanel /> <EnrollmentTokensPanel />
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-500"> <p className="text-sm text-ink-muted">
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here. Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
</p> </p>
@@ -292,7 +292,7 @@ export default function NodeOwnershipSettingsPage() {
<Card padding="none" className="overflow-hidden"> <Card padding="none" className="overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900"> <tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
<th className="px-4 py-3 text-left">Node</th> <th className="px-4 py-3 text-left">Node</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th> <th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
<th className="px-4 py-3 text-left">Owner</th> <th className="px-4 py-3 text-left">Owner</th>
@@ -302,17 +302,17 @@ export default function NodeOwnershipSettingsPage() {
{loading ? ( {loading ? (
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />) Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr> <tr><td colSpan={3} className="px-4 py-8 text-center text-ink-muted text-sm">No nodes registered yet.</td></tr>
) : ( ) : (
nodes.map((n) => { nodes.map((n) => {
const owner = ownerByNode.get(n.node_id); const owner = ownerByNode.get(n.node_id);
return ( return (
<tr key={n.node_id} className="border-b border-gray-800 last:border-0"> <tr key={n.node_id} className="border-b border-line last:border-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors"> <Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
{n.name} {n.name}
</Link> </Link>
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p> <p className="text-ink-muted text-xs font-mono">{n.node_id}</p>
</td> </td>
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td> <td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -320,7 +320,7 @@ export default function NodeOwnershipSettingsPage() {
value={owner?.uid ?? UNASSIGNED} value={owner?.uid ?? UNASSIGNED}
disabled={savingNodeId === n.node_id} disabled={savingNodeId === n.node_id}
onChange={(e) => reassign(n.node_id, e.target.value)} onChange={(e) => reassign(n.node_id, e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]" className="bg-raised border border-line-strong rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
> >
<option value={UNASSIGNED}>Unassigned</option> <option value={UNASSIGNED}>Unassigned</option>
{assignable.map((u) => ( {assignable.map((u) => (
@@ -16,7 +16,7 @@ import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
function StatTile({ label, value }: { label: string; value: string | number }) { function StatTile({ label, value }: { label: string; value: string | number }) {
return ( return (
<div> <div>
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono">{label}</p>
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p> <p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
</div> </div>
); );
@@ -69,12 +69,12 @@ export default function OrganizationSettingsPage() {
/> />
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label> <label className="text-xs text-ink-2 block mb-1">Organization name</label>
<input <input
value={orgName} value={orgName}
disabled={orgLoading || !canEdit} disabled={orgLoading || !canEdit}
onChange={(e) => setOrgName(e.target.value)} onChange={(e) => setOrgName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50"
/> />
</div> </div>
{saveError && <p className="text-red-400 text-xs">{saveError}</p>} {saveError && <p className="text-red-400 text-xs">{saveError}</p>}
@@ -110,7 +110,7 @@ export default function OrganizationSettingsPage() {
value={sub ? sub.status.replace("_", " ") : "—"} value={sub ? sub.status.replace("_", " ") : "—"}
/> />
</div> </div>
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm"> <div className="mt-5 pt-5 border-t border-line flex items-center gap-4 text-sm">
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors"> <Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
Manage plan & billing → Manage plan & billing →
</Link> </Link>
@@ -124,7 +124,7 @@ export default function OrganizationSettingsPage() {
<PreferredBotsCard canEdit={canEdit} /> <PreferredBotsCard canEdit={canEdit} />
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5"> <div className="bg-surface border border-red-800/60 rounded-xl p-5">
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." /> <CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support"> <Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
+7 -7
View File
@@ -61,23 +61,23 @@ export default function SignupPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
<h1 className="text-white text-lg font-bold">Create your account</h1> <h1 className="text-white text-lg font-bold">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Password</label> <label className="text-xs text-ink-2 block mb-1">Password</label>
<input <input
type="password" type="password"
value={password} value={password}
@@ -85,7 +85,7 @@ export default function SignupPage() {
required required
minLength={6} minLength={6}
autoComplete="new-password" autoComplete="new-password"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{error && ( {error && (
@@ -102,7 +102,7 @@ export default function SignupPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span> <span className="text-xs text-ink-muted">or</span>
<div className="flex-1 h-px bg-gray-700" /> <div className="flex-1 h-px bg-gray-700" />
</div> </div>
@@ -121,7 +121,7 @@ export default function SignupPage() {
Continue with Google Continue with Google
</button> </button>
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-ink-muted">
Already have an account?{" "} Already have an account?{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link> <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
</p> </p>
+127 -116
View File
@@ -438,17 +438,17 @@ function RRImportModal({
return ( return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono"> <div className="bg-surface border border-line-strong rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
{/* Header */} {/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-gray-800"> <div className="px-5 pt-5 pb-4 border-b border-line">
<h2 className="text-white font-semibold">{system.name}</h2> <h2 className="text-white font-semibold">{system.name}</h2>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-ink-muted mt-0.5">
{system.systemType}{system.location ? ` · ${system.location}` : ""} {system.systemType}{system.location ? ` · ${system.location}` : ""}
</p> </p>
{system.sysIds && ( {system.sysIds && (
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p> <p className="text-xs text-ink-muted mt-0.5">System IDs: {system.sysIds}</p>
)} )}
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-ink-muted mt-1">
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups {system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
</p> </p>
</div> </div>
@@ -456,7 +456,7 @@ function RRImportModal({
{/* Category list */} {/* Category list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p> <p className="text-xs text-ink-2 uppercase tracking-wider">Talkgroup Categories</p>
<div className="flex gap-3 text-xs text-indigo-400"> <div className="flex gap-3 text-xs text-indigo-400">
<button <button
type="button" type="button"
@@ -480,18 +480,18 @@ function RRImportModal({
type="checkbox" type="checkbox"
checked={selected.has(cat.name)} checked={selected.has(cat.name)}
onChange={() => toggle(cat.name)} onChange={() => toggle(cat.name)}
className="rounded border-gray-600 bg-gray-800 accent-indigo-500" className="rounded border-line-strong bg-raised accent-indigo-500"
/> />
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate"> <span className="text-sm text-ink flex-1 group-hover:text-white transition-colors truncate">
{cat.name} {cat.name}
</span> </span>
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span> <span className="text-xs text-ink-muted shrink-0">{cat.talkgroups.length} TGs</span>
</label> </label>
))} ))}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3"> <div className="px-5 pb-5 pt-4 border-t border-line flex gap-3">
<button <button
type="button" type="button"
onClick={handleImport} onClick={handleImport}
@@ -503,7 +503,7 @@ function RRImportModal({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -533,10 +533,10 @@ function LocalKnowledgeField({
multiline?: boolean; multiline?: boolean;
}) { }) {
const cls = const cls =
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500"; "w-full mt-0.5 bg-surface border border-line-strong rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
return ( return (
<label className="block"> <label className="block">
<span className="text-xs text-gray-500 font-sans">{label}</span> <span className="text-xs text-ink-muted font-sans">{label}</span>
{multiline ? ( {multiline ? (
<textarea <textarea
value={value} value={value}
@@ -656,8 +656,8 @@ function TalkgroupEditor({
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-gray-400"> <label className="text-xs text-ink-2">
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>} Talkgroups{talkgroups.length > 0 && <span className="text-ink-muted ml-1">({talkgroups.length})</span>}
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
{/* RadioReference file import */} {/* RadioReference file import */}
@@ -704,10 +704,10 @@ function TalkgroupEditor({
)} )}
{showPaste && ( {showPaste && (
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700"> <div className="space-y-2 p-3 bg-raised rounded-lg border border-line-strong">
<p className="text-xs text-gray-500"> <p className="text-xs text-ink-muted">
Paste rows from RadioReference — tab- or comma-separated:{" "} Paste rows from RadioReference — tab- or comma-separated:{" "}
<span className="text-gray-400">ID, Name, Tag</span> <span className="text-ink-2">ID, Name, Tag</span>
<br />Tags: fire · police · ems · transit · public works · other <br />Tags: fire · police · ems · transit · public works · other
</p> </p>
<textarea <textarea
@@ -715,7 +715,7 @@ function TalkgroupEditor({
onChange={(e) => setPasteText(e.target.value)} onChange={(e) => setPasteText(e.target.value)}
rows={6} rows={6}
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"} placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-surface border border-line-strong rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="button" type="button"
@@ -729,10 +729,10 @@ function TalkgroupEditor({
)} )}
{talkgroups.length > 0 ? ( {talkgroups.length > 0 ? (
<div className="border border-gray-800 rounded-lg overflow-hidden"> <div className="border border-line rounded-lg overflow-hidden">
<table className="w-full text-xs font-mono"> <table className="w-full text-xs font-mono">
<thead> <thead>
<tr className="bg-gray-800 text-gray-400"> <tr className="bg-raised text-ink-2">
<th className="px-3 py-1.5 text-left w-20">Dec ID</th> <th className="px-3 py-1.5 text-left w-20">Dec ID</th>
<th className="px-3 py-1.5 text-left">Name</th> <th className="px-3 py-1.5 text-left">Name</th>
<th className="px-3 py-1.5 text-left w-28">Tag</th> <th className="px-3 py-1.5 text-left w-28">Tag</th>
@@ -742,7 +742,7 @@ function TalkgroupEditor({
<tbody> <tbody>
{talkgroups.map((tg, i) => ( {talkgroups.map((tg, i) => (
<Fragment key={i}> <Fragment key={i}>
<tr className="border-t border-gray-800 hover:bg-gray-800/30"> <tr className="border-t border-line hover:bg-raised/30">
<td className="px-2 py-1"> <td className="px-2 py-1">
<input <input
value={tg.id} value={tg.id}
@@ -763,7 +763,7 @@ function TalkgroupEditor({
<select <select
value={tg.tag} value={tg.tag}
onChange={(e) => updateRow(i, "tag", e.target.value)} onChange={(e) => updateRow(i, "tag", e.target.value)}
className="w-full bg-gray-900 text-gray-300 focus:outline-none rounded px-1" className="w-full bg-surface text-ink-2 focus:outline-none rounded px-1"
> >
{TG_TAGS.map((t) => ( {TG_TAGS.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>{t}</option>
@@ -776,7 +776,7 @@ function TalkgroupEditor({
title="Local knowledge for the transcript corrector" title="Local knowledge for the transcript corrector"
onClick={() => setExpanded(expanded === i ? null : i)} onClick={() => setExpanded(expanded === i ? null : i)}
className={`transition-colors font-bold mr-2 ${ className={`transition-colors font-bold mr-2 ${
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300" hasLocalKnowledge(tg) ? "text-indigo-400" : "text-ink-muted hover:text-ink"
}`} }`}
> >
{expanded === i ? "▾" : "▸"} {expanded === i ? "▾" : "▸"}
@@ -784,7 +784,7 @@ function TalkgroupEditor({
<button <button
type="button" type="button"
onClick={() => removeRow(i)} onClick={() => removeRow(i)}
className="text-gray-600 hover:text-red-400 transition-colors font-bold" className="text-ink-muted hover:text-red-400 transition-colors font-bold"
> >
× ×
</button> </button>
@@ -794,11 +794,11 @@ function TalkgroupEditor({
talkgroups and most inherit the system's context rather than talkgroups and most inherit the system's context rather than
override it. */} override it. */}
{expanded === i && ( {expanded === i && (
<tr className="border-t border-gray-800 bg-gray-900/60"> <tr className="border-t border-line bg-surface/60">
<td colSpan={4} className="px-3 py-3"> <td colSpan={4} className="px-3 py-3">
<p className="text-xs text-gray-500 mb-2 font-sans"> <p className="text-xs text-ink-muted mb-2 font-sans">
Given to the transcript corrector for this talkgroup only, ranked Given to the transcript corrector for this talkgroup only, ranked
<span className="text-gray-300"> above</span> the system&apos;s own list. <span className="text-ink-2"> above</span> the system&apos;s own list.
Leave blank to inherit the system&apos;s. Leave blank to inherit the system&apos;s.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -849,7 +849,7 @@ function TalkgroupEditor({
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-xs text-gray-600 italic py-1"> <p className="text-xs text-ink-muted italic py-1">
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button. No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
</p> </p>
)} )}
@@ -864,52 +864,52 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">NAC (hex)</label> <label className="text-xs text-ink-2 block mb-1">NAC (hex)</label>
<input <input
value={value.nac} value={value.nac}
onChange={(e) => onChange({ ...value, nac: e.target.value })} onChange={(e) => onChange({ ...value, nac: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0x293" placeholder="0x293"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">System ID</label> <label className="text-xs text-ink-2 block mb-1">System ID</label>
<input <input
value={value.system_id} value={value.system_id}
onChange={(e) => onChange({ ...value, system_id: e.target.value })} onChange={(e) => onChange({ ...value, system_id: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="50513" placeholder="50513"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">WACN (hex)</label> <label className="text-xs text-ink-2 block mb-1">WACN (hex)</label>
<input <input
value={value.wacn} value={value.wacn}
onChange={(e) => onChange({ ...value, wacn: e.target.value })} onChange={(e) => onChange({ ...value, wacn: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0xBEE00" placeholder="0xBEE00"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span> Control Channels <span className="text-ink-muted">(MHz, comma-separated)</span>
</label> </label>
<input <input
value={value.control_channels} value={value.control_channels}
onChange={(e) => onChange({ ...value, control_channels: e.target.value })} onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="851.0125, 851.5125, 852.0125" placeholder="851.0125, 851.5125, 852.0125"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Voice Channels <span className="text-gray-600">(MHz, comma-separated — leave blank for auto-discovery)</span> Voice Channels <span className="text-ink-muted">(MHz, comma-separated — leave blank for auto-discovery)</span>
</label> </label>
<input <input
value={value.voice_channels} value={value.voice_channels}
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })} onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
@@ -923,7 +923,7 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
// ── Fixed-channel editors (node-26#24) ──────────────────────────────────────── // ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
const INPUT = "w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"; const INPUT = "w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) { function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
const chs = value.channels; const chs = value.channels;
@@ -931,13 +931,13 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) }); onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500"> <p className="text-xs text-ink-muted">
Fixed voice channels with no control channel — RadioReference lists them as conventional Fixed voice channels with no control channel — RadioReference lists them as conventional
frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name. one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-gray-400"> <div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-ink-2">
<span>Frequency (MHz)</span> <span>Frequency (MHz)</span>
<span>Channel name</span> <span>Channel name</span>
<span>NAC (hex, optional)</span> <span>NAC (hex, optional)</span>
@@ -992,7 +992,7 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
talkgroups={value.talkgroups} talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })} onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/> />
<p className="text-xs text-gray-600 -mt-2"> <p className="text-xs text-ink-muted -mt-2">
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named. Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
</p> </p>
</div> </div>
@@ -1004,7 +1004,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
return ( return (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label htmlFor="analog-freq" className="text-xs text-gray-400 block mb-1">Frequency (MHz)</label> <label htmlFor="analog-freq" className="text-xs text-ink-2 block mb-1">Frequency (MHz)</label>
<input <input
id="analog-freq" id="analog-freq"
value={ch.freq} value={ch.freq}
@@ -1016,7 +1016,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
/> />
</div> </div>
<div> <div>
<label htmlFor="analog-name" className="text-xs text-gray-400 block mb-1">Channel name</label> <label htmlFor="analog-name" className="text-xs text-ink-2 block mb-1">Channel name</label>
<input <input
id="analog-name" id="analog-name"
value={ch.name} value={ch.name}
@@ -1124,27 +1124,27 @@ function SystemForm({
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System"; const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
return ( return (
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono"> <form onSubmit={handleSubmit} className="bg-surface border border-line-strong rounded-xl p-5 space-y-4 font-mono">
<h3 className="text-white font-semibold">{title}</h3> <h3 className="text-white font-semibold">{title}</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Westchester County P25" placeholder="Westchester County P25"
/> />
</div> </div>
<div> <div>
<label htmlFor="system-kind" className="text-xs text-gray-400 block mb-1">Type</label> <label htmlFor="system-kind" className="text-xs text-ink-2 block mb-1">Type</label>
<select <select
id="system-kind" id="system-kind"
value={kind} value={kind}
onChange={(e) => handleKindChange(e.target.value as SystemKind)} onChange={(e) => handleKindChange(e.target.value as SystemKind)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
{KIND_OPTIONS.map((o) => ( {KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option> <option key={o.value} value={o.value}>{o.label}</option>
@@ -1165,12 +1165,12 @@ function SystemForm({
<AnalogForm value={analog} onChange={setAnalog} /> <AnalogForm value={analog} onChange={setAnalog} />
) : ( ) : (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label> <label className="text-xs text-ink-2 block mb-1">Config (JSON)</label>
<textarea <textarea
value={rawJson} value={rawJson}
onChange={(e) => setRawJson(e.target.value)} onChange={(e) => setRawJson(e.target.value)}
rows={10} rows={10}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
)} )}
@@ -1179,7 +1179,7 @@ function SystemForm({
<button <button
type="button" type="button"
onClick={toggleRaw} onClick={toggleRaw}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors" className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
> >
{showRaw ? "← Use structured form" : "Edit raw JSON →"} {showRaw ? "← Use structured form" : "Edit raw JSON →"}
</button> </button>
@@ -1198,7 +1198,7 @@ function SystemForm({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -1267,10 +1267,10 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
const currentToken = tokens?.find((t) => t.token_id === preferredId); const currentToken = tokens?.find((t) => t.token_id === preferredId);
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
@@ -1282,14 +1282,14 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
{open && ( {open && (
<div className="mt-3 space-y-2 font-mono text-xs"> <div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? ( {tokens === null ? (
<p className="text-gray-600 italic">Loading tokens…</p> <p className="text-ink-muted italic">Loading tokens…</p>
) : error ? ( ) : error ? (
<p className="text-red-400">{error}</p> <p className="text-red-400">{error}</p>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<p className="text-gray-600 italic">No tokens in pool.</p> <p className="text-ink-muted italic">No tokens in pool.</p>
) : ( ) : (
<> <>
<p className="text-gray-600"> <p className="text-ink-muted">
When a node on this system joins a voice channel, this token is tried first. When a node on this system joins a voice channel, this token is tried first.
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -1303,7 +1303,7 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
disabled={saving} disabled={saving}
className="accent-indigo-500" className="accent-indigo-500"
/> />
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}> <span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-ink-muted" : "text-ink-2"}`}>
{t.name} {t.name}
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>} {t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
</span> </span>
@@ -1314,13 +1314,13 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
<button <button
onClick={handleClear} onClick={handleClear}
disabled={saving} disabled={saving}
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50" className="text-ink-muted hover:text-ink-2 transition-colors disabled:opacity-50"
> >
Clear preference (use any free token) Clear preference (use any free token)
</button> </button>
)} )}
{!preferredId && ( {!preferredId && (
<p className="text-gray-700">No preference — any free token will be used.</p> <p className="text-ink-muted">No preference — any free token will be used.</p>
)} )}
{currentToken && ( {currentToken && (
<p className="text-indigo-500">Preferred: {currentToken.name}</p> <p className="text-indigo-500">Preferred: {currentToken.name}</p>
@@ -1371,10 +1371,10 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
]; ];
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span>AI Flags</span> <span>AI Flags</span>
@@ -1400,23 +1400,23 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
> >
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} /> <span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
</button> </button>
<span className="text-gray-300 flex-1">{label}</span> <span className="text-ink-2 flex-1">{label}</span>
{isSet ? ( {isSet ? (
<button <button
onClick={() => handleClear(key)} onClick={() => handleClear(key)}
disabled={saving === key} disabled={saving === key}
className="text-gray-600 hover:text-gray-400 transition-colors" className="text-ink-muted hover:text-ink-2 transition-colors"
title="Clear override (inherit global)" title="Clear override (inherit global)"
> >
reset reset
</button> </button>
) : ( ) : (
<span className="text-gray-700">inherits global</span> <span className="text-ink-muted">inherits global</span>
)} )}
</div> </div>
); );
})} })}
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p> <p className="text-ink-muted pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
</div> </div>
)} )}
</div> </div>
@@ -1456,15 +1456,15 @@ function SourceCallPlayer({ callId }: { callId: string }) {
{loading ? "loading…" : open ? "▲ source" : "▶ source"} {loading ? "loading…" : open ? "▲ source" : "▶ source"}
</button> </button>
{open && call && ( {open && call && (
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700"> <div className="mt-1.5 space-y-1 pl-2 border-l border-line-strong">
{call.audio_url ? ( {call.audio_url ? (
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} /> <audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
) : ( ) : (
<p className="text-gray-600 italic">No audio</p> <p className="text-ink-muted italic">No audio</p>
)} )}
{transcript && ( {transcript && (
<> <>
<p className="text-gray-500 italic line-clamp-2">{transcript}</p> <p className="text-ink-muted italic line-clamp-2">{transcript}</p>
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */} {/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
<MachineOutputNotice variant="inline" className="text-[10px]" /> <MachineOutputNotice variant="inline" className="text-[10px]" />
</> </>
@@ -1528,16 +1528,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
: 0; : 0;
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Local Area Local Area
{area !== null && ( {area !== null && (
<span className="text-gray-600 ml-1"> <span className="text-ink-muted ml-1">
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"}) ({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
</span> </span>
)} )}
@@ -1546,16 +1546,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 text-xs"> <div className="mt-3 space-y-3 text-xs">
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>} {loading && <p className="text-ink-muted italic font-mono">Loading…</p>}
{area && ( {area && (
<> <>
<p className="text-gray-500"> <p className="text-ink-muted">
Given to the transcript corrector for every talkgroup on this system. Given to the transcript corrector for every talkgroup on this system.
Whisper mishears local names constantly — naming them here is what lets Whisper mishears local names constantly — naming them here is what lets
them be put back. them be put back.
</p> </p>
<p className="text-gray-500"> <p className="text-ink-muted">
Fill this in <span className="text-gray-300">only if it is true of every Fill this in <span className="text-ink-2">only if it is true of every
talkgroup</span> on the system. One town, one department — fill it once here. talkgroup</span> on the system. One town, one department — fill it once here.
A system spanning several counties — leave it blank and describe each A system spanning several counties — leave it blank and describe each
talkgroup in the edit form instead. talkgroup in the edit form instead.
@@ -1592,7 +1592,7 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
{/* The anchor is the backend's answer to what was typed above, and {/* The anchor is the backend's answer to what was typed above, and
it decides whether location checking runs at all — so it is it decides whether location checking runs at all — so it is
worth showing rather than leaving as invisible state. */} worth showing rather than leaving as invisible state. */}
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p> <p className="text-ink-muted font-mono">{anchorLabel(area)}</p>
<button <button
type="button" type="button"
onClick={save} onClick={save}
@@ -1659,16 +1659,16 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0); const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Proposed Terms Proposed Terms
{rows !== null && ( {rows !== null && (
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}> <span className={total > 0 ? "text-indigo-400 ml-1" : "text-ink-muted ml-1"}>
({total > 0 ? `${total} awaiting review` : "none"}) ({total > 0 ? `${total} awaiting review` : "none"})
</span> </span>
)} )}
@@ -1677,9 +1677,9 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 text-xs"> <div className="mt-3 space-y-3 text-xs">
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>} {rows === null && <p className="text-ink-muted italic font-mono">Loading…</p>}
{rows !== null && total === 0 && ( {rows !== null && total === 0 && (
<p className="text-gray-600 italic"> <p className="text-ink-muted italic">
Nothing proposed yet. Terms appear here when a corrected place name Nothing proposed yet. Terms appear here when a corrected place name
turns out to be real nearby, or when the induction loop spots one in turns out to be real nearby, or when the induction loop spots one in
recent traffic. recent traffic.
@@ -1687,19 +1687,19 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
)} )}
{(rows ?? []).map((row) => ( {(rows ?? []).map((row) => (
<div key={row.talkgroup_id}> <div key={row.talkgroup_id}>
<p className="text-gray-400 font-mono mb-1"> <p className="text-ink-2 font-mono mb-1">
{row.talkgroup_name || `TGID ${row.talkgroup_id}`} {row.talkgroup_name || `TGID ${row.talkgroup_id}`}
</p> </p>
<ul className="space-y-1"> <ul className="space-y-1">
{row.pending.map((p) => ( {row.pending.map((p) => (
<li <li
key={p.term} key={p.term}
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1" className="flex items-start gap-2 bg-surface/60 border border-line rounded px-2 py-1"
> >
<span className="flex-1"> <span className="flex-1">
<span className="text-white font-mono">{p.term}</span> <span className="text-white font-mono">{p.term}</span>
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>} {p.meaning && <span className="text-ink-muted"> — {p.meaning}</span>}
<span className="text-gray-700 ml-2">{p.source}</span> <span className="text-ink-muted ml-2">{p.source}</span>
</span> </span>
<button <button
type="button" type="button"
@@ -1713,7 +1713,7 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
type="button" type="button"
disabled={busy === `${row.talkgroup_id}:${p.term}`} disabled={busy === `${row.talkgroup_id}:${p.term}`}
onClick={() => act(row.talkgroup_id, p.term, false)} onClick={() => act(row.talkgroup_id, p.term, false)}
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold" className="text-ink-muted hover:text-red-400 disabled:opacity-40 font-semibold"
> >
Drop Drop
</button> </button>
@@ -1802,16 +1802,16 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
} }
return ( return (
<div className="mt-3 border-t border-gray-800 pt-3"> <div className="mt-3 border-t border-line pt-3">
<button <button
onClick={toggle} onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1" className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
> >
<span>{open ? "▲" : "▼"}</span> <span>{open ? "▲" : "▼"}</span>
<span> <span>
Vocabulary Vocabulary
{vocab !== null && ( {vocab !== null && (
<span className="text-gray-600 ml-1"> <span className="text-ink-muted ml-1">
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""}) ({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
</span> </span>
)} )}
@@ -1820,7 +1820,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{open && ( {open && (
<div className="mt-3 space-y-3 font-mono text-xs"> <div className="mt-3 space-y-3 font-mono text-xs">
{loading && <p className="text-gray-600 italic">Loading…</p>} {loading && <p className="text-ink-muted italic">Loading…</p>}
{!loading && vocab !== null && ( {!loading && vocab !== null && (
<> <>
@@ -1832,19 +1832,19 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
> >
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"} {bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
</button> </button>
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span> <span className="text-ink-muted">GPT-4o generates local knowledge (agencies, units, streets)</span>
</div> </div>
<div> <div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p> <p className="text-ink-muted uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? ( {vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{vocab.map((term) => ( {vocab.map((term) => (
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"> <span key={term} className="inline-flex items-center gap-1 bg-raised text-ink-2 px-2 py-0.5 rounded-full">
{term} {term}
<button <button
onClick={() => handleRemove(term)} onClick={() => handleRemove(term)}
className="text-gray-600 hover:text-red-400 transition-colors leading-none" className="text-ink-muted hover:text-red-400 transition-colors leading-none"
> >
× ×
</button> </button>
@@ -1852,7 +1852,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-gray-600 italic">No terms yet — bootstrap or add manually.</p> <p className="text-ink-muted italic">No terms yet — bootstrap or add manually.</p>
)} )}
</div> </div>
@@ -1861,12 +1861,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
value={newTerm} value={newTerm}
onChange={(e) => setNewTerm(e.target.value)} onChange={(e) => setNewTerm(e.target.value)}
placeholder="Add term (e.g. 5-baker, YVAC)" placeholder="Add term (e.g. 5-baker, YVAC)"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500" className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="submit" type="submit"
disabled={adding || !newTerm.trim()} disabled={adding || !newTerm.trim()}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors" className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-ink px-3 py-1.5 rounded-lg transition-colors"
> >
Add Add
</button> </button>
@@ -1874,17 +1874,17 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{pending.length > 0 && ( {pending.length > 0 && (
<div> <div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5"> <p className="text-ink-muted uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length}) Induction suggestions ({pending.length})
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{pending.map((p) => ( {pending.map((p) => (
<div key={p.term} className="space-y-1"> <div key={p.term} className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-300 flex-1">{p.term}</span> <span className="text-ink-2 flex-1">{p.term}</span>
<span className="text-gray-600">{p.source}</span> <span className="text-ink-muted">{p.source}</span>
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button> <button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button> <button onClick={() => handleDismiss(p.term)} className="text-ink-muted hover:text-red-400 transition-colors px-1">✕</button>
</div> </div>
{p.source_call_ids && p.source_call_ids.length > 0 && ( {p.source_call_ids && p.source_call_ids.length > 0 && (
<div className="pl-1 space-y-1"> <div className="pl-1 space-y-1">
@@ -1930,7 +1930,8 @@ export default function SystemsPage() {
if (authLoading || (!isAdmin && !isOperator)) return null; if (authLoading || (!isAdmin && !isOperator)) return null;
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!confirm("Delete this system?")) return; if (!confirm("Delete this system?")) return;
await c2api.deleteSystem(id); try { await c2api.deleteSystem(id); }
catch (e) { alert(`Couldn't delete the system: ${e instanceof Error ? e.message : String(e)}`); }
} }
function openEdit(s: SystemRecord) { function openEdit(s: SystemRecord) {
@@ -1952,12 +1953,16 @@ export default function SystemsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1> <h1 className="text-xl font-bold text-white font-mono">Systems</h1>
{/* Every systems write route is require_admin_token (server-26#283);
operators get a read-only view. */}
{isAdmin && (
<button <button
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }} onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors" className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
> >
+ New System + New System
</button> </button>
)}
</div> </div>
{editing && ( {editing && (
@@ -1970,9 +1975,9 @@ export default function SystemsPage() {
)} )}
{loading ? ( {loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : systems.length === 0 ? ( ) : systems.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No systems defined yet.</p> <p className="text-ink-muted text-sm font-mono">No systems defined yet.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{systems.map((s) => { {systems.map((s) => {
@@ -1987,13 +1992,13 @@ export default function SystemsPage() {
? (s.config.channels as unknown[]).length ? (s.config.channels as unknown[]).length
: null; : null;
return ( return (
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono"> <div key={s.system_id} className="bg-surface border border-line rounded-lg p-4 font-mono">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-white font-semibold">{s.name}</p> <p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-gray-500">{s.system_id}</p> <p className="text-xs text-ink-muted">{s.system_id}</p>
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && ( {(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
<p className="text-xs text-gray-600 mt-0.5 space-x-2"> <p className="text-xs text-ink-muted mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>} {!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>} {ccCount !== null && <span>· {ccCount} CC</span>}
{chCount !== null && <span>· {chCount} ch</span>} {chCount !== null && <span>· {chCount} ch</span>}
@@ -2001,11 +2006,11 @@ export default function SystemsPage() {
</p> </p>
)} )}
</div> </div>
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded"> <span className="text-xs bg-raised border border-line-strong text-ink-2 px-2 py-0.5 rounded">
{kindLabel(s)} {kindLabel(s)}
</span> </span>
</div> </div>
<div className="mt-3 flex gap-3"> {isAdmin && <div className="mt-3 flex gap-3">
<button <button
onClick={() => openEdit(s)} onClick={() => openEdit(s)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
@@ -2014,7 +2019,7 @@ export default function SystemsPage() {
</button> </button>
<button <button
onClick={() => openDuplicate(s)} onClick={() => openDuplicate(s)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors" className="text-xs text-ink-muted hover:text-ink transition-colors"
> >
Duplicate Duplicate
</button> </button>
@@ -2024,15 +2029,21 @@ export default function SystemsPage() {
> >
Delete Delete
</button> </button>
</div> </div>}
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only {/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
(server-26#204 item 6), so for anyone else this panel could (server-26#204 item 6), so for anyone else this panel could
neither list the pool nor save a choice (server-26#213 item 4). */} neither list the pool nor save a choice (server-26#213 item 4). */}
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />} {isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
{/* All admin-only routes (server-26#283), incl. area-context and
talkgroup-pending GETs and every vocabulary write. */}
{isAdmin && (
<>
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} /> <AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} /> <AreaContextPanel systemId={s.system_id} />
<TalkgroupPendingPanel systemId={s.system_id} /> <TalkgroupPendingPanel systemId={s.system_id} />
<VocabularyPanel systemId={s.system_id} /> <VocabularyPanel systemId={s.system_id} />
</>
)}
</div> </div>
); );
})} })}
+3 -3
View File
@@ -69,18 +69,18 @@ export default function TermsPage() {
</div> </div>
<h1 className="text-display-sm text-white">Terms of Service</h1> <h1 className="text-display-sm text-white">Terms of Service</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p> <p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8"> <div className="mt-10 space-y-8">
{SECTIONS.map((s) => ( {SECTIONS.map((s) => (
<section key={s.heading}> <section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2> <h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p> <p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section> </section>
))} ))}
</div> </div>
<p className="text-gray-600 text-xs font-mono mt-16"> <p className="text-ink-muted text-xs font-mono mt-16">
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "} Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>. <Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
</p> </p>
+80 -77
View File
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types"; import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -141,7 +142,7 @@ function PlaceSearch({
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
placeholder="Search a place…" placeholder="Search a place…"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="button" type="button"
@@ -153,7 +154,7 @@ function PlaceSearch({
</button> </button>
</div> </div>
{results.length > 0 && ( {results.length > 0 && (
<div className="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden divide-y divide-gray-700"> <div className="bg-raised border border-line-strong rounded-lg overflow-hidden divide-y divide-line-strong">
{results.map((p) => ( {results.map((p) => (
<button <button
key={p.place_id} key={p.place_id}
@@ -162,7 +163,7 @@ function PlaceSearch({
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors" className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
> >
<p className="text-white text-sm font-medium">{p.name}</p> <p className="text-white text-sm font-medium">{p.name}</p>
<p className="text-gray-400 text-xs">{p.address}</p> <p className="text-ink-2 text-xs">{p.address}</p>
{p.rating && ( {p.rating && (
<p className="text-yellow-400 text-xs">★ {p.rating}</p> <p className="text-yellow-400 text-xs">★ {p.rating}</p>
)} )}
@@ -240,60 +241,60 @@ function AddEventModal({
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto" className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
> >
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2> <h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Title</label> <label className="text-xs text-ink-2 block mb-1">Title</label>
<input <input
required value={title} onChange={(e) => setTitle(e.target.value)} required value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="Dinner at the bar" placeholder="Dinner at the bar"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Date</label> <label className="text-xs text-ink-2 block mb-1">Date</label>
<input <input
required type="date" required type="date"
min={trip.start_date} max={trip.end_date} min={trip.start_date} max={trip.end_date}
value={date} onChange={(e) => setDate(e.target.value)} value={date} onChange={(e) => setDate(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Start</label> <label className="text-xs text-ink-2 block mb-1">Start</label>
<input <input
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">End</label> <label className="text-xs text-ink-2 block mb-1">End</label>
<input <input
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
min={startTime} min={startTime}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Find a place</label> <label className="text-xs text-ink-2 block mb-1">Find a place</label>
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} /> <PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1"> <label className="text-xs text-ink-2 block mb-1">
Location{" "} Location{" "}
<span className="text-gray-600">(auto-filled from search, or type manually)</span> <span className="text-ink-muted">(auto-filled from search, or type manually)</span>
</label> </label>
<input <input
value={location} onChange={(e) => setLocation(e.target.value)} value={location} onChange={(e) => setLocation(e.target.value)}
placeholder={`Inherits: ${trip.location}`} placeholder={`Inherits: ${trip.location}`}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
@@ -307,16 +308,16 @@ function AddEventModal({
)} )}
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Notes (optional)</label> <label className="text-xs text-ink-2 block mb-1">Notes (optional)</label>
<textarea <textarea
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
/> />
</div> </div>
{availableTags.length > 0 && ( {availableTags.length > 0 && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Tags</label> <label className="text-xs text-ink-2 block mb-1">Tags</label>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => { {availableTags.map((tag) => {
const active = tags.includes(tag); const active = tags.includes(tag);
@@ -326,7 +327,7 @@ function AddEventModal({
type="button" type="button"
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])} onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${ className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400" active ? tagColor(tag, availableTags) : "bg-raised text-ink-muted border-line-strong hover:border-line-strong hover:text-ink-2"
}`} }`}
> >
{tag} {tag}
@@ -340,7 +341,7 @@ function AddEventModal({
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end pt-1"> <div className="flex gap-3 justify-end pt-1">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2"> <button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
Cancel Cancel
</button> </button>
<button <button
@@ -389,7 +390,7 @@ function DayTimeline({
if (timed.length === 0 && untimed.length === 0) { if (timed.length === 0 && untimed.length === 0) {
return ( return (
<p className="text-gray-600 text-sm font-mono py-8 text-center"> <p className="text-ink-muted text-sm font-mono py-8 text-center">
No events on this day yet. No events on this day yet.
</p> </p>
); );
@@ -424,10 +425,10 @@ function DayTimeline({
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }} style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
className="absolute inset-x-0 flex items-center pointer-events-none" className="absolute inset-x-0 flex items-center pointer-events-none"
> >
<span className="w-14 text-right pr-3 text-xs text-gray-600 font-mono shrink-0 -translate-y-2"> <span className="w-14 text-right pr-3 text-xs text-ink-muted font-mono shrink-0 -translate-y-2">
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`} {h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
</span> </span>
<div className="flex-1 border-t border-gray-800/70" /> <div className="flex-1 border-t border-line/70" />
</div> </div>
))} ))}
@@ -444,7 +445,7 @@ function DayTimeline({
{e.end_time && ( {e.end_time && (
<div <div
style={{ top, height, left: 60, right: 0 }} style={{ top, height, left: 60, right: 0 }}
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none" className="absolute bg-raised/30 border-l-2 border-line-strong/30 z-0 pointer-events-none"
/> />
)} )}
{/* Dashed marker line + label */} {/* Dashed marker line + label */}
@@ -452,15 +453,15 @@ function DayTimeline({
style={{ top, left: 60, right: 0 }} style={{ top, left: 60, right: 0 }}
className="absolute z-10 flex items-center gap-2" className="absolute z-10 flex items-center gap-2"
> >
<div className="flex-1 border-t border-dashed border-gray-600/40" /> <div className="flex-1 border-t border-dashed border-line-strong/40" />
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1"> <span className="text-ink-muted text-[10px] font-mono shrink-0 pr-1">
{fmtTime(e.start_time)} {fmtTime(e.start_time)}
</span> </span>
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span> <span className="text-ink-muted text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
{isAdmin && ( {isAdmin && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button> <button onClick={() => onEdit(e)} className="text-ink-muted hover:text-indigo-400 text-[10px]">Edit</button>
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button> <button onClick={() => onDelete(e.event_id)} className="text-ink-muted hover:text-red-400 text-xs leading-none">×</button>
</div> </div>
)} )}
</div> </div>
@@ -483,25 +484,27 @@ function DayTimeline({
style={{ top, height, left: 60, right: 0 }} style={{ top, height, left: 60, right: 0 }}
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${ className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
isConflict isConflict
? "bg-red-950/70 border border-red-700/70" // Theme tokens: the old bg-indigo-950/60 had no light-mode
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70" // override, so light mode got dark text on a dark block.
? "bg-sev-major/15 border border-sev-major/50"
: "bg-accent/15 border border-accent/40 hover:border-accent/70"
}`} }`}
> >
<div className="flex items-start justify-between gap-2 h-full"> <div className="flex items-start justify-between gap-2 h-full">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p> <p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
{height >= 44 && ( {height >= 44 && (
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-red-400" : "text-indigo-400"}`}> <p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-sev-major" : "text-accent"}`}>
{fmtTime(e.start_time)} {fmtTime(e.start_time)}
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""} {e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
{isConflict ? " ⚠ conflict" : ""} {isConflict ? " ⚠ conflict" : ""}
</p> </p>
)} )}
{height >= 60 && !e.location_inherited && e.location && ( {height >= 60 && !e.location_inherited && e.location && (
<p className="text-gray-500 text-xs mt-0.5 truncate">{e.location}</p> <p className="text-ink-muted text-xs mt-0.5 truncate">{e.location}</p>
)} )}
{height >= 60 && e.notes && ( {height >= 60 && e.notes && (
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p> <p className="text-ink-muted text-xs mt-0.5 italic truncate">{e.notes}</p>
)} )}
{e.tags?.length > 0 && ( {e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
@@ -525,13 +528,13 @@ function DayTimeline({
<> <>
<button <button
onClick={() => onEdit(e)} onClick={() => onEdit(e)}
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity" className="text-ink-muted hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
> >
Edit Edit
</button> </button>
<button <button
onClick={() => onDelete(e.event_id)} onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity" className="text-ink-muted hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
> >
× ×
</button> </button>
@@ -543,9 +546,9 @@ function DayTimeline({
{drive && ( {drive && (
<div <div
style={{ top: top + height + 2, left: 60 }} style={{ top: top + height + 2, left: 60 }}
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20" className="absolute text-xs text-ink-muted font-mono flex items-center gap-1 z-20"
> >
<span className="text-gray-700">↓</span> {drive} drive <span className="text-ink-muted">↓</span> {drive} drive
</div> </div>
)} )}
</div> </div>
@@ -557,18 +560,18 @@ function DayTimeline({
{/* Untimed / all-day events */} {/* Untimed / all-day events */}
{untimed.length > 0 && ( {untimed.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-mono text-gray-600 uppercase tracking-wider">Unscheduled</p> <p className="text-xs font-mono text-ink-muted uppercase tracking-wider">Unscheduled</p>
{untimed.map((e) => ( {untimed.map((e) => (
<div <div
key={e.event_id} key={e.event_id}
className="bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 flex items-center justify-between group" className="bg-surface border border-line rounded-lg px-4 py-2.5 flex items-center justify-between group"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="text-white text-sm">{e.title}</p> <p className="text-white text-sm">{e.title}</p>
{!e.location_inherited && e.location && ( {!e.location_inherited && e.location && (
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p> <p className="text-ink-muted text-xs mt-0.5">{e.location}</p>
)} )}
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>} {e.notes && <p className="text-ink-muted text-xs italic mt-0.5">{e.notes}</p>}
{e.tags?.length > 0 && ( {e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)} {e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
@@ -585,11 +588,11 @@ function DayTimeline({
{isAdmin && ( {isAdmin && (
<> <>
<button onClick={() => onEdit(e)} <button onClick={() => onEdit(e)}
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity"> className="text-xs text-ink-muted hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
Edit Edit
</button> </button>
<button onClick={() => onDelete(e.event_id)} <button onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity"> className="text-ink-muted hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
× ×
</button> </button>
</> </>
@@ -728,17 +731,17 @@ function AssistantPanel({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="px-4 py-3 border-b border-gray-800 shrink-0 flex items-start justify-between gap-2"> <div className="px-4 py-3 border-b border-line shrink-0 flex items-start justify-between gap-2">
<div> <div>
<p className="text-white text-sm font-semibold">Trip Assistant</p> <p className="text-white text-sm font-semibold">Trip Assistant</p>
<p className="text-gray-500 text-xs mt-0.5"> <p className="text-ink-muted text-xs mt-0.5">
Tell me what you want to do — I can search places and suggest events. Tell me what you want to do — I can search places and suggest events.
</p> </p>
</div> </div>
{messages.length > 0 && ( {messages.length > 0 && (
<button <button
onClick={clearChat} onClick={clearChat}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors shrink-0 mt-0.5" className="text-xs text-ink-muted hover:text-ink-2 transition-colors shrink-0 mt-0.5"
> >
Clear Clear
</button> </button>
@@ -758,7 +761,7 @@ function AssistantPanel({
<button <button
key={prompt} key={prompt}
onClick={() => { setInput(prompt); }} onClick={() => { setInput(prompt); }}
className="w-full text-left bg-gray-800/60 hover:bg-gray-800 border border-gray-700/50 rounded-lg px-3 py-2 text-gray-400 text-xs transition-colors" className="w-full text-left bg-raised/60 hover:bg-raised border border-line-strong/50 rounded-lg px-3 py-2 text-ink-2 text-xs transition-colors"
> >
{prompt} {prompt}
</button> </button>
@@ -772,7 +775,7 @@ function AssistantPanel({
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${ className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
msg.role === "user" msg.role === "user"
? "bg-indigo-600 text-white" ? "bg-indigo-600 text-white"
: "bg-gray-800 text-gray-200" : "bg-raised text-ink"
}`} }`}
> >
{msg.role === "user" ? msg.content : ( {msg.role === "user" ? msg.content : (
@@ -804,11 +807,11 @@ function AssistantPanel({
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${ className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
s.added s.added
? "border-green-800/50 bg-green-950/30" ? "border-green-800/50 bg-green-950/30"
: "border-gray-700 bg-gray-900" : "border-line-strong bg-surface"
}`} }`}
> >
<p className="text-white text-sm font-semibold">{s.title}</p> <p className="text-white text-sm font-semibold">{s.title}</p>
<div className="text-xs text-gray-400 space-y-0.5"> <div className="text-xs text-ink-2 space-y-0.5">
{s.date && ( {s.date && (
<p> <p>
{fmtDate(s.date)} {fmtDate(s.date)}
@@ -817,7 +820,7 @@ function AssistantPanel({
</p> </p>
)} )}
{s.location && <p className="truncate">{s.location}</p>} {s.location && <p className="truncate">{s.location}</p>}
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>} {s.notes && <p className="text-ink-muted italic">{s.notes}</p>}
{s.tags && s.tags.length > 0 && ( {s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5"> <div className="flex flex-wrap gap-1 pt-0.5">
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)} {s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
@@ -840,7 +843,7 @@ function AssistantPanel({
</button> </button>
<button <button
onClick={() => dismissSuggestion(msg.id, idx)} onClick={() => dismissSuggestion(msg.id, idx)}
className="px-3 text-gray-500 hover:text-gray-300 text-xs transition-colors" className="px-3 text-ink-muted hover:text-ink text-xs transition-colors"
> >
Skip Skip
</button> </button>
@@ -856,7 +859,7 @@ function AssistantPanel({
{loading && ( {loading && (
<div className="flex items-start"> <div className="flex items-start">
<div className="bg-gray-800 rounded-xl px-3 py-2 text-gray-400 text-sm"> <div className="bg-raised rounded-xl px-3 py-2 text-ink-2 text-sm">
<span className="animate-pulse">Thinking…</span> <span className="animate-pulse">Thinking…</span>
</div> </div>
</div> </div>
@@ -865,7 +868,7 @@ function AssistantPanel({
</div> </div>
{/* Input */} {/* Input */}
<div className="px-3 py-3 border-t border-gray-800 shrink-0"> <div className="px-3 py-3 border-t border-line shrink-0">
<div className="flex gap-2"> <div className="flex gap-2">
<textarea <textarea
ref={inputRef} ref={inputRef}
@@ -875,7 +878,7 @@ function AssistantPanel({
placeholder="What do you want to do?" placeholder="What do you want to do?"
disabled={loading} disabled={loading}
rows={1} rows={1}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden" className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
/> />
<button <button
onClick={send} onClick={send}
@@ -955,7 +958,7 @@ export default function TripDetailPage() {
async function handleDeleteTrip() { async function handleDeleteTrip() {
if (!trip) return; if (!trip) return;
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); } try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
catch (e) { console.error(e); } catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
} }
async function handleDeleteEvent(eventId: string) { async function handleDeleteEvent(eventId: string) {
@@ -963,7 +966,7 @@ export default function TripDetailPage() {
try { try {
await c2api.deleteTripEvent(trip.trip_id, eventId); await c2api.deleteTripEvent(trip.trip_id, eventId);
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev); setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
} catch (e) { console.error(e); } } catch (e) { alert(`Couldn't delete the event: ${errorMessage(e)}`); }
} }
async function handleUpdateEvent(body: object) { async function handleUpdateEvent(body: object) {
@@ -1055,8 +1058,8 @@ export default function TripDetailPage() {
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev); setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
} }
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>; if (loading) return <p className="text-ink-muted text-sm font-mono">Loading…</p>;
if (!trip) return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>; if (!trip) return <p className="text-ink-muted text-sm font-mono">Trip not found.</p>;
const days = dateRange(trip.start_date, trip.end_date); const days = dateRange(trip.start_date, trip.end_date);
const attendees = Object.values(trip.attendees ?? {}); const attendees = Object.values(trip.attendees ?? {});
@@ -1070,7 +1073,7 @@ export default function TripDetailPage() {
<div className="space-y-4"> <div className="space-y-4">
<button <button
onClick={() => router.push("/trips")} onClick={() => router.push("/trips")}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors" className="text-sm font-mono text-ink-muted hover:text-ink transition-colors"
> >
← Trips ← Trips
</button> </button>
@@ -1078,7 +1081,7 @@ export default function TripDetailPage() {
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-white text-2xl font-bold">{trip.name}</h1> <h1 className="text-white text-2xl font-bold">{trip.name}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2">
<span>{trip.location}</span> <span>{trip.location}</span>
{trip.maps_link && ( {trip.maps_link && (
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer" <a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
@@ -1086,11 +1089,11 @@ export default function TripDetailPage() {
Maps Maps
</a> </a>
)} )}
<span className="text-gray-700">•</span> <span className="text-ink-muted">•</span>
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span> <span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
{attendees.length > 0 && ( {attendees.length > 0 && (
<> <>
<span className="text-gray-700">•</span> <span className="text-ink-muted">•</span>
<span className="text-xs">{attendees.join(", ")}</span> <span className="text-xs">{attendees.join(", ")}</span>
</> </>
)} )}
@@ -1114,7 +1117,7 @@ export default function TripDetailPage() {
</button> </button>
<button <button
onClick={handleToggleVisibility} onClick={handleToggleVisibility}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2" className="text-xs text-ink-muted hover:text-ink transition-colors border border-line-strong rounded-lg px-3 py-2"
> >
{trip.visibility === "private" ? "Make public" : "Make private"} {trip.visibility === "private" ? "Make public" : "Make private"}
</button> </button>
@@ -1159,9 +1162,9 @@ export default function TripDetailPage() {
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
placeholder="Add tag…" placeholder="Add tag…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24" className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-24"
/> />
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button> <button onClick={handleAddTag} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
</div> </div>
)} )}
</div> </div>
@@ -1170,13 +1173,13 @@ export default function TripDetailPage() {
{/* Invite management — admin only, only when private */} {/* Invite management — admin only, only when private */}
{isAdmin && trip.visibility === "private" && ( {isAdmin && trip.visibility === "private" && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p> <p className="text-xs text-ink-muted uppercase tracking-wider font-mono">Invited</p>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{(trip.invited_discord_ids ?? []).length === 0 && ( {(trip.invited_discord_ids ?? []).length === 0 && (
<span className="text-xs text-gray-600">No invites yet</span> <span className="text-xs text-ink-muted">No invites yet</span>
)} )}
{(trip.invited_discord_ids ?? []).map((discordId) => ( {(trip.invited_discord_ids ?? []).map((discordId) => (
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300"> <span key={discordId} className="inline-flex items-center gap-1 text-xs bg-raised border border-line-strong rounded-full px-2.5 py-0.5 text-ink-2">
<span className="font-mono">{discordId}</span> <span className="font-mono">{discordId}</span>
<button <button
onClick={() => handleRevokeInvite(discordId)} onClick={() => handleRevokeInvite(discordId)}
@@ -1192,9 +1195,9 @@ export default function TripDetailPage() {
onChange={(e) => setInviteInput(e.target.value)} onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())} onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
placeholder="Discord user ID…" placeholder="Discord user ID…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36" className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-36"
/> />
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button> <button onClick={handleInvite} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1218,13 +1221,13 @@ export default function TripDetailPage() {
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${ className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
day === selectedDay day === selectedDay
? "bg-indigo-600 text-white" ? "bg-indigo-600 text-white"
: "bg-gray-900 border border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-200" : "bg-surface border border-line text-ink-2 hover:border-line-strong hover:text-ink"
}`} }`}
> >
<span className="font-mono font-bold">{short}</span> <span className="font-mono font-bold">{short}</span>
<span className="font-mono">{date}</span> <span className="font-mono">{date}</span>
{count > 0 && ( {count > 0 && (
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-gray-600"}`}> <span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-ink-muted"}`}>
{count} {count}
</span> </span>
)} )}
@@ -1238,7 +1241,7 @@ export default function TripDetailPage() {
{/* Selected day */} {/* Selected day */}
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm font-mono text-gray-400 uppercase tracking-wider"> <p className="text-sm font-mono text-ink-2 uppercase tracking-wider">
{fmtDayHeading(selectedDay)} {fmtDayHeading(selectedDay)}
</p> </p>
<DayTimeline <DayTimeline
@@ -1254,7 +1257,7 @@ export default function TripDetailPage() {
</div> </div>
{/* Right: AI Assistant — sticky */} {/* Right: AI Assistant — sticky */}
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-surface border border-line rounded-xl overflow-hidden">
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} /> <AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
</div> </div>
</div> </div>
+24 -22
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useTrips } from "@/lib/useTrips"; import { useTrips } from "@/lib/useTrips";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripRecord } from "@/lib/types"; import type { TripRecord } from "@/lib/types";
function fmtDate(iso: string) { function fmtDate(iso: string) {
@@ -29,25 +30,25 @@ function TripCard({ trip, isAdmin, onDelete }: {
return ( return (
<div <div
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors" className="bg-surface border border-line rounded-xl p-5 cursor-pointer hover:border-line-strong transition-colors"
onClick={() => router.push(`/trips/${trip.trip_id}`)} onClick={() => router.push(`/trips/${trip.trip_id}`)}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ <span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500" upcoming ? "bg-indigo-900 text-indigo-300" : "bg-raised text-ink-muted"
}`}> }`}>
{upcoming ? "Upcoming" : "Past"} {upcoming ? "Upcoming" : "Past"}
</span> </span>
</div> </div>
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3> <h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
<p className="text-gray-400 text-xs mt-1">{trip.location}</p> <p className="text-ink-2 text-xs mt-1">{trip.location}</p>
<p className="text-gray-500 text-xs font-mono mt-1"> <p className="text-ink-muted text-xs font-mono mt-1">
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)} {fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
</p> </p>
{attendeeCount > 0 && ( {attendeeCount > 0 && (
<p className="text-gray-500 text-xs mt-1"> <p className="text-ink-muted text-xs mt-1">
{attendeeCount} going {attendeeCount} going
</p> </p>
)} )}
@@ -102,59 +103,59 @@ function CreateModal({ onClose, onCreate }: {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto" className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
> >
<h2 className="text-white font-bold">New Trip</h2> <h2 className="text-white font-bold">New Trip</h2>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Name</label> <label className="text-xs text-ink-2 block mb-1">Name</label>
<input <input
required value={name} onChange={(e) => setName(e.target.value)} required value={name} onChange={(e) => setName(e.target.value)}
placeholder="Road trip to Nashville" placeholder="Road trip to Nashville"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Location</label> <label className="text-xs text-ink-2 block mb-1">Location</label>
<input <input
required value={location} onChange={(e) => setLocation(e.target.value)} required value={location} onChange={(e) => setLocation(e.target.value)}
placeholder="Nashville, TN" placeholder="Nashville, TN"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Start date</label> <label className="text-xs text-ink-2 block mb-1">Start date</label>
<input <input
required type="date" value={start} onChange={(e) => setStart(e.target.value)} required type="date" value={start} onChange={(e) => setStart(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">End date</label> <label className="text-xs text-ink-2 block mb-1">End date</label>
<input <input
required type="date" value={end} onChange={(e) => setEnd(e.target.value)} required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
min={start} min={start}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label> <label className="text-xs text-ink-2 block mb-1">Google Maps link (optional)</label>
<input <input
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)} type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
placeholder="https://maps.google.com/…" placeholder="https://maps.google.com/…"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2"> <button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
Cancel Cancel
</button> </button>
<button <button
@@ -179,8 +180,9 @@ export default function TripsPage() {
const past = trips.filter((t) => t.end_date < today); const past = trips.filter((t) => t.end_date < today);
async function handleDelete(id: string) { async function handleDelete(id: string) {
if (!confirm("Delete this trip?")) return;
try { await c2api.deleteTrip(id); } try { await c2api.deleteTrip(id); }
catch (e) { console.error(e); } catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
} }
return ( return (
@@ -198,12 +200,12 @@ export default function TripsPage() {
</div> </div>
{loading ? ( {loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<> <>
{upcoming.length > 0 && ( {upcoming.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2> <h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Upcoming</h2>
<div className="space-y-3"> <div className="space-y-3">
{upcoming.map((t) => ( {upcoming.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} /> <TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
@@ -214,7 +216,7 @@ export default function TripsPage() {
{past.length > 0 && ( {past.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2> <h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Past</h2>
<div className="space-y-3"> <div className="space-y-3">
{past.map((t) => ( {past.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} /> <TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
@@ -224,7 +226,7 @@ export default function TripsPage() {
)} )}
{trips.length === 0 && ( {trips.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No trips yet.</p> <p className="text-ink-muted text-sm font-mono">No trips yet.</p>
)} )}
</> </>
)} )}
+9 -9
View File
@@ -40,11 +40,11 @@ export default function WaitlistPage() {
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span> <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB DRB
</Link> </Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"> <div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
{done ? ( {done ? (
<> <>
<h1 className="text-white text-lg font-bold">You&apos;re on the list</h1> <h1 className="text-white text-lg font-bold">You&apos;re on the list</h1>
<p className="text-gray-400 text-sm leading-relaxed"> <p className="text-ink-2 text-sm leading-relaxed">
Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "} Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>. <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
</p> </p>
@@ -53,39 +53,39 @@ export default function WaitlistPage() {
<> <>
<div> <div>
<h1 className="text-white text-lg font-bold">Request access</h1> <h1 className="text-white text-lg font-bold">Request access</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed"> <p className="text-ink-2 text-xs mt-2 leading-relaxed">
Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up. Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Email</label> <label className="text-xs text-ink-2 block mb-1">Email</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoComplete="email" autoComplete="email"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label> <label className="text-xs text-ink-2 block mb-1">Organization (optional)</label>
<input <input
type="text" type="text"
value={orgName} value={orgName}
onChange={(e) => setOrgName(e.target.value)} onChange={(e) => setOrgName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label> <label className="text-xs text-ink-2 block mb-1">Anything else? (optional)</label>
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={3} rows={3}
maxLength={2000} maxLength={2000}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 resize-none" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 resize-none"
/> />
</div> </div>
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
+32 -32
View File
@@ -85,17 +85,17 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
return ( return (
<> <>
<tr <tr
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`} className={`border-b border-line font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-raised/50" : "hover:bg-raised/30"}`}
onClick={() => hasDetails && setExpanded((v) => !v)} onClick={() => hasDetails && setExpanded((v) => !v)}
> >
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap"> <td className="px-4 py-2 text-ink-2 text-xs whitespace-nowrap">
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span> <span className="text-ink-muted">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} {new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</td> </td>
<td className="px-4 py-2 text-gray-300"> <td className="px-4 py-2 text-ink-2">
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span> <span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
{call.tags && call.tags.length > 0 && ( {call.tags && call.tags.length > 0 && (
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-gray-800 text-gray-400"}`}> <span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-raised text-ink-2"}`}>
{call.tags[0]} {call.tags[0]}
</span> </span>
)} )}
@@ -104,29 +104,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<span className="ml-2">{severityBadge(call.severity)}</span> <span className="ml-2">{severityBadge(call.severity)}</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td> <td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td> <td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{call.node_id}</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{isActive ? ( {isActive ? (
<span className="text-orange-400 animate-pulse">● live</span> <span className="text-orange-400 animate-pulse">● live</span>
) : ( ) : (
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span> <span className="text-ink-muted">{duration(call.started_at, call.ended_at)}</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-xs"> <td className="px-4 py-2 text-xs">
{hasAudio ? ( {hasAudio ? (
<span className="text-blue-400">▶</span> <span className="text-accent">▶</span>
) : ( ) : (
<span className="text-gray-700">—</span> <span className="text-ink-muted">—</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-gray-600 text-xs"> <td className="px-4 py-2 text-ink-muted text-xs">
{hasDetails && (expanded ? "▲" : "▼")} {hasDetails && (expanded ? "▲" : "▼")}
</td> </td>
</tr> </tr>
{expanded && hasDetails && ( {expanded && hasDetails && (
<tr className="bg-gray-900/60 border-b border-gray-800"> <tr className="bg-surface/60 border-b border-line">
<td colSpan={7} className="px-6 py-3 space-y-2"> <td colSpan={7} className="px-6 py-3 space-y-2">
{/* Audio player */} {/* Audio player */}
{hasAudio && ( {hasAudio && (
@@ -140,7 +140,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : ( ) : (
<p className="text-xs text-gray-600 font-mono">Loading audio…</p> <p className="text-xs text-ink-muted font-mono">Loading audio…</p>
) )
)} )}
@@ -150,7 +150,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{call.tags.map((tag) => ( {call.tags.map((tag) => (
<span <span
key={tag} key={tag}
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`} className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-raised text-ink-2"}`}
> >
{tag} {tag}
</span> </span>
@@ -161,7 +161,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{/* Incident links — one per scene detected in the recording */} {/* Incident links — one per scene detected in the recording */}
{incidentIds.length > 0 && ( {incidentIds.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400"> <div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
<span className="text-gray-600">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span> <span className="text-ink-muted">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
{incidentIds.map((id) => ( {incidentIds.map((id) => (
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300"> <a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
{id.slice(0, 8)}… {id.slice(0, 8)}…
@@ -172,9 +172,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{/* Correlation debug — admin only */} {/* Correlation debug — admin only */}
{isAdmin && call.corr_path && ( {isAdmin && call.corr_path && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600"> <div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-ink-muted">
<span>corr:</span> <span>corr:</span>
<span className="text-gray-400">{call.corr_path}</span> <span className="text-ink-2">{call.corr_path}</span>
{call.corr_incident_idle_min != null && ( {call.corr_incident_idle_min != null && (
<span>idle {call.corr_incident_idle_min}min</span> <span>idle {call.corr_incident_idle_min}min</span>
)} )}
@@ -200,7 +200,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
value={editText} value={editText}
onChange={(e) => setEditText(e.target.value)} onChange={(e) => setEditText(e.target.value)}
rows={4} rows={4}
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-xs text-ink font-mono focus:outline-none focus:border-indigo-500 resize-none"
/> />
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
@@ -212,7 +212,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); setEditing(false); }} onClick={(e) => { e.stopPropagation(); setEditing(false); }}
className="text-xs text-gray-500 hover:text-gray-300 px-3 py-1 transition-colors" className="text-xs text-ink-muted hover:text-ink px-3 py-1 transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -224,23 +224,23 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
) : hasSegments ? ( ) : hasSegments ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-gray-600 font-mono"> <span className="text-xs text-ink-muted font-mono">
{hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`} {hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
</span> </span>
{isAdmin && ( {isAdmin && (
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button> <button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
)} )}
</div> </div>
{hasBoth && !showOriginal ? ( {hasBoth && !showOriginal ? (
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto"> <pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
{call.transcript_corrected} {call.transcript_corrected}
</pre> </pre>
) : ( ) : (
<div className="bg-gray-800 rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto"> <div className="bg-raised rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
{call.segments!.map((seg, i) => ( {call.segments!.map((seg, i) => (
<div key={i} className="flex gap-3 text-xs font-mono"> <div key={i} className="flex gap-3 text-xs font-mono">
<span className="text-gray-600 shrink-0">{i + 1}. [{seg.start}s]</span> <span className="text-ink-muted shrink-0">{i + 1}. [{seg.start}s]</span>
<span className="text-gray-300">{seg.text}</span> <span className="text-ink-2">{seg.text}</span>
</div> </div>
))} ))}
</div> </div>
@@ -248,7 +248,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{hasBoth && ( {hasBoth && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }} onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors" className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
> >
{showOriginal ? "show corrected ↑" : "show original ↓"} {showOriginal ? "show corrected ↑" : "show original ↓"}
</button> </button>
@@ -258,27 +258,27 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "corrected"}</span>} {hasBoth && <span className="text-xs text-ink-muted font-mono">{showOriginal ? "original" : "corrected"}</span>}
{!hasBoth && call.transcript_corrected && <span className="text-xs text-gray-600 font-mono">corrected</span>} {!hasBoth && call.transcript_corrected && <span className="text-xs text-ink-muted font-mono">corrected</span>}
</div> </div>
{isAdmin && ( {isAdmin && (
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button> <button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
)} )}
</div> </div>
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto"> <pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
{displayTranscript} {displayTranscript}
</pre> </pre>
{hasBoth && ( {hasBoth && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }} onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors" className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
> >
{showOriginal ? "show corrected ↑" : "show original ↓"} {showOriginal ? "show corrected ↑" : "show original ↓"}
</button> </button>
)} )}
</div> </div>
) : ( ) : (
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p> <p className="text-xs text-ink-muted font-mono italic">No transcript available.</p>
)} )}
</td> </td>
</tr> </tr>
+2 -2
View File
@@ -283,11 +283,11 @@ export function CameraFeed({ camera, className, mode = "auto", priority = "norma
{camera.image_url ? "…" : "No still for this camera"} {camera.image_url ? "…" : "No still for this camera"}
</div> </div>
)} )}
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame"> <span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[10px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
{badge.text} {badge.text}
</span> </span>
{credit?.attribution && ( {credit?.attribution && (
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate"> <span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[10px] leading-[13px] truncate">
{credit.attribution_url ? ( {credit.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a> <a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
) : credit.attribution} ) : credit.attribution}
+163 -19
View File
@@ -18,19 +18,31 @@
* applied ONCE here and the filtered lists feed the map (pins, paths, rail), * applied ONCE here and the filtered lists feed the map (pins, paths, rail),
* the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline * the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline
* health stays on the unfiltered data — a filter must never hide an outage. * health stays on the unfiltered data — a filter must never hide an outage.
*
* Call window (server-26#239): the 500-call window feeds the ticker, scrubber
* and region; the SELECTED incident also loads its own calls by incident id
* and merges them in, so a long incident's path and spine are complete. An
* `#inc=` that is not active is fetched by id (resolved → banner; missing or
* another org → "not found"). The scrubber hatches time the window didn't load.
*
* Attention cues (server-26#238): lib/attention.ts diffs successive snapshots
* (Major opened, severity up, pursuit path stop, priority keyword) after a
* silent baseline. Cues, the NEW lane and pin pulses only ever cover the
* FILTERED incidents; tone and notifications are opt-in (AttentionControls).
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { useActiveCalls, useCalls } from "@/lib/useCalls"; import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents"; import { useActiveIncidentsState, useIncident } from "@/lib/useIncidents";
import { useAuth } from "@/components/AuthProvider";
import { useAircraft } from "@/lib/useAircraft"; import { useAircraft } from "@/lib/useAircraft";
import { useVessels } from "@/lib/useVessels"; import { useVessels } from "@/lib/useVessels";
import { useCamerasInBbox } from "@/lib/useCameras"; import { useCamerasInBbox } from "@/lib/useCameras";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { severityRank } from "@/lib/severity"; import { severityRank } from "@/lib/severity";
import type { Camera, Ny511Response } from "@/lib/types"; import type { CallRecord, Camera, IncidentRecord, Ny511Response } from "@/lib/types";
import { import {
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers, DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink, onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
@@ -47,17 +59,22 @@ import { CameraDrill } from "@/components/live/CameraDrill";
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels"; import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
import { TruthStrip } from "@/components/live/TruthStrip"; import { TruthStrip } from "@/components/live/TruthStrip";
import { usePipelineHealth } from "@/lib/usePipelineHealth"; import { usePipelineHealth } from "@/lib/usePipelineHealth";
import { DEFAULT_ATTENTION_PREFS, loadAttentionPrefs, saveAttentionPrefs, useAttention, type AttentionPrefs } from "@/lib/attention";
import { NewLane } from "@/components/live/NewLane";
import { AttentionControls, useAttentionCues } from "@/components/live/AttentionControls";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const TOUR_MS = 20_000; const TOUR_MS = 20_000;
/** The wall's recent-call window (server-26#239: a selected incident loads past it). */
const RECENT_CALLS = 500;
// 10-foot type for kiosk walls (server-26#234): scales every small text class // 10-foot type for kiosk walls (server-26#234): scales every small text class
// under the wall root — including MapView's rail and WallPanels, which are not // under the wall root — including MapView's rail and WallPanels, which are not
// edited for it — to 18-20 px. Literal strings so Tailwind's scanner sees them. // edited for it — to 18-20 px. Literal strings so Tailwind's scanner sees them.
const KIOSK_TYPE = [ const KIOSK_TYPE = [
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug", "[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug",
"[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug", "[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug",
"[&[data-kiosk]_[class*='text-[9px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
"[&[data-kiosk]_.w-56]:w-80", "[&[data-kiosk]_.w-56]:w-80",
@@ -67,6 +84,18 @@ const MAX_TAG_CHIPS = 8;
const MAX_PINNED = 6; const MAX_PINNED = 6;
const EVENTS_POLL_MS = 2 * 60 * 1000; const EVENTS_POLL_MS = 2 * 60 * 1000;
/** Incident-doc fields the frontend type doesn't declare yet (backend writes both). */
type IncidentDoc = IncidentRecord & { org_id?: string | null; resolved_at?: string | null };
/** "Resolved 14:32", with the date when it wasn't today. */
function resolvedLabel(inc: IncidentDoc): string {
const d = inc.resolved_at ? new Date(inc.resolved_at) : null;
if (!d || Number.isNaN(d.getTime())) return "Resolved";
const hm = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
const today = d.toDateString() === new Date().toDateString();
return today ? `Resolved ${hm}` : `Resolved ${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${hm}`;
}
function timeAgo(date: Date): string { function timeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000); const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`; if (s < 60) return `${s}s ago`;
@@ -119,10 +148,11 @@ function Kpi({ value, label }: { value: string | number; label: string }) {
export function LiveView() { export function LiveView() {
const { nodes, loading: nodesLoading } = useNodes(); const { nodes, loading: nodesLoading } = useNodes();
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
const activeIncidents = useActiveIncidents(); const { incidents: activeIncidents, loaded: incidentsLoaded } = useActiveIncidentsState();
// Wide-ish recent window: feeds incident paths, the call spine, the ticker // Wide-ish recent window: feeds incident paths, the call spine, the ticker
// and the scrubber's density bars. Real recent calls, not a fixture. // and the scrubber's density bars. Real recent calls, not a fixture.
const { calls: recentCalls, loading: callsLoading, error: callsError } = useCalls(500); const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS);
const { orgId } = useAuth();
const { systems } = useSystems(); const { systems } = useSystems();
const { aircraft } = useAircraft(); const { aircraft } = useAircraft();
const { vessels } = useVessels(); const { vessels } = useVessels();
@@ -250,10 +280,53 @@ export function LiveView() {
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id))); for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
return m; return m;
}, [activeIncidents, callsByIncident]); }, [activeIncidents, callsByIncident]);
// ── Selection (server-26#239) ─────────────────────────────────────────────
// Active first; an #inc= that isn't active (resolved, or the active list is
// still loading) is looked up by id. Another org's incident never selects:
// the rules should deny the read, and the org check here covers a project
// where they aren't deployed yet (DEFERRED.md, server-26#13).
const activeSelected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
const lookupId = link.inc && !activeSelected ? link.inc : null;
const { incident: lookedUp, loading: lookupLoading } = useIncident(lookupId);
const fetchedSelected: IncidentDoc | null =
lookupId && lookedUp && lookedUp.incident_id === lookupId && !!orgId && (lookedUp as IncidentDoc).org_id === orgId
? lookedUp
: null;
const lookupPending = !!lookupId && !fetchedSelected && (lookupLoading || !orgId);
const notFound = !!lookupId && !fetchedSelected && !lookupPending;
// The incident on screen resolving drops it from the active list before its
// by-id lookup lands. Hold the last doc for that same id meanwhile, so the
// wall doesn't fly back to the region and straight back again.
const lastSelected = useRef<IncidentDoc | null>(null);
const held = lookupPending && lastSelected.current?.incident_id === link.inc ? lastSelected.current : null;
const selected: IncidentDoc | null = activeSelected ?? fetchedSelected ?? held;
lastSelected.current = selected;
const resolvedSelected = fetchedSelected?.status === "resolved" ? fetchedSelected : null;
// The selected incident's own calls, by incident id — no limit, so a long
// incident keeps every stop the 500-call window has already dropped. The
// hook keeps the previous incident's rows until its new snapshot lands, so
// rows are re-checked against the current id before merging.
const { calls: incidentCalls } = useCallsByIncident(link.inc);
const selectedCalls = useMemo<CallRecord[]>(() => {
if (!selected) return [];
const id = selected.incident_id;
const byId = new Map<string, CallRecord>();
for (const c of callsByIncident.get(id) ?? []) byId.set(c.call_id, c);
for (const c of incidentCalls) {
if (c.incident_ids?.includes(id) || c.incident_id === id) byId.set(c.call_id, c);
}
return Array.from(byId.values());
}, [selected, callsByIncident, incidentCalls]);
const selectedPoints = useMemo(() => (selected ? incidentPoints(selected, selectedCalls) : []), [selected, selectedCalls]);
const regionBox = useMemo(() => { const regionBox = useMemo(() => {
const pts = Array.from(pointsById.values()).flat(); const pts = Array.from(pointsById.values()).flat();
// A resolved incident opened by link sits outside the active set; count it
// so its nearby cameras load.
if (resolvedSelected) pts.push(...selectedPoints);
return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5); return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5);
}, [pointsById, nodes]); }, [pointsById, nodes, resolvedSelected, selectedPoints]);
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox); const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]); const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
// Pins live in this browser and carry their image URLs, so a pin must not // Pins live in this browser and carry their image URLs, so a pin must not
@@ -276,19 +349,20 @@ export function LiveView() {
}); });
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]); const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
// A drilled-into incident stays on the map even if the filter excludes it // A drilled-into incident stays on the map even if the filter excludes it
// (a shared #inc= link must still land); it is not added to the tour. // or it has resolved (a shared #inc= link must still land); it is not added
// to the tour. Its full call list rides along so the path is complete.
const keepSelected = !!selected && !filtered.ids.has(selected.incident_id); const keepSelected = !!selected && !filtered.ids.has(selected.incident_id);
const mapIncidents = useMemo( const mapIncidents = useMemo(
() => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents), () => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents),
[keepSelected, selected, filtered.incidents], [keepSelected, selected, filtered.incidents],
); );
const mapCalls = useMemo(() => { const mapCalls = useMemo(() => {
if (!keepSelected || !selected) return filtered.calls; if (!selectedCalls.length) return filtered.calls;
const have = new Set(filtered.calls.map((c) => c.call_id)); const have = new Set(filtered.calls.map((c) => c.call_id));
return [...filtered.calls, ...(callsByIncident.get(selected.incident_id) ?? []).filter((c) => !have.has(c.call_id))]; const extra = selectedCalls.filter((c) => !have.has(c.call_id));
}, [keepSelected, selected, filtered.calls, callsByIncident]); return extra.length ? [...filtered.calls, ...extra] : filtered.calls;
}, [selectedCalls, filtered.calls]);
// Filter choices come from what the wall has loaded (plus system names). // Filter choices come from what the wall has loaded (plus system names).
const filterOptions = useMemo(() => { const filterOptions = useMemo(() => {
@@ -315,8 +389,7 @@ export function LiveView() {
tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t), tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t),
}; };
}, [systems, recentCalls, activeIncidents]); }, [systems, recentCalls, activeIncidents]);
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : []; const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]);
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
const drillCam: Camera | null = link.cam const drillCam: Camera | null = link.cam
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null ? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
: null; : null;
@@ -334,6 +407,32 @@ export function LiveView() {
// ── Navigation ──────────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────────
const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]); const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]);
// ── Attention cues (server-26#238) ────────────────────────────────────────
// Diffed over everything loaded (so widening the filter is never "new");
// emitted, laned and pulsed only for what the filter shows.
const [attnPrefs, setAttnPrefs] = useState<AttentionPrefs>(DEFAULT_ATTENTION_PREFS);
useEffect(() => { setAttnPrefs(loadAttentionPrefs()); }, []);
const changeAttnPrefs = useCallback((p: AttentionPrefs) => { setAttnPrefs(p); saveAttentionPrefs(p); }, []);
const cues = useAttentionCues(attnPrefs, selectIncident);
const attentionCalls = useMemo(() => {
if (!incidentCalls.length) return recentCalls;
const have = new Set(recentCalls.map((c) => c.call_id));
const extra = incidentCalls.filter((c) => !have.has(c.call_id));
return extra.length ? [...recentCalls, ...extra] : recentCalls;
}, [recentCalls, incidentCalls]);
const visibleCallIds = useMemo(() => new Set(filtered.calls.map((c) => c.call_id)), [filtered.calls]);
const attention = useAttention({
ready: incidentsLoaded && !!orgId,
resetKey: orgId ?? null,
incidents: activeIncidents,
calls: attentionCalls,
keywords: attnPrefs.keywords,
visible: filtered.incidents,
visibleIds: filtered.ids,
visibleCallIds,
onEvents: cues.fire,
});
const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]); const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]);
const stepUp = useCallback(() => { const stepUp = useCallback(() => {
if (link.cam) patchLink({ cam: null, tour: false }); if (link.cam) patchLink({ cam: null, tour: false });
@@ -370,6 +469,14 @@ export function LiveView() {
return () => { clearInterval(id); clearInterval(prog); }; return () => { clearInterval(id); clearInterval(prog); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [link.tour, tourList.join(","), tourMs]); }, [link.tour, tourList.join(","), tourMs]);
// Scrubber honesty (server-26#239): when the window came back full, nothing
// older than its oldest call was loaded — that span is unknown, not quiet.
const loadedSince = useMemo(() => {
if (!windowFull) return null;
let oldest: string | null = null;
for (const c of recentCalls) if (c.started_at && (!oldest || c.started_at < oldest)) oldest = c.started_at;
return oldest;
}, [windowFull, recentCalls]);
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0; const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0;
// ── KPIs ────────────────────────────────────────────────────────────────── // ── KPIs ──────────────────────────────────────────────────────────────────
@@ -429,6 +536,7 @@ export function LiveView() {
className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite"> className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite">
{copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"} {copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"}
</button> </button>
<AttentionControls prefs={attnPrefs} onChange={changeAttnPrefs} armed={cues.armed} />
<KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} /> <KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} />
<span className="font-mono text-ink-2 tabular-nums">{clock}</span> <span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div> </div>
@@ -452,7 +560,11 @@ export function LiveView() {
{/* Degraded → grey the map and its incident rail (the rail lives inside {/* Degraded → grey the map and its incident rail (the rail lives inside
MapView, so it is wrapped here rather than edited). */} MapView, so it is wrapped here rather than edited). */}
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}> <div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
{nodesLoading || callsLoading || !mapState ? ( {/* Mount the map as soon as its starting view is known. The 500-call
window took ~9s on prod and used to hold the whole map back;
calls and paths now stream in once it lands. Nodes are only
awaited when there's no saved view, since they pick the centre. */}
{!mapState || (!mapState.view && nodesLoading) ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div> <div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : ( ) : (
<MapView <MapView
@@ -466,6 +578,8 @@ export function LiveView() {
onOpenCamera={openCamera} onOpenCamera={openCamera}
onAir={onAir} onAir={onAir}
camCountByIncident={camCountByIncident} camCountByIncident={camCountByIncident}
pulseIncidentIds={attention.pulseIds}
filteredFrom={filtered.active ? activeIncidents.length : undefined}
key={mapGen} key={mapGen}
initialLayers={mapState.layers} initialLayers={mapState.layers}
initialBase={mapState.base} initialBase={mapState.base}
@@ -475,6 +589,10 @@ export function LiveView() {
)} )}
</div> </div>
{!nodesLoading && !callsLoading && mapState && (
<NewLane entries={attention.lane} now={attention.now} onSelect={selectIncident} />
)}
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's {/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). */} Activation screen handles the zero-node case). */}
{configuredButQuiet && ( {configuredButQuiet && (
@@ -487,11 +605,35 @@ export function LiveView() {
</div> </div>
<aside className={`${kiosk ? "lg:w-[480px]" : "lg:w-[360px]"} lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface`} <aside className={`${kiosk ? "lg:w-[480px]" : "lg:w-[360px]"} lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface`}
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}> aria-live="polite" aria-label={link.inc ? "Incident detail" : "Region overview"}>
{selected ? ( {link.inc && !selected ? (
<div className="p-4 text-sm">
{lookupPending ? (
<p className="text-ink-muted">Loading incident…</p>
) : notFound ? (
<>
<p className="font-semibold text-ink">Incident not found</p>
<p className="mt-1 text-xs text-ink-muted">
This link points to an incident that doesn&apos;t exist or isn&apos;t in your organization.
</p>
<button type="button" onClick={() => patchLink({ inc: null, cam: null, tour: false })}
className="mt-3 px-2 py-0.5 rounded border border-line text-xs text-ink-2 hover:text-ink">
Back to region
</button>
</>
) : null}
</div>
) : selected ? (
<>
{resolvedSelected && (
<div role="status" className="px-3 py-1.5 border-b border-line bg-raised text-xs text-ink-2">
<b className="text-ink font-semibold">{resolvedLabel(resolvedSelected)}</b>
{" · no longer active — opened from a link"}
</div>
)}
<IncidentPanel <IncidentPanel
inc={selected} inc={selected}
calls={callsByIncident.get(selected.incident_id) ?? []} calls={selectedCalls}
points={selectedPoints} points={selectedPoints}
near={near} near={near}
feeds={feeds} feeds={feeds}
@@ -503,6 +645,7 @@ export function LiveView() {
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }} eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
onOpenCamera={openCamera} onOpenCamera={openCamera}
/> />
</>
) : ( ) : (
<RegionPanel <RegionPanel
incidents={filtered.incidents} incidents={filtered.incidents}
@@ -524,7 +667,8 @@ export function LiveView() {
</div> </div>
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} /> <TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} /> <TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident} loadedSince={loadedSince} windowSize={RECENT_CALLS} />
{drillCam && ( {drillCam && (
<CameraDrill <CameraDrill
File diff suppressed because it is too large Load Diff
+20 -10
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import type { NodeRecord, SystemRecord } from "@/lib/types"; import type { NodeRecord, SystemRecord } from "@/lib/types";
import { diskState } from "@/lib/nodeDisk";
interface Props { interface Props {
node: NodeRecord; node: NodeRecord;
@@ -19,22 +20,22 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
: "never"; : "never";
const body = ( const body = (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer"> <div className="bg-surface border border-line rounded-lg p-4 hover:border-line-strong transition-colors cursor-pointer">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div> <div>
<p className="font-mono font-semibold text-white">{node.name}</p> <p className="font-mono font-semibold text-white">{node.name}</p>
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p> <p className="text-xs text-ink-muted font-mono">{node.node_id}</p>
</div> </div>
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </div>
<div className="space-y-1 text-xs font-mono text-gray-400"> <div className="space-y-1 text-xs font-mono text-ink-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span>System</span> <span>System</span>
<span className="text-gray-300">{system?.name ?? "Unassigned"}</span> <span className="text-ink-2">{system?.name ?? "Unassigned"}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Location</span> <span>Location</span>
<span className="text-gray-300"> <span className="text-ink-2">
{node.lat != null && node.lon != null {node.lat != null && node.lon != null
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}` ? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
: "Unknown"} : "Unknown"}
@@ -42,23 +43,32 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Last seen</span> <span>Last seen</span>
<span className="text-gray-300">{lastSeen}</span> <span className="text-ink-2">{lastSeen}</span>
</div> </div>
</div> </div>
{(() => {
const disk = diskState(node);
if (!disk || disk.level === "ok") return null;
return (
<div className={`mt-3 text-xs font-mono border-t border-line pt-2 ${disk.level === "critical" ? "text-sev-major" : "text-sev-moderate"}`}>
⚠ Disk {disk.level === "critical" ? "nearly full" : "low"}: {disk.label}
</div>
);
})()}
{!node.configured && ( {!node.configured && (
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-gray-800 pt-2"> <div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
⚠ Needs configuration ⚠ Needs configuration
</div> </div>
)} )}
{node.is_overridden && ( {node.is_overridden && (
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between"> <div className="mt-3 text-xs text-yellow-500 font-mono border-t border-line pt-2 flex justify-between">
<span>⚠ Local Override</span> <span>⚠ Local Override</span>
{node.override_timeout_at ? ( {node.override_timeout_at ? (
<span className="text-gray-500"> <span className="text-ink-muted">
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()} Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
</span> </span>
) : ( ) : (
<span className="text-gray-500">Permanent</span> <span className="text-ink-muted">Permanent</span>
)} )}
</div> </div>
)} )}
+21 -17
View File
@@ -51,20 +51,24 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto"> <div className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-semibold mb-1">Configure Node</h2> <h2 className="text-white font-semibold mb-1">Configure Node</h2>
<p className="text-gray-400 text-sm mb-5"> <p className="text-ink-2 text-sm mb-5">
<span className="text-indigo-400">{node.node_id}</span> connected for the first time. {node.configured ? (
Assign it a radio system to begin monitoring. <><span className="text-indigo-400">{node.node_id}</span>: choose the radio system it should monitor.</>
) : (
<><span className="text-indigo-400">{node.node_id}</span> connected for the first time.
Assign it a radio system to begin monitoring.</>
)}
</p> </p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-xs text-gray-400 mb-1">Radio System</label> <label className="block text-xs text-ink-2 mb-1">Radio System</label>
<select <select
value={systemId} value={systemId}
onChange={(e) => setSystemId(e.target.value)} onChange={(e) => setSystemId(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
required required
> >
<option value="">Select a system…</option> <option value="">Select a system…</option>
@@ -77,41 +81,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1">SDR Hardware</label> <label className="block text-xs text-ink-2 mb-1">SDR Hardware</label>
<select <select
value={preset} value={preset}
onChange={(e) => setPreset(e.target.value)} onChange={(e) => setPreset(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
> >
{PRESETS.map((p) => ( {PRESETS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option> <option key={p.value} value={p.value}>{p.label}</option>
))} ))}
</select> </select>
{selectedPreset && ( {selectedPreset && (
<p className="text-xs text-gray-600 mt-1">{selectedPreset.hint}</p> <p className="text-xs text-ink-muted mt-1">{selectedPreset.hint}</p>
)} )}
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1"> <label className="block text-xs text-ink-2 mb-1">
PPM offset <span className="text-gray-600">(0 = use preset default; calibrate via OP25 web UI)</span> PPM offset <span className="text-ink-muted">(0 = use preset default; calibrate via OP25 web UI)</span>
</label> </label>
<input <input
type="number" type="number"
value={ppm} value={ppm}
onChange={(e) => setPpm(e.target.value)} onChange={(e) => setPpm(e.target.value)}
step="0.1" step="0.1"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0" placeholder="0"
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-400 mb-1">Node Type *</label> <label className="block text-xs text-ink-2 mb-1">Node Type *</label>
<select <select
value={nodeType} value={nodeType}
onChange={(e) => setNodeType(e.target.value)} onChange={(e) => setNodeType(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
required required
> >
<option value="">Select node type...</option> <option value="">Select node type...</option>
@@ -127,9 +131,9 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
id="enforceTimeout" id="enforceTimeout"
checked={enforceTimeout} checked={enforceTimeout}
onChange={(e) => setEnforceTimeout(e.target.checked)} onChange={(e) => setEnforceTimeout(e.target.checked)}
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900" className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
/> />
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none"> <label htmlFor="enforceTimeout" className="text-xs text-ink-2 cursor-pointer select-none">
Enforce Local Override Timeout (24 hours) Enforce Local Override Timeout (24 hours)
</label> </label>
</div> </div>
@@ -148,7 +152,7 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors" className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
> >
Cancel Cancel
</button> </button>
+5 -5
View File
@@ -27,7 +27,7 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`; : `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
return ( return (
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Conventional Channels</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Conventional Channels</h2>
{encrypted.length > 0 && ( {encrypted.length > 0 && (
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono"> <p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
{subject} encrypted — those calls can&apos;t be decoded, so nothing from{" "} {subject} encrypted — those calls can&apos;t be decoded, so nothing from{" "}
@@ -37,17 +37,17 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
)} )}
<ul className="space-y-1 font-mono text-xs"> <ul className="space-y-1 font-mono text-xs">
{channels.map((c, i) => ( {channels.map((c, i) => (
<li key={i} className="flex flex-wrap gap-x-3 text-gray-300"> <li key={i} className="flex flex-wrap gap-x-3 text-ink-2">
<span className="text-white">{c.name || mhz(c.freq)}</span> <span className="text-white">{c.name || mhz(c.freq)}</span>
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>} {c.name && <span className="text-ink-muted">{mhz(c.freq)}</span>}
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>} {c.nac != null && <span className="text-ink-muted">NAC {hex(c.nac, 3)}</span>}
{c.encrypted ? ( {c.encrypted ? (
<span className="text-red-400"> <span className="text-red-400">
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""} ✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""} {c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
</span> </span>
) : ( ) : (
<span className="text-gray-400"> <span className="text-ink-2">
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"} {c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
</span> </span>
)} )}
+15 -15
View File
@@ -23,7 +23,7 @@ function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
} }
const selectClass = const selectClass =
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]"; "bg-raised border border-line-strong rounded px-2 py-1 text-ink text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
function DeviceSelect({ function DeviceSelect({
value, devices, devicesKnown, autoLabel, label, disabled, onChange, value, devices, devicesKnown, autoLabel, label, disabled, onChange,
@@ -116,18 +116,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
return ( return (
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2> <h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-1">SDRs</h2>
<p className="text-xs text-gray-500 font-mono mb-3"> <p className="text-xs text-ink-muted font-mono mb-3">
{devicesKnown {devicesKnown
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.` ? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
: "This node hasn't reported its SDRs yet."} : "This node hasn't reported its SDRs yet."}
</p> </p>
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5"> <div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-gray-200">OP25 SDR</div> <div className="text-ink">OP25 SDR</div>
<div className="text-xs text-gray-500"> <div className="text-xs text-ink-muted">
{op25Moving {op25Moving
? "Saving restarts OP25 on the selected SDR." ? "Saving restarts OP25 on the selected SDR."
: node.op25_sdr_serial : node.op25_sdr_serial
@@ -162,18 +162,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
: "Waiting for SDR"; : "Waiting for SDR";
return ( return (
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5"> <div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span> <span className="w-4 text-right text-ink-muted text-xs">{row.enabled ? ++rank : ""}</span>
<input <input
type="checkbox" type="checkbox"
checked={row.enabled} checked={row.enabled}
disabled={!canEdit} disabled={!canEdit}
aria-label={`Enable ${meta.name}`} aria-label={`Enable ${meta.name}`}
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))} onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900" className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
/> />
<div className="flex-1 min-w-[7rem]"> <div className="flex-1 min-w-[7rem]">
<div className="text-gray-200">{meta.name}</div> <div className="text-ink">{meta.name}</div>
<div className="text-xs text-gray-500">{meta.hint}</div> <div className="text-xs text-ink-muted">{meta.hint}</div>
</div> </div>
<DeviceSelect <DeviceSelect
value={row.pin} value={row.pin}
@@ -191,7 +191,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
onClick={() => move(i, -1)} onClick={() => move(i, -1)}
disabled={i === 0} disabled={i === 0}
aria-label={`Move ${meta.name} up`} aria-label={`Move ${meta.name} up`}
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30" className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
> >
▲ ▲
</button> </button>
@@ -200,13 +200,13 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
onClick={() => move(i, 1)} onClick={() => move(i, 1)}
disabled={i === rows.length - 1} disabled={i === rows.length - 1}
aria-label={`Move ${meta.name} down`} aria-label={`Move ${meta.name} down`}
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30" className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
> >
▼ ▼
</button> </button>
</div> </div>
)} )}
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}> <span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-ink-muted"}`}>
{state} {state}
</span> </span>
</div> </div>
@@ -214,7 +214,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
})} })}
</div> </div>
<p className="text-xs text-gray-500 font-mono mt-2"> <p className="text-xs text-ink-muted font-mono mt-2">
Every SDR besides OP25&apos;s runs the next enabled service, top first. Pin a service to the SDR with its Every SDR besides OP25&apos;s runs the next enabled service, top first. Pin a service to the SDR with its
antenna, or leave it on &quot;Any spare SDR&quot;. antenna, or leave it on &quot;Any spare SDR&quot;.
</p> </p>
@@ -235,7 +235,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
> >
{saving ? "Saving…" : "Save"} {saving ? "Saving…" : "Save"}
</button> </button>
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>} {message && <span className="text-xs text-ink-muted font-mono">{message}</span>}
</div> </div>
)} )}
</section> </section>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { NodeStatus } from "@/lib/types";
const styles: Record<NodeStatus, string> = { const styles: Record<NodeStatus, string> = {
online: "bg-green-950 text-green-400 border border-green-800", online: "bg-green-950 text-green-400 border border-green-800",
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse", recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
offline: "bg-gray-900 text-gray-500 border border-gray-700", offline: "bg-surface text-ink-muted border border-line-strong",
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800", unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
}; };
+48 -1
View File
@@ -26,12 +26,20 @@ const BUCKETS = 48;
* `incidents` (optional) adds one band per active incident from its start to * `incidents` (optional) adds one band per active incident from its start to
* its last update, tinted by severity — click a band to drill into it (live * its last update, tinted by severity — click a band to drill into it (live
* ops wall). The playhead still does not move; see the header. * ops wall). The playhead still does not move; see the header.
*
* `loadedSince` (server-26#239): ISO time of the oldest call the caller's
* window loaded when that window came back FULL (null = everything in range
* was loaded). The axis before it is hatched and labelled "not loaded", so a
* 7d view never reads as a quiet week for time nobody fetched.
*/ */
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident }: { export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident, loadedSince = null, windowSize }: {
calls: CallRecord[]; calls: CallRecord[];
incidents?: IncidentRecord[]; incidents?: IncidentRecord[];
selectedIncidentId?: string | null; selectedIncidentId?: string | null;
onSelectIncident?: (id: string) => void; onSelectIncident?: (id: string) => void;
loadedSince?: string | null;
/** Size of the caller's call window, for the "not loaded" tooltip. */
windowSize?: number;
}) { }) {
const [rangeIdx, setRangeIdx] = useState(2); // default 24h const [rangeIdx, setRangeIdx] = useState(2); // default 24h
@@ -56,6 +64,24 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
const maxCount = Math.max(1, ...buckets.map((b) => b.count)); const maxCount = Math.max(1, ...buckets.map((b) => b.count));
// Share of the axis (0-100) before the loaded window starts; 0 = all loaded.
const unloaded = useMemo(() => {
const t = loadedSince ? new Date(loadedSince).getTime() : NaN;
if (Number.isNaN(t)) return { pct: 0, firstLoaded: 0, since: null as Date | null };
const start = Date.now() - range.ms;
if (t <= start) return { pct: 0, firstLoaded: 0, since: null as Date | null };
const frac = Math.min(1, (t - start) / range.ms);
// Buckets wholly before the window are unknown, not empty: drawn blank.
return { pct: frac * 100, firstLoaded: Math.floor(frac * BUCKETS), since: new Date(t) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadedSince, rangeIdx, calls]);
const sinceLabel = unloaded.since
? unloaded.since.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })
: "";
const unloadedTitle = unloaded.since
? `Not loaded: only the latest ${windowSize ?? "few hundred"} calls are on this page (back to ${sinceLabel}). Earlier activity is unknown, not quiet.`
: "";
const bands = useMemo(() => { const bands = useMemo(() => {
const now = Date.now(); const now = Date.now();
const start = now - range.ms; const start = now - range.ms;
@@ -88,7 +114,28 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
})} })}
</div> </div>
)} )}
{unloaded.pct > 0 && (
<div
role="img"
aria-label={unloadedTitle}
title={unloadedTitle}
className="absolute left-0 top-0 bottom-0 z-[1] flex items-center justify-center overflow-hidden rounded-sm border border-dashed border-line"
style={{
width: `${unloaded.pct}%`,
background: "repeating-linear-gradient(135deg, var(--line-strong) 0 2px, transparent 2px 7px)",
}}
>
{unloaded.pct >= 8 && (
<span className="px-1.5 rounded bg-surface/90 text-[10px] font-mono uppercase tracking-wide text-ink-muted whitespace-nowrap">
not loaded
</span>
)}
</div>
)}
{buckets.map((b, i) => { {buckets.map((b, i) => {
if (i < unloaded.firstLoaded) {
return <div key={i} aria-hidden="true" style={{ flex: 1, minWidth: 2 }} />;
}
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100); const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
const color = const color =
b.worstRank === 3 b.worstRank === 3
@@ -14,7 +14,7 @@ import type { CameraProviderAdmin, LicenceStatus } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = { const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" }, commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" }, attribution_required: { label: "Attribution required", cls: "bg-indigo-950 text-indigo-300 border-indigo-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" }, agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" }, non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" }, unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
@@ -58,16 +58,16 @@ export function CameraProvidersTab() {
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<p className="text-gray-400 text-xs leading-snug"> <p className="text-ink-2 text-xs leading-snug">
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery. camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
Required attributions render on every camera tile. Required attributions render on every camera tile.
</p> </p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>} {error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? ( {!providers ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <div className="bg-surface border border-line rounded-xl divide-y divide-line">
{providers.map((p) => { {providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status]; const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null; const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
@@ -77,8 +77,8 @@ export function CameraProvidersTab() {
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span> <span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p> <p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p> <p className="text-ink-2 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p> <p className="text-ink-muted text-xs">{p.region} · {p.formats}</p>
</div> </div>
<div className="shrink-0 text-right space-y-1"> <div className="shrink-0 text-right space-y-1">
<button <button
@@ -93,25 +93,25 @@ export function CameraProvidersTab() {
> >
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} /> <span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button> </button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}> <p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"} {p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p> </p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-gray-400"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-ink-2">
<span>cameras: {p.health ? p.health.count : "—"}</span> <span>cameras: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span> <span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span> <span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span> <span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
</div> </div>
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>} {p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with imagery: “{p.licence.attribution}”</p>} {p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with imagery: “{p.licence.attribution}”</p>}
{p.acknowledgement && ( {p.acknowledgement && (
<p className="text-[11px] text-amber-300"> <p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()} Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p> </p>
)} )}
<details className="text-[11px] text-gray-500"> <details className="text-[11px] text-ink-muted">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary> <summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p> <p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p> <p className="mt-1">{p.notes}</p>
@@ -129,16 +129,16 @@ export function CameraProvidersTab() {
{confirming && ( {confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3"> <div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2> <h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p> <p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p> <p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
<label className="flex items-start gap-2 text-sm text-gray-200"> <label className="flex items-start gap-2 text-sm text-ink">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" /> <input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s imagery. This confirmation is recorded with my account and the time in the audit log.</span> <span>I confirm DRB holds the right to show this source&apos;s imagery. This confirmation is recorded with my account and the time in the audit log.</span>
</label> </label>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button> <button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)} <button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button> className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div> </div>
@@ -15,7 +15,7 @@ import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = { const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" }, commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" }, attribution_required: { label: "Attribution required", cls: "bg-indigo-950 text-indigo-300 border-indigo-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" }, agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" }, non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" }, unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
@@ -60,7 +60,7 @@ export function OutageProvidersTab() {
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<p className="text-gray-400 text-xs leading-snug"> <p className="text-ink-2 text-xs leading-snug">
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
@@ -68,9 +68,9 @@ export function OutageProvidersTab() {
</p> </p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>} {error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? ( {!providers ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-ink-muted text-sm font-mono">Loading…</p>
) : ( ) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> <div className="bg-surface border border-line rounded-xl divide-y divide-line">
{providers.map((p) => { {providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status]; const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null; const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
@@ -80,8 +80,8 @@ export function OutageProvidersTab() {
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span> <span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p> <p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p> <p className="text-ink-2 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.granularity}</p> <p className="text-ink-muted text-xs">{p.region} · {p.granularity}</p>
</div> </div>
<div className="shrink-0 text-right space-y-1"> <div className="shrink-0 text-right space-y-1">
<button <button
@@ -96,27 +96,27 @@ export function OutageProvidersTab() {
> >
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} /> <span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button> </button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}> <p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"} {p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p> </p>
</div> </div>
</div> </div>
{p.implemented && ( {p.implemented && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-gray-400"> <div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-ink-2">
<span>counties out: {p.health ? p.health.count : "—"}</span> <span>counties out: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span> <span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span> <span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
</div> </div>
)} )}
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>} {p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.coverage_note && <p className="text-[11px] text-gray-400">{p.coverage_note}</p>} {p.coverage_note && <p className="text-[11px] text-ink-2">{p.coverage_note}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with the data: “{p.licence.attribution}”</p>} {p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with the data: “{p.licence.attribution}”</p>}
{p.acknowledgement && ( {p.acknowledgement && (
<p className="text-[11px] text-amber-300"> <p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()} Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p> </p>
)} )}
<details className="text-[11px] text-gray-500"> <details className="text-[11px] text-ink-muted">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary> <summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p> <p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p> <p className="mt-1">{p.notes}</p>
@@ -134,17 +134,17 @@ export function OutageProvidersTab() {
{confirming && ( {confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title"> <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3"> <div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2> <h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p> <p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p> <p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>} {error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
<label className="flex items-start gap-2 text-sm text-gray-200"> <label className="flex items-start gap-2 text-sm text-ink">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" /> <input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span> <span>I confirm DRB holds the right to show this source&apos;s outage data. This confirmation is recorded with my account and the time in the audit log.</span>
</label> </label>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button> <button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)} <button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button> className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div> </div>
+41 -41
View File
@@ -24,9 +24,9 @@ const MODES: { key: ReplayMode; label: string; help: string }[] = [
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"]; const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
const input = const input =
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"; "bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
const btn = const btn =
"bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"; "bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
function toLocalInput(d: Date): string { function toLocalInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
@@ -88,18 +88,18 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun); const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4"> <div className="bg-surface border border-line rounded-xl p-4 space-y-4">
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="text-xs text-gray-400 block mb-1">From</label> <label className="text-xs text-ink-2 block mb-1">From</label>
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} /> <input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">To</label> <label className="text-xs text-ink-2 block mb-1">To</label>
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} /> <input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Label</label> <label className="text-xs text-ink-2 block mb-1">Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} /> <input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
</div> </div>
</div> </div>
@@ -109,14 +109,14 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
<label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer"> <label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer">
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" /> <input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
<span className="text-white">{m.label}</span> <span className="text-white">{m.label}</span>
<span className="text-gray-500 text-xs mt-0.5">{m.help}</span> <span className="text-ink-muted text-xs mt-0.5">{m.help}</span>
</label> </label>
))} ))}
</div> </div>
{mode === "reuse" && ( {mode === "reuse" && (
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Reuse extraction from</label> <label className="text-xs text-ink-2 block mb-1">Reuse extraction from</label>
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}> <select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
<option value="">— pick a finished run —</option> <option value="">— pick a finished run —</option>
{sources.map((r) => ( {sources.map((r) => (
@@ -125,15 +125,15 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
</option> </option>
))} ))}
</select> </select>
<p className="text-xs text-gray-500 mt-1">Set the range to match that run; calls outside it are skipped.</p> <p className="text-xs text-ink-muted mt-1">Set the range to match that run; calls outside it are skipped.</p>
</div> </div>
)} )}
{systems.length > 1 && ( {systems.length > 1 && (
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<span className="text-xs text-gray-400">Systems (none = all):</span> <span className="text-xs text-ink-2">Systems (none = all):</span>
{systems.map((s) => ( {systems.map((s) => (
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-gray-300 cursor-pointer"> <label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-ink-2 cursor-pointer">
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} /> <input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
{s.name} {s.name}
</label> </label>
@@ -154,7 +154,7 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
</div> </div>
{est && ( {est && (
<p className="text-sm font-mono text-gray-300"> <p className="text-sm font-mono text-ink-2">
{est.truncated ? ( {est.truncated ? (
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span> <span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
) : ( ) : (
@@ -193,15 +193,15 @@ function RunsTable({
onChanged(); onChanged();
} }
if (!runs.length) return <p className="text-sm text-gray-500 font-mono">No runs yet.</p>; if (!runs.length) return <p className="text-sm text-ink-muted font-mono">No runs yet.</p>;
const th = "text-left text-xs text-gray-500 font-normal px-2 py-1.5 whitespace-nowrap"; const th = "text-left text-xs text-ink-muted font-normal px-2 py-1.5 whitespace-nowrap";
const td = "px-2 py-1.5 whitespace-nowrap"; const td = "px-2 py-1.5 whitespace-nowrap";
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm font-mono"> <table className="w-full text-sm font-mono">
<thead> <thead>
<tr className="border-b border-gray-800"> <tr className="border-b border-line">
<th className={th}>Run</th> <th className={th}>Run</th>
<th className={th}>Range</th> <th className={th}>Range</th>
<th className={th}>Status</th> <th className={th}>Status</th>
@@ -222,13 +222,13 @@ function RunsTable({
<tr <tr
key={r.run_id} key={r.run_id}
onClick={() => onSelect(r.run_id)} onClick={() => onSelect(r.run_id)}
className={`border-b border-gray-800/60 cursor-pointer ${selected === r.run_id ? "bg-gray-800/60" : "hover:bg-gray-900"}`} className={`border-b border-line/60 cursor-pointer ${selected === r.run_id ? "bg-raised/60" : "hover:bg-raised"}`}
> >
<td className={td}> <td className={td}>
<div className="text-white">{r.label || r.run_id}</div> <div className="text-white">{r.label || r.run_id}</div>
<div className="text-xs text-gray-500">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div> <div className="text-xs text-ink-muted">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
</td> </td>
<td className={`${td} text-xs text-gray-400`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td> <td className={`${td} text-xs text-ink-2`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
<td className={td}> <td className={td}>
{running ? ( {running ? (
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span> <span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
@@ -245,7 +245,7 @@ function RunsTable({
<> <>
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span> <span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
{" / "} {" / "}
<span className="text-gray-400">{m.resolved_via.idle_timeout ?? 0}</span> <span className="text-ink-2">{m.resolved_via.idle_timeout ?? 0}</span>
</> </>
) : "—"} ) : "—"}
</td> </td>
@@ -255,7 +255,7 @@ function RunsTable({
{running ? ( {running ? (
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button> <button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button>
) : ( ) : (
<button onClick={() => remove(r.run_id)} className="text-xs text-gray-500 hover:text-red-400">delete</button> <button onClick={() => remove(r.run_id)} className="text-xs text-ink-muted hover:text-red-400">delete</button>
)} )}
</td> </td>
</tr> </tr>
@@ -278,21 +278,21 @@ function CallLine({ call }: { call: ReplayCallRow }) {
} catch { /* audio is a convenience */ } } catch { /* audio is a convenience */ }
} }
return ( return (
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono"> <div className="py-1.5 border-t border-line/60 text-xs font-mono">
<div className="flex flex-wrap gap-2 text-gray-500"> <div className="flex flex-wrap gap-2 text-ink-muted">
<span>{fmtTime(call.started_at)}</span> <span>{fmtTime(call.started_at)}</span>
<span>{call.talkgroup_name}</span> <span>{call.talkgroup_name}</span>
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)} {call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)}
{call.units?.length ? <span>units {call.units.join(", ")}</span> : null} {call.units?.length ? <span>units {call.units.join(", ")}</span> : null}
{call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null} {call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null}
{call.skip_reason && <span className="text-gray-600">{call.skip_reason}</span>} {call.skip_reason && <span className="text-ink-muted">{call.skip_reason}</span>}
{audio ? ( {audio ? (
<audio src={audio} controls autoPlay className="h-6" /> <audio src={audio} controls autoPlay className="h-6" />
) : ( ) : (
<button onClick={play} className="text-gray-400 hover:text-white">▶ audio</button> <button onClick={play} className="text-ink-2 hover:text-white">▶ audio</button>
)} )}
</div> </div>
<div className="text-gray-200 mt-0.5">{call.transcript || <span className="text-gray-600">(no transcript)</span>}</div> <div className="text-ink mt-0.5">{call.transcript || <span className="text-ink-muted">(no transcript)</span>}</div>
</div> </div>
); );
} }
@@ -301,13 +301,13 @@ function IncidentCard({ inc }: { inc: ReplayIncident }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via); const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2"> <div className="bg-surface border border-line rounded-lg px-3 py-2">
<button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono"> <button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono">
<span className="text-gray-500">{open ? "▾" : "▸"}</span> <span className="text-ink-muted">{open ? "▾" : "▸"}</span>
<span className="text-white">{inc.title || inc.incident_id}</span> <span className="text-white">{inc.title || inc.incident_id}</span>
<span className="text-gray-500 text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span> <span className="text-ink-muted text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
<span className="text-gray-500 text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span> <span className="text-ink-muted text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
<span className={`text-xs ${signal ? "text-green-400" : "text-gray-500"}`}>{inc.resolved_via ?? inc.status}</span> <span className={`text-xs ${signal ? "text-green-400" : "text-ink-muted"}`}>{inc.resolved_via ?? inc.status}</span>
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>} {inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
</button> </button>
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>} {open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
@@ -347,24 +347,24 @@ function RunDetail({ run }: { run: ReplayRun }) {
["With units cleared", m.incidents_with_units_cleared], ["With units cleared", m.incidents_with_units_cleared],
["With map pin", m.incidents_with_coords], ["With map pin", m.incidents_with_coords],
].map(([k, v]) => ( ].map(([k, v]) => (
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2"> <div key={k as string} className="bg-surface border border-line rounded-lg p-2">
<div className="text-gray-500">{k}</div> <div className="text-ink-muted">{k}</div>
<div className="text-white text-base">{v}</div> <div className="text-white text-base">{v}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
{m && ( {m && (
<p className="text-xs font-mono text-gray-500"> <p className="text-xs font-mono text-ink-muted">
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")} resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
<br /> <br />
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")} paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
</p> </p>
)} )}
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && ( {m?.llm_usage && Object.keys(m.llm_usage).length > 0 && (
<p className="text-xs font-mono text-gray-500"> <p className="text-xs font-mono text-ink-muted">
Gemini tokens:{" "} Gemini tokens:{" "}
{Object.entries(m.gemini_usage) {Object.entries(m.llm_usage)
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`) .map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`)
.join(" · ")} .join(" · ")}
</p> </p>
@@ -380,7 +380,7 @@ function RunDetail({ run }: { run: ReplayRun }) {
{run.errors.map((e, i) => <div key={i}>{e}</div>)} {run.errors.map((e, i) => <div key={i}>{e}</div>)}
</details> </details>
)} )}
{run.status === "running" && <p className="text-sm text-gray-500 font-mono">Incidents appear when the run finishes.</p>} {run.status === "running" && <p className="text-sm text-ink-muted font-mono">Incidents appear when the run finishes.</p>}
{error && <p className="text-red-400 text-sm font-mono">{error}</p>} {error && <p className="text-red-400 text-sm font-mono">{error}</p>}
{data && ( {data && (
<> <>
@@ -389,20 +389,20 @@ function RunDetail({ run }: { run: ReplayRun }) {
<button <button
key={f} key={f}
onClick={() => setFilter(f)} onClick={() => setFilter(f)}
className={`px-3 py-1 rounded-md ${filter === f ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`} className={`px-3 py-1 rounded-md ${filter === f ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
> >
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"} {f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
</button> </button>
))} ))}
<button <button
onClick={() => setShowOrphans(!showOrphans)} onClick={() => setShowOrphans(!showOrphans)}
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`} className={`px-3 py-1 rounded-md ${showOrphans ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
> >
Orphans ({data.orphans.length}) Orphans ({data.orphans.length})
</button> </button>
</div> </div>
{showOrphans ? ( {showOrphans ? (
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2"> <div className="bg-surface border border-line rounded-lg px-3 py-2">
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)} {data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
</div> </div>
) : ( ) : (
@@ -443,7 +443,7 @@ export function ReplayTab() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<p className="text-xs text-gray-500 font-mono"> <p className="text-xs text-ink-muted font-mono">
Re-run the pipeline over a past time range, in the calls&apos; original order, into a sandbox — live incidents are never Re-run the pipeline over a past time range, in the calls&apos; original order, into a sandbox — live incidents are never
touched. Replay the same range after each change and compare the rows below. A run starts with no incidents touched. Replay the same range after each change and compare the rows below. A run starts with no incidents
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live. open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
@@ -0,0 +1,253 @@
"use client";
/**
* Attention cues (server-26#238) — the opt-in half: an audible tone and
* browser notifications, both OFF by default.
*
* Tone: Web Audio beep, no asset. Browsers only let audio start from a user
* gesture, so the AudioContext is created by the click that turns the tone
* on (and that click plays one confirmation beep). After a reload with the
* tone already on, the first click/key anywhere on the page re-arms it; until
* then nothing plays and the button says so.
*
* Notifications: Notification.requestPermission() on click; fired only while
* the tab is hidden (a visible wall already shows the lane and pulse). The
* body ends with the Gate A machine-output notice — the title it carries is
* pipeline output. No transcript text goes into a notification.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { DEFAULT_KEYWORDS, parseKeywords, type AttentionEvent, type AttentionPrefs } from "@/lib/attention";
import { MACHINE_OUTPUT_NOTICE_TEXT } from "@/components/ui/MachineOutputNotice";
const TONE_GAP_MS = 4_000;
const MAX_NOTIFICATIONS = 3;
// ── Audio (one context per page, only ever created inside a user gesture) ────
let audioCtx: AudioContext | null = null;
function createAudio(): AudioContext | null {
try {
if (!audioCtx) {
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return null;
audioCtx = new AC();
}
if (audioCtx.state === "suspended") void audioCtx.resume().catch(() => undefined);
return audioCtx;
} catch {
return null;
}
}
/** n short beeps, alternating 880/660 Hz. Silent unless a gesture already started the context. */
function beep(n: number) {
const a = audioCtx;
if (!a || a.state !== "running") return;
try {
for (let i = 0; i < n; i++) {
const t0 = a.currentTime + i * 0.22;
const osc = a.createOscillator();
const gain = a.createGain();
osc.type = "sine";
osc.frequency.value = i % 2 ? 660 : 880;
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
osc.connect(gain).connect(a.destination);
osc.start(t0);
osc.stop(t0 + 0.2);
}
} catch { /* audio unavailable */ }
}
type NotifyState = "unsupported" | "default" | "granted" | "denied";
function notifyState(): NotifyState {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
return Notification.permission as NotifyState;
}
const HEADLINE: Record<AttentionEvent["kind"], string> = {
major: "Major incident",
escalated: "Severity escalated",
stop: "Pursuit path: new stop",
keyword: "Priority keyword",
};
/**
* Fires the opted-in cues for a batch of in-view events. Returns `fire` (pass
* to useAttention's onEvents) and the live audio state for the controls.
*/
export function useAttentionCues(prefs: AttentionPrefs, onOpenIncident: (id: string) => void) {
const [armed, setArmed] = useState(false);
const lastTone = useRef(0);
const prefsRef = useRef(prefs);
prefsRef.current = prefs;
const openRef = useRef(onOpenIncident);
openRef.current = onOpenIncident;
// Tone on: track the context's state. Already on from an earlier visit →
// the next gesture anywhere on the page creates/resumes the context.
useEffect(() => {
if (!prefs.tone) { setArmed(false); return; }
const sync = () => setArmed(audioCtx?.state === "running");
const arm = () => {
const a = createAudio();
if (a) { a.onstatechange = sync; a.resume().then(sync, () => undefined); }
};
if (audioCtx) { audioCtx.onstatechange = sync; sync(); }
window.addEventListener("pointerdown", arm, { once: true });
window.addEventListener("keydown", arm, { once: true });
return () => {
window.removeEventListener("pointerdown", arm);
window.removeEventListener("keydown", arm);
};
}, [prefs.tone]);
const fire = useCallback((events: AttentionEvent[]) => {
if (!events.length) return;
const p = prefsRef.current;
if (p.tone && Date.now() - lastTone.current > TONE_GAP_MS) {
lastTone.current = Date.now();
// Three beeps for anything reaching Major or a priority keyword, one otherwise.
beep(events.some((e) => e.kind === "major" || e.kind === "keyword" || e.label.endsWith("Major")) ? 3 : 1);
}
if (!p.notify || typeof document === "undefined" || !document.hidden || notifyState() !== "granted") return;
const send = (title: string, body: string, tag: string, incidentId: string | null) => {
try {
const n = new Notification(title, { body: `${body}\n${MACHINE_OUTPUT_NOTICE_TEXT}`, tag });
n.onclick = () => {
window.focus();
if (incidentId) openRef.current(incidentId);
n.close();
};
} catch { /* e.g. mobile Chrome needs a service worker — silently no notification */ }
};
for (const e of events.slice(0, MAX_NOTIFICATIONS)) {
const head = e.kind === "keyword" ? `${HEADLINE.keyword}: ${e.label}` : e.kind === "escalated" ? `${HEADLINE.escalated}: ${e.label.replace(/^↑\s*/, "")}` : HEADLINE[e.kind];
send(`DRB · ${head}`, e.title, `drb-attn:${e.incidentIds[0] ?? e.id}`, e.incidentIds[0] ?? null);
}
if (events.length > MAX_NOTIFICATIONS) {
send("DRB · More activity", `${events.length - MAX_NOTIFICATIONS} more on the live wall.`, "drb-attn:more", null);
}
}, []);
return { fire, armed };
}
interface Props {
prefs: AttentionPrefs;
onChange: (p: AttentionPrefs) => void;
armed: boolean;
}
export function AttentionControls({ prefs, onChange, armed }: Props) {
const [open, setOpen] = useState(false);
const [perm, setPerm] = useState<NotifyState>("default");
const [kwDraft, setKwDraft] = useState(prefs.keywords.join(", "));
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => { setPerm(notifyState()); }, [open]);
useEffect(() => { setKwDraft(prefs.keywords.join(", ")); }, [prefs.keywords]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
}, [open]);
const toggleTone = () => {
if (prefs.tone) { onChange({ ...prefs, tone: false }); return; }
// This click is the gesture that unlocks audio; confirm with one beep.
const a = createAudio();
onChange({ ...prefs, tone: true });
if (a) a.resume().then(() => beep(1), () => undefined);
};
const toggleNotify = async () => {
if (prefs.notify) { onChange({ ...prefs, notify: false }); return; }
if (notifyState() === "unsupported") { setPerm("unsupported"); return; }
let p: NotifyState = notifyState();
if (p === "default") {
try { p = (await Notification.requestPermission()) as NotifyState; } catch { p = notifyState(); }
}
if (p === "granted") {
// Confirm with one notification (as the tone confirms with a beep). It
// doubles as the support check: mobile Chrome grants permission but
// throws on `new Notification()` without a service worker, which would
// otherwise leave a switched-on control that never delivers anything.
try {
new Notification("DRB · Notifications on", {
body: `Cues will appear here while the live wall tab is hidden.\n${MACHINE_OUTPUT_NOTICE_TEXT}`,
tag: "drb-attn:on",
});
} catch {
p = "unsupported";
}
}
setPerm(p);
if (p === "granted") onChange({ ...prefs, notify: true });
};
const commitKeywords = () => {
const next = parseKeywords(kwDraft);
if (next.join("\u0001") !== prefs.keywords.join("\u0001")) onChange({ ...prefs, keywords: next });
else setKwDraft(prefs.keywords.join(", "));
};
const notifyOn = prefs.notify && perm !== "denied" && perm !== "unsupported";
const summary = [prefs.tone ? (armed ? "tone" : "tone (click to arm)") : null, notifyOn ? "notify" : null].filter(Boolean).join(" · ") || "off";
const on = prefs.tone || notifyOn;
return (
<div ref={boxRef} className="relative">
<button type="button" aria-expanded={open} aria-haspopup="dialog" onClick={() => setOpen((v) => !v)}
title="Attention cues: an audible tone and browser notifications for new Majors, escalations, pursuit stops and priority keywords"
className={`px-2 py-0.5 rounded border whitespace-nowrap ${on ? "border-accent text-accent" : "border-line text-ink-2 hover:text-ink"}`}>
Cues: {summary}
</button>
{open && (
<div role="dialog" aria-label="Attention cues"
className="absolute right-0 top-full mt-1 z-[1100] w-72 max-w-[calc(100vw-2rem)] bg-surface border border-line rounded-lg shadow-lg p-3 space-y-3 text-xs text-ink-2">
<p className="text-ink-muted leading-snug">
Cues fire on: a Major opened, severity escalated, a pursuit path gaining a stop, or a priority keyword in a new
call. Only for incidents the current filter shows.
</p>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={prefs.tone} onChange={toggleTone} />
<span>
<span className="text-ink font-medium">Sound a tone</span>
{prefs.tone && !armed && (
<span className="block text-ink-muted">Click anywhere on the page to arm it — browsers block sound until you do.</span>
)}
</span>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={notifyOn} disabled={perm === "unsupported"} onChange={() => { void toggleNotify(); }} />
<span>
<span className="text-ink font-medium">Browser notifications while this tab is hidden</span>
{perm === "denied" && <span className="block text-ink-muted">Blocked in this browser&apos;s site settings — allow notifications there first.</span>}
{perm === "unsupported" && <span className="block text-ink-muted">This browser doesn&apos;t show notifications from a web page (mobile browsers often only allow them from installed apps).</span>}
</span>
</label>
<div>
<label htmlFor="attn-kw" className="block text-ink font-medium mb-1">Priority keywords</label>
<input id="attn-kw" type="text" value={kwDraft} onChange={(e) => setKwDraft(e.target.value)} onBlur={commitKeywords}
onKeyDown={(e) => { if (e.key === "Enter") commitKeywords(); }}
placeholder="comma-separated"
className="w-full px-2 py-1 rounded border border-line bg-raised text-ink" />
<div className="mt-1 flex items-center justify-between text-ink-muted">
<span>Matched against new call transcripts.</span>
<button type="button" className="underline hover:text-ink"
onClick={() => onChange({ ...prefs, keywords: [...DEFAULT_KEYWORDS] })}>
Reset
</button>
</div>
</div>
</div>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
/**
* "NEW · last 15 min" (server-26#238). The rail is worst-first, so a fresh
* minor that is escalating sits under stale Majors; this lane is the
* recency view of the same FILTERED incidents, newest first, with why each
* one is here ("New", "↑ Major", "+1 stop", a priority keyword).
*
* Desktop: overlay at the map's top-left, right of the zoom stack and above
* MapView's rail (the rail starts at top-[9.5rem], so the lane is capped
* just short of it and its rows scroll). The Gate A notice is pinned below
* the rows, never inside the scroll area — same rule as the rail (#46).
* Mobile: a compact chip under MapView's centred "Live" chip (same top-3
* centre slot would cover it), right of the fit-all button; it expands.
* Hidden when there is nothing new.
*/
import { useState } from "react";
import type { LaneEntry } from "@/lib/attention";
import { SEVERITY_COLORS, isKnownSeverity } from "@/lib/severity";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
function ago(at: number, now: number): string {
const m = Math.max(0, Math.floor((now - at) / 60_000));
return m < 1 ? "now" : `${m}m`;
}
function Row({ e, now, onSelect }: { e: LaneEntry; now: number; onSelect: (id: string) => void }) {
const color = isKnownSeverity(e.severity) ? SEVERITY_COLORS[e.severity] : "var(--ink-muted)";
const body = (
<>
<span className="w-1 self-stretch rounded-full shrink-0" style={{ background: color }} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-ink">{e.title}</span>
<span className="shrink-0 px-1 rounded bg-accent/15 text-accent font-semibold">{e.reason}</span>
<span className="shrink-0 tabular-nums text-ink-muted">{ago(e.at, now)}</span>
</>
);
const cls = "w-full flex items-center gap-1.5 px-1.5 py-1 rounded text-xs text-left";
return (
<li>
{e.incidentId ? (
<button type="button" className={`${cls} hover:bg-raised`} onClick={() => onSelect(e.incidentId!)}
title={`${e.title} — ${e.reason}`}>
{body}
</button>
) : (
<div className={cls} title={`${e.title} — ${e.reason} (call not linked to an incident yet)`}>{body}</div>
)}
</li>
);
}
export function NewLane({ entries, now, onSelect }: { entries: LaneEntry[]; now: number; onSelect: (id: string) => void }) {
const [open, setOpen] = useState(false);
if (!entries.length) return null;
const notice = <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start px-1.5 pt-1" />;
return (
<>
<section aria-label="New in the last 15 minutes"
className="absolute top-3 left-14 z-[1002] hidden md:flex flex-col w-56 max-h-[8.5rem] bg-surface/90 backdrop-blur-sm border border-accent/60 rounded-lg shadow pointer-events-auto">
<h2 className="shrink-0 px-2 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
New · last 15 min <span className="text-ink-muted font-normal">({entries.length})</span>
</h2>
<ul className="min-h-0 overflow-y-auto px-0.5">
{entries.map((e) => <Row key={e.key} e={e} now={now} onSelect={onSelect} />)}
</ul>
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
</section>
<div className="absolute top-[5.25rem] left-14 z-[1002] md:hidden flex flex-col items-start">
<button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}
className="bg-surface/95 border border-accent/60 rounded-full px-3 py-1 text-xs text-accent font-semibold whitespace-nowrap shadow">
● {entries.length} new · 15 min {open ? "▴" : "▾"}
</button>
{open && (
<div className="mt-1 w-64 max-w-[calc(100vw-4.5rem)] bg-surface/95 border border-line rounded-lg shadow p-0.5 flex flex-col">
<ul className="max-h-40 overflow-y-auto">
{entries.map((e) => (
<Row key={e.key} e={e} now={now} onSelect={(id) => { setOpen(false); onSelect(id); }} />
))}
</ul>
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
</div>
)}
</div>
</>
);
}
+174 -19
View File
@@ -16,7 +16,13 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { InlinePlayer } from "@/components/InlinePlayer"; import { InlinePlayer } from "@/components/InlinePlayer";
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue"; import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
import { CONTEXT_KM, NEAR_CAMERA_KM, bboxAround, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall"; import {
CONTEXT_KM, MAY_BE_MERGED_KM, NEAR_CAMERA_KM, PRECIP_ECHO_DBZ, alertsAt, bboxAround, compass, contextFor, pathStops,
sampleRadarDbz, stopNumbers, wantsWind, wxStyle, wxTime,
type LatLon, type NearCamera,
} from "@/lib/liveWall";
import { useWeatherAlertsIn, useWind, type Polled } from "@/lib/useWeather";
import type { WeatherAlertsResponse } from "@/lib/types";
import { useOutagesIn, type OutagesState } from "@/lib/useOutages"; import { useOutagesIn, type OutagesState } from "@/lib/useOutages";
import { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch"; import { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch";
@@ -26,8 +32,10 @@ function sevColor(s: string | null | undefined) {
function sevLabel(s: string | null | undefined) { function sevLabel(s: string | null | undefined) {
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown"; return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
} }
function elapsed(iso: string): string { /** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000)); function elapsed(iso: string, endIso?: string | null): string {
const end = endIso ? new Date(endIso).getTime() : Date.now();
const m = Math.max(0, Math.round((end - new Date(iso).getTime()) / 60_000));
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`; return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
} }
function hhmm(iso: string): string { function hhmm(iso: string): string {
@@ -43,7 +51,7 @@ export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: Came
return ( return (
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group"> <button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" /> <CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[9px] px-1 rounded-sm">{km.toFixed(1)} km</span>} {km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[10px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span> <span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
</button> </button>
); );
@@ -178,6 +186,126 @@ function OutageFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; node
); );
} }
// ── Weather (server-26#237) ──────────────────────────────────────────────────
const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null };
const METAR_CREDIT: CameraCredit = { attribution: "NOAA Aviation Weather Center (METAR)", attribution_url: "https://aviationweather.gov/", disclaimer: null };
/**
* Why the NWS answer is unusable, in one line (same wording as the map's
* banner) — or null when it is fine to read.
*/
function nwsProblem(state: Polled<WeatherAlertsResponse>): string | null {
const { data, error, loaded } = state;
if (!loaded) return "checking…";
if (!data) return `could not reach server${error ? ` (${error})` : ""}`;
if (data.error && !data.fetched_at) return "NWS feed error, no data yet";
return null;
}
function nwsFreshness(state: Polled<WeatherAlertsResponse>): string {
const d = state.data!;
return `fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · NWS feed error, showing the last good data" : ""}${state.error ? " · could not refresh" : ""}`;
}
/** Incident panel "NWS" lines: the warning polygons the incident's head point sits inside. */
function WeatherWarningContext({ head }: { head: LatLon | null }) {
const box = useMemo(() => (head ? bboxAround([head], 1) : null), [head]);
const state = useWeatherAlertsIn(box);
const inside = useMemo(() => (head && state.data ? alertsAt(head, state.data.alerts) : []), [head, state.data]);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
if (!head) return <li>{tag}—</li>;
const problem = nwsProblem(state);
if (problem) return <li>{tag}{problem}</li>;
const zoneOnly = state.data!.zone_only.length;
return (
<li>
{tag}
{inside.length ? (
<span className="inline-flex flex-wrap gap-1 align-middle">
{inside.map((a) => {
const st = wxStyle(a.event);
return (
<span key={a.id} title={a.headline ?? a.event}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-raised text-ink"
style={{ borderLeft: `3px ${st.dashArray ? "dashed" : "solid"} ${st.color}` }}>
inside {a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}
</span>
);
})}
</span>
) : (
<>not inside any active NWS alert area</>
)}
{zoneOnly > 0 && <span className="text-ink-muted"> · {zoneOnly} zone-based alert{zoneOnly !== 1 ? "s" : ""} in the served area can&apos;t be placed (Weather Warnings layer lists them)</span>}
<SourceCredit credit={NWS_CREDIT} updated={nwsFreshness(state)} />
</li>
);
}
/** Arrow pointing where the wind blows TO (METAR gives where it comes FROM). */
function WindArrow({ fromDeg }: { fromDeg: number }) {
return (
<svg width="14" height="14" viewBox="0 0 14 14" className="inline-block align-[-2px] mr-1" aria-hidden>
<g transform={`rotate(${(fromDeg + 180) % 360} 7 7)`}>
<line x1="7" y1="12" x2="7" y2="2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<polyline points="3.8,5.6 7,2.2 10.2,5.6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</g>
</svg>
);
}
/** Incident panel "WIND" line for fire/hazmat incidents: nearest METAR station. */
function WindContext({ head }: { head: LatLon | null }) {
const state = useWind(head);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">WIND</span>;
if (!head) return <li>{tag}— (no geocoded location)</li>;
if (!state.loaded) return <li>{tag}checking…</li>;
const d = state.data;
if (!d) return <li>{tag}could not reach server{state.error ? ` (${state.error})` : ""}</li>;
if (!d.available || !d.wind) return <li>{tag}{d.reason ?? "no wind report"}</li>;
const w = d.wind;
const calm = w.wind_kt === 0;
const dir = calm ? "calm" : w.variable || w.wind_dir_deg == null ? "variable" : `from ${compass(w.wind_dir_deg)} (${Math.round(w.wind_dir_deg)}°)`;
const obs = w.obs_time ? new Date(w.obs_time) : null;
const obsLabel = obs && !isNaN(obs.getTime()) ? obs.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null;
return (
<li>
{tag}
{!calm && !w.variable && w.wind_dir_deg != null && <WindArrow fromDeg={w.wind_dir_deg} />}
<b>{calm ? "Calm" : `${Math.round(w.wind_kt)} kt`}</b>
{w.gust_kt != null && !calm ? <b> G {Math.round(w.gust_kt)} kt</b> : null}
{calm ? "" : ` ${dir}`}
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
{obsLabel ? ` · observed ${obsLabel}` : ""}
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[10px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
<SourceCredit credit={METAR_CREDIT}
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
</li>
);
}
/** Region Feeds "NWS" row: active alerts over the wall's region, with fetch time or the error. */
function WeatherFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; nodes: NodeRecord[] }) {
const pts = useMemo(() => {
const inc = incidents.filter((i) => i.location_coords).map((i) => [i.location_coords!.lat, i.location_coords!.lng] as LatLon);
return inc.length ? inc : nodes.map((n) => [n.lat, n.lon] as LatLon);
}, [incidents, nodes]);
const box = useMemo(() => bboxAround(pts, 10), [pts]);
const state = useWeatherAlertsIn(box);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
const problem = nwsProblem(state);
if (problem) return <li>{tag}{problem}</li>;
const d = state.data!;
const warnings = d.alerts.filter((a) => wxStyle(a.event).kind === "warning").length;
return (
<li>{tag}{d.alerts.length
? `${d.alerts.length} active alert${d.alerts.length !== 1 ? "s" : ""} in region${warnings ? ` (${warnings} warning${warnings !== 1 ? "s" : ""})` : ""}`
: "no active alert areas in region"}
{d.zone_only.length ? ` · ${d.zone_only.length} zone-based in served area` : ""} · {nwsFreshness(state)} · Weather Warnings layer (map menu)</li>
);
}
// ── Level 1: region ────────────────────────────────────────────────────────── // ── Level 1: region ──────────────────────────────────────────────────────────
export function RegionPanel({ export function RegionPanel({
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount, incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
@@ -222,7 +350,7 @@ export function RegionPanel({
{wallCameras.map(({ cam, km }) => ( {wallCameras.map(({ cam, km }) => (
<div key={cam.id} className="relative"> <div key={cam.id} className="relative">
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} /> <CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[9px] px-1 rounded-sm">PINNED</span>} {pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[10px] px-1 rounded-sm">PINNED</span>}
</div> </div>
))} ))}
</div> </div>
@@ -241,7 +369,8 @@ export function RegionPanel({
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li> <li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
))} ))}
<OutageFeedRow incidents={incidents} nodes={nodes} /> <OutageFeedRow incidents={incidents} nodes={nodes} />
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li> <WeatherFeedRow incidents={incidents} nodes={nodes} />
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu, IEM n0q, with valid time); sampled per incident</li>
</ul> </ul>
</div> </div>
<div className={SEC}> <div className={SEC}>
@@ -295,15 +424,22 @@ export function IncidentPanel({
onOpenCamera: (c: Camera) => void; onOpenCamera: (c: Camera) => void;
}) { }) {
const color = sevColor(inc.severity); const color = sevColor(inc.severity);
// A resolved incident opened from an #inc= link (server-26#275): nothing in
// the panel may read as still running. Its duration freezes at resolved_at
// (the correlator writes it; the frontend type doesn't declare it yet), and
// the live camera / context feeds are labelled as "now", not incident time.
const resolved = inc.status === "resolved";
const resolvedAt = (inc as IncidentRecord & { resolved_at?: string | null }).resolved_at ?? null;
const resolvedAtOk = !!resolvedAt && !Number.isNaN(new Date(resolvedAt).getTime());
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]); const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
const radar = useRadarDbz(points.at(-1) ?? null); const head = points.at(-1) ?? null;
// Stop numbers share the index with the map path: geocoded calls, oldest first. const radar = useRadarDbz(head);
const stopOf = useMemo(() => { // Stop numbers come from the same pathStops() the map draws from, so the
const m = new Map<string, number>(); // spine numbers exactly what the map numbers (server-26#236): only movement
calls.filter((c) => c.location_coords).slice().sort((a, b) => a.started_at.localeCompare(b.started_at)) // incidents get numbers, and a ghosted (impossible-speed) stop gets none.
.forEach((c, i) => m.set(c.call_id, i + 1)); const path = useMemo(() => pathStops(inc, calls), [inc, calls]);
return m; const stopOf = useMemo(() => stopNumbers(path), [path]);
}, [calls]); const ghostIds = useMemo(() => new Set(path.stops.filter((s) => s.ghost).map((s) => s.call.call_id)), [path]);
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]); const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
// "Play incident" (server-26#233): every loaded call with a recording, oldest first. // "Play incident" (server-26#233): every loaded call with a recording, oldest first.
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]); const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
@@ -328,14 +464,24 @@ export function IncidentPanel({
<div className="flex items-center gap-2 text-[11px] mb-1"> <div className="flex items-center gap-2 text-[11px] mb-1">
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span> <span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
<span className="text-ink-muted">{inc.type ?? "other"}</span> <span className="text-ink-muted">{inc.type ?? "other"}</span>
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : <span className="text-ink-muted">active</span>} {resolved ? <span className="text-ink-muted">resolved</span>
: onAir ? <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold">ON AIR</span>
: <span className="text-ink-muted">active</span>}
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span> <span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
</div> </div>
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2> <h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3"> <div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
{inc.location && <span>{inc.location}</span>} {inc.location && <span>{inc.location}</span>}
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span> {!resolved ? <span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
: resolvedAtOk ? <span className="font-mono">lasted {elapsed(inc.started_at, resolvedAt)}</span>
: null}
<span>{inc.call_ids?.length ?? 0} calls</span> <span>{inc.call_ids?.length ?? 0} calls</span>
{path.mayBeMerged && (
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
may be merged · {path.lengthKm.toFixed(0)} km
</span>
)}
</div> </div>
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link> <Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
</div> </div>
@@ -347,6 +493,7 @@ export function IncidentPanel({
<div className={SEC}> <div className={SEC}>
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3> <h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Live views as of now, not from when this incident was active.</p>}
{near.length ? ( {near.length ? (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{near.map(({ cam, km }) => ( {near.map(({ cam, km }) => (
@@ -365,6 +512,7 @@ export function IncidentPanel({
<div className={SEC}> <div className={SEC}>
<h3 className={H3}>Situational context · fused layers</h3> <h3 className={H3}>Situational context · fused layers</h3>
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Current conditions near the location, not at incident time.</p>}
<ul className="space-y-1 text-xs text-ink-2"> <ul className="space-y-1 text-xs text-ink-2">
{ctx.aircraft.map(({ a, km }) => ( {ctx.aircraft.map(({ a, km }) => (
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span> <li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
@@ -386,8 +534,11 @@ export function IncidentPanel({
<li> <li>
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span> <span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable" {!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
: radar.dbz === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`} : radar.dbz < PRECIP_ECHO_DBZ ? "no precipitation echo within ~5 km"
: `up to ~${Math.round(radar.dbz)} dBZ within ~5 km (approx., read from the radar layer's n0q tile)`}
</li> </li>
<WeatherWarningContext head={head} />
{wantsWind(inc) && <WindContext head={head} />}
<OutageContext inc={inc} points={points} /> <OutageContext inc={inc} points={points} />
</ul> </ul>
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && ( {!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
@@ -397,7 +548,7 @@ export function IncidentPanel({
<div className={`${SEC} grid grid-cols-2 gap-3`}> <div className={`${SEC} grid grid-cols-2 gap-3`}>
<div> <div>
<h3 className={H3}>On scene</h3> <h3 className={H3}>{resolved ? "Last on scene" : "On scene"}</h3>
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div> <div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
</div> </div>
<div> <div>
@@ -441,6 +592,7 @@ export function IncidentPanel({
<ol className="space-y-2"> <ol className="space-y-2">
{shown.map((c) => { {shown.map((c) => {
const stop = stopOf.get(c.call_id); const stop = stopOf.get(c.call_id);
const ghost = ghostIds.has(c.call_id);
const text = c.transcript_corrected || c.transcript; const text = c.transcript_corrected || c.transcript;
const isPlaying = c.call_id === playingCallId; const isPlaying = c.call_id === playingCallId;
return ( return (
@@ -448,7 +600,10 @@ export function IncidentPanel({
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`} className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
aria-current={isPlaying ? "true" : undefined}> aria-current={isPlaying ? "true" : undefined}>
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]" <span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span> title={ghost ? "Geocoded too far from the previous stop to reach in time — left off the path" : undefined}
style={stop ? { background: color, color: "var(--page)" }
: ghost ? { border: "1.5px dashed var(--ink-muted)", color: "var(--ink-muted)" }
: { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? (ghost ? "?" : "·")}</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex gap-1.5 items-center"> <div className="flex gap-1.5 items-center">
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span> <span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
+1 -1
View File
@@ -7,7 +7,7 @@ interface EmptyStateProps {
action?: ReactNode; action?: ReactNode;
} }
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */ /** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-ink-muted">` scattered across pages. */
export function EmptyState({ icon, title, description, action }: EmptyStateProps) { export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return ( return (
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl"> <div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
@@ -50,6 +50,14 @@ function InfoGlyph({ className }: { className?: string }) {
} }
const LEAD = "Machine-generated and unverified"; const LEAD = "Machine-generated and unverified";
const SHORT_BODY = "automated output, may contain errors.";
/**
* The short notice as plain text, for surfaces that cannot render this
* component — e.g. a browser notification body (server-26#238). Same copy
* rules as above; it is the "popup"/"inline" wording verbatim.
*/
export const MACHINE_OUTPUT_NOTICE_TEXT = `${LEAD} — ${SHORT_BODY}`;
interface Props { interface Props {
variant?: Variant; variant?: Variant;
@@ -62,7 +70,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
const body = const body =
detail ?? detail ??
"transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them."; "transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them.";
const shortBody = detail ?? "automated output, may contain errors."; const shortBody = detail ?? SHORT_BODY;
if (variant === "popup") { if (variant === "popup") {
// Leaflet popups render on a white wrapper regardless of theme, so this // Leaflet popups render on a white wrapper regardless of theme, so this
@@ -70,7 +78,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
return ( return (
<p <p
role="note" role="note"
className={["text-[10px] leading-snug text-gray-500 mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""] className={["text-[10px] leading-snug text-ink-muted mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
> >
+419
View File
@@ -0,0 +1,419 @@
/**
* Attention cues for the live wall (server-26#238): what changed between two
* snapshots that a person should look at, the "NEW · last 15 min" lane, and
* which pins pulse.
*
* `diffAttention` is pure: (previous state, snapshot) → (next state, events).
* The first snapshot is a BASELINE and emits nothing, so opening the page
* never fires a cue for every incident already open. Calls baseline
* separately, on the first non-empty call snapshot, because the incident and
* call subscriptions land independently.
*
* The diff runs over ALL active incidents, not the filtered list: diffing the
* filtered list would make widening a filter look like a burst of "new"
* Majors. Visibility is applied when an event is emitted (`isVisibleEvent`),
* against the filter in force at that moment — so a cue or notification
* never fires for an incident filtered out of view.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { CallRecord, IncidentRecord } from "@/lib/types";
import { SEVERITY_LABEL, isKnownSeverity, severityRank } from "@/lib/severity";
import { groupCallsByIncident, pathStops } from "@/lib/liveWall";
/** How long something counts as "new" on the lane. */
export const LANE_WINDOW_MS = 15 * 60_000;
/** How long a new / changed incident's pin pulses. */
export const PULSE_MS = 2 * 60_000;
export const DEFAULT_KEYWORDS: readonly string[] = ["MAYDAY", "10-13", "shots fired", "officer down"];
const MAJOR = 3;
/** A stop on an incident with no known stops counts as new only if it started after (first seen − this). */
const STOP_GRACE_MS = 2 * 60_000;
/** Forget incidents gone from the active list this long (state stays bounded on an all-day wall). */
const FORGET_MS = 6 * 3600_000;
/** Checked-call memory: anything older than the lane window is never cued anyway. */
const CALL_MEMORY_MS = LANE_WINDOW_MS + 30 * 60_000;
const MAX_LANE = 12;
const MAX_LOG = 200;
export type AttentionKind = "major" | "escalated" | "stop" | "keyword";
export interface AttentionEvent {
/** Stable key (also the notification tag's suffix). */
id: string;
kind: AttentionKind;
/** Incidents this concerns; empty = a call not linked to any incident yet. */
incidentIds: string[];
callId: string | null;
at: number;
/** Short reason chip, e.g. "Major", "↑ Moderate", "+1 stop", "MAYDAY". */
label: string;
/** Incident title, or the talkgroup for an unlinked call. */
title: string;
}
interface IncState {
rank: number;
/** When this incident first counted as new: its start for the baseline, else when the wall first saw it. */
seenAt: number;
/** When the wall started tracking it (stop-grace floor). */
knownSince: number;
lastSeen: number;
stopIds: Set<string>;
newestStopAt: number | null;
}
export interface AttentionState {
baselineAt: number;
callsBaselined: boolean;
incidents: Map<string, IncState>;
/** call_id → started_at ms, for calls whose transcript has been keyword-checked. */
checkedCalls: Map<string, number>;
}
const ms = (iso: string | null | undefined): number => {
const t = iso ? Date.parse(iso) : NaN;
return Number.isNaN(t) ? 0 : t;
};
function callIncidentIds(c: CallRecord): string[] {
return c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
}
function transcriptOf(c: CallRecord): string {
return c.transcript_corrected || c.transcript || "";
}
/** Non-ghost geocoded stops of a movement incident (pursuit path), oldest first. Empty otherwise. */
function movementStops(inc: IncidentRecord, calls: CallRecord[] | undefined): { id: string; at: number }[] {
const path = pathStops(inc, calls);
if (!path.movement) return [];
return path.stops.filter((s) => !s.ghost).map((s) => ({ id: s.call.call_id, at: ms(s.call.started_at) }));
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Configured keywords found in `text`, case-insensitive, whole-word: "10-13"
* does not fire on "210-135". Spaces match any whitespace and a hyphen may be
* a space or absent ("10 13", "1013"), since transcription renders codes both ways.
*/
export function matchKeywords(text: string, keywords: readonly string[]): string[] {
if (!text) return [];
const t = text.toLowerCase();
const hits: string[] = [];
for (const k of keywords) {
const kw = k.trim().toLowerCase();
if (!kw) continue;
const body = kw.split(/\s+/).map((w) => escapeRe(w).replace(/-/g, "[-\\s]?")).join("\\s+");
if (new RegExp(`(^|[^a-z0-9])${body}($|[^a-z0-9])`).test(t)) hits.push(k.trim());
}
return hits;
}
function sevLabel(sev: string | null | undefined): string {
return isKnownSeverity(sev) ? SEVERITY_LABEL[sev] : "Unrated";
}
/**
* One snapshot step. `prev === null` → baseline (no events). Never mutates `prev`.
* `incidents` = every active incident (unfiltered); `calls` = every call the
* wall has loaded (recent window + the selected incident's own calls).
*/
export function diffAttention(
prev: AttentionState | null,
incidents: IncidentRecord[],
calls: CallRecord[],
keywords: readonly string[],
now: number = Date.now(),
): { state: AttentionState; events: AttentionEvent[] } {
const byInc = groupCallsByIncident(calls);
const events: AttentionEvent[] = [];
if (!prev) {
const map = new Map<string, IncState>();
for (const inc of incidents) {
const stops = movementStops(inc, byInc.get(inc.incident_id));
map.set(inc.incident_id, {
rank: severityRank(inc.severity),
seenAt: ms(inc.started_at) || now,
knownSince: now,
lastSeen: now,
stopIds: new Set(stops.map((s) => s.id)),
newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null,
});
}
const checked = new Map<string, number>();
for (const c of calls) if (transcriptOf(c)) checked.set(c.call_id, ms(c.started_at));
return { state: { baselineAt: now, callsBaselined: calls.length > 0, incidents: map, checkedCalls: checked }, events };
}
// The first non-empty call snapshot is the calls' own baseline: record what
// is there, and re-seed every known incident's stops from it, silently.
const baselineCalls = !prev.callsBaselined && calls.length > 0;
const map = new Map<string, IncState>();
const present = new Set<string>();
for (const inc of incidents) {
const id = inc.incident_id;
present.add(id);
const rank = severityRank(inc.severity);
const stops = movementStops(inc, byInc.get(id));
const p = prev.incidents.get(id);
const title = inc.title || "Untitled incident";
if (!p) {
map.set(id, {
rank, seenAt: now, knownSince: now, lastSeen: now,
stopIds: new Set(stops.map((s) => s.id)),
newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null,
});
if (rank === MAJOR) events.push({ id: `major:${id}`, kind: "major", incidentIds: [id], callId: null, at: now, label: "Major", title });
continue;
}
const st: IncState = { ...p, rank, lastSeen: now, stopIds: new Set(p.stopIds) };
// An unrated incident getting its first rating is classification, not
// escalation — unless that first rating is Major.
if (rank > p.rank && (p.rank >= 0 || rank === MAJOR)) {
events.push({
id: `sev:${id}:${rank}`,
kind: rank === MAJOR && p.rank < 0 ? "major" : "escalated",
incidentIds: [id], callId: null, at: now,
label: p.rank < 0 ? sevLabel(inc.severity) : `↑ ${sevLabel(inc.severity)}`,
title,
});
}
if (stops.length) {
const floor = st.newestStopAt ?? st.knownSince - STOP_GRACE_MS;
const fresh = baselineCalls ? [] : stops.filter((s) => !p.stopIds.has(s.id) && s.at > floor);
for (const s of stops) st.stopIds.add(s.id);
st.newestStopAt = Math.max(st.newestStopAt ?? 0, ...stops.map((s) => s.at));
if (fresh.length) {
const newest = fresh[fresh.length - 1];
events.push({
id: `stop:${id}:${newest.id}`, kind: "stop", incidentIds: [id], callId: newest.id, at: now,
label: fresh.length === 1 ? "+1 stop" : `+${fresh.length} stops`, title,
});
}
}
map.set(id, st);
}
// Keep incidents that dropped out for a while, so one that flickers out of
// the active list and back is not "new" again.
prev.incidents.forEach((st, id) => {
if (!present.has(id) && now - st.lastSeen < FORGET_MS) map.set(id, st);
});
const checked = new Map<string, number>();
prev.checkedCalls.forEach((t, id) => { if (now - t < CALL_MEMORY_MS) checked.set(id, t); });
const incTitle = new Map(incidents.map((i) => [i.incident_id, i.title || "Untitled incident"]));
for (const c of calls) {
const text = transcriptOf(c);
if (!text || checked.has(c.call_id)) continue;
const started = ms(c.started_at);
checked.set(c.call_id, started || now);
if (baselineCalls || !prev.callsBaselined) continue;
if (now - started > LANE_WINDOW_MS) continue; // an old call pulled in late (e.g. by selection) is not news
const hits = matchKeywords(text, keywords);
if (!hits.length) continue;
const ids = callIncidentIds(c);
events.push({
id: `kw:${c.call_id}`, kind: "keyword", incidentIds: ids, callId: c.call_id, at: now,
label: hits.join(" · ").toUpperCase(),
title: ids.map((i) => incTitle.get(i)).find(Boolean) ?? c.talkgroup_name ?? (c.talkgroup_id != null ? `TG ${c.talkgroup_id}` : "Unlinked call"),
});
}
return {
state: { baselineAt: prev.baselineAt, callsBaselined: prev.callsBaselined || baselineCalls, incidents: map, checkedCalls: checked },
events,
};
}
/**
* In view under the current filter: a linked event needs one of its incidents
* in the filtered list (never the call alone — a filtered-out incident's call
* must not cue); an unlinked call needs the call itself in the filtered calls.
*/
export function isVisibleEvent(e: AttentionEvent, visibleIds: ReadonlySet<string>, visibleCallIds: ReadonlySet<string>): boolean {
if (e.incidentIds.length) return e.incidentIds.some((id) => visibleIds.has(id));
return !!e.callId && visibleCallIds.has(e.callId);
}
export interface LaneEntry {
key: string;
incidentId: string | null;
title: string;
severity: string | null;
/** Most recent reason to be on the lane (ms). */
at: number;
/** "New" or the latest event's label. */
reason: string;
}
/** The lane: visible incidents new in the window or with a cue in it, plus unlinked keyword calls; newest first. */
export function laneEntries(
visible: IncidentRecord[],
seenAt: ReadonlyMap<string, number>,
log: AttentionEvent[],
now: number,
visibleCallIds: ReadonlySet<string>,
): LaneEntry[] {
const since = now - LANE_WINDOW_MS;
const latest = new Map<string, AttentionEvent>();
for (const e of log) {
if (e.at < since) continue;
for (const id of e.incidentIds) {
const cur = latest.get(id);
if (!cur || e.at >= cur.at) latest.set(id, e);
}
}
const out: LaneEntry[] = [];
for (const inc of visible) {
const seen = seenAt.get(inc.incident_id) ?? 0;
const ev = latest.get(inc.incident_id);
const isNew = seen >= since;
if (!isNew && !ev) continue;
const at = Math.max(isNew ? seen : 0, ev?.at ?? 0);
out.push({
key: inc.incident_id, incidentId: inc.incident_id, title: inc.title || "Untitled incident", severity: inc.severity,
at, reason: ev && ev.at >= (isNew ? seen : 0) ? ev.label : "New",
});
}
for (const e of log) {
if (e.at < since || e.incidentIds.length || !e.callId || !visibleCallIds.has(e.callId)) continue;
out.push({ key: e.id, incidentId: null, title: e.title, severity: null, at: e.at, reason: e.label });
}
return out.sort((a, b) => b.at - a.at).slice(0, MAX_LANE);
}
/** Visible incidents first seen, or cued, within PULSE_MS. */
export function pulseIds(visibleIds: ReadonlySet<string>, seenAt: ReadonlyMap<string, number>, log: AttentionEvent[], now: number): Set<string> {
const since = now - PULSE_MS;
const out = new Set<string>();
seenAt.forEach((t, id) => { if (t >= since && visibleIds.has(id)) out.add(id); });
for (const e of log) if (e.at >= since) for (const id of e.incidentIds) if (visibleIds.has(id)) out.add(id);
return out;
}
// ── Preferences (per browser; a convenience, so storage failure = defaults) ──
export interface AttentionPrefs {
/** Audible cue. OFF until the viewer turns it on (autoplay policy + no surprise noise). */
tone: boolean;
/** Browser notifications while the tab is hidden. OFF until granted and turned on. */
notify: boolean;
keywords: string[];
}
const PREFS_KEY = "drb.wall.attention";
export const DEFAULT_ATTENTION_PREFS: AttentionPrefs = { tone: false, notify: false, keywords: [...DEFAULT_KEYWORDS] };
export function loadAttentionPrefs(): AttentionPrefs {
try {
const raw = window.localStorage.getItem(PREFS_KEY);
if (!raw) return DEFAULT_ATTENTION_PREFS;
const v = JSON.parse(raw) as Partial<AttentionPrefs>;
return {
tone: v.tone === true,
notify: v.notify === true,
keywords: Array.isArray(v.keywords)
? v.keywords.filter((k): k is string => typeof k === "string" && !!k.trim()).slice(0, 30)
: [...DEFAULT_KEYWORDS],
};
} catch {
return DEFAULT_ATTENTION_PREFS;
}
}
export function saveAttentionPrefs(p: AttentionPrefs): void {
try { window.localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch { /* storage blocked */ }
}
/** "MAYDAY, 10-13 , ,shots fired" → ["MAYDAY", "10-13", "shots fired"] (deduped, case-insensitively). */
export function parseKeywords(s: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const part of s.split(",")) {
const k = part.trim().replace(/\s+/g, " ");
if (!k || seen.has(k.toLowerCase())) continue;
seen.add(k.toLowerCase());
out.push(k);
}
return out.slice(0, 30);
}
// ── Hook ─────────────────────────────────────────────────────────────────────
interface UseAttentionInput {
/** True once the incident list's first server snapshot has landed (and the org is known). */
ready: boolean;
/** Changing this (e.g. the org) starts a fresh baseline. */
resetKey: string | null;
incidents: IncidentRecord[];
calls: CallRecord[];
keywords: readonly string[];
/** The FILTERED incidents, their ids, and the filtered call ids — what is in view right now. */
visible: IncidentRecord[];
visibleIds: ReadonlySet<string>;
visibleCallIds: ReadonlySet<string>;
/** Called with each snapshot's new, in-view events (tone / notification). */
onEvents?: (events: AttentionEvent[]) => void;
}
export function useAttention({ ready, resetKey, incidents, calls, keywords, visible, visibleIds, visibleCallIds, onEvents }: UseAttentionInput) {
const stateRef = useRef<AttentionState | null>(null);
const keyRef = useRef<string | null>(resetKey);
const [log, setLog] = useState<AttentionEvent[]>([]);
const [seenAt, setSeenAt] = useState<Map<string, number>>(new Map());
const [now, setNow] = useState(() => Date.now());
const visRef = useRef({ visibleIds, visibleCallIds });
visRef.current = { visibleIds, visibleCallIds };
const onEventsRef = useRef(onEvents);
onEventsRef.current = onEvents;
const kwKey = keywords.join("\u0001");
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 15_000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (keyRef.current !== resetKey) {
keyRef.current = resetKey;
stateRef.current = null;
setLog([]);
setSeenAt(new Map());
}
if (!ready) return;
const t = Date.now();
const { state, events } = diffAttention(stateRef.current, incidents, calls, keywords, t);
stateRef.current = state;
const seen = new Map<string, number>();
state.incidents.forEach((s, id) => seen.set(id, s.seenAt));
setSeenAt(seen);
setNow(t);
if (!events.length) return;
setLog((cur) => [...cur.filter((e) => t - e.at < LANE_WINDOW_MS), ...events].slice(-MAX_LOG));
const { visibleIds: vi, visibleCallIds: vc } = visRef.current;
const shown = events.filter((e) => isVisibleEvent(e, vi, vc));
if (shown.length) onEventsRef.current?.(shown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, resetKey, incidents, calls, kwKey]);
// A keyword usually fires on the transcript, before correlation links the
// call. Re-resolve unlinked events against the calls as they are now, so
// the lane row becomes the (clickable) incident and its pin pulses.
const linkedLog = useMemo(() => {
if (!log.some((e) => !e.incidentIds.length && e.callId)) return log;
const links = new Map<string, string[]>();
for (const c of calls) { const ids = callIncidentIds(c); if (ids.length) links.set(c.call_id, ids); }
return log.map((e) => (e.incidentIds.length || !e.callId || !links.has(e.callId) ? e : { ...e, incidentIds: links.get(e.callId)! }));
}, [log, calls]);
const lane = useMemo(() => laneEntries(visible, seenAt, linkedLog, now, visibleCallIds), [visible, seenAt, linkedLog, now, visibleCallIds]);
const pulse = useMemo(() => pulseIds(visibleIds, seenAt, linkedLog, now), [visibleIds, seenAt, linkedLog, now]);
return { lane, pulseIds: pulse, now };
}
+13 -1
View File
@@ -1,6 +1,8 @@
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import { C2ApiError } from "@/lib/c2error";
import type { import type {
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending, AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
WeatherAlertsResponse, WindResponse,
} from "@/lib/types"; } from "@/lib/types";
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord"; import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
@@ -18,7 +20,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...(options?.headers as Record<string, string> | undefined), ...(options?.headers as Record<string, string> | undefined),
}, },
}); });
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`); if (!res.ok) throw new C2ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
return res.json(); return res.json();
} }
@@ -532,6 +534,16 @@ export const c2api = {
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }), body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
}), }),
// Weather context (server-26#237): active NWS alerts intersecting a bbox (bbox
// optional — omitted, the whole served area), and nearest-METAR wind. Both are
// cached server-side; an upstream failure comes back as `error`, not an HTTP error.
getWeatherAlerts: (bbox?: { south: number; west: number; north: number; east: number } | null) =>
request<WeatherAlertsResponse>(`/weather/alerts${bbox ? `?${new URLSearchParams({
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
})}` : ""}`),
getWind: (lat: number, lon: number) =>
request<WindResponse>(`/weather/wind?${new URLSearchParams({ lat: String(lat), lon: String(lon) })}`),
// Public waitlist — no auth, see routers/waitlist.py // Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) => joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }), request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
+64
View File
@@ -0,0 +1,64 @@
/**
* Error thrown by every C2 API request (server-26#286).
*
* `message` is a sentence a user can read — FastAPI's `detail` when the
* server sent one, otherwise a plain mapping of the status. The raw status
* and body stay on the error for callers that need the structured parts
* (the Discord parsers read `invite`/`code` out of `body`).
*/
export class C2ApiError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string) {
super(humanMessage(status, body));
this.name = "C2ApiError";
this.status = status;
this.body = body;
}
// Call sites render String(e); without this it reads "C2ApiError: …".
toString(): string {
return this.message;
}
}
function detailOf(body: string): string | null {
try {
const parsed = JSON.parse(body);
const d = parsed?.detail;
if (typeof d === "string" && d.trim()) return d;
// pydantic validation errors: [{loc, msg, type}, ...]
if (Array.isArray(d)) {
const msgs = d.map((x: { msg?: unknown }) => (typeof x?.msg === "string" ? x.msg : null)).filter(Boolean);
if (msgs.length) return msgs.join("; ");
}
} catch {
/* not JSON */
}
return null;
}
function humanMessage(status: number, body: string): string {
const detail = detailOf(body);
if (status === 401) return "Your session has expired. Sign in again.";
if (status === 403) return detail ? `Not allowed: ${detail}` : "You don't have permission to do that.";
if (detail) return detail;
if (status === 404) return "Not found. It may have been deleted.";
if (status === 429) return "Too many requests. Wait a moment and try again.";
if (status >= 500) return "The server hit an error. Try again in a moment.";
return `Request failed (${status}).`;
}
/** Message for any caught value — use in catch blocks instead of String(e). */
export function errorMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
/** Rebuilds the legacy `C2 API error <status>: <body>` string for the
* Discord parsers, which predate C2ApiError and match on that shape. */
export function legacyErrorText(err: unknown): string {
if (err instanceof C2ApiError) return `C2 API error ${err.status}: ${err.body}`;
return err instanceof Error ? err.message : String(err);
}
+3 -1
View File
@@ -2,6 +2,8 @@
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and // (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
// small helpers shared by /settings/discord and /tokens (server-26#213). // small helpers shared by /settings/discord and /tokens (server-26#213).
import { legacyErrorText } from "@/lib/c2error";
export interface RadioBotLease { export interface RadioBotLease {
node_id: string; node_id: string;
lease_id?: string; lease_id?: string;
@@ -160,7 +162,7 @@ export const SNOWFLAKE = /^\d{15,21}$/;
* so "no bot in this server" can render its invite links. * so "no bot in this server" can render its invite links.
*/ */
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } { export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
const raw = err instanceof Error ? err.message : String(err); const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/); const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
if (m) { if (m) {
try { try {
+3 -2
View File
@@ -9,6 +9,7 @@
// browser: the code exchange happens in c2-core. // browser: the code exchange happens in c2-core.
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import { C2ApiError } from "@/lib/c2error";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000"; const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -24,7 +25,7 @@ async function send<T>(method: "POST" | "PUT" | "DELETE", path: string, body?: o
}, },
...(body ? { body: JSON.stringify(body) } : {}), ...(body ? { body: JSON.stringify(body) } : {}),
}); });
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`); if (!res.ok) throw new C2ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
return res.json(); return res.json();
} }
@@ -58,7 +59,7 @@ export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthSt
try { try {
raw = await post("/discord/oauth/start", req); raw = await post("/discord/oauth/start", req);
} catch (err) { } catch (err) {
if (err instanceof Error && /^C2 API error 404:/.test(err.message)) throw new OAuthNotConfigured(err.message); if (err instanceof C2ApiError && err.status === 404) throw new OAuthNotConfigured(err.message);
throw err; throw err;
} }
const url = raw.url ?? raw.authorize_url ?? ""; const url = raw.url ?? raw.authorize_url ?? "";
+3 -1
View File
@@ -2,6 +2,8 @@
// Shapes returned by c2-core app/routers/discord_web.py. No token strings — // Shapes returned by c2-core app/routers/discord_web.py. No token strings —
// the server never sends them to the browser. // the server never sends them to the browser.
import { legacyErrorText } from "@/lib/c2error";
export type StreamState = export type StreamState =
| "pending" | "pending"
| "connecting" | "connecting"
@@ -131,7 +133,7 @@ function reasonOf(b: Record<string, unknown>): string | null {
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the /** c2api's request() throws `C2 API error <status>: <body>`. Pull the
* detail, code and invite links back out of a JSON body. */ * detail, code and invite links back out of a JSON body. */
export function parseC2Error(err: unknown): ParsedC2Error { export function parseC2Error(err: unknown): ParsedC2Error {
const raw = err instanceof Error ? err.message : String(err); const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/); const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
const status = m ? Number(m[1]) : null; const status = m ? Number(m[1]) : null;
const idx = raw.indexOf(": "); const idx = raw.indexOf(": ");
+347 -29
View File
@@ -3,7 +3,9 @@
* gods-eye-view-notes.md top 5). No React here, so the logic is testable in * gods-eye-view-notes.md top 5). No React here, so the logic is testable in
* isolation and shared by MapView and LiveView. * isolation and shared by MapView and LiveView.
*/ */
import type { AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack } from "@/lib/types"; import type {
AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack, WeatherAlert, WeatherGeometry,
} from "@/lib/types";
import { severityRank } from "@/lib/severity"; import { severityRank } from "@/lib/severity";
export type LatLon = [number, number]; export type LatLon = [number, number];
@@ -34,18 +36,125 @@ export function groupCallsByIncident(calls: CallRecord[]): Map<string, CallRecor
/** /**
* An incident's geography, oldest first: its geocoded call stops (the path — * An incident's geography, oldest first: its geocoded call stops (the path —
* same order as the call spine) or, failing that, its pin. Empty = not plotted. * same order as the call spine) or, failing that, its pin. Empty = not plotted.
* Ghosted stops (an implied speed no vehicle makes, server-26#236) are left
* out, so one bad geocode can't drag "nearest cameras" across the county.
*/ */
export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] { export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] {
const stops = (calls ?? []) const stops = pathStops(inc, calls).stops.filter((s) => !s.ghost).map((s) => s.at);
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at))
.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as LatLon);
if (stops.length >= 2) return stops; if (stops.length >= 2) return stops;
if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]]; if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]];
return stops; return stops;
} }
// ── Honest paths (server-26#236) ─────────────────────────────────────────────
// Pure, and shared by MapView and the call spine (WallPanels), so a stop's
// number is identical on both — and only what the map numbers is numbered.
/**
* Mirrors `_PURSUIT_TAGS` in drb-c2-core/app/internal/incident_correlator.py.
* Copied, not imported: keep the two in step by hand.
*/
export const PURSUIT_TAGS: ReadonlySet<string> = new Set([
"vehicle-pursuit", "pursuit", "foot-pursuit", "chase",
"fleeing-vehicle", "suspect-vehicle", "eluding",
]);
/** Mirrors `_MAX_PURSUIT_SPEED_KM_PER_MIN` (incident_correlator.py) — ~300 km/h. */
export const MAX_PATH_SPEED_KM_PER_MIN = 8.0;
/**
* Mirrors settings.location_proximity_km: the backend only runs its speed
* check past this distance, so two geocodes of one scene a few seconds apart
* are never called impossible.
*/
export const SPEED_CHECK_MIN_KM = 0.5;
/** A segment spanning a longer silence than this is drawn dashed — we don't know the route. */
export const PATH_GAP_DASH_MIN = 10;
/** Mirrors `_PURSUIT_PROXIMITY_KM`: a path longer than a pursuit's own link radius is a merge suspect. */
export const MAY_BE_MERGED_KM = 20;
/** Police or accident incident carrying a pursuit tag — the only kind that draws a numbered path. */
export function isMovementIncident(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
if (inc.type !== "police" && inc.type !== "accident") return false;
return (inc.tags ?? []).some((t) => PURSUIT_TAGS.has(t.toLowerCase()));
}
export interface PathStop {
call: CallRecord;
at: LatLon;
/** Shared map/spine stop number; null = not numbered (ghosted, or not a movement incident). */
n: number | null;
/** Reaching this stop from the previous kept one implies > MAX_PATH_SPEED_KM_PER_MIN. */
ghost: boolean;
/** The segment INTO this stop is dashed: a long silence or a talkgroup change. Never set on the first kept stop. */
dashedIn: boolean;
}
export interface IncidentPath {
movement: boolean;
/** Every geocoded call, oldest first — ghosts included, so the map can still show them faintly. */
stops: PathStop[];
/** Sum of the kept (non-ghost) segments. */
lengthKm: number;
mayBeMerged: boolean;
}
function minutesBetween(a: CallRecord, b: CallRecord): number {
return Math.abs(Date.parse(b.started_at) - Date.parse(a.started_at)) / 60_000;
}
function impossible(a: PathStop, b: PathStop): boolean {
const d = km(a.at, b.at);
if (d <= SPEED_CHECK_MIN_KM) return false;
// Same floor the backend uses (incident_correlator.py: max(idle, 0.1)).
return d / Math.max(minutesBetween(a.call, b.call), 0.1) > MAX_PATH_SPEED_KM_PER_MIN;
}
/**
* An incident's geocoded calls as path stops, oldest first.
*
* Ghosting walks forward from the last kept stop: a stop that could only be
* reached faster than a pursuit can drive is ghosted and does not move the
* anchor. A bad FIRST geocode would otherwise ghost everything after it, so
* when stop 1 is impossible from stop 0 but stop 2 agrees with stop 1 and not
* with stop 0, stop 0 is the one ghosted.
*/
export function pathStops(inc: IncidentRecord, calls: CallRecord[] | undefined): IncidentPath {
const movement = isMovementIncident(inc);
const stops: PathStop[] = (calls ?? [])
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at))
.map((c) => ({ call: c, at: [c.location_coords!.lat, c.location_coords!.lng] as LatLon, n: null, ghost: false, dashedIn: false }));
let start = 0;
if (stops.length >= 3 && impossible(stops[0], stops[1]) && !impossible(stops[1], stops[2]) && impossible(stops[0], stops[2])) {
stops[0].ghost = true;
start = 1;
}
let prev: PathStop | null = null;
let lengthKm = 0;
let n = 0;
for (let i = start; i < stops.length; i++) {
const s = stops[i];
if (prev && impossible(prev, s)) { s.ghost = true; continue; }
if (prev) {
lengthKm += km(prev.at, s.at);
s.dashedIn = minutesBetween(prev.call, s.call) > PATH_GAP_DASH_MIN
|| (prev.call.talkgroup_id != null && s.call.talkgroup_id != null && prev.call.talkgroup_id !== s.call.talkgroup_id);
}
if (movement) s.n = ++n;
prev = s;
}
return { movement, stops, lengthKm, mayBeMerged: lengthKm > MAY_BE_MERGED_KM };
}
/** call_id → stop number, for the spine. Empty for a non-movement incident. */
export function stopNumbers(path: IncidentPath): Map<string, number> {
const m = new Map<string, number>();
for (const s of path.stops) if (s.n != null) m.set(s.call.call_id, s.n);
return m;
}
/** Worst-first, then most recent (UI_REDESIGN.md §5.1). */ /** Worst-first, then most recent (UI_REDESIGN.md §5.1). */
export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] { export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] {
return incidents return incidents
@@ -154,7 +263,7 @@ export function bboxAround(points: LatLon[], padKm: number) {
// ── Deep links / saved views ──────────────────────────────────────────────── // ── Deep links / saved views ────────────────────────────────────────────────
// #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk // #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk
// &layers=nodes,inc,radar&z=11&lat=41.12345&lon=-73.98765&base=light // &layers=nodes,inc,radar,wx&z=11&lat=41.12345&lon=-73.98765&base=light
// &f.<name>=<value> // &f.<name>=<value>
// Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour // Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour
// only) still parses and a newer one degrades on an older build. Pure string // only) still parses and a newer one degrades on an older build. Pure string
@@ -171,6 +280,7 @@ export const LAYER_NAMES = {
vs: "Camera Viewsheds (approx.)", vs: "Camera Viewsheds (approx.)",
traffic: "Traffic Events", traffic: "Traffic Events",
radar: "Weather Radar", radar: "Weather Radar",
wx: "Weather Warnings",
} as const; } as const;
export type LayerKey = keyof typeof LAYER_NAMES; export type LayerKey = keyof typeof LAYER_NAMES;
export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[]; export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[];
@@ -300,28 +410,103 @@ export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallR
); );
} }
// ── NEXRAD sample near an incident ─────────────────────────────────────────── // ── NEXRAD (IEM n0q composite) ───────────────────────────────────────────────
// Reads the same IEM n0r composite tile the Weather Radar layer draws (IEM // The Weather Radar layer, its valid-time badge, its legend and the per-incident
// sends ACAO: *), and maps pixel colours back to the NWS reflectivity scale. // sample all use ONE product: IEM's n0q (base reflectivity, 0.5 dBZ steps,
// Approximate by construction — label it so. // -32..95 dBZ). Verified first-hand 2026-10-05:
const N0R_PALETTE: [number, number, number, number][] = [ // tiles https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913/{z}/{x}/{y}.png
[5, 4, 233, 231], [10, 1, 159, 244], [15, 3, 0, 244], [20, 2, 253, 2], [25, 1, 197, 1], [30, 0, 142, 0], // (RGBA PNG, ACAO: *), and nexrad-n0q-900913-m05m … -m55m for the
[35, 253, 248, 2], [40, 229, 188, 0], [45, 253, 149, 0], [50, 253, 0, 0], [55, 212, 0, 0], [60, 188, 0, 0], // 5-minute frames of the last hour (-m60m is a 404);
[65, 248, 0, 253], [70, 152, 84, 198], [75, 253, 253, 253], // valid https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json
]; // → {"meta": {"valid": "2026-10-05T07:35:00Z", "product": "N0Q", …}}
const RADAR_TILE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913"; // (n0q_<k>.json is k × 5 min earlier; ACAO: *);
// ramp https://mesonet.agron.iastate.edu/GIS/rasters.php?rid=2 — 255 colours,
// index i (1..255) = -32 + (i-1)/2 dBZ. Every opaque pixel of five
// sampled live tiles matched this table exactly.
export const RADAR_TILE_BASE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913";
export const RADAR_META_URL = "https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json";
/** A frame older than this is flagged STALE on the map (#237 acceptance). */
export const RADAR_STALE_MIN = 15;
/** Loop frames, oldest first, as minutes before the current composite (IEM keeps -m05m … -m55m). */
export const RADAR_LOOP_OFFSETS_MIN = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0] as const;
/** Light rain starts around here; weaker returns are clear-air / clutter. */
export const PRECIP_ECHO_DBZ = 15;
function pixelDbz(r: number, g: number, b: number): number { /** Tile URL for the composite `offsetMin` minutes before the current one (0 = current). */
let best = 0; export function radarTileUrl(offsetMin = 0): string {
let bestD = Infinity; const layer = offsetMin > 0 ? `${RADAR_TILE_BASE}-m${String(offsetMin).padStart(2, "0")}m` : RADAR_TILE_BASE;
for (const [dbz, pr, pg, pb] of N0R_PALETTE) { return `${layer}/{z}/{x}/{y}.png`;
const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2; }
/** The composite's valid time from IEM's n0q metadata JSON, or null if the shape isn't the verified one. */
export function parseRadarMeta(body: unknown): Date | null {
const valid = (body as { meta?: { valid?: unknown } } | null)?.meta?.valid;
if (typeof valid !== "string") return null;
const d = new Date(valid);
return isNaN(d.getTime()) ? null : d;
}
/** IEM's published n0q colour table, 6 hex digits per entry, index 1..255 (index 0 = missing). */
const N0Q_RAMP_HEX =
"85718f85728f86738d87758b87768b887789897987897a878a7b858b7d848b7e848c7f828d81808d82808e837e8f847c" +
"8f857c90877b918879918979928b77938d759691539894579b975b9d9a60a09d64a3a068a5a36da8a671aaa976adac7a" +
"b0af7eb2b283b7b88cbabb90bdbe94bfc199c2c49dc4c7a2c7caa6cacdaaccd0afd2d4b4cfd2b4c9ccb4c6c9b4c3c7b4" +
"c0c4b4bdc1b4b9beb4b6bbb4b3b9b4b0b6b4adb3b4aab0b4a4abb4a0a8b49da5b49aa2b497a0b4949db4919ab4949bb5" +
"9098b48c95b38892b2808cb07c89af7886ae7483ac7080ab6c7daa6779a96376a85f73a75b70a6576da44f67a24b64a1" +
"4761a0435e9f415b9e4361a24568a6486faa4a76ae4d7db24f84b6518bbb5699c3599fc75ba6cb5eadcf60b4d462bbd8" +
"65c2dc67c9e06ad0e46fd6e868d6d759d6b352d6a24bd69043d67e3cd66d35d65b11d51811d11710cd1710c81610c416" +
"0fbc150fb7140eb3140eaf130eab130da6120da2120d9e110c99110c95100c91100b880f0b840e0a800e0a7c0d0a770d" +
"09730c096f0c096b0b08660b08620a095e09327308467d085b88076f9207849d0698a806adb205c1bd05d6c704ead204" +
"ffe200ffd800ffd300ffce00ffc900ffc400ffc000ffbb00ffb600ffb100ffac00ffa700ffa200ff9900ff9400ff8f00" +
"ff8a00ff8500ff8000ff0000f80000f10000ea0000e30000d50000cd0000c60000bf0000b80000b10000aa0000a30000" +
"9b00009400008d00007f0000780000710000fffffffff5ffffeaffffdfffffd4ffffc9ffffbeffffb3ffff9dffff92ff" +
"ff75fffc6bfdf960faf656f7f34bf4f040f1ed36efea2bece720e9e10be3b200ffac00fca400f79b00f49300ef8800ea" +
"8300e87900e27200dd6900db05ecf005ebf005eaf005dde005dce005dbe005cdd005ccd004bdc004bcc004bbc004aeb0" +
"04adb0049ea0049da0049ca0038e90038d90038c90037e80037d80036f70036e70036d70025f60025e60024f50024e50" +
"024d50023f40023e40023d40013030012f30012020011f20011e203a67b53a66b53a65b53a64b53a63b53a62b5";
function n0qIndex(dbz: number): number {
return Math.min(255, Math.max(1, Math.round((dbz + 32) * 2) + 1));
}
/** "#rrggbb" the radar layer draws for a reflectivity. */
export function n0qColor(dbz: number): string {
const i = n0qIndex(dbz) - 1;
return `#${N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6)}`;
}
let rgbToDbz: Map<number, number> | null = null;
function n0qLookup(): Map<number, number> {
if (!rgbToDbz) {
rgbToDbz = new Map();
for (let i = 0; i < 255; i++) rgbToDbz.set(parseInt(N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6), 16), -32 + i / 2);
}
return rgbToDbz;
}
/** dBZ for a radar-tile pixel. Exact table hit (tiles are lossless); else nearest colour within a small distance; else null. */
export function pixelDbz(r: number, g: number, b: number): number | null {
const lut = n0qLookup();
const exact = lut.get((r << 16) | (g << 8) | b);
if (exact != null) return exact;
let best: number | null = null;
let bestD = 12 * 12;
lut.forEach((dbz, rgb) => {
const d = (r - ((rgb >> 16) & 255)) ** 2 + (g - ((rgb >> 8) & 255)) ** 2 + (b - (rgb & 255)) ** 2;
if (d < bestD) { bestD = d; best = dbz; } if (d < bestD) { bestD = d; best = dbz; }
} });
return bestD < 60 * 60 ? best : 0; return best;
} }
/** Max reflectivity (dBZ) within ~radiusKm of a point, or null if it can't be read. */ /** No opaque radar pixel near the point (the tile is transparent there). */
export const NO_ECHO_DBZ = -Infinity;
/**
* Max reflectivity (dBZ) within ~radiusKm of a point, read from the same n0q
* tile the Weather Radar layer draws (cache-busted on the same 5-min clock).
* Approximate by construction — label it so. NO_ECHO_DBZ when nothing is
* drawn there; null when the tile can't be read.
*/
export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> { export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> {
const z = 9; const z = 9;
const n = 2 ** z; const n = 2 ** z;
@@ -332,7 +517,7 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr
const ty = Math.floor(yf); const ty = Math.floor(yf);
const img = new Image(); const img = new Image();
img.crossOrigin = "anonymous"; img.crossOrigin = "anonymous";
img.src = `${RADAR_TILE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`; img.src = `${RADAR_TILE_BASE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`;
try { try {
await img.decode(); await img.decode();
} catch { } catch {
@@ -359,10 +544,143 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr
} catch { } catch {
return null; // tainted canvas — CORS changed upstream return null; // tainted canvas — CORS changed upstream
} }
let max = 0; let max = NO_ECHO_DBZ;
for (let i = 0; i < data.length; i += 4) { for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 32) continue; if (data[i + 3] < 128) continue; // transparent: nothing drawn
max = Math.max(max, pixelDbz(data[i], data[i + 1], data[i + 2])); const dbz = pixelDbz(data[i], data[i + 1], data[i + 2]);
if (dbz != null && dbz > max) max = dbz;
} }
return max; return max;
} }
// ── NWS warnings (server-26#237) ─────────────────────────────────────────────
/** Ray-cast point-in-ring; `ring` is GeoJSON [lon, lat] pairs. */
function inRing(lat: number, lon: number, ring: number[][]): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/**
* True when [lat, lon] is inside a GeoJSON Polygon / MultiPolygon (holes
* respected: inside the outer ring and outside every hole).
*/
export function pointInPolygon(point: LatLon, geom: WeatherGeometry | null | undefined): boolean {
if (!geom) return false;
const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.type === "MultiPolygon" ? geom.coordinates : [];
const [lat, lon] = point;
return polys.some((rings) =>
rings.length > 0 && inRing(lat, lon, rings[0]) && !rings.slice(1).some((hole) => inRing(lat, lon, hole)));
}
/** Active alerts whose drawn area contains the point, worst-looking (Warning) first. */
export function alertsAt(point: LatLon, alerts: WeatherAlert[]): WeatherAlert[] {
return alerts
.filter((a) => pointInPolygon(point, a.geometry))
.sort((a, b) => wxKindRank(a.event) - wxKindRank(b.event));
}
export type WxKind = "warning" | "watch" | "advisory" | "statement";
export function wxKind(event: string): WxKind {
const e = event.toLowerCase();
if (e.includes("warning") || e.includes("emergency")) return "warning";
if (e.includes("watch")) return "watch";
if (e.includes("advisory") || e.includes("alert")) return "advisory";
return "statement";
}
function wxKindRank(event: string): number {
return ["warning", "watch", "advisory", "statement"].indexOf(wxKind(event));
}
/**
* NWS hazard-map colours (weather.gov/help-map) for the events this region
* sees most. Unlisted events fall back by kind. Colour is never the only cue:
* the outline dash says the kind (warning solid, watch dashed, advisory and
* statement dotted), and the legend explains it.
*/
const NWS_EVENT_COLORS: Record<string, string> = {
"tornado warning": "#ff0000",
"severe thunderstorm warning": "#ffa500",
"flash flood warning": "#8b0000",
"flood warning": "#00ff00",
"flood advisory": "#00ff7f",
"flood watch": "#2e8b57",
"flash flood watch": "#2e8b57",
"tornado watch": "#ffff00",
"severe thunderstorm watch": "#db7093",
"special marine warning": "#ffa500",
"winter storm warning": "#ff69b4",
"winter storm watch": "#4682b4",
"winter weather advisory": "#7b68ee",
"blizzard warning": "#ff4500",
"ice storm warning": "#8b008b",
"lake effect snow warning": "#008b8b",
"high wind warning": "#daa520",
"high wind watch": "#b8860b",
"wind advisory": "#d2b48c",
"heat advisory": "#ff7f50",
"excessive heat warning": "#c71585",
"extreme heat warning": "#c71585",
"excessive heat watch": "#800000",
"extreme heat watch": "#800000",
"coastal flood warning": "#228b22",
"coastal flood watch": "#66cdaa",
"coastal flood advisory": "#7cfc00",
"dense fog advisory": "#708090",
"special weather statement": "#ffe4b5",
"red flag warning": "#ff1493",
"fire weather watch": "#ffdead",
"freeze warning": "#483d8b",
"frost advisory": "#6495ed",
"small craft advisory": "#d8bfd8",
"gale warning": "#dda0dd",
"storm warning": "#9400d3",
"hurricane warning": "#dc143c",
"tropical storm warning": "#b22222",
"air quality alert": "#808080",
};
const WX_KIND_FALLBACK: Record<WxKind, string> = {
warning: "#dc2626", watch: "#eab308", advisory: "#60a5fa", statement: "#a3a3a3",
};
export const WX_KIND_DASH: Record<WxKind, string | undefined> = {
warning: undefined, watch: "8 5", advisory: "2 4", statement: "2 4",
};
export function wxStyle(event: string): { color: string; kind: WxKind; dashArray: string | undefined; weight: number } {
const kind = wxKind(event);
return {
color: NWS_EVENT_COLORS[event.toLowerCase()] ?? WX_KIND_FALLBACK[kind],
kind,
dashArray: WX_KIND_DASH[kind],
weight: kind === "warning" ? 2.5 : kind === "watch" ? 2 : 1.5,
};
}
/** Local "Oct 5, 2:30 PM" for an NWS ISO timestamp (they carry their own offset). */
export function wxTime(iso: string | null | undefined): string | null {
if (!iso) return null;
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
// ── Wind at the incident (server-26#237) ─────────────────────────────────────
// Fire and hazmat are where wind direction changes the call (smoke / plume
// drift, approach side). There is no hazmat incident type, so tags decide.
const WIND_TAG_RE = /(^|-)(hazmat|haz-mat|gas-leak|natural-gas|chemical|spill|smoke|fumes|odor|odour|plume|brush|wildfire|propane|ammonia|chlorine|carbon-monoxide)(-|$)/;
export function wantsWind(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
if (inc.type === "fire") return true;
return (inc.tags ?? []).some((t) => WIND_TAG_RE.test(t.toLowerCase()));
}
/** 16-point compass name for the direction the wind blows FROM. */
export function compass(deg: number): string {
const pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
return pts[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16];
}

Some files were not shown because too many files have changed in this diff Show More