18 Commits
Author SHA1 Message Date
logan e79b8bc37d Merge pull request 'Incident date filter matched nothing' (#168) from fix/incident-date-filter into main
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 1m27s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-24 01:15:31 -04:00
Logan CusanoandClaude Opus 5.5 c72c28f5dc frontend: incident date filter matched nothing
Incident started_at is an isoformat() string (incident_correlator.py,
routers/incidents.py), not a Firestore timestamp like calls. The range
bounds were Dates, which Firestore compares by type, so any date range
returned zero incidents. Bounds are now UTC ISO strings in the same
"+00:00" shape, which order lexicographically by time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:15:23 -04:00
logan 02b5b7b5a5 Merge pull request 'Archive Load more skipped 150 of every 200 calls' (#166) from fix/archive-paging-skip into main
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 3m45s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-24 01:05:37 -04:00
Logan CusanoandClaude Opus 5.5 40014a47a3 c2-core: Archive "Load more" skipped 150 of every 200 calls
/calls/search scans a 200-row window and returns 50, but the next cursor
was always the last SCANNED row — so with an unfiltered list each page
jumped past the 150 matches it had already read and not shown. Resume
after the last RETURNED row when matches overflow the page; keep the
last-scanned cursor only when the page holds every match (the sparse-
filter case that cursor exists for). Same fix for /calls/eval-queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:05:31 -04:00
logan 6c0e7a4f8e Merge pull request 'Date range picker on Incidents and Archive; fix Archive Load more' (#165) from feat/date-range into main
Build & Deploy / Build & push images (push) Successful in 4m22s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 2m35s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-24 01:04:06 -04:00
Logan CusanoandClaude Opus 5.5 6479174022 frontend: date range picker on Incidents and Archive; fix Archive paging
Incidents and Archive get a from/to date range (native date inputs,
local-day bounds). Incidents filters in the Firestore query; Archive
passes date_from/date_to to GET /calls/search, which applies them as a
started_at range — both ride the existing org_id/started_at index.

Also fixes /calls/search and /calls/eval-queue paging: the cursor went to
Firestore as a raw ISO string against a timestamp field, which compares
by type rather than time, so "Load more" re-read the first page. Cursor
and range bounds are now parsed to datetimes (400 on garbage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:03:40 -04:00
logan c043298902 Merge pull request 'Incidents search/filter/load-more; Archive viewable by viewers' (#164) from feat/archive-search-viewers into main
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 4s
Build & Deploy / Deploy to VM (push) Successful in 2m26s
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-23 23:54:35 -04:00
Logan CusanoandClaude Opus 5.5 fa194e0f0a frontend: search, filters and load-more on Incidents; open Archive to viewers
Incidents page: text search (title, location, summary, units, vehicles,
tags, location mentions), status and type filters, and a Load more button
that pages the Firestore query 100 at a time. Filtering runs over the
loaded window, and the page says so when older incidents exist.

Archive (/calls): readable by every org member, not just admins.
GET /calls/search now takes any Firebase token scoped to the caller's org
— the Firestore rules already let members read every call in their org,
so this widens nothing. Attach/detach stays admin-only (UI and routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:53:46 -04:00
Logan CusanoandClaude Sonnet 5 5f85a878fa admin: STT eval harness — record human-verified transcripts, measure real WER (#163)
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 1m56s
Build & Deploy / Report a failed deploy (push) Successful in 1s
Backend: three new routes on the calls router, deliberately separate from
PATCH /{call_id}/transcript (a production correction with real side effects
-- re-extraction, incident unlinking, vocabulary learning). This is pure
measurement and must never share that code path.

  GET  /calls/eval-queue        -- calls with a transcript but no
                                    eval_transcript yet, paged (same bounded-
                                    window-plus-cursor shape as /search)
  PUT  /{call_id}/eval-transcript -- records eval_transcript/_by/_at only;
                                      never touches transcript/transcript_corrected
  GET  /calls/eval-stats        -- eval_count + average word error rate of
                                    the raw and corrected machine transcripts
                                    against the human-verified ones

internal/wer.py: standard word-level Levenshtein WER. Returns None (not 0.0)
when the reference is empty -- a call nobody transcribed must not score as a
perfect match.

Frontend: a new "STT Eval" tab on /admin -- one call at a time, audio player,
a textarea pre-filled with the machine transcript to correct into ground
truth, Save & next / Skip, running WER stats at the top. Built for working a
handful of calls at a time over however many sittings it takes, not a
one-shot form: the queue auto-refills from where the last save left off.

Verified: 438 pass, 0 fail (12 new backend tests). Frontend is UNVERIFIED --
this box has no Node.js/npm (confirmed absent), so neither typecheck nor the
dev server could be run. Matches existing code patterns and the CallRecord/
c2api types by manual review only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:04:30 -04:00
Logan CusanoandClaude Sonnet 5 241a15b8da transcript_correction: reject a correction that changes a ten-code (#162)
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 2m1s
Build & Deploy / Report a failed deploy (push) Successful in 1s
correct()'s prompt says "Do NOT expand ten-codes" and "NEVER add
information", but nothing checked the model's output against its own
rules -- raw["corrected"] was accepted verbatim past a non-empty/changed
check, and the "changed" field it returns was logged for audit and never
validated.

Caught live: the same call's raw vs corrected transcript showed "10-7"
rewritten to "10-13" in one place and "10-4" in another, plus a bare "7"
expanded into "ShotSpotter" -- alongside genuinely good fixes (Holmes
Street and 4th and Rowe -> Home Street and Forest Ave, from this system's
own vocabulary). A wrong 10-13 standing in for a real 10-7 reads exactly
as trustworthy as a correct transcript, which is worse than leaving the
raw mishearing in place.

_code_tokens() extracts every ten-code/signal-shaped token from the
original and corrected text/segments; any change to that set discards
the correction and falls back to raw. Checked independently for the
joined text and for segments, consistent with the existing
all-or-nothing segment-alignment rule.

Does not catch a wrong word swapped for another equally plausible
non-code word -- that class still depends entirely on the model
following its own prompt. Filed server-26#161 for the broader STT/audio
quality initiative this belongs alongside.

Verified: 426 pass, 0 fail (4 new tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 21:51:41 -04:00
Logan CusanoandClaude Sonnet 5 f91d4559f3 deploy: treat an empty .last_good_tag the same as a missing one (#156)
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 2m0s
Build & Deploy / Report a failed deploy (push) Successful in 1s
cat file 2>/dev/null || echo latest only falls back when cat itself fails
(nonzero exit, i.e. the file is missing) -- a file that EXISTS but is EMPTY
makes cat succeed with empty output, so PREV_TAG became "" instead of
"latest". That "" failed the emptiness check further down and exited 1 --
AFTER git pull + docker compose up -d had already succeeded. Worse: exiting
there skips the Health check step (Gitea Actions doesn't run later steps
after a failure), and Health check is the ONLY step that ever writes a real
value to .last_good_tag. Self-perpetuating: once the file went empty, every
future deploy failed the same way forever, with the app itself deploying
fine underneath it every time (confirmed against run 611: deploy log shows
all three images pulled/recreated/started at 66bbf5b, and /health
self-reports that exact GIT_SHA, baked into the image at build time --
two independent signals, same commit).

${VAR:-default} covers empty and unset in one expansion, matching the
fallback behavior the surrounding comment already documented as intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:36:17 -04:00
Logan CusanoandClaude Sonnet 5 66bbf5b473 intelligence: don't reject a geocode just because it is far from the node (#159)
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 2s
Build & Deploy / Deploy to VM (push) Failing after 2m4s
Build & Deploy / Report a failed deploy (push) Successful in 1s
_geocode_location's node-distance fallback (geocode_max_km, 40km) 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 doesn't hold for a
citywide/patched feed: node-002 sits in Westchester but relays "New York
City - NYPD Citywide 2 Patch", ~56km from the addresses on it. Every real,
correctly-geocoded address on that talkgroup was rejected by this check,
every 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 ("Shots Fired at Jackson Avenue" /
"Shots Fired at 1108 Jackson Avenue", 2026-09-20 ~23:00 UTC, merged by
hand via the Archive page's attach/detach while this fix went in).

trust_named_region skips the node-distance rejection 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. The anchor path is untouched; an anchor's own radius is always
authoritative when one has been resolved.

Also fixes a compounding defect found while verifying: the query was
grafting the node's own COUNTY onto an already-self-named region
("...New York City..., Westchester, New York"), which is self-contradictory
and could degrade the geocode independent of the distance check. The
node's county is now used only when nothing else names the place; state
stays in both branches since it's coarse enough to be correct either way.
Extracted the query-assembly logic into a pure, unit-tested helper
(_location_query_parts) rather than testing it only through the full
extraction pipeline.

Filed #160 as a follow-up: place_verifier.py's verify() has the identical
no-anchor gap for transcript place-name correction, not fixed here.

Verified: 422 pass, 0 fail (9 new tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:22:42 -04:00
Logan CusanoandClaude Sonnet 5 6c095083fc correlator: use srcaddr for thin-call disambiguation instead of pure recency (#158)
The fast/thin path (short acks like "10-4", enabled to attempt linking at
all by 1ffff25) had no identity signal available -- transcript_too_short
skips GPT extraction entirely, so call_units is always empty for this
population. It fell back to "most recently updated incident on this
talkgroup", which silently misattaches a short ack to the wrong incident
whenever two are live on the same busy dispatch channel at once.

metadata_watcher.py already captures the P25 source radio ID (srcaddr) on
every call independent of transcript content, and it already reaches the
call doc (models.py, mqtt_handler.py:245) -- it was just never read by the
correlator. Thread it through _build_context, check it against the
srcaddrs already seen on each TG-matched incident before falling back to
recency, and accumulate it on the incident (_update_incident/_create_incident)
so later calls from the same radio can match.

Verified: 422 pass, 0 fail (4 new tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:22:06 -04:00
Logan CusanoandClaude Sonnet 5 2e67d1bad6 ci: deploy Firestore rules/indexes from the runner, not the VM (server-26#51)
The rules/indexes deploy step already existed here, gated on `command -v
firebase` over SSH on the deploy VM. It never found one (no node on the
VM), so it silently warned-and-skipped on every single deploy for weeks —
PR #124 even auto-closed #13/#51 as if this were fixed, when it wasn't.

New standalone deploy-firestore-rules job runs on the Gitea runner itself
(always has node), authenticated via a new FIREBASE_TOKEN secret (from
`firebase login:ci`) instead of anything pre-installed on the VM. It's
independent of the deploy job's health-check/rollback chain on purpose —
a rules deploy failure has nothing to roll back and must not trigger that
logic. notify-failure now distinguishes which job actually failed so the
Discord alert doesn't misreport "production is unchanged" when the app
deployed fine and only the rules push failed.

Needs FIREBASE_TOKEN added as a Gitea Actions secret before this actually
runs — it will fail loudly (by design) until then, which is the whole
point: a loud failure beats a silent skip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:08:58 -04:00
Logan CusanoandClaude Sonnet 5 1ffff25cd2 upload: let short transcripts (<=5 words) attempt correlation instead of never linking at all
Build & Deploy / Build & push images (push) Successful in 4m8s
Build & Deploy / Deploy to VM (push) Failing after 2m21s
Build & Deploy / Report a failed deploy (push) Successful in 1s
intelligence.py skips GPT extraction for transcripts <=5 words ("10-8", "show
me clear", a unit check-in) -- real cost/hallucination guard, kept as-is. But
upload.py's no-scenes correlation fallback (the path that thin-links a call
by talkgroup even with zero extracted content) excluded ANY skip_reason,
including transcript_too_short -- so this exact population, brief but real
follow-up and clearance traffic, never even attempted to attach to anything.
Found live: "Live, Ossining." sitting an orphan 6 seconds before a real
incident's founding call, on the same talkgroup.

Now only garbage_transcript (Whisper hallucination, no real content) stays
excluded; transcript_too_short reaches the same thin/fast-path fallback
already trusted for no-transcript calls, gated the same way -- same-talkgroup,
recently-active incident required before anything attaches. No GPT re-invoked,
no new cost.

Verified: 410 pass, 0 fail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:39:46 -04:00
Logan CusanoandClaude Sonnet 5 3d2b722c64 Wire AIS end to end: telemetry ingestion + live map overlay
node-26#9, same shape as the ADS-B commit. Adds POST /telemetry/ais
(same node-key auth, same org_id-stamped upsert-by-key pattern, this time
by mmsi into a new `vessels` collection) and its docInMyOrg() firestore
rule. Frontend gets useVessels() (mirrors useAircraft(), longer staleness
window since AIS position reports are minutes apart, not seconds) and an
opt-in "Vessels" map overlay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:12:56 -04:00
Logan CusanoandClaude Sonnet 5 5537b095df Wire ADS-B end to end: telemetry ingestion + live map overlay
node-26#9. Adds POST /telemetry/adsb (node-key authed via
require_node_service_or_firebase_token) that upserts one Firestore doc per
icao into a new `aircraft` collection, org_id stamped from the reporting
node the same way upload.py defensively stamps `calls`. firestore.rules
gets a matching docInMyOrg()-gated read rule.

Frontend: useAircraft() mirrors useNodes()'s onSnapshot pattern, filtering
docs older than 2 minutes client-side since nothing prunes a stale aircraft
doc server-side yet. MapView gets an opt-in "Aircraft" overlay (unchecked
by default, like the weather radar layer) rendering a rotated plane glyph
per sighting.

Unverified via typecheck — no Node.js/npm on this authoring box yet (see
CLAUDE.md Testing reality). Server side is pytest-covered (test_telemetry.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:08:29 -04:00
Logan CusanoandClaude Sonnet 5 8892e824fc Add second-SDR fields: secondary_sdr_mode + sdr_count on NodeRecord
Server-side half of node-26#9. NodeRecord gains secondary_sdr_mode
(none|adsb|ais|op25_2) and sdr_count; checkin ingestion stores both,
and PATCH /nodes/{id} accepts and re-pushes secondary_sdr_mode the same
way hardware_preset/ppm_override already work, so it isn't wiped by a
system reassignment (see server-26#111 for the pre-existing bug that
pattern avoids repeating).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 15:56:33 -04:00
30 changed files with 1963 additions and 101 deletions
+75 -24
View File
@@ -115,27 +115,14 @@ jobs:
# Update compose files + mosquitto config
git pull origin main
# server-26#51: Firestore rules + composite indexes had no deploy
# path and regressed silently after every fix (the alert_events and
# calls(org_id,started_at) indexes among them). The VM runs as the
# project service account, so firebase-tools authenticates via ADC
# with no key file, and infra/firestore/firebase.json pins database
# c2-server. Indexes go on additively -- no --force -- so a stray
# edit to firestore.indexes.json can never delete a live index;
# rules are a full replace, which is the intent. --non-interactive
# means the FIRST run after a drift still needs a one-time manual
# `firebase deploy` on the VM to clear pending deletions (it aborts
# rather than guess). A failure here warns but does NOT fail the
# deploy: a transient Firebase API error must not roll back a good
# app build.
if command -v firebase >/dev/null 2>&1; then
( cd /opt/drb/infra/firestore \
&& firebase deploy --only firestore:rules,firestore:indexes \
--project ${{ secrets.FIREBASE_PROJECT_ID }} --non-interactive ) \
|| echo "WARNING: firestore deploy failed (server-26#51) -- rules/indexes may be stale"
else
echo "WARNING: firebase CLI not on the VM -- skipped firestore deploy (server-26#51); install once with: npm i -g firebase-tools"
fi
# server-26#51: Firestore rules/indexes deploy used to be attempted
# HERE, over SSH, gated on the VM having firebase-tools installed.
# It never did (no node on the VM), so this silently warned and
# skipped on every deploy for weeks -- PR #124 even auto-closed
# #13/#51 as if it were fixed. Moved to a standalone
# deploy-firestore-rules job below that runs on the Gitea runner
# itself (which always has node), so it no longer depends on
# anything being pre-installed on this VM.
# server-26#65: capture what is actually live BEFORE switching, so
# 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
# fresh VM with no file yet falls back to :latest, same escape
# 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"
# Deploy THIS commit's images, not :latest. Overlapping runs are
@@ -291,9 +291,48 @@ jobs:
echo "status=success" >> "$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:
name: Report a failed deploy
needs: [build, deploy]
needs: [build, deploy, deploy-firestore-rules]
if: failure()
runs-on: ubuntu-latest
@@ -310,6 +349,8 @@ jobs:
SHA: ${{ gitea.sha }}
ROLLBACK_STATUS: ${{ needs.deploy.outputs.rollback_status }}
ROLLBACK_SHA: ${{ needs.deploy.outputs.rollback_sha }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
RULES_RESULT: ${{ needs.deploy-firestore-rules.result }}
run: |
if [ -z "$WEBHOOK" ]; then
echo "DEPLOY_ALERT_WEBHOOK is not set - skipping notification."
@@ -321,6 +362,16 @@ jobs:
run_url = os.environ["RUN_URL"]
status = os.environ.get("ROLLBACK_STATUS", "")
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
# "production is still running the previous build" -- true only
@@ -329,7 +380,7 @@ jobs:
# class of bug the correlator instrumentation exists to catch), or
# once the deploy job's own rollback path has run. Say what
# 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]
elif status == "failed":
detail = ("Automatic rollback to `%s` FAILED. Production state is UNKNOWN -- "
+63 -21
View File
@@ -875,11 +875,17 @@ async def _build_context(
is_thin_call = _is_thin_call(
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 {
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
"call_doc": call_doc, "call_embedding": call_embedding,
"scene_transcript": scene_transcript,
"scene_transcript": scene_transcript, "call_srcaddr": call_srcaddr,
"call_units": call_units, "call_vehicles": call_vehicles,
"call_cleared": call_cleared, "call_severity": call_severity,
"coords": coords, "is_thin_call": is_thin_call, "now": now,
@@ -933,6 +939,7 @@ def _run_decision(ctx: dict) -> dict:
call_severity = ctx["call_severity"]
coords = ctx["coords"]
is_thin_call = ctx["is_thin_call"]
call_srcaddr = ctx.get("call_srcaddr")
system_id = ctx["system_id"]
talkgroup_id = ctx["talkgroup_id"]
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
# single-candidate requirement and no fit test of any kind. Four
# 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_window_min = settings.tg_dispatch_thin_idle_minutes
very_recent = [
inc for inc in tg_recent
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", ""))]
if srcaddr_matches:
thin_pool = [max(srcaddr_matches, key=lambda inc: inc.get("updated_at", ""))]
logger.info(
f"Correlator fast-path thin (tier-1, ≤{THIN_CONVERSATIONAL_SECS}s): "
f"using most-recent of {len(very_recent)} candidate(s) for call {call_id}"
f"Correlator fast-path thin (srcaddr match): radio {call_srcaddr} "
f"already on {len(srcaddr_matches)} candidate(s) for call {call_id}"
)
else:
# Tier 2: less certain — require a single candidate inside the
# channel's thin window.
thin_pool = [
very_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(
f"Correlator fast-path thin (tier-2): {len(thin_pool)} active incidents "
f"— ambiguous, skipping thin call {call_id}"
f"Correlator fast-path thin (tier-1, ≤{THIN_CONVERSATIONAL_SECS}s): "
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:
logger.info(
@@ -1049,8 +1076,10 @@ def _run_decision(ctx: dict) -> dict:
# no fit signal, so the admin debug view's "fit_signal
# distribution" panel read empty on 95% of calls and looked
# broken. Name what actually decided it: recency on this
# talkgroup, with no content to check a fit against.
"corr_fit_signal": "thin_recency",
# talkgroup, with no content to check a fit against — or,
# 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),
}
logger.info(
@@ -1511,6 +1540,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
call_cleared = ctx["call_cleared"]
coords = ctx["coords"]
now = ctx["now"]
call_srcaddr = ctx.get("call_srcaddr")
incident_type = decision["incident_type"]
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,
talkgroup_name=talkgroup_name, incident_type=incident_type,
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"]
@@ -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,
tags, location, location_coords,
call_units, call_vehicles, call_embedding, call_severity, now,
call_srcaddr=call_srcaddr,
)
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,
tags, location, location_coords,
call_units, call_vehicles, call_embedding, call_severity, now,
call_srcaddr=call_srcaddr,
)
decision["corr_debug"]["corr_path"] = "new"
@@ -1945,6 +1977,7 @@ async def _update_incident(
cleared_units: Optional[list[str]] = None,
refresh_activity: bool = True,
call_severity: Optional[str] = None,
call_srcaddr: Optional[str] = None,
) -> None:
incident_id = inc["incident_id"]
@@ -1963,6 +1996,12 @@ async def _update_incident(
merged_tags = list(dict.fromkeys((inc.get("tags") or []) + tags))
merged_units = list(dict.fromkeys((inc.get("units") or []) + call_units))
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
# units_active = units currently on scene; units_cleared = units back in service
@@ -1993,6 +2032,7 @@ async def _update_incident(
"tags": merged_tags,
"units": merged_units,
"vehicles": merged_vehicles,
"srcaddrs": merged_srcaddrs,
"units_active": units_active,
"units_cleared": units_cleared,
"location_mentions": location_mentions,
@@ -2059,6 +2099,7 @@ async def _create_incident(
call_embedding: Optional[list],
call_severity: str,
now: datetime,
call_srcaddr: Optional[str] = None,
) -> str:
incident_id = str(uuid.uuid4())
tg_label = (
@@ -2102,6 +2143,7 @@ async def _create_incident(
"units_active": list(call_units),
"units_cleared": [],
"vehicles": call_vehicles,
"srcaddrs": [call_srcaddr] if call_srcaddr else [],
"severity": call_severity,
"summary": None,
"summary_stale": True,
+83 -13
View File
@@ -368,18 +368,20 @@ async def extract_scenes(
# the country.
location_coords: Optional[dict] = None
if location:
parts = [location]
if tg_area.get("municipality") or tg_area.get("county") or tg_area.get("state"):
parts += [tg_area[f] for f in area_context.PLACE_FIELDS if tg_area.get(f)]
elif node_lat is not None and node_lon is not None:
muni = _municipality_from_tg(talkgroup_name)
state = await _get_node_state(node_id or "", node_lat, node_lon) if node_id else ""
county = _node_county_cache.get(node_id or "") if node_id else ""
parts += [p for p in (muni, county, state) if p]
node_state, node_county = "", ""
if not area_context.has_place(tg_area) and node_id and node_lat is not None and node_lon is not None:
# Only worth the (cached-after-first-call) reverse-geocode
# when nothing better already describes this talkgroup.
node_state = await _get_node_state(node_id, node_lat, node_lon)
node_county = _node_county_cache.get(node_id) or ""
parts, tg_named_region = _location_query_parts(
location, tg_area, talkgroup_name, node_state, node_county,
)
query = ", ".join(parts)
if tg_anchor or (node_lat is not None and node_lon is not None):
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
@@ -514,6 +516,7 @@ async def _geocode_location(
node_lat: Optional[float] = None,
node_lon: Optional[float] = None,
anchor: Optional[dict] = None,
trust_named_region: bool = False,
) -> Optional[dict]:
"""
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
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.
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
from app.config import settings
@@ -594,11 +617,21 @@ async def _geocode_location(
lat, lng = float(loc["lat"]), float(loc["lng"])
dist_km = _geo_dist_km(ref_lat, ref_lon, lat, lng)
if dist_km > max_km:
logger.warning(
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
# server-26#159: the node-distance bound is a proxy for "is
# this plausible" that only makes sense when the node's own
# 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}
logger.info(
f"Geocoded '{location_str}' → {coords} "
@@ -624,6 +657,43 @@ def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
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:
"""Format transcript as numbered transmissions if segments are available."""
if segments and len(segments) > 1:
+7
View File
@@ -111,6 +111,8 @@ class MQTTHandler:
"assigned_system_id": None,
"approval_status": "pending",
"node_type": payload.get("node_type", "fixed"),
"secondary_sdr_mode": payload.get("secondary_sdr_mode", "none"),
"sdr_count": payload.get("sdr_count", 1),
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False,
"override_system_id": None,
@@ -141,6 +143,11 @@ class MQTTHandler:
updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout
if "secondary_sdr_mode" in payload:
updates["secondary_sdr_mode"] = payload["secondary_sdr_mode"]
if "sdr_count" in payload:
updates["sdr_count"] = payload["sdr_count"]
if node_type == "portable":
updates["is_overridden"] = False
updates["override_system_id"] = None
@@ -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
single-municipality system is the degenerate case: populate the system level and
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 json
import re
from typing import Any, Optional
from app.config import settings
@@ -96,6 +109,19 @@ def _dedupe(items: list[str]) -> list[str]:
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:
"""The config.talkgroups[] entry for this talkgroup, or {}."""
if talkgroup_id is None:
@@ -317,6 +343,31 @@ async def correct(
if 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:
changed = raw.get("changed") or []
logger.info(
+44
View File
@@ -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
+2 -1
View File
@@ -17,7 +17,7 @@ from app.internal.auth import (
require_node_service_or_firebase_token,
)
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist
from app.routers import enrollment, media, org, waitlist, telemetry
from app.internal import dynsec
from app.internal import firestore as fstore
@@ -120,6 +120,7 @@ app.include_router(nodes.router, dependencies=[Depends(require_service_or_fi
# write routes inside carry their own require_admin_token, so nodes get read
# access only.
app.include_router(systems.router, dependencies=[Depends(require_node_service_or_firebase_token)])
app.include_router(telemetry.router, dependencies=[Depends(require_node_service_or_firebase_token)])
app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
+31
View File
@@ -62,12 +62,43 @@ class NodeRecord(BaseModel):
last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = None
node_type: str = "fixed" # fixed or portable
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
enforce_override_timeout: bool = True
is_overridden: bool = False
override_system_id: Optional[str] = None
override_timeout_at: Optional[datetime] = None
class AircraftTrack(BaseModel):
"""Live ADS-B position, one doc per icao. Overwritten on every sighting —
this is a live-map snapshot, not a history (see node-26#9)."""
icao: str
org_id: Optional[str] = None
node_id: str
callsign: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
altitude_ft: Optional[float] = None
ground_speed_kt: Optional[float] = None
track_deg: Optional[float] = None
last_seen: datetime
class VesselTrack(BaseModel):
"""Live AIS position, one doc per mmsi. Same live-snapshot shape as
AircraftTrack — overwritten on every sighting (see node-26#9)."""
mmsi: str
org_id: Optional[str] = None
node_id: str
name: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
speed_kt: Optional[float] = None
heading_deg: Optional[float] = None
last_seen: datetime
class CommandPayload(BaseModel):
action: str # discord_join / discord_leave / op25_restart
guild_id: Optional[str] = None
+190 -10
View File
@@ -5,6 +5,7 @@ from typing import Optional
from app.internal import firestore as fstore
from app.internal.auth import (
require_admin_token,
require_firebase_token,
require_service_or_firebase_token,
resolve_caller_org_id,
reprocess_limiter,
@@ -15,9 +16,49 @@ from app.internal.storage import gcs_uri_for_call, with_playback_url
class TranscriptUpdate(BaseModel):
transcript: str
class EvalTranscriptUpdate(BaseModel):
text: str
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("")
async def list_calls(
node_id: Optional[str] = Query(None),
@@ -50,7 +91,9 @@ async def search_calls(
link: str = Query("any", pattern="^(any|orphan|linked)$"),
transcript: str = Query("any", pattern="^(any|yes|no)$"),
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.
@@ -68,6 +111,11 @@ async def search_calls(
`window_exhausted` says the scan hit its cap before filling the page, so an
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)
if org_id is None:
@@ -78,13 +126,24 @@ async def search_calls(
if not org_id:
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)
rows = await fstore.collection_where(
"calls",
[("org_id", "==", org_id)],
conditions,
order_by=[("started_at", "DESCENDING")],
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()
@@ -113,13 +172,7 @@ async def search_calls(
matches = [c for c in rows if _keep(c)]
page = matches[:limit]
# Cursor advances over the SCANNED window, not the filtered page — otherwise
# 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
next_cursor = _next_cursor(rows, matches, page, window)
return {
"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}")
async def get_call(call_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
call = await fstore.doc_get("calls", call_id)
@@ -313,3 +467,29 @@ async def patch_transcript(
preserve_transcript_correction=True,
)
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}
+3
View File
@@ -195,6 +195,7 @@ async def assign_system(
class NodeUpdateBody(BaseModel):
node_type: Optional[str] = None
enforce_override_timeout: Optional[bool] = None
secondary_sdr_mode: Optional[str] = None # none | adsb | ais | op25_2
@router.patch("/{node_id}")
@@ -227,6 +228,8 @@ async def update_node(
}
if updated_node.get("ppm_override") is not None:
push_payload["ppm_override"] = updated_node["ppm_override"]
if updated_node.get("secondary_sdr_mode") is not None:
push_payload["secondary_sdr_mode"] = updated_node["secondary_sdr_mode"]
mqtt_handler.push_config(node_id, push_payload)
return {"ok": True}
+125
View File
@@ -0,0 +1,125 @@
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_node_service_or_firebase_token
from app.internal.logger import logger
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
class AircraftReport(BaseModel):
icao: str
callsign: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
altitude_ft: Optional[float] = None
ground_speed_kt: Optional[float] = None
track_deg: Optional[float] = None
class AdsbUploadBody(BaseModel):
aircraft: List[AircraftReport]
@router.post("/adsb")
async def upload_adsb(
body: AdsbUploadBody,
decoded: dict = Depends(require_node_service_or_firebase_token),
):
"""
Node-initiated: a second-SDR ADS-B decoder (node-26#9) periodically posts
its current aircraft snapshot here. One doc per icao, last-seen-wins —
this is a live-map overlay, not a flight history.
"""
node_id = decoded.get("node_id")
if not node_id:
raise HTTPException(400, "This endpoint requires node identity, not a service/admin token.")
node = await fstore.doc_get_cached("nodes", node_id)
org_id = node.get("org_id") if node else None
now = datetime.now(timezone.utc).isoformat()
writes = []
for ac in body.aircraft:
if not ac.icao:
continue
doc = {
"icao": ac.icao,
"node_id": node_id,
"callsign": ac.callsign,
"lat": ac.lat,
"lon": ac.lon,
"altitude_ft": ac.altitude_ft,
"ground_speed_kt": ac.ground_speed_kt,
"track_deg": ac.track_deg,
"last_seen": now,
}
if org_id:
doc["org_id"] = org_id
writes.append(("aircraft", ac.icao, doc))
for collection, doc_id, doc in writes:
try:
await fstore.doc_set(collection, doc_id, doc, merge=True)
except Exception as e:
logger.warning(f"Failed to upsert {collection}/{doc_id} from node {node_id}: {e}")
return {"ok": True, "count": len(writes)}
class VesselReport(BaseModel):
mmsi: str
name: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
speed_kt: Optional[float] = None
heading_deg: Optional[float] = None
class AisUploadBody(BaseModel):
vessels: List[VesselReport]
@router.post("/ais")
async def upload_ais(
body: AisUploadBody,
decoded: dict = Depends(require_node_service_or_firebase_token),
):
"""Same shape as /telemetry/adsb, one doc per mmsi in `vessels`."""
node_id = decoded.get("node_id")
if not node_id:
raise HTTPException(400, "This endpoint requires node identity, not a service/admin token.")
node = await fstore.doc_get_cached("nodes", node_id)
org_id = node.get("org_id") if node else None
now = datetime.now(timezone.utc).isoformat()
writes = []
for v in body.vessels:
if not v.mmsi:
continue
doc = {
"mmsi": v.mmsi,
"node_id": node_id,
"name": v.name,
"lat": v.lat,
"lon": v.lon,
"speed_kt": v.speed_kt,
"heading_deg": v.heading_deg,
"last_seen": now,
}
if org_id:
doc["org_id"] = org_id
writes.append(("vessels", v.mmsi, doc))
for collection, doc_id, doc in writes:
try:
await fstore.doc_set(collection, doc_id, doc, merge=True)
except Exception as e:
logger.warning(f"Failed to upsert {collection}/{doc_id} from node {node_id}: {e}")
return {"ok": True, "count": len(writes)}
+10 -3
View File
@@ -522,11 +522,18 @@ async def _run_intelligence_pipeline(
# Correlator also runs for calls with no scenes (unclassified) to attempt
# talkgroup-based linking even when no transcript could be produced.
# Skip when extraction flagged the call — garbage or too-short transcripts
# carry no signal and would only attach spuriously via the thin path.
# transcript_too_short (<=5 words: "10-8", "show me clear", a unit
# check-in) still carries a real transcript and talkgroup — exactly the
# brief follow-up/clearance traffic an incident needs, and the thin-path
# merge below already requires a same-talkgroup, recently-active
# incident before attaching anything, same guard already trusted for
# no-transcript calls. Previously excluded here, so these calls never
# attached to anything at all. garbage_transcript (Whisper
# hallucination) has no real content behind it and stays excluded.
if not scenes:
_call_doc = await fstore.doc_get("calls", call_id)
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(
call_id=call_id,
node_id=node_id,
+59
View File
@@ -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"
# ---------------------------------------------------------------------------
# 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():
"""
Positive control: real evidence still links. Carrying units also means the
+145
View File
@@ -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
+94
View File
@@ -0,0 +1,94 @@
"""
node-26#9 — second-SDR ADS-B telemetry ingestion.
Two things matter here: the endpoint requires node identity (a service/admin
token has no node_id to attribute the sighting to, so it must 400 rather than
silently write an orphan doc), and org_id gets stamped from the node's own
Firestore doc so firestore.rules' docInMyOrg() can gate the frontend's read —
the same defensive-stamp pattern upload.py already uses for `calls`.
"""
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.internal.auth import require_node_service_or_firebase_token
from app.routers import telemetry
client = TestClient(app)
def _override(decoded: dict):
app.dependency_overrides[require_node_service_or_firebase_token] = lambda: decoded
def teardown_function():
app.dependency_overrides.pop(require_node_service_or_firebase_token, None)
def test_service_token_without_node_id_is_rejected():
_override({"service": True})
resp = client.post("/telemetry/adsb", json={"aircraft": []})
assert resp.status_code == 400
def test_node_upload_upserts_and_stamps_org_id():
_override({"node": True, "node_id": "node-1"})
with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value={"org_id": "org-A"})), \
patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set:
resp = client.post("/telemetry/adsb", json={
"aircraft": [{"icao": "A1B2C3", "callsign": "UAL123", "lat": 41.1, "lon": -73.8}],
})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 1}
mock_set.assert_awaited_once()
(collection, doc_id, doc), kwargs = mock_set.await_args
assert collection == "aircraft"
assert doc_id == "A1B2C3"
assert doc["node_id"] == "node-1"
assert doc["org_id"] == "org-A"
assert kwargs.get("merge") is True
def test_node_upload_skips_entries_missing_icao():
_override({"node": True, "node_id": "node-1"})
with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value=None)), \
patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set:
resp = client.post("/telemetry/adsb", json={"aircraft": [{"icao": ""}]})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 0}
mock_set.assert_not_awaited()
def test_ais_service_token_without_node_id_is_rejected():
_override({"service": True})
resp = client.post("/telemetry/ais", json={"vessels": []})
assert resp.status_code == 400
def test_ais_node_upload_upserts_and_stamps_org_id():
_override({"node": True, "node_id": "node-1"})
with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value={"org_id": "org-A"})), \
patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set:
resp = client.post("/telemetry/ais", json={
"vessels": [{"mmsi": "123456789", "name": "MV TEST", "lat": 41.0, "lon": -73.9}],
})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 1}
mock_set.assert_awaited_once()
(collection, doc_id, doc), kwargs = mock_set.await_args
assert collection == "vessels"
assert doc_id == "123456789"
assert doc["node_id"] == "node-1"
assert doc["org_id"] == "org-A"
assert kwargs.get("merge") is True
def test_ais_node_upload_skips_entries_missing_mmsi():
_override({"node": True, "node_id": "node-1"})
with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value=None)), \
patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set:
resp = client.post("/telemetry/ais", json={"vessels": [{"mmsi": ""}]})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 0}
mock_set.assert_not_awaited()
@@ -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)
# ── 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
async def test_reference_data_reaches_the_prompt():
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()
+180 -2
View File
@@ -4,7 +4,7 @@ import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
// ---------------------------------------------------------------------------
// 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&apos;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
// ---------------------------------------------------------------------------
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls";
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls" | "eval";
const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" },
{ key: "correlation", label: "Correlation Debug" },
{ key: "calls", label: "Calls" },
{ key: "eval", label: "STT Eval" },
{ key: "users", label: "Users" },
{ key: "audit", label: "Audit Log" },
];
@@ -1102,6 +1279,7 @@ export default function AdminPage() {
{tab === "features" && <FeaturesTab />}
{tab === "correlation" && <CorrelationDebugTab />}
{tab === "calls" && <StaleCallsTab />}
{tab === "eval" && <SttEvalTab />}
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />}
</div>
+28 -14
View File
@@ -6,8 +6,9 @@
// never correlated was invisible. That is the wrong way round when correlation
// quality is the thing under development — the orphans are the evidence.
//
// Admin-only, because it exposes every call in the org regardless of node
// ownership and carries the manual attribution controls.
// Readable by every org member — the Firestore rules already let any member
// 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 { useRouter } from "next/navigation";
@@ -23,6 +24,7 @@ import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
type LinkFilter = "any" | "orphan" | "linked";
type TranscriptFilter = "any" | "yes" | "no";
@@ -68,11 +70,13 @@ function ArchiveRow({
call,
systemName,
incidents,
canEdit,
onChanged,
}: {
call: CallRecord;
systemName?: string;
incidents: IncidentRecord[];
canEdit: boolean;
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
@@ -178,18 +182,18 @@ function ArchiveRow({
<div key={id} className="flex items-center gap-2 text-xs">
<span className="text-ink-muted">attached to</span>
<span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span>
<button
{canEdit && <button
onClick={() => detach(id)}
disabled={busy}
className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
>
detach
</button>
</button>}
</div>
);
})}
<div className="flex flex-wrap items-center gap-2">
{canEdit && <div className="flex flex-wrap items-center gap-2">
<select
value={attachTo}
onChange={(e) => setAttachTo(e.target.value)}
@@ -208,7 +212,7 @@ function ArchiveRow({
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
{busy ? "Saving…" : "Attach"}
</Button>
</div>
</div>}
</div>
{error && <ErrorBanner message={error} />}
@@ -219,8 +223,9 @@ function ArchiveRow({
}
export default function ArchivePage() {
const { isAdmin, loading: authLoading } = useAuth();
const { user, orgId, isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const canView = Boolean(user && (orgId || isAdmin));
const { systems } = useSystems();
const { incidents } = useIncidents(200);
@@ -235,10 +240,12 @@ export default function ArchivePage() {
const [systemId, setSystemId] = useState("");
const [q, setQ] = useState("");
const [submittedQ, setSubmittedQ] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/");
}, [authLoading, isAdmin, router]);
if (!authLoading && !canView) router.replace("/");
}, [authLoading, canView, router]);
const load = useCallback(
async (nextCursor: string | null, append: boolean) => {
@@ -252,6 +259,8 @@ export default function ArchivePage() {
transcript,
system_id: systemId || undefined,
q: submittedQ || undefined,
date_from: dayStart(dateFrom)?.toISOString(),
date_to: dayEnd(dateTo)?.toISOString(),
});
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
setCursor(res.next_cursor);
@@ -262,14 +271,14 @@ export default function ArchivePage() {
setLoading(false);
}
},
[link, transcript, systemId, submittedQ],
[link, transcript, systemId, submittedQ, dateFrom, dateTo],
);
// Reload from the top whenever a filter changes.
useEffect(() => {
if (authLoading || !isAdmin) return;
if (authLoading || !canView) return;
load(null, false);
}, [authLoading, isAdmin, load]);
}, [authLoading, canView, load]);
const systemName = useMemo(() => {
const m = new Map(systems.map((s) => [s.system_id, s.name]));
@@ -277,7 +286,7 @@ export default function ArchivePage() {
}, [systems]);
// 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 noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length;
@@ -286,7 +295,9 @@ export default function ArchivePage() {
<div className="space-y-6">
<PageHeader
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">
@@ -329,6 +340,8 @@ export default function ArchivePage() {
))}
</select>
<DateRange from={dateFrom} to={dateTo} onChange={(f, t) => { setDateFrom(f); setDateTo(t); }} />
<form
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
className="flex items-center gap-2 ml-auto"
@@ -373,6 +386,7 @@ export default function ArchivePage() {
call={call}
systemName={systemName(call.system_id)}
incidents={incidents}
canEdit={isAdmin}
onChanged={() => load(null, false)}
/>
))}
+89 -10
View File
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/Badge";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
import { isKnownSeverity, severityRank } from "@/lib/severity";
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
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 };
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
// 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() {
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 [showCreate, setShowCreate] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
const [typeFilter, setTypeFilter] = useState("");
const [search, setSearch] = useState("");
const onAirIncidentIds = useMemo(() => {
const s = new Set<string>();
@@ -194,12 +220,24 @@ export default function IncidentsPage() {
const filtered = useMemo(() => {
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") {
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
}, [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 activeCount = filtered.filter((i) => i.status === "active").length;
@@ -249,7 +287,39 @@ export default function IncidentsPage() {
</button>
))}
</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
<select
value={sortMode}
@@ -270,7 +340,8 @@ export default function IncidentsPage() {
<>
{hiddenCount > 0 && (
<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>
)}
@@ -302,19 +373,27 @@ export default function IncidentsPage() {
{filtered.length === 0 && !error && (
<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={
incidents.length === 0
incidents.length === 0 && !filtersActive
? "Incidents appear automatically once calls start correlating."
: "Try a lower severity threshold."
: "Try clearing a filter, or load older incidents."
}
action={
incidents.length > 0 && severityFilter !== "all" ? (
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
filtersActive ? (
<Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
) : undefined
}
/>
)}
{hasMore && (
<div className="flex justify-center">
<Button variant="secondary" onClick={() => setPageLimit((n) => n + PAGE_SIZE)}>
Load more
</Button>
</div>
)}
</>
)}
+83
View File
@@ -15,6 +15,8 @@ import L from "leaflet";
import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAircraft } from "@/lib/useAircraft";
import { useVessels } from "@/lib/useVessels";
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -90,6 +92,73 @@ function nodeIcon(status: NodeStatus): L.DivIcon {
});
}
// ── Aircraft icon — node-26#9 second-SDR ADS-B overlay ────────────────────────
function aircraftIcon(trackDeg: number | null): L.DivIcon {
const size = 16;
const rotation = trackDeg ?? 0;
return L.divIcon({
className: "",
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L15 11 L22 15 L15 15.5 L14 21 L17 22.5 L12 21.5 L7 22.5 L10 21 L9 15.5 L2 15 L9 11 Z"/></svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function AircraftLayer() {
const { aircraft } = useAircraft();
return (
<>
{aircraft
.filter((a) => a.lat != null && a.lon != null)
.map((a) => (
<Marker key={a.icao} position={[a.lat as number, a.lon as number]} icon={aircraftIcon(a.track_deg)}>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{a.callsign || a.icao}</div>
<div className="text-xs text-ink-muted">ICAO {a.icao}</div>
{a.altitude_ft != null && <div className="text-xs">Altitude: {Math.round(a.altitude_ft)} ft</div>}
{a.ground_speed_kt != null && <div className="text-xs">Speed: {Math.round(a.ground_speed_kt)} kt</div>}
</div>
</Popup>
</Marker>
))}
</>
);
}
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
function vesselIcon(headingDeg: number | null): L.DivIcon {
const size = 14;
const rotation = headingDeg ?? 0;
return L.divIcon({
className: "",
html: `<div style="width:${size}px;height:${size}px;transform:rotate(${rotation}deg)"><svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="var(--accent)" stroke="var(--surface)" stroke-width="1"><path d="M12 2 L18 14 L18 20 L6 20 L6 14 Z"/></svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function VesselLayer() {
const { vessels } = useVessels();
return (
<>
{vessels
.filter((v) => v.lat != null && v.lon != null)
.map((v) => (
<Marker key={v.mmsi} position={[v.lat as number, v.lon as number]} icon={vesselIcon(v.heading_deg)}>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{v.name || v.mmsi}</div>
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
{v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
</div>
</Popup>
</Marker>
))}
</>
);
}
function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
const n = members.length;
const CARD = 13;
@@ -577,6 +646,20 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */}
<LayersControl.Overlay name="Aircraft">
<FeatureGroup>
<AircraftLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */}
<LayersControl.Overlay name="Vessels">
<FeatureGroup>
<VesselLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
<LayersControl.Overlay name="Weather Radar">
<TileLayer
+57
View File
@@ -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>
);
}
+26
View File
@@ -82,6 +82,8 @@ export const c2api = {
link?: "any" | "orphan" | "linked";
transcript?: "any" | "yes" | "no";
q?: string;
date_from?: string;
date_to?: string;
}) => {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
@@ -100,6 +102,30 @@ export const c2api = {
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" }),
// 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
getIncidents: (params?: { status?: string; type?: string }) => {
const qs = params ? "?" + new URLSearchParams(params as Record<string, string>).toString() : "";
+31
View File
@@ -53,12 +53,39 @@ export interface NodeRecord {
hardware_preset?: string;
ppm_override?: number | null;
node_type?: string;
secondary_sdr_mode?: string;
sdr_count?: number;
enforce_override_timeout?: boolean;
is_overridden?: boolean;
override_system_id?: string | null;
override_timeout_at?: string | null;
}
export interface AircraftTrack {
icao: string;
org_id?: string;
node_id: string;
callsign: string | null;
lat: number | null;
lon: number | null;
altitude_ft: number | null;
ground_speed_kt: number | null;
track_deg: number | null;
last_seen: string;
}
export interface VesselTrack {
mmsi: string;
org_id?: string;
node_id: string;
name: string | null;
lat: number | null;
lon: number | null;
speed_kt: number | null;
heading_deg: number | null;
last_seen: string;
}
export interface VocabularyPendingTerm {
term: string;
source: "induction" | "correction";
@@ -131,6 +158,10 @@ export interface CallRecord {
corr_incident_idle_min?: number | null;
corr_shared_units?: 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 {
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { AircraftTrack } from "@/lib/types";
// `aircraft` docs are a live snapshot (one per icao, overwritten on every
// sighting, node-26#9) — nothing prunes a doc when a plane leaves range, so
// staleness is filtered client-side rather than assuming the collection only
// ever holds current traffic.
const STALE_AFTER_MS = 2 * 60 * 1000;
export function useAircraft() {
const [aircraft, setAircraft] = useState<AircraftTrack[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user || !orgId) {
setAircraft([]);
setLoading(false);
return;
}
const q = query(collection(db, "aircraft"), where("org_id", "==", orgId));
unsubFirestore = onSnapshot(q, (snap) => {
const now = Date.now();
const fresh = snap.docs
.map((d) => d.data() as AircraftTrack)
.filter((a) => now - new Date(a.last_seen).getTime() < STALE_AFTER_MS);
setAircraft(fresh);
setLoading(false);
}, (err: FirestoreError) => { console.error("useAircraft:", err); setError(err.message); setLoading(false); });
});
return () => {
unsubAuth();
if (unsubFirestore) unsubFirestore();
};
}, [orgId]);
return { aircraft, loading, error };
}
+20 -3
View File
@@ -11,12 +11,19 @@ const toISO = (v: unknown): string =>
(v as { toDate?: () => Date })?.toDate?.()?.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 [loading, setLoading] = useState(true);
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();
// Stable ms values so the effect dependency doesn't fire on every render
const dateFromMs = dateFrom?.getTime();
const dateToMs = dateTo?.getTime();
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
@@ -34,9 +41,18 @@ export function useIncidents(limitCount = 100) {
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(
collection(db, "incidents"),
where("org_id", "==", orgId),
...(dateFromMs != null ? [where("started_at", ">=", isoBound(dateFromMs))] : []),
...(dateToMs != null ? [where("started_at", "<=", isoBound(dateToMs))] : []),
orderBy("started_at", "desc"),
limit(limitCount)
);
@@ -49,6 +65,7 @@ export function useIncidents(limitCount = 100) {
updated_at: toISO(data.updated_at),
} as IncidentRecord;
}));
setHasMore(snap.size >= limitCount);
setLoading(false);
}, (err: FirestoreError) => {
console.error("useIncidents:", err);
@@ -61,9 +78,9 @@ export function useIncidents(limitCount = 100) {
unsubAuth();
if (unsubFirestore) unsubFirestore();
};
}, [limitCount, orgId]);
}, [limitCount, dateFromMs, dateToMs, orgId]);
return { incidents, loading, error };
return { incidents, loading, error, hasMore };
}
export function useIncident(incidentId: string | null) {
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { VesselTrack } from "@/lib/types";
// Same shape as useAircraft — `vessels` is a live snapshot (one per mmsi,
// overwritten on every sighting, node-26#9), nothing prunes a doc when a
// vessel goes out of range, so staleness is filtered client-side. AIS
// position reports are much less frequent than ADS-B (minutes, not
// seconds), so this window is longer than useAircraft's.
const STALE_AFTER_MS = 10 * 60 * 1000;
export function useVessels() {
const [vessels, setVessels] = useState<VesselTrack[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user || !orgId) {
setVessels([]);
setLoading(false);
return;
}
const q = query(collection(db, "vessels"), where("org_id", "==", orgId));
unsubFirestore = onSnapshot(q, (snap) => {
const now = Date.now();
const fresh = snap.docs
.map((d) => d.data() as VesselTrack)
.filter((v) => now - new Date(v.last_seen).getTime() < STALE_AFTER_MS);
setVessels(fresh);
setLoading(false);
}, (err: FirestoreError) => { console.error("useVessels:", err); setError(err.message); setLoading(false); });
});
return () => {
unsubAuth();
if (unsubFirestore) unsubFirestore();
};
}, [orgId]);
return { vessels, loading, error };
}
+12
View File
@@ -95,6 +95,18 @@ service cloud.firestore {
allow write: if false;
}
// Live map overlays fed by a node's second SDR (node-26#9). Snapshot
// docs, one per icao/mmsi, last-seen-wins — not a history collection.
match /aircraft/{icao} {
allow read: if docInMyOrg();
allow write: if false;
}
match /vessels/{mmsi} {
allow read: if docInMyOrg();
allow write: if false;
}
match /alert_events/{alertId} {
allow read: if docInMyOrg();
allow write: if false;