Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7701b6d49 | ||
|
|
3ae0bb2d5b | ||
|
|
76db41adf7 | ||
|
|
e97dab22ce | ||
|
|
05ddec8284 | ||
|
|
11c98daed0 | ||
|
|
83beb2bf35 | ||
|
|
598054746a | ||
|
|
400b74b519 | ||
|
|
07ff9ba193 | ||
|
|
15a9d10666 | ||
|
|
dd426572fc | ||
|
|
ca1d8fbdae | ||
|
|
7f4d684966 | ||
|
|
bd04bdbd69 | ||
|
|
775244bbde | ||
|
|
bc3251e8df | ||
|
|
7a5bd5dbbb | ||
|
|
629bd1c340 | ||
|
|
cea094d66b | ||
|
|
01c146e21e | ||
|
|
8a0412b529 | ||
|
|
52edbf105c | ||
|
|
77f1d2f93f | ||
|
|
d60fef67ad | ||
|
|
fe643924c7 | ||
|
|
bccb3e0316 | ||
|
|
1a631d65d0 | ||
|
|
3a944f35c1 | ||
|
|
0712e7a437 | ||
|
|
a739fa64f0 | ||
|
|
d67b2057e6 | ||
|
|
c1c3e89e1d | ||
|
|
968134f8ee |
@@ -63,6 +63,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
||||||
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
|
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
|
||||||
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
|
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
|
||||||
|
NEXT_PUBLIC_MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to VM
|
name: Deploy to VM
|
||||||
@@ -96,9 +97,46 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd /opt/drb
|
cd /opt/drb
|
||||||
|
|
||||||
|
# server-26#129: every deploy pushes 3 freshly SHA-tagged images and
|
||||||
|
# nothing ever removed the old ones except a prune that only ran
|
||||||
|
# AFTER a successful `compose pull` -- so a run that never got that
|
||||||
|
# far (this one) left the leak unaddressed forever. That silently
|
||||||
|
# filled the disk to 100% over ~week of deploys (2026-09-12: 29G/29G
|
||||||
|
# used, 96 of 100 local images unreferenced, 23.76GB reclaimable) and
|
||||||
|
# took `git pull` itself down with "No space left on device" before
|
||||||
|
# the deploy could even determine a rollback target. Prune BEFORE
|
||||||
|
# doing anything else, not after: `docker image prune -af` only
|
||||||
|
# removes images with no container referencing them, so it can never
|
||||||
|
# touch what's currently running -- there is nothing here for a
|
||||||
|
# mid-flight deploy to lose. Warn-not-fail: a prune failure must not
|
||||||
|
# block a deploy that doesn't actually need the space this time.
|
||||||
|
docker image prune -af || echo "WARNING: pre-deploy image prune failed (server-26#129) -- disk pressure may persist"
|
||||||
|
|
||||||
# Update compose files + mosquitto config
|
# Update compose files + mosquitto config
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|
||||||
|
# server-26#51: Firestore rules + composite indexes had no deploy
|
||||||
|
# path and regressed silently after every fix (the alert_events and
|
||||||
|
# calls(org_id,started_at) indexes among them). The VM runs as the
|
||||||
|
# project service account, so firebase-tools authenticates via ADC
|
||||||
|
# with no key file, and infra/firestore/firebase.json pins database
|
||||||
|
# c2-server. Indexes go on additively -- no --force -- so a stray
|
||||||
|
# edit to firestore.indexes.json can never delete a live index;
|
||||||
|
# rules are a full replace, which is the intent. --non-interactive
|
||||||
|
# means the FIRST run after a drift still needs a one-time manual
|
||||||
|
# `firebase deploy` on the VM to clear pending deletions (it aborts
|
||||||
|
# rather than guess). A failure here warns but does NOT fail the
|
||||||
|
# deploy: a transient Firebase API error must not roll back a good
|
||||||
|
# app build.
|
||||||
|
if command -v firebase >/dev/null 2>&1; then
|
||||||
|
( cd /opt/drb/infra/firestore \
|
||||||
|
&& firebase deploy --only firestore:rules,firestore:indexes \
|
||||||
|
--project ${{ secrets.FIREBASE_PROJECT_ID }} --non-interactive ) \
|
||||||
|
|| echo "WARNING: firestore deploy failed (server-26#51) -- rules/indexes may be stale"
|
||||||
|
else
|
||||||
|
echo "WARNING: firebase CLI not on the VM -- skipped firestore deploy (server-26#51); install once with: npm i -g firebase-tools"
|
||||||
|
fi
|
||||||
|
|
||||||
# server-26#65: capture what is actually live BEFORE switching, so
|
# server-26#65: capture what is actually live BEFORE switching, so
|
||||||
# a bad deploy has something concrete to fall back to. This reads
|
# a bad deploy has something concrete to fall back to. This reads
|
||||||
# from a state file rather than re-deriving it from git log,
|
# from a state file rather than re-deriving it from git log,
|
||||||
@@ -136,7 +174,12 @@ jobs:
|
|||||||
$COMPOSE pull
|
$COMPOSE pull
|
||||||
fi
|
fi
|
||||||
$COMPOSE up -d --remove-orphans
|
$COMPOSE up -d --remove-orphans
|
||||||
docker image prune -f
|
# server-26#129: -f alone only removes dangling (untagged) images --
|
||||||
|
# the SHA-tagged image from every PAST deploy is not dangling, just
|
||||||
|
# unreferenced once `up -d` swaps the running container to the new
|
||||||
|
# tag, so it survived this indefinitely. -a catches those too; see
|
||||||
|
# the pre-pull prune above for why this can't touch anything live.
|
||||||
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
)
|
)
|
||||||
echo "$OUTPUT"
|
echo "$OUTPUT"
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ SUMMARY_INTERVAL_MINUTES=15
|
|||||||
CORRELATION_WINDOW_HOURS=4
|
CORRELATION_WINDOW_HOURS=4
|
||||||
EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
||||||
|
|
||||||
|
# Browser origins allowed to call this API cross-origin (JSON list). The only
|
||||||
|
# browser caller is the frontend's Archive page (GET /calls/search). Set this
|
||||||
|
# to the exact origin the frontend is served from — scheme + host, no path.
|
||||||
|
# Defaults to https://drb.cusano.net. A "*" entry works for local dev but is
|
||||||
|
# logged as a probable misconfiguration and never gets a credentialed response.
|
||||||
|
CORS_ORIGINS=["https://drb.cusano.net"]
|
||||||
|
|
||||||
# Fleet-wide token edge nodes present as X-Enrollment-Token on first boot
|
# Fleet-wide token edge nodes present as X-Enrollment-Token on first boot
|
||||||
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
|
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ class Settings(BaseSettings):
|
|||||||
# Across that dump every correct thin attach was <= 3.4 min idle and every wrong
|
# Across that dump every correct thin attach was <= 3.4 min idle and every wrong
|
||||||
# one was >= 8.2, so 5 separates them with room on both sides. Genuine
|
# one was >= 8.2, so 5 separates them with room on both sides. Genuine
|
||||||
# back-and-forth is handled by the 30-second tier-1 path above this.
|
# back-and-forth is handled by the 30-second tier-1 path above this.
|
||||||
|
# Second consumer (server-26#115): routers/upload.py's LLM-orphan-gate escape
|
||||||
|
# hatch (_recent_incident_on_same_talkgroup) reuses this same value, selected
|
||||||
|
# the same way (dispatch vs tactical) via _is_dispatch_channel. Retuning this
|
||||||
|
# for fast/thin reasons moves that gate's behavior too — check both call
|
||||||
|
# sites before changing it.
|
||||||
tg_dispatch_thin_idle_minutes: int = 5
|
tg_dispatch_thin_idle_minutes: int = 5
|
||||||
# Every other channel: tier-2 thin calls attach to a lone candidate idle < this.
|
# Every other channel: tier-2 thin calls attach to a lone candidate idle < this.
|
||||||
# Non-dispatch talkgroups previously had NO tier-2 bound at all — they used the
|
# Non-dispatch talkgroups previously had NO tier-2 bound at all — they used the
|
||||||
@@ -180,16 +185,18 @@ class Settings(BaseSettings):
|
|||||||
# between genuinely separate transmissions on a busy dispatch channel.
|
# between genuinely separate transmissions on a busy dispatch channel.
|
||||||
duplicate_window_seconds: int = 10
|
duplicate_window_seconds: int = 10
|
||||||
|
|
||||||
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
# Browser origins allowed to call this API cross-origin. The only browser
|
||||||
# Defaults to "*" for local development only.
|
# caller is the frontend's Archive page (GET /calls/search) — every other
|
||||||
|
# page reads Firestore directly. The frontend is served on the BARE domain
|
||||||
|
# (see infra Caddyfile.j2 — only drb. and api. have DNS records), so the
|
||||||
|
# default is that origin, not app.<domain>. Override via CORS_ORIGINS (JSON
|
||||||
|
# list) if the frontend ever moves; keep infra/.../c2-core.env.j2 in sync.
|
||||||
#
|
#
|
||||||
# Leaving this as "*" is not merely permissive: main.py turns OFF
|
# A "*" entry here still works for local dev but is refused a credentialed
|
||||||
# allow_credentials when it sees a wildcard, because Starlette would
|
# response: main.py never enables allow_credentials (auth is a Bearer
|
||||||
# otherwise reflect each caller's origin back WITH
|
# header, not a cookie), and it logs a loud ERROR when it sees a wildcard
|
||||||
# Access-Control-Allow-Credentials. So a production deployment that
|
# in a deployment so a forgotten override is visible.
|
||||||
# forgets to set this gets a loud ERROR at startup and loses credentialed
|
cors_origins: list[str] = ["https://drb.cusano.net"]
|
||||||
# cross-origin requests, rather than silently accepting every origin.
|
|
||||||
cors_origins: list[str] = ["*"]
|
|
||||||
|
|
||||||
# Discord webhook URL that app/internal/ai_health.py posts to when an AI
|
# Discord webhook URL that app/internal/ai_health.py posts to when an AI
|
||||||
# tier (transcription/correlation) transitions into or out of degraded
|
# tier (transcription/correlation) transitions into or out of degraded
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
Upstream dispatch-vs-chatter classifier — SHADOW MODE (server-26#115 follow-up).
|
||||||
|
|
||||||
|
Three live measurement windows (CORRELATION_REVIEW_0907.md, _0907b.md, _0912.md)
|
||||||
|
and two consensus-layer fixes (#125, #126) all converged on the same conclusion:
|
||||||
|
the actual non-event-promotion problem lives upstream of correlation entirely.
|
||||||
|
Radio housekeeping — unit check-ins, roll call, bare 10-4/10-8/98 acknowledgements
|
||||||
|
— has no incident content for `intelligence.extract_scenes` to find, but nothing
|
||||||
|
stops it from being sent to the scene-extraction LLM and coming out the other end
|
||||||
|
as a thin "scene" for the correlator to then judge. See CORRELATION_REVIEW_0912.md
|
||||||
|
("Reminder: the real fix is still unscoped") and issue #115.
|
||||||
|
|
||||||
|
This module is that classifier. It is a PURE function of the transcript text —
|
||||||
|
no Firestore, no LLM call, no side effects — so it is cheap to run on every
|
||||||
|
transcript and cheap to test against real dumps offline.
|
||||||
|
|
||||||
|
SHADOW MODE ONLY. As of this module's introduction, nothing skips scene
|
||||||
|
extraction based on this verdict. `intelligence.extract_scenes` calls
|
||||||
|
`classify_chatter` purely to record the verdict on the call doc
|
||||||
|
(`chatter_classifier_verdict` / `chatter_classifier_reason`) so it becomes
|
||||||
|
observable in the next `/admin` correlation-debug dump, exactly like
|
||||||
|
`corr_gate_veto` (server-26#115 / PR #126). See the TODO at that call site for
|
||||||
|
what has to be true before this flips live.
|
||||||
|
|
||||||
|
Precision over recall, deliberately. A false positive here — flagging a REAL
|
||||||
|
event as chatter — would, once live, silently mean that event never gets a
|
||||||
|
scene, never gets tags/location/severity, and never has a chance to become an
|
||||||
|
incident. That is a much bigger, harder-to-notice failure than a false
|
||||||
|
negative (a housekeeping call that still goes through the existing expensive
|
||||||
|
pipeline and gets judged "not an incident" the same way it is today). When a
|
||||||
|
transcript doesn't clearly match one of the shapes below, this returns
|
||||||
|
(False, None) and the existing pipeline runs exactly as it does today.
|
||||||
|
|
||||||
|
Patterns are drawn from hand-labeled examples in CORRELATION_REVIEW_0907b.md
|
||||||
|
and CORRELATION_REVIEW_0912.md, cross-referenced against the real transcripts
|
||||||
|
in corr_dump_9-7_0437am.json / corr_dump_9-7_pm.json / corr_dump_9-12.json —
|
||||||
|
not invented regexes. See the backtest script referenced in the PR for the
|
||||||
|
per-dump catch rate and false-positive count.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Police/law-enforcement phonetic alphabet words (APCO + NATO). Deliberately
|
||||||
|
# duplicated from intelligence.py's `_PHONETIC_ALPHA_WORDS` rather than
|
||||||
|
# imported — intelligence.py imports this module (to write the shadow-mode
|
||||||
|
# verdict onto the call doc), so importing back would be circular. Keep the
|
||||||
|
# two sets in sync if either changes; they're small and rarely touched.
|
||||||
|
_PHONETIC_ALPHA_WORDS = frozenset({
|
||||||
|
# APCO (law enforcement)
|
||||||
|
"adam", "baker", "charles", "david", "edward", "frank", "george", "henry",
|
||||||
|
"ida", "john", "king", "lincoln", "mary", "nora", "ocean", "paul", "queen",
|
||||||
|
"robert", "sam", "tom", "union", "victor", "william", "x-ray", "young", "zebra",
|
||||||
|
# NATO
|
||||||
|
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
|
||||||
|
"india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa",
|
||||||
|
"quebec", "romeo", "sierra", "tango", "uniform", "whiskey", "yankee", "zulu",
|
||||||
|
})
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9\-]*")
|
||||||
|
|
||||||
|
# Bare radio-procedure words that carry zero incident content by themselves.
|
||||||
|
# Deliberately small and literal — this is not a general stopword list, it's
|
||||||
|
# the exact vocabulary observed in hand-labeled chatter transcripts. Words
|
||||||
|
# that are ambiguous outside a pure-procedure context (e.g. "location",
|
||||||
|
# "call", "phone", "number", "go") are left OUT on purpose: including them
|
||||||
|
# risks reducing a real, substantive transcript down to nothing.
|
||||||
|
_FILLER_WORDS = frozenset({
|
||||||
|
"to", "this", "is", "the", "a", "and", "for", "you", "can", "i", "in",
|
||||||
|
"on", "of", "that", "just", "from", "out", "ok", "okay", "at", "be",
|
||||||
|
"show", "me", "mark", "marked", "charge", "standby", "stand", "by",
|
||||||
|
"clear", "available", "affirm", "affirmative", "negative", "copy",
|
||||||
|
"copies", "received", "roger",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Agency/procedural designators — who's being addressed, not what happened.
|
||||||
|
_RADIO_DESIGNATORS = frozenset({
|
||||||
|
"central", "dispatch", "headquarters", "hq", "post", "unit", "sergeant",
|
||||||
|
"sgt", "metro", "mta", "division", "county",
|
||||||
|
})
|
||||||
|
|
||||||
|
_ROLL_CALL_RE = re.compile(r"\broll\s*call\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(transcript: str) -> list[str]:
|
||||||
|
return _TOKEN_RE.findall(transcript.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_filler_token(token: str) -> bool:
|
||||||
|
# Any token starting with a digit is a unit ID, 10-code, badge/post
|
||||||
|
# number, or call-number fragment ("10-4", "6-8", "72-holland",
|
||||||
|
# "11-victor", "98", "114") — procedural, not incident content. This is
|
||||||
|
# deliberately broad: a real event transcript that happens to include a
|
||||||
|
# digit-led token (an address number, a case number) still has other,
|
||||||
|
# non-digit descriptive words left over, so this alone never reduces a
|
||||||
|
# real transcript to nothing. See the backtest for confirmation.
|
||||||
|
if token[0].isdigit():
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
token in _FILLER_WORDS
|
||||||
|
or token in _RADIO_DESIGNATORS
|
||||||
|
or token in _PHONETIC_ALPHA_WORDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_chatter(transcript: Optional[str]) -> tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Pure classification of a transcript as non-event radio housekeeping.
|
||||||
|
|
||||||
|
Returns (is_chatter, reason):
|
||||||
|
(True, "roll_call") — contains a roll-call announcement
|
||||||
|
(True, "bare_acknowledgement") — every token is a callsign/10-code/
|
||||||
|
procedural filler word; nothing else
|
||||||
|
(False, None) — not confidently chatter; let the
|
||||||
|
existing pipeline run as today
|
||||||
|
|
||||||
|
Takes only the transcript. Other call metadata (talkgroup, severity, tags)
|
||||||
|
doesn't exist yet at the point this needs to run — this classifier is
|
||||||
|
upstream of the scene-extraction call that produces those fields — so it
|
||||||
|
deliberately doesn't take them as input.
|
||||||
|
"""
|
||||||
|
if not transcript or not transcript.strip():
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
lowered = transcript.lower()
|
||||||
|
if _ROLL_CALL_RE.search(lowered):
|
||||||
|
return True, "roll_call"
|
||||||
|
|
||||||
|
tokens = _tokenize(transcript)
|
||||||
|
if not tokens:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
if any(not _is_filler_token(t) for t in tokens):
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
return True, "bare_acknowledgement"
|
||||||
@@ -91,6 +91,13 @@ def _max_severity(current: Optional[str], new: Optional[str]) -> str:
|
|||||||
_MAX_PURSUIT_SPEED_KM_PER_MIN = 8.0 # ~300 km/h, intentionally generous
|
_MAX_PURSUIT_SPEED_KM_PER_MIN = 8.0 # ~300 km/h, intentionally generous
|
||||||
_PURSUIT_PROXIMITY_KM = 20.0 # expanded radius for moving incidents
|
_PURSUIT_PROXIMITY_KM = 20.0 # expanded radius for moving incidents
|
||||||
|
|
||||||
|
# server-26#115 — the location path linked on `location_proximity_km` (0.5 km)
|
||||||
|
# alone, with no unit or content check. In a dense village two unrelated events
|
||||||
|
# routinely geocode that close (a vehicle lockout stitched to a station-restroom
|
||||||
|
# slip; two different churches an hour apart). A location link now needs unit
|
||||||
|
# overlap with the candidate OR a distance under this tighter bar.
|
||||||
|
_LOCATION_TIGHT_PROXIMITY_KM = 0.2
|
||||||
|
|
||||||
_DISPATCH_TG_RE = re.compile(
|
_DISPATCH_TG_RE = re.compile(
|
||||||
r"\bdispatch\b|\bdisp\b"
|
r"\bdispatch\b|\bdisp\b"
|
||||||
r"|\bpatched\b" # patched channels aggregate multiple call streams
|
r"|\bpatched\b" # patched channels aggregate multiple call streams
|
||||||
@@ -108,16 +115,31 @@ _ROAD_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Street-type synonyms collapsed to one token so "Mohegan Park Avenue" and
|
||||||
|
# "Mohegan Park Ave" produce the same road id (server-26#115 — that one
|
||||||
|
# difference was splitting a car-alarm incident into two).
|
||||||
|
_ROAD_SUFFIX_CANON = {
|
||||||
|
"avenue": "ave", "street": "st", "road": "rd", "drive": "dr",
|
||||||
|
"boulevard": "blvd", "lane": "ln", "court": "ct", "place": "pl",
|
||||||
|
"highway": "hwy", "parkway": "pkwy",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_road_ids(text: str) -> set[str]:
|
def _extract_road_ids(text: str) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Extract normalised road/route identifiers from a location string.
|
Extract normalised road/route identifiers from a location string.
|
||||||
e.g. "suspect east on Route 202" → {"route 202"}
|
e.g. "suspect east on Route 202" → {"route 202"}
|
||||||
"at Main Street and Oak Ave" → {"main street", "oak ave"}
|
"at Main Street and Oak Ave" → {"main st", "oak ave"}
|
||||||
"""
|
"""
|
||||||
return {
|
ids: set[str] = set()
|
||||||
re.sub(r"[\s.\-]+", " ", m.group().lower()).strip()
|
for m in _ROAD_RE.finditer(text):
|
||||||
for m in _ROAD_RE.finditer(text)
|
key = re.sub(r"[\s.\-]+", " ", m.group().lower()).strip()
|
||||||
}
|
parts = key.split()
|
||||||
|
if parts and parts[-1] in _ROAD_SUFFIX_CANON:
|
||||||
|
parts[-1] = _ROAD_SUFFIX_CANON[parts[-1]]
|
||||||
|
key = " ".join(parts)
|
||||||
|
ids.add(key)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def _location_mentions_road_overlap(new_location: str, inc_mentions: list[str]) -> bool:
|
def _location_mentions_road_overlap(new_location: str, inc_mentions: list[str]) -> bool:
|
||||||
@@ -224,6 +246,22 @@ def _matching_units(call_units: Optional[list[str]], inc_units: Optional[list[st
|
|||||||
return [u for u in (call_units or []) if _normalize_unit(u) in inc_keys]
|
return [u for u in (call_units or []) if _normalize_unit(u) in inc_keys]
|
||||||
|
|
||||||
|
|
||||||
|
def has_event_substance(ctx: dict) -> bool:
|
||||||
|
"""
|
||||||
|
True when the call carries content beyond who-was-speaking-and-where:
|
||||||
|
a vehicle, a geocode, or a tag.
|
||||||
|
|
||||||
|
This is the substance half of the incident-creation gate (see
|
||||||
|
`_run_decision`, "Severity, not type, decides..."), factored out so the
|
||||||
|
consensus LLM-orphan gate in routers/upload.py mirrors it exactly and can
|
||||||
|
never drop a call the creation gate would have opened. `call_units` and
|
||||||
|
`location` are deliberately excluded — radio protocol puts a unit ID and a
|
||||||
|
place name in almost every transmission, so counting them as substance
|
||||||
|
makes the check trivially true.
|
||||||
|
"""
|
||||||
|
return bool(ctx.get("call_vehicles") or ctx.get("coords") or ctx.get("tags"))
|
||||||
|
|
||||||
|
|
||||||
def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
|
def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
|
||||||
"""Return an incident type inferred from tags, or None if ambiguous."""
|
"""Return an incident type inferred from tags, or None if ambiguous."""
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
@@ -1158,6 +1196,10 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
|
|
||||||
# ── 2. Location path: proximity match (time-limited, cross-type) ─────────
|
# ── 2. Location path: proximity match (time-limited, cross-type) ─────────
|
||||||
if not matched_incident and coords:
|
if not matched_incident and coords:
|
||||||
|
# server-26#115 — score every in-radius candidate and link the NEAREST
|
||||||
|
# that carries corroboration, rather than whichever incident happened to
|
||||||
|
# come first in an unsorted `recent`.
|
||||||
|
loc_candidates: list[tuple] = []
|
||||||
for inc in recent:
|
for inc in recent:
|
||||||
inc_coords = inc.get("location_coords")
|
inc_coords = inc.get("location_coords")
|
||||||
if not inc_coords:
|
if not inc_coords:
|
||||||
@@ -1174,18 +1216,49 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
elapsed_min = max(_incident_idle_minutes(inc, now), 0.1)
|
elapsed_min = max(_incident_idle_minutes(inc, now), 0.1)
|
||||||
if (dist_km / elapsed_min) > _MAX_PURSUIT_SPEED_KM_PER_MIN:
|
if (dist_km / elapsed_min) > _MAX_PURSUIT_SPEED_KM_PER_MIN:
|
||||||
continue # implausible speed — skip this candidate
|
continue # implausible speed — skip this candidate
|
||||||
if dist_km <= radius:
|
if dist_km > radius:
|
||||||
matched_incident = inc
|
continue
|
||||||
corr_debug = {
|
# server-26#115 — a bare sub-radius distance is not enough on its
|
||||||
"corr_path": "location",
|
# own. Require corroboration: unit overlap with the candidate, OR
|
||||||
"corr_distance_km": round(dist_km, 3),
|
# a much tighter proximity. Pursuit incidents keep their
|
||||||
"corr_pursuit_mode": is_pursuit_inc,
|
# movement-speed-validated wide radius (they passed the speed
|
||||||
}
|
# check above), so they are exempt.
|
||||||
|
unit_overlap = bool(
|
||||||
|
_unit_keys(call_units) & _unit_keys(inc.get("units"))
|
||||||
|
)
|
||||||
|
tight_proximity = dist_km <= _LOCATION_TIGHT_PROXIMITY_KM
|
||||||
|
if not (is_pursuit_inc or unit_overlap or tight_proximity):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator location-path: call {call_id} → {inc['incident_id']} "
|
f"Correlator location-path skipped: call {call_id} vs "
|
||||||
f"(dist={dist_km:.2f}km, pursuit={is_pursuit_inc})"
|
f"{inc['incident_id']} — dist={dist_km:.2f}km within radius "
|
||||||
|
f"but no unit overlap and not tight-proximity "
|
||||||
|
f"(<= {_LOCATION_TIGHT_PROXIMITY_KM}km)"
|
||||||
)
|
)
|
||||||
break
|
continue
|
||||||
|
loc_candidates.append((dist_km, unit_overlap, is_pursuit_inc, inc))
|
||||||
|
|
||||||
|
if loc_candidates:
|
||||||
|
loc_candidates.sort(key=lambda c: c[0])
|
||||||
|
dist_km, unit_overlap, is_pursuit_inc, inc = loc_candidates[0]
|
||||||
|
matched_incident = inc
|
||||||
|
# Distinct from the fast path's "unit_overlap" so the admin
|
||||||
|
# corr_fit_signal histogram (routers/admin.py) does not merge a
|
||||||
|
# location-path link into the fast-path bucket (#35).
|
||||||
|
fit_signal = "location_unit_overlap" if unit_overlap else "location_proximity"
|
||||||
|
corr_debug = {
|
||||||
|
"corr_path": "location",
|
||||||
|
"corr_distance_km": round(dist_km, 3),
|
||||||
|
"corr_pursuit_mode": is_pursuit_inc,
|
||||||
|
"corr_fit_signal": fit_signal,
|
||||||
|
}
|
||||||
|
if unit_overlap and call_units:
|
||||||
|
corr_debug["corr_matched_units"] = _matching_units(
|
||||||
|
call_units, inc.get("units")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Correlator location-path: call {call_id} → {inc['incident_id']} "
|
||||||
|
f"(dist={dist_km:.2f}km, pursuit={is_pursuit_inc}, signal={fit_signal})"
|
||||||
|
)
|
||||||
|
|
||||||
# ── 2.5. Cross-TG path: same department, overlapping units, moderate similarity ──
|
# ── 2.5. Cross-TG path: same department, overlapping units, moderate similarity ──
|
||||||
#
|
#
|
||||||
@@ -1322,7 +1395,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
# each. A vehicle, a geocode, or a tag means the extractor found something
|
# each. A vehicle, a geocode, or a tag means the extractor found something
|
||||||
# beyond who was speaking and where they stood.
|
# beyond who was speaking and where they stood.
|
||||||
if not resolved_type:
|
if not resolved_type:
|
||||||
has_substance = bool(call_vehicles or coords or tags)
|
has_substance = has_event_substance(ctx)
|
||||||
if call_severity in ("minor", "moderate", "major") or has_substance:
|
if call_severity in ("minor", "moderate", "major") or has_substance:
|
||||||
resolved_type = "other"
|
resolved_type = "other"
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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 area_context
|
from app.internal import area_context
|
||||||
|
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
|
||||||
# location/pin invariant. incident_correlator does not import this module, so
|
# location/pin invariant. incident_correlator does not import this module, so
|
||||||
# this is not a cycle.
|
# this is not a cycle.
|
||||||
@@ -199,6 +200,28 @@ async def extract_scenes(
|
|||||||
pass
|
pass
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# server-26#127 — SHADOW MODE ONLY. Computes whether this transcript looks
|
||||||
|
# like non-event radio housekeeping (roll call, bare 10-4/10-8/98
|
||||||
|
# acknowledgements, unit check-ins) and records the verdict on the call
|
||||||
|
# doc, but does NOT skip extraction anywhere below — every path runs
|
||||||
|
# exactly as it did before this landed. Deliberately ahead of the ≤5-word
|
||||||
|
# skip: most bare acknowledgements ARE ≤5 words, and the first pass of
|
||||||
|
# this feature put the classifier after that return, so it never saw the
|
||||||
|
# bulk of its own target population — a review backtest against three
|
||||||
|
# live dumps found 82% of what it would have flagged already exits above
|
||||||
|
# as transcript_too_short, meaning a shadow-mode window would have shown
|
||||||
|
# roughly a fifth of the real catch rate. Computing it once, here, and
|
||||||
|
# folding the result into whichever skip/continue path runs below fixes
|
||||||
|
# that without adding a second Firestore write.
|
||||||
|
# TODO(server-26#127): flip this from shadow to live (skip extraction and
|
||||||
|
# write skip_reason="non_event_chatter" instead of just recording the
|
||||||
|
# verdict) once a live shadow-mode window confirms 0 false positives on
|
||||||
|
# real production traffic — pay particular attention to whole-transcript
|
||||||
|
# vs contains-anywhere matching for "roll call" and to digit-hyphen street
|
||||||
|
# addresses (e.g. "72-Holland"), both flagged as classifier risks that the
|
||||||
|
# dump backtest could not surface on its own.
|
||||||
|
chatter_is_chatter, chatter_reason = classify_chatter(transcript)
|
||||||
|
|
||||||
# Transcripts with ≤5 words carry no extractable intelligence — GPT hallucinates
|
# Transcripts with ≤5 words carry no extractable intelligence — GPT hallucinates
|
||||||
# units and tags from thin context (e.g. "Main Lot", "10-4", "David").
|
# units and tags from thin context (e.g. "Main Lot", "10-4", "David").
|
||||||
if len(transcript.split()) <= 5:
|
if len(transcript.split()) <= 5:
|
||||||
@@ -213,11 +236,21 @@ async def extract_scenes(
|
|||||||
await fstore.doc_set("calls", call_id, {
|
await fstore.doc_set("calls", call_id, {
|
||||||
"skip_reason": "transcript_too_short",
|
"skip_reason": "transcript_too_short",
|
||||||
"severity": "routine",
|
"severity": "routine",
|
||||||
|
"chatter_classifier_verdict": chatter_is_chatter,
|
||||||
|
"chatter_classifier_reason": chatter_reason,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
await fstore.doc_set("calls", call_id, {
|
||||||
|
"chatter_classifier_verdict": chatter_is_chatter,
|
||||||
|
"chatter_classifier_reason": chatter_reason,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
raw_scenes: list[dict] = await asyncio.to_thread(
|
raw_scenes: list[dict] = await asyncio.to_thread(
|
||||||
_sync_extract,
|
_sync_extract,
|
||||||
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
||||||
|
|||||||
@@ -45,7 +45,18 @@ def _fmt_idle(inc: dict, now: datetime) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _inc_summary(inc: dict, now: datetime) -> str:
|
def _inc_summary(inc: dict, now: datetime) -> str:
|
||||||
|
# server-26#115: the model was given no title and no talkgroup, so it
|
||||||
|
# could not tell that "car alarms, Mohegan Park Ave" and "car alarms,
|
||||||
|
# Mohegan Park Avenue" on the same channel were one incident — it defaulted
|
||||||
|
# to "new". Title is the single strongest human-readable signal for "is
|
||||||
|
# this the same event"; talkgroup is what makes same-channel continuation
|
||||||
|
# obvious.
|
||||||
parts = [f"id:{inc['incident_id']}", f"type:{inc.get('type') or '?'}"]
|
parts = [f"id:{inc['incident_id']}", f"type:{inc.get('type') or '?'}"]
|
||||||
|
tgs = inc.get("talkgroup_ids") or []
|
||||||
|
if tgs:
|
||||||
|
parts.append(f"tg:[{', '.join(str(t) for t in tgs[:3])}]")
|
||||||
|
if inc.get("title"):
|
||||||
|
parts.append(f"title:{inc['title']!r}")
|
||||||
if inc.get("location"):
|
if inc.get("location"):
|
||||||
parts.append(f"loc:{inc['location']}")
|
parts.append(f"loc:{inc['location']}")
|
||||||
units = inc.get("units") or []
|
units = inc.get("units") or []
|
||||||
@@ -80,19 +91,50 @@ def _call_block(ctx: dict) -> str:
|
|||||||
lines.append(f"Units: {ctx['call_units']}")
|
lines.append(f"Units: {ctx['call_units']}")
|
||||||
if ctx["call_vehicles"]:
|
if ctx["call_vehicles"]:
|
||||||
lines.append(f"Vehicles: {ctx['call_vehicles']}")
|
lines.append(f"Vehicles: {ctx['call_vehicles']}")
|
||||||
if ctx["talkgroup_name"]:
|
if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None:
|
||||||
lines.append(f"Talkgroup: {ctx['talkgroup_name']}")
|
# Both the name and the id — _inc_summary emits numeric tg ids, so the
|
||||||
|
# id is what makes the "same talkgroup" rule in _RULES evaluable
|
||||||
|
# (server-26#115 review).
|
||||||
|
tgid = ctx.get("talkgroup_id")
|
||||||
|
name = ctx["talkgroup_name"] or "?"
|
||||||
|
lines.append(f"Talkgroup: {name}" + (f" (id {tgid})" if tgid is not None else ""))
|
||||||
return "\n".join(lines) if lines else "(no details)"
|
return "\n".join(lines) if lines else "(no details)"
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_incidents(recent: list[dict]) -> list[dict]:
|
||||||
|
"""The ≤20 candidates shown to the model, most-recently-active first.
|
||||||
|
|
||||||
|
`ctx["recent"]` is an unordered slice of a Firestore result with no
|
||||||
|
order_by, so a busy 2h window (~40 active incidents) meant the model saw
|
||||||
|
an arbitrary half of the candidates (server-26#115 review). Sorting by
|
||||||
|
updated_at desc also makes each row's `idle:` field monotonic.
|
||||||
|
"""
|
||||||
|
def _key(inc: dict):
|
||||||
|
return str(inc.get("updated_at") or inc.get("started_at") or "")
|
||||||
|
return sorted(recent, key=_key, reverse=True)[:20]
|
||||||
|
|
||||||
|
|
||||||
_SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}'
|
_SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}'
|
||||||
|
|
||||||
_RULES = """
|
_RULES = """
|
||||||
Rules:
|
Rules (this system OVER-SPLITS — a real incident routinely gets shattered into
|
||||||
- "link" only with clear positive evidence: same units, same geocoded location, or semantically identical scene on the same talkgroup within the last few minutes.
|
5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure
|
||||||
- A call on a DIFFERENT talkgroup than an incident requires unit overlap or geocoded location match — topic similarity alone is not enough.
|
mode. Bias accordingly.):
|
||||||
- "new" only if the call has a clear incident_type AND describes a distinct, identifiable scene.
|
- Prefer "link" when the call plausibly continues a recent incident ON THE SAME
|
||||||
- "orphan" when in doubt — conservative is always correct.
|
TALKGROUP: same or overlapping units, the same or an adjacent location (treat
|
||||||
|
"Ave"/"Avenue", "St"/"Street", "Rd"/"Road" as identical; a house number plus
|
||||||
|
the same street is the same place), the same subject/vehicle/case number, or a
|
||||||
|
follow-up beat ("units clearing", "negative contact", "tow en route", "event
|
||||||
|
number 214-201", a status update) to an incident that is only a few minutes
|
||||||
|
idle. The bar for "link" on the same talkgroup is LOW.
|
||||||
|
- Reserve "new" for a call that clearly describes a DIFFERENT event from every
|
||||||
|
recent incident — a different place, different units, and a different subject,
|
||||||
|
not merely a different transmission about the same job.
|
||||||
|
- "orphan" a call that is not an incident at all: radio checks, roll call,
|
||||||
|
a unit marking on/off duty or 10-8/10-98, mileage/log entries, a bare
|
||||||
|
acknowledgement. Do not open a "new" incident for these.
|
||||||
|
- A call on a DIFFERENT talkgroup than an incident still requires unit overlap
|
||||||
|
or a geocoded/location match — topic similarity alone is not enough there.
|
||||||
- Do NOT link just because both calls involve police or both mention a road.
|
- Do NOT link just because both calls involve police or both mention a road.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -101,7 +143,7 @@ def _build_decide_prompt(ctx: dict) -> str:
|
|||||||
now = ctx["now"]
|
now = ctx["now"]
|
||||||
recent = ctx["recent"]
|
recent = ctx["recent"]
|
||||||
inc_block = (
|
inc_block = (
|
||||||
"\n".join(_inc_summary(inc, now) for inc in recent[:20])
|
"\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
|
||||||
if recent else "(none)"
|
if recent else "(none)"
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
@@ -119,7 +161,7 @@ def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict)
|
|||||||
now = ctx["now"]
|
now = ctx["now"]
|
||||||
recent = ctx["recent"]
|
recent = ctx["recent"]
|
||||||
inc_block = (
|
inc_block = (
|
||||||
"\n".join(_inc_summary(inc, now) for inc in recent[:20])
|
"\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
|
||||||
if recent else "(none)"
|
if recent else "(none)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,22 @@ from app.internal.logger import logger
|
|||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
# Standard link-only retry budget before a call is tombstoned corr_path="unlinked".
|
||||||
|
MAX_SWEEP_ATTEMPTS = 3
|
||||||
|
# server-26#115 — a call the consensus LLM-orphan gate parked (llm=orphan vs
|
||||||
|
# rules=new, no substance) gets a longer budget. The gate fires before any
|
||||||
|
# incident for the job may exist, so the substantive call that would justify
|
||||||
|
# linking can land well after the standard ~6 min. Still link-only: a genuinely
|
||||||
|
# thin call must not mint an incident, and the rules creation gate would re-orphan
|
||||||
|
# it anyway.
|
||||||
|
GATED_ORPHAN_SWEEP_ATTEMPTS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _max_sweep_attempts(call: dict) -> int:
|
||||||
|
if call.get("corr_consensus") == "llm_orphan_gate":
|
||||||
|
return GATED_ORPHAN_SWEEP_ATTEMPTS
|
||||||
|
return MAX_SWEEP_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
async def recorrelation_loop() -> None:
|
async def recorrelation_loop() -> None:
|
||||||
interval = settings.summary_interval_minutes * 60
|
interval = settings.summary_interval_minutes * 60
|
||||||
@@ -46,10 +62,9 @@ async def _run_sweep_pass() -> None:
|
|||||||
("status", "==", "ended"),
|
("status", "==", "ended"),
|
||||||
("ended_at", ">=", cutoff),
|
("ended_at", ">=", cutoff),
|
||||||
])
|
])
|
||||||
# corr_path="unlinked" is written after MAX_SWEEP_ATTEMPTS failures.
|
# corr_path="unlinked" is written after the attempt budget is exhausted.
|
||||||
# Allows a few retries so a welfare-check call can link to an escalation
|
# Allows a few retries so a welfare-check call can link to an escalation
|
||||||
# incident that is created a few minutes later, without sweeping 30× forever.
|
# incident that is created a few minutes later, without sweeping 30× forever.
|
||||||
MAX_SWEEP_ATTEMPTS = 3
|
|
||||||
orphans = [
|
orphans = [
|
||||||
c for c in recent_ended
|
c for c in recent_ended
|
||||||
if not c.get("incident_ids") and not c.get("incident_id")
|
if not c.get("incident_ids") and not c.get("incident_id")
|
||||||
@@ -61,7 +76,7 @@ async def _run_sweep_pass() -> None:
|
|||||||
# the thin path minutes later and attached to whatever was most recent —
|
# the thin path minutes later and attached to whatever was most recent —
|
||||||
# a second route into the over-merge the thin fix above addresses.
|
# a second route into the over-merge the thin fix above addresses.
|
||||||
and not c.get("skip_reason")
|
and not c.get("skip_reason")
|
||||||
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS
|
and c.get("corr_sweep_count", 0) < _max_sweep_attempts(c)
|
||||||
]
|
]
|
||||||
|
|
||||||
if not orphans:
|
if not orphans:
|
||||||
@@ -120,12 +135,12 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Increment the attempt counter. Once MAX_SWEEP_ATTEMPTS is reached the
|
# Increment the attempt counter. Once the budget is reached the orphan filter
|
||||||
# orphan filter above will stop picking this call up, and we write
|
# above will stop picking this call up, and we write corr_path="unlinked" as
|
||||||
# corr_path="unlinked" as a permanent tombstone.
|
# a permanent tombstone.
|
||||||
attempts = call.get("corr_sweep_count", 0) + 1
|
attempts = call.get("corr_sweep_count", 0) + 1
|
||||||
update: dict = {"corr_sweep_count": attempts}
|
update: dict = {"corr_sweep_count": attempts}
|
||||||
if attempts >= 3:
|
if attempts >= _max_sweep_attempts(call):
|
||||||
update["corr_path"] = "unlinked"
|
update["corr_path"] = "unlinked"
|
||||||
await fstore.doc_set("calls", call_id, update)
|
await fstore.doc_set("calls", call_id, update)
|
||||||
return False
|
return False
|
||||||
|
|||||||
+24
-17
@@ -78,33 +78,40 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
|
app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
|
||||||
|
|
||||||
# "*" plus allow_credentials=True is not the permissive-but-harmless setting it
|
# The browser needs CORS to reach this API at all: the frontend's Archive page
|
||||||
# looks like. Starlette does not refuse the combination -- it reflects the
|
# calls GET /calls/search with Authorization + Content-Type headers, which
|
||||||
# caller's Origin back and still sends Access-Control-Allow-Credentials: true,
|
# forces a preflight. Without this middleware the OPTIONS gets a bare 405 and
|
||||||
# so the effective policy becomes "any origin, with credentials", the opposite
|
# the fetch fails (#110). allow_origins is an explicit list -- never "*" in a
|
||||||
# of what a wildcard normally means. Rather than trust every deployment to
|
# deployment -- so name every host the frontend is served from in CORS_ORIGINS.
|
||||||
# remember to override CORS_ORIGINS, make the dangerous pair unrepresentable.
|
#
|
||||||
|
# allow_credentials stays False on purpose: auth here is a Bearer header, not a
|
||||||
|
# cookie, so credentialed CORS is never needed, and keeping it False is what
|
||||||
|
# lets an explicit-origin allowlist work without Starlette's "*"-only
|
||||||
|
# restriction. "*" + credentials is the dangerous pair (Starlette reflects the
|
||||||
|
# caller's Origin back WITH Access-Control-Allow-Credentials: true); this code
|
||||||
|
# cannot produce it because credentials are hard-off.
|
||||||
def cors_allows_credentials(origins: list[str]) -> bool:
|
def cors_allows_credentials(origins: list[str]) -> bool:
|
||||||
"""False when any entry is a wildcard. Extracted so it can be tested
|
"""Always False -- credentialed CORS is never enabled here (Bearer auth,
|
||||||
without re-importing this module, which drags in every router."""
|
not cookies). Kept as a named predicate so a future edit that wants to
|
||||||
return "*" not in origins
|
turn credentials on has to go through here and confront the "*" case.
|
||||||
|
A wildcard entry would additionally be refused a credentialed response."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
_cors_is_wildcard = not cors_allows_credentials(settings.cors_origins)
|
_cors_is_wildcard = "*" in settings.cors_origins
|
||||||
if _cors_is_wildcard:
|
if _cors_is_wildcard:
|
||||||
logger.error(
|
logger.error(
|
||||||
"CORS_ORIGINS is '*', so credentialed cross-origin requests are being "
|
"CORS_ORIGINS contains '*'. That is fine for local dev but is almost "
|
||||||
"DISABLED to avoid reflecting every caller's origin back with "
|
"certainly a misconfigured deployment -- set CORS_ORIGINS to your "
|
||||||
"Access-Control-Allow-Credentials. Set CORS_ORIGINS to your frontend "
|
"frontend origin(s), e.g. [\"https://drb.cusano.net\"]."
|
||||||
"origin(s) in production, e.g. [\"https://app.example.com\"]."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["authorization", "content-type"],
|
||||||
allow_credentials=not _cors_is_wildcard,
|
allow_credentials=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
|||||||
@@ -135,6 +135,19 @@ async def debug_correlation(
|
|||||||
"corr_llm_reasoning": call.get("corr_llm_reasoning"),
|
"corr_llm_reasoning": call.get("corr_llm_reasoning"),
|
||||||
"corr_llm_action": call.get("corr_llm_action"),
|
"corr_llm_action": call.get("corr_llm_action"),
|
||||||
"corr_rules_action": call.get("corr_rules_action"),
|
"corr_rules_action": call.get("corr_rules_action"),
|
||||||
|
# server-26#115 — why an llm=orphan/rules=new disagreement escalated
|
||||||
|
# to tiebreak instead of being gated (see upload.py's
|
||||||
|
# _call_is_substanceless). Present only on that disagreement shape;
|
||||||
|
# written here specifically so a live measurement window can read
|
||||||
|
# the reason instead of reconstructing it by hand from the dump.
|
||||||
|
"corr_gate_veto": call.get("corr_gate_veto"),
|
||||||
|
# server-26#127 — shadow-mode upstream chatter classifier verdict.
|
||||||
|
# Written by intelligence.extract_scenes on every transcript that
|
||||||
|
# reaches real scene extraction (not on garbage/too-short skips).
|
||||||
|
# Nothing skips extraction on this yet — it's here purely so a
|
||||||
|
# live measurement window can read the false-positive rate.
|
||||||
|
"chatter_classifier_verdict": call.get("chatter_classifier_verdict"),
|
||||||
|
"chatter_classifier_reason": call.get("chatter_classifier_reason"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Determine which systems have AI active ────────────────────────────────
|
# ── Determine which systems have AI active ────────────────────────────────
|
||||||
@@ -293,6 +306,20 @@ async def debug_correlation(
|
|||||||
"corr_fit_signal": _tally(c.get("corr_fit_signal") for c in linked),
|
"corr_fit_signal": _tally(c.get("corr_fit_signal") for c in linked),
|
||||||
"corr_consensus": _tally(c.get("corr_consensus") for c in linked),
|
"corr_consensus": _tally(c.get("corr_consensus") for c in linked),
|
||||||
"corr_llm_action": _tally(c.get("corr_llm_action") for c in linked),
|
"corr_llm_action": _tally(c.get("corr_llm_action") for c in linked),
|
||||||
|
# server-26#115 — this IS the number the escape-hatch fix exists to
|
||||||
|
# produce: why each llm=orphan/rules=new call escaped the gate.
|
||||||
|
"corr_gate_veto": _tally(c.get("corr_gate_veto") for c in linked),
|
||||||
|
# server-26#127 — shadow-mode chatter classifier. The target
|
||||||
|
# population is non-events, which land as orphans or single-call
|
||||||
|
# incidents, NOT as a slice of every linked call -- tally `orphans`
|
||||||
|
# too or this undercounts the exact thing the feature measures.
|
||||||
|
"chatter_classifier_flagged": sum(
|
||||||
|
1 for c in (linked + orphans) if c.get("chatter_classifier_verdict")
|
||||||
|
),
|
||||||
|
"chatter_classifier_reason": _tally(
|
||||||
|
c.get("chatter_classifier_reason") for c in (linked + orphans)
|
||||||
|
if c.get("chatter_classifier_verdict")
|
||||||
|
),
|
||||||
# STT coverage: correlation quality is capped by this, so it belongs in
|
# STT coverage: correlation quality is capped by this, so it belongs in
|
||||||
# the same view rather than a separate investigation.
|
# the same view rather than a separate investigation.
|
||||||
"linked_calls_with_transcript": with_transcript,
|
"linked_calls_with_transcript": with_transcript,
|
||||||
|
|||||||
@@ -100,6 +100,129 @@ async def upload_call_audio(
|
|||||||
return {"url": gcs_uri}
|
return {"url": gcs_uri}
|
||||||
|
|
||||||
|
|
||||||
|
# server-26#115 — the consensus LLM-orphan gate only fires when the call is
|
||||||
|
# genuinely substanceless. The earlier version tested `rules_decision["corr_debug"]`
|
||||||
|
# for a "positive signal", but corr_debug is EMPTY at preview time for
|
||||||
|
# action=="new" (corr_path:"new" is written at APPLY time), so that test was
|
||||||
|
# always False and the gate dropped real events — a major "extinguishing fire",
|
||||||
|
# geocoded calls, pursuit updates. The substance test now runs against `ctx`,
|
||||||
|
# which is fully populated at preview time.
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
|
||||||
|
"""
|
||||||
|
True when one of the already-loaded recent incidents is running on this
|
||||||
|
call's own system + talkgroup AND was active within the last
|
||||||
|
`settings.tg_dispatch_thin_idle_minutes` minutes. Covers the "unit
|
||||||
|
dispatched on the dispatch channel, thin acknowledgement 10-30s later"
|
||||||
|
case: the ack carries no substance of its own but plainly belongs to the
|
||||||
|
job just opened.
|
||||||
|
|
||||||
|
The window mirrors whatever the fast/thin path would use for this same
|
||||||
|
channel — `tg_dispatch_thin_idle_minutes` (5 min) on a dispatch backbone,
|
||||||
|
`tg_thin_idle_minutes` (15 min) on a tactical/working channel, selected via
|
||||||
|
the same `_is_dispatch_channel` test incident_correlator.py uses at its own
|
||||||
|
fast/thin idle-window selection (~:1005-1007). Using the dispatch constant
|
||||||
|
unconditionally would be wrong off dispatch — a retune of one for fast/thin
|
||||||
|
reasons would then silently widen or narrow this gate too, on channels
|
||||||
|
window #3 never measured.
|
||||||
|
|
||||||
|
This used to be a plain "does any recent incident exist on this
|
||||||
|
talkgroup" check against a 2-hour window (`correlation_window_hours`).
|
||||||
|
Measured live in production (server-26#115, CORRELATION_REVIEW_0912.md,
|
||||||
|
window #3): on a busy dispatch channel producing 3-13 incidents per 2h,
|
||||||
|
that condition is satisfied almost unconditionally, so the surrounding
|
||||||
|
LLM-orphan gate never fired on exactly the channels it exists to
|
||||||
|
protect (0/24 target-shaped calls gated in a 4h window). The docstring's
|
||||||
|
own intent was always "10-30 seconds", not "hours" — a few minutes is
|
||||||
|
the right shape.
|
||||||
|
|
||||||
|
Reads ctx["recent"] — the same window-filtered candidate list the rules
|
||||||
|
engine already loaded — so this adds no Firestore read.
|
||||||
|
|
||||||
|
Known limitation (server-26#115): ctx["recent"] is derived from
|
||||||
|
`all_active` in `_build_context` — incidents with `status=="active"`
|
||||||
|
for the call's org, with over-capacity incidents already dropped by
|
||||||
|
`_drop_capped` — not a full scan of the `incidents` collection. A
|
||||||
|
same-talkgroup incident that has already auto-resolved (no longer
|
||||||
|
"active") or hit `incident_max_calls`/`incident_max_duration_minutes`
|
||||||
|
will NOT appear here even though it is chronologically recent. A proper
|
||||||
|
fix needs a dedicated Firestore query that is not status/capacity
|
||||||
|
filtered — a new read, out of scope for this pass.
|
||||||
|
|
||||||
|
Whether this limitation explains the 2/24 unexplained gate misses in the
|
||||||
|
window #3 measurement is UNANSWERED, not confirmed either way — a prior
|
||||||
|
pass here claimed a "confirmed explanation" for both that turned out to
|
||||||
|
be self-contradictory. Read `corr_gate_veto` (written to corr_debug on
|
||||||
|
every escalation of this exact disagreement shape — see the caller) in
|
||||||
|
the next measurement window instead of guessing from the raw dump again.
|
||||||
|
# TODO(server-26#115): add a talkgroup-scoped incident lookup (any
|
||||||
|
# status, no capacity filter) if a future measurement window pins a real
|
||||||
|
# gate miss on a resolved/capped same-talkgroup incident.
|
||||||
|
"""
|
||||||
|
from app.internal.incident_correlator import _idle_gate_minutes, _is_dispatch_channel
|
||||||
|
|
||||||
|
tg_id = ctx.get("talkgroup_id")
|
||||||
|
system_id = ctx.get("system_id")
|
||||||
|
if tg_id is None or not system_id:
|
||||||
|
return False
|
||||||
|
tg_str = str(tg_id)
|
||||||
|
now = ctx.get("now") or datetime.now(timezone.utc)
|
||||||
|
idle_limit = (
|
||||||
|
settings.tg_dispatch_thin_idle_minutes
|
||||||
|
if _is_dispatch_channel(ctx.get("talkgroup_name"))
|
||||||
|
else settings.tg_thin_idle_minutes
|
||||||
|
)
|
||||||
|
for inc in ctx.get("recent") or []:
|
||||||
|
if system_id not in (inc.get("system_ids") or []):
|
||||||
|
continue
|
||||||
|
if tg_str not in (inc.get("talkgroup_ids") or []):
|
||||||
|
continue
|
||||||
|
if _idle_gate_minutes(inc, now) <= idle_limit:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _call_is_substanceless(ctx: dict) -> tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
True when the call carries nothing that marks it as a real event:
|
||||||
|
• no resolved incident_type and not a reassignment, AND
|
||||||
|
• severity is not moderate/major, AND
|
||||||
|
• no vehicle, geocode or tag (incident_correlator.has_event_substance —
|
||||||
|
the same predicate the incident-creation gate uses), AND
|
||||||
|
• no recent incident already running on the same talkgroup.
|
||||||
|
Only then may the LLM-orphan gate drop the call without a tiebreak.
|
||||||
|
|
||||||
|
Returns (substanceless, veto_reason). veto_reason names whichever
|
||||||
|
condition kept the tiebreak alive ("type" | "reassignment" | "severity" |
|
||||||
|
"substance" | "recent_tg"), or None when the call is substanceless. The
|
||||||
|
caller writes this into corr_debug on the escalation path so a live
|
||||||
|
measurement window can see *why* each llm=orphan/rules=new call escaped
|
||||||
|
the gate instead of inferring it after the fact from the raw dump —
|
||||||
|
exactly the guesswork that produced a wrong "confirmed explanation" for
|
||||||
|
2 window-#3 misses on the first pass of this fix.
|
||||||
|
"""
|
||||||
|
from app.internal import incident_correlator
|
||||||
|
|
||||||
|
# The incident-creation gate skips the has_event_substance check entirely
|
||||||
|
# when a type resolved (incident_correlator._run_decision ~:1397), so a
|
||||||
|
# typed call — fire/medical/etc. — opens an incident on substance we do not
|
||||||
|
# re-check here. reassignment=True is dispatch pulling a unit onto a NEW
|
||||||
|
# job (units are blanked at :296 for exactly that reason): the strongest
|
||||||
|
# new-incident signal in the pipeline. Either one means "keep the tiebreak".
|
||||||
|
if ctx.get("incident_type"):
|
||||||
|
return False, "type"
|
||||||
|
if ctx.get("reassignment"):
|
||||||
|
return False, "reassignment"
|
||||||
|
if (ctx.get("call_severity") or "routine") in ("moderate", "major"):
|
||||||
|
return False, "severity"
|
||||||
|
if incident_correlator.has_event_substance(ctx):
|
||||||
|
return False, "substance"
|
||||||
|
if _recent_incident_on_same_talkgroup(ctx):
|
||||||
|
return False, "recent_tg"
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
async def _correlate_with_consensus(
|
async def _correlate_with_consensus(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
@@ -151,6 +274,37 @@ async def _correlate_with_consensus(
|
|||||||
rules_decision["corr_debug"]["corr_llm_reasoning"] = llm_decision.get("reasoning", "")
|
rules_decision["corr_debug"]["corr_llm_reasoning"] = llm_decision.get("reasoning", "")
|
||||||
return await incident_correlator.apply_correlation(preview)
|
return await incident_correlator.apply_correlation(preview)
|
||||||
|
|
||||||
|
# server-26#115 — LLM-orphan gate.
|
||||||
|
# When the cheap LLM says `orphan`, the rules engine says `new`, and the call
|
||||||
|
# is genuinely substanceless (routine severity, no vehicle/geocode/tag, and
|
||||||
|
# no incident already running on this talkgroup), resolve to `orphan` and DO
|
||||||
|
# NOT pay for the smart tiebreaker. A bare rules `new` there means only
|
||||||
|
# "nothing to link to" — trivially true for radio housekeeping (check-ins,
|
||||||
|
# roll call, 10-8/10-98) — and the tiebreaker rubber-stamped it ~21/21 of the
|
||||||
|
# time on exactly this disagreement (CORRELATION_REVIEW_0907b.md). Any real
|
||||||
|
# signal (severity, coords, tags, a live same-talkgroup incident) still
|
||||||
|
# escalates, so an event the LLM misreads as orphan is not lost.
|
||||||
|
is_orphan_vs_new = llm_decision["action"] == "orphan" and rules_decision["action"] == "new"
|
||||||
|
substanceless, gate_veto_reason = _call_is_substanceless(ctx) if is_orphan_vs_new else (False, None)
|
||||||
|
if is_orphan_vs_new and substanceless:
|
||||||
|
logger.info(
|
||||||
|
f"Consensus gate for call {call_id}: llm=orphan vs rules=new and call "
|
||||||
|
f"is substanceless — resolving orphan, skipping tiebreak"
|
||||||
|
)
|
||||||
|
gated = {
|
||||||
|
"action": "orphan",
|
||||||
|
"matched_incident": None,
|
||||||
|
"incident_type": None,
|
||||||
|
"corr_debug": dict(rules_decision.get("corr_debug") or {}),
|
||||||
|
}
|
||||||
|
gated["corr_debug"].update({
|
||||||
|
"corr_consensus": "llm_orphan_gate",
|
||||||
|
"corr_rules_action": rules_decision["action"],
|
||||||
|
"corr_llm_action": llm_decision["action"],
|
||||||
|
"corr_llm_reasoning": llm_decision.get("reasoning", ""),
|
||||||
|
})
|
||||||
|
return await incident_correlator.apply_correlation({"decision": gated, "ctx": ctx})
|
||||||
|
|
||||||
# Disagree — escalate to the smarter tiebreaker.
|
# Disagree — escalate to the smarter tiebreaker.
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Consensus disagreement for call {call_id}: "
|
f"Consensus disagreement for call {call_id}: "
|
||||||
@@ -160,6 +314,12 @@ async def _correlate_with_consensus(
|
|||||||
final["corr_debug"]["corr_consensus"] = "tiebreak"
|
final["corr_debug"]["corr_consensus"] = "tiebreak"
|
||||||
final["corr_debug"]["corr_rules_action"] = rules_decision["action"]
|
final["corr_debug"]["corr_rules_action"] = rules_decision["action"]
|
||||||
final["corr_debug"]["corr_llm_action"] = llm_decision["action"]
|
final["corr_debug"]["corr_llm_action"] = llm_decision["action"]
|
||||||
|
if is_orphan_vs_new:
|
||||||
|
# server-26#115 — record *why* the llm=orphan/rules=new gate stood
|
||||||
|
# down instead of leaving a future measurement window to guess it
|
||||||
|
# from the raw dump (which produced a wrong "confirmed explanation"
|
||||||
|
# for 2/24 misses the first time around).
|
||||||
|
final["corr_debug"]["corr_gate_veto"] = gate_veto_reason
|
||||||
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx})
|
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
server-26#127 — upstream dispatch-vs-chatter classifier, shadow mode.
|
||||||
|
|
||||||
|
Fixtures are real transcripts, not invented ones: pulled from
|
||||||
|
`corr_dump_9-7_0437am.json`, `corr_dump_9-7_pm.json`, `corr_dump_9-12.json`
|
||||||
|
and the hand-labeled examples in `CORRELATION_REVIEW_0907b.md` /
|
||||||
|
`CORRELATION_REVIEW_0912.md`. The "must classify False" set specifically
|
||||||
|
includes every transcript those review docs flagged as dangerous to drop —
|
||||||
|
a false positive here is a real event silently losing its scene once this
|
||||||
|
classifier ever goes live, which is a much worse failure than a missed
|
||||||
|
chatter call staying in the existing (already-working) pipeline.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.internal.chatter_classifier import classify_chatter
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Must classify as chatter
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CHATTER_EXAMPLES = [
|
||||||
|
# Bare acknowledgements / unit check-ins (CORRELATION_REVIEW_0907b.md)
|
||||||
|
("114 Paul.\n114 Paul, Metro Central.\n10-4.", "bare_acknowledgement"),
|
||||||
|
("Affirmative, in charge of 10-8. 10-8, 10-4.", "bare_acknowledgement"),
|
||||||
|
("6-8, you can show me 98. 10-4.", "bare_acknowledgement"),
|
||||||
|
("10-4, 10-4 Central, 98. 10-4, 98.", "bare_acknowledgement"),
|
||||||
|
("7 for Post 1 and 2, 98. Affirm.", "bare_acknowledgement"),
|
||||||
|
("11-Victor to Central. 11-Victor. 72-Holland, 1-5. Central.", "bare_acknowledgement"),
|
||||||
|
# Roll call (CORRELATION_REVIEW_0907b.md / _0912.md)
|
||||||
|
("Post 4, Ossining. And to volunteer patrol, stand by for roll call.", "roll_call"),
|
||||||
|
("Headquarters to all cars, stand by for roll call.", "roll_call"),
|
||||||
|
("All Troop NYC Patrols, stand by for roll call.", "roll_call"),
|
||||||
|
(
|
||||||
|
"Car 100, roll call.\nHenry 1.\nHenry 1.\nSam 1.\nSam 1.\n45 Baker.\n"
|
||||||
|
"45 Baker.\n11 Adam.\nAdam.\n11 Baker.\nBaker.\nStaff 1.\n1.\nStaff 2.",
|
||||||
|
"roll_call",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Headquarters, all cars on a roll call. Baker 1? Baker 1. Henry 1? "
|
||||||
|
"Henry 1. Sam 2? Sam 2. 11 Adam? 11. 11 Baker? 11 Baker.",
|
||||||
|
"roll_call",
|
||||||
|
),
|
||||||
|
("Because all cars came out for roll call.", "roll_call"),
|
||||||
|
("10-1. KL Cars, that concludes roll call, time is 3-31.", "roll_call"),
|
||||||
|
# Minimal single-word / bare-code transmissions (orphan pool, all 3 dumps)
|
||||||
|
("10-4.", "bare_acknowledgement"),
|
||||||
|
("Roger.", "bare_acknowledgement"),
|
||||||
|
("Clear.", "bare_acknowledgement"),
|
||||||
|
("Affirmative.", "bare_acknowledgement"),
|
||||||
|
("Received.", "bare_acknowledgement"),
|
||||||
|
("10-8, clear. 10-4.", "bare_acknowledgement"),
|
||||||
|
("Post 4, 10-8. 10-4.", "bare_acknowledgement"),
|
||||||
|
("Central to 6 Henry.", "bare_acknowledgement"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("transcript,expected_reason", CHATTER_EXAMPLES)
|
||||||
|
def test_classifies_chatter(transcript, expected_reason):
|
||||||
|
is_chatter, reason = classify_chatter(transcript)
|
||||||
|
assert is_chatter is True
|
||||||
|
assert reason == expected_reason
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Must NOT classify as chatter — real events, including every transcript the
|
||||||
|
# review docs specifically named as dangerous to drop.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
REAL_EVENT_EXAMPLES = [
|
||||||
|
# The major "extinguishing fire" call (severity=major, tags=[extinguishing-fire])
|
||||||
|
("Dispatch, this is 7-4, extinguishing fire.", "extinguishing_fire"),
|
||||||
|
# Geocoded 911-hangup call (has location_coords)
|
||||||
|
(
|
||||||
|
"7, Charlie. Charlie, check and advise, we've got a call for service "
|
||||||
|
"coming over, it's going to be a 9-1-1 hangout, no voice contact. "
|
||||||
|
"Looks like it was an automated message saying it's the Doral Hat Company.",
|
||||||
|
"geocoded_911_hangup",
|
||||||
|
),
|
||||||
|
# Pursuit updates (severity=major, tags include pursuit / low-speed-pursuit)
|
||||||
|
("I'm aware of that one. It's a low-speed pursuit. It's refusing to pull over.", "low_speed_pursuit"),
|
||||||
|
(
|
||||||
|
"1. Headquarters to 5-charlie. I'm going to say the last thing to anyone.\n"
|
||||||
|
"2. Info, Sgt. Repeat.\n"
|
||||||
|
"3. The SP is on a pursuit southbound on I-684. It's approaching the airport.\n"
|
||||||
|
"4. Okay, thank you.\n5. 23-59.",
|
||||||
|
"pursuit_i684",
|
||||||
|
),
|
||||||
|
# "6 Alpha ... Pelham Station" subject check (CORRELATION_REVIEW_0907b.md's
|
||||||
|
# own "genuinely distinct events" list) — looks like a bare check-in but
|
||||||
|
# dispatches a unit to a specific location.
|
||||||
|
(
|
||||||
|
"6 Alpha, this is Central. 7 Alpha here.\n"
|
||||||
|
"6 Alpha, can you show me on scene at Pelham Station? Stand by.",
|
||||||
|
"pelham_station_subject_check",
|
||||||
|
),
|
||||||
|
# Property-retrieval call (tags=[property-retrieval])
|
||||||
|
(
|
||||||
|
"Property was retrieved with a 911. Can I get a phone number? 10-4. "
|
||||||
|
"Phone number is 214792. 214792.",
|
||||||
|
"property_retrieval",
|
||||||
|
),
|
||||||
|
# Subject check south of Maronex Station (tags=[subject-check])
|
||||||
|
(
|
||||||
|
"Proceed. Show me on a subject south of Maronex Station. Can I get a "
|
||||||
|
"15 check by New York client ID?",
|
||||||
|
"maronex_subject_check",
|
||||||
|
),
|
||||||
|
# Trespassing at milepost 13.7 (tags=[trespassing])
|
||||||
|
(
|
||||||
|
"Can you just 10-5 that job? You came over real muffled.\n"
|
||||||
|
"10-4, there's going to be a trespass on the tracks.\n"
|
||||||
|
"Train 8755 reports two juveniles, one male, one female, both wearing "
|
||||||
|
"white shirts, track three side, at milepost 13.7.",
|
||||||
|
"trespass_milepost_13_7",
|
||||||
|
),
|
||||||
|
# MVA (severity=moderate, tags=[traffic-accident])
|
||||||
|
("1. Train patrol 9.\n2. MVA 4, how close is it?\n3. 10-4.", "mva"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("transcript,label", REAL_EVENT_EXAMPLES, ids=[l for _, l in REAL_EVENT_EXAMPLES])
|
||||||
|
def test_does_not_classify_real_events_as_chatter(transcript, label):
|
||||||
|
is_chatter, reason = classify_chatter(transcript)
|
||||||
|
assert is_chatter is False, f"{label}: false positive, reason={reason!r}"
|
||||||
|
assert reason is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Edge cases
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_empty_transcript_not_chatter():
|
||||||
|
assert classify_chatter("") == (False, None)
|
||||||
|
assert classify_chatter(None) == (False, None)
|
||||||
|
assert classify_chatter(" ") == (False, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrecognized_content_defaults_to_not_chatter():
|
||||||
|
# Anything containing real descriptive words the classifier doesn't
|
||||||
|
# recognize must fall through to (False, None), not guess.
|
||||||
|
is_chatter, reason = classify_chatter("Shots fired, officer down, requesting backup immediately.")
|
||||||
|
assert is_chatter is False
|
||||||
|
assert reason is None
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""
|
||||||
|
server-26#115 — two consensus-quality fixes.
|
||||||
|
|
||||||
|
Fix 1 (routers/upload.py): when the cheap LLM says `orphan`, the rules engine
|
||||||
|
says `new`, and the call is genuinely SUBSTANCELESS (routine severity, no
|
||||||
|
vehicle/geocode/tag, and no incident already running on the same talkgroup),
|
||||||
|
resolve to `orphan` and DO NOT pay for the smart tiebreaker. Radio housekeeping
|
||||||
|
(unit check-ins, roll call, 10-8/10-98) was being promoted to incidents because
|
||||||
|
the tiebreaker rubber-stamped the rules `new` ~21/21 of the time
|
||||||
|
(CORRELATION_REVIEW_0907b.md).
|
||||||
|
|
||||||
|
The substance test runs against `ctx` (fully populated at preview time), NOT
|
||||||
|
against `rules_decision["corr_debug"]` — that dict is EMPTY at preview time for
|
||||||
|
action=="new" (corr_path:"new" is written at APPLY time), so the first version of
|
||||||
|
this gate fired on real events (a `major` "extinguishing fire", geocoded calls,
|
||||||
|
pursuit updates).
|
||||||
|
|
||||||
|
Fix 2 (incident_correlator.py): the `location` correlation path linked on a bare
|
||||||
|
sub-`location_proximity_km` (0.5 km) distance alone, taking whichever incident
|
||||||
|
came first in an unsorted `recent`. In a dense village two unrelated events
|
||||||
|
routinely geocode that close. A `location` link now needs unit overlap with the
|
||||||
|
candidate OR a distance under a tighter bar, and picks the NEAREST qualifying
|
||||||
|
candidate. A unit-overlap location link is tagged `location_unit_overlap` so it
|
||||||
|
does not merge into the fast path's bucket in the admin fit-signal histogram.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.routers import upload
|
||||||
|
from app.internal.incident_correlator import _run_decision, has_event_substance
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 7, 21, 30, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Fix 1 — the LLM-orphan gate in _correlate_with_consensus
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _preview(action, corr_debug=None, ctx=None):
|
||||||
|
base_ctx = {"call_id": "call-1"}
|
||||||
|
if ctx:
|
||||||
|
base_ctx.update(ctx)
|
||||||
|
return {
|
||||||
|
"decision": {
|
||||||
|
"action": action,
|
||||||
|
"matched_incident": None,
|
||||||
|
"incident_type": "other" if action == "new" else None,
|
||||||
|
"corr_debug": {} if corr_debug is None else dict(corr_debug),
|
||||||
|
},
|
||||||
|
"ctx": base_ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _llm(action, reasoning="—"):
|
||||||
|
md = {"incident_id": "inc-1"} if action == "link" else None
|
||||||
|
return {"action": action, "matched_incident": md, "reasoning": reasoning}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_consensus(preview, llm_decision):
|
||||||
|
tiebreak_result = {
|
||||||
|
"action": "new", "matched_incident": None, "incident_type": "other",
|
||||||
|
"corr_debug": {}, "reasoning": "tb",
|
||||||
|
}
|
||||||
|
with patch("app.internal.incident_correlator.preview_correlation",
|
||||||
|
new=AsyncMock(return_value=preview)), \
|
||||||
|
patch("app.internal.incident_correlator.apply_correlation",
|
||||||
|
new=AsyncMock(return_value="incident-x")) as m_apply, \
|
||||||
|
patch("app.internal.llm_correlator.decide",
|
||||||
|
new=AsyncMock(return_value=llm_decision)), \
|
||||||
|
patch("app.internal.llm_correlator.tiebreak",
|
||||||
|
new=AsyncMock(return_value=tiebreak_result)) as m_tiebreak:
|
||||||
|
await upload._correlate_with_consensus(
|
||||||
|
call_id="call-1", node_id="n1", system_id="sys-1",
|
||||||
|
talkgroup_id=9048, talkgroup_name="Dispatch", tags=[],
|
||||||
|
incident_type=None, location=None, location_coords=None,
|
||||||
|
)
|
||||||
|
return m_apply, m_tiebreak
|
||||||
|
|
||||||
|
|
||||||
|
async def test_substanceless_no_recent_same_tg_incident_gates_without_tiebreak():
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}), _llm("orphan", "unit check-in, not an incident"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_not_called()
|
||||||
|
m_apply.assert_called_once()
|
||||||
|
gated = m_apply.call_args[0][0]["decision"]
|
||||||
|
assert gated["action"] == "orphan"
|
||||||
|
dbg = gated["corr_debug"]
|
||||||
|
assert dbg["corr_consensus"] == "llm_orphan_gate"
|
||||||
|
assert dbg["corr_consensus"] != "tiebreak"
|
||||||
|
assert dbg["corr_rules_action"] == "new"
|
||||||
|
assert dbg["corr_llm_action"] == "orphan"
|
||||||
|
assert dbg["corr_llm_reasoning"] == "unit check-in, not an incident"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("severity", ["moderate", "major"])
|
||||||
|
async def test_moderate_or_major_severity_is_not_gated(severity):
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx={"call_severity": severity}), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_routine_severity_alone_still_gates():
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx={"call_severity": "routine"}), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_not_called()
|
||||||
|
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_with_coords_is_not_gated():
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx={"coords": {"lat": 41.15, "lng": -73.86}}),
|
||||||
|
_llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_with_tags_is_not_gated():
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx={"tags": ["structure-fire"]}), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_with_vehicles_is_not_gated():
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx={"call_vehicles": ["red sedan"]}), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_with_resolved_incident_type_is_not_gated():
|
||||||
|
# The creation gate skips has_event_substance when a type resolved, so a
|
||||||
|
# typed call (fire/medical/…) opens an incident on substance the gate does
|
||||||
|
# not re-check — it must keep the tiebreak, not be dropped.
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx={"incident_type": "fire"}), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reassignment_call_is_not_gated():
|
||||||
|
# reassignment=True is dispatch pulling a unit onto a NEW job (units are
|
||||||
|
# blanked for exactly that reason) — the strongest new-incident signal.
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx={"reassignment": True}), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recent_incident_on_same_talkgroup_is_not_gated():
|
||||||
|
ctx = {
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 9048,
|
||||||
|
"talkgroup_name": "Dispatch",
|
||||||
|
"now": NOW,
|
||||||
|
"recent": [{
|
||||||
|
"incident_id": "inc-live",
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["9048"],
|
||||||
|
"updated_at": (NOW - timedelta(minutes=1)).isoformat(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# server-26#115 window #3 (CORRELATION_REVIEW_0912.md): the escape hatch used
|
||||||
|
# to treat ANY same-talkgroup incident inside the 2h correlation_window_hours
|
||||||
|
# as "recent", which on a busy dispatch channel (3-13 incidents/2h) was
|
||||||
|
# satisfied almost unconditionally — the gate fired 0/24 times against its own
|
||||||
|
# target shape. It now only counts an incident as recent within
|
||||||
|
# settings.tg_dispatch_thin_idle_minutes (5 min) on a dispatch channel, or
|
||||||
|
# tg_thin_idle_minutes (15 min) on a tactical channel — the same split
|
||||||
|
# incident_correlator's own fast/thin path uses, selected by the same
|
||||||
|
# _is_dispatch_channel test, so a retune of one for fast/thin reasons doesn't
|
||||||
|
# silently move this escape hatch on channels never re-measured for it.
|
||||||
|
|
||||||
|
async def test_recent_same_tg_incident_inside_new_short_window_still_escapes_gate():
|
||||||
|
ctx = {
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 9048,
|
||||||
|
"talkgroup_name": "Dispatch",
|
||||||
|
"now": NOW,
|
||||||
|
"recent": [{
|
||||||
|
"incident_id": "inc-live",
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["9048"],
|
||||||
|
# 3 min ago — inside tg_dispatch_thin_idle_minutes (5).
|
||||||
|
"updated_at": (NOW - timedelta(minutes=3)).isoformat(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recent_same_tg_incident_older_than_short_window_now_gates():
|
||||||
|
# Regression test for the fix: 8 minutes is past the 5-minute DISPATCH
|
||||||
|
# bound but still inside the 15-minute TACTICAL bound and the OLD 2-hour
|
||||||
|
# correlation_window_hours lookback — this specifically proves the
|
||||||
|
# dispatch-channel number is being used here, not just "some window
|
||||||
|
# shorter than 2h". Before the fix this escaped the gate on any channel;
|
||||||
|
# after the fix a dispatch channel gates at this age (a tactical channel
|
||||||
|
# would not — see test_tactical_channel_uses_the_longer_window below).
|
||||||
|
ctx = {
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 9048,
|
||||||
|
"talkgroup_name": "Dispatch",
|
||||||
|
"now": NOW,
|
||||||
|
"recent": [{
|
||||||
|
"incident_id": "inc-stale",
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["9048"],
|
||||||
|
"updated_at": (NOW - timedelta(minutes=8)).isoformat(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_not_called()
|
||||||
|
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tactical_channel_uses_the_longer_window():
|
||||||
|
# Same 8-minute age as the dispatch test above, but on a channel name that
|
||||||
|
# does not match _DISPATCH_TG_RE — this must fall back to the 15-minute
|
||||||
|
# tg_thin_idle_minutes bound, same as incident_correlator's own fast/thin
|
||||||
|
# selection, and 8 min is still "recent" under that bound.
|
||||||
|
ctx = {
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 383,
|
||||||
|
"talkgroup_name": "Tac 3",
|
||||||
|
"now": NOW,
|
||||||
|
"recent": [{
|
||||||
|
"incident_id": "inc-tac",
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["383"],
|
||||||
|
"updated_at": (NOW - timedelta(minutes=8)).isoformat(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gate_veto_reason_is_recorded_on_the_escalation_path():
|
||||||
|
# server-26#115: a live measurement window must be able to see *why* an
|
||||||
|
# llm=orphan/rules=new call escaped the gate without guessing from the raw
|
||||||
|
# dump (which produced a wrong "confirmed explanation" for 2 window-#3
|
||||||
|
# misses the first time). corr_gate_veto names the surviving condition.
|
||||||
|
ctx = {"call_severity": "major"}
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
final = m_apply.call_args[0][0]["decision"]
|
||||||
|
assert final["corr_debug"]["corr_gate_veto"] == "severity"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gate_veto_reason_is_absent_when_the_disagreement_is_not_orphan_vs_new():
|
||||||
|
# corr_gate_veto is only meaningful for the llm=orphan/rules=new shape the
|
||||||
|
# gate targets — it must not appear (or be misleadingly None-vs-absent) on
|
||||||
|
# an unrelated disagreement shape.
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("link", {}), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
final = m_apply.call_args[0][0]["decision"]
|
||||||
|
assert "corr_gate_veto" not in final["corr_debug"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recent_incident_on_a_different_talkgroup_still_gates():
|
||||||
|
ctx = {
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 9048,
|
||||||
|
"recent": [{
|
||||||
|
"incident_id": "inc-other",
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["1200"],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(
|
||||||
|
_preview("new", {}, ctx=ctx), _llm("orphan"),
|
||||||
|
)
|
||||||
|
m_tiebreak.assert_not_called()
|
||||||
|
assert m_apply.call_args[0][0]["decision"]["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_llm_link_vs_rules_new_still_escalates():
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(_preview("new", {}), _llm("link", "same job"))
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_llm_orphan_vs_rules_link_still_escalates():
|
||||||
|
# Not the gate condition (gate needs rules=="new"); must fall through.
|
||||||
|
m_apply, m_tiebreak = await _run_consensus(_preview("link", {}), _llm("orphan"))
|
||||||
|
m_tiebreak.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_event_substance_predicate():
|
||||||
|
assert has_event_substance({"coords": {"lat": 1, "lng": 2}})
|
||||||
|
assert has_event_substance({"tags": ["fire"]})
|
||||||
|
assert has_event_substance({"call_vehicles": ["sedan"]})
|
||||||
|
assert not has_event_substance({})
|
||||||
|
assert not has_event_substance({"coords": None, "tags": [], "call_vehicles": []})
|
||||||
|
# units and location are NOT substance — nearly every transmission has them.
|
||||||
|
assert not has_event_substance({"call_units": ["7-Adam"], "location": "Main St"})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Fix 2 — tighten corr_path=location
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CALL_COORDS = {"lat": 41.150000, "lng": -73.860000}
|
||||||
|
# ~0.39 km north of the call — inside location_proximity_km (0.5) but well
|
||||||
|
# outside the tight bar (_LOCATION_TIGHT_PROXIMITY_KM, 0.2).
|
||||||
|
FAR_INC_COORDS = {"lat": 41.153500, "lng": -73.860000}
|
||||||
|
# ~0.13 km north of the call — inside the tight bar.
|
||||||
|
NEAR_INC_COORDS = {"lat": 41.151200, "lng": -73.860000}
|
||||||
|
# ~0.28 km north — inside the 0.5 radius, outside the 0.2 tight bar; used as a
|
||||||
|
# second candidate that must lose the nearest-wins sort to NEAR_INC_COORDS.
|
||||||
|
MID_INC_COORDS = {"lat": 41.152500, "lng": -73.860000}
|
||||||
|
|
||||||
|
|
||||||
|
def _inc(incident_id, coords, units):
|
||||||
|
return {
|
||||||
|
"incident_id": incident_id,
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["100"], # different TGID → fast path is a no-op
|
||||||
|
"location_coords": coords,
|
||||||
|
"units": units,
|
||||||
|
"tags": [],
|
||||||
|
"type": "police",
|
||||||
|
"updated_at": (NOW - timedelta(minutes=6)).isoformat(),
|
||||||
|
"started_at": (NOW - timedelta(minutes=20)).isoformat(),
|
||||||
|
"status": "active",
|
||||||
|
"call_ids": ["c0"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _loc_ctx(*, incidents, call_units):
|
||||||
|
return {
|
||||||
|
"call_id": "call-loc",
|
||||||
|
"all_active": list(incidents),
|
||||||
|
"recent": list(incidents),
|
||||||
|
"call_doc": {},
|
||||||
|
"call_embedding": None,
|
||||||
|
"call_units": call_units,
|
||||||
|
"call_vehicles": [],
|
||||||
|
"call_cleared": [],
|
||||||
|
"call_severity": "routine",
|
||||||
|
"coords": CALL_COORDS,
|
||||||
|
"is_thin_call": False,
|
||||||
|
"now": NOW,
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 999, # not in inc.talkgroup_ids
|
||||||
|
"talkgroup_name": "Tactical",
|
||||||
|
"tags": [],
|
||||||
|
"incident_type": "police",
|
||||||
|
"location": "Main St",
|
||||||
|
"location_coords": CALL_COORDS,
|
||||||
|
"reassignment": True, # suppress the unit-continuity path
|
||||||
|
"create_if_new": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_path_in_radius_but_no_unit_overlap_no_tight_proximity_does_not_link(caplog):
|
||||||
|
ctx = _loc_ctx(
|
||||||
|
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["7-Adam"])],
|
||||||
|
call_units=["3-Boy"],
|
||||||
|
)
|
||||||
|
with caplog.at_level("INFO", logger="drb-c2-core"):
|
||||||
|
decision = _run_decision(ctx)
|
||||||
|
# Reaches, and is rejected by, the new guard (not an earlier path).
|
||||||
|
assert "location-path skipped" in caplog.text
|
||||||
|
assert decision["action"] != "link"
|
||||||
|
assert (decision.get("corr_debug") or {}).get("corr_path") != "location"
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_path_links_on_unit_overlap_with_distinct_fit_signal():
|
||||||
|
ctx = _loc_ctx(
|
||||||
|
incidents=[_inc("inc-loc", FAR_INC_COORDS, ["5-Adam"])],
|
||||||
|
call_units=["5-Adam"],
|
||||||
|
)
|
||||||
|
decision = _run_decision(ctx)
|
||||||
|
assert decision["action"] == "link"
|
||||||
|
assert decision["corr_debug"]["corr_path"] == "location"
|
||||||
|
# NOT "unit_overlap" — that value belongs to the fast path's histogram bucket.
|
||||||
|
assert decision["corr_debug"]["corr_fit_signal"] == "location_unit_overlap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_path_links_on_tight_proximity_without_unit_overlap():
|
||||||
|
ctx = _loc_ctx(
|
||||||
|
incidents=[_inc("inc-loc", NEAR_INC_COORDS, ["7-Adam"])],
|
||||||
|
call_units=["3-Boy"],
|
||||||
|
)
|
||||||
|
decision = _run_decision(ctx)
|
||||||
|
assert decision["action"] == "link"
|
||||||
|
assert decision["corr_debug"]["corr_path"] == "location"
|
||||||
|
assert decision["corr_debug"]["corr_fit_signal"] == "location_proximity"
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_path_picks_nearest_in_radius_candidate():
|
||||||
|
# `recent` order puts the farther tight-proximity incident first; the guard
|
||||||
|
# must still select the nearest one.
|
||||||
|
ctx = _loc_ctx(
|
||||||
|
incidents=[
|
||||||
|
_inc("inc-mid", MID_INC_COORDS, ["3-Boy"]), # ~0.28 km, tight-fail
|
||||||
|
_inc("inc-near", NEAR_INC_COORDS, ["3-Boy"]), # ~0.13 km, tight-pass
|
||||||
|
],
|
||||||
|
call_units=["3-Boy"],
|
||||||
|
)
|
||||||
|
decision = _run_decision(ctx)
|
||||||
|
assert decision["action"] == "link"
|
||||||
|
assert decision["matched_incident"]["incident_id"] == "inc-near"
|
||||||
|
assert decision["corr_debug"]["corr_path"] == "location"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
server-26#115 — the tiebreaker manufactured incidents because it was blind to
|
||||||
|
what would tell it two incidents are one.
|
||||||
|
|
||||||
|
Two low-risk supports for the reframed prompt:
|
||||||
|
1. `_extract_road_ids` collapses street-type synonyms, so "Mohegan Park Ave"
|
||||||
|
and "Mohegan Park Avenue" share a road id (they were splitting one
|
||||||
|
car-alarm incident into two).
|
||||||
|
2. `_inc_summary` now carries the incident title and talkgroup, the two
|
||||||
|
signals the model needs to recognise a same-channel continuation.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.internal.incident_correlator import (
|
||||||
|
_extract_road_ids, _location_mentions_road_overlap,
|
||||||
|
)
|
||||||
|
from app.internal.llm_correlator import _inc_summary, _prompt_incidents
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 7, 8, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_avenue_and_ave_are_the_same_road_id():
|
||||||
|
assert _extract_road_ids("Mohegan Park Avenue") == _extract_road_ids("Mohegan Park Ave")
|
||||||
|
assert _extract_road_ids("191 Broadway Street") == _extract_road_ids("191 Broadway St")
|
||||||
|
assert _extract_road_ids("North State Road") == _extract_road_ids("North State Rd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_road_overlap_matches_across_the_synonym():
|
||||||
|
assert _location_mentions_road_overlap("multiple car alarms Mohegan Park Avenue",
|
||||||
|
["patrol to Mohegan Park Ave"]) is True
|
||||||
|
# still discriminates genuinely different streets
|
||||||
|
assert _location_mentions_road_overlap("Oak Avenue", ["Elm Avenue"]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_inc_summary_carries_title_and_talkgroup():
|
||||||
|
s = _inc_summary({
|
||||||
|
"incident_id": "abc123",
|
||||||
|
"type": "police",
|
||||||
|
"talkgroup_ids": [9560],
|
||||||
|
"title": "Nuisance Alarm at Mohegan Park Ave",
|
||||||
|
"location": "Mohegan Park Ave",
|
||||||
|
"units": ["Headquarters"],
|
||||||
|
"tags": ["car-alarm"],
|
||||||
|
"updated_at": NOW.isoformat(),
|
||||||
|
}, NOW)
|
||||||
|
assert "title:'Nuisance Alarm at Mohegan Park Ave'" in s
|
||||||
|
assert "tg:[9560]" in s
|
||||||
|
assert "id:abc123" in s
|
||||||
|
|
||||||
|
|
||||||
|
def test_inc_summary_omits_missing_optional_fields():
|
||||||
|
s = _inc_summary({"incident_id": "x", "updated_at": NOW.isoformat()}, NOW)
|
||||||
|
assert "title:" not in s and "tg:" not in s and "loc:" not in s
|
||||||
|
assert s.startswith("id:x")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_incidents_is_most_recently_active_first_and_capped():
|
||||||
|
recent = [
|
||||||
|
{"incident_id": f"i{n}", "updated_at": f"2026-09-07T0{n}:00:00+00:00"}
|
||||||
|
for n in range(1, 8)
|
||||||
|
]
|
||||||
|
ordered = _prompt_incidents(recent)
|
||||||
|
assert [i["incident_id"] for i in ordered] == ["i7", "i6", "i5", "i4", "i3", "i2", "i1"]
|
||||||
|
assert len(_prompt_incidents(recent * 5)) == 20
|
||||||
|
# falls back to started_at when updated_at is absent, and never raises
|
||||||
|
assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()},
|
||||||
|
{"incident_id": "b"}])[0]["incident_id"] == "a"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
End-to-end CORS wiring for the one browser-facing REST surface.
|
||||||
|
|
||||||
|
The frontend's Archive page calls GET /calls/search with Authorization +
|
||||||
|
Content-Type headers, which forces the browser to send a CORS preflight
|
||||||
|
first. Before #110 that OPTIONS got a bare 405 with no Access-Control-*
|
||||||
|
headers and the fetch failed with "TypeError: Failed to fetch". These
|
||||||
|
tests drive the real app through TestClient so a regression in the
|
||||||
|
middleware wiring (not just the helper) is caught.
|
||||||
|
|
||||||
|
TestClient is NOT used as a context manager on purpose: that would run the
|
||||||
|
lifespan (mqtt_handler.connect(), the sweeper loops, dynsec bootstrap),
|
||||||
|
none of which is needed here -- CORSMiddleware answers a preflight before
|
||||||
|
routing or dependencies run.
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
ALLOWED_ORIGIN = "https://drb.cusano.net"
|
||||||
|
DISALLOWED_ORIGIN = "https://evil.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_allowed_origin_matches_the_deployed_frontend():
|
||||||
|
# The frontend is served on the bare domain (infra Caddyfile.j2), so the
|
||||||
|
# default must allow exactly that origin without any env override.
|
||||||
|
assert ALLOWED_ORIGIN in settings.cors_origins
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_for_calls_search_is_allowed():
|
||||||
|
resp = client.options(
|
||||||
|
"/calls/search",
|
||||||
|
headers={
|
||||||
|
"Origin": ALLOWED_ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": "authorization,content-type",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
|
||||||
|
allow_methods = resp.headers.get("access-control-allow-methods", "").upper()
|
||||||
|
assert "GET" in allow_methods
|
||||||
|
# Bearer auth, not cookies -- credentials must never be advertised.
|
||||||
|
assert "access-control-allow-credentials" not in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_from_disallowed_origin_gets_no_allow_origin():
|
||||||
|
resp = client.options(
|
||||||
|
"/calls/search",
|
||||||
|
headers={
|
||||||
|
"Origin": DISALLOWED_ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.headers.get("access-control-allow-origin") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_get_from_allowed_origin_is_annotated():
|
||||||
|
# Even a non-preflight GET must carry Access-Control-Allow-Origin or the
|
||||||
|
# browser hides the response body from the page.
|
||||||
|
resp = client.get("/health", headers={"Origin": ALLOWED_ORIGIN})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
|
||||||
@@ -5,8 +5,9 @@ Starlette does not reject `allow_origins=["*"]` combined with
|
|||||||
`allow_credentials=True`. It reflects the caller's Origin back in
|
`allow_credentials=True`. It reflects the caller's Origin back in
|
||||||
Access-Control-Allow-Origin and still sends
|
Access-Control-Allow-Origin and still sends
|
||||||
Access-Control-Allow-Credentials: true, so the effective policy is the
|
Access-Control-Allow-Credentials: true, so the effective policy is the
|
||||||
opposite of what a wildcard usually means. main.py defuses that by turning
|
opposite of what a wildcard usually means. main.py never enables
|
||||||
credentials off whenever it sees a wildcard; these tests hold it to that.
|
credentials at all (auth is a Bearer header, not a cookie), which makes
|
||||||
|
that pair unrepresentable; these tests hold it to that.
|
||||||
|
|
||||||
The policy lives in a pure function so it can be exercised directly --
|
The policy lives in a pure function so it can be exercised directly --
|
||||||
reloading app.main to vary settings drags every router back through import
|
reloading app.main to vary settings drags every router back through import
|
||||||
@@ -28,11 +29,11 @@ def test_wildcard_among_real_origins_still_disables_credentials():
|
|||||||
assert cors_allows_credentials(["https://app.example.com", "*"]) is False
|
assert cors_allows_credentials(["https://app.example.com", "*"]) is False
|
||||||
|
|
||||||
|
|
||||||
def test_named_origins_keep_credentials():
|
def test_credentials_never_enabled_even_for_named_origins():
|
||||||
# Naming your origins is how you ask for credentialed requests, so a
|
# Auth here is a Bearer header, not a cookie, so credentialed CORS is
|
||||||
# correctly configured deployment must not be penalised.
|
# never needed. The predicate is hard-off regardless of the origin list.
|
||||||
assert cors_allows_credentials(["https://app.example.com"]) is True
|
assert cors_allows_credentials(["https://app.example.com"]) is False
|
||||||
assert cors_allows_credentials([]) is True
|
assert cors_allows_credentials([]) is False
|
||||||
|
|
||||||
|
|
||||||
def test_the_app_actually_mounted_that_policy():
|
def test_the_app_actually_mounted_that_policy():
|
||||||
@@ -42,6 +43,7 @@ def test_the_app_actually_mounted_that_policy():
|
|||||||
(mw.kwargs for mw in app.user_middleware if mw.cls is CORSMiddleware), None
|
(mw.kwargs for mw in app.user_middleware if mw.cls is CORSMiddleware), None
|
||||||
)
|
)
|
||||||
assert opts is not None, "CORSMiddleware is not mounted at all"
|
assert opts is not None, "CORSMiddleware is not mounted at all"
|
||||||
|
assert opts["allow_credentials"] is False
|
||||||
assert opts["allow_credentials"] is cors_allows_credentials(settings.cors_origins)
|
assert opts["allow_credentials"] is cors_allows_credentials(settings.cors_origins)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useAlerts } from "@/lib/useAlerts";
|
import { useAlerts } from "@/lib/useAlerts";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
@@ -32,8 +32,8 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on first render of this tab
|
// Load once when this tab mounts (load() self-guards on `loaded`).
|
||||||
if (!loaded) { load(); }
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -186,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
export default function AlertsPage() {
|
export default function AlertsPage() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { alerts, loading } = useAlerts();
|
const { alerts, loading, error } = useAlerts();
|
||||||
const [tab, setTab] = useState<"events" | "rules">("events");
|
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||||
|
|
||||||
async function handleAcknowledge(id: string) {
|
async function handleAcknowledge(id: string) {
|
||||||
@@ -226,6 +226,12 @@ export default function AlertsPage() {
|
|||||||
{tab === "events" && (
|
{tab === "events" && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-red-400 text-sm font-mono">
|
||||||
|
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
||||||
|
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
|
||||||
|
: `Couldn't load alerts: ${error}`}
|
||||||
|
</p>
|
||||||
) : alerts.length === 0 ? (
|
) : alerts.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
animation: pulse-ring 1.8s ease-out infinite;
|
animation: pulse-ring 1.8s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
|
||||||
|
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
|
||||||
|
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
|
||||||
|
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
|
||||||
|
* map container to its own low stacking context keeps Leaflet's internal layer
|
||||||
|
* order intact while dropping the whole map (tiles + controls) below the app
|
||||||
|
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
|
||||||
|
* outside .leaflet-container, so it is unaffected and still renders on top.
|
||||||
|
*/
|
||||||
|
.leaflet-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 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,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export default function IncidentDetailPage() {
|
|||||||
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
||||||
const unitsActive = incident.units_active ?? incident.units ?? [];
|
const unitsActive = incident.units_active ?? incident.units ?? [];
|
||||||
const unitsCleared = incident.units_cleared ?? [];
|
const unitsCleared = incident.units_cleared ?? [];
|
||||||
|
const vehicles = incident.vehicles ?? [];
|
||||||
const active = incident.status === "active";
|
const active = incident.status === "active";
|
||||||
|
|
||||||
const visible = newestFirst.slice(0, earlierShown);
|
const visible = newestFirst.slice(0, earlierShown);
|
||||||
@@ -213,11 +214,11 @@ export default function IncidentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{incident.vehicles?.length > 0 && (
|
{vehicles.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{incident.vehicles.map((v) => (
|
{vehicles.map((v) => (
|
||||||
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
|
|||||||
|
|
||||||
type SortMode = "recent" | "severity";
|
type SortMode = "recent" | "severity";
|
||||||
|
|
||||||
|
// The Firestore client surfaces a missing composite index or an undeployed
|
||||||
|
// ruleset as a raw multi-line string with a console URL in it — not something
|
||||||
|
// to put in front of an operator. Collapse the known infra failures to a plain
|
||||||
|
// line; pass anything else straight through so a real bug still shows.
|
||||||
|
function friendlyIncidentsError(raw: string): string {
|
||||||
|
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
|
||||||
|
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
|
||||||
|
}
|
||||||
|
return `Couldn't load incidents: ${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
||||||
}
|
}
|
||||||
@@ -286,7 +297,7 @@ export default function IncidentsPage() {
|
|||||||
recorded yet" over the top of it told the operator the radio was
|
recorded yet" over the top of it told the operator the radio was
|
||||||
quiet when the page had simply failed to load — server-26#13. */}
|
quiet when the page had simply failed to load — server-26#13. */}
|
||||||
{filtered.length === 0 && error && (
|
{filtered.length === 0 && error && (
|
||||||
<ErrorBanner message={`Couldn't load incidents: ${error}`} />
|
<ErrorBanner message={friendlyIncidentsError(error)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 && !error && (
|
{filtered.length === 0 && !error && (
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function DiscordJoinModal({
|
|||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm"
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h3 className="text-white font-semibold">Join Discord Voice</h3>
|
<h3 className="text-white font-semibold">Join Discord Voice</h3>
|
||||||
<div>
|
<div>
|
||||||
@@ -120,7 +120,10 @@ export default function NodeDetailPage() {
|
|||||||
const [approving, setApproving] = useState(false);
|
const [approving, setApproving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const { systems } = useSystems();
|
const { systems } = useSystems();
|
||||||
const { calls } = useCalls(20);
|
// TODO(server-26#109 item5): server-side node_id filter. A where("node_id","==",id)
|
||||||
|
// alongside the existing org_id equality + started_at orderBy needs a brand-new
|
||||||
|
// composite index, so for now pull a wider window and filter client-side.
|
||||||
|
const { calls } = useCalls(200);
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function NodesPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{pending.map((n) => (
|
{pending.map((n) => (
|
||||||
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
||||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
|
|||||||
// Firebase custom claims only show up in a *freshly fetched* ID token —
|
// Firebase custom claims only show up in a *freshly fetched* ID token —
|
||||||
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
|
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
|
||||||
// AuthProvider's own state (orgId) updates and the effect above
|
// AuthProvider's own state (orgId) updates and the effect above
|
||||||
// redirects to /dashboard.
|
// redirects to "/" (Live).
|
||||||
await refreshClaims();
|
await refreshClaims();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
|
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ function TripCard({ trip, isAdmin, onDelete }: {
|
|||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const upcoming = trip.start_date >= today;
|
// Bucket and badge must agree: the list groups on end_date (page.tsx ~L176),
|
||||||
|
// so a trip isn't "Past" until it's over, not when it starts.
|
||||||
|
const upcoming = trip.end_date >= today;
|
||||||
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
|
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,10 +99,10 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-white font-bold">New Trip</h2>
|
<h2 className="text-white font-bold">New Trip</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function fmtClock(s: number): string {
|
|||||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) {
|
function InlinePlayer({ callId }: { callId: string }) {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -32,8 +32,6 @@ function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
if (!hasAudio) return null;
|
|
||||||
|
|
||||||
async function ensureUrl() {
|
async function ensureUrl() {
|
||||||
if (url || loading) return;
|
if (url || loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -169,7 +167,7 @@ export function CallSpineEntry({
|
|||||||
|
|
||||||
{hasAudio && (
|
{hasAudio && (
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} />
|
<InlinePlayer callId={call.call_id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ L.Icon.Default.mergeOptions({
|
|||||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Basemap tiles ─────────────────────────────────────────────────────────────
|
||||||
|
// Prod sets NEXT_PUBLIC_MAP_TILE_URL to a keyed style (a CARTO account style,
|
||||||
|
// MapTiler, Mapbox, …). The in-code fallback is plain OpenStreetMap so the map
|
||||||
|
// still renders if that var is missing — CARTO's keyless CDN has proven flaky.
|
||||||
|
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme;
|
||||||
|
// the {z}/{x}/{y} tokens below are substituted by Leaflet at runtime.
|
||||||
|
const MAP_TILE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
|
||||||
|
"https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||||
|
const MAP_TILE_ATTRIBUTION = "© OpenStreetMap contributors";
|
||||||
|
|
||||||
// ── Colour ────────────────────────────────────────────────────────────────────
|
// ── Colour ────────────────────────────────────────────────────────────────────
|
||||||
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
||||||
// type is carried by the glyph knocked out of the pin, never by colour, and
|
// type is carried by the glyph knocked out of the pin, never by colour, and
|
||||||
@@ -448,9 +459,6 @@ 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 [clockStr, setClockStr] = useState(() =>
|
|
||||||
new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||||
@@ -463,15 +471,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Live clock for TOC situational awareness
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() =>
|
|
||||||
setClockStr(new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })),
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||||
|
|
||||||
@@ -540,14 +539,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
{/* Base layers */}
|
{/* Base layers */}
|
||||||
<LayersControl.BaseLayer checked name="Dark">
|
<LayersControl.BaseLayer checked name="Dark">
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
url={MAP_TILE_URL}
|
||||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
attribution={MAP_TILE_ATTRIBUTION}
|
||||||
/>
|
/>
|
||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
<LayersControl.BaseLayer name="Light">
|
<LayersControl.BaseLayer name="Light">
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
attribution={MAP_TILE_ATTRIBUTION}
|
||||||
/>
|
/>
|
||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
<LayersControl.BaseLayer name="Streets">
|
<LayersControl.BaseLayer name="Streets">
|
||||||
@@ -612,13 +611,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
|
|
||||||
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2 pointer-events-none">
|
|
||||||
<span className="text-ink text-sm font-mono tabular-nums">{clockStr}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
||||||
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2">
|
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
||||||
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
||||||
@@ -662,15 +656,19 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||||
{incidents.length > 0 && (
|
{incidents.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Desktop: left sidebar — starts below zoom controls + fit-all button */}
|
{/* Desktop: left sidebar — offset below the zoom stack + fit-all button
|
||||||
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5">
|
so it never overlaps the Leaflet +/- controls (#118). Height is
|
||||||
|
capped and the list scrolls on its own, so the rail never reaches
|
||||||
|
the bottom-right legend. pointer-events are off on the wrapper and
|
||||||
|
back on for the cards, so the map still pans in the gaps. */}
|
||||||
|
<div className="absolute top-[9.5rem] left-3 z-[1001] hidden md:flex flex-col w-56 gap-1.5 max-h-[calc(100%-12rem)] pointer-events-none">
|
||||||
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
|
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
|
||||||
unit counts are pipeline output. Pinned above the scroll area
|
unit counts are pipeline output. Pinned above the scroll area
|
||||||
so it cannot be scrolled off the screen it qualifies. */}
|
so it cannot be scrolled off the screen it qualifies. */}
|
||||||
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0">
|
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
|
||||||
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 overflow-y-auto">
|
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
|
||||||
{incidents.map((inc) => {
|
{incidents.map((inc) => {
|
||||||
const color = severityColor(inc.severity);
|
const color = severityColor(inc.severity);
|
||||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import type { NodeRecord, SystemRecord } from "@/lib/types";
|
|||||||
interface Props {
|
interface Props {
|
||||||
node: NodeRecord;
|
node: NodeRecord;
|
||||||
system?: SystemRecord;
|
system?: SystemRecord;
|
||||||
|
/**
|
||||||
|
* When false, the card renders without its `/nodes/[id]` Link wrapper so a
|
||||||
|
* parent click handler can take the interaction (pending nodes open the
|
||||||
|
* config modal instead of navigating). Defaults to true.
|
||||||
|
*/
|
||||||
|
linkToDetail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeCard({ node, system }: Props) {
|
export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
||||||
const lastSeen = node.last_seen
|
const lastSeen = node.last_seen
|
||||||
? new Date(node.last_seen).toLocaleTimeString()
|
? new Date(node.last_seen).toLocaleTimeString()
|
||||||
: "never";
|
: "never";
|
||||||
|
|
||||||
return (
|
const body = (
|
||||||
<Link href={`/nodes/${node.node_id}`}>
|
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -58,6 +63,7 @@ export function NodeCard({ node, system }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return linkToDetail ? <Link href={`/nodes/${node.node_id}`}>{body}</Link> : body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
const selectedPreset = PRESETS.find((p) => p.value === preset);
|
const selectedPreset = PRESETS.find((p) => p.value === preset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono">
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||||
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||||
<p className="text-gray-400 text-sm mb-5">
|
<p className="text-gray-400 text-sm mb-5">
|
||||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||||
|
|||||||
@@ -143,8 +143,9 @@ export interface IncidentRecord {
|
|||||||
call_ids: string[];
|
call_ids: string[];
|
||||||
system_ids: string[];
|
system_ids: string[];
|
||||||
talkgroup_ids: string[];
|
talkgroup_ids: string[];
|
||||||
units: string[];
|
/** Omitted on incident docs written before these fields existed. */
|
||||||
vehicles: string[];
|
units?: string[];
|
||||||
|
vehicles?: string[];
|
||||||
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
||||||
units_active?: string[];
|
units_active?: string[];
|
||||||
/** Units that reported clearing/back in service on this incident. */
|
/** Units that reported clearing/back in service on this incident. */
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"//": "Composite indexes for the c2-server database. Firestore auto-indexes single-field lookups and equality-only compound queries; an equality filter combined with an inequality, an orderBy on a different field, or array-contains needs an explicit composite index or the query fails at runtime with FAILED_PRECONDITION. Deploy with: firebase deploy --only firestore:indexes --project <project-id> (firebase.json pins database c2-server — without that key the CLI targets (default) and changes nothing the app can see).",
|
"//": "Composite indexes for the c2-server database. Firestore auto-indexes single-field lookups and equality-only compound queries; an equality filter combined with an inequality, an orderBy on a different field, or array-contains needs an explicit composite index or the query fails at runtime with FAILED_PRECONDITION. Deploy with: firebase deploy --only firestore:indexes --project <project-id> (firebase.json pins database c2-server — without that key the CLI targets (default) and changes nothing the app can see).",
|
||||||
"//direction": "Every index here is declared ASCENDING. Firestore scans an index in either direction, so org_id+started_at ASC serves orderBy(started_at, 'desc') as well — which is what every frontend hook actually asks for. Declaring only the ASC form keeps one index per query shape instead of a matched pair.",
|
"//direction": "The sort field's ORDER here must match the query's orderBy direction. The old note claimed 'Firestore scans either direction so ASC serves orderBy(desc)' — that is WRONG for these query shapes and cost us three broken pages (server-26 #33/#51/#110-followup, 2026-09-08): useCalls/useIncidents/useAlerts and c2-core search_calls all orderBy(x,'desc') and each got FAILED_PRECONDITION until an explicit DESCENDING index existed. A range/inequality filter with no orderBy (the backend status/ended_at, system_id/started_at, system_id/ended_at entries) is genuinely direction-agnostic and stays ASCENDING.",
|
||||||
"//drift-2026-08-23": "Reconciled against `gcloud firestore indexes composite list --database=c2-server` (server-26#33). The file had drifted four indexes behind the live database, and a deploy against the stale file then added ASC copies of indexes that already existed as DESC. The next deploy will offer to delete three live indexes that are deliberately not declared here — answer YES to all three: calls(org_id ASC, started_at DESC) and incidents(org_id ASC, started_at DESC) are duplicates of the ASC entries below, and alert_events(acknowledged ASC, triggered_at DESC) predates tenancy and is superseded by the org-scoped entry below. Nothing else may be deleted.",
|
"//drift-2026-09-08": "Reconciled against the live c2-server via `gcloud firestore indexes composite list` (server-26#33). Live already carries the three DESC indexes below (calls(org_id,started_at DESC), incidents(org_id,started_at DESC), alert_events(org_id,triggered_at DESC)) plus alert_events(acknowledged,org_id,triggered_at DESC) — created directly with gcloud on 2026-09-08 to unbreak Archive + Watch. This file now declares them so a `firebase deploy --only firestore:indexes` is a no-op, NOT a set of deletions. Do NOT delete calls(org_id,started_at DESC) or incidents(org_id,started_at DESC) — the pre-2026-09-08 note calling them deletable 'duplicates of the ASC entries' was the bug. The only genuinely dead index is the pre-tenancy alert_events(acknowledged,triggered_at) with no org_id, which may be deleted.",
|
||||||
"indexes": [
|
"indexes": [
|
||||||
{
|
{
|
||||||
"//": "drb-frontend lib/useCalls.ts — org-scoped call list, orderBy started_at desc.",
|
"//": "drb-frontend lib/useCalls.ts + c2-core routers/calls.py search_calls — org-scoped call list, orderBy started_at DESC.",
|
||||||
"collectionGroup": "calls",
|
"collectionGroup": "calls",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
||||||
{ "fieldPath": "started_at", "order": "ASCENDING" }
|
{ "fieldPath": "started_at", "order": "DESCENDING" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"//": "c2-core internal/recorrelation_sweep.py:45 — status == 'ended' AND ended_at >= cutoff. Backend only; was live but undeclared until 2026-08-23.",
|
"//": "c2-core internal/recorrelation_sweep.py:45 — status == 'ended' AND ended_at >= cutoff. Range filter, no orderBy: direction-agnostic. Backend only.",
|
||||||
"collectionGroup": "calls",
|
"collectionGroup": "calls",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"//": "c2-core internal/dedup.py:84 — system_id == X AND started_at within a +/- window. Was live-failing on essentially every inbound call (server-26#84): dedup caught the FAILED_PRECONDITION and degraded to \"not a duplicate\", so double-heard transmissions were stored twice and would have been transcribed and correlated twice the moment an AI window opened. Created directly on c2-server 2026-08-28.",
|
"//": "c2-core internal/dedup.py:84 — system_id == X AND started_at within a +/- window. Range filter, direction-agnostic. Was live-failing on essentially every inbound call (server-26#84): dedup caught the FAILED_PRECONDITION and degraded to \"not a duplicate\", so double-heard transmissions were stored twice. Created directly on c2-server 2026-08-28.",
|
||||||
"collectionGroup": "calls",
|
"collectionGroup": "calls",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"//": "c2-core internal/vocabulary_learner.py:290 — system_id == X AND ended_at >= cutoff. Backend only; was live but undeclared until 2026-08-23.",
|
"//": "c2-core internal/vocabulary_learner.py:290 — system_id == X AND ended_at >= cutoff. Range filter, direction-agnostic. Backend only.",
|
||||||
"collectionGroup": "calls",
|
"collectionGroup": "calls",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -49,31 +49,31 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"//": "drb-frontend lib/useIncidents.ts — org-scoped incident browse, orderBy started_at desc.",
|
"//": "drb-frontend lib/useIncidents.ts — org-scoped incident browse, orderBy started_at DESC.",
|
||||||
"collectionGroup": "incidents",
|
"collectionGroup": "incidents",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
||||||
{ "fieldPath": "started_at", "order": "ASCENDING" }
|
{ "fieldPath": "started_at", "order": "DESCENDING" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"//": "drb-frontend lib/useAlerts.ts — org-scoped alert feed, orderBy triggered_at desc.",
|
"//": "drb-frontend lib/useAlerts.ts — org-scoped alert feed, where(org_id ==) orderBy(triggered_at DESC).",
|
||||||
"collectionGroup": "alert_events",
|
"collectionGroup": "alert_events",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
||||||
{ "fieldPath": "triggered_at", "order": "ASCENDING" }
|
{ "fieldPath": "triggered_at", "order": "DESCENDING" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"//": "drb-frontend lib/useAlerts.ts useUnacknowledgedAlerts — the nav badge.",
|
"//": "drb-frontend lib/useAlerts.ts useUnacknowledgedAlerts (nav badge) and the /watch \"Triggered Alerts\" tab — where(org_id ==) where(acknowledged == false) orderBy(triggered_at DESC). Field tuple + triggered_at DESCENDING copy the console create_composite link verbatim (server-26#51). Distinct from the (org_id, triggered_at) feed index above (no acknowledged filter).",
|
||||||
"collectionGroup": "alert_events",
|
"collectionGroup": "alert_events",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
|
||||||
{ "fieldPath": "acknowledged", "order": "ASCENDING" },
|
{ "fieldPath": "acknowledged", "order": "ASCENDING" },
|
||||||
{ "fieldPath": "triggered_at", "order": "ASCENDING" }
|
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
||||||
|
{ "fieldPath": "triggered_at", "order": "DESCENDING" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user