Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79b8bc37d | ||
|
|
c72c28f5dc | ||
|
|
02b5b7b5a5 | ||
|
|
40014a47a3 | ||
|
|
6c0e7a4f8e | ||
|
|
6479174022 | ||
|
|
c043298902 | ||
|
|
fa194e0f0a | ||
|
|
5f85a878fa | ||
|
|
241a15b8da | ||
|
|
f91d4559f3 | ||
|
|
66bbf5b473 | ||
|
|
6c095083fc | ||
|
|
2e67d1bad6 | ||
|
|
1ffff25cd2 |
+75
-24
@@ -115,27 +115,14 @@ jobs:
|
|||||||
# Update compose files + mosquitto config
|
# Update compose files + mosquitto config
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|
||||||
# server-26#51: Firestore rules + composite indexes had no deploy
|
# server-26#51: Firestore rules/indexes deploy used to be attempted
|
||||||
# path and regressed silently after every fix (the alert_events and
|
# HERE, over SSH, gated on the VM having firebase-tools installed.
|
||||||
# calls(org_id,started_at) indexes among them). The VM runs as the
|
# It never did (no node on the VM), so this silently warned and
|
||||||
# project service account, so firebase-tools authenticates via ADC
|
# skipped on every deploy for weeks -- PR #124 even auto-closed
|
||||||
# with no key file, and infra/firestore/firebase.json pins database
|
# #13/#51 as if it were fixed. Moved to a standalone
|
||||||
# c2-server. Indexes go on additively -- no --force -- so a stray
|
# deploy-firestore-rules job below that runs on the Gitea runner
|
||||||
# edit to firestore.indexes.json can never delete a live index;
|
# itself (which always has node), so it no longer depends on
|
||||||
# rules are a full replace, which is the intent. --non-interactive
|
# anything being pre-installed on this VM.
|
||||||
# means the FIRST run after a drift still needs a one-time manual
|
|
||||||
# `firebase deploy` on the VM to clear pending deletions (it aborts
|
|
||||||
# rather than guess). A failure here warns but does NOT fail the
|
|
||||||
# deploy: a transient Firebase API error must not roll back a good
|
|
||||||
# app build.
|
|
||||||
if command -v firebase >/dev/null 2>&1; then
|
|
||||||
( cd /opt/drb/infra/firestore \
|
|
||||||
&& firebase deploy --only firestore:rules,firestore:indexes \
|
|
||||||
--project ${{ secrets.FIREBASE_PROJECT_ID }} --non-interactive ) \
|
|
||||||
|| echo "WARNING: firestore deploy failed (server-26#51) -- rules/indexes may be stale"
|
|
||||||
else
|
|
||||||
echo "WARNING: firebase CLI not on the VM -- skipped firestore deploy (server-26#51); install once with: npm i -g firebase-tools"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# server-26#65: capture what is actually live BEFORE switching, so
|
# server-26#65: capture what is actually live BEFORE switching, so
|
||||||
# a bad deploy has something concrete to fall back to. This reads
|
# a bad deploy has something concrete to fall back to. This reads
|
||||||
@@ -146,7 +133,20 @@ jobs:
|
|||||||
# has confirmed the tag it names actually answered /health. A
|
# has confirmed the tag it names actually answered /health. A
|
||||||
# fresh VM with no file yet falls back to :latest, same escape
|
# fresh VM with no file yet falls back to :latest, same escape
|
||||||
# hatch as a manual `up -d` with no TAG set.
|
# hatch as a manual `up -d` with no TAG set.
|
||||||
PREV_TAG=$(cat /opt/drb/.last_good_tag 2>/dev/null || echo latest)
|
#
|
||||||
|
# server-26#156: `cat missing-file || echo latest` only falls back
|
||||||
|
# when cat itself fails (nonzero exit) -- a file that EXISTS but is
|
||||||
|
# EMPTY (the state this file was found in, 2026-09-20) makes cat
|
||||||
|
# succeed with empty output, so PREV_TAG became "" instead of
|
||||||
|
# "latest". That "" then failed the emptiness check below and
|
||||||
|
# exited 1 -- AFTER git pull + up -d had already succeeded -- which
|
||||||
|
# skips the Health check step entirely (later steps don't run after
|
||||||
|
# a failure), and Health check is the ONLY thing that ever writes a
|
||||||
|
# real value here. Self-perpetuating: every deploy failed the same
|
||||||
|
# way forever, with the app itself deploying fine underneath it.
|
||||||
|
# ${VAR:-default} covers empty AND unset in one expansion.
|
||||||
|
PREV_TAG=$(cat /opt/drb/.last_good_tag 2>/dev/null)
|
||||||
|
PREV_TAG="${PREV_TAG:-latest}"
|
||||||
echo "PREV_TAG=$PREV_TAG"
|
echo "PREV_TAG=$PREV_TAG"
|
||||||
|
|
||||||
# Deploy THIS commit's images, not :latest. Overlapping runs are
|
# Deploy THIS commit's images, not :latest. Overlapping runs are
|
||||||
@@ -291,9 +291,48 @@ jobs:
|
|||||||
echo "status=success" >> "$GITHUB_OUTPUT"
|
echo "status=success" >> "$GITHUB_OUTPUT"
|
||||||
echo "rolled_back_to=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
echo "rolled_back_to=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
deploy-firestore-rules:
|
||||||
|
name: Deploy Firestore rules & indexes
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Deliberately independent of the `deploy` job (app containers) and its
|
||||||
|
# health-check/rollback chain above: a rules/indexes deploy failure has
|
||||||
|
# nothing to roll back (there is no previous "build" of a ruleset to
|
||||||
|
# revert to via this pipeline) and must never be conflated with an app
|
||||||
|
# deploy failure by triggering that job's rollback logic. This job
|
||||||
|
# failing is its own, separate red run -- picked up by notify-failure
|
||||||
|
# below -- not a signal to touch the running containers.
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy firestore rules and indexes
|
||||||
|
env:
|
||||||
|
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# server-26#51: this used to run over SSH on the deploy VM, gated
|
||||||
|
# on the VM having firebase-tools installed. It never did, so it
|
||||||
|
# silently warned-and-skipped on every single deploy for weeks.
|
||||||
|
# Running it here instead means the only prerequisite is a secret
|
||||||
|
# -- FIREBASE_TOKEN, from `firebase login:ci` -- rather than
|
||||||
|
# something installed by hand on a machine this pipeline doesn't
|
||||||
|
# otherwise touch. A missing token now fails this job LOUDLY
|
||||||
|
# (picked up by notify-failure) instead of a buried warning line
|
||||||
|
# nobody reads in the app deploy's logs.
|
||||||
|
if [ -z "$FIREBASE_TOKEN" ]; then
|
||||||
|
echo "FIREBASE_TOKEN secret is not set -- cannot deploy Firestore rules/indexes." >&2
|
||||||
|
echo "Generate one with 'firebase login:ci' and add it as a Gitea Actions secret." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm install -g firebase-tools
|
||||||
|
cd infra/firestore
|
||||||
|
firebase deploy --only firestore:rules,firestore:indexes \
|
||||||
|
--project ${{ secrets.FIREBASE_PROJECT_ID }} \
|
||||||
|
--token "$FIREBASE_TOKEN" --non-interactive
|
||||||
|
|
||||||
notify-failure:
|
notify-failure:
|
||||||
name: Report a failed deploy
|
name: Report a failed deploy
|
||||||
needs: [build, deploy]
|
needs: [build, deploy, deploy-firestore-rules]
|
||||||
if: failure()
|
if: failure()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@@ -310,6 +349,8 @@ jobs:
|
|||||||
SHA: ${{ gitea.sha }}
|
SHA: ${{ gitea.sha }}
|
||||||
ROLLBACK_STATUS: ${{ needs.deploy.outputs.rollback_status }}
|
ROLLBACK_STATUS: ${{ needs.deploy.outputs.rollback_status }}
|
||||||
ROLLBACK_SHA: ${{ needs.deploy.outputs.rollback_sha }}
|
ROLLBACK_SHA: ${{ needs.deploy.outputs.rollback_sha }}
|
||||||
|
DEPLOY_RESULT: ${{ needs.deploy.result }}
|
||||||
|
RULES_RESULT: ${{ needs.deploy-firestore-rules.result }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$WEBHOOK" ]; then
|
if [ -z "$WEBHOOK" ]; then
|
||||||
echo "DEPLOY_ALERT_WEBHOOK is not set - skipping notification."
|
echo "DEPLOY_ALERT_WEBHOOK is not set - skipping notification."
|
||||||
@@ -321,6 +362,16 @@ jobs:
|
|||||||
run_url = os.environ["RUN_URL"]
|
run_url = os.environ["RUN_URL"]
|
||||||
status = os.environ.get("ROLLBACK_STATUS", "")
|
status = os.environ.get("ROLLBACK_STATUS", "")
|
||||||
rollback_sha = os.environ.get("ROLLBACK_SHA", "")
|
rollback_sha = os.environ.get("ROLLBACK_SHA", "")
|
||||||
|
deploy_result = os.environ.get("DEPLOY_RESULT", "")
|
||||||
|
rules_result = os.environ.get("RULES_RESULT", "")
|
||||||
|
|
||||||
|
# deploy-firestore-rules runs independent of the app deploy/rollback
|
||||||
|
# chain (see its own job comment), so its failure needs its own
|
||||||
|
# branch here -- otherwise this fell through to the generic "Build
|
||||||
|
# failed before any deploy was attempted" text even when the app
|
||||||
|
# deployed fine and only the Firestore rules/indexes push failed.
|
||||||
|
if deploy_result != "failure" and rules_result == "failure":
|
||||||
|
detail = "App deploy succeeded; Firestore rules/indexes deploy FAILED (server-26#51). Rules may be stale — check FIREBASE_TOKEN and the job log."
|
||||||
|
|
||||||
# server-26#65: the old text here unconditionally claimed
|
# server-26#65: the old text here unconditionally claimed
|
||||||
# "production is still running the previous build" -- true only
|
# "production is still running the previous build" -- true only
|
||||||
@@ -329,7 +380,7 @@ jobs:
|
|||||||
# class of bug the correlator instrumentation exists to catch), or
|
# class of bug the correlator instrumentation exists to catch), or
|
||||||
# once the deploy job's own rollback path has run. Say what
|
# once the deploy job's own rollback path has run. Say what
|
||||||
# actually happened instead.
|
# actually happened instead.
|
||||||
if status == "success":
|
elif status == "success":
|
||||||
detail = "Automatic rollback to `%s` succeeded. Production is back on the previous good build." % rollback_sha[:8]
|
detail = "Automatic rollback to `%s` succeeded. Production is back on the previous good build." % rollback_sha[:8]
|
||||||
elif status == "failed":
|
elif status == "failed":
|
||||||
detail = ("Automatic rollback to `%s` FAILED. Production state is UNKNOWN -- "
|
detail = ("Automatic rollback to `%s` FAILED. Production state is UNKNOWN -- "
|
||||||
|
|||||||
@@ -875,11 +875,17 @@ async def _build_context(
|
|||||||
is_thin_call = _is_thin_call(
|
is_thin_call = _is_thin_call(
|
||||||
call_units, call_vehicles, coords, tags, location, call_severity, reassignment
|
call_units, call_vehicles, coords, tags, location, call_severity, reassignment
|
||||||
)
|
)
|
||||||
|
# server-26#158: the P25 source radio ID. Captured on every call by the
|
||||||
|
# edge node's metadata_watcher.py independent of transcript content, so
|
||||||
|
# it survives even when transcript_too_short skips GPT extraction
|
||||||
|
# entirely and leaves call_units empty — exactly the population the
|
||||||
|
# thin-call path below has no other identity signal for.
|
||||||
|
call_srcaddr = call_doc.get("srcaddr")
|
||||||
|
|
||||||
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,
|
"scene_transcript": scene_transcript, "call_srcaddr": call_srcaddr,
|
||||||
"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,
|
||||||
@@ -933,6 +939,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
call_severity = ctx["call_severity"]
|
call_severity = ctx["call_severity"]
|
||||||
coords = ctx["coords"]
|
coords = ctx["coords"]
|
||||||
is_thin_call = ctx["is_thin_call"]
|
is_thin_call = ctx["is_thin_call"]
|
||||||
|
call_srcaddr = ctx.get("call_srcaddr")
|
||||||
system_id = ctx["system_id"]
|
system_id = ctx["system_id"]
|
||||||
talkgroup_id = ctx["talkgroup_id"]
|
talkgroup_id = ctx["talkgroup_id"]
|
||||||
talkgroup_name = ctx["talkgroup_name"]
|
talkgroup_name = ctx["talkgroup_name"]
|
||||||
@@ -1008,32 +1015,52 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
# incident idle up to tg_fast_path_idle_minutes (90) with no
|
# incident idle up to tg_fast_path_idle_minutes (90) with no
|
||||||
# single-candidate requirement and no fit test of any kind. Four
|
# single-candidate requirement and no fit test of any kind. Four
|
||||||
# hours is not a bound, and neither is ninety minutes.
|
# hours is not a bound, and neither is ninety minutes.
|
||||||
|
# server-26#158: identity beats guesswork. A thin call has no
|
||||||
|
# extracted units (GPT never ran), but it still carries the P25
|
||||||
|
# radio ID that transmitted it — stronger, cheaper evidence than
|
||||||
|
# "most recently active" and immune to the exact failure this
|
||||||
|
# path exists to guard against: two incidents both live on one
|
||||||
|
# busy dispatch channel. If the radio that sent this call already
|
||||||
|
# has calls on one of the TG-matched incidents, that IS the
|
||||||
|
# thread, regardless of which incident is more recently updated
|
||||||
|
# or how many candidates are in the window.
|
||||||
|
srcaddr_matches = [
|
||||||
|
inc for inc in tg_recent
|
||||||
|
if call_srcaddr and call_srcaddr in (inc.get("srcaddrs") or [])
|
||||||
|
]
|
||||||
THIN_CONVERSATIONAL_SECS = 30
|
THIN_CONVERSATIONAL_SECS = 30
|
||||||
thin_window_min = settings.tg_dispatch_thin_idle_minutes
|
thin_window_min = settings.tg_dispatch_thin_idle_minutes
|
||||||
very_recent = [
|
if srcaddr_matches:
|
||||||
inc for inc in tg_recent
|
thin_pool = [max(srcaddr_matches, key=lambda inc: inc.get("updated_at", ""))]
|
||||||
if _idle_gate_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
|
||||||
]
|
|
||||||
if very_recent:
|
|
||||||
# Tier 1: direct conversational reply — most recent wins.
|
|
||||||
thin_pool = [max(very_recent, key=lambda inc: inc.get("updated_at", ""))]
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator fast-path thin (tier-1, ≤{THIN_CONVERSATIONAL_SECS}s): "
|
f"Correlator fast-path thin (srcaddr match): radio {call_srcaddr} "
|
||||||
f"using most-recent of {len(very_recent)} candidate(s) for call {call_id}"
|
f"already on {len(srcaddr_matches)} candidate(s) for call {call_id}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Tier 2: less certain — require a single candidate inside the
|
very_recent = [
|
||||||
# channel's thin window.
|
|
||||||
thin_pool = [
|
|
||||||
inc for inc in tg_recent
|
inc for inc in tg_recent
|
||||||
if _idle_gate_minutes(inc, now) <= thin_window_min
|
if _idle_gate_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
||||||
]
|
]
|
||||||
if len(thin_pool) > 1:
|
if very_recent:
|
||||||
|
# Tier 1: direct conversational reply — most recent wins.
|
||||||
|
thin_pool = [max(very_recent, key=lambda inc: inc.get("updated_at", ""))]
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator fast-path thin (tier-2): {len(thin_pool)} active incidents "
|
f"Correlator fast-path thin (tier-1, ≤{THIN_CONVERSATIONAL_SECS}s): "
|
||||||
f"— ambiguous, skipping thin call {call_id}"
|
f"using most-recent of {len(very_recent)} candidate(s) for call {call_id}"
|
||||||
)
|
)
|
||||||
thin_pool = []
|
else:
|
||||||
|
# Tier 2: less certain — require a single candidate inside the
|
||||||
|
# channel's thin window.
|
||||||
|
thin_pool = [
|
||||||
|
inc for inc in tg_recent
|
||||||
|
if _idle_gate_minutes(inc, now) <= thin_window_min
|
||||||
|
]
|
||||||
|
if len(thin_pool) > 1:
|
||||||
|
logger.info(
|
||||||
|
f"Correlator fast-path thin (tier-2): {len(thin_pool)} active incidents "
|
||||||
|
f"— ambiguous, skipping thin call {call_id}"
|
||||||
|
)
|
||||||
|
thin_pool = []
|
||||||
|
|
||||||
if not thin_pool:
|
if not thin_pool:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1049,8 +1076,10 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
# no fit signal, so the admin debug view's "fit_signal
|
# no fit signal, so the admin debug view's "fit_signal
|
||||||
# distribution" panel read empty on 95% of calls and looked
|
# distribution" panel read empty on 95% of calls and looked
|
||||||
# broken. Name what actually decided it: recency on this
|
# broken. Name what actually decided it: recency on this
|
||||||
# talkgroup, with no content to check a fit against.
|
# talkgroup, with no content to check a fit against — or,
|
||||||
"corr_fit_signal": "thin_recency",
|
# when the same radio ID already touched a candidate
|
||||||
|
# (server-26#158), that identity match instead of a guess.
|
||||||
|
"corr_fit_signal": "thin_srcaddr_match" if srcaddr_matches else "thin_recency",
|
||||||
"corr_candidates": len(thin_pool),
|
"corr_candidates": len(thin_pool),
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1511,6 +1540,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
call_cleared = ctx["call_cleared"]
|
call_cleared = ctx["call_cleared"]
|
||||||
coords = ctx["coords"]
|
coords = ctx["coords"]
|
||||||
now = ctx["now"]
|
now = ctx["now"]
|
||||||
|
call_srcaddr = ctx.get("call_srcaddr")
|
||||||
incident_type = decision["incident_type"]
|
incident_type = decision["incident_type"]
|
||||||
|
|
||||||
if action == "link":
|
if action == "link":
|
||||||
@@ -1522,7 +1552,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
location, location_coords, call_units, call_vehicles, call_embedding, now,
|
location, location_coords, call_units, call_vehicles, call_embedding, now,
|
||||||
talkgroup_name=talkgroup_name, incident_type=incident_type,
|
talkgroup_name=talkgroup_name, incident_type=incident_type,
|
||||||
cleared_units=call_cleared, refresh_activity=not thin_link,
|
cleared_units=call_cleared, refresh_activity=not thin_link,
|
||||||
call_severity=call_severity,
|
call_severity=call_severity, call_srcaddr=call_srcaddr,
|
||||||
)
|
)
|
||||||
return matched_incident["incident_id"]
|
return matched_incident["incident_id"]
|
||||||
|
|
||||||
@@ -1554,6 +1584,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
||||||
tags, location, location_coords,
|
tags, location, location_coords,
|
||||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
call_units, call_vehicles, call_embedding, call_severity, now,
|
||||||
|
call_srcaddr=call_srcaddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing_master_id:
|
if existing_master_id:
|
||||||
@@ -1599,6 +1630,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
||||||
tags, location, location_coords,
|
tags, location, location_coords,
|
||||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
call_units, call_vehicles, call_embedding, call_severity, now,
|
||||||
|
call_srcaddr=call_srcaddr,
|
||||||
)
|
)
|
||||||
decision["corr_debug"]["corr_path"] = "new"
|
decision["corr_debug"]["corr_path"] = "new"
|
||||||
|
|
||||||
@@ -1945,6 +1977,7 @@ async def _update_incident(
|
|||||||
cleared_units: Optional[list[str]] = None,
|
cleared_units: Optional[list[str]] = None,
|
||||||
refresh_activity: bool = True,
|
refresh_activity: bool = True,
|
||||||
call_severity: Optional[str] = None,
|
call_severity: Optional[str] = None,
|
||||||
|
call_srcaddr: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
incident_id = inc["incident_id"]
|
incident_id = inc["incident_id"]
|
||||||
|
|
||||||
@@ -1963,6 +1996,12 @@ async def _update_incident(
|
|||||||
merged_tags = list(dict.fromkeys((inc.get("tags") or []) + tags))
|
merged_tags = list(dict.fromkeys((inc.get("tags") or []) + tags))
|
||||||
merged_units = list(dict.fromkeys((inc.get("units") or []) + call_units))
|
merged_units = list(dict.fromkeys((inc.get("units") or []) + call_units))
|
||||||
merged_vehicles = list(dict.fromkeys((inc.get("vehicles") or []) + call_vehicles))
|
merged_vehicles = list(dict.fromkeys((inc.get("vehicles") or []) + call_vehicles))
|
||||||
|
# server-26#158: accumulate every radio ID that has transmitted on this
|
||||||
|
# incident, so a later thin call from the same radio can identity-match
|
||||||
|
# instead of guessing off recency alone.
|
||||||
|
merged_srcaddrs = list(dict.fromkeys(
|
||||||
|
(inc.get("srcaddrs") or []) + ([call_srcaddr] if call_srcaddr else [])
|
||||||
|
))
|
||||||
|
|
||||||
# Unit activity tracking: units_active / units_cleared
|
# Unit activity tracking: units_active / units_cleared
|
||||||
# units_active = units currently on scene; units_cleared = units back in service
|
# units_active = units currently on scene; units_cleared = units back in service
|
||||||
@@ -1993,6 +2032,7 @@ async def _update_incident(
|
|||||||
"tags": merged_tags,
|
"tags": merged_tags,
|
||||||
"units": merged_units,
|
"units": merged_units,
|
||||||
"vehicles": merged_vehicles,
|
"vehicles": merged_vehicles,
|
||||||
|
"srcaddrs": merged_srcaddrs,
|
||||||
"units_active": units_active,
|
"units_active": units_active,
|
||||||
"units_cleared": units_cleared,
|
"units_cleared": units_cleared,
|
||||||
"location_mentions": location_mentions,
|
"location_mentions": location_mentions,
|
||||||
@@ -2059,6 +2099,7 @@ async def _create_incident(
|
|||||||
call_embedding: Optional[list],
|
call_embedding: Optional[list],
|
||||||
call_severity: str,
|
call_severity: str,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
|
call_srcaddr: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
incident_id = str(uuid.uuid4())
|
incident_id = str(uuid.uuid4())
|
||||||
tg_label = (
|
tg_label = (
|
||||||
@@ -2102,6 +2143,7 @@ async def _create_incident(
|
|||||||
"units_active": list(call_units),
|
"units_active": list(call_units),
|
||||||
"units_cleared": [],
|
"units_cleared": [],
|
||||||
"vehicles": call_vehicles,
|
"vehicles": call_vehicles,
|
||||||
|
"srcaddrs": [call_srcaddr] if call_srcaddr else [],
|
||||||
"severity": call_severity,
|
"severity": call_severity,
|
||||||
"summary": None,
|
"summary": None,
|
||||||
"summary_stale": True,
|
"summary_stale": True,
|
||||||
|
|||||||
@@ -368,18 +368,20 @@ async def extract_scenes(
|
|||||||
# the country.
|
# the country.
|
||||||
location_coords: Optional[dict] = None
|
location_coords: Optional[dict] = None
|
||||||
if location:
|
if location:
|
||||||
parts = [location]
|
node_state, node_county = "", ""
|
||||||
if tg_area.get("municipality") or tg_area.get("county") or tg_area.get("state"):
|
if not area_context.has_place(tg_area) and node_id and node_lat is not None and node_lon is not None:
|
||||||
parts += [tg_area[f] for f in area_context.PLACE_FIELDS if tg_area.get(f)]
|
# Only worth the (cached-after-first-call) reverse-geocode
|
||||||
elif node_lat is not None and node_lon is not None:
|
# when nothing better already describes this talkgroup.
|
||||||
muni = _municipality_from_tg(talkgroup_name)
|
node_state = await _get_node_state(node_id, node_lat, node_lon)
|
||||||
state = await _get_node_state(node_id or "", node_lat, node_lon) if node_id else ""
|
node_county = _node_county_cache.get(node_id) or ""
|
||||||
county = _node_county_cache.get(node_id or "") if node_id else ""
|
parts, tg_named_region = _location_query_parts(
|
||||||
parts += [p for p in (muni, county, state) if p]
|
location, tg_area, talkgroup_name, node_state, node_county,
|
||||||
|
)
|
||||||
query = ", ".join(parts)
|
query = ", ".join(parts)
|
||||||
if tg_anchor or (node_lat is not None and node_lon is not None):
|
if tg_anchor or (node_lat is not None and node_lon is not None):
|
||||||
location_coords = await _geocode_location(
|
location_coords = await _geocode_location(
|
||||||
query, node_lat, node_lon, anchor=tg_anchor
|
query, node_lat, node_lon, anchor=tg_anchor,
|
||||||
|
trust_named_region=tg_named_region,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Embed this scene's content
|
# Embed this scene's content
|
||||||
@@ -514,6 +516,7 @@ async def _geocode_location(
|
|||||||
node_lat: Optional[float] = None,
|
node_lat: Optional[float] = None,
|
||||||
node_lon: Optional[float] = None,
|
node_lon: Optional[float] = None,
|
||||||
anchor: Optional[dict] = None,
|
anchor: Optional[dict] = None,
|
||||||
|
trust_named_region: bool = False,
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Geocode using Google Maps Geocoding API, biased toward the channel's area.
|
Geocode using Google Maps Geocoding API, biased toward the channel's area.
|
||||||
@@ -529,6 +532,26 @@ async def _geocode_location(
|
|||||||
talkgroup has a resolved anchor, that is the reference and its own radius is
|
talkgroup has a resolved anchor, that is the reference and its own radius is
|
||||||
the bound. Distance-from-node stays only as the fallback for a system nobody
|
the bound. Distance-from-node stays only as the fallback for a system nobody
|
||||||
has described yet — it was always a stand-in for this.
|
has described yet — it was always a stand-in for this.
|
||||||
|
|
||||||
|
server-26#159: "a system nobody has described yet" turned out to include
|
||||||
|
systems that describe themselves — "New York City - NYPD Citywide 2 Patch"
|
||||||
|
names its own coverage area right in the talkgroup name, parsed into the
|
||||||
|
query by `_municipality_from_tg`, but a large aggregated/patched feed like
|
||||||
|
this is routinely received 40-70km from an antenna that happens to sit
|
||||||
|
wherever the node owner lives. Real, correctly-geocoded addresses on that
|
||||||
|
feed were being rejected by the node-distance check every single time —
|
||||||
|
location_coords stayed permanently null for the whole system, which killed
|
||||||
|
the location_proximity correlation signal and let duplicate incidents form
|
||||||
|
for the same event reported at two nearby addresses two minutes apart.
|
||||||
|
|
||||||
|
`trust_named_region` is True exactly when the query already carries a place
|
||||||
|
name that isn't the node's own position — operator-set area_context, or a
|
||||||
|
municipality parsed from the talkgroup's own name. In that case a distant
|
||||||
|
node is not evidence of a bad geocode, so the node-distance check is
|
||||||
|
skipped and precision is judged by `location_type` alone (still required
|
||||||
|
to be ROOFTOP/RANGE_INTERPOLATED/GEOMETRIC_CENTER, below). This does not
|
||||||
|
touch the anchor path at all — an anchor's own radius is always authoritative
|
||||||
|
when one has been resolved.
|
||||||
"""
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -594,11 +617,21 @@ async def _geocode_location(
|
|||||||
lat, lng = float(loc["lat"]), float(loc["lng"])
|
lat, lng = float(loc["lat"]), float(loc["lng"])
|
||||||
dist_km = _geo_dist_km(ref_lat, ref_lon, lat, lng)
|
dist_km = _geo_dist_km(ref_lat, ref_lon, lat, lng)
|
||||||
if dist_km > max_km:
|
if dist_km > max_km:
|
||||||
logger.warning(
|
# server-26#159: the node-distance bound is a proxy for "is
|
||||||
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
|
# this plausible" that only makes sense when the node's own
|
||||||
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
|
# position is our best guess at the area — never when the
|
||||||
|
# query already names a different region on its own terms.
|
||||||
|
if not (ref_label == "node" and trust_named_region):
|
||||||
|
logger.warning(
|
||||||
|
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
|
||||||
|
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
logger.info(
|
||||||
|
f"Geocoding '{location_str}' → ({lat:.4f}, {lng:.4f}) is "
|
||||||
|
f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — "
|
||||||
|
f"accepted anyway: the query names its own region, not the node's"
|
||||||
)
|
)
|
||||||
return None
|
|
||||||
coords = {"lat": lat, "lng": lng}
|
coords = {"lat": lat, "lng": lng}
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Geocoded '{location_str}' → {coords} "
|
f"Geocoded '{location_str}' → {coords} "
|
||||||
@@ -624,6 +657,43 @@ def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
|
|||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _location_query_parts(
|
||||||
|
location: str,
|
||||||
|
tg_area: dict,
|
||||||
|
talkgroup_name: Optional[str],
|
||||||
|
node_state: str,
|
||||||
|
node_county: str,
|
||||||
|
) -> tuple[list[str], bool]:
|
||||||
|
"""
|
||||||
|
Build the geocode query parts for `location`, plus whether the query names
|
||||||
|
a region the *talkgroup itself* covers (operator-set area_context, or a
|
||||||
|
municipality parsed from the talkgroup's own name) rather than one guessed
|
||||||
|
from wherever the receiving node happens to sit (server-26#159).
|
||||||
|
|
||||||
|
That distinction matters downstream: `_geocode_location`'s node-distance
|
||||||
|
sanity check is only a valid proxy for "is this plausible" when the node's
|
||||||
|
own position is the best guess we have at the area. A citywide/patched
|
||||||
|
feed ("New York City - NYPD Citywide 2 Patch") names its own coverage area
|
||||||
|
right in the talkgroup name — grafting the node's own county onto that
|
||||||
|
(Ossining-style: valid when the feed genuinely is local to the node,
|
||||||
|
actively wrong when it names a distant region of its own) would make the
|
||||||
|
query self-contradictory, so the node's COUNTY is used only when nothing
|
||||||
|
better names the place. The node's STATE is coarse enough to still be
|
||||||
|
correct either way and is kept in both branches.
|
||||||
|
"""
|
||||||
|
parts = [location]
|
||||||
|
if area_context.has_place(tg_area):
|
||||||
|
parts += [tg_area[f] for f in area_context.PLACE_FIELDS if tg_area.get(f)]
|
||||||
|
return parts, True
|
||||||
|
|
||||||
|
muni = _municipality_from_tg(talkgroup_name)
|
||||||
|
if muni:
|
||||||
|
parts += [p for p in (muni, node_state) if p]
|
||||||
|
else:
|
||||||
|
parts += [p for p in (node_county, node_state) if p]
|
||||||
|
return parts, muni is not None
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -28,10 +28,23 @@ counties may have one talkgroup covering a single municipality, and that
|
|||||||
municipality's streets must not be buried under a county-wide list. A
|
municipality's streets must not be buried under a county-wide list. A
|
||||||
single-municipality system is the degenerate case: populate the system level and
|
single-municipality system is the degenerate case: populate the system level and
|
||||||
every talkgroup inherits it.
|
every talkgroup inherits it.
|
||||||
|
|
||||||
|
THE PROMPT'S OWN RULES ARE NOT ENFORCED (server-26#162). "Do NOT expand
|
||||||
|
ten-codes" and "NEVER add information" are instructions to the model, not
|
||||||
|
checks on its output — `correct()` used to accept `raw["corrected"]` verbatim.
|
||||||
|
Caught live: the same call came back with "10-7" rewritten to "10-13" in one
|
||||||
|
place and "10-4" in another, and "7" expanded into "ShotSpotter" — a real code
|
||||||
|
swapped for a different real code reads exactly as confident and trustworthy
|
||||||
|
as a correct one, which is worse than leaving the raw mishearing in place. The
|
||||||
|
model isn't graded on this at write time; `_code_tokens()` is a
|
||||||
|
verify-what-you-can-cheaply-check backstop, not a fix to the model's judgment:
|
||||||
|
it only catches a code-shaped token changing, not a wrong word substituted for
|
||||||
|
another equally plausible word.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -96,6 +109,19 @@ def _dedupe(items: list[str]) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Ten-codes ("10-4"), unit/signal shorthand ("4-2"), and the digit-group
|
||||||
|
# fragments radio traffic reads out loud ("7-2-1" of a case number) all share
|
||||||
|
# this shape. The guard below does not need to know which of those a given
|
||||||
|
# token is — it only needs the SET of them to survive a "correction"
|
||||||
|
# unchanged, in order. A model rewriting "10-7" as "10-13" is not the kind of
|
||||||
|
# mishearing this pass exists to fix (server-26#162).
|
||||||
|
_CODE_TOKEN_RE = re.compile(r"\b\d{1,3}(?:-\d{1,3})+\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _code_tokens(text: str) -> list[str]:
|
||||||
|
return _CODE_TOKEN_RE.findall(text or "")
|
||||||
|
|
||||||
|
|
||||||
def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
|
def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
|
||||||
"""The config.talkgroups[] entry for this talkgroup, or {}."""
|
"""The config.talkgroups[] entry for this talkgroup, or {}."""
|
||||||
if talkgroup_id is None:
|
if talkgroup_id is None:
|
||||||
@@ -317,6 +343,31 @@ async def correct(
|
|||||||
if verified_segments:
|
if verified_segments:
|
||||||
corrected_segments = verified_segments
|
corrected_segments = verified_segments
|
||||||
|
|
||||||
|
# server-26#162: a code-shaped token ("10-7", "4-2", a case-number
|
||||||
|
# fragment like "7-2-1") changing at all — not just going missing, any
|
||||||
|
# change — means the model touched something this pass has no business
|
||||||
|
# touching. Reject that half of the correction outright rather than trust
|
||||||
|
# a rewrite that already broke its own instructions once. Checked against
|
||||||
|
# the ORIGINAL text/segment, not each other, so a joined-text correction
|
||||||
|
# and a segment correction are judged independently, same as everywhere
|
||||||
|
# else in this function.
|
||||||
|
if corrected is not None and _code_tokens(corrected) != _code_tokens(text):
|
||||||
|
logger.warning(
|
||||||
|
f"Transcript correction for call {call_id} changed code-shaped "
|
||||||
|
f"tokens ({_code_tokens(text)} -> {_code_tokens(corrected)}) — "
|
||||||
|
f"discarding the joined correction"
|
||||||
|
)
|
||||||
|
corrected = None
|
||||||
|
if corrected_segments is not None:
|
||||||
|
for seg, orig in zip(corrected_segments, segments or []):
|
||||||
|
if _code_tokens(seg["text"]) != _code_tokens(orig.get("text", "")):
|
||||||
|
logger.warning(
|
||||||
|
f"Transcript correction for call {call_id} changed "
|
||||||
|
f"code-shaped tokens in a segment — discarding segment corrections"
|
||||||
|
)
|
||||||
|
corrected_segments = None
|
||||||
|
break
|
||||||
|
|
||||||
if corrected or corrected_segments or not_speech:
|
if corrected or corrected_segments or not_speech:
|
||||||
changed = raw.get("changed") or []
|
changed = raw.get("changed") or []
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Word error rate — server-26#163's eval harness needs a real number to compare
|
||||||
|
against, not a vibe. Standard definition: word-level Levenshtein distance
|
||||||
|
between a human-verified reference and the machine hypothesis, divided by the
|
||||||
|
reference's own word count. Case-insensitive, punctuation-insensitive — this
|
||||||
|
measures whether the right WORDS came out, not transcript formatting.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(text: str) -> list[str]:
|
||||||
|
return re.findall(r"[\w']+", (text or "").lower())
|
||||||
|
|
||||||
|
|
||||||
|
def word_error_rate(reference: str, hypothesis: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
(substitutions + deletions + insertions) / len(reference words).
|
||||||
|
|
||||||
|
None when the reference has no words — WER is undefined there, not 0.0;
|
||||||
|
a caller that defaults a None to 0.0 would report a perfect score for a
|
||||||
|
call nobody actually transcribed.
|
||||||
|
"""
|
||||||
|
ref = _tokenize(reference)
|
||||||
|
hyp = _tokenize(hypothesis)
|
||||||
|
if not ref:
|
||||||
|
return None
|
||||||
|
if not hyp:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
n, m = len(ref), len(hyp)
|
||||||
|
# Single-row DP over Levenshtein distance — O(n*m) time, O(m) space.
|
||||||
|
row = list(range(m + 1))
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
prev_diag = row[0]
|
||||||
|
row[0] = i
|
||||||
|
for j in range(1, m + 1):
|
||||||
|
prev_row_j = row[j]
|
||||||
|
if ref[i - 1] == hyp[j - 1]:
|
||||||
|
row[j] = prev_diag
|
||||||
|
else:
|
||||||
|
row[j] = 1 + min(prev_diag, row[j], row[j - 1])
|
||||||
|
prev_diag = prev_row_j
|
||||||
|
return row[m] / n
|
||||||
@@ -5,6 +5,7 @@ from typing import Optional
|
|||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import (
|
from app.internal.auth import (
|
||||||
require_admin_token,
|
require_admin_token,
|
||||||
|
require_firebase_token,
|
||||||
require_service_or_firebase_token,
|
require_service_or_firebase_token,
|
||||||
resolve_caller_org_id,
|
resolve_caller_org_id,
|
||||||
reprocess_limiter,
|
reprocess_limiter,
|
||||||
@@ -15,9 +16,49 @@ from app.internal.storage import gcs_uri_for_call, with_playback_url
|
|||||||
class TranscriptUpdate(BaseModel):
|
class TranscriptUpdate(BaseModel):
|
||||||
transcript: str
|
transcript: str
|
||||||
|
|
||||||
|
|
||||||
|
class EvalTranscriptUpdate(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
router = APIRouter(prefix="/calls", tags=["calls"])
|
router = APIRouter(prefix="/calls", tags=["calls"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(value: Optional[str], field: str) -> Optional[datetime]:
|
||||||
|
"""ISO string from a query param → aware datetime, or 400.
|
||||||
|
|
||||||
|
started_at is stored as a Firestore timestamp, so a cursor or range bound
|
||||||
|
passed through as the raw string compares by *type* (every string sorts
|
||||||
|
after every timestamp) rather than by time — a string cursor made "Load
|
||||||
|
more" return the first page again.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"{field} is not an ISO-8601 timestamp.")
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_cursor(rows: list[dict], matches: list[dict], page: list[dict], window: int) -> Optional[str]:
|
||||||
|
"""Where the next page of a bounded-window scan starts.
|
||||||
|
|
||||||
|
More matches than fit on the page → resume right after the last row
|
||||||
|
returned, or every match between it and the end of the window is skipped
|
||||||
|
(a 200-row window shown 50 at a time lost 150 calls per "Load more").
|
||||||
|
Otherwise resume after the last row SCANNED, not the last match — a page
|
||||||
|
whose last match sits early in the window would re-scan everything after
|
||||||
|
it and loop forever on a sparse filter. A short window is the end.
|
||||||
|
"""
|
||||||
|
if len(matches) > len(page):
|
||||||
|
last = page[-1].get("started_at")
|
||||||
|
elif len(rows) == window:
|
||||||
|
last = rows[-1].get("started_at")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return last.isoformat() if hasattr(last, "isoformat") else last
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_calls(
|
async def list_calls(
|
||||||
node_id: Optional[str] = Query(None),
|
node_id: Optional[str] = Query(None),
|
||||||
@@ -50,7 +91,9 @@ async def search_calls(
|
|||||||
link: str = Query("any", pattern="^(any|orphan|linked)$"),
|
link: str = Query("any", pattern="^(any|orphan|linked)$"),
|
||||||
transcript: str = Query("any", pattern="^(any|yes|no)$"),
|
transcript: str = Query("any", pattern="^(any|yes|no)$"),
|
||||||
q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"),
|
q: Optional[str] = Query(None, description="case-insensitive substring of the transcript"),
|
||||||
decoded: dict = Depends(require_admin_token),
|
date_from: Optional[str] = Query(None, description="ISO timestamp, inclusive lower bound on started_at"),
|
||||||
|
date_to: Optional[str] = Query(None, description="ISO timestamp, inclusive upper bound on started_at"),
|
||||||
|
decoded: dict = Depends(require_firebase_token),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Paged, filterable call archive — the backend for the /calls page.
|
Paged, filterable call archive — the backend for the /calls page.
|
||||||
@@ -68,6 +111,11 @@ async def search_calls(
|
|||||||
|
|
||||||
`window_exhausted` says the scan hit its cap before filling the page, so an
|
`window_exhausted` says the scan hit its cap before filling the page, so an
|
||||||
empty result means "not in this window", not "none exist".
|
empty result means "not in this window", not "none exist".
|
||||||
|
|
||||||
|
Open to every org member (viewer included), not just admins: the Firestore
|
||||||
|
rules already let any member read every call doc in their org
|
||||||
|
(firestore.rules `calls` → docInMyOrg), so this route exposes nothing a
|
||||||
|
viewer's browser couldn't already read directly.
|
||||||
"""
|
"""
|
||||||
org_id = await resolve_caller_org_id(decoded)
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
if org_id is None:
|
if org_id is None:
|
||||||
@@ -78,13 +126,24 @@ async def search_calls(
|
|||||||
if not org_id:
|
if not org_id:
|
||||||
raise HTTPException(403, "No organization scope for this caller.")
|
raise HTTPException(403, "No organization scope for this caller.")
|
||||||
|
|
||||||
|
cursor_dt = _parse_ts(cursor, "cursor")
|
||||||
|
from_dt = _parse_ts(date_from, "date_from")
|
||||||
|
to_dt = _parse_ts(date_to, "date_to")
|
||||||
|
|
||||||
|
# A range on the ordered field rides the same org_id/started_at index.
|
||||||
|
conditions: list[tuple[str, str, object]] = [("org_id", "==", org_id)]
|
||||||
|
if from_dt:
|
||||||
|
conditions.append(("started_at", ">=", from_dt))
|
||||||
|
if to_dt:
|
||||||
|
conditions.append(("started_at", "<=", to_dt))
|
||||||
|
|
||||||
window = max(limit * 10, 200)
|
window = max(limit * 10, 200)
|
||||||
rows = await fstore.collection_where(
|
rows = await fstore.collection_where(
|
||||||
"calls",
|
"calls",
|
||||||
[("org_id", "==", org_id)],
|
conditions,
|
||||||
order_by=[("started_at", "DESCENDING")],
|
order_by=[("started_at", "DESCENDING")],
|
||||||
limit_to=window,
|
limit_to=window,
|
||||||
start_after={"started_at": cursor} if cursor else None,
|
start_after={"started_at": cursor_dt} if cursor_dt else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
needle = (q or "").strip().lower()
|
needle = (q or "").strip().lower()
|
||||||
@@ -113,13 +172,7 @@ async def search_calls(
|
|||||||
matches = [c for c in rows if _keep(c)]
|
matches = [c for c in rows if _keep(c)]
|
||||||
page = matches[:limit]
|
page = matches[:limit]
|
||||||
|
|
||||||
# Cursor advances over the SCANNED window, not the filtered page — otherwise
|
next_cursor = _next_cursor(rows, matches, page, window)
|
||||||
# a page whose last match sits early in the window would re-scan everything
|
|
||||||
# after it on the next request and loop forever on a sparse filter.
|
|
||||||
next_cursor = None
|
|
||||||
if len(rows) == window:
|
|
||||||
last_scanned = rows[-1].get("started_at")
|
|
||||||
next_cursor = last_scanned.isoformat() if hasattr(last_scanned, "isoformat") else last_scanned
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"calls": [with_playback_url(c) for c in page],
|
"calls": [with_playback_url(c) for c in page],
|
||||||
@@ -130,6 +183,107 @@ async def search_calls(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/eval-queue")
|
||||||
|
async def eval_queue(
|
||||||
|
limit: int = Query(5, ge=1, le=20),
|
||||||
|
cursor: Optional[str] = Query(None, description="started_at of the last row of the previous page"),
|
||||||
|
decoded: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A batch of calls that have a machine transcript but no human-verified one
|
||||||
|
yet — the backend for the STT eval page (server-26#163).
|
||||||
|
|
||||||
|
Deliberately separate from `PATCH /{call_id}/transcript`: that route is a
|
||||||
|
PRODUCTION correction — it re-runs extraction, unlinks incidents, and
|
||||||
|
feeds the vocabulary learner. An eval annotation must never trigger any
|
||||||
|
of that; it only exists to measure the pipeline, not to change what it
|
||||||
|
already decided. `eval_transcript` lives next to `transcript`/
|
||||||
|
`transcript_corrected` on the call doc and nothing downstream reads it.
|
||||||
|
|
||||||
|
Same bounded-window-scan-plus-cursor shape as `/search`, for the same
|
||||||
|
reason: no composite index exists for "eval_transcript is unset", and one
|
||||||
|
scan ordered by started_at is already trusted here. Paging through with
|
||||||
|
the returned cursor is how "however many, over time" actually works —
|
||||||
|
each call is where the last session left off, not a fresh random sample.
|
||||||
|
"""
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is None:
|
||||||
|
org_id = decoded.get("org_id")
|
||||||
|
if not org_id:
|
||||||
|
raise HTTPException(403, "No organization scope for this caller.")
|
||||||
|
|
||||||
|
cursor_dt = _parse_ts(cursor, "cursor")
|
||||||
|
window = max(limit * 20, 300)
|
||||||
|
rows = await fstore.collection_where(
|
||||||
|
"calls",
|
||||||
|
[("org_id", "==", org_id)],
|
||||||
|
order_by=[("started_at", "DESCENDING")],
|
||||||
|
limit_to=window,
|
||||||
|
start_after={"started_at": cursor_dt} if cursor_dt else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _eligible(c: dict) -> bool:
|
||||||
|
text = c.get("transcript_corrected") or c.get("transcript") or ""
|
||||||
|
return bool(text) and not c.get("eval_transcript")
|
||||||
|
|
||||||
|
matches = [c for c in rows if _eligible(c)]
|
||||||
|
page = matches[:limit]
|
||||||
|
|
||||||
|
next_cursor = _next_cursor(rows, matches, page, window)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"calls": [with_playback_url(c) for c in page],
|
||||||
|
"next_cursor": next_cursor,
|
||||||
|
"scanned": len(rows),
|
||||||
|
"matched": len(matches),
|
||||||
|
"window_exhausted": len(rows) == window,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/eval-stats")
|
||||||
|
async def eval_stats(decoded: dict = Depends(require_admin_token)):
|
||||||
|
"""
|
||||||
|
How many calls have a human-verified transcript, and the WER of the raw
|
||||||
|
and corrected machine transcripts against them (server-26#163).
|
||||||
|
|
||||||
|
Whole-collection scan, matching `GET /calls` (list_calls above) rather
|
||||||
|
than the bounded-window pattern the paged routes use: the eval set this
|
||||||
|
is measuring is built a few calls at a time and expected to stay small
|
||||||
|
(tens to hundreds), so a full scan filtered in Python is the honest
|
||||||
|
answer rather than a windowed guess that could miss eval'd calls sitting
|
||||||
|
outside a recency window.
|
||||||
|
"""
|
||||||
|
from app.internal.wer import word_error_rate
|
||||||
|
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
filters = {"org_id": org_id} if org_id is not None else {}
|
||||||
|
calls = await fstore.collection_list("calls", **filters)
|
||||||
|
|
||||||
|
raw_wers: list[float] = []
|
||||||
|
corrected_wers: list[float] = []
|
||||||
|
for c in calls:
|
||||||
|
ref = c.get("eval_transcript")
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
raw = c.get("transcript") or ""
|
||||||
|
corrected = c.get("transcript_corrected") or raw
|
||||||
|
raw_wer = word_error_rate(ref, raw)
|
||||||
|
corrected_wer = word_error_rate(ref, corrected)
|
||||||
|
if raw_wer is not None:
|
||||||
|
raw_wers.append(raw_wer)
|
||||||
|
if corrected_wer is not None:
|
||||||
|
corrected_wers.append(corrected_wer)
|
||||||
|
|
||||||
|
def _avg(xs: list[float]) -> Optional[float]:
|
||||||
|
return round(sum(xs) / len(xs), 4) if xs else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"eval_count": len(raw_wers),
|
||||||
|
"raw_wer": _avg(raw_wers),
|
||||||
|
"corrected_wer": _avg(corrected_wers),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{call_id}")
|
@router.get("/{call_id}")
|
||||||
async def get_call(call_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
async def get_call(call_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
call = await fstore.doc_get("calls", call_id)
|
call = await fstore.doc_get("calls", call_id)
|
||||||
@@ -313,3 +467,29 @@ async def patch_transcript(
|
|||||||
preserve_transcript_correction=True,
|
preserve_transcript_correction=True,
|
||||||
)
|
)
|
||||||
return {"ok": True, "call_id": call_id}
|
return {"ok": True, "call_id": call_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{call_id}/eval-transcript")
|
||||||
|
async def put_eval_transcript(
|
||||||
|
call_id: str,
|
||||||
|
body: EvalTranscriptUpdate,
|
||||||
|
decoded: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Record a human-verified reference transcript for the STT eval harness
|
||||||
|
(server-26#163). Pure data capture — unlike `PATCH /{call_id}/transcript`
|
||||||
|
above, this never touches `transcript`/`transcript_corrected`, never
|
||||||
|
re-runs extraction, never unlinks incidents, and never feeds the
|
||||||
|
vocabulary learner. It exists to MEASURE the pipeline's output, not to
|
||||||
|
change it; the two must not share a code path.
|
||||||
|
"""
|
||||||
|
call = await fstore.doc_get("calls", call_id)
|
||||||
|
if not call:
|
||||||
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
|
|
||||||
|
await fstore.doc_set("calls", call_id, {
|
||||||
|
"eval_transcript": body.text,
|
||||||
|
"eval_transcript_by": decoded.get("email") or decoded.get("uid"),
|
||||||
|
"eval_transcript_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
return {"ok": True, "call_id": call_id}
|
||||||
|
|||||||
@@ -522,11 +522,18 @@ async def _run_intelligence_pipeline(
|
|||||||
|
|
||||||
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
||||||
# talkgroup-based linking even when no transcript could be produced.
|
# talkgroup-based linking even when no transcript could be produced.
|
||||||
# Skip when extraction flagged the call — garbage or too-short transcripts
|
# transcript_too_short (<=5 words: "10-8", "show me clear", a unit
|
||||||
# carry no signal and would only attach spuriously via the thin path.
|
# check-in) still carries a real transcript and talkgroup — exactly the
|
||||||
|
# brief follow-up/clearance traffic an incident needs, and the thin-path
|
||||||
|
# merge below already requires a same-talkgroup, recently-active
|
||||||
|
# incident before attaching anything, same guard already trusted for
|
||||||
|
# no-transcript calls. Previously excluded here, so these calls never
|
||||||
|
# attached to anything at all. garbage_transcript (Whisper
|
||||||
|
# hallucination) has no real content behind it and stays excluded.
|
||||||
if not scenes:
|
if not scenes:
|
||||||
_call_doc = await fstore.doc_get("calls", call_id)
|
_call_doc = await fstore.doc_get("calls", call_id)
|
||||||
if not (_call_doc or {}).get("skip_reason"):
|
skip_reason = (_call_doc or {}).get("skip_reason")
|
||||||
|
if not skip_reason or skip_reason == "transcript_too_short":
|
||||||
incident_id = await _correlate_with_consensus(
|
incident_id = await _correlate_with_consensus(
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""calls._parse_ts — cursor/date bounds must reach Firestore as datetimes.
|
||||||
|
|
||||||
|
A raw ISO string compared against a timestamp field sorts by type, not time,
|
||||||
|
which made the Archive's "Load more" return the first page again.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.calls import _next_cursor, _parse_ts
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_is_none():
|
||||||
|
assert _parse_ts(None, "cursor") is None
|
||||||
|
assert _parse_ts("", "cursor") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_z_suffix_parses_as_utc():
|
||||||
|
assert _parse_ts("2026-09-20T12:00:00Z", "date_from") == datetime(2026, 9, 20, 12, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_naive_is_assumed_utc():
|
||||||
|
assert _parse_ts("2026-09-20T12:00:00", "date_to").tzinfo == timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_trips_isoformat_cursor():
|
||||||
|
dt = datetime(2026, 9, 20, 12, 30, 5, 123456, tzinfo=timezone.utc)
|
||||||
|
assert _parse_ts(dt.isoformat(), "cursor") == dt
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_is_400():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_parse_ts("yesterday", "date_from")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── _next_cursor ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(n):
|
||||||
|
return [{"started_at": datetime(2026, 9, 20, 12, i // 60, i % 60, tzinfo=timezone.utc)} for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_resumes_after_last_returned_row_when_matches_overflow():
|
||||||
|
rows = _rows(200)
|
||||||
|
page = rows[:50]
|
||||||
|
assert _next_cursor(rows, rows, page, 200) == page[-1]["started_at"].isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_resumes_after_window_when_page_holds_every_match():
|
||||||
|
rows = _rows(200)
|
||||||
|
matches = rows[:3]
|
||||||
|
assert _next_cursor(rows, matches, matches, 200) == rows[-1]["started_at"].isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_window_is_the_end():
|
||||||
|
rows = _rows(20)
|
||||||
|
assert _next_cursor(rows, rows[:5], rows[:5], 200) is None
|
||||||
@@ -180,6 +180,55 @@ def test_tactical_thin_call_is_ambiguous_with_two_candidates():
|
|||||||
assert decision["action"] == "orphan"
|
assert decision["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# server-26#158: srcaddr identity beats recency guesswork for thin calls
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_thin_call_srcaddr_match_resolves_tier2_ambiguity():
|
||||||
|
"""
|
||||||
|
Same fixture as test_tactical_thin_call_is_ambiguous_with_two_candidates —
|
||||||
|
two candidates, tier-2 window, no unit ID parsed (transcript_too_short
|
||||||
|
skipped GPT). Without srcaddr this orphans. With it, the radio that sent
|
||||||
|
the call already touched inc-b, so that's the thread — not a guess.
|
||||||
|
"""
|
||||||
|
a = _incident(idle_minutes=3.0, incident_id="inc-a", srcaddrs=["9001"])
|
||||||
|
b = _incident(idle_minutes=4.0, incident_id="inc-b", srcaddrs=["9002"])
|
||||||
|
decision = _run_decision(_ctx(
|
||||||
|
all_active=[a, b], recent=[a, b], talkgroup_name=TACTICAL_TG,
|
||||||
|
call_srcaddr="9002",
|
||||||
|
))
|
||||||
|
assert decision["action"] == "link"
|
||||||
|
assert decision["matched_incident"]["incident_id"] == "inc-b"
|
||||||
|
assert decision["corr_debug"]["corr_fit_signal"] == "thin_srcaddr_match"
|
||||||
|
|
||||||
|
|
||||||
|
def test_thin_call_srcaddr_match_overrides_recency_in_tier1():
|
||||||
|
"""
|
||||||
|
Both candidates are inside the 30s conversational window, where recency
|
||||||
|
alone would pick inc-a (more recently updated) even though the radio that
|
||||||
|
sent this call has only ever touched inc-b — the exact busy-channel,
|
||||||
|
two-concurrent-incidents misattach server-26#158 was filed for.
|
||||||
|
"""
|
||||||
|
a = _incident(idle_minutes=0.1, incident_id="inc-a", srcaddrs=["9001"])
|
||||||
|
b = _incident(idle_minutes=0.2, incident_id="inc-b", srcaddrs=["9002"])
|
||||||
|
decision = _run_decision(_ctx(
|
||||||
|
all_active=[a, b], recent=[a, b], call_srcaddr="9002",
|
||||||
|
))
|
||||||
|
assert decision["action"] == "link"
|
||||||
|
assert decision["matched_incident"]["incident_id"] == "inc-b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_thin_call_with_no_srcaddr_match_falls_back_to_recency():
|
||||||
|
"""A radio ID that matches nothing on this talkgroup behaves exactly as
|
||||||
|
before — no regression for the ordinary case."""
|
||||||
|
a = _incident(idle_minutes=0.1, incident_id="inc-a", srcaddrs=["9001"])
|
||||||
|
decision = _run_decision(_ctx(
|
||||||
|
all_active=[a], recent=[a], call_srcaddr="unrelated-radio",
|
||||||
|
))
|
||||||
|
assert decision["action"] == "link"
|
||||||
|
assert decision["corr_debug"]["corr_fit_signal"] == "thin_recency"
|
||||||
|
|
||||||
|
|
||||||
def test_call_with_unit_overlap_does_attach():
|
def test_call_with_unit_overlap_does_attach():
|
||||||
"""
|
"""
|
||||||
Positive control: real evidence still links. Carrying units also means the
|
Positive control: real evidence still links. Carrying units also means the
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
server-26#163 — the STT eval harness: word_error_rate() and the three routes
|
||||||
|
that back the /admin "STT Eval" tab.
|
||||||
|
|
||||||
|
Load-bearing property, checked directly: eval annotation must never touch
|
||||||
|
`transcript`/`transcript_corrected`, re-run extraction, or unlink incidents —
|
||||||
|
that's PATCH /{call_id}/transcript's job, a production correction with real
|
||||||
|
side effects. This is pure measurement and must stay pure.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal.wer import word_error_rate
|
||||||
|
from app.main import app
|
||||||
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token
|
||||||
|
from app.routers import calls
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
ADMIN = {"role": "admin", "org_id": "org-A"}
|
||||||
|
|
||||||
|
|
||||||
|
def _override(decoded: dict):
|
||||||
|
# calls.router carries its own router-level require_service_or_firebase_token
|
||||||
|
# (app/main.py) ON TOP OF each admin route's own require_admin_token — both
|
||||||
|
# have to be overridden or the router-level one 401s before the route's own
|
||||||
|
# dependency is ever evaluated.
|
||||||
|
app.dependency_overrides[require_admin_token] = lambda: decoded
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = lambda: decoded
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
app.dependency_overrides.pop(require_admin_token, None)
|
||||||
|
app.dependency_overrides.pop(require_service_or_firebase_token, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── word_error_rate ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_identical_transcripts_are_zero_wer():
|
||||||
|
assert word_error_rate("K on the 600, I'm on Jackson Avenue.",
|
||||||
|
"K on the 600, I'm on Jackson Avenue.") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_and_punctuation_are_ignored():
|
||||||
|
assert word_error_rate("Home Street and Forest Ave!", "home street and forest ave") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_substitution_out_of_three_words():
|
||||||
|
assert word_error_rate("the cat sat", "the cat sit") == pytest.approx(1 / 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_reference_is_undefined_not_zero():
|
||||||
|
"""A call nobody transcribed must not score as a perfect match."""
|
||||||
|
assert word_error_rate("", "anything") is None
|
||||||
|
assert word_error_rate(None, "anything") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_hypothesis_against_real_reference_is_total_loss():
|
||||||
|
assert word_error_rate("home street and forest ave", "") == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_insertion_counts_against_the_hypothesis():
|
||||||
|
# reference 3 words, hypothesis adds 2 extra -> 2 insertions / 3 ref words
|
||||||
|
assert word_error_rate("show me clear", "show me clear right now") == pytest.approx(2 / 3)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /calls/eval-queue ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _call(call_id, transcript="a real transcript here", corrected=None, eval_transcript=None, org_id="org-A"):
|
||||||
|
return {
|
||||||
|
"call_id": call_id, "org_id": org_id, "started_at": "2026-09-21T00:00:00+00:00",
|
||||||
|
"transcript": transcript, "transcript_corrected": corrected, "eval_transcript": eval_transcript,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_eval_queue_skips_already_evaluated_and_transcript_less_calls():
|
||||||
|
rows = [
|
||||||
|
_call("c1", eval_transcript="already done"),
|
||||||
|
_call("c2", transcript=None),
|
||||||
|
_call("c3"),
|
||||||
|
]
|
||||||
|
_override(ADMIN)
|
||||||
|
with patch.object(calls.fstore, "collection_where", AsyncMock(return_value=rows)):
|
||||||
|
resp = client.get("/calls/eval-queue")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert [c["call_id"] for c in body["calls"]] == ["c3"]
|
||||||
|
assert body["matched"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_eval_queue_requires_an_org_scope():
|
||||||
|
_override({"role": "admin"}) # platform admin, no org claim
|
||||||
|
resp = client.get("/calls/eval-queue")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /calls/eval-stats ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_eval_stats_averages_wer_across_evaluated_calls_only():
|
||||||
|
rows = [
|
||||||
|
_call("c1", transcript="the cat sat", corrected="the cat sat", eval_transcript="the cat sat"), # 0.0 / 0.0
|
||||||
|
_call("c2", transcript="the cat sit", corrected="the cat sat", eval_transcript="the cat sat"), # raw 1/3, corrected 0.0
|
||||||
|
_call("c3", eval_transcript=None), # excluded entirely
|
||||||
|
]
|
||||||
|
_override(ADMIN)
|
||||||
|
with patch.object(calls.fstore, "collection_list", AsyncMock(return_value=rows)):
|
||||||
|
resp = client.get("/calls/eval-stats")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["eval_count"] == 2
|
||||||
|
assert body["raw_wer"] == pytest.approx((0.0 + 1 / 3) / 2, abs=1e-4)
|
||||||
|
assert body["corrected_wer"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_eval_stats_with_nothing_evaluated_yet_reports_none_not_zero():
|
||||||
|
_override(ADMIN)
|
||||||
|
with patch.object(calls.fstore, "collection_list", AsyncMock(return_value=[_call("c1")])):
|
||||||
|
resp = client.get("/calls/eval-stats")
|
||||||
|
body = resp.json()
|
||||||
|
assert body == {"eval_count": 0, "raw_wer": None, "corrected_wer": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ── PUT /{call_id}/eval-transcript ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_put_eval_transcript_writes_only_eval_fields():
|
||||||
|
_override(ADMIN)
|
||||||
|
existing = _call("c1", transcript="raw text", corrected="corrected text")
|
||||||
|
with patch.object(calls.fstore, "doc_get", AsyncMock(return_value=existing)), \
|
||||||
|
patch.object(calls.fstore, "doc_set", AsyncMock()) as mock_set:
|
||||||
|
resp = client.put("/calls/c1/eval-transcript", json={"text": "the verified ground truth"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
(collection, doc_id, doc), _ = mock_set.await_args
|
||||||
|
assert collection == "calls" and doc_id == "c1"
|
||||||
|
assert doc["eval_transcript"] == "the verified ground truth"
|
||||||
|
assert doc["eval_transcript_at"]
|
||||||
|
assert "transcript" not in doc and "transcript_corrected" not in doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_eval_transcript_404s_on_missing_call():
|
||||||
|
_override(ADMIN)
|
||||||
|
with patch.object(calls.fstore, "doc_get", AsyncMock(return_value=None)):
|
||||||
|
resp = client.put("/calls/nope/eval-transcript", json={"text": "x"})
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
server-26#159: a citywide/patched feed can be received far from its own
|
||||||
|
coverage area — "New York City - NYPD Citywide 2 Patch" was ~56km from the
|
||||||
|
receiving node, well past geocode_max_km (40km). Real, correctly-geocoded
|
||||||
|
addresses on that talkgroup were rejected by intelligence._geocode_location's
|
||||||
|
node-distance sanity check every time, so location_coords never populated for
|
||||||
|
the whole system: location_proximity correlation was permanently dead there,
|
||||||
|
and the same real event reported at two nearby addresses two minutes apart
|
||||||
|
became two separate incidents instead of one.
|
||||||
|
|
||||||
|
`trust_named_region` fixes this narrowly: the node-distance check is a proxy
|
||||||
|
for "is this plausible" that only makes sense when the node's own position is
|
||||||
|
the best guess we have at the area. It must not apply when the query already
|
||||||
|
names a different region on its own terms (operator-set area_context, or a
|
||||||
|
municipality parsed straight from the talkgroup's own name) — and it must
|
||||||
|
never touch the anchor path, whose own radius is always authoritative.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.internal.intelligence as intel
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _maps_result(lat: float, lng: float, location_type: str = "ROOFTOP"):
|
||||||
|
payload = {
|
||||||
|
"status": "OK",
|
||||||
|
"results": [{
|
||||||
|
"geometry": {
|
||||||
|
"location": {"lat": lat, "lng": lng},
|
||||||
|
"location_type": location_type,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self): return payload
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): return False
|
||||||
|
async def get(self, *a, **k): return _Resp()
|
||||||
|
|
||||||
|
return patch("httpx.AsyncClient", lambda *a, **k: _Client())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _api_key():
|
||||||
|
# intelligence.py imports settings locally per-function (`from app.config
|
||||||
|
# import settings`), which binds the same cached singleton — patching the
|
||||||
|
# module-level object here reaches it, but `intel.settings` itself does
|
||||||
|
# not exist as an attribute.
|
||||||
|
with patch.object(settings, "google_maps_api_key", "test-key"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# Node at (0, 0); result at (1, 0) is ~111km away — well past the 40km default.
|
||||||
|
NODE_LAT, NODE_LON = 0.0, 0.0
|
||||||
|
FAR_LAT, FAR_LNG = 1.0, 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_named_region_geocode_accepted_beyond_node_distance():
|
||||||
|
with _maps_result(FAR_LAT, FAR_LNG):
|
||||||
|
coords = await intel._geocode_location(
|
||||||
|
"1108 Jackson Avenue, New York City - NYPD Citywide 2 Patch",
|
||||||
|
node_lat=NODE_LAT, node_lon=NODE_LON,
|
||||||
|
trust_named_region=True,
|
||||||
|
)
|
||||||
|
assert coords == {"lat": FAR_LAT, "lng": FAR_LNG}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_geocode_still_rejected_beyond_node_distance_without_named_region():
|
||||||
|
"""Regression guard: a bare street name with no named region still uses
|
||||||
|
the node as its only plausibility check, exactly as before this fix."""
|
||||||
|
with _maps_result(FAR_LAT, FAR_LNG):
|
||||||
|
coords = await intel._geocode_location(
|
||||||
|
"Main Street",
|
||||||
|
node_lat=NODE_LAT, node_lon=NODE_LON,
|
||||||
|
trust_named_region=False,
|
||||||
|
)
|
||||||
|
assert coords is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anchor_path_ignores_trust_named_region():
|
||||||
|
"""The anchor's own radius is always authoritative — trust_named_region
|
||||||
|
is only a statement about the node fallback, never a way to widen an
|
||||||
|
anchor that was itself deliberately sized to discriminate."""
|
||||||
|
anchor = {"lat": NODE_LAT, "lng": NODE_LON, "radius_km": 10.0}
|
||||||
|
with _maps_result(FAR_LAT, FAR_LNG):
|
||||||
|
coords = await intel._geocode_location(
|
||||||
|
"1108 Jackson Avenue, New York City - NYPD Citywide 2 Patch",
|
||||||
|
node_lat=NODE_LAT, node_lon=NODE_LON,
|
||||||
|
anchor=anchor, trust_named_region=True,
|
||||||
|
)
|
||||||
|
assert coords is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_named_region_geocode_within_node_distance_is_unaffected():
|
||||||
|
"""A close result is accepted the same way regardless of the flag."""
|
||||||
|
near_lat, near_lng = 0.05, 0.0 # ~5.5km from the node
|
||||||
|
with _maps_result(near_lat, near_lng):
|
||||||
|
coords = await intel._geocode_location(
|
||||||
|
"Main Street, Ossining, New York",
|
||||||
|
node_lat=NODE_LAT, node_lon=NODE_LON,
|
||||||
|
trust_named_region=True,
|
||||||
|
)
|
||||||
|
assert coords == {"lat": near_lat, "lng": near_lng}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_imprecise_result_still_rejected_regardless_of_trust():
|
||||||
|
"""trust_named_region relaxes the distance check only — the location_type
|
||||||
|
precision filter (server-26#37) still applies unconditionally."""
|
||||||
|
with _maps_result(FAR_LAT, FAR_LNG, location_type="APPROXIMATE"):
|
||||||
|
coords = await intel._geocode_location(
|
||||||
|
"1108 Jackson Avenue, New York City - NYPD Citywide 2 Patch",
|
||||||
|
node_lat=NODE_LAT, node_lon=NODE_LON,
|
||||||
|
trust_named_region=True,
|
||||||
|
)
|
||||||
|
assert coords is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _location_query_parts — pure query assembly, no HTTP involved
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_operator_configured_area_wins_and_is_named_region():
|
||||||
|
parts, named = intel._location_query_parts(
|
||||||
|
"High Street", {"municipality": "Yorktown", "state": "New York"},
|
||||||
|
"Tac 1", node_state="New York", node_county="Westchester",
|
||||||
|
)
|
||||||
|
assert parts == ["High Street", "Yorktown", "New York"]
|
||||||
|
assert named is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_talkgroup_name_gets_node_state_but_not_node_county():
|
||||||
|
"""
|
||||||
|
"Ossining PD" is genuinely local to the node, so appending the node's own
|
||||||
|
state is correct. Its COUNTY is dropped even here — server-26#159's fix
|
||||||
|
applies uniformly once a municipality is derived, since there is no way
|
||||||
|
to tell "local" and "distant-but-node-adjacent" apart from the string
|
||||||
|
alone, and the county was never necessary for a bare municipality name
|
||||||
|
that already disambiguates via the state.
|
||||||
|
"""
|
||||||
|
parts, named = intel._location_query_parts(
|
||||||
|
"High Street", {}, "Ossining PD",
|
||||||
|
node_state="New York", node_county="Westchester",
|
||||||
|
)
|
||||||
|
assert parts == ["High Street", "Ossining", "New York"]
|
||||||
|
assert named is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_citywide_patched_feed_does_not_get_the_nodes_county_grafted_on():
|
||||||
|
"""
|
||||||
|
server-26#159's actual production case: the talkgroup names its own
|
||||||
|
(distant) region, so the node's county (Westchester, ~56km away) must not
|
||||||
|
be appended — it would make the query self-contradictory ("...New York
|
||||||
|
City..., Westchester, New York") and risks degrading the geocode result's
|
||||||
|
precision independently of the distance check this issue also fixes.
|
||||||
|
"""
|
||||||
|
parts, named = intel._location_query_parts(
|
||||||
|
"1108 Jackson Avenue", {}, "New York City - NYPD Citywide 2 Patch",
|
||||||
|
node_state="New York", node_county="Westchester",
|
||||||
|
)
|
||||||
|
assert "Westchester" not in parts
|
||||||
|
assert parts == ["1108 Jackson Avenue", "New York City - NYPD Citywide 2 Patch", "New York"]
|
||||||
|
assert named is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninformative_talkgroup_name_falls_back_to_node_county_and_state():
|
||||||
|
"""A tactical channel or bare code gives _municipality_from_tg nothing —
|
||||||
|
the only remaining evidence really is where the node sits, so the
|
||||||
|
original node-county-and-state fallback is preserved for this case."""
|
||||||
|
parts, named = intel._location_query_parts(
|
||||||
|
"High Street", {}, "Tac 1",
|
||||||
|
node_state="New York", node_county="Westchester",
|
||||||
|
)
|
||||||
|
assert parts == ["High Street", "Westchester", "New York"]
|
||||||
|
assert named is False
|
||||||
@@ -211,6 +211,60 @@ async def test_model_failure_leaves_the_transcript_alone():
|
|||||||
assert await tc.correct("c1", "x y z w", SEGS, system_id="sys-1") == (None, None, False)
|
assert await tc.correct("c1", "x y z w", SEGS, system_id="sys-1") == (None, None, False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Code-token guard (server-26#162) ────────────────────────────────────────
|
||||||
|
# Caught live: the same call came back with "10-7" rewritten to "10-13" in one
|
||||||
|
# place and "10-4" in another. A real code swapped for a different real code
|
||||||
|
# reads exactly as trustworthy as a correct one — worse than leaving the raw
|
||||||
|
# mishearing in place, since nothing downstream can tell it happened.
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_changed_ten_code_is_discarded():
|
||||||
|
payload = {"corrected": "10-13, we're back in town."}
|
||||||
|
with _system(), _gemini(payload):
|
||||||
|
text, _, _ = await tc.correct("c1", "10-7, we're back in town.", None, system_id="sys-1")
|
||||||
|
assert text is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invented_code_token_is_discarded():
|
||||||
|
"""Nothing code-shaped in the original — the model added one from nothing."""
|
||||||
|
payload = {"corrected": "ShotSpotter, 10-4, group of 3 shooting outside."}
|
||||||
|
with _system(), _gemini(payload):
|
||||||
|
text, _, _ = await tc.correct("c1", "Seven, group of 3 shooting outside.", None, system_id="sys-1")
|
||||||
|
assert text is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legitimate_place_correction_with_unchanged_codes_still_applies():
|
||||||
|
"""The guard must not collateral-damage a correction that never touches
|
||||||
|
a code token — Home/Forest for Holmes/4th-and-Rowe is exactly the kind of
|
||||||
|
fix this pass exists to make."""
|
||||||
|
payload = {"corrected": "10-13 coming over on Home Street and Forest Ave, 4-2."}
|
||||||
|
with _system(), _gemini(payload):
|
||||||
|
text, _, _ = await tc.correct(
|
||||||
|
"c1", "10-13 coming over on Holmes Street and 4th and Rowe, 4-2.",
|
||||||
|
None, system_id="sys-1",
|
||||||
|
)
|
||||||
|
assert text == "10-13 coming over on Home Street and Forest Ave, 4-2."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_segment_code_change_discards_segments_only():
|
||||||
|
"""A code change in one segment discards the whole segments array (same
|
||||||
|
all-or-nothing rule as a length mismatch), but the independently-checked
|
||||||
|
joined correction still stands if it kept its own codes intact. The
|
||||||
|
joined `text`/`corrected` pair here is deliberately code-free — this test
|
||||||
|
isolates the segment-level guard, not the joined-text one."""
|
||||||
|
payload = {
|
||||||
|
"corrected": "Show it out to Ossining, back to Route 9.",
|
||||||
|
"segments": ["Headquarters, 10-13.", "Show it out to Ossining.", "360 north, back to Route 9."],
|
||||||
|
}
|
||||||
|
with _system(), _gemini(payload):
|
||||||
|
text, segs, _ = await tc.correct("c1", "x y z w", SEGS, system_id="sys-1", talkgroup_id=9048)
|
||||||
|
assert segs is None
|
||||||
|
assert text == "Show it out to Ossining, back to Route 9."
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reference_data_reaches_the_prompt():
|
async def test_reference_data_reaches_the_prompt():
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
server-26#<pending> — a transcript_too_short call (<=5 words: "10-8", "show me
|
||||||
|
clear", a unit check-in) never reached correlation at all. upload.py's
|
||||||
|
no-scenes fallback (the path that lets a no-transcript call still thin-link
|
||||||
|
by talkgroup) explicitly excluded ANY skip_reason, so short-but-real follow-up
|
||||||
|
and clearance traffic was permanently unlinkable — not just unextracted by
|
||||||
|
GPT, but never even attempted against the fast/thin path that already exists
|
||||||
|
for exactly this kind of content-free signal. garbage_transcript (Whisper
|
||||||
|
hallucination) has no real content behind it and should stay excluded.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.routers import upload
|
||||||
|
|
||||||
|
ALL_ON = {
|
||||||
|
"stt_enabled": True,
|
||||||
|
"correlation_enabled": True,
|
||||||
|
"summaries_enabled": True,
|
||||||
|
"vocabulary_learning_enabled": True,
|
||||||
|
"transcript_correction_enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_ingest(skip_reason):
|
||||||
|
with patch("app.internal.feature_flags.get_flags",
|
||||||
|
AsyncMock(return_value=ALL_ON)), \
|
||||||
|
patch("app.internal.firestore.doc_get_cached",
|
||||||
|
AsyncMock(return_value={"system_id": "sys-1", "ai_flags": {}})), \
|
||||||
|
patch.object(upload, "fstore") as fs, \
|
||||||
|
patch.object(upload, "_correlate_with_consensus", AsyncMock(return_value=None)) as corr, \
|
||||||
|
patch("app.internal.transcription.transcribe_call",
|
||||||
|
AsyncMock(return_value=("10-8", []))), \
|
||||||
|
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])), \
|
||||||
|
patch("app.internal.alerter.check_and_dispatch", AsyncMock()):
|
||||||
|
fs.doc_get = AsyncMock(return_value={"skip_reason": skip_reason} if skip_reason else {})
|
||||||
|
fs.doc_set = AsyncMock()
|
||||||
|
await upload._run_intelligence_pipeline(
|
||||||
|
call_id="call-1", node_id="node-1", system_id="sys-1",
|
||||||
|
talkgroup_id=101, talkgroup_name="PD Dispatch",
|
||||||
|
gcs_uri="gs://bucket/call-1.mp3",
|
||||||
|
)
|
||||||
|
return corr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcript_too_short_now_attempts_correlation():
|
||||||
|
corr = await _run_ingest("transcript_too_short")
|
||||||
|
corr.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_garbage_transcript_still_skips_correlation():
|
||||||
|
corr = await _run_ingest("garbage_transcript")
|
||||||
|
corr.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_skip_reason_still_attempts_correlation():
|
||||||
|
corr = await _run_ingest(None)
|
||||||
|
corr.assert_awaited_once()
|
||||||
@@ -4,7 +4,7 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
|
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared primitives
|
// Shared primitives
|
||||||
@@ -1047,16 +1047,193 @@ function StaleCallsTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// STT eval (server-26#163) — the eval harness for real transcription
|
||||||
|
// accuracy. Separate from patchTranscript's "fix this call" flow: this never
|
||||||
|
// re-runs extraction or touches an incident, it only records what was
|
||||||
|
// actually said next to what Whisper heard, so eval-stats can report a real
|
||||||
|
// WER instead of a guess. Built to be worked in short sessions, a handful of
|
||||||
|
// calls at a time, over however many sittings it takes — not a one-shot form.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fmtPct(x: number | null | undefined): string {
|
||||||
|
return x === null || x === undefined ? "—" : `${(x * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">Calls verified</p>
|
||||||
|
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">Raw WER (whisper-1)</p>
|
||||||
|
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">Corrected WER (shipped)</p>
|
||||||
|
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
|
||||||
|
</div>
|
||||||
|
{stats && stats.eval_count > 0 && stats.eval_count < 20 && (
|
||||||
|
<p className="text-xs text-amber-400 font-mono self-end">
|
||||||
|
fewer than 20 calls — numbers will move a lot until this grows
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SttEvalTab() {
|
||||||
|
const [stats, setStats] = useState<{ eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null>(null);
|
||||||
|
const [queue, setQueue] = useState<CallRecord[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
const [exhausted, setExhausted] = useState(false);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [loadingBatch, setLoadingBatch] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fetching = useRef(false);
|
||||||
|
|
||||||
|
const current = queue[0] ?? null;
|
||||||
|
|
||||||
|
const refreshStats = useCallback(() => {
|
||||||
|
c2api.getEvalStats().then(setStats).catch(() => { /* stats are a nice-to-have, not load-bearing */ });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadBatch = useCallback(async () => {
|
||||||
|
if (fetching.current) return;
|
||||||
|
fetching.current = true;
|
||||||
|
setLoadingBatch(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await c2api.getEvalQueue(5, cursor);
|
||||||
|
setQueue((q) => [...q, ...res.calls]);
|
||||||
|
setCursor(res.next_cursor);
|
||||||
|
if (res.calls.length === 0 && !res.next_cursor) setExhausted(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoadingBatch(false);
|
||||||
|
fetching.current = false;
|
||||||
|
}
|
||||||
|
}, [cursor]);
|
||||||
|
|
||||||
|
useEffect(() => { refreshStats(); }, [refreshStats]);
|
||||||
|
|
||||||
|
// Auto-refill: whenever the local queue runs dry and there's more to scan
|
||||||
|
// (or we haven't checked yet), pull another batch. Covers the sparse-window
|
||||||
|
// case too — a page with matches:0 but a next_cursor just means "keep
|
||||||
|
// scanning", not "done", so this fires again on its own.
|
||||||
|
useEffect(() => {
|
||||||
|
if (queue.length === 0 && !exhausted) loadBatch();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [queue.length, exhausted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
async function saveAndNext() {
|
||||||
|
if (!current) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await c2api.putEvalTranscript(current.call_id, draft);
|
||||||
|
setQueue((q) => q.slice(1));
|
||||||
|
refreshStats();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
setQueue((q) => q.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
Listen to the audio, correct the transcript below until it matches what was actually said, then save.
|
||||||
|
This never touches the call's real transcript or re-runs anything — it only records ground truth
|
||||||
|
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<EvalStatsBar stats={stats} />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current ? (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-gray-400">
|
||||||
|
<span>{new Date(current.started_at).toLocaleString()}</span>
|
||||||
|
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{current.audio_url ? (
|
||||||
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
|
<audio controls src={current.audio_url} className="w-full h-9" />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500 italic">No audio on this call — skip it.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">
|
||||||
|
Machine transcript (pre-filled) — correct it into what was actually said
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={saveAndNext}
|
||||||
|
disabled={saving || !draft.trim()}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save & next"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={skip}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||||
|
<p className="text-sm font-mono text-gray-400">
|
||||||
|
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main admin page
|
// Main admin page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls";
|
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls" | "eval";
|
||||||
|
|
||||||
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||||||
{ key: "features", label: "AI Features" },
|
{ key: "features", label: "AI Features" },
|
||||||
{ key: "correlation", label: "Correlation Debug" },
|
{ key: "correlation", label: "Correlation Debug" },
|
||||||
{ key: "calls", label: "Calls" },
|
{ key: "calls", label: "Calls" },
|
||||||
|
{ key: "eval", label: "STT Eval" },
|
||||||
{ key: "users", label: "Users" },
|
{ key: "users", label: "Users" },
|
||||||
{ key: "audit", label: "Audit Log" },
|
{ key: "audit", label: "Audit Log" },
|
||||||
];
|
];
|
||||||
@@ -1102,6 +1279,7 @@ export default function AdminPage() {
|
|||||||
{tab === "features" && <FeaturesTab />}
|
{tab === "features" && <FeaturesTab />}
|
||||||
{tab === "correlation" && <CorrelationDebugTab />}
|
{tab === "correlation" && <CorrelationDebugTab />}
|
||||||
{tab === "calls" && <StaleCallsTab />}
|
{tab === "calls" && <StaleCallsTab />}
|
||||||
|
{tab === "eval" && <SttEvalTab />}
|
||||||
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||||
{tab === "audit" && <AuditLogTab />}
|
{tab === "audit" && <AuditLogTab />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
// never correlated was invisible. That is the wrong way round when correlation
|
// never correlated was invisible. That is the wrong way round when correlation
|
||||||
// quality is the thing under development — the orphans are the evidence.
|
// quality is the thing under development — the orphans are the evidence.
|
||||||
//
|
//
|
||||||
// Admin-only, because it exposes every call in the org regardless of node
|
// Readable by every org member — the Firestore rules already let any member
|
||||||
// ownership and carries the manual attribution controls.
|
// read every call in their org. The manual attribution controls stay
|
||||||
|
// admin-only, matching the admin gate on the link/unlink routes.
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -23,6 +24,7 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
|
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
|
||||||
|
|
||||||
type LinkFilter = "any" | "orphan" | "linked";
|
type LinkFilter = "any" | "orphan" | "linked";
|
||||||
type TranscriptFilter = "any" | "yes" | "no";
|
type TranscriptFilter = "any" | "yes" | "no";
|
||||||
@@ -68,11 +70,13 @@ function ArchiveRow({
|
|||||||
call,
|
call,
|
||||||
systemName,
|
systemName,
|
||||||
incidents,
|
incidents,
|
||||||
|
canEdit,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
call: CallRecord;
|
call: CallRecord;
|
||||||
systemName?: string;
|
systemName?: string;
|
||||||
incidents: IncidentRecord[];
|
incidents: IncidentRecord[];
|
||||||
|
canEdit: boolean;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -178,18 +182,18 @@ function ArchiveRow({
|
|||||||
<div key={id} className="flex items-center gap-2 text-xs">
|
<div key={id} className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-ink-muted">attached to</span>
|
<span className="text-ink-muted">attached to</span>
|
||||||
<span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span>
|
<span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span>
|
||||||
<button
|
{canEdit && <button
|
||||||
onClick={() => detach(id)}
|
onClick={() => detach(id)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
|
className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
|
||||||
>
|
>
|
||||||
detach
|
detach
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{canEdit && <div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={attachTo}
|
value={attachTo}
|
||||||
onChange={(e) => setAttachTo(e.target.value)}
|
onChange={(e) => setAttachTo(e.target.value)}
|
||||||
@@ -208,7 +212,7 @@ function ArchiveRow({
|
|||||||
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
|
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
|
||||||
{busy ? "Saving…" : "Attach"}
|
{busy ? "Saving…" : "Attach"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <ErrorBanner message={error} />}
|
{error && <ErrorBanner message={error} />}
|
||||||
@@ -219,8 +223,9 @@ function ArchiveRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ArchivePage() {
|
export default function ArchivePage() {
|
||||||
const { isAdmin, loading: authLoading } = useAuth();
|
const { user, orgId, isAdmin, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const canView = Boolean(user && (orgId || isAdmin));
|
||||||
const { systems } = useSystems();
|
const { systems } = useSystems();
|
||||||
const { incidents } = useIncidents(200);
|
const { incidents } = useIncidents(200);
|
||||||
|
|
||||||
@@ -235,10 +240,12 @@ export default function ArchivePage() {
|
|||||||
const [systemId, setSystemId] = useState("");
|
const [systemId, setSystemId] = useState("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [submittedQ, setSubmittedQ] = useState("");
|
const [submittedQ, setSubmittedQ] = useState("");
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !isAdmin) router.replace("/");
|
if (!authLoading && !canView) router.replace("/");
|
||||||
}, [authLoading, isAdmin, router]);
|
}, [authLoading, canView, router]);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (nextCursor: string | null, append: boolean) => {
|
async (nextCursor: string | null, append: boolean) => {
|
||||||
@@ -252,6 +259,8 @@ export default function ArchivePage() {
|
|||||||
transcript,
|
transcript,
|
||||||
system_id: systemId || undefined,
|
system_id: systemId || undefined,
|
||||||
q: submittedQ || undefined,
|
q: submittedQ || undefined,
|
||||||
|
date_from: dayStart(dateFrom)?.toISOString(),
|
||||||
|
date_to: dayEnd(dateTo)?.toISOString(),
|
||||||
});
|
});
|
||||||
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
|
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
|
||||||
setCursor(res.next_cursor);
|
setCursor(res.next_cursor);
|
||||||
@@ -262,14 +271,14 @@ export default function ArchivePage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[link, transcript, systemId, submittedQ],
|
[link, transcript, systemId, submittedQ, dateFrom, dateTo],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reload from the top whenever a filter changes.
|
// Reload from the top whenever a filter changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading || !isAdmin) return;
|
if (authLoading || !canView) return;
|
||||||
load(null, false);
|
load(null, false);
|
||||||
}, [authLoading, isAdmin, load]);
|
}, [authLoading, canView, load]);
|
||||||
|
|
||||||
const systemName = useMemo(() => {
|
const systemName = useMemo(() => {
|
||||||
const m = new Map(systems.map((s) => [s.system_id, s.name]));
|
const m = new Map(systems.map((s) => [s.system_id, s.name]));
|
||||||
@@ -277,7 +286,7 @@ export default function ArchivePage() {
|
|||||||
}, [systems]);
|
}, [systems]);
|
||||||
|
|
||||||
// Every hook runs before this guard — see the note in app/nodes/page.tsx.
|
// Every hook runs before this guard — see the note in app/nodes/page.tsx.
|
||||||
if (authLoading || !isAdmin) return null;
|
if (authLoading || !canView) return null;
|
||||||
|
|
||||||
const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length;
|
const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length;
|
||||||
const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length;
|
const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length;
|
||||||
@@ -286,7 +295,9 @@ export default function ArchivePage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Archive"
|
title="Archive"
|
||||||
description="Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong."
|
description={isAdmin
|
||||||
|
? "Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong."
|
||||||
|
: "Every call on the account, correlated or not."}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -329,6 +340,8 @@ export default function ArchivePage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<DateRange from={dateFrom} to={dateTo} onChange={(f, t) => { setDateFrom(f); setDateTo(t); }} />
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
|
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
|
||||||
className="flex items-center gap-2 ml-auto"
|
className="flex items-center gap-2 ml-auto"
|
||||||
@@ -373,6 +386,7 @@ export default function ArchivePage() {
|
|||||||
call={call}
|
call={call}
|
||||||
systemName={systemName(call.system_id)}
|
systemName={systemName(call.system_id)}
|
||||||
incidents={incidents}
|
incidents={incidents}
|
||||||
|
canEdit={isAdmin}
|
||||||
onChanged={() => load(null, false)}
|
onChanged={() => load(null, false)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/Badge";
|
|||||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
|
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
|
||||||
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
||||||
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
||||||
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||||
@@ -27,6 +28,23 @@ const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
|
|||||||
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
|
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
|
||||||
|
|
||||||
type SortMode = "recent" | "severity";
|
type SortMode = "recent" | "severity";
|
||||||
|
type StatusFilter = "any" | "active" | "resolved";
|
||||||
|
|
||||||
|
const INCIDENT_TYPES = ["fire", "police", "ems", "accident", "other"];
|
||||||
|
|
||||||
|
// Firestore holds the paging; text/type/status filtering runs over the loaded
|
||||||
|
// window, so "Load more" also widens what the search can find.
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
function matchesSearch(inc: IncidentRecord, needle: string): boolean {
|
||||||
|
if (!needle) return true;
|
||||||
|
const hay = [
|
||||||
|
inc.title, inc.location, inc.summary, inc.type,
|
||||||
|
...(inc.units ?? []), ...(inc.vehicles ?? []), ...(inc.tags ?? []),
|
||||||
|
...(inc.location_mentions ?? []),
|
||||||
|
].filter(Boolean).join(" ").toLowerCase();
|
||||||
|
return hay.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
// The Firestore client surfaces a missing composite index or an undeployed
|
// The Firestore client surfaces a missing composite index or an undeployed
|
||||||
// ruleset as a raw multi-line string with a console URL in it — not something
|
// ruleset as a raw multi-line string with a console URL in it — not something
|
||||||
@@ -178,11 +196,19 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
|||||||
|
|
||||||
export default function IncidentsPage() {
|
export default function IncidentsPage() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { incidents, loading, error } = useIncidents();
|
const [pageLimit, setPageLimit] = useState(PAGE_SIZE);
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
const rangeFrom = useMemo(() => dayStart(dateFrom), [dateFrom]);
|
||||||
|
const rangeTo = useMemo(() => dayEnd(dateTo), [dateTo]);
|
||||||
|
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const onAirIncidentIds = useMemo(() => {
|
const onAirIncidentIds = useMemo(() => {
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
@@ -194,12 +220,24 @@ export default function IncidentsPage() {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const threshold = FILTER_THRESHOLD[severityFilter];
|
const threshold = FILTER_THRESHOLD[severityFilter];
|
||||||
const list = incidents.filter((i) => severityRank(i.severity) >= threshold);
|
const needle = search.trim().toLowerCase();
|
||||||
|
const list = incidents.filter((i) =>
|
||||||
|
severityRank(i.severity) >= threshold &&
|
||||||
|
(statusFilter === "any" || i.status === statusFilter) &&
|
||||||
|
(!typeFilter || i.type === typeFilter) &&
|
||||||
|
matchesSearch(i, needle)
|
||||||
|
);
|
||||||
if (sortMode === "severity") {
|
if (sortMode === "severity") {
|
||||||
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
|
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
|
||||||
}
|
}
|
||||||
return list; // useIncidents() already orders by started_at desc
|
return list; // useIncidents() already orders by started_at desc
|
||||||
}, [incidents, severityFilter, sortMode]);
|
}, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]);
|
||||||
|
|
||||||
|
const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== "" || dateFrom !== "" || dateTo !== "";
|
||||||
|
function clearFilters() {
|
||||||
|
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
|
||||||
|
setDateFrom(""); setDateTo(""); setPageLimit(PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
const hiddenCount = incidents.length - filtered.length;
|
const hiddenCount = incidents.length - filtered.length;
|
||||||
const activeCount = filtered.filter((i) => i.status === "active").length;
|
const activeCount = filtered.filter((i) => i.status === "active").length;
|
||||||
@@ -249,7 +287,39 @@ export default function IncidentsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-xs text-ink-muted">
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search title, location, units…"
|
||||||
|
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2 w-full sm:w-64 focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||||
|
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="any">Any status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">All types</option>
|
||||||
|
{INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<DateRange
|
||||||
|
from={dateFrom}
|
||||||
|
to={dateTo}
|
||||||
|
onChange={(f, t) => { setDateFrom(f); setDateTo(t); setPageLimit(PAGE_SIZE); }}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
|
||||||
Sort
|
Sort
|
||||||
<select
|
<select
|
||||||
value={sortMode}
|
value={sortMode}
|
||||||
@@ -270,7 +340,8 @@ export default function IncidentsPage() {
|
|||||||
<>
|
<>
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
<p className="text-xs text-ink-muted">
|
<p className="text-xs text-ink-muted">
|
||||||
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
|
{hiddenCount} of {incidents.length} loaded incident{incidents.length !== 1 ? "s" : ""} hidden by filters
|
||||||
|
{hasMore && " — load more to search further back"}.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -302,19 +373,27 @@ export default function IncidentsPage() {
|
|||||||
|
|
||||||
{filtered.length === 0 && !error && (
|
{filtered.length === 0 && !error && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
|
title={incidents.length === 0 && !filtersActive ? "No incidents recorded yet" : "No incidents match these filters"}
|
||||||
description={
|
description={
|
||||||
incidents.length === 0
|
incidents.length === 0 && !filtersActive
|
||||||
? "Incidents appear automatically once calls start correlating."
|
? "Incidents appear automatically once calls start correlating."
|
||||||
: "Try a lower severity threshold."
|
: "Try clearing a filter, or load older incidents."
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
incidents.length > 0 && severityFilter !== "all" ? (
|
filtersActive ? (
|
||||||
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
|
<Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button variant="secondary" onClick={() => setPageLimit((n) => n + PAGE_SIZE)}>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// A from/to pair of native date inputs. Values are the inputs' own
|
||||||
|
// "YYYY-MM-DD" strings; dayStart/dayEnd turn them into the local-midnight
|
||||||
|
// bounds a started_at range query needs, so "to" includes the whole day.
|
||||||
|
|
||||||
|
export function dayStart(ymd: string): Date | undefined {
|
||||||
|
if (!ymd) return undefined;
|
||||||
|
const [y, m, d] = ymd.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayEnd(ymd: string): Date | undefined {
|
||||||
|
if (!ymd) return undefined;
|
||||||
|
const [y, m, d] = ymd.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d, 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent";
|
||||||
|
|
||||||
|
export function DateRange({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
onChange: (from: string, to: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-ink-muted">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="From date"
|
||||||
|
value={from}
|
||||||
|
max={to || undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value, to)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<span>to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="To date"
|
||||||
|
value={to}
|
||||||
|
min={from || undefined}
|
||||||
|
onChange={(e) => onChange(from, e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
{(from || to) && (
|
||||||
|
<button onClick={() => onChange("", "")} className="text-ink-muted hover:text-ink-2">
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@ export const c2api = {
|
|||||||
link?: "any" | "orphan" | "linked";
|
link?: "any" | "orphan" | "linked";
|
||||||
transcript?: "any" | "yes" | "no";
|
transcript?: "any" | "yes" | "no";
|
||||||
q?: string;
|
q?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
@@ -100,6 +102,30 @@ export const c2api = {
|
|||||||
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
||||||
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
|
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
|
||||||
|
|
||||||
|
// STT eval harness (server-26#163) — separate from patchTranscript above,
|
||||||
|
// which is a production correction with real side effects (re-extraction,
|
||||||
|
// incident unlinking, vocabulary learning). This is pure measurement.
|
||||||
|
getEvalQueue: (limit: number, cursor?: string | null) => {
|
||||||
|
const qs = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (cursor) qs.set("cursor", cursor);
|
||||||
|
return request<{
|
||||||
|
calls: import("@/lib/types").CallRecord[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
scanned: number;
|
||||||
|
matched: number;
|
||||||
|
window_exhausted: boolean;
|
||||||
|
}>(`/calls/eval-queue?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
getEvalStats: () =>
|
||||||
|
request<{ eval_count: number; raw_wer: number | null; corrected_wer: number | null }>(
|
||||||
|
"/calls/eval-stats"
|
||||||
|
),
|
||||||
|
putEvalTranscript: (callId: string, text: string) =>
|
||||||
|
request<{ ok: boolean; call_id: string }>(`/calls/${callId}/eval-transcript`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Incidents
|
// Incidents
|
||||||
getIncidents: (params?: { status?: string; type?: string }) => {
|
getIncidents: (params?: { status?: string; type?: string }) => {
|
||||||
const qs = params ? "?" + new URLSearchParams(params as Record<string, string>).toString() : "";
|
const qs = params ? "?" + new URLSearchParams(params as Record<string, string>).toString() : "";
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ export interface CallRecord {
|
|||||||
corr_incident_idle_min?: number | null;
|
corr_incident_idle_min?: number | null;
|
||||||
corr_shared_units?: number | null;
|
corr_shared_units?: number | null;
|
||||||
corr_candidates?: number | null;
|
corr_candidates?: number | null;
|
||||||
|
/** Human-verified reference transcript for the STT eval harness (server-26#163) — never read by anything downstream. */
|
||||||
|
eval_transcript?: string | null;
|
||||||
|
eval_transcript_by?: string | null;
|
||||||
|
eval_transcript_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IncidentRecord {
|
export interface IncidentRecord {
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ const toISO = (v: unknown): string =>
|
|||||||
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
|
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
|
||||||
(typeof v === "string" ? v : new Date().toISOString());
|
(typeof v === "string" ? v : new Date().toISOString());
|
||||||
|
|
||||||
export function useIncidents(limitCount = 100) {
|
export function useIncidents(limitCount = 100, dateFrom?: Date, dateTo?: Date) {
|
||||||
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// A full page means there may be older incidents past the limit; a short
|
||||||
|
// page means the query reached the end of the collection.
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const { orgId } = useAuth();
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
|
// Stable ms values so the effect dependency doesn't fire on every render
|
||||||
|
const dateFromMs = dateFrom?.getTime();
|
||||||
|
const dateToMs = dateTo?.getTime();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -34,9 +41,18 @@ export function useIncidents(limitCount = 100) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A range on the ordered field rides the existing org_id/started_at index.
|
||||||
|
// Incident started_at is stored as a Python isoformat() STRING
|
||||||
|
// ("2026-09-20T12:00:00.123456+00:00", incident_correlator.py), not a
|
||||||
|
// Firestore timestamp — unlike calls. A Date bound compares by type and
|
||||||
|
// matches nothing, so the bounds go in as UTC ISO strings in the same
|
||||||
|
// shape, which then compare lexicographically in time order.
|
||||||
|
const isoBound = (ms: number) => new Date(ms).toISOString().replace("Z", "+00:00");
|
||||||
const q = query(
|
const q = query(
|
||||||
collection(db, "incidents"),
|
collection(db, "incidents"),
|
||||||
where("org_id", "==", orgId),
|
where("org_id", "==", orgId),
|
||||||
|
...(dateFromMs != null ? [where("started_at", ">=", isoBound(dateFromMs))] : []),
|
||||||
|
...(dateToMs != null ? [where("started_at", "<=", isoBound(dateToMs))] : []),
|
||||||
orderBy("started_at", "desc"),
|
orderBy("started_at", "desc"),
|
||||||
limit(limitCount)
|
limit(limitCount)
|
||||||
);
|
);
|
||||||
@@ -49,6 +65,7 @@ export function useIncidents(limitCount = 100) {
|
|||||||
updated_at: toISO(data.updated_at),
|
updated_at: toISO(data.updated_at),
|
||||||
} as IncidentRecord;
|
} as IncidentRecord;
|
||||||
}));
|
}));
|
||||||
|
setHasMore(snap.size >= limitCount);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, (err: FirestoreError) => {
|
}, (err: FirestoreError) => {
|
||||||
console.error("useIncidents:", err);
|
console.error("useIncidents:", err);
|
||||||
@@ -61,9 +78,9 @@ export function useIncidents(limitCount = 100) {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, [limitCount, orgId]);
|
}, [limitCount, dateFromMs, dateToMs, orgId]);
|
||||||
|
|
||||||
return { incidents, loading, error };
|
return { incidents, loading, error, hasMore };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIncident(incidentId: string | null) {
|
export function useIncident(incidentId: string | null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user