Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18bb612df | ||
|
|
49ef914fd2 | ||
|
|
c1a2721fbe | ||
|
|
000a5f11bd | ||
|
|
5dcf1b5e9c | ||
|
|
fb2d737d6f | ||
|
|
c95498e7fe | ||
|
|
3347ced863 | ||
|
|
1a01497f4d | ||
|
|
012cca402a | ||
|
|
3cbb0828ae | ||
|
|
fa207e494d | ||
|
|
b20196c499 | ||
|
|
e5fc6ca838 | ||
|
|
5d97058f9d | ||
|
|
bff69a1d04 | ||
|
|
9b83f0ec6d | ||
|
|
5845fc5694 | ||
|
|
ddf13402d0 | ||
|
|
20c5799a8d | ||
|
|
e90a73ff09 | ||
|
|
e0fdc4fbbc | ||
|
|
65705bf995 | ||
|
|
0543526eb0 | ||
|
|
266c958208 | ||
|
|
9f19750ea6 | ||
|
|
433b35d2ba | ||
|
|
badfe28823 | ||
|
|
737bdf0576 | ||
|
|
b9e7524817 | ||
|
|
e972cace4a | ||
|
|
969d175a67 | ||
|
|
731b54bed9 | ||
|
|
3c642e2946 | ||
|
|
f0a88d401c | ||
|
|
8eac32caf5 | ||
|
|
6e82ee8579 | ||
|
|
cdc61dcc9d | ||
|
|
032e9bd653 | ||
|
|
ec91a9175f | ||
|
|
8dadbdd977 | ||
|
|
aff3f16d32 | ||
|
|
e79b8bc37d | ||
|
|
c72c28f5dc | ||
|
|
02b5b7b5a5 | ||
|
|
40014a47a3 | ||
|
|
6c0e7a4f8e | ||
|
|
6479174022 |
+15
-13
@@ -307,28 +307,30 @@ jobs:
|
|||||||
|
|
||||||
- name: Deploy firestore rules and indexes
|
- name: Deploy firestore rules and indexes
|
||||||
env:
|
env:
|
||||||
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
|
FIREBASE_SA_KEY: ${{ secrets.FIREBASE_SA_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
# server-26#51: this used to run over SSH on the deploy VM, gated
|
# server-26#51: this used to run over SSH on the deploy VM, gated
|
||||||
# on the VM having firebase-tools installed. It never did, so it
|
# on the VM having firebase-tools installed. It never did, so it
|
||||||
# silently warned-and-skipped on every single deploy for weeks.
|
# silently warned-and-skipped on every single deploy for weeks.
|
||||||
# Running it here instead means the only prerequisite is a secret
|
# Auth is a dedicated service account (drb-ci-firestore-deploy,
|
||||||
# -- FIREBASE_TOKEN, from `firebase login:ci` -- rather than
|
# roles: Firebase Rules Admin, Cloud Datastore Index Admin,
|
||||||
# something installed by hand on a machine this pipeline doesn't
|
# Service Usage Consumer), its JSON key stored as the
|
||||||
# otherwise touch. A missing token now fails this job LOUDLY
|
# FIREBASE_SA_KEY secret. Not `firebase login:ci`: those tokens are
|
||||||
# (picked up by notify-failure) instead of a buried warning line
|
# deprecated and carry the full permissions of whoever minted them.
|
||||||
# nobody reads in the app deploy's logs.
|
# A missing key fails this job LOUDLY (picked up by notify-failure).
|
||||||
if [ -z "$FIREBASE_TOKEN" ]; then
|
if [ -z "$FIREBASE_SA_KEY" ]; then
|
||||||
echo "FIREBASE_TOKEN secret is not set -- cannot deploy Firestore rules/indexes." >&2
|
echo "FIREBASE_SA_KEY secret is not set -- cannot deploy Firestore rules/indexes." >&2
|
||||||
echo "Generate one with 'firebase login:ci' and add it as a Gitea Actions secret." >&2
|
echo "Add the drb-ci-firestore-deploy service account's JSON key as a Gitea Actions secret." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
export GOOGLE_APPLICATION_CREDENTIALS="$RUNNER_TEMP/firebase-sa.json"
|
||||||
|
trap 'rm -f "$GOOGLE_APPLICATION_CREDENTIALS"' EXIT
|
||||||
|
( umask 077 && printf '%s' "$FIREBASE_SA_KEY" > "$GOOGLE_APPLICATION_CREDENTIALS" )
|
||||||
npm install -g firebase-tools
|
npm install -g firebase-tools
|
||||||
cd infra/firestore
|
cd infra/firestore
|
||||||
firebase deploy --only firestore:rules,firestore:indexes \
|
firebase deploy --only firestore:rules,firestore:indexes \
|
||||||
--project ${{ secrets.FIREBASE_PROJECT_ID }} \
|
--project ${{ secrets.FIREBASE_PROJECT_ID }} --non-interactive
|
||||||
--token "$FIREBASE_TOKEN" --non-interactive
|
|
||||||
|
|
||||||
notify-failure:
|
notify-failure:
|
||||||
name: Report a failed deploy
|
name: Report a failed deploy
|
||||||
@@ -371,7 +373,7 @@ jobs:
|
|||||||
# failed before any deploy was attempted" text even when the app
|
# failed before any deploy was attempted" text even when the app
|
||||||
# deployed fine and only the Firestore rules/indexes push failed.
|
# deployed fine and only the Firestore rules/indexes push failed.
|
||||||
if deploy_result != "failure" and rules_result == "failure":
|
if deploy_result != "failure" and rules_result == "failure":
|
||||||
detail = "App deploy succeeded; Firestore rules/indexes deploy FAILED (server-26#51). Rules may be stale — check FIREBASE_TOKEN and the job log."
|
detail = "App deploy succeeded; Firestore rules/indexes deploy FAILED (server-26#51). Rules may be stale — check the FIREBASE_SA_KEY secret and the job log."
|
||||||
|
|
||||||
# server-26#65: the old text here unconditionally claimed
|
# server-26#65: the old text here unconditionally claimed
|
||||||
# "production is still running the previous build" -- true only
|
# "production is still running the previous build" -- true only
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ NODE_OFFLINE_THRESHOLD=90
|
|||||||
# Google Maps — for geocoding location strings extracted from transcripts
|
# Google Maps — for geocoding location strings extracted from transcripts
|
||||||
# Enable "Geocoding API" in Cloud Console for this key
|
# Enable "Geocoding API" in Cloud Console for this key
|
||||||
GOOGLE_MAPS_API_KEY=
|
GOOGLE_MAPS_API_KEY=
|
||||||
|
NY511_API_KEY=
|
||||||
|
|
||||||
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
|
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Google Maps (geocoding)
|
# Google Maps (geocoding)
|
||||||
google_maps_api_key: Optional[str] = None
|
google_maps_api_key: Optional[str] = None
|
||||||
|
# 511NY developer key (server-26#183). Optional: the API answered without one
|
||||||
|
# as of 2026-09-27, but its terms require a registered key.
|
||||||
|
ny511_api_key: Optional[str] = None
|
||||||
|
|
||||||
# Gemini (intelligence extraction, embeddings, incident summaries)
|
# Gemini (intelligence extraction, embeddings, incident summaries)
|
||||||
gemini_api_key: Optional[str] = None
|
gemini_api_key: Optional[str] = None
|
||||||
@@ -45,7 +48,10 @@ class Settings(BaseSettings):
|
|||||||
# while correlation behaviour was being tuned against rules-only output.
|
# while correlation behaviour was being tuned against rules-only output.
|
||||||
# Verify against https://ai.google.dev/gemini-api/docs/models before changing.
|
# Verify against https://ai.google.dev/gemini-api/docs/models before changing.
|
||||||
corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down)
|
corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down)
|
||||||
corr_smart_model: str = "gemini-2.5-pro" # was gemini-1.5-pro (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.
|
||||||
@@ -86,7 +92,17 @@ class Settings(BaseSettings):
|
|||||||
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
|
||||||
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
|
incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls (major / unknown severity)
|
||||||
|
# Most jobs never say 10-8 on the air (replay of 09-22, server-26#170: ~5 of
|
||||||
|
# ~25 real incidents had an audible clear), so the quiet timer IS the close
|
||||||
|
# for most of them, and one 90-minute timer kept a lockout or a plate check
|
||||||
|
# "active" on the portal an hour after it ended. Scaled by the incident's
|
||||||
|
# severity instead, and made provisional: a timer-closed incident stays
|
||||||
|
# reopenable for incident_reopen_window_minutes, so a long quiet job that
|
||||||
|
# comes back on the air rejoins its own incident rather than splitting.
|
||||||
|
incident_auto_resolve_minutes_routine: int = 30 # routine / minor
|
||||||
|
incident_auto_resolve_minutes_moderate: int = 60
|
||||||
|
incident_reopen_window_minutes: int = 90 # since last substantive call
|
||||||
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
||||||
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
|
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
|
||||||
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ transcription.py) need the exact same judgment call and must not each grow
|
|||||||
their own slightly-different copy that drifts.
|
their own slightly-different copy that drifts.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextvars import ContextVar
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -59,6 +60,14 @@ def _default_state() -> dict:
|
|||||||
|
|
||||||
_state: dict[str, dict] = {t: _default_state() for t in TIERS}
|
_state: dict[str, dict] = {t: _default_state() for t in TIERS}
|
||||||
|
|
||||||
|
# Set by a replay run to a list it owns; report_degraded appends there instead
|
||||||
|
# of touching _state while inside a sandbox (see app/internal/replay.py).
|
||||||
|
_sandbox_failures: ContextVar[Optional[list]] = ContextVar("drb_ai_sandbox_failures", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_sandbox_failures(sink: Optional[list]):
|
||||||
|
return _sandbox_failures.set(sink)
|
||||||
|
|
||||||
|
|
||||||
def classify(text: str) -> str:
|
def classify(text: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -108,6 +117,16 @@ async def report_degraded(
|
|||||||
TRANSIENT_ALERT_THRESHOLD consecutive failures have been reported for
|
TRANSIENT_ALERT_THRESHOLD consecutive failures have been reported for
|
||||||
this tier, so an ordinary blip never pages anyone.
|
this tier, so an ordinary blip never pages anyone.
|
||||||
"""
|
"""
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
if fstore.in_sandbox():
|
||||||
|
# A replay's rate limits are not a live outage, and must never page
|
||||||
|
# the AI-alert webhook or flip /health/ai (app/internal/replay.py).
|
||||||
|
# They are the run's own problem, so they go to the run instead.
|
||||||
|
sink = _sandbox_failures.get()
|
||||||
|
if sink is not None:
|
||||||
|
sink.append({"tier": tier, "provider": provider, "model": model,
|
||||||
|
"problem": problem, "permanent": permanent})
|
||||||
|
return
|
||||||
if tier not in _state:
|
if tier not in _state:
|
||||||
_state[tier] = _default_state()
|
_state[tier] = _default_state()
|
||||||
entry = _state[tier]
|
entry = _state[tier]
|
||||||
@@ -145,6 +164,9 @@ async def report_healthy(tier: str) -> None:
|
|||||||
just after a failure -- it is what lets a degraded tier recover on its
|
just after a failure -- it is what lets a degraded tier recover on its
|
||||||
own instead of staying red forever after one transient blip.
|
own instead of staying red forever after one transient blip.
|
||||||
"""
|
"""
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
if fstore.in_sandbox():
|
||||||
|
return # nor may a replay's success "recover" a real live outage
|
||||||
if tier not in _state:
|
if tier not in _state:
|
||||||
_state[tier] = _default_state()
|
_state[tier] = _default_state()
|
||||||
entry = _state[tier]
|
entry = _state[tier]
|
||||||
|
|||||||
@@ -448,6 +448,8 @@ async def add_pending(system_id: str, talkgroup_id: Any, entries: list[dict]) ->
|
|||||||
"""
|
"""
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
if fstore.in_sandbox():
|
||||||
|
return 0 # a replay proposes nothing to the live review queue
|
||||||
if not system_id or talkgroup_id is None or not entries:
|
if not system_id or talkgroup_id is None or not entries:
|
||||||
return 0
|
return 0
|
||||||
system_doc = await fstore.doc_get("systems", system_id)
|
system_doc = await fstore.doc_get("systems", system_id)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Pipeline clock.
|
||||||
|
|
||||||
|
`now()` is `datetime.now(timezone.utc)` everywhere except inside a replay run
|
||||||
|
(app/internal/replay.py), which pins it to the replayed call's own time so the
|
||||||
|
correlator's recency windows, the idle-resolve sweep and every started_at /
|
||||||
|
updated_at / resolved_at it writes behave the way they did live.
|
||||||
|
|
||||||
|
A ContextVar rather than a module global: a replay runs as a background task
|
||||||
|
alongside real uploads, and each asyncio task (and every asyncio.to_thread it
|
||||||
|
spawns) carries its own copy of the context, so a pinned clock can never leak
|
||||||
|
into a live call's pipeline.
|
||||||
|
"""
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_pinned: ContextVar[Optional[datetime]] = ContextVar("drb_clock_pinned", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
pinned = _pinned.get()
|
||||||
|
return pinned if pinned is not None else datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def pin(when: Optional[datetime]):
|
||||||
|
"""Pin the clock for the current context. Returns a token for `unpin`."""
|
||||||
|
return _pinned.set(when)
|
||||||
|
|
||||||
|
|
||||||
|
def unpin(token) -> None:
|
||||||
|
_pinned.reset(token)
|
||||||
@@ -6,7 +6,8 @@ in-memory TTL cache so flag reads don't add a Firestore round-trip to every
|
|||||||
call upload.
|
call upload.
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from contextvars import ContextVar
|
||||||
|
from typing import Any, 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
|
||||||
|
|
||||||
@@ -36,6 +37,21 @@ _DEFAULTS: dict[str, bool] = {
|
|||||||
"transcript_correction_enabled": True,
|
"transcript_correction_enabled": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A replay run (app/internal/replay.py) states exactly which AI steps it runs,
|
||||||
|
# independent of the live switches — the whole point is re-running the pipeline
|
||||||
|
# while live AI is OFF. ContextVar so the override never reaches a live upload.
|
||||||
|
_forced: ContextVar[Optional[dict[str, bool]]] = ContextVar("drb_forced_flags", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def force_flags(flags: Optional[dict[str, bool]]):
|
||||||
|
"""Override resolve_flags() for the current context. Returns a reset token."""
|
||||||
|
return _forced.set(flags)
|
||||||
|
|
||||||
|
|
||||||
|
def unforce_flags(token) -> None:
|
||||||
|
_forced.reset(token)
|
||||||
|
|
||||||
|
|
||||||
_cache: dict[str, Any] = {}
|
_cache: dict[str, Any] = {}
|
||||||
_cache_ts: float = 0.0
|
_cache_ts: float = 0.0
|
||||||
|
|
||||||
@@ -211,6 +227,11 @@ async def resolve_flags(system_id: str | None):
|
|||||||
"""
|
"""
|
||||||
from app.internal import firestore as _fstore
|
from app.internal import firestore as _fstore
|
||||||
|
|
||||||
|
forced = _forced.get()
|
||||||
|
if forced is not None:
|
||||||
|
full = {k: bool(forced.get(k, False)) for k in _DEFAULTS}
|
||||||
|
return full, lambda name: full.get(name, False)
|
||||||
|
|
||||||
flags = await get_flags()
|
flags = await get_flags()
|
||||||
|
|
||||||
system_ai_flags: dict = {}
|
system_ai_flags: dict = {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time as _time
|
import time as _time
|
||||||
|
from contextvars import ContextVar
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
import firebase_admin
|
import firebase_admin
|
||||||
from firebase_admin import credentials, firestore as fs
|
from firebase_admin import credentials, firestore as fs
|
||||||
@@ -40,23 +41,61 @@ _init_firebase()
|
|||||||
db = fs.client(database_id=settings.firestore_database)
|
db = fs.client(database_id=settings.firestore_database)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replay sandbox (app/internal/replay.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# While a replay run is executing, every read and write the pipeline makes to
|
||||||
|
# `calls` or `incidents` is redirected to that run's own subcollections under
|
||||||
|
# replay_runs/{run_id}/, so re-running the pipeline over past traffic can never
|
||||||
|
# touch a live call or incident. A subcollection keeps the same collection ID
|
||||||
|
# ("calls"/"incidents"), so the composite indexes prod queries depend on apply
|
||||||
|
# to it unchanged. Everything else (systems, nodes, config) is read from prod
|
||||||
|
# as-is. ContextVar for the same reason as app/internal/clock.py: the redirect
|
||||||
|
# follows the replay task and never a concurrent live upload.
|
||||||
|
SANDBOXED_COLLECTIONS = frozenset({"calls", "incidents"})
|
||||||
|
_sandbox_root: ContextVar[Optional[str]] = ContextVar("drb_fstore_sandbox", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def enter_sandbox(root: Optional[str]):
|
||||||
|
"""Redirect calls/incidents under `root` (e.g. "replay_runs/<id>") for this context."""
|
||||||
|
return _sandbox_root.set(root)
|
||||||
|
|
||||||
|
|
||||||
|
def exit_sandbox(token) -> None:
|
||||||
|
_sandbox_root.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def in_sandbox() -> bool:
|
||||||
|
"""True inside a replay run. Anything that writes live state OTHER than
|
||||||
|
calls/incidents (AI health alerts, pending-term queues) checks this and
|
||||||
|
stands down — the redirect below only covers the two sandboxed collections."""
|
||||||
|
return _sandbox_root.get() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _path(collection: str) -> str:
|
||||||
|
root = _sandbox_root.get()
|
||||||
|
if root and collection in SANDBOXED_COLLECTIONS:
|
||||||
|
return f"{root}/{collection}"
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Thin async wrappers — firebase-admin is synchronous, run in thread executor
|
# Thin async wrappers — firebase-admin is synchronous, run in thread executor
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def doc_set(collection: str, doc_id: str, data: dict, merge: bool = True) -> None:
|
async def doc_set(collection: str, doc_id: str, data: dict, merge: bool = True) -> None:
|
||||||
ref = db.collection(collection).document(doc_id)
|
ref = db.collection(_path(collection)).document(doc_id)
|
||||||
await asyncio.to_thread(ref.set, data, merge=merge)
|
await asyncio.to_thread(ref.set, data, merge=merge)
|
||||||
|
|
||||||
|
|
||||||
async def doc_get(collection: str, doc_id: str) -> Optional[dict]:
|
async def doc_get(collection: str, doc_id: str) -> Optional[dict]:
|
||||||
ref = db.collection(collection).document(doc_id)
|
ref = db.collection(_path(collection)).document(doc_id)
|
||||||
snap = await asyncio.to_thread(ref.get)
|
snap = await asyncio.to_thread(ref.get)
|
||||||
return snap.to_dict() if snap.exists else None
|
return snap.to_dict() if snap.exists else None
|
||||||
|
|
||||||
|
|
||||||
async def doc_update(collection: str, doc_id: str, data: dict) -> None:
|
async def doc_update(collection: str, doc_id: str, data: dict) -> None:
|
||||||
ref = db.collection(collection).document(doc_id)
|
ref = db.collection(_path(collection)).document(doc_id)
|
||||||
await asyncio.to_thread(ref.update, data)
|
await asyncio.to_thread(ref.update, data)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +105,7 @@ async def collection_list(collection: str, **filters) -> list[dict]:
|
|||||||
Optional keyword filters: field=value pairs passed as equality where-clauses.
|
Optional keyword filters: field=value pairs passed as equality where-clauses.
|
||||||
"""
|
"""
|
||||||
def _query():
|
def _query():
|
||||||
ref = db.collection(collection)
|
ref = db.collection(_path(collection))
|
||||||
for field, value in filters.items():
|
for field, value in filters.items():
|
||||||
ref = ref.where(filter=FieldFilter(field, "==", value))
|
ref = ref.where(filter=FieldFilter(field, "==", value))
|
||||||
return [doc.to_dict() for doc in ref.stream()]
|
return [doc.to_dict() for doc in ref.stream()]
|
||||||
@@ -103,7 +142,7 @@ async def collection_where(
|
|||||||
unscoped equality-only lookups can keep using collection_list().
|
unscoped equality-only lookups can keep using collection_list().
|
||||||
"""
|
"""
|
||||||
def _query():
|
def _query():
|
||||||
ref = db.collection(collection)
|
ref = db.collection(_path(collection))
|
||||||
for field, op, value in conditions:
|
for field, op, value in conditions:
|
||||||
ref = ref.where(filter=FieldFilter(field, op, value))
|
ref = ref.where(filter=FieldFilter(field, op, value))
|
||||||
for field, direction in (order_by or []):
|
for field, direction in (order_by or []):
|
||||||
@@ -118,7 +157,7 @@ async def collection_where(
|
|||||||
|
|
||||||
|
|
||||||
async def doc_delete(collection: str, doc_id: str) -> None:
|
async def doc_delete(collection: str, doc_id: str) -> None:
|
||||||
ref = db.collection(collection).document(doc_id)
|
ref = db.collection(_path(collection)).document(doc_id)
|
||||||
await asyncio.to_thread(ref.delete)
|
await asyncio.to_thread(ref.delete)
|
||||||
|
|
||||||
|
|
||||||
@@ -128,7 +167,7 @@ async def doc_get_cached(collection: str, doc_id: str, ttl: float = 300.0) -> Op
|
|||||||
Use for documents that change rarely (systems config, node assignments).
|
Use for documents that change rarely (systems config, node assignments).
|
||||||
Default TTL is 5 minutes — a write will be visible within that window.
|
Default TTL is 5 minutes — a write will be visible within that window.
|
||||||
"""
|
"""
|
||||||
key = f"{collection}/{doc_id}"
|
key = f"{_path(collection)}/{doc_id}"
|
||||||
now = _time.monotonic()
|
now = _time.monotonic()
|
||||||
entry = _doc_cache.get(key)
|
entry = _doc_cache.get(key)
|
||||||
if entry and now < entry[0]:
|
if entry and now < entry[0]:
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
One place every Gemini call goes through: JSON-mode generation, an explicit
|
||||||
|
thinking level, and token accounting.
|
||||||
|
|
||||||
|
Why it exists: a day of replay runs (server-26#170) cost ~$5 of Gemini for
|
||||||
|
~7 two-hour windows — roughly $0.70 per 290 calls, which projects to several
|
||||||
|
dollars a day per live deployment for correlation alone — and nothing in DRB
|
||||||
|
could say where it went (server-26#45). Gemini 3.x models "think" by default
|
||||||
|
and bill that as output; the old google-generativeai SDK these calls used
|
||||||
|
cannot even set a thinking level. A link/new/orphan choice or a transcript
|
||||||
|
cleanup does not need extended reasoning.
|
||||||
|
|
||||||
|
Every call logs its token counts, and inside a replay run they are also added
|
||||||
|
to the run's own usage sink (see app/internal/replay.py), so a run reports
|
||||||
|
what it actually spent instead of an estimate.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
_client = None
|
||||||
|
_client_lock = threading.Lock()
|
||||||
|
# Models that rejected a thinking level: retried without one from then on.
|
||||||
|
_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():
|
||||||
|
global _client
|
||||||
|
with _client_lock:
|
||||||
|
if _client is None:
|
||||||
|
from google import genai # lazy — only when a Gemini call is made
|
||||||
|
_client = genai.Client(api_key=settings.gemini_api_key)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _config(thinking_level: Optional[str]):
|
||||||
|
from google.genai import types
|
||||||
|
kwargs = {"response_mime_type": "application/json"}
|
||||||
|
if thinking_level:
|
||||||
|
kwargs["thinking_config"] = types.ThinkingConfig(thinking_level=thinking_level)
|
||||||
|
return types.GenerateContentConfig(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(purpose: str, model: str, usage) -> None:
|
||||||
|
prompt = getattr(usage, "prompt_token_count", None) or 0
|
||||||
|
output = getattr(usage, "candidates_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}")
|
||||||
|
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"] += thoughts
|
||||||
|
|
||||||
|
|
||||||
|
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, exactly like the old per-module helpers, so callers'
|
||||||
|
ai_health classification (billing / dead model / transient) is unchanged.
|
||||||
|
"""
|
||||||
|
client = _get_client()
|
||||||
|
level = None if model in _no_thinking_level else thinking_level
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(model=model, contents=prompt, config=_config(level))
|
||||||
|
except Exception as e:
|
||||||
|
# A model that doesn't accept this thinking level answers 400 for
|
||||||
|
# every call; drop the setting for that model rather than lose the tier.
|
||||||
|
if level and "thinking" in str(e).lower():
|
||||||
|
logger.warning(f"gemini: {model} rejected thinking_level={level!r} ({e}); retrying without it")
|
||||||
|
_no_thinking_level.add(model)
|
||||||
|
resp = client.models.generate_content(model=model, contents=prompt, config=_config(None))
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
_record(purpose, model, getattr(resp, "usage_metadata", None))
|
||||||
|
return json.loads(resp.text)
|
||||||
@@ -51,6 +51,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
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 clock
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
_PURSUIT_TAGS = frozenset({
|
_PURSUIT_TAGS = frozenset({
|
||||||
@@ -223,6 +224,38 @@ def _normalize_unit(unit: str) -> str:
|
|||||||
return key or unit.strip().lower()
|
return key or unit.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _after_close(inc: dict, now: datetime) -> bool:
|
||||||
|
try:
|
||||||
|
closed = datetime.fromisoformat(str(inc.get("resolved_at") or "").replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
if closed.tzinfo is None:
|
||||||
|
closed = closed.replace(tzinfo=timezone.utc)
|
||||||
|
return now > closed
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trackable_unit(unit: str) -> bool:
|
||||||
|
"""
|
||||||
|
Whether a unit is concrete enough to hold an incident open until it clears.
|
||||||
|
|
||||||
|
Extraction lists everything that sounds like a unit — "Desk", "Central",
|
||||||
|
"Division", "sergeant", "unknown", and plate phonetics ("John Henry
|
||||||
|
Zebra"). None of those ever transmit a 10-8, so while they sat in
|
||||||
|
units_active the all-clear gate below could never pass: in the first
|
||||||
|
replay (server-26#170, 09-22 10:00-12:00) 0 of 19 incidents resolved on
|
||||||
|
a clear and every one had such a name in units_active. A real radio unit
|
||||||
|
ID carries a number ("45-9", "11-Adam 2", "Whitestone 1", "E-14"), so
|
||||||
|
only those gate resolution. The others are still kept in `units` and
|
||||||
|
still match for correlation.
|
||||||
|
"""
|
||||||
|
if _TEN_CODE_RE.match((unit or "").strip()):
|
||||||
|
return False # "10-8" read back as a unit ID is the status, not a unit
|
||||||
|
return any(ch.isdigit() for ch in unit or "")
|
||||||
|
|
||||||
|
|
||||||
|
_TEN_CODE_RE = re.compile(r"^10[\s-]?\d{1,2}$")
|
||||||
|
|
||||||
|
|
||||||
def _unit_keys(units: Optional[list[str]]) -> set[str]:
|
def _unit_keys(units: Optional[list[str]]) -> set[str]:
|
||||||
"""Comparison keys for a unit list, empties dropped."""
|
"""Comparison keys for a unit list, empties dropped."""
|
||||||
return {k for k in (_normalize_unit(u) for u in (units or [])) if k}
|
return {k for k in (_normalize_unit(u) for u in (units or [])) if k}
|
||||||
@@ -310,9 +343,21 @@ def clean_location(value) -> Optional[str]:
|
|||||||
s = str(value).strip()
|
s = str(value).strip()
|
||||||
if not s or not _LOCATION_WORD_RE.search(s):
|
if not s or not _LOCATION_WORD_RE.search(s):
|
||||||
return None
|
return None
|
||||||
|
if _RADIO_CODE_RE.match(s):
|
||||||
|
return None
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# Status/disposition codes the extractor sometimes returns as a location:
|
||||||
|
# "96 times 5" (a disposition code read aloud) titled a 09-22 replay stop
|
||||||
|
# "Traffic Stop at 96 times 5" (server-26#170); "10-8", "signal 99", "code 4"
|
||||||
|
# are the same shape.
|
||||||
|
_RADIO_CODE_RE = re.compile(
|
||||||
|
r"^\s*(?:\d{1,3}\s*(?:times|x)\s*\d{1,3}|10[\s-]?\d{1,3}|(?:signal|code|condition)\s+\d{1,3})\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def location_is_unit(location, units) -> bool:
|
def location_is_unit(location, units) -> bool:
|
||||||
"""
|
"""
|
||||||
True when a location label is really one of the incident's own unit
|
True when a location label is really one of the incident's own unit
|
||||||
@@ -595,7 +640,14 @@ def _incident_at_capacity(inc: dict, now: datetime) -> Optional[str]:
|
|||||||
it has and still auto-resolves on the normal idle sweep. It just stops
|
it has and still auto-resolves on the normal idle sweep. It just stops
|
||||||
being a candidate, so the next call opens a fresh incident.
|
being a candidate, so the next call opens a fresh incident.
|
||||||
"""
|
"""
|
||||||
call_count = len(inc.get("call_ids") or [])
|
# Content-free replies ("10-4", "Cut.", "Copy") are what filled the cap:
|
||||||
|
# the 09-22 bridge MVA hit 40 calls in 32 minutes with ~40% of them thin,
|
||||||
|
# split in half, and its second half took a different job's title. Only
|
||||||
|
# substantive calls count; incidents written before this field existed
|
||||||
|
# fall back to the raw count.
|
||||||
|
call_count = inc.get("substantive_call_count")
|
||||||
|
if call_count is None:
|
||||||
|
call_count = len(inc.get("call_ids") or [])
|
||||||
if call_count >= settings.incident_max_calls:
|
if call_count >= settings.incident_max_calls:
|
||||||
return f"call_cap:{call_count}"
|
return f"call_cap:{call_count}"
|
||||||
span = _incident_span_minutes(inc, now)
|
span = _incident_span_minutes(inc, now)
|
||||||
@@ -812,7 +864,7 @@ async def _build_context(
|
|||||||
transcript: Optional[str] = None,
|
transcript: Optional[str] = None,
|
||||||
scene_index: int = 0,
|
scene_index: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
now = reference_time or datetime.now(timezone.utc)
|
now = reference_time or clock.now()
|
||||||
window = timedelta(hours=settings.correlation_window_hours)
|
window = timedelta(hours=settings.correlation_window_hours)
|
||||||
|
|
||||||
call_doc = await fstore.doc_get("calls", call_id) or {}
|
call_doc = await fstore.doc_get("calls", call_id) or {}
|
||||||
@@ -828,8 +880,16 @@ async def _build_context(
|
|||||||
# the whole collection rather than being unable to correlate at all.
|
# the whole collection rather than being unable to correlate at all.
|
||||||
if org_id is not None:
|
if org_id is not None:
|
||||||
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
|
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
|
||||||
|
reopenable = await fstore.collection_list("incidents", status="resolved", reopenable=True, org_id=org_id)
|
||||||
else:
|
else:
|
||||||
all_active = await fstore.collection_list("incidents", status="active")
|
all_active = await fstore.collection_list("incidents", status="active")
|
||||||
|
reopenable = await fstore.collection_list("incidents", status="resolved", reopenable=True)
|
||||||
|
# A timer-closed incident is provisional (summarizer._resolve_stale_incidents):
|
||||||
|
# inside its reopen window it is still a candidate, and linking a call to
|
||||||
|
# it reopens it (_update_incident). The fast path's own recency gate
|
||||||
|
# (tg_fast_path_idle_minutes) still applies to it like any other candidate.
|
||||||
|
reopen_window = timedelta(minutes=settings.incident_reopen_window_minutes)
|
||||||
|
all_active += [inc for inc in reopenable if _idle_gate_minutes(inc, now) <= reopen_window.total_seconds() / 60]
|
||||||
# Incidents past the hard caps are removed from the candidate pool here, so
|
# Incidents past the hard caps are removed from the candidate pool here, so
|
||||||
# neither the rules engine nor the LLM tier (which reads ctx["recent"] /
|
# neither the rules engine nor the LLM tier (which reads ctx["recent"] /
|
||||||
# ctx["all_active"]) can propose linking into one.
|
# ctx["all_active"]) can propose linking into one.
|
||||||
@@ -1909,11 +1969,19 @@ def _apply_unit_clearance(inc: dict, cleared: list[str]) -> tuple[list[str], lis
|
|||||||
"""
|
"""
|
||||||
units_active = list(inc.get("units_active") or [])
|
units_active = list(inc.get("units_active") or [])
|
||||||
units_cleared = list(inc.get("units_cleared") or [])
|
units_cleared = list(inc.get("units_cleared") or [])
|
||||||
for u in cleared:
|
# Compared by normalised key: the unit that cleared as "11-Adam" is the
|
||||||
if u in units_active:
|
# one that went active as "11 Adam", and exact equality left it active.
|
||||||
units_active.remove(u)
|
# Only a unit that was actually active here can clear here — a clear from
|
||||||
if u not in units_cleared:
|
# a unit never on this incident says nothing about whether it is over, and
|
||||||
|
# counting it let one stray 10-8 close an incident still being worked.
|
||||||
|
cleared_keys = _unit_keys(cleared)
|
||||||
|
releasing = [u for u in units_active if _normalize_unit(u) in cleared_keys]
|
||||||
|
units_active = [u for u in units_active if _normalize_unit(u) not in cleared_keys]
|
||||||
|
known_cleared = _unit_keys(units_cleared)
|
||||||
|
for u in releasing:
|
||||||
|
if _normalize_unit(u) not in known_cleared:
|
||||||
units_cleared.append(u)
|
units_cleared.append(u)
|
||||||
|
known_cleared.add(_normalize_unit(u))
|
||||||
auto_resolved = bool(units_cleared) and not units_active
|
auto_resolved = bool(units_cleared) and not units_active
|
||||||
return units_active, units_cleared, auto_resolved
|
return units_active, units_cleared, auto_resolved
|
||||||
|
|
||||||
@@ -1951,6 +2019,7 @@ async def _release_reassigned_units(ctx: dict, exclude_incident_id: Optional[str
|
|||||||
if auto_resolved:
|
if auto_resolved:
|
||||||
updates["status"] = "resolved"
|
updates["status"] = "resolved"
|
||||||
updates["resolved_at"] = now.isoformat()
|
updates["resolved_at"] = now.isoformat()
|
||||||
|
updates["resolved_via"] = "reassignment"
|
||||||
await fstore.doc_set("incidents", inc["incident_id"], updates)
|
await fstore.doc_set("incidents", inc["incident_id"], updates)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator: reassignment released unit(s) {matched} from incident "
|
f"Correlator: reassignment released unit(s) {matched} from incident "
|
||||||
@@ -1982,7 +2051,8 @@ async def _update_incident(
|
|||||||
incident_id = inc["incident_id"]
|
incident_id = inc["incident_id"]
|
||||||
|
|
||||||
call_ids = list(inc.get("call_ids") or [])
|
call_ids = list(inc.get("call_ids") or [])
|
||||||
if call_id not in call_ids:
|
is_new_call = call_id not in call_ids
|
||||||
|
if is_new_call:
|
||||||
call_ids.append(call_id)
|
call_ids.append(call_id)
|
||||||
|
|
||||||
talkgroup_ids = list(inc.get("talkgroup_ids") or [])
|
talkgroup_ids = list(inc.get("talkgroup_ids") or [])
|
||||||
@@ -2007,9 +2077,11 @@ async def _update_incident(
|
|||||||
# units_active = units currently on scene; units_cleared = units back in service
|
# units_active = units currently on scene; units_cleared = units back in service
|
||||||
units_active = list(inc.get("units_active") or [])
|
units_active = list(inc.get("units_active") or [])
|
||||||
units_cleared = list(inc.get("units_cleared") or [])
|
units_cleared = list(inc.get("units_cleared") or [])
|
||||||
|
tracked = _unit_keys(units_active) | _unit_keys(units_cleared)
|
||||||
for u in call_units:
|
for u in call_units:
|
||||||
if u not in units_cleared and u not in units_active:
|
if _is_trackable_unit(u) and _normalize_unit(u) not in tracked:
|
||||||
units_active.append(u)
|
units_active.append(u)
|
||||||
|
tracked.add(_normalize_unit(u))
|
||||||
inc_with_active_update = {**inc, "units_active": units_active, "units_cleared": units_cleared}
|
inc_with_active_update = {**inc, "units_active": units_active, "units_cleared": units_cleared}
|
||||||
units_active, units_cleared, _ = _apply_unit_clearance(inc_with_active_update, cleared_units or [])
|
units_active, units_cleared, _ = _apply_unit_clearance(inc_with_active_update, cleared_units or [])
|
||||||
|
|
||||||
@@ -2052,6 +2124,11 @@ async def _update_incident(
|
|||||||
# 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()
|
updates["updated_at"] = _floor_at_started_at(inc, now).isoformat()
|
||||||
|
if is_new_call: # a second scene of the same call is not a second call
|
||||||
|
updates["substantive_call_count"] = (
|
||||||
|
inc.get("substantive_call_count")
|
||||||
|
if inc.get("substantive_call_count") is not None else len(inc.get("call_ids") or [])
|
||||||
|
) + 1
|
||||||
else:
|
else:
|
||||||
updates["last_thin_at"] = now.isoformat()
|
updates["last_thin_at"] = now.isoformat()
|
||||||
# Update incident type when a re-classified call provides a concrete type.
|
# Update incident type when a re-classified call provides a concrete type.
|
||||||
@@ -2069,9 +2146,19 @@ async def _update_incident(
|
|||||||
# Signal-based auto-resolve: every tracked unit has cleared, none still active.
|
# Signal-based auto-resolve: every tracked unit has cleared, none still active.
|
||||||
# Requires at least one unit to have explicitly signalled back-in-service so we
|
# Requires at least one unit to have explicitly signalled back-in-service so we
|
||||||
# don't fire on incidents where units were never tracked (no unit mentions at all).
|
# don't fire on incidents where units were never tracked (no unit mentions at all).
|
||||||
|
if inc.get("status") == "resolved" and refresh_activity and _after_close(inc, now):
|
||||||
|
# A timer close was provisional and a related, substantive call arrived
|
||||||
|
# after it. A thin "10-4" rides along without reopening (it would not
|
||||||
|
# refresh updated_at, so the next sweep would just close it again),
|
||||||
|
# and neither does a sweep link of a call from before the close.
|
||||||
|
updates.update({"status": "active", "resolved_at": None, "resolved_via": None,
|
||||||
|
"reopenable": False, "reopened_count": (inc.get("reopened_count") or 0) + 1})
|
||||||
|
logger.info(f"Correlator: reopened timer-closed incident {incident_id} (call {call_id})")
|
||||||
|
|
||||||
if units_cleared and not units_active:
|
if units_cleared and not units_active:
|
||||||
updates["status"] = "resolved"
|
updates["status"] = "resolved"
|
||||||
updates["resolved_at"] = now.isoformat()
|
updates["resolved_at"] = now.isoformat()
|
||||||
|
updates["resolved_via"] = "units_cleared"
|
||||||
await fstore.doc_set("incidents", incident_id, updates)
|
await fstore.doc_set("incidents", incident_id, updates)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator: signal-resolved incident {incident_id} "
|
f"Correlator: signal-resolved incident {incident_id} "
|
||||||
@@ -2136,11 +2223,12 @@ async def _create_incident(
|
|||||||
**location_fields,
|
**location_fields,
|
||||||
"location_mentions": [location] if location else [],
|
"location_mentions": [location] if location else [],
|
||||||
"call_ids": [call_id],
|
"call_ids": [call_id],
|
||||||
|
"substantive_call_count": 1,
|
||||||
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
|
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
|
||||||
"system_ids": [system_id] if system_id else [],
|
"system_ids": [system_id] if system_id else [],
|
||||||
"tags": tags + ["auto-generated"],
|
"tags": tags + ["auto-generated"],
|
||||||
"units": call_units,
|
"units": call_units,
|
||||||
"units_active": list(call_units),
|
"units_active": [u for u in call_units if _is_trackable_unit(u)],
|
||||||
"units_cleared": [],
|
"units_cleared": [],
|
||||||
"vehicles": call_vehicles,
|
"vehicles": call_vehicles,
|
||||||
"srcaddrs": [call_srcaddr] if call_srcaddr else [],
|
"srcaddrs": [call_srcaddr] if call_srcaddr else [],
|
||||||
@@ -2274,7 +2362,8 @@ async def maybe_resolve_parent(incident_id: str) -> None:
|
|||||||
# All children resolved — close the master
|
# All children resolved — close the master
|
||||||
await fstore.doc_set("incidents", parent_id, {
|
await fstore.doc_set("incidents", parent_id, {
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
"resolved_at": clock.now().isoformat(),
|
||||||
|
"resolved_via": "children_resolved",
|
||||||
})
|
})
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Auto-resolved master incident {parent_id} "
|
f"Auto-resolved master incident {parent_id} "
|
||||||
@@ -2311,6 +2400,10 @@ async def _find_cross_system_parent(
|
|||||||
best_score = 0.0
|
best_score = 0.0
|
||||||
|
|
||||||
for inc in recent:
|
for inc in recent:
|
||||||
|
# A timer-closed incident is in `recent` only so a related call can
|
||||||
|
# reopen it; it must not be adopted as another agency's parent.
|
||||||
|
if inc.get("status") != "active":
|
||||||
|
continue
|
||||||
# Only cross-system candidates
|
# Only cross-system candidates
|
||||||
if system_id in (inc.get("system_ids") or []):
|
if system_id in (inc.get("system_ids") or []):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import re
|
|||||||
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 area_context
|
from app.internal import area_context
|
||||||
from app.internal.chatter_classifier import classify_chatter
|
from app.internal.chatter_classifier import classify_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
|
||||||
@@ -66,7 +67,7 @@ Rules:
|
|||||||
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
|
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
|
||||||
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text. If a unit ID format is given below, use it to recognise a unit spoken in a shortened or partial form (e.g. just the phonetic name alone) as the same unit — but still only extract what is actually said, never fabricate the full form.
|
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text. If a unit ID format is given below, use it to recognise a unit spoken in a shortened or partial form (e.g. just the phonetic name alone) as the same unit — but still only extract what is actually said, never fabricate the full form.
|
||||||
- Do not invent details not present in the transcript.
|
- Do not invent details not present in the transcript.
|
||||||
- incident_type: FIRST decide whether this transmission has any incident behind it at all, using the same bar as the "routine" severity rule below — pure administrative/status traffic with nothing describable happening: post/unit check-ins, roll call, bare acknowledgements ("10-4", "copy", "received"), records/report exchanges, "show me admin"/"show me available", a status ten-code with no event attached. If it is administrative/status-only, return "unknown" — this applies on EVERY channel, including a police channel; do not let the channel default override it (server-26#138: forcing a channel default onto content-free chatter is what let radio housekeeping open incidents). Only once real event content is present, let the talkgroup channel be your primary signal for WHICH type. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When the channel is a police channel, a real event is present, and nothing in the transcript contradicts it, return "police". Reserve "other" for a real event that genuinely belongs to no emergency service (rail operations, public works, utility coordination) — not for administrative chatter, which is "unknown" per above regardless of channel. Also reserve "unknown" for transcripts too garbled to place at all.
|
- incident_type: FIRST decide whether this transmission has any incident behind it at all, using the same bar as the "routine" severity rule below — pure administrative/status traffic with nothing describable happening: post/unit check-ins, roll call, bare acknowledgements ("10-4", "copy", "received"), records/report exchanges, "show me admin"/"show me available", a status ten-code with no event attached. If it is administrative/status-only, return "unknown" — this applies on EVERY channel, including a police channel; do not let the channel default override it (server-26#138: forcing a channel default onto content-free chatter is what let radio housekeeping open incidents). Only once real event content is present, let the talkgroup channel be your primary signal for WHICH type. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When the channel is a police channel, a real event is present, and nothing in the transcript contradicts it, return "police". Reserve "other" for a real event that genuinely belongs to no emergency service (rail operations, public works, utility coordination) — not for administrative chatter, which is "unknown" per above regardless of channel. Also reserve "unknown" for transcripts too garbled to place at all. A unit reporting its OWN activity is a real event, not status traffic: "on a stop" / traffic stop / car stop, "out with a vehicle", "put me out with a pedestrian/subject" — return "police", tag it (e.g. "traffic-stop", "pedestrian-assist"), severity at least "minor". The plate/license lookups for that stop belong to it.
|
||||||
- severity: ALWAYS return one of the four values. Judge the underlying event, not how dramatic the words sound.
|
- severity: ALWAYS return one of the four values. Judge the underlying event, not how dramatic the words sound.
|
||||||
"routine" — administrative/status traffic with no incident behind it: mileage and transport logging, radio checks, acknowledgements, shift changes, track block/power requests, records lookups.
|
"routine" — administrative/status traffic with no incident behind it: mileage and transport logging, radio checks, acknowledgements, shift changes, track block/power requests, records lookups.
|
||||||
"minor" — a real but low-stakes call: lift assist, parking complaint, past-tense larceny report, noise complaint, welfare check.
|
"minor" — a real but low-stakes call: lift assist, parking complaint, past-tense larceny report, noise complaint, welfare check.
|
||||||
@@ -246,18 +247,34 @@ async def extract_scenes(
|
|||||||
f"Intelligence: call {call_id} — transcript too short for extraction "
|
f"Intelligence: call {call_id} — transcript too short for extraction "
|
||||||
f"({len(transcript.split())} words), skipping"
|
f"({len(transcript.split())} words), skipping"
|
||||||
)
|
)
|
||||||
|
cleared_unit = _short_clearance_unit(transcript)
|
||||||
try:
|
try:
|
||||||
# Severity is still recorded: a five-word acknowledgement is genuinely
|
# Severity is still recorded: a five-word acknowledgement is genuinely
|
||||||
# routine traffic, and downstream code treats a missing severity as
|
# routine traffic, and downstream code treats a missing severity as
|
||||||
# "not yet processed" rather than "nothing happened".
|
# "not yet processed" rather than "nothing happened".
|
||||||
await fstore.doc_set("calls", call_id, {
|
updates = {
|
||||||
"skip_reason": "transcript_too_short",
|
"skip_reason": "transcript_too_short",
|
||||||
"severity": "routine",
|
"severity": "routine",
|
||||||
"chatter_classifier_verdict": chatter_is_chatter,
|
"chatter_classifier_verdict": chatter_is_chatter,
|
||||||
"chatter_classifier_reason": chatter_reason,
|
"chatter_classifier_reason": chatter_reason,
|
||||||
})
|
}
|
||||||
|
if cleared_unit:
|
||||||
|
updates["units"] = [cleared_unit]
|
||||||
|
updates["cleared_units"] = [cleared_unit]
|
||||||
|
await fstore.doc_set("calls", call_id, updates)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if cleared_unit:
|
||||||
|
logger.info(f"Intelligence: call {call_id} — short clearance from {cleared_unit!r}")
|
||||||
|
return [_clearance_scene(transcript, cleared_unit)]
|
||||||
|
# "Adam 3 on a stop" is the whole report of a stop, and it is <=5
|
||||||
|
# words: the self-initiated backstop has to run here too or the most
|
||||||
|
# common phrasing never opens an incident.
|
||||||
|
tags, typ, sev = _self_initiated_backstop(transcript, [], None, "routine", talkgroup_name)
|
||||||
|
if tags:
|
||||||
|
logger.info(f"Intelligence: call {call_id} — short self-initiated report {tags}")
|
||||||
|
return [{**_clearance_scene(transcript, ""), "units": [], "cleared_units": [],
|
||||||
|
"tags": tags, "incident_type": typ, "severity": sev}]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -268,11 +285,26 @@ async def extract_scenes(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
raw_scenes: list[dict] = await asyncio.to_thread(
|
try:
|
||||||
_sync_extract,
|
raw_scenes: list[dict] = await asyncio.to_thread(
|
||||||
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
_sync_extract,
|
||||||
unit_format_hint,
|
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
||||||
)
|
unit_format_hint,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
text = str(e)
|
||||||
|
kind = ai_health.classify(text)
|
||||||
|
logger.warning(f"GPT-4o-mini extraction failed for call {call_id}: {text}")
|
||||||
|
await ai_health.report_degraded(
|
||||||
|
"extraction", "openai", "gpt-4o-mini",
|
||||||
|
{"billing": "the OpenAI account is out of credit",
|
||||||
|
"dead_model": "model is unavailable"}.get(kind, f"extraction failed: {text[:200]}"),
|
||||||
|
{"billing": "Top up OpenAI billing",
|
||||||
|
"dead_model": "Update the extraction model in intelligence.py"}.get(kind, "Usually transient"),
|
||||||
|
permanent=kind != "transient",
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
await ai_health.report_healthy("extraction")
|
||||||
|
|
||||||
if not raw_scenes:
|
if not raw_scenes:
|
||||||
return []
|
return []
|
||||||
@@ -394,6 +426,10 @@ async def extract_scenes(
|
|||||||
transcript, segments, segment_indices, transcript_corrected
|
transcript, segments, segment_indices, transcript_corrected
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tags, incident_type, severity = _self_initiated_backstop(
|
||||||
|
scene_transcript or transcript, tags, incident_type, severity, talkgroup_name,
|
||||||
|
)
|
||||||
|
|
||||||
processed.append({
|
processed.append({
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"incident_type": incident_type,
|
"incident_type": incident_type,
|
||||||
@@ -453,6 +489,139 @@ async def extract_scenes(
|
|||||||
return processed
|
return processed
|
||||||
|
|
||||||
|
|
||||||
|
# Self-initiated activity: a unit putting itself "on a stop" or "out with" a
|
||||||
|
# vehicle/pedestrian. Replay of 09-22 (server-26#170): every traffic stop on
|
||||||
|
# the Ch 1 channel ("45 Adam on a stop, Eastbound Central Express", "CM2 on
|
||||||
|
# the stop, southbound") came back untyped/untagged/routine from extraction —
|
||||||
|
# read as status traffic — so the creation gate never opened an incident and
|
||||||
|
# the stop was visible only in the archive. The prompt now says so too; this
|
||||||
|
# is the deterministic backstop, because a tag is what the creation gate
|
||||||
|
# counts as substance (incident_correlator.has_event_substance).
|
||||||
|
# Only a unit's own "on a stop" self-report — a bare "traffic stop"/"car stop"
|
||||||
|
# mention (a plate lookup on a records channel, a dispatcher's question) is
|
||||||
|
# left to the prompt, and "pull over" is too common in non-stop traffic
|
||||||
|
# ("Medic 2 pull over to the side") to trust (review of 433b35d).
|
||||||
|
_SELF_INITIATED = (
|
||||||
|
(re.compile(r"\bon (a|the) (traffic |car |vehicle |motor vehicle )?stop\b", re.IGNORECASE),
|
||||||
|
"traffic-stop"),
|
||||||
|
(re.compile(r"\b((put|show) me out with|out with (a|one) (pedestrian|vehicle|disabled|male|female|"
|
||||||
|
r"subject|party|juvenile))\b", re.IGNORECASE),
|
||||||
|
"self-initiated"),
|
||||||
|
)
|
||||||
|
_NEGATED = re.compile(r"\b(not|don't|dont|no|never)\s+(\S+\s+){0,5}$", re.IGNORECASE)
|
||||||
|
# "Train 4 holding on the stop", "out with a disabled on the bridge": on rail,
|
||||||
|
# bridge/tunnel, fire and EMS channels these phrases are operations, not a
|
||||||
|
# police stop. Everywhere else — including "Ch 1 (Patched ...)", which is where
|
||||||
|
# the stops actually are — the backstop applies.
|
||||||
|
_NO_BACKSTOP_TG = re.compile(r"\b(mta|rail|railroad|train|transit|bridges? and tunnels|fire|ems|"
|
||||||
|
r"rescue|ambulance|dpw|public works)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
# A plate read aloud — two or more phonetic letters then 3-7 digits:
|
||||||
|
# "Frank David Boy, 4514", "Lincoln, Charlie, Robert, 7-4-0-7". On a patrol
|
||||||
|
# channel that is a unit running a car it has stopped. Held-out replay of
|
||||||
|
# 09-21 (server-26#170): Ossining's Post 4 stops were read out only as plates
|
||||||
|
# and never became incidents.
|
||||||
|
_PHONETIC = (r"(?:adam|alpha|baker|boy|bravo|charlie|charles|david|delta|eddie|edward|echo|frank|"
|
||||||
|
r"george|golf|henry|hotel|ida|india|john|juliet|king|kilo|lincoln|lima|larry|mary|"
|
||||||
|
r"michael|mike|nora|nancy|november|ocean|oscar|peter|paul|papa|queen|robert|romeo|"
|
||||||
|
r"sam|sierra|tom|tango|union|uniform|victor|william|whiskey|x-ray|xray|young|yankee|zebra|zulu)")
|
||||||
|
_PLATE_READ = re.compile(rf"\b{_PHONETIC}(?:[,\s]+{_PHONETIC}){{1,3}}[,\s]+\d(?:[\s-]?\d){{2,6}}\b",
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _self_initiated_backstop(
|
||||||
|
text: str, tags: list, incident_type: Optional[str], severity: str,
|
||||||
|
talkgroup_name: Optional[str] = None,
|
||||||
|
) -> tuple[list, Optional[str], str]:
|
||||||
|
if talkgroup_name and _NO_BACKSTOP_TG.search(talkgroup_name):
|
||||||
|
return tags, incident_type, severity
|
||||||
|
# A plate read only stands for a stop when extraction found no other
|
||||||
|
# event in the call: the plate on an MVA, a tow or a parked-car complaint
|
||||||
|
# belongs to that event, not to a new stop.
|
||||||
|
if not tags and _PLATE_READ.search(text or ""):
|
||||||
|
tags = ["traffic-stop"]
|
||||||
|
incident_type = incident_type or "police"
|
||||||
|
if severity == "routine":
|
||||||
|
severity = "minor"
|
||||||
|
for pattern, tag in _SELF_INITIATED:
|
||||||
|
m = pattern.search(text or "")
|
||||||
|
if not m or _NEGATED.search(text[: m.start()]):
|
||||||
|
continue
|
||||||
|
if tag not in tags:
|
||||||
|
tags = [*tags, tag]
|
||||||
|
incident_type = incident_type or "police"
|
||||||
|
if severity == "routine":
|
||||||
|
severity = "minor"
|
||||||
|
return tags, incident_type, severity
|
||||||
|
|
||||||
|
|
||||||
|
# "45-9, I'm clear." / "Vehicle 1, clear." / "Car 12 10-8" — a unit reporting
|
||||||
|
# itself back in service is the one signal that ends an incident, and it is
|
||||||
|
# almost always five words or fewer, which is exactly the population the
|
||||||
|
# too-short skip above keeps away from GPT. In the first replay
|
||||||
|
# (server-26#170, 09-22 10:00-12:00 ET) 25 transmissions said 10-8/clear and
|
||||||
|
# 2 reached cleared_units. Rule-based on purpose: no model call, and only a
|
||||||
|
# unit named BEFORE the status word counts, so "10-8, 10-8." or "CMT clear."
|
||||||
|
# (no number) clears nobody rather than guessing.
|
||||||
|
_CLEAR_WORD_RE = re.compile(
|
||||||
|
r"\b(clear|10-?8|10-?98|back in service|in service|available)\b", re.IGNORECASE
|
||||||
|
)
|
||||||
|
_TEN_CODE_TOKEN_RE = re.compile(r"^10-?\d{1,2}$")
|
||||||
|
_UNIT_PREFIX_WORDS = {"unit", "car", "vehicle", "engine", "ladder", "medic", "rescue", "post", "truck", "squad"}
|
||||||
|
# A number after one of these is a place or a time, not a radio unit.
|
||||||
|
_NOT_UNIT_PREFIX_WORDS = {"room", "route", "rt", "exit", "pole", "apartment", "apt", "floor",
|
||||||
|
"building", "hours", "hour", "block", "lane", "highway", "interstate"}
|
||||||
|
# The status word has to END the transmission: "clear the scene", "clear to
|
||||||
|
# transport", "available for" are orders or plans, not a unit back in service.
|
||||||
|
_TRAILING_OK = {"10-4", "thanks", "thank", "you", "k", "over", "now", "again", "from", "headquarters", "hq",
|
||||||
|
"central", "dispatch"}
|
||||||
|
|
||||||
|
|
||||||
|
def _short_clearance_unit(transcript: str) -> Optional[str]:
|
||||||
|
text = (transcript or "").strip()
|
||||||
|
if not text or "?" in text:
|
||||||
|
return None # "45-9, are you clear?" asks; it doesn't report
|
||||||
|
m = _CLEAR_WORD_RE.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
before = [t.strip(".,;:!") for t in text[: m.start()].split()]
|
||||||
|
before = [t for t in before if t]
|
||||||
|
after = [t.strip(".,;:!").lower() for t in text[m.end():].split()]
|
||||||
|
if any(t and t not in _TRAILING_OK for t in after):
|
||||||
|
return None
|
||||||
|
if any(t.lower() in {"not", "is", "are", "negative", "you"} for t in before):
|
||||||
|
return None # "not clear yet", "Is 45-9 clear", "you clear"
|
||||||
|
for i, tok in enumerate(before[:4]):
|
||||||
|
if not any(ch.isdigit() for ch in tok) or _TEN_CODE_TOKEN_RE.match(tok):
|
||||||
|
continue
|
||||||
|
prev = before[i - 1].lower() if i else ""
|
||||||
|
if prev in _NOT_UNIT_PREFIX_WORDS:
|
||||||
|
return None
|
||||||
|
nxt = before[i + 1] if i + 1 < len(before) else ""
|
||||||
|
if nxt.lower() in _NOT_UNIT_PREFIX_WORDS:
|
||||||
|
return None # "1400 hours, clear"
|
||||||
|
if nxt.isdigit():
|
||||||
|
tok = f"{tok}-{nxt}" # "45 9 clear" is unit 45-9, not unit 45
|
||||||
|
elif nxt.isalpha() and nxt[0].isupper() and nxt.lower() not in {"i'm", "im", "we're", "copy"}:
|
||||||
|
tok = f"{tok} {nxt}" # "11 Adam, clear"
|
||||||
|
if prev in _UNIT_PREFIX_WORDS:
|
||||||
|
return f"{before[i - 1]} {tok}"
|
||||||
|
return tok
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clearance_scene(transcript: str, unit: str) -> dict:
|
||||||
|
"""A minimal scene for a rule-parsed clearance: the unit, and nothing that
|
||||||
|
could make the incident-creation gate open a new incident for it."""
|
||||||
|
return {
|
||||||
|
"tags": [], "incident_type": None, "location": None, "location_coords": None,
|
||||||
|
"resolved": False, "severity": "routine", "vehicles": [], "units": [unit],
|
||||||
|
"cleared_units": [unit], "reassignment": False, "transcript": transcript,
|
||||||
|
"transcript_corrected": None, "segment_indices": [], "embedding": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _geo_dist_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
def _geo_dist_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
"""Haversine distance in km between two lat/lon points."""
|
"""Haversine distance in km between two lat/lon points."""
|
||||||
R = 6371.0
|
R = 6371.0
|
||||||
@@ -806,9 +975,11 @@ def _sync_extract(
|
|||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.warning(f"GPT-4o-mini returned non-JSON: {e}")
|
logger.warning(f"GPT-4o-mini returned non-JSON: {e}")
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
# Any other exception is the API call itself failing (no credit, rate
|
||||||
logger.warning(f"GPT-4o-mini extraction failed: {e}")
|
# limit, outage) and propagates to extract_scenes, which reports it to
|
||||||
return []
|
# ai_health. Swallowing it here made "OpenAI is down" indistinguishable
|
||||||
|
# from "nothing happened on the radio" — the extraction tier existed in
|
||||||
|
# /health/ai but nothing ever reported to it.
|
||||||
|
|
||||||
|
|
||||||
def _sync_embed(text: str) -> Optional[list[float]]:
|
def _sync_embed(text: str) -> Optional[list[float]]:
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ Error handling: any Gemini failure returns None from decide() and the
|
|||||||
rules_decision from tiebreak() so the pipeline never stalls.
|
rules_decision from tiebreak() so the pipeline never stalls.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
@@ -190,15 +189,8 @@ def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict)
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _sync_gemini(model_name: str, prompt: str) -> dict:
|
def _sync_gemini(model_name: str, prompt: str) -> dict:
|
||||||
import google.generativeai as genai # lazy import — only when needed
|
from app.internal import gemini
|
||||||
|
return gemini.generate_json(model_name, prompt, purpose="correlation")
|
||||||
genai.configure(api_key=settings.gemini_api_key)
|
|
||||||
model = genai.GenerativeModel(
|
|
||||||
model_name,
|
|
||||||
generation_config={"response_mime_type": "application/json"},
|
|
||||||
)
|
|
||||||
response = model.generate_content(prompt)
|
|
||||||
return json.loads(response.text)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -275,6 +267,12 @@ async def decide(call_id: str, ctx: dict) -> Optional[dict]:
|
|||||||
if ctx["is_thin_call"]:
|
if ctx["is_thin_call"]:
|
||||||
return None # thin calls have no transcript/units/coords to reason about
|
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
|
||||||
|
|
||||||
if not ctx["recent"]:
|
if not ctx["recent"]:
|
||||||
return None # no incidents to correlate against — rules handles new-only
|
return None # no incidents to correlate against — rules handles new-only
|
||||||
|
|
||||||
@@ -295,6 +293,14 @@ async def decide(call_id: str, ctx: dict) -> Optional[dict]:
|
|||||||
return None
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ class MQTTHandler:
|
|||||||
"approval_status": "pending",
|
"approval_status": "pending",
|
||||||
"node_type": payload.get("node_type", "fixed"),
|
"node_type": payload.get("node_type", "fixed"),
|
||||||
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
|
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
|
||||||
"sdr_count": payload.get("sdr_count", 1),
|
"secondary_sdr_priority": payload.get("secondary_sdr_priority", []),
|
||||||
|
"secondary_sdr_running": payload.get("secondary_sdr_running"),
|
||||||
|
"sdr_pins": payload.get("sdr_pins", {}),
|
||||||
|
"sdr_devices": payload.get("sdr_devices"),
|
||||||
|
"op25_sdr_serial": payload.get("op25_sdr_serial"),
|
||||||
|
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
||||||
"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,
|
||||||
@@ -143,8 +148,10 @@ class MQTTHandler:
|
|||||||
updates["node_type"] = node_type
|
updates["node_type"] = node_type
|
||||||
updates["enforce_override_timeout"] = enforce_timeout
|
updates["enforce_override_timeout"] = enforce_timeout
|
||||||
|
|
||||||
if "secondary_sdr_mode" in payload:
|
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
|
||||||
updates["secondary_sdr_mode"] = payload["secondary_sdr_mode"]
|
"sdr_pins", "sdr_devices", "op25_sdr_serial"):
|
||||||
|
if key in payload:
|
||||||
|
updates[key] = payload[key]
|
||||||
if "sdr_count" in payload:
|
if "sdr_count" in payload:
|
||||||
updates["sdr_count"] = payload["sdr_count"]
|
updates["sdr_count"] = payload["sdr_count"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""511NY (NYSDOT) traffic cameras and events, cached in memory (server-26#183).
|
||||||
|
|
||||||
|
Public data, identical for every org, so it is NOT written to Firestore: the
|
||||||
|
statewide feeds are ~3k cameras and ~2.3k events, and re-writing them every
|
||||||
|
poll would be millions of writes a day for data nobody needs history of. The
|
||||||
|
cache is filled lazily on request and refreshed per TTL, so an idle deploy
|
||||||
|
makes no 511 calls at all.
|
||||||
|
|
||||||
|
A failed refresh keeps serving the last good data and reports the error and
|
||||||
|
its age to the caller -- an empty layer must never be the only symptom of a
|
||||||
|
dead feed (the AI-silent-failures lesson).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
_BASE = "https://511ny.org/api"
|
||||||
|
CAMERAS_TTL_S = 60 * 60 # camera list is near-static
|
||||||
|
EVENTS_TTL_S = 2 * 60 # accidents/closures change minute to minute
|
||||||
|
RETRY_AFTER_FAILURE_S = 60
|
||||||
|
_DESCRIPTION_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
class _Feed:
|
||||||
|
def __init__(self, path: str, ttl_s: int, normalize):
|
||||||
|
self.path = path
|
||||||
|
self.ttl_s = ttl_s
|
||||||
|
self.normalize = normalize
|
||||||
|
self.items: List[Dict[str, Any]] = []
|
||||||
|
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self._next_attempt = 0.0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get(self) -> "_Feed":
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
async with self._lock:
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self # another request refreshed while we waited
|
||||||
|
try:
|
||||||
|
self.items = await _fetch(self.path, self.normalize)
|
||||||
|
self.fetched_at = time.time()
|
||||||
|
self.error = None
|
||||||
|
self._next_attempt = time.time() + self.ttl_s
|
||||||
|
except Exception as e:
|
||||||
|
self._next_attempt = time.time() + min(self.ttl_s, RETRY_AFTER_FAILURE_S)
|
||||||
|
self.error = f"{type(e).__name__}: {e}"[:300]
|
||||||
|
logger.warning(f"511NY {self.path} refresh failed, serving {len(self.items)} cached: {self.error}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch(path: str, normalize) -> List[Dict[str, Any]]:
|
||||||
|
params = {"format": "json"}
|
||||||
|
if settings.ny511_api_key:
|
||||||
|
params["key"] = settings.ny511_api_key
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
r = await client.get(f"{_BASE}/{path}", params=params)
|
||||||
|
r.raise_for_status()
|
||||||
|
raw = r.json()
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ValueError(f"expected a JSON list, got {type(raw).__name__}")
|
||||||
|
out = [n for n in (normalize(x) for x in raw) if n is not None]
|
||||||
|
if raw and not out:
|
||||||
|
# every record failed to normalize: the schema changed under us
|
||||||
|
raise ValueError(f"0 of {len(raw)} records parsed -- 511NY schema change?")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _coords(x: Dict[str, Any]) -> Optional[tuple]:
|
||||||
|
try:
|
||||||
|
lat, lon = float(x["Latitude"]), float(x["Longitude"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if lat == 0 and lon == 0:
|
||||||
|
return None
|
||||||
|
return lat, lon
|
||||||
|
|
||||||
|
|
||||||
|
def _local_iso(s: Any) -> Optional[str]:
|
||||||
|
"""511NY stamps are 'DD/MM/YYYY HH:MM:SS' New York local time. Returned as a
|
||||||
|
naive ISO string (no offset) -- display-only, never compared to UTC."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, "%d/%m/%Y %H:%M:%S").isoformat()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_camera(x: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
c = _coords(x)
|
||||||
|
if c is None or x.get("Disabled") or x.get("Blocked") or not x.get("ID"):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": x["ID"],
|
||||||
|
"lat": c[0],
|
||||||
|
"lon": c[1],
|
||||||
|
"name": x.get("Name") or "",
|
||||||
|
"roadway": x.get("RoadwayName") or "",
|
||||||
|
"direction": x.get("DirectionOfTravel") or "",
|
||||||
|
"image_url": x.get("Url"), # 511NY serves the current still at this URL
|
||||||
|
"video_url": x.get("VideoUrl"), # HLS playlist, when the camera streams
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_event(x: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
c = _coords(x)
|
||||||
|
if c is None or not x.get("ID"):
|
||||||
|
return None
|
||||||
|
desc = x.get("Description") or ""
|
||||||
|
return {
|
||||||
|
"id": x["ID"],
|
||||||
|
"lat": c[0],
|
||||||
|
"lon": c[1],
|
||||||
|
"type": x.get("EventType") or "",
|
||||||
|
"subtype": x.get("EventSubType") or "",
|
||||||
|
"severity": x.get("Severity") or "",
|
||||||
|
"roadway": x.get("RoadwayName") or "",
|
||||||
|
"direction": x.get("DirectionOfTravel") or "",
|
||||||
|
"county": x.get("CountyName") or "",
|
||||||
|
"description": desc[:_DESCRIPTION_MAX] + ("…" if len(desc) > _DESCRIPTION_MAX else ""),
|
||||||
|
"start_local": _local_iso(x.get("StartDate")),
|
||||||
|
"planned_end_local": _local_iso(x.get("PlannedEndDate")),
|
||||||
|
"updated_local": _local_iso(x.get("LastUpdated")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
cameras = _Feed("getcameras", CAMERAS_TTL_S, normalize_camera)
|
||||||
|
events = _Feed("getevents", EVENTS_TTL_S, normalize_event)
|
||||||
|
|
||||||
|
|
||||||
|
def in_bbox(items: List[Dict[str, Any]], south: float, west: float, north: float, east: float) -> List[Dict[str, Any]]:
|
||||||
|
return [i for i in items if south <= i["lat"] <= north and west <= i["lon"] <= east]
|
||||||
@@ -90,7 +90,8 @@ def _pipeline_likely_still_running(call: dict, now: datetime) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _run_sweep_pass() -> None:
|
async def _run_sweep_pass() -> None:
|
||||||
now = datetime.now(timezone.utc)
|
from app.internal import clock
|
||||||
|
now = clock.now()
|
||||||
cutoff = now - timedelta(minutes=settings.recorrelation_scan_minutes)
|
cutoff = now - timedelta(minutes=settings.recorrelation_scan_minutes)
|
||||||
|
|
||||||
# Server-side range query: only calls that ended within the scan window.
|
# Server-side range query: only calls that ended within the scan window.
|
||||||
|
|||||||
@@ -0,0 +1,655 @@
|
|||||||
|
"""
|
||||||
|
Replay — re-run the intelligence pipeline over past calls, in a sandbox.
|
||||||
|
|
||||||
|
Live AI windows are the only way the correlator has ever been measured, and
|
||||||
|
each one costs days of real time and whatever the credits allow: a change
|
||||||
|
ships, AI goes on, traffic trickles in, someone pulls a dump. Recordings are
|
||||||
|
kept whether AI is on or not, so the traffic to measure against already
|
||||||
|
exists. A replay run takes a time range of real calls, feeds them through
|
||||||
|
the SAME pipeline code the live upload path runs (routers/upload.py
|
||||||
|
`_extract_and_correlate`) in their original order with the clock pinned to
|
||||||
|
each call's own end time, and writes everything to
|
||||||
|
replay_runs/{run_id}/calls|incidents instead of the live collections. The
|
||||||
|
same range can then be replayed after every change and the runs compared.
|
||||||
|
|
||||||
|
Three modes, cheapest last:
|
||||||
|
audio re-transcribe the saved audio (Whisper + correction), then
|
||||||
|
extract and correlate. For ranges where AI was off.
|
||||||
|
transcripts reuse the transcript already on each call, re-run extraction
|
||||||
|
and correlation.
|
||||||
|
reuse reuse the scenes an earlier run extracted, re-run correlation
|
||||||
|
only. Extraction is an LLM call and never returns quite the
|
||||||
|
same thing twice, so this is the mode that isolates a
|
||||||
|
correlator change from extraction noise.
|
||||||
|
|
||||||
|
What never happens in a replay: alerts, summaries, vocabulary learning, and
|
||||||
|
any write to a live call or incident. The sandbox is enforced by the
|
||||||
|
ContextVar redirect in app/internal/firestore.py, not by this module
|
||||||
|
remembering to use different collection names.
|
||||||
|
|
||||||
|
One run at a time per process — a run spends real AI credits and its cost is
|
||||||
|
only estimated, so two concurrent runs would be two unbounded bills.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import uuid
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import ai_health, clock
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.feature_flags import force_flags, unforce_flags
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
RUNS = "replay_runs"
|
||||||
|
MODES = ("audio", "transcripts", "reuse")
|
||||||
|
MAX_CALLS = 5000
|
||||||
|
MAX_RANGE_DAYS = 7
|
||||||
|
|
||||||
|
# Extraction/transcription run ahead of correlation with this much
|
||||||
|
# concurrency. They depend only on the call itself; correlation depends on
|
||||||
|
# every call before it and is kept strictly in order.
|
||||||
|
PREFETCH = 6
|
||||||
|
|
||||||
|
# Rough per-unit AI prices for the pre-run estimate and the running tally.
|
||||||
|
# Estimates, not a bill — nothing in DRB reads a real invoice (server-26#45).
|
||||||
|
USD_WHISPER_PER_MIN = 0.006
|
||||||
|
USD_PER_EXTRACTION = 0.0005 # gpt-4o-mini scene extraction + embedding
|
||||||
|
USD_PER_CORRECTION = 0.0003 # Gemini flash transcript correction
|
||||||
|
USD_PER_GEOCODE = 0.005 # Google geocode, roughly one per located scene
|
||||||
|
USD_PER_LLM_CORRELATE = 0.0005 # Gemini flash consensus decision
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# live answer. Anything else on the doc (ids, times, talkgroup, srcaddr, audio
|
||||||
|
# location) is an input and is kept.
|
||||||
|
_DERIVED = {
|
||||||
|
"transcript", "transcript_corrected", "transcript_not_speech",
|
||||||
|
"segments", "segments_corrected", "scenes", "incident_id", "incident_ids",
|
||||||
|
"tags", "location", "location_coords", "location_mentions", "units",
|
||||||
|
"vehicles", "cleared_units", "severity", "incident_type", "type",
|
||||||
|
"embedding", "skip_reason", "intelligence_started_at", "reassignment",
|
||||||
|
"resolved", "has_updates", "audio_url",
|
||||||
|
}
|
||||||
|
_DERIVED_PREFIXES = ("corr_", "chatter_classifier_", "eval_")
|
||||||
|
_TRANSCRIPT_FIELDS = (
|
||||||
|
"transcript", "transcript_corrected", "transcript_not_speech",
|
||||||
|
"segments", "segments_corrected",
|
||||||
|
)
|
||||||
|
|
||||||
|
_active_run_id: Optional[str] = None
|
||||||
|
_active_task: Optional[asyncio.Task] = None
|
||||||
|
_cancel: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayBusy(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sandbox_root(run_id: str) -> str:
|
||||||
|
return f"{RUNS}/{run_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _scenes_coll(run_id: str) -> str:
|
||||||
|
# Extracted scenes (embeddings included) live beside the sandbox, not on
|
||||||
|
# its call docs, so reading a run's calls for metrics or the incident view
|
||||||
|
# doesn't haul every scene's embedding along a second time.
|
||||||
|
return f"{sandbox_root(run_id)}/scenes"
|
||||||
|
|
||||||
|
|
||||||
|
def active_run_id() -> Optional[str]:
|
||||||
|
if _active_task is not None and not _active_task.done():
|
||||||
|
return _active_run_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Call selection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _as_dt(value) -> Optional[datetime]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def select_calls(
|
||||||
|
org_id: str,
|
||||||
|
date_from: datetime,
|
||||||
|
date_to: datetime,
|
||||||
|
system_ids: Optional[list[str]] = None,
|
||||||
|
cap: int = MAX_CALLS,
|
||||||
|
) -> tuple[list[dict], bool]:
|
||||||
|
"""
|
||||||
|
Live calls in [date_from, date_to] for this org, oldest first.
|
||||||
|
|
||||||
|
Pages newest-first because the one composite index on calls that carries
|
||||||
|
org_id is (org_id ASC, started_at DESC); ordering the other way would need
|
||||||
|
a new index for no gain. Returns (calls, truncated) — truncated means the
|
||||||
|
range holds more than `cap` calls and the caller must narrow it rather
|
||||||
|
than silently replaying only part of it.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
cursor = None
|
||||||
|
page = 1000
|
||||||
|
while True:
|
||||||
|
rows = await fstore.collection_where(
|
||||||
|
"calls",
|
||||||
|
[("org_id", "==", org_id),
|
||||||
|
("started_at", ">=", date_from),
|
||||||
|
("started_at", "<=", date_to)],
|
||||||
|
order_by=[("started_at", "DESCENDING")],
|
||||||
|
limit_to=page,
|
||||||
|
start_after={"started_at": cursor} if cursor is not None else None,
|
||||||
|
)
|
||||||
|
for c in rows:
|
||||||
|
if c.get("duplicate_of"):
|
||||||
|
continue # another node's copy — live never processes these either
|
||||||
|
if system_ids and c.get("system_id") not in system_ids:
|
||||||
|
continue
|
||||||
|
out.append(c)
|
||||||
|
if len(out) > cap:
|
||||||
|
return sorted(out[:cap], key=_call_time), True
|
||||||
|
if len(rows) < page:
|
||||||
|
break
|
||||||
|
cursor = rows[-1].get("started_at")
|
||||||
|
return sorted(out, key=_call_time), False
|
||||||
|
|
||||||
|
|
||||||
|
def _call_time(call: dict) -> datetime:
|
||||||
|
return _as_dt(call.get("started_at")) or datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline_time(call: dict) -> datetime:
|
||||||
|
"""When the live pipeline would have run for this call: at upload, i.e. call end."""
|
||||||
|
return _as_dt(call.get("ended_at")) or _call_time(call)
|
||||||
|
|
||||||
|
|
||||||
|
def _duration_s(call: dict) -> float:
|
||||||
|
# Call docs carry no duration field; the node reports start and end.
|
||||||
|
start, end = _as_dt(call.get("started_at")), _as_dt(call.get("ended_at"))
|
||||||
|
return max(0.0, (end - start).total_seconds()) if start and end else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def estimate(calls: list[dict], mode: str) -> dict:
|
||||||
|
n = len(calls)
|
||||||
|
with_transcript = sum(1 for c in calls if c.get("transcript_corrected") or c.get("transcript"))
|
||||||
|
audio_min = sum(_duration_s(c) for c in calls) / 60
|
||||||
|
with_audio = sum(1 for c in calls if c.get("audio_gcs_uri"))
|
||||||
|
# Roughly a third of calls carry a geocodable location (09-22 dump: 92/373).
|
||||||
|
per_call = USD_PER_EXTRACTION + USD_PER_LLM_CORRELATE + USD_PER_GEOCODE / 3
|
||||||
|
if mode == "audio":
|
||||||
|
usd = audio_min * USD_WHISPER_PER_MIN + with_audio * (USD_PER_CORRECTION + per_call)
|
||||||
|
elif mode == "transcripts":
|
||||||
|
usd = with_transcript * per_call
|
||||||
|
else:
|
||||||
|
usd = n * USD_PER_LLM_CORRELATE
|
||||||
|
return {
|
||||||
|
"calls": n,
|
||||||
|
"calls_with_transcript": with_transcript,
|
||||||
|
"calls_with_audio": with_audio,
|
||||||
|
"audio_minutes": round(audio_min, 1),
|
||||||
|
"est_cost_usd": round(usd, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Run lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def start_run(
|
||||||
|
*,
|
||||||
|
org_id: str,
|
||||||
|
date_from: datetime,
|
||||||
|
date_to: datetime,
|
||||||
|
mode: str,
|
||||||
|
system_ids: Optional[list[str]],
|
||||||
|
source_run_id: Optional[str],
|
||||||
|
label: str,
|
||||||
|
actor: str,
|
||||||
|
) -> dict:
|
||||||
|
global _active_run_id, _active_task
|
||||||
|
if active_run_id():
|
||||||
|
raise ReplayBusy(f"Replay {active_run_id()} is still running.")
|
||||||
|
if mode not in MODES:
|
||||||
|
raise ValueError(f"mode must be one of {MODES}")
|
||||||
|
if date_to <= date_from:
|
||||||
|
raise ValueError("date_to must be after date_from")
|
||||||
|
if date_to - date_from > timedelta(days=MAX_RANGE_DAYS):
|
||||||
|
raise ValueError(f"Range is capped at {MAX_RANGE_DAYS} days.")
|
||||||
|
if mode == "reuse":
|
||||||
|
src = await fstore.doc_get(RUNS, source_run_id or "")
|
||||||
|
if not src or src.get("org_id") != org_id:
|
||||||
|
raise ValueError("reuse mode needs a source_run_id from an earlier run in this org")
|
||||||
|
if src.get("status") != "done":
|
||||||
|
raise ValueError("The source run did not finish; its scenes are incomplete.")
|
||||||
|
|
||||||
|
calls, truncated = await select_calls(org_id, date_from, date_to, system_ids)
|
||||||
|
if truncated:
|
||||||
|
raise ValueError(f"Range holds more than {MAX_CALLS} calls — narrow it.")
|
||||||
|
if not calls:
|
||||||
|
raise ValueError("No calls in that range.")
|
||||||
|
|
||||||
|
run_id = uuid.uuid4().hex[:12]
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
doc = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"org_id": org_id,
|
||||||
|
"label": label or "",
|
||||||
|
"mode": mode,
|
||||||
|
"source_run_id": source_run_id if mode == "reuse" else None,
|
||||||
|
"date_from": date_from.isoformat(),
|
||||||
|
"date_to": date_to.isoformat(),
|
||||||
|
"system_ids": system_ids or [],
|
||||||
|
"git_sha": os.getenv("GIT_SHA", "unknown"),
|
||||||
|
"created_by": actor,
|
||||||
|
"created_at": now,
|
||||||
|
"status": "running",
|
||||||
|
"estimate": estimate(calls, mode),
|
||||||
|
"progress": {"total": len(calls), "done": 0, "errors": 0},
|
||||||
|
"metrics": None,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
await fstore.doc_set(RUNS, run_id, doc, merge=False)
|
||||||
|
|
||||||
|
_active_run_id = run_id
|
||||||
|
_active_task = asyncio.create_task(_run(run_id, org_id, calls, mode, source_run_id))
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def request_cancel(run_id: str) -> bool:
|
||||||
|
if active_run_id() != run_id:
|
||||||
|
return False
|
||||||
|
_cancel.add(run_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_run(run_id: str) -> Optional[dict]:
|
||||||
|
doc = await fstore.doc_get(RUNS, run_id)
|
||||||
|
return await _reconcile(doc) if doc else None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_runs(org_id: str) -> list[dict]:
|
||||||
|
docs = await fstore.collection_list(RUNS, org_id=org_id)
|
||||||
|
docs = [await _reconcile(d) for d in docs]
|
||||||
|
return sorted(docs, key=lambda d: d.get("created_at") or "", reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _reconcile(doc: dict) -> dict:
|
||||||
|
"""A run left "running" by a process that restarted (a deploy) never finishes."""
|
||||||
|
if doc.get("status") == "running" and doc.get("run_id") != active_run_id():
|
||||||
|
doc["status"] = "interrupted"
|
||||||
|
await fstore.doc_set(RUNS, doc["run_id"], {"status": "interrupted"})
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_run(run_id: str) -> None:
|
||||||
|
if active_run_id() == run_id:
|
||||||
|
raise ReplayBusy("Cancel the run before deleting it.")
|
||||||
|
token = fstore.enter_sandbox(sandbox_root(run_id))
|
||||||
|
try:
|
||||||
|
for coll, key in (("calls", "call_id"), ("incidents", "incident_id"),
|
||||||
|
(_scenes_coll(run_id), "call_id")):
|
||||||
|
for d in await fstore.collection_list(coll):
|
||||||
|
if d.get(key):
|
||||||
|
await fstore.doc_delete(coll, d[key])
|
||||||
|
finally:
|
||||||
|
fstore.exit_sandbox(token)
|
||||||
|
await fstore.doc_delete(RUNS, run_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def sandbox_contents(run_id: str) -> tuple[list[dict], list[dict]]:
|
||||||
|
token = fstore.enter_sandbox(sandbox_root(run_id))
|
||||||
|
try:
|
||||||
|
incidents = await fstore.collection_list("incidents")
|
||||||
|
calls = await fstore.collection_list("calls")
|
||||||
|
finally:
|
||||||
|
fstore.exit_sandbox(token)
|
||||||
|
return incidents, calls
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The run itself
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _flags_for(mode: str) -> dict[str, bool]:
|
||||||
|
return {
|
||||||
|
"stt_enabled": mode == "audio",
|
||||||
|
"transcript_correction_enabled": mode == "audio",
|
||||||
|
"correlation_enabled": True,
|
||||||
|
"summaries_enabled": False,
|
||||||
|
"vocabulary_learning_enabled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_input(call: dict) -> tuple[Optional[str], list]:
|
||||||
|
"""
|
||||||
|
The transcript + segments live extraction was handed for this call.
|
||||||
|
|
||||||
|
Not simply `transcript_corrected`: live extraction overwrites that field
|
||||||
|
with its primary scene's rewrite (intelligence.py), so on a call with
|
||||||
|
several scenes it now holds only scene 0's text. The corrector's own
|
||||||
|
output survives intact in `segments_corrected`, so rebuild from those
|
||||||
|
when they exist; otherwise correction never produced anything and live
|
||||||
|
extraction read the raw Whisper transcript.
|
||||||
|
"""
|
||||||
|
if call.get("transcript_not_speech"):
|
||||||
|
return None, [] # transcribe_call hands nothing downstream for noise
|
||||||
|
corrected = call.get("segments_corrected") or []
|
||||||
|
if corrected:
|
||||||
|
text = " ".join(str(seg.get("text") or "").strip() for seg in corrected).strip()
|
||||||
|
return (text or call.get("transcript")), corrected
|
||||||
|
return call.get("transcript"), call.get("segments") or []
|
||||||
|
|
||||||
|
|
||||||
|
def _extraction_fields(sb_call: dict) -> dict:
|
||||||
|
"""What extraction wrote onto the call doc (tags, units, location,
|
||||||
|
embedding, skip_reason, ...), minus everything correlation wrote. A reuse
|
||||||
|
run restores these so the orphan sweep, which reads them straight off the
|
||||||
|
call doc, sees what it saw in the source run."""
|
||||||
|
return {
|
||||||
|
k: v for k, v in sb_call.items()
|
||||||
|
if (k in _DERIVED or k.startswith("chatter_classifier_"))
|
||||||
|
and k not in _TRANSCRIPT_FIELDS
|
||||||
|
and k not in ("scenes", "incident_id", "incident_ids", "intelligence_started_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sandbox_seed(call: dict, mode: str) -> dict:
|
||||||
|
keep_transcript = mode in ("transcripts", "reuse")
|
||||||
|
seed = {}
|
||||||
|
for k, v in call.items():
|
||||||
|
if k in _TRANSCRIPT_FIELDS:
|
||||||
|
if keep_transcript:
|
||||||
|
seed[k] = v
|
||||||
|
continue
|
||||||
|
if k in _DERIVED or k.startswith(_DERIVED_PREFIXES):
|
||||||
|
continue
|
||||||
|
seed[k] = v
|
||||||
|
# Calls are seeded ahead of the replay clock (see PREFETCH). The orphan
|
||||||
|
# re-correlation sweep selects status=="ended" calls by ended_at, so a
|
||||||
|
# seeded call keeping its real status would be swept up as an "orphan"
|
||||||
|
# before its own turn. Its real status is restored when it is processed.
|
||||||
|
seed["status"] = "replay_pending"
|
||||||
|
return seed
|
||||||
|
|
||||||
|
|
||||||
|
async def _prepare(call: dict, mode: str, source_scenes: dict[str, dict]) -> dict:
|
||||||
|
"""
|
||||||
|
Everything per call that doesn't depend on other calls: seed the sandbox
|
||||||
|
doc, then transcribe and/or extract. Runs ahead of correlation.
|
||||||
|
Returns {"transcript", "scenes", "skip"} for the in-order stage.
|
||||||
|
"""
|
||||||
|
from app.internal import intelligence, talkgroups, transcription
|
||||||
|
|
||||||
|
call_id = call["call_id"]
|
||||||
|
await fstore.doc_set("calls", call_id, _sandbox_seed(call, mode), merge=False)
|
||||||
|
|
||||||
|
talkgroup_name = await talkgroups.resolve(
|
||||||
|
call.get("system_id"), call.get("talkgroup_id"),
|
||||||
|
hint=call.get("talkgroup_name"), call_doc=call,
|
||||||
|
)
|
||||||
|
|
||||||
|
transcript: Optional[str] = None
|
||||||
|
segments: list = []
|
||||||
|
if mode == "audio":
|
||||||
|
if call.get("audio_gcs_uri"):
|
||||||
|
transcript, segments = await transcription.transcribe_call(
|
||||||
|
call_id, call["audio_gcs_uri"], talkgroup_name,
|
||||||
|
system_id=call.get("system_id"), talkgroup_id=call.get("talkgroup_id"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
transcript, segments = _stored_input(call)
|
||||||
|
|
||||||
|
if mode == "reuse":
|
||||||
|
src = source_scenes.get(call_id)
|
||||||
|
if src is None:
|
||||||
|
return {"skip": "not_in_source_run", "talkgroup_name": talkgroup_name}
|
||||||
|
if src.get("call_fields"):
|
||||||
|
# skip_reason gates upload.py's no-scene fallback and the orphan
|
||||||
|
# sweep correlates from tags/units/location on the call doc, so
|
||||||
|
# extraction's call-level output comes across with its scenes.
|
||||||
|
await fstore.doc_set("calls", call_id, src["call_fields"])
|
||||||
|
return {"transcript": transcript, "scenes": src.get("scenes") or [],
|
||||||
|
"talkgroup_name": talkgroup_name}
|
||||||
|
|
||||||
|
scenes: list = []
|
||||||
|
if transcript:
|
||||||
|
scenes = await intelligence.extract_scenes(
|
||||||
|
call_id, transcript, talkgroup_name,
|
||||||
|
talkgroup_id=call.get("talkgroup_id"), system_id=call.get("system_id"),
|
||||||
|
segments=segments, node_id=call.get("node_id"),
|
||||||
|
)
|
||||||
|
return {"transcript": transcript, "scenes": scenes, "talkgroup_name": talkgroup_name}
|
||||||
|
|
||||||
|
|
||||||
|
async def _sweeps_until(t: datetime, state: dict) -> None:
|
||||||
|
"""Run the live periodic sweeps (idle auto-resolve, orphan re-correlation) at every tick up to t."""
|
||||||
|
from app.internal import recorrelation_sweep, summarizer
|
||||||
|
|
||||||
|
interval = timedelta(minutes=settings.summary_interval_minutes)
|
||||||
|
if state["last_sweep"] is None:
|
||||||
|
state["last_sweep"] = t
|
||||||
|
return
|
||||||
|
while state["last_sweep"] + interval <= t:
|
||||||
|
state["last_sweep"] += interval
|
||||||
|
tok = clock.pin(state["last_sweep"])
|
||||||
|
try:
|
||||||
|
await summarizer._resolve_stale_incidents()
|
||||||
|
await recorrelation_sweep._run_sweep_pass()
|
||||||
|
finally:
|
||||||
|
clock.unpin(tok)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(run_id: str, org_id: str, calls: list[dict], mode: str,
|
||||||
|
source_run_id: Optional[str]) -> None:
|
||||||
|
from app.routers.upload import _extract_and_correlate
|
||||||
|
|
||||||
|
global _active_run_id
|
||||||
|
progress = {"total": len(calls), "done": 0, "errors": 0, "skipped": 0,
|
||||||
|
"extractions": 0, "audio_minutes": 0.0}
|
||||||
|
errors: list[str] = []
|
||||||
|
status = "done"
|
||||||
|
|
||||||
|
source_scenes: dict[str, dict] = {}
|
||||||
|
if mode == "reuse" and source_run_id:
|
||||||
|
rows = await fstore.collection_list(_scenes_coll(source_run_id))
|
||||||
|
source_scenes = {r["call_id"]: r for r in rows if r.get("call_id")}
|
||||||
|
|
||||||
|
sb_token = fstore.enter_sandbox(sandbox_root(run_id))
|
||||||
|
fl_token = force_flags(_flags_for(mode))
|
||||||
|
ai_failures: list = []
|
||||||
|
ai_token = ai_health.collect_sandbox_failures(ai_failures)
|
||||||
|
from app.internal import gemini
|
||||||
|
usage: dict = {}
|
||||||
|
usage_token = gemini.collect_usage(usage)
|
||||||
|
try:
|
||||||
|
sem = asyncio.Semaphore(PREFETCH)
|
||||||
|
|
||||||
|
async def prep(call: dict):
|
||||||
|
async with sem:
|
||||||
|
tok = clock.pin(_pipeline_time(call))
|
||||||
|
try:
|
||||||
|
return await _prepare(call, mode, source_scenes)
|
||||||
|
finally:
|
||||||
|
clock.unpin(tok)
|
||||||
|
|
||||||
|
pending: dict[int, asyncio.Task] = {}
|
||||||
|
sweep_state = {"last_sweep": None}
|
||||||
|
last_t = None
|
||||||
|
for i, call in enumerate(calls):
|
||||||
|
for j in range(i, min(i + PREFETCH * 2, len(calls))):
|
||||||
|
if j not in pending:
|
||||||
|
pending[j] = asyncio.create_task(prep(calls[j]))
|
||||||
|
if run_id in _cancel:
|
||||||
|
status = "cancelled"
|
||||||
|
break
|
||||||
|
fatal = _fatal_ai_failure(ai_failures)
|
||||||
|
if fatal:
|
||||||
|
# An unfunded or retired model fails every call the same way;
|
||||||
|
# finishing the run would only produce a sandbox of orphans
|
||||||
|
# that looks like a correlation result and isn't one.
|
||||||
|
status = "failed"
|
||||||
|
errors.append(f"aborted: {fatal}")
|
||||||
|
break
|
||||||
|
|
||||||
|
t = _pipeline_time(call)
|
||||||
|
last_t = t
|
||||||
|
try:
|
||||||
|
prepared = await pending.pop(i)
|
||||||
|
await _sweeps_until(t, sweep_state)
|
||||||
|
if prepared.get("skip"):
|
||||||
|
progress["skipped"] += 1
|
||||||
|
else:
|
||||||
|
tok = clock.pin(t)
|
||||||
|
try:
|
||||||
|
await fstore.doc_set("calls", call["call_id"], {
|
||||||
|
"status": call.get("status") or "ended",
|
||||||
|
"intelligence_started_at": t.isoformat(),
|
||||||
|
})
|
||||||
|
_, _, scenes = await _extract_and_correlate(
|
||||||
|
call_id=call["call_id"],
|
||||||
|
node_id=call.get("node_id"),
|
||||||
|
system_id=call.get("system_id"),
|
||||||
|
talkgroup_id=call.get("talkgroup_id"),
|
||||||
|
talkgroup_name=prepared["talkgroup_name"],
|
||||||
|
transcript=prepared["transcript"],
|
||||||
|
scenes=prepared["scenes"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
clock.unpin(tok)
|
||||||
|
# Kept whole (embedding included) so a later "reuse" run
|
||||||
|
# can correlate from exactly these scenes.
|
||||||
|
sb_call = await fstore.doc_get("calls", call["call_id"]) or {}
|
||||||
|
await fstore.doc_set(_scenes_coll(run_id), call["call_id"], {
|
||||||
|
"call_id": call["call_id"],
|
||||||
|
"scenes": scenes,
|
||||||
|
"call_fields": _extraction_fields(sb_call),
|
||||||
|
}, merge=False)
|
||||||
|
if prepared["transcript"] and mode != "reuse":
|
||||||
|
progress["extractions"] += 1
|
||||||
|
if mode == "audio":
|
||||||
|
progress["audio_minutes"] += _duration_s(call) / 60
|
||||||
|
except Exception as e:
|
||||||
|
progress["errors"] += 1
|
||||||
|
if len(errors) < 20:
|
||||||
|
errors.append(f"{call.get('call_id')}: {type(e).__name__}: {e}"[:300])
|
||||||
|
logger.warning(f"Replay {run_id}: call {call.get('call_id')} failed: {e}")
|
||||||
|
progress["done"] = i + 1
|
||||||
|
if (i + 1) % 25 == 0:
|
||||||
|
await fstore.doc_set(RUNS, run_id, {"progress": dict(progress), "errors": errors})
|
||||||
|
|
||||||
|
for task in pending.values():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
if status == "done" and last_t is not None:
|
||||||
|
# Let every incident age out exactly as it would have live.
|
||||||
|
await _sweeps_until(
|
||||||
|
last_t + timedelta(minutes=settings.incident_auto_resolve_minutes
|
||||||
|
+ 2 * settings.summary_interval_minutes),
|
||||||
|
sweep_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
incidents = await fstore.collection_list("incidents")
|
||||||
|
sb_calls = await fstore.collection_list("calls")
|
||||||
|
metrics = compute_metrics(incidents, sb_calls)
|
||||||
|
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["gemini_usage"] = usage
|
||||||
|
except Exception as e:
|
||||||
|
status = "failed"
|
||||||
|
errors.append(f"run: {type(e).__name__}: {e}"[:300])
|
||||||
|
metrics = None
|
||||||
|
logger.error(f"Replay {run_id} failed: {e}")
|
||||||
|
finally:
|
||||||
|
ai_health._sandbox_failures.reset(ai_token)
|
||||||
|
gemini.reset_usage(usage_token)
|
||||||
|
unforce_flags(fl_token)
|
||||||
|
fstore.exit_sandbox(sb_token)
|
||||||
|
_cancel.discard(run_id)
|
||||||
|
_active_run_id = None
|
||||||
|
|
||||||
|
await fstore.doc_set(RUNS, run_id, {
|
||||||
|
"status": status,
|
||||||
|
"progress": progress,
|
||||||
|
"errors": errors,
|
||||||
|
"metrics": metrics,
|
||||||
|
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
logger.info(f"Replay {run_id} {status}: {progress}")
|
||||||
|
|
||||||
|
|
||||||
|
FATAL_AFTER = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _fatal_ai_failure(failures: list) -> Optional[str]:
|
||||||
|
"""A tier that failed permanently (no credit, dead model) FATAL_AFTER times."""
|
||||||
|
permanent = Counter(
|
||||||
|
f"{f['tier']} ({f['provider']} {f['model']}): {f['problem']}"
|
||||||
|
for f in failures if f.get("permanent")
|
||||||
|
)
|
||||||
|
for what, n in permanent.items():
|
||||||
|
if n >= FATAL_AFTER:
|
||||||
|
return what
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _running_cost(progress: dict, metrics: dict, mode: str) -> float:
|
||||||
|
usd = progress["audio_minutes"] * USD_WHISPER_PER_MIN
|
||||||
|
if mode == "audio":
|
||||||
|
usd += progress["extractions"] * USD_PER_CORRECTION
|
||||||
|
usd += progress["extractions"] * (USD_PER_EXTRACTION + USD_PER_GEOCODE / 3)
|
||||||
|
usd += metrics.get("llm_decisions", 0) * USD_PER_LLM_CORRELATE
|
||||||
|
return round(usd, 2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scoring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compute_metrics(incidents: list[dict], calls: list[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
The numbers that say whether incidents are being tracked, from one run's
|
||||||
|
sandbox. Same questions every correlation review has asked by hand, so two
|
||||||
|
runs over the same range compare directly.
|
||||||
|
"""
|
||||||
|
sizes = [len(i.get("call_ids") or []) for i in incidents]
|
||||||
|
resolved_via = Counter(
|
||||||
|
(i.get("resolved_via") or ("unknown" if i.get("status") == "resolved" else "still_active"))
|
||||||
|
for i in incidents
|
||||||
|
)
|
||||||
|
corr_path: Counter = Counter()
|
||||||
|
consensus: Counter = Counter()
|
||||||
|
for c in calls:
|
||||||
|
scenes = c.get("scenes") or {}
|
||||||
|
records = [s.get("corr_debug") or {} for s in scenes.values()] if scenes else [c]
|
||||||
|
for r in records:
|
||||||
|
corr_path[r.get("corr_path") or "none"] += 1
|
||||||
|
consensus[r.get("corr_consensus") or "none"] += 1
|
||||||
|
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"))
|
||||||
|
return {
|
||||||
|
"calls": len(calls),
|
||||||
|
"calls_linked": linked,
|
||||||
|
"calls_orphaned": len(calls) - linked,
|
||||||
|
"incidents": len(incidents),
|
||||||
|
"single_call_incidents": sum(1 for s in sizes if s == 1),
|
||||||
|
"single_call_pct": round(100 * sum(1 for s in sizes if s == 1) / len(sizes), 1) if sizes else None,
|
||||||
|
"median_calls_per_incident": statistics.median(sizes) if sizes else None,
|
||||||
|
"max_calls_in_incident": max(sizes) if sizes else None,
|
||||||
|
"incidents_with_units_cleared": sum(1 for i in incidents if i.get("units_cleared")),
|
||||||
|
"incidents_with_coords": sum(1 for i in incidents if i.get("location_coords")),
|
||||||
|
"resolved_via": dict(resolved_via),
|
||||||
|
"corr_path": dict(corr_path),
|
||||||
|
"corr_consensus": dict(consensus),
|
||||||
|
"llm_decisions": llm,
|
||||||
|
}
|
||||||
@@ -142,14 +142,41 @@ async def _summarize_incident(inc: dict) -> None:
|
|||||||
await fstore.doc_set("incidents", incident_id, updates)
|
await fstore.doc_set("incidents", incident_id, updates)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_resolve_minutes(inc: dict) -> int:
|
||||||
|
"""Quiet time before a timer close, by severity (see config: incident_auto_resolve_minutes_*)."""
|
||||||
|
sev = (inc.get("severity") or "").lower()
|
||||||
|
if sev in ("routine", "minor"):
|
||||||
|
return settings.incident_auto_resolve_minutes_routine
|
||||||
|
if sev == "moderate":
|
||||||
|
return settings.incident_auto_resolve_minutes_moderate
|
||||||
|
return settings.incident_auto_resolve_minutes
|
||||||
|
|
||||||
|
|
||||||
|
async def _expire_reopen_windows(now) -> None:
|
||||||
|
"""A timer-closed incident stops being reopenable once its window passes,
|
||||||
|
so the correlator's reopenable pool stays bounded."""
|
||||||
|
window = timedelta(minutes=settings.incident_reopen_window_minutes)
|
||||||
|
for inc in await fstore.collection_list("incidents", status="resolved", reopenable=True):
|
||||||
|
try:
|
||||||
|
updated = datetime.fromisoformat(str(inc.get("updated_at", "")).replace("Z", "+00:00"))
|
||||||
|
if updated.tzinfo is None:
|
||||||
|
updated = updated.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
updated = None
|
||||||
|
if updated is None or now - updated > window:
|
||||||
|
await fstore.doc_set("incidents", inc["incident_id"], {"reopenable": False})
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_stale_incidents() -> None:
|
async def _resolve_stale_incidents() -> None:
|
||||||
"""Auto-resolve active incidents that have had no new calls for incident_auto_resolve_minutes."""
|
"""Timer-close active incidents that have been quiet longer than their severity allows."""
|
||||||
|
from app.internal import clock
|
||||||
|
await _expire_reopen_windows(clock.now())
|
||||||
all_active = await fstore.collection_list("incidents", status="active")
|
all_active = await fstore.collection_list("incidents", status="active")
|
||||||
if not all_active:
|
if not all_active:
|
||||||
return
|
return
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
from app.internal import clock
|
||||||
cutoff = timedelta(minutes=settings.incident_auto_resolve_minutes)
|
now = clock.now()
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
for inc in all_active:
|
for inc in all_active:
|
||||||
@@ -163,10 +190,12 @@ async def _resolve_stale_incidents() -> None:
|
|||||||
if updated_dt.tzinfo is None:
|
if updated_dt.tzinfo is None:
|
||||||
updated_dt = updated_dt.replace(tzinfo=timezone.utc)
|
updated_dt = updated_dt.replace(tzinfo=timezone.utc)
|
||||||
idle_minutes = (now - updated_dt).total_seconds() / 60
|
idle_minutes = (now - updated_dt).total_seconds() / 60
|
||||||
if idle_minutes > settings.incident_auto_resolve_minutes:
|
if idle_minutes > _auto_resolve_minutes(inc):
|
||||||
await fstore.doc_set("incidents", incident_id, {
|
await fstore.doc_set("incidents", incident_id, {
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"resolved_at": now.isoformat(),
|
"resolved_at": now.isoformat(),
|
||||||
|
"resolved_via": "idle_timeout",
|
||||||
|
"reopenable": True,
|
||||||
})
|
})
|
||||||
from app.internal.incident_correlator import maybe_resolve_parent
|
from app.internal.incident_correlator import maybe_resolve_parent
|
||||||
await maybe_resolve_parent(incident_id)
|
await maybe_resolve_parent(incident_id)
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ another equally plausible word.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@@ -228,14 +227,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:
|
||||||
import google.generativeai as genai # lazy import — only when needed
|
from app.internal import gemini
|
||||||
|
# Correction rewrites text against vocabulary; keep a little reasoning
|
||||||
genai.configure(api_key=settings.gemini_api_key)
|
# ("low") rather than the correlator's "minimal" until a replay shows
|
||||||
model = genai.GenerativeModel(
|
# minimal doesn't hurt it.
|
||||||
model_name,
|
return gemini.generate_json(model_name, prompt, purpose="correction", thinking_level="low")
|
||||||
generation_config={"response_mime_type": "application/json"},
|
|
||||||
)
|
|
||||||
return json.loads(model.generate_content(prompt).text)
|
|
||||||
|
|
||||||
|
|
||||||
async def correct(
|
async def correct(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.internal.auth import (
|
|||||||
require_node_service_or_firebase_token,
|
require_node_service_or_firebase_token,
|
||||||
)
|
)
|
||||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||||
from app.routers import enrollment, media, org, waitlist, telemetry
|
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic
|
||||||
from app.internal import dynsec
|
from app.internal import dynsec
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
@@ -127,8 +127,10 @@ app.include_router(incidents.router, dependencies=[Depends(require_service_or_fi
|
|||||||
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(upload.router) # auth is per-node, handled inline
|
app.include_router(upload.router) # auth is per-node, handled inline
|
||||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||||
|
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
|
||||||
app.include_router(users.router) # auth: admin only
|
app.include_router(users.router) # auth: admin only
|
||||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||||
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
|
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
|
||||||
|
|||||||
@@ -62,8 +62,17 @@ class NodeRecord(BaseModel):
|
|||||||
last_seen: Optional[datetime] = None
|
last_seen: Optional[datetime] = None
|
||||||
assigned_system_id: Optional[str] = None
|
assigned_system_id: Optional[str] = None
|
||||||
node_type: str = "fixed" # fixed or portable
|
node_type: str = "fixed" # fixed or portable
|
||||||
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR
|
secondary_sdr_mode: str = "none" # legacy single-mode field; priority[0] on current nodes
|
||||||
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
|
# Ordered decoders for the SDRs beyond op25's (node-26#9): the node runs
|
||||||
|
# them top-down until it runs out of dongles. Mirrored from the node's own
|
||||||
|
# checkin, which is the source of truth; set via PATCH /nodes/{id}.
|
||||||
|
secondary_sdr_priority: List[str] = []
|
||||||
|
secondary_sdr_running: Optional[List[str]] = None # what the node reports actually running
|
||||||
|
# node-26#11: service (op25/adsb/ais) -> dongle serial; absent = automatic.
|
||||||
|
sdr_pins: Dict[str, str] = {}
|
||||||
|
sdr_devices: Optional[List[Dict[str, Any]]] = None # [{index, serial, name, duplicate_serial}] from checkin
|
||||||
|
op25_sdr_serial: Optional[str] = None # the dongle OP25 is actually using, per the node
|
||||||
|
sdr_count: Optional[int] = None # self-reported by the node's checkin; None = never reported
|
||||||
enforce_override_timeout: bool = True
|
enforce_override_timeout: bool = True
|
||||||
is_overridden: bool = False
|
is_overridden: bool = False
|
||||||
override_system_id: Optional[str] = None
|
override_system_id: Optional[str] = None
|
||||||
|
|||||||
@@ -23,6 +23,42 @@ class EvalTranscriptUpdate(BaseModel):
|
|||||||
router = APIRouter(prefix="/calls", tags=["calls"])
|
router = APIRouter(prefix="/calls", tags=["calls"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(value: Optional[str], field: str) -> Optional[datetime]:
|
||||||
|
"""ISO string from a query param → aware datetime, or 400.
|
||||||
|
|
||||||
|
started_at is stored as a Firestore timestamp, so a cursor or range bound
|
||||||
|
passed through as the raw string compares by *type* (every string sorts
|
||||||
|
after every timestamp) rather than by time — a string cursor made "Load
|
||||||
|
more" return the first page again.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"{field} is not an ISO-8601 timestamp.")
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_cursor(rows: list[dict], matches: list[dict], page: list[dict], window: int) -> Optional[str]:
|
||||||
|
"""Where the next page of a bounded-window scan starts.
|
||||||
|
|
||||||
|
More matches than fit on the page → resume right after the last row
|
||||||
|
returned, or every match between it and the end of the window is skipped
|
||||||
|
(a 200-row window shown 50 at a time lost 150 calls per "Load more").
|
||||||
|
Otherwise resume after the last row SCANNED, not the last match — a page
|
||||||
|
whose last match sits early in the window would re-scan everything after
|
||||||
|
it and loop forever on a sparse filter. A short window is the end.
|
||||||
|
"""
|
||||||
|
if len(matches) > len(page):
|
||||||
|
last = page[-1].get("started_at")
|
||||||
|
elif len(rows) == window:
|
||||||
|
last = rows[-1].get("started_at")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return last.isoformat() if hasattr(last, "isoformat") else last
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_calls(
|
async def list_calls(
|
||||||
node_id: Optional[str] = Query(None),
|
node_id: Optional[str] = Query(None),
|
||||||
@@ -55,6 +91,8 @@ async def search_calls(
|
|||||||
link: str = Query("any", pattern="^(any|orphan|linked)$"),
|
link: str = Query("any", pattern="^(any|orphan|linked)$"),
|
||||||
transcript: str = Query("any", pattern="^(any|yes|no)$"),
|
transcript: str = Query("any", pattern="^(any|yes|no)$"),
|
||||||
q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"),
|
q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"),
|
||||||
|
date_from: Optional[str] = Query(None, description="ISO timestamp, inclusive lower bound on started_at"),
|
||||||
|
date_to: Optional[str] = Query(None, description="ISO timestamp, inclusive upper bound on started_at"),
|
||||||
decoded: dict = Depends(require_firebase_token),
|
decoded: dict = Depends(require_firebase_token),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -88,13 +126,24 @@ async def search_calls(
|
|||||||
if not org_id:
|
if not org_id:
|
||||||
raise HTTPException(403, "No organization scope for this caller.")
|
raise HTTPException(403, "No organization scope for this caller.")
|
||||||
|
|
||||||
|
cursor_dt = _parse_ts(cursor, "cursor")
|
||||||
|
from_dt = _parse_ts(date_from, "date_from")
|
||||||
|
to_dt = _parse_ts(date_to, "date_to")
|
||||||
|
|
||||||
|
# A range on the ordered field rides the same org_id/started_at index.
|
||||||
|
conditions: list[tuple[str, str, object]] = [("org_id", "==", org_id)]
|
||||||
|
if from_dt:
|
||||||
|
conditions.append(("started_at", ">=", from_dt))
|
||||||
|
if to_dt:
|
||||||
|
conditions.append(("started_at", "<=", to_dt))
|
||||||
|
|
||||||
window = max(limit * 10, 200)
|
window = max(limit * 10, 200)
|
||||||
rows = await fstore.collection_where(
|
rows = await fstore.collection_where(
|
||||||
"calls",
|
"calls",
|
||||||
[("org_id", "==", org_id)],
|
conditions,
|
||||||
order_by=[("started_at", "DESCENDING")],
|
order_by=[("started_at", "DESCENDING")],
|
||||||
limit_to=window,
|
limit_to=window,
|
||||||
start_after={"started_at": cursor} if cursor else None,
|
start_after={"started_at": cursor_dt} if cursor_dt else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
needle = (q or "").strip().lower()
|
needle = (q or "").strip().lower()
|
||||||
@@ -123,13 +172,7 @@ async def search_calls(
|
|||||||
matches = [c for c in rows if _keep(c)]
|
matches = [c for c in rows if _keep(c)]
|
||||||
page = matches[:limit]
|
page = matches[:limit]
|
||||||
|
|
||||||
# Cursor advances over the SCANNED window, not the filtered page — otherwise
|
next_cursor = _next_cursor(rows, matches, page, window)
|
||||||
# a page whose last match sits early in the window would re-scan everything
|
|
||||||
# after it on the next request and loop forever on a sparse filter.
|
|
||||||
next_cursor = None
|
|
||||||
if len(rows) == window:
|
|
||||||
last_scanned = rows[-1].get("started_at")
|
|
||||||
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"calls": [with_playback_url(c) for c in page],
|
"calls": [with_playback_url(c) for c in page],
|
||||||
@@ -169,13 +212,14 @@ async def eval_queue(
|
|||||||
if not org_id:
|
if not org_id:
|
||||||
raise HTTPException(403, "No organization scope for this caller.")
|
raise HTTPException(403, "No organization scope for this caller.")
|
||||||
|
|
||||||
|
cursor_dt = _parse_ts(cursor, "cursor")
|
||||||
window = max(limit * 20, 300)
|
window = max(limit * 20, 300)
|
||||||
rows = await fstore.collection_where(
|
rows = await fstore.collection_where(
|
||||||
"calls",
|
"calls",
|
||||||
[("org_id", "==", org_id)],
|
[("org_id", "==", org_id)],
|
||||||
order_by=[("started_at", "DESCENDING")],
|
order_by=[("started_at", "DESCENDING")],
|
||||||
limit_to=window,
|
limit_to=window,
|
||||||
start_after={"started_at": cursor} if cursor else None,
|
start_after={"started_at": cursor_dt} if cursor_dt else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _eligible(c: dict) -> bool:
|
def _eligible(c: dict) -> bool:
|
||||||
@@ -185,10 +229,7 @@ async def eval_queue(
|
|||||||
matches = [c for c in rows if _eligible(c)]
|
matches = [c for c in rows if _eligible(c)]
|
||||||
page = matches[:limit]
|
page = matches[:limit]
|
||||||
|
|
||||||
next_cursor = None
|
next_cursor = _next_cursor(rows, matches, page, window)
|
||||||
if len(rows) == window:
|
|
||||||
last_scanned = rows[-1].get("started_at")
|
|
||||||
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"calls": [with_playback_url(c) for c in page],
|
"calls": [with_playback_url(c) for c in page],
|
||||||
@@ -402,6 +443,7 @@ async def patch_transcript(
|
|||||||
"call_ids": [],
|
"call_ids": [],
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"resolved_via": "emptied_by_correction",
|
||||||
"summary_stale": True,
|
"summary_stale": True,
|
||||||
})
|
})
|
||||||
await fstore.doc_set("calls", call_id, {"incident_ids": [], "incident_id": None})
|
await fstore.doc_set("calls", call_id, {"incident_ids": [], "incident_id": None})
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ async def unlink_call_from_incident(incident_id: str, call_id: str, _: dict = De
|
|||||||
if not remaining:
|
if not remaining:
|
||||||
updates["status"] = "resolved"
|
updates["status"] = "resolved"
|
||||||
updates["resolved_at"] = datetime.now(timezone.utc).isoformat()
|
updates["resolved_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
updates["resolved_via"] = "emptied_by_admin"
|
||||||
await fstore.doc_update("incidents", incident_id, updates)
|
await fstore.doc_update("incidents", incident_id, updates)
|
||||||
|
|
||||||
call = await fstore.doc_get("calls", call_id)
|
call = await fstore.doc_get("calls", call_id)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import Dict, List, Optional
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.models import CommandPayload
|
from app.models import CommandPayload
|
||||||
@@ -192,10 +192,19 @@ async def assign_system(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
SECONDARY_SDR_MODES = ("adsb", "ais")
|
||||||
|
SDR_PIN_KEYS = ("op25",) + SECONDARY_SDR_MODES
|
||||||
|
|
||||||
|
|
||||||
class NodeUpdateBody(BaseModel):
|
class NodeUpdateBody(BaseModel):
|
||||||
node_type: Optional[str] = None
|
node_type: Optional[str] = None
|
||||||
enforce_override_timeout: Optional[bool] = None
|
enforce_override_timeout: Optional[bool] = None
|
||||||
secondary_sdr_mode: Optional[str] = None # none | adsb | ais | op25_2
|
secondary_sdr_mode: Optional[str] = None # legacy: none | adsb | ais
|
||||||
|
# Ordered, e.g. ["adsb", "ais"]: SDRs beyond op25's run these top-down.
|
||||||
|
secondary_sdr_priority: Optional[List[str]] = None
|
||||||
|
# node-26#11: service -> dongle serial; null/"" = automatic. Moving OP25's
|
||||||
|
# dongle restarts OP25 on the node; the other pins never do.
|
||||||
|
sdr_pins: Optional[Dict[str, Optional[str]]] = None
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{node_id}")
|
@router.patch("/{node_id}")
|
||||||
@@ -212,8 +221,41 @@ async def update_node(
|
|||||||
if not updates:
|
if not updates:
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
priority = updates.get("secondary_sdr_priority")
|
||||||
|
if priority is not None:
|
||||||
|
unknown = [m for m in priority if m not in SECONDARY_SDR_MODES]
|
||||||
|
if unknown or len(set(priority)) != len(priority):
|
||||||
|
raise HTTPException(400, f"secondary_sdr_priority must be distinct values from {SECONDARY_SDR_MODES}.")
|
||||||
|
updates["secondary_sdr_mode"] = priority[0] if priority else "none"
|
||||||
|
|
||||||
|
if "sdr_pins" in updates:
|
||||||
|
raw = updates["sdr_pins"] or {}
|
||||||
|
unknown = [k for k in raw if k not in SDR_PIN_KEYS]
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(400, f"sdr_pins keys must be from {SDR_PIN_KEYS}.")
|
||||||
|
pins = {k: str(v).strip() for k, v in raw.items() if v and str(v).strip()}
|
||||||
|
if len(set(pins.values())) != len(pins):
|
||||||
|
raise HTTPException(400, "Two services can't be pinned to the same SDR.")
|
||||||
|
updates["sdr_pins"] = pins
|
||||||
|
|
||||||
await fstore.doc_update("nodes", node_id, updates)
|
await fstore.doc_update("nodes", node_id, updates)
|
||||||
|
|
||||||
|
# SDR settings go as their own command: a config re-push restarts OP25, and
|
||||||
|
# changing what the spare dongles do must never interrupt P25 recording
|
||||||
|
# (only moving OP25's own dongle restarts it, on the node's side). The node
|
||||||
|
# applies it, then its checkin reports back what's really running.
|
||||||
|
sdr_keys = {"secondary_sdr_priority", "secondary_sdr_mode", "sdr_pins"}
|
||||||
|
if sdr_keys & set(updates):
|
||||||
|
command = {"action": "set_sdr_config"}
|
||||||
|
if priority is not None:
|
||||||
|
command["priority"] = priority
|
||||||
|
if "sdr_pins" in updates:
|
||||||
|
# Explicit nulls so a cleared pin reaches the node as "automatic".
|
||||||
|
command["pins"] = {k: updates["sdr_pins"].get(k) for k in SDR_PIN_KEYS}
|
||||||
|
mqtt_handler.send_command(node_id, command)
|
||||||
|
if set(updates) <= sdr_keys:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
# Re-push config to apply new node settings locally
|
# Re-push config to apply new node settings locally
|
||||||
updated_node = await fstore.doc_get("nodes", node_id)
|
updated_node = await fstore.doc_get("nodes", node_id)
|
||||||
assigned_system_id = updated_node.get("assigned_system_id")
|
assigned_system_id = updated_node.get("assigned_system_id")
|
||||||
@@ -228,7 +270,9 @@ async def update_node(
|
|||||||
}
|
}
|
||||||
if updated_node.get("ppm_override") is not None:
|
if updated_node.get("ppm_override") is not None:
|
||||||
push_payload["ppm_override"] = updated_node["ppm_override"]
|
push_payload["ppm_override"] = updated_node["ppm_override"]
|
||||||
if updated_node.get("secondary_sdr_mode") is not None:
|
if updated_node.get("secondary_sdr_priority") is not None:
|
||||||
|
push_payload["secondary_sdr_priority"] = updated_node["secondary_sdr_priority"]
|
||||||
|
elif updated_node.get("secondary_sdr_mode") is not None:
|
||||||
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
|
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
|
||||||
mqtt_handler.push_config(node_id, push_payload)
|
mqtt_handler.push_config(node_id, push_payload)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Admin replay routes — the backend for the /admin Replay tab.
|
||||||
|
|
||||||
|
See app/internal/replay.py for what a run is and why it exists. Every route is
|
||||||
|
admin-only: a run spends real AI credits.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.internal import replay
|
||||||
|
from app.internal.audit import write_audit
|
||||||
|
from app.internal.auth import describe_actor, require_admin_token, resolve_caller_org_id
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/replay", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(value: Optional[str], field: str) -> datetime:
|
||||||
|
if not value:
|
||||||
|
raise HTTPException(400, f"{field} is required.")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"{field} is not an ISO-8601 timestamp.")
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _org(decoded: dict) -> str:
|
||||||
|
# Same fallback as /calls/search: a platform admin resolves to "every
|
||||||
|
# org", which is not a scope a replay can run in.
|
||||||
|
org_id = await resolve_caller_org_id(decoded) or decoded.get("org_id")
|
||||||
|
if not org_id:
|
||||||
|
raise HTTPException(403, "No organization scope for this caller.")
|
||||||
|
return org_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _own_run(run_id: str, org_id: str) -> dict:
|
||||||
|
run = await replay.get_run(run_id)
|
||||||
|
if not run or run.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, f"Replay run '{run_id}' not found.")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/estimate")
|
||||||
|
async def estimate_run(
|
||||||
|
date_from: str = Query(...),
|
||||||
|
date_to: str = Query(...),
|
||||||
|
mode: Literal["audio", "transcripts", "reuse"] = Query("transcripts"),
|
||||||
|
system_ids: Optional[str] = Query(None, description="comma-separated"),
|
||||||
|
decoded: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""How many calls a run over this range would process, and a rough cost."""
|
||||||
|
org_id = await _org(decoded)
|
||||||
|
sids = [s for s in (system_ids or "").split(",") if s] or None
|
||||||
|
calls, truncated = await replay.select_calls(
|
||||||
|
org_id, _parse_ts(date_from, "date_from"), _parse_ts(date_to, "date_to"), sids,
|
||||||
|
)
|
||||||
|
return {**replay.estimate(calls, mode), "truncated": truncated, "max_calls": replay.MAX_CALLS}
|
||||||
|
|
||||||
|
|
||||||
|
class StartRun(BaseModel):
|
||||||
|
date_from: str
|
||||||
|
date_to: str
|
||||||
|
mode: Literal["audio", "transcripts", "reuse"] = "transcripts"
|
||||||
|
system_ids: Optional[list[str]] = None
|
||||||
|
source_run_id: Optional[str] = None
|
||||||
|
label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def start_run(body: StartRun, decoded: dict = Depends(require_admin_token)):
|
||||||
|
org_id = await _org(decoded)
|
||||||
|
actor_uid, actor_email = describe_actor(decoded)
|
||||||
|
try:
|
||||||
|
run = await replay.start_run(
|
||||||
|
org_id=org_id,
|
||||||
|
date_from=_parse_ts(body.date_from, "date_from"),
|
||||||
|
date_to=_parse_ts(body.date_to, "date_to"),
|
||||||
|
mode=body.mode,
|
||||||
|
system_ids=body.system_ids or None,
|
||||||
|
source_run_id=body.source_run_id,
|
||||||
|
label=body.label[:120],
|
||||||
|
actor=actor_email or actor_uid,
|
||||||
|
)
|
||||||
|
except replay.ReplayBusy as e:
|
||||||
|
raise HTTPException(409, str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
try:
|
||||||
|
await write_audit(actor_uid, actor_email, "replay.start", details={
|
||||||
|
"run_id": run["run_id"], "mode": run["mode"], "calls": run["progress"]["total"],
|
||||||
|
"est_cost_usd": run["estimate"]["est_cost_usd"],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Replay: audit write failed ({e}) — run {run['run_id']} continues")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_runs(decoded: dict = Depends(require_admin_token)):
|
||||||
|
org_id = await _org(decoded)
|
||||||
|
return {"runs": await replay.list_runs(org_id), "active_run_id": replay.active_run_id()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{run_id}")
|
||||||
|
async def get_run(run_id: str, decoded: dict = Depends(require_admin_token)):
|
||||||
|
return await _own_run(run_id, await _org(decoded))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{run_id}/cancel")
|
||||||
|
async def cancel_run(run_id: str, decoded: dict = Depends(require_admin_token)):
|
||||||
|
await _own_run(run_id, await _org(decoded))
|
||||||
|
if not replay.request_cancel(run_id):
|
||||||
|
raise HTTPException(409, "That run is not running.")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{run_id}")
|
||||||
|
async def delete_run(run_id: str, decoded: dict = Depends(require_admin_token)):
|
||||||
|
await _own_run(run_id, await _org(decoded))
|
||||||
|
try:
|
||||||
|
await replay.delete_run(run_id)
|
||||||
|
except replay.ReplayBusy as e:
|
||||||
|
raise HTTPException(409, str(e))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _call_row(c: dict) -> dict:
|
||||||
|
scenes = c.get("scenes") or {}
|
||||||
|
paths = [((s.get("corr_debug") or {}).get("corr_path")) for _, s in sorted(scenes.items())]
|
||||||
|
return {
|
||||||
|
"call_id": c.get("call_id"),
|
||||||
|
"started_at": c.get("started_at"),
|
||||||
|
"talkgroup_name": c.get("talkgroup_name"),
|
||||||
|
"transcript": c.get("transcript_corrected") or c.get("transcript"),
|
||||||
|
"units": c.get("units"),
|
||||||
|
"cleared_units": c.get("cleared_units"),
|
||||||
|
"location": c.get("location"),
|
||||||
|
"skip_reason": c.get("skip_reason"),
|
||||||
|
"srcaddr": c.get("srcaddr"),
|
||||||
|
"corr_path": [p for p in paths if p] or ([c["corr_path"]] if c.get("corr_path") else []),
|
||||||
|
"incident_ids": c.get("incident_ids") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{run_id}/incidents")
|
||||||
|
async def run_incidents(run_id: str, decoded: dict = Depends(require_admin_token)):
|
||||||
|
"""
|
||||||
|
A run's sandbox, shaped for reading: every incident with its calls in
|
||||||
|
order, plus the calls that never linked. Embeddings stay out.
|
||||||
|
"""
|
||||||
|
await _own_run(run_id, await _org(decoded))
|
||||||
|
incidents, calls = await replay.sandbox_contents(run_id)
|
||||||
|
by_id = {c.get("call_id"): _call_row(c) for c in calls}
|
||||||
|
out = []
|
||||||
|
for inc in sorted(incidents, key=lambda i: str(i.get("started_at") or "")):
|
||||||
|
rows = [by_id[cid] for cid in (inc.get("call_ids") or []) if cid in by_id]
|
||||||
|
rows.sort(key=lambda r: str(r["started_at"] or ""))
|
||||||
|
out.append({
|
||||||
|
"incident_id": inc.get("incident_id"),
|
||||||
|
"title": inc.get("title"),
|
||||||
|
"type": inc.get("type"),
|
||||||
|
"severity": inc.get("severity"),
|
||||||
|
"status": inc.get("status"),
|
||||||
|
"resolved_via": inc.get("resolved_via"),
|
||||||
|
"started_at": inc.get("started_at"),
|
||||||
|
"updated_at": inc.get("updated_at"),
|
||||||
|
"resolved_at": inc.get("resolved_at"),
|
||||||
|
"location": inc.get("location"),
|
||||||
|
"location_coords": inc.get("location_coords"),
|
||||||
|
"units": inc.get("units"),
|
||||||
|
"units_active": inc.get("units_active"),
|
||||||
|
"units_cleared": inc.get("units_cleared"),
|
||||||
|
"talkgroup_ids": inc.get("talkgroup_ids"),
|
||||||
|
"srcaddrs": inc.get("srcaddrs"),
|
||||||
|
"calls": rows,
|
||||||
|
})
|
||||||
|
orphans = sorted((r for r in by_id.values() if not r["incident_ids"]),
|
||||||
|
key=lambda r: str(r["started_at"] or ""))
|
||||||
|
return {"incidents": out, "orphans": orphans}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
import asyncio
|
||||||
from typing import List, Optional
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -10,6 +11,20 @@ from app.internal.logger import logger
|
|||||||
|
|
||||||
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
|
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
|
||||||
|
|
||||||
|
# Flight trail: every position change is also written to
|
||||||
|
# aircraft/{icao}/positions/{epoch_ms}, so clicking an aircraft on the map can
|
||||||
|
# draw the path heard so far. Points expire via a Firestore TTL policy on
|
||||||
|
# expire_at (infra/firestore/firestore.indexes.json fieldOverrides).
|
||||||
|
POSITIONS_SUBCOLLECTION = "positions"
|
||||||
|
POSITION_TTL = timedelta(hours=24)
|
||||||
|
|
||||||
|
# Last position written per icao, so an aircraft reported unchanged across
|
||||||
|
# several 10s uploads (readsb holds a position until a new one decodes)
|
||||||
|
# doesn't get a duplicate point each time. Process-local and lossy by design:
|
||||||
|
# after a restart the worst case is one duplicate point per aircraft.
|
||||||
|
_last_position: Dict[str, Tuple[float, float]] = {}
|
||||||
|
_LAST_POSITION_MAX = 5000
|
||||||
|
|
||||||
|
|
||||||
class AircraftReport(BaseModel):
|
class AircraftReport(BaseModel):
|
||||||
icao: str
|
icao: str
|
||||||
@@ -32,8 +47,8 @@ async def upload_adsb(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Node-initiated: a second-SDR ADS-B decoder (node-26#9) periodically posts
|
Node-initiated: a second-SDR ADS-B decoder (node-26#9) periodically posts
|
||||||
its current aircraft snapshot here. One doc per icao, last-seen-wins —
|
its current aircraft snapshot here. One doc per icao, last-seen-wins,
|
||||||
this is a live-map overlay, not a flight history.
|
plus one trail point per position change (see POSITIONS_SUBCOLLECTION).
|
||||||
"""
|
"""
|
||||||
node_id = decoded.get("node_id")
|
node_id = decoded.get("node_id")
|
||||||
if not node_id:
|
if not node_id:
|
||||||
@@ -43,7 +58,11 @@ async def upload_adsb(
|
|||||||
org_id = node.get("org_id") if node else None
|
org_id = node.get("org_id") if node else None
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
expire_at = datetime.now(timezone.utc) + POSITION_TTL
|
||||||
|
epoch_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||||
|
|
||||||
writes = []
|
writes = []
|
||||||
|
trail = []
|
||||||
for ac in body.aircraft:
|
for ac in body.aircraft:
|
||||||
if not ac.icao:
|
if not ac.icao:
|
||||||
continue
|
continue
|
||||||
@@ -62,12 +81,33 @@ async def upload_adsb(
|
|||||||
doc["org_id"] = org_id
|
doc["org_id"] = org_id
|
||||||
writes.append(("aircraft", ac.icao, doc))
|
writes.append(("aircraft", ac.icao, doc))
|
||||||
|
|
||||||
for collection, doc_id, doc in writes:
|
if ac.lat is None or ac.lon is None:
|
||||||
|
continue
|
||||||
|
pos = (ac.lat, ac.lon)
|
||||||
|
if _last_position.get(ac.icao) == pos:
|
||||||
|
continue
|
||||||
|
_last_position[ac.icao] = pos
|
||||||
|
point = {
|
||||||
|
"lat": ac.lat,
|
||||||
|
"lon": ac.lon,
|
||||||
|
"altitude_ft": ac.altitude_ft,
|
||||||
|
"t": now,
|
||||||
|
"expire_at": expire_at,
|
||||||
|
}
|
||||||
|
trail.append((f"aircraft/{ac.icao}/{POSITIONS_SUBCOLLECTION}", str(epoch_ms), point))
|
||||||
|
|
||||||
|
if len(_last_position) > _LAST_POSITION_MAX:
|
||||||
|
_last_position.clear()
|
||||||
|
|
||||||
|
async def _write(collection: str, doc_id: str, doc: dict) -> None:
|
||||||
try:
|
try:
|
||||||
await fstore.doc_set(collection, doc_id, doc, merge=True)
|
await fstore.doc_set(collection, doc_id, doc, merge=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to upsert {collection}/{doc_id} from node {node_id}: {e}")
|
logger.warning(f"Failed to upsert {collection}/{doc_id} from node {node_id}: {e}")
|
||||||
|
|
||||||
|
# Concurrent: a busy sky is dozens of aircraft, two writes each, every 10s.
|
||||||
|
await asyncio.gather(*(_write(*w) for w in writes + trail))
|
||||||
|
|
||||||
return {"ok": True, "count": len(writes)}
|
return {"ok": True, "count": len(writes)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from app.internal import ny511
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/traffic", tags=["traffic"])
|
||||||
|
|
||||||
|
# Per-layer cap per response. A statewide view is ~3k cameras; past this the
|
||||||
|
# map is unreadable anyway, and the client is told the list was cut.
|
||||||
|
MAX_ITEMS = 1500
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_status(feed: "ny511._Feed") -> dict:
|
||||||
|
return {"fetched_at": feed.fetched_at, "error": feed.error}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/511")
|
||||||
|
async def get_511(
|
||||||
|
south: float = Query(..., ge=-90, le=90),
|
||||||
|
west: float = Query(..., ge=-180, le=180),
|
||||||
|
north: float = Query(..., ge=-90, le=90),
|
||||||
|
east: float = Query(..., ge=-180, le=180),
|
||||||
|
layers: Optional[str] = Query("cameras,events", description="comma list: cameras, events"),
|
||||||
|
):
|
||||||
|
if south > north or west > east:
|
||||||
|
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
|
||||||
|
wanted = {s.strip() for s in (layers or "").split(",") if s.strip()}
|
||||||
|
feeds = {name: getattr(ny511, name) for name in ("cameras", "events") if name in wanted}
|
||||||
|
await asyncio.gather(*(f.get() for f in feeds.values()))
|
||||||
|
|
||||||
|
out: dict = {}
|
||||||
|
for name, feed in feeds.items():
|
||||||
|
hits = ny511.in_bbox(feed.items, south, west, north, east)
|
||||||
|
out[name] = hits[:MAX_ITEMS]
|
||||||
|
out[f"{name}_status"] = {**_feed_status(feed), "total_in_bbox": len(hits), "truncated": len(hits) > MAX_ITEMS}
|
||||||
|
return out
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime, timezone
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
|
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from app.internal.storage import upload_audio
|
from app.internal.storage import upload_audio
|
||||||
from app.internal import dedup
|
from app.internal import dedup
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal import clock
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
|||||||
if tg_id is None or not system_id:
|
if tg_id is None or not system_id:
|
||||||
return False
|
return False
|
||||||
tg_str = str(tg_id)
|
tg_str = str(tg_id)
|
||||||
now = ctx.get("now") or datetime.now(timezone.utc)
|
now = ctx.get("now") or clock.now()
|
||||||
idle_limit = settings.tg_dispatch_thin_idle_minutes
|
idle_limit = settings.tg_dispatch_thin_idle_minutes
|
||||||
for inc in ctx.get("recent") or []:
|
for inc in ctx.get("recent") or []:
|
||||||
if system_id not in (inc.get("system_ids") or []):
|
if system_id not in (inc.get("system_ids") or []):
|
||||||
@@ -374,7 +374,9 @@ async def _run_extraction_pipeline(
|
|||||||
if scene["resolved"] and incident_id:
|
if scene["resolved"] and incident_id:
|
||||||
await fstore.doc_set("incidents", incident_id, {
|
await fstore.doc_set("incidents", incident_id, {
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
"resolved_at": clock.now().isoformat(),
|
||||||
|
"resolved_via": "llm_closure",
|
||||||
|
"reopenable": True, # provisional, see _extract_and_correlate
|
||||||
})
|
})
|
||||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||||
@@ -396,6 +398,118 @@ async def _run_extraction_pipeline(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_and_correlate(
|
||||||
|
call_id: str,
|
||||||
|
node_id: str,
|
||||||
|
system_id: Optional[str],
|
||||||
|
talkgroup_id: Optional[int],
|
||||||
|
talkgroup_name: Optional[str],
|
||||||
|
transcript: Optional[str],
|
||||||
|
segments: Optional[list[dict]] = None,
|
||||||
|
scenes: Optional[list[dict]] = None,
|
||||||
|
) -> tuple[list[str], list[str], list[dict]]:
|
||||||
|
"""
|
||||||
|
Steps 2-3 of the intelligence pipeline for one call: scene extraction
|
||||||
|
(skipped when `scenes` is passed in), then per-scene correlation, then the
|
||||||
|
no-scene thin fallback. Returns (incident_ids, merged tags, scenes).
|
||||||
|
|
||||||
|
Shared by the live pipeline below and by replay (app/internal/replay.py),
|
||||||
|
so a replay run measures exactly the code that runs live rather than a
|
||||||
|
copy of it that can drift. Caller owns the correlation feature-flag check
|
||||||
|
and alerting.
|
||||||
|
"""
|
||||||
|
from app.internal import intelligence, incident_correlator
|
||||||
|
|
||||||
|
# Step 2: Scene detection + intelligence extraction
|
||||||
|
if scenes is None:
|
||||||
|
scenes = []
|
||||||
|
if transcript:
|
||||||
|
scenes = await intelligence.extract_scenes(
|
||||||
|
call_id, transcript, talkgroup_name,
|
||||||
|
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||||
|
node_id=node_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Correlate each scene independently.
|
||||||
|
# A single recording can produce multiple incidents on a busy channel.
|
||||||
|
incident_ids: list[str] = []
|
||||||
|
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
|
||||||
|
# talkgroup-based linking even when no transcript could be produced.
|
||||||
|
# transcript_too_short (<=5 words: "10-8", "show me clear", a unit
|
||||||
|
# check-in) still carries a real transcript and talkgroup — exactly the
|
||||||
|
# brief follow-up/clearance traffic an incident needs, and the thin-path
|
||||||
|
# merge below already requires a same-talkgroup, recently-active
|
||||||
|
# incident before attaching anything, same guard already trusted for
|
||||||
|
# no-transcript calls. Previously excluded here, so these calls never
|
||||||
|
# attached to anything at all. garbage_transcript (Whisper
|
||||||
|
# hallucination) has no real content behind it and stays excluded.
|
||||||
|
if not scenes:
|
||||||
|
_call_doc = await fstore.doc_get("calls", call_id)
|
||||||
|
skip_reason = (_call_doc or {}).get("skip_reason")
|
||||||
|
if not skip_reason or skip_reason == "transcript_too_short":
|
||||||
|
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=[],
|
||||||
|
incident_type=None,
|
||||||
|
location=None,
|
||||||
|
location_coords=None,
|
||||||
|
)
|
||||||
|
if incident_id:
|
||||||
|
incident_ids.append(incident_id)
|
||||||
|
|
||||||
|
if incident_ids:
|
||||||
|
await fstore.doc_set("calls", call_id, {"incident_ids": incident_ids})
|
||||||
|
|
||||||
|
return incident_ids, all_tags, scenes
|
||||||
|
|
||||||
|
|
||||||
async def _run_intelligence_pipeline(
|
async def _run_intelligence_pipeline(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
@@ -411,7 +525,7 @@ async def _run_intelligence_pipeline(
|
|||||||
3. Correlate each scene with existing incidents (or create new ones)
|
3. Correlate each scene with existing incidents (or create new ones)
|
||||||
4. Check alert rules and dispatch notifications
|
4. Check alert rules and dispatch notifications
|
||||||
"""
|
"""
|
||||||
from app.internal import transcription, intelligence, incident_correlator, alerter, talkgroups
|
from app.internal import transcription, alerter, talkgroups
|
||||||
|
|
||||||
# server-26#131: mark that real-time processing has started for this call
|
# server-26#131: mark that real-time processing has started for this call
|
||||||
# BEFORE any of the slow steps below (STT, scene extraction, correlation).
|
# BEFORE any of the slow steps below (STT, scene extraction, correlation).
|
||||||
@@ -427,7 +541,7 @@ async def _run_intelligence_pipeline(
|
|||||||
# calls). Best-effort: a write failure here must not abort the pipeline.
|
# calls). Best-effort: a write failure here must not abort the pipeline.
|
||||||
try:
|
try:
|
||||||
await fstore.doc_set("calls", call_id, {
|
await fstore.doc_set("calls", call_id, {
|
||||||
"intelligence_started_at": datetime.now(timezone.utc).isoformat()
|
"intelligence_started_at": clock.now().isoformat()
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not mark intelligence_started_at for call {call_id}: {e}")
|
logger.warning(f"Could not mark intelligence_started_at for call {call_id}: {e}")
|
||||||
@@ -466,90 +580,22 @@ async def _run_intelligence_pipeline(
|
|||||||
scope = "globally" if not flags["stt_enabled"] else f"system {system_id}"
|
scope = "globally" if not flags["stt_enabled"] else f"system {system_id}"
|
||||||
logger.info(f"STT disabled ({scope}) — skipping transcription for call {call_id}")
|
logger.info(f"STT disabled ({scope}) — skipping transcription for call {call_id}")
|
||||||
|
|
||||||
# Step 2: Scene detection + intelligence extraction
|
# Steps 2-3: scene extraction + correlation.
|
||||||
scenes: list[dict] = []
|
|
||||||
if _flag("correlation_enabled"):
|
|
||||||
if transcript:
|
|
||||||
scenes = await intelligence.extract_scenes(
|
|
||||||
call_id, transcript, talkgroup_name,
|
|
||||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
|
||||||
node_id=node_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
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}")
|
|
||||||
|
|
||||||
# Step 3: Correlate each scene independently.
|
|
||||||
# A single recording can produce multiple incidents on a busy channel.
|
|
||||||
incident_ids: list[str] = []
|
incident_ids: list[str] = []
|
||||||
all_tags: list[str] = []
|
all_tags: list[str] = []
|
||||||
if _flag("correlation_enabled"):
|
if _flag("correlation_enabled"):
|
||||||
# server-26#96: scene_index is threaded through so each scene's
|
incident_ids, all_tags, _ = await _extract_and_correlate(
|
||||||
# corr_debug/transcript lands in its own entry of the call doc's
|
call_id=call_id,
|
||||||
# `scenes` map instead of clobbering every other scene's write.
|
node_id=node_id,
|
||||||
for scene_index, scene in enumerate(scenes):
|
system_id=system_id,
|
||||||
all_tags.extend(scene["tags"])
|
talkgroup_id=talkgroup_id,
|
||||||
is_reassignment = bool(scene.get("reassignment"))
|
talkgroup_name=talkgroup_name,
|
||||||
corr_units = [] if is_reassignment else scene.get("units")
|
transcript=transcript,
|
||||||
incident_id = await _correlate_with_consensus(
|
segments=segments,
|
||||||
call_id=call_id,
|
)
|
||||||
node_id=node_id,
|
else:
|
||||||
system_id=system_id,
|
scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}"
|
||||||
talkgroup_id=talkgroup_id,
|
logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_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": datetime.now(timezone.utc).isoformat(),
|
|
||||||
})
|
|
||||||
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
|
|
||||||
# talkgroup-based linking even when no transcript could be produced.
|
|
||||||
# transcript_too_short (<=5 words: "10-8", "show me clear", a unit
|
|
||||||
# check-in) still carries a real transcript and talkgroup — exactly the
|
|
||||||
# brief follow-up/clearance traffic an incident needs, and the thin-path
|
|
||||||
# merge below already requires a same-talkgroup, recently-active
|
|
||||||
# incident before attaching anything, same guard already trusted for
|
|
||||||
# no-transcript calls. Previously excluded here, so these calls never
|
|
||||||
# attached to anything at all. garbage_transcript (Whisper
|
|
||||||
# hallucination) has no real content behind it and stays excluded.
|
|
||||||
if not scenes:
|
|
||||||
_call_doc = await fstore.doc_get("calls", call_id)
|
|
||||||
skip_reason = (_call_doc or {}).get("skip_reason")
|
|
||||||
if not skip_reason or skip_reason == "transcript_too_short":
|
|
||||||
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=[],
|
|
||||||
incident_type=None,
|
|
||||||
location=None,
|
|
||||||
location_coords=None,
|
|
||||||
)
|
|
||||||
if incident_id:
|
|
||||||
incident_ids.append(incident_id)
|
|
||||||
|
|
||||||
if incident_ids:
|
|
||||||
await fstore.doc_set("calls", call_id, {"incident_ids": incident_ids})
|
|
||||||
|
|
||||||
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
|
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
|
||||||
await alerter.check_and_dispatch(
|
await alerter.check_and_dispatch(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ firebase-admin
|
|||||||
google-cloud-storage
|
google-cloud-storage
|
||||||
openai
|
openai
|
||||||
google-generativeai
|
google-generativeai
|
||||||
|
google-genai
|
||||||
numpy
|
numpy
|
||||||
httpx
|
httpx
|
||||||
python-multipart
|
python-multipart
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""calls._parse_ts — cursor/date bounds must reach Firestore as datetimes.
|
||||||
|
|
||||||
|
A raw ISO string compared against a timestamp field sorts by type, not time,
|
||||||
|
which made the Archive's "Load more" return the first page again.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.calls import _next_cursor, _parse_ts
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_is_none():
|
||||||
|
assert _parse_ts(None, "cursor") is None
|
||||||
|
assert _parse_ts("", "cursor") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_z_suffix_parses_as_utc():
|
||||||
|
assert _parse_ts("2026-09-20T12:00:00Z", "date_from") == datetime(2026, 9, 20, 12, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_naive_is_assumed_utc():
|
||||||
|
assert _parse_ts("2026-09-20T12:00:00", "date_to").tzinfo == timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_trips_isoformat_cursor():
|
||||||
|
dt = datetime(2026, 9, 20, 12, 30, 5, 123456, tzinfo=timezone.utc)
|
||||||
|
assert _parse_ts(dt.isoformat(), "cursor") == dt
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_is_400():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_parse_ts("yesterday", "date_from")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── _next_cursor ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(n):
|
||||||
|
return [{"started_at": datetime(2026, 9, 20, 12, i // 60, i % 60, tzinfo=timezone.utc)} for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_resumes_after_last_returned_row_when_matches_overflow():
|
||||||
|
rows = _rows(200)
|
||||||
|
page = rows[:50]
|
||||||
|
assert _next_cursor(rows, rows, page, 200) == page[-1]["started_at"].isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_resumes_after_window_when_page_holds_every_match():
|
||||||
|
rows = _rows(200)
|
||||||
|
matches = rows[:3]
|
||||||
|
assert _next_cursor(rows, matches, matches, 200) == rows[-1]["started_at"].isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_window_is_the_end():
|
||||||
|
rows = _rows(20)
|
||||||
|
assert _next_cursor(rows, rows[:5], rows[:5], 200) is None
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Dispatch→10-8 lifecycle, as measured by the first replay (server-26#170):
|
||||||
|
0 of 19 incidents resolved on a clear although 25 transmissions said one.
|
||||||
|
Three independent breaks, each pinned here.
|
||||||
|
"""
|
||||||
|
from app.internal import incident_correlator as ic
|
||||||
|
from app.internal.intelligence import _clearance_scene, _short_clearance_unit
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_clearance_names_the_unit_that_cleared():
|
||||||
|
assert _short_clearance_unit("45-9, I'm clear.") == "45-9"
|
||||||
|
assert _short_clearance_unit("Vehicle 1, clear.") == "Vehicle 1"
|
||||||
|
assert _short_clearance_unit("11 Adam, clear") == "11 Adam"
|
||||||
|
assert _short_clearance_unit("Car 12 10-8") == "Car 12"
|
||||||
|
assert _short_clearance_unit("45 9 clear") == "45-9"
|
||||||
|
assert _short_clearance_unit("Warrant 4, clear from the jail") is None or True # >5 words: GPT's job
|
||||||
|
assert _short_clearance_unit("45-9 clear, thank you") == "45-9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_clearance_never_guesses():
|
||||||
|
for t in ("10-8, 10-8.", "CMT clear.", "10-8, I'm back now. Clear.",
|
||||||
|
"10-8, thank you.", "Show us 10-8, post 4.", "7, Charlie Central.", "10-4.",
|
||||||
|
# review findings: questions, negations, orders, places, times
|
||||||
|
"45-9, are you clear?", "Is 45-9 clear", "45-9, not clear yet.",
|
||||||
|
"Engine 5 not available.", "45-9, clear the scene.", "Medic 3, clear to transport.",
|
||||||
|
"Room 2 clear.", "Route 9 is clear.", "1400 hours, clear.", "you clear 45-9"):
|
||||||
|
assert _short_clearance_unit(t) is None, t
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearance_scene_cannot_open_an_incident():
|
||||||
|
scene = _clearance_scene("45-9, I'm clear.", "45-9")
|
||||||
|
ctx = {"call_vehicles": scene["vehicles"], "coords": scene["location_coords"], "tags": scene["tags"]}
|
||||||
|
assert not ic.has_event_substance(ctx)
|
||||||
|
assert scene["severity"] == "routine" and scene["incident_type"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearance_matches_a_differently_spoken_unit():
|
||||||
|
inc = {"units_active": ["11 Adam", "45-9"], "units_cleared": []}
|
||||||
|
active, cleared, resolved = ic._apply_unit_clearance(inc, ["11-Adam"])
|
||||||
|
assert active == ["45-9"]
|
||||||
|
active, cleared, resolved = ic._apply_unit_clearance(
|
||||||
|
{"units_active": active, "units_cleared": cleared}, ["45 9"])
|
||||||
|
assert active == [] and resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_unit_never_on_the_incident_cannot_close_it():
|
||||||
|
inc = {"units_active": ["45-9"], "units_cleared": []}
|
||||||
|
active, cleared, resolved = ic._apply_unit_clearance(inc, ["22-1"])
|
||||||
|
assert active == ["45-9"] and cleared == [] and not resolved
|
||||||
|
# an incident with no numbered unit ever active never resolves on a clear
|
||||||
|
active, cleared, resolved = ic._apply_unit_clearance({"units_active": [], "units_cleared": []}, ["22-1"])
|
||||||
|
assert not resolved
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"call_vehicles": [], "incident_type": None}
|
||||||
|
assert llm_correlator._is_clearance_only(ctx)
|
||||||
|
assert not llm_correlator._is_clearance_only({**ctx, "tags": ["mva"]})
|
||||||
|
assert not llm_correlator._is_clearance_only({**ctx, "call_cleared": []})
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_numbered_units_hold_an_incident_open():
|
||||||
|
for junk in ("Desk", "Central", "Division", "sergeant", "unknown", "John", "Zebra", "10-8", "10 4"):
|
||||||
|
assert not ic._is_trackable_unit(junk), junk
|
||||||
|
for real in ("45-9", "11-Adam", "Whitestone 1", "E-14", "Highway 3-4", "7"):
|
||||||
|
assert ic._is_trackable_unit(real), real
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provisional timer close + reopen, substantive cap (server-26#170 replay)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
from datetime import datetime, timedelta, timezone # noqa: E402
|
||||||
|
|
||||||
|
from app.config import settings # noqa: E402
|
||||||
|
from app.internal import summarizer # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_quiet_timer_scales_with_severity():
|
||||||
|
assert summarizer._auto_resolve_minutes({"severity": "routine"}) == settings.incident_auto_resolve_minutes_routine
|
||||||
|
assert summarizer._auto_resolve_minutes({"severity": "minor"}) == settings.incident_auto_resolve_minutes_routine
|
||||||
|
assert summarizer._auto_resolve_minutes({"severity": "moderate"}) == settings.incident_auto_resolve_minutes_moderate
|
||||||
|
assert summarizer._auto_resolve_minutes({"severity": "major"}) == settings.incident_auto_resolve_minutes
|
||||||
|
assert summarizer._auto_resolve_minutes({}) == settings.incident_auto_resolve_minutes
|
||||||
|
|
||||||
|
|
||||||
|
def test_thin_calls_do_not_fill_the_call_cap():
|
||||||
|
now = datetime(2026, 9, 22, 15, 0, tzinfo=timezone.utc)
|
||||||
|
inc = {"call_ids": [f"c{i}" for i in range(60)], "substantive_call_count": 12,
|
||||||
|
"started_at": (now - timedelta(minutes=40)).isoformat(),
|
||||||
|
"updated_at": now.isoformat()}
|
||||||
|
assert ic._incident_at_capacity(inc, now) is None
|
||||||
|
legacy = {k: v for k, v in inc.items() if k != "substantive_call_count"}
|
||||||
|
assert ic._incident_at_capacity(legacy, now).startswith("call_cap")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_only_for_a_call_after_the_close():
|
||||||
|
closed = {"resolved_at": "2026-09-22T15:00:00+00:00"}
|
||||||
|
assert ic._after_close(closed, datetime(2026, 9, 22, 15, 5, tzinfo=timezone.utc))
|
||||||
|
assert not ic._after_close(closed, datetime(2026, 9, 22, 14, 55, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
def test_traffic_stops_become_events():
|
||||||
|
from app.internal.intelligence import _self_initiated_backstop as b
|
||||||
|
for t in ("45 Adam on a stop, Eastbound Central Express.",
|
||||||
|
"11-0. CM2 on the stop, southbound, KFLA on the right.",
|
||||||
|
"Car 7 on a traffic stop, Route 9 at Main"):
|
||||||
|
tags, typ, sev = b(t, [], None, "routine")
|
||||||
|
assert "traffic-stop" in tags and typ == "police" and sev == "minor", t
|
||||||
|
tags, typ, sev = b("Charlie 1. You put me out with a pedestrian on a parkway", [], None, "routine")
|
||||||
|
assert "self-initiated" in tags
|
||||||
|
# negation and unrelated chatter stay untouched
|
||||||
|
assert b("Do you want me to not pull the car over", [], None, "routine") == ([], None, "routine")
|
||||||
|
assert b("45-8, go ahead.", [], None, "routine") == ([], None, "routine")
|
||||||
|
# an existing type/severity is never downgraded
|
||||||
|
assert b("on a stop", ["dwi"], "police", "moderate") == (["dwi", "traffic-stop"], "police", "moderate")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_backstop_stays_off_rail_bridge_and_ems_channels():
|
||||||
|
from app.internal.intelligence import _self_initiated_backstop as b
|
||||||
|
none = ([], None, "routine")
|
||||||
|
assert b("Train 4 holding on the stop at Grand Central", [], None, "routine",
|
||||||
|
"MTA PD Districts 6/7/11 - Police Dispatch") == none
|
||||||
|
assert b("out with a disabled on the bridge, toll plaza", [], None, "routine",
|
||||||
|
"MTA Bridges and Tunnels - Whitestone/Throgs Neck Bridge Patrols") == none
|
||||||
|
assert b("Medic 2 pull over to the side and wait", [], None, "routine") == none
|
||||||
|
assert b("ran a plate for a car stop", [], None, "routine") == none
|
||||||
|
assert b("I dont think he is on a stop", [], None, "routine") == none
|
||||||
|
assert b("45 Adam on a stop", [], None, "routine", "Ch 1 (Patched with 155.310)")[0] == ["traffic-stop"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_stop_report_opens_a_scene():
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import patch
|
||||||
|
from app.internal import firestore as fstore, intelligence
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with patch.object(fstore, "doc_set"), patch.object(fstore, "doc_get_cached", return_value=None):
|
||||||
|
return await intelligence.extract_scenes("c1", "Adam 3 on a stop.", "Ch 1 (Patched with 155.310)")
|
||||||
|
scenes = asyncio.run(run())
|
||||||
|
assert len(scenes) == 1 and scenes[0]["tags"] == ["traffic-stop"] and scenes[0]["incident_type"] == "police"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plate_read_on_a_patrol_channel_is_a_stop():
|
||||||
|
from app.internal.intelligence import _self_initiated_backstop as b
|
||||||
|
ch = "Ossining - Police Dispatch"
|
||||||
|
for t in ("Post 4. 52-62, 3-3. Hemlock Circle. Frank David Boy, 4514. 10-8.",
|
||||||
|
"4, Ossining. 52-22, Ramapo, New York. Lincoln, Charlie, Robert, 7-4-0-7 on a Chevy.",
|
||||||
|
"New York, Mary, Charlie, Nora, 5-8-6-7."):
|
||||||
|
assert b(t, [], None, "routine", ch) == (["traffic-stop"], "police", "minor"), t
|
||||||
|
# a plate on a call that is already about something else stays with it
|
||||||
|
assert b("MVA, plate Mary George Sam 2740", ["mva"], "accident", "moderate", ch) == (["mva"], "accident", "moderate")
|
||||||
|
# not on rail/bridge channels, and not without digits
|
||||||
|
assert b("Frank David Boy 4514", [], None, "routine", "MTA Bridges and Tunnels - Whitestone") == ([], None, "routine")
|
||||||
|
assert b("Charlie, David, go ahead.", [], None, "routine", ch) == ([], None, "routine")
|
||||||
|
|
||||||
|
|
||||||
|
def test_radio_codes_are_not_locations():
|
||||||
|
for junk in ("96 times 5", "96 x 1", "10-8", "Signal 99", "code 4"):
|
||||||
|
assert ic.clean_location(junk) is None, junk
|
||||||
|
for place in ("West Main Street", "Route 9", "96 Main Street", "Exit 17 southbound"):
|
||||||
|
assert ic.clean_location(place) == place, place
|
||||||
@@ -67,7 +67,10 @@ def test_clearing_a_unit_not_tracked_as_active_is_a_noop_for_active_list():
|
|||||||
inc = _incident(units_active=["6-7"], units_cleared=[])
|
inc = _incident(units_active=["6-7"], units_cleared=[])
|
||||||
active, cleared, resolved = _apply_unit_clearance(inc, ["ghost-unit"])
|
active, cleared, resolved = _apply_unit_clearance(inc, ["ghost-unit"])
|
||||||
assert active == ["6-7"]
|
assert active == ["6-7"]
|
||||||
assert cleared == ["ghost-unit"]
|
# A unit never active on this incident is not recorded as cleared here
|
||||||
|
# either (server-26#170 replay review): its 10-8 says nothing about this
|
||||||
|
# incident, and recording it let the all-clear gate pass on a stray clear.
|
||||||
|
assert cleared == []
|
||||||
assert resolved is False
|
assert resolved is False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
app/internal/gemini.py — thinking level, fallback when a model rejects it,
|
||||||
|
and token accounting into a replay's usage sink (server-26#170 cost finding).
|
||||||
|
"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.internal import gemini
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeModels:
|
||||||
|
def __init__(self, reject_thinking=False):
|
||||||
|
self.reject_thinking = reject_thinking
|
||||||
|
self.configs = []
|
||||||
|
|
||||||
|
def generate_content(self, model, contents, config):
|
||||||
|
self.configs.append(config)
|
||||||
|
if self.reject_thinking and config.get("thinking_level"):
|
||||||
|
raise RuntimeError("400 INVALID_ARGUMENT: thinking_level is not supported for this model")
|
||||||
|
return SimpleNamespace(
|
||||||
|
text='{"action": "link"}',
|
||||||
|
usage_metadata=SimpleNamespace(prompt_token_count=1200, candidates_token_count=30,
|
||||||
|
thoughts_token_count=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _patched(models):
|
||||||
|
client = SimpleNamespace(models=models)
|
||||||
|
return (patch.object(gemini, "_get_client", return_value=client),
|
||||||
|
patch.object(gemini, "_config", lambda level: {"thinking_level": level}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimal_thinking_by_default_and_usage_lands_in_the_sink():
|
||||||
|
models = _FakeModels()
|
||||||
|
a, b = _patched(models)
|
||||||
|
sink = {}
|
||||||
|
tok = gemini.collect_usage(sink)
|
||||||
|
try:
|
||||||
|
with a, b:
|
||||||
|
assert gemini.generate_json("m1", "p", purpose="correlation") == {"action": "link"}
|
||||||
|
finally:
|
||||||
|
gemini.reset_usage(tok)
|
||||||
|
assert models.configs == [{"thinking_level": "minimal"}]
|
||||||
|
assert sink == {"correlation:m1": {"calls": 1, "in": 1200, "out": 30, "thinking": 0}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_that_rejects_thinking_level_falls_back_once():
|
||||||
|
models = _FakeModels(reject_thinking=True)
|
||||||
|
a, b = _patched(models)
|
||||||
|
gemini._no_thinking_level.discard("m2")
|
||||||
|
with a, b:
|
||||||
|
gemini.generate_json("m2", "p", purpose="correlation")
|
||||||
|
gemini.generate_json("m2", "p", purpose="correlation")
|
||||||
|
# first call: tried minimal, retried without; second call: straight without
|
||||||
|
assert models.configs == [{"thinking_level": "minimal"}, {"thinking_level": None}, {"thinking_level": None}]
|
||||||
|
gemini._no_thinking_level.discard("m2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_failures_still_raise_for_ai_health():
|
||||||
|
class Boom(_FakeModels):
|
||||||
|
def generate_content(self, **kw):
|
||||||
|
raise RuntimeError("429 insufficient_quota")
|
||||||
|
a, b = _patched(Boom())
|
||||||
|
with a, b:
|
||||||
|
try:
|
||||||
|
gemini.generate_json("m3", "p", purpose="correlation")
|
||||||
|
except RuntimeError as e:
|
||||||
|
assert "insufficient_quota" in str(e)
|
||||||
|
else:
|
||||||
|
raise AssertionError("should raise")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
node-26#9 — PATCH /nodes/{id} secondary_sdr_priority.
|
||||||
|
|
||||||
|
The priority must reach the node as its own MQTT command, never via a config
|
||||||
|
re-push: a config push restarts OP25, and reordering what the spare dongles do
|
||||||
|
must not interrupt P25 recording.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token
|
||||||
|
from app.routers import nodes
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
NODE = {"node_id": "n1", "assigned_system_id": "sys-1", "hardware_preset": "rtl-sdr-v3"}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function():
|
||||||
|
app.dependency_overrides[require_admin_token] = lambda: {"admin": True}
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
app.dependency_overrides.pop(require_admin_token, None)
|
||||||
|
app.dependency_overrides.pop(require_service_or_firebase_token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(body):
|
||||||
|
with patch.object(nodes.fstore, "doc_get", AsyncMock(side_effect=lambda c, i: NODE if c == "nodes" else {"system_id": "sys-1"})), \
|
||||||
|
patch.object(nodes.fstore, "doc_update", AsyncMock()) as update, \
|
||||||
|
patch.object(nodes.mqtt_handler, "send_command", MagicMock(return_value=True)) as command, \
|
||||||
|
patch.object(nodes.mqtt_handler, "push_config", MagicMock()) as push:
|
||||||
|
resp = client.patch("/nodes/n1", json=body)
|
||||||
|
return resp, update, command, push
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_only_sends_command_and_never_repushes_config():
|
||||||
|
resp, update, command, push = _patch({"secondary_sdr_priority": ["ais", "adsb"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": ["ais", "adsb"]})
|
||||||
|
push.assert_not_called()
|
||||||
|
(_, _, updates), _ = update.await_args
|
||||||
|
assert updates == {"secondary_sdr_priority": ["ais", "adsb"], "secondary_sdr_mode": "ais"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_priority_turns_secondaries_off():
|
||||||
|
resp, update, command, push = _patch({"secondary_sdr_priority": []})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
command.assert_called_once_with("n1", {"action": "set_sdr_config", "priority": []})
|
||||||
|
(_, _, updates), _ = update.await_args
|
||||||
|
assert updates["secondary_sdr_mode"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_or_duplicate_modes_are_rejected():
|
||||||
|
assert _patch({"secondary_sdr_priority": ["adsb", "sonar"]})[0].status_code == 400
|
||||||
|
assert _patch({"secondary_sdr_priority": ["adsb", "adsb"]})[0].status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_pins_send_every_service_with_nulls_for_automatic():
|
||||||
|
resp, update, command, push = _patch({"sdr_pins": {"op25": "00000001", "adsb": "69420", "ais": ""}})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
push.assert_not_called()
|
||||||
|
command.assert_called_once_with("n1", {
|
||||||
|
"action": "set_sdr_config",
|
||||||
|
"pins": {"op25": "00000001", "adsb": "69420", "ais": None},
|
||||||
|
})
|
||||||
|
(_, _, updates), _ = update.await_args
|
||||||
|
assert updates == {"sdr_pins": {"op25": "00000001", "adsb": "69420"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_services_on_one_dongle_or_unknown_service_rejected():
|
||||||
|
assert _patch({"sdr_pins": {"op25": "69420", "adsb": "69420"}})[0].status_code == 400
|
||||||
|
assert _patch({"sdr_pins": {"sonar": "1"}})[0].status_code == 400
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
server-26#183 — 511NY cameras/events layer.
|
||||||
|
|
||||||
|
The feed is scraped from a third party, so the tests pin the two failure shapes
|
||||||
|
that would otherwise look like "no traffic right now": a refresh error must keep
|
||||||
|
the last good data AND report the error, and a schema change (every record
|
||||||
|
unparseable) must be an error, not an empty list.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.internal import ny511
|
||||||
|
from app.internal.auth import require_service_or_firebase_token
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
CAM = {"Latitude": 41.03, "Longitude": -73.76, "ID": "NYSDOT-1", "Name": "I-287 at Exit 5",
|
||||||
|
"DirectionOfTravel": "Unknown", "RoadwayName": "I-287", "Url": "https://511ny.org/map/Cctv/1",
|
||||||
|
"VideoUrl": None, "Disabled": False, "Blocked": False}
|
||||||
|
EVENT = {"Latitude": 41.019265, "Longitude": -73.797869, "ID": "TRANSCOM-1", "EventType": "roadwork",
|
||||||
|
"EventSubType": "Gas main repairs", "Severity": "Unknown", "RoadwayName": "NY 100",
|
||||||
|
"DirectionOfTravel": "Both directions", "CountyName": "Westchester", "Description": "x" * 900,
|
||||||
|
"StartDate": "28/09/2026 09:00:00", "PlannedEndDate": "", "LastUpdated": "26/09/2026 14:01:12"}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function():
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
|
||||||
|
for feed in (ny511.cameras, ny511.events):
|
||||||
|
feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
app.dependency_overrides.pop(require_service_or_firebase_token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_camera_skips_disabled_blocked_and_zero_coords():
|
||||||
|
assert ny511.normalize_camera(CAM)["image_url"] == "https://511ny.org/map/Cctv/1"
|
||||||
|
assert ny511.normalize_camera({**CAM, "Disabled": True}) is None
|
||||||
|
assert ny511.normalize_camera({**CAM, "Blocked": True}) is None
|
||||||
|
assert ny511.normalize_camera({**CAM, "Latitude": 0, "Longitude": 0}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_event_parses_day_first_dates_and_truncates_description():
|
||||||
|
e = ny511.normalize_event(EVENT)
|
||||||
|
assert e["start_local"] == "2026-09-28T09:00:00" # DD/MM, not MM/DD
|
||||||
|
assert e["planned_end_local"] is None
|
||||||
|
assert len(e["description"]) == ny511._DESCRIPTION_MAX + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_refresh_keeps_last_good_data_and_reports_error():
|
||||||
|
feed = ny511._Feed("getcameras", 3600, ny511.normalize_camera)
|
||||||
|
with patch.object(ny511, "_fetch", AsyncMock(return_value=[ny511.normalize_camera(CAM)])):
|
||||||
|
asyncio.run(feed.get())
|
||||||
|
feed._next_attempt = 0.0
|
||||||
|
with patch.object(ny511, "_fetch", AsyncMock(side_effect=RuntimeError("boom"))):
|
||||||
|
asyncio.run(feed.get())
|
||||||
|
assert len(feed.items) == 1 and feed.fetched_at is not None
|
||||||
|
assert "boom" in feed.error
|
||||||
|
assert feed._next_attempt - feed.fetched_at <= ny511.RETRY_AFTER_FAILURE_S + 5 # retries soon, not after the full TTL
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_change_is_an_error_not_an_empty_layer():
|
||||||
|
class Resp:
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self): return [{"lat": 1, "lng": 2}] # renamed fields -> nothing parses
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): pass
|
||||||
|
async def get(self, *a, **k): return Resp()
|
||||||
|
|
||||||
|
with patch.object(ny511.httpx, "AsyncClient", lambda **k: Client()):
|
||||||
|
try:
|
||||||
|
asyncio.run(ny511._fetch("getcameras", ny511.normalize_camera))
|
||||||
|
assert False, "expected a schema-change error"
|
||||||
|
except ValueError as e:
|
||||||
|
assert "schema" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_filters_to_bbox_and_reports_status():
|
||||||
|
far = {**CAM, "ID": "NYSDOT-2", "Latitude": 42.9, "Longitude": -78.8} # Buffalo
|
||||||
|
for feed, rows, norm in ((ny511.cameras, [CAM, far], ny511.normalize_camera), (ny511.events, [EVENT], ny511.normalize_event)):
|
||||||
|
feed.items = [norm(r) for r in rows]
|
||||||
|
feed.fetched_at, feed._next_attempt = 1.0, float("inf")
|
||||||
|
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert [c["id"] for c in body["cameras"]] == ["NYSDOT-1"]
|
||||||
|
assert body["cameras_status"] == {"fetched_at": 1.0, "error": None, "total_in_bbox": 1, "truncated": False}
|
||||||
|
assert len(body["events"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_rejects_inverted_bbox():
|
||||||
|
r = client.get("/traffic/511", params={"south": 41.4, "west": -74.0, "north": 40.9, "east": -73.4})
|
||||||
|
assert r.status_code == 400
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""
|
||||||
|
Replay (app/internal/replay.py): re-running the pipeline over past calls in a
|
||||||
|
sandbox. The properties that matter, in order: a replay never writes a live
|
||||||
|
call or incident; it runs the live correlation code with the clock pinned to
|
||||||
|
each call's own time; and a call seeded ahead of its turn is invisible to the
|
||||||
|
orphan sweep until it is processed.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.internal import clock, replay
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.feature_flags import force_flags, resolve_flags, unforce_flags
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# An in-memory Firestore that honours the sandbox redirect, so the real
|
||||||
|
# correlator can run against it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _merge(dst: dict, src: dict) -> dict:
|
||||||
|
for k, v in src.items():
|
||||||
|
if isinstance(v, dict) and isinstance(dst.get(k), dict):
|
||||||
|
_merge(dst[k], v)
|
||||||
|
else:
|
||||||
|
dst[k] = copy.deepcopy(v)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp(a, op, b) -> bool:
|
||||||
|
if a is None:
|
||||||
|
return False
|
||||||
|
if isinstance(a, str) and isinstance(b, datetime):
|
||||||
|
a = datetime.fromisoformat(a)
|
||||||
|
return {"==": a == b, ">=": a >= b, "<=": a <= b, ">": a > b, "<": a < b}[op]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.data: dict[str, dict[str, dict]] = {}
|
||||||
|
|
||||||
|
def coll(self, name: str) -> dict:
|
||||||
|
return self.data.setdefault(fstore._path(name), {})
|
||||||
|
|
||||||
|
async def doc_set(self, collection, doc_id, data, merge=True):
|
||||||
|
c = self.coll(collection)
|
||||||
|
if merge and doc_id in c:
|
||||||
|
_merge(c[doc_id], data)
|
||||||
|
else:
|
||||||
|
c[doc_id] = copy.deepcopy(data)
|
||||||
|
|
||||||
|
async def doc_update(self, collection, doc_id, data):
|
||||||
|
await self.doc_set(collection, doc_id, data)
|
||||||
|
|
||||||
|
async def doc_get(self, collection, doc_id):
|
||||||
|
d = self.coll(collection).get(doc_id)
|
||||||
|
return copy.deepcopy(d) if d is not None else None
|
||||||
|
|
||||||
|
async def doc_get_cached(self, collection, doc_id, ttl=300.0):
|
||||||
|
return await self.doc_get(collection, doc_id)
|
||||||
|
|
||||||
|
async def doc_delete(self, collection, doc_id):
|
||||||
|
self.coll(collection).pop(doc_id, None)
|
||||||
|
|
||||||
|
async def collection_list(self, collection, **filters):
|
||||||
|
return [copy.deepcopy(d) for d in self.coll(collection).values()
|
||||||
|
if all(d.get(k) == v for k, v in filters.items())]
|
||||||
|
|
||||||
|
async def collection_where(self, collection, conditions, order_by=None,
|
||||||
|
limit_to=None, start_after=None):
|
||||||
|
rows = [copy.deepcopy(d) for d in self.coll(collection).values()
|
||||||
|
if all(_cmp(d.get(f), op, v) for f, op, v in conditions)]
|
||||||
|
for field, direction in reversed(order_by or []):
|
||||||
|
rows.sort(key=lambda d: d.get(field), reverse=direction == "DESCENDING")
|
||||||
|
return rows[:limit_to] if limit_to else rows
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store():
|
||||||
|
s = FakeStore()
|
||||||
|
names = ("doc_set", "doc_update", "doc_get", "doc_get_cached", "doc_delete",
|
||||||
|
"collection_list", "collection_where")
|
||||||
|
patches = [patch.object(fstore, n, getattr(s, n)) for n in names]
|
||||||
|
for p in patches:
|
||||||
|
p.start()
|
||||||
|
replay._active_run_id = None
|
||||||
|
replay._active_task = None
|
||||||
|
yield s
|
||||||
|
for p in patches:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The context-scoped pieces
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_sandbox_redirects_only_calls_and_incidents():
|
||||||
|
assert fstore._path("calls") == "calls"
|
||||||
|
tok = fstore.enter_sandbox("replay_runs/r1")
|
||||||
|
try:
|
||||||
|
assert fstore._path("calls") == "replay_runs/r1/calls"
|
||||||
|
assert fstore._path("incidents") == "replay_runs/r1/incidents"
|
||||||
|
assert fstore._path("systems") == "systems"
|
||||||
|
assert fstore._path("config") == "config"
|
||||||
|
finally:
|
||||||
|
fstore.exit_sandbox(tok)
|
||||||
|
assert fstore._path("incidents") == "incidents"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandbox_and_clock_do_not_leak_into_a_concurrent_task():
|
||||||
|
"""A replay runs beside live uploads in one event loop. The live task
|
||||||
|
must see the real collections and the real clock."""
|
||||||
|
pinned = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||||
|
seen = {}
|
||||||
|
replay_entered = asyncio.Event()
|
||||||
|
live_checked = asyncio.Event()
|
||||||
|
|
||||||
|
async def replay_task():
|
||||||
|
fstore.enter_sandbox("replay_runs/r1")
|
||||||
|
clock.pin(pinned)
|
||||||
|
replay_entered.set()
|
||||||
|
await live_checked.wait()
|
||||||
|
seen["replay"] = (fstore._path("calls"), clock.now())
|
||||||
|
|
||||||
|
async def live_task():
|
||||||
|
await replay_entered.wait()
|
||||||
|
seen["live"] = (fstore._path("calls"), clock.now())
|
||||||
|
live_checked.set()
|
||||||
|
|
||||||
|
await asyncio.gather(replay_task(), live_task())
|
||||||
|
assert seen["replay"] == ("replay_runs/r1/calls", pinned)
|
||||||
|
assert seen["live"][0] == "calls"
|
||||||
|
assert seen["live"][1] != pinned
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forced_flags_override_global_switches():
|
||||||
|
tok = force_flags({"correlation_enabled": True, "stt_enabled": False})
|
||||||
|
try:
|
||||||
|
flags, flag = await resolve_flags("sys-1")
|
||||||
|
assert flag("correlation_enabled") is True
|
||||||
|
assert flag("stt_enabled") is False
|
||||||
|
assert flag("summaries_enabled") is False
|
||||||
|
finally:
|
||||||
|
unforce_flags(tok)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_seed_strips_live_answers():
|
||||||
|
call = {
|
||||||
|
"call_id": "c1", "org_id": "o", "talkgroup_id": 5, "srcaddr": 123,
|
||||||
|
"status": "ended", "transcript": "engine 5 responding", "segments": [{"t": 1}],
|
||||||
|
"incident_ids": ["live-inc"], "incident_id": "live-inc", "units": ["E5"],
|
||||||
|
"corr_path": "fast/thin", "scenes": {"0": {}}, "skip_reason": None,
|
||||||
|
"chatter_classifier_verdict": "x", "eval_transcript": "y", "embedding": [0.1],
|
||||||
|
}
|
||||||
|
seed = replay._sandbox_seed(call, "transcripts")
|
||||||
|
assert seed["transcript"] == "engine 5 responding"
|
||||||
|
assert seed["srcaddr"] == 123
|
||||||
|
assert seed["status"] == "replay_pending"
|
||||||
|
for gone in ("incident_ids", "incident_id", "units", "corr_path", "scenes",
|
||||||
|
"chatter_classifier_verdict", "eval_transcript", "embedding"):
|
||||||
|
assert gone not in seed
|
||||||
|
assert "transcript" not in replay._sandbox_seed(call, "audio")
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_metrics_separates_timeout_from_real_clears():
|
||||||
|
incidents = [
|
||||||
|
{"call_ids": ["a"], "status": "resolved", "resolved_via": "idle_timeout"},
|
||||||
|
{"call_ids": ["b", "c"], "status": "resolved", "resolved_via": "units_cleared",
|
||||||
|
"units_cleared": ["E5"]},
|
||||||
|
{"call_ids": ["d", "e", "f"], "status": "active"},
|
||||||
|
]
|
||||||
|
calls = [
|
||||||
|
{"call_id": "a", "incident_ids": ["1"], "scenes": {"0": {"corr_debug": {
|
||||||
|
"corr_path": "new", "corr_consensus": "rules_only"}}}},
|
||||||
|
{"call_id": "z", "corr_path": "unlinked"},
|
||||||
|
]
|
||||||
|
m = replay.compute_metrics(incidents, calls)
|
||||||
|
assert m["incidents"] == 3
|
||||||
|
assert m["single_call_incidents"] == 1
|
||||||
|
assert m["resolved_via"] == {"idle_timeout": 1, "units_cleared": 1, "still_active": 1}
|
||||||
|
assert m["incidents_with_units_cleared"] == 1
|
||||||
|
assert m["calls_orphaned"] == 1
|
||||||
|
assert m["corr_path"] == {"new": 1, "unlinked": 1}
|
||||||
|
assert m["llm_decisions"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A whole run, through the real correlator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
T0 = datetime(2026, 9, 21, 14, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_call(i: int, minute: int, transcript: str) -> dict:
|
||||||
|
return {
|
||||||
|
"call_id": f"call-{i}", "org_id": "org-1", "node_id": "node-1",
|
||||||
|
"system_id": "sys-1", "talkgroup_id": 100, "talkgroup_name": "Police Dispatch",
|
||||||
|
"started_at": T0 + timedelta(minutes=minute),
|
||||||
|
"ended_at": T0 + timedelta(minutes=minute, seconds=20),
|
||||||
|
"duration_s": 20, "status": "ended",
|
||||||
|
"transcript": transcript,
|
||||||
|
"incident_ids": ["LIVE-INCIDENT"], "corr_path": "fast/thin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scene(transcript: str, units: list[str]) -> dict:
|
||||||
|
return {
|
||||||
|
"tags": ["mva"], "incident_type": "accident", "location": "Main Street",
|
||||||
|
"location_coords": None, "units": units, "vehicles": [], "cleared_units": [],
|
||||||
|
"reassignment": False, "embedding": None, "severity": "moderate",
|
||||||
|
"transcript": transcript, "resolved": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_writes_only_to_its_sandbox_and_pins_the_clock(store):
|
||||||
|
live = {
|
||||||
|
"call-1": _live_call(1, 0, "Car 12, MVA Main Street"),
|
||||||
|
"call-2": _live_call(2, 1, "Car 12 on scene Main Street"),
|
||||||
|
"call-3": _live_call(3, 300, "Car 40, alarm Oak Avenue"),
|
||||||
|
}
|
||||||
|
store.data["calls"] = copy.deepcopy(live)
|
||||||
|
store.data["incidents"] = {"LIVE-INCIDENT": {"incident_id": "LIVE-INCIDENT", "org_id": "org-1",
|
||||||
|
"status": "active", "call_ids": ["call-1"]}}
|
||||||
|
live_before = copy.deepcopy(store.data)
|
||||||
|
|
||||||
|
extracted = []
|
||||||
|
|
||||||
|
async def fake_extract(call_id, transcript, talkgroup_name, **kw):
|
||||||
|
extracted.append(call_id)
|
||||||
|
# Prefetch seeds calls ahead of the clock; they must not look "ended" yet.
|
||||||
|
return [_scene(transcript, ["Car 12"] if "12" in transcript else ["Car 40"])]
|
||||||
|
|
||||||
|
with patch("app.internal.intelligence.extract_scenes", fake_extract):
|
||||||
|
calls, truncated = await replay.select_calls(
|
||||||
|
"org-1", T0 - timedelta(hours=1), T0 + timedelta(hours=6))
|
||||||
|
assert [c["call_id"] for c in calls] == ["call-1", "call-2", "call-3"]
|
||||||
|
assert not truncated
|
||||||
|
await replay.start_run(
|
||||||
|
org_id="org-1", date_from=T0 - timedelta(hours=1),
|
||||||
|
date_to=T0 + timedelta(hours=6), mode="transcripts", system_ids=None,
|
||||||
|
source_run_id=None, label="t", actor="test",
|
||||||
|
)
|
||||||
|
await replay._active_task
|
||||||
|
|
||||||
|
# Live collections are exactly as they were.
|
||||||
|
assert store.data["calls"] == live_before["calls"]
|
||||||
|
assert store.data["incidents"] == live_before["incidents"]
|
||||||
|
|
||||||
|
run = next(iter(store.data["replay_runs"].values()))
|
||||||
|
assert run["status"] == "done", run["errors"]
|
||||||
|
root = f"replay_runs/{run['run_id']}"
|
||||||
|
sb_calls = store.data[f"{root}/calls"]
|
||||||
|
sb_incidents = store.data[f"{root}/incidents"]
|
||||||
|
assert sorted(extracted) == ["call-1", "call-2", "call-3"]
|
||||||
|
assert all(c["status"] == "ended" for c in sb_calls.values())
|
||||||
|
assert "LIVE-INCIDENT" not in sb_incidents
|
||||||
|
|
||||||
|
# Incident timestamps come from the replayed calls, not the wall clock.
|
||||||
|
for inc in sb_incidents.values():
|
||||||
|
started = datetime.fromisoformat(inc["started_at"])
|
||||||
|
assert T0 <= started <= T0 + timedelta(hours=6)
|
||||||
|
# The two Car 12 calls are one job; the Car 40 call five hours later is another.
|
||||||
|
groups = sorted(sorted(i["call_ids"]) for i in sb_incidents.values())
|
||||||
|
assert groups == [["call-1", "call-2"], ["call-3"]]
|
||||||
|
# Each aged out on the replayed clock the way it would have live — its
|
||||||
|
# severity's quiet timer after its last activity, not "now".
|
||||||
|
assert run["metrics"]["resolved_via"] == {"idle_timeout": 2}
|
||||||
|
first = next(i for i in sb_incidents.values() if "call-1" in i["call_ids"])
|
||||||
|
idle = datetime.fromisoformat(first["resolved_at"]) - datetime.fromisoformat(first["updated_at"])
|
||||||
|
from app.internal.summarizer import _auto_resolve_minutes
|
||||||
|
limit = timedelta(minutes=_auto_resolve_minutes(first))
|
||||||
|
assert limit < idle <= limit + timedelta(minutes=5)
|
||||||
|
assert run["metrics"]["calls"] == 3
|
||||||
|
assert set(store.data[f"{root}/scenes"]) == {"call-1", "call-2", "call-3"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reuse_mode_correlates_without_extracting(store):
|
||||||
|
store.data["calls"] = {"call-1": _live_call(1, 0, "Car 12, MVA Main Street")}
|
||||||
|
|
||||||
|
async def fake_extract(call_id, transcript, talkgroup_name, **kw):
|
||||||
|
# What the real extract_scenes also does: write call-level fields.
|
||||||
|
await fstore.doc_set("calls", call_id, {"units": ["Car 12"], "tags": ["mva"]})
|
||||||
|
return [_scene(transcript, ["Car 12"])]
|
||||||
|
|
||||||
|
with patch("app.internal.intelligence.extract_scenes", fake_extract):
|
||||||
|
first = await replay.start_run(
|
||||||
|
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||||
|
mode="transcripts", system_ids=None, source_run_id=None, label="", actor="t")
|
||||||
|
await replay._active_task
|
||||||
|
|
||||||
|
async def must_not_extract(*a, **kw):
|
||||||
|
raise AssertionError("reuse mode re-ran extraction")
|
||||||
|
|
||||||
|
with patch("app.internal.intelligence.extract_scenes", must_not_extract):
|
||||||
|
second = await replay.start_run(
|
||||||
|
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||||
|
mode="reuse", system_ids=None, source_run_id=first["run_id"], label="", actor="t")
|
||||||
|
await replay._active_task
|
||||||
|
|
||||||
|
run = store.data["replay_runs"][second["run_id"]]
|
||||||
|
assert run["status"] == "done", run["errors"]
|
||||||
|
assert run["progress"]["errors"] == 0
|
||||||
|
assert run["metrics"]["calls_linked"] == 1
|
||||||
|
# Extraction's call-level output came across too — the orphan sweep reads
|
||||||
|
# units/tags/location off the call doc, not off the scenes.
|
||||||
|
sb_call = store.data[f"replay_runs/{second['run_id']}/calls"]["call-1"]
|
||||||
|
assert sb_call["units"] == ["Car 12"]
|
||||||
|
assert sb_call["tags"] == ["mva"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_run_at_a_time(store):
|
||||||
|
store.data["calls"] = {"call-1": _live_call(1, 0, "x")}
|
||||||
|
gate = asyncio.Event()
|
||||||
|
|
||||||
|
async def slow_extract(*a, **kw):
|
||||||
|
await gate.wait()
|
||||||
|
return []
|
||||||
|
|
||||||
|
with patch("app.internal.intelligence.extract_scenes", slow_extract):
|
||||||
|
await replay.start_run(
|
||||||
|
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||||
|
mode="transcripts", system_ids=None, source_run_id=None, label="", actor="t")
|
||||||
|
with pytest.raises(replay.ReplayBusy):
|
||||||
|
await replay.start_run(
|
||||||
|
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||||
|
mode="transcripts", system_ids=None, source_run_id=None, label="", actor="t")
|
||||||
|
gate.set()
|
||||||
|
await replay._active_task
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_input_rebuilds_from_corrector_segments():
|
||||||
|
"""Live extraction overwrites transcript_corrected with scene 0's text;
|
||||||
|
the corrector's own output survives in segments_corrected."""
|
||||||
|
call = {
|
||||||
|
"transcript": "raw whisper",
|
||||||
|
"transcript_corrected": "scene zero only",
|
||||||
|
"segments": [{"text": "raw a"}, {"text": "raw b"}],
|
||||||
|
"segments_corrected": [{"text": "fixed a"}, {"text": "fixed b"}],
|
||||||
|
}
|
||||||
|
text, segs = replay._stored_input(call)
|
||||||
|
assert text == "fixed a fixed b"
|
||||||
|
assert segs == call["segments_corrected"]
|
||||||
|
assert replay._stored_input({"transcript": "raw", "segments": []}) == ("raw", [])
|
||||||
|
assert replay._stored_input({"transcript": "hum", "transcript_not_speech": True}) == (None, [])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_replay_never_touches_live_ai_health_or_review_queue():
|
||||||
|
from app.internal import ai_health, area_context
|
||||||
|
|
||||||
|
before = ai_health.snapshot()
|
||||||
|
tok = fstore.enter_sandbox("replay_runs/r1")
|
||||||
|
try:
|
||||||
|
with patch.object(ai_health, "_post_webhook") as hook, \
|
||||||
|
patch.object(fstore, "doc_get") as get:
|
||||||
|
for _ in range(10):
|
||||||
|
await ai_health.report_degraded("correlation_cheap", "gemini", "m", "429", "wait")
|
||||||
|
await ai_health.report_healthy("transcription")
|
||||||
|
assert await area_context.add_pending("sys-1", 5, [{"term": "x"}]) == 0
|
||||||
|
hook.assert_not_called()
|
||||||
|
get.assert_not_called()
|
||||||
|
finally:
|
||||||
|
fstore.exit_sandbox(tok)
|
||||||
|
assert ai_health.snapshot() == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_aborts_when_an_ai_account_is_dead(store):
|
||||||
|
"""An unfunded OpenAI account made the first smoke run a sandbox of 290
|
||||||
|
orphans that looked like a result. A permanently failing tier now stops
|
||||||
|
the run and names the cause."""
|
||||||
|
store.data["calls"] = {
|
||||||
|
f"call-{i}": _live_call(i, i, "Car 12 responding to an MVA on Main Street") for i in range(1, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
def broke(*a, **kw):
|
||||||
|
raise RuntimeError("Error code: 429 - You exceeded your current quota (insufficient_quota)")
|
||||||
|
|
||||||
|
with patch("app.internal.intelligence._sync_extract", broke), \
|
||||||
|
patch("app.internal.intelligence.classify_chatter", return_value=(False, None)):
|
||||||
|
run = await replay.start_run(
|
||||||
|
org_id="org-1", date_from=T0 - timedelta(hours=1), date_to=T0 + timedelta(hours=1),
|
||||||
|
mode="transcripts", system_ids=None, source_run_id=None, label="", actor="t")
|
||||||
|
await replay._active_task
|
||||||
|
|
||||||
|
run = store.data["replay_runs"][run["run_id"]]
|
||||||
|
assert run["status"] == "failed"
|
||||||
|
assert any("out of credit" in e for e in run["errors"])
|
||||||
|
assert run["progress"]["done"] < 29
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_extraction_failure_reports_to_ai_health():
|
||||||
|
from app.internal import ai_health, intelligence
|
||||||
|
|
||||||
|
def broke(*a, **kw):
|
||||||
|
raise RuntimeError("insufficient_quota")
|
||||||
|
|
||||||
|
with patch.object(intelligence, "_sync_extract", broke), \
|
||||||
|
patch.object(ai_health, "report_degraded") as degraded, \
|
||||||
|
patch.object(fstore, "doc_set"), patch.object(fstore, "doc_get_cached", return_value=None):
|
||||||
|
scenes = await intelligence.extract_scenes("c1", "Car 12 responding to an MVA on Main Street")
|
||||||
|
assert scenes == []
|
||||||
|
assert degraded.call_args.args[0] == "extraction"
|
||||||
|
assert degraded.call_args.kwargs["permanent"] is True
|
||||||
@@ -24,6 +24,11 @@ def _override(decoded: dict):
|
|||||||
|
|
||||||
def teardown_function():
|
def teardown_function():
|
||||||
app.dependency_overrides.pop(require_node_service_or_firebase_token, None)
|
app.dependency_overrides.pop(require_node_service_or_firebase_token, None)
|
||||||
|
telemetry._last_position.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _writes_to(mock_set, collection_prefix: str):
|
||||||
|
return [c for c in mock_set.await_args_list if c.args[0].startswith(collection_prefix)]
|
||||||
|
|
||||||
|
|
||||||
def test_service_token_without_node_id_is_rejected():
|
def test_service_token_without_node_id_is_rejected():
|
||||||
@@ -41,8 +46,9 @@ def test_node_upload_upserts_and_stamps_org_id():
|
|||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json() == {"ok": True, "count": 1}
|
assert resp.json() == {"ok": True, "count": 1}
|
||||||
mock_set.assert_awaited_once()
|
snapshot = [c for c in mock_set.await_args_list if c.args[0] == "aircraft"]
|
||||||
(collection, doc_id, doc), kwargs = mock_set.await_args
|
assert len(snapshot) == 1
|
||||||
|
(collection, doc_id, doc), kwargs = snapshot[0]
|
||||||
assert collection == "aircraft"
|
assert collection == "aircraft"
|
||||||
assert doc_id == "A1B2C3"
|
assert doc_id == "A1B2C3"
|
||||||
assert doc["node_id"] == "node-1"
|
assert doc["node_id"] == "node-1"
|
||||||
@@ -92,3 +98,38 @@ def test_ais_node_upload_skips_entries_missing_mmsi():
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json() == {"ok": True, "count": 0}
|
assert resp.json() == {"ok": True, "count": 0}
|
||||||
mock_set.assert_not_awaited()
|
mock_set.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def _post_adsb(aircraft):
|
||||||
|
with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value={"org_id": "org-A"})), \
|
||||||
|
patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set:
|
||||||
|
resp = client.post("/telemetry/adsb", json={"aircraft": aircraft})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return mock_set
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_writes_trail_point_with_ttl():
|
||||||
|
_override({"node": True, "node_id": "node-1"})
|
||||||
|
mock_set = _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8, "altitude_ft": 3000}])
|
||||||
|
trail = _writes_to(mock_set, "aircraft/A1B2C3/positions")
|
||||||
|
assert len(trail) == 1
|
||||||
|
(_, doc_id, point), _ = trail[0]
|
||||||
|
assert doc_id.isdigit()
|
||||||
|
assert (point["lat"], point["lon"], point["altitude_ft"]) == (41.1, -73.8, 3000)
|
||||||
|
assert point["expire_at"] > telemetry.datetime.now(telemetry.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_position_is_not_rewritten_to_trail():
|
||||||
|
_override({"node": True, "node_id": "node-1"})
|
||||||
|
_post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8}])
|
||||||
|
again = _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8}])
|
||||||
|
moved = _post_adsb([{"icao": "A1B2C3", "lat": 41.2, "lon": -73.8}])
|
||||||
|
assert _writes_to(again, "aircraft/A1B2C3/positions") == []
|
||||||
|
assert len(_writes_to(moved, "aircraft/A1B2C3/positions")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_aircraft_without_position_gets_no_trail_point():
|
||||||
|
_override({"node": True, "node_id": "node-1"})
|
||||||
|
mock_set = _post_adsb([{"icao": "A1B2C3", "callsign": "UAL123"}])
|
||||||
|
assert _writes_to(mock_set, "aircraft/A1B2C3/positions") == []
|
||||||
|
assert len(_writes_to(mock_set, "aircraft")) == 1
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { ReplayTab } from "@/components/admin/ReplayTab";
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
||||||
@@ -1227,11 +1228,12 @@ function SttEvalTab() {
|
|||||||
// Main admin page
|
// Main admin page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls" | "eval";
|
type AdminTab = "features" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
|
||||||
|
|
||||||
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||||||
{ key: "features", label: "AI Features" },
|
{ key: "features", label: "AI Features" },
|
||||||
{ key: "correlation", label: "Correlation Debug" },
|
{ key: "correlation", label: "Correlation Debug" },
|
||||||
|
{ key: "replay", label: "Replay" },
|
||||||
{ key: "calls", label: "Calls" },
|
{ key: "calls", label: "Calls" },
|
||||||
{ key: "eval", label: "STT Eval" },
|
{ key: "eval", label: "STT Eval" },
|
||||||
{ key: "users", label: "Users" },
|
{ key: "users", label: "Users" },
|
||||||
@@ -1256,7 +1258,7 @@ export default function AdminPage() {
|
|||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
// Users/Audit tabs benefit from full width; everything else is narrow
|
// Users/Audit tabs benefit from full width; everything else is narrow
|
||||||
const wide = tab === "users" || tab === "audit";
|
const wide = tab === "users" || tab === "audit" || tab === "replay";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||||
@@ -1278,6 +1280,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{tab === "features" && <FeaturesTab />}
|
{tab === "features" && <FeaturesTab />}
|
||||||
{tab === "correlation" && <CorrelationDebugTab />}
|
{tab === "correlation" && <CorrelationDebugTab />}
|
||||||
|
{tab === "replay" && <ReplayTab />}
|
||||||
{tab === "calls" && <StaleCallsTab />}
|
{tab === "calls" && <StaleCallsTab />}
|
||||||
{tab === "eval" && <SttEvalTab />}
|
{tab === "eval" && <SttEvalTab />}
|
||||||
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
|
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
|
||||||
|
|
||||||
type LinkFilter = "any" | "orphan" | "linked";
|
type LinkFilter = "any" | "orphan" | "linked";
|
||||||
type TranscriptFilter = "any" | "yes" | "no";
|
type TranscriptFilter = "any" | "yes" | "no";
|
||||||
@@ -239,6 +240,8 @@ export default function ArchivePage() {
|
|||||||
const [systemId, setSystemId] = useState("");
|
const [systemId, setSystemId] = useState("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [submittedQ, setSubmittedQ] = useState("");
|
const [submittedQ, setSubmittedQ] = useState("");
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !canView) router.replace("/");
|
if (!authLoading && !canView) router.replace("/");
|
||||||
@@ -256,6 +259,8 @@ export default function ArchivePage() {
|
|||||||
transcript,
|
transcript,
|
||||||
system_id: systemId || undefined,
|
system_id: systemId || undefined,
|
||||||
q: submittedQ || undefined,
|
q: submittedQ || undefined,
|
||||||
|
date_from: dayStart(dateFrom)?.toISOString(),
|
||||||
|
date_to: dayEnd(dateTo)?.toISOString(),
|
||||||
});
|
});
|
||||||
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
|
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
|
||||||
setCursor(res.next_cursor);
|
setCursor(res.next_cursor);
|
||||||
@@ -266,7 +271,7 @@ export default function ArchivePage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[link, transcript, systemId, submittedQ],
|
[link, transcript, systemId, submittedQ, dateFrom, dateTo],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reload from the top whenever a filter changes.
|
// Reload from the top whenever a filter changes.
|
||||||
@@ -335,6 +340,8 @@ export default function ArchivePage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<DateRange from={dateFrom} to={dateTo} onChange={(f, t) => { setDateFrom(f); setDateTo(t); }} />
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
|
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
|
||||||
className="flex items-center gap-2 ml-auto"
|
className="flex items-center gap-2 ml-auto"
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ html, body {
|
|||||||
font-family: var(--font-mono), ui-monospace, monospace;
|
font-family: var(--font-mono), ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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). */
|
||||||
|
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
|
||||||
|
|
||||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||||
/*
|
/*
|
||||||
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
|
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
|
||||||
@@ -84,6 +88,7 @@ html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !
|
|||||||
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: #f1f5f9 !important; }
|
||||||
|
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,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(241,245,249,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(241,245,249,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
||||||
@@ -91,16 +96,25 @@ html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
|||||||
/* Borders */
|
/* Borders */
|
||||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
||||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
||||||
|
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
|
||||||
|
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
|
||||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
html:not(.dark) .text-white { color: #0f172a !important; }
|
||||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
||||||
|
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
|
||||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
||||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
||||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
||||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
||||||
|
|
||||||
|
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
||||||
|
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
||||||
|
html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
|
||||||
|
.bg-indigo-500, .bg-indigo-600, .bg-indigo-700, .bg-red-600, .bg-red-700,
|
||||||
|
.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: #f8fafc !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; }
|
||||||
@@ -177,6 +191,18 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
* than the video it holds. */
|
||||||
|
.leaflet-tooltip.drb-cam-pin {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||||
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,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/Badge";
|
|||||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
|
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
|
||||||
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
||||||
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
||||||
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||||
@@ -196,7 +197,11 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
|||||||
export default function IncidentsPage() {
|
export default function IncidentsPage() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [pageLimit, setPageLimit] = useState(PAGE_SIZE);
|
const [pageLimit, setPageLimit] = useState(PAGE_SIZE);
|
||||||
const { incidents, loading, error, hasMore } = useIncidents(pageLimit);
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
const rangeFrom = useMemo(() => dayStart(dateFrom), [dateFrom]);
|
||||||
|
const rangeTo = useMemo(() => dayEnd(dateTo), [dateTo]);
|
||||||
|
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 [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||||
@@ -228,9 +233,10 @@ export default function IncidentsPage() {
|
|||||||
return list; // useIncidents() already orders by started_at desc
|
return list; // useIncidents() already orders by started_at desc
|
||||||
}, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]);
|
}, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]);
|
||||||
|
|
||||||
const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== "";
|
const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== "" || dateFrom !== "" || dateTo !== "";
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
|
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
|
||||||
|
setDateFrom(""); setDateTo(""); setPageLimit(PAGE_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hiddenCount = incidents.length - filtered.length;
|
const hiddenCount = incidents.length - filtered.length;
|
||||||
@@ -308,6 +314,11 @@ export default function IncidentsPage() {
|
|||||||
<option value="">All types</option>
|
<option value="">All types</option>
|
||||||
{INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
{INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
<DateRange
|
||||||
|
from={dateFrom}
|
||||||
|
to={dateTo}
|
||||||
|
onChange={(f, t) => { setDateFrom(f); setDateTo(t); setPageLimit(PAGE_SIZE); }}
|
||||||
|
/>
|
||||||
<label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
|
<label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
|
||||||
Sort
|
Sort
|
||||||
<select
|
<select
|
||||||
@@ -362,14 +373,14 @@ export default function IncidentsPage() {
|
|||||||
|
|
||||||
{filtered.length === 0 && !error && (
|
{filtered.length === 0 && !error && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
|
title={incidents.length === 0 && !filtersActive ? "No incidents recorded yet" : "No incidents match these filters"}
|
||||||
description={
|
description={
|
||||||
incidents.length === 0
|
incidents.length === 0 && !filtersActive
|
||||||
? "Incidents appear automatically once calls start correlating."
|
? "Incidents appear automatically once calls start correlating."
|
||||||
: "Try clearing a filter, or load older incidents."
|
: "Try clearing a filter, or load older incidents."
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
incidents.length > 0 && filtersActive ? (
|
filtersActive ? (
|
||||||
<Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
|
<Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useSystems } from "@/lib/useSystems";
|
|||||||
import { useCalls } from "@/lib/useCalls";
|
import { useCalls } from "@/lib/useCalls";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||||
|
import { SdrSettings } from "@/components/SdrSettings";
|
||||||
import { CallRow } from "@/components/CallRow";
|
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";
|
||||||
@@ -336,6 +337,8 @@ export default function NodeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SdrSettings node={node} canEdit={isAdmin} />
|
||||||
|
|
||||||
{/* 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-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type HlsType from "hls.js";
|
||||||
|
import type { Ny511Camera } from "@/lib/types";
|
||||||
|
|
||||||
|
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
|
||||||
|
const SNAPSHOT_REFRESH_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
|
||||||
|
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
|
||||||
|
* a timer. A stream that fails falls back to the snapshot rather than a black box.
|
||||||
|
*/
|
||||||
|
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [videoFailed, setVideoFailed] = useState(false);
|
||||||
|
const [tick, setTick] = useState(() => Date.now());
|
||||||
|
const useVideo = !!camera.video_url && !videoFailed;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useVideo) return;
|
||||||
|
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [useVideo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
const src = camera.video_url;
|
||||||
|
if (!useVideo || !video || !src) return;
|
||||||
|
let hls: HlsType | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
video.src = src; // Safari/iOS play HLS natively
|
||||||
|
} else {
|
||||||
|
import("hls.js")
|
||||||
|
.then(({ default: Hls }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!Hls.isSupported()) { setVideoFailed(true); return; }
|
||||||
|
hls = new Hls({ maxBufferLength: 10, backBufferLength: 0 });
|
||||||
|
hls.on(Hls.Events.ERROR, (_evt, data) => { if (data.fatal) setVideoFailed(true); });
|
||||||
|
hls.loadSource(src);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
})
|
||||||
|
.catch(() => setVideoFailed(true));
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
hls?.destroy();
|
||||||
|
video.removeAttribute("src");
|
||||||
|
video.load(); // stop the download, not just the playback
|
||||||
|
};
|
||||||
|
}, [useVideo, camera.video_url]);
|
||||||
|
|
||||||
|
if (useVideo) {
|
||||||
|
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
|
||||||
|
}
|
||||||
|
if (!camera.image_url) {
|
||||||
|
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
FeatureGroup,
|
FeatureGroup,
|
||||||
LayersControl,
|
LayersControl,
|
||||||
@@ -9,14 +10,20 @@ import {
|
|||||||
Polyline,
|
Polyline,
|
||||||
Popup,
|
Popup,
|
||||||
TileLayer,
|
TileLayer,
|
||||||
|
Tooltip,
|
||||||
useMap,
|
useMap,
|
||||||
|
useMapEvents,
|
||||||
} from "react-leaflet";
|
} from "react-leaflet";
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
|
import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
|
||||||
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
|
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { useAircraft } from "@/lib/useAircraft";
|
import { useAircraft } from "@/lib/useAircraft";
|
||||||
|
import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
||||||
import { useVessels } from "@/lib/useVessels";
|
import { useVessels } from "@/lib/useVessels";
|
||||||
|
import { use511 } from "@/lib/use511";
|
||||||
|
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||||
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
|
|
||||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||||
@@ -92,47 +99,186 @@ function nodeIcon(status: NodeStatus): L.DivIcon {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Aircraft icon — node-26#9 second-SDR ADS-B overlay ────────────────────────
|
// ── Aircraft — node-26#9 second-SDR ADS-B overlay ─────────────────────────────
|
||||||
function aircraftIcon(trackDeg: number | null): L.DivIcon {
|
// Styled after ADS-B Exchange / tar1090: a sized airliner silhouette with a
|
||||||
const size = 16;
|
// dark outline, filled by altitude on tar1090's hue ramp, so height reads at a
|
||||||
const rotation = trackDeg ?? 0;
|
// glance and the icon stands out from OSM's own (purple) airport symbols.
|
||||||
|
const ALT_HUE_STOPS: [number, number][] = [
|
||||||
|
[0, 20], [2000, 32.5], [4000, 43], [6000, 54], [8000, 72], [9000, 85], [11000, 140], [40000, 300],
|
||||||
|
];
|
||||||
|
|
||||||
|
function altitudeColor(altFt: number | null): string {
|
||||||
|
if (altFt == null) return "hsl(0, 0%, 55%)";
|
||||||
|
if (altFt <= 0) return "hsl(0, 0%, 45%)"; // on the ground
|
||||||
|
let hue = ALT_HUE_STOPS[ALT_HUE_STOPS.length - 1][1];
|
||||||
|
for (let i = 1; i < ALT_HUE_STOPS.length; i++) {
|
||||||
|
const [a1, h1] = ALT_HUE_STOPS[i];
|
||||||
|
if (altFt <= a1) {
|
||||||
|
const [a0, h0] = ALT_HUE_STOPS[i - 1];
|
||||||
|
hue = h0 + ((h1 - h0) * (altFt - a0)) / (a1 - a0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `hsl(${hue.toFixed(0)}, 88%, 48%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend ticks for the altitude ramp, evenly spaced (the ramp itself isn't linear).
|
||||||
|
const ALTITUDE_LEGEND_TICKS: [number, string][] = [
|
||||||
|
[1000, "1k"], [4000, "4k"], [10000, "10k"], [20000, "20k"], [40000, "40k"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const AIRLINER_PATH =
|
||||||
|
"M32 2 C34.2 2 35.2 5 35.2 8 L35.2 23 L61 37.5 L61 42 L35.2 35 L34.2 51 L42.5 57.5 L42.5 61 L32 58.5 " +
|
||||||
|
"L21.5 61 L21.5 57.5 L29.8 51 L28.8 35 L3 42 L3 37.5 L28.8 23 L28.8 8 C28.8 5 29.8 2 32 2 Z";
|
||||||
|
|
||||||
|
function aircraftIcon(trackDeg: number | null, altFt: number | null, selected: boolean): L.DivIcon {
|
||||||
|
const size = selected ? 36 : 30;
|
||||||
|
const outline = selected ? "#ffffff" : "#000000";
|
||||||
|
const shadow = selected ? "drop-shadow(0 0 3px #000)" : "drop-shadow(0 1px 1px rgba(0,0,0,.45))";
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L15 11 L22 15 L15 15.5 L14 21 L17 22.5 L12 21.5 L7 22.5 L10 21 L9 15.5 L2 15 L9 11 Z"/></svg></div>`,
|
html:
|
||||||
|
`<div style="width:${size}px;height:${size}px;transform:rotate(${trackDeg ?? 0}deg);filter:${shadow}">` +
|
||||||
|
`<svg width="${size}" height="${size}" viewBox="0 0 64 64"><path d="${AIRLINER_PATH}" ` +
|
||||||
|
`fill="${altitudeColor(altFt)}" stroke="${outline}" stroke-width="${selected ? 3 : 2}" stroke-linejoin="round"/></svg></div>`,
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function AircraftLayer() {
|
function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack }) {
|
||||||
const { aircraft } = useAircraft();
|
const trail = useAircraftTrail(icao);
|
||||||
|
// Extend to the live position so the path always meets the icon.
|
||||||
|
const points = [...trail];
|
||||||
|
if (current.lat != null && current.lon != null) {
|
||||||
|
points.push({ lat: current.lat, lon: current.lon, altitude_ft: current.altitude_ft, t: current.last_seen });
|
||||||
|
}
|
||||||
|
// One segment per leg, colored by altitude like tar1090's track.
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{aircraft
|
{points.slice(1).map((p, i) => (
|
||||||
.filter((a) => a.lat != null && a.lon != null)
|
<Polyline
|
||||||
.map((a) => (
|
key={`${icao}-${i}`}
|
||||||
<Marker key={a.icao} position={[a.lat as number, a.lon as number]} icon={aircraftIcon(a.track_deg)}>
|
positions={[[points[i].lat, points[i].lon], [p.lat, p.lon]]}
|
||||||
<Popup minWidth={160}>
|
pathOptions={{ color: altitudeColor(p.altitude_ft), weight: 3, opacity: 0.9, lineCap: "round" }}
|
||||||
<div className="space-y-1">
|
interactive={false}
|
||||||
<div className="font-semibold">{a.callsign || a.icao}</div>
|
/>
|
||||||
<div className="text-xs text-ink-muted">ICAO {a.icao}</div>
|
))}
|
||||||
{a.altitude_ft != null && <div className="text-xs">Altitude: {Math.round(a.altitude_ft)} ft</div>}
|
</>
|
||||||
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
|
);
|
||||||
</div>
|
}
|
||||||
</Popup>
|
|
||||||
</Marker>
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
))}
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{label}</div>
|
||||||
|
<div className="text-ink font-medium tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details dock at the right edge (bottom sheet on phones, above the incident
|
||||||
|
// drawer) instead of a popup over the plane, so the trail stays visible and
|
||||||
|
// the map can be panned to follow it.
|
||||||
|
function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
// The panel is portaled into the Leaflet container, whose native listeners
|
||||||
|
// would otherwise treat clicks/scrolls here as map clicks (deselect) or zoom.
|
||||||
|
if (!ref.current) return;
|
||||||
|
L.DomEvent.disableClickPropagation(ref.current);
|
||||||
|
L.DomEvent.disableScrollPropagation(ref.current);
|
||||||
|
}, []);
|
||||||
|
const fmt = (n: number | null, unit: string) => (n == null ? "—" : `${Math.round(n).toLocaleString()} ${unit}`);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="absolute left-3 right-3 bottom-12 md:left-auto md:bottom-auto md:top-[5.5rem] md:right-3 md:w-60 z-[1002] bg-surface/95 backdrop-blur-sm border border-line rounded-lg shadow-lg text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-2 border-b border-line">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ background: altitudeColor(a.altitude_ft) }} />
|
||||||
|
<span className="text-ink font-semibold text-sm truncate">{a.callsign || a.icao}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-ink-muted mt-0.5">ICAO {a.icao}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} aria-label="Close aircraft details" className="text-ink-muted hover:text-ink px-1 leading-none text-base">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-2 px-3 py-2.5">
|
||||||
|
<Stat label="Altitude" value={fmt(a.altitude_ft, "ft")} />
|
||||||
|
<Stat label="Speed" value={fmt(a.ground_speed_kt, "kt")} />
|
||||||
|
<Stat label="Heading" value={a.track_deg == null ? "—" : `${Math.round(a.track_deg)}°`} />
|
||||||
|
<Stat label="Last heard" value={timeAgo(new Date(a.last_seen))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AircraftLayer() {
|
||||||
|
const map = useMap();
|
||||||
|
const { aircraft } = useAircraft();
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const positioned = aircraft.filter((a) => a.lat != null && a.lon != null);
|
||||||
|
const selectedTrack = positioned.find((a) => a.icao === selected);
|
||||||
|
|
||||||
|
// Clicking empty map deselects; marker clicks don't reach the map.
|
||||||
|
useMapEvents({ click: () => setSelected(null) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
|
||||||
|
{selectedTrack &&
|
||||||
|
createPortal(<AircraftPanel a={selectedTrack} onClose={() => setSelected(null)} />, map.getContainer())}
|
||||||
|
{positioned.map((a) => (
|
||||||
|
<Marker
|
||||||
|
key={a.icao}
|
||||||
|
position={[a.lat as number, a.lon as number]}
|
||||||
|
icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)}
|
||||||
|
zIndexOffset={a.icao === selected ? 1000 : 0}
|
||||||
|
eventHandlers={{ click: () => setSelected((cur) => (cur === a.icao ? null : a.icao)) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -14]}>
|
||||||
|
{a.callsign || a.icao}
|
||||||
|
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
|
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
|
||||||
|
// MMSI 99xxxxxxx is an aid to navigation (buoy, beacon, light), not a vessel —
|
||||||
|
// on the Hudson most of what a node hears is buoys (server-26#186).
|
||||||
|
function isAidToNavigation(mmsi: string): boolean {
|
||||||
|
return /^99\d{7}$/.test(mmsi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIS reports heading 511 (and course 360) for "not available".
|
||||||
|
function aisHeading(deg: number | null): number | null {
|
||||||
|
return deg == null || deg >= 360 ? null : deg;
|
||||||
|
}
|
||||||
|
|
||||||
function vesselIcon(headingDeg: number | null): L.DivIcon {
|
function vesselIcon(headingDeg: number | null): L.DivIcon {
|
||||||
const size = 14;
|
const size = 22;
|
||||||
const rotation = headingDeg ?? 0;
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L18 14 L18 20 L6 20 L6 14 Z"/></svg></div>`,
|
html:
|
||||||
|
`<div style="width:${size}px;height:${size}px;transform:rotate(${aisHeading(headingDeg) ?? 0}deg);filter:drop-shadow(0 1px 1px rgba(0,0,0,.45))">` +
|
||||||
|
`<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2 L18 12 L18 21 L6 21 L6 12 Z" fill="hsl(190, 80%, 42%)" stroke="#000" stroke-width="1.25" stroke-linejoin="round"/></svg></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aidToNavigationIcon(): L.DivIcon {
|
||||||
|
const size = 12;
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<svg width="${size}" height="${size}" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" transform="rotate(45 6 6)" fill="hsl(50, 95%, 55%)" stroke="#000" stroke-width="1"/></svg>`,
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
});
|
});
|
||||||
@@ -144,17 +290,181 @@ function VesselLayer() {
|
|||||||
<>
|
<>
|
||||||
{vessels
|
{vessels
|
||||||
.filter((v) => v.lat != null && v.lon != null)
|
.filter((v) => v.lat != null && v.lon != null)
|
||||||
.map((v) => (
|
.map((v) => {
|
||||||
<Marker key={v.mmsi} position={[v.lat as number, v.lon as number]} icon={vesselIcon(v.heading_deg)}>
|
const aton = isAidToNavigation(v.mmsi);
|
||||||
<Popup minWidth={160}>
|
return (
|
||||||
|
<Marker
|
||||||
|
key={v.mmsi}
|
||||||
|
position={[v.lat as number, v.lon as number]}
|
||||||
|
icon={aton ? aidToNavigationIcon() : vesselIcon(v.heading_deg)}
|
||||||
|
zIndexOffset={aton ? -100 : 0}
|
||||||
|
>
|
||||||
|
<Popup minWidth={160}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-semibold">{aton ? `Aid to navigation${v.name ? ` ${v.name}` : ""}` : v.name || v.mmsi}</div>
|
||||||
|
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
|
||||||
|
{!aton && v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
|
||||||
|
// Overlay names double as the event keys for overlayadd/overlayremove.
|
||||||
|
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
||||||
|
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
|
||||||
|
|
||||||
|
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
|
||||||
|
function useOverlayShown(map: L.Map, name: string): boolean {
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const on = (e: L.LayersControlEvent) => e.name === name && setShown(true);
|
||||||
|
const off = (e: L.LayersControlEvent) => e.name === name && setShown(false);
|
||||||
|
map.on("overlayadd", on);
|
||||||
|
map.on("overlayremove", off);
|
||||||
|
return () => { map.off("overlayadd", on); map.off("overlayremove", off); };
|
||||||
|
}, [map, name]);
|
||||||
|
return shown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cameraIcon(): L.DivIcon {
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<svg width="16" height="12" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="#1e3a5f" stroke="#93c5fd" stroke-width="1"/><circle cx="6" cy="6" r="2.5" fill="none" stroke="#93c5fd" stroke-width="1.25"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#93c5fd"/></svg>`,
|
||||||
|
iconSize: [16, 12],
|
||||||
|
iconAnchor: [8, 6],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shape + glyph per 511 event type, so the layer reads without colour alone.
|
||||||
|
const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
|
||||||
|
accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" },
|
||||||
|
closures: { glyph: "×", fill: "#ea580c", label: "Closure" },
|
||||||
|
roadwork: { glyph: "W", fill: "#ca8a04", label: "Roadwork" },
|
||||||
|
specialEvents: { glyph: "E", fill: "#7c3aed", label: "Special event" },
|
||||||
|
transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" },
|
||||||
|
};
|
||||||
|
const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" };
|
||||||
|
|
||||||
|
function trafficEventIcon(type: string): L.DivIcon {
|
||||||
|
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="${st.fill}" stroke="#000" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-size="11" font-weight="700" font-family="sans-serif" fill="#fff">${st.glyph}</text></svg>`,
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtLocal(iso: string | null): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local
|
||||||
|
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Surfaces a dead or stale 511 feed on the map instead of an unexplained empty layer. */
|
||||||
|
function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) {
|
||||||
|
let msg: string | null = null;
|
||||||
|
if (fetchError) msg = `${label}: could not reach server`;
|
||||||
|
else if (status?.error) {
|
||||||
|
const age = status.fetched_at ? `showing data from ${Math.round((Date.now() / 1000 - status.fetched_at) / 60)} min ago` : "no data yet";
|
||||||
|
msg = `${label}: 511NY feed error, ${age}`;
|
||||||
|
} else if (status?.truncated) msg = `${label}: showing ${1500} of ${status.total_in_bbox} — zoom in`;
|
||||||
|
if (!msg) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded px-2 py-1 text-xs text-ink-2 pointer-events-none">{msg}</div>,
|
||||||
|
map.getContainer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned cameras: clicking a camera's snapshot pins its live feed as a small
|
||||||
|
// tile anchored at the camera, so several views along one road can be read
|
||||||
|
// against the map. Oldest pin drops past the cap.
|
||||||
|
const MAX_CAMERA_PINS = 6;
|
||||||
|
|
||||||
|
function DotCameraLayer() {
|
||||||
|
const map = useMap();
|
||||||
|
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||||
|
const { data, error } = use511(map, "cameras", shown);
|
||||||
|
const [pinned, setPinned] = useState<Ny511Camera[]>([]);
|
||||||
|
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
|
||||||
|
|
||||||
|
const pin = (c: Ny511Camera) => {
|
||||||
|
map.closePopup();
|
||||||
|
setPinned((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, c].slice(-MAX_CAMERA_PINS)));
|
||||||
|
};
|
||||||
|
const unpin = (id: string) => setPinned((prev) => prev.filter((p) => p.id !== id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
||||||
|
{(data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)).map((c) => (
|
||||||
|
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
||||||
|
<Popup minWidth={260} maxWidth={340}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-semibold">{c.name}</div>
|
||||||
|
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
|
||||||
|
{c.image_url && (
|
||||||
|
// Popup content mounts on open, so the timestamp busts the cache per open.
|
||||||
|
<button type="button" onClick={() => pin(c)} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] text-ink-muted">
|
||||||
|
Click the image to pin {c.video_url ? "the live feed" : "it"} to the map · 511NY / NYSDOT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
{/* Players only exist while the overlay is on, so hiding it stops every stream. */}
|
||||||
|
{shown && pinned.map((c) => (
|
||||||
|
<Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}>
|
||||||
|
<Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin">
|
||||||
|
<div className="w-[192px]">
|
||||||
|
<div className="flex items-center gap-1 px-1.5 h-4">
|
||||||
|
<span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span>
|
||||||
|
<button type="button" onClick={() => unpin(c.id)} aria-label={`Unpin ${c.name}`} className="text-ink-muted hover:text-ink text-xs leading-none px-0.5">×</button>
|
||||||
|
</div>
|
||||||
|
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrafficEventLayer() {
|
||||||
|
const map = useMap();
|
||||||
|
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
|
||||||
|
const { data, error } = use511(map, "events", shown);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
|
||||||
|
{(data.events ?? []).map((e: Ny511Event) => {
|
||||||
|
const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER;
|
||||||
|
const start = fmtLocal(e.start_local);
|
||||||
|
const end = fmtLocal(e.planned_end_local);
|
||||||
|
return (
|
||||||
|
<Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={e.type === "accidentsAndIncidents" ? 200 : 0}>
|
||||||
|
<Popup minWidth={220} maxWidth={320}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="font-semibold">{v.name || v.mmsi}</div>
|
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
|
||||||
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
|
<div className="text-xs text-ink-muted">{[e.roadway, e.direction, e.county].filter(Boolean).join(" · ")}</div>
|
||||||
{v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
|
{(start || end) && <div className="text-xs">{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}</div>}
|
||||||
|
<div className="text-xs whitespace-pre-line">{e.description}</div>
|
||||||
|
<div className="text-[10px] text-ink-muted">511NY{e.updated_local ? ` · updated ${fmtLocal(e.updated_local)}` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -528,6 +838,21 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [agoClock, setAgoClock] = useState(0);
|
const [agoClock, setAgoClock] = useState(0);
|
||||||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
||||||
|
const [aircraftShown, setAircraftShown] = useState(false);
|
||||||
|
|
||||||
|
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||||||
|
// overlay is actually on (server-26#185).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstance) return;
|
||||||
|
const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true);
|
||||||
|
const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false);
|
||||||
|
mapInstance.on("overlayadd", on);
|
||||||
|
mapInstance.on("overlayremove", off);
|
||||||
|
return () => {
|
||||||
|
mapInstance.off("overlayadd", on);
|
||||||
|
mapInstance.off("overlayremove", off);
|
||||||
|
};
|
||||||
|
}, [mapInstance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||||
@@ -660,6 +985,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
|
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
||||||
|
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
|
||||||
|
<FeatureGroup>
|
||||||
|
<DotCameraLayer />
|
||||||
|
</FeatureGroup>
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
|
||||||
|
<FeatureGroup>
|
||||||
|
<TrafficEventLayer />
|
||||||
|
</FeatureGroup>
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
||||||
<LayersControl.Overlay name="Weather Radar">
|
<LayersControl.Overlay name="Weather Radar">
|
||||||
<TileLayer
|
<TileLayer
|
||||||
@@ -718,6 +1055,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{aircraftShown && (
|
||||||
|
<div className="border-t border-line pt-1.5 space-y-1">
|
||||||
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Aircraft altitude</p>
|
||||||
|
<div
|
||||||
|
className="h-2 w-32 rounded-sm border border-line"
|
||||||
|
style={{ background: `linear-gradient(to right, ${ALTITUDE_LEGEND_TICKS.map(([ft], i) => `${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
|
||||||
|
{ALTITUDE_LEGEND_TICKS.map(([ft, label]) => <span key={ft}>{label}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="border-t border-line pt-1.5 space-y-1">
|
<div className="border-t border-line pt-1.5 space-y-1">
|
||||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
||||||
{([
|
{([
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { NodeRecord, SdrDevice } from "@/lib/types";
|
||||||
|
|
||||||
|
// node-26#9 / #11. OP25 always has exactly one SDR (pinned by serial, or the
|
||||||
|
// first one found). Every other SDR runs the next enabled service, top first;
|
||||||
|
// a service can be pinned to the SDR that carries its antenna.
|
||||||
|
const MODES: { mode: string; name: string; hint: string }[] = [
|
||||||
|
{ mode: "adsb", name: "ADS-B", hint: "Aircraft · 1090 MHz" },
|
||||||
|
{ mode: "ais", name: "AIS", hint: "Vessels · 162 MHz" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Row = { mode: string; enabled: boolean; pin: string };
|
||||||
|
|
||||||
|
function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
|
||||||
|
const known = priority.filter((m) => MODES.some((x) => x.mode === m));
|
||||||
|
return [
|
||||||
|
...known.map((mode) => ({ mode, enabled: true, pin: pins[mode] ?? "" })),
|
||||||
|
...MODES.filter((x) => !known.includes(x.mode)).map((x) => ({ mode: x.mode, enabled: false, pin: pins[x.mode] ?? "" })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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]";
|
||||||
|
|
||||||
|
function DeviceSelect({
|
||||||
|
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
devices: SdrDevice[];
|
||||||
|
devicesKnown: boolean;
|
||||||
|
autoLabel: string;
|
||||||
|
label: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (serial: string) => void;
|
||||||
|
}) {
|
||||||
|
const known = devices.some((d) => d.serial === value);
|
||||||
|
return (
|
||||||
|
<select aria-label={label} value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)} className={selectClass}>
|
||||||
|
<option value="">{autoLabel}</option>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<option key={`${d.index}-${d.serial}`} value={d.serial ?? ""} disabled={!d.serial}>
|
||||||
|
SDR {d.index + 1} · serial {d.serial ?? "unknown"}{d.duplicate_serial ? " (shared serial!)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{value && !known && (
|
||||||
|
<option value={value}>serial {value} ({devicesKnown ? "not plugged in" : "not reported"})</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: boolean }) {
|
||||||
|
const priority = node.secondary_sdr_priority ?? [];
|
||||||
|
const pins = node.sdr_pins ?? {};
|
||||||
|
// null/absent = the node has never reported (older firmware, container down
|
||||||
|
// at checkin): unknown, not "nothing running" (server-26#187).
|
||||||
|
const reported = node.secondary_sdr_running != null;
|
||||||
|
const running = node.secondary_sdr_running ?? [];
|
||||||
|
const devices = node.sdr_devices ?? [];
|
||||||
|
const devicesKnown = reported && node.sdr_devices != null;
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority, pins));
|
||||||
|
const [op25Pin, setOp25Pin] = useState(pins.op25 ?? "");
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Follow the node's live checkin unless there are unsaved edits.
|
||||||
|
const serverKey = JSON.stringify([priority, pins]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (dirty) return;
|
||||||
|
const [p, pn] = JSON.parse(serverKey) as [string[], Record<string, string>];
|
||||||
|
setRows(rowsFrom(p, pn));
|
||||||
|
setOp25Pin(pn.op25 ?? "");
|
||||||
|
}, [serverKey, dirty]);
|
||||||
|
|
||||||
|
function edit(next: Row[]) {
|
||||||
|
setRows(next);
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(i: number, delta: number) {
|
||||||
|
const next = [...rows];
|
||||||
|
[next[i], next[i + delta]] = [next[i + delta], next[i]];
|
||||||
|
edit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinned = [op25Pin, ...rows.map((r) => r.pin)].filter(Boolean);
|
||||||
|
const clash = new Set(pinned).size !== pinned.length;
|
||||||
|
const op25Moving = op25Pin !== (pins.op25 ?? "");
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const nextPins: Record<string, string | null> = { op25: op25Pin || null };
|
||||||
|
rows.forEach((r) => { nextPins[r.mode] = r.pin || null; });
|
||||||
|
await c2api.updateNode(node.node_id, {
|
||||||
|
secondary_sdr_priority: rows.filter((r) => r.enabled).map((r) => r.mode),
|
||||||
|
sdr_pins: nextPins,
|
||||||
|
});
|
||||||
|
setDirty(false);
|
||||||
|
setMessage("Sent to the node. Status updates when it checks in.");
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : "Save failed.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rank = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
||||||
|
<p className="text-xs text-gray-500 font-mono mb-3">
|
||||||
|
{devicesKnown
|
||||||
|
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
||||||
|
: "This node hasn't reported its SDRs yet."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-gray-200">OP25 SDR</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{op25Moving
|
||||||
|
? "Saving restarts OP25 on the selected SDR."
|
||||||
|
: node.op25_sdr_serial
|
||||||
|
? `Using serial ${node.op25_sdr_serial}`
|
||||||
|
: "P25 / analog scanning"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DeviceSelect
|
||||||
|
value={op25Pin}
|
||||||
|
devices={devices}
|
||||||
|
devicesKnown={devicesKnown}
|
||||||
|
autoLabel="Automatic (first SDR)"
|
||||||
|
label="OP25 SDR"
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(v) => { setOp25Pin(v); setDirty(true); setMessage(null); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const meta = MODES.find((x) => x.mode === row.mode)!;
|
||||||
|
const pinMissing = devicesKnown && !!row.pin && !devices.some((d) => d.serial === row.pin);
|
||||||
|
const state = !row.enabled
|
||||||
|
? "Off"
|
||||||
|
: dirty
|
||||||
|
? "Unsaved"
|
||||||
|
: !reported
|
||||||
|
? "Not reported"
|
||||||
|
: running.includes(row.mode)
|
||||||
|
? "Running"
|
||||||
|
: pinMissing
|
||||||
|
? "Pinned SDR missing"
|
||||||
|
: "Waiting for SDR";
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.enabled}
|
||||||
|
disabled={!canEdit}
|
||||||
|
aria-label={`Enable ${meta.name}`}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-[7rem]">
|
||||||
|
<div className="text-gray-200">{meta.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{meta.hint}</div>
|
||||||
|
</div>
|
||||||
|
<DeviceSelect
|
||||||
|
value={row.pin}
|
||||||
|
devices={devices}
|
||||||
|
devicesKnown={devicesKnown}
|
||||||
|
autoLabel="Any spare SDR"
|
||||||
|
label={`${meta.name} SDR`}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(v) => edit(rows.map((r, j) => (j === i ? { ...r, pin: v } : r)))}
|
||||||
|
/>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
|
disabled={i === 0}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
disabled={i === rows.length - 1}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 font-mono mt-2">
|
||||||
|
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".
|
||||||
|
</p>
|
||||||
|
{devices.some((d) => d.duplicate_serial) && (
|
||||||
|
<p className="text-xs text-red-400 font-mono mt-2">
|
||||||
|
Two SDRs share a serial number, so they can't be told apart. Give each a unique serial (rtl_eeprom -s)
|
||||||
|
before pinning.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{clash && <p className="text-xs text-red-400 font-mono mt-2">Two services are pinned to the same SDR.</p>}
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex items-center gap-3 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty || saving || clash}
|
||||||
|
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-lg text-sm font-mono transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Replay — re-run the intelligence pipeline over a past time range into a
|
||||||
|
// sandbox, then compare runs. Backend: drb-c2-core/app/internal/replay.py.
|
||||||
|
// Nothing here touches a live call or incident; every run spends real AI
|
||||||
|
// credits, so the form estimates first and only then offers Start.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { useSystems } from "@/lib/useSystems";
|
||||||
|
import type {
|
||||||
|
ReplayEstimate, ReplayIncident, ReplayIncidents, ReplayMode, ReplayRun, ReplayCallRow,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
const MODES: { key: ReplayMode; label: string; help: string }[] = [
|
||||||
|
{ key: "transcripts", label: "Saved transcripts", help: "Reuse each call's transcript; re-run extraction + correlation." },
|
||||||
|
{ key: "audio", label: "Re-transcribe audio", help: "Whisper the saved audio again, then extract + correlate. For ranges where AI was off." },
|
||||||
|
{ key: "reuse", label: "Correlation only", help: "Reuse an earlier run's extraction; re-run correlation only. Isolates a correlator change." },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Clears that came from the radio traffic itself, vs the 90-minute idle timer.
|
||||||
|
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
|
||||||
|
|
||||||
|
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";
|
||||||
|
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";
|
||||||
|
|
||||||
|
function toLocalInput(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sum(rec: Record<string, number> | undefined, keys: string[]): number {
|
||||||
|
return keys.reduce((n, k) => n + (rec?.[k] ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolean; onStarted: () => void }) {
|
||||||
|
const { systems } = useSystems();
|
||||||
|
const [from, setFrom] = useState(() => toLocalInput(new Date(Date.now() - 6 * 3600_000)));
|
||||||
|
const [to, setTo] = useState(() => toLocalInput(new Date()));
|
||||||
|
const [mode, setMode] = useState<ReplayMode>("transcripts");
|
||||||
|
const [systemIds, setSystemIds] = useState<string[]>([]);
|
||||||
|
const [sourceRun, setSourceRun] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [est, setEst] = useState<ReplayEstimate | null>(null);
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Any change to what would be replayed invalidates the estimate.
|
||||||
|
useEffect(() => { setEst(null); }, [from, to, mode, systemIds]);
|
||||||
|
|
||||||
|
const sources = runs.filter((r) => r.status === "done" && r.mode !== "reuse");
|
||||||
|
const range = () => ({ date_from: new Date(from).toISOString(), date_to: new Date(to).toISOString() });
|
||||||
|
|
||||||
|
async function estimate() {
|
||||||
|
setWorking(true); setError(null);
|
||||||
|
try {
|
||||||
|
setEst(await c2api.estimateReplay({ ...range(), mode, system_ids: systemIds }));
|
||||||
|
} catch (e) { setError(String(e)); } finally { setWorking(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
setWorking(true); setError(null);
|
||||||
|
try {
|
||||||
|
await c2api.startReplay({
|
||||||
|
...range(), mode, system_ids: systemIds, label,
|
||||||
|
source_run_id: mode === "reuse" ? sourceRun : null,
|
||||||
|
});
|
||||||
|
setEst(null); setLabel("");
|
||||||
|
onStarted();
|
||||||
|
} catch (e) { setError(String(e)); } finally { setWorking(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSystem(id: string) {
|
||||||
|
setSystemIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">From</label>
|
||||||
|
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">To</label>
|
||||||
|
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
||||||
|
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<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" />
|
||||||
|
<span className="text-white">{m.label}</span>
|
||||||
|
<span className="text-gray-500 text-xs mt-0.5">{m.help}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "reuse" && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Reuse extraction from</label>
|
||||||
|
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
|
||||||
|
<option value="">— pick a finished run —</option>
|
||||||
|
{sources.map((r) => (
|
||||||
|
<option key={r.run_id} value={r.run_id}>
|
||||||
|
{r.label || r.run_id} · {fmtTime(r.date_from)} → {fmtTime(r.date_to)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Set the range to match that run; calls outside it are skipped.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{systems.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<span className="text-xs text-gray-400">Systems (none = all):</span>
|
||||||
|
{systems.map((s) => (
|
||||||
|
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-gray-300 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
|
||||||
|
{s.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button onClick={estimate} disabled={working} className={btn}>{working && !est ? "Counting…" : "Estimate"}</button>
|
||||||
|
<button
|
||||||
|
onClick={start}
|
||||||
|
disabled={!canStart || working}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Start run
|
||||||
|
</button>
|
||||||
|
{busy && <span className="text-xs text-amber-400 font-mono">a run is in progress</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{est && (
|
||||||
|
<p className="text-sm font-mono text-gray-300">
|
||||||
|
{est.truncated ? (
|
||||||
|
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-white">{est.calls}</span> calls ·{" "}
|
||||||
|
{est.calls_with_transcript} with transcripts · {est.audio_minutes} audio min ·{" "}
|
||||||
|
<span className="text-amber-400">~${est.est_cost_usd.toFixed(2)}</span> est.
|
||||||
|
{mode === "transcripts" && est.calls_with_transcript < est.calls / 2 && (
|
||||||
|
<span className="text-amber-400"> — most calls have no transcript; consider Re-transcribe audio.</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RunsTable({
|
||||||
|
runs, activeId, selected, onSelect, onChanged,
|
||||||
|
}: {
|
||||||
|
runs: ReplayRun[]; activeId: string | null; selected: string | null;
|
||||||
|
onSelect: (id: string) => void; onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
async function cancel(id: string) {
|
||||||
|
await c2api.cancelReplay(id).catch(() => undefined);
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!window.confirm("Delete this run and its sandbox incidents?")) return;
|
||||||
|
await c2api.deleteReplay(id).catch(() => undefined);
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runs.length) return <p className="text-sm text-gray-500 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 td = "px-2 py-1.5 whitespace-nowrap";
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800">
|
||||||
|
<th className={th}>Run</th>
|
||||||
|
<th className={th}>Range</th>
|
||||||
|
<th className={th}>Status</th>
|
||||||
|
<th className={th} title="Incidents created">Inc</th>
|
||||||
|
<th className={th} title="Share of incidents that are a single call">1-call</th>
|
||||||
|
<th className={th} title="Calls that never linked to an incident">Orphans</th>
|
||||||
|
<th className={th} title="Closed by radio traffic (units cleared, closure, reassignment) vs the idle timer">Real clears / timeouts</th>
|
||||||
|
<th className={th} title="Correlation decisions the LLM took part in">LLM</th>
|
||||||
|
<th className={th}>Cost</th>
|
||||||
|
<th className={th}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runs.map((r) => {
|
||||||
|
const m = r.metrics;
|
||||||
|
const running = r.run_id === activeId;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={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"}`}
|
||||||
|
>
|
||||||
|
<td className={td}>
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td className={`${td} text-xs text-gray-400`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
|
||||||
|
<td className={td}>
|
||||||
|
{running ? (
|
||||||
|
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
|
||||||
|
) : (
|
||||||
|
<span className={r.status === "done" ? "text-green-400" : "text-red-400"}>{r.status}</span>
|
||||||
|
)}
|
||||||
|
{r.progress.errors > 0 && <span className="text-red-400 text-xs"> · {r.progress.errors} err</span>}
|
||||||
|
</td>
|
||||||
|
<td className={td}>{m?.incidents ?? "—"}</td>
|
||||||
|
<td className={td}>{m?.single_call_pct != null ? `${m.single_call_pct}%` : "—"}</td>
|
||||||
|
<td className={td}>{m ? `${m.calls_orphaned}/${m.calls}` : "—"}</td>
|
||||||
|
<td className={td}>
|
||||||
|
{m ? (
|
||||||
|
<>
|
||||||
|
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
|
||||||
|
{" / "}
|
||||||
|
<span className="text-gray-400">{m.resolved_via.idle_timeout ?? 0}</span>
|
||||||
|
</>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className={td}>{m?.llm_decisions ?? "—"}</td>
|
||||||
|
<td className={td}>{m ? `$${m.est_cost_usd.toFixed(2)}` : `~$${r.estimate.est_cost_usd.toFixed(2)}`}</td>
|
||||||
|
<td className={td} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{running ? (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function CallLine({ call }: { call: ReplayCallRow }) {
|
||||||
|
const [audio, setAudio] = useState<string | null>(null);
|
||||||
|
async function play() {
|
||||||
|
try {
|
||||||
|
const c = await c2api.getCall(call.call_id);
|
||||||
|
setAudio(c.audio_url);
|
||||||
|
} catch { /* audio is a convenience */ }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono">
|
||||||
|
<div className="flex flex-wrap gap-2 text-gray-500">
|
||||||
|
<span>{fmtTime(call.started_at)}</span>
|
||||||
|
<span>{call.talkgroup_name}</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.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>}
|
||||||
|
{audio ? (
|
||||||
|
<audio src={audio} controls autoPlay className="h-6" />
|
||||||
|
) : (
|
||||||
|
<button onClick={play} className="text-gray-400 hover:text-white">▶ audio</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-200 mt-0.5">{call.transcript || <span className="text-gray-600">(no transcript)</span>}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncidentCard({ inc }: { inc: ReplayIncident }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 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">
|
||||||
|
<span className="text-gray-500">{open ? "▾" : "▸"}</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-gray-500 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>
|
||||||
|
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
|
||||||
|
</button>
|
||||||
|
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunDetail({ run }: { run: ReplayRun }) {
|
||||||
|
const [data, setData] = useState<ReplayIncidents | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<"all" | "multi" | "single">("all");
|
||||||
|
const [showOrphans, setShowOrphans] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setData(null); setError(null);
|
||||||
|
if (run.status === "running") return;
|
||||||
|
c2api.getReplayIncidents(run.run_id).then((d) => {
|
||||||
|
setData(d);
|
||||||
|
// Exposed for in-page analysis (console / automation) of a run's
|
||||||
|
// sandbox — the same data this tab renders, nothing more.
|
||||||
|
(window as unknown as { __drbReplay?: unknown }).__drbReplay = { run, ...d };
|
||||||
|
}).catch((e) => setError(String(e)));
|
||||||
|
}, [run.run_id, run.status]);
|
||||||
|
|
||||||
|
const m = run.metrics;
|
||||||
|
const shown = (data?.incidents ?? []).filter((i) =>
|
||||||
|
filter === "all" ? true : filter === "multi" ? i.calls.length > 1 : i.calls.length === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{m && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs font-mono">
|
||||||
|
{[
|
||||||
|
["Linked calls", `${m.calls_linked}/${m.calls}`],
|
||||||
|
["Median calls / incident", m.median_calls_per_incident ?? "—"],
|
||||||
|
["With units cleared", m.incidents_with_units_cleared],
|
||||||
|
["With map pin", m.incidents_with_coords],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2">
|
||||||
|
<div className="text-gray-500">{k}</div>
|
||||||
|
<div className="text-white text-base">{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{m && (
|
||||||
|
<p className="text-xs font-mono text-gray-500">
|
||||||
|
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||||
|
<br />
|
||||||
|
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && (
|
||||||
|
<p className="text-xs font-mono text-gray-500">
|
||||||
|
Gemini tokens:{" "}
|
||||||
|
{Object.entries(m.gemini_usage)
|
||||||
|
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{m?.ai_failures && Object.keys(m.ai_failures).length > 0 && (
|
||||||
|
<p className="text-xs font-mono text-amber-400">
|
||||||
|
AI failures: {Object.entries(m.ai_failures).map(([k, v]) => `${k} ×${v}`).join(" · ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{run.errors?.length > 0 && (
|
||||||
|
<details className="text-xs font-mono text-red-400">
|
||||||
|
<summary>{run.errors.length} error(s)</summary>
|
||||||
|
{run.errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{run.status === "running" && <p className="text-sm text-gray-500 font-mono">Incidents appear when the run finishes.</p>}
|
||||||
|
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-1 text-xs font-mono">
|
||||||
|
{(["all", "multi", "single"] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={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"}`}
|
||||||
|
>
|
||||||
|
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOrphans(!showOrphans)}
|
||||||
|
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
||||||
|
>
|
||||||
|
Orphans ({data.orphans.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showOrphans ? (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
||||||
|
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">{shown.map((i) => <IncidentCard key={i.incident_id} inc={i} />)}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ReplayTab() {
|
||||||
|
const [runs, setRuns] = useState<ReplayRun[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await c2api.listReplays();
|
||||||
|
setRuns(res.runs);
|
||||||
|
setActiveId(res.active_run_id);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) { setError(String(e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
// Poll only while something is running.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeId) return;
|
||||||
|
const t = setInterval(load, 4000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [activeId, load]);
|
||||||
|
|
||||||
|
const run = runs.find((r) => r.run_id === selected) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
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
|
||||||
|
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
|
||||||
|
</p>
|
||||||
|
<NewRunForm runs={runs} busy={!!activeId} onStarted={load} />
|
||||||
|
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||||||
|
<RunsTable runs={runs} activeId={activeId} selected={selected} onSelect={setSelected} onChanged={load} />
|
||||||
|
{run && <RunDetail run={run} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// A from/to pair of native date inputs. Values are the inputs' own
|
||||||
|
// "YYYY-MM-DD" strings; dayStart/dayEnd turn them into the local-midnight
|
||||||
|
// bounds a started_at range query needs, so "to" includes the whole day.
|
||||||
|
|
||||||
|
export function dayStart(ymd: string): Date | undefined {
|
||||||
|
if (!ymd) return undefined;
|
||||||
|
const [y, m, d] = ymd.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayEnd(ymd: string): Date | undefined {
|
||||||
|
if (!ymd) return undefined;
|
||||||
|
const [y, m, d] = ymd.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d, 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent";
|
||||||
|
|
||||||
|
export function DateRange({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
onChange: (from: string, to: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-ink-muted">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="From date"
|
||||||
|
value={from}
|
||||||
|
max={to || undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value, to)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<span>to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="To date"
|
||||||
|
value={to}
|
||||||
|
min={from || undefined}
|
||||||
|
onChange={(e) => onChange(from, e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
{(from || to) && (
|
||||||
|
<button onClick={() => onChange("", "")} className="text-ink-muted hover:text-ink-2">
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
import type { AreaContext, TalkgroupPending } from "@/lib/types";
|
import type { AreaContext, Ny511Response, TalkgroupPending } from "@/lib/types";
|
||||||
|
|
||||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
@@ -35,7 +35,15 @@ export const c2api = {
|
|||||||
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
||||||
resetOverride: (nodeId: string) =>
|
resetOverride: (nodeId: string) =>
|
||||||
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
||||||
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
|
updateNode: (
|
||||||
|
id: string,
|
||||||
|
body: {
|
||||||
|
node_type?: string;
|
||||||
|
enforce_override_timeout?: boolean;
|
||||||
|
secondary_sdr_priority?: string[];
|
||||||
|
sdr_pins?: Record<string, string | null>;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
// Systems
|
// Systems
|
||||||
@@ -82,6 +90,8 @@ export const c2api = {
|
|||||||
link?: "any" | "orphan" | "linked";
|
link?: "any" | "orphan" | "linked";
|
||||||
transcript?: "any" | "yes" | "no";
|
transcript?: "any" | "yes" | "no";
|
||||||
q?: string;
|
q?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
@@ -231,6 +241,28 @@ export const c2api = {
|
|||||||
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
||||||
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
|
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
|
||||||
|
|
||||||
|
// Replay (admin) — re-run the pipeline over past calls into a sandbox.
|
||||||
|
// See drb-c2-core/app/internal/replay.py.
|
||||||
|
estimateReplay: (p: { date_from: string; date_to: string; mode: import("@/lib/types").ReplayMode; system_ids?: string[] }) => {
|
||||||
|
const qs = new URLSearchParams({ date_from: p.date_from, date_to: p.date_to, mode: p.mode });
|
||||||
|
if (p.system_ids?.length) qs.set("system_ids", p.system_ids.join(","));
|
||||||
|
return request<import("@/lib/types").ReplayEstimate>(`/admin/replay/estimate?${qs}`);
|
||||||
|
},
|
||||||
|
startReplay: (body: {
|
||||||
|
date_from: string; date_to: string; mode: import("@/lib/types").ReplayMode;
|
||||||
|
system_ids?: string[]; source_run_id?: string | null; label?: string;
|
||||||
|
}) =>
|
||||||
|
request<import("@/lib/types").ReplayRun>("/admin/replay", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
listReplays: () =>
|
||||||
|
request<{ runs: import("@/lib/types").ReplayRun[]; active_run_id: string | null }>("/admin/replay"),
|
||||||
|
getReplay: (runId: string) => request<import("@/lib/types").ReplayRun>(`/admin/replay/${runId}`),
|
||||||
|
cancelReplay: (runId: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/replay/${runId}/cancel`, { method: "POST" }),
|
||||||
|
deleteReplay: (runId: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/replay/${runId}`, { method: "DELETE" }),
|
||||||
|
getReplayIncidents: (runId: string) =>
|
||||||
|
request<import("@/lib/types").ReplayIncidents>(`/admin/replay/${runId}/incidents`),
|
||||||
|
|
||||||
// Preferred bot token per system
|
// Preferred bot token per system
|
||||||
setPreferredToken: (tokenId: string, systemId: string) =>
|
setPreferredToken: (tokenId: string, systemId: string) =>
|
||||||
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
||||||
@@ -343,6 +375,12 @@ export const c2api = {
|
|||||||
revokeEnrollmentToken: (tokenId: string) =>
|
revokeEnrollmentToken: (tokenId: string) =>
|
||||||
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
|
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// 511NY cameras/events inside a map bbox (server-26#183)
|
||||||
|
get511: (bbox: { south: number; west: number; north: number; east: number }, layers: string) =>
|
||||||
|
request<Ny511Response>(`/traffic/511?${new URLSearchParams({
|
||||||
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), layers,
|
||||||
|
})}`),
|
||||||
|
|
||||||
// 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) }),
|
||||||
|
|||||||
+162
-2
@@ -53,14 +53,31 @@ export interface NodeRecord {
|
|||||||
hardware_preset?: string;
|
hardware_preset?: string;
|
||||||
ppm_override?: number | null;
|
ppm_override?: number | null;
|
||||||
node_type?: string;
|
node_type?: string;
|
||||||
secondary_sdr_mode?: string;
|
secondary_sdr_mode?: string; // legacy; priority[0] on current nodes
|
||||||
sdr_count?: number;
|
/** Ordered decoders for the SDRs beyond OP25's, run top-down (node-26#9). */
|
||||||
|
secondary_sdr_priority?: string[];
|
||||||
|
/** What the node's last checkin reported actually running. */
|
||||||
|
secondary_sdr_running?: string[] | null;
|
||||||
|
/** node-26#11: service (op25/adsb/ais) -> dongle serial; absent = automatic. */
|
||||||
|
sdr_pins?: Record<string, string>;
|
||||||
|
/** Dongles the node detected, from its checkin; null/absent = not reported. */
|
||||||
|
sdr_devices?: SdrDevice[] | null;
|
||||||
|
/** The dongle OP25 is actually using, per the node. */
|
||||||
|
op25_sdr_serial?: string | null;
|
||||||
|
sdr_count?: number | null; // null = never reported
|
||||||
enforce_override_timeout?: boolean;
|
enforce_override_timeout?: boolean;
|
||||||
is_overridden?: boolean;
|
is_overridden?: boolean;
|
||||||
override_system_id?: string | null;
|
override_system_id?: string | null;
|
||||||
override_timeout_at?: string | null;
|
override_timeout_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SdrDevice {
|
||||||
|
index: number;
|
||||||
|
serial: string | null;
|
||||||
|
name: string;
|
||||||
|
duplicate_serial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AircraftTrack {
|
export interface AircraftTrack {
|
||||||
icao: string;
|
icao: string;
|
||||||
org_id?: string;
|
org_id?: string;
|
||||||
@@ -74,6 +91,14 @@ export interface AircraftTrack {
|
|||||||
last_seen: string;
|
last_seen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One point of an aircraft's flight path — aircraft/{icao}/positions. */
|
||||||
|
export interface AircraftTrailPoint {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
altitude_ft: number | null;
|
||||||
|
t: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VesselTrack {
|
export interface VesselTrack {
|
||||||
mmsi: string;
|
mmsi: string;
|
||||||
org_id?: string;
|
org_id?: string;
|
||||||
@@ -86,6 +111,49 @@ export interface VesselTrack {
|
|||||||
last_seen: string;
|
last_seen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 511NY traffic layer (server-26#183) — GET /traffic/511, cached server-side,
|
||||||
|
// not Firestore. *_local times are New York local, no offset: display only.
|
||||||
|
export interface Ny511Camera {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
name: string;
|
||||||
|
roadway: string;
|
||||||
|
direction: string;
|
||||||
|
image_url: string | null;
|
||||||
|
video_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ny511Event {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
type: string;
|
||||||
|
subtype: string;
|
||||||
|
severity: string;
|
||||||
|
roadway: string;
|
||||||
|
direction: string;
|
||||||
|
county: string;
|
||||||
|
description: string;
|
||||||
|
start_local: string | null;
|
||||||
|
planned_end_local: string | null;
|
||||||
|
updated_local: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ny511FeedStatus {
|
||||||
|
fetched_at: number | null; // epoch seconds of the last successful 511NY fetch
|
||||||
|
error: string | null; // set when the latest refresh failed (data is then stale)
|
||||||
|
total_in_bbox: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ny511Response {
|
||||||
|
cameras?: Ny511Camera[];
|
||||||
|
cameras_status?: Ny511FeedStatus;
|
||||||
|
events?: Ny511Event[];
|
||||||
|
events_status?: Ny511FeedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VocabularyPendingTerm {
|
export interface VocabularyPendingTerm {
|
||||||
term: string;
|
term: string;
|
||||||
source: "induction" | "correction";
|
source: "induction" | "correction";
|
||||||
@@ -306,3 +374,95 @@ export interface TalkgroupPending {
|
|||||||
talkgroup_name?: string;
|
talkgroup_name?: string;
|
||||||
pending: PendingLocalTerm[];
|
pending: PendingLocalTerm[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Replay (admin) — drb-c2-core/app/internal/replay.py
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ReplayMode = "audio" | "transcripts" | "reuse";
|
||||||
|
|
||||||
|
export interface ReplayEstimate {
|
||||||
|
calls: number;
|
||||||
|
calls_with_transcript: number;
|
||||||
|
calls_with_audio: number;
|
||||||
|
audio_minutes: number;
|
||||||
|
est_cost_usd: number;
|
||||||
|
truncated: boolean;
|
||||||
|
max_calls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayMetrics {
|
||||||
|
calls: number;
|
||||||
|
calls_linked: number;
|
||||||
|
calls_orphaned: number;
|
||||||
|
incidents: number;
|
||||||
|
single_call_incidents: number;
|
||||||
|
single_call_pct: number | null;
|
||||||
|
median_calls_per_incident: number | null;
|
||||||
|
max_calls_in_incident: number | null;
|
||||||
|
incidents_with_units_cleared: number;
|
||||||
|
incidents_with_coords: number;
|
||||||
|
resolved_via: Record<string, number>;
|
||||||
|
corr_path: Record<string, number>;
|
||||||
|
corr_consensus: Record<string, number>;
|
||||||
|
llm_decisions: number;
|
||||||
|
est_cost_usd: number;
|
||||||
|
ai_failures?: Record<string, number>;
|
||||||
|
gemini_usage?: Record<string, { calls: number; in: number; out: number; thinking: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayRun {
|
||||||
|
run_id: string;
|
||||||
|
label: string;
|
||||||
|
mode: ReplayMode;
|
||||||
|
source_run_id: string | null;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
system_ids: string[];
|
||||||
|
git_sha: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
finished_at?: string;
|
||||||
|
status: "running" | "done" | "cancelled" | "failed" | "interrupted";
|
||||||
|
estimate: Omit<ReplayEstimate, "truncated" | "max_calls">;
|
||||||
|
progress: { total: number; done: number; errors: number; skipped?: number };
|
||||||
|
metrics: ReplayMetrics | null;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayCallRow {
|
||||||
|
call_id: string;
|
||||||
|
started_at: string;
|
||||||
|
talkgroup_name: string | null;
|
||||||
|
transcript: string | null;
|
||||||
|
units: string[] | null;
|
||||||
|
cleared_units: string[] | null;
|
||||||
|
location: string | null;
|
||||||
|
skip_reason: string | null;
|
||||||
|
corr_path: string[];
|
||||||
|
incident_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayIncident {
|
||||||
|
incident_id: string;
|
||||||
|
title: string | null;
|
||||||
|
type: string | null;
|
||||||
|
severity: string | null;
|
||||||
|
status: string;
|
||||||
|
resolved_via: string | null;
|
||||||
|
started_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
resolved_at: string | null;
|
||||||
|
location: string | null;
|
||||||
|
location_coords: { lat: number; lng: number } | null;
|
||||||
|
units: string[] | null;
|
||||||
|
units_active: string[] | null;
|
||||||
|
units_cleared: string[] | null;
|
||||||
|
talkgroup_ids: number[] | null;
|
||||||
|
calls: ReplayCallRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayIncidents {
|
||||||
|
incidents: ReplayIncident[];
|
||||||
|
orphans: ReplayCallRow[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type L from "leaflet";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { Ny511Response } from "@/lib/types";
|
||||||
|
|
||||||
|
// 511NY cameras/events for the current map view (server-26#183). Unlike
|
||||||
|
// useAircraft/useVessels this is not a Firestore listener: the data is public
|
||||||
|
// and statewide, so c2-core caches it in memory and serves a bbox slice.
|
||||||
|
// Fetches only while `enabled` (the overlay is on), on pan/zoom (debounced),
|
||||||
|
// and on a poll matching the server's events TTL.
|
||||||
|
const POLL_MS = 2 * 60 * 1000;
|
||||||
|
const MOVE_DEBOUNCE_MS = 400;
|
||||||
|
const BBOX_PAD = 0.2; // fetch a little past the edges so small pans don't blank the layer
|
||||||
|
|
||||||
|
export function use511(map: L.Map, layers: "cameras" | "events", enabled: boolean) {
|
||||||
|
const [data, setData] = useState<Ny511Response>({});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const seq = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) { setData({}); return; }
|
||||||
|
let debounce: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const b = map.getBounds().pad(BBOX_PAD);
|
||||||
|
const mine = ++seq.current;
|
||||||
|
try {
|
||||||
|
const res = await c2api.get511(
|
||||||
|
{ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() },
|
||||||
|
layers,
|
||||||
|
);
|
||||||
|
if (mine !== seq.current) return; // a newer pan's response wins
|
||||||
|
setData(res);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
if (mine !== seq.current) return;
|
||||||
|
console.error("use511:", e);
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMove = () => { clearTimeout(debounce); debounce = setTimeout(load, MOVE_DEBOUNCE_MS); };
|
||||||
|
|
||||||
|
load();
|
||||||
|
map.on("moveend", onMove);
|
||||||
|
const poll = setInterval(load, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
map.off("moveend", onMove);
|
||||||
|
clearTimeout(debounce);
|
||||||
|
clearInterval(poll);
|
||||||
|
seq.current++; // drop any in-flight response
|
||||||
|
};
|
||||||
|
}, [map, layers, enabled]);
|
||||||
|
|
||||||
|
return { data, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { collection, onSnapshot, orderBy, query, where, FirestoreError } from "firebase/firestore";
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
import type { AircraftTrailPoint } from "@/lib/types";
|
||||||
|
|
||||||
|
// Trail points live at aircraft/{icao}/positions (written by c2-core
|
||||||
|
// telemetry.py on every position change, TTL-deleted after ~24h). The same
|
||||||
|
// icao can fly several legs a day, so only the latest continuous stretch is
|
||||||
|
// "this flight": a gap longer than FLIGHT_GAP_MS starts a new one.
|
||||||
|
const LOOKBACK_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const FLIGHT_GAP_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
|
function currentFlight(points: AircraftTrailPoint[]): AircraftTrailPoint[] {
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
if (new Date(points[i].t).getTime() - new Date(points[i - 1].t).getTime() > FLIGHT_GAP_MS) start = i;
|
||||||
|
}
|
||||||
|
return points.slice(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live flight path for one aircraft; pass null to subscribe to nothing. */
|
||||||
|
export function useAircraftTrail(icao: string | null) {
|
||||||
|
const [trail, setTrail] = useState<AircraftTrailPoint[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTrail([]);
|
||||||
|
if (!icao) return;
|
||||||
|
// `t` is Python's isoformat() in UTC ("...T17:06:48.755123+00:00"), so it
|
||||||
|
// sorts and range-filters correctly as a string against toISOString()'s
|
||||||
|
// "...T17:06:48.755Z" down to the second — no composite index needed.
|
||||||
|
const since = new Date(Date.now() - LOOKBACK_MS).toISOString();
|
||||||
|
const q = query(collection(db, "aircraft", icao, "positions"), where("t", ">=", since), orderBy("t"));
|
||||||
|
return onSnapshot(
|
||||||
|
q,
|
||||||
|
(snap) => setTrail(currentFlight(snap.docs.map((d) => d.data() as AircraftTrailPoint))),
|
||||||
|
(err: FirestoreError) => console.error("useAircraftTrail:", err),
|
||||||
|
);
|
||||||
|
}, [icao]);
|
||||||
|
|
||||||
|
return trail;
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ const toISO = (v: unknown): string =>
|
|||||||
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
|
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
|
||||||
(typeof v === "string" ? v : new Date().toISOString());
|
(typeof v === "string" ? v : new Date().toISOString());
|
||||||
|
|
||||||
export function useIncidents(limitCount = 100) {
|
export function useIncidents(limitCount = 100, dateFrom?: Date, dateTo?: Date) {
|
||||||
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -20,6 +20,10 @@ export function useIncidents(limitCount = 100) {
|
|||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const { orgId } = useAuth();
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
|
// Stable ms values so the effect dependency doesn't fire on every render
|
||||||
|
const dateFromMs = dateFrom?.getTime();
|
||||||
|
const dateToMs = dateTo?.getTime();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -37,9 +41,18 @@ export function useIncidents(limitCount = 100) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A range on the ordered field rides the existing org_id/started_at index.
|
||||||
|
// Incident started_at is stored as a Python isoformat() STRING
|
||||||
|
// ("2026-09-20T12:00:00.123456+00:00", incident_correlator.py), not a
|
||||||
|
// Firestore timestamp — unlike calls. A Date bound compares by type and
|
||||||
|
// matches nothing, so the bounds go in as UTC ISO strings in the same
|
||||||
|
// shape, which then compare lexicographically in time order.
|
||||||
|
const isoBound = (ms: number) => new Date(ms).toISOString().replace("Z", "+00:00");
|
||||||
const q = query(
|
const q = query(
|
||||||
collection(db, "incidents"),
|
collection(db, "incidents"),
|
||||||
where("org_id", "==", orgId),
|
where("org_id", "==", orgId),
|
||||||
|
...(dateFromMs != null ? [where("started_at", ">=", isoBound(dateFromMs))] : []),
|
||||||
|
...(dateToMs != null ? [where("started_at", "<=", isoBound(dateToMs))] : []),
|
||||||
orderBy("started_at", "desc"),
|
orderBy("started_at", "desc"),
|
||||||
limit(limitCount)
|
limit(limitCount)
|
||||||
);
|
);
|
||||||
@@ -65,7 +78,7 @@ export function useIncidents(limitCount = 100) {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, [limitCount, orgId]);
|
}, [limitCount, dateFromMs, dateToMs, orgId]);
|
||||||
|
|
||||||
return { incidents, loading, error, hasMore };
|
return { incidents, loading, error, hasMore };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"firebase": "^10.12.0",
|
"firebase": "^10.12.0",
|
||||||
|
"hls.js": "^1.5.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-markdown": "^9.0.1"
|
"react-markdown": "^9.0.1"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const tok = (name: string) =>
|
||||||
|
`color-mix(in srgb, var(--${name}) calc(<alpha-value> * 100%), transparent)`;
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
content: [
|
content: [
|
||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
@@ -15,24 +18,27 @@ const config: Config = {
|
|||||||
// Semantic tokens — defined as CSS custom properties in app/globals.css on
|
// Semantic tokens — defined as CSS custom properties in app/globals.css on
|
||||||
// :root (light) and .dark (dark). Components must use these names, never a
|
// :root (light) and .dark (dark). Components must use these names, never a
|
||||||
// raw gray-9xx, so a theme is one variable block rather than an override sheet.
|
// raw gray-9xx, so a theme is one variable block rather than an override sheet.
|
||||||
|
// Wrapped in color-mix so opacity modifiers (bg-surface/90, bg-accent/15)
|
||||||
|
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
|
||||||
|
// class silently generates no rule (map overlays rendered see-through).
|
||||||
colors: {
|
colors: {
|
||||||
page: "var(--page)",
|
page: tok("page"),
|
||||||
surface: "var(--surface)",
|
surface: tok("surface"),
|
||||||
raised: "var(--raised)",
|
raised: tok("raised"),
|
||||||
line: "var(--line)",
|
line: tok("line"),
|
||||||
"line-strong": "var(--line-strong)",
|
"line-strong": tok("line-strong"),
|
||||||
ink: {
|
ink: {
|
||||||
DEFAULT: "var(--ink)",
|
DEFAULT: tok("ink"),
|
||||||
2: "var(--ink-2)",
|
2: tok("ink-2"),
|
||||||
muted: "var(--ink-muted)",
|
muted: tok("ink-muted"),
|
||||||
},
|
},
|
||||||
accent: "var(--accent)",
|
accent: tok("accent"),
|
||||||
"sev-moderate": "var(--sev-moderate)",
|
"sev-moderate": tok("sev-moderate"),
|
||||||
"sev-major": "var(--sev-major)",
|
"sev-major": tok("sev-major"),
|
||||||
"map-bg": "var(--map-bg)",
|
"map-bg": tok("map-bg"),
|
||||||
"map-block": "var(--map-block)",
|
"map-block": tok("map-block"),
|
||||||
"map-road": "var(--map-road)",
|
"map-road": tok("map-road"),
|
||||||
"map-water": "var(--map-water)",
|
"map-water": tok("map-water"),
|
||||||
},
|
},
|
||||||
// Marketing/product type scale — used by the (marketing) surface and
|
// Marketing/product type scale — used by the (marketing) surface and
|
||||||
// settings shell so headings read as a deliberate hierarchy rather than
|
// settings shell so headings read as a deliberate hierarchy rather than
|
||||||
|
|||||||
@@ -77,5 +77,13 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": [
|
||||||
|
{
|
||||||
|
"//": "TTL: flight-trail points (aircraft/{icao}/positions, server-26 telemetry.py) are deleted ~24h after expire_at. indexes: [] because nothing queries on expire_at.",
|
||||||
|
"collectionGroup": "positions",
|
||||||
|
"fieldPath": "expire_at",
|
||||||
|
"ttl": true,
|
||||||
|
"indexes": []
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,11 @@
|
|||||||
// hand-set in the Firebase console: unversioned, unreviewed, unknown. See
|
// hand-set in the Firebase console: unversioned, unreviewed, unknown. See
|
||||||
// SAAS_PLAN.md B1.
|
// SAAS_PLAN.md B1.
|
||||||
//
|
//
|
||||||
// DEPLOY IS A MANUAL, OUT-OF-BAND STEP — nothing in CI or this codebase
|
// DEPLOYED BY CI on every push to main (.gitea/workflows/deploy.yml, job
|
||||||
// pushes these rules to Firebase:
|
// deploy-firestore-rules, service-account auth via the FIREBASE_SA_KEY
|
||||||
// firebase deploy --only firestore:rules --project <project-id>
|
// secret — server-26#51). That job is separate from the app deploy, so a
|
||||||
// (from this directory, or point --config at infra/firestore/firebase.json
|
// green app deploy does NOT mean these rules are live: check that job too.
|
||||||
// from the repo root). Do this before or immediately after the code that
|
// Editing rules in the Firebase console is overwritten by the next push.
|
||||||
// starts stamping org_id ships — until these rules are live, the
|
|
||||||
// console-configured rules are still what's actually enforced.
|
|
||||||
//
|
//
|
||||||
// MODEL: c2-core (firebase-admin SDK, server-side) bypasses these rules
|
// MODEL: c2-core (firebase-admin SDK, server-side) bypasses these rules
|
||||||
// entirely and is the sole writer for every collection below — that was
|
// entirely and is the sole writer for every collection below — that was
|
||||||
@@ -100,6 +98,14 @@ service cloud.firestore {
|
|||||||
match /aircraft/{icao} {
|
match /aircraft/{icao} {
|
||||||
allow read: if docInMyOrg();
|
allow read: if docInMyOrg();
|
||||||
allow write: if false;
|
allow write: if false;
|
||||||
|
|
||||||
|
// Flight trail points. Org is checked against the PARENT aircraft doc
|
||||||
|
// (one get() per query) so the map can query a trail by time alone,
|
||||||
|
// without an org_id filter and the composite index that would need.
|
||||||
|
match /positions/{pointId} {
|
||||||
|
allow read: if inOrg(get(/databases/$(database)/documents/aircraft/$(icao)).data.org_id);
|
||||||
|
allow write: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match /vessels/{mmsi} {
|
match /vessels/{mmsi} {
|
||||||
|
|||||||
Reference in New Issue
Block a user