Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc3251e8df | ||
|
|
7a5bd5dbbb | ||
|
|
629bd1c340 | ||
|
|
cea094d66b | ||
|
|
01c146e21e | ||
|
|
8a0412b529 | ||
|
|
52edbf105c | ||
|
|
77f1d2f93f | ||
|
|
d60fef67ad | ||
|
|
fe643924c7 | ||
|
|
bccb3e0316 | ||
|
|
1a631d65d0 | ||
|
|
3a944f35c1 | ||
|
|
0712e7a437 | ||
|
|
a739fa64f0 | ||
|
|
7189ba03e4 | ||
|
|
ef1e3d7f9d |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -180,16 +180,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
|
||||||
|
|||||||
@@ -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]:
|
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:
|
||||||
@@ -670,6 +685,7 @@ async def correlate_call(
|
|||||||
reassignment: bool = False,
|
reassignment: bool = False,
|
||||||
embedding: Optional[list] = None,
|
embedding: Optional[list] = None,
|
||||||
severity: Optional[str] = None,
|
severity: Optional[str] = None,
|
||||||
|
transcript: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Link call_id to an existing incident or create a new one.
|
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,
|
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||||
tags=tags, incident_type=incident_type, location=location,
|
tags=tags, incident_type=incident_type, location=location,
|
||||||
reassignment=reassignment, create_if_new=create_if_new,
|
reassignment=reassignment, create_if_new=create_if_new,
|
||||||
embedding=embedding, severity=severity,
|
embedding=embedding, severity=severity, transcript=transcript,
|
||||||
)
|
)
|
||||||
decision = _run_decision(ctx)
|
decision = _run_decision(ctx)
|
||||||
return await _apply_and_log(decision, ctx)
|
return await _apply_and_log(decision, ctx)
|
||||||
@@ -710,6 +726,7 @@ async def preview_correlation(
|
|||||||
reassignment: bool = False,
|
reassignment: bool = False,
|
||||||
embedding: Optional[list] = None,
|
embedding: Optional[list] = None,
|
||||||
severity: Optional[str] = None,
|
severity: Optional[str] = None,
|
||||||
|
transcript: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Run the rules engine and return the decision WITHOUT committing to Firestore.
|
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,
|
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||||
tags=tags, incident_type=incident_type, location=location,
|
tags=tags, incident_type=incident_type, location=location,
|
||||||
reassignment=reassignment, create_if_new=create_if_new,
|
reassignment=reassignment, create_if_new=create_if_new,
|
||||||
embedding=embedding, severity=severity,
|
embedding=embedding, severity=severity, transcript=transcript,
|
||||||
)
|
)
|
||||||
decision = _run_decision(ctx)
|
decision = _run_decision(ctx)
|
||||||
return {"decision": decision, "ctx": ctx}
|
return {"decision": decision, "ctx": ctx}
|
||||||
@@ -765,6 +782,7 @@ async def _build_context(
|
|||||||
create_if_new: bool,
|
create_if_new: bool,
|
||||||
embedding: Optional[list] = None,
|
embedding: Optional[list] = None,
|
||||||
severity: Optional[str] = None,
|
severity: Optional[str] = None,
|
||||||
|
transcript: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
now = reference_time or datetime.now(timezone.utc)
|
now = reference_time or datetime.now(timezone.utc)
|
||||||
window = timedelta(hours=settings.correlation_window_hours)
|
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_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_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||||
call_severity = severity or "routine"
|
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
|
# 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
|
# 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
|
# not on the incident. Its coordinates go with it: coords are geocoded
|
||||||
@@ -826,6 +851,7 @@ async def _build_context(
|
|||||||
return {
|
return {
|
||||||
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
|
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
|
||||||
"call_doc": call_doc, "call_embedding": call_embedding,
|
"call_doc": call_doc, "call_embedding": call_embedding,
|
||||||
|
"scene_transcript": scene_transcript,
|
||||||
"call_units": call_units, "call_vehicles": call_vehicles,
|
"call_units": call_units, "call_vehicles": call_vehicles,
|
||||||
"call_cleared": call_cleared, "call_severity": call_severity,
|
"call_cleared": call_cleared, "call_severity": call_severity,
|
||||||
"coords": coords, "is_thin_call": is_thin_call, "now": now,
|
"coords": coords, "is_thin_call": is_thin_call, "now": now,
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ async def extract_scenes(
|
|||||||
|
|
||||||
Each scene dict contains:
|
Each scene dict contains:
|
||||||
tags, incident_type, location, location_coords, resolved,
|
tags, incident_type, location, location_coords, resolved,
|
||||||
severity, vehicles, units, transcript_corrected,
|
severity, vehicles, units, transcript, transcript_corrected,
|
||||||
segment_indices, embedding
|
segment_indices, embedding
|
||||||
|
|
||||||
Side-effect: updates calls/{call_id} in Firestore with merged tags,
|
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)
|
embedding = await asyncio.to_thread(_sync_embed, scene_text)
|
||||||
|
|
||||||
|
scene_transcript = _scene_transcript_text(
|
||||||
|
transcript, segments, segment_indices, transcript_corrected
|
||||||
|
)
|
||||||
|
|
||||||
processed.append({
|
processed.append({
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"incident_type": incident_type,
|
"incident_type": incident_type,
|
||||||
@@ -348,6 +352,7 @@ async def extract_scenes(
|
|||||||
"severity": severity,
|
"severity": severity,
|
||||||
"resolved": resolved,
|
"resolved": resolved,
|
||||||
"reassignment": reassignment,
|
"reassignment": reassignment,
|
||||||
|
"transcript": scene_transcript,
|
||||||
"transcript_corrected": transcript_corrected,
|
"transcript_corrected": transcript_corrected,
|
||||||
"segment_indices": segment_indices,
|
"segment_indices": segment_indices,
|
||||||
"embedding": embedding,
|
"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:
|
def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
|
||||||
"""Format transcript as numbered transmissions if segments are available."""
|
"""Format transcript as numbered transmissions if segments are available."""
|
||||||
if segments and len(segments) > 1:
|
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"Transmissions ({len(segments)}):\n" + "\n".join(lines)
|
||||||
return f"Transcript:\n{transcript}"
|
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(
|
def _build_scene_embed_text(
|
||||||
transcript: str,
|
transcript: str,
|
||||||
segments: Optional[list[dict]],
|
segments: Optional[list[dict]],
|
||||||
|
|||||||
@@ -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 []
|
||||||
@@ -61,7 +72,13 @@ def _inc_summary(inc: dict, now: datetime) -> str:
|
|||||||
def _call_block(ctx: dict) -> str:
|
def _call_block(ctx: dict) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
call_doc = ctx["call_doc"]
|
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:
|
if transcript:
|
||||||
lines.append(f"Transcript: {transcript[:700]}")
|
lines.append(f"Transcript: {transcript[:700]}")
|
||||||
if ctx["tags"]:
|
if ctx["tags"]:
|
||||||
@@ -74,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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -95,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 (
|
||||||
@@ -113,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)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
|||||||
cleared_units = call.get("cleared_units") or [],
|
cleared_units = call.get("cleared_units") or [],
|
||||||
embedding = call.get("embedding"),
|
embedding = call.get("embedding"),
|
||||||
severity = call.get("severity"),
|
severity = call.get("severity"),
|
||||||
|
transcript = call.get("transcript_corrected") or call.get("transcript"),
|
||||||
reference_time = started_at, # anchor window to when the call happened
|
reference_time = started_at, # anchor window to when the call happened
|
||||||
create_if_new = False, # never create — link-only
|
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)
|
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)])
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ async def _correlate_with_consensus(
|
|||||||
reassignment: bool = False,
|
reassignment: bool = False,
|
||||||
embedding: Optional[list] = None,
|
embedding: Optional[list] = None,
|
||||||
severity: Optional[str] = None,
|
severity: Optional[str] = None,
|
||||||
|
transcript: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
|
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,
|
tags=tags, incident_type=incident_type, location=location,
|
||||||
location_coords=location_coords, units=units, vehicles=vehicles,
|
location_coords=location_coords, units=units, vehicles=vehicles,
|
||||||
cleared_units=cleared_units, reassignment=reassignment,
|
cleared_units=cleared_units, reassignment=reassignment,
|
||||||
embedding=embedding, severity=severity,
|
embedding=embedding, severity=severity, transcript=transcript,
|
||||||
)
|
)
|
||||||
ctx = preview["ctx"]
|
ctx = preview["ctx"]
|
||||||
rules_decision = preview["decision"]
|
rules_decision = preview["decision"]
|
||||||
@@ -226,6 +227,7 @@ async def _run_extraction_pipeline(
|
|||||||
reassignment=is_reassignment,
|
reassignment=is_reassignment,
|
||||||
embedding=scene.get("embedding"),
|
embedding=scene.get("embedding"),
|
||||||
severity=scene.get("severity"),
|
severity=scene.get("severity"),
|
||||||
|
transcript=scene.get("transcript"),
|
||||||
)
|
)
|
||||||
if incident_id and incident_id not in incident_ids:
|
if incident_id and incident_id not in incident_ids:
|
||||||
incident_ids.append(incident_id)
|
incident_ids.append(incident_id)
|
||||||
@@ -343,6 +345,7 @@ async def _run_intelligence_pipeline(
|
|||||||
reassignment=is_reassignment,
|
reassignment=is_reassignment,
|
||||||
embedding=scene.get("embedding"),
|
embedding=scene.get("embedding"),
|
||||||
severity=scene.get("severity"),
|
severity=scene.get("severity"),
|
||||||
|
transcript=scene.get("transcript"),
|
||||||
)
|
)
|
||||||
if incident_id and incident_id not in incident_ids:
|
if incident_id and incident_id not in incident_ids:
|
||||||
incident_ids.append(incident_id)
|
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
|
`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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,39 @@ async def test_a_scene_is_judged_on_its_own_embedding_and_severity():
|
|||||||
assert ctx["call_severity"] == "major"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_a_bare_number_never_becomes_an_incident_location_or_title():
|
async def test_a_bare_number_never_becomes_an_incident_location_or_title():
|
||||||
inc = await _create(tags=["flames"], location="49", coords=None,
|
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";
|
"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();
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ L.Icon.Default.mergeOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Basemap tiles ─────────────────────────────────────────────────────────────
|
// ── Basemap tiles ─────────────────────────────────────────────────────────────
|
||||||
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
|
// Prod sets NEXT_PUBLIC_MAP_TILE_URL to a keyed style (a CARTO account style,
|
||||||
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
|
// MapTiler, Mapbox, …). The in-code fallback is plain OpenStreetMap so the map
|
||||||
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
|
// 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.
|
// 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 =
|
const MAP_TILE_URL =
|
||||||
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
|
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
|
||||||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
"https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||||
const MAP_TILE_ATTRIBUTION =
|
const MAP_TILE_ATTRIBUTION = "© OpenStreetMap contributors";
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>';
|
|
||||||
|
|
||||||
// ── 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
|
||||||
@@ -459,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);
|
||||||
@@ -474,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]);
|
||||||
|
|
||||||
@@ -623,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) => (
|
||||||
@@ -673,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;
|
||||||
|
|||||||
@@ -75,6 +75,16 @@
|
|||||||
{ "fieldPath": "acknowledged", "order": "ASCENDING" },
|
{ "fieldPath": "acknowledged", "order": "ASCENDING" },
|
||||||
{ "fieldPath": "triggered_at", "order": "ASCENDING" }
|
{ "fieldPath": "triggered_at", "order": "ASCENDING" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"//": "drb-frontend lib/useAlerts.ts useUnacknowledgedAlerts (nav badge) and the /watch \"Triggered Alerts\" tab — where(org_id ==) where(acknowledged == false) orderBy(triggered_at desc). Threw \"the query requires an index\" on every page until this was declared (server-26#51). Field tuple and triggered_at DESCENDING copy the console create_composite link in that issue verbatim, so a gcloud/console create and a firebase deploy converge on one index rather than the ASC-vs-DESC pair that caused server-26#33. Distinct from the (org_id, triggered_at) alert-feed index above (no acknowledged filter) and supersedes the pre-tenancy live alert_events(acknowledged, triggered_at) index #33 says to delete.",
|
||||||
|
"collectionGroup": "alert_events",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldPath": "acknowledged", "order": "ASCENDING" },
|
||||||
|
{ "fieldPath": "org_id", "order": "ASCENDING" },
|
||||||
|
{ "fieldPath": "triggered_at", "order": "DESCENDING" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": []
|
||||||
|
|||||||
Reference in New Issue
Block a user