Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc41d1badb | ||
|
|
df837d7d81 | ||
|
|
4fab959ddb | ||
|
|
0060ccde75 | ||
|
|
3e9ca4be06 | ||
|
|
90a3c27a24 | ||
|
|
ff0a89e9f1 | ||
|
|
240bbee5a4 | ||
|
|
d485eae41a | ||
|
|
7ecfca7648 | ||
|
|
641028699f | ||
|
|
ec35d80580 | ||
|
|
3246fc97b0 | ||
|
|
f4d78aba77 | ||
|
|
268470b6c0 | ||
|
|
4a93390967 | ||
|
|
94898d1af1 | ||
|
|
33056e2269 | ||
|
|
e8084357c3 | ||
|
|
9702b90d40 | ||
|
|
606b0a103c | ||
|
|
f16f0a9f93 | ||
|
|
6d667e81c9 | ||
|
|
bf206334e3 | ||
|
|
3968b49b80 | ||
|
|
8099871c68 | ||
|
|
49166789f4 | ||
|
|
9698d463ad | ||
|
|
3c5496acb1 | ||
|
|
cde86a8ff9 | ||
|
|
292c4fffd9 | ||
|
|
da55114eef | ||
|
|
1a84bf46d7 | ||
|
|
eb0faf2aea | ||
|
|
1d6e79ba25 | ||
|
|
0d613019e0 | ||
|
|
e1bd3feac5 | ||
|
|
7a0e86decf | ||
|
|
3ef8d586ea | ||
|
|
6bf072973c | ||
|
|
a267fdf982 | ||
|
|
9c124d4c8b | ||
|
|
6e462720a1 | ||
|
|
fdcedf175c | ||
|
|
e7070a0afa | ||
|
|
2aaf474828 | ||
|
|
d10c2f732a | ||
|
|
f479578942 |
@@ -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
|
||||||
|
|||||||
+124
-19
@@ -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 ──────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -441,7 +441,9 @@ ERROR_TEXT = {
|
|||||||
"pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice "
|
"pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice "
|
||||||
"channel per server, and a bot carrying radio on another node can't be shared. Add another "
|
"channel per server, and a bot carrying radio on another node can't be shared. Add another "
|
||||||
"radio bot to DRB and invite it to this server, or stop a radio channel here."),
|
"radio bot to DRB and invite it to this server, or stop a radio channel here."),
|
||||||
"limit": "This server already has its maximum number of radio channels. Stop one with /leave first.",
|
# Reaches the web picker and /join alike: name both ways to stop one.
|
||||||
|
"limit": "This server already has its maximum number of radio channels. Stop one first (Stop on a node's "
|
||||||
|
"Discord radio panel, or /leave in Discord).",
|
||||||
"preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to "
|
"preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to "
|
||||||
"another organization). Pick another bot, or let DRB choose any free one.",
|
"another organization). Pick another bot, or let DRB choose any free one.",
|
||||||
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
|
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} vs "
|
||||||
f"Correlator unit-continuity skipped: call {call_id} — "
|
f"{best_unit_inc['incident_id']} — {uc_signal}"
|
||||||
f"unit match but location conflict ({uc_dist:.2f}km) with "
|
)
|
||||||
f"{best_unit_inc['incident_id']}"
|
else:
|
||||||
)
|
|
||||||
if not reassigned_away and not location_conflict:
|
|
||||||
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:
|
||||||
"matched_incident": matched_incident,
|
action, resolved_type = "orphan", None
|
||||||
"incident_type": incident_type,
|
else:
|
||||||
"corr_debug": corr_debug,
|
resolved_type = resolve_new_incident_type(ctx)
|
||||||
}
|
action = "new" if resolved_type else "orphan"
|
||||||
|
if resolved_type and resolved_type != incident_type:
|
||||||
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(
|
logger.info(
|
||||||
f"Correlator: inferred incident_type={resolved_type!r} from tags {tags} for call {call_id}"
|
f"Correlator: call {call_id} has no incident_type — opening a "
|
||||||
|
f"{resolved_type!r} incident (severity={call_severity}, tags={tags})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Severity, not type, decides whether a call is incident-worthy.
|
decision = {
|
||||||
#
|
"action": action,
|
||||||
# Requiring a concrete incident_type here meant a channel whose traffic never
|
"matched_incident": matched_incident,
|
||||||
# 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,
|
||||||
|
|||||||
@@ -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,70 +791,122 @@ 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",
|
return None
|
||||||
params=params,
|
location_type = result.get("geometry", {}).get("location_type", "")
|
||||||
)
|
# Reject only APPROXIMATE — a region/city boundary centroid, which is
|
||||||
r.raise_for_status()
|
# what an ungeocodable string degrades to and is genuinely useless.
|
||||||
data = r.json()
|
#
|
||||||
if data.get("status") != "OK" or not data.get("results"):
|
# ROOFTOP-only was too strict and emptied the map: dispatch names
|
||||||
return None
|
# places the way people speak, and Google returns GEOMETRIC_CENTER for
|
||||||
result = data["results"][0]
|
# exactly those forms — intersections ("Lake Street and Veterans
|
||||||
location_type = result.get("geometry", {}).get("location_type", "")
|
# Memorial Drive") and named POIs ("Brewster Station"). Both are
|
||||||
# Reject only APPROXIMATE — a region/city boundary centroid, which is
|
# precise enough to plot and to proximity-match; requiring a street
|
||||||
# what an ungeocodable string degrades to and is genuinely useless.
|
# address threw away nearly every real dispatch location, leaving only
|
||||||
#
|
# numbered addresses geocoded.
|
||||||
# ROOFTOP-only was too strict and emptied the map: dispatch names
|
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
|
||||||
# places the way people speak, and Google returns GEOMETRIC_CENTER for
|
|
||||||
# exactly those forms — intersections ("Lake Street and Veterans
|
|
||||||
# Memorial Drive") and named POIs ("Brewster Station"). Both are
|
|
||||||
# precise enough to plot and to proximity-match; requiring a street
|
|
||||||
# address threw away nearly every real dispatch location, leaving only
|
|
||||||
# numbered addresses geocoded.
|
|
||||||
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
|
|
||||||
logger.info(
|
|
||||||
f"Geocoding rejected '{location_str}' — imprecise result "
|
|
||||||
f"(location_type={location_type!r}), returning None"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
loc = result["geometry"]["location"]
|
|
||||||
lat, lng = float(loc["lat"]), float(loc["lng"])
|
|
||||||
dist_km = _geo_dist_km(ref_lat, ref_lon, lat, lng)
|
|
||||||
if dist_km > max_km:
|
|
||||||
# server-26#159: the node-distance bound is a proxy for "is
|
|
||||||
# this plausible" that only makes sense when the node's own
|
|
||||||
# position is our best guess at the area — never when the
|
|
||||||
# query already names a different region on its own terms.
|
|
||||||
if not (ref_label == "node" and trust_named_region):
|
|
||||||
logger.warning(
|
|
||||||
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
|
|
||||||
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
logger.info(
|
|
||||||
f"Geocoding '{location_str}' → ({lat:.4f}, {lng:.4f}) is "
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
coords = {"lat": lat, "lng": lng}
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Geocoded '{location_str}' → {coords} "
|
f"Geocoding rejected '{location_str}' — imprecise result "
|
||||||
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
|
f"(location_type={location_type!r}), returning None"
|
||||||
)
|
)
|
||||||
return coords
|
return None
|
||||||
|
loc = result["geometry"]["location"]
|
||||||
|
lat, lng = float(loc["lat"]), float(loc["lng"])
|
||||||
|
dist_km = _geo_dist_km(ref_lat, ref_lon, lat, lng)
|
||||||
|
if dist_km > max_km:
|
||||||
|
# server-26#159: the node-distance bound is a proxy for "is
|
||||||
|
# this plausible" that only makes sense when the node's own
|
||||||
|
# position is our best guess at the area — never when the
|
||||||
|
# query already names a different region on its own terms.
|
||||||
|
if not (ref_label == "node" and trust_named_region):
|
||||||
|
logger.warning(
|
||||||
|
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
|
||||||
|
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
logger.info(
|
||||||
|
f"Geocoding '{location_str}' → ({lat:.4f}, {lng:.4f}) is "
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
coords = {"lat": lat, "lng": lng, "location_type": location_type}
|
||||||
|
logger.info(
|
||||||
|
f"Geocoded '{location_str}' → {coords} "
|
||||||
|
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
|
||||||
|
)
|
||||||
|
return coords
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
||||||
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}")
|
||||||
|
|||||||
@@ -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 "{}")
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ 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"),
|
||||||
"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 +163,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"):
|
||||||
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:
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -100,31 +100,11 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
|
|||||||
return gcs_uri
|
return gcs_uri
|
||||||
|
|
||||||
|
|
||||||
async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
|
||||||
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
|
|
||||||
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
|
||||||
if not bucket_name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _download() -> bytes:
|
|
||||||
from google.cloud import storage
|
|
||||||
if settings.gcp_credentials_path:
|
|
||||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
|
||||||
else:
|
|
||||||
client = storage.Client()
|
|
||||||
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await asyncio.to_thread(_download)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
|
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
|
||||||
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object
|
# A 600 s FLAC call is ~13 MB. /media used to pull the whole object into
|
||||||
# into memory, so /media used to make the browser wait for all of it before
|
# memory (the old download_audio(), removed once nothing called it — #225 S8),
|
||||||
# the first sample played. These two read only what a request asks for: the
|
# so the browser waited for all of it before the first sample played. These
|
||||||
|
# two read only what a request asks for: the
|
||||||
# object size (one metadata GET) and then an inclusive byte span, which the
|
# object size (one metadata GET) and then an inclusive byte span, which the
|
||||||
# router streams out in chunks. One cached client, because the router issues
|
# router streams out in chunks. One cached client, because the router issues
|
||||||
# several span reads per playback and a fresh Client() per read re-resolves
|
# several span reads per playback and a fresh Client() per read re-resolves
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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:
|
||||||
|
context = await transcript_correction.resolve_context(system_id, talkgroup_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not resolve STT vocabulary for call {call_id}: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transcript, segments, degenerate = await asyncio.to_thread(
|
tmp_path = await asyncio.to_thread(_download, gcs_uri)
|
||||||
_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)
|
# 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,77 +594,116 @@ 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:
|
||||||
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).
|
|
||||||
# gpt-4o-transcribe and gpt-4o-mini-transcribe only support json/text.
|
|
||||||
use_verbose = settings.stt_model == "whisper-1"
|
|
||||||
|
|
||||||
# Own timeout, not openai_timeout_seconds: this request uploads the
|
|
||||||
# whole recording (server-26#153).
|
|
||||||
stt_client = openai_client(timeout=settings.stt_timeout_seconds)
|
|
||||||
with open(tmp_path, "rb") as f:
|
|
||||||
response = stt_client.audio.transcriptions.create(
|
|
||||||
model=settings.stt_model,
|
|
||||||
file=f,
|
|
||||||
language="en",
|
|
||||||
prompt=prompt,
|
|
||||||
response_format="verbose_json" if use_verbose else "json",
|
|
||||||
temperature=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if use_verbose:
|
|
||||||
# Filter hallucinated segments. Two sources of hallucination in P25 recordings:
|
|
||||||
#
|
|
||||||
# 1. Trailing silence / static — Whisper fills silence past real content with
|
|
||||||
# sequential radio codes (10-4, 10-5...). Clamped by audio duration.
|
|
||||||
#
|
|
||||||
# 2. Leading silence — OP25 recordings typically have a short silence at the
|
|
||||||
# start before the first PTT press. Whisper sometimes hallucinates filler
|
|
||||||
# words or codes over this silence. Detected via no_speech_prob > 0.8
|
|
||||||
# (Whisper's own confidence that a segment contains no real speech).
|
|
||||||
audio_duration: float = getattr(response, "duration", None) or float("inf")
|
|
||||||
segments = [
|
|
||||||
{"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text.strip()}
|
|
||||||
for s in (response.segments or [])
|
|
||||||
if s.text.strip()
|
|
||||||
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
|
|
||||||
# in sync. If every segment was filtered, text becomes None which prevents
|
|
||||||
# the intelligence pipeline from running on hallucinated content.
|
|
||||||
text = " ".join(s["text"] for s in segments) or None
|
|
||||||
if _is_degenerate(text or "", segments):
|
|
||||||
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
|
||||||
return None, [], True
|
|
||||||
return text, segments, False
|
|
||||||
else:
|
|
||||||
# json format returns just {"text": "..."} — no segments or timestamps.
|
|
||||||
# Intelligence extraction falls back to treating the whole transcript as one block.
|
|
||||||
text = (response.text or "").strip() or None
|
|
||||||
if _is_degenerate(text or "", []):
|
|
||||||
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
|
||||||
return None, [], True
|
|
||||||
return text, [], False
|
|
||||||
finally:
|
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
raise
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _segment_confidence(raw_segments: list) -> Optional[float]:
|
||||||
|
"""exp(avg_logprob) per segment, weighted by segment length; None if absent."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_transcribe_file(
|
||||||
|
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:
|
||||||
|
response = client.audio.transcriptions.create(
|
||||||
|
model=target.model,
|
||||||
|
file=f,
|
||||||
|
language="en",
|
||||||
|
prompt=prompt,
|
||||||
|
response_format="verbose_json" if use_verbose else "json",
|
||||||
|
temperature=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_verbose:
|
||||||
|
# Filter hallucinated segments. Two sources of hallucination in P25 recordings:
|
||||||
|
#
|
||||||
|
# 1. Trailing silence / static — Whisper fills silence past real content with
|
||||||
|
# sequential radio codes (10-4, 10-5...). Clamped by audio duration.
|
||||||
|
#
|
||||||
|
# 2. Leading silence — OP25 recordings typically have a short silence at the
|
||||||
|
# start before the first PTT press. Whisper sometimes hallucinates filler
|
||||||
|
# words or codes over this silence. Detected via no_speech_prob > 0.8
|
||||||
|
# (Whisper's own confidence that a segment contains no real speech).
|
||||||
|
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 = [
|
||||||
|
{"start": round(_seg(s, "start") or 0.0, 2), "end": round(_seg(s, "end") or 0.0, 2),
|
||||||
|
"text": _seg(s, "text").strip()}
|
||||||
|
for s in kept
|
||||||
|
]
|
||||||
|
# Reconstruct text from non-hallucinated segments only so the two stay
|
||||||
|
# in sync. If every segment was filtered, text becomes None which prevents
|
||||||
|
# the intelligence pipeline from running on hallucinated content.
|
||||||
|
text = " ".join(s["text"] for s in segments) or None
|
||||||
|
base = {"duration": duration, "confidence": _segment_confidence([_SegView(s) for s in kept])}
|
||||||
|
if _is_degenerate(text or "", segments, glossary):
|
||||||
|
logger.info(f"Discarded hallucinated transcript from {target.provider}: {(text or '')[:80]!r}")
|
||||||
|
return {"text": None, "segments": [], "degenerate": True, **base}
|
||||||
|
return {"text": text, "segments": segments, "degenerate": False, **base}
|
||||||
|
|
||||||
|
# json format returns just {"text": "..."} — no segments or timestamps.
|
||||||
|
# Intelligence extraction falls back to treating the whole transcript as one block.
|
||||||
|
text = (response.text or "").strip() or None
|
||||||
|
if _is_degenerate(text or "", [], glossary):
|
||||||
|
logger.info(f"Discarded hallucinated transcript from {target.provider}: {(text or '')[:80]!r}")
|
||||||
|
return {"text": None, "segments": [], "degenerate": True, "duration": None, "confidence": None}
|
||||||
|
return {"text": text, "segments": [], "degenerate": False, "duration": None, "confidence": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _seg(s, field: str):
|
||||||
|
"""Segment field from an SDK object or a plain dict (OpenAI-compatible
|
||||||
|
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")
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -269,6 +269,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
|||||||
Radio bots come from ``GET /tokens``; no token string is returned here.
|
Radio bots come from ``GET /tokens``; no token string is returned here.
|
||||||
Discord user ids (``created_by_discord_id``) and the linking admin's uid
|
Discord user ids (``created_by_discord_id``) and the linking admin's uid
|
||||||
are deliberately left out.
|
are deliberately left out.
|
||||||
|
|
||||||
|
Per server, ``main_bot_present`` (the DRB server bot — optional since
|
||||||
|
server-26#225: True/False as it last reported, None = never heard from it
|
||||||
|
there) and ``radio_bots`` (names of radio bots Discord last said are in
|
||||||
|
it). A server with radio bots and no server bot is a normal setup.
|
||||||
"""
|
"""
|
||||||
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
|
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
|
||||||
if g.get("guild_id")}
|
if g.get("guild_id")}
|
||||||
@@ -284,6 +289,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
|||||||
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
|
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
|
||||||
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
|
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
|
||||||
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
|
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
|
||||||
|
"linked_via": link.get("linked_via"),
|
||||||
|
# The DRB server bot is optional (server-26#225 S8): True/False
|
||||||
|
# = it said it is / is not in the server, None = never heard.
|
||||||
|
"main_bot_present": link.get("bot_present"),
|
||||||
|
"directory_source": link.get("directory_source"),
|
||||||
# Explicitly unlinked servers get no fallback org (mirrors
|
# Explicitly unlinked servers get no fallback org (mirrors
|
||||||
# streams.resolve_guild_org once server-26#218 lands).
|
# streams.resolve_guild_org once server-26#218 lands).
|
||||||
"_no_fallback": bool(link.get("unlinked_at"))}
|
"_no_fallback": bool(link.get("unlinked_at"))}
|
||||||
@@ -291,7 +301,8 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
|||||||
for s in all_streams:
|
for s in all_streams:
|
||||||
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
|
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
|
||||||
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
|
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
|
||||||
"org_id": None, "linked_at": None})
|
"org_id": None, "linked_at": None, "linked_via": None,
|
||||||
|
"main_bot_present": None, "directory_source": None})
|
||||||
if s.get("guild_name") and not g.get("guild_name"):
|
if s.get("guild_name") and not g.get("guild_name"):
|
||||||
g["guild_name"] = s["guild_name"]
|
g["guild_name"] = s["guild_name"]
|
||||||
node = nodes.get(s.get("node_id")) or {}
|
node = nodes.get(s.get("node_id")) or {}
|
||||||
@@ -305,6 +316,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
|||||||
g["acting_org_id"] = acting
|
g["acting_org_id"] = acting
|
||||||
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
|
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
|
||||||
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
|
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
|
||||||
|
# Radio bots Discord last said are in the server (names only). Any one
|
||||||
|
# of them is enough to list channels, check permissions and stream.
|
||||||
|
g["radio_bots"] = sorted(str(t.get("name") or tid) for tid, t in tokens.items()
|
||||||
|
if t.get("valid") is not False and isinstance(t.get("guild_ids"), list)
|
||||||
|
and g["guild_id"] in t["guild_ids"])
|
||||||
|
|
||||||
# Streams sharing a voice channel sit together, oldest first.
|
# Streams sharing a voice channel sit together, oldest first.
|
||||||
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
||||||
|
|||||||
@@ -21,9 +21,20 @@ Where the directory comes from (server-26#225 S5 — the main bot is optional):
|
|||||||
stored list is missing (inline, inside the permission-check time budget)
|
stored list is missing (inline, inside the permission-check time budget)
|
||||||
or older than ``discord_directory_refresh_seconds`` (in the background).
|
or older than ``discord_directory_refresh_seconds`` (in the background).
|
||||||
|
|
||||||
So C2 does call Discord when a signed-in user opens the picker — with a
|
This reverses the old rule "C2 never calls Discord on the browser's behalf"
|
||||||
radio bot's token, for a server already linked to the caller's org, through
|
on purpose (server-26#225 plan finding 5): C2 does call Discord when a
|
||||||
the rate-limited, cached client. No token string ever reaches the browser.
|
signed-in user opens the picker, presses Start or Re-check — with a radio
|
||||||
|
bot's token, only for a server already linked to the caller's org, through
|
||||||
|
discord_rest's rate-limited, cached, breaker-guarded client (refresh=1 at
|
||||||
|
most once per 10 s per user and server). No token string ever reaches the
|
||||||
|
browser, and REST answers never create or re-link a ``discord_guilds`` doc.
|
||||||
|
|
||||||
|
The DRB server bot is optional end to end (server-26#225 S8). One radio bot
|
||||||
|
in the server is enough to link it (per-bot OAuth, routers/discord_oauth.py),
|
||||||
|
list its voice channels (here), check permissions (routers/discord_checks.py)
|
||||||
|
and stream (streams.start_stream). The server bot adds its slash commands
|
||||||
|
(/join, /leave, /status, /drb link) and a gateway-fed channel list; every
|
||||||
|
path it uses keeps working.
|
||||||
|
|
||||||
Which servers an org sees (``_org_guilds``):
|
Which servers an org sees (``_org_guilds``):
|
||||||
|
|
||||||
|
|||||||
+252
-252
@@ -1,4 +1,5 @@
|
|||||||
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
|
||||||
@@ -66,6 +67,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:
|
||||||
@@ -112,99 +131,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 +198,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
|
||||||
|
|
||||||
@@ -247,68 +226,119 @@ async def _correlate_with_consensus(
|
|||||||
embedding=embedding, severity=severity, transcript=transcript,
|
embedding=embedding, severity=severity, transcript=transcript,
|
||||||
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.
|
if llm is not None:
|
||||||
# When the cheap LLM says `orphan`, the rules engine says `new`, and the call
|
final["corr_debug"]["corr_llm_action"] = llm["action"]
|
||||||
# is genuinely substanceless (routine severity, no vehicle/geocode/tag, and
|
final["corr_debug"]["corr_llm_reasoning"] = llm.get("reasoning", "")
|
||||||
# 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})
|
|
||||||
|
|
||||||
# Disagree — escalate to the smarter tiebreaker.
|
|
||||||
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 +365,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 +383,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 +424,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 +437,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 +454,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 +468,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 +483,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 +574,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,15 +584,27 @@ 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"):
|
||||||
incident_ids, all_tags, _ = await _extract_and_correlate(
|
# server-26#301: a late upload (the node spools and retries for up to
|
||||||
call_id=call_id,
|
# 72h) is correlated as of when the call happened, not as of now — the
|
||||||
node_id=node_id,
|
# same clock pin replay uses, so every recency gate and every
|
||||||
system_id=system_id,
|
# started_at/updated_at/resolved_at it writes is on the call's own
|
||||||
talkgroup_id=talkgroup_id,
|
# timeline. Without it a call hours late linked into whatever incident
|
||||||
talkgroup_name=talkgroup_name,
|
# was live on its talkgroup now, or opened one at the wrong time.
|
||||||
transcript=transcript,
|
anchor = _late_upload_anchor(_call_doc)
|
||||||
segments=segments,
|
tok = clock.pin(anchor) if anchor else None
|
||||||
)
|
try:
|
||||||
|
incident_ids, all_tags, _ = await _extract_and_correlate(
|
||||||
|
call_id=call_id,
|
||||||
|
node_id=node_id,
|
||||||
|
system_id=system_id,
|
||||||
|
talkgroup_id=talkgroup_id,
|
||||||
|
talkgroup_name=talkgroup_name,
|
||||||
|
transcript=transcript,
|
||||||
|
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}")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""
|
||||||
|
The two owner setups from server-26#225, end to end (S8).
|
||||||
|
|
||||||
|
1. One-bot org: no DRB server bot anywhere. A platform admin adds the org's
|
||||||
|
own radio bot and its OAuth secret; the owner marks it preferred for the
|
||||||
|
org's system, connects the Discord server through that bot's invite,
|
||||||
|
opens the node page's picker and starts a stream.
|
||||||
|
2. Full setup: the DRB server bot is in the server (directory push, /drb
|
||||||
|
link, /join) beside several radio bots. C2 places each stream by
|
||||||
|
membership, Discord permissions on the voice channel, one voice channel
|
||||||
|
per bot per server and the system's preferred bot, lets several bots
|
||||||
|
share one voice channel, and explains when nothing fits.
|
||||||
|
|
||||||
|
Every route handler here is the real one, and so is every module under it
|
||||||
|
(discord_streams, discord_leases, discord_rest, discord_oauth,
|
||||||
|
discord_guild_link, discord_perms). Only the edges are fake: Firestore is
|
||||||
|
tests/fake_firestore.py, MQTT records what would be published, and Discord is
|
||||||
|
``DiscordWorld`` behind httpx.MockTransport — a small model of servers, roles,
|
||||||
|
channel overrides and bot applications that answers the same REST and OAuth2
|
||||||
|
calls Discord does.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_perms as perms
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
|
from app.routers import discord as discord_router
|
||||||
|
from app.routers import discord_checks as checks
|
||||||
|
from app.routers import discord_link as link_router
|
||||||
|
from app.routers import discord_oauth as oauth_router
|
||||||
|
from app.routers import discord_web as web
|
||||||
|
from app.routers import tokens as tokens_router
|
||||||
|
from tests.fake_firestore import FakeFirestore
|
||||||
|
|
||||||
|
ORG = "org-a"
|
||||||
|
VOICE = perms.VIEW_CHANNEL | perms.CONNECT | perms.SPEAK
|
||||||
|
REDIRECT = "https://drb.example.test/discord/connected"
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "admin@example.test", "role": "admin", "org_id": "founding"}
|
||||||
|
OWNER = {"uid": "owner-a", "email": "owner@example.test", "org_id": ORG, "org_role": "owner"}
|
||||||
|
SERVICE = {"service": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A small Discord
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BotApp:
|
||||||
|
name: str
|
||||||
|
token: str
|
||||||
|
user_id: str
|
||||||
|
app_id: str
|
||||||
|
secret: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Guild:
|
||||||
|
gid: str
|
||||||
|
name: str
|
||||||
|
roles: dict = field(default_factory=dict) # role id -> permission int (@everyone id = gid)
|
||||||
|
role_names: dict = field(default_factory=dict)
|
||||||
|
channels: list = field(default_factory=list)
|
||||||
|
members: dict = field(default_factory=dict) # user id -> [role ids]
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordWorld:
|
||||||
|
"""Answers Discord REST (``Authorization: Bot …``) and OAuth2
|
||||||
|
(``Authorization: Basic client_id:secret``) for the bots and servers in
|
||||||
|
it. Adding a bot through the OAuth code exchange makes it a member, like
|
||||||
|
the real "Authorize" click does."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bots: dict[str, BotApp] = {} # token -> app
|
||||||
|
self.guilds: dict[str, Guild] = {}
|
||||||
|
self.pending: dict[str, tuple] = {} # oauth code -> (app_id, guild_id)
|
||||||
|
self.requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
# --- world building ---------------------------------------------------
|
||||||
|
def bot(self, name: str, n: int) -> BotApp:
|
||||||
|
b = BotApp(name=name, token=f"bot-token-{n}", user_id=f"9000{n}", app_id=f"7000{n}",
|
||||||
|
secret=f"client-secret-{n}-0123456789abcdef")
|
||||||
|
self.bots[b.token] = b
|
||||||
|
return b
|
||||||
|
|
||||||
|
def guild(self, gid: str, name: str) -> Guild:
|
||||||
|
g = Guild(gid=gid, name=name, roles={gid: VOICE}, role_names={gid: "@everyone"})
|
||||||
|
self.guilds[gid] = g
|
||||||
|
return g
|
||||||
|
|
||||||
|
def authorize(self, app: BotApp, gid: str) -> str:
|
||||||
|
"""The owner clicks Authorize in Discord for ``gid``: Discord sends
|
||||||
|
the browser back with a code."""
|
||||||
|
code = f"code-{app.app_id}-{gid}"
|
||||||
|
self.pending[code] = (app.app_id, gid)
|
||||||
|
return code
|
||||||
|
|
||||||
|
def auth_kinds(self) -> list[str]:
|
||||||
|
return [r.headers.get("authorization", "").split(" ", 1)[0] for r in self.requests]
|
||||||
|
|
||||||
|
def bot_tokens_used(self) -> set[str]:
|
||||||
|
return {r.headers["authorization"].split(" ", 1)[1] for r in self.requests
|
||||||
|
if r.headers.get("authorization", "").startswith("Bot ")}
|
||||||
|
|
||||||
|
# --- transport --------------------------------------------------------
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
self.requests.append(request)
|
||||||
|
path = request.url.path.removeprefix("/api/v10")
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if path.startswith("/oauth2/token"):
|
||||||
|
return self._oauth(request, path, auth)
|
||||||
|
if not auth.startswith("Bot "):
|
||||||
|
return httpx.Response(401, json={"code": 0, "message": "401: Unauthorized"})
|
||||||
|
app = self.bots.get(auth[4:])
|
||||||
|
if app is None:
|
||||||
|
return httpx.Response(401, json={"code": 0, "message": "401: Unauthorized"})
|
||||||
|
return self._rest(app, request, path)
|
||||||
|
|
||||||
|
def _oauth(self, request: httpx.Request, path: str, auth: str) -> httpx.Response:
|
||||||
|
if not auth.startswith("Basic "):
|
||||||
|
return httpx.Response(401, json={"error": "invalid_client"})
|
||||||
|
client_id, _, secret = base64.b64decode(auth[6:]).decode().partition(":")
|
||||||
|
app = next((a for a in self.bots.values() if a.app_id == client_id), None)
|
||||||
|
if app is None or app.secret != secret:
|
||||||
|
return httpx.Response(401, json={"error": "invalid_client"})
|
||||||
|
if path == "/oauth2/token/revoke":
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||||
|
if form.get("grant_type") == "client_credentials":
|
||||||
|
return httpx.Response(200, json={"access_token": "cc-token", "token_type": "Bearer"})
|
||||||
|
if form.get("grant_type") != "authorization_code" or form.get("redirect_uri") != REDIRECT:
|
||||||
|
return httpx.Response(400, json={"error": "invalid_grant"})
|
||||||
|
pending = self.pending.pop(form.get("code"), None)
|
||||||
|
if not pending or pending[0] != client_id:
|
||||||
|
return httpx.Response(400, json={"error": "invalid_grant"})
|
||||||
|
g = self.guilds[pending[1]]
|
||||||
|
g.members.setdefault(app.user_id, []) # the bot joins the server
|
||||||
|
return httpx.Response(200, json={"access_token": "user-access-token", "token_type": "Bearer",
|
||||||
|
"scope": "bot", "guild": {"id": g.gid, "name": g.name}})
|
||||||
|
|
||||||
|
def _rest(self, app: BotApp, request: httpx.Request, path: str) -> httpx.Response:
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
if path == "/users/@me":
|
||||||
|
return httpx.Response(200, json={"id": app.user_id, "username": app.name, "bot": True})
|
||||||
|
if path == "/oauth2/applications/@me":
|
||||||
|
return httpx.Response(200, json={"id": app.app_id, "name": app.name})
|
||||||
|
if path == "/users/@me/guilds":
|
||||||
|
after = int(request.url.params.get("after") or 0)
|
||||||
|
mine = sorted((g for g in self.guilds.values() if app.user_id in g.members), key=lambda g: int(g.gid))
|
||||||
|
return httpx.Response(200, json=[{"id": g.gid, "name": g.name} for g in mine if int(g.gid) > after])
|
||||||
|
if parts[0] == "guilds" and len(parts) >= 2:
|
||||||
|
g = self.guilds.get(parts[1])
|
||||||
|
if g is None or app.user_id not in g.members:
|
||||||
|
return httpx.Response(404, json={"code": discord_rest.UNKNOWN_GUILD, "message": "Unknown Guild"})
|
||||||
|
if len(parts) == 2:
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"id": g.gid, "name": g.name, "owner_id": "human-owner",
|
||||||
|
"roles": [{"id": rid, "name": g.role_names.get(rid, rid), "permissions": str(p)}
|
||||||
|
for rid, p in g.roles.items()]})
|
||||||
|
if parts[2] == "members" and len(parts) == 4:
|
||||||
|
if parts[3] not in g.members:
|
||||||
|
return httpx.Response(404, json={"code": discord_rest.UNKNOWN_MEMBER})
|
||||||
|
return httpx.Response(200, json={"user": {"id": parts[3]}, "roles": g.members[parts[3]]})
|
||||||
|
if parts[2] == "channels":
|
||||||
|
return httpx.Response(200, json=g.channels)
|
||||||
|
return httpx.Response(404, json={"code": 0, "message": "404: Not Found"})
|
||||||
|
|
||||||
|
|
||||||
|
def voice(cid: str, name: str, position: int, *overwrites: dict, parent: Optional[str] = None) -> dict:
|
||||||
|
return {"id": cid, "name": name, "type": 2, "position": position, "user_limit": 0,
|
||||||
|
"parent_id": parent, "permission_overwrites": list(overwrites)}
|
||||||
|
|
||||||
|
|
||||||
|
def deny(role_id: str, bits: int) -> dict:
|
||||||
|
return {"id": role_id, "type": 0, "allow": "0", "deny": str(bits)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture: fake Firestore + MQTT + Discord, one org
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(monkeypatch):
|
||||||
|
fake = FakeFirestore.install(monkeypatch)
|
||||||
|
world = DiscordWorld()
|
||||||
|
discord_rest.reset()
|
||||||
|
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(world))
|
||||||
|
published: list = []
|
||||||
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
|
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", lambda n, p: published.append((n, p)) or True)
|
||||||
|
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", REDIRECT)
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||||
|
monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
|
||||||
|
streams._publish_locks.clear()
|
||||||
|
streams._guild_locks.clear()
|
||||||
|
checks._last_refresh.clear()
|
||||||
|
web._directory_tasks.clear()
|
||||||
|
for lim in (oauth_router.start_limiter, oauth_router.complete_limiter, link_router.mint_limiter,
|
||||||
|
link_router.redeem_guild_limiter, link_router.redeem_user_limiter):
|
||||||
|
lim._log.clear()
|
||||||
|
fake.put("organizations", ORG, {"org_id": ORG, "name": "Riverton"})
|
||||||
|
fake.put("org_members", OWNER["uid"], {"uid": OWNER["uid"], "org_id": ORG, "org_role": "owner"})
|
||||||
|
yield fake, world, published
|
||||||
|
discord_rest.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def _node(fake, nid: str, system_id: str):
|
||||||
|
fake.put("nodes", nid, {"node_id": nid, "name": f"Node {nid}", "org_id": ORG, "status": "online",
|
||||||
|
"approval_status": "approved", "assigned_system_id": system_id})
|
||||||
|
|
||||||
|
|
||||||
|
def _body(resp):
|
||||||
|
return json.loads(resp.body) if hasattr(resp, "body") else resp
|
||||||
|
|
||||||
|
|
||||||
|
def _status(resp):
|
||||||
|
return getattr(resp, "status_code", 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_secrets(world: DiscordWorld, *payloads) -> None:
|
||||||
|
text = json.dumps(payloads, default=str)
|
||||||
|
for app in world.bots.values():
|
||||||
|
assert app.token not in text and app.secret not in text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. One-bot org — no DRB server bot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_one_bot_org_links_lists_checks_and_streams_without_the_server_bot(env):
|
||||||
|
fake, world, published = env
|
||||||
|
fake.put("systems", "sys-a", {"system_id": "sys-a", "name": "Riverton P25", "org_id": ORG})
|
||||||
|
_node(fake, "n1", "sys-a")
|
||||||
|
fake.put("node_keys", "n1", {"api_key": "node-key-n1"})
|
||||||
|
g = world.guild("400000000000000001", "Riverton FD")
|
||||||
|
g.channels = [
|
||||||
|
{"id": "400000000000000010", "name": "Radio", "type": 4, "position": 0, "permission_overwrites": []},
|
||||||
|
voice("400000000000000011", "dispatch", 0, parent="400000000000000010"),
|
||||||
|
{"id": "400000000000000012", "name": "general", "type": 0, "position": 1, "permission_overwrites": []},
|
||||||
|
]
|
||||||
|
riverton = world.bot("Riverton Radio", 1)
|
||||||
|
# A pool bot exists too (no OAuth secret, not in the server): never chosen here.
|
||||||
|
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "pool-token",
|
||||||
|
"org_id": None, "application_id": "60001", "guild_ids": [], "valid": True,
|
||||||
|
"lease": None})
|
||||||
|
|
||||||
|
# Platform admin: add the org's own bot, then its OAuth client secret.
|
||||||
|
added = await tokens_router.add_token(
|
||||||
|
tokens_router.TokenCreate(name="Riverton Radio", token=riverton.token, org_id=ORG), ADMIN)
|
||||||
|
tid = added["token_id"]
|
||||||
|
assert added["valid"] is True and added["guild_ids"] == []
|
||||||
|
out = await tokens_router.set_oauth_secret(tid, tokens_router.OAuthSecret(client_secret=riverton.secret), ADMIN)
|
||||||
|
assert out["oauth_enabled"] is True
|
||||||
|
|
||||||
|
# Owner (Q2): their own bot, preferred for their own system.
|
||||||
|
await tokens_router.set_preferred_system(tid, "sys-a", OWNER)
|
||||||
|
assert fake.get("systems", "sys-a")["preferred_token_id"] == tid
|
||||||
|
|
||||||
|
# Owner: Connect a Discord server from the node — the preferred bot is invited.
|
||||||
|
opts = await oauth_router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER)
|
||||||
|
assert opts["bot"] == {"name": "Riverton Radio", "reason": "preferred", "own_bot": True}
|
||||||
|
start = await oauth_router.oauth_start(oauth_router.StartBody(node_id="n1", return_to="/nodes/n1"),
|
||||||
|
org_id=None, decoded=OWNER)
|
||||||
|
q = {k: v[0] for k, v in parse_qs(urlparse(start["authorize_url"]).query).items()}
|
||||||
|
assert q["client_id"] == riverton.app_id and q["scope"] == "bot" and q["redirect_uri"] == REDIRECT
|
||||||
|
|
||||||
|
# In Discord: pick Riverton FD, Authorize. Discord sends the browser back.
|
||||||
|
code = world.authorize(riverton, g.gid)
|
||||||
|
done = _body(await oauth_router.oauth_complete(oauth_router.CompleteBody(code=code, state=q["state"]),
|
||||||
|
decoded=OWNER))
|
||||||
|
assert done["ok"] is True and done["guild_id"] == g.gid and done["guild_name"] == "Riverton FD"
|
||||||
|
assert done["return_to"] == "/nodes/n1" and done["bot_name"] == "Riverton Radio"
|
||||||
|
# Permissions were checked in the same request, from the radio bot's view.
|
||||||
|
assert done["checked"] is True and done["channels_ready"] == 1 and done["channels_blocked"] == 0
|
||||||
|
assert [c["name"] for c in done["channels"]] == ["dispatch"]
|
||||||
|
|
||||||
|
link = fake.get("discord_guilds", g.gid)
|
||||||
|
assert link["org_id"] == ORG and link["linked_via"] == "oauth"
|
||||||
|
assert "bot_present" not in link # the server bot never said anything
|
||||||
|
assert g.gid in fake.get("bot_tokens", tid)["guild_ids"]
|
||||||
|
|
||||||
|
# Node page: the server is listed, its channels read through the radio bot.
|
||||||
|
opts = await web.join_options("n1", OWNER)
|
||||||
|
[gopt] = opts["guilds"]
|
||||||
|
assert gopt["guild_id"] == g.gid and gopt["linked"] is True
|
||||||
|
assert gopt["main_bot_present"] is False and gopt["directory_source"] == "radio_bot"
|
||||||
|
assert [c["name"] for c in gopt["voice_channels"]] == ["dispatch"]
|
||||||
|
assert gopt["bot"]["ok"] is True
|
||||||
|
vc = await checks.node_voice_check("n1", g.gid, refresh=0, channel_id=None, decoded=OWNER)
|
||||||
|
[row] = vc["channels"]
|
||||||
|
assert row["state"] == "ready" and [b["name"] for b in row["bots"]] == ["Riverton Radio"]
|
||||||
|
|
||||||
|
# Start: the preferred bot carries it, on this node.
|
||||||
|
started = await web.start_node_stream(
|
||||||
|
"n1", web.NodeStreamStart(guild_id=g.gid, channel_id="400000000000000011", mode="persistent"), OWNER)
|
||||||
|
assert _status(started) == 200, _body(started)
|
||||||
|
assert started["token_id"] == tid and started["delivered"] is True
|
||||||
|
stream = fake.get("discord_streams", started["stream_id"])
|
||||||
|
assert stream["node_id"] == "n1" and stream["guild_name"] == "Riverton FD"
|
||||||
|
assert stream["channel_name"] == "dispatch" and stream["mode"] == "persistent"
|
||||||
|
node_id, payload = published[-1]
|
||||||
|
assert node_id == "n1"
|
||||||
|
assert [(s["stream_id"], s["token_id"]) for s in payload["streams"]] == [(started["stream_id"], tid)]
|
||||||
|
|
||||||
|
# The node picks the token string up with its own key — the only place it leaves C2.
|
||||||
|
got = await discord_router.node_token(
|
||||||
|
"n1", tid, HTTPAuthorizationCredentials(scheme="Bearer", credentials="node-key-n1"))
|
||||||
|
assert got["token"] == riverton.token
|
||||||
|
|
||||||
|
# Nothing but this org's radio bot ever talked to Discord, and nothing a
|
||||||
|
# browser saw carries a token or a client secret.
|
||||||
|
assert world.bot_tokens_used() == {riverton.token}
|
||||||
|
assert set(world.auth_kinds()) <= {"Bot", "Basic"}
|
||||||
|
_no_secrets(world, opts, vc, done, started, start, await tokens_router.list_tokens(ADMIN))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_one_bot_org_reconciler_replaces_the_stream_without_the_server_bot(env):
|
||||||
|
"""Persistent stream, node lost: the reconciler re-places it on another
|
||||||
|
node of the same system with the same radio bot — no server bot involved."""
|
||||||
|
fake, world, published = env
|
||||||
|
fake.put("systems", "sys-a", {"system_id": "sys-a", "name": "Riverton P25", "org_id": ORG})
|
||||||
|
_node(fake, "n1", "sys-a")
|
||||||
|
_node(fake, "n2", "sys-a")
|
||||||
|
g = world.guild("400000000000000001", "Riverton FD")
|
||||||
|
g.channels = [voice("400000000000000011", "dispatch", 0)]
|
||||||
|
riverton = world.bot("Riverton Radio", 1)
|
||||||
|
g.members[riverton.user_id] = []
|
||||||
|
fake.put("bot_tokens", "own", {"token_id": "own", "name": "Riverton Radio", "token": riverton.token,
|
||||||
|
"org_id": ORG, "application_id": riverton.app_id, "bot_user_id": riverton.user_id,
|
||||||
|
"guild_ids": [g.gid], "valid": True, "lease": None})
|
||||||
|
fake.put("discord_guilds", g.gid, {"guild_id": g.gid, "org_id": ORG, "linked_via": "oauth"})
|
||||||
|
|
||||||
|
started = await web.start_node_stream(
|
||||||
|
"n1", web.NodeStreamStart(guild_id=g.gid, channel_id="400000000000000011", mode="persistent"), OWNER)
|
||||||
|
sid = started["stream_id"]
|
||||||
|
# n1 goes away for good (past the lease grace).
|
||||||
|
fake.put("nodes", "n1", {**fake.get("nodes", "n1"), "status": "offline",
|
||||||
|
"last_seen": "2026-01-01T00:00:00+00:00"})
|
||||||
|
await streams.reconcile_once()
|
||||||
|
s = fake.get("discord_streams", sid)
|
||||||
|
assert s["desired"] == "on" and s["node_id"] == "n2" and s["token_id"] == "own"
|
||||||
|
assert world.bot_tokens_used() == {riverton.token}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Full setup — DRB server bot + several radio bots
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GID = "500000000000000001"
|
||||||
|
DISPATCH, FIREGROUND, LOBBY = "500000000000000011", "500000000000000012", "500000000000000013"
|
||||||
|
MUTED = "500000000000000099"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def full(env):
|
||||||
|
fake, world, published = env
|
||||||
|
for sid, name in (("sys-a", "County P25"), ("sys-b", "City Fire"), ("sys-c", "EMS")):
|
||||||
|
fake.put("systems", sid, {"system_id": sid, "name": name, "org_id": ORG})
|
||||||
|
_node(fake, "n1", "sys-a")
|
||||||
|
_node(fake, "n2", "sys-b")
|
||||||
|
_node(fake, "n3", "sys-c")
|
||||||
|
g = world.guild(GID, "Station 7")
|
||||||
|
g.roles[MUTED], g.role_names[MUTED] = 0, "Muted"
|
||||||
|
g.channels = [
|
||||||
|
voice(DISPATCH, "dispatch", 0, deny(MUTED, perms.SPEAK)), # @Muted can't Speak here
|
||||||
|
voice(FIREGROUND, "fireground", 1, deny(GID, perms.CONNECT)), # nobody can Connect here
|
||||||
|
voice(LOBBY, "lobby", 2),
|
||||||
|
]
|
||||||
|
bots = {}
|
||||||
|
for n, (tid, name, org, member, roles) in enumerate((
|
||||||
|
("own-a", "Riverton Radio", ORG, True, [MUTED]),
|
||||||
|
("pool-1", "DRB Radio 1", None, True, []),
|
||||||
|
("pool-2", "DRB Radio 2", None, True, []),
|
||||||
|
("pool-3", "DRB Radio 3", None, False, []),
|
||||||
|
), start=1):
|
||||||
|
app = world.bot(name, n)
|
||||||
|
if member:
|
||||||
|
g.members[app.user_id] = roles
|
||||||
|
fake.put("bot_tokens", tid, {"token_id": tid, "name": name, "token": app.token, "org_id": org,
|
||||||
|
"application_id": app.app_id, "bot_user_id": app.user_id,
|
||||||
|
"guild_ids": [GID] if member else [], "valid": True, "lease": None})
|
||||||
|
bots[tid] = app
|
||||||
|
# A platform admin pinned DRB Radio 2 to City Fire.
|
||||||
|
fake.put("systems", "sys-b", {**fake.get("systems", "sys-b"), "preferred_token_id": "pool-2"})
|
||||||
|
fake.bots = bots
|
||||||
|
return fake, world, published
|
||||||
|
|
||||||
|
|
||||||
|
async def _server_bot_sets_up(fake):
|
||||||
|
"""The DRB server bot is in Station 7: it pushes its channel list, and
|
||||||
|
someone with Manage Server runs /drb link with the owner's code."""
|
||||||
|
await web.put_guild_directory(GID, web.GuildDirectory(name="Station 7", voice_channels=[
|
||||||
|
web.VoiceChannelIn(channel_id=DISPATCH, name="dispatch", category="Radio", position=0),
|
||||||
|
web.VoiceChannelIn(channel_id=FIREGROUND, name="fireground", category="Radio", position=1),
|
||||||
|
web.VoiceChannelIn(channel_id=LOBBY, name="lobby", category="Radio", position=2),
|
||||||
|
]), SERVICE)
|
||||||
|
minted = await link_router.create_link_code(org_id=None, decoded=OWNER)
|
||||||
|
out = _body(await link_router.redeem_link_code(
|
||||||
|
GID, link_router.RedeemBody(code=minted["code"], discord_user_id="123456", guild_name="Station 7"),
|
||||||
|
SERVICE))
|
||||||
|
assert out["ok"] is True and out["org_id"] == ORG
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_setup_places_each_stream_and_explains_every_refusal(full):
|
||||||
|
fake, world, published = full
|
||||||
|
await _server_bot_sets_up(fake)
|
||||||
|
link = fake.get("discord_guilds", GID)
|
||||||
|
assert link["org_id"] == ORG and link["bot_present"] is True and link["directory_source"] == "main_bot"
|
||||||
|
|
||||||
|
# Before anything plays: nobody may Connect in #fireground. Every member
|
||||||
|
# bot is named with what removed the permission; the bot that isn't in
|
||||||
|
# the server yet is offered as an invite.
|
||||||
|
r = await web.start_node_stream("n1", web.NodeStreamStart(guild_id=GID, channel_id=FIREGROUND), OWNER)
|
||||||
|
body = _body(r)
|
||||||
|
assert _status(r) == 409 and body["code"] == "missing_permissions"
|
||||||
|
assert sorted(d["name"] for d in body["denied"]) == ["DRB Radio 1", "DRB Radio 2", "Riverton Radio"]
|
||||||
|
assert all(d["missing"][0] == "Connect" for d in body["denied"])
|
||||||
|
assert body["detail"].startswith("No radio bot can play in #fireground:")
|
||||||
|
assert "#fireground's override for @everyone" in body["detail"]
|
||||||
|
assert [i["name"] for i in body["invite"]] == ["DRB Radio 3"]
|
||||||
|
assert fake.data.get("discord_streams") in (None, {})
|
||||||
|
|
||||||
|
# /join County P25 in #dispatch (DRB server bot): the org's own bot comes
|
||||||
|
# first but @Muted can't Speak there, so DRB Radio 1 takes it.
|
||||||
|
a = _body(await discord_router.start_stream(discord_router.StreamStart(
|
||||||
|
guild_id=GID, channel_id=DISPATCH, system_id="sys-a", channel_name="dispatch", created_by="111"), SERVICE))
|
||||||
|
assert a["token_id"] == "pool-1" and a["node_id"] == "n1"
|
||||||
|
|
||||||
|
# /join City Fire in the SAME channel: its preferred bot, beside the first.
|
||||||
|
b = _body(await discord_router.start_stream(discord_router.StreamStart(
|
||||||
|
guild_id=GID, channel_id=DISPATCH, system_id="sys-b", channel_name="dispatch", created_by="111"), SERVICE))
|
||||||
|
assert b["token_id"] == "pool-2" and b["node_id"] == "n2" and b["stream_id"] != a["stream_id"]
|
||||||
|
on_dispatch = [s for s in fake.data["discord_streams"].values()
|
||||||
|
if s["channel_id"] == DISPATCH and s["desired"] == "on"]
|
||||||
|
assert sorted(s["token_id"] for s in on_dispatch) == ["pool-1", "pool-2"]
|
||||||
|
|
||||||
|
# EMS on #dispatch from the web picker: both pool bots already hold a
|
||||||
|
# voice channel in this server, and the org's own bot is muted there.
|
||||||
|
vc = await checks.node_voice_check("n3", GID, refresh=0, channel_id=DISPATCH, decoded=OWNER)
|
||||||
|
[row] = vc["channels"]
|
||||||
|
assert row["state"] == "needs_fix"
|
||||||
|
by_name = {b["name"]: b for b in row["bots"]}
|
||||||
|
assert by_name["DRB Radio 1"]["unavailable_reason"] == "busy_here"
|
||||||
|
assert by_name["Riverton Radio"]["missing"] == ["Speak"]
|
||||||
|
assert "@Muted" in by_name["Riverton Radio"]["reason"]
|
||||||
|
r = await web.start_node_stream("n3", web.NodeStreamStart(guild_id=GID, channel_id=DISPATCH), OWNER)
|
||||||
|
body = _body(r)
|
||||||
|
assert _status(r) == 409 and body["code"] == "missing_permissions"
|
||||||
|
assert body["detail"].startswith("Riverton Radio can't Speak in #dispatch:")
|
||||||
|
assert "Edit Channel → Permissions → add Riverton Radio → allow Speak" in body["detail"]
|
||||||
|
|
||||||
|
# EMS on #lobby: nothing overrides @Muted there, so the org's own bot plays it.
|
||||||
|
c = await web.start_node_stream("n3", web.NodeStreamStart(guild_id=GID, channel_id=LOBBY), OWNER)
|
||||||
|
assert _status(c) == 200 and c["token_id"] == "own-a"
|
||||||
|
|
||||||
|
# Naming a bot that already holds a channel here is refused, not swapped.
|
||||||
|
r = await discord_router.start_stream(discord_router.StreamStart(
|
||||||
|
guild_id=GID, channel_id=LOBBY, system_id="sys-a", preferred_token_id="pool-1"), SERVICE)
|
||||||
|
body = _body(r)
|
||||||
|
assert _status(r) == 409 and body["code"] == "preferred_busy"
|
||||||
|
assert "DRB Radio 1 is already playing County P25 in #dispatch" in body["detail"]
|
||||||
|
|
||||||
|
# Each node was told only about its own stream, by token id.
|
||||||
|
last = {n: p for n, p in published}
|
||||||
|
assert {n: [s["token_id"] for s in p["streams"]] for n, p in last.items()} == {
|
||||||
|
"n1": ["pool-1"], "n2": ["pool-2"], "n3": ["own-a"]}
|
||||||
|
|
||||||
|
# /status and the admin page see both kinds of bot.
|
||||||
|
ov = await discord_router.guild_overview(GID, SERVICE)
|
||||||
|
assert ov["linked"] is True and len(ov["streams"]) == 3
|
||||||
|
adm = await discord_router.admin_overview(ADMIN)
|
||||||
|
[ga] = [x for x in adm["guilds"] if x["guild_id"] == GID]
|
||||||
|
assert ga["main_bot_present"] is True and ga["linked_via"] == "code"
|
||||||
|
assert ga["radio_bots"] == ["DRB Radio 1", "DRB Radio 2", "Riverton Radio"]
|
||||||
|
# Only radio bots ever called Discord; the server bot works over the gateway.
|
||||||
|
assert world.bot_tokens_used() <= {app.token for app in fake.bots.values()}
|
||||||
|
_no_secrets(world, ov, adm, vc)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_setup_server_bot_leaving_keeps_radio_working(full):
|
||||||
|
"""The DRB server bot is kicked: the server stays usable through its
|
||||||
|
radio bots (picker, permission check, start) — the main bot is optional."""
|
||||||
|
fake, world, published = full
|
||||||
|
await _server_bot_sets_up(fake)
|
||||||
|
await web.put_guild_directory(GID, web.GuildDirectory(present=False), SERVICE)
|
||||||
|
assert fake.get("discord_guilds", GID)["bot_present"] is False
|
||||||
|
|
||||||
|
opts = await web.join_options("n1", OWNER)
|
||||||
|
[gopt] = opts["guilds"]
|
||||||
|
assert gopt["main_bot_present"] is False
|
||||||
|
assert [c["name"] for c in gopt["voice_channels"]] == ["dispatch", "fireground", "lobby"]
|
||||||
|
c = await web.start_node_stream("n1", web.NodeStreamStart(guild_id=GID, channel_id=LOBBY), OWNER)
|
||||||
|
assert _status(c) == 200 and c["token_id"] == "own-a"
|
||||||
|
adm = await discord_router.admin_overview(ADMIN)
|
||||||
|
[ga] = [x for x in adm["guilds"] if x["guild_id"] == GID]
|
||||||
|
assert ga["main_bot_present"] is False and len(ga["radio_bots"]) == 3
|
||||||
@@ -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}}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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=[])), \
|
||||||
|
|||||||
+118
-111
@@ -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's real transcript or re-runs anything — it only records ground truth
|
This never touches the call'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 />}
|
||||||
@@ -1335,6 +1341,7 @@ export default function AdminPage() {
|
|||||||
{tab === "eval" && <SttEvalTab />}
|
{tab === "eval" && <SttEvalTab />}
|
||||||
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||||
{tab === "audit" && <AuditLogTab />}
|
{tab === "audit" && <AuditLogTab />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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't find what you're looking for? Sign in and reach out from your account.</p>
|
<p className="text-ink-2 mt-4">Can't find what you'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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't have an account?{" "}
|
Don'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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<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">
|
<div className="flex justify-between px-4 py-2.5">
|
||||||
<span className="text-gray-500">Approval</span>
|
<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'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">
|
||||||
<button
|
{isAdmin && (
|
||||||
onClick={() => setShowConfig(true)}
|
<button
|
||||||
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
|
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"
|
||||||
{node.configured ? "Reassign System" : "Configure"}
|
>
|
||||||
</button>
|
{node.configured ? "Reassign System" : "Configure"}
|
||||||
<button
|
</button>
|
||||||
disabled={sending}
|
)}
|
||||||
onClick={() => sendCommand("op25_restart")}
|
{isAdmin && (
|
||||||
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"
|
<button
|
||||||
>
|
disabled={sending}
|
||||||
Restart OP25
|
onClick={() => sendCommand("op25_restart", "Restart OP25")}
|
||||||
</button>
|
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
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
// Configuring is admin-only on the server (server-26#222); for
|
||||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
// everyone else the card links to the node page instead.
|
||||||
</div>
|
isAdmin ? (
|
||||||
|
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
||||||
|
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||||
|
</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) => (
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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's happening on the radio, as an incident — not a wall of calls.
|
See what'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">
|
||||||
|
|||||||
@@ -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{" "}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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't be shown again. This is a sample key from the demo module in{" "}
|
Copy this key now — it won'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't authenticate against anything.
|
<code className="text-ink-2">lib/apiKeys.ts</code>; it doesn'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't backed by a real endpoint yet — this screen runs against an in-memory
|
Organization API keys aren'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>
|
||||||
|
|||||||
@@ -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't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
Demo data — this page isn'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>
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
|
|||||||
|
|
||||||
function HowItWorks() {
|
function HowItWorks() {
|
||||||
const steps = [
|
const steps = [
|
||||||
["1 · Desired", "/join in Discord or this page writes a discord_streams doc."],
|
["1 · Desired", "A node page's picker, this page, or /join (DRB server bot) writes a discord_streams doc."],
|
||||||
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, in one transaction."],
|
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server that Discord lets View, Connect and Speak in the channel — the system's preferred bot first — in one transaction."],
|
||||||
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
|
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
|
||||||
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
|
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
|
||||||
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
|
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
|
||||||
@@ -54,6 +54,12 @@ function HowItWorks() {
|
|||||||
title="How a radio channel stays up"
|
title="How a radio channel stays up"
|
||||||
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
|
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
|
||||||
>
|
>
|
||||||
|
<p className="px-4 pt-4 text-xs text-ink-2">
|
||||||
|
<b className="text-ink">The DRB server bot is optional.</b> One radio bot in a Discord server is enough: adding it
|
||||||
|
through <i>Connect a Discord server</i> (Settings → Organization) links the server, and C2 reads its voice
|
||||||
|
channels and permissions with that bot. The server bot adds the slash commands (/join, /leave, /status,
|
||||||
|
/drb link) and pushes its own channel list; both ways keep working side by side.
|
||||||
|
</p>
|
||||||
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
{steps.map(([t, d]) => (
|
{steps.map(([t, d]) => (
|
||||||
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
|
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+137
-126
@@ -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's own list.
|
<span className="text-ink-2"> above</span> the system's own list.
|
||||||
Leave blank to inherit the system's.
|
Leave blank to inherit the system'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 "DE"). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
|
frequencies with mode P25 (or "DE"). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
|
||||||
one SDR, within about 1.5 MHz of each other. Each channel's calls are labelled with its name.
|
one SDR, within about 1.5 MHz of each other. Each channel'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>
|
||||||
<button
|
{/* Every systems write route is require_admin_token (server-26#283);
|
||||||
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
operators get a read-only view. */}
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
{isAdmin && (
|
||||||
>
|
<button
|
||||||
+ New System
|
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
||||||
</button>
|
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||||
|
>
|
||||||
|
+ New System
|
||||||
|
</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} />}
|
||||||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
{/* All admin-only routes (server-26#283), incl. area-context and
|
||||||
<AreaContextPanel systemId={s.system_id} />
|
talkgroup-pending GETs and every vocabulary write. */}
|
||||||
<TalkgroupPendingPanel systemId={s.system_id} />
|
{isAdmin && (
|
||||||
<VocabularyPanel systemId={s.system_id} />
|
<>
|
||||||
|
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||||
|
<AreaContextPanel systemId={s.system_id} />
|
||||||
|
<TalkgroupPendingPanel systemId={s.system_id} />
|
||||||
|
<VocabularyPanel systemId={s.system_id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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're on the list</h1>
|
<h1 className="text-white text-lg font-bold">You'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'll reach out at the email you gave us. In the meantime, feel free to{" "}
|
Thanks — we'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't open yet. Leave your details and we'll follow up to get your organization set up.
|
Self-serve signup isn't open yet. Leave your details and we'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>}
|
||||||
|
|||||||
@@ -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-blue-400">▶</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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -329,12 +329,13 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
|||||||
|
|
||||||
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
|
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
|
||||||
{restricted ? (
|
{restricted ? (
|
||||||
<>Unlinked servers see only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
|
<>Where the DRB server bot is present, an unlinked server sees only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
|
||||||
) : isAdmin ? (
|
) : isAdmin ? (
|
||||||
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
|
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
|
||||||
) : null}
|
) : null}
|
||||||
Every command and autocomplete is scoped to the linked organization — no server sees another
|
Radio channels, and every DRB server bot command and autocomplete, are scoped to the linked organization — no
|
||||||
organization's nodes, systems or bots.
|
server sees another organization's nodes, systems or bots. The DRB server bot is optional: a radio bot
|
||||||
|
alone is enough to stream.
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,22 +13,42 @@
|
|||||||
* back the same; each browser also remembers its last view (lib/wallPrefs.ts).
|
* back the same; each browser also remembers its last view (lib/wallPrefs.ts).
|
||||||
* Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the
|
* Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the
|
||||||
* wall-display mode (lib/useKiosk.ts).
|
* wall-display mode (lib/useKiosk.ts).
|
||||||
|
*
|
||||||
|
* Filter bar (server-26#235): the `f.*` hash keys (lib/liveFilter.ts) are
|
||||||
|
* applied ONCE here and the filtered lists feed the map (pins, paths, rail),
|
||||||
|
* the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline
|
||||||
|
* 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 { useActiveCalls, useCalls } from "@/lib/useCalls";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls";
|
||||||
|
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 {
|
||||||
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
|
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
|
||||||
parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
||||||
} from "@/lib/liveWall";
|
} from "@/lib/liveWall";
|
||||||
|
import { PRESETS, applyFilter, parseFilter, serialiseFilter, type PresetKey, type WallFilter } from "@/lib/liveFilter";
|
||||||
|
import { FilterBar, type FilterOption } from "@/components/live/FilterBar";
|
||||||
import { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs";
|
import { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs";
|
||||||
import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk";
|
import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk";
|
||||||
import { KioskBanner, KioskControls } from "@/components/live/KioskControls";
|
import { KioskBanner, KioskControls } from "@/components/live/KioskControls";
|
||||||
@@ -39,25 +59,43 @@ 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",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
const PIN_KEY = "drb.wall.pinnedCameras";
|
const PIN_KEY = "drb.wall.pinnedCameras";
|
||||||
|
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`;
|
||||||
@@ -110,10 +148,12 @@ 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 { aircraft } = useAircraft();
|
const { aircraft } = useAircraft();
|
||||||
const { vessels } = useVessels();
|
const { vessels } = useVessels();
|
||||||
const [clock, setClock] = useState<string>("");
|
const [clock, setClock] = useState<string>("");
|
||||||
@@ -166,6 +206,22 @@ export function LiveView() {
|
|||||||
announceKioskChange();
|
announceKioskChange();
|
||||||
}, [link, mapState]);
|
}, [link, mapState]);
|
||||||
const patchLink = useCallback((p: Partial<WallLink>) => setLinkState((cur) => ({ ...cur, ...p })), []);
|
const patchLink = useCallback((p: Partial<WallLink>) => setLinkState((cur) => ({ ...cur, ...p })), []);
|
||||||
|
// ── Filter (server-26#235) ────────────────────────────────────────────────
|
||||||
|
const filterKey = JSON.stringify(link.filter ?? {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const filter = useMemo(() => parseFilter(link.filter), [filterKey]);
|
||||||
|
const setFilter = useCallback((f: WallFilter) => patchLink({ filter: serialiseFilter(f) }), [patchLink]);
|
||||||
|
/** A preset switches its map layers on through the saved-view layer state (no MapView edit). */
|
||||||
|
const onPreset = useCallback((p: PresetKey) => {
|
||||||
|
const cur = mapStateRef.current;
|
||||||
|
if (!cur) return;
|
||||||
|
const have = cur.layers ?? DEFAULT_LAYERS;
|
||||||
|
if (PRESETS[p].layers.every((l) => have.includes(l))) return;
|
||||||
|
const next = { ...cur, layers: normaliseLayers([...have, ...PRESETS[p].layers]) };
|
||||||
|
saveWallPrefs(next);
|
||||||
|
setMapState(next);
|
||||||
|
setMapGen((g) => g + 1);
|
||||||
|
}, []);
|
||||||
const onViewStateChange = useCallback((s: MapViewState) => {
|
const onViewStateChange = useCallback((s: MapViewState) => {
|
||||||
setMapState((cur) => {
|
setMapState((cur) => {
|
||||||
// Keep a known viewport if the map hasn't reported one yet.
|
// Keep a known viewport if the map hasn't reported one yet.
|
||||||
@@ -214,16 +270,63 @@ export function LiveView() {
|
|||||||
|
|
||||||
// ── Geography ─────────────────────────────────────────────────────────────
|
// ── Geography ─────────────────────────────────────────────────────────────
|
||||||
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
|
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
|
||||||
const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]);
|
const filtered = useMemo(
|
||||||
|
() => applyFilter(activeIncidents, recentCalls, filter, callsByIncident),
|
||||||
|
[activeIncidents, recentCalls, filter, callsByIncident],
|
||||||
|
);
|
||||||
|
const sorted = useMemo(() => worstFirst(filtered.incidents), [filtered.incidents]);
|
||||||
const pointsById = useMemo(() => {
|
const pointsById = useMemo(() => {
|
||||||
const m = new Map<string, LatLon[]>();
|
const m = new Map<string, LatLon[]>();
|
||||||
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
|
||||||
@@ -246,9 +349,47 @@ 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
|
||||||
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
|
// or it has resolved (a shared #inc= link must still land); it is not added
|
||||||
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
// to the tour. Its full call list rides along so the path is complete.
|
||||||
|
const keepSelected = !!selected && !filtered.ids.has(selected.incident_id);
|
||||||
|
const mapIncidents = useMemo(
|
||||||
|
() => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents),
|
||||||
|
[keepSelected, selected, filtered.incidents],
|
||||||
|
);
|
||||||
|
const mapCalls = useMemo(() => {
|
||||||
|
if (!selectedCalls.length) return filtered.calls;
|
||||||
|
const have = new Set(filtered.calls.map((c) => c.call_id));
|
||||||
|
const extra = selectedCalls.filter((c) => !have.has(c.call_id));
|
||||||
|
return extra.length ? [...filtered.calls, ...extra] : filtered.calls;
|
||||||
|
}, [selectedCalls, filtered.calls]);
|
||||||
|
|
||||||
|
// Filter choices come from what the wall has loaded (plus system names).
|
||||||
|
const filterOptions = useMemo(() => {
|
||||||
|
const sysNames = new Map(systems.map((s) => [s.system_id, s.name]));
|
||||||
|
const sysIds = new Set<string>();
|
||||||
|
const tgNames = new Map<string, string>();
|
||||||
|
for (const c of recentCalls) {
|
||||||
|
if (c.system_id) sysIds.add(c.system_id);
|
||||||
|
if (c.talkgroup_id != null) {
|
||||||
|
const id = String(c.talkgroup_id);
|
||||||
|
if (c.talkgroup_name || !tgNames.has(id)) tgNames.set(id, c.talkgroup_name || id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tagCount = new Map<string, number>();
|
||||||
|
for (const i of activeIncidents) {
|
||||||
|
for (const s of i.system_ids ?? []) sysIds.add(String(s));
|
||||||
|
for (const t of i.talkgroup_ids ?? []) if (!tgNames.has(String(t))) tgNames.set(String(t), String(t));
|
||||||
|
for (const t of i.tags ?? []) tagCount.set(t.toLowerCase(), (tagCount.get(t.toLowerCase()) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label);
|
||||||
|
return {
|
||||||
|
systems: Array.from(sysIds).map((id) => ({ id, label: sysNames.get(id) ?? id })).sort(byLabel),
|
||||||
|
talkgroups: Array.from(tgNames.entries()).map(([id, name]) => ({ id, label: name === id ? id : `${name} (${id})` })).sort(byLabel),
|
||||||
|
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]);
|
||||||
|
const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]);
|
||||||
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;
|
||||||
@@ -266,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 });
|
||||||
@@ -302,10 +469,18 @@ 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 ──────────────────────────────────────────────────────────────────
|
||||||
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
const count = (sev: string) => filtered.incidents.filter((i) => i.severity === sev).length;
|
||||||
// "Nodes hearing": a call in the last N min, not merely not-offline — a node
|
// "Nodes hearing": a call in the last N min, not merely not-offline — a node
|
||||||
// that checks in but hears nothing is not contributing to the picture.
|
// that checks in but hears nothing is not contributing to the picture.
|
||||||
const nodesHearing = health.nodesHearing;
|
const nodesHearing = health.nodesHearing;
|
||||||
@@ -337,7 +512,8 @@ export function LiveView() {
|
|||||||
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
||||||
<Kpi value={count("major")} label="major" />
|
<Kpi value={count("major")} label="major" />
|
||||||
<Kpi value={count("moderate")} label="moderate" />
|
<Kpi value={count("moderate")} label="moderate" />
|
||||||
<Kpi value={activeIncidents.length} label="active" />
|
<Kpi value={filtered.active ? `${filtered.incidents.length} of ${activeIncidents.length}` : activeIncidents.length}
|
||||||
|
label={filtered.active ? "active · filtered" : "active"} />
|
||||||
<Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
|
<Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
|
||||||
<Kpi value={cams.cameras.length} label="cams" />
|
<Kpi value={cams.cameras.length} label="cams" />
|
||||||
<Kpi value={aircraft.length} label="aircraft" />
|
<Kpi value={aircraft.length} label="aircraft" />
|
||||||
@@ -360,10 +536,22 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
|
<FilterBar
|
||||||
|
filter={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
onPreset={onPreset}
|
||||||
|
systems={filterOptions.systems}
|
||||||
|
talkgroups={filterOptions.talkgroups}
|
||||||
|
tags={filterOptions.tags}
|
||||||
|
shown={filtered.incidents.length}
|
||||||
|
total={activeIncidents.length}
|
||||||
|
readOnly={kiosk}
|
||||||
|
/>
|
||||||
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
|
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
|
||||||
<TruthStrip health={health} placement="bar" />
|
<TruthStrip health={health} placement="bar" />
|
||||||
|
|
||||||
@@ -372,20 +560,26 @@ 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
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
activeCalls={activeCalls}
|
activeCalls={activeCalls}
|
||||||
incidents={activeIncidents}
|
incidents={mapIncidents}
|
||||||
calls={recentCalls}
|
calls={mapCalls}
|
||||||
selectedIncidentId={selected?.incident_id ?? null}
|
selectedIncidentId={selected?.incident_id ?? null}
|
||||||
onSelectIncident={selectIncident}
|
onSelectIncident={selectIncident}
|
||||||
nearCameras={near}
|
nearCameras={near}
|
||||||
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}
|
||||||
@@ -395,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 && (
|
||||||
@@ -407,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't exist or isn'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}
|
||||||
@@ -423,10 +645,11 @@ 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={activeIncidents}
|
incidents={filtered.incidents}
|
||||||
calls={recentCalls}
|
calls={filtered.calls}
|
||||||
onAirCount={onAir.size}
|
onAirCount={onAir.size}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
wallCameras={wallCameras}
|
wallCameras={wallCameras}
|
||||||
@@ -443,8 +666,9 @@ export function LiveView() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
|
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
|
||||||
<TimeScrubber calls={recentCalls} incidents={activeIncidents} 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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
// Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13).
|
// Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13).
|
||||||
// Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only
|
// Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only
|
||||||
// the Discord servers linked to this node's org and the voice channels the
|
// the Discord servers linked to this node's org and their voice channels, as
|
||||||
// DRB bot reported in them; a pick becomes a radio channel pinned to this node
|
// read by a radio bot in the server or pushed by the DRB server bot — either
|
||||||
// (desired state — the node reconciles and reports back, shown per row).
|
// is enough; the server bot is optional (server-26#225). A pick becomes a
|
||||||
|
// radio channel pinned to this node (desired state — the node reconciles and
|
||||||
|
// reports back, shown per row).
|
||||||
// A voice channel can carry several radio streams, each its own bot playing
|
// A voice channel can carry several radio streams, each its own bot playing
|
||||||
// its own system (server-26#213 follow-up): picking a channel that already
|
// its own system (server-26#213 follow-up): picking a channel that already
|
||||||
// plays another system ADDS this node's system beside it; only a stream of
|
// plays another system ADDS this node's system beside it; only a stream of
|
||||||
@@ -211,8 +213,9 @@ function DiscordPicker({
|
|||||||
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
|
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
|
||||||
{usable.length === 0 ? (
|
{usable.length === 0 ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
||||||
No Discord server is linked to your organization yet. Add the DRB bot to your Discord server and
|
No Discord server is linked to your organization yet. The organization owner connects one under
|
||||||
link it to your organization; it shows up here once the bot reports its channels.
|
Settings → Organization → <b className="text-ink">Connect a Discord server</b>: adding a radio bot
|
||||||
|
to the server links it, and its voice channels show up here.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -259,12 +262,13 @@ function DiscordPicker({
|
|||||||
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
|
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
|
||||||
{!guild.synced ? (
|
{!guild.synced ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
||||||
The DRB bot has not reported this server's channels yet. It does when it starts and every 15
|
DRB hasn't read this server's voice channels yet. A radio bot in the server lists them
|
||||||
minutes — check back shortly.
|
(or the DRB server bot, if it's there) — close this and open it again in a moment. If it
|
||||||
|
stays empty, no radio bot is in this server yet — invite one with the link under the server above.
|
||||||
</p>
|
</p>
|
||||||
) : guild.voice_channels.length === 0 ? (
|
) : guild.voice_channels.length === 0 ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
||||||
This server has no voice channels the DRB bot can see.
|
This server has no voice channels DRB's bots can see.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -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't be decoded, so nothing from{" "}
|
{subject} encrypted — those calls can'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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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's runs the next enabled service, top first. Pin a service to the SDR with its
|
Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its
|
||||||
antenna, or leave it on "Any spare SDR".
|
antenna, or leave it on "Any spare SDR".
|
||||||
</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>
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
|
|||||||
@@ -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' original order, into a sandbox — live incidents are never
|
Re-run the pipeline over a past time range, in the calls' 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.
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
|
|||||||
|
|
||||||
// Linked Discord servers = discord_guilds docs, plus any server that has had a
|
// Linked Discord servers = discord_guilds docs, plus any server that has had a
|
||||||
// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID).
|
// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID).
|
||||||
// Linking is PUT /discord/guilds/{id}/link (platform admin). The owner-side
|
// Linking here is PUT /discord/guilds/{id}/link (platform admin, support).
|
||||||
// "/drb link code:" flow is server-26#213 item 1 and is not built here.
|
// Org owners link their own from Settings → Organization: "Connect a Discord
|
||||||
|
// server" (a radio bot's OAuth invite, no DRB server bot needed) or a
|
||||||
|
// /drb link code (needs the server bot) — server-26#213 item 1, #225.
|
||||||
|
// The Bots column says which bots are in each server: the DRB server bot is
|
||||||
|
// optional (server-26#225 S8), one radio bot is enough to stream.
|
||||||
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
|
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
|
||||||
// deleted, and the server is unlinked with no fallback org.
|
// deleted, and the server is unlinked with no fallback org.
|
||||||
|
|
||||||
@@ -85,6 +89,30 @@ function LinkForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which bots are in the server. The DRB server bot is optional (server-26#225):
|
||||||
|
// "not there" is only a problem when no radio bot is either.
|
||||||
|
function BotsCell({ guild }: { guild: DiscordGuild }) {
|
||||||
|
const radio = guild.radio_bots ?? [];
|
||||||
|
const main = guild.main_bot_present;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{radio.length > 0 ? (
|
||||||
|
<span title={radio.join(", ")}>
|
||||||
|
{radio.length} radio bot{radio.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={main ? DIM : "text-sev-moderate"}>no radio bot</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={DIM}>
|
||||||
|
DRB server bot: {main === true ? "present" : main === false ? "not there" : "never seen"}
|
||||||
|
{guild.directory_source === "radio_bot" && " · channels via radio bot"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
|
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
|
||||||
|
|
||||||
function RemoveForm({
|
function RemoveForm({
|
||||||
@@ -131,7 +159,7 @@ function RemoveForm({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-ink-muted">
|
<p className="text-ink-muted">
|
||||||
The DRB bot itself stays in the Discord server until someone kicks it there.
|
The DRB server bot, if it is in the Discord server, stays until someone kicks it there.
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="text-sev-major">{error}</p>}
|
{error && <p className="text-sev-major">{error}</p>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -171,8 +199,9 @@ export function ServersPanel({
|
|||||||
fallback ? (
|
fallback ? (
|
||||||
<>
|
<>
|
||||||
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
|
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
|
||||||
server the DRB bot is invited to can use that org's systems until it is linked here. Set the variable
|
server the DRB server bot is invited to can use that org's systems until it is linked here (a radio
|
||||||
to an empty string once every real server is linked.
|
bot alone never puts an unlinked server here). Set the variable to an empty string once every real server
|
||||||
|
is linked.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Unlinked servers can't use radio commands until they are linked here.</>
|
<>Unlinked servers can't use radio commands until they are linked here.</>
|
||||||
@@ -189,14 +218,16 @@ export function ServersPanel({
|
|||||||
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
|
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
|
||||||
</>
|
</>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
The DRB bot is still in that Discord server until someone kicks it.
|
The DRB server bot, if it was there, stays in that Discord server until someone kicks it.
|
||||||
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
|
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{overview.guilds.length === 0 ? (
|
{overview.guilds.length === 0 ? (
|
||||||
<p className="px-4 py-6 text-sm text-ink-muted">No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.</p>
|
<p className="px-4 py-6 text-sm text-ink-muted">
|
||||||
|
No Discord servers yet. Link one here, or an organization owner connects one under Settings → Organization.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Table head={["Server", "Acts for", "Radio channels", "Linked", "Status", ""]}>
|
<Table head={["Server", "Acts for", "Bots", "Radio channels", "Linked", "Status", ""]}>
|
||||||
{overview.guilds.flatMap((g) => {
|
{overview.guilds.flatMap((g) => {
|
||||||
const row = (
|
const row = (
|
||||||
<tr key={g.guild_id}>
|
<tr key={g.guild_id}>
|
||||||
@@ -208,6 +239,9 @@ export function ServersPanel({
|
|||||||
{g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>}
|
{g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>}
|
||||||
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
|
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={TD}>
|
||||||
|
<BotsCell guild={g} />
|
||||||
|
</td>
|
||||||
<td className={TD}>
|
<td className={TD}>
|
||||||
{g.live_streams} / {max}
|
{g.live_streams} / {max}
|
||||||
</td>
|
</td>
|
||||||
@@ -251,7 +285,7 @@ export function ServersPanel({
|
|||||||
return [
|
return [
|
||||||
row,
|
row,
|
||||||
<tr key={`${g.guild_id}-remove`}>
|
<tr key={`${g.guild_id}-remove`}>
|
||||||
<td colSpan={6} className="p-0 border-b border-line">
|
<td colSpan={7} className="p-0 border-b border-line">
|
||||||
<RemoveForm
|
<RemoveForm
|
||||||
guild={g}
|
guild={g}
|
||||||
onCancel={() => setRemoving(null)}
|
onCancel={() => setRemoving(null)}
|
||||||
@@ -269,7 +303,7 @@ export function ServersPanel({
|
|||||||
return [
|
return [
|
||||||
row,
|
row,
|
||||||
<tr key={`${g.guild_id}-edit`}>
|
<tr key={`${g.guild_id}-edit`}>
|
||||||
<td colSpan={6} className="p-0 border-b border-line">
|
<td colSpan={7} className="p-0 border-b border-line">
|
||||||
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
|
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
|
||||||
</td>
|
</td>
|
||||||
</tr>,
|
</tr>,
|
||||||
|
|||||||
@@ -348,7 +348,8 @@ export function StreamsPanel({
|
|||||||
)}
|
)}
|
||||||
{overview.streams.length === 0 ? (
|
{overview.streams.length === 0 ? (
|
||||||
<p className="px-4 py-6 text-sm text-ink-muted">
|
<p className="px-4 py-6 text-sm text-ink-muted">
|
||||||
No radio channels yet. Start one here, or run <span className="font-mono">/join</span> in a linked Discord server.
|
No radio channels yet. Start one here or from a node's Discord radio panel, or run{" "}
|
||||||
|
<span className="font-mono">/join</span> where the DRB server bot is in a linked Discord server.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
|
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
|
||||||
|
|||||||
@@ -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's site settings — allow notifications there first.</span>}
|
||||||
|
{perm === "unsupported" && <span className="block text-ink-muted">This browser doesn'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live wall filter bar (server-26#235): one row under the wall bar — presets,
|
||||||
|
* free-text search, type chips, severity floor, system/talkgroup, age window,
|
||||||
|
* tag chips, clear. Presentation only; the filter lives in the URL hash
|
||||||
|
* (lib/liveFilter.ts) and LiveView applies it once to everything it plots.
|
||||||
|
* Wraps on narrow screens; below md the less-used controls fold behind a
|
||||||
|
* "Filters" toggle so the map keeps its height.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { SEVERITY_LABEL, type Severity } from "@/lib/severity";
|
||||||
|
import {
|
||||||
|
AGE_WINDOWS_H, EMPTY_FILTER, INCIDENT_TYPES, PRESETS, PRESET_KEYS, describeFilter, isFilterActive,
|
||||||
|
type IncidentType, type PresetKey, type WallFilter,
|
||||||
|
} from "@/lib/liveFilter";
|
||||||
|
|
||||||
|
export interface FilterOption { id: string; label: string }
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<IncidentType, string> = {
|
||||||
|
fire: "Fire", ems: "EMS", police: "Police", accident: "Accident", other: "Other",
|
||||||
|
};
|
||||||
|
const SEV_FLOORS: Severity[] = ["minor", "moderate", "major"];
|
||||||
|
const Q_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
const chip = (on: boolean) =>
|
||||||
|
`px-2 py-0.5 rounded-full border whitespace-nowrap ${on ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`;
|
||||||
|
const select = "px-1.5 py-0.5 rounded border border-line bg-surface text-ink-2 max-w-[11rem]";
|
||||||
|
|
||||||
|
const toggle = <T,>(arr: T[], v: T): T[] => (arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]);
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
filter, onChange, onPreset, systems, talkgroups, tags, shown, total, readOnly = false,
|
||||||
|
}: {
|
||||||
|
filter: WallFilter;
|
||||||
|
onChange: (f: WallFilter) => void;
|
||||||
|
/** A preset was switched on — the caller also turns its map layers on. */
|
||||||
|
onPreset: (p: PresetKey) => void;
|
||||||
|
systems: FilterOption[];
|
||||||
|
talkgroups: FilterOption[];
|
||||||
|
/** Most common tags in the loaded incidents (selected ones are always shown). */
|
||||||
|
tags: string[];
|
||||||
|
shown: number;
|
||||||
|
total: number;
|
||||||
|
/** Kiosk: no controls, just the summary when a filter is on. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
const active = isFilterActive(filter);
|
||||||
|
const [more, setMore] = useState(false);
|
||||||
|
|
||||||
|
// Search is debounced locally so each keystroke doesn't rewrite the hash
|
||||||
|
// and re-filter every transcript; an outside change (hash edit, Clear) wins.
|
||||||
|
const [q, setQ] = useState(filter.q);
|
||||||
|
const committed = useRef(filter.q);
|
||||||
|
useEffect(() => {
|
||||||
|
// Compare trimmed: the hash stores q trimmed, so a just-typed trailing
|
||||||
|
// space ("main ") must not read as an outside change and get eaten.
|
||||||
|
if (filter.q !== committed.current.trim()) { committed.current = filter.q; setQ(filter.q); }
|
||||||
|
}, [filter.q]);
|
||||||
|
const filterRef = useRef(filter);
|
||||||
|
filterRef.current = filter;
|
||||||
|
useEffect(() => {
|
||||||
|
if (q === committed.current) return;
|
||||||
|
const id = setTimeout(() => { committed.current = q; onChange({ ...filterRef.current, q }); }, Q_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [q, onChange]);
|
||||||
|
|
||||||
|
const sysName = (id: string) => systems.find((s) => s.id === id)?.label ?? id;
|
||||||
|
const tgName = (id: string) => talkgroups.find((t) => t.id === id)?.label ?? id;
|
||||||
|
const summary = (
|
||||||
|
<span className={`whitespace-nowrap ${active ? "text-ink font-semibold" : "text-ink-muted"}`} aria-live="polite">
|
||||||
|
{active ? `Filtered: ${shown} of ${total}` : `${total} active`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
if (!active) return null;
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1 border-b border-line bg-surface/95 text-xs">
|
||||||
|
{summary}
|
||||||
|
<span className="text-ink-2 min-w-0 break-words">{describeFilter(filter, { sys: sysName, tg: tgName })}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = (p: Partial<WallFilter>) => onChange({ ...filter, ...p });
|
||||||
|
const tagList = Array.from(new Set([...filter.tags, ...tags]));
|
||||||
|
const tgValue = filter.talkgroups.length === 1 ? filter.talkgroups[0] : "";
|
||||||
|
const sysValue = filter.systems.length === 1 ? filter.systems[0] : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="search" aria-label="Filter the live map"
|
||||||
|
className="shrink-0 flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
|
||||||
|
<div className="flex items-center gap-1" role="group" aria-label="Presets">
|
||||||
|
{PRESET_KEYS.map((k) => (
|
||||||
|
<button key={k} type="button" aria-pressed={filter.preset === k} title={PRESETS[k].title} className={chip(filter.preset === k)}
|
||||||
|
onClick={() => {
|
||||||
|
if (filter.preset === k) { set({ preset: null }); return; }
|
||||||
|
set({ preset: k });
|
||||||
|
onPreset(k);
|
||||||
|
}}>
|
||||||
|
{PRESETS[k].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title, location, transcript"
|
||||||
|
aria-label="Search incidents and transcripts"
|
||||||
|
className="min-w-0 flex-1 basis-40 max-w-xs px-2 py-0.5 rounded border border-line bg-surface text-ink placeholder:text-ink-muted" />
|
||||||
|
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more} className={`md:hidden ${chip(false)}`}>
|
||||||
|
{more ? "Fewer filters" : "Filters"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={`${more ? "flex" : "hidden"} md:flex flex-wrap items-center gap-x-2 gap-y-1 w-full md:w-auto`}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="Incident type">
|
||||||
|
{INCIDENT_TYPES.map((t) => (
|
||||||
|
<button key={t} type="button" aria-pressed={filter.types.includes(t)} className={chip(filter.types.includes(t))}
|
||||||
|
onClick={() => set({ types: toggle(filter.types, t) })}>
|
||||||
|
{TYPE_LABEL[t]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<select aria-label="Severity floor" className={select} value={filter.sevFloor ?? ""}
|
||||||
|
onChange={(e) => set({ sevFloor: (e.target.value || null) as Severity | null })}>
|
||||||
|
<option value="">Any severity</option>
|
||||||
|
{SEV_FLOORS.map((s) => <option key={s} value={s}>{SEVERITY_LABEL[s]}{s === "major" ? "" : "+"}</option>)}
|
||||||
|
</select>
|
||||||
|
<select aria-label="Age window" className={select} value={filter.ageH ?? ""}
|
||||||
|
onChange={(e) => set({ ageH: e.target.value ? Number(e.target.value) : null })}>
|
||||||
|
<option value="">Any age</option>
|
||||||
|
{AGE_WINDOWS_H.map((h) => <option key={h} value={h}>Last {h}h</option>)}
|
||||||
|
{filter.ageH && !(AGE_WINDOWS_H as readonly number[]).includes(filter.ageH) && (
|
||||||
|
<option value={filter.ageH}>Last {filter.ageH}h</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
{systems.length > 1 || sysValue ? (
|
||||||
|
<select aria-label="Radio system" className={select} value={sysValue}
|
||||||
|
onChange={(e) => set({ systems: e.target.value ? [e.target.value] : [] })}>
|
||||||
|
<option value="">All systems</option>
|
||||||
|
{filter.systems.length > 1 && <option value="" disabled>{filter.systems.length} systems</option>}
|
||||||
|
{systems.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{talkgroups.length > 0 || tgValue ? (
|
||||||
|
<select aria-label="Talkgroup" className={select} value={tgValue}
|
||||||
|
onChange={(e) => set({ talkgroups: e.target.value ? [e.target.value] : [] })}>
|
||||||
|
<option value="">All talkgroups</option>
|
||||||
|
{filter.talkgroups.length > 1 && <option value="" disabled>{filter.talkgroups.length} talkgroups</option>}
|
||||||
|
{talkgroups.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{tagList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="Tags">
|
||||||
|
{tagList.map((t) => (
|
||||||
|
<button key={t} type="button" aria-pressed={filter.tags.includes(t)} className={chip(filter.tags.includes(t))}
|
||||||
|
onClick={() => set({ tags: toggle(filter.tags, t) })}>
|
||||||
|
#{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{summary}
|
||||||
|
{active && (
|
||||||
|
<button type="button" onClick={() => { committed.current = ""; setQ(""); onChange(EMPTY_FILTER); }} className={chip(false)}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,15 @@ 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, 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 { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch";
|
||||||
|
|
||||||
function sevColor(s: string | null | undefined) {
|
function sevColor(s: string | null | undefined) {
|
||||||
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
|
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
|
||||||
@@ -24,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 {
|
||||||
@@ -41,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>
|
||||||
);
|
);
|
||||||
@@ -61,6 +71,241 @@ function NoCameras({ servingCount, radiusKm }: { servingCount: number; radiusKm:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Power outages (server-26#184) ────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Why the outage answer is empty or unusable, in one line — or null when the
|
||||||
|
* data is fine to read. Shared by the region Feeds row and the incident panel.
|
||||||
|
*/
|
||||||
|
function OutageProblem({ state, where }: { state: OutagesState; where: string }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { data, error, loaded } = state;
|
||||||
|
if (!loaded) return <>checking…</>;
|
||||||
|
if (!data) return <>could not reach server{error ? ` (${error})` : ""}</>;
|
||||||
|
if (data.status === "none_enabled") {
|
||||||
|
return isAdmin
|
||||||
|
? <>no outage source enabled (<Link href="/admin#outages" className="underline">Admin → Outage providers</Link>)</>
|
||||||
|
: <>no outage source enabled — a platform admin can turn one on</>;
|
||||||
|
}
|
||||||
|
if (data.status === "no_source") return <>no licensed outage source covers {where}</>;
|
||||||
|
if (data.status === "error") {
|
||||||
|
const bad = data.providers.filter((p) => p.covers_bbox && p.error);
|
||||||
|
return <>feed error{bad.length ? `: ${bad.map((p) => `${p.name} (${p.error})`).join("; ")}` : ""}</>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outageIsUsable(state: OutagesState): boolean {
|
||||||
|
return !!state.data && state.data.status === "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incident panel "OUTAGE" lines: the cross-match, with source credit and fetch time. */
|
||||||
|
function OutageContext({ inc, points }: { inc: IncidentRecord; points: LatLon[] }) {
|
||||||
|
// 1 km pad: county data is matched by the county bbox containing a point, so
|
||||||
|
// any county the incident touches intersects this box.
|
||||||
|
const box = useMemo(() => bboxAround(points, 1), [points]);
|
||||||
|
const state = useOutagesIn(box);
|
||||||
|
const hazard = isUtilityHazard(inc);
|
||||||
|
const matches = useMemo(
|
||||||
|
() => (state.data ? matchOutages(inc, points, state.data.areas, state.data.providers) : []),
|
||||||
|
[inc, points, state.data],
|
||||||
|
);
|
||||||
|
const staleHere = useMemo(() => (state.data ? staleCountiesAt(points, state.data.areas) : []), [points, state.data]);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">OUTAGE</span>;
|
||||||
|
if (!points.length) return <li>{tag}—</li>;
|
||||||
|
if (!outageIsUsable(state)) return <li>{tag}<OutageProblem state={state} where="this location" /></li>;
|
||||||
|
const covering = state.data!.providers.filter((p) => p.covers_bbox);
|
||||||
|
const refreshNote = state.error ? " · could not refresh, showing the last answer" : "";
|
||||||
|
const feedErr = covering.find((p) => p.error);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hazard && (
|
||||||
|
<li>
|
||||||
|
<span className={`inline-block text-[10px] font-semibold px-1.5 py-0.5 rounded ${matches.length ? "bg-accent text-white" : "bg-raised text-ink-2"}`}>
|
||||||
|
{matches.length
|
||||||
|
? matches[0].kind === "point"
|
||||||
|
? `⚡ Utility-hazard call within ${OUTAGE_POINT_KM} km of a reported outage`
|
||||||
|
: "⚡ Utility-hazard call in a county with reported outages"
|
||||||
|
: "⚡ Utility-hazard call · no reported outage matched"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{matches.map((m) => (
|
||||||
|
<li key={m.area.id}>
|
||||||
|
{tag}{m.text}
|
||||||
|
{m.area.etr ? ` · est. restoration ${new Date(m.area.etr).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : ""}
|
||||||
|
<SourceCredit credit={m.feed} fallbackName={m.feed?.name ?? m.area.provider}
|
||||||
|
updated={m.feed ? `fetched ${fetchedLabel(m.feed.fetched_at) ?? "—"}${refreshNote}` : null} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!matches.length && (
|
||||||
|
<li>
|
||||||
|
{tag}
|
||||||
|
{feedErr
|
||||||
|
? `${feedErr.name} feed error${feedErr.fetched_at ? `, last good data ${fetchedLabel(feedErr.fetched_at)}` : ", no data yet"} — none matched`
|
||||||
|
: `none reported here by ${covering.map((p) => p.name).join(", ") || "the enabled source"}`}
|
||||||
|
{staleHere.length > 0 && ` (${staleHere.map(countyName).join(", ")}: stale upstream rows only, not shown as live)`}
|
||||||
|
{covering.map((p) => (
|
||||||
|
<SourceCredit key={p.id} credit={p} fallbackName={p.name}
|
||||||
|
updated={`fetched ${fetchedLabel(p.fetched_at) ?? "—"}${refreshNote}${p.coverage_note ? ` · ${p.coverage_note}` : ""}`} />
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Region Feeds "OUTAGE" row: provider status for the wall's region. */
|
||||||
|
function OutageFeedRow({ 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]);
|
||||||
|
// Counties are large; pad the region so a county just outside the pins still counts.
|
||||||
|
const box = useMemo(() => bboxAround(pts, 10), [pts]);
|
||||||
|
const state = useOutagesIn(box);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">OUTAGE</span>;
|
||||||
|
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
|
||||||
|
if (!outageIsUsable(state)) return <li>{tag}<OutageProblem state={state} where="this region" /></li>;
|
||||||
|
const { areas, providers } = state.data!;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{providers.filter((p) => p.covers_bbox).map((p) => {
|
||||||
|
const mine = areas.filter((a) => a.provider === p.id);
|
||||||
|
const live = mine.filter((a) => !a.stale && a.customers_out > 0);
|
||||||
|
const out = live.reduce((t, a) => t + a.customers_out, 0);
|
||||||
|
const stale = mine.filter((a) => a.stale).length;
|
||||||
|
return (
|
||||||
|
<li key={p.id}>{tag}{p.name}: {live.length
|
||||||
|
? `${live.length} count${live.length !== 1 ? "ies" : "y"} with outages, ${out.toLocaleString()} meters/customers out`
|
||||||
|
: "no outages reported in region"}
|
||||||
|
{stale ? ` · ${stale} stale` : ""}{p.error ? " · feed error" : ""}{p.fetched_at ? ` · fetched ${fetchedLabel(p.fetched_at)}` : ""}
|
||||||
|
{state.error ? " · could not refresh" : ""} · Power Outages layer (map menu)</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── 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'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,
|
||||||
@@ -105,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>
|
||||||
@@ -123,7 +368,9 @@ export function RegionPanel({
|
|||||||
{providers.map((p) => (
|
{providers.map((p) => (
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li>
|
<OutageFeedRow incidents={incidents} nodes={nodes} />
|
||||||
|
<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}>
|
||||||
@@ -177,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]);
|
||||||
@@ -210,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>
|
||||||
@@ -229,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 }) => (
|
||||||
@@ -247,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>
|
||||||
@@ -268,8 +534,12 @@ 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} />
|
||||||
</ul>
|
</ul>
|
||||||
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
||||||
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
|
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
|
||||||
@@ -278,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>
|
||||||
@@ -322,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 (
|
||||||
@@ -329,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>
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Power Outages" map overlay (server-26#184). County-level areas from the
|
||||||
|
* admin-enabled, licence-gated outage providers, drawn at the county's label
|
||||||
|
* point and sized by meters out. The marker is a county, not an outage
|
||||||
|
* location, and every popup says so.
|
||||||
|
*
|
||||||
|
* Never a silently empty layer: while the overlay is on, a status chip always
|
||||||
|
* says what the layer is showing — loading, N counties, nothing reported in
|
||||||
|
* view (with the source's coverage caveat), no provider enabled, no licensed
|
||||||
|
* source covering this area, or a feed error.
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Marker, Popup, useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import type { OutageArea, OutageProviderFeed } from "@/lib/types";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
|
import { useMapOutages, type OutagesState } from "@/lib/useOutages";
|
||||||
|
import { countyName } from "@/lib/outageMatch";
|
||||||
|
|
||||||
|
/** The outage glyph (lightning bolt in a ring), shared with the map legend. */
|
||||||
|
export function outageGlyphSvg(size: number, stale = false): string {
|
||||||
|
const ring = stale
|
||||||
|
? `<circle cx="12" cy="12" r="10.5" fill="#1f2937" fill-opacity="0.55" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="3 2"/>`
|
||||||
|
: `<circle cx="12" cy="12" r="10.5" fill="#1f2937" stroke="#facc15" stroke-width="1.75"/>`;
|
||||||
|
const bolt = `<path d="M13.2 4.5 7.5 13.2h4l-1 6.3 5.9-9h-4.1z" fill="${stale ? "#9ca3af" : "#facc15"}"/>`;
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24">${ring}${bolt}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marker diameter in px: grows with log10(meters out), 16–34. */
|
||||||
|
function sizeFor(out: number): number {
|
||||||
|
return Math.round(Math.min(34, 16 + 4 * Math.log10(Math.max(1, out))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function outageIcon(a: OutageArea): L.DivIcon {
|
||||||
|
const stale = a.stale;
|
||||||
|
const out = stale ? a.stale_customers_out : a.customers_out;
|
||||||
|
const s = sizeFor(out);
|
||||||
|
const label = out >= 1000 ? `${(out / 1000).toFixed(out >= 10_000 ? 0 : 1)}k` : String(out);
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div style="position:relative;width:${s}px;height:${s}px;opacity:${stale ? 0.6 : 1}">${outageGlyphSvg(s, stale)}`
|
||||||
|
+ `<span style="position:absolute;top:-7px;left:${s - 6}px;padding:0 3px;border-radius:7px;background:#0f172a;color:#fff;border:1px solid ${stale ? "#9ca3af" : "#facc15"};font:700 9px/12px sans-serif;white-space:nowrap">${label}</span></div>`,
|
||||||
|
iconSize: [s, s],
|
||||||
|
iconAnchor: [s / 2, s / 2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(iso: string | null): 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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutagePopupBody({ a, feed }: { a: OutageArea; feed?: OutageProviderFeed }) {
|
||||||
|
const since = fmt(a.since);
|
||||||
|
const etr = fmt(a.etr);
|
||||||
|
const updated = fmt(a.updated_at);
|
||||||
|
const pct = a.customers_served ? (100 * a.customers_out) / a.customers_served : null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="font-semibold text-sm">{countyName(a)}</div>
|
||||||
|
{a.stale ? (
|
||||||
|
<div className="text-ink-muted">
|
||||||
|
Stale upstream rows only: {a.stale_customers_out.toLocaleString()} meters on events whose own restoration
|
||||||
|
estimate passed more than 24 h ago. The source still lists them; they are not shown as live outages.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<b>{a.customers_out.toLocaleString()}</b> meters/customers out (utility-reported)
|
||||||
|
{pct != null ? ` · ${pct < 0.1 ? "<0.1" : pct.toFixed(1)}% of ${a.customers_served!.toLocaleString()} served` : " · % served not known"}
|
||||||
|
{a.stale_customers_out > 0 && <span className="text-ink-muted"> · plus {a.stale_customers_out.toLocaleString()} on stale rows</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(since || etr) && (
|
||||||
|
<div>{since ? `Out since ${since}` : ""}{since && etr ? " · " : ""}{etr ? `Est. restoration ${etr}` : ""}</div>
|
||||||
|
)}
|
||||||
|
{!etr && !a.stale && <div className="text-ink-muted">No restoration estimate given.</div>}
|
||||||
|
{a.utilities.length > 0 && (
|
||||||
|
<ul className="text-ink-2">
|
||||||
|
{a.utilities.map((u, i) => (
|
||||||
|
<li key={u.eia_id ?? i}>
|
||||||
|
{u.name ?? (u.eia_id ? `EIA ${u.eia_id}` : "Utility")}: {u.customers_out.toLocaleString()} out
|
||||||
|
{u.stale_customers_out ? ` (+${u.stale_customers_out.toLocaleString()} stale)` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] text-ink-muted">County-level count — the marker is the county's centre, not where the outage is.</div>
|
||||||
|
{feed?.coverage_note && <div className="text-[10px] text-ink-muted">{feed.coverage_note}</div>}
|
||||||
|
<SourceCredit credit={feed} fallbackName={feed?.name ?? a.provider}
|
||||||
|
updated={[updated && `utility data ${updated}`, feed && `fetched ${fetchedLabel(feed.fetched_at) ?? "—"}`].filter(Boolean).join(" · ") || null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The layer's always-on status chip. Returns the message, or null while there's nothing to say yet. */
|
||||||
|
function statusFor(state: OutagesState, isAdmin: boolean, liveCount: number, staleCount: number): { text: string; adminLink?: boolean; problem: boolean } {
|
||||||
|
const { data, error, loaded } = state;
|
||||||
|
if (!loaded) return { text: "Power outages: loading…", problem: false };
|
||||||
|
if (error && !data) return { text: "Power outages: could not reach server", problem: true };
|
||||||
|
if (!data) return { text: "Power outages: no data", problem: true };
|
||||||
|
const stale = error ? " (could not refresh — showing the last answer)" : "";
|
||||||
|
switch (data.status) {
|
||||||
|
case "none_enabled":
|
||||||
|
return isAdmin
|
||||||
|
? { text: "Power outages: no outage source is enabled —", adminLink: true, problem: true }
|
||||||
|
: { text: "Power outages: no outage source is enabled. A platform admin can turn one on.", problem: true };
|
||||||
|
case "no_source":
|
||||||
|
return { text: `Power outages: ${data.message ?? "No licensed outage source covers this area."}`, problem: true };
|
||||||
|
case "error": {
|
||||||
|
const bad = data.providers.filter((p) => p.covers_bbox && p.error);
|
||||||
|
return { text: `Power outages: ${bad.map((p) => `${p.name} feed error (${p.error})`).join("; ") || data.message || "feed error"}`, problem: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const covering = data.providers.filter((p) => p.covers_bbox);
|
||||||
|
const names = covering.map((p) => p.name).join(", ");
|
||||||
|
const bad = covering.find((p) => p.error);
|
||||||
|
if (bad) {
|
||||||
|
return {
|
||||||
|
text: `Power outages: ${bad.name} feed error, ${bad.fetched_at ? `showing data fetched ${fetchedLabel(bad.fetched_at)}` : "no data yet"}${stale}`,
|
||||||
|
problem: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (covering.some((p) => p.truncated)) return { text: "Power outages: too many areas in view — zoom in", problem: true };
|
||||||
|
const fetched = covering.map((p) => fetchedLabel(p.fetched_at)).find(Boolean);
|
||||||
|
const when = fetched ? ` · fetched ${fetched}` : "";
|
||||||
|
if (!liveCount) {
|
||||||
|
const note = covering.map((p) => p.coverage_note).find(Boolean);
|
||||||
|
return {
|
||||||
|
text: `Power outages: none reported in view by ${names || "the enabled source"}${staleCount ? ` (${staleCount} stale count${staleCount !== 1 ? "ies" : "y"} dimmed)` : ""}${when}.${note ? ` ${note}` : ""}${stale}`,
|
||||||
|
problem: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: `Power outages: ${liveCount} count${liveCount !== 1 ? "ies" : "y"} with outages · ${names}${when}${staleCount ? ` · ${staleCount} stale dimmed` : ""}${stale}`,
|
||||||
|
problem: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutageStatus({ map, state, liveCount, staleCount }: { map: L.Map; state: OutagesState; liveCount: number; staleCount: number }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
|
||||||
|
});
|
||||||
|
const s = statusFor(state, isAdmin, liveCount, staleCount);
|
||||||
|
return createPortal(
|
||||||
|
<div ref={ref} role="status"
|
||||||
|
className={`absolute bottom-[3.75rem] left-3 z-[1001] max-w-[min(28rem,calc(100%-1.5rem))] bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 ${s.problem ? "border-accent" : "border-line"}`}>
|
||||||
|
{s.text}
|
||||||
|
{s.adminLink && <> <Link href="/admin#outages" className="underline">Admin → Outage providers</Link></>}
|
||||||
|
</div>,
|
||||||
|
map.getContainer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutageLayer({ shown }: { shown: boolean }) {
|
||||||
|
const map = useMap();
|
||||||
|
const state = useMapOutages(map, shown);
|
||||||
|
const areas = useMemo(() => state.data?.areas ?? [], [state.data]);
|
||||||
|
const feeds = useMemo(() => new Map((state.data?.providers ?? []).map((p) => [p.id, p])), [state.data]);
|
||||||
|
const drawable = useMemo(() => areas.filter((a) => a.lat != null && a.lon != null), [areas]);
|
||||||
|
// One icon per area per response: a fresh divIcon each render would make
|
||||||
|
// Leaflet rebuild every marker's DOM whenever MapView re-renders.
|
||||||
|
const icons = useMemo(() => new Map(drawable.map((a) => [a.id, outageIcon(a)])), [drawable]);
|
||||||
|
const liveCount = areas.filter((a) => !a.stale && a.customers_out > 0).length;
|
||||||
|
const staleCount = areas.filter((a) => a.stale).length;
|
||||||
|
if (!shown) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OutageStatus map={map} state={state} liveCount={liveCount} staleCount={staleCount} />
|
||||||
|
{drawable.map((a) => (
|
||||||
|
<Marker key={a.id} position={[a.lat!, a.lon!]} icon={icons.get(a.id)!} zIndexOffset={a.stale ? -100 : 100}
|
||||||
|
title={`${countyName(a)}: ${a.stale ? "stale rows only" : `${a.customers_out.toLocaleString()} out`}`}>
|
||||||
|
<Popup minWidth={240} maxWidth={320}>
|
||||||
|
<OutagePopupBody a={a} feed={feeds.get(a.provider)} />
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(" ")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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) }),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user