Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f4d684966 | ||
|
|
bd04bdbd69 | ||
|
|
775244bbde | ||
|
|
bc3251e8df | ||
|
|
7a5bd5dbbb | ||
|
|
629bd1c340 | ||
|
|
cea094d66b | ||
|
|
01c146e21e | ||
|
|
8a0412b529 | ||
|
|
52edbf105c | ||
|
|
77f1d2f93f | ||
|
|
d60fef67ad | ||
|
|
fe643924c7 | ||
|
|
bccb3e0316 | ||
|
|
1a631d65d0 | ||
|
|
3a944f35c1 | ||
|
|
0712e7a437 | ||
|
|
a739fa64f0 | ||
|
|
7189ba03e4 | ||
|
|
d67b2057e6 | ||
|
|
ef1e3d7f9d | ||
|
|
c1c3e89e1d |
@@ -63,6 +63,7 @@ jobs:
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
|
||||
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
|
||||
NEXT_PUBLIC_MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
|
||||
deploy:
|
||||
name: Deploy to VM
|
||||
@@ -99,6 +100,28 @@ jobs:
|
||||
# Update compose files + mosquitto config
|
||||
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
|
||||
# a bad deploy has something concrete to fall back to. This reads
|
||||
# from a state file rather than re-deriving it from git log,
|
||||
|
||||
@@ -33,6 +33,13 @@ SUMMARY_INTERVAL_MINUTES=15
|
||||
CORRELATION_WINDOW_HOURS=4
|
||||
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
|
||||
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
|
||||
# Generate with: openssl rand -hex 32
|
||||
|
||||
@@ -180,16 +180,18 @@ class Settings(BaseSettings):
|
||||
# between genuinely separate transmissions on a busy dispatch channel.
|
||||
duplicate_window_seconds: int = 10
|
||||
|
||||
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||
# Defaults to "*" for local development only.
|
||||
# Browser origins allowed to call this API cross-origin. The only browser
|
||||
# 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
|
||||
# allow_credentials when it sees a wildcard, because Starlette would
|
||||
# otherwise reflect each caller's origin back WITH
|
||||
# Access-Control-Allow-Credentials. So a production deployment that
|
||||
# forgets to set this gets a loud ERROR at startup and loses credentialed
|
||||
# cross-origin requests, rather than silently accepting every origin.
|
||||
cors_origins: list[str] = ["*"]
|
||||
# A "*" entry here still works for local dev but is refused a credentialed
|
||||
# response: main.py never enables allow_credentials (auth is a Bearer
|
||||
# header, not a cookie), and it logs a loud ERROR when it sees a wildcard
|
||||
# in a deployment so a forgotten override is visible.
|
||||
cors_origins: list[str] = ["https://drb.cusano.net"]
|
||||
|
||||
# Discord webhook URL that app/internal/ai_health.py posts to when an AI
|
||||
# tier (transcription/correlation) transitions into or out of degraded
|
||||
|
||||
@@ -108,16 +108,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]:
|
||||
"""
|
||||
Extract normalised road/route identifiers from a location string.
|
||||
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 {
|
||||
re.sub(r"[\s.\-]+", " ", m.group().lower()).strip()
|
||||
for m in _ROAD_RE.finditer(text)
|
||||
}
|
||||
ids: set[str] = set()
|
||||
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:
|
||||
@@ -670,6 +685,7 @@ async def correlate_call(
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
transcript: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Link call_id to an existing incident or create a new one.
|
||||
@@ -686,7 +702,7 @@ async def correlate_call(
|
||||
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
reassignment=reassignment, create_if_new=create_if_new,
|
||||
embedding=embedding, severity=severity,
|
||||
embedding=embedding, severity=severity, transcript=transcript,
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
return await _apply_and_log(decision, ctx)
|
||||
@@ -710,6 +726,7 @@ async def preview_correlation(
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
transcript: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Run the rules engine and return the decision WITHOUT committing to Firestore.
|
||||
@@ -730,7 +747,7 @@ async def preview_correlation(
|
||||
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
reassignment=reassignment, create_if_new=create_if_new,
|
||||
embedding=embedding, severity=severity,
|
||||
embedding=embedding, severity=severity, transcript=transcript,
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
return {"decision": decision, "ctx": ctx}
|
||||
@@ -765,6 +782,7 @@ async def _build_context(
|
||||
create_if_new: bool,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
transcript: Optional[str] = None,
|
||||
) -> dict:
|
||||
now = reference_time or datetime.now(timezone.utc)
|
||||
window = timedelta(hours=settings.correlation_window_hours)
|
||||
@@ -804,6 +822,13 @@ async def _build_context(
|
||||
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
||||
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||
call_severity = severity or "routine"
|
||||
# The transcript the LLM correlation tier reasons over. Prefer the SCENE's
|
||||
# own words (server-26#102) — passed by upload.py's scene loop — and fall
|
||||
# back to the call doc only when no scene text was supplied (the
|
||||
# recorrelation sweep, and single-scene calls where the two are identical).
|
||||
# Without this, every non-primary scene of a multi-scene call was judged by
|
||||
# the LLM against a transcript containing the OTHER scenes.
|
||||
scene_transcript = transcript or call_doc.get("transcript_corrected") or call_doc.get("transcript")
|
||||
# A string that is not a place is not a location anywhere downstream — not
|
||||
# in the fit tests, not in the thin-call test, not in the LLM prompt, and
|
||||
# not on the incident. Its coordinates go with it: coords are geocoded
|
||||
@@ -826,6 +851,7 @@ async def _build_context(
|
||||
return {
|
||||
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
|
||||
"call_doc": call_doc, "call_embedding": call_embedding,
|
||||
"scene_transcript": scene_transcript,
|
||||
"call_units": call_units, "call_vehicles": call_vehicles,
|
||||
"call_cleared": call_cleared, "call_severity": call_severity,
|
||||
"coords": coords, "is_thin_call": is_thin_call, "now": now,
|
||||
|
||||
@@ -172,7 +172,7 @@ async def extract_scenes(
|
||||
|
||||
Each scene dict contains:
|
||||
tags, incident_type, location, location_coords, resolved,
|
||||
severity, vehicles, units, transcript_corrected,
|
||||
severity, vehicles, units, transcript, transcript_corrected,
|
||||
segment_indices, embedding
|
||||
|
||||
Side-effect: updates calls/{call_id} in Firestore with merged tags,
|
||||
@@ -337,6 +337,10 @@ async def extract_scenes(
|
||||
)
|
||||
embedding = await asyncio.to_thread(_sync_embed, scene_text)
|
||||
|
||||
scene_transcript = _scene_transcript_text(
|
||||
transcript, segments, segment_indices, transcript_corrected
|
||||
)
|
||||
|
||||
processed.append({
|
||||
"tags": tags,
|
||||
"incident_type": incident_type,
|
||||
@@ -348,6 +352,7 @@ async def extract_scenes(
|
||||
"severity": severity,
|
||||
"resolved": resolved,
|
||||
"reassignment": reassignment,
|
||||
"transcript": scene_transcript,
|
||||
"transcript_corrected": transcript_corrected,
|
||||
"segment_indices": segment_indices,
|
||||
"embedding": embedding,
|
||||
@@ -571,11 +576,49 @@ def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
|
||||
def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
|
||||
"""Format transcript as numbered transmissions if segments are available."""
|
||||
if segments and len(segments) > 1:
|
||||
lines = [f"{i+1}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)]
|
||||
# 0-based labels, matching the prompt's "0-based indices into the
|
||||
# numbered transmissions" — the model echoes these back as
|
||||
# `segment_indices`, which _build_scene_embed_text and the per-scene
|
||||
# `transcript` (server-26#102) then slice with directly.
|
||||
lines = [f"{i}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)]
|
||||
return f"Transmissions ({len(segments)}):\n" + "\n".join(lines)
|
||||
return f"Transcript:\n{transcript}"
|
||||
|
||||
|
||||
def _scene_transcript_text(
|
||||
transcript: str,
|
||||
segments: Optional[list[dict]],
|
||||
segment_indices: Optional[list[int]],
|
||||
transcript_corrected: Optional[str],
|
||||
) -> str:
|
||||
"""
|
||||
This scene's own words, unprefixed — the segments it owns, joined.
|
||||
|
||||
server-26#102: the correlator's LLM tier reads this per scene instead of
|
||||
the call doc's whole-call transcript, so on a multi-scene call scene N is
|
||||
no longer judged against scenes 1..N-1's text.
|
||||
|
||||
Never returns "". Anything that would leave the slice empty — no
|
||||
`segment_indices` (a single-segment call is never numbered by
|
||||
`_build_transcript_block`), or indices that are out of range / not ints —
|
||||
falls back to the whole-call transcript, which for a single-scene call is
|
||||
the same text and for a mis-sliced multi-scene call is at least this
|
||||
call's own words. `_sync_extract`'s prompt documents 0-based indices and
|
||||
`_build_transcript_block` numbers to match, so no base normalisation here.
|
||||
"""
|
||||
if transcript_corrected:
|
||||
return transcript_corrected
|
||||
if segments and segment_indices:
|
||||
joined = " ".join(
|
||||
segments[i]["text"]
|
||||
for i in segment_indices
|
||||
if isinstance(i, int) and 0 <= i < len(segments)
|
||||
)
|
||||
if joined:
|
||||
return joined
|
||||
return transcript
|
||||
|
||||
|
||||
def _build_scene_embed_text(
|
||||
transcript: str,
|
||||
segments: Optional[list[dict]],
|
||||
|
||||
@@ -45,7 +45,18 @@ def _fmt_idle(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 '?'}"]
|
||||
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"):
|
||||
parts.append(f"loc:{inc['location']}")
|
||||
units = inc.get("units") or []
|
||||
@@ -61,7 +72,13 @@ def _inc_summary(inc: dict, now: datetime) -> str:
|
||||
def _call_block(ctx: dict) -> str:
|
||||
lines = []
|
||||
call_doc = ctx["call_doc"]
|
||||
transcript = call_doc.get("transcript_corrected") or call_doc.get("transcript")
|
||||
# The SCENE's own transcript, resolved in _build_context (server-26#102).
|
||||
# Falls back to the call doc for a ctx built without a scene (tests, sweep).
|
||||
transcript = (
|
||||
ctx.get("scene_transcript")
|
||||
or call_doc.get("transcript_corrected")
|
||||
or call_doc.get("transcript")
|
||||
)
|
||||
if transcript:
|
||||
lines.append(f"Transcript: {transcript[:700]}")
|
||||
if ctx["tags"]:
|
||||
@@ -74,19 +91,50 @@ def _call_block(ctx: dict) -> str:
|
||||
lines.append(f"Units: {ctx['call_units']}")
|
||||
if ctx["call_vehicles"]:
|
||||
lines.append(f"Vehicles: {ctx['call_vehicles']}")
|
||||
if ctx["talkgroup_name"]:
|
||||
lines.append(f"Talkgroup: {ctx['talkgroup_name']}")
|
||||
if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None:
|
||||
# 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)"
|
||||
|
||||
|
||||
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>"}'
|
||||
|
||||
_RULES = """
|
||||
Rules:
|
||||
- "link" only with clear positive evidence: same units, same geocoded location, or semantically identical scene on the same talkgroup within the last few minutes.
|
||||
- A call on a DIFFERENT talkgroup than an incident requires unit overlap or geocoded location match — topic similarity alone is not enough.
|
||||
- "new" only if the call has a clear incident_type AND describes a distinct, identifiable scene.
|
||||
- "orphan" when in doubt — conservative is always correct.
|
||||
Rules (this system OVER-SPLITS — a real incident routinely gets shattered into
|
||||
5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure
|
||||
mode. Bias accordingly.):
|
||||
- Prefer "link" when the call plausibly continues a recent incident ON THE SAME
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -95,7 +143,7 @@ def _build_decide_prompt(ctx: dict) -> str:
|
||||
now = ctx["now"]
|
||||
recent = ctx["recent"]
|
||||
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)"
|
||||
)
|
||||
return (
|
||||
@@ -113,7 +161,7 @@ def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict)
|
||||
now = ctx["now"]
|
||||
recent = ctx["recent"]
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
||||
cleared_units = call.get("cleared_units") or [],
|
||||
embedding = call.get("embedding"),
|
||||
severity = call.get("severity"),
|
||||
transcript = call.get("transcript_corrected") or call.get("transcript"),
|
||||
reference_time = started_at, # anchor window to when the call happened
|
||||
create_if_new = False, # never create — link-only
|
||||
)
|
||||
|
||||
+24
-17
@@ -78,33 +78,40 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
|
||||
|
||||
# "*" plus allow_credentials=True is not the permissive-but-harmless setting it
|
||||
# looks like. Starlette does not refuse the combination -- it reflects the
|
||||
# caller's Origin back and still sends Access-Control-Allow-Credentials: true,
|
||||
# so the effective policy becomes "any origin, with credentials", the opposite
|
||||
# of what a wildcard normally means. Rather than trust every deployment to
|
||||
# remember to override CORS_ORIGINS, make the dangerous pair unrepresentable.
|
||||
# The browser needs CORS to reach this API at all: the frontend's Archive page
|
||||
# calls GET /calls/search with Authorization + Content-Type headers, which
|
||||
# forces a preflight. Without this middleware the OPTIONS gets a bare 405 and
|
||||
# the fetch fails (#110). allow_origins is an explicit list -- never "*" in a
|
||||
# deployment -- so name every host the frontend is served from in CORS_ORIGINS.
|
||||
#
|
||||
# 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:
|
||||
"""False when any entry is a wildcard. Extracted so it can be tested
|
||||
without re-importing this module, which drags in every router."""
|
||||
return "*" not in origins
|
||||
"""Always False -- credentialed CORS is never enabled here (Bearer auth,
|
||||
not cookies). Kept as a named predicate so a future edit that wants to
|
||||
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:
|
||||
logger.error(
|
||||
"CORS_ORIGINS is '*', so credentialed cross-origin requests are being "
|
||||
"DISABLED to avoid reflecting every caller's origin back with "
|
||||
"Access-Control-Allow-Credentials. Set CORS_ORIGINS to your frontend "
|
||||
"origin(s) in production, e.g. [\"https://app.example.com\"]."
|
||||
"CORS_ORIGINS contains '*'. That is fine for local dev but is almost "
|
||||
"certainly a misconfigured deployment -- set CORS_ORIGINS to your "
|
||||
"frontend origin(s), e.g. [\"https://drb.cusano.net\"]."
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=not _cors_is_wildcard,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["authorization", "content-type"],
|
||||
allow_credentials=False,
|
||||
)
|
||||
|
||||
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
|
||||
@@ -116,6 +116,7 @@ async def _correlate_with_consensus(
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
transcript: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
|
||||
@@ -133,7 +134,7 @@ async def _correlate_with_consensus(
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
location_coords=location_coords, units=units, vehicles=vehicles,
|
||||
cleared_units=cleared_units, reassignment=reassignment,
|
||||
embedding=embedding, severity=severity,
|
||||
embedding=embedding, severity=severity, transcript=transcript,
|
||||
)
|
||||
ctx = preview["ctx"]
|
||||
rules_decision = preview["decision"]
|
||||
@@ -226,6 +227,7 @@ async def _run_extraction_pipeline(
|
||||
reassignment=is_reassignment,
|
||||
embedding=scene.get("embedding"),
|
||||
severity=scene.get("severity"),
|
||||
transcript=scene.get("transcript"),
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
@@ -343,6 +345,7 @@ async def _run_intelligence_pipeline(
|
||||
reassignment=is_reassignment,
|
||||
embedding=scene.get("embedding"),
|
||||
severity=scene.get("severity"),
|
||||
transcript=scene.get("transcript"),
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
|
||||
@@ -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
|
||||
Access-Control-Allow-Origin and still sends
|
||||
Access-Control-Allow-Credentials: true, so the effective policy is the
|
||||
opposite of what a wildcard usually means. main.py defuses that by turning
|
||||
credentials off whenever it sees a wildcard; these tests hold it to that.
|
||||
opposite of what a wildcard usually means. main.py never enables
|
||||
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 --
|
||||
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
|
||||
|
||||
|
||||
def test_named_origins_keep_credentials():
|
||||
# Naming your origins is how you ask for credentialed requests, so a
|
||||
# correctly configured deployment must not be penalised.
|
||||
assert cors_allows_credentials(["https://app.example.com"]) is True
|
||||
assert cors_allows_credentials([]) is True
|
||||
def test_credentials_never_enabled_even_for_named_origins():
|
||||
# Auth here is a Bearer header, not a cookie, so credentialed CORS is
|
||||
# never needed. The predicate is hard-off regardless of the origin list.
|
||||
assert cors_allows_credentials(["https://app.example.com"]) is False
|
||||
assert cors_allows_credentials([]) is False
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -304,6 +304,39 @@ async def test_a_scene_is_judged_on_its_own_embedding_and_severity():
|
||||
assert ctx["call_severity"] == "major"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_llm_tier_reads_the_scene_transcript_not_the_whole_call():
|
||||
"""
|
||||
server-26#102. intelligence.py writes only the primary scene's corrected
|
||||
text to calls/{id}. _call_block (the LLM correlation prompt) must reason
|
||||
over the SCENE being correlated, not a whole-call transcript that also
|
||||
contains the other scenes. _build_context threads the scene's text in;
|
||||
with no scene text it falls back to the call doc (sweep / single-scene).
|
||||
"""
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(return_value={
|
||||
"transcript": "scene one about a fire. scene two about a traffic stop.",
|
||||
})
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
scene = await _build_context(
|
||||
call_id="call-1", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
transcript="scene two about a traffic stop.",
|
||||
)
|
||||
fallback = await _build_context(
|
||||
call_id="call-1", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
)
|
||||
assert scene["scene_transcript"] == "scene two about a traffic stop."
|
||||
assert fallback["scene_transcript"] == "scene one about a fire. scene two about a traffic stop."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bare_number_never_becomes_an_incident_location_or_title():
|
||||
inc = await _create(tags=["flames"], location="49", coords=None,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
server-26#102 — a scene is correlated on its OWN transcript, not the whole call.
|
||||
|
||||
_scene_transcript_text slices the segments a scene owns. It must never return
|
||||
"" (an empty slice would let incident_correlator._build_context fall back to
|
||||
the call doc's whole-call transcript, re-opening the leak in exactly the case
|
||||
— bad indices — where it matters).
|
||||
"""
|
||||
from app.internal.intelligence import _scene_transcript_text
|
||||
|
||||
SEGS = [
|
||||
{"text": "structure fire, 12 Main"},
|
||||
{"text": "engine 4 responding"},
|
||||
{"text": "traffic stop, plate ABC"},
|
||||
{"text": "one occupant"},
|
||||
]
|
||||
WHOLE = "structure fire, 12 Main engine 4 responding traffic stop, plate ABC one occupant"
|
||||
|
||||
|
||||
def test_scene_owns_a_subset_of_segments():
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [0, 1], None) == "structure fire, 12 Main engine 4 responding"
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [2, 3], None) == "traffic stop, plate ABC one occupant"
|
||||
|
||||
|
||||
def test_corrected_text_wins_when_present():
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [0], "cleaned up text") == "cleaned up text"
|
||||
|
||||
|
||||
def test_no_segment_indices_falls_back_to_whole_call():
|
||||
# single-segment calls are never numbered by _build_transcript_block → null indices
|
||||
assert _scene_transcript_text(WHOLE, SEGS, None, None) == WHOLE
|
||||
assert _scene_transcript_text(WHOLE, None, [0, 1], None) == WHOLE
|
||||
|
||||
|
||||
def test_out_of_range_or_nonint_indices_fall_back_never_empty():
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [9, 10], None) == WHOLE # all out of range
|
||||
assert _scene_transcript_text(WHOLE, SEGS, ["1", "2"], None) == WHOLE # 1-based strings, rejected
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [-1], None) == WHOLE # negative
|
||||
# partial validity: keep what's in range
|
||||
assert _scene_transcript_text(WHOLE, SEGS, [3, 99], None) == "one occupant"
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useAlerts } from "@/lib/useAlerts";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
@@ -32,8 +32,8 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load on first render of this tab
|
||||
if (!loaded) { load(); }
|
||||
// Load once when this tab mounts (load() self-guards on `loaded`).
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -100,6 +100,7 @@ export default function IncidentDetailPage() {
|
||||
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
||||
const unitsActive = incident.units_active ?? incident.units ?? [];
|
||||
const unitsCleared = incident.units_cleared ?? [];
|
||||
const vehicles = incident.vehicles ?? [];
|
||||
const active = incident.status === "active";
|
||||
|
||||
const visible = newestFirst.slice(0, earlierShown);
|
||||
@@ -213,11 +214,11 @@ export default function IncidentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{incident.vehicles?.length > 0 && (
|
||||
{vehicles.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ function DiscordJoinModal({
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<form
|
||||
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>
|
||||
<div>
|
||||
@@ -120,7 +120,10 @@ export default function NodeDetailPage() {
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
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 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">
|
||||
{pending.map((n) => (
|
||||
<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>
|
||||
|
||||
@@ -22,7 +22,9 @@ function TripCard({ trip, isAdmin, onDelete }: {
|
||||
}) {
|
||||
const router = useRouter();
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -97,10 +99,10 @@ function CreateModal({ onClose, onCreate }: {
|
||||
}
|
||||
|
||||
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
|
||||
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>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function fmtClock(s: number): string {
|
||||
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 [error, setError] = 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 audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
if (!hasAudio) return null;
|
||||
|
||||
async function ensureUrl() {
|
||||
if (url || loading) return;
|
||||
setLoading(true);
|
||||
@@ -169,7 +167,7 @@ export function CallSpineEntry({
|
||||
|
||||
{hasAudio && (
|
||||
<div className="mt-1.5">
|
||||
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} />
|
||||
<InlinePlayer callId={call.call_id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -25,15 +25,15 @@ L.Icon.Default.mergeOptions({
|
||||
});
|
||||
|
||||
// ── Basemap tiles ─────────────────────────────────────────────────────────────
|
||||
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
|
||||
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
|
||||
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
|
||||
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
|
||||
// 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://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||
const MAP_TILE_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>';
|
||||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
const MAP_TILE_ATTRIBUTION = "© OpenStreetMap contributors";
|
||||
|
||||
// ── Colour ────────────────────────────────────────────────────────────────────
|
||||
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
||||
@@ -459,9 +459,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agoClock, setAgoClock] = useState(0);
|
||||
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(() => {
|
||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||
@@ -474,15 +471,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
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
|
||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||
|
||||
@@ -623,13 +611,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
)}
|
||||
</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. ──── */}
|
||||
<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">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
||||
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
||||
@@ -673,15 +656,19 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||
{incidents.length > 0 && (
|
||||
<>
|
||||
{/* Desktop: left sidebar — starts below zoom controls + 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">
|
||||
{/* Desktop: left sidebar — offset below the zoom stack + fit-all button
|
||||
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
|
||||
unit counts are pipeline output. Pinned above the scroll area
|
||||
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" />
|
||||
</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) => {
|
||||
const color = severityColor(inc.severity);
|
||||
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 {
|
||||
node: NodeRecord;
|
||||
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
|
||||
? new Date(node.last_seen).toLocaleTimeString()
|
||||
: "never";
|
||||
|
||||
return (
|
||||
<Link href={`/nodes/${node.node_id}`}>
|
||||
const body = (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
@@ -58,6 +63,7 @@ export function NodeCard({ node, system }: Props) {
|
||||
</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);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono">
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||
<p className="text-gray-400 text-sm mb-5">
|
||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||
|
||||
@@ -143,8 +143,9 @@ export interface IncidentRecord {
|
||||
call_ids: string[];
|
||||
system_ids: string[];
|
||||
talkgroup_ids: string[];
|
||||
units: string[];
|
||||
vehicles: string[];
|
||||
/** Omitted on incident docs written before these fields existed. */
|
||||
units?: string[];
|
||||
vehicles?: string[];
|
||||
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
||||
units_active?: string[];
|
||||
/** 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).",
|
||||
"//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.",
|
||||
"//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.",
|
||||
"//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-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": [
|
||||
{
|
||||
"//": "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",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "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",
|
||||
"queryScope": "COLLECTION",
|
||||
"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",
|
||||
"queryScope": "COLLECTION",
|
||||
"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",
|
||||
"queryScope": "COLLECTION",
|
||||
"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",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "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",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "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",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "org_id", "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