Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968134f8ee | ||
|
|
b430cf32f2 | ||
|
|
93fa3a6054 | ||
|
|
de03f5bcaf | ||
|
|
0651bfe07a | ||
|
|
c4656a9607 | ||
|
|
a9d1d2475a | ||
|
|
85393bdb26 | ||
|
|
8b6c170265 | ||
|
|
b7222230bd | ||
|
|
bdb57ae75a | ||
|
|
29c2fb11b9 | ||
|
|
865b5b4317 | ||
|
|
0635de8dac | ||
|
|
3df427f914 | ||
|
|
e30d594eea | ||
|
|
187b8c1500 | ||
|
|
d18e4f0743 | ||
|
|
5fc4e2c57b | ||
|
|
a1bdccff45 | ||
|
|
cc038e6326 | ||
|
|
964343c819 | ||
|
|
58efdbd6eb | ||
|
|
1bfa856d1b | ||
|
|
457e6d7e0f | ||
|
|
140dfbfc74 | ||
|
|
7ef5704be2 | ||
|
|
039a06dc72 | ||
|
|
a278e2215a | ||
|
|
be79499635 | ||
|
|
861ea41cec | ||
|
|
82c88379d4 | ||
|
|
c7be6416f2 | ||
|
|
8fbfe7d6de | ||
|
|
33a247d306 | ||
|
|
baa9d1811f | ||
|
|
a250c29e3c | ||
|
|
5355095c48 | ||
|
|
6dfa5bc66d | ||
|
|
4919b02238 | ||
|
|
4b5cf1971e | ||
|
|
bc636c00ce | ||
|
|
31c0b3addf | ||
|
|
eaae452d4e |
@@ -0,0 +1,4 @@
|
||||
# Shell scripts run inside Linux containers. A CRLF shebang there fails as
|
||||
# "bad interpreter: /bin/sh^M", which surfaces only as a container that will
|
||||
# not start. Windows checkouts have core.autocrlf=true, so pin these to LF.
|
||||
*.sh text eol=lf
|
||||
+207
-8
@@ -31,6 +31,8 @@ jobs:
|
||||
with:
|
||||
context: ./drb-c2-core
|
||||
push: true
|
||||
build-args: |
|
||||
GIT_SHA=${{ gitea.sha }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/c2-core:latest
|
||||
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
|
||||
@@ -66,6 +68,10 @@ jobs:
|
||||
name: Deploy to VM
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
prev_sha: ${{ steps.deploy.outputs.prev_sha }}
|
||||
rollback_status: ${{ steps.rollback.outputs.status }}
|
||||
rollback_sha: ${{ steps.rollback.outputs.rolled_back_to }}
|
||||
|
||||
steps:
|
||||
- name: Check runner outbound IP
|
||||
@@ -78,27 +84,220 @@ jobs:
|
||||
ssh-keygen -l -f /tmp/deploy_key
|
||||
|
||||
- name: Deploy
|
||||
id: deploy
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no \
|
||||
set -o pipefail
|
||||
OUTPUT=$(ssh -o StrictHostKeyChecking=no \
|
||||
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||
-o ConnectTimeout=15 \
|
||||
-v \
|
||||
-i /tmp/deploy_key \
|
||||
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
|
||||
drb@${{ secrets.SERVER_IP }} << 'ENDSSH' | tee /dev/stderr
|
||||
set -e
|
||||
cd /opt/drb
|
||||
|
||||
# Update compose files + mosquitto config
|
||||
git pull origin main
|
||||
|
||||
# Pull pre-built images and restart (no build on the VM)
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans
|
||||
# server-26#65: capture what is actually live BEFORE switching, so
|
||||
# a bad deploy has something concrete to fall back to. This reads
|
||||
# from a state file rather than re-deriving it from git log,
|
||||
# because a PRIOR deploy could itself have failed and already
|
||||
# rolled back to something older than HEAD~1 -- the file is only
|
||||
# ever written by the Health check step below, after that step
|
||||
# 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)
|
||||
echo "PREV_TAG=$PREV_TAG"
|
||||
|
||||
# Deploy THIS commit's images, not :latest. Overlapping runs are
|
||||
# normal here, and with :latest whichever finishes last wins for
|
||||
# both -- run 544 asserted its own SHA and found run 545's build
|
||||
# already serving. compose already supports ${TAG:-latest}, so
|
||||
# pinning makes each deploy deterministic and a rollback just a
|
||||
# different tag. A later manual `up -d` on the VM without TAG set
|
||||
# still falls back to :latest, which is the intended escape hatch.
|
||||
export TAG=${{ gitea.sha }}
|
||||
|
||||
# Pull pre-built images and restart (no build on the VM).
|
||||
#
|
||||
# The retry is not defensive padding: this exact step failed fifteen
|
||||
# deploys in a row (2026-08-18 to 08-20) with containerd unable to
|
||||
# extract a layer -- "failed to Lchown ... no such file or directory"
|
||||
# -- a corrupted entry in the snapshot store. Pruning clears the bad
|
||||
# layer and the second pull succeeds. If it fails again after a
|
||||
# prune that is a real problem (check the VM's disk) and should stop
|
||||
# the deploy rather than be retried forever.
|
||||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
if ! $COMPOSE pull; then
|
||||
echo "image pull failed - pruning and retrying once"
|
||||
docker image prune -af
|
||||
$COMPOSE pull
|
||||
fi
|
||||
$COMPOSE up -d --remove-orphans
|
||||
docker image prune -f
|
||||
ENDSSH
|
||||
)
|
||||
echo "$OUTPUT"
|
||||
PREV_TAG=$(printf '%s\n' "$OUTPUT" | grep '^PREV_TAG=' | tail -n1 | cut -d'=' -f2)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "Could not determine the previous tag from deploy output - rollback target unknown."
|
||||
exit 1
|
||||
fi
|
||||
echo "prev_sha=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Health check
|
||||
id: health
|
||||
run: |
|
||||
sleep 20
|
||||
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
|
||||
(echo "Health check failed" && exit 1)
|
||||
# Poll rather than sleep-once: the container has to finish starting,
|
||||
# and a fixed sleep is either too short (flaky red) or wastes time on
|
||||
# every deploy. A health check that cries wolf gets ignored, which is
|
||||
# the failure mode this whole job exists to prevent.
|
||||
BODY=""
|
||||
for _ in $(seq 1 20); do
|
||||
sleep 5
|
||||
BODY=$(curl -fsS https://api.${{ secrets.DRB_DOMAIN }}/health) || continue
|
||||
case "$BODY" in *"${{ gitea.sha }}"*) break ;; esac
|
||||
done
|
||||
if [ -z "$BODY" ]; then
|
||||
echo "Health check failed: /health never responded"; exit 1
|
||||
fi
|
||||
echo "$BODY"
|
||||
|
||||
# Liveness alone is not enough. A deploy can report success while the
|
||||
# PREVIOUS container keeps serving -- that is how production ran
|
||||
# 08-18 code for two days without a single red run. Assert that the
|
||||
# build which answered is the commit we just pushed.
|
||||
RUNNING=$(printf '%s' "$BODY" | tr ',' '\n' | grep git_sha | cut -d'"' -f4)
|
||||
if [ "$RUNNING" != "${{ gitea.sha }}" ]; then
|
||||
echo "Deployed build is '$RUNNING', expected '${{ gitea.sha }}'."
|
||||
echo "The container was not actually replaced."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server-26#65: only now -- confirmed by /health, not by "up -d
|
||||
# returned 0" -- record this as the rollback target for the NEXT
|
||||
# deploy. A failure to write this is a bookkeeping problem, not a
|
||||
# deploy problem, so it warns instead of failing the job (a hard
|
||||
# failure here would trigger the Rollback step below against a
|
||||
# perfectly good deploy).
|
||||
ssh -o StrictHostKeyChecking=no \
|
||||
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||
-o ConnectTimeout=15 \
|
||||
-i /tmp/deploy_key \
|
||||
drb@${{ secrets.SERVER_IP }} \
|
||||
"echo '${{ gitea.sha }}' > /opt/drb/.last_good_tag" \
|
||||
|| echo "warning: failed to persist .last_good_tag - next deploy's rollback target may be stale"
|
||||
|
||||
- name: Rollback on failed health check
|
||||
id: rollback
|
||||
if: failure()
|
||||
run: |
|
||||
# server-26#65 decision 3 / board minutes #62: up -d used to be the
|
||||
# last word -- a build that passes tests, returns 200, and still
|
||||
# corrupts incidents on live traffic would stay live for 12+ hours
|
||||
# before a human noticed. This step is what makes that impossible:
|
||||
# any failure above (pull, restart, or the health/SHA check) lands
|
||||
# here and puts the previously-verified tag back.
|
||||
PREV_TAG="${{ steps.deploy.outputs.prev_sha }}"
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag was captured (Deploy step itself failed before recording one) - cannot roll back automatically."
|
||||
echo "status=skipped" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Rolling back to $PREV_TAG"
|
||||
|
||||
ssh -o StrictHostKeyChecking=no \
|
||||
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||
-o ConnectTimeout=15 \
|
||||
-i /tmp/deploy_key \
|
||||
drb@${{ secrets.SERVER_IP }} << ENDSSH
|
||||
set -e
|
||||
cd /opt/drb
|
||||
export TAG=$PREV_TAG
|
||||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
if ! \$COMPOSE pull; then
|
||||
echo "rollback image pull failed - pruning and retrying once"
|
||||
docker image prune -af
|
||||
\$COMPOSE pull
|
||||
fi
|
||||
\$COMPOSE up -d --remove-orphans
|
||||
ENDSSH
|
||||
|
||||
# Re-verify exactly like the forward health check does: liveness
|
||||
# alone doesn't prove the rollback took, the SHA has to match the
|
||||
# tag we just switched back to.
|
||||
BODY=""
|
||||
for _ in $(seq 1 12); do
|
||||
sleep 5
|
||||
BODY=$(curl -fsS https://api.${{ secrets.DRB_DOMAIN }}/health) || continue
|
||||
case "$BODY" in *"$PREV_TAG"*) break ;; esac
|
||||
done
|
||||
|
||||
RUNNING=$(printf '%s' "$BODY" | tr ',' '\n' | grep git_sha | cut -d'"' -f4)
|
||||
if [ "$RUNNING" != "$PREV_TAG" ]; then
|
||||
echo "ROLLBACK FAILED: expected git_sha '$PREV_TAG', got '$RUNNING'."
|
||||
echo "Production state is UNKNOWN - check the VM by hand immediately."
|
||||
echo "status=failed" >> "$GITHUB_OUTPUT"
|
||||
echo "rolled_back_to=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Rolled back successfully to $PREV_TAG"
|
||||
echo "status=success" >> "$GITHUB_OUTPUT"
|
||||
echo "rolled_back_to=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
notify-failure:
|
||||
name: Report a failed deploy
|
||||
needs: [build, deploy]
|
||||
if: failure()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Post to Discord
|
||||
# A red run in Gitea is only visible to someone who opens Gitea, and
|
||||
# nobody did for two days. Same shape as an AI tier dying quietly,
|
||||
# which is why both now push a message out of the box instead of
|
||||
# waiting to be discovered. No webhook configured => skip quietly
|
||||
# rather than fail, since not every deployment will set one.
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DEPLOY_ALERT_WEBHOOK }}
|
||||
RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}
|
||||
SHA: ${{ gitea.sha }}
|
||||
ROLLBACK_STATUS: ${{ needs.deploy.outputs.rollback_status }}
|
||||
ROLLBACK_SHA: ${{ needs.deploy.outputs.rollback_sha }}
|
||||
run: |
|
||||
if [ -z "$WEBHOOK" ]; then
|
||||
echo "DEPLOY_ALERT_WEBHOOK is not set - skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
python3 - <<'PY' > /tmp/payload.json
|
||||
import json, os
|
||||
sha = os.environ["SHA"][:8]
|
||||
run_url = os.environ["RUN_URL"]
|
||||
status = os.environ.get("ROLLBACK_STATUS", "")
|
||||
rollback_sha = os.environ.get("ROLLBACK_SHA", "")
|
||||
|
||||
# server-26#65: the old text here unconditionally claimed
|
||||
# "production is still running the previous build" -- true only
|
||||
# when the pull/restart itself failed. It's false the moment a
|
||||
# build passes the SHA check but has a live logic bug (exactly the
|
||||
# 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":
|
||||
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 -- "
|
||||
"check the VM by hand immediately.") % rollback_sha[:8]
|
||||
elif status == "skipped":
|
||||
detail = "No rollback was attempted (no previous tag captured, or build/push failed before any deploy). Check the VM by hand."
|
||||
else:
|
||||
detail = "Build failed before any deploy was attempted. Production is unchanged."
|
||||
|
||||
print(json.dumps({"content":
|
||||
"**DRB deploy failed** on `%s`\n%s\n%s" % (sha, run_url, detail)}))
|
||||
PY
|
||||
curl -sS -X POST -H "Content-Type: application/json" \
|
||||
--data @/tmp/payload.json "$WEBHOOK" || echo "notification POST failed"
|
||||
|
||||
@@ -44,3 +44,6 @@ recordings/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Out of scope - not a deployed service (server-26#56)
|
||||
drb-telegram-bot/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Gate B3 (server-26#43) -- Engineering Scope
|
||||
Owner: CTO. Scope only -- no implementation. Ship date unchanged: 2026-09-30.
|
||||
|
||||
## 1. The two defaults, testable
|
||||
- EMS exclusion: for any call whose talkgroup is classified medical, the AI pipeline (Whisper STT + GPT-4o-mini intelligence.py extraction + correlation) must not run. Opt-in only via a per-customer contract flag. Test: upload a call on a talkgroup marked medical, calls/{id}.transcript stays null, no incident_ids.
|
||||
- Name suppression: no surface serving a calls or incidents document (API, frontend render, alert webhook, future export/Discord/API-key tiers) may return an unredacted transcript, summary, or title to any account -- public, comped, or paid -- until an E&O policy is bound (#43 comment 1). Test: same document, two reads -- direct Firestore read and /incidents/{id} API read -- both redacted.
|
||||
|
||||
## 2. Talkgroup-granularity gap
|
||||
feature_flags.py:80-107 (resolve_flags) only layers a per-system ai_flags dict (routers/systems.py:107-129, flat {flag_name: bool}, no talkgroup key) on top of the global default. DEFERREDs own entry for this file says the fix shape is talkgroup_ai_flags: {tgid: {...}} on the system doc, consulted where flag() is built. That field does not exist. Without it, "EMS excluded, rest of the system processed" is not buildable -- the flag is all-on/all-off per system, and most systems mix EMS with police/fire dispatch under one system_id (the exact case BUSINESS_MODEL section 5.5 is trying to protect against). This data-model change is a hard prerequisite, not an enhancement: add talkgroup_ai_flags: {tgid: {stt_enabled, correlation_enabled}} to the system doc, consult it in resolve_flags() before the system-level flag, default every unclassified talkgroup on a system that has at least one confirmed-medical talkgroup to excluded until explicitly classified.
|
||||
|
||||
## 3. Redaction design -- write time, not read time
|
||||
Pick: compute and store a redacted copy alongside the raw one, at extraction/summarization time. Two sentences: the frontend reads Firestore directly for calls/incidents (CLAUDE.md gotcha -- middleware.ts is UX-only, Firestore rules are the real boundary), and Firestore rules can allow/deny a whole document but cannot mask one field inside it -- so a redaction step that only runs inside c2-cores API responses leaves the exact same unredacted transcript/summary/title readable by any authenticated browser via onSnapshot/getDocs against the collection directly. The only enforcement point that actually covers both paths is: the client-readable document never contains the unredacted field. Raw content moves to a field/subcollection excluded from client-facing Firestore rules and readable only server-side by c2-core (satisfies "never deletion, reversible the day a policy binds" -- #43 comment 1).
|
||||
incident.title is template-composed from tag/location/talkgroup (incident_correlator.py:380-389), not LLM freeform -- already name-free by construction, no redaction needed there. The actual carriers are calls.transcript (models.py:165) and the GPT summary (summarizer.py:146-161, built directly from raw transcripts, no name-avoidance instruction today).
|
||||
|
||||
## 4. A premise in #43 does not hold
|
||||
#43s body says "entities are already extracted, so the redaction has a data source to work from." Not true as of this read. intelligence.pys extraction prompt (_PROMPT_TEMPLATE, lines 24-72) has no person-name field -- it extracts tags, incident_type, location, vehicles, units, cleared_units, severity. units is explicitly restricted to "unit IDs or officer numbers... never infer or guess" (line 57) -- radio callsigns, not private-citizen names. There is no structured entity to redact against. Redaction must run against unstructured free text (transcript + GPT summary), via a new regex/NER-style pass with its own unmeasured false-negative rate -- the same class of problem #48 raised about the extractor, one level down, on code that does not exist yet.
|
||||
|
||||
## 5. Surface inventory (complete)
|
||||
- drb-frontend: app/incidents/page.tsx, app/incidents/[id]/page.tsx, app/calls/page.tsx, components/CallRow.tsx, components/CallSpineEntry.tsx -- render title/summary/transcript. Every one is backed by a direct Firestore listener per the section 3 gotcha, not just the page component -- any future onSnapshot/getDocs against calls/incidents inherits the same exposure and must be audited, not assumed covered.
|
||||
- drb-c2-core API: routers/calls.py, routers/incidents.py (JSON responses).
|
||||
- drb-c2-core/app/internal/alerter.py:56,68 -- transcript_snippet (200 chars, raw, unredacted today) written into alert_events and POSTed to the customers own Discord webhook. This is the live, sellable Pro-tier "Alerting" feature (BUSINESS_MODEL section 3.4 item 1) -- highest-priority surface, it is the actual product hook for the beachhead segment.
|
||||
- drb-server-discord-bot: checked app/commands/radio.py, app/commands/trips.py -- embeds today are node status/help/trip content only, no incident transcript/summary rendering exists yet. Nothing to redact today; must inherit this design the day incident-to-Discord posting ships.
|
||||
- drb-telegram-bot: app/handlers/__init__.py is a stub, no incident-surfacing code exists. Same note as above.
|
||||
- Not yet built, but must inherit the design when built: CSV export, Network-tier API access (lib/apiKeys.ts is an in-memory stub per DEFERRED.md).
|
||||
|
||||
## 6. Out of scope for #43
|
||||
- Raw-audio/live-relay exclusion of EMS talkgroups -- the ruling excludes them from the AI pipeline only, not from live audio/Discord voice relay.
|
||||
- Building an accurate NER model -- a heuristic/regex redactor is scope; measuring or improving its accuracy is a follow-on issue (mirrors #48, on the redactor instead of the extractor).
|
||||
- Retroactive redaction of historical calls/incidents already in Firestore (no backfill infra exists -- same unscoped-backfill pattern already logged in DEFERRED.md for _verified_pin). Tracked as a new follow-on issue at ship time, not built now.
|
||||
- A UI for classifying talkgroups as EMS/medical beyond a minimal toggle reusing the existing per-system ai-flags PUT route pattern (routers/systems.py:107).
|
||||
|
||||
## 7. Needs a CEO/owner ruling
|
||||
- Urgent -- is the comped (friends/family) tier suspended today? #43 comment 1 states suppression must hold "on every surface -- public, comped and paid," and #79 comment says no login proceeds until this ships -- but the comped tier is described in BUSINESS_MODEL section 3.2 as already live with "todays live full product," unredacted. Either comped access is in active breach of the ruling right now, or it is meant to be paused pending this ship date. My recommendation: pause comped access to incident detail/transcript views (or accept and log the breach explicitly) until #43 ships -- silently continuing is worse than either choice on record.
|
||||
- How is a talkgroup classified EMS/medical? Recommend: name-pattern heuristic (reusing the existing _TG_SUFFIX_RE EMS/rescue matching in intelligence.py:101-108) as the default classification, manual override in the system editor, and default-exclude on no match rather than default-include -- a false negative here is the exact liability #43 exists to prevent.
|
||||
- Does exclusion/redaction apply retroactively to already-processed calls? Recommend: prospective only for 2026-09-30; backfill is a separate follow-on issue (see section 6).
|
||||
|
||||
## 8. Effort estimate vs 2026-09-30
|
||||
Roughly 6-10 engineering-days, agent-buildable (no human/contractor per GOALS.md), contingent on the section 7 rulings landing quickly -- they gate the design, not just the code:
|
||||
- Talkgroup-flag data model + resolve_flags() wiring: ~1 day.
|
||||
- Minimal EMS-classification toggle (reuse ai-flags PUT pattern): ~1-2 days.
|
||||
- Redacted-copy storage split + Firestore rules change + regex/heuristic redactor + alerter.py snippet redaction + audit of all direct Firestore listeners in frontend: ~4-6 days -- this is the long pole, because section 4 means it is new code, not a wire-up of an existing field.
|
||||
#48 does not block this. #43 comment 1 is explicit: the 200-call accuracy measurement "can no longer decide whether names are published, because they are suppressed regardless. It remains a Gate B condition for other reasons." Sequence independently.
|
||||
@@ -19,6 +19,14 @@ services:
|
||||
- mosquitto_data:/mosquitto/data
|
||||
- mosquitto_certs:/mosquitto/certs
|
||||
|
||||
# c2-core takes ALL of its configuration from ./drb-c2-core/.env — there is
|
||||
# deliberately no `environment:` block here. An entry in that block wins over
|
||||
# env_file, so listing a key here (e.g. AGENT_SERVICE_KEY=${AGENT_SERVICE_KEY})
|
||||
# would let an unset top-level .env silently blank out a value the owner had
|
||||
# correctly pasted into drb-c2-core/.env. New settings go in
|
||||
# drb-c2-core/.env.example and, for the VM, in
|
||||
# infra/ansible/roles/deploy/templates/c2-core.env.j2 + vault.yml.
|
||||
# AGENT_SERVICE_KEY (server-26#64) is configured that way.
|
||||
c2-core:
|
||||
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||
build: ./drb-c2-core
|
||||
|
||||
@@ -37,3 +37,15 @@ EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
||||
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
|
||||
# Generate with: openssl rand -hex 32
|
||||
ENROLLMENT_TOKEN=
|
||||
|
||||
# Shared key the Discord bot presents to reach C2 without Firebase.
|
||||
# Generate with: openssl rand -hex 32
|
||||
SERVICE_KEY=
|
||||
|
||||
# Agent/automation key for the unattended work session's headless routes
|
||||
# (GET/PUT /admin/features). DELIBERATELY a different value from SERVICE_KEY —
|
||||
# reusing the bot's key would make both principals indistinguishable in
|
||||
# audit_log, which is the whole point of server-26#64. Leave blank to keep the
|
||||
# agent path closed; the routes still take a Firebase admin token either way.
|
||||
# Generate with: openssl rand -hex 32
|
||||
AGENT_SERVICE_KEY=
|
||||
|
||||
@@ -8,4 +8,10 @@ RUN pip install uv && uv pip install --system --no-cache-dir -r requirements.txt
|
||||
COPY app/ ./app/
|
||||
COPY tests/ ./tests/
|
||||
|
||||
# Stamped by CI so /health can prove WHICH build is running. A deploy that
|
||||
# reports success while the old container keeps running is otherwise silent
|
||||
# -- exactly how production served two-day-old code for two days.
|
||||
ARG GIT_SHA=unknown
|
||||
ENV GIT_SHA=$GIT_SHA
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -46,6 +46,39 @@ class Settings(BaseSettings):
|
||||
# Verify against https://ai.google.dev/gemini-api/docs/models before changing.
|
||||
corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down)
|
||||
corr_smart_model: str = "gemini-2.5-pro" # was gemini-1.5-pro (shut down)
|
||||
# Transcript correction (server-26#36). Runs inside transcription, once per
|
||||
# transcribed call above MIN_WORDS_FOR_CORRECTION, so it is priced like STT
|
||||
# rather than like the correlation tier — cheap model on purpose.
|
||||
transcript_correction_enabled: bool = True
|
||||
transcript_correction_model: str = "gemini-3.6-flash"
|
||||
# Retry Whisper once when its output is degenerate. The same clip produced a
|
||||
# 56-word ten-code counting run on one attempt and real speech on the next
|
||||
# (2026-08-23, call e49ea32c), so a hallucination is a coin-flip rather than
|
||||
# a property of the audio, and discarding on the first bad roll threw away a
|
||||
# recoverable transcript.
|
||||
stt_retry_on_degenerate: bool = True
|
||||
# Place verification (server-26#37). Checks the corrector's location nouns
|
||||
# against the talkgroup's own anchor instead of stuffing every road in town
|
||||
# into the prompt, so cost scales with location nouns rather than call volume.
|
||||
place_verification_enabled: bool = True
|
||||
# Raw transcript text in alert payloads (server-26#85). Default CLOSED.
|
||||
# Board minutes #42 suppress person names on every surface until E&O is
|
||||
# bound, and an alert webhook is the least recoverable surface there is:
|
||||
# once the text is in a Discord channel we do not own it, cannot unsend
|
||||
# it, and cannot audit who read it. This switch is the operator-level
|
||||
# gate and is deliberately NOT reachable from the app -- the per-org
|
||||
# opt-in alone would let an org owner self-serve their way to somebody
|
||||
# else's PII. Both gates must be open before any snippet leaves.
|
||||
alert_transcript_snippet_enabled: bool = False
|
||||
place_verify_max_per_call: int = 3
|
||||
# How close a candidate has to sound before it may rewrite a transcript.
|
||||
# Below this, Places Text Search will confidently hand back the nearest
|
||||
# business for any garbage string.
|
||||
place_soundalike_min_ratio: float = 0.6
|
||||
# An anchor wider than this is not stored at all. A statewide radius would
|
||||
# confirm any location inside it, so the check would rubber-stamp everything
|
||||
# while appearing to work — absent anchor means SKIP, never "accept anything".
|
||||
area_anchor_max_radius_km: float = 60.0
|
||||
summary_interval_minutes: int = 2 # how often the summary loop runs
|
||||
correlation_window_hours: int = 2 # slow/location path: max hours since last call
|
||||
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
|
||||
@@ -65,6 +98,41 @@ class Settings(BaseSettings):
|
||||
# one was >= 8.2, so 5 separates them with room on both sides. Genuine
|
||||
# back-and-forth is handled by the 30-second tier-1 path above this.
|
||||
tg_dispatch_thin_idle_minutes: int = 5
|
||||
# Every other channel: tier-2 thin calls attach to a lone candidate idle < this.
|
||||
# Non-dispatch talkgroups previously had NO tier-2 bound at all — they used the
|
||||
# whole 90-minute tg_fast_path_idle_minutes window with no single-candidate
|
||||
# requirement and no fit test, which is the widest version of the 2026-08-20
|
||||
# over-merge. A tactical channel really is dedicated to one scene, so it earns
|
||||
# a longer window than a dispatch backbone, but not an unbounded one: 15 sits
|
||||
# inside the 20-minute tactical-default window in _call_fits_incident, so the
|
||||
# no-evidence thin path is never more permissive than the fit-tested path on
|
||||
# the same channel.
|
||||
tg_thin_idle_minutes: int = 15
|
||||
|
||||
# ── Hard caps: an incident past either of these stops accepting calls ──────
|
||||
# Enforced on every correlation path (see _incident_at_capacity). Pairwise fit
|
||||
# tests judge one call against one incident and cannot see the shape of the
|
||||
# chain they are building, so these are the only guard against a "work shift"
|
||||
# incident regardless of how individually plausible each link looked.
|
||||
#
|
||||
# 120 minutes: the one incident in the 2026-08-20 dump that was genuinely a
|
||||
# single event ran 63 minutes (06:15 wrong-way driver → 07:18 closeout), so
|
||||
# the cap has to clear an hour with real headroom. The four junk chains ran
|
||||
# 3h41m, 3h43m, 4h05m and 4h09m, so it has to sit well under three hours.
|
||||
# 120 also equals correlation_window_hours: the location and slow paths
|
||||
# already refuse to consider a candidate older than that, and the fast path
|
||||
# was the only one exempt. Making it agree removes that inconsistency rather
|
||||
# than inventing a new number.
|
||||
incident_max_duration_minutes: int = 120
|
||||
# 40 calls: a backstop for a burst that fills up inside the duration cap
|
||||
# rather than the primary bound. The worst observed chain averaged ~16
|
||||
# calls/hour while absorbing an ENTIRE dispatch backbone, so 40 calls in
|
||||
# under two hours means the incident is eating most of the channel — that is
|
||||
# a chain, not an event. Set deliberately above any plausible single-incident
|
||||
# call volume (a multi-alarm fire on its own tactical channel) so this cap
|
||||
# errs toward keeping real incidents whole and lets the duration cap do the
|
||||
# cutting.
|
||||
incident_max_calls: int = 40
|
||||
|
||||
# Vocabulary learning
|
||||
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
|
||||
@@ -73,6 +141,21 @@ class Settings(BaseSettings):
|
||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||
service_key: Optional[str] = None
|
||||
|
||||
# Automation/agent service key — the unattended work-session agent's own
|
||||
# credential for the headless routes it needs (currently GET/PUT
|
||||
# /admin/features).
|
||||
#
|
||||
# DELIBERATELY SEPARATE from service_key above, not a second consumer of
|
||||
# it. service_key is the Discord bot's, and it is handed to a process that
|
||||
# relays radio traffic to a chat server; sharing it here would make "the
|
||||
# bot" and "the agent" the same principal in every log line and audit
|
||||
# entry, so a global AI-cost flag flip could never be attributed to whoever
|
||||
# actually made it. Two keys, two identities (server-26#64 item 1).
|
||||
#
|
||||
# Unset means the agent path is simply closed — the routes still accept a
|
||||
# Firebase admin token. Generate with: openssl rand -hex 32
|
||||
agent_service_key: Optional[str] = None
|
||||
|
||||
# Fleet-wide token edge nodes present to POST /nodes/enroll on first boot.
|
||||
# Not a per-node secret — see routers/enrollment.py for why a leaked copy
|
||||
# of this alone can't steal an already-approved node's key.
|
||||
@@ -99,8 +182,21 @@ class Settings(BaseSettings):
|
||||
|
||||
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||
# Defaults to "*" for local development only.
|
||||
#
|
||||
# Leaving this as "*" is not merely permissive: main.py turns OFF
|
||||
# allow_credentials when it sees a wildcard, because Starlette would
|
||||
# otherwise reflect each caller's origin back WITH
|
||||
# Access-Control-Allow-Credentials. So a production deployment that
|
||||
# forgets to set this gets a loud ERROR at startup and loses credentialed
|
||||
# cross-origin requests, rather than silently accepting every origin.
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
# Discord webhook URL that app/internal/ai_health.py posts to when an AI
|
||||
# tier (transcription/correlation) transitions into or out of degraded
|
||||
# state. Empty disables the POST entirely — not every self-hosted
|
||||
# deployment will set this up, and skipping it must be silent.
|
||||
ai_alert_webhook_url: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Shared AI-provider degradation registry.
|
||||
|
||||
On the night of 2026-08-18 three independent AI dependency failures (a
|
||||
retired Gemini model ID, a depleted Gemini balance, an unpayable OpenAI
|
||||
account) each surfaced only as a single ERROR log line -- and nobody reads
|
||||
container logs continuously. This module is the fix: every AI call site
|
||||
reports its outcome here instead of (or in addition to) just logging, so the
|
||||
current state of every AI tier can be read back over HTTP (see
|
||||
app/main.py's /health/ai) and pushed out to Discord on state changes.
|
||||
|
||||
Tiers are tracked independently and in memory only (module-level singleton,
|
||||
no Firestore/DI -- consistent with the rest of this codebase). State is lost
|
||||
on restart, which is fine: a fresh process should re-derive degradation from
|
||||
the next few calls rather than resurrect a possibly-stale alert.
|
||||
|
||||
The load-bearing distinction, from the incident this module exists to
|
||||
prevent: a PERMANENT condition (retired model, dead billing account, bad API
|
||||
key) will never clear on its own and must alert on the very first
|
||||
occurrence. A TRANSIENT condition (rate limit, network blip) clears by
|
||||
itself constantly and must NOT page anyone for the first failure -- only if
|
||||
it persists. classify() is the one place that tells the two apart from a
|
||||
provider error message, because both this module's callers (llm_correlator.py,
|
||||
transcription.py) need the exact same judgment call and must not each grow
|
||||
their own slightly-different copy that drifts.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.internal.logger import logger
|
||||
from app.config import settings
|
||||
|
||||
TIERS = ("transcription", "correlation_cheap", "correlation_smart", "extraction")
|
||||
|
||||
# Consecutive failures a TRANSIENT condition must reach before it alerts.
|
||||
# Permanent conditions skip this entirely and alert on failure #1.
|
||||
TRANSIENT_ALERT_THRESHOLD = 5
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _default_state() -> dict:
|
||||
return {
|
||||
"degraded": False,
|
||||
"permanent": False,
|
||||
"provider": None,
|
||||
"model": None,
|
||||
"problem": None,
|
||||
"fix": None,
|
||||
"first_seen": None,
|
||||
"last_seen": None,
|
||||
"consecutive_failures": 0,
|
||||
"alerted": False,
|
||||
}
|
||||
|
||||
|
||||
_state: dict[str, dict] = {t: _default_state() for t in TIERS}
|
||||
|
||||
|
||||
def classify(text: str) -> str:
|
||||
"""
|
||||
Classify a provider failure message body.
|
||||
|
||||
Returns "dead_model", "billing", or "transient".
|
||||
|
||||
A depleted balance and an ordinary rate limit both arrive as HTTP 429 --
|
||||
the status code can't tell them apart, only the message body can. This
|
||||
logic previously lived independently in llm_correlator.py and (in a
|
||||
slightly different shape) transcription.py; it now lives here once, and
|
||||
both call in rather than re-matching the text themselves.
|
||||
"""
|
||||
low = text.lower()
|
||||
|
||||
if "404" in text or "not found" in low or "no longer available" in low:
|
||||
return "dead_model"
|
||||
|
||||
if (
|
||||
"credits are depleted" in low
|
||||
or "prepayment" in low
|
||||
or "billing" in low
|
||||
or "insufficient_quota" in low
|
||||
or "credit" in low
|
||||
or "exceeded your current quota" in low
|
||||
):
|
||||
return "billing"
|
||||
|
||||
return "transient"
|
||||
|
||||
|
||||
async def report_degraded(
|
||||
tier: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
problem: str,
|
||||
fix: str,
|
||||
permanent: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Record a failure for `tier`. Call this from a failure path, once per
|
||||
failure (it does its own once-per-episode alert suppression -- do not
|
||||
gate the call site on that yourself).
|
||||
|
||||
permanent=True (dead model, unpayable account, bad key) alerts on this
|
||||
very call. permanent=False (rate limit, network blip) only alerts once
|
||||
TRANSIENT_ALERT_THRESHOLD consecutive failures have been reported for
|
||||
this tier, so an ordinary blip never pages anyone.
|
||||
"""
|
||||
if tier not in _state:
|
||||
_state[tier] = _default_state()
|
||||
entry = _state[tier]
|
||||
|
||||
now = _now()
|
||||
if entry["consecutive_failures"] == 0:
|
||||
entry["first_seen"] = now
|
||||
entry["last_seen"] = now
|
||||
entry["consecutive_failures"] += 1
|
||||
entry["provider"] = provider
|
||||
entry["model"] = model
|
||||
entry["problem"] = problem
|
||||
entry["fix"] = fix
|
||||
entry["permanent"] = permanent
|
||||
|
||||
should_alert_now = permanent or entry["consecutive_failures"] >= TRANSIENT_ALERT_THRESHOLD
|
||||
|
||||
if should_alert_now and not entry["degraded"]:
|
||||
entry["degraded"] = True
|
||||
|
||||
if should_alert_now and not entry["alerted"]:
|
||||
entry["alerted"] = True
|
||||
await _post_webhook(
|
||||
f"**AI tier degraded: {tier}**\n"
|
||||
f"Provider: {provider} ({model})\n"
|
||||
f"Problem: {problem}\n"
|
||||
f"Fix: {fix}\n"
|
||||
f"Kind: {'permanent' if permanent else 'transient, persisted ' + str(entry['consecutive_failures']) + ' calls'}"
|
||||
)
|
||||
|
||||
|
||||
async def report_healthy(tier: str) -> None:
|
||||
"""
|
||||
Record a successful call for `tier`. Call this on every success, not
|
||||
just after a failure -- it is what lets a degraded tier recover on its
|
||||
own instead of staying red forever after one transient blip.
|
||||
"""
|
||||
if tier not in _state:
|
||||
_state[tier] = _default_state()
|
||||
entry = _state[tier]
|
||||
|
||||
was_alerted = entry["alerted"]
|
||||
was_degraded = entry["degraded"]
|
||||
provider, model = entry["provider"], entry["model"]
|
||||
|
||||
_state[tier] = _default_state()
|
||||
# Keep the last-known provider/model around for the recovery message
|
||||
# and for a quick glance at snapshot() even when healthy.
|
||||
_state[tier]["provider"] = provider
|
||||
_state[tier]["model"] = model
|
||||
|
||||
if was_alerted:
|
||||
await _post_webhook(f"**AI tier recovered: {tier}**\nProvider: {provider} ({model})")
|
||||
elif was_degraded:
|
||||
# Reached "degraded" internally but never crossed the alert
|
||||
# threshold before recovering -- nothing was ever posted, so
|
||||
# nothing needs un-posting. Nothing to do.
|
||||
pass
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Current state of every tier, for /health/ai."""
|
||||
return {tier: dict(entry) for tier, entry in _state.items()}
|
||||
|
||||
|
||||
async def _post_webhook(content: str) -> None:
|
||||
"""
|
||||
POST a message to the AI-alert Discord webhook, if one is configured.
|
||||
|
||||
Same httpx pattern as app/internal/alerter.py's _post_webhook: short
|
||||
timeout, never raises. Self-hosted deployments that don't set
|
||||
ai_alert_webhook_url just skip this silently.
|
||||
"""
|
||||
url = settings.ai_alert_webhook_url
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.post(url, json={"content": content})
|
||||
except Exception as e:
|
||||
logger.warning(f"ai_health: Discord webhook POST failed: {e}")
|
||||
@@ -6,11 +6,15 @@ talkgroup ID, tags, and transcript. On a match:
|
||||
1. Creates an AlertEvent document in Firestore.
|
||||
2. Optionally POSTs a Discord webhook message if the rule has one configured.
|
||||
|
||||
Raw transcript text is withheld from both by default -- see _snippet_allowed
|
||||
and server-26#85.
|
||||
|
||||
Never raises — failures are logged as warnings so the pipeline always completes.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
@@ -47,13 +51,17 @@ async def check_and_dispatch(
|
||||
logger.warning(f"Alerter: could not load rules: {e}")
|
||||
return
|
||||
|
||||
# Loop-invariant: every rule here belongs to the same org, so the opt-in is
|
||||
# resolved once rather than per match.
|
||||
snippet_allowed = await _snippet_allowed(org_id)
|
||||
|
||||
for rule in rules:
|
||||
matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript)
|
||||
if not matched_keywords:
|
||||
continue
|
||||
|
||||
alert_id = str(uuid.uuid4())
|
||||
snippet = _snippet(transcript)
|
||||
snippet = _snippet(transcript) if snippet_allowed else None
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
event = {
|
||||
"alert_id": alert_id,
|
||||
@@ -85,6 +93,43 @@ async def check_and_dispatch(
|
||||
await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet)
|
||||
|
||||
|
||||
async def _snippet_allowed(org_id: Optional[str]) -> bool:
|
||||
"""
|
||||
Whether raw transcript text may be attached to an alert (server-26#85).
|
||||
|
||||
Two gates, both of which must be open:
|
||||
|
||||
1. ``settings.alert_transcript_snippet_enabled`` -- the operator switch,
|
||||
default False, set from the environment and unreachable from the app.
|
||||
2. ``alert_snippet_opt_in`` on the org document -- the customer's own
|
||||
explicit, contractual opt-in.
|
||||
|
||||
Gate 1 exists because gate 2 alone is not a real control: the frontend
|
||||
reads and (per the Firestore rules, not ``auth.py``) can write org state
|
||||
directly from the browser, so an org owner could otherwise opt themselves
|
||||
into receiving person names lifted from live public-safety traffic. Board
|
||||
minutes #42 suppress names on every surface until E&O is bound.
|
||||
|
||||
Fails CLOSED on any error, and on a call with no org (a pre-tenancy node
|
||||
that has not been backfilled), because the cost of wrongly withholding a
|
||||
snippet is a less informative alert and the cost of wrongly emitting one
|
||||
is unrecallable disclosure to a third party.
|
||||
"""
|
||||
if not settings.alert_transcript_snippet_enabled:
|
||||
return False
|
||||
if not org_id:
|
||||
return False
|
||||
try:
|
||||
org = await fstore.doc_get("organizations", org_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Alerter: could not read snippet opt-in for org={org_id}, "
|
||||
f"withholding transcript: {e}"
|
||||
)
|
||||
return False
|
||||
return bool((org or {}).get("alert_snippet_opt_in"))
|
||||
|
||||
|
||||
def _match_rule(
|
||||
rule: dict,
|
||||
talkgroup_id: Optional[int],
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Area context — the ground truth an operator sets about where a channel operates.
|
||||
|
||||
One shape, used at two scopes (server-26#36):
|
||||
|
||||
area_context: {
|
||||
municipality?, county?, state?,
|
||||
center?, radius_km?, resolved_from?, resolved_at?, # backend-written
|
||||
local_knowledge?: [ { term, meaning } ]
|
||||
}
|
||||
|
||||
WHY EVERY FIELD IS NULLABLE. The system level is only meaningful when it is true
|
||||
of *every* talkgroup on that system. White Plains PD — it is, so an operator
|
||||
fills it once and every talkgroup inherits. A statewide Colorado system — it is
|
||||
not, so they leave it null and fill each talkgroup. Which level someone fills IS
|
||||
their declaration of how homogeneous the system is, which is what lets one
|
||||
schema serve both without a `system_type` flag to get out of sync.
|
||||
|
||||
WHY `local_knowledge` REPLACED `roads[]`/`landmarks[]`. Radio traffic references
|
||||
intersections, schools, housing developments, rail stations and nicknames ("the
|
||||
flats"), none of which fit two lists. And a bare term is half the information:
|
||||
`11-X-ray` tells a corrector nothing, `11-X-ray — MTA PD patrol unit` is what
|
||||
lets it recognise the sound.
|
||||
|
||||
WHY THE ANCHOR CAN BE ABSENT ON PURPOSE. `center`/`radius_km` exist so a
|
||||
geocoded place name can be sanity-checked against the area the channel actually
|
||||
covers (server-26#37). If municipality/county/state only resolve to something as
|
||||
wide as a state, that check would confirm anything inside it while appearing to
|
||||
work — worse than useless. So an anchor wider than
|
||||
`settings.area_anchor_max_radius_km` is not written at all, and an absent anchor
|
||||
means SKIP THE CHECK, never "accept anything".
|
||||
|
||||
THE CLIENT DOES NOT WRITE THE DERIVED FIELDS. `center`, `radius_km`,
|
||||
`resolved_from` and `resolved_at` are computed here and merged in by the server.
|
||||
Taking them from the request body is the same defect as the `ten_codes` wipe
|
||||
fixed in 58efdbd: the frontend does not decide what is in a system document.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
# Fields an operator sets. Anything else in an incoming body is dropped.
|
||||
CLIENT_FIELDS = ("municipality", "county", "state", "local_knowledge")
|
||||
# Fields this module owns. Carried forward from the stored document on every
|
||||
# write, never read from the request.
|
||||
SERVER_FIELDS = ("center", "radius_km", "resolved_from", "resolved_at")
|
||||
# The three that identify a place, in the order they are geocoded.
|
||||
PLACE_FIELDS = ("municipality", "county", "state")
|
||||
|
||||
_anchor_cache: dict[str, Optional[dict]] = {}
|
||||
|
||||
|
||||
def geo_dist_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Haversine distance in km between two lat/lon points."""
|
||||
R = 6371.0
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
|
||||
)
|
||||
return R * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
# -- Normalisation -------------------------------------------------------------
|
||||
|
||||
def normalize_local_knowledge(raw: Any) -> list[dict]:
|
||||
"""
|
||||
Coerce whatever arrived into [{term, meaning}], dropping junk.
|
||||
|
||||
Accepts a bare string list too — that is what `roads[]`/`landmarks[]` and the
|
||||
old flat `vocabulary` look like, and a term with no meaning is still worth
|
||||
having in the reference list.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw:
|
||||
if isinstance(item, str):
|
||||
term, meaning = item.strip(), None
|
||||
elif isinstance(item, dict):
|
||||
term = str(item.get("term") or "").strip()
|
||||
meaning = str(item.get("meaning") or "").strip() or None
|
||||
else:
|
||||
continue
|
||||
key = term.lower()
|
||||
if not term or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({"term": term, "meaning": meaning} if meaning else {"term": term})
|
||||
return out
|
||||
|
||||
|
||||
def knowledge_of(area: Optional[dict]) -> list[dict]:
|
||||
"""
|
||||
This scope's local knowledge, folding the pre-#36 shape forward.
|
||||
|
||||
`roads[]` and `landmarks[]` were the original fields and real systems still
|
||||
have them stored. Reading them as bare terms means the corrector keeps the
|
||||
ground truth an operator already entered instead of silently losing it the
|
||||
day this shipped; they disappear from the document the next time that scope
|
||||
is saved.
|
||||
"""
|
||||
area = area or {}
|
||||
legacy = list(area.get("roads") or []) + list(area.get("landmarks") or [])
|
||||
return normalize_local_knowledge(list(area.get("local_knowledge") or []) + legacy)
|
||||
|
||||
|
||||
def normalize(raw: Any) -> dict:
|
||||
"""Client-supplied area_context -> the stored shape, server fields excluded."""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
for field in PLACE_FIELDS:
|
||||
value = raw.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
out[field] = value.strip()
|
||||
knowledge = knowledge_of(raw)
|
||||
if knowledge:
|
||||
out["local_knowledge"] = knowledge
|
||||
return out
|
||||
|
||||
|
||||
def merge_server_fields(incoming: dict, existing: Optional[dict]) -> dict:
|
||||
"""Carry the backend-owned anchor forward across a client write."""
|
||||
out = dict(incoming)
|
||||
for field in SERVER_FIELDS:
|
||||
if existing and existing.get(field) is not None:
|
||||
out[field] = existing[field]
|
||||
return out
|
||||
|
||||
|
||||
def merge_config(incoming: Any, existing: Optional[dict]) -> Any:
|
||||
"""
|
||||
Reconcile a client-sent config blob with what the server already owns.
|
||||
|
||||
The systems form sends `config.talkgroups[]` in full, so writing it verbatim
|
||||
destroys everything the backend put there — the resolved anchor and the
|
||||
pending term queue. That is the same defect as the `ten_codes` wipe fixed in
|
||||
58efdbd, and the same fix applies: the backend merges its own fields back in
|
||||
rather than taking dictation from the frontend.
|
||||
"""
|
||||
if not isinstance(incoming, dict):
|
||||
return incoming
|
||||
incoming_tgs = incoming.get("talkgroups")
|
||||
if not isinstance(incoming_tgs, list):
|
||||
return incoming
|
||||
|
||||
by_id: dict[int, dict] = {}
|
||||
for tg in ((existing or {}).get("talkgroups") or []):
|
||||
if isinstance(tg, dict):
|
||||
try:
|
||||
by_id[int(tg.get("id", -1))] = tg
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
merged: list[Any] = []
|
||||
for tg in incoming_tgs:
|
||||
if not isinstance(tg, dict):
|
||||
merged.append(tg)
|
||||
continue
|
||||
try:
|
||||
prior = by_id.get(int(tg.get("id", -1))) or {}
|
||||
except (TypeError, ValueError):
|
||||
prior = {}
|
||||
out = dict(tg)
|
||||
area = normalize(tg.get("area_context"))
|
||||
prior_area = prior.get("area_context") or {}
|
||||
if area:
|
||||
out["area_context"] = merge_server_fields(area, prior_area)
|
||||
else:
|
||||
out.pop("area_context", None)
|
||||
if prior.get(PENDING_KEY):
|
||||
out[PENDING_KEY] = prior[PENDING_KEY]
|
||||
merged.append(out)
|
||||
|
||||
return {**incoming, "talkgroups": merged}
|
||||
|
||||
|
||||
# -- Scope resolution ----------------------------------------------------------
|
||||
|
||||
def effective(system_area: Optional[dict], tg_area: Optional[dict]) -> dict:
|
||||
"""
|
||||
Merge the two scopes: talkgroup wins where set, system fills the gaps.
|
||||
|
||||
`local_knowledge` concatenates rather than replaces, talkgroup entries first
|
||||
so they survive any downstream truncation and outrank a system entry for the
|
||||
same term. A multi-county system whose talkgroup covers one municipality must
|
||||
not have that municipality's terms buried under a county-wide list.
|
||||
"""
|
||||
system_area = system_area or {}
|
||||
tg_area = tg_area or {}
|
||||
out: dict[str, Any] = {}
|
||||
for field in PLACE_FIELDS:
|
||||
value = tg_area.get(field) or system_area.get(field)
|
||||
if value:
|
||||
out[field] = value
|
||||
knowledge = normalize_local_knowledge(knowledge_of(tg_area) + knowledge_of(system_area))
|
||||
if knowledge:
|
||||
out["local_knowledge"] = knowledge
|
||||
return out
|
||||
|
||||
|
||||
def talkgroup_entry(system_doc: Optional[dict], talkgroup_id: Any) -> dict:
|
||||
"""The `config.talkgroups[]` entry for this talkgroup, or `{}`."""
|
||||
if not system_doc or talkgroup_id is None:
|
||||
return {}
|
||||
talkgroups = (system_doc.get("config") or {}).get("talkgroups") or []
|
||||
idx = _tg_index(talkgroups, talkgroup_id)
|
||||
return talkgroups[idx] if idx >= 0 else {}
|
||||
|
||||
|
||||
def anchor_key(area: Optional[dict]) -> str:
|
||||
"""
|
||||
Stable identity of the place an anchor was resolved from.
|
||||
|
||||
Stored as `resolved_from`, which is what makes "did this actually change?"
|
||||
decidable — so the geocode happens when someone edits a town name, not on
|
||||
every read or every five minutes.
|
||||
"""
|
||||
area = area or {}
|
||||
return "|".join((area.get(f) or "").strip().lower() for f in PLACE_FIELDS)
|
||||
|
||||
|
||||
def has_place(area: Optional[dict]) -> bool:
|
||||
return bool(anchor_key(area).strip("|"))
|
||||
|
||||
|
||||
def anchor_for(system_area: Optional[dict], tg_area: Optional[dict]) -> Optional[dict]:
|
||||
"""
|
||||
The anchor to sanity-check geocoded locations against, or None.
|
||||
|
||||
None has one meaning and it is load-bearing: SKIP THE CHECK. It covers an
|
||||
unconfigured system, an area too wide to discriminate, and a stored anchor
|
||||
whose `resolved_from` no longer matches the place it was computed for (an
|
||||
edit landed and the refresh has not run). Accepting a stale or oversized
|
||||
anchor would rubber-stamp locations while looking like verification.
|
||||
"""
|
||||
key = anchor_key(effective(system_area, tg_area))
|
||||
for area in (tg_area, system_area):
|
||||
if not area:
|
||||
continue
|
||||
center, radius = area.get("center"), area.get("radius_km")
|
||||
if area.get("resolved_from") == key and center and radius:
|
||||
try:
|
||||
return {
|
||||
"lat": float(center["lat"]),
|
||||
"lng": float(center["lng"]),
|
||||
"radius_km": float(radius),
|
||||
}
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# -- Anchor geocoding ----------------------------------------------------------
|
||||
|
||||
def _query(area: dict) -> str:
|
||||
return ", ".join(area[f] for f in PLACE_FIELDS if area.get(f))
|
||||
|
||||
|
||||
async def resolve_anchor(area: dict) -> Optional[dict]:
|
||||
"""
|
||||
Geocode municipality/county/state into {center, radius_km}, or None.
|
||||
|
||||
The radius comes from the result's own viewport — half its diagonal — so a
|
||||
village anchors tightly and a county loosely, which is the real difference
|
||||
we care about. Anything wider than `area_anchor_max_radius_km` is discarded
|
||||
rather than stored: see the module docstring.
|
||||
"""
|
||||
if not has_place(area):
|
||||
return None
|
||||
query = _query(area)
|
||||
if query in _anchor_cache:
|
||||
return _anchor_cache[query]
|
||||
if not settings.google_maps_api_key:
|
||||
logger.warning("GOOGLE_MAPS_API_KEY not set — area anchors cannot be resolved")
|
||||
return None
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(
|
||||
"https://maps.googleapis.com/maps/api/geocode/json",
|
||||
params={"address": query, "region": "us", "key": settings.google_maps_api_key},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("status") != "OK" or not data.get("results"):
|
||||
logger.warning(f"Area anchor: {query!r} did not geocode ({data.get('status')})")
|
||||
_anchor_cache[query] = None
|
||||
return None
|
||||
geometry = data["results"][0].get("geometry") or {}
|
||||
loc = geometry.get("location") or {}
|
||||
lat, lng = float(loc["lat"]), float(loc["lng"])
|
||||
viewport = geometry.get("viewport") or {}
|
||||
ne, sw = viewport.get("northeast"), viewport.get("southwest")
|
||||
if ne and sw:
|
||||
radius_km = geo_dist_km(sw["lat"], sw["lng"], ne["lat"], ne["lng"]) / 2
|
||||
else:
|
||||
radius_km = settings.geocode_max_km
|
||||
except Exception as e:
|
||||
logger.warning(f"Area anchor geocoding failed for {query!r}: {e}")
|
||||
return None # not cached — a transient failure should be retried
|
||||
|
||||
if radius_km > settings.area_anchor_max_radius_km:
|
||||
logger.info(
|
||||
f"Area anchor: {query!r} spans ~{radius_km:.0f}km, wider than "
|
||||
f"area_anchor_max_radius_km={settings.area_anchor_max_radius_km} — storing no "
|
||||
f"anchor, so verification skips rather than rubber-stamps"
|
||||
)
|
||||
_anchor_cache[query] = None
|
||||
return None
|
||||
|
||||
anchor = {
|
||||
"center": {"lat": lat, "lng": lng},
|
||||
"radius_km": round(radius_km, 2),
|
||||
"resolved_from": anchor_key(area),
|
||||
"resolved_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_anchor_cache[query] = anchor
|
||||
logger.info(f"Area anchor: {query!r} -> ({lat:.4f}, {lng:.4f}) r={radius_km:.1f}km")
|
||||
return anchor
|
||||
|
||||
|
||||
def _apply(area: dict, anchor: Optional[dict], key: str) -> dict:
|
||||
"""Write (or clear) the derived fields on one scope's area_context."""
|
||||
out = {k: v for k, v in area.items() if k not in SERVER_FIELDS}
|
||||
if anchor:
|
||||
out.update(anchor)
|
||||
elif key.strip("|"):
|
||||
# A place is set but produced no usable anchor. Record that we tried, so
|
||||
# the next write does not geocode it again for the same answer.
|
||||
out["resolved_from"] = key
|
||||
out["resolved_at"] = datetime.now(timezone.utc).isoformat()
|
||||
return out
|
||||
|
||||
|
||||
async def refresh_anchors(system_doc: dict) -> dict:
|
||||
"""
|
||||
Recompute anchors for a system and every talkgroup that sets a place.
|
||||
|
||||
Returns a Firestore patch — `{}` when nothing needed resolving. Talkgroups
|
||||
are refreshed alongside the system because a talkgroup's anchor is derived
|
||||
from its EFFECTIVE place (its own fields over the system's), so editing the
|
||||
system's county silently changes what every talkgroup should be anchored to.
|
||||
|
||||
Only scopes whose `resolved_from` no longer matches are geocoded, and the
|
||||
per-query cache means N talkgroups in one town cost one request.
|
||||
"""
|
||||
system_area = dict(system_doc.get("area_context") or {})
|
||||
patch: dict[str, Any] = {}
|
||||
|
||||
system_key = anchor_key(system_area)
|
||||
if system_area.get("resolved_from") != system_key:
|
||||
anchor = await resolve_anchor(system_area) if has_place(system_area) else None
|
||||
patch["area_context"] = _apply(system_area, anchor, system_key)
|
||||
system_area = patch["area_context"]
|
||||
|
||||
config = system_doc.get("config") or {}
|
||||
talkgroups = config.get("talkgroups")
|
||||
if not isinstance(talkgroups, list):
|
||||
return patch
|
||||
|
||||
updated: list[dict] = []
|
||||
changed = False
|
||||
for tg in talkgroups:
|
||||
if not isinstance(tg, dict):
|
||||
updated.append(tg)
|
||||
continue
|
||||
tg_area = tg.get("area_context") or {}
|
||||
# No place of its own means it inherits the system's anchor wholesale —
|
||||
# nothing to store here, and anchor_for() falls back to the system.
|
||||
if not has_place(tg_area):
|
||||
if any(tg_area.get(f) is not None for f in SERVER_FIELDS):
|
||||
tg = {**tg, "area_context": {k: v for k, v in tg_area.items() if k not in SERVER_FIELDS}}
|
||||
changed = True
|
||||
updated.append(tg)
|
||||
continue
|
||||
key = anchor_key(effective(system_area, tg_area))
|
||||
if tg_area.get("resolved_from") == key:
|
||||
updated.append(tg)
|
||||
continue
|
||||
anchor = await resolve_anchor(effective(system_area, tg_area))
|
||||
updated.append({**tg, "area_context": _apply(tg_area, anchor, key)})
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
patch["config"] = {**config, "talkgroups": updated}
|
||||
return patch
|
||||
|
||||
|
||||
# -- Talkgroup-level pending terms ---------------------------------------------
|
||||
#
|
||||
# Proposals land on the TALKGROUP and are never promoted to the system
|
||||
# automatically (server-26#37). The argument is blast radius: a wrong term on a
|
||||
# talkgroup misleads one channel, the same term at system level misleads every
|
||||
# channel on that system — including one 400km away on a statewide system, which
|
||||
# is exactly the context poisoning the scope rule exists to prevent. If a term
|
||||
# genuinely applies system-wide, carrying it on several talkgroups costs almost
|
||||
# nothing; auto-promoting a wrong one is expensive to notice.
|
||||
|
||||
PENDING_KEY = "local_knowledge_pending"
|
||||
|
||||
|
||||
def _tg_index(talkgroups: list, talkgroup_id: Any) -> int:
|
||||
try:
|
||||
wanted = int(talkgroup_id)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
for i, tg in enumerate(talkgroups):
|
||||
if not isinstance(tg, dict):
|
||||
continue
|
||||
try:
|
||||
if int(tg.get("id", -1)) == wanted:
|
||||
return i
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return -1
|
||||
|
||||
|
||||
def _known_terms(tg: dict, system_doc: dict) -> set[str]:
|
||||
"""Everything this talkgroup already knows, at either scope, plus pending."""
|
||||
known = {
|
||||
e["term"].lower()
|
||||
for e in effective(system_doc.get("area_context"), tg.get("area_context"))
|
||||
.get("local_knowledge", [])
|
||||
}
|
||||
known |= {str(t).lower() for t in (tg.get("vocabulary") or [])}
|
||||
known |= {str(t).lower() for t in (system_doc.get("vocabulary") or [])}
|
||||
known |= {str(p.get("term", "")).lower() for p in (tg.get(PENDING_KEY) or [])}
|
||||
return known
|
||||
|
||||
|
||||
async def add_pending(system_id: str, talkgroup_id: Any, entries: list[dict]) -> int:
|
||||
"""
|
||||
Queue proposed {term, meaning} entries on one talkgroup for human review.
|
||||
|
||||
Returns how many were actually queued. Nothing here writes to
|
||||
`local_knowledge` — approval is a person's decision, always.
|
||||
"""
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
if not system_id or talkgroup_id is None or not entries:
|
||||
return 0
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
if not system_doc:
|
||||
return 0
|
||||
config = dict(system_doc.get("config") or {})
|
||||
talkgroups = list(config.get("talkgroups") or [])
|
||||
idx = _tg_index(talkgroups, talkgroup_id)
|
||||
if idx < 0:
|
||||
return 0
|
||||
|
||||
tg = dict(talkgroups[idx])
|
||||
known = _known_terms(tg, system_doc)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
queued: list[dict] = []
|
||||
for entry in entries:
|
||||
term = str(entry.get("term") or "").strip()
|
||||
if not term or term.lower() in known:
|
||||
continue
|
||||
known.add(term.lower())
|
||||
queued.append({
|
||||
"term": term,
|
||||
"meaning": entry.get("meaning") or None,
|
||||
"source": entry.get("source") or "verifier",
|
||||
"added_at": now,
|
||||
"source_call_ids": entry.get("source_call_ids") or [],
|
||||
})
|
||||
if not queued:
|
||||
return 0
|
||||
|
||||
tg[PENDING_KEY] = list(tg.get(PENDING_KEY) or []) + queued
|
||||
talkgroups[idx] = tg
|
||||
config["talkgroups"] = talkgroups
|
||||
await fstore.doc_update("systems", system_id, {"config": config})
|
||||
logger.info(
|
||||
f"Local knowledge: {len(queued)} term(s) proposed for talkgroup "
|
||||
f"{talkgroup_id} on system {system_id}: {[q['term'] for q in queued]}"
|
||||
)
|
||||
return len(queued)
|
||||
|
||||
|
||||
async def resolve_pending(system_id: str, talkgroup_id: Any, term: str, approve: bool) -> bool:
|
||||
"""Approve a pending term onto the talkgroup, or dismiss it. Never promotes."""
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
if not system_doc:
|
||||
return False
|
||||
config = dict(system_doc.get("config") or {})
|
||||
talkgroups = list(config.get("talkgroups") or [])
|
||||
idx = _tg_index(talkgroups, talkgroup_id)
|
||||
if idx < 0:
|
||||
return False
|
||||
|
||||
tg = dict(talkgroups[idx])
|
||||
pending = list(tg.get(PENDING_KEY) or [])
|
||||
match = next((p for p in pending if str(p.get("term", "")).lower() == term.lower()), None)
|
||||
if match is None:
|
||||
return False
|
||||
tg[PENDING_KEY] = [p for p in pending if p is not match]
|
||||
if approve:
|
||||
area = dict(tg.get("area_context") or {})
|
||||
area["local_knowledge"] = normalize_local_knowledge(
|
||||
list(area.get("local_knowledge") or [])
|
||||
+ [{"term": match["term"], "meaning": match.get("meaning")}]
|
||||
)
|
||||
tg["area_context"] = area
|
||||
talkgroups[idx] = tg
|
||||
config["talkgroups"] = talkgroups
|
||||
await fstore.doc_update("systems", system_id, {"config": config})
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_anchors_bg(system_id: str) -> None:
|
||||
"""Fire-and-forget refresh, for callers that must not block on Maps."""
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
try:
|
||||
doc = await fstore.doc_get("systems", system_id)
|
||||
if not doc:
|
||||
return
|
||||
patch = await refresh_anchors(doc)
|
||||
if patch:
|
||||
await fstore.doc_update("systems", system_id, patch)
|
||||
except Exception as e:
|
||||
logger.warning(f"Area anchor refresh failed for system {system_id}: {e}")
|
||||
|
||||
|
||||
def schedule_refresh(system_id: str) -> None:
|
||||
"""Kick a refresh without making the caller wait for the geocoder."""
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(refresh_anchors_bg(system_id))
|
||||
except RuntimeError: # no loop (tests, scripts) — nothing to schedule
|
||||
pass
|
||||
@@ -220,6 +220,74 @@ async def require_service_key_or_admin(
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automation / agent principal
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity written into audit_log when the agent key is what authenticated a
|
||||
# request. A Firebase admin gets their own uid/email instead, so the two are
|
||||
# always distinguishable after the fact — which is the point.
|
||||
AGENT_PRINCIPAL_UID = "agent-service"
|
||||
AGENT_PRINCIPAL_EMAIL = "agent-service@drb.internal"
|
||||
|
||||
|
||||
async def require_agent_key_or_admin(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept either the agent service key or a Firebase admin token.
|
||||
|
||||
Deliberately does NOT accept ``settings.service_key``. That key belongs to
|
||||
the Discord bot, and honouring it here would collapse two principals into
|
||||
one unattributable identity in every log line and audit entry — the exact
|
||||
thing server-26#64 exists to end. The bot has no business flipping
|
||||
platform-wide AI flags either way.
|
||||
|
||||
Exists so the unattended runbook can flip AI flags over HTTP instead of
|
||||
SSHing into the container and writing ``config/ai_features`` with the admin
|
||||
SDK, which needs a full container shell to move a cost switch.
|
||||
|
||||
The ``settings.agent_service_key and ...`` guard is load-bearing, not
|
||||
stylistic: ``secrets.compare_digest("", "")`` is a MATCH, so any form of
|
||||
``compare_digest(token, settings.agent_service_key or "")`` would turn a
|
||||
deployment that never configured the key into one that accepts an empty
|
||||
credential. Check the key is configured first and never substitute a
|
||||
placeholder. (``require_service_key`` states the same intent by raising
|
||||
503 when unset; both are correct, this one just stays open to admins.)
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.agent_service_key and secrets.compare_digest(token, settings.agent_service_key):
|
||||
return {
|
||||
"service": True,
|
||||
"principal": "agent",
|
||||
"uid": AGENT_PRINCIPAL_UID,
|
||||
"email": AGENT_PRINCIPAL_EMAIL,
|
||||
}
|
||||
try:
|
||||
decoded = firebase_auth.verify_id_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
if get_role(decoded) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
def describe_actor(principal: dict) -> tuple[str, str]:
|
||||
"""Return ``(actor_uid, actor_email)`` for an audit entry.
|
||||
|
||||
Works for any credential shape the dependencies above produce, so an audit
|
||||
call site never has to switch on principal type itself.
|
||||
"""
|
||||
if principal.get("principal") == "agent":
|
||||
return AGENT_PRINCIPAL_UID, AGENT_PRINCIPAL_EMAIL
|
||||
if principal.get("service"):
|
||||
return "service", "service@drb.internal"
|
||||
if principal.get("node"):
|
||||
node_id = principal.get("node_id") or "unknown"
|
||||
return f"node:{node_id}", ""
|
||||
return principal.get("uid") or "unknown", principal.get("email") or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-memory sliding-window rate limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,6 +19,21 @@ _DEFAULTS: dict[str, bool] = {
|
||||
"correlation_enabled": True,
|
||||
"summaries_enabled": True,
|
||||
"vocabulary_learning_enabled": True,
|
||||
# Transcript correction runs inside transcribe_call and spends Gemini
|
||||
# tokens plus Places quota on every transcribed call. Until server-26#76
|
||||
# it was reachable only through an env var and an ansible run, which meant
|
||||
# an "STT-only" evaluation window was never STT-only and its cost could
|
||||
# not be attributed (server-26#45).
|
||||
#
|
||||
# NOT a pure cost lever. The corrector is also the noise gate: it is what
|
||||
# sets not_speech, and transcription.py returns nothing for a call it
|
||||
# flags. _is_degenerate does not catch what the corrector catches, so with
|
||||
# this off, recogniser noise reaches extraction as a real transcript, comes
|
||||
# back with no units/tags/location, is judged thin, and auto-attaches to the
|
||||
# most recent incident on the talkgroup with no fit check. Turning this off
|
||||
# while correlation_enabled is on therefore pushes over-merging -- do not do
|
||||
# it during an evaluation window.
|
||||
"transcript_correction_enabled": True,
|
||||
}
|
||||
|
||||
_cache: dict[str, Any] = {}
|
||||
@@ -48,15 +63,164 @@ async def get_flags() -> dict[str, bool]:
|
||||
return dict(_cache)
|
||||
|
||||
|
||||
async def set_flags(updates: dict[str, bool]) -> dict[str, bool]:
|
||||
"""Write flag updates to Firestore and invalidate the cache."""
|
||||
global _cache, _cache_ts
|
||||
async def _cascade_to_systems(clean: dict[str, bool]) -> tuple[list[dict], list[dict]]:
|
||||
"""Clear per-system ``ai_flags`` overrides for the keys just set globally.
|
||||
|
||||
Returns ``(changes, errors)``.
|
||||
|
||||
Why clearing rather than overwriting with the new value: an override that
|
||||
stays present, merely agreeing with the global switch for now, defeats the
|
||||
NEXT flip exactly the same way. Removing it makes the system inherit, which
|
||||
is the same semantics the human-facing route already offers
|
||||
(``PUT /systems/{id}/ai-flags`` with null → "clear override, inherit
|
||||
global").
|
||||
|
||||
Systems are discovered by scanning for documents that actually carry an
|
||||
``ai_flags`` map — never a hardcoded id list. Two systems carry overrides
|
||||
today; a third added tomorrow would silently defeat a global shutoff if
|
||||
this were pinned to the current pair.
|
||||
"""
|
||||
changes: list[dict] = []
|
||||
errors: list[dict] = []
|
||||
|
||||
systems = await fstore.collection_list("systems")
|
||||
for system in systems:
|
||||
sid = system.get("system_id")
|
||||
ai_flags = system.get("ai_flags")
|
||||
# Only documents that actually carry the map. A system with no
|
||||
# overrides already inherits, so there is nothing to cascade to.
|
||||
if not sid or not isinstance(ai_flags, dict) or not ai_flags:
|
||||
continue
|
||||
removed = {k: ai_flags[k] for k in clean if k in ai_flags}
|
||||
if not removed:
|
||||
continue
|
||||
remaining = {k: v for k, v in ai_flags.items() if k not in clean}
|
||||
try:
|
||||
await fstore.doc_update("systems", sid, {"ai_flags": remaining})
|
||||
except Exception as e:
|
||||
# Report rather than swallow: a half-applied cascade is the exact
|
||||
# failure mode this helper exists to prevent, so it must be visible
|
||||
# in the log and the audit entry.
|
||||
logger.error(f"Feature flags: cascade to system '{sid}' failed ({e})")
|
||||
errors.append({"system_id": sid, "error": str(e)})
|
||||
continue
|
||||
changes.append({
|
||||
"system_id": sid,
|
||||
"cleared_overrides": removed,
|
||||
"now_inherits": {k: clean[k] for k in removed},
|
||||
})
|
||||
|
||||
return changes, errors
|
||||
|
||||
|
||||
async def set_flags(
|
||||
updates: dict[str, bool],
|
||||
actor: tuple[str, str] | None = None,
|
||||
cascade: bool = False,
|
||||
) -> dict[str, bool]:
|
||||
"""Write flag updates to Firestore, invalidate the cache, and audit it.
|
||||
|
||||
``actor`` is ``(actor_uid, actor_email)`` — see auth.describe_actor. It is
|
||||
optional so existing callers keep working; an unattributed flip is logged
|
||||
as "unknown" rather than not logged at all.
|
||||
|
||||
``cascade`` also clears the matching per-system ``ai_flags`` overrides, so
|
||||
one call is a total flip. Defaults to False deliberately — see the route's
|
||||
comment in routers/admin.py.
|
||||
|
||||
Returns the resulting global flags dict, unchanged in shape: the admin UI
|
||||
(drb-frontend/lib/c2api.ts setFeatureFlags) types the response as
|
||||
Record<string, boolean>, so cascade/audit detail goes to the log and the
|
||||
audit entry rather than into this payload.
|
||||
"""
|
||||
global _cache_ts
|
||||
|
||||
clean = {k: bool(v) for k, v in updates.items() if k in _DEFAULTS}
|
||||
if not clean:
|
||||
raise ValueError(f"No recognised flag keys in update: {list(updates)}")
|
||||
|
||||
# Force a fresh read for the "before" side of the audit entry: the TTL
|
||||
# cache can be up to _TTL seconds stale, and a wrong previous value in an
|
||||
# audit log is worse than none.
|
||||
_cache_ts = 0.0
|
||||
before = await get_flags()
|
||||
|
||||
await fstore.doc_set(_COLLECTION, _DOC_ID, clean)
|
||||
_cache_ts = 0.0 # force re-read on next get_flags()
|
||||
logger.info(f"Feature flags updated: {clean}")
|
||||
return await get_flags()
|
||||
|
||||
cascaded: list[dict] = []
|
||||
cascade_errors: list[dict] = []
|
||||
if cascade:
|
||||
cascaded, cascade_errors = await _cascade_to_systems(clean)
|
||||
logger.info(
|
||||
f"Feature flags: cascaded {list(clean)} to {len(cascaded)} system(s), "
|
||||
f"{len(cascade_errors)} error(s)"
|
||||
)
|
||||
|
||||
after = await get_flags()
|
||||
|
||||
# The audit entry is a record OF the write, never a precondition for it.
|
||||
# audit_log lives in the same Firestore that just accepted the flag write,
|
||||
# so a failure here is nearly always transient — losing the flip (or 500ing
|
||||
# a route that already succeeded, which invites a retry that flips it back)
|
||||
# would be a far worse outcome than an unrecorded flip that is still in the
|
||||
# service log above.
|
||||
try:
|
||||
# Deferred import: app.internal.audit pulls in firestore, and this
|
||||
# module is imported from router module scope.
|
||||
from app.internal import audit
|
||||
actor_uid, actor_email = actor or ("unknown", "")
|
||||
changed = {
|
||||
k: {"from": before.get(k), "to": after.get(k)}
|
||||
for k in clean
|
||||
if before.get(k) != after.get(k)
|
||||
}
|
||||
await audit.write_audit(
|
||||
actor_uid=actor_uid,
|
||||
actor_email=actor_email,
|
||||
action="feature_flags.update",
|
||||
details={
|
||||
"requested": clean,
|
||||
"changed": changed,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"cascade": cascade,
|
||||
"cascaded_systems": cascaded,
|
||||
"cascade_errors": cascade_errors,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Feature flags: audit write failed ({e}) — flag change stands")
|
||||
|
||||
return after
|
||||
|
||||
|
||||
async def resolve_flags(system_id: str | None):
|
||||
"""
|
||||
Resolve the AI feature flags for one radio system.
|
||||
|
||||
Returns ``(flags, flag)``: ``flags`` is the raw global config/ai_features
|
||||
document, and ``flag(name)`` layers the system's own ``ai_flags`` on top of
|
||||
it. A system flag of False beats a global True, but a global False beats
|
||||
everything -- config/ai_features is the master switch, which is the whole
|
||||
point of having one (server-26#75, server-26#76).
|
||||
|
||||
Every AI spend path resolves through here. A path that reads ``flags``
|
||||
directly re-introduces #75; a path that reads neither re-introduces #76.
|
||||
"""
|
||||
from app.internal import firestore as _fstore
|
||||
|
||||
flags = await get_flags()
|
||||
|
||||
system_ai_flags: dict = {}
|
||||
if system_id:
|
||||
sys_doc = await _fstore.doc_get_cached("systems", system_id)
|
||||
system_ai_flags = (sys_doc or {}).get("ai_flags") or {}
|
||||
|
||||
def flag(name: str) -> bool:
|
||||
if not flags[name]: # global master off
|
||||
return False
|
||||
return system_ai_flags.get(name, True) # system override, else inherit
|
||||
|
||||
return flags, flag
|
||||
|
||||
@@ -21,9 +21,16 @@ Matching priority (in order):
|
||||
or location proximity) to link. Without this, every ungeocoded call on a
|
||||
dispatch backbone would link to the one active incident on that channel.
|
||||
|
||||
Thin calls (no units/vehicles/coords) skip scene verification and link to the
|
||||
most recently updated incident on this TGID — but only if that incident is
|
||||
within the recency window.
|
||||
Thin calls — genuinely content-free housekeeping, see `_is_thin_call` — skip
|
||||
scene verification and link to the most recently updated incident on this
|
||||
TGID, but only inside a tight conversational window (30s any-candidate, then
|
||||
single-candidate up to the channel's thin window). They are the one class of
|
||||
call that links without a fit test, so that window is the whole guard.
|
||||
|
||||
0. Hard caps (`_incident_at_capacity`) — an incident past
|
||||
`incident_max_duration_minutes` or `incident_max_calls` stops being a
|
||||
candidate on every path below, including the LLM tier. Pairwise fit tests
|
||||
cannot see the shape of a chain; only a cap can.
|
||||
|
||||
2. Location path — geocoded coords within `location_proximity_km` (time-limited)
|
||||
Primary mutual-aid signal: EMS + police at the same scene.
|
||||
@@ -234,6 +241,221 @@ def _tag_to_title(tag: str) -> str:
|
||||
return " ".join(w.capitalize() for w in tag.replace("-", " ").split())
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Location label + map pin — one value, written together (server-26#23)
|
||||
#
|
||||
# `location` (the label under the incident title) and `location_coords` (the pin
|
||||
# on the map) used to be two independent last-write-wins fields. Each call that
|
||||
# linked could move one without the other, so they drifted apart: in the
|
||||
# 2026-08-20 production dump 5 of 6 incidents were pinned somewhere other than
|
||||
# the place they were labelled — `b9b4f392` said "100 South Mosher" and pinned
|
||||
# `Westmed`. A missing pin reads as missing data; a wrong pin reads as fact,
|
||||
# and this is a map people may act on. So the pair is resolved as ONE value,
|
||||
# the pin carries the label it was geocoded from (`location_coords_source`), and
|
||||
# a pin that cannot be tied back to the current label is dropped rather than
|
||||
# shown.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# A place name contains a pronounceable word. Bare numbers ("49", from
|
||||
# "Flames from 49"), ten-codes ("10-24") and unit designators ("5-5-2") are box
|
||||
# or unit references that the extractor picked up because they followed a
|
||||
# preposition. They are not places, they never geocode, and once one reaches
|
||||
# `location` the summarizer repeats it as fact — incident `9d376ffe` carried
|
||||
# `location: "49"` and a summary reading "A fire incident was reported at
|
||||
# location 49". Two or more consecutive letters is the whole test: it keeps
|
||||
# "Rt 9" and "Westmed", and rejects everything that is only digits and dashes.
|
||||
_LOCATION_WORD_RE = re.compile(r"[^\W\d_]{2,}")
|
||||
|
||||
|
||||
def clean_location(value) -> Optional[str]:
|
||||
"""
|
||||
Return a usable location label, or None when the string is not a place.
|
||||
|
||||
Public because `intelligence.py` applies it at extraction time, so junk
|
||||
never reaches the call document, the geocoder, or the summarizer prompt.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s or not _LOCATION_WORD_RE.search(s):
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def location_is_unit(location, units) -> bool:
|
||||
"""
|
||||
True when a location label is really one of the incident's own unit
|
||||
call-signs.
|
||||
|
||||
Extraction returns `location` and `units` from the same pass, so a string
|
||||
appearing in both is a misclassification, not two facts. "Post 1-2" reached
|
||||
the geocoder that way, resolved against its talkgroup anchor, and produced a
|
||||
confident pin in the right town for an event that has no known location at
|
||||
all — worse than no pin, because nothing downstream can tell it is wrong.
|
||||
See server-26#52.
|
||||
|
||||
Public because `intelligence.py` applies it at extraction time, alongside
|
||||
`clean_location`, so the string never reaches the geocoder.
|
||||
"""
|
||||
key = _place_key(location)
|
||||
if not key:
|
||||
return False
|
||||
return any(key == _place_key(u) for u in (units or []))
|
||||
|
||||
|
||||
def _place_key(value) -> str:
|
||||
"""Case- and punctuation-blind key for comparing two location labels."""
|
||||
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
|
||||
|
||||
|
||||
def _same_place(a, b) -> bool:
|
||||
key = _place_key(a)
|
||||
return bool(key) and key == _place_key(b)
|
||||
|
||||
|
||||
def _verified_pin(inc: dict) -> Optional[dict]:
|
||||
"""
|
||||
The incident's map pin, but only when it provably belongs to the incident's
|
||||
current label.
|
||||
|
||||
Incidents written before this change carry no `location_coords_source`, so
|
||||
their pin cannot be tied to their label at all — and those are exactly the
|
||||
ones the dump showed to be wrong 5 times in 6. Unverifiable means dropped:
|
||||
the incident keeps its label and loses the pin until a call geocodes that
|
||||
same label again.
|
||||
"""
|
||||
coords = inc.get("location_coords")
|
||||
label = clean_location(inc.get("location"))
|
||||
if not coords or not label:
|
||||
return None
|
||||
if _same_place(inc.get("location_coords_source"), label):
|
||||
return coords
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_location_pair(
|
||||
inc: dict,
|
||||
location: Optional[str],
|
||||
location_coords: Optional[dict],
|
||||
) -> dict:
|
||||
"""
|
||||
Resolve (label, pin) for an incident as a single value, given one linking
|
||||
call's location. Always returns all three stored fields, so no code path
|
||||
can update one and leave another behind.
|
||||
|
||||
Rules:
|
||||
• An incident with no place yet takes the first one a call gives it.
|
||||
• An incident that already has a place KEEPS it. A later call naming a
|
||||
different street is that call's address, not a correction of this
|
||||
incident's — that is the same defect as the title (server-26#26), and
|
||||
letting it win is how "100 South Mosher" replaced the Grasslands Road
|
||||
search the incident actually opened on. Every mention is still kept in
|
||||
`location_mentions`, which is what the map path is drawn from.
|
||||
• The one permitted change is filling in a pin the incident never had,
|
||||
from a later call naming the SAME place — an address that failed to
|
||||
geocode once often succeeds on a cleaner transcription of it.
|
||||
• A pin is only ever kept alongside the label it was geocoded from.
|
||||
"""
|
||||
inc_label = clean_location(inc.get("location"))
|
||||
inc_pin = _verified_pin(inc)
|
||||
new_label = clean_location(location)
|
||||
# Coordinates come from geocoding the call's own location string, so a
|
||||
# rejected label takes its coordinates with it.
|
||||
new_pin = location_coords if new_label else None
|
||||
|
||||
if not inc_label:
|
||||
label, pin = new_label, new_pin
|
||||
elif inc_pin is None and new_pin and _same_place(new_label, inc_label):
|
||||
label, pin = inc_label, new_pin
|
||||
else:
|
||||
label, pin = inc_label, inc_pin
|
||||
|
||||
return {
|
||||
"location": label,
|
||||
"location_coords": pin,
|
||||
"location_coords_source": label if pin else None,
|
||||
}
|
||||
|
||||
|
||||
def _compose_title(primary_tag: str, location: Optional[str], tg_label: Optional[str]) -> str:
|
||||
"""Render an incident title from its event name and where it is."""
|
||||
if location and primary_tag.lower() != location.lower():
|
||||
return f"{primary_tag} at {location}"
|
||||
if tg_label:
|
||||
return f"{primary_tag} — {tg_label}"
|
||||
return primary_tag
|
||||
|
||||
|
||||
def _resolve_incident_title(
|
||||
inc: dict,
|
||||
tags: list[str],
|
||||
incident_type: Optional[str],
|
||||
location: Optional[str],
|
||||
talkgroup_name: Optional[str],
|
||||
talkgroup_id: Optional[int],
|
||||
call_severity: Optional[str],
|
||||
) -> dict:
|
||||
"""
|
||||
Decide whether a linking call may rename the incident (server-26#26).
|
||||
|
||||
The title used to be re-derived from the newest classified call, so an
|
||||
incident was named after its most recent transmission: `b9b4f392` opened on
|
||||
a suspect search and was titled "Open 911 at 100 South Mosher", its third
|
||||
call; `f5190670` was titled from the thirteenth of its thirteen events.
|
||||
|
||||
The title now names the FOUNDING event and can only be replaced by a call
|
||||
of strictly higher severity. Rationale in the commit message; in short, an
|
||||
incident's identity is the event that opened it, and the one situation
|
||||
where the header must change is the one where things got worse. This
|
||||
mirrors `_max_severity`: monotonic, never walked back by later chatter.
|
||||
|
||||
Two non-renames are still allowed, because neither replaces an event name:
|
||||
• filling in a placeholder title on an incident that opened on a call
|
||||
with no content tags ("Police — TGID 383"), and
|
||||
• re-rendering the same event once the incident learns its address.
|
||||
"""
|
||||
if not incident_type:
|
||||
# Routine status traffic ("10-4", "en route") never touches the title.
|
||||
return {}
|
||||
|
||||
content_tags = [t for t in tags if t != "auto-generated"]
|
||||
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None
|
||||
|
||||
current_title = inc.get("title") or ""
|
||||
# Incidents created before this change have no `title_tag` key at all, so
|
||||
# their founding event name is unrecoverable — treat their existing title
|
||||
# as the founding one rather than letting the next call claim it.
|
||||
if "title_tag" in inc:
|
||||
current_tag = inc.get("title_tag")
|
||||
else:
|
||||
current_tag = current_title or None
|
||||
|
||||
current_rank = _SEVERITY_RANK.get(inc.get("title_severity") or "routine", 0)
|
||||
new_rank = _SEVERITY_RANK.get(call_severity or "routine", 0)
|
||||
|
||||
tg_label = (
|
||||
talkgroup_name
|
||||
or (f"TGID {talkgroup_id}" if talkgroup_id else current_title.split(" — ")[-1])
|
||||
or None
|
||||
)
|
||||
|
||||
if primary_tag and (not current_tag or new_rank > current_rank):
|
||||
return {
|
||||
"title": _compose_title(primary_tag, location, tg_label),
|
||||
"title_tag": primary_tag,
|
||||
"title_severity": call_severity if call_severity in _SEVERITY_RANK else "routine",
|
||||
}
|
||||
|
||||
# Same event as before — but the incident may only now have learned where
|
||||
# it is, and the title should say so.
|
||||
stored_tag = inc.get("title_tag")
|
||||
if stored_tag:
|
||||
retitled = _compose_title(stored_tag, location, tg_label)
|
||||
if retitled != current_title:
|
||||
return {"title": retitled}
|
||||
return {}
|
||||
|
||||
|
||||
def _is_dispatch_channel(talkgroup_name: Optional[str]) -> bool:
|
||||
"""True when the talkgroup is a shared dispatch backbone (not a tactical/working channel)."""
|
||||
if not talkgroup_name:
|
||||
@@ -253,6 +475,179 @@ def _incident_idle_minutes(inc: dict, now: datetime) -> float:
|
||||
return 9999.0
|
||||
|
||||
|
||||
def _idle_gate_minutes(inc: dict, now: datetime) -> float:
|
||||
"""
|
||||
Absolute distance in minutes between `now` and the incident's last activity —
|
||||
the value every recency gate must compare against.
|
||||
|
||||
`_incident_idle_minutes` is signed and can go NEGATIVE: the re-correlation
|
||||
sweep anchors `now` to the call's own `started_at`, so a back-dated call is
|
||||
routinely evaluated against an incident that was updated later. Observed
|
||||
2026-08-20 on incident `9d376ffe`: `corr_incident_idle_min: -4.1`. Every
|
||||
`idle <= window` gate in this module reads True for a negative number, so
|
||||
those gates silently stopped bounding anything for exactly the calls the
|
||||
sweep re-examines. Distance in either direction is what the gates actually
|
||||
mean, so they compare against the magnitude and the signed value is kept
|
||||
only for the debug field.
|
||||
"""
|
||||
return abs(_incident_idle_minutes(inc, now))
|
||||
|
||||
|
||||
def _floor_at_started_at(inc: dict, when: datetime) -> datetime:
|
||||
"""
|
||||
Clamp a candidate `updated_at` timestamp so it can never land before the
|
||||
incident's own `started_at`.
|
||||
|
||||
The re-correlation sweep anchors `now` to the linking call's own
|
||||
`started_at` (server-26#24 / recorrelation_sweep.py) so that its window
|
||||
math is correct regardless of when the sweep happens to run. But that
|
||||
same back-dated `now` was also being written straight into `updated_at`
|
||||
here — so an orphan whose real-world `started_at` predates the incident's
|
||||
own `started_at` could set `updated_at` earlier than `started_at`,
|
||||
producing the negative `corr_incident_idle_min` observed on 2026-08-19
|
||||
(commit 33a247d fixed the *gates* misreading that negative value, not
|
||||
this write). `started_at` is never rewritten after creation, so it's a
|
||||
safe floor: activity can never honestly be older than the incident itself.
|
||||
"""
|
||||
try:
|
||||
raw = inc.get("started_at") or ""
|
||||
started = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||
if started.tzinfo is None:
|
||||
started = started.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return when
|
||||
return max(when, started)
|
||||
|
||||
|
||||
def _incident_span_minutes(inc: dict, now: datetime) -> float:
|
||||
"""
|
||||
Wall-clock minutes the incident has been open: from its `started_at` to the
|
||||
later of `now` and its last update. The `max` matters because the
|
||||
re-correlation sweep passes a back-dated `now` (the call's own started_at),
|
||||
and a raw `now - started_at` would understate — or invert — the span for
|
||||
exactly the calls most likely to be force-attached to an old chain.
|
||||
|
||||
Returns 0.0 when `started_at` is unparseable, so a malformed timestamp can
|
||||
never be the sole reason an incident is capped.
|
||||
"""
|
||||
try:
|
||||
raw = inc.get("started_at") or ""
|
||||
started = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||
if started.tzinfo is None:
|
||||
started = started.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return 0.0
|
||||
end = now
|
||||
try:
|
||||
raw_u = inc.get("updated_at") or ""
|
||||
updated = datetime.fromisoformat(str(raw_u).replace("Z", "+00:00"))
|
||||
if updated.tzinfo is None:
|
||||
updated = updated.replace(tzinfo=timezone.utc)
|
||||
# The sweep evaluates old calls against incidents that have since moved
|
||||
# on, so `now` alone understates the span. Take whichever is later.
|
||||
end = max(now, updated)
|
||||
except Exception:
|
||||
pass
|
||||
return max((end - started).total_seconds() / 60, 0.0)
|
||||
|
||||
|
||||
def _incident_at_capacity(inc: dict, now: datetime) -> Optional[str]:
|
||||
"""
|
||||
Return a reason string when this incident has grown past the hard caps and
|
||||
must not absorb any more calls, or None when it is still open for business.
|
||||
|
||||
These caps are deliberately path-independent. Every fit test in this module
|
||||
is a *pairwise* judgement — does THIS call belong with THAT incident — and
|
||||
each one can be individually defensible while the chain they build is not.
|
||||
`f5190670` (2026-08-20 dump) is the proof: 68 calls, 4h09m, 44 units, 12
|
||||
tags and at least 13 genuinely distinct events, every link arrived at one
|
||||
call at a time. No pairwise rule catches that, because the mistake is the
|
||||
accumulated shape, not any single link. A cap is the only guard that can
|
||||
see the shape, so it is applied once, to the candidate pool itself, before
|
||||
any path gets to choose — which is also why it binds the LLM tier, since
|
||||
that reads the same ctx["recent"] / ctx["all_active"] lists.
|
||||
|
||||
Capping does not delete or truncate anything: the incident keeps the calls
|
||||
it has and still auto-resolves on the normal idle sweep. It just stops
|
||||
being a candidate, so the next call opens a fresh incident.
|
||||
"""
|
||||
call_count = len(inc.get("call_ids") or [])
|
||||
if call_count >= settings.incident_max_calls:
|
||||
return f"call_cap:{call_count}"
|
||||
span = _incident_span_minutes(inc, now)
|
||||
if span > settings.incident_max_duration_minutes:
|
||||
return f"duration_cap:{span:.0f}min"
|
||||
return None
|
||||
|
||||
|
||||
def _drop_capped(incidents: list[dict], now: datetime) -> list[dict]:
|
||||
"""Remove over-cap incidents from a candidate pool (see _incident_at_capacity)."""
|
||||
kept: list[dict] = []
|
||||
for inc in incidents:
|
||||
reason = _incident_at_capacity(inc, now)
|
||||
if reason:
|
||||
logger.info(
|
||||
f"Correlator: incident {inc.get('incident_id', '?')} is at capacity "
|
||||
f"({reason}) — excluded as a correlation candidate"
|
||||
)
|
||||
continue
|
||||
kept.append(inc)
|
||||
return kept
|
||||
|
||||
|
||||
def _is_thin_call(
|
||||
call_units: list,
|
||||
call_vehicles: list,
|
||||
coords: Optional[dict],
|
||||
tags: Optional[list],
|
||||
location: Optional[str],
|
||||
call_severity: Optional[str],
|
||||
reassignment: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
True only for genuinely content-free radio housekeeping — "10-4", "Copy",
|
||||
"En route" — the traffic that legitimately has no evidence of its own and
|
||||
can only be placed by conversational context.
|
||||
|
||||
This test used to be `not units and not vehicles and not coords`, which is
|
||||
where the 2026-08-20 junk chains came from. Thin calls are the ONE class
|
||||
that attaches without a `_call_fits_incident` check, so anything wrongly
|
||||
called thin is force-merged into whatever was most recent. Six substantive
|
||||
dispatches in that dump qualified as thin purely because no unit ID parsed
|
||||
and the geocode failed — including *"All units head over to the powerhouse,
|
||||
55 Hyman Hills Road … she's 87 years old"*, a brand-new job that attached to
|
||||
a four-hour chain and then overwrote its location and title.
|
||||
|
||||
So the test now also rejects a call as thin when it carries:
|
||||
• tags — the extractor classified the event, so there IS content
|
||||
• a location — the call names a place; that is a claim to be checked,
|
||||
not context-free chatter
|
||||
• real severity (minor/moderate/major) — the extractor judged it an event
|
||||
• reassignment — dispatch pulling a unit to a NEW job, by definition not
|
||||
a continuation. `upload.py` blanks `units` on these to
|
||||
stop unit-overlap chaining, which used to make the call
|
||||
thin and route it to the one path with no fit check —
|
||||
the guard produced the merge it existed to prevent.
|
||||
|
||||
Such calls now go through the normal fit-tested path. On a dispatch channel
|
||||
with no positive signal that means they open their own incident or orphan,
|
||||
which is the correct direction: a wrongly-separate incident is visibly
|
||||
wrong and can be merged later, a wrongly-merged one silently corrupts
|
||||
every unit, tag, severity and map pin on the incident it joined.
|
||||
"""
|
||||
if call_units or call_vehicles or coords:
|
||||
return False
|
||||
if tags:
|
||||
return False
|
||||
if location and str(location).strip():
|
||||
return False
|
||||
if call_severity in ("minor", "moderate", "major"):
|
||||
return False
|
||||
if reassignment:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -273,10 +668,17 @@ async def correlate_call(
|
||||
vehicles: Optional[list[str]] = None,
|
||||
cleared_units: Optional[list[str]] = None,
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Link call_id to an existing incident or create a new one.
|
||||
Thin wrapper: builds context → runs rules decision → commits.
|
||||
|
||||
``embedding`` and ``severity`` are the SCENE's own values (server-26#80/#95).
|
||||
Callers that re-correlate a whole call rather than a scene — the
|
||||
recorrelation sweep — pass the call doc's stored values explicitly; they are
|
||||
no longer read from the doc inside _build_context.
|
||||
"""
|
||||
ctx = await _build_context(
|
||||
call_id=call_id, units=units, vehicles=vehicles, cleared_units=cleared_units,
|
||||
@@ -284,6 +686,7 @@ async def correlate_call(
|
||||
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
reassignment=reassignment, create_if_new=create_if_new,
|
||||
embedding=embedding, severity=severity,
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
return await _apply_and_log(decision, ctx)
|
||||
@@ -305,6 +708,8 @@ async def preview_correlation(
|
||||
vehicles: Optional[list[str]] = None,
|
||||
cleared_units: Optional[list[str]] = None,
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Run the rules engine and return the decision WITHOUT committing to Firestore.
|
||||
@@ -325,6 +730,7 @@ async def preview_correlation(
|
||||
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
reassignment=reassignment, create_if_new=create_if_new,
|
||||
embedding=embedding, severity=severity,
|
||||
)
|
||||
decision = _run_decision(ctx)
|
||||
return {"decision": decision, "ctx": ctx}
|
||||
@@ -357,6 +763,8 @@ async def _build_context(
|
||||
location: Optional[str],
|
||||
reassignment: bool,
|
||||
create_if_new: bool,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
) -> dict:
|
||||
now = reference_time or datetime.now(timezone.utc)
|
||||
window = timedelta(hours=settings.correlation_window_hours)
|
||||
@@ -376,15 +784,44 @@ async def _build_context(
|
||||
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
|
||||
else:
|
||||
all_active = await fstore.collection_list("incidents", status="active")
|
||||
# Incidents past the hard caps are removed from the candidate pool here, so
|
||||
# neither the rules engine nor the LLM tier (which reads ctx["recent"] /
|
||||
# ctx["all_active"]) can propose linking into one.
|
||||
all_active = _drop_capped(all_active, now)
|
||||
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
|
||||
|
||||
call_embedding = call_doc.get("embedding")
|
||||
# embedding and severity come from the SCENE being correlated, not the call
|
||||
# doc — server-26#80 / #95. intelligence.py writes only the primary scene's
|
||||
# embedding and severity to calls/{id}, so reading them back here handed
|
||||
# every non-primary scene the primary scene's semantic vector and severity
|
||||
# rung: a scene about a different event scored against the wrong incident on
|
||||
# the embedding path (:1166/:1205/:1533) and could inherit a minor/moderate/
|
||||
# major severity it never had, clearing the creation gate on borrowed
|
||||
# weight. Same failure and same fix as the #87 coords leak directly below —
|
||||
# a scene that passes none has none, and is judged thin on its own signal.
|
||||
call_embedding = embedding
|
||||
call_units = units if units is not None else (call_doc.get("units") or [])
|
||||
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
||||
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||
call_severity = call_doc.get("severity") or "routine"
|
||||
coords = location_coords or call_doc.get("location_coords")
|
||||
is_thin_call = not call_units and not call_vehicles and not coords
|
||||
call_severity = severity or "routine"
|
||||
# A string that is not a place is not a location anywhere downstream — not
|
||||
# in the fit tests, not in the thin-call test, not in the LLM prompt, and
|
||||
# not on the incident. Its coordinates go with it: coords are geocoded
|
||||
# from this very string, so a rejected label invalidates them.
|
||||
location = clean_location(location)
|
||||
if location is None:
|
||||
location_coords = None
|
||||
# NOT `location_coords or call_doc.get("location_coords")` — server-26#87.
|
||||
# A radio call can be split into several scenes, and only the primary
|
||||
# scene's geocode is written to the call doc. Falling back to it here
|
||||
# would hand every non-primary scene the primary scene's pin, fabricating
|
||||
# location_proximity (the strongest accept signal) for a scene that has
|
||||
# no location of its own and driving over-merges. If a scene passes no
|
||||
# coords, it has none — it is judged thin and must win on its own signal.
|
||||
coords = location_coords
|
||||
is_thin_call = _is_thin_call(
|
||||
call_units, call_vehicles, coords, tags, location, call_severity, reassignment
|
||||
)
|
||||
|
||||
return {
|
||||
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
|
||||
@@ -415,8 +852,23 @@ def _run_decision(ctx: dict) -> dict:
|
||||
incident_type resolved type (action == "new")
|
||||
corr_debug fields to write to the call doc
|
||||
"""
|
||||
all_active = ctx["all_active"]
|
||||
recent = ctx["recent"]
|
||||
now = ctx["now"]
|
||||
# Hard caps, enforced regardless of which path would have matched. Normally
|
||||
# a no-op because _build_context already filtered these out; re-applied here
|
||||
# so the guarantee holds for any caller that assembles a ctx directly
|
||||
# (tests, the LLM tiebreak path) rather than through _build_context.
|
||||
_capped_ids = {
|
||||
inc.get("incident_id")
|
||||
for inc in (list(ctx["all_active"]) + list(ctx["recent"]))
|
||||
if _incident_at_capacity(inc, now)
|
||||
}
|
||||
if _capped_ids:
|
||||
logger.info(
|
||||
f"Correlator: {len(_capped_ids)} incident(s) at capacity, excluded as "
|
||||
f"candidates for call {ctx['call_id']}: {sorted(_capped_ids)}"
|
||||
)
|
||||
all_active = [inc for inc in ctx["all_active"] if inc.get("incident_id") not in _capped_ids]
|
||||
recent = [inc for inc in ctx["recent"] if inc.get("incident_id") not in _capped_ids]
|
||||
call_doc = ctx["call_doc"]
|
||||
call_embedding = ctx["call_embedding"]
|
||||
call_units = ctx["call_units"]
|
||||
@@ -424,7 +876,6 @@ def _run_decision(ctx: dict) -> dict:
|
||||
call_severity = ctx["call_severity"]
|
||||
coords = ctx["coords"]
|
||||
is_thin_call = ctx["is_thin_call"]
|
||||
now = ctx["now"]
|
||||
system_id = ctx["system_id"]
|
||||
talkgroup_id = ctx["talkgroup_id"]
|
||||
talkgroup_name = ctx["talkgroup_name"]
|
||||
@@ -469,7 +920,7 @@ def _run_decision(ctx: dict) -> dict:
|
||||
# Apply recency gate — only incidents active within the rolling window.
|
||||
tg_recent = [
|
||||
inc for inc in tg_matches
|
||||
if _incident_idle_minutes(inc, now) <= settings.tg_fast_path_idle_minutes
|
||||
if _idle_gate_minutes(inc, now) <= settings.tg_fast_path_idle_minutes
|
||||
]
|
||||
|
||||
if tg_matches and not tg_recent:
|
||||
@@ -479,21 +930,36 @@ def _run_decision(ctx: dict) -> dict:
|
||||
)
|
||||
|
||||
if tg_recent and is_thin_call:
|
||||
# Content-free status calls ("10-4", "Copy", "En route") — two tiers:
|
||||
# Content-free status calls ("10-4", "Copy", "En route") — the only
|
||||
# class of call that links without a `_call_fits_incident` check,
|
||||
# because by construction it has no unit, vehicle, coordinate, tag,
|
||||
# location or severity to test. There is therefore nothing to fit;
|
||||
# the only honest evidence is "someone just said something on this
|
||||
# channel and this is the reply". That is a claim about SECONDS,
|
||||
# so the window is the whole guard and it has to be tight.
|
||||
#
|
||||
# Tier 1 — ≤30 seconds idle: this is a direct conversational reply to
|
||||
# whatever was just transmitted. Attach to the most recently updated
|
||||
# incident regardless of how many are active; within 30 seconds, the
|
||||
# "most recently updated" IS the active thread.
|
||||
# Tier 1 — ≤30 seconds idle: a direct conversational reply. Attach
|
||||
# to the most recently updated incident regardless of how many are
|
||||
# active; within 30 seconds, "most recently updated" IS the live
|
||||
# thread on the channel.
|
||||
#
|
||||
# Tier 2 — 30 seconds to tg_dispatch_thin_idle_minutes: channel context
|
||||
# is less clear. Only attach when there is exactly ONE candidate to
|
||||
# avoid guessing on a busy multi-incident channel.
|
||||
if is_dispatch:
|
||||
# Tier 2 — 30 seconds up to the channel's thin window: context is
|
||||
# less clear. Only attach when there is exactly ONE candidate, so
|
||||
# we never guess on a busy multi-incident channel.
|
||||
#
|
||||
# This bounding used to apply to dispatch channels only; every other
|
||||
# talkgroup fell through to `thin_pool = tg_recent`, i.e. any
|
||||
# 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.
|
||||
THIN_CONVERSATIONAL_SECS = 30
|
||||
thin_window_min = (
|
||||
settings.tg_dispatch_thin_idle_minutes if is_dispatch
|
||||
else settings.tg_thin_idle_minutes
|
||||
)
|
||||
very_recent = [
|
||||
inc for inc in tg_recent
|
||||
if _incident_idle_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
||||
if _idle_gate_minutes(inc, now) * 60 <= THIN_CONVERSATIONAL_SECS
|
||||
]
|
||||
if very_recent:
|
||||
# Tier 1: direct conversational reply — most recent wins.
|
||||
@@ -503,19 +969,19 @@ def _run_decision(ctx: dict) -> dict:
|
||||
f"using most-recent of {len(very_recent)} candidate(s) for call {call_id}"
|
||||
)
|
||||
else:
|
||||
# Tier 2: less certain — require single candidate.
|
||||
# Tier 2: less certain — require a single candidate inside the
|
||||
# channel's thin window.
|
||||
thin_pool = [
|
||||
inc for inc in tg_recent
|
||||
if _incident_idle_minutes(inc, now) <= settings.tg_dispatch_thin_idle_minutes
|
||||
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"on dispatch channel — ambiguous, skipping thin call {call_id}"
|
||||
f"on {'dispatch' if is_dispatch else 'tactical'} channel — "
|
||||
f"ambiguous, skipping thin call {call_id}"
|
||||
)
|
||||
thin_pool = []
|
||||
else:
|
||||
thin_pool = tg_recent
|
||||
|
||||
if not thin_pool:
|
||||
logger.info(
|
||||
@@ -527,6 +993,13 @@ def _run_decision(ctx: dict) -> dict:
|
||||
corr_debug = {
|
||||
"corr_path": "fast/thin",
|
||||
"corr_incident_idle_min": round(_incident_idle_minutes(matched_incident, now), 1),
|
||||
# This path is 63% of all links and was the only one writing
|
||||
# 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",
|
||||
"corr_candidates": len(thin_pool),
|
||||
}
|
||||
logger.info(
|
||||
f"Correlator fast-path (thin→last TGID incident): "
|
||||
@@ -627,7 +1100,7 @@ def _run_decision(ctx: dict) -> dict:
|
||||
# has almost certainly moved on or the incident closed.
|
||||
unit_candidates = [
|
||||
inc for inc in unit_candidates
|
||||
if _incident_idle_minutes(inc, now) <= settings.unit_continuity_max_idle_minutes
|
||||
if _idle_gate_minutes(inc, now) <= settings.unit_continuity_max_idle_minutes
|
||||
]
|
||||
if unit_candidates:
|
||||
best_unit_inc = max(unit_candidates, key=lambda i: i.get("updated_at", ""))
|
||||
@@ -660,6 +1133,11 @@ def _run_decision(ctx: dict) -> dict:
|
||||
corr_debug = {
|
||||
"corr_path": "unit-continuity",
|
||||
"corr_incident_idle_min": round(_incident_idle_minutes(best_unit_inc, now), 1),
|
||||
# Unlike this file's other two paths (fast/single, fast/disambig),
|
||||
# a match here is unit-driven by construction (call_unit_set &
|
||||
# _unit_keys(...) is what built unit_candidates), so this is
|
||||
# always populated rather than gated on fit_signal (server-26#16).
|
||||
"corr_matched_units": _matching_units(call_units, best_unit_inc.get("units")),
|
||||
}
|
||||
logger.info(
|
||||
f"Correlator unit-continuity: call {call_id} → "
|
||||
@@ -948,13 +1426,23 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
||||
)
|
||||
else:
|
||||
# Candidate is a standalone — create master shell, demote both
|
||||
# Take the master's place from ONE side, label and pin together —
|
||||
# the old `parent.location or call.location` / `parent.coords or
|
||||
# call.coords` pair could take the label from the parent and the
|
||||
# pin from the call, which is server-26#23 in its purest form.
|
||||
if clean_location(cross_parent.get("location")):
|
||||
master_location = cross_parent.get("location")
|
||||
master_coords = _verified_pin(cross_parent)
|
||||
else:
|
||||
master_location = location
|
||||
master_coords = location_coords
|
||||
master_id = await _create_master_incident(
|
||||
first_child_id=existing_child_id,
|
||||
second_child_id=incident_id,
|
||||
org_id=org_id,
|
||||
operational_type=incident_type,
|
||||
location=cross_parent.get("location") or location,
|
||||
location_coords=cross_parent.get("location_coords") or coords,
|
||||
location=master_location,
|
||||
location_coords=master_coords,
|
||||
now=now,
|
||||
)
|
||||
await _demote_to_child(existing_child_id, master_id)
|
||||
@@ -1139,7 +1627,15 @@ def _call_fits_incident(
|
||||
Thin calls (no units/vehicles/coords) never reach this function —
|
||||
they are intercepted before it in correlate_call.
|
||||
"""
|
||||
idle_min = _incident_idle_minutes(inc, now) if now is not None else 9999.0
|
||||
# Gate comparisons in this function must use the unsigned distance, not the
|
||||
# signed value: the re-correlation sweep anchors `now` to the call's own
|
||||
# started_at, which can be earlier than the incident's last activity and
|
||||
# send the signed value negative — silently defeating every `idle_min`
|
||||
# gate below (content-divergence veto and the tactical default alike).
|
||||
# See `_idle_gate_minutes` docstring. The signed value is reported to
|
||||
# callers separately as `corr_incident_idle_min` (they compute it via
|
||||
# `_incident_idle_minutes` themselves) — nothing here needs it.
|
||||
idle_min = _idle_gate_minutes(inc, now) if now is not None else 9999.0
|
||||
inc_id = inc.get("incident_id", "?")
|
||||
|
||||
# ── 1. Unit overlap ───────────────────────────────────────────────────────
|
||||
@@ -1311,13 +1807,16 @@ async def _update_incident(
|
||||
if u not in units_cleared:
|
||||
units_cleared.append(u)
|
||||
|
||||
# The incident's label and its pin are resolved together, as one value.
|
||||
location = clean_location(location)
|
||||
location_coords = location_coords if location else None
|
||||
location_fields = _resolve_location_pair(inc, location, location_coords)
|
||||
best_location = location_fields["location"]
|
||||
|
||||
location_mentions = list(inc.get("location_mentions") or [])
|
||||
if location and location not in location_mentions:
|
||||
location_mentions.append(location)
|
||||
|
||||
best_location = location or inc.get("location")
|
||||
best_coords = location_coords or inc.get("location_coords")
|
||||
|
||||
embedding_updates = _merge_embedding_vecs(inc, call_embedding) if call_embedding else {}
|
||||
|
||||
updates: dict = {
|
||||
@@ -1332,6 +1831,9 @@ async def _update_incident(
|
||||
"location_mentions": location_mentions,
|
||||
"summary_stale": True,
|
||||
"severity": _max_severity(inc.get("severity"), call_severity),
|
||||
# Always all three, always together — writing one without the others is
|
||||
# what let the label and the pin drift apart (server-26#23).
|
||||
**location_fields,
|
||||
**embedding_updates,
|
||||
}
|
||||
|
||||
@@ -1342,34 +1844,20 @@ async def _update_incident(
|
||||
# acknowledging. The incident now ages from its last SUBSTANTIVE call, and
|
||||
# thin traffic rides along without extending its life.
|
||||
if refresh_activity:
|
||||
updates["updated_at"] = now.isoformat()
|
||||
updates["updated_at"] = _floor_at_started_at(inc, now).isoformat()
|
||||
else:
|
||||
updates["last_thin_at"] = now.isoformat()
|
||||
if best_location:
|
||||
updates["location"] = best_location
|
||||
if best_coords:
|
||||
updates["location_coords"] = best_coords
|
||||
|
||||
# Update incident type when a re-classified call provides a concrete type.
|
||||
# This handles the case where admin correction changes fire→police, etc.
|
||||
if incident_type and incident_type != inc.get("type"):
|
||||
updates["type"] = incident_type
|
||||
|
||||
# Re-evaluate title when a substantive call (classified incident_type) brings new tags.
|
||||
# Routine status calls (type=None) do not clobber the title.
|
||||
if incident_type:
|
||||
content_tags = [t for t in tags if t != "auto-generated"]
|
||||
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None
|
||||
tg_label = (
|
||||
talkgroup_name
|
||||
or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split(" — ")[-1])
|
||||
)
|
||||
if primary_tag and best_location and best_coords and primary_tag.lower() != best_location.lower():
|
||||
updates["title"] = f"{primary_tag} at {best_location}"
|
||||
elif primary_tag and tg_label:
|
||||
updates["title"] = f"{primary_tag} — {tg_label}"
|
||||
elif primary_tag:
|
||||
updates["title"] = primary_tag
|
||||
# The title names the founding event and only escalates — see
|
||||
# _resolve_incident_title (server-26#26).
|
||||
updates.update(_resolve_incident_title(
|
||||
inc, tags, incident_type, best_location,
|
||||
talkgroup_name, talkgroup_id, call_severity,
|
||||
))
|
||||
|
||||
# Signal-based auto-resolve: every tracked unit has cleared, none still active.
|
||||
# Requires at least one unit to have explicitly signalled back-in-service so we
|
||||
@@ -1411,13 +1899,17 @@ async def _create_incident(
|
||||
or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup")
|
||||
)
|
||||
|
||||
# Build a descriptive title from tags + location when available
|
||||
# Label and pin resolve as one value, from this founding call only.
|
||||
location_fields = _resolve_location_pair({}, location, location_coords)
|
||||
location = location_fields["location"]
|
||||
|
||||
# Build a descriptive title from tags + location when available. This is
|
||||
# the incident's name for the rest of its life unless something worse
|
||||
# happens on it — see _resolve_incident_title.
|
||||
content_tags = [t for t in tags if t != "auto-generated"]
|
||||
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None
|
||||
if primary_tag and location and location_coords and primary_tag.lower() != location.lower():
|
||||
title = f"{primary_tag} at {location}"
|
||||
elif primary_tag:
|
||||
title = f"{primary_tag} — {tg_label}"
|
||||
if primary_tag:
|
||||
title = _compose_title(primary_tag, location, tg_label)
|
||||
else:
|
||||
title = f"{_tag_to_title(incident_type)} — {tg_label}"
|
||||
|
||||
@@ -1425,11 +1917,15 @@ async def _create_incident(
|
||||
"incident_id": incident_id,
|
||||
"org_id": org_id,
|
||||
"title": title,
|
||||
# Which event the title names, and how bad it was judged to be. A
|
||||
# later call may only take the title over by being worse than this.
|
||||
# Written even when None: its absence marks a pre-server-26#26 doc.
|
||||
"title_tag": primary_tag,
|
||||
"title_severity": call_severity if call_severity in _SEVERITY_RANK else "routine",
|
||||
"incident_type": "master", # structural role; "child" set on demotion
|
||||
"type": incident_type,
|
||||
"status": "active",
|
||||
"location": location,
|
||||
"location_coords": location_coords,
|
||||
**location_fields,
|
||||
"location_mentions": [location] if location else [],
|
||||
"call_ids": [call_id],
|
||||
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
|
||||
@@ -1490,8 +1986,7 @@ async def _create_master_incident(
|
||||
"incident_type": "master",
|
||||
"type": operational_type,
|
||||
"status": "active",
|
||||
"location": location,
|
||||
"location_coords": location_coords,
|
||||
**_resolve_location_pair({}, location, location_coords),
|
||||
"child_incident_ids": [first_child_id, second_child_id],
|
||||
"parent_incident_id": None,
|
||||
"call_ids": [],
|
||||
|
||||
@@ -15,11 +15,25 @@ import re
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import area_context
|
||||
# Location validity is defined once, by the module that owns the incident's
|
||||
# location/pin invariant. incident_correlator does not import this module, so
|
||||
# this is not a cycle.
|
||||
from app.internal.incident_correlator import clean_location, location_is_unit
|
||||
|
||||
_PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio.
|
||||
|
||||
SCENE DETECTION:
|
||||
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Detect whether this recording contains ONE scene (all transmissions relate to a single event) or MULTIPLE scenes (clearly distinct dispatch conversations with different units being assigned, different locations, different event types). Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
|
||||
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Your default is ONE scene. Return MULTIPLE scenes ONLY when the recording clearly contains two or more SEPARATE EVENTS — different incidents at different places, with no shared units, no shared subject, and no conversational thread connecting them.
|
||||
|
||||
These do NOT make a new scene — keep them in the same scene:
|
||||
- a different unit or speaker joining the same event
|
||||
- a follow-up transmission about the same job (records check, case number, tow/mileage, a unit clearing, an ETA, a location correction)
|
||||
- the same subject or location being discussed again minutes later
|
||||
- an administrative or status exchange that follows an event on the same channel
|
||||
If you are unsure whether two exchanges are one event or two, treat them as ONE.
|
||||
|
||||
Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
|
||||
|
||||
Always respond with the scenes array, even for a single scene.
|
||||
|
||||
@@ -45,7 +59,6 @@ Response format — a JSON object with a "scenes" array. Each scene:
|
||||
severity: one of "routine" | "minor" | "moderate" | "major"
|
||||
resolved: true if this scene explicitly signals incident closure, false otherwise
|
||||
reassignment: true if a unit is breaking from their current scene to respond to a completely different call — whether dispatch-initiated ("Baker, can you clear and respond to...", "Adam, break from that and go to...") OR unit-initiated ("Show me headed to the vehicle complaint", "Can you show me to that call", a unit going 10-8 and self-requesting a new assignment). False if the unit is reporting in on their current scene, giving a status update, or requesting information about their existing call.
|
||||
transcript_corrected: corrected text for this scene's transmissions only, or null
|
||||
|
||||
Rules:
|
||||
- location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Dispatch-provided addresses take priority over unit-reported positions. Empty string if none.
|
||||
@@ -62,7 +75,6 @@ Rules:
|
||||
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
|
||||
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
|
||||
- reassignment: only true when a unit is explicitly being pulled to a completely new call or location. A unit going en route to their first dispatch is NOT a reassignment. Routine status updates, acknowledgements, and scene updates are NOT reassignments.
|
||||
- transcript_corrected: fix only clear STT/vocoder errors (e.g. "Several" → "10-4", misheard street names, garbled unit IDs). Keep all radio language as-is — do NOT decode codes into plain English. Return null if accurate.
|
||||
|
||||
System: {system_id}
|
||||
Talkgroup: {talkgroup_name}
|
||||
@@ -223,13 +235,44 @@ async def extract_scenes(
|
||||
node_lat = node_doc.get("lat")
|
||||
node_lon = node_doc.get("lon")
|
||||
|
||||
# The talkgroup's own anchor and place, when an operator has described it
|
||||
# (server-26#36). This is what "where is this channel" should mean; the node
|
||||
# position below is only the fallback for a system nobody has described.
|
||||
tg_anchor: Optional[dict] = None
|
||||
tg_area: dict = {}
|
||||
if system_id:
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if system_doc:
|
||||
system_area = system_doc.get("area_context") or {}
|
||||
tg_entry = area_context.talkgroup_entry(system_doc, talkgroup_id)
|
||||
own_area = tg_entry.get("area_context") or {}
|
||||
tg_area = area_context.effective(system_area, own_area)
|
||||
tg_anchor = area_context.anchor_for(system_area, own_area)
|
||||
|
||||
processed: list[dict] = []
|
||||
for scene in raw_scenes:
|
||||
tags: list[str] = scene.get("tags") or []
|
||||
incident_type: Optional[str] = scene.get("incident_type") or None
|
||||
location: Optional[str] = scene.get("location") or None
|
||||
# A location that is not a place ("49", from "Flames from 49") is
|
||||
# rejected here, at the source: it never reaches the geocoder, the call
|
||||
# document, the correlator or the summarizer prompt — which used to
|
||||
# repeat it back as "A fire incident was reported at location 49".
|
||||
# See incident_correlator.clean_location (server-26#23).
|
||||
location: Optional[str] = clean_location(scene.get("location"))
|
||||
vehicles: list[str] = scene.get("vehicles") or []
|
||||
units: list[str] = scene.get("units") or []
|
||||
# A "location" that is also one of this scene's own units is a unit
|
||||
# call-sign, not a place. Both lists come from the same extraction pass,
|
||||
# so the disagreement is free to detect and the string must be dropped
|
||||
# before it reaches the geocoder — anchored place verification will
|
||||
# otherwise resolve "Post 1-2" to a confident, plausible, wrong pin in
|
||||
# the right town. See server-26#52.
|
||||
if location and location_is_unit(location, units):
|
||||
logger.info(
|
||||
f"Intelligence: dropping location {location!r} — it is one of "
|
||||
f"this scene's units, not a place"
|
||||
)
|
||||
location = None
|
||||
cleared_units: list[str] = scene.get("cleared_units") or []
|
||||
# Every call carries a severity — it is the signal the correlator uses to
|
||||
# decide whether a call is incident-worthy at all, so it must never be
|
||||
@@ -266,20 +309,27 @@ async def extract_scenes(
|
||||
# Build the most specific query possible: location + municipality + state.
|
||||
# e.g. "High Street" → "High Street, Yorktown, New York"
|
||||
# This prevents generic street names from resolving to wrong-country results.
|
||||
#
|
||||
# Prefer the place an operator actually set over the one guessed from
|
||||
# the talkgroup's name and the node's reverse-geocoded position. A name
|
||||
# like "Ossining PD" gives a municipality with no state behind it, which
|
||||
# is how a generic street name ends up resolving in the wrong half of
|
||||
# the country.
|
||||
location_coords: Optional[dict] = None
|
||||
if location and node_lat is not None and node_lon is not 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 = [location]
|
||||
if muni:
|
||||
parts.append(muni)
|
||||
if county:
|
||||
parts.append(county)
|
||||
if state:
|
||||
parts.append(state)
|
||||
parts += [p for p in (muni, county, state) if p]
|
||||
query = ", ".join(parts)
|
||||
location_coords = await _geocode_location(query, node_lat, node_lon)
|
||||
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
|
||||
)
|
||||
|
||||
# Embed this scene's content
|
||||
scene_text = _build_scene_embed_text(
|
||||
@@ -314,8 +364,9 @@ async def extract_scenes(
|
||||
|
||||
updates: dict = {"tags": all_tags, "severity": primary["severity"]}
|
||||
if primary["location"]:
|
||||
# Both, together, always — a re-extraction that produces a new address
|
||||
# must not leave the previous address's pin on the call (server-26#23).
|
||||
updates["location"] = primary["location"]
|
||||
if primary["location_coords"]:
|
||||
updates["location_coords"] = primary["location_coords"]
|
||||
if all_units:
|
||||
updates["units"] = all_units
|
||||
@@ -403,12 +454,25 @@ async def _get_node_state(node_id: str, lat: float, lon: float) -> str:
|
||||
|
||||
|
||||
async def _geocode_location(
|
||||
location_str: str, node_lat: float, node_lon: float
|
||||
location_str: str,
|
||||
node_lat: Optional[float] = None,
|
||||
node_lon: Optional[float] = None,
|
||||
anchor: Optional[dict] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Geocode using Google Maps Geocoding API, biased toward the node's area.
|
||||
Returns {"lat": float, "lng": float} or None if geocoding fails or the
|
||||
result is farther than geocode_max_km from the node (wrong-jurisdiction guard).
|
||||
Geocode using Google Maps Geocoding API, biased toward the channel's area.
|
||||
|
||||
Returns {"lat": float, "lng": float}, or None if geocoding fails or the
|
||||
result lands outside the area this channel covers.
|
||||
|
||||
THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This
|
||||
used to reject anything more than geocode_max_km (40km) from the receiving
|
||||
node, which conflates an antenna with a jurisdiction: a system can span a
|
||||
county or several, so a node legitimately sits far from the area a talkgroup
|
||||
covers, and real dispatch locations were being thrown away for it. When the
|
||||
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.
|
||||
"""
|
||||
import httpx
|
||||
from app.config import settings
|
||||
@@ -417,9 +481,24 @@ async def _geocode_location(
|
||||
logger.warning("GOOGLE_MAPS_API_KEY not set — geocoding disabled")
|
||||
return None
|
||||
|
||||
if anchor:
|
||||
ref_lat, ref_lon = anchor["lat"], anchor["lng"]
|
||||
max_km = anchor["radius_km"]
|
||||
# Bias box scaled to the anchor rather than a fixed half-degree, so a
|
||||
# village biases tightly and a county loosely.
|
||||
delta = max(max_km / 111.0, 0.05)
|
||||
ref_label = "anchor"
|
||||
elif node_lat is not None and node_lon is not None:
|
||||
ref_lat, ref_lon = node_lat, node_lon
|
||||
max_km = settings.geocode_max_km
|
||||
delta = _GEO_DELTA
|
||||
ref_label = "node"
|
||||
else:
|
||||
return None
|
||||
|
||||
bounds = (
|
||||
f"{node_lat - _GEO_DELTA},{node_lon - _GEO_DELTA}"
|
||||
f"|{node_lat + _GEO_DELTA},{node_lon + _GEO_DELTA}"
|
||||
f"{ref_lat - delta},{ref_lon - delta}"
|
||||
f"|{ref_lat + delta},{ref_lon + delta}"
|
||||
)
|
||||
params = {
|
||||
"address": location_str,
|
||||
@@ -457,15 +536,18 @@ async def _geocode_location(
|
||||
return None
|
||||
loc = result["geometry"]["location"]
|
||||
lat, lng = float(loc["lat"]), float(loc["lng"])
|
||||
dist_km = _geo_dist_km(node_lat, node_lon, lat, lng)
|
||||
if dist_km > settings.geocode_max_km:
|
||||
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 node exceeds geocode_max_km={settings.geocode_max_km}"
|
||||
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
|
||||
)
|
||||
return None
|
||||
coords = {"lat": lat, "lng": lng}
|
||||
logger.info(f"Geocoded '{location_str}' → {coords} ({dist_km:.1f}km from node) [{location_type}]")
|
||||
logger.info(
|
||||
f"Geocoded '{location_str}' → {coords} "
|
||||
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
|
||||
)
|
||||
return coords
|
||||
except Exception as e:
|
||||
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
||||
|
||||
@@ -24,6 +24,7 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import ai_health
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@@ -239,18 +240,21 @@ async def decide(call_id: str, ctx: dict) -> Optional[dict]:
|
||||
f"action={decision['action']} incident={_id} "
|
||||
f"reasoning={decision['reasoning']!r}"
|
||||
)
|
||||
await ai_health.report_healthy("correlation_cheap")
|
||||
return decision
|
||||
except Exception as e:
|
||||
_log_llm_failure("LLM correlator", call_id, settings.corr_cheap_model, e)
|
||||
await _log_llm_failure("LLM correlator", "correlation_cheap", call_id, settings.corr_cheap_model, e)
|
||||
return None
|
||||
|
||||
|
||||
_dead_models: set[str] = set()
|
||||
|
||||
|
||||
def _log_llm_failure(where: str, call_id: str, model: str, exc: Exception) -> None:
|
||||
async def _log_llm_failure(where: str, tier: str, call_id: str, model: str, exc: Exception) -> None:
|
||||
"""
|
||||
Log an LLM failure, escalating a dead model ID to ERROR once per model.
|
||||
Log an LLM failure, escalating a dead model ID to ERROR once per model,
|
||||
and report it to the shared app.internal.ai_health registry either way
|
||||
(which is what drives /health/ai and the Discord degradation alert).
|
||||
|
||||
A per-call WARNING was the only signal that gemini-2.0-flash had been shut
|
||||
down, and since every failure falls back to the rules decision the pipeline
|
||||
@@ -260,27 +264,32 @@ def _log_llm_failure(where: str, call_id: str, model: str, exc: Exception) -> No
|
||||
that will never fix itself, so it gets ERROR and says what to do.
|
||||
"""
|
||||
text = str(exc)
|
||||
low = text.lower()
|
||||
kind = ai_health.classify(text)
|
||||
|
||||
if "404" in text or "not found" in low or "no longer available" in low:
|
||||
_log_tier_down(where, model, "model is unavailable",
|
||||
if kind == "dead_model":
|
||||
await _log_tier_down(where, tier, model, "model is unavailable",
|
||||
"Update CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py", text)
|
||||
return
|
||||
|
||||
# A depleted balance reads as 429, the same status as an ordinary rate limit,
|
||||
# but it is the opposite kind of problem: a rate limit clears on its own and a
|
||||
# dead account never does. Matching on the billing wording keeps a burst of
|
||||
# rate limits at WARNING while an empty account escalates like a bad model ID.
|
||||
if "credits are depleted" in low or "prepayment" in low or "billing" in low:
|
||||
_log_tier_down(where, model, "the Gemini account is out of credit",
|
||||
# dead account never does. ai_health.classify() keeps a burst of rate limits
|
||||
# at WARNING while an empty account escalates like a bad model ID.
|
||||
if kind == "billing":
|
||||
await _log_tier_down(where, tier, model, "the Gemini account is out of credit",
|
||||
"Top up billing at https://ai.studio/projects", text)
|
||||
return
|
||||
|
||||
logger.warning(f"{where} failed for call {call_id}: {text}")
|
||||
await ai_health.report_degraded(
|
||||
tier, "gemini", model, "transient API error",
|
||||
"no action needed unless this persists", permanent=False,
|
||||
)
|
||||
|
||||
|
||||
def _log_tier_down(where: str, model: str, problem: str, fix: str, text: str) -> None:
|
||||
async def _log_tier_down(where: str, tier: str, model: str, problem: str, fix: str, text: str) -> None:
|
||||
"""ERROR once per model, not once per call — this runs at radio-traffic volume."""
|
||||
await ai_health.report_degraded(tier, "gemini", model, problem, fix, permanent=True)
|
||||
if model in _dead_models:
|
||||
return
|
||||
_dead_models.add(model)
|
||||
@@ -306,9 +315,10 @@ async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
|
||||
f"action={decision['action']} incident={_id} "
|
||||
f"reasoning={decision['reasoning']!r}"
|
||||
)
|
||||
await ai_health.report_healthy("correlation_smart")
|
||||
return decision
|
||||
except Exception as e:
|
||||
_log_llm_failure("LLM tiebreak", call_id, settings.corr_smart_model, e)
|
||||
await _log_llm_failure("LLM tiebreak", "correlation_smart", call_id, settings.corr_smart_model, e)
|
||||
return rules_decision
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import paho.mqtt.client as mqtt
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import talkgroups
|
||||
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||
|
||||
|
||||
@@ -219,16 +220,12 @@ class MQTTHandler:
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# Prefer the name from OP25 metadata; fall back to the system config
|
||||
tgid_name = payload.get("tgid_name") or ""
|
||||
if not tgid_name and system_id and payload.get("tgid"):
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if system_doc:
|
||||
tgid_int = int(payload["tgid"])
|
||||
for tg in system_doc.get("config", {}).get("talkgroups", []):
|
||||
if int(tg.get("id", -1)) == tgid_int:
|
||||
tgid_name = tg.get("name", "")
|
||||
break
|
||||
# Prefer the name from OP25 metadata; fall back to the system config.
|
||||
# The lookup lives in internal/talkgroups.py because /upload needs the
|
||||
# identical resolution and used to go without it — see server-26#34.
|
||||
tgid_name = await talkgroups.resolve(
|
||||
system_id, payload.get("tgid"), hint=payload.get("tgid_name") or None
|
||||
) or ""
|
||||
|
||||
doc = {
|
||||
"call_id": call_id,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Place verification — is the name the corrector produced a real place *here*?
|
||||
|
||||
The transcript corrector (`transcript_correction.py`) substitutes sound-alikes
|
||||
against a reference list. It has no way to tell whether its own output is a real
|
||||
place, so "Cool Parts, Illinois" and "Shout out to Optum" are exactly as
|
||||
acceptable to it as a genuine street name. This module is the check
|
||||
(server-26#37).
|
||||
|
||||
MAPS AS A VERIFIER, NOT AS PROMPT STUFFING. Injecting every road and POI in a
|
||||
town would be hundreds of names on a pass that runs on every transcribed call.
|
||||
Instead we take the handful of location-shaped nouns a transcript actually
|
||||
contains and ask one question per noun:
|
||||
|
||||
1. Geocode it, bounded by the talkgroup's anchor.
|
||||
2. Inside the radius -> accept, done.
|
||||
3. Outside, or no result -> look for a sound-alike that DOES resolve inside.
|
||||
4. Found one -> correct to it, and propose {term, meaning} to that
|
||||
talkgroup's local_knowledge as pending.
|
||||
|
||||
Cost scales with location nouns, not call volume, and every verified miss
|
||||
permanently improves the reference data for that channel.
|
||||
|
||||
NO ANCHOR MEANS SKIP, NOT ACCEPT. An anchor too wide to discriminate is not
|
||||
stored at all (see `area_context`), and without one this module returns
|
||||
immediately. A statewide radius would confirm anything inside it, which is worse
|
||||
than not checking — it looks like verification and is not.
|
||||
|
||||
THE FREE TIER RUNS FIRST. A sound-alike among the terms the operator already
|
||||
entered costs nothing and is more trustworthy than anything Maps guesses, so
|
||||
`local_knowledge` and `vocabulary` are searched before any request goes out.
|
||||
"""
|
||||
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import area_context
|
||||
from app.internal.logger import logger
|
||||
|
||||
# Soundex-style consonant classes. Letters that a vocoder + Whisper routinely
|
||||
# swap land in the same bucket, so "Optum"/"Ossining" stay far apart while
|
||||
# "Snowden"/"Snowdon" collapse together.
|
||||
_CLASSES = {
|
||||
"b": "1", "f": "1", "p": "1", "v": "1",
|
||||
"c": "2", "g": "2", "j": "2", "k": "2", "q": "2", "s": "2", "x": "2", "z": "2",
|
||||
"d": "3", "t": "3",
|
||||
"l": "4",
|
||||
"m": "5", "n": "5",
|
||||
"r": "6",
|
||||
}
|
||||
_DIGRAPHS = (("ph", "f"), ("gh", "g"), ("ck", "k"), ("wr", "r"), ("kn", "n"), ("wh", "w"))
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", (text or "").lower()).strip()
|
||||
|
||||
|
||||
def phonetic_key(text: str) -> str:
|
||||
"""
|
||||
Consonant-class skeleton of a name. Vowels drop out; a run of the same class
|
||||
collapses unless a vowel separates it.
|
||||
"""
|
||||
letters = re.sub(r"[^a-z]", "", (text or "").lower())
|
||||
for a, b in _DIGRAPHS:
|
||||
letters = letters.replace(a, b)
|
||||
out: list[str] = []
|
||||
prev = ""
|
||||
for ch in letters:
|
||||
code = _CLASSES.get(ch, "")
|
||||
if code and code != prev:
|
||||
out.append(code)
|
||||
prev = code if ch not in "aeiouyhw" else ""
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def sounds_like(heard: str, candidate: str) -> float:
|
||||
"""
|
||||
0..1 similarity, the better of the phonetic and the literal comparison.
|
||||
|
||||
Both are needed: Whisper errors are sometimes phonetic ("5 acre" for
|
||||
"5-baker") and sometimes near-spellings ("Croton Ave" for "Croton Avenue"),
|
||||
and a key comparison alone scores the second one poorly.
|
||||
"""
|
||||
literal = SequenceMatcher(None, _norm(heard), _norm(candidate)).ratio()
|
||||
ka, kb = phonetic_key(heard), phonetic_key(candidate)
|
||||
phonetic = SequenceMatcher(None, ka, kb).ratio() if ka and kb else 0.0
|
||||
return max(literal, phonetic)
|
||||
|
||||
|
||||
# -- Maps ----------------------------------------------------------------------
|
||||
|
||||
def _place_suffix(area: dict) -> str:
|
||||
parts = [area[f] for f in area_context.PLACE_FIELDS if area.get(f)]
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
async def _geocode_in_anchor(query: str, anchor: dict) -> Optional[dict]:
|
||||
"""Geocode `query` and return its coords only if they land inside the anchor."""
|
||||
from app.internal.intelligence import _geocode_location
|
||||
|
||||
coords = await _geocode_location(query, anchor=anchor)
|
||||
return coords
|
||||
|
||||
|
||||
async def _places_soundalike(heard: str, anchor: dict) -> Optional[dict]:
|
||||
"""
|
||||
Ask Maps for places near the anchor matching the misheard text.
|
||||
|
||||
Places Text Search does its own fuzzy matching against a biased region, which
|
||||
is usually enough — but "usually" is not a standard, so the result still has
|
||||
to pass `sounds_like` before it is allowed to rewrite a transcript. Without
|
||||
that guard the API happily returns the nearest gas station for any garbage
|
||||
string.
|
||||
"""
|
||||
if not settings.google_maps_api_key:
|
||||
return None
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(
|
||||
"https://maps.googleapis.com/maps/api/place/textsearch/json",
|
||||
params={
|
||||
"query": heard,
|
||||
"location": f"{anchor['lat']},{anchor['lng']}",
|
||||
"radius": int(anchor["radius_km"] * 1000),
|
||||
"key": settings.google_maps_api_key,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Place search failed for {heard!r}: {e}")
|
||||
return None
|
||||
|
||||
if data.get("status") not in ("OK", "ZERO_RESULTS"):
|
||||
logger.warning(f"Place search for {heard!r} returned {data.get('status')}")
|
||||
return None
|
||||
|
||||
for result in (data.get("results") or [])[:5]:
|
||||
name = (result.get("name") or "").strip()
|
||||
loc = (result.get("geometry") or {}).get("location") or {}
|
||||
if not name or "lat" not in loc:
|
||||
continue
|
||||
distance = area_context.geo_dist_km(
|
||||
anchor["lat"], anchor["lng"], float(loc["lat"]), float(loc["lng"])
|
||||
)
|
||||
if distance > anchor["radius_km"]:
|
||||
continue
|
||||
score = sounds_like(heard, name)
|
||||
if score >= settings.place_soundalike_min_ratio:
|
||||
return {"term": name, "meaning": result.get("formatted_address") or None, "score": score}
|
||||
return None
|
||||
|
||||
|
||||
def _known_soundalike(heard: str, area: dict) -> Optional[dict]:
|
||||
"""Best sound-alike among terms the operator already entered. Free."""
|
||||
best: Optional[dict] = None
|
||||
for entry in area.get("local_knowledge") or []:
|
||||
term = entry.get("term") or ""
|
||||
if not term or _norm(term) == _norm(heard):
|
||||
continue
|
||||
score = sounds_like(heard, term)
|
||||
if score >= settings.place_soundalike_min_ratio and (best is None or score > best["score"]):
|
||||
best = {"term": term, "meaning": entry.get("meaning"), "score": score, "known": True}
|
||||
return best
|
||||
|
||||
|
||||
# -- Public --------------------------------------------------------------------
|
||||
|
||||
def _substitute(text: str, swaps: list[tuple[str, str]]) -> str:
|
||||
for heard, replacement in swaps:
|
||||
text = re.sub(rf"\b{re.escape(heard)}\b", replacement, text, flags=re.IGNORECASE)
|
||||
return text
|
||||
|
||||
|
||||
async def verify(
|
||||
call_id: str,
|
||||
text: str,
|
||||
segments: Optional[list[dict]],
|
||||
locations: list[str],
|
||||
system_area: Optional[dict],
|
||||
tg_area: Optional[dict],
|
||||
system_id: Optional[str] = None,
|
||||
talkgroup_id: Optional[Any] = None,
|
||||
) -> tuple[Optional[str], Optional[list[dict]]]:
|
||||
"""
|
||||
Check the corrector's location nouns against the talkgroup's anchor.
|
||||
|
||||
Returns (text, segments) with verified substitutions applied, or (None, None)
|
||||
when nothing changed. Like correction itself, this is an improvement and
|
||||
never a dependency: any failure leaves the transcript exactly as it was.
|
||||
"""
|
||||
if not settings.place_verification_enabled or not locations:
|
||||
return None, None
|
||||
|
||||
anchor = area_context.anchor_for(system_area, tg_area)
|
||||
if not anchor:
|
||||
return None, None # load-bearing: no anchor means skip, never accept
|
||||
|
||||
area = area_context.effective(system_area, tg_area)
|
||||
suffix = _place_suffix(area)
|
||||
swaps: list[tuple[str, str]] = []
|
||||
proposals: list[dict] = []
|
||||
|
||||
for heard in locations[: settings.place_verify_max_per_call]:
|
||||
heard = (heard or "").strip()
|
||||
if not heard:
|
||||
continue
|
||||
query = f"{heard}, {suffix}" if suffix else heard
|
||||
try:
|
||||
if await _geocode_in_anchor(query, anchor):
|
||||
continue # real place, in the right area — nothing to do
|
||||
candidate = _known_soundalike(heard, area) or await _places_soundalike(heard, anchor)
|
||||
except Exception as e:
|
||||
logger.warning(f"Place verification failed for {heard!r} on call {call_id}: {e}")
|
||||
continue
|
||||
if not candidate:
|
||||
logger.info(
|
||||
f"Place verification: {heard!r} (call {call_id}) does not resolve near the "
|
||||
f"anchor and has no sound-alike that does — leaving it alone"
|
||||
)
|
||||
continue
|
||||
swaps.append((heard, candidate["term"]))
|
||||
if not candidate.get("known"):
|
||||
proposals.append({
|
||||
"term": candidate["term"],
|
||||
"meaning": candidate.get("meaning"),
|
||||
"source": "place_verifier",
|
||||
"source_call_ids": [call_id],
|
||||
})
|
||||
logger.info(
|
||||
f"Place verification: {heard!r} -> {candidate['term']!r} "
|
||||
f"(score {candidate['score']:.2f}, call {call_id})"
|
||||
)
|
||||
|
||||
if not swaps:
|
||||
return None, None
|
||||
|
||||
if proposals and system_id and talkgroup_id is not None:
|
||||
try:
|
||||
await area_context.add_pending(system_id, talkgroup_id, proposals)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not queue verified terms for call {call_id}: {e}")
|
||||
|
||||
new_text = _substitute(text or "", swaps)
|
||||
new_segments = None
|
||||
if segments:
|
||||
new_segments = [{**s, "text": _substitute(s.get("text", ""), swaps)} for s in segments]
|
||||
if all(a["text"] == b.get("text") for a, b in zip(new_segments, segments)):
|
||||
new_segments = None
|
||||
return (new_text if new_text != (text or "") else None), new_segments
|
||||
@@ -90,6 +90,11 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
||||
return False
|
||||
|
||||
# All data needed for correlation was stored by the first-pass extraction.
|
||||
# embedding/severity are no longer read from the call doc inside
|
||||
# _build_context (server-26#80/#95) — the sweep re-links a whole call, not a
|
||||
# scene, so it passes the call doc's stored (primary-scene) values here. It
|
||||
# is link-only (create_if_new=False), so a borrowed severity cannot open a
|
||||
# new incident off this path.
|
||||
incident_id = await incident_correlator.correlate_call(
|
||||
call_id = call_id,
|
||||
node_id = call.get("node_id", ""),
|
||||
@@ -101,6 +106,8 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
||||
location = call.get("location"),
|
||||
location_coords= call.get("location_coords"),
|
||||
cleared_units = call.get("cleared_units") or [],
|
||||
embedding = call.get("embedding"),
|
||||
severity = call.get("severity"),
|
||||
reference_time = started_at, # anchor window to when the call happened
|
||||
create_if_new = False, # never create — link-only
|
||||
)
|
||||
|
||||
@@ -44,11 +44,33 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||
The original extension is preserved only if it's a known audio type.
|
||||
"""
|
||||
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
||||
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
||||
if ext not in AUDIO_CONTENT_TYPES:
|
||||
ext = ".mp3"
|
||||
return f"{call_id}{ext}"
|
||||
|
||||
|
||||
# Extension → Content-Type. The node used to send nothing but 16 kbps MP3, so
|
||||
# "audio/mpeg" was hardcoded at every point audio is written or served; it now
|
||||
# sends FLAC (lossless, for Whisper's benefit — see call_recorder.py's AUDIO_*
|
||||
# constants) and a stored object mislabelled audio/mpeg will not play in a
|
||||
# browser. Old .mp3 objects keep working: the map is keyed off the real
|
||||
# extension, not off what the current node happens to produce.
|
||||
AUDIO_CONTENT_TYPES = {
|
||||
".flac": "audio/flac",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
}
|
||||
|
||||
|
||||
def content_type_for(name: str) -> str:
|
||||
"""Content-Type for a stored audio object, by extension. Defaults to MP3."""
|
||||
ext = os.path.splitext(name or "")[-1].lower()
|
||||
return AUDIO_CONTENT_TYPES.get(ext, "audio/mpeg")
|
||||
|
||||
|
||||
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
||||
"""Upload audio bytes to GCS and return the canonical gs:// URI, or None if disabled."""
|
||||
if not settings.gcs_bucket:
|
||||
@@ -65,7 +87,7 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
|
||||
else:
|
||||
client = storage.Client()
|
||||
blob = client.bucket(settings.gcs_bucket).blob(blob_path)
|
||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||
blob.upload_from_string(data, content_type=content_type_for(safe_name))
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_upload)
|
||||
|
||||
@@ -25,9 +25,14 @@ async def summarizer_loop() -> None:
|
||||
flags = await get_flags()
|
||||
if flags["summaries_enabled"]:
|
||||
await _run_summary_pass()
|
||||
await _resolve_stale_incidents()
|
||||
else:
|
||||
logger.info("Summaries disabled — skipping summary pass and stale incident sweep")
|
||||
logger.info("Summaries disabled — skipping summary pass")
|
||||
# Deliberately outside the flag. Auto-resolving a quiet incident is
|
||||
# pure Firestore with no model call in it, and gating it behind the
|
||||
# AI kill switch meant nothing ever auto-resolved in the standing
|
||||
# flags-off configuration — leaving every incident "active" forever
|
||||
# and growing the candidate set every correlation reads.
|
||||
await _resolve_stale_incidents()
|
||||
except Exception as e:
|
||||
logger.error(f"Summarizer pass failed: {e}")
|
||||
|
||||
@@ -43,10 +48,17 @@ async def _run_summary_pass() -> None:
|
||||
|
||||
|
||||
async def _summarize_incident(inc: dict) -> None:
|
||||
from app.internal.feature_flags import get_flags
|
||||
|
||||
incident_id = inc.get("incident_id")
|
||||
if not incident_id:
|
||||
return
|
||||
|
||||
flags = await get_flags()
|
||||
if not flags["summaries_enabled"]:
|
||||
logger.info(f"Summaries disabled — skipping summary for incident {incident_id}")
|
||||
return
|
||||
|
||||
call_ids: list[str] = inc.get("call_ids", [])
|
||||
if not call_ids:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Talkgroup name resolution.
|
||||
|
||||
C2 owns the `systems` collection, and a system's config carries the full
|
||||
talkgroup table — id and human name for every channel the node scans. The edge
|
||||
node only knows the name when OP25 happened to have it in the loaded tags file,
|
||||
so `tgid_name` on a call_start, and the `talkgroup_name` form field on /upload,
|
||||
are both frequently empty for a talkgroup C2 can name perfectly well.
|
||||
|
||||
This resolver is the single place that closes that gap. `mqtt_handler` had its
|
||||
own copy of the lookup on the call_start path, so calls got a name written to
|
||||
their document while the /upload path — the one that drives transcription,
|
||||
correlation and, critically, the incident *title* — kept whatever empty string
|
||||
the node sent. The result was 84 of 100 incidents named "Ems — TGID 9048"
|
||||
instead of "Ems — Ossining Police Dispatch" (server-26#34).
|
||||
|
||||
Order of preference: whatever the caller was given, then the call document
|
||||
(written at call_start), then the system config. Returns None when nothing
|
||||
knows the name, so callers keep their existing "TGID {id}" fallback.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
async def name_from_system(system_id: Optional[str], talkgroup_id: Optional[int]) -> Optional[str]:
|
||||
"""Look a talkgroup's name up in its system's config. None if unknown."""
|
||||
if not system_id or talkgroup_id is None:
|
||||
return None
|
||||
try:
|
||||
tgid_int = int(talkgroup_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if not system_doc:
|
||||
return None
|
||||
|
||||
for tg in system_doc.get("config", {}).get("talkgroups", []):
|
||||
try:
|
||||
if int(tg.get("id", -1)) == tgid_int:
|
||||
return tg.get("name") or None
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
async def resolve(
|
||||
system_id: Optional[str],
|
||||
talkgroup_id: Optional[int],
|
||||
hint: Optional[str] = None,
|
||||
call_doc: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Best available human name for a talkgroup.
|
||||
|
||||
`hint` is whatever the caller already had (OP25 metadata, a form field).
|
||||
`call_doc` is an already-fetched call document, if the caller has one —
|
||||
passing it avoids a second read.
|
||||
"""
|
||||
if hint:
|
||||
return hint
|
||||
|
||||
if call_doc:
|
||||
from_doc = call_doc.get("talkgroup_name")
|
||||
if from_doc:
|
||||
return from_doc
|
||||
|
||||
resolved = await name_from_system(system_id, talkgroup_id)
|
||||
if resolved:
|
||||
logger.info(
|
||||
f"Resolved talkgroup name from system config: "
|
||||
f"TGID {talkgroup_id} → {resolved!r}"
|
||||
)
|
||||
return resolved
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Transcript correction — the second opinion on what was said.
|
||||
|
||||
Whisper hears a P25 vocoder through a narrowband channel and guesses at proper
|
||||
nouns it has no reason to know: street names, business names, unit call signs.
|
||||
It guesses confidently, so the output reads like speech and is wrong in exactly
|
||||
the places that matter downstream — "Cool Parts, Illinois" and "Shout out to
|
||||
Optum" both became incident locations.
|
||||
|
||||
Correction used to be a line in intelligence.py's EXTRACTION_PROMPT, which put
|
||||
it in the wrong place twice over (server-26#36): the same model call that
|
||||
extracted units, location and severity emitted the correction *afterwards*, so
|
||||
extraction reasoned over uncorrected text; and it sat behind
|
||||
`correlation_enabled`, so during a cost-controlled STT-only window nothing was
|
||||
ever corrected at all. It belongs here, between transcription and everything
|
||||
that consumes a transcript.
|
||||
|
||||
WHY A SEPARATE PASS AND NOT A WHISPER PROMPT: Whisper treats its prompt as
|
||||
preceding transcript text and will happily continue a pattern it finds there —
|
||||
an enumerated ten-code prompt made it emit "10-4. 10-5. 10-6. …" over silence
|
||||
(see transcription.py). Vocabulary can never be a transcription prior. A
|
||||
corrector that receives an already-produced transcript plus a reference list has
|
||||
no series to extend; it can only substitute what it was given.
|
||||
|
||||
SCOPE RESOLUTION: reference data is merged from the talkgroup and the system,
|
||||
**talkgroup first**. The specific beats the general — a system spanning several
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import area_context
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import place_verifier
|
||||
from app.internal.logger import logger
|
||||
|
||||
# A transcript this short has no proper nouns to get wrong — "10-4.", "6-2,
|
||||
# stand by." — and 9 of 29 calls in the 2026-08-23 sample sat at or under this.
|
||||
# Skipping them is most of the cost saving for none of the value.
|
||||
MIN_WORDS_FOR_CORRECTION = 4
|
||||
|
||||
_PROMPT = """You are correcting a police/fire radio transcript produced by an automatic speech recogniser.
|
||||
|
||||
The recogniser hears a low-bitrate vocoded radio channel. It reliably mishears proper nouns — street names, business names, town names, unit call signs — and substitutes common words that sound similar. Your job is to put back what was almost certainly said.
|
||||
|
||||
{context_block}
|
||||
Rules:
|
||||
- Change ONLY what is likely a mishearing. If a phrase is already plausible radio traffic, leave it exactly as it is.
|
||||
- Prefer a name from the reference lists above when the transcript contains something that sounds like it. That is the entire point of this pass.
|
||||
- NEVER add information. No new sentences, no invented units, no addresses that are not implied by the audio's own words.
|
||||
- Keep radio language as radio language. Do NOT expand ten-codes or signals into plain English: "10-4" stays "10-4".
|
||||
- Keep the speaker's structure and order. This is not a rewrite or a summary.
|
||||
- If the text is clearly not speech at all — a counting run like "10-11. 10-12. 10-13.", or one phrase repeating many times over static — set not_speech to true.
|
||||
|
||||
Return JSON:
|
||||
corrected: the corrected transcript, or null if nothing needed changing
|
||||
segments: REQUIRED when numbered transmissions are given below — the corrected
|
||||
text for each one, as an array of exactly the same length and order.
|
||||
Never merge, split, reorder or drop a transmission; an unchanged one
|
||||
is returned verbatim. Omit this field entirely when no transmissions
|
||||
are numbered.
|
||||
not_speech: true if this is recogniser noise rather than a transmission
|
||||
changed: list of ["heard" -> "corrected"] pairs you applied, for audit
|
||||
locations: every place name in your corrected output, exactly as it appears
|
||||
there — streets, intersections, businesses, schools, towns,
|
||||
landmarks. Include ones you are unsure of; that is the point.
|
||||
A unit call sign or a person's name is NOT a location.
|
||||
|
||||
{transcript}"""
|
||||
|
||||
|
||||
def _render_input(text: str, segments: Optional[list[dict]]) -> str:
|
||||
"""Numbered transmissions when we have them, so corrections stay aligned."""
|
||||
if segments and len(segments) > 1:
|
||||
lines = [f"{i + 1}. {s.get('text', '')}" for i, s in enumerate(segments)]
|
||||
body = "\n".join(lines)
|
||||
return f"Transmissions ({len(segments)}):\n{body}"
|
||||
return f"Transcript:\n{text}"
|
||||
|
||||
|
||||
def _dedupe(items: list[str]) -> list[str]:
|
||||
"""Preserve order, drop case-insensitive duplicates."""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for item in items:
|
||||
key = (item or "").strip().lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(item.strip())
|
||||
return out
|
||||
|
||||
|
||||
def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
|
||||
"""The config.talkgroups[] entry for this talkgroup, or {}."""
|
||||
if talkgroup_id is None:
|
||||
return {}
|
||||
try:
|
||||
wanted = int(talkgroup_id)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
for tg in (system_doc.get("config") or {}).get("talkgroups", []) or []:
|
||||
try:
|
||||
if int(tg.get("id", -1)) == wanted:
|
||||
return tg
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def _area_lines(area: dict) -> list[str]:
|
||||
"""
|
||||
Render a merged area_context as prompt lines. Empty when nothing is set.
|
||||
|
||||
One block, not one per scope: by the time this runs the two scopes have
|
||||
already been merged with talkgroup ahead of system, and showing the model
|
||||
two competing lists invites it to pick from the wrong one.
|
||||
"""
|
||||
if not area:
|
||||
return []
|
||||
lines: list[str] = []
|
||||
place = ", ".join(
|
||||
str(area[f]) for f in area_context.PLACE_FIELDS if area.get(f)
|
||||
)
|
||||
if place:
|
||||
lines.append(f"Area covered by this channel: {place}")
|
||||
knowledge = area.get("local_knowledge") or []
|
||||
if knowledge:
|
||||
lines.append("Local names heard on this channel:")
|
||||
lines.extend(
|
||||
f" {e['term']} — {e['meaning']}" if e.get("meaning") else f" {e['term']}"
|
||||
for e in knowledge
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
async def resolve_context(system_id: Optional[str], talkgroup_id: Optional[int]) -> dict:
|
||||
"""
|
||||
Merge the reference data a corrector needs, talkgroup ahead of system.
|
||||
|
||||
Returns {"vocabulary", "ten_codes", "area_lines", "area", "system_area",
|
||||
"tg_area"}. Empty everywhere is legitimate — a system nobody has configured
|
||||
yet. The two raw scopes come back alongside the merge because the place
|
||||
verifier needs them to pick an anchor (server-26#37).
|
||||
"""
|
||||
empty: dict[str, Any] = {
|
||||
"vocabulary": [], "ten_codes": {}, "area_lines": [],
|
||||
"area": {}, "system_area": {}, "tg_area": {},
|
||||
}
|
||||
if not system_id:
|
||||
return empty
|
||||
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if not system_doc:
|
||||
return empty
|
||||
|
||||
tg = _talkgroup_entry(system_doc, talkgroup_id)
|
||||
|
||||
# Talkgroup terms first so they survive any downstream truncation.
|
||||
vocabulary = _dedupe(
|
||||
list(tg.get("vocabulary") or []) + list(system_doc.get("vocabulary") or [])
|
||||
)
|
||||
|
||||
# Ten-codes: system-wide reference, with talkgroup entries overriding a
|
||||
# code that means something different on this channel.
|
||||
ten_codes = dict(system_doc.get("ten_codes") or {})
|
||||
ten_codes.update(tg.get("ten_codes") or {})
|
||||
|
||||
system_area = system_doc.get("area_context") or {}
|
||||
tg_area = tg.get("area_context") or {}
|
||||
area = area_context.effective(system_area, tg_area)
|
||||
|
||||
return {
|
||||
"vocabulary": vocabulary,
|
||||
"ten_codes": ten_codes,
|
||||
"area_lines": _area_lines(area),
|
||||
"area": area,
|
||||
"system_area": system_area,
|
||||
"tg_area": tg_area,
|
||||
}
|
||||
|
||||
|
||||
def build_context_block(context: dict, talkgroup_name: Optional[str]) -> str:
|
||||
"""Render resolved context into the prompt's reference section."""
|
||||
lines: list[str] = []
|
||||
if talkgroup_name:
|
||||
lines.append(f"Channel: {talkgroup_name}")
|
||||
lines.extend(context.get("area_lines") or [])
|
||||
vocabulary = context.get("vocabulary") or []
|
||||
if vocabulary:
|
||||
lines.append("Known local names and terms: " + ", ".join(vocabulary))
|
||||
ten_codes = context.get("ten_codes") or {}
|
||||
if ten_codes:
|
||||
rendered = ", ".join(f"{code}={meaning}" for code, meaning in sorted(ten_codes.items()))
|
||||
lines.append(f"Ten-codes used on this system: {rendered}")
|
||||
return ("\n".join(lines) + "\n") if lines else ""
|
||||
|
||||
|
||||
def _sync_gemini(model_name: str, prompt: str) -> dict:
|
||||
import google.generativeai as genai # lazy import — only when needed
|
||||
|
||||
genai.configure(api_key=settings.gemini_api_key)
|
||||
model = genai.GenerativeModel(
|
||||
model_name,
|
||||
generation_config={"response_mime_type": "application/json"},
|
||||
)
|
||||
return json.loads(model.generate_content(prompt).text)
|
||||
|
||||
|
||||
async def correct(
|
||||
call_id: str,
|
||||
text: str,
|
||||
segments: Optional[list[dict]] = None,
|
||||
system_id: Optional[str] = None,
|
||||
talkgroup_id: Optional[int] = None,
|
||||
talkgroup_name: Optional[str] = None,
|
||||
) -> tuple[Optional[str], Optional[list[dict]], bool]:
|
||||
"""
|
||||
Second-opinion pass over a transcript.
|
||||
|
||||
Returns (corrected_text, corrected_segments, not_speech). ``None`` for
|
||||
either correction means "no change" — the corrector found nothing to fix,
|
||||
could not run, or returned segments that did not line up. Callers keep the
|
||||
original in that case; correction is an improvement, never a dependency.
|
||||
|
||||
Segments matter as much as the joined text: intelligence.py builds its
|
||||
extraction prompt from NUMBERED SEGMENTS whenever there is more than one,
|
||||
so a correction that only fixed the joined transcript would never reach the
|
||||
model on exactly the multi-transmission calls that carry the most content.
|
||||
"""
|
||||
if not settings.gemini_api_key or not settings.transcript_correction_enabled:
|
||||
return None, None, False
|
||||
|
||||
if len((text or "").split()) < MIN_WORDS_FOR_CORRECTION:
|
||||
return None, None, False
|
||||
|
||||
context = await resolve_context(system_id, talkgroup_id)
|
||||
prompt = _PROMPT.format(
|
||||
context_block=build_context_block(context, talkgroup_name),
|
||||
transcript=_render_input(text, segments),
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await asyncio.to_thread(
|
||||
_sync_gemini, settings.transcript_correction_model, prompt
|
||||
)
|
||||
except Exception as e:
|
||||
# Never fail the transcript over a failed correction — the raw text is
|
||||
# still worth having. ai_health reporting is the caller's business.
|
||||
logger.warning(f"Transcript correction failed for call {call_id}: {e}")
|
||||
return None, None, False
|
||||
|
||||
not_speech = bool(raw.get("not_speech"))
|
||||
|
||||
corrected = raw.get("corrected")
|
||||
if not isinstance(corrected, str) or not corrected.strip():
|
||||
corrected = None
|
||||
elif corrected.strip() == (text or "").strip():
|
||||
corrected = None
|
||||
|
||||
# Segment alignment is non-negotiable: scene extraction maps scenes back to
|
||||
# transmissions by INDEX (segment_indices), so a returned array of the wrong
|
||||
# length would silently attribute the wrong audio to a scene. Wrong length,
|
||||
# wrong type, or any non-string entry and the segments are discarded whole —
|
||||
# the joined correction still stands.
|
||||
corrected_segments: Optional[list[dict]] = None
|
||||
if segments and len(segments) > 1:
|
||||
returned = raw.get("segments")
|
||||
if (
|
||||
isinstance(returned, list)
|
||||
and len(returned) == len(segments)
|
||||
and all(isinstance(x, str) for x in returned)
|
||||
):
|
||||
corrected_segments = [
|
||||
{**seg, "text": new.strip() or seg.get("text", "")}
|
||||
for seg, new in zip(segments, returned)
|
||||
]
|
||||
if all(s["text"] == o.get("text") for s, o in zip(corrected_segments, segments)):
|
||||
corrected_segments = None
|
||||
elif returned is not None:
|
||||
logger.warning(
|
||||
f"Transcript correction for call {call_id} returned "
|
||||
f"{len(returned) if isinstance(returned, list) else type(returned).__name__} "
|
||||
f"segment(s) against {len(segments)} — discarding segment corrections"
|
||||
)
|
||||
|
||||
# Maps has the last word on place names (server-26#37). The corrector can
|
||||
# only match against the list it was handed, so a plausible-sounding invention
|
||||
# — "Cool Parts, Illinois" — reads exactly like a real street to it. The
|
||||
# verifier geocodes each location noun against the talkgroup's anchor and,
|
||||
# on a miss, looks for a sound-alike that does resolve there. It runs on the
|
||||
# corrected copy so it judges the text everything downstream will actually
|
||||
# read, and it skips entirely when there is no discriminating anchor.
|
||||
if not not_speech:
|
||||
locations = [x for x in (raw.get("locations") or []) if isinstance(x, str)]
|
||||
try:
|
||||
verified_text, verified_segments = await place_verifier.verify(
|
||||
call_id,
|
||||
corrected or text,
|
||||
corrected_segments or segments,
|
||||
locations,
|
||||
context.get("system_area"),
|
||||
context.get("tg_area"),
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Place verification failed for call {call_id}: {e}")
|
||||
verified_text, verified_segments = None, None
|
||||
if verified_text:
|
||||
corrected = verified_text
|
||||
if verified_segments:
|
||||
corrected_segments = verified_segments
|
||||
|
||||
if corrected or corrected_segments or not_speech:
|
||||
changed = raw.get("changed") or []
|
||||
logger.info(
|
||||
f"Transcript correction ({settings.transcript_correction_model}): call {call_id} "
|
||||
f"not_speech={not_speech} segments={'yes' if corrected_segments else 'no'} "
|
||||
f"changes={changed if isinstance(changed, list) else '?'}"
|
||||
)
|
||||
return corrected, corrected_segments, not_speech
|
||||
@@ -11,6 +11,9 @@ import os
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import ai_health
|
||||
from app.internal import transcript_correction
|
||||
from app.config import settings
|
||||
|
||||
# Whisper treats `prompt` as preceding transcript text, not instructions.
|
||||
# Writing it as actual radio speech primes the vocabulary toward P25 codes
|
||||
@@ -93,12 +96,12 @@ def _is_degenerate(text: str, segments: list[dict]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_billing_reported = False
|
||||
|
||||
|
||||
def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
|
||||
async def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
|
||||
"""
|
||||
Log a transcription failure, escalating an unpayable account to ERROR once.
|
||||
Log a transcription failure, escalating a permanent condition to ERROR
|
||||
once (via app.internal.ai_health, which also drives the /health/ai
|
||||
endpoint and the Discord degradation alert) and reporting it to the
|
||||
shared registry either way.
|
||||
|
||||
Transcription failing returns None and the pipeline carries on by design, so
|
||||
a per-call WARNING is invisible: no transcript means no extraction, which
|
||||
@@ -110,23 +113,41 @@ def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
|
||||
The same failure mode already bit the Gemini correlator twice (a retired
|
||||
model ID, then a depleted balance), which is why this is worth the code.
|
||||
"""
|
||||
global _billing_reported
|
||||
text = str(exc)
|
||||
low = text.lower()
|
||||
kind = ai_health.classify(text)
|
||||
|
||||
if ("insufficient_quota" in low or "billing" in low
|
||||
or "credit" in low or "exceeded your current quota" in low):
|
||||
if not _billing_reported:
|
||||
_billing_reported = True
|
||||
if kind == "billing":
|
||||
problem = "the OpenAI account cannot be billed"
|
||||
fix = "top up at https://platform.openai.com/settings/organization/billing"
|
||||
logger.error(
|
||||
"Transcription: the OpenAI account cannot be billed -- EVERY call is "
|
||||
"now stored with no transcript, so extraction, correlation and "
|
||||
"incidents are all dead downstream. Top up at "
|
||||
f"https://platform.openai.com/settings/organization/billing. API said: {text}"
|
||||
)
|
||||
await ai_health.report_degraded(
|
||||
"transcription", "openai", settings.stt_model, problem, fix, permanent=True
|
||||
)
|
||||
return
|
||||
|
||||
if kind == "dead_model":
|
||||
problem = "the STT model is unavailable"
|
||||
fix = "update STT_MODEL in config.py"
|
||||
logger.error(
|
||||
f"Transcription: the configured model ({settings.stt_model!r}) is unavailable "
|
||||
"-- EVERY call is now stored with no transcript, so extraction, correlation "
|
||||
f"and incidents are all dead downstream. Update STT_MODEL in config.py. API said: {text}"
|
||||
)
|
||||
await ai_health.report_degraded(
|
||||
"transcription", "openai", settings.stt_model, problem, fix, permanent=True
|
||||
)
|
||||
return
|
||||
|
||||
logger.warning(f"Transcription failed for call {call_id}: {text}")
|
||||
await ai_health.report_degraded(
|
||||
"transcription", "openai", settings.stt_model,
|
||||
"transient API error", "no action needed unless this persists", permanent=False,
|
||||
)
|
||||
|
||||
|
||||
async def transcribe_call(
|
||||
@@ -134,6 +155,7 @@ async def transcribe_call(
|
||||
gcs_uri: str,
|
||||
talkgroup_name: Optional[str] = None,
|
||||
system_id: Optional[str] = None,
|
||||
talkgroup_id: Optional[int] = None,
|
||||
) -> tuple[Optional[str], list[dict]]:
|
||||
"""
|
||||
Transcribe audio at the given GCS URI and store the result in Firestore.
|
||||
@@ -146,34 +168,107 @@ async def transcribe_call(
|
||||
return None, []
|
||||
|
||||
try:
|
||||
transcript, segments = await asyncio.to_thread(
|
||||
transcript, segments, degenerate = await asyncio.to_thread(
|
||||
_sync_transcribe, gcs_uri, talkgroup_name
|
||||
)
|
||||
# A hallucination is a coin-flip, not a property of the clip: call
|
||||
# e49ea32c produced a 56-word ten-code counting run on one attempt and
|
||||
# ordinary speech on the next, same audio and temperature=0. Discarding
|
||||
# on the first bad roll threw away a recoverable transcript, so spend
|
||||
# one more request before giving up.
|
||||
if degenerate and settings.stt_retry_on_degenerate:
|
||||
logger.info(f"Retrying transcription for call {call_id} after degenerate output")
|
||||
transcript, segments, degenerate = await asyncio.to_thread(
|
||||
_sync_transcribe, gcs_uri, talkgroup_name
|
||||
)
|
||||
if degenerate:
|
||||
logger.warning(
|
||||
f"Transcription for call {call_id} was degenerate twice — giving up"
|
||||
)
|
||||
except Exception as e:
|
||||
_log_transcribe_failure(call_id, e)
|
||||
await _log_transcribe_failure(call_id, e)
|
||||
return None, []
|
||||
|
||||
# No exception means the provider call itself succeeded (this also
|
||||
# covers transcripts discarded as degenerate/hallucinated output —
|
||||
# that's a filtering decision, not a provider failure), so the
|
||||
# transcription tier is healthy and any prior degradation clears.
|
||||
await ai_health.report_healthy("transcription")
|
||||
|
||||
if transcript:
|
||||
updates: dict = {"transcript": transcript}
|
||||
if segments:
|
||||
updates["segments"] = segments
|
||||
|
||||
# Second opinion, before anything downstream sees the text. Whisper
|
||||
# mishears proper nouns confidently, and extraction/embedding/
|
||||
# correlation all consume the transcript — correcting it afterwards
|
||||
# (which is where it used to live, inside the extraction prompt) meant
|
||||
# every one of them reasoned over known-bad text. server-26#36.
|
||||
# Correction is a second model call plus a Places lookup per proposed
|
||||
# location, so it is real spend that used to be reachable only through
|
||||
# an env var and an ansible run. That made an "STT-only" evaluation
|
||||
# window not STT-only, and its cost unattributable (server-26#76, #45).
|
||||
from app.internal.feature_flags import resolve_flags
|
||||
_, _ai_flag = await resolve_flags(system_id)
|
||||
|
||||
corrected, corrected_segments, not_speech = (None, None, False)
|
||||
if _ai_flag("transcript_correction_enabled"):
|
||||
corrected, corrected_segments, not_speech = await transcript_correction.correct(
|
||||
call_id, transcript, segments,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Transcript correction disabled — saving raw transcript for call {call_id}"
|
||||
)
|
||||
if corrected_segments:
|
||||
# Raw stays as evidence; the corrected copy is what extraction reads.
|
||||
updates["segments_corrected"] = corrected_segments
|
||||
if not_speech:
|
||||
# The corrector sees what _is_degenerate misses — novel repetition
|
||||
# shapes rather than the two it pattern-matches. Keep the raw text
|
||||
# (it is evidence) but do not let it reach extraction as fact.
|
||||
updates["transcript_not_speech"] = True
|
||||
logger.info(
|
||||
f"Corrector flagged call {call_id} as recogniser noise: {transcript[:80]!r}"
|
||||
)
|
||||
elif corrected:
|
||||
updates["transcript_corrected"] = corrected
|
||||
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, updates)
|
||||
logger.info(
|
||||
f"Transcript saved for call {call_id} "
|
||||
f"({len(transcript)} chars, {len(segments)} segment(s))"
|
||||
f"({len(transcript)} chars, {len(segments)} segment(s)"
|
||||
f"{', corrected' if corrected and not not_speech else ''})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not save transcript for {call_id}: {e}")
|
||||
|
||||
if not_speech:
|
||||
return None, []
|
||||
# Hand the corrected copies downstream. extract_scenes prefers numbered
|
||||
# segments over the joined transcript, so returning corrected text with
|
||||
# raw segments would have thrown the correction away on every call with
|
||||
# more than one transmission.
|
||||
return corrected or transcript, corrected_segments or segments
|
||||
|
||||
return transcript, segments
|
||||
|
||||
|
||||
def _sync_transcribe(
|
||||
gcs_uri: str,
|
||||
talkgroup_name: Optional[str] = None,
|
||||
) -> tuple[Optional[str], list[dict]]:
|
||||
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
||||
) -> tuple[Optional[str], list[dict], bool]:
|
||||
"""Download audio from GCS and transcribe with OpenAI Whisper.
|
||||
|
||||
Third element is True when output was DISCARDED as degenerate, which the
|
||||
caller distinguishes from ordinary silence so it can retry — the same clip
|
||||
can hallucinate on one attempt and transcribe on the next.
|
||||
"""
|
||||
from google.cloud import storage as gcs
|
||||
from google.oauth2 import service_account
|
||||
from openai import OpenAI
|
||||
@@ -184,7 +279,7 @@ def _sync_transcribe(
|
||||
# Tuple, not a bare None: the caller unpacks two values, so returning
|
||||
# None here raised a TypeError that surfaced as a misleading
|
||||
# "Transcription failed" instead of the real missing-key warning.
|
||||
return None, []
|
||||
return None, [], False
|
||||
|
||||
without_scheme = gcs_uri[len("gs://"):]
|
||||
bucket_name, blob_path = without_scheme.split("/", 1)
|
||||
@@ -255,16 +350,16 @@ def _sync_transcribe(
|
||||
text = " ".join(s["text"] for s in segments) or None
|
||||
if _is_degenerate(text or "", segments):
|
||||
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
||||
return None, []
|
||||
return text, segments
|
||||
return None, [], True
|
||||
return text, segments, False
|
||||
else:
|
||||
# json format returns just {"text": "..."} — no segments or timestamps.
|
||||
# Intelligence extraction falls back to treating the whole transcript as one block.
|
||||
text = (response.text or "").strip() or None
|
||||
if _is_degenerate(text or "", []):
|
||||
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
||||
return None, []
|
||||
return text, []
|
||||
return None, [], True
|
||||
return text, [], False
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -20,8 +20,9 @@ import json
|
||||
import random
|
||||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import area_context
|
||||
from app.internal import firestore as fstore
|
||||
from app.config import settings
|
||||
|
||||
@@ -57,26 +58,32 @@ Do NOT include common English words. Max 80 terms. Only include what you are con
|
||||
accurate for this specific area; return fewer terms rather than guessing."""
|
||||
|
||||
_INDUCTION_PROMPT = """\
|
||||
You are analyzing P25 emergency radio transcripts to find vocabulary terms that should be \
|
||||
added to improve future speech-to-text accuracy for this system.
|
||||
You are analyzing P25 emergency radio transcripts from ONE talkgroup (a single radio channel) \
|
||||
to find local terms that should be added to improve future speech-to-text accuracy for that \
|
||||
channel.
|
||||
|
||||
System: {system_name}
|
||||
Existing approved vocabulary (do not re-propose these): {existing_vocab}
|
||||
Channel: {talkgroup_name}
|
||||
Area: {area_hint}
|
||||
Terms this channel already knows (do not re-propose these): {existing_vocab}
|
||||
|
||||
Sampled transcripts:
|
||||
{transcript_block}
|
||||
|
||||
Find terms that are LIKELY STT errors or local terms missing from the vocabulary:
|
||||
Find terms that are LIKELY STT errors or local terms missing from the list:
|
||||
- Unit IDs that appear garbled (e.g. "5 acre" → "5-baker")
|
||||
- Agency acronyms spelled out phonetically (e.g. "why vac" → "YVAC")
|
||||
- Street names or locations that look misspelled or oddly transcribed
|
||||
- Callsigns or local codes not yet in the vocabulary
|
||||
- Callsigns or local codes not yet known
|
||||
|
||||
Return ONLY a JSON object:
|
||||
{{"new_terms": ["term1", "term2", ...]}}
|
||||
{{"new_terms": [{{"term": "YVAC", "meaning": "Yorktown Volunteer Ambulance Corps"}}, ...]}}
|
||||
|
||||
Only include high-confidence additions not already in existing vocabulary.
|
||||
Return {{"new_terms": []}} if nothing new is found."""
|
||||
`meaning` is what the term refers to — an agency, a road, a unit type. Omit it or use null \
|
||||
when you genuinely do not know; a term with no meaning is still worth proposing.
|
||||
|
||||
Only propose what is specific to THIS channel and this area. Do not propose a term just \
|
||||
because it appears often. Return {{"new_terms": []}} if nothing new is found."""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -97,7 +104,14 @@ async def bootstrap_system_vocabulary(system_id: str) -> list[str]:
|
||||
system_name = system_doc.get("name", "Unknown")
|
||||
system_type = system_doc.get("type", "P25")
|
||||
|
||||
# Build area hint from configured talkgroup names
|
||||
# Prefer the place an operator actually set. Guessing the area from talkgroup
|
||||
# names is thin for a single-municipality system and close to useless for a
|
||||
# multi-county one (server-26#36), so it is only the fallback now.
|
||||
area = system_doc.get("area_context") or {}
|
||||
place = ", ".join(str(area[f]) for f in area_context.PLACE_FIELDS if area.get(f))
|
||||
if place:
|
||||
area_hint = place
|
||||
else:
|
||||
talkgroups = system_doc.get("config", {}).get("talkgroups", [])
|
||||
tg_names = [tg.get("name", "") for tg in talkgroups if tg.get("name")][:8]
|
||||
area_hint = f"Talkgroups include: {', '.join(tg_names)}" if tg_names else "Unknown area"
|
||||
@@ -279,9 +293,20 @@ async def _run_induction_pass() -> None:
|
||||
|
||||
|
||||
async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
"""Sample random transcripts for a system and propose new vocabulary."""
|
||||
"""
|
||||
Sample recent transcripts per TALKGROUP and propose local knowledge there.
|
||||
|
||||
Proposals used to land at system level, which is the wrong blast radius
|
||||
(server-26#37). A wrong term on a talkgroup misleads one channel; the same
|
||||
term at system level misleads every channel on that system — including one
|
||||
400km away on a statewide system, which is exactly the context poisoning the
|
||||
scope rule exists to prevent. If a term really does apply system-wide,
|
||||
carrying it on several talkgroups costs almost nothing, while auto-promoting
|
||||
a wrong one is expensive to notice. So: talkgroup-level pending terms only,
|
||||
and nothing here ever promotes upward or approves itself.
|
||||
"""
|
||||
system_name = system_doc.get("name", "Unknown")
|
||||
existing_vocab: list[str] = system_doc.get("vocabulary") or []
|
||||
system_area = system_doc.get("area_context") or {}
|
||||
|
||||
# Fetch calls from the last 7 days only — avoids scanning the entire history.
|
||||
# Active calls have ended_at=None and are excluded by the range filter automatically.
|
||||
@@ -294,57 +319,87 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
if not all_calls:
|
||||
return
|
||||
|
||||
# Random sample up to the token budget (4 chars ≈ 1 token)
|
||||
random.shuffle(all_calls)
|
||||
char_budget = settings.vocabulary_induction_sample_tokens * 4
|
||||
by_tg: dict[Any, list[dict]] = {}
|
||||
for call in all_calls:
|
||||
tgid = call.get("talkgroup_id")
|
||||
if tgid is None:
|
||||
continue
|
||||
by_tg.setdefault(tgid, []).append(call)
|
||||
|
||||
# The sample budget is per system, split across the talkgroups that have
|
||||
# traffic — a channel with 400 calls should not starve one with 12.
|
||||
char_budget = max(
|
||||
(settings.vocabulary_induction_sample_tokens * 4) // max(len(by_tg), 1), 800
|
||||
)
|
||||
|
||||
for talkgroup_id, calls in by_tg.items():
|
||||
try:
|
||||
await _induct_talkgroup(
|
||||
system_id, system_doc, system_name, system_area,
|
||||
talkgroup_id, calls, char_budget,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Induction failed for talkgroup {talkgroup_id} on system {system_id}: {e}"
|
||||
)
|
||||
|
||||
|
||||
async def _induct_talkgroup(
|
||||
system_id: str,
|
||||
system_doc: dict,
|
||||
system_name: str,
|
||||
system_area: dict,
|
||||
talkgroup_id: Any,
|
||||
calls: list[dict],
|
||||
char_budget: int,
|
||||
) -> None:
|
||||
tg_entry = area_context.talkgroup_entry(system_doc, talkgroup_id)
|
||||
tg_area = tg_entry.get("area_context") or {}
|
||||
area = area_context.effective(system_area, tg_area)
|
||||
|
||||
talkgroup_name = (
|
||||
tg_entry.get("name")
|
||||
or calls[0].get("talkgroup_name")
|
||||
or f"TGID {talkgroup_id}"
|
||||
)
|
||||
known = area_context._known_terms(tg_entry, system_doc)
|
||||
|
||||
random.shuffle(calls)
|
||||
transcript_block = ""
|
||||
sampled_call_docs: list[dict] = []
|
||||
sampled = 0
|
||||
for call in all_calls:
|
||||
for call in calls:
|
||||
text = call.get("transcript_corrected") or call.get("transcript") or ""
|
||||
if not text:
|
||||
continue
|
||||
if len(transcript_block) + len(text) > char_budget:
|
||||
break
|
||||
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
|
||||
transcript_block += f"[{tg}] {text}\n"
|
||||
transcript_block += f"{text}\n"
|
||||
sampled_call_docs.append(call)
|
||||
sampled += 1
|
||||
|
||||
if sampled < 3:
|
||||
return # not enough data to learn from yet
|
||||
if len(sampled_call_docs) < 3:
|
||||
return # not enough data on this channel to learn from yet
|
||||
|
||||
new_terms = await asyncio.to_thread(
|
||||
_sync_induct, system_name, existing_vocab, transcript_block
|
||||
place = ", ".join(str(area[f]) for f in area_context.PLACE_FIELDS if area.get(f))
|
||||
proposed = await asyncio.to_thread(
|
||||
_sync_induct,
|
||||
system_name, talkgroup_name, place or "not set",
|
||||
sorted(known)[:80], transcript_block,
|
||||
)
|
||||
if not new_terms:
|
||||
if not proposed:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing_pending: list[dict] = system_doc.get("vocabulary_pending") or []
|
||||
pending_lower = {p["term"].lower() for p in existing_pending}
|
||||
vocab_lower = {t.lower() for t in existing_vocab}
|
||||
|
||||
to_queue = []
|
||||
for t in new_terms:
|
||||
if t.lower() in vocab_lower or t.lower() in pending_lower:
|
||||
continue
|
||||
to_queue.append({
|
||||
"term": t,
|
||||
entries = [
|
||||
{
|
||||
"term": p["term"],
|
||||
"meaning": p.get("meaning"),
|
||||
"source": "induction",
|
||||
"added_at": now,
|
||||
"source_call_ids": _find_source_calls(t, sampled_call_docs),
|
||||
})
|
||||
if not to_queue:
|
||||
return
|
||||
|
||||
await fstore.doc_set("systems", system_id, {
|
||||
"vocabulary_pending": existing_pending + to_queue,
|
||||
})
|
||||
logger.info(
|
||||
f"Vocabulary induction: {len(to_queue)} new term(s) proposed for "
|
||||
f"system {system_id} ({system_name}): {[p['term'] for p in to_queue]}"
|
||||
)
|
||||
"source_call_ids": _find_source_calls(p["term"], sampled_call_docs),
|
||||
}
|
||||
for p in proposed
|
||||
if p.get("term") and p["term"].lower() not in known
|
||||
]
|
||||
if entries:
|
||||
await area_context.add_pending(system_id, talkgroup_id, entries)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -441,8 +496,13 @@ def _sync_bootstrap(system_name: str, system_type: str, area_hint: str) -> list[
|
||||
|
||||
|
||||
def _sync_induct(
|
||||
system_name: str, existing_vocab: list[str], transcript_block: str
|
||||
) -> list[str]:
|
||||
system_name: str,
|
||||
talkgroup_name: str,
|
||||
area_hint: str,
|
||||
existing_vocab: list[str],
|
||||
transcript_block: str,
|
||||
) -> list[dict]:
|
||||
"""Returns [{term, meaning}] — a bare string is still accepted from the model."""
|
||||
from app.config import settings as cfg
|
||||
from openai import OpenAI
|
||||
|
||||
@@ -452,6 +512,8 @@ def _sync_induct(
|
||||
vocab_str = ", ".join(existing_vocab[:80]) if existing_vocab else "(none yet)"
|
||||
prompt = _INDUCTION_PROMPT.format(
|
||||
system_name=system_name,
|
||||
talkgroup_name=talkgroup_name,
|
||||
area_hint=area_hint,
|
||||
existing_vocab=vocab_str,
|
||||
transcript_block=transcript_block[:8000],
|
||||
)
|
||||
@@ -463,8 +525,7 @@ def _sync_induct(
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
data = json.loads(response.choices[0].message.content)
|
||||
terms = data.get("new_terms") or []
|
||||
return [str(t).strip() for t in terms if str(t).strip()]
|
||||
return area_context.normalize_local_knowledge(data.get("new_terms") or [])
|
||||
except Exception as e:
|
||||
logger.warning(f"Vocabulary induction GPT call failed: {e}")
|
||||
return []
|
||||
|
||||
+46
-2
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Depends
|
||||
@@ -8,6 +9,7 @@ from app.internal.node_sweeper import sweeper_loop
|
||||
from app.internal.summarizer import summarizer_loop
|
||||
from app.internal.vocabulary_learner import vocabulary_induction_loop
|
||||
from app.internal.recorrelation_sweep import recorrelation_loop
|
||||
from app.internal import ai_health
|
||||
from app.config import settings
|
||||
from app.internal.auth import (
|
||||
require_firebase_token,
|
||||
@@ -76,12 +78,33 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
|
||||
|
||||
# "*" plus allow_credentials=True is not the permissive-but-harmless setting it
|
||||
# looks like. Starlette does not refuse the combination -- it reflects the
|
||||
# caller's Origin back and still sends Access-Control-Allow-Credentials: true,
|
||||
# so the effective policy becomes "any origin, with credentials", the opposite
|
||||
# of what a wildcard normally means. Rather than trust every deployment to
|
||||
# remember to override CORS_ORIGINS, make the dangerous pair unrepresentable.
|
||||
def cors_allows_credentials(origins: list[str]) -> bool:
|
||||
"""False when any entry is a wildcard. Extracted so it can be tested
|
||||
without re-importing this module, which drags in every router."""
|
||||
return "*" not in origins
|
||||
|
||||
|
||||
_cors_is_wildcard = not cors_allows_credentials(settings.cors_origins)
|
||||
if _cors_is_wildcard:
|
||||
logger.error(
|
||||
"CORS_ORIGINS is '*', so credentialed cross-origin requests are being "
|
||||
"DISABLED to avoid reflecting every caller's origin back with "
|
||||
"Access-Control-Allow-Credentials. Set CORS_ORIGINS to your frontend "
|
||||
"origin(s) in production, e.g. [\"https://app.example.com\"]."
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
allow_credentials=not _cors_is_wildcard,
|
||||
)
|
||||
|
||||
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
@@ -117,6 +140,27 @@ app.include_router(media.router)
|
||||
# against a future /internal/* route being added and forgotten there.
|
||||
|
||||
|
||||
# Read straight from the environment rather than through Settings: this is a
|
||||
# build stamp baked in by the Dockerfile, not configuration anyone sets or
|
||||
# tunes, and keeping it out of Settings avoids implying it can be changed.
|
||||
_GIT_SHA = os.getenv("GIT_SHA", "unknown")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True, "mqtt_connected": mqtt_handler.is_connected}
|
||||
return {
|
||||
"ok": True,
|
||||
"mqtt_connected": mqtt_handler.is_connected,
|
||||
# CI asserts this equals the commit it just deployed. Without it a
|
||||
# deploy can "succeed" while the previous container is still serving.
|
||||
"git_sha": _GIT_SHA,
|
||||
}
|
||||
|
||||
|
||||
# Deliberately unauthenticated, same as /health above: the CI deploy step
|
||||
# curls /health with no credentials, and this is diagnostic state (which AI
|
||||
# tier is degraded and why), not a secret — no API keys or tokens appear in
|
||||
# it. Keeping it auth-free means an external uptime check can watch it too.
|
||||
@app.get("/health/ai")
|
||||
async def health_ai():
|
||||
return {"tiers": ai_health.snapshot()}
|
||||
|
||||
@@ -78,6 +78,49 @@ class CommandPayload(BaseModel):
|
||||
# Systems
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LocalKnowledgeEntry(BaseModel):
|
||||
"""A local name and what it is. Both scopes, one shape (server-26#36)."""
|
||||
term: str
|
||||
meaning: Optional[str] = None
|
||||
|
||||
|
||||
class AreaContextBody(BaseModel):
|
||||
"""
|
||||
Ground truth about the area a system or talkgroup covers.
|
||||
|
||||
Every field is nullable on purpose: which SCOPE an operator fills is their
|
||||
declaration of how homogeneous the system is. A single-municipality system
|
||||
is described once at system level and inherited by every talkgroup; a
|
||||
statewide one is left null there and described per talkgroup. See
|
||||
`internal/area_context.py` for the merge rules and the anchor.
|
||||
|
||||
`center`/`radius_km`/`resolved_from`/`resolved_at` are absent here by
|
||||
design — the backend geocodes and writes those. A client that sends them is
|
||||
ignored.
|
||||
"""
|
||||
municipality: Optional[str] = None
|
||||
county: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
local_knowledge: List[LocalKnowledgeEntry] = []
|
||||
|
||||
|
||||
class TalkgroupEntry(BaseModel):
|
||||
"""
|
||||
One entry in `config.talkgroups[]`.
|
||||
|
||||
Declared so the talkgroup copy of `area_context` stops being unvalidated
|
||||
JSON riding inside the config blob — it is the same shape as the system's
|
||||
and gets the same validator (server-26#36).
|
||||
"""
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
id: int
|
||||
name: str = ""
|
||||
tag: str = "other"
|
||||
vocabulary: List[str] = []
|
||||
area_context: Optional[AreaContextBody] = None
|
||||
|
||||
|
||||
class SystemRecord(BaseModel):
|
||||
system_id: str
|
||||
org_id: Optional[str] = None
|
||||
@@ -85,6 +128,12 @@ class SystemRecord(BaseModel):
|
||||
type: str # P25 / DMR / NBFM
|
||||
config: Dict[str, Any] = {} # OP25-compatible config blob
|
||||
ten_codes: Dict[str, str] = {} # {"10-10": "Commercial Alarm", ...}
|
||||
# Ground truth about the area this system covers, fed to the transcript
|
||||
# corrector and the place verifier (server-26#36 / #37). Shape is
|
||||
# AreaContextBody plus the backend-owned anchor. Per-talkgroup overrides
|
||||
# live inside config.talkgroups[] and rank ABOVE this, so a multi-county
|
||||
# system narrows per channel rather than replacing this wholesale.
|
||||
area_context: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class SystemCreate(BaseModel):
|
||||
@@ -92,6 +141,7 @@ class SystemCreate(BaseModel):
|
||||
type: str
|
||||
config: Dict[str, Any] = {}
|
||||
ten_codes: Dict[str, str] = {}
|
||||
area_context: Dict[str, Any] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.auth import require_admin_token, require_agent_key_or_admin, describe_actor
|
||||
from app.internal.feature_flags import get_flags, set_flags
|
||||
from app.internal import firestore as fstore
|
||||
from app.config import settings
|
||||
|
||||
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
|
||||
"""Return system_ids where at least one AI function (STT or correlation) is effectively on."""
|
||||
@@ -24,26 +25,61 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/features")
|
||||
async def get_feature_flags(_=Depends(require_admin_token)):
|
||||
async def get_feature_flags(_=Depends(require_agent_key_or_admin)):
|
||||
"""
|
||||
Return the current AI feature flag state. Admin-only (SAAS_PLAN.md B2c) —
|
||||
was previously any authenticated user via require_firebase_token, which
|
||||
handed platform-wide AI configuration state to every signed-in viewer
|
||||
regardless of org.
|
||||
|
||||
Also reachable with the agent service key (server-26#64) so the unattended
|
||||
runbook can read the switch over HTTP instead of shelling into the
|
||||
container. Note this is require_agent_key_or_admin, NOT the Discord bot's
|
||||
service key — see internal/auth.py.
|
||||
"""
|
||||
return await get_flags()
|
||||
|
||||
|
||||
@router.put("/features")
|
||||
async def update_feature_flags(body: dict, _=Depends(require_admin_token)):
|
||||
"""Update one or more AI feature flags. Admin only."""
|
||||
return await set_flags(body)
|
||||
async def update_feature_flags(
|
||||
body: dict,
|
||||
cascade: bool = Query(
|
||||
False,
|
||||
description=(
|
||||
"Also clear per-system ai_flags overrides for the keys being set, "
|
||||
"so the flip applies to every radio system."
|
||||
),
|
||||
),
|
||||
principal: dict = Depends(require_agent_key_or_admin),
|
||||
):
|
||||
"""Update one or more AI feature flags. Admin or agent service key.
|
||||
|
||||
``cascade`` defaults to **False**, deliberately.
|
||||
|
||||
The tempting default is True: feature_flags.resolve_flags lets a
|
||||
system-level False beat a global True, so turning AI back ON globally can
|
||||
half-apply and leave a system dark, and cascade-by-default would make every
|
||||
flip total. That reasoning holds only if per-system ai_flags are set
|
||||
exclusively by hand. They are not — PUT /systems/{system_id}/ai-flags
|
||||
(routers/systems.py) is a real admin route and drb-frontend's AiFlagsPanel
|
||||
(app/systems/page.tsx) is a real toggle in the UI. So an override is a
|
||||
deliberate operator decision that is visible in the interface, and
|
||||
cascading by default would silently erase it on the next unrelated global
|
||||
flip, with the operator's own UI still showing what they set until reload.
|
||||
|
||||
Silently destroying operator intent is the worse failure, so the caller
|
||||
says when it means "everywhere": the runbook passes cascade=true on the
|
||||
shutoff, and the admin UI (which does not pass it) keeps its per-system
|
||||
overrides.
|
||||
"""
|
||||
return await set_flags(body, actor=describe_actor(principal), cascade=cascade)
|
||||
|
||||
|
||||
@router.get("/debug/correlation")
|
||||
async def debug_correlation(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
orphan_hours: int = Query(48, ge=1, le=168),
|
||||
ai_systems_only: bool = Query(False, description="Restrict to systems with STT or correlation currently enabled"),
|
||||
_=Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
@@ -91,12 +127,30 @@ async def debug_correlation(
|
||||
"corr_matched_units": call.get("corr_matched_units"),
|
||||
"corr_sweep_count": call.get("corr_sweep_count"),
|
||||
"skip_reason": call.get("skip_reason"),
|
||||
# LLM consensus tier fields — written by upload.py's
|
||||
# _correlate_with_consensus / llm_correlator.py, but previously
|
||||
# dropped here, making it impossible to tell from this endpoint
|
||||
# whether the LLM correlation tier is actually running (server-26#24).
|
||||
"corr_consensus": call.get("corr_consensus"),
|
||||
"corr_llm_reasoning": call.get("corr_llm_reasoning"),
|
||||
"corr_llm_action": call.get("corr_llm_action"),
|
||||
"corr_rules_action": call.get("corr_rules_action"),
|
||||
}
|
||||
|
||||
# ── Determine which systems have AI active ────────────────────────────────
|
||||
# NOT a filter by default. Restricting to AI-enabled systems meant the view
|
||||
# emptied itself the moment the flags went off — which is precisely when a
|
||||
# window gets reviewed. On 2026-08-23 it dropped from 100 incidents to 6
|
||||
# between switching correlation off and opening the tab. Pass
|
||||
# ai_systems_only=true to get the old behaviour.
|
||||
global_flags = await get_flags()
|
||||
ai_systems = await _get_ai_enabled_system_ids(global_flags)
|
||||
|
||||
def _in_scope(system_ids: list) -> bool:
|
||||
if not ai_systems_only:
|
||||
return True
|
||||
return any(sid in ai_systems for sid in system_ids)
|
||||
|
||||
# ── Fetch recent incidents (AI-enabled systems only) ──────────────────────
|
||||
# Read a bounded, already-sorted window rather than the whole collection.
|
||||
# This route used to pull every incident ever created and sort in Python,
|
||||
@@ -115,10 +169,7 @@ async def debug_correlation(
|
||||
order_by=[("updated_at", "DESCENDING")],
|
||||
limit_to=window,
|
||||
)
|
||||
ai_incidents = [
|
||||
i for i in all_incidents
|
||||
if any(sid in ai_systems for sid in (i.get("system_ids") or []))
|
||||
]
|
||||
ai_incidents = [i for i in all_incidents if _in_scope(i.get("system_ids") or [])]
|
||||
incidents = ai_incidents[:limit]
|
||||
incidents_window_exhausted = len(all_incidents) >= window and len(ai_incidents) < limit
|
||||
|
||||
@@ -169,7 +220,7 @@ async def debug_correlation(
|
||||
if c.get("status") == "ended"
|
||||
and not c.get("incident_ids") and not c.get("incident_id")
|
||||
and not c.get("duplicate_of") # another node's copy — never meant to correlate
|
||||
and c.get("system_id") in ai_systems
|
||||
and _in_scope([c.get("system_id")])
|
||||
]
|
||||
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
|
||||
|
||||
@@ -191,8 +242,80 @@ async def debug_correlation(
|
||||
if (o.get("corr_sweep_count") or 0) >= 3:
|
||||
orphans_by_tg[tg_key]["sweep_exhausted_count"] += 1
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
# Everything below was being recomputed by hand from the raw payload on
|
||||
# every review — path counts, how much of the run the LLM tier actually saw,
|
||||
# how many incidents ended up with the "Ems — TGID 9048" fallback name, and
|
||||
# whether anything blew past the server-26#22 caps. Compute it once, here,
|
||||
# where the data already is.
|
||||
def _tally(values) -> dict:
|
||||
out: dict[str, int] = {}
|
||||
for v in values:
|
||||
k = str(v) if v is not None else "none"
|
||||
out[k] = out.get(k, 0) + 1
|
||||
return dict(sorted(out.items(), key=lambda kv: kv[1], reverse=True))
|
||||
|
||||
linked = [c for inc in incident_records for c in (inc.get("calls_detail") or [])]
|
||||
call_counts = [len(inc.get("call_ids") or []) for inc in incident_records]
|
||||
|
||||
def _span_minutes(inc: dict) -> float:
|
||||
stamps = sorted(
|
||||
s for s in ((c.get("started_at") or "") for c in (inc.get("calls_detail") or [])) if s
|
||||
)
|
||||
if len(stamps) < 2:
|
||||
return 0.0
|
||||
try:
|
||||
first = datetime.fromisoformat(str(stamps[0]).replace("Z", "+00:00"))
|
||||
last = datetime.fromisoformat(str(stamps[-1]).replace("Z", "+00:00"))
|
||||
return round((last - first).total_seconds() / 60, 1)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
spans = [_span_minutes(inc) for inc in incident_records]
|
||||
with_transcript = sum(1 for c in linked if (c.get("transcript") or "").strip())
|
||||
fallback_titles = sum(
|
||||
1 for inc in incident_records
|
||||
if " — TGID " in (inc.get("title") or "") or (inc.get("title") or "").endswith("Unknown Talkgroup")
|
||||
)
|
||||
over_cap = [
|
||||
{"incident_id": inc.get("incident_id"), "title": inc.get("title"),
|
||||
"calls": len(inc.get("call_ids") or []), "span_minutes": _span_minutes(inc)}
|
||||
for inc in incident_records
|
||||
if len(inc.get("call_ids") or []) > settings.incident_max_calls
|
||||
or _span_minutes(inc) > settings.incident_max_duration_minutes
|
||||
]
|
||||
|
||||
summary = {
|
||||
"ai_systems_only": ai_systems_only,
|
||||
"ai_enabled_system_ids": sorted(ai_systems),
|
||||
"linked_call_count": len(linked),
|
||||
"corr_path": _tally(c.get("corr_path") for c in linked),
|
||||
"corr_fit_signal": _tally(c.get("corr_fit_signal") for c in linked),
|
||||
"corr_consensus": _tally(c.get("corr_consensus") for c in linked),
|
||||
"corr_llm_action": _tally(c.get("corr_llm_action") for c in linked),
|
||||
# STT coverage: correlation quality is capped by this, so it belongs in
|
||||
# the same view rather than a separate investigation.
|
||||
"linked_calls_with_transcript": with_transcript,
|
||||
"linked_calls_without_transcript": len(linked) - with_transcript,
|
||||
"orphans_with_transcript": sum(1 for o in orphans if (o.get("transcript") or "").strip()),
|
||||
# Fragmentation vs merging, the two failure directions.
|
||||
"single_call_incidents": sum(1 for n in call_counts if n == 1),
|
||||
"median_calls_per_incident": sorted(call_counts)[len(call_counts) // 2] if call_counts else 0,
|
||||
"max_calls_in_one_incident": max(call_counts) if call_counts else 0,
|
||||
"max_span_minutes": max(spans) if spans else 0.0,
|
||||
"incidents_over_cap": over_cap,
|
||||
"caps": {
|
||||
"incident_max_calls": settings.incident_max_calls,
|
||||
"incident_max_duration_minutes": settings.incident_max_duration_minutes,
|
||||
},
|
||||
# Titling health — server-26#34.
|
||||
"fallback_titled_incidents": fallback_titles,
|
||||
"titled_incidents": len(incident_records) - fallback_titles,
|
||||
}
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"summary": summary,
|
||||
# Both reads are capped, so say plainly when a cap was hit — otherwise a
|
||||
# truncated window is indistinguishable from a quiet night.
|
||||
"incidents_window_exhausted": incidents_window_exhausted,
|
||||
|
||||
@@ -40,6 +40,96 @@ async def list_calls(
|
||||
return [with_playback_url(c) for c in calls]
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_calls(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
cursor: Optional[str] = Query(None, description="started_at of the last row of the previous page"),
|
||||
system_id: Optional[str] = Query(None),
|
||||
node_id: Optional[str] = Query(None),
|
||||
talkgroup_id: Optional[int] = Query(None),
|
||||
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),
|
||||
):
|
||||
"""
|
||||
Paged, filterable call archive — the backend for the /calls page.
|
||||
|
||||
`GET /calls` returns every call in one unordered shot, which is fine for a
|
||||
node's handful of active calls and useless as an archive: no order, no
|
||||
paging, no way to find the orphans. This route is the archive read.
|
||||
|
||||
Only the org scope and the started_at ordering go to Firestore, because
|
||||
that pair is the one composite index that exists (infra/firestore/
|
||||
firestore.indexes.json). Every other filter runs in Python over a bounded
|
||||
window, the same shape admin.py's correlation debug route uses — adding a
|
||||
composite index per filter combination would be a worse trade than reading
|
||||
10x the page and discarding most of it.
|
||||
|
||||
`window_exhausted` says the scan hit its cap before filling the page, so an
|
||||
empty result means "not in this window", not "none exist".
|
||||
"""
|
||||
org_id = await resolve_caller_org_id(decoded)
|
||||
if org_id is None:
|
||||
# resolve_caller_org_id lets platform admins see every org (server-26#4).
|
||||
# A new browse surface shouldn't widen that, so fall back to the
|
||||
# caller's own org claim when they have one.
|
||||
org_id = decoded.get("org_id")
|
||||
if not org_id:
|
||||
raise HTTPException(403, "No organization scope for this caller.")
|
||||
|
||||
window = max(limit * 10, 200)
|
||||
rows = await fstore.collection_where(
|
||||
"calls",
|
||||
[("org_id", "==", org_id)],
|
||||
order_by=[("started_at", "DESCENDING")],
|
||||
limit_to=window,
|
||||
start_after={"started_at": cursor} if cursor else None,
|
||||
)
|
||||
|
||||
needle = (q or "").strip().lower()
|
||||
|
||||
def _keep(c: dict) -> bool:
|
||||
if system_id and c.get("system_id") != system_id:
|
||||
return False
|
||||
if node_id and c.get("node_id") != node_id:
|
||||
return False
|
||||
if talkgroup_id is not None and c.get("talkgroup_id") != talkgroup_id:
|
||||
return False
|
||||
linked = bool(c.get("incident_ids") or c.get("incident_id"))
|
||||
if link == "orphan" and linked:
|
||||
return False
|
||||
if link == "linked" and not linked:
|
||||
return False
|
||||
text = c.get("transcript_corrected") or c.get("transcript") or ""
|
||||
if transcript == "yes" and not text:
|
||||
return False
|
||||
if transcript == "no" and text:
|
||||
return False
|
||||
if needle and needle not in text.lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
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
|
||||
|
||||
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("/{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)
|
||||
@@ -139,10 +229,26 @@ async def patch_transcript(
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Overwrite a call's transcript and re-run intelligence extraction."""
|
||||
from app.internal.feature_flags import resolve_flags
|
||||
|
||||
call = await fstore.doc_get("calls", call_id)
|
||||
if not call:
|
||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||
|
||||
# This route is destructive before it is constructive: it wipes the call's
|
||||
# tags, severity, location, units and embedding and unlinks it from every
|
||||
# incident, on the promise that re-extraction will rebuild all of it. With
|
||||
# correlation off that promise cannot be kept, and the call would be left
|
||||
# permanently blank and orphaned while the route still answered 200.
|
||||
# Refuse before the first write rather than half-run (server-26#76).
|
||||
_, flag = await resolve_flags(call.get("system_id"))
|
||||
if not flag("correlation_enabled"):
|
||||
raise HTTPException(
|
||||
409,
|
||||
"Correlation is disabled, so the re-extraction this correction depends on "
|
||||
"cannot run. The transcript was not changed. Enable correlation and retry.",
|
||||
)
|
||||
|
||||
# Save user correction as transcript_corrected; leave original transcript intact.
|
||||
# Clear stale intelligence fields so re-extraction runs fresh.
|
||||
await fstore.doc_set("calls", call_id, {
|
||||
@@ -181,7 +287,7 @@ async def patch_transcript(
|
||||
# Learn from the correction: diff original → corrected and add new tokens to vocabulary
|
||||
system_id = call.get("system_id")
|
||||
original_text = call.get("transcript_corrected") or call.get("transcript") or ""
|
||||
if system_id and original_text:
|
||||
if system_id and original_text and flag("vocabulary_learning_enabled"):
|
||||
from app.internal.vocabulary_learner import learn_from_correction
|
||||
await learn_from_correction(system_id, original_text, body.transcript)
|
||||
|
||||
|
||||
@@ -97,30 +97,89 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
|
||||
async def summarize_incident(
|
||||
incident_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
decoded: dict = Depends(require_service_or_firebase_token),
|
||||
decoded: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Immediately run the summarizer for a specific incident."""
|
||||
from app.internal.summarizer import _summarize_incident
|
||||
from app.internal.feature_flags import get_flags
|
||||
inc = await fstore.doc_get("incidents", incident_id)
|
||||
if not inc:
|
||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||
flags = await get_flags()
|
||||
if not flags["summaries_enabled"]:
|
||||
return {"ok": False, "incident_id": incident_id, "summaries_enabled": False}
|
||||
# Rate limit by incident ID to prevent repeated expensive LLM calls
|
||||
summarize_limiter.check(incident_id)
|
||||
background_tasks.add_task(_summarize_incident, inc)
|
||||
return {"ok": True, "incident_id": incident_id}
|
||||
return {"ok": True, "incident_id": incident_id, "summaries_enabled": True}
|
||||
|
||||
|
||||
@router.post("/{incident_id}/calls/{call_id}")
|
||||
async def link_call_to_incident(incident_id: str, call_id: str, _: dict = Depends(require_admin_token)):
|
||||
"""Manually attach a call to an incident (the /calls page's attribution action)."""
|
||||
doc = await fstore.doc_get("incidents", incident_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||
call_ids = doc.get("call_ids", [])
|
||||
call = await fstore.doc_get("calls", call_id)
|
||||
if not call:
|
||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||
|
||||
call_ids = list(doc.get("call_ids") or [])
|
||||
if call_id not in call_ids:
|
||||
call_ids.append(call_id)
|
||||
await fstore.doc_update("incidents", incident_id, {
|
||||
"call_ids": call_ids,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
# A manually attached call changes what the incident is about.
|
||||
"summary_stale": True,
|
||||
})
|
||||
await fstore.doc_update("calls", call_id, {"incident_id": incident_id})
|
||||
return {"ok": True}
|
||||
|
||||
# incident_ids is the canonical link — it is what the correlator writes and
|
||||
# what the frontend queries with array-contains. This route only ever set
|
||||
# the legacy scalar incident_id, so a manually attached call stayed
|
||||
# invisible on the incident's own page.
|
||||
incident_ids = list(call.get("incident_ids") or ([call["incident_id"]] if call.get("incident_id") else []))
|
||||
if incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
await fstore.doc_update("calls", call_id, {
|
||||
"incident_ids": incident_ids,
|
||||
"incident_id": incident_id,
|
||||
"corr_path": "manual",
|
||||
})
|
||||
return {"ok": True, "incident_ids": incident_ids}
|
||||
|
||||
|
||||
@router.delete("/{incident_id}/calls/{call_id}")
|
||||
async def unlink_call_from_incident(incident_id: str, call_id: str, _: dict = Depends(require_admin_token)):
|
||||
"""
|
||||
Detach a call from an incident — the other half of manual attribution.
|
||||
|
||||
An incident left with no calls is resolved rather than deleted, matching
|
||||
what calls.py's transcript correction does when it empties one.
|
||||
"""
|
||||
doc = await fstore.doc_get("incidents", incident_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||
|
||||
remaining = [c for c in (doc.get("call_ids") or []) if c != call_id]
|
||||
updates: dict = {
|
||||
"call_ids": remaining,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"summary_stale": True,
|
||||
}
|
||||
if not remaining:
|
||||
updates["status"] = "resolved"
|
||||
updates["resolved_at"] = datetime.now(timezone.utc).isoformat()
|
||||
await fstore.doc_update("incidents", incident_id, updates)
|
||||
|
||||
call = await fstore.doc_get("calls", call_id)
|
||||
if call:
|
||||
incident_ids = [
|
||||
i for i in (call.get("incident_ids") or ([call["incident_id"]] if call.get("incident_id") else []))
|
||||
if i != incident_id
|
||||
]
|
||||
await fstore.doc_update("calls", call_id, {
|
||||
"incident_ids": incident_ids,
|
||||
"incident_id": incident_ids[0] if incident_ids else None,
|
||||
})
|
||||
return {"ok": True, "incident_emptied": not remaining}
|
||||
|
||||
@@ -13,7 +13,7 @@ service-account private key on the VM — is involved anywhere in this path.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio
|
||||
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for
|
||||
|
||||
router = APIRouter(prefix="/media", tags=["media"])
|
||||
|
||||
@@ -42,12 +42,18 @@ async def get_call_audio(
|
||||
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="audio/mpeg",
|
||||
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser
|
||||
# will not play a FLAC body labelled audio/mpeg. Derived from the stored
|
||||
# object's extension so old .mp3 recordings keep working unchanged.
|
||||
media_type=content_type_for(gcs_uri),
|
||||
headers={
|
||||
"Content-Length": str(len(data)),
|
||||
# Recordings are small (16 kbps mono — a 30s call is ~60 KB), so the
|
||||
# whole body is sent at once and the browser seeks within its own
|
||||
# buffer. Range support would only matter for long files.
|
||||
# Whole body at once, no Range support. This was comfortable at
|
||||
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a
|
||||
# long call is now tens of MB and the browser must download all of
|
||||
# it before playback starts. Acceptable for typical few-second
|
||||
# transmissions, but this is the change that makes Range support
|
||||
# actually matter — see DEFERRED.md.
|
||||
"Accept-Ranges": "none",
|
||||
# Immutable content, but the URL expires — cache privately only.
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Optional
|
||||
from app.models import SystemCreate, SystemRecord
|
||||
from typing import Dict, List, Optional
|
||||
from app.models import AreaContextBody, SystemCreate, SystemRecord
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import area_context as area_ctx
|
||||
from app.internal.auth import (
|
||||
require_admin_token,
|
||||
require_node_service_or_firebase_token,
|
||||
@@ -23,6 +24,11 @@ class TenCodesBody(BaseModel):
|
||||
ten_codes: Dict[str, str]
|
||||
|
||||
|
||||
class PendingTermBody(BaseModel):
|
||||
talkgroup_id: int
|
||||
term: str
|
||||
|
||||
|
||||
class AiFlagsBody(BaseModel):
|
||||
stt_enabled: Optional[bool] = None
|
||||
correlation_enabled: Optional[bool] = None
|
||||
@@ -64,8 +70,28 @@ async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(re
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
await fstore.doc_update("systems", system_id, body.model_dump())
|
||||
return {**existing, **body.model_dump()}
|
||||
# exclude_unset, or every field the caller omitted gets written as its
|
||||
# default and silently erases what was there. The systems page PUTs only
|
||||
# {name, type, config}, so a plain model_dump() wiped ten_codes on every
|
||||
# save — they are edited through PUT /{id}/ten-codes and were never in this
|
||||
# payload. area_context (server-26#36) would have been the second casualty.
|
||||
patch = body.model_dump(exclude_unset=True)
|
||||
# The form sends config.talkgroups[] in full, which would erase the resolved
|
||||
# anchor and the pending-term queue the backend put there. Same class of bug
|
||||
# as ten_codes above; the backend merges its own fields back rather than
|
||||
# taking dictation from the client (server-26#36).
|
||||
if "config" in patch:
|
||||
patch["config"] = area_ctx.merge_config(patch["config"], existing.get("config"))
|
||||
if "area_context" in patch:
|
||||
patch["area_context"] = area_ctx.merge_server_fields(
|
||||
area_ctx.normalize(patch["area_context"]), existing.get("area_context")
|
||||
)
|
||||
await fstore.doc_update("systems", system_id, patch)
|
||||
# Geocoding the anchor is a write-time job — a place changes when someone
|
||||
# edits a town name, not every five minutes — but the operator should not
|
||||
# wait on Maps to see their save land.
|
||||
area_ctx.schedule_refresh(system_id)
|
||||
return {**existing, **patch}
|
||||
|
||||
|
||||
@router.delete("/{system_id}", status_code=204)
|
||||
@@ -129,6 +155,105 @@ async def update_ten_codes(
|
||||
return {"ok": True, "ten_codes": body.ten_codes}
|
||||
|
||||
|
||||
# ── Area context ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{system_id}/area-context")
|
||||
async def get_area_context(system_id: str, _: dict = Depends(require_admin_token)):
|
||||
system = await fstore.doc_get("systems", system_id)
|
||||
if not system:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
return {"area_context": system.get("area_context") or {}}
|
||||
|
||||
|
||||
@router.put("/{system_id}/area-context")
|
||||
async def update_area_context(
|
||||
system_id: str,
|
||||
body: AreaContextBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
Replace the system-wide area context used by the corrector and the verifier.
|
||||
|
||||
Ground truth about where this system operates — municipality, county, state,
|
||||
and the local names whose sound Whisper mangles. Per-talkgroup overrides live
|
||||
inside config.talkgroups[] and rank ABOVE this (server-26#36), so a
|
||||
multi-county system narrows per channel rather than replacing this wholesale.
|
||||
Leaving it entirely empty is legitimate and meaningful: it says nothing here
|
||||
is true of every talkgroup.
|
||||
|
||||
The derived anchor (`center`, `radius_km`, `resolved_from`, `resolved_at`) is
|
||||
never taken from the body — it is carried forward and then recomputed here.
|
||||
|
||||
Its own route rather than a field on PUT /systems/{id} for the same reason
|
||||
ten-codes has one: the systems form does not carry it, and folding it into
|
||||
that payload is how ten_codes kept getting wiped.
|
||||
"""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
area = area_ctx.merge_server_fields(
|
||||
area_ctx.normalize(body.model_dump(exclude_none=True)),
|
||||
existing.get("area_context"),
|
||||
)
|
||||
await fstore.doc_update("systems", system_id, {"area_context": area})
|
||||
# Awaited, not scheduled: this route exists to edit the place, so the caller
|
||||
# should get back the anchor its edit produced. Talkgroups are refreshed with
|
||||
# it because their anchor derives from the merged place, not their own.
|
||||
patch = await area_ctx.refresh_anchors({**existing, "area_context": area})
|
||||
if patch:
|
||||
await fstore.doc_update("systems", system_id, patch)
|
||||
area = patch.get("area_context", area)
|
||||
return {"ok": True, "area_context": area}
|
||||
|
||||
|
||||
# -- Talkgroup-level pending local knowledge (server-26#37) --------------------
|
||||
|
||||
@router.get("/{system_id}/talkgroup-pending")
|
||||
async def list_talkgroup_pending(system_id: str, _: dict = Depends(require_admin_token)):
|
||||
"""
|
||||
Every pending local-knowledge proposal on this system, by talkgroup.
|
||||
|
||||
Proposals are made at talkgroup level and are never promoted to the system
|
||||
automatically — a wrong term on one channel misleads one channel, the same
|
||||
term system-wide misleads every channel on it.
|
||||
"""
|
||||
system = await fstore.doc_get("systems", system_id)
|
||||
if not system:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
out = []
|
||||
for tg in ((system.get("config") or {}).get("talkgroups") or []):
|
||||
if not isinstance(tg, dict):
|
||||
continue
|
||||
pending = tg.get(area_ctx.PENDING_KEY) or []
|
||||
if pending:
|
||||
out.append({
|
||||
"talkgroup_id": tg.get("id"),
|
||||
"talkgroup_name": tg.get("name"),
|
||||
"pending": pending,
|
||||
})
|
||||
return {"talkgroups": out}
|
||||
|
||||
|
||||
@router.post("/{system_id}/talkgroup-pending/approve")
|
||||
async def approve_talkgroup_pending(
|
||||
system_id: str, body: PendingTermBody, _: dict = Depends(require_admin_token)
|
||||
):
|
||||
"""Move a pending term into that talkgroup's local_knowledge."""
|
||||
if not await area_ctx.resolve_pending(system_id, body.talkgroup_id, body.term, approve=True):
|
||||
raise HTTPException(404, "No such pending term on that talkgroup.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{system_id}/talkgroup-pending/dismiss")
|
||||
async def dismiss_talkgroup_pending(
|
||||
system_id: str, body: PendingTermBody, _: dict = Depends(require_admin_token)
|
||||
):
|
||||
"""Drop a pending term without adding it."""
|
||||
if not await area_ctx.resolve_pending(system_id, body.talkgroup_id, body.term, approve=False):
|
||||
raise HTTPException(404, "No such pending term on that talkgroup.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Vocabulary endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{system_id}/vocabulary")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
|
||||
@@ -36,7 +37,11 @@ async def upload_call_audio(
|
||||
if not key_doc:
|
||||
logger.warning(f"Upload 401: no key_doc in Firestore for node_id={node_id!r}")
|
||||
raise HTTPException(401, "Invalid node API key")
|
||||
if key_doc.get("api_key") != credentials.credentials:
|
||||
# compare_digest, not !=, so the comparison cost does not depend on how many
|
||||
# leading characters matched. enrollment.py and dynsec.py were explicit about
|
||||
# this for the same class of credential; this route was the odd one out.
|
||||
stored_key = key_doc.get("api_key") or ""
|
||||
if not secrets.compare_digest(stored_key, credentials.credentials):
|
||||
logger.warning(
|
||||
f"Upload 401: key mismatch for node_id={node_id!r} "
|
||||
f"(received prefix: {credentials.credentials[:8]}...)"
|
||||
@@ -109,6 +114,8 @@ async def _correlate_with_consensus(
|
||||
vehicles: Optional[list] = None,
|
||||
cleared_units: Optional[list] = None,
|
||||
reassignment: bool = False,
|
||||
embedding: Optional[list] = None,
|
||||
severity: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
|
||||
@@ -126,6 +133,7 @@ async def _correlate_with_consensus(
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
location_coords=location_coords, units=units, vehicles=vehicles,
|
||||
cleared_units=cleared_units, reassignment=reassignment,
|
||||
embedding=embedding, severity=severity,
|
||||
)
|
||||
ctx = preview["ctx"]
|
||||
rules_decision = preview["decision"]
|
||||
@@ -154,6 +162,19 @@ async def _correlate_with_consensus(
|
||||
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx})
|
||||
|
||||
|
||||
async def _resolve_flags(system_id: Optional[str]):
|
||||
"""
|
||||
Resolve AI feature flags for a given system.
|
||||
|
||||
Thin alias for `feature_flags.resolve_flags` — the resolver lives there
|
||||
because transcription and the calls router need the same answer, and three
|
||||
copies of it is how server-26#75 happened in the first place.
|
||||
"""
|
||||
from app.internal.feature_flags import resolve_flags
|
||||
|
||||
return await resolve_flags(system_id)
|
||||
|
||||
|
||||
async def _run_extraction_pipeline(
|
||||
call_id: str,
|
||||
node_id: str,
|
||||
@@ -167,6 +188,12 @@ async def _run_extraction_pipeline(
|
||||
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
|
||||
from app.internal import intelligence, incident_correlator, alerter
|
||||
|
||||
flags, _flag = await _resolve_flags(system_id)
|
||||
|
||||
incident_ids: list[str] = []
|
||||
all_tags: list[str] = []
|
||||
|
||||
if _flag("correlation_enabled"):
|
||||
# Step 2: Scene detection + intelligence extraction.
|
||||
# Returns one scene per distinct incident detected in the recording.
|
||||
scenes = await intelligence.extract_scenes(
|
||||
@@ -177,8 +204,6 @@ async def _run_extraction_pipeline(
|
||||
)
|
||||
|
||||
# Step 3: Correlate each scene to an incident independently.
|
||||
incident_ids: list[str] = []
|
||||
all_tags: list[str] = []
|
||||
for scene in scenes:
|
||||
all_tags.extend(scene["tags"])
|
||||
# When dispatch is pulling a unit to a NEW call (reassignment), suppress unit
|
||||
@@ -199,6 +224,8 @@ async def _run_extraction_pipeline(
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
reassignment=is_reassignment,
|
||||
embedding=scene.get("embedding"),
|
||||
severity=scene.get("severity"),
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
@@ -209,6 +236,9 @@ async def _run_extraction_pipeline(
|
||||
})
|
||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
else:
|
||||
scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}"
|
||||
logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id} (reprocess)")
|
||||
|
||||
if incident_ids:
|
||||
await fstore.doc_set("calls", call_id, {"incident_ids": incident_ids})
|
||||
@@ -239,22 +269,27 @@ async def _run_intelligence_pipeline(
|
||||
3. Correlate each scene with existing incidents (or create new ones)
|
||||
4. Check alert rules and dispatch notifications
|
||||
"""
|
||||
from app.internal import transcription, intelligence, incident_correlator, alerter
|
||||
from app.internal.feature_flags import get_flags
|
||||
from app.internal import transcription, intelligence, incident_correlator, alerter, talkgroups
|
||||
|
||||
flags = await get_flags()
|
||||
# The node only sends talkgroup_name when OP25 had it in the loaded tags
|
||||
# file, so it arrives empty for exactly the talkgroups C2 can name from the
|
||||
# system config. Resolve it once, here, at the single funnel both /upload
|
||||
# and /calls/{id}/reprocess pass through — everything downstream (the
|
||||
# dispatch-channel test, scene extraction, and the incident title) then
|
||||
# gets a real name instead of "TGID 9048". server-26#34.
|
||||
_call_doc = await fstore.doc_get("calls", call_id)
|
||||
talkgroup_name = await talkgroups.resolve(
|
||||
system_id, talkgroup_id, hint=talkgroup_name, call_doc=_call_doc,
|
||||
)
|
||||
# Backfill the call document too, so the archive and the orphan panel stop
|
||||
# showing a bare TGID for a channel we can now name.
|
||||
if talkgroup_name and _call_doc is not None and not _call_doc.get("talkgroup_name"):
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, {"talkgroup_name": talkgroup_name})
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not backfill talkgroup_name on call {call_id}: {e}")
|
||||
|
||||
# Resolve per-system overrides: system flag=False beats global flag=True,
|
||||
# but global flag=False beats everything (master switch).
|
||||
system_ai_flags: dict = {}
|
||||
if system_id:
|
||||
sys_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
system_ai_flags = (sys_doc or {}).get("ai_flags") or {}
|
||||
|
||||
def _flag(name: str) -> bool:
|
||||
if not flags[name]: # global master off
|
||||
return False
|
||||
return system_ai_flags.get(name, True) # system override, default inherit
|
||||
flags, _flag = await _resolve_flags(system_id)
|
||||
|
||||
transcript: Optional[str] = None
|
||||
segments: list[dict] = []
|
||||
@@ -263,7 +298,8 @@ async def _run_intelligence_pipeline(
|
||||
if gcs_uri:
|
||||
if _flag("stt_enabled"):
|
||||
transcript, segments = await transcription.transcribe_call(
|
||||
call_id, gcs_uri, talkgroup_name, system_id=system_id
|
||||
call_id, gcs_uri, talkgroup_name,
|
||||
system_id=system_id, talkgroup_id=talkgroup_id,
|
||||
)
|
||||
else:
|
||||
scope = "globally" if not flags["stt_enabled"] else f"system {system_id}"
|
||||
@@ -286,7 +322,7 @@ async def _run_intelligence_pipeline(
|
||||
# A single recording can produce multiple incidents on a busy channel.
|
||||
incident_ids: list[str] = []
|
||||
all_tags: list[str] = []
|
||||
if flags["correlation_enabled"]:
|
||||
if _flag("correlation_enabled"):
|
||||
for scene in scenes:
|
||||
all_tags.extend(scene["tags"])
|
||||
is_reassignment = bool(scene.get("reassignment"))
|
||||
@@ -305,6 +341,8 @@ async def _run_intelligence_pipeline(
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
reassignment=is_reassignment,
|
||||
embedding=scene.get("embedding"),
|
||||
severity=scene.get("severity"),
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
|
||||
@@ -40,6 +40,15 @@ except ModuleNotFoundError:
|
||||
_auth.set_custom_user_claims = MagicMock()
|
||||
_auth.get_user_by_email = MagicMock()
|
||||
_auth.get_user = MagicMock()
|
||||
# Type used in annotations at import time by routers/users.py, so it has to
|
||||
# exist as a name even though nothing here ever instantiates it. Without it,
|
||||
# importing app.main -- and therefore testing anything wired at app level,
|
||||
# like the CORS policy -- fails at collection.
|
||||
_auth.UserRecord = MagicMock()
|
||||
_auth.list_users = MagicMock()
|
||||
_auth.update_user = MagicMock()
|
||||
_auth.create_user = MagicMock()
|
||||
_auth.delete_user = MagicMock()
|
||||
|
||||
_firebase.auth = _auth
|
||||
_firebase.credentials = _credentials
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Unit tests for /admin/debug/correlation (server-26#24).
|
||||
|
||||
The endpoint used to strip the LLM consensus tier's fields (corr_consensus,
|
||||
corr_llm_reasoning, corr_llm_action, corr_rules_action) out of its response
|
||||
even though upload.py / llm_correlator.py write them straight onto the call
|
||||
doc via corr_debug — making this endpoint unable to answer "is the LLM
|
||||
correlation tier actually running", the one thing it exists to answer.
|
||||
|
||||
Firestore is fully mocked (patch app.routers.admin.fstore); the route
|
||||
function is called directly, bypassing FastAPI's dependency injection, so
|
||||
Query/Depends defaults are supplied explicitly.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.routers import admin
|
||||
|
||||
NOW = datetime(2026, 8, 20, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _incident(call_ids):
|
||||
return {
|
||||
"incident_id": "inc-1",
|
||||
"system_ids": ["sys-1"],
|
||||
"call_ids": call_ids,
|
||||
"updated_at": NOW.isoformat(),
|
||||
"started_at": NOW.isoformat(),
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
async def _run(incidents, calls_by_id, orphan_calls=None):
|
||||
"""Drive debug_correlation() with fstore fully mocked."""
|
||||
system = {"system_id": "sys-1", "ai_flags": {}}
|
||||
|
||||
async def fake_collection_where(collection, conditions, order_by=None, limit_to=None, start_after=None):
|
||||
if collection == "incidents":
|
||||
return incidents
|
||||
if collection == "calls":
|
||||
return orphan_calls or []
|
||||
return []
|
||||
|
||||
async def fake_doc_get(collection, doc_id):
|
||||
return calls_by_id.get(doc_id)
|
||||
|
||||
with patch(
|
||||
"app.routers.admin.get_flags",
|
||||
new=AsyncMock(return_value={"stt_enabled": True, "correlation_enabled": True}),
|
||||
), patch("app.routers.admin.fstore") as mock_fstore:
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[system])
|
||||
mock_fstore.collection_where = AsyncMock(side_effect=fake_collection_where)
|
||||
mock_fstore.doc_get = AsyncMock(side_effect=fake_doc_get)
|
||||
return await admin.debug_correlation(limit=20, orphan_hours=48, _=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_correlation_surfaces_llm_consensus_fields():
|
||||
"""A call that went through the tiebreaker must show all four LLM fields."""
|
||||
call = {
|
||||
"call_id": "call-1",
|
||||
"corr_path": "fast/single",
|
||||
"corr_consensus": "tiebreak",
|
||||
"corr_llm_reasoning": "Same units on scene as the anchor call.",
|
||||
"corr_llm_action": "link",
|
||||
"corr_rules_action": "orphan",
|
||||
}
|
||||
result = await _run([_incident(["call-1"])], {"call-1": call})
|
||||
|
||||
detail = result["incidents"][0]["calls_detail"][0]
|
||||
assert detail["corr_consensus"] == "tiebreak"
|
||||
assert detail["corr_llm_reasoning"] == "Same units on scene as the anchor call."
|
||||
assert detail["corr_llm_action"] == "link"
|
||||
assert detail["corr_rules_action"] == "orphan"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_correlation_llm_fields_absent_when_rules_only():
|
||||
"""
|
||||
A call that never reached the LLM (GEMINI_API_KEY unset, thin call, or LLM
|
||||
error) has corr_consensus == "rules_only" and no corr_llm_* fields — the
|
||||
endpoint must pass that through as None rather than erroring, since this
|
||||
is the normal/expected state whenever the tier is legitimately idle.
|
||||
"""
|
||||
call = {"call_id": "call-2", "corr_path": "fast/single", "corr_consensus": "rules_only"}
|
||||
result = await _run([_incident(["call-2"])], {"call-2": call})
|
||||
|
||||
detail = result["incidents"][0]["calls_detail"][0]
|
||||
assert detail["corr_consensus"] == "rules_only"
|
||||
assert detail["corr_llm_reasoning"] is None
|
||||
assert detail["corr_llm_action"] is None
|
||||
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
server-26#64 — a headless, attributable, total AI-flag flip.
|
||||
|
||||
Three things are held here:
|
||||
|
||||
* ``require_agent_key_or_admin`` is a DISTINCT principal. It takes the agent
|
||||
service key or a Firebase admin token and refuses the Discord bot's
|
||||
``service_key``, so an audit entry can name who flipped the switch.
|
||||
* ``set_flags`` writes an ``audit_log`` entry carrying before/after values,
|
||||
and an audit failure can neither lose the flag write nor 500 the route.
|
||||
* ``cascade=True`` clears per-system ``ai_flags`` overrides for the keys
|
||||
being set, so a flip cannot half-apply — discovered by scanning for
|
||||
documents that carry the map, never a hardcoded system-id list.
|
||||
|
||||
The dependency is exercised directly rather than through TestClient: these are
|
||||
assertions about the credential check, and routing them through the ASGI stack
|
||||
would only add ways for the test to pass for the wrong reason.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import auth, feature_flags
|
||||
from app.routers import admin
|
||||
|
||||
AGENT_KEY = "agent-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
BOT_KEY = "bot-key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
|
||||
def _creds(token: str) -> HTTPAuthorizationCredentials:
|
||||
return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keys(monkeypatch):
|
||||
"""Both keys configured and different — the production shape."""
|
||||
monkeypatch.setattr(settings, "agent_service_key", AGENT_KEY, raising=False)
|
||||
monkeypatch.setattr(settings, "service_key", BOT_KEY, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_flag_cache():
|
||||
"""feature_flags keeps module-level cache state; don't leak it across tests."""
|
||||
feature_flags._cache = {}
|
||||
feature_flags._cache_ts = 0.0
|
||||
yield
|
||||
feature_flags._cache = {}
|
||||
feature_flags._cache_ts = 0.0
|
||||
|
||||
|
||||
# ── Item 1: the credential ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_key_is_accepted_and_identifies_itself(keys):
|
||||
principal = await auth.require_agent_key_or_admin(_creds(AGENT_KEY))
|
||||
assert principal["principal"] == "agent"
|
||||
# The caller must be able to tell the agent from a human admin, or the
|
||||
# audit entry in item 3 cannot name the actor.
|
||||
assert auth.describe_actor(principal) == (
|
||||
auth.AGENT_PRINCIPAL_UID, auth.AGENT_PRINCIPAL_EMAIL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_bot_service_key_is_rejected(keys):
|
||||
"""The whole point of a second key: the bot's key must not open this door.
|
||||
|
||||
It falls through to the Firebase branch and fails there, so the bot gets a
|
||||
401 rather than an unattributable flag flip.
|
||||
"""
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(_creds(BOT_KEY))
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unset_agent_key_cannot_be_bypassed(monkeypatch):
|
||||
"""An unconfigured key must match nothing — especially not an empty string.
|
||||
|
||||
``secrets.compare_digest("", "")`` is a match, so the guard has to be on
|
||||
the key being configured, not on a ``or ""`` fallback.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "agent_service_key", None, raising=False)
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
|
||||
for token in ("", " ", "None", "null", AGENT_KEY):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(_creds(token))
|
||||
assert exc.value.status_code == 401, token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_agent_key_cannot_be_bypassed(monkeypatch):
|
||||
"""Same guarantee for a key set to "" by an empty env var."""
|
||||
monkeypatch.setattr(settings, "agent_service_key", "", raising=False)
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(_creds(""))
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firebase_admin_token_still_works(keys):
|
||||
decoded = {"uid": "u-1", "email": "admin@example.com", "role": "admin"}
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", return_value=decoded):
|
||||
principal = await auth.require_agent_key_or_admin(_creds("firebase-id-token"))
|
||||
assert principal == decoded
|
||||
assert auth.describe_actor(principal) == ("u-1", "admin@example.com")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_firebase_token_is_forbidden(keys):
|
||||
decoded = {"uid": "u-2", "email": "viewer@example.com", "role": "viewer"}
|
||||
with patch.object(auth.firebase_auth, "verify_id_token", return_value=decoded):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(_creds("firebase-id-token"))
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_credentials_is_401(keys):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await auth.require_agent_key_or_admin(None)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_features_routes_use_the_agent_dependency_and_others_do_not():
|
||||
"""Guards the wiring: only /admin/features moved off require_admin_token."""
|
||||
def deps(path, method):
|
||||
for r in admin.router.routes:
|
||||
if r.path == path and method in r.methods:
|
||||
return {d.call for d in r.dependant.dependencies}
|
||||
raise AssertionError(f"no route {method} {path}")
|
||||
|
||||
assert auth.require_agent_key_or_admin in deps("/admin/features", "GET")
|
||||
assert auth.require_agent_key_or_admin in deps("/admin/features", "PUT")
|
||||
assert auth.require_admin_token in deps("/admin/audit", "GET")
|
||||
assert auth.require_admin_token in deps("/admin/debug/correlation", "GET")
|
||||
|
||||
|
||||
# ── Items 3 and 4: set_flags audits, and cascades on request ──────────────────
|
||||
|
||||
def _fstore_mock(stored: dict, systems: list[dict], updates_sink: list):
|
||||
"""A Firestore stand-in for feature_flags: one config doc, N system docs."""
|
||||
mock = AsyncMock()
|
||||
|
||||
async def doc_get(collection, doc_id):
|
||||
return dict(stored) if collection == "config" else None
|
||||
|
||||
async def doc_set(collection, doc_id, data, merge=True):
|
||||
stored.update(data)
|
||||
|
||||
async def collection_list(collection, **filters):
|
||||
return systems if collection == "systems" else []
|
||||
|
||||
async def doc_update(collection, doc_id, data):
|
||||
updates_sink.append((collection, doc_id, data))
|
||||
|
||||
mock.doc_get = AsyncMock(side_effect=doc_get)
|
||||
mock.doc_set = AsyncMock(side_effect=doc_set)
|
||||
mock.collection_list = AsyncMock(side_effect=collection_list)
|
||||
mock.doc_update = AsyncMock(side_effect=doc_update)
|
||||
return mock
|
||||
|
||||
|
||||
def _systems():
|
||||
return [
|
||||
# Two systems carry overrides today; the ids are irrelevant to the
|
||||
# helper and must stay that way.
|
||||
{"system_id": "sys-a", "ai_flags": {"stt_enabled": False, "correlation_enabled": False}},
|
||||
{"system_id": "sys-b", "ai_flags": {"stt_enabled": False}},
|
||||
# Carries the map but not the key being flipped — must be left alone.
|
||||
{"system_id": "sys-c", "ai_flags": {"summaries_enabled": False}},
|
||||
# No overrides at all: already inherits, nothing to cascade to.
|
||||
{"system_id": "sys-d"},
|
||||
{"system_id": "sys-e", "ai_flags": {}},
|
||||
]
|
||||
|
||||
|
||||
async def _run_set_flags(updates, *, stored=None, systems=None, cascade=False, actor=None,
|
||||
audit_side_effect=None):
|
||||
stored = stored if stored is not None else {"stt_enabled": True, "correlation_enabled": True}
|
||||
systems = systems if systems is not None else _systems()
|
||||
updates_sink: list = []
|
||||
audit_mock = AsyncMock(side_effect=audit_side_effect)
|
||||
with patch.object(feature_flags, "fstore", _fstore_mock(stored, systems, updates_sink)), \
|
||||
patch("app.internal.audit.write_audit", new=audit_mock):
|
||||
result = await feature_flags.set_flags(updates, actor=actor, cascade=cascade)
|
||||
return result, stored, updates_sink, audit_mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_flags_is_backward_compatible_without_actor_or_cascade():
|
||||
"""Existing call shape — set_flags({...}) — must keep working."""
|
||||
result, stored, updates_sink, audit_mock = await _run_set_flags({"stt_enabled": False})
|
||||
assert result["stt_enabled"] is False
|
||||
assert stored["stt_enabled"] is False
|
||||
assert updates_sink == [] # no cascade unless asked
|
||||
assert audit_mock.await_count == 1 # but still audited
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_records_before_and_after_and_the_actor():
|
||||
_, _, _, audit_mock = await _run_set_flags(
|
||||
{"stt_enabled": False},
|
||||
actor=(auth.AGENT_PRINCIPAL_UID, auth.AGENT_PRINCIPAL_EMAIL),
|
||||
)
|
||||
kwargs = audit_mock.await_args.kwargs
|
||||
assert kwargs["action"] == "feature_flags.update"
|
||||
assert kwargs["actor_uid"] == auth.AGENT_PRINCIPAL_UID
|
||||
assert kwargs["actor_email"] == auth.AGENT_PRINCIPAL_EMAIL
|
||||
details = kwargs["details"]
|
||||
assert details["changed"]["stt_enabled"] == {"from": True, "to": False}
|
||||
assert details["before"]["stt_enabled"] is True
|
||||
assert details["after"]["stt_enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_failure_neither_loses_the_write_nor_raises():
|
||||
"""audit_log is a record OF the write, never a precondition for it."""
|
||||
result, stored, _, audit_mock = await _run_set_flags(
|
||||
{"stt_enabled": False},
|
||||
audit_side_effect=RuntimeError("firestore down"),
|
||||
)
|
||||
assert audit_mock.await_count == 1
|
||||
assert stored["stt_enabled"] is False # flag write survived
|
||||
assert result["stt_enabled"] is False # and the route returns normally
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_clears_matching_system_overrides_at_both_levels():
|
||||
result, stored, updates_sink, audit_mock = await _run_set_flags(
|
||||
{"stt_enabled": True}, cascade=True,
|
||||
)
|
||||
# Global level.
|
||||
assert stored["stt_enabled"] is True
|
||||
assert result["stt_enabled"] is True
|
||||
# System level: only the two documents whose ai_flags carry stt_enabled.
|
||||
written = {sid: data["ai_flags"] for _, sid, data in updates_sink}
|
||||
assert set(written) == {"sys-a", "sys-b"}
|
||||
# The flipped key is removed so the system inherits; unrelated overrides stay.
|
||||
assert written["sys-a"] == {"correlation_enabled": False}
|
||||
assert written["sys-b"] == {}
|
||||
# And the cascade is recorded, per system, in the audit entry.
|
||||
cascaded = audit_mock.await_args.kwargs["details"]["cascaded_systems"]
|
||||
assert {c["system_id"] for c in cascaded} == {"sys-a", "sys-b"}
|
||||
assert cascaded[0]["cleared_overrides"] == {"stt_enabled": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_finds_systems_by_shape_not_by_hardcoded_id():
|
||||
"""A newly added system carrying an override must not defeat a flip."""
|
||||
systems = _systems() + [{"system_id": "sys-new", "ai_flags": {"stt_enabled": False}}]
|
||||
_, _, updates_sink, _ = await _run_set_flags(
|
||||
{"stt_enabled": True}, systems=systems, cascade=True,
|
||||
)
|
||||
assert "sys-new" in {sid for _, sid, _ in updates_sink}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_off_leaves_every_system_override_intact():
|
||||
"""The default path must not silently erase a deliberate per-system value."""
|
||||
_, _, updates_sink, _ = await _run_set_flags({"stt_enabled": True}, cascade=False)
|
||||
assert updates_sink == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_error_on_one_system_does_not_stop_the_others():
|
||||
systems = _systems()
|
||||
stored = {"stt_enabled": True, "correlation_enabled": True}
|
||||
updates_sink: list = []
|
||||
fs = _fstore_mock(stored, systems, updates_sink)
|
||||
real_update = fs.doc_update.side_effect
|
||||
|
||||
async def flaky(collection, doc_id, data):
|
||||
if doc_id == "sys-a":
|
||||
raise RuntimeError("write conflict")
|
||||
return await real_update(collection, doc_id, data)
|
||||
|
||||
fs.doc_update = AsyncMock(side_effect=flaky)
|
||||
audit_mock = AsyncMock()
|
||||
with patch.object(feature_flags, "fstore", fs), \
|
||||
patch("app.internal.audit.write_audit", new=audit_mock):
|
||||
await feature_flags.set_flags({"stt_enabled": True}, cascade=True)
|
||||
|
||||
assert [sid for _, sid, _ in updates_sink] == ["sys-b"]
|
||||
details = audit_mock.await_args.kwargs["details"]
|
||||
assert [e["system_id"] for e in details["cascade_errors"]] == ["sys-a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrecognised_keys_still_raise():
|
||||
with pytest.raises(ValueError):
|
||||
await _run_set_flags({"not_a_flag": True})
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
The AI feature flags have to be an enforceable statement about the system,
|
||||
not just about the ingest path (server-26#75, server-26#76).
|
||||
|
||||
Three defects motivate these tests:
|
||||
|
||||
#75 Correlation read the raw global config/ai_features flag instead of the
|
||||
per-system resolution, so a system that had opted out via its own
|
||||
ai_flags still correlated -- with empty tags, down the thin/recency
|
||||
path, blindly attaching to whatever incident was most recent.
|
||||
|
||||
#76 Transcript correction and the transcript-PATCH extraction path checked
|
||||
no Firestore flag at all, so "AI is off" still spent money.
|
||||
|
||||
Plus the destructive half of PATCH /calls/{id}/transcript, which wipes a
|
||||
call's intelligence fields on the promise that re-extraction rebuilds them.
|
||||
|
||||
Firestore and the lazily-imported pipeline modules are fully mocked; the
|
||||
functions are called directly rather than through FastAPI.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers import upload, calls
|
||||
from app.internal import summarizer, transcription
|
||||
|
||||
|
||||
ALL_ON = {
|
||||
"stt_enabled": True,
|
||||
"correlation_enabled": True,
|
||||
"summaries_enabled": True,
|
||||
"vocabulary_learning_enabled": True,
|
||||
"transcript_correction_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def _flags(**overrides):
|
||||
return {**ALL_ON, **overrides}
|
||||
|
||||
|
||||
def _system(ai_flags):
|
||||
return {"system_id": "sys-1", "ai_flags": ai_flags or {}}
|
||||
|
||||
|
||||
def _patch_flags(global_flags, system_ai_flags):
|
||||
"""Patch the two reads resolve_flags() makes: the global doc and the system doc."""
|
||||
return (
|
||||
patch("app.internal.feature_flags.get_flags", AsyncMock(return_value=global_flags)),
|
||||
patch("app.internal.firestore.doc_get_cached",
|
||||
AsyncMock(return_value=_system(system_ai_flags))),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# resolve_flags: global master off beats everything, system false beats
|
||||
# global true, absent system key inherits global.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"global_on, system_ai_flags, expected",
|
||||
[
|
||||
(True, {"correlation_enabled": False}, False), # #75: system opt-out holds
|
||||
(True, {}, True), # absent -> inherit global
|
||||
(True, {"correlation_enabled": True}, True),
|
||||
(False, {"correlation_enabled": True}, False), # global is the master switch
|
||||
(False, {}, False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_flags_precedence(global_on, system_ai_flags, expected):
|
||||
g, sysdoc = _patch_flags(_flags(correlation_enabled=global_on), system_ai_flags)
|
||||
with g, sysdoc:
|
||||
_, flag = await upload._resolve_flags("sys-1")
|
||||
|
||||
assert flag("correlation_enabled") is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_flags_without_a_system_id_does_not_read_the_system_doc():
|
||||
with patch("app.internal.feature_flags.get_flags", AsyncMock(return_value=_flags())), \
|
||||
patch("app.internal.firestore.doc_get_cached", AsyncMock()) as cached:
|
||||
_, flag = await upload._resolve_flags(None)
|
||||
|
||||
assert flag("correlation_enabled") is True
|
||||
cached.assert_not_awaited()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The ingest path. This is the exact shape of #75: with the global on and the
|
||||
# system opted out, extraction was skipped but the empty-scenes fallback still
|
||||
# ran, correlating the call with no tags and attaching it to whatever incident
|
||||
# was most recent on that system.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def _run_ingest(global_correlation, system_ai_flags):
|
||||
g, sysdoc = _patch_flags(
|
||||
_flags(correlation_enabled=global_correlation), system_ai_flags
|
||||
)
|
||||
with g, sysdoc, \
|
||||
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=("units respond to main street", []))), \
|
||||
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])) as scenes, \
|
||||
patch("app.internal.alerter.check_and_dispatch", AsyncMock()):
|
||||
fs.doc_get = AsyncMock(return_value={})
|
||||
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 scenes, corr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_system_opt_out_blocks_the_blind_recency_fallback_too():
|
||||
scenes, corr = await _run_ingest(True, {"correlation_enabled": False})
|
||||
|
||||
scenes.assert_not_awaited()
|
||||
# The regression that mattered: the no-scenes fallback correlating on empty tags.
|
||||
corr.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_correlates_when_the_system_has_not_opted_out():
|
||||
scenes, corr = await _run_ingest(True, {})
|
||||
|
||||
scenes.assert_awaited_once()
|
||||
corr.assert_awaited_once()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# _run_extraction_pipeline -- the transcript-PATCH path (#76).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def _run_extraction(global_correlation, system_ai_flags=None):
|
||||
g, sysdoc = _patch_flags(
|
||||
_flags(correlation_enabled=global_correlation), system_ai_flags
|
||||
)
|
||||
with g, sysdoc, \
|
||||
patch.object(upload, "fstore") as fs, \
|
||||
patch("app.internal.intelligence.extract_scenes", AsyncMock(return_value=[])) as scenes, \
|
||||
patch("app.internal.alerter.check_and_dispatch", AsyncMock()) as alert:
|
||||
fs.doc_set = AsyncMock()
|
||||
await upload._run_extraction_pipeline(
|
||||
call_id="call-1",
|
||||
node_id="node-1",
|
||||
system_id="sys-1",
|
||||
talkgroup_id=101,
|
||||
talkgroup_name="PD Dispatch",
|
||||
transcript="units respond to main street",
|
||||
)
|
||||
return scenes, alert, fs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extraction_does_not_spend_when_correlation_is_off():
|
||||
scenes, alert, fs = await _run_extraction(False)
|
||||
|
||||
scenes.assert_not_awaited()
|
||||
# No incidents produced, so nothing may be stamped onto the call doc.
|
||||
fs.doc_set.assert_not_awaited()
|
||||
# Alerting is rule-based and free -- it still runs.
|
||||
alert.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extraction_respects_a_per_system_opt_out():
|
||||
scenes, _alert, _fs = await _run_extraction(True, {"correlation_enabled": False})
|
||||
|
||||
scenes.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extraction_runs_when_the_flag_is_on():
|
||||
scenes, _alert, _fs = await _run_extraction(True)
|
||||
|
||||
scenes.assert_awaited_once()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PATCH /calls/{id}/transcript is destructive before it is constructive.
|
||||
# With correlation off it must refuse rather than blank the call out.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_patch_refuses_when_correlation_is_off():
|
||||
g, sysdoc = _patch_flags(_flags(correlation_enabled=False), {})
|
||||
with g, sysdoc, patch.object(calls, "fstore") as fs:
|
||||
fs.doc_get = AsyncMock(return_value={"call_id": "call-1", "system_id": "sys-1"})
|
||||
fs.doc_set = AsyncMock()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await calls.patch_transcript(
|
||||
call_id="call-1",
|
||||
body=MagicMock(transcript="corrected text"),
|
||||
background_tasks=MagicMock(),
|
||||
_={},
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
# The refusal has to land before the first write, or the call is already ruined.
|
||||
fs.doc_set.assert_not_awaited()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Transcript correction is a second model call plus a Places lookup (#76).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_correction_is_skipped_when_its_flag_is_off():
|
||||
g, sysdoc = _patch_flags(_flags(transcript_correction_enabled=False), {})
|
||||
with g, sysdoc, \
|
||||
patch.object(transcription, "fstore") as fs, \
|
||||
patch.object(transcription, "ai_health") as health, \
|
||||
patch.object(transcription, "transcript_correction") as tc, \
|
||||
patch("asyncio.to_thread", AsyncMock(return_value=("units respond", [], False))):
|
||||
fs.doc_set = AsyncMock()
|
||||
health.report_healthy = AsyncMock()
|
||||
health.report_failure = AsyncMock()
|
||||
tc.correct = AsyncMock()
|
||||
await transcription.transcribe_call(
|
||||
"call-1", "gs://bucket/call-1.mp3", "PD Dispatch", system_id="sys-1"
|
||||
)
|
||||
|
||||
tc.correct.assert_not_awaited()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Summarizer: the flag guards model spend, not the free Firestore sweep.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarize_incident_is_a_no_op_when_summaries_are_off():
|
||||
with patch("app.internal.feature_flags.get_flags",
|
||||
AsyncMock(return_value=_flags(summaries_enabled=False))), \
|
||||
patch.object(summarizer, "fstore") as fs, \
|
||||
patch.object(summarizer, "_sync_summarize") as sync:
|
||||
fs.doc_get = AsyncMock()
|
||||
fs.doc_set = AsyncMock()
|
||||
await summarizer._summarize_incident(
|
||||
{"incident_id": "inc-1", "call_ids": ["call-1"]}
|
||||
)
|
||||
|
||||
sync.assert_not_called()
|
||||
fs.doc_get.assert_not_awaited()
|
||||
fs.doc_set.assert_not_awaited()
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Unit tests for app.internal.ai_health — the shared AI-provider degradation
|
||||
registry added for logan/server-26#14 (no alerting when a provider account
|
||||
runs dry or a model is retired).
|
||||
|
||||
Covers:
|
||||
* classify() telling a permanent condition (dead model, depleted billing —
|
||||
both of which can arrive as the same HTTP status a rate limit uses) apart
|
||||
from a transient one.
|
||||
* report_degraded() alerting immediately for a permanent condition but only
|
||||
after TRANSIENT_ALERT_THRESHOLD consecutive failures for a transient one.
|
||||
* Alerting exactly once per episode, not once per call, and again exactly
|
||||
once on recovery.
|
||||
* report_healthy() clearing degraded state so a later re-degradation can
|
||||
alert again (a fresh episode, not a continuation of the old one).
|
||||
|
||||
The Discord webhook is patched at ai_health._post_webhook so no real HTTP is
|
||||
made; settings.ai_alert_webhook_url is irrelevant to these tests since
|
||||
_post_webhook itself is replaced.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.internal import ai_health
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state():
|
||||
"""Every test gets a clean registry — module-level state persists otherwise."""
|
||||
ai_health._state = {t: ai_health._default_state() for t in ai_health.TIERS}
|
||||
yield
|
||||
ai_health._state = {t: ai_health._default_state() for t in ai_health.TIERS}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# classify()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_classify_dead_model_404():
|
||||
assert ai_health.classify("404 models/gemini-2.0-flash is not found") == "dead_model"
|
||||
|
||||
|
||||
def test_classify_dead_model_no_longer_available():
|
||||
assert ai_health.classify("this model is no longer available") == "dead_model"
|
||||
|
||||
|
||||
def test_classify_billing_depleted_credits():
|
||||
# The exact wording that bit the Gemini correlator on 2026-08-18.
|
||||
assert ai_health.classify("429 prepayment credits are depleted") == "billing"
|
||||
|
||||
|
||||
def test_classify_billing_openai_insufficient_quota():
|
||||
assert ai_health.classify("Error: insufficient_quota — exceeded your current quota") == "billing"
|
||||
|
||||
|
||||
def test_classify_ordinary_rate_limit_is_transient():
|
||||
# Same HTTP status (429) as the depleted-balance case, but no billing
|
||||
# wording — this must NOT be classified as billing.
|
||||
assert ai_health.classify("429 Too Many Requests, please retry later") == "transient"
|
||||
|
||||
|
||||
def test_classify_network_error_is_transient():
|
||||
assert ai_health.classify("Connection reset by peer") == "transient"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# report_degraded — permanent alerts immediately
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_permanent_failure_alerts_on_first_occurrence():
|
||||
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
|
||||
await ai_health.report_degraded(
|
||||
"correlation_cheap", "gemini", "gemini-2.0-flash",
|
||||
"model is unavailable", "update the model ID", permanent=True,
|
||||
)
|
||||
webhook.assert_awaited_once()
|
||||
state = ai_health.snapshot()["correlation_cheap"]
|
||||
assert state["degraded"] is True
|
||||
assert state["alerted"] is True
|
||||
assert state["permanent"] is True
|
||||
|
||||
|
||||
async def test_permanent_failure_alerts_only_once_per_episode():
|
||||
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
|
||||
for _ in range(5):
|
||||
await ai_health.report_degraded(
|
||||
"correlation_cheap", "gemini", "gemini-2.0-flash",
|
||||
"model is unavailable", "update the model ID", permanent=True,
|
||||
)
|
||||
# Once per episode, not once per call — this runs at radio-traffic volume.
|
||||
webhook.assert_awaited_once()
|
||||
assert ai_health.snapshot()["correlation_cheap"]["consecutive_failures"] == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# report_degraded — transient only alerts once it persists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_transient_failure_does_not_alert_below_threshold():
|
||||
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
|
||||
for _ in range(ai_health.TRANSIENT_ALERT_THRESHOLD - 1):
|
||||
await ai_health.report_degraded(
|
||||
"transcription", "openai", "whisper-1",
|
||||
"transient API error", "no action needed unless this persists",
|
||||
permanent=False,
|
||||
)
|
||||
webhook.assert_not_awaited()
|
||||
state = ai_health.snapshot()["transcription"]
|
||||
assert state["degraded"] is False
|
||||
assert state["alerted"] is False
|
||||
|
||||
|
||||
async def test_transient_failure_alerts_once_threshold_crossed():
|
||||
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
|
||||
for _ in range(ai_health.TRANSIENT_ALERT_THRESHOLD):
|
||||
await ai_health.report_degraded(
|
||||
"transcription", "openai", "whisper-1",
|
||||
"transient API error", "no action needed unless this persists",
|
||||
permanent=False,
|
||||
)
|
||||
webhook.assert_awaited_once()
|
||||
|
||||
# Further failures in the same episode must not re-alert.
|
||||
await ai_health.report_degraded(
|
||||
"transcription", "openai", "whisper-1",
|
||||
"transient API error", "no action needed unless this persists",
|
||||
permanent=False,
|
||||
)
|
||||
webhook.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# report_healthy — recovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_recovery_alerts_once_after_an_alerted_episode():
|
||||
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
|
||||
await ai_health.report_degraded(
|
||||
"correlation_smart", "gemini", "gemini-1.5-pro",
|
||||
"the Gemini account is out of credit", "top up billing", permanent=True,
|
||||
)
|
||||
webhook.reset_mock()
|
||||
|
||||
await ai_health.report_healthy("correlation_smart")
|
||||
|
||||
webhook.assert_awaited_once()
|
||||
state = ai_health.snapshot()["correlation_smart"]
|
||||
assert state["degraded"] is False
|
||||
assert state["alerted"] is False
|
||||
assert state["consecutive_failures"] == 0
|
||||
|
||||
|
||||
async def test_recovery_from_never_alerted_transient_state_is_silent():
|
||||
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
|
||||
# Two failures — below the transient threshold, never alerted.
|
||||
await ai_health.report_degraded(
|
||||
"extraction", "gemini", "gemini-3.6-flash",
|
||||
"transient API error", "no action needed unless this persists",
|
||||
permanent=False,
|
||||
)
|
||||
await ai_health.report_degraded(
|
||||
"extraction", "gemini", "gemini-3.6-flash",
|
||||
"transient API error", "no action needed unless this persists",
|
||||
permanent=False,
|
||||
)
|
||||
webhook.reset_mock()
|
||||
|
||||
await ai_health.report_healthy("extraction")
|
||||
|
||||
# Nothing was ever posted for this episode, so recovery posts nothing either.
|
||||
webhook.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_recovery_then_re_degradation_alerts_again_as_a_new_episode():
|
||||
with patch.object(ai_health, "_post_webhook", new=AsyncMock()) as webhook:
|
||||
await ai_health.report_degraded(
|
||||
"correlation_cheap", "gemini", "gemini-2.0-flash",
|
||||
"model is unavailable", "update the model ID", permanent=True,
|
||||
)
|
||||
await ai_health.report_healthy("correlation_cheap")
|
||||
webhook.reset_mock()
|
||||
|
||||
# A second, later episode must alert on its own first occurrence.
|
||||
await ai_health.report_degraded(
|
||||
"correlation_cheap", "gemini", "gemini-2.0-flash",
|
||||
"model is unavailable", "update the model ID", permanent=True,
|
||||
)
|
||||
|
||||
webhook.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# snapshot()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_snapshot_reports_all_tiers_healthy_by_default():
|
||||
state = ai_health.snapshot()
|
||||
assert set(state.keys()) == set(ai_health.TIERS)
|
||||
for tier_state in state.values():
|
||||
assert tier_state["degraded"] is False
|
||||
assert tier_state["consecutive_failures"] == 0
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Alert payload redaction — server-26#85.
|
||||
|
||||
Board minutes #42 suppress person names on every surface until E&O is bound.
|
||||
A Discord webhook is the least recoverable surface the system has: once the
|
||||
text is in a channel we do not own it, cannot unsend it, and cannot audit who
|
||||
read it. These tests pin the default-closed behaviour so it cannot regress
|
||||
quietly the way it shipped.
|
||||
|
||||
The transcript below deliberately contains a person name; every assertion is
|
||||
"this string did not leave the process", not "some flag was set".
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import alerter
|
||||
|
||||
|
||||
TRANSCRIPT = "Units respond, subject identified as Michael Brennan, 42 Elm Street"
|
||||
ORG = "org-1"
|
||||
RULE = {
|
||||
"rule_id": "r1",
|
||||
"name": "Structure fire",
|
||||
"enabled": True,
|
||||
"keywords": ["respond"],
|
||||
"discord_webhook": "https://discord.example/webhook",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured(monkeypatch):
|
||||
"""Capture what alerter would write to Firestore and POST outbound."""
|
||||
saved: list[dict] = []
|
||||
posted: list[dict] = []
|
||||
|
||||
async def _doc_set(collection, doc_id, data, merge=False):
|
||||
saved.append(data)
|
||||
|
||||
async def _post(url, json=None, **kwargs):
|
||||
posted.append(json or {})
|
||||
|
||||
class _R:
|
||||
status_code = 204
|
||||
return _R()
|
||||
|
||||
monkeypatch.setattr(alerter.fstore, "doc_set", _doc_set)
|
||||
monkeypatch.setattr(
|
||||
alerter.fstore, "collection_list", AsyncMock(return_value=[dict(RULE)])
|
||||
)
|
||||
return saved, posted, _post
|
||||
|
||||
|
||||
async def _run(captured, org_doc):
|
||||
saved, posted, _post = captured
|
||||
with patch.object(
|
||||
alerter.fstore,
|
||||
"doc_get",
|
||||
AsyncMock(side_effect=lambda c, i: {"org_id": ORG} if c == "calls" else org_doc),
|
||||
):
|
||||
client = AsyncMock()
|
||||
client.post = _post
|
||||
with patch("httpx.AsyncClient") as ac:
|
||||
ac.return_value.__aenter__.return_value = client
|
||||
await alerter.check_and_dispatch(
|
||||
call_id="c1",
|
||||
node_id="n1",
|
||||
talkgroup_id=1,
|
||||
talkgroup_name="Fire Dispatch",
|
||||
tags=[],
|
||||
transcript=TRANSCRIPT,
|
||||
)
|
||||
return saved, posted
|
||||
|
||||
|
||||
def _blob(payloads) -> str:
|
||||
return " ".join(str(p) for p in payloads)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_carries_no_transcript_by_default(captured):
|
||||
"""The shipped default must not put raw transcript text on the wire."""
|
||||
saved, posted = await _run(captured, {})
|
||||
|
||||
assert posted, "the webhook should still fire — alerting is not disabled, only the text is"
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert "Elm Street" not in _blob(posted)
|
||||
# The alert is still useful: it names the rule and the talkgroup.
|
||||
assert "Structure fire" in _blob(posted)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_event_stores_no_transcript_by_default(captured):
|
||||
"""Firestore is a surface too — the frontend reads it directly."""
|
||||
saved, _ = await _run(captured, {})
|
||||
|
||||
assert saved, "the alert event should still be recorded"
|
||||
assert saved[0]["transcript_snippet"] is None
|
||||
assert "Michael Brennan" not in _blob(saved)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_opt_in_alone_does_not_open_the_gate(captured):
|
||||
"""
|
||||
An org owner writing their own org document must not be able to opt
|
||||
themselves into receiving somebody else's PII. The operator switch is
|
||||
the control; the org flag is only consent.
|
||||
"""
|
||||
assert settings.alert_transcript_snippet_enabled is False
|
||||
saved, posted = await _run(captured, {"alert_snippet_opt_in": True})
|
||||
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert "Michael Brennan" not in _blob(saved)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_gates_open_emits_the_snippet(monkeypatch, captured):
|
||||
"""The opt-in path still works, so this is a gate and not a deletion."""
|
||||
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
|
||||
saved, posted = await _run(captured, {"alert_snippet_opt_in": True})
|
||||
|
||||
assert "Michael Brennan" in _blob(posted)
|
||||
assert saved[0]["transcript_snippet"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_switch_alone_does_not_open_the_gate(monkeypatch, captured):
|
||||
"""Consent is required as well as capability."""
|
||||
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
|
||||
saved, posted = await _run(captured, {})
|
||||
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert saved[0]["transcript_snippet"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreadable_org_fails_closed(monkeypatch, captured):
|
||||
"""A Firestore error must withhold the transcript, not default to sending it."""
|
||||
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
|
||||
saved, posted, _post = captured
|
||||
|
||||
async def _doc_get(collection, doc_id):
|
||||
if collection == "calls":
|
||||
return {"org_id": ORG}
|
||||
raise RuntimeError("firestore unavailable")
|
||||
|
||||
with patch.object(alerter.fstore, "doc_get", _doc_get):
|
||||
client = AsyncMock()
|
||||
client.post = _post
|
||||
with patch("httpx.AsyncClient") as ac:
|
||||
ac.return_value.__aenter__.return_value = client
|
||||
await alerter.check_and_dispatch(
|
||||
call_id="c1", node_id="n1", talkgroup_id=1,
|
||||
talkgroup_name="Fire Dispatch", tags=[], transcript=TRANSCRIPT,
|
||||
)
|
||||
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert saved[0]["transcript_snippet"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_tenancy_call_with_no_org_fails_closed(monkeypatch, captured):
|
||||
"""A call with no org_id has nobody who could have consented to anything."""
|
||||
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
|
||||
saved, posted, _post = captured
|
||||
|
||||
with patch.object(alerter.fstore, "doc_get", AsyncMock(return_value={})):
|
||||
client = AsyncMock()
|
||||
client.post = _post
|
||||
with patch("httpx.AsyncClient") as ac:
|
||||
ac.return_value.__aenter__.return_value = client
|
||||
await alerter.check_and_dispatch(
|
||||
call_id="c1", node_id="n1", talkgroup_id=1,
|
||||
talkgroup_name="Fire Dispatch", tags=[], transcript=TRANSCRIPT,
|
||||
)
|
||||
|
||||
assert "Michael Brennan" not in _blob(posted)
|
||||
assert saved[0]["transcript_snippet"] is None
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Unit tests for the area_context schema and anchor (server-26#36).
|
||||
|
||||
Three properties carry the real risk:
|
||||
|
||||
* NULLABILITY IS THE MECHANISM. Which scope an operator fills is their
|
||||
declaration of how homogeneous the system is. Merging must let a talkgroup
|
||||
narrow the system without dropping what the system already said — a
|
||||
talkgroup that sets only a town must still inherit the state, or "Ossining"
|
||||
is nationally ambiguous again.
|
||||
|
||||
* NO ANCHOR IS BETTER THAN A USELESS ONE. An anchor wider than
|
||||
area_anchor_max_radius_km, or one whose resolved_from no longer matches the
|
||||
place it came from, must read as ABSENT. Verification then skips. Treating
|
||||
either as usable would rubber-stamp any location while looking like a check.
|
||||
|
||||
* THE CLIENT DOES NOT WRITE SERVER FIELDS. The systems form sends
|
||||
config.talkgroups[] in full; taking it verbatim destroys the resolved anchor
|
||||
and the pending queue, which is the same bug as the ten_codes wipe.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.internal import area_context as ac
|
||||
|
||||
SYSTEM_AREA = {
|
||||
"county": "Westchester",
|
||||
"state": "New York",
|
||||
"local_knowledge": [{"term": "Route 9", "meaning": "state highway"}],
|
||||
"center": {"lat": 41.1, "lng": -73.8},
|
||||
"radius_km": 30.0,
|
||||
"resolved_from": "|westchester|new york",
|
||||
"resolved_at": "2026-08-23T00:00:00+00:00",
|
||||
}
|
||||
|
||||
TG_AREA = {
|
||||
"municipality": "Ossining",
|
||||
"local_knowledge": [{"term": "Sing Sing", "meaning": "state prison"}],
|
||||
"center": {"lat": 41.16, "lng": -73.86},
|
||||
"radius_km": 6.0,
|
||||
"resolved_from": "ossining|westchester|new york",
|
||||
"resolved_at": "2026-08-23T00:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
# -- Merging -------------------------------------------------------------------
|
||||
|
||||
def test_talkgroup_narrows_without_dropping_the_system():
|
||||
merged = ac.effective(SYSTEM_AREA, TG_AREA)
|
||||
assert merged["municipality"] == "Ossining"
|
||||
assert merged["county"] == "Westchester"
|
||||
assert merged["state"] == "New York", "the state must survive the narrowing"
|
||||
|
||||
|
||||
def test_talkgroup_knowledge_ranks_first_and_dedupes():
|
||||
system = {"local_knowledge": [{"term": "Route 9"}, {"term": "Metro-North"}]}
|
||||
tg = {"local_knowledge": [{"term": "route 9", "meaning": "the local name"}]}
|
||||
merged = ac.effective(system, tg)
|
||||
assert [e["term"] for e in merged["local_knowledge"]] == ["route 9", "Metro-North"]
|
||||
assert merged["local_knowledge"][0]["meaning"] == "the local name"
|
||||
|
||||
|
||||
def test_empty_at_both_scopes_is_legal():
|
||||
assert ac.effective(None, None) == {}
|
||||
assert ac.effective({}, {}) == {}
|
||||
|
||||
|
||||
def test_bare_strings_are_accepted_as_terms():
|
||||
"""roads[]/landmarks[] from the old shape, and anything a model returns."""
|
||||
assert ac.normalize_local_knowledge(["Route 9", "", "Route 9", 7]) == [{"term": "Route 9"}]
|
||||
|
||||
|
||||
def test_pre_36_roads_and_landmarks_are_read_forward():
|
||||
"""
|
||||
Real systems still have the old shape stored. Dropping it the day this
|
||||
shipped would silently discard ground truth an operator already entered.
|
||||
"""
|
||||
legacy = {"county": "Westchester", "roads": ["Route 9"], "landmarks": ["Sing Sing"]}
|
||||
merged = ac.effective(legacy, None)
|
||||
assert [e["term"] for e in merged["local_knowledge"]] == ["Route 9", "Sing Sing"]
|
||||
assert ac.normalize(legacy) == {
|
||||
"county": "Westchester",
|
||||
"local_knowledge": [{"term": "Route 9"}, {"term": "Sing Sing"}],
|
||||
}, "and the next save writes them in the new shape"
|
||||
|
||||
|
||||
def test_normalize_drops_client_sent_server_fields():
|
||||
out = ac.normalize({"municipality": " Ossining ", "radius_km": 5000, "center": {"lat": 0}})
|
||||
assert out == {"municipality": "Ossining"}
|
||||
|
||||
|
||||
# -- Anchor selection ----------------------------------------------------------
|
||||
|
||||
def test_talkgroup_anchor_wins():
|
||||
anchor = ac.anchor_for(SYSTEM_AREA, TG_AREA)
|
||||
assert anchor == {"lat": 41.16, "lng": -73.86, "radius_km": 6.0}
|
||||
|
||||
|
||||
def test_system_anchor_used_when_talkgroup_sets_no_place():
|
||||
anchor = ac.anchor_for(SYSTEM_AREA, {"local_knowledge": [{"term": "Post 4"}]})
|
||||
assert anchor == {"lat": 41.1, "lng": -73.8, "radius_km": 30.0}
|
||||
|
||||
|
||||
def test_no_anchor_when_nothing_is_configured():
|
||||
assert ac.anchor_for({}, {}) is None
|
||||
|
||||
|
||||
def test_stale_anchor_reads_as_absent():
|
||||
"""
|
||||
Someone edited the town and the refresh has not run yet. The stored centre
|
||||
is for the OLD place, so using it would validate locations against an area
|
||||
the channel no longer covers.
|
||||
"""
|
||||
stale = {**TG_AREA, "municipality": "Croton"}
|
||||
assert ac.anchor_for(SYSTEM_AREA, stale) is None
|
||||
|
||||
|
||||
def test_anchor_key_ignores_case_and_padding():
|
||||
assert ac.anchor_key({"municipality": " OSSINING "}) == ac.anchor_key({"municipality": "ossining"})
|
||||
|
||||
|
||||
# -- Anchor resolution ---------------------------------------------------------
|
||||
|
||||
def _maps(viewport_span_deg: float):
|
||||
"""A geocode response whose viewport spans roughly the given degrees."""
|
||||
payload = {
|
||||
"status": "OK",
|
||||
"results": [{
|
||||
"geometry": {
|
||||
"location": {"lat": 41.0, "lng": -73.0},
|
||||
"viewport": {
|
||||
"northeast": {"lat": 41.0 + viewport_span_deg, "lng": -73.0 + viewport_span_deg},
|
||||
"southwest": {"lat": 41.0 - viewport_span_deg, "lng": -73.0 - viewport_span_deg},
|
||||
},
|
||||
}
|
||||
}],
|
||||
}
|
||||
|
||||
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 _clear_cache():
|
||||
ac._anchor_cache.clear()
|
||||
with patch.object(ac.settings, "google_maps_api_key", "test-key"):
|
||||
yield
|
||||
ac._anchor_cache.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_place_produces_an_anchor():
|
||||
with _maps(0.05):
|
||||
anchor = await ac.resolve_anchor({"municipality": "Ossining", "state": "New York"})
|
||||
assert anchor is not None
|
||||
assert anchor["radius_km"] < 10
|
||||
assert anchor["resolved_from"] == "ossining||new york"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_statewide_place_produces_no_anchor():
|
||||
"""
|
||||
A radius that covers a state would confirm any location inside it. Storing
|
||||
it would make the geocode check worse than useless — it would look like
|
||||
verification and pass everything.
|
||||
"""
|
||||
with _maps(4.0), patch.object(ac.settings, "area_anchor_max_radius_km", 60.0):
|
||||
assert await ac.resolve_anchor({"state": "Colorado"}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_place_never_calls_maps():
|
||||
with patch("httpx.AsyncClient") as client:
|
||||
assert await ac.resolve_anchor({"local_knowledge": [{"term": "Post 4"}]}) is None
|
||||
client.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_skips_scopes_whose_place_is_unchanged():
|
||||
doc = {"area_context": SYSTEM_AREA, "config": {"talkgroups": [{"id": 1, "area_context": TG_AREA}]}}
|
||||
with patch("httpx.AsyncClient") as client:
|
||||
assert await ac.refresh_anchors(doc) == {}
|
||||
client.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editing_the_system_place_re_anchors_its_talkgroups():
|
||||
"""
|
||||
A talkgroup's anchor derives from its EFFECTIVE place, so changing the
|
||||
system's county silently changes what every talkgroup should be anchored to.
|
||||
"""
|
||||
doc = {
|
||||
"area_context": {"county": "Putnam", "state": "New York"},
|
||||
"config": {"talkgroups": [{"id": 1, "area_context": {"municipality": "Ossining"}}]},
|
||||
}
|
||||
with _maps(0.05):
|
||||
patch_out = await ac.refresh_anchors(doc)
|
||||
tg = patch_out["config"]["talkgroups"][0]
|
||||
assert tg["area_context"]["resolved_from"] == "ossining|putnam|new york"
|
||||
assert tg["area_context"]["center"]["lat"] == 41.0
|
||||
|
||||
|
||||
# -- Client writes -------------------------------------------------------------
|
||||
|
||||
def test_merge_config_preserves_the_anchor_and_the_pending_queue():
|
||||
existing = {"talkgroups": [{
|
||||
"id": 9048,
|
||||
"area_context": TG_AREA,
|
||||
ac.PENDING_KEY: [{"term": "Snowden Avenue"}],
|
||||
}]}
|
||||
# What the systems form actually sends: no anchor, no pending queue.
|
||||
incoming = {"talkgroups": [{"id": 9048, "name": "Ossining PD",
|
||||
"area_context": {"municipality": "Ossining"}}]}
|
||||
merged = ac.merge_config(incoming, existing)
|
||||
tg = merged["talkgroups"][0]
|
||||
assert tg["area_context"]["center"] == TG_AREA["center"]
|
||||
assert tg[ac.PENDING_KEY] == [{"term": "Snowden Avenue"}]
|
||||
assert tg["name"] == "Ossining PD", "the client still owns the fields it owns"
|
||||
|
||||
|
||||
def test_merge_config_drops_an_emptied_area():
|
||||
existing = {"talkgroups": [{"id": 1, "area_context": TG_AREA}]}
|
||||
merged = ac.merge_config({"talkgroups": [{"id": 1}]}, existing)
|
||||
assert "area_context" not in merged["talkgroups"][0]
|
||||
|
||||
|
||||
# -- Pending terms -------------------------------------------------------------
|
||||
|
||||
def _store(doc):
|
||||
saved = {}
|
||||
|
||||
async def _get(_col, _id):
|
||||
return doc
|
||||
|
||||
async def _update(_col, _id, patch):
|
||||
saved.update(patch)
|
||||
|
||||
return saved, patch.multiple(
|
||||
"app.internal.firestore", doc_get=AsyncMock(side_effect=_get),
|
||||
doc_update=AsyncMock(side_effect=_update),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_terms_land_on_the_talkgroup():
|
||||
doc = {"config": {"talkgroups": [{"id": 9048}]}, "vocabulary": []}
|
||||
saved, store = _store(doc)
|
||||
with store:
|
||||
assert await ac.add_pending("sys-1", 9048, [{"term": "Snowden Avenue"}]) == 1
|
||||
assert saved["config"]["talkgroups"][0][ac.PENDING_KEY][0]["term"] == "Snowden Avenue"
|
||||
assert "vocabulary" not in saved, "nothing writes to the system"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_known_terms_are_not_re_proposed():
|
||||
doc = {
|
||||
"vocabulary": ["Metro-North"],
|
||||
"area_context": {"local_knowledge": [{"term": "Route 9"}]},
|
||||
"config": {"talkgroups": [{"id": 9048, "local_knowledge_pending": [{"term": "Sing Sing"}]}]},
|
||||
}
|
||||
saved, store = _store(doc)
|
||||
with store:
|
||||
queued = await ac.add_pending("sys-1", 9048, [
|
||||
{"term": "route 9"}, {"term": "Metro-North"}, {"term": "sing sing"},
|
||||
])
|
||||
assert queued == 0
|
||||
assert saved == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approving_writes_to_the_talkgroup_and_never_the_system():
|
||||
"""
|
||||
Blast radius: the same term at system level misleads every channel on the
|
||||
system, including one 400km away on a statewide system.
|
||||
"""
|
||||
doc = {"config": {"talkgroups": [{"id": 9048, ac.PENDING_KEY: [
|
||||
{"term": "Snowden Avenue", "meaning": "residential street"}]}]}}
|
||||
saved, store = _store(doc)
|
||||
with store:
|
||||
assert await ac.resolve_pending("sys-1", 9048, "snowden avenue", approve=True) is True
|
||||
tg = saved["config"]["talkgroups"][0]
|
||||
assert tg["area_context"]["local_knowledge"] == [
|
||||
{"term": "Snowden Avenue", "meaning": "residential street"}
|
||||
]
|
||||
assert tg[ac.PENDING_KEY] == []
|
||||
assert "vocabulary" not in saved and "area_context" not in saved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismissing_adds_nothing():
|
||||
doc = {"config": {"talkgroups": [{"id": 9048, ac.PENDING_KEY: [{"term": "Optum"}]}]}}
|
||||
saved, store = _store(doc)
|
||||
with store:
|
||||
assert await ac.resolve_pending("sys-1", 9048, "Optum", approve=False) is True
|
||||
tg = saved["config"]["talkgroups"][0]
|
||||
assert tg[ac.PENDING_KEY] == []
|
||||
assert not (tg.get("area_context") or {}).get("local_knowledge")
|
||||
@@ -333,6 +333,79 @@ async def test_signal_resolve_stamps_resolved_at():
|
||||
assert updates["resolved_at"] == NOW.isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #16 — unit-continuity path must populate corr_matched_units
|
||||
#
|
||||
# fast/single and fast/disambig only set corr_matched_units when
|
||||
# fit_signal == "unit_overlap"; unit-continuity has no such gate because a
|
||||
# match there is unit-driven by construction (call_unit_set intersects the
|
||||
# incident's units is literally how unit_candidates gets built) — so it must
|
||||
# always populate the field, not conditionally.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_unit_continuity_link_reports_matched_units():
|
||||
"""
|
||||
Reproduces the server-26#16 production example: call units=["Post 1-2"]
|
||||
should match an incident with units=["5-4", "9-0-8", "1-2"] via the
|
||||
normalizer collapsing "Post 1-2" and "1-2" to the same key, on a
|
||||
DIFFERENT talkgroup than the incident (so the fast/talkgroup path can't
|
||||
fire first and this falls through to unit-continuity).
|
||||
"""
|
||||
inc = _incident(10.0) # idle 10min, within unit_continuity_max_idle_minutes (20)
|
||||
inc["talkgroup_ids"] = ["1234"]
|
||||
inc["units"] = ["5-4", "9-0-8", "1-2"]
|
||||
decision = _run_decision(_ctx(
|
||||
talkgroup_id=9999, # not in inc["talkgroup_ids"] — fast path can't match
|
||||
all_active=[inc], recent=[],
|
||||
call_units=["Post 1-2"], is_thin_call=False, call_severity="routine",
|
||||
))
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_path"] == "unit-continuity"
|
||||
assert decision["corr_debug"]["corr_matched_units"] == ["Post 1-2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# server-26#24 — updated_at must never precede started_at
|
||||
#
|
||||
# The re-correlation sweep anchors `now` to the linking call's own
|
||||
# started_at, which can be earlier than the incident's own started_at. Left
|
||||
# unclamped that produces updated_at < started_at on the incident doc, which
|
||||
# is what caused corr_incident_idle_min: -4.1 in production (commit 33a247d
|
||||
# fixed the gates reading that negative value, not this write).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_at_never_precedes_started_at():
|
||||
inc = _incident(5) # started_at == updated_at == NOW - 5min
|
||||
inc["started_at"] = NOW.isoformat() # incident "started" at NOW
|
||||
back_dated_now = NOW - timedelta(minutes=30) # a much older orphan call links in
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
await _update_incident(
|
||||
inc, "call-1", 9048, "sys-1", [], None, None, ["6-Adam"], [], None,
|
||||
back_dated_now,
|
||||
)
|
||||
updates = mock_fstore.doc_set.await_args.args[2]
|
||||
assert updates["updated_at"] == NOW.isoformat(), (
|
||||
"updated_at must be floored at started_at, not the back-dated `now`"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_at_uses_now_when_now_is_later_than_started_at():
|
||||
"""The normal case (now is not back-dated before started_at) is unaffected."""
|
||||
inc = _incident(5)
|
||||
later_now = NOW + timedelta(minutes=1)
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
await _update_incident(
|
||||
inc, "call-1", 9048, "sys-1", [], None, None, ["6-Adam"], [], None,
|
||||
later_now,
|
||||
)
|
||||
updates = mock_fstore.doc_set.await_args.args[2]
|
||||
assert updates["updated_at"] == later_now.isoformat()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_master_auto_resolve_stamps_resolved_at():
|
||||
"""maybe_resolve_parent closes a master once every child has resolved."""
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Over-merge guards — server-26#22.
|
||||
|
||||
All of this comes from the 2026-08-20 production dump (CORRELATION_REVIEW_0820.md),
|
||||
where 4 of 6 sampled incidents were junk chains and the worst, `f5190670`, was
|
||||
68 calls over 4h09m carrying 44 units, 12 tags and at least 13 genuinely distinct
|
||||
events. That is a work shift filed as one incident.
|
||||
|
||||
Three defects combined to produce it, and each has cases below:
|
||||
|
||||
1. `is_thin_call` was `not units and not vehicles and not coords`, so a real
|
||||
dispatch with tags and a street address counted as thin whenever no unit ID
|
||||
parsed and the geocode failed. Thin calls are the one class that links with
|
||||
NO `_call_fits_incident` check, so those dispatches were force-merged.
|
||||
2. The thin path was bounded only on dispatch channels. Everywhere else it
|
||||
used the full 90-minute fast-path window, any number of candidates, no fit.
|
||||
3. Nothing capped an incident's total size. Every fit test in the correlator
|
||||
is pairwise, so each individual link can be defensible while the chain they
|
||||
accumulate is not — no pairwise rule can see the shape.
|
||||
|
||||
`_run_decision` and `_is_thin_call` are pure, so none of this needs Firestore.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.config import settings
|
||||
import app.internal.incident_correlator as correlator_mod
|
||||
from app.internal.incident_correlator import (
|
||||
_run_decision, _is_thin_call, _idle_gate_minutes,
|
||||
_incident_at_capacity, _incident_span_minutes, _call_fits_incident,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 8, 20, 7, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# TG 383 from the dump: "Ch 1 (Patched with 155.310)". _DISPATCH_TG_RE matches
|
||||
# "patched", so this is a shared dispatch backbone carrying the whole department.
|
||||
DISPATCH_TG = "Ch 1 (Patched with 155.310)"
|
||||
TACTICAL_TG = "Fireground 2"
|
||||
|
||||
|
||||
def _ctx(**overrides) -> dict:
|
||||
base = {
|
||||
"call_id": "call-1",
|
||||
"all_active": [],
|
||||
"recent": [],
|
||||
"call_doc": {},
|
||||
"call_embedding": None,
|
||||
"call_units": [],
|
||||
"call_vehicles": [],
|
||||
"call_cleared": [],
|
||||
"call_severity": "routine",
|
||||
"coords": None,
|
||||
"is_thin_call": True,
|
||||
"now": NOW,
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 383,
|
||||
"talkgroup_name": DISPATCH_TG,
|
||||
"tags": [],
|
||||
"incident_type": None,
|
||||
"location": None,
|
||||
"location_coords": None,
|
||||
"reassignment": False,
|
||||
"create_if_new": True,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _incident(idle_minutes: float = 0.2, **overrides) -> dict:
|
||||
updated = NOW - timedelta(minutes=idle_minutes)
|
||||
inc = {
|
||||
"incident_id": "inc-1",
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["383"],
|
||||
"updated_at": updated.isoformat(),
|
||||
"started_at": updated.isoformat(),
|
||||
"status": "active",
|
||||
"call_ids": ["seed-call"],
|
||||
}
|
||||
inc.update(overrides)
|
||||
return inc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. What counts as thin — the misclassification that drove the chains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_content_free_acknowledgement_is_thin():
|
||||
""""10-4." — no unit, no vehicle, no coords, no tags, no place, routine."""
|
||||
assert _is_thin_call([], [], None, [], None, "routine", False) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [
|
||||
("tags", ["welfare-check"]),
|
||||
("location", "55 Hyman Hills Road"),
|
||||
("call_severity", "minor"),
|
||||
("call_severity", "moderate"),
|
||||
("call_severity", "major"),
|
||||
])
|
||||
def test_extracted_content_makes_a_call_substantive(field, value):
|
||||
"""
|
||||
The 07:08 call in `f5190670`: "All units head over to the powerhouse, 55
|
||||
Hyman Hills Road … she's 87 years old" — a brand new job that was called
|
||||
thin purely because no unit ID parsed and the geocode failed. It attached
|
||||
with no fit check and then overwrote the four-hour chain's title and pin.
|
||||
"""
|
||||
kwargs = {"tags": [], "location": None, "call_severity": "routine"}
|
||||
kwargs[field] = value
|
||||
assert _is_thin_call([], [], None, kwargs["tags"], kwargs["location"],
|
||||
kwargs["call_severity"], False) is False
|
||||
|
||||
|
||||
def test_reassignment_is_never_thin():
|
||||
"""
|
||||
upload.py blanks `units` when dispatch pulls a unit onto a NEW job, to stop
|
||||
unit-overlap chaining. That made the call thin and routed it to the only
|
||||
path with no fit check — the guard produced the merge it existed to prevent.
|
||||
"""
|
||||
assert _is_thin_call([], [], None, [], None, "routine", True) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("units,vehicles,coords", [
|
||||
(["6-Adam"], [], None),
|
||||
([], ["black Toyota Camry"], None),
|
||||
([], [], {"lat": 41.08, "lng": -73.81}),
|
||||
])
|
||||
def test_original_thinness_signals_still_apply(units, vehicles, coords):
|
||||
assert _is_thin_call(units, vehicles, coords, [], None, "routine", False) is False
|
||||
|
||||
|
||||
def test_blank_location_string_does_not_make_a_call_substantive():
|
||||
assert _is_thin_call([], [], None, [], " ", "routine", False) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. A thin call needs a tight window; a real one needs a fit signal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_thin_call_with_no_overlap_does_not_attach_on_a_dispatch_channel():
|
||||
inc = _incident(idle_minutes=40)
|
||||
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan"
|
||||
|
||||
|
||||
def test_thin_call_with_no_overlap_does_not_attach_on_a_tactical_channel():
|
||||
"""
|
||||
The widest version of the bug: non-dispatch talkgroups skipped the tiering
|
||||
entirely and used the whole 90-minute fast-path window with no
|
||||
single-candidate requirement, so ANY thin call joined whatever was newest.
|
||||
"""
|
||||
inc = _incident(idle_minutes=40)
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG,
|
||||
))
|
||||
assert decision["action"] == "orphan"
|
||||
|
||||
|
||||
def test_tactical_thin_call_still_attaches_inside_its_own_window():
|
||||
"""Bounded, not removed — a "10-4" on a working channel is still context."""
|
||||
inc = _incident(idle_minutes=settings.tg_thin_idle_minutes - 1)
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], talkgroup_name=TACTICAL_TG,
|
||||
))
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_path"] == "fast/thin"
|
||||
|
||||
|
||||
def test_tactical_thin_call_is_ambiguous_with_two_candidates():
|
||||
a = _incident(idle_minutes=3.0, incident_id="inc-a")
|
||||
b = _incident(idle_minutes=4.0, incident_id="inc-b")
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[a, b], recent=[a, b], talkgroup_name=TACTICAL_TG,
|
||||
))
|
||||
assert decision["action"] == "orphan"
|
||||
|
||||
|
||||
def test_call_with_unit_overlap_does_attach():
|
||||
"""
|
||||
Positive control: real evidence still links. Carrying units also means the
|
||||
call is not thin, so it reaches _call_fits_incident and passes on
|
||||
unit_overlap rather than being force-attached.
|
||||
"""
|
||||
inc = _incident(idle_minutes=3.0, units=["6-Adam", "K-9A2"])
|
||||
assert _is_thin_call(["6-Adam"], [], None, [], None, "routine", False) is False
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], call_units=["6-Adam"], is_thin_call=False,
|
||||
))
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_fit_signal"] == "unit_overlap"
|
||||
|
||||
|
||||
def test_substantive_call_with_no_signal_opens_its_own_incident():
|
||||
"""
|
||||
A tagged dispatch on a shared backbone with no unit/vehicle/geocode match
|
||||
is a separate job, not a follow-up. Under the old thinness test this exact
|
||||
call took fast/thin and merged.
|
||||
"""
|
||||
inc = _incident(idle_minutes=2.0)
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], is_thin_call=False,
|
||||
tags=["welfare-check"], location="55 Hyman Hills Road",
|
||||
))
|
||||
assert decision["action"] == "new"
|
||||
assert decision["incident_type"] == "other"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Negative idle — the sweep anchors `now` to the call's own started_at
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_idle_gate_uses_distance_not_sign():
|
||||
"""
|
||||
Observed on `9d376ffe`: corr_incident_idle_min -4.1, because the sweep
|
||||
evaluated a 02:45 call against an incident updated at 02:50. Every
|
||||
`idle <= window` gate reads True for a negative number, so the gates
|
||||
stopped bounding anything for precisely the calls the sweep re-examines.
|
||||
"""
|
||||
future = _incident(idle_minutes=-45)
|
||||
assert _idle_gate_minutes(future, NOW) == pytest.approx(45.0)
|
||||
|
||||
|
||||
def test_back_dated_thin_call_does_not_sail_through_the_recency_gate():
|
||||
future = _incident(idle_minutes=-45)
|
||||
assert _run_decision(_ctx(all_active=[future], recent=[future]))["action"] == "orphan"
|
||||
|
||||
|
||||
def test_back_dated_call_does_not_bypass_the_content_divergence_veto(monkeypatch):
|
||||
"""
|
||||
Same `9d376ffe` failure mode, but exercised directly against
|
||||
`_call_fits_incident` on a dispatch channel: unit overlap plus a
|
||||
back-dated call (incident updated 45 minutes AFTER the call's own
|
||||
`started_at`, which the sweep passes as `now`) used to make the signed
|
||||
idle -45, so `idle_min >= 15` read False and the content-divergence
|
||||
veto never ran — unit overlap alone forced the merge regardless of
|
||||
what the call was actually about. With the gate fixed to compare
|
||||
distance, idle_min is 45 (>= 15), the veto runs, and a divergent
|
||||
embedding (patched below so the assertion doesn't depend on numpy
|
||||
being installed in this environment) fails it.
|
||||
"""
|
||||
monkeypatch.setattr(correlator_mod, "_cosine_similarity", lambda a, b: 0.0)
|
||||
inc = _incident(idle_minutes=-45, units=["6-Adam"])
|
||||
inc["embedding"] = [1.0, 0.0]
|
||||
fits, signal = _call_fits_incident(
|
||||
inc, call_units=["6-Adam"], call_vehicles=[], call_coords=None,
|
||||
proximity_km=settings.location_proximity_km, is_dispatch=True,
|
||||
call_embedding=[0.0, 1.0], now=NOW,
|
||||
)
|
||||
assert (fits, signal) == (False, "content_divergence")
|
||||
|
||||
|
||||
def test_back_dated_call_on_tactical_channel_does_not_get_the_default():
|
||||
"""
|
||||
Tactical-channel counterpart: no unit/vehicle/location signal, so the
|
||||
function falls through to step 4's `idle_min < 20.0` default. A
|
||||
back-dated call (incident updated 45 minutes after the call's own
|
||||
started_at) used to read idle_min as -45, which is always < 20.0, so
|
||||
`tactical_default` fired unconditionally no matter how stale the
|
||||
incident actually was relative to this call. Fixed, idle_min is the
|
||||
45-minute distance, which is not < 20.0.
|
||||
"""
|
||||
inc = _incident(idle_minutes=-45)
|
||||
fits, signal = _call_fits_incident(
|
||||
inc, call_units=[], call_vehicles=[], call_coords=None,
|
||||
proximity_km=settings.location_proximity_km, is_dispatch=False,
|
||||
call_embedding=None, now=NOW,
|
||||
)
|
||||
assert (fits, signal) == (False, "tactical_idle")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Hard caps — path-independent, because pairwise fit tests can't see shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _long_running(minutes: float) -> dict:
|
||||
started = NOW - timedelta(minutes=minutes)
|
||||
return _incident(idle_minutes=0.2, started_at=started.isoformat())
|
||||
|
||||
|
||||
def test_duration_cap_forces_a_new_incident():
|
||||
"""
|
||||
`f5190670` ran 4h09m. The one real incident in the dump ran 63 minutes.
|
||||
The unit here overlaps the incident's own roster, so without the cap this
|
||||
links on unit_overlap — the cap is the only thing separating them.
|
||||
"""
|
||||
inc = _long_running(settings.incident_max_duration_minutes + 30)
|
||||
inc["units"] = ["6-Adam"]
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], is_thin_call=False,
|
||||
call_units=["6-Adam"], tags=["welfare-check"],
|
||||
))
|
||||
assert decision["action"] == "new"
|
||||
|
||||
|
||||
def test_duration_cap_blocks_the_thin_path_too():
|
||||
"""The cap is checked before any path runs, so 'no fit test' is no escape."""
|
||||
inc = _long_running(settings.incident_max_duration_minutes + 30)
|
||||
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan"
|
||||
|
||||
|
||||
def test_incident_just_under_the_duration_cap_still_accepts_calls():
|
||||
inc = _long_running(settings.incident_max_duration_minutes - 10)
|
||||
inc["units"] = ["6-Adam"]
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], is_thin_call=False, call_units=["6-Adam"],
|
||||
))
|
||||
assert decision["action"] == "link"
|
||||
|
||||
|
||||
def test_call_count_cap_forces_a_new_incident():
|
||||
inc = _incident(idle_minutes=0.2, units=["6-Adam"])
|
||||
inc["call_ids"] = [f"c{i}" for i in range(settings.incident_max_calls)]
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], is_thin_call=False,
|
||||
call_units=["6-Adam"], tags=["vehicle-accident"],
|
||||
))
|
||||
assert decision["action"] == "new"
|
||||
|
||||
|
||||
def test_incident_one_call_under_the_count_cap_still_accepts_calls():
|
||||
inc = _incident(idle_minutes=0.2, units=["6-Adam"])
|
||||
inc["call_ids"] = [f"c{i}" for i in range(settings.incident_max_calls - 1)]
|
||||
decision = _run_decision(_ctx(
|
||||
all_active=[inc], recent=[inc], is_thin_call=False, call_units=["6-Adam"],
|
||||
))
|
||||
assert decision["action"] == "link"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inc,expect", [
|
||||
(_incident(), None),
|
||||
(_long_running(9999), "duration"),
|
||||
])
|
||||
def test_capacity_reason_names_the_cap_that_fired(inc, expect):
|
||||
reason = _incident_at_capacity(inc, NOW)
|
||||
assert (reason is None) if expect is None else reason.startswith(expect)
|
||||
|
||||
|
||||
def test_span_survives_a_back_dated_reference_time():
|
||||
"""
|
||||
The sweep passes the call's own started_at as `now`, which can precede the
|
||||
incident's last update — the span must still reflect what the incident has
|
||||
actually accumulated, not go negative and defeat the cap.
|
||||
"""
|
||||
started = NOW - timedelta(hours=5)
|
||||
inc = {"started_at": started.isoformat(), "updated_at": NOW.isoformat()}
|
||||
past = NOW - timedelta(hours=4)
|
||||
assert _incident_span_minutes(inc, past) == pytest.approx(300.0, abs=0.1)
|
||||
|
||||
|
||||
def test_unparseable_started_at_is_never_capped_on_duration():
|
||||
assert _incident_span_minutes({"started_at": "not-a-date"}, NOW) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Regression: the `f5190670` shape must not reassemble
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The 13 distinct events visible in `f5190670`, at their real offsets from the
|
||||
# 03:01 opener. Each arrived exactly as reproduced here: tags and often a place
|
||||
# name, but no parsed unit and no successful geocode — which is what made the
|
||||
# old thinness test classify them as chatter.
|
||||
_F5190670_EVENTS = [
|
||||
(0, ["vehicle-accident", "telephone-pole-strike"], "Airport Road traffic circle"),
|
||||
(2, ["uber-passenger", "phone-pinging"], "traffic circle near New King Street"),
|
||||
(13, ["sign-down"], None),
|
||||
(26, ["burglary-alarm"], "34 Carlton Drive"),
|
||||
(67, ["inspection"], "2 Filno River Road"),
|
||||
(108, ["disabled-vehicle"], "Yonkers Ave"),
|
||||
(119, ["altercation"], "137 East Main Street"),
|
||||
(131, ["inspection"], "80 North Grasslands Road"),
|
||||
(156, ["foot-patrol"], "Tanzania Road"),
|
||||
(211, [], None), # unit roll call — pure noise
|
||||
(222, ["vehicle-off-roadway"], None),
|
||||
(240, ["premises-check"], "Hillcrest Drive"),
|
||||
(247, ["welfare-check"], "55 Hyman Hills Road"),
|
||||
]
|
||||
|
||||
|
||||
# The department roster heard on that channel. `f5190670` accumulated 44 units,
|
||||
# partly from the nine phonetic-alphabet roll calls it absorbed, and once an
|
||||
# incident holds most of the roster essentially every later call overlaps it —
|
||||
# mechanism B in the review, unit-overlap positive feedback. Reproduced here so
|
||||
# the chain has a real engine driving it, not just the thin path.
|
||||
_ROSTER = ["6-Adam", "7-Baker", "11-Victor", "45-Charlie", "K-9A2", "22-47"]
|
||||
|
||||
_START = datetime(2026, 5, 24, 3, 1, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _overnight_traffic():
|
||||
"""
|
||||
The real shape of that channel: a transmission roughly every two minutes for
|
||||
4h07m — 13 dispatched jobs, routine unit traffic drawn from one roster, and
|
||||
acknowledgements in between. Yields (offset_min, units, tags, location).
|
||||
"""
|
||||
events = {off: (tags, loc) for off, tags, loc in _F5190670_EVENTS}
|
||||
# The dispatches, at their real offsets, exactly as they arrived: tags and
|
||||
# usually a place name, but no parsed unit and no successful geocode.
|
||||
traffic = [(off, [], tags, loc) for off, (tags, loc) in events.items()]
|
||||
|
||||
# Mechanism B, the engine that kept the real chain alive for four hours: one
|
||||
# job that legitimately opens with units, then keeps producing unit traffic
|
||||
# all shift. Each follow-up genuinely overlaps on unit, so each link is
|
||||
# individually defensible and each one refreshes updated_at — which keeps
|
||||
# the incident permanently inside every recency gate. No pairwise fit test
|
||||
# can refuse these; only a cap can stop the accumulation.
|
||||
traffic.append((1, [_ROSTER[0]], ["prisoner-transport"], "Medical Center"))
|
||||
traffic += [(m, [_ROSTER[0]], [], None) for m in range(5, 249, 4)]
|
||||
|
||||
# Everything else on the channel — one transmission every two minutes.
|
||||
for n, minute in enumerate(range(0, 249, 2)):
|
||||
if minute in events:
|
||||
continue
|
||||
if n % 3 == 0:
|
||||
traffic.append((minute, [_ROSTER[1 + n % (len(_ROSTER) - 1)]], [], None))
|
||||
else:
|
||||
traffic.append((minute, [], [], None)) # "10-4"
|
||||
return traffic
|
||||
|
||||
|
||||
def _simulate(traffic):
|
||||
"""
|
||||
Replay traffic through the real decision engine, applying the same incident
|
||||
mutations the commit layer would: a link appends the call, merges units, and
|
||||
(unless thin) refreshes updated_at; "new" opens a doc. Returns
|
||||
(incidents, placement) where placement maps call_id → incident_id.
|
||||
"""
|
||||
incidents: list[dict] = []
|
||||
placement: dict[str, str] = {}
|
||||
for i, (offset, units, tags, location) in enumerate(sorted(traffic)):
|
||||
now = _START + timedelta(minutes=offset)
|
||||
call_id = f"call-{i}"
|
||||
thin = _is_thin_call(units, [], None, tags, location, "routine", False)
|
||||
active = [inc for inc in incidents if inc["status"] == "active"]
|
||||
decision = _run_decision(_ctx(
|
||||
call_id=call_id, now=now, all_active=active, recent=active,
|
||||
tags=tags, location=location, call_units=units, is_thin_call=thin,
|
||||
))
|
||||
if decision["action"] == "link":
|
||||
inc = decision["matched_incident"]
|
||||
inc["call_ids"].append(call_id)
|
||||
inc["units"] = list(dict.fromkeys(inc["units"] + units))
|
||||
inc["_last_call_at"] = now
|
||||
if decision["corr_debug"].get("corr_path") != "fast/thin":
|
||||
inc["updated_at"] = now.isoformat()
|
||||
placement[call_id] = inc["incident_id"]
|
||||
elif decision["action"] == "new":
|
||||
incidents.append({
|
||||
"incident_id": f"inc-{i}", "system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["383"], "status": "active",
|
||||
"started_at": now.isoformat(), "updated_at": now.isoformat(),
|
||||
"call_ids": [call_id], "units": list(units),
|
||||
"_started": now, "_last_call_at": now, "_offset": offset,
|
||||
})
|
||||
placement[call_id] = incidents[-1]["incident_id"]
|
||||
return incidents, placement
|
||||
|
||||
|
||||
def _live_span_minutes(inc: dict) -> float:
|
||||
"""Minutes from an incident's first call to the last one it actually took."""
|
||||
return (inc["_last_call_at"] - inc["_started"]).total_seconds() / 60
|
||||
|
||||
|
||||
def test_f5190670_does_not_become_one_incident():
|
||||
"""
|
||||
The headline regression: a full overnight shift on one patched dispatch
|
||||
backbone must not end up as a single incident. The real one was 68 calls,
|
||||
4h09m, 44 units, 12 tags and at least 13 distinct events.
|
||||
"""
|
||||
traffic = _overnight_traffic()
|
||||
incidents, placement = _simulate(traffic)
|
||||
|
||||
assert len(incidents) >= 12, f"the shift merged into {len(incidents)} incident(s)"
|
||||
|
||||
# Without the caps this same traffic produces a 125-call incident spanning
|
||||
# 244 minutes; with the old thinness test on top of that, 153 calls over
|
||||
# 247 minutes in 3 incidents — the `f5190670` shape, reproduced.
|
||||
biggest = max(len(inc["call_ids"]) for inc in incidents)
|
||||
assert biggest <= settings.incident_max_calls, (
|
||||
f"one incident holds {biggest} calls, past the "
|
||||
f"{settings.incident_max_calls}-call cap"
|
||||
)
|
||||
|
||||
longest = max(_live_span_minutes(inc) for inc in incidents)
|
||||
assert longest <= settings.incident_max_duration_minutes, (
|
||||
f"an incident took calls across {longest:.0f}min, past the "
|
||||
f"{settings.incident_max_duration_minutes}min cap"
|
||||
)
|
||||
|
||||
|
||||
def test_each_dispatched_job_gets_its_own_incident():
|
||||
"""
|
||||
The 13 events are unrelated jobs — a pole strike, a burglar alarm, two
|
||||
inspections, an altercation, a welfare check. On a dispatch backbone with no
|
||||
unit or geocode tying them together, none of them may join another's
|
||||
incident. Under the old thinness test every one of these was "thin" and
|
||||
force-attached to whatever was most recent.
|
||||
"""
|
||||
traffic = _overnight_traffic()
|
||||
incidents, placement = _simulate(traffic)
|
||||
dispatch_offsets = {off for off, _, tags, _ in traffic if tags}
|
||||
dispatch_incidents = {
|
||||
placement[f"call-{i}"]
|
||||
for i, (off, _, tags, _) in enumerate(sorted(traffic))
|
||||
if tags and f"call-{i}" in placement
|
||||
}
|
||||
assert len(dispatch_incidents) == len(dispatch_offsets), (
|
||||
f"{len(dispatch_offsets)} jobs landed in {len(dispatch_incidents)} incident(s)"
|
||||
)
|
||||
|
||||
|
||||
def test_a_long_run_of_pure_chatter_never_builds_an_incident():
|
||||
"""
|
||||
Acknowledgements alone carry no content, so they cannot open an incident and
|
||||
— with nothing recent to reply to — must not accrete into one either.
|
||||
"""
|
||||
incidents, _ = _simulate([(m, [], [], None) for m in range(0, 240, 6)])
|
||||
assert incidents == []
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
CORS must never end up as "any origin, WITH credentials".
|
||||
|
||||
Starlette does not reject `allow_origins=["*"]` combined with
|
||||
`allow_credentials=True`. It reflects the caller's Origin back in
|
||||
Access-Control-Allow-Origin and still sends
|
||||
Access-Control-Allow-Credentials: true, so the effective policy is the
|
||||
opposite of what a wildcard usually means. main.py defuses that by turning
|
||||
credentials off whenever it sees a wildcard; these tests hold it to that.
|
||||
|
||||
The policy lives in a pure function so it can be exercised directly --
|
||||
reloading app.main to vary settings drags every router back through import
|
||||
and is not worth the fragility.
|
||||
"""
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app, cors_allows_credentials
|
||||
|
||||
|
||||
def test_wildcard_alone_disables_credentials():
|
||||
assert cors_allows_credentials(["*"]) is False
|
||||
|
||||
|
||||
def test_wildcard_among_real_origins_still_disables_credentials():
|
||||
# A list that merely CONTAINS "*" is as permissive as ["*"] alone --
|
||||
# Starlette treats any wildcard entry as allow-all.
|
||||
assert cors_allows_credentials(["https://app.example.com", "*"]) is False
|
||||
|
||||
|
||||
def test_named_origins_keep_credentials():
|
||||
# Naming your origins is how you ask for credentialed requests, so a
|
||||
# correctly configured deployment must not be penalised.
|
||||
assert cors_allows_credentials(["https://app.example.com"]) is True
|
||||
assert cors_allows_credentials([]) is True
|
||||
|
||||
|
||||
def test_the_app_actually_mounted_that_policy():
|
||||
"""Guards the wiring, not just the helper: a future edit to main.py that
|
||||
hardcodes allow_credentials=True again fails here."""
|
||||
opts = next(
|
||||
(mw.kwargs for mw in app.user_middleware if mw.cls is CORSMiddleware), None
|
||||
)
|
||||
assert opts is not None, "CORSMiddleware is not mounted at all"
|
||||
assert opts["allow_credentials"] is cors_allows_credentials(settings.cors_origins)
|
||||
|
||||
|
||||
def test_health_exposes_a_build_stamp():
|
||||
"""CI compares this against the commit it just deployed; a deploy that
|
||||
leaves the previous container running is otherwise invisible."""
|
||||
from app.main import _GIT_SHA
|
||||
|
||||
assert isinstance(_GIT_SHA, str) and _GIT_SHA
|
||||
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
An incident must not lie about what it is or where it is — server-26#23 / #26.
|
||||
|
||||
Both defects come from the 2026-08-20 production dump
|
||||
(CORRELATION_REVIEW_0820.md) and both are the same shape: a field of the
|
||||
incident header re-derived from whichever call linked most recently.
|
||||
|
||||
* `location` (the label) and `location_coords` (the map pin) were two
|
||||
independent last-write-wins fields. A call could move one and not the
|
||||
other, so they drifted: 5 of 6 incidents in the dump were pinned somewhere
|
||||
other than the place they were labelled. `b9b4f392` said "100 South
|
||||
Mosher" and pinned `Westmed`.
|
||||
* `location` was never validated, so "Flames from 49" put `location: "49"`
|
||||
on `9d376ffe` and the summarizer wrote "reported at location 49".
|
||||
* `title` was re-derived from every classified call, so `b9b4f392` — opened
|
||||
on a suspect search at 80 Grasslands Road — was named after its third
|
||||
call, and `f5190670` after the thirteenth of its thirteen events.
|
||||
|
||||
The rules under test:
|
||||
1. label and pin are ONE value, written together on every path;
|
||||
2. a pin is only ever kept next to the label it was geocoded from;
|
||||
3. a string with no word in it is not a place;
|
||||
4. the title names the founding event and only ever escalates.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.internal.incident_correlator import (
|
||||
_build_context, _create_incident, _update_incident,
|
||||
_resolve_location_pair, _verified_pin, clean_location, location_is_unit,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 8, 20, 7, 25, 0, tzinfo=timezone.utc)
|
||||
|
||||
# TG 383 from the dump: "Ch 1 (Patched with 155.310)" — a shared dispatch
|
||||
# backbone, which is where every one of these chains happened.
|
||||
DISPATCH_TG = "Ch 1 (Patched with 155.310)"
|
||||
|
||||
GRASSLANDS = {"lat": 41.0891, "lng": -73.8010}
|
||||
WESTMED = {"lat": 41.0348, "lng": -73.7629}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Harness — incidents are stored with merge=True, so folding each write back
|
||||
# into the dict is exactly what Firestore does between calls.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _create(**call) -> dict:
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
await _create_incident(
|
||||
call.get("call_id", "call-0"), "org-1",
|
||||
call.get("incident_type", "police"), 383, DISPATCH_TG, "sys-1",
|
||||
call.get("tags", []), call.get("location"), call.get("coords"),
|
||||
call.get("units", []), [], None,
|
||||
call.get("severity", "routine"), call.get("now", NOW),
|
||||
)
|
||||
return dict(mock_fstore.doc_set.await_args.args[2])
|
||||
|
||||
|
||||
async def _link(inc: dict, **call) -> dict:
|
||||
"""Run one call through _update_incident, fold the write back, return it."""
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
await _update_incident(
|
||||
inc, call.get("call_id", "call-n"), 383, "sys-1",
|
||||
call.get("tags", []), call.get("location"), call.get("coords"),
|
||||
call.get("units", []), [], None, call.get("now", NOW),
|
||||
talkgroup_name=DISPATCH_TG,
|
||||
incident_type=call.get("incident_type"),
|
||||
call_severity=call.get("severity", "routine"),
|
||||
)
|
||||
updates = dict(mock_fstore.doc_set.await_args.args[2])
|
||||
inc.update(updates)
|
||||
return updates
|
||||
|
||||
|
||||
def _assert_pin_matches_label(doc: dict):
|
||||
"""The invariant: a pin exists only alongside the label it was geocoded from."""
|
||||
if doc.get("location_coords") is not None:
|
||||
assert doc.get("location"), "pin with no label"
|
||||
assert doc.get("location_coords_source") == doc["location"], (
|
||||
f"pin sourced from {doc.get('location_coords_source')!r} "
|
||||
f"but incident is labelled {doc['location']!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# server-26#23 — the label and the pin are one value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_label_and_pin_stay_consistent_across_a_chain_of_calls():
|
||||
"""
|
||||
Replays `b9b4f392` exactly: a suspect search at 80 Grasslands Road, then an
|
||||
EMS transport to Westmed, then a brand-new open-911 dispatch at 100 South
|
||||
Mosher. Production ended up labelled "100 South Mosher" and pinned at
|
||||
Westmed — the label from one call, the pin from another.
|
||||
"""
|
||||
inc = await _create(
|
||||
tags=["suspect-search"], location="80 Grasslands Road", coords=GRASSLANDS,
|
||||
severity="moderate", incident_type="police",
|
||||
)
|
||||
_assert_pin_matches_label(inc)
|
||||
|
||||
await _link( # 07:41 — "one female to Westmed"
|
||||
inc, call_id="call-ems", tags=["ems-transport"], location="Westmed",
|
||||
coords=WESTMED, incident_type="ems", now=NOW + timedelta(minutes=16),
|
||||
)
|
||||
_assert_pin_matches_label(inc)
|
||||
|
||||
await _link( # 07:55 — "open 911 line", a different job entirely
|
||||
inc, call_id="call-911", tags=["open-911"], location="100 South Mosher",
|
||||
coords=None, incident_type="police", now=NOW + timedelta(minutes=30),
|
||||
)
|
||||
_assert_pin_matches_label(inc)
|
||||
|
||||
assert inc["location"] == "80 Grasslands Road"
|
||||
assert inc["location_coords"] == GRASSLANDS
|
||||
# Every place anyone named is still recorded — that is what the map path
|
||||
# is drawn from; it just isn't the incident's own location.
|
||||
assert inc["location_mentions"] == [
|
||||
"80 Grasslands Road", "Westmed", "100 South Mosher",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_later_call_never_moves_the_pin_without_the_label():
|
||||
"""The direct mechanism: coords updating on their own."""
|
||||
inc = await _create(tags=["suspect-search"], location="80 Grasslands Road",
|
||||
coords=None, incident_type="police")
|
||||
assert inc["location_coords"] is None
|
||||
|
||||
updates = await _link(inc, tags=["ems-transport"], location="Westmed",
|
||||
coords=WESTMED, incident_type="ems")
|
||||
|
||||
assert updates["location"] == "80 Grasslands Road"
|
||||
assert updates["location_coords"] is None
|
||||
_assert_pin_matches_label(inc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_pin_can_still_be_filled_in_for_the_same_place():
|
||||
"""
|
||||
The one permitted change. Geocoding is not deterministic in practice — it
|
||||
needs the node's position, an API quota and a response — so the same
|
||||
address can fail on one call and resolve on the next. Filling in a pin the
|
||||
incident never had is not a move; matching is deliberately by exact label,
|
||||
so it can never quietly re-point at a different street.
|
||||
"""
|
||||
inc = await _create(tags=["welfare-check"], location="55 Hyman Hills Road",
|
||||
coords=None, incident_type="police")
|
||||
assert inc["location_coords"] is None
|
||||
|
||||
await _link(inc, tags=["welfare-check"], location="55 Hyman Hills Road",
|
||||
coords=GRASSLANDS, incident_type="police")
|
||||
|
||||
assert inc["location"] == "55 Hyman Hills Road"
|
||||
assert inc["location_coords"] == GRASSLANDS
|
||||
_assert_pin_matches_label(inc)
|
||||
|
||||
|
||||
def test_a_pin_that_cannot_be_tied_to_the_label_is_not_shown():
|
||||
"""
|
||||
Every incident written before this change carries a pin with no record of
|
||||
where it came from — and the dump says 5 of 6 of those are wrong. An
|
||||
unverifiable pin is dropped, not displayed: a missing pin reads as missing
|
||||
data, a wrong one reads as fact.
|
||||
"""
|
||||
legacy = {"location": "100 South Mosher", "location_coords": WESTMED}
|
||||
assert _verified_pin(legacy) is None
|
||||
|
||||
resolved = _resolve_location_pair(legacy, None, None)
|
||||
assert resolved["location"] == "100 South Mosher"
|
||||
assert resolved["location_coords"] is None
|
||||
assert resolved["location_coords_source"] is None
|
||||
|
||||
tagged = {"location": "Westmed", "location_coords": WESTMED,
|
||||
"location_coords_source": "westmed"}
|
||||
assert _verified_pin(tagged) == WESTMED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# server-26#23 — "49" is not a place
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("junk", [
|
||||
"49", # 9d376ffe, from "Fire received. Flames from 49."
|
||||
"10-24", # a ten-code
|
||||
"5-5-2", # a unit designator
|
||||
" ",
|
||||
"",
|
||||
None,
|
||||
"1",
|
||||
])
|
||||
def test_bare_numbers_are_rejected_as_locations(junk):
|
||||
assert clean_location(junk) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("place", [
|
||||
"80 Grasslands Road",
|
||||
"Westmed",
|
||||
"Rt 9",
|
||||
"226 East Main Street, apartment number 1",
|
||||
])
|
||||
def test_real_place_names_survive(place):
|
||||
assert clean_location(place) == place
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bare_number_never_reaches_the_correlator():
|
||||
"""
|
||||
Rejected at the context boundary, so it is not a location in the fit tests,
|
||||
the thin-call test, the LLM prompt or the incident — and its coordinates go
|
||||
with it, because they were geocoded from that very string.
|
||||
"""
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(return_value={})
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
ctx = await _build_context(
|
||||
call_id="call-49", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords={"lat": 41.0, "lng": -73.8}, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="fire", location="49",
|
||||
reassignment=False, create_if_new=True,
|
||||
)
|
||||
assert ctx["location"] is None
|
||||
assert ctx["location_coords"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin():
|
||||
"""
|
||||
server-26#87. One call can be split into several scenes, and only the
|
||||
primary scene's geocode is written to the call doc. A non-primary scene
|
||||
that passes no location of its own must not inherit that pin — doing so
|
||||
fabricates location_proximity, the strongest accept signal, for a scene
|
||||
that has none, and drives it into the primary scene's incident.
|
||||
"""
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(
|
||||
return_value={"location_coords": GRASSLANDS}
|
||||
)
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
ctx = await _build_context(
|
||||
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
)
|
||||
assert ctx["coords"] is None
|
||||
assert ctx["is_thin_call"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_scene_does_not_inherit_the_call_docs_embedding_or_severity():
|
||||
"""
|
||||
server-26#80 / #95. Same shape as the #87 coords leak above:
|
||||
intelligence.py writes only the PRIMARY scene's embedding and severity to
|
||||
calls/{id}. A non-primary scene being correlated must be judged on its own
|
||||
embedding (or none) and its own severity — not the call doc's — or a scene
|
||||
about a different event scores against the wrong incident on the embedding
|
||||
path and can inherit a minor/moderate/major rung it never had, clearing the
|
||||
creation gate on borrowed weight.
|
||||
"""
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(
|
||||
return_value={"embedding": [0.1] * 1536, "severity": "major"}
|
||||
)
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
ctx = await _build_context(
|
||||
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
embedding=None, severity=None,
|
||||
)
|
||||
assert ctx["call_embedding"] is None
|
||||
assert ctx["call_severity"] == "routine"
|
||||
assert ctx["is_thin_call"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_scene_is_judged_on_its_own_embedding_and_severity():
|
||||
"""The other half of #80/#95: the scene's own values are what land in ctx."""
|
||||
scene_vec = [0.9] * 1536
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(
|
||||
return_value={"embedding": [0.1] * 1536, "severity": "routine"}
|
||||
)
|
||||
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||
ctx = await _build_context(
|
||||
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
|
||||
location_coords=None, reference_time=NOW,
|
||||
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||
tags=[], incident_type="police", location=None,
|
||||
reassignment=False, create_if_new=True,
|
||||
embedding=scene_vec, severity="major",
|
||||
)
|
||||
assert ctx["call_embedding"] == scene_vec
|
||||
assert ctx["call_severity"] == "major"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bare_number_never_becomes_an_incident_location_or_title():
|
||||
inc = await _create(tags=["flames"], location="49", coords=None,
|
||||
incident_type="fire")
|
||||
assert inc["location"] is None
|
||||
assert inc["location_coords"] is None
|
||||
assert "49" not in inc["title"]
|
||||
assert inc["location_mentions"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# server-26#26 — the title names the founding event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrelated_later_call_does_not_rename_the_incident():
|
||||
"""`b9b4f392` again, from the title's side."""
|
||||
inc = await _create(tags=["suspect-search"], location="80 Grasslands Road",
|
||||
coords=GRASSLANDS, severity="moderate", incident_type="police")
|
||||
assert inc["title"] == "Suspect Search at 80 Grasslands Road"
|
||||
|
||||
await _link(inc, tags=["ems-transport"], location="Westmed", coords=WESTMED,
|
||||
incident_type="ems", severity="routine")
|
||||
await _link(inc, tags=["open-911"], location="100 South Mosher",
|
||||
incident_type="police", severity="moderate")
|
||||
|
||||
assert inc["title"] == "Suspect Search at 80 Grasslands Road"
|
||||
assert inc["title_tag"] == "Suspect Search"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routine_status_traffic_never_touches_the_title():
|
||||
inc = await _create(tags=["welfare-check"], location="55 Hyman Hills Road",
|
||||
incident_type="police")
|
||||
updates = await _link(inc, tags=[], incident_type=None, units=["6-Adam"])
|
||||
assert "title" not in updates
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_worse_event_takes_the_title_over():
|
||||
"""
|
||||
The one case where the header must change: a check-condition that turns
|
||||
into a structure fire is a structure fire. Monotonic like _max_severity —
|
||||
a calmer later call can never take it back.
|
||||
"""
|
||||
inc = await _create(tags=["check-condition"], location="226 East Main Street",
|
||||
severity="minor", incident_type="police")
|
||||
assert inc["title"] == "Check Condition at 226 East Main Street"
|
||||
|
||||
await _link(inc, tags=["structure-fire"], incident_type="fire", severity="major")
|
||||
assert inc["title"] == "Structure Fire at 226 East Main Street"
|
||||
assert inc["severity"] == "major"
|
||||
|
||||
await _link(inc, tags=["ems-transport"], incident_type="ems", severity="routine")
|
||||
assert inc["title"] == "Structure Fire at 226 East Main Street"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_placeholder_title_is_filled_in_not_overwritten():
|
||||
"""
|
||||
An incident that opened on a call with no content tags is named after its
|
||||
type ("Police — <talkgroup>"). That is a placeholder, not an event name,
|
||||
so the first classified call may name it — and only the first.
|
||||
"""
|
||||
inc = await _create(tags=[], location=None, incident_type="police")
|
||||
assert inc["title"] == f"Police — {DISPATCH_TG}"
|
||||
assert inc["title_tag"] is None
|
||||
|
||||
await _link(inc, tags=["vehicle-accident"], location="Airport Road",
|
||||
incident_type="police", severity="minor")
|
||||
assert inc["title"] == "Vehicle Accident at Airport Road"
|
||||
|
||||
await _link(inc, tags=["disabled-vehicle"], location="Yonkers Avenue",
|
||||
incident_type="police", severity="minor")
|
||||
assert inc["title"] == "Vehicle Accident at Airport Road"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_title_picks_up_an_address_learned_later():
|
||||
"""
|
||||
Same event, new information — not a rename. The founding call classified
|
||||
the event but named no place; a later call on the same event does.
|
||||
"""
|
||||
inc = await _create(tags=["welfare-check"], location=None, incident_type="police")
|
||||
assert inc["title"] == f"Welfare Check — {DISPATCH_TG}"
|
||||
|
||||
await _link(inc, tags=["welfare-check"], location="55 Hyman Hills Road",
|
||||
incident_type="police")
|
||||
assert inc["title"] == "Welfare Check at 55 Hyman Hills Road"
|
||||
assert inc["location"] == "55 Hyman Hills Road"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_legacy_incidents_title_is_not_claimed_by_the_next_call():
|
||||
"""
|
||||
Incidents created before this change have no `title_tag`, so their founding
|
||||
event is unrecoverable. Their existing title is treated as the founding
|
||||
one rather than handed to whichever call links next.
|
||||
"""
|
||||
legacy = {
|
||||
"incident_id": "b9b4f392",
|
||||
"title": "Suspect Search at 80 Grasslands Road",
|
||||
"location": "80 Grasslands Road",
|
||||
"call_ids": ["call-0"],
|
||||
"started_at": NOW.isoformat(),
|
||||
"updated_at": NOW.isoformat(),
|
||||
}
|
||||
updates = await _link(legacy, tags=["open-911"], location="100 South Mosher",
|
||||
incident_type="police", severity="routine")
|
||||
assert "title" not in updates
|
||||
assert updates["location"] == "80 Grasslands Road"
|
||||
|
||||
|
||||
# ── server-26#52: a unit call-sign must never become a map pin ────────────────
|
||||
#
|
||||
# "Post 1-2" passed clean_location (it has a word in it), geocoded against the
|
||||
# Ossining anchor and produced a confident pin in the right town for an event
|
||||
# with no known location. It was in the same incident's `units` all along.
|
||||
|
||||
@pytest.mark.parametrize("location,units", [
|
||||
("Post 1-2", ["1-2", "Lincoln", "Post 1-2"]), # the dump's actual incident
|
||||
("post 1-2", ["Post 1-2"]), # case-blind
|
||||
("Post 1-2.", ["Post 1-2"]), # punctuation-blind
|
||||
("Engine 4", ["Engine 4", "Ladder 1"]),
|
||||
])
|
||||
def test_location_matching_a_unit_is_rejected(location, units):
|
||||
assert location_is_unit(location, units) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("location,units", [
|
||||
("Water Street", ["1-2", "Post 1-2"]), # a real place, same incident
|
||||
("South High", []), # no units extracted
|
||||
("Riverdale Station", ["Lincoln"]),
|
||||
("", ["Post 1-2"]), # nothing to compare
|
||||
(None, ["Post 1-2"]),
|
||||
])
|
||||
def test_real_places_survive_the_unit_check(location, units):
|
||||
assert location_is_unit(location, units) is False
|
||||
|
||||
|
||||
def test_unit_check_does_not_match_on_substrings():
|
||||
"""`1-2` is a unit; "1-2 Main Street" is an address that contains it."""
|
||||
assert location_is_unit("1-2 Main Street", ["1-2"]) is False
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
server-26#81 — any signed-in viewer could trigger OpenAI summary spend.
|
||||
|
||||
``POST /incidents/{incident_id}/summarize`` was gated by
|
||||
``require_service_or_firebase_token``, which accepts ANY authenticated
|
||||
Firebase user (including role "viewer"), not just admins. Hitting the route
|
||||
spends OpenAI credits via the background summarizer task. The call-side
|
||||
equivalent (``PATCH /calls/{id}/transcript``) was already moved to
|
||||
``require_admin_token``; the incident side was not moved with it.
|
||||
|
||||
Following the wiring-test convention in test_admin_feature_flags.py
|
||||
(``test_features_routes_use_the_agent_dependency_and_others_do_not``): assert
|
||||
against the route's actual dependant.dependencies rather than round-tripping
|
||||
through TestClient, so this pins the credential wiring itself and would fail
|
||||
immediately if someone reverts the dependency back to the weak one.
|
||||
"""
|
||||
from app.internal import auth
|
||||
from app.routers import incidents
|
||||
|
||||
|
||||
def _deps(path: str, method: str) -> set:
|
||||
for r in incidents.router.routes:
|
||||
if r.path == path and method in r.methods:
|
||||
return {d.call for d in r.dependant.dependencies}
|
||||
raise AssertionError(f"no route {method} {path}")
|
||||
|
||||
|
||||
def test_summarize_incident_requires_admin_not_any_firebase_user():
|
||||
deps = _deps("/incidents/{incident_id}/summarize", "POST")
|
||||
assert auth.require_admin_token in deps
|
||||
assert auth.require_service_or_firebase_token not in deps
|
||||
|
||||
|
||||
def test_read_only_incident_routes_still_accept_any_signed_in_user():
|
||||
"""Guards against an overcorrection: reads are not spend, they stay open
|
||||
to any authenticated viewer."""
|
||||
assert auth.require_service_or_firebase_token in _deps("/incidents", "GET")
|
||||
assert auth.require_service_or_firebase_token in _deps("/incidents/{incident_id}", "GET")
|
||||
|
||||
|
||||
def test_other_mutating_incident_routes_are_still_admin_only():
|
||||
"""Unchanged by this fix, but pinned so a future edit can't quietly
|
||||
loosen them while touching this file."""
|
||||
for path, method in [
|
||||
("/incidents/summarize", "POST"),
|
||||
("/incidents", "POST"),
|
||||
("/incidents/{incident_id}", "PUT"),
|
||||
("/incidents/{incident_id}", "DELETE"),
|
||||
("/incidents/{incident_id}/calls/{call_id}", "POST"),
|
||||
("/incidents/{incident_id}/calls/{call_id}", "DELETE"),
|
||||
]:
|
||||
assert auth.require_admin_token in _deps(path, method), f"{method} {path}"
|
||||
@@ -67,7 +67,9 @@ async def test_checkin_creates_new_node(handler):
|
||||
)
|
||||
|
||||
mock_fstore.doc_set.assert_called_once()
|
||||
_, _, doc, _ = mock_fstore.doc_set.call_args[0]
|
||||
# doc_set(collection, doc_id, data, merge=False) — merge is passed as a
|
||||
# kwarg in mqtt_handler.py, so only 3 positional args land in call_args[0].
|
||||
_, _, doc = mock_fstore.doc_set.call_args[0]
|
||||
assert doc["node_id"] == "new-node"
|
||||
assert doc["name"] == "Pi Zero W"
|
||||
assert doc["status"] == "unconfigured"
|
||||
@@ -84,7 +86,7 @@ async def test_checkin_new_node_defaults_lat_lon(handler):
|
||||
|
||||
await handler._handle_checkin("new-node", {})
|
||||
|
||||
_, _, doc, _ = mock_fstore.doc_set.call_args[0]
|
||||
_, _, doc = mock_fstore.doc_set.call_args[0]
|
||||
assert doc["lat"] == 0.0
|
||||
assert doc["lon"] == 0.0
|
||||
|
||||
@@ -200,13 +202,17 @@ async def test_call_start_creates_call_doc(handler):
|
||||
}
|
||||
|
||||
with patch("app.internal.mqtt_handler.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(return_value=node)
|
||||
# _on_call_start looks the node up via doc_get_cached (cached read,
|
||||
# added to cut Firestore read volume — see doc_get_cached in
|
||||
# app/internal/firestore.py), not the uncached doc_get.
|
||||
mock_fstore.doc_get_cached = AsyncMock(return_value=node)
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
|
||||
await handler._on_call_start("node-01", payload)
|
||||
|
||||
mock_fstore.doc_set.assert_called_once()
|
||||
_, _, doc, _ = mock_fstore.doc_set.call_args[0]
|
||||
# doc_set(collection, doc_id, data, merge=False) — merge is a kwarg here too.
|
||||
_, _, doc = mock_fstore.doc_set.call_args[0]
|
||||
assert doc["call_id"] == "call-abc123"
|
||||
assert doc["node_id"] == "node-01"
|
||||
assert doc["system_id"] == "sys-001"
|
||||
@@ -233,12 +239,12 @@ async def test_call_start_uses_now_when_started_at_missing(handler):
|
||||
payload = {"call_id": "call-xyz", "tgid": 99}
|
||||
|
||||
with patch("app.internal.mqtt_handler.fstore") as mock_fstore:
|
||||
mock_fstore.doc_get = AsyncMock(return_value=node)
|
||||
mock_fstore.doc_get_cached = AsyncMock(return_value=node)
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
|
||||
await handler._on_call_start("node-01", payload)
|
||||
|
||||
_, _, doc, _ = mock_fstore.doc_set.call_args[0]
|
||||
_, _, doc = mock_fstore.doc_set.call_args[0]
|
||||
assert doc["started_at"] is not None
|
||||
|
||||
|
||||
@@ -250,11 +256,17 @@ async def test_call_end_updates_status_and_times(handler):
|
||||
}
|
||||
|
||||
with patch("app.internal.mqtt_handler.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
# _on_call_end writes via doc_set(merge=True) now, not doc_update — see
|
||||
# the "Fix Upload 404 warning" commit: doc_update raised "No document
|
||||
# to update" when call_end raced ahead of call_start, so it was
|
||||
# switched to a merging doc_set. It also reads the node via the
|
||||
# cached doc_get_cached to stamp org_id.
|
||||
mock_fstore.doc_get_cached = AsyncMock(return_value=None)
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
|
||||
await handler._on_call_end("node-01", payload)
|
||||
|
||||
updates = mock_fstore.doc_update.call_args[0][2]
|
||||
updates = mock_fstore.doc_set.call_args[0][2]
|
||||
assert updates["status"] == "ended"
|
||||
assert updates["ended_at"] is not None
|
||||
|
||||
@@ -268,11 +280,12 @@ async def test_call_end_sets_audio_url_when_present(handler):
|
||||
}
|
||||
|
||||
with patch("app.internal.mqtt_handler.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
mock_fstore.doc_get_cached = AsyncMock(return_value=None)
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
|
||||
await handler._on_call_end("node-01", payload)
|
||||
|
||||
updates = mock_fstore.doc_update.call_args[0][2]
|
||||
updates = mock_fstore.doc_set.call_args[0][2]
|
||||
assert updates["audio_url"] == "https://storage.example.com/call.mp3"
|
||||
|
||||
|
||||
|
||||
@@ -35,8 +35,16 @@ def _node_naive(node_id, status, age_seconds):
|
||||
async def test_stale_online_node_marked_offline():
|
||||
nodes = [_node("node-01", "online", age_seconds=120)]
|
||||
|
||||
# A stale node also triggers app.routers.tokens.release_token(node_id) —
|
||||
# added by the PulseAudio/Discord-token work (commit 2a690ec). It's
|
||||
# imported inline inside _sweep, so it must be patched at its source
|
||||
# module rather than relying on the global asyncio.to_thread patch above,
|
||||
# which is scoped to the node-query call and would otherwise feed
|
||||
# release_token's own internal to_thread call the wrong shape of data
|
||||
# (raw node dicts instead of Firestore doc snapshots with .id).
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore, \
|
||||
patch("app.routers.tokens.release_token", new=AsyncMock()):
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
@@ -50,7 +58,8 @@ async def test_stale_recording_node_marked_offline():
|
||||
nodes = [_node("node-02", "recording", age_seconds=200)]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore, \
|
||||
patch("app.routers.tokens.release_token", new=AsyncMock()):
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
@@ -106,7 +115,8 @@ async def test_tz_naive_last_seen_is_handled():
|
||||
nodes = [_node_naive("node-06", "online", age_seconds=120)]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore, \
|
||||
patch("app.routers.tokens.release_token", new=AsyncMock()):
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
@@ -141,10 +151,16 @@ async def test_only_stale_nodes_updated_in_batch():
|
||||
]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore, \
|
||||
patch("app.routers.tokens.release_token", new=AsyncMock()) as mock_release:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
assert mock_fstore.doc_update.call_count == 2
|
||||
updated_ids = {call.args[1] for call in mock_fstore.doc_update.call_args_list}
|
||||
assert updated_ids == {"node-08", "node-11"}
|
||||
|
||||
# Both newly-offline nodes should have their Discord token freed.
|
||||
assert mock_release.call_count == 2
|
||||
released_ids = {call.args[0] for call in mock_release.call_args_list}
|
||||
assert released_ids == {"node-08", "node-11"}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Unit tests for Maps-based place verification (server-26#37).
|
||||
|
||||
The property that matters most is the one that looks like a no-op: WITHOUT AN
|
||||
ANCHOR, NOTHING HAPPENS. A system whose area is too wide to discriminate stores
|
||||
no anchor, and verification must then skip entirely rather than accept whatever
|
||||
geocodes. A check that passes everything is worse than no check, because it
|
||||
reads as verification in the logs and in the data.
|
||||
|
||||
After that: a candidate may only rewrite a transcript if it actually sounds like
|
||||
what was heard. Places Text Search will return the nearest plausible business
|
||||
for any garbage string, so the API answering at all is not evidence.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.internal import place_verifier as pv
|
||||
|
||||
ANCHOR_AREA = {
|
||||
"municipality": "Ossining",
|
||||
"county": "Westchester",
|
||||
"state": "New York",
|
||||
"local_knowledge": [{"term": "Snowden Avenue", "meaning": "residential street"}],
|
||||
"center": {"lat": 41.16, "lng": -73.86},
|
||||
"radius_km": 6.0,
|
||||
"resolved_from": "ossining|westchester|new york",
|
||||
}
|
||||
|
||||
SEGS = [{"start": 0.0, "end": 1.0, "text": "Shout out to Optum."},
|
||||
{"start": 1.0, "end": 2.0, "text": "Copy that."}]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enabled():
|
||||
with patch.object(pv.settings, "place_verification_enabled", True), \
|
||||
patch.object(pv.settings, "google_maps_api_key", "test-key"):
|
||||
yield
|
||||
|
||||
|
||||
def _geocode(result):
|
||||
return patch.object(pv, "_geocode_in_anchor", AsyncMock(return_value=result))
|
||||
|
||||
|
||||
def _places(result):
|
||||
return patch.object(pv, "_places_soundalike", AsyncMock(return_value=result))
|
||||
|
||||
|
||||
# -- Phonetics -----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("heard, real", [
|
||||
("Snowden Avenue", "Snowdon Ave"),
|
||||
("5 acre", "5-baker"),
|
||||
("why vac", "YVAC"),
|
||||
("Croton Ave", "Croton Avenue"),
|
||||
])
|
||||
def test_real_mishearings_score_above_the_threshold(heard, real):
|
||||
assert pv.sounds_like(heard, real) >= pv.settings.place_soundalike_min_ratio
|
||||
|
||||
|
||||
@pytest.mark.parametrize("heard, unrelated", [
|
||||
("Optum", "Ossining"),
|
||||
("Cool Parts", "Croton Point"),
|
||||
])
|
||||
def test_unrelated_names_score_below_it(heard, unrelated):
|
||||
assert pv.sounds_like(heard, unrelated) < pv.settings.place_soundalike_min_ratio
|
||||
|
||||
|
||||
# -- The skip path -------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_anchor_means_skip_not_accept():
|
||||
"""A statewide system stores no anchor. Nothing may be checked or rewritten."""
|
||||
with patch.object(pv, "_geocode_in_anchor") as geo:
|
||||
out = await pv.verify("c1", "text here", SEGS, ["Optum"], {"state": "Colorado"}, {})
|
||||
assert out == (None, None)
|
||||
geo.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_locations_means_no_requests():
|
||||
with patch.object(pv, "_geocode_in_anchor") as geo:
|
||||
assert await pv.verify("c1", "t", None, [], ANCHOR_AREA, {}) == (None, None)
|
||||
geo.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_by_setting():
|
||||
with patch.object(pv.settings, "place_verification_enabled", False), \
|
||||
patch.object(pv, "_geocode_in_anchor") as geo:
|
||||
assert await pv.verify("c1", "t", None, ["Optum"], ANCHOR_AREA, {}) == (None, None)
|
||||
geo.assert_not_called()
|
||||
|
||||
|
||||
# -- The accept path -----------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_place_that_resolves_inside_the_anchor_is_left_alone():
|
||||
with _geocode({"lat": 41.16, "lng": -73.86}), _places(None) as places:
|
||||
out = await pv.verify("c1", "Units to Snowden Avenue.", None,
|
||||
["Snowden Avenue"], ANCHOR_AREA, {})
|
||||
assert out == (None, None)
|
||||
places.assert_not_called() # a hit must not cost a second request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_query_carries_the_full_place():
|
||||
seen = {}
|
||||
|
||||
async def capture(query, anchor):
|
||||
seen["query"] = query
|
||||
return {"lat": 41.16, "lng": -73.86}
|
||||
|
||||
with patch.object(pv, "_geocode_in_anchor", capture):
|
||||
await pv.verify("c1", "t", None, ["High Street"], ANCHOR_AREA, {})
|
||||
assert seen["query"] == "High Street, Ossining, Westchester, New York"
|
||||
|
||||
|
||||
# -- The correction path -------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_known_term_is_preferred_and_costs_nothing():
|
||||
"""
|
||||
A sound-alike the operator already entered is both free and more trustworthy
|
||||
than anything Maps guesses, so it must be tried before any request goes out.
|
||||
"""
|
||||
with _geocode(None), _places(None) as places, \
|
||||
patch.object(pv.area_context, "add_pending", AsyncMock()) as add:
|
||||
text, segs = await pv.verify(
|
||||
"c1", "Units to Snowdon Ave.", None, ["Snowdon Ave"], ANCHOR_AREA, {}
|
||||
)
|
||||
assert text == "Units to Snowden Avenue."
|
||||
places.assert_not_called()
|
||||
add.assert_not_called() # already known — nothing to propose
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_maps_soundalike_is_applied_and_proposed_to_the_talkgroup():
|
||||
candidate = {"term": "Croton Point", "meaning": "Croton Point Ave, Croton NY", "score": 0.8}
|
||||
with _geocode(None), _places(candidate), \
|
||||
patch.object(pv.area_context, "add_pending", AsyncMock(return_value=1)) as add:
|
||||
text, segs = await pv.verify(
|
||||
"c1", "Respond to Cool Parts.", None, ["Cool Parts"], ANCHOR_AREA, {},
|
||||
system_id="sys-1", talkgroup_id=9048,
|
||||
)
|
||||
assert text == "Respond to Croton Point."
|
||||
args = add.await_args.args
|
||||
assert args[0] == "sys-1" and args[1] == 9048
|
||||
assert args[2][0]["term"] == "Croton Point"
|
||||
assert args[2][0]["source_call_ids"] == ["c1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nothing_plausible_leaves_the_transcript_untouched():
|
||||
"""
|
||||
An invented name with no real counterpart nearby stays as it is. Guessing
|
||||
would put a fabricated location into the incident record, which is the
|
||||
outcome this whole pass exists to avoid.
|
||||
"""
|
||||
with _geocode(None), _places(None):
|
||||
assert await pv.verify("c1", "Shout out to Optum.", SEGS,
|
||||
["Optum"], ANCHOR_AREA, {}) == (None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_segments_are_corrected_alongside_the_joined_text():
|
||||
"""Extraction reads numbered segments, so a joined-only fix reaches nothing."""
|
||||
with _geocode(None), _places(None), \
|
||||
patch.object(pv.area_context, "add_pending", AsyncMock()):
|
||||
text, segs = await pv.verify(
|
||||
"c1", "Shout out to Snowdon Ave. Copy that.",
|
||||
[{"start": 0.0, "end": 1.0, "text": "Shout out to Snowdon Ave."},
|
||||
{"start": 1.0, "end": 2.0, "text": "Copy that."}],
|
||||
["Snowdon Ave"], ANCHOR_AREA, {},
|
||||
)
|
||||
assert segs is not None
|
||||
assert segs[0]["text"] == "Shout out to Snowden Avenue."
|
||||
assert segs[0]["start"] == 0.0, "timing survives untouched"
|
||||
assert segs[1]["text"] == "Copy that."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_geocoder_failure_never_breaks_the_transcript():
|
||||
with patch.object(pv, "_geocode_in_anchor", AsyncMock(side_effect=RuntimeError("boom"))):
|
||||
assert await pv.verify("c1", "t here", None, ["Optum"], ANCHOR_AREA, {}) == (None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_a_bounded_number_of_nouns_is_checked():
|
||||
with patch.object(pv.settings, "place_verify_max_per_call", 2), \
|
||||
patch.object(pv, "_geocode_in_anchor", AsyncMock(return_value={"lat": 41.16, "lng": -73.86})) as geo:
|
||||
await pv.verify("c1", "t", None, ["a", "b", "c", "d"], ANCHOR_AREA, {})
|
||||
assert geo.await_count == 2
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Unit tests for talkgroup name resolution.
|
||||
|
||||
From the 2026-08-23 correlation dump: 84 of 100 incidents were titled
|
||||
"Ems — TGID 9048" rather than "Ems — Ossining Police Dispatch", because
|
||||
/upload took `talkgroup_name` from a multipart form field the node only fills
|
||||
when OP25 already had the name — and never fell back to the system config the
|
||||
way mqtt_handler's call_start path did. server-26#34.
|
||||
|
||||
resolve() is the single implementation both paths now share. These tests pin
|
||||
its preference order, since the whole bug was one caller skipping a step.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.internal import talkgroups
|
||||
|
||||
SYSTEM = {
|
||||
"config": {
|
||||
"talkgroups": [
|
||||
{"id": 9048, "name": "Ossining - Police Dispatch"},
|
||||
{"id": 9600, "name": "MTA PD Districts 6/7/11 - Police Dispatch"},
|
||||
{"id": 9563, "name": ""}, # present but unnamed
|
||||
{"id": "9211", "name": "Ardsley"}, # id stored as a string
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _system(doc=SYSTEM):
|
||||
return patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock(return_value=doc))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hint_wins_over_everything():
|
||||
"""OP25 knew the name — no Firestore read at all."""
|
||||
with patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock()) as m:
|
||||
got = await talkgroups.resolve("sys-1", 9048, hint="Whatever OP25 Said")
|
||||
assert got == "Whatever OP25 Said"
|
||||
m.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_doc_used_before_system_config():
|
||||
"""The call document already carries the name written at call_start."""
|
||||
with patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock()) as m:
|
||||
got = await talkgroups.resolve(
|
||||
"sys-1", 9048, hint=None, call_doc={"talkgroup_name": "From Call Doc"}
|
||||
)
|
||||
assert got == "From Call Doc"
|
||||
m.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_system_config():
|
||||
"""The case that was broken: nothing upstream knew the name, C2 did."""
|
||||
with _system():
|
||||
got = await talkgroups.resolve("sys-1", 9048, hint=None, call_doc={})
|
||||
assert got == "Ossining - Police Dispatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_call_doc_name_does_not_block_the_lookup():
|
||||
"""A falsy talkgroup_name on the call doc must not short-circuit."""
|
||||
with _system():
|
||||
got = await talkgroups.resolve(
|
||||
"sys-1", 9600, hint=None, call_doc={"talkgroup_name": ""}
|
||||
)
|
||||
assert got == "MTA PD Districts 6/7/11 - Police Dispatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_talkgroup_ids_in_config_still_match():
|
||||
with _system():
|
||||
assert await talkgroups.resolve("sys-1", 9211) == "Ardsley"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"system_id, tgid",
|
||||
[(None, 9048), ("sys-1", None), (None, None)],
|
||||
)
|
||||
async def test_missing_inputs_return_none_without_reading(system_id, tgid):
|
||||
with patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock()) as m:
|
||||
assert await talkgroups.resolve(system_id, tgid) is None
|
||||
m.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_talkgroup_returns_none_so_caller_keeps_tgid_fallback():
|
||||
with _system():
|
||||
assert await talkgroups.resolve("sys-1", 1234) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_entry_with_empty_string_returns_none():
|
||||
"""An entry that exists but has no name is not a name."""
|
||||
with _system():
|
||||
assert await talkgroups.resolve("sys-1", 9563) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_system_document_returns_none():
|
||||
with _system(doc=None):
|
||||
assert await talkgroups.resolve("sys-1", 9048) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unparseable_talkgroup_id_returns_none():
|
||||
with _system():
|
||||
assert await talkgroups.resolve("sys-1", "not-a-number") is None
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Unit tests for the transcript correction pass (server-26#36).
|
||||
|
||||
Two properties carry real risk and are pinned hardest here:
|
||||
|
||||
* SCOPE RESOLUTION — talkgroup reference data must rank ABOVE system data.
|
||||
A system spanning several counties can have a talkgroup covering one
|
||||
municipality, and burying that municipality's streets under a county-wide
|
||||
list is the failure this whole feature exists to avoid.
|
||||
|
||||
* SEGMENT ALIGNMENT — scene extraction maps scenes to transmissions by index
|
||||
(segment_indices), so a corrected array of the wrong length would silently
|
||||
attribute the wrong audio to a scene. Anything but an exact 1:1 match must
|
||||
be discarded whole.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.internal import transcript_correction as tc
|
||||
|
||||
SYSTEM = {
|
||||
"vocabulary": ["Croton-Harmon", "Metro-North"],
|
||||
"ten_codes": {"10-4": "acknowledged", "10-13": "officer needs assistance"},
|
||||
"area_context": {
|
||||
"county": "Westchester",
|
||||
"state": "New York",
|
||||
"local_knowledge": [
|
||||
{"term": "Route 9", "meaning": "north-south state highway"},
|
||||
{"term": "Saw Mill Parkway"},
|
||||
],
|
||||
},
|
||||
"config": {
|
||||
"talkgroups": [
|
||||
{
|
||||
"id": 9048,
|
||||
"name": "Ossining - Police Dispatch",
|
||||
"vocabulary": ["Snowden Avenue", "Croton-Harmon"],
|
||||
"area_context": {
|
||||
"municipality": "Ossining",
|
||||
"local_knowledge": [{"term": "Sing Sing", "meaning": "state prison"}],
|
||||
},
|
||||
},
|
||||
{"id": 9600, "name": "Harrison - Police/EMS Dispatch"},
|
||||
{"id": 9563, "ten_codes": {"10-4": "on scene"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _system(doc=SYSTEM):
|
||||
return patch.object(tc.fstore, "doc_get_cached", AsyncMock(return_value=doc))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _api_key():
|
||||
"""
|
||||
The dev venv has no GEMINI_API_KEY, and correct() returns early without one
|
||||
— which would make every assertion below pass for the wrong reason.
|
||||
"""
|
||||
with patch.object(tc.settings, "gemini_api_key", "test-key"):
|
||||
yield
|
||||
|
||||
|
||||
# ── Scope resolution ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_talkgroup_vocabulary_ranks_above_system():
|
||||
with _system():
|
||||
ctx = await tc.resolve_context("sys-1", 9048)
|
||||
assert ctx["vocabulary"][0] == "Snowden Avenue", "talkgroup terms must come first"
|
||||
assert "Metro-North" in ctx["vocabulary"], "system terms are still inherited"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_terms_are_not_repeated():
|
||||
"""Croton-Harmon is on both scopes; it should appear once, at talkgroup rank."""
|
||||
with _system():
|
||||
ctx = await tc.resolve_context("sys-1", 9048)
|
||||
assert [t.lower() for t in ctx["vocabulary"]].count("croton-harmon") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_talkgroup_area_precedes_system_area():
|
||||
with _system():
|
||||
ctx = await tc.resolve_context("sys-1", 9048)
|
||||
joined = "\n".join(ctx["area_lines"])
|
||||
assert joined.index("Ossining") < joined.index("Westchester")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_talkgroup_without_own_data_inherits_system():
|
||||
with _system():
|
||||
ctx = await tc.resolve_context("sys-1", 9600)
|
||||
assert ctx["vocabulary"] == ["Croton-Harmon", "Metro-North"]
|
||||
assert any("Westchester" in line for line in ctx["area_lines"])
|
||||
assert ctx["area"].get("municipality") is None, "inherits, invents nothing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_talkgroup_ten_code_overrides_system_meaning():
|
||||
with _system():
|
||||
ctx = await tc.resolve_context("sys-1", 9563)
|
||||
assert ctx["ten_codes"]["10-4"] == "on scene"
|
||||
assert ctx["ten_codes"]["10-13"] == "officer needs assistance"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("system_id, tgid", [(None, 9048), ("sys-1", None)])
|
||||
async def test_missing_scope_is_not_an_error(system_id, tgid):
|
||||
with _system():
|
||||
ctx = await tc.resolve_context(system_id, tgid)
|
||||
assert isinstance(ctx["vocabulary"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconfigured_system_yields_empty_context():
|
||||
with _system(doc=None):
|
||||
ctx = await tc.resolve_context("sys-1", 9048)
|
||||
assert ctx == {
|
||||
"vocabulary": [], "ten_codes": {}, "area_lines": [],
|
||||
"area": {}, "system_area": {}, "tg_area": {},
|
||||
}
|
||||
|
||||
|
||||
# ── Correction behaviour ────────────────────────────────────────────────────
|
||||
|
||||
def _gemini(payload):
|
||||
return patch.object(tc, "_sync_gemini", lambda model, prompt: payload)
|
||||
|
||||
|
||||
SEGS = [{"start": 0.0, "end": 1.0, "text": "Headquarters, 11-9."},
|
||||
{"start": 1.0, "end": 2.0, "text": "Shout out to Optum."},
|
||||
{"start": 2.0, "end": 3.0, "text": "360 north, back to Rose."}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_transcript_is_never_sent():
|
||||
"""9 of 29 calls in the sample window were <=3 words. Nothing to correct."""
|
||||
with patch.object(tc, "_sync_gemini") as m:
|
||||
out = await tc.correct("c1", "10-4.", None, system_id="sys-1")
|
||||
assert out == (None, None, False)
|
||||
m.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_by_setting():
|
||||
with patch.object(tc.settings, "transcript_correction_enabled", False), \
|
||||
patch.object(tc, "_sync_gemini") as m:
|
||||
assert await tc.correct("c1", "a b c d e", None) == (None, None, False)
|
||||
m.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_segments_corrected_when_lengths_match():
|
||||
payload = {"corrected": "Headquarters, 11-9. Show it out to Ossining. 360 north, back to Route 9.",
|
||||
"segments": ["Headquarters, 11-9.", "Show it out to Ossining.", "360 north, back to Route 9."]}
|
||||
with _system(), _gemini(payload):
|
||||
text, segs, not_speech = await tc.correct("c1", "x y z w", SEGS, system_id="sys-1", talkgroup_id=9048)
|
||||
assert not_speech is False
|
||||
assert segs is not None and len(segs) == 3
|
||||
assert segs[1]["text"] == "Show it out to Ossining."
|
||||
assert segs[1]["start"] == 1.0, "timing must survive correction untouched"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_segment_count_is_discarded_whole():
|
||||
"""A short array would silently misattribute audio to the wrong scene."""
|
||||
payload = {"corrected": "fine", "segments": ["only", "two"]}
|
||||
with _system(), _gemini(payload):
|
||||
text, segs, _ = await tc.correct("c1", "x y z w", SEGS, system_id="sys-1")
|
||||
assert segs is None
|
||||
assert text == "fine", "the joined correction still stands"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_string_segment_entries_are_discarded():
|
||||
payload = {"corrected": None, "segments": ["ok", 42, "ok"]}
|
||||
with _system(), _gemini(payload):
|
||||
_, segs, _ = await tc.correct("c1", "x y z w", SEGS, system_id="sys-1")
|
||||
assert segs is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unchanged_segments_report_no_correction():
|
||||
payload = {"corrected": None, "segments": [s["text"] for s in SEGS]}
|
||||
with _system(), _gemini(payload):
|
||||
text, segs, _ = await tc.correct("c1", "x y z w", SEGS, system_id="sys-1")
|
||||
assert (text, segs) == (None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_echoed_transcript_counts_as_no_change():
|
||||
with _system(), _gemini({"corrected": " x y z w "}):
|
||||
text, _, _ = await tc.correct("c1", "x y z w", None, system_id="sys-1")
|
||||
assert text is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_speech_is_surfaced():
|
||||
with _system(), _gemini({"corrected": None, "not_speech": True}):
|
||||
_, _, not_speech = await tc.correct("c1", "10-11. 10-12. 10-13. 10-14.", None, system_id="sys-1")
|
||||
assert not_speech is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_failure_leaves_the_transcript_alone():
|
||||
"""Correction is an improvement, never a dependency."""
|
||||
def boom(model, prompt):
|
||||
raise RuntimeError("gemini exploded")
|
||||
with _system(), patch.object(tc, "_sync_gemini", boom):
|
||||
assert await tc.correct("c1", "x y z w", SEGS, system_id="sys-1") == (None, None, False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reference_data_reaches_the_prompt():
|
||||
seen = {}
|
||||
def capture(model, prompt):
|
||||
seen["prompt"] = prompt
|
||||
return {"corrected": None}
|
||||
with _system(), patch.object(tc, "_sync_gemini", capture):
|
||||
await tc.correct("c1", "x y z w", None, system_id="sys-1",
|
||||
talkgroup_id=9048, talkgroup_name="Ossining - Police Dispatch")
|
||||
p = seen["prompt"]
|
||||
assert "Snowden Avenue" in p and "Ossining - Police Dispatch" in p
|
||||
assert "Sing Sing — state prison" in p, "a term without its meaning is half the information"
|
||||
assert "Ossining, Westchester, New York" in p, "state must reach the prompt (server-26#36)"
|
||||
assert "10-13=officer needs assistance" in p
|
||||
@@ -1062,14 +1062,20 @@ const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||||
];
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, isAdmin } = useAuth();
|
||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<AdminTab>("features");
|
||||
|
||||
// Wait for the claims to resolve before deciding. isAdmin is false for the
|
||||
// first render of every cold load (typed URL, hard refresh, bookmark) while
|
||||
// AuthProvider fetches the ID token, so a guard that ignores authLoading
|
||||
// redirects the admin off their own page every time and only ever lets them
|
||||
// in via an in-app link. Same shape as /nodes, /systems and /settings.
|
||||
useEffect(() => {
|
||||
if (!isAdmin) router.replace("/dashboard");
|
||||
}, [isAdmin, router]);
|
||||
if (!authLoading && !isAdmin) router.replace("/");
|
||||
}, [authLoading, isAdmin, router]);
|
||||
|
||||
if (authLoading) return null;
|
||||
if (!isAdmin) return null;
|
||||
|
||||
// Users/Audit tabs benefit from full width; everything else is narrow
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useAlerts } from "@/lib/useAlerts";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { AlertRule } from "@/lib/types";
|
||||
|
||||
@@ -185,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
export default function AlertsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { alerts, loading } = useAlerts();
|
||||
const { alerts, loading, error } = useAlerts();
|
||||
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||
|
||||
async function handleAcknowledge(id: string) {
|
||||
@@ -225,9 +226,22 @@ export default function AlertsPage() {
|
||||
{tab === "events" && (
|
||||
loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
) : error ? (
|
||||
<p className="text-red-400 text-sm font-mono">
|
||||
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
||||
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
|
||||
: `Couldn't load alerts: ${error}`}
|
||||
</p>
|
||||
) : alerts.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
|
||||
and the keyword match that fired the alert was made against it. */}
|
||||
<MachineOutputNotice
|
||||
variant="inline"
|
||||
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
|
||||
/>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
@@ -280,6 +294,7 @@ export default function AlertsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
|
||||
+342
-212
@@ -1,261 +1,391 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useCalls } from "@/lib/useCalls";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
// Archive — the call-level view. Until now /calls was a ten-line stub that
|
||||
// redirected to /incidents, so there was no way to look at a call anywhere in
|
||||
// the app: the nav's "Archive" link led to the incident list, and a call that
|
||||
// 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.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { useIncidents } from "@/lib/useIncidents";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { CallRecord, IncidentRecord } from "@/lib/types";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
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";
|
||||
|
||||
const inputCls =
|
||||
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white font-mono " +
|
||||
"placeholder:text-gray-600 focus:outline-none focus:border-indigo-500 w-full";
|
||||
type LinkFilter = "any" | "orphan" | "linked";
|
||||
type TranscriptFilter = "any" | "yes" | "no";
|
||||
|
||||
function filterCalls(calls: CallRecord[], filters: Filters): CallRecord[] {
|
||||
const q = filters.query.trim().toLowerCase();
|
||||
const tgid = filters.tgid.trim();
|
||||
const LINK_FILTERS: { key: LinkFilter; label: string }[] = [
|
||||
{ key: "any", label: "All" },
|
||||
{ key: "orphan", label: "Orphans" },
|
||||
{ key: "linked", label: "Linked" },
|
||||
];
|
||||
|
||||
return calls.filter((c) => {
|
||||
// System filter
|
||||
if (filters.systemId && c.system_id !== filters.systemId) return false;
|
||||
const TRANSCRIPT_FILTERS: { key: TranscriptFilter; label: string }[] = [
|
||||
{ key: "any", label: "Any" },
|
||||
{ key: "yes", label: "Transcribed" },
|
||||
{ key: "no", label: "No transcript" },
|
||||
];
|
||||
|
||||
// TGID filter (exact match on the number)
|
||||
if (tgid && String(c.talkgroup_id ?? "") !== tgid) return false;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Free-text: talkgroup name, node_id, transcript, tags
|
||||
if (q) {
|
||||
const hay = [
|
||||
c.talkgroup_name ?? "",
|
||||
c.node_id,
|
||||
c.transcript ?? "",
|
||||
c.transcript_corrected ?? "",
|
||||
...(c.tags ?? []),
|
||||
].join(" ").toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
function fmtWhen(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
|
||||
} catch {
|
||||
return String(iso);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDuration(call: CallRecord): string {
|
||||
if (!call.ended_at) return "active";
|
||||
const ms = new Date(call.ended_at).getTime() - new Date(call.started_at).getTime();
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function callIncidentIds(call: CallRecord): string[] {
|
||||
if (call.incident_ids?.length) return call.incident_ids;
|
||||
return call.incident_id ? [call.incident_id] : [];
|
||||
}
|
||||
|
||||
/** One archive row: metadata, transcript, audio, and the attribution control. */
|
||||
function ArchiveRow({
|
||||
call,
|
||||
systemName,
|
||||
incidents,
|
||||
onChanged,
|
||||
}: {
|
||||
call: CallRecord;
|
||||
systemName?: string;
|
||||
incidents: IncidentRecord[];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attachTo, setAttachTo] = useState("");
|
||||
|
||||
const linkedIds = callIncidentIds(call);
|
||||
const text = call.transcript_corrected || call.transcript || "";
|
||||
|
||||
// The stored document holds only the private gs:// object location; a
|
||||
// playable link is minted per read by the API, so fetch it on expand.
|
||||
useEffect(() => {
|
||||
if (!open || audioUrl) return;
|
||||
let cancelled = false;
|
||||
c2api
|
||||
.getCall(call.call_id)
|
||||
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
|
||||
.catch(() => { /* audio is optional — the row is still useful without it */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [open, audioUrl, call.call_id]);
|
||||
|
||||
async function attach() {
|
||||
if (!attachTo) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await c2api.linkCallToIncident(attachTo, call.call_id);
|
||||
setAttachTo("");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
async function detach(incidentId: string) {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await c2api.unlinkCallFromIncident(incidentId, call.call_id);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-raised/40 transition-colors"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="text-ink font-mono text-xs shrink-0">{fmtWhen(call.started_at)}</span>
|
||||
<span className="text-ink-2 text-sm font-medium truncate">
|
||||
{call.talkgroup_name || (call.talkgroup_id ? `TGID ${call.talkgroup_id}` : "unknown talkgroup")}
|
||||
</span>
|
||||
<span className="text-ink-muted text-xs font-mono">{fmtDuration(call)}</span>
|
||||
{linkedIds.length === 0 ? (
|
||||
<Badge tone="warning">orphan</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{linkedIds.length === 1 ? "linked" : `${linkedIds.length} incidents`}</Badge>
|
||||
)}
|
||||
{!text && <Badge tone="danger">no transcript</Badge>}
|
||||
{systemName && <span className="text-ink-muted text-xs ml-auto shrink-0">{systemName}</span>}
|
||||
</div>
|
||||
{text && !open && (
|
||||
<p className="text-ink-muted text-xs mt-1.5 truncate">{text}</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-line pt-3">
|
||||
{text ? (
|
||||
<p className="text-ink-2 text-sm leading-relaxed">{text}</p>
|
||||
) : (
|
||||
<p className="text-ink-muted text-xs italic">
|
||||
No transcript. Either STT was off when this call landed, or Whisper rejected it as
|
||||
silence or degenerate output.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{audioUrl && (
|
||||
/* eslint-disable-next-line jsx-a11y/media-has-caption */
|
||||
<audio controls src={audioUrl} className="w-full h-9" />
|
||||
)}
|
||||
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 text-xs">
|
||||
<div><dt className="text-ink-muted inline">call </dt><dd className="text-ink-2 font-mono inline">{call.call_id.slice(0, 8)}</dd></div>
|
||||
<div><dt className="text-ink-muted inline">node </dt><dd className="text-ink-2 font-mono inline">{call.node_id ?? "—"}</dd></div>
|
||||
<div><dt className="text-ink-muted inline">tgid </dt><dd className="text-ink-2 font-mono inline">{call.talkgroup_id ?? "—"}</dd></div>
|
||||
<div><dt className="text-ink-muted inline">path </dt><dd className="text-ink-2 font-mono inline">{call.corr_path ?? "—"}</dd></div>
|
||||
</dl>
|
||||
|
||||
{/* Manual attribution */}
|
||||
<div className="space-y-2">
|
||||
{linkedIds.map((id) => {
|
||||
const inc = incidents.find((i) => i.incident_id === id);
|
||||
return (
|
||||
<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
|
||||
onClick={() => detach(id)}
|
||||
disabled={busy}
|
||||
className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
|
||||
>
|
||||
detach
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={attachTo}
|
||||
onChange={(e) => setAttachTo(e.target.value)}
|
||||
className="bg-surface border border-line rounded-md text-xs text-ink px-2 py-1.5 max-w-xs"
|
||||
>
|
||||
<option value="">Attach to incident…</option>
|
||||
{incidents
|
||||
.filter((i) => !linkedIds.includes(i.incident_id))
|
||||
.slice(0, 100)
|
||||
.map((i) => (
|
||||
<option key={i.incident_id} value={i.incident_id}>
|
||||
{fmtWhen(i.started_at)} — {i.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
|
||||
{busy ? "Saving…" : "Attach"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
query: string;
|
||||
tgid: string;
|
||||
systemId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: Filters = {
|
||||
query: "",
|
||||
tgid: "",
|
||||
systemId: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
};
|
||||
|
||||
function isActive(f: Filters) {
|
||||
return f.query || f.tgid || f.systemId || f.dateFrom || f.dateTo;
|
||||
}
|
||||
|
||||
export default function CallsPage() {
|
||||
const [limitCount, setLimitCount] = useState(100);
|
||||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
||||
|
||||
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom + "T00:00:00") : undefined;
|
||||
const dateTo = filters.dateTo ? new Date(filters.dateTo + "T23:59:59") : undefined;
|
||||
|
||||
const { calls, loading } = useCalls(limitCount, dateFrom, dateTo);
|
||||
export default function ArchivePage() {
|
||||
const { isAdmin, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { systems } = useSystems();
|
||||
const { isAdmin } = useAuth();
|
||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||
const { incidents } = useIncidents(200);
|
||||
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [moreAvailable, setMoreAvailable] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof Filters>(key: K, value: string) {
|
||||
setFilters((f) => ({ ...f, [key]: value }));
|
||||
const [link, setLink] = useState<LinkFilter>("any");
|
||||
const [transcript, setTranscript] = useState<TranscriptFilter>("any");
|
||||
const [systemId, setSystemId] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [submittedQ, setSubmittedQ] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin) router.replace("/");
|
||||
}, [authLoading, isAdmin, router]);
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor: string | null, append: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await c2api.searchCalls({
|
||||
limit: PAGE_SIZE,
|
||||
cursor: nextCursor,
|
||||
link,
|
||||
transcript,
|
||||
system_id: systemId || undefined,
|
||||
q: submittedQ || undefined,
|
||||
});
|
||||
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
|
||||
setCursor(res.next_cursor);
|
||||
setMoreAvailable(Boolean(res.next_cursor));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[link, transcript, systemId, submittedQ],
|
||||
);
|
||||
|
||||
const active = calls.filter((c) => c.status === "active");
|
||||
const ended = calls.filter((c) => c.status === "ended");
|
||||
const filtered = useMemo(() => filterCalls(ended, filters), [ended, filters]);
|
||||
// Reload from the top whenever a filter changes.
|
||||
useEffect(() => {
|
||||
if (authLoading || !isAdmin) return;
|
||||
load(null, false);
|
||||
}, [authLoading, isAdmin, load]);
|
||||
|
||||
const activeFilters = isActive(filters);
|
||||
const systemName = useMemo(() => {
|
||||
const m = new Map(systems.map((s) => [s.system_id, s.name]));
|
||||
return (id?: string | null) => (id ? m.get(id) : undefined);
|
||||
}, [systems]);
|
||||
|
||||
// Every hook runs before this guard — see the note in app/nodes/page.tsx.
|
||||
if (authLoading || !isAdmin) return null;
|
||||
|
||||
const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length;
|
||||
const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
|
||||
<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."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex gap-1 bg-surface border border-line rounded-lg p-1">
|
||||
{LINK_FILTERS.map(({ key, label }) => (
|
||||
<button
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
className={`text-xs font-mono px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
activeFilters
|
||||
? "border-indigo-600 bg-indigo-950 text-indigo-300"
|
||||
: "border-gray-700 bg-gray-900 text-gray-400 hover:text-gray-200"
|
||||
key={key}
|
||||
onClick={() => setLink(key)}
|
||||
className={`text-sm px-3 py-1.5 rounded-md transition-colors ${
|
||||
link === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{showFilters ? "Hide filters" : "Filter"}
|
||||
{activeFilters && " •"}
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{showFilters && (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{/* Text search */}
|
||||
<div className="lg:col-span-2">
|
||||
<label className="text-xs text-gray-500 block mb-1">Search (talkgroup, node, transcript, tags)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.query}
|
||||
onChange={(e) => set("query", e.target.value)}
|
||||
placeholder="fire, Engine 5, dispatch…"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex gap-1 bg-surface border border-line rounded-lg p-1">
|
||||
{TRANSCRIPT_FILTERS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTranscript(key)}
|
||||
className={`text-sm px-3 py-1.5 rounded-md transition-colors ${
|
||||
transcript === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* TGID */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">Talkgroup ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.tgid}
|
||||
onChange={(e) => set("tgid", e.target.value)}
|
||||
placeholder="e.g. 9048"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">System</label>
|
||||
<select
|
||||
value={filters.systemId}
|
||||
onChange={(e) => set("systemId", e.target.value)}
|
||||
className={inputCls}
|
||||
value={systemId}
|
||||
onChange={(e) => setSystemId(e.target.value)}
|
||||
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2"
|
||||
>
|
||||
<option value="">All systems</option>
|
||||
{systems.map((s) => (
|
||||
<option key={s.system_id} value={s.system_id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date from */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">From date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
onChange={(e) => set("dateFrom", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date to */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">To date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
onChange={(e) => set("dateTo", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilters && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{filtered.length} of {ended.length} calls match
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setFilters(DEFAULT_FILTERS)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
|
||||
className="flex items-center gap-2 ml-auto"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search transcripts…"
|
||||
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2 w-56"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" type="submit">Search</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live calls — never filtered */}
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
|
||||
Live ({active.length})
|
||||
</h2>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
<th className="px-4 py-2 text-left">Time</th>
|
||||
<th className="px-4 py-2 text-left">Talkgroup</th>
|
||||
<th className="px-4 py-2 text-left">System</th>
|
||||
<th className="px-4 py-2 text-left">Node</th>
|
||||
<th className="px-4 py-2 text-left">Duration</th>
|
||||
<th className="px-4 py-2 text-left">Audio</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.map((c) => (
|
||||
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
History{activeFilters && <span className="ml-2 text-indigo-400">({filtered.length} filtered)</span>}
|
||||
</h2>
|
||||
{loading ? (
|
||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">
|
||||
{activeFilters ? "No calls match the current filters." : "No calls recorded yet."}
|
||||
{calls.length > 0 && (
|
||||
<p className="text-ink-muted text-xs font-mono">
|
||||
{calls.length} calls · {orphanCount} orphaned · {noTranscript} without a transcript
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Gate A / A2 (server-26#46) — every row expands to a transcript. */}
|
||||
<MachineOutputNotice
|
||||
detail="transcripts and the incident links derived from them are automated output and may contain errors, including misheard names, addresses and unit numbers. Check the recording before acting on them."
|
||||
/>
|
||||
|
||||
{error && <ErrorBanner message={`Couldn't load calls: ${error}`} />}
|
||||
|
||||
{loading && calls.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
) : calls.length === 0 && !error ? (
|
||||
<EmptyState
|
||||
title="No calls match these filters"
|
||||
description="The search scans a bounded window of the most recent calls — widen the filters or clear the search text."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
<th className="px-4 py-2 text-left">Time</th>
|
||||
<th className="px-4 py-2 text-left">Talkgroup</th>
|
||||
<th className="px-4 py-2 text-left">System</th>
|
||||
<th className="px-4 py-2 text-left">Node</th>
|
||||
<th className="px-4 py-2 text-left">Duration</th>
|
||||
<th className="px-4 py-2 text-left">Audio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((c) => (
|
||||
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
||||
<div className="space-y-2">
|
||||
{calls.map((call) => (
|
||||
<ArchiveRow
|
||||
key={call.call_id}
|
||||
call={call}
|
||||
systemName={systemName(call.system_id)}
|
||||
incidents={incidents}
|
||||
onChanged={() => load(null, false)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{ended.length >= limitCount && (
|
||||
<button
|
||||
onClick={() => setLimitCount((n) => n + 100)}
|
||||
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300 font-mono transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
||||
{moreAvailable && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="secondary" onClick={() => load(cursor, true)} disabled={loading}>
|
||||
{loading ? "Loading…" : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
|
||||
import { useCalls, useActiveCalls } from "@/lib/useCalls";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||
import { NodeCard } from "@/components/NodeCard";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||
import { TypeBadge } from "@/components/IncidentBadges";
|
||||
import { severityBadge, severityRank } from "@/lib/severity";
|
||||
import { useState } from "react";
|
||||
import type { NodeRecord, IncidentRecord } from "@/lib/types";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
|
||||
catch { return iso; }
|
||||
}
|
||||
|
||||
function IncidentSummaryCard({ incident }: { incident: IncidentRecord }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card hover className="cursor-pointer" onClick={() => router.push(`/incidents/${incident.incident_id}`)}>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<TypeBadge type={incident.type} />
|
||||
{severityBadge(incident.severity)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug line-clamp-2">{incident.title ?? "Untitled incident"}</p>
|
||||
<p className="text-gray-500 text-xs font-mono mt-2">
|
||||
{fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { nodes, error: nodesError } = useNodes();
|
||||
const { nodes: pending } = useUnconfiguredNodes();
|
||||
const { calls, error: callsError } = useCalls(20);
|
||||
const activeCalls = useActiveCalls();
|
||||
const { systems, error: systemsError } = useSystems();
|
||||
const activeIncidents = useActiveIncidents();
|
||||
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||
const onlineCount = nodes.filter((n) => n.status !== "offline").length;
|
||||
|
||||
const fsError = nodesError ?? callsError ?? systemsError;
|
||||
|
||||
// Worst-first: the incident that most needs a human's attention leads the panel.
|
||||
const sortedIncidents = [...activeIncidents].sort(
|
||||
(a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)
|
||||
);
|
||||
const notableIncidentCount = activeIncidents.filter((i) => severityRank(i.severity) >= 2).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
badge={notableIncidentCount > 0 && <Badge tone="danger">{notableIncidentCount} moderate+ active</Badge>}
|
||||
/>
|
||||
|
||||
{fsError && <ErrorBanner message={`Firestore error: ${fsError}`} />}
|
||||
|
||||
{/* Pending config banner */}
|
||||
{pending.length > 0 && (
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-lg p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<p className="text-indigo-300 text-sm font-mono">
|
||||
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setConfigNode(pending[0])}>Configure now</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard label="Active Incidents" value={activeIncidents.length} accent={activeIncidents.length > 0 ? "text-orange-400" : undefined} />
|
||||
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
|
||||
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
|
||||
<StatCard label="Systems" value={systems.length} />
|
||||
</div>
|
||||
|
||||
{/* Active incidents — the primary "what's happening" view */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Active Incidents</h2>
|
||||
<Link href="/incidents" className="text-xs text-indigo-400 hover:text-indigo-300 font-mono transition-colors">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{sortedIncidents.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No active incidents"
|
||||
description="Incidents appear here automatically as calls correlate into events."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedIncidents.slice(0, 6).map((inc) => (
|
||||
<IncidentSummaryCard key={inc.incident_id} incident={inc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Nodes */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
|
||||
{nodes.length === 0 ? (
|
||||
<EmptyState title="No nodes registered yet" description="Deploy a field SDR node and it will show up here automatically." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{nodes.map((n) => (
|
||||
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent calls */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||
{calls.length === 0 ? (
|
||||
<EmptyState title="No calls recorded yet" />
|
||||
) : (
|
||||
<Card padding="none" className="overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
<th className="px-4 py-2 text-left">Time</th>
|
||||
<th className="px-4 py-2 text-left">Talkgroup</th>
|
||||
<th className="px-4 py-2 text-left">System</th>
|
||||
<th className="px-4 py-2 text-left">Node</th>
|
||||
<th className="px-4 py-2 text-left">Duration</th>
|
||||
<th className="px-4 py-2 text-left">Audio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{calls.map((c) => (
|
||||
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{configNode && (
|
||||
<NodeConfigModal node={configNode} systems={systems} onClose={() => setConfigNode(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
import { UnbuiltMarker } from "@/components/ui/UnbuiltMarker";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
|
||||
const FAQS: { q: string; a: string }[] = [
|
||||
const FAQS: { q: string; a: ReactNode }[] = [
|
||||
{
|
||||
q: "What hardware do I need to run a node?",
|
||||
a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.",
|
||||
@@ -15,7 +18,14 @@ const FAQS: { q: string; a: string }[] = [
|
||||
},
|
||||
{
|
||||
q: "Does DRB do the transcription and AI work itself, or is that a separate cost?",
|
||||
a: "Transcription and incident correlation are included in every paid plan and run automatically on every recorded call. The Community plan includes AI features on a limited call volume; Pro and Enterprise scale with your node count.",
|
||||
a: (
|
||||
<>
|
||||
Transcription and incident correlation run automatically on every recorded call and are
|
||||
included — they are not billed as an add-on.
|
||||
{/* Gate A / A2 (server-26#46) — qualified on the same screen as the claim. */}
|
||||
<MachineOutputNotice className="mt-3 not-italic" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
q: "Can I listen to live radio traffic without opening the dashboard?",
|
||||
@@ -30,8 +40,22 @@ const FAQS: { q: string; a: string }[] = [
|
||||
a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.",
|
||||
},
|
||||
{
|
||||
// Gate A / A1 (server-26#46): plan-tiered retention windows are an unbuilt
|
||||
// entitlement — there is no TTL and no deletion sweep anywhere in the
|
||||
// product (server-26#44). The claim is marked unbuilt inline, on this
|
||||
// screen, rather than quietly dropped.
|
||||
q: "How long is call and incident history kept?",
|
||||
a: "Retention depends on plan — 7 days on Community, 90 days on Pro, and a year or more on Enterprise (negotiable). Historical calls remain searchable and linked to their incidents for the full retention window.",
|
||||
a: (
|
||||
<>
|
||||
<UnbuiltMarker>Retention limits — not yet available</UnbuiltMarker>
|
||||
<p className="mt-2">
|
||||
Today nothing is deleted automatically: calls, recordings and incidents stay searchable and
|
||||
linked to their incidents for as long as your account is open. Per-plan retention windows and
|
||||
automatic deletion are not built yet, so we make no commitment about how long anything is kept
|
||||
or when it goes away. If you need data removed, ask us and we will remove it by hand.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
q: "Is DMR supported?",
|
||||
@@ -66,7 +90,7 @@ export default function FaqPage() {
|
||||
{FAQS.map((item, i) => {
|
||||
const open = openIndex === i;
|
||||
return (
|
||||
<div key={item.q}>
|
||||
<div key={i}>
|
||||
<button
|
||||
onClick={() => setOpenIndex(open ? null : i)}
|
||||
className="w-full flex items-center justify-between gap-4 py-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-lg"
|
||||
@@ -76,7 +100,7 @@ export default function FaqPage() {
|
||||
<ChevronIcon open={open} />
|
||||
</button>
|
||||
{open && (
|
||||
<p className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</p>
|
||||
<div className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
|
||||
const SECTIONS = [
|
||||
const SECTIONS: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
body: string;
|
||||
points: string[];
|
||||
/** Section describes AI pipeline output — render the Gate A / A2 qualifier. */
|
||||
qualify?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
eyebrow: "Correlation",
|
||||
title: "Calls become incidents",
|
||||
@@ -13,6 +21,7 @@ const SECTIONS = [
|
||||
"Distance, timing, shared units, and talkgroup signals all feed the match",
|
||||
"Every call keeps its correlation debug trail for admins to audit",
|
||||
],
|
||||
qualify: true,
|
||||
},
|
||||
{
|
||||
eyebrow: "AI pipeline",
|
||||
@@ -24,6 +33,7 @@ const SECTIONS = [
|
||||
"Scene & entity extraction feeds the correlator and the incident summary",
|
||||
"AI-generated incident summaries, regenerable on demand",
|
||||
],
|
||||
qualify: true,
|
||||
},
|
||||
{
|
||||
eyebrow: "Situational awareness",
|
||||
@@ -89,6 +99,9 @@ export default function FeaturesPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Gate A / A2 (server-26#46) — the sections that describe the AI
|
||||
pipeline carry the same qualifier the product surfaces do. */}
|
||||
{s.qualify && <MachineOutputNotice className="mt-5" />}
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||
animation: pulse-ring 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
|
||||
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
|
||||
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
|
||||
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
|
||||
* map container to its own low stacking context keeps Leaflet's internal layer
|
||||
* order intact while dropping the whole map (tiles + controls) below the app
|
||||
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
|
||||
* outside .leaflet-container, so it is unaffected and still renders on top.
|
||||
*/
|
||||
.leaflet-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||
html:not(.dark) select,
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useIncident } from "@/lib/useIncidents";
|
||||
import { useCallsByIncident } from "@/lib/useCalls";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { CallSpineEntry } from "@/components/CallSpineEntry";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
import { TypeBadge } from "@/components/IncidentBadges";
|
||||
import { severityBadge } from "@/lib/severity";
|
||||
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||
import { SeverityMark } from "@/components/marks/SeverityMark";
|
||||
import { isKnownSeverity } from "@/lib/severity";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||
|
||||
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
|
||||
status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
const EARLIER_PAGE_SIZE = 8;
|
||||
|
||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const s =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
|
||||
}
|
||||
|
||||
type Tab = "summary" | "units" | "details";
|
||||
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = active ? Date.now() : new Date(updatedAt).getTime();
|
||||
const mins = Math.max(0, Math.round((end - start) / 60000));
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
return `${hrs}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
export default function IncidentDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -34,14 +45,40 @@ export default function IncidentDetailPage() {
|
||||
|
||||
const { incident, loading } = useIncident(id);
|
||||
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
||||
const { systems } = useSystems();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const [tab, setTab] = useState<Tab>("summary");
|
||||
const [summarizing, setSummarizing] = useState(false);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
||||
|
||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||
// Same ordering/filtering MapView's IncidentPathLayer uses, so the stop
|
||||
// number shown on a spine entry matches the number on its map marker.
|
||||
const geocodedCalls = useMemo(
|
||||
() =>
|
||||
calls
|
||||
.filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords)
|
||||
.slice()
|
||||
.sort((a, b) => a.started_at.localeCompare(b.started_at)),
|
||||
[calls]
|
||||
);
|
||||
const stopNumberByCallId = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1));
|
||||
return m;
|
||||
}, [geocodedCalls]);
|
||||
|
||||
const pathLengthKm = useMemo(() => {
|
||||
let total = 0;
|
||||
for (let i = 1; i < geocodedCalls.length; i++) {
|
||||
total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!);
|
||||
}
|
||||
return total;
|
||||
}, [geocodedCalls]);
|
||||
|
||||
const newestFirst = useMemo(
|
||||
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
|
||||
[calls]
|
||||
);
|
||||
|
||||
async function handleResolve() {
|
||||
setResolving(true);
|
||||
@@ -57,220 +94,169 @@ export default function IncidentDetailPage() {
|
||||
finally { setSummarizing(false); }
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-gray-500 text-sm font-mono p-6">Loading…</p>;
|
||||
if (!incident) return <p className="text-gray-500 text-sm font-mono p-6">Incident not found.</p>;
|
||||
if (loading) return <p className="text-ink-muted text-sm p-6">Loading…</p>;
|
||||
if (!incident) return <p className="text-ink-muted text-sm p-6">Incident not found.</p>;
|
||||
|
||||
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
||||
const unitsActive = incident.units_active ?? incident.units ?? [];
|
||||
const unitsCleared = incident.units_cleared ?? [];
|
||||
const active = incident.status === "active";
|
||||
|
||||
const visible = newestFirst.slice(0, earlierShown);
|
||||
const remaining = newestFirst.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back */}
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
|
||||
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
|
||||
>
|
||||
← Incidents
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<TypeBadge type={incident.type} />
|
||||
<StatusBadge status={incident.status} />
|
||||
{severityBadge(incident.severity)}
|
||||
<TypeGlyph type={incident.type} size={20} className="text-ink-2" />
|
||||
{isKnownSeverity(incident.severity) && <SeverityMark severity={incident.severity} showLabel size="md" />}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${active ? "bg-accent/15 text-accent" : "bg-raised text-ink-2"}`}>
|
||||
{active ? "Active" : "Resolved"}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
|
||||
<h1 className="text-[27px] font-semibold text-ink leading-tight">
|
||||
{incident.title ?? "Incident"}
|
||||
</h1>
|
||||
<p className="text-xs text-ink-muted font-mono">
|
||||
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
|
||||
{pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path</>}
|
||||
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2 shrink-0 flex-wrap">
|
||||
<button
|
||||
onClick={handleSummarize}
|
||||
disabled={summarizing}
|
||||
className="text-xs bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={handleSummarize} disabled={summarizing}>
|
||||
{summarizing ? "Generating…" : "Regenerate summary"}
|
||||
</button>
|
||||
{incident.status === "active" && (
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={resolving}
|
||||
className="text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 text-gray-300 px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
{resolving ? "Resolving…" : "Resolve"}
|
||||
</button>
|
||||
</Button>
|
||||
{active && (
|
||||
<Button variant="secondary" size="sm" onClick={handleResolve} disabled={resolving}>
|
||||
{resolving ? "Resolving…" : "Mark resolved"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{displayTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{displayTags.map((t) => (
|
||||
<span key={t} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
||||
{t}
|
||||
</span>
|
||||
<span key={t} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded-full">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{/* Two columns: 828 / 612 per UI_REDESIGN.md §5.2 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
||||
{/* Left */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{incident.location_coords && (
|
||||
<div style={{ height: "280px" }}>
|
||||
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} />
|
||||
<div style={{ height: "352px" }} className="rounded-xl overflow-hidden border border-line">
|
||||
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} calls={calls} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-panel body */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
||||
|
||||
{/* Left: tabs — Summary / Units / Details */}
|
||||
<div className="lg:col-span-2 bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-gray-800 shrink-0">
|
||||
{(["summary", "units", "details"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 px-4 py-2.5 text-xs font-mono capitalize transition-colors ${
|
||||
tab === t
|
||||
? "text-white border-b-2 border-indigo-500 bg-gray-800/40"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="p-4 flex-1 overflow-y-auto">
|
||||
{tab === "summary" && (
|
||||
incident.summary ? (
|
||||
<p className="text-sm text-gray-300 leading-relaxed">{incident.summary}</p>
|
||||
{/* Summary — first, in prose. Not a tab. */}
|
||||
<div className="space-y-2.5">
|
||||
{incident.summary ? (
|
||||
<p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 font-mono italic">
|
||||
<p className="text-sm text-ink-muted italic">
|
||||
No summary yet.{" "}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={handleSummarize}
|
||||
disabled={summarizing}
|
||||
className="text-indigo-400 hover:text-indigo-300 not-italic transition-colors"
|
||||
>
|
||||
<button onClick={handleSummarize} disabled={summarizing} className="text-accent not-italic hover:underline">
|
||||
Generate now
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{/* Gate A / A2 (server-26#46): the summary, the title, the location,
|
||||
the units and the vehicles below are ALL pipeline output, so the
|
||||
notice sits on this screen with them — not on a policy page. */}
|
||||
<MachineOutputNotice />
|
||||
</div>
|
||||
|
||||
{tab === "units" && (
|
||||
<div className="space-y-4">
|
||||
{/* On scene / Cleared */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Units</p>
|
||||
{incident.units?.length > 0 ? (
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">On scene</p>
|
||||
{unitsActive.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{incident.units.map((u) => (
|
||||
<span key={u} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded font-mono">{u}</span>
|
||||
{unitsActive.map((u) => (
|
||||
<span key={u} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{u}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 font-mono italic">None extracted.</p>
|
||||
<p className="text-xs text-ink-muted italic">None extracted.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Vehicles</p>
|
||||
{incident.vehicles?.length > 0 ? (
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Cleared</p>
|
||||
{unitsCleared.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{unitsCleared.map((u) => (
|
||||
<span key={u} className="text-xs bg-transparent border border-line text-ink-muted px-2 py-0.5 rounded font-mono line-through">{u}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-muted italic">None yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{incident.vehicles?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{incident.vehicles.map((v) => (
|
||||
<span key={v} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded font-mono">{v}</span>
|
||||
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 font-mono italic">None extracted.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "details" && (
|
||||
<div className="space-y-3 text-xs font-mono">
|
||||
{incident.location && (
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wider mb-1">Location</p>
|
||||
<p className="text-gray-300">{incident.location}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wider mb-1">Started</p>
|
||||
<p className="text-gray-300">{new Date(incident.started_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wider mb-1">Last activity</p>
|
||||
<p className="text-gray-300">{new Date(incident.updated_at).toLocaleString()}</p>
|
||||
</div>
|
||||
{incident.talkgroup_ids?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wider mb-1">Talkgroups</p>
|
||||
<p className="text-gray-300">{incident.talkgroup_ids.join(", ")}</p>
|
||||
</div>
|
||||
)}
|
||||
{incident.severity && (
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wider mb-1">Severity</p>
|
||||
<p className="text-gray-300 capitalize">{incident.severity}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wider mb-1">Total calls</p>
|
||||
<p className="text-gray-300">{incident.call_ids.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: calls */}
|
||||
<div className="lg:col-span-3">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">
|
||||
{/* Right — the call spine */}
|
||||
<div className="lg:col-span-2">
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-1">
|
||||
Calls ({calls.length})
|
||||
</p>
|
||||
{/* Gate A / A2 — the spine renders transcripts. */}
|
||||
{calls.length > 0 && <MachineOutputNotice variant="inline" className="mb-2" />}
|
||||
{callsLoading ? (
|
||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
||||
<p className="text-ink-muted text-sm">Loading…</p>
|
||||
) : calls.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
|
||||
<p className="text-ink-muted text-sm">No calls linked yet.</p>
|
||||
) : (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
<th className="px-4 py-2 text-left">Time</th>
|
||||
<th className="px-4 py-2 text-left">Talkgroup</th>
|
||||
<th className="px-4 py-2 text-left hidden sm:table-cell">System</th>
|
||||
<th className="px-4 py-2 text-left hidden sm:table-cell">Node</th>
|
||||
<th className="px-4 py-2 text-left">Duration</th>
|
||||
<th className="px-4 py-2 text-left">Audio</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{calls.map((c) => (
|
||||
<CallRow
|
||||
<div>
|
||||
{visible.map((c) => (
|
||||
<CallSpineEntry
|
||||
key={c.call_id}
|
||||
call={c}
|
||||
systemName={systemMap[c.system_id ?? ""]?.name}
|
||||
stopNumber={stopNumberByCallId.get(c.call_id)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{remaining > 0 && (
|
||||
<button
|
||||
onClick={() => setEarlierShown((n) => n + EARLIER_PAGE_SIZE)}
|
||||
className="text-xs text-accent hover:underline mt-2"
|
||||
>
|
||||
{remaining} earlier call{remaining !== 1 ? "s" : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+143
-136
@@ -4,19 +4,18 @@ import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useIncidents } from "@/lib/useIncidents";
|
||||
import { useActiveCalls } from "@/lib/useCalls";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { severityBadge, severityRank } from "@/lib/severity";
|
||||
import { TypeBadge } from "@/components/IncidentBadges";
|
||||
|
||||
// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow).
|
||||
// `severityBadge()` already returns null for the legacy "unknown" value.
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
||||
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
||||
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||
|
||||
type SeverityFilter = "all" | "minor" | "moderate" | "major";
|
||||
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
|
||||
@@ -29,121 +28,96 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
|
||||
|
||||
type SortMode = "recent" | "severity";
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||
// The Firestore client surfaces a missing composite index or an undeployed
|
||||
// ruleset as a raw multi-line string with a console URL in it — not something
|
||||
// to put in front of an operator. Collapse the known infra failures to a plain
|
||||
// line; pass anything else straight through so a real bug still shows.
|
||||
function friendlyIncidentsError(raw: string): string {
|
||||
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
|
||||
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
|
||||
}
|
||||
return `Couldn't load incidents: ${raw}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rows / cards
|
||||
// ---------------------------------------------------------------------------
|
||||
function fmtTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
||||
}
|
||||
|
||||
function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
function dayBucket(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const diffDays = Math.round((startOfDay(now) - startOfDay(d)) / 86_400_000);
|
||||
if (diffDays === 0) return "Today";
|
||||
if (diffDays === 1) return "Yesterday";
|
||||
return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
// Same rail-card anatomy as Live (MapView's incident panel), at browse
|
||||
// density: severity spine, type glyph, severity chip, ON AIR pill, title,
|
||||
// location, units-on-scene chips, age + call count. UI_REDESIGN.md §5.1/§5.2
|
||||
// — the point is that Live and Incidents read as the same object.
|
||||
function IncidentBrowseRow({
|
||||
incident,
|
||||
isAdmin,
|
||||
onAir,
|
||||
onResolve,
|
||||
}: {
|
||||
incident: IncidentRecord;
|
||||
isAdmin: boolean;
|
||||
onAir: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const sev = isKnownSeverity(incident.severity) ? incident.severity : "routine";
|
||||
const units = incident.units_active ?? incident.units ?? [];
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-gray-800 last:border-0 hover:bg-gray-900/60 cursor-pointer transition-colors"
|
||||
<div
|
||||
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-raised cursor-pointer transition-colors items-stretch"
|
||||
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
||||
>
|
||||
<td className="px-4 py-3"><TypeBadge type={incident.type} /></td>
|
||||
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.started_at)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<SeveritySpine severity={sev} />
|
||||
<TypeGlyph type={incident.type} size={20} className="text-ink-2 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SeverityMark severity={sev} showLabel />
|
||||
{onAir && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-sev-major/15 text-sev-major uppercase tracking-wide">
|
||||
On air
|
||||
</span>
|
||||
)}
|
||||
<Badge tone={incident.status === "active" ? "brand" : "neutral"}>{incident.status}</Badge>
|
||||
</div>
|
||||
<p className="text-ink text-sm font-semibold leading-snug mt-0.5 truncate">{incident.title ?? "Incident"}</p>
|
||||
{incident.location && <p className="text-ink-muted text-xs mt-0.5 truncate">{incident.location}</p>}
|
||||
<div className="flex items-center gap-2 flex-wrap mt-1">
|
||||
{units.slice(0, 4).map((u) => (
|
||||
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-raised text-ink-2">{u}</span>
|
||||
))}
|
||||
<span className="text-xs text-ink-muted font-mono ml-auto">
|
||||
{fmtTime(incident.started_at)} · {timeAgo(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && incident.status === "active" && (
|
||||
<Button
|
||||
size="sm" variant="secondary"
|
||||
className="self-center shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{incidents.map((inc) => (
|
||||
<Card
|
||||
key={inc.incident_id}
|
||||
padding="sm"
|
||||
hover
|
||||
className="cursor-pointer active:bg-gray-800"
|
||||
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeBadge type={inc.type} />
|
||||
<Badge tone={inc.status === "active" ? "success" : "neutral"}>{inc.status}</Badge>
|
||||
</div>
|
||||
{isAdmin && inc.status === "active" && (
|
||||
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{severityBadge(inc.severity)}
|
||||
<p className="text-gray-500 text-xs font-mono">
|
||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="sm:hidden">
|
||||
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
</div>
|
||||
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Severity</th>
|
||||
<th className="px-4 py-3">Calls</th>
|
||||
<th className="px-4 py-3">Started</th>
|
||||
<th className="px-4 py-3">Updated</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidents.map((inc) => (
|
||||
<IncidentRow key={inc.incident_id} incident={inc} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,20 +140,20 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4">
|
||||
<h2 className="text-white font-bold">Create Incident</h2>
|
||||
<form onSubmit={handleSubmit} className="bg-surface border border-line rounded-xl p-6 w-full max-w-md space-y-4">
|
||||
<h2 className="text-ink font-semibold">Create Incident</h2>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
||||
<label className="text-xs text-ink-muted block mb-1">Title</label>
|
||||
<input
|
||||
required value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Type</label>
|
||||
<label className="text-xs text-ink-muted block mb-1">Type</label>
|
||||
<select
|
||||
value={type} onChange={(e) => setType(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none"
|
||||
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none"
|
||||
>
|
||||
{["fire", "police", "ems", "accident", "other"].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
@@ -187,10 +161,10 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Summary (optional)</label>
|
||||
<label className="text-xs text-ink-muted block mb-1">Summary (optional)</label>
|
||||
<textarea
|
||||
value={summary} onChange={(e) => setSummary(e.target.value)} rows={2}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
||||
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
@@ -202,17 +176,22 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function IncidentsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { incidents, loading } = useIncidents();
|
||||
const { incidents, loading, error } = useIncidents();
|
||||
const activeCalls = useActiveCalls();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||
|
||||
const onAirIncidentIds = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const c of activeCalls) {
|
||||
for (const id of c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : []) s.add(id);
|
||||
}
|
||||
return s;
|
||||
}, [activeCalls]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const threshold = FILTER_THRESHOLD[severityFilter];
|
||||
const list = incidents.filter((i) => severityRank(i.severity) >= threshold);
|
||||
@@ -222,9 +201,22 @@ export default function IncidentsPage() {
|
||||
return list; // useIncidents() already orders by started_at desc
|
||||
}, [incidents, severityFilter, sortMode]);
|
||||
|
||||
const active = filtered.filter((i) => i.status === "active");
|
||||
const resolved = filtered.filter((i) => i.status === "resolved");
|
||||
const hiddenCount = incidents.length - filtered.length;
|
||||
const activeCount = filtered.filter((i) => i.status === "active").length;
|
||||
|
||||
// Timeline grouping (Today / Yesterday / date) replaces the old
|
||||
// active/resolved two-table split — status is now a chip on the row, not a
|
||||
// section boundary, so an active and a resolved incident from the same
|
||||
// evening read as what they are: the same kind of object.
|
||||
const groups = useMemo(() => {
|
||||
const byDay = new Map<string, IncidentRecord[]>();
|
||||
for (const inc of filtered) {
|
||||
const key = dayBucket(inc.started_at);
|
||||
if (!byDay.has(key)) byDay.set(key, []);
|
||||
byDay.get(key)!.push(inc);
|
||||
}
|
||||
return byDay;
|
||||
}, [filtered]);
|
||||
|
||||
async function handleResolve(id: string) {
|
||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||
@@ -235,31 +227,34 @@ export default function IncidentsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Incidents"
|
||||
badge={active.length > 0 && <Badge tone="danger">{active.length} active</Badge>}
|
||||
badge={activeCount > 0 && <Badge tone="danger">{activeCount} active</Badge>}
|
||||
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
|
||||
/>
|
||||
|
||||
{/* Severity filter + sort — severity is a filter dimension, not decoration */}
|
||||
{/* Gate A / A2 (server-26#46) — every row's title, location and unit
|
||||
chips are pipeline output, so the notice rides with the list. */}
|
||||
<MachineOutputNotice variant="inline" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSeverityFilter(key)}
|
||||
className={`text-sm font-mono px-3.5 py-1.5 rounded-md transition-colors ${
|
||||
severityFilter === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
className={`text-sm px-3.5 py-1.5 rounded-md transition-colors ${
|
||||
severityFilter === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs font-mono text-gray-500">
|
||||
<label className="flex items-center gap-2 text-xs text-ink-muted">
|
||||
Sort
|
||||
<select
|
||||
value={sortMode}
|
||||
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500"
|
||||
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-ink-2 focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="recent">Most recent</option>
|
||||
<option value="severity">Highest severity</option>
|
||||
@@ -274,26 +269,38 @@ export default function IncidentsPage() {
|
||||
) : (
|
||||
<>
|
||||
{hiddenCount > 0 && (
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
<p className="text-xs text-ink-muted">
|
||||
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
||||
<IncidentTable incidents={active} isAdmin={isAdmin} onResolve={handleResolve} />
|
||||
{Array.from(groups.entries()).map(([day, incs]) => (
|
||||
<section key={day}>
|
||||
<h2 className="text-sm text-ink-muted font-medium mb-1">{day}</h2>
|
||||
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||
{incs.map((inc) => (
|
||||
<IncidentBrowseRow
|
||||
key={inc.incident_id}
|
||||
incident={inc}
|
||||
isAdmin={isAdmin}
|
||||
onAir={onAirIncidentIds.has(inc.incident_id)}
|
||||
onResolve={handleResolve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* An empty list is only news when the query actually succeeded.
|
||||
A failed Firestore query (missing composite index, denied rules)
|
||||
also leaves `incidents` empty, and rendering "no incidents
|
||||
recorded yet" over the top of it told the operator the radio was
|
||||
quiet when the page had simply failed to load — server-26#13. */}
|
||||
{filtered.length === 0 && error && (
|
||||
<ErrorBanner message={friendlyIncidentsError(error)} />
|
||||
)}
|
||||
|
||||
{resolved.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Resolved</h2>
|
||||
<IncidentTable incidents={resolved} isAdmin={isAdmin} onResolve={handleResolve} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
{filtered.length === 0 && !error && (
|
||||
<EmptyState
|
||||
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
|
||||
description={
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function LoginPage() {
|
||||
|
||||
// Do NOT navigate straight from the sign-in handlers below: signInWith*
|
||||
// resolves before AuthProvider's onAuthStateChanged listener has fetched
|
||||
// claims and set/cleared the drb_session cookie. Pushing to /dashboard
|
||||
// claims and set/cleared the drb_session cookie. Pushing to the home route
|
||||
// immediately races that — for a no-org account the cookie never gets
|
||||
// set, so middleware.ts bounces the very next request straight back to
|
||||
// /login, which is the ping-pong this screen used to cause. Instead,
|
||||
@@ -31,7 +31,7 @@ export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) return;
|
||||
router.replace(orgId ? "/dashboard" : "/onboarding");
|
||||
router.replace(orgId ? "/" : "/onboarding");
|
||||
}, [authLoading, user, orgId, router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
|
||||
@@ -1,80 +1,7 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useActiveCalls } from "@/lib/useCalls";
|
||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||
|
||||
export default function MapPage() {
|
||||
const { nodes, loading } = useNodes();
|
||||
const activeCalls = useActiveCalls();
|
||||
const incidents = useActiveIncidents();
|
||||
const [kiosk, setKiosk] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
// Track when data last refreshed
|
||||
useEffect(() => {
|
||||
if (!loading) setLastUpdated(new Date());
|
||||
}, [nodes, activeCalls, incidents, loading]);
|
||||
|
||||
// Kiosk mode: full-viewport fixed overlay sits above the sticky nav (z-40 → z-50)
|
||||
if (kiosk) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-gray-950">
|
||||
<MapView
|
||||
nodes={nodes}
|
||||
activeCalls={activeCalls}
|
||||
incidents={incidents}
|
||||
lastUpdated={lastUpdated}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setKiosk(false)}
|
||||
title="Exit fullscreen"
|
||||
className="absolute bottom-[5.5rem] left-3 z-[1002] bg-gray-950/90 border border-gray-700 rounded px-3 py-1.5 text-xs font-mono text-gray-300 hover:text-white hover:border-gray-500 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
|
||||
</svg>
|
||||
Exit fullscreen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
||||
<button
|
||||
onClick={() => setKiosk(true)}
|
||||
title="Fullscreen / kiosk mode"
|
||||
className="text-xs font-mono text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-10rem)] border border-gray-800 rounded-lg text-gray-600 font-mono text-sm">
|
||||
Loading map…
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-[calc(100vh-10rem)] border border-gray-800 rounded-lg overflow-hidden">
|
||||
<MapView
|
||||
nodes={nodes}
|
||||
activeCalls={activeCalls}
|
||||
incidents={incidents}
|
||||
lastUpdated={lastUpdated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
// The map is no longer a destination you navigate to — it's the product,
|
||||
// and the product is the landing page. See UI_REDESIGN.md §3.
|
||||
export default function MapPageRedirect() {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
// The nav's "Network" destination (components/Nav.tsx) — "my equipment".
|
||||
// The redesign added the link but never the route, so it 404'd and the three
|
||||
// screens behind it (/nodes, /systems, /tokens) had no entry point in the nav
|
||||
// at all. This is the hub: it counts what's there, surfaces nodes that still
|
||||
// need configuring, and hands off to the existing pages.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
|
||||
function HubCard({
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
count,
|
||||
countLabel,
|
||||
badge,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
count: number | null;
|
||||
countLabel: string;
|
||||
badge?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="block">
|
||||
<Card hover className="h-full">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-ink font-semibold text-sm">{title}</h2>
|
||||
{badge}
|
||||
</div>
|
||||
<p className="text-ink-muted text-xs mt-1.5 leading-snug">{description}</p>
|
||||
<p className="text-ink text-2xl font-mono mt-4">
|
||||
{count === null ? "—" : count}
|
||||
<span className="text-ink-muted text-xs font-sans ml-2">{countLabel}</span>
|
||||
</p>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkPage() {
|
||||
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { nodes, loading: nodesLoading } = useNodes();
|
||||
const { systems, loading: systemsLoading } = useSystems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
|
||||
}, [authLoading, isAdmin, isOperator, router]);
|
||||
|
||||
// Every hook runs before this guard — see the note in app/nodes/page.tsx.
|
||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||
|
||||
const pending = nodes.filter((n) => !n.configured);
|
||||
const online = nodes.filter((n) => n.status === "online" || n.status === "recording");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Network"
|
||||
description="The equipment on your account — field nodes, the radio systems they decode, and the Discord bot tokens they use."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<HubCard
|
||||
href="/nodes"
|
||||
title="Nodes"
|
||||
description="Field SDR nodes: status, location, and per-node configuration."
|
||||
count={nodesLoading ? null : nodes.length}
|
||||
countLabel={nodesLoading ? "" : `total · ${online.length} up`}
|
||||
badge={
|
||||
pending.length > 0 ? (
|
||||
<Badge tone="warning">{pending.length} need setup</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<HubCard
|
||||
href="/systems"
|
||||
title="Systems"
|
||||
description="Radio system definitions — control channels, talkgroups, and per-system AI flags."
|
||||
count={systemsLoading ? null : systems.length}
|
||||
countLabel={systemsLoading ? "" : "configured"}
|
||||
/>
|
||||
<HubCard
|
||||
href="/tokens"
|
||||
title="Bot Tokens"
|
||||
description="Discord bot tokens available for nodes to claim when relaying live audio."
|
||||
count={null}
|
||||
countLabel="manage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useCalls } from "@/lib/useCalls";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
@@ -335,6 +336,8 @@ export default function NodeDetailPage() {
|
||||
{/* Recent calls */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
|
||||
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
|
||||
{nodeCalls.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p>
|
||||
) : (
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function NodesPage() {
|
||||
const { systems } = useSystems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
|
||||
}, [authLoading, isAdmin, isOperator, router]);
|
||||
|
||||
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function OnboardingPage() {
|
||||
return;
|
||||
}
|
||||
if (orgId) {
|
||||
router.replace("/dashboard");
|
||||
router.replace("/");
|
||||
}
|
||||
}, [loading, user, orgId, router]);
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
|
||||
// Firebase custom claims only show up in a *freshly fetched* ID token —
|
||||
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
|
||||
// AuthProvider's own state (orgId) updates and the effect above
|
||||
// redirects to /dashboard.
|
||||
// redirects to "/" (Live).
|
||||
await refreshClaims();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
|
||||
|
||||
+32
-24
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PLANS } from "@/lib/billing";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { LiveView } from "@/components/LiveView";
|
||||
|
||||
const CAPABILITIES = [
|
||||
{
|
||||
@@ -37,7 +40,7 @@ const STEPS = [
|
||||
{ n: "03", title: "Your team watches", body: "Incidents show up on the live map and dashboard with an AI summary, units on scene, and every related recording." },
|
||||
];
|
||||
|
||||
export default function MarketingHomePage() {
|
||||
function MarketingHomePage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
@@ -54,8 +57,8 @@ export default function MarketingHomePage() {
|
||||
bot to relay the audio live to your team.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-8">
|
||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||
<LinkButton href="/pricing" variant="secondary" size="lg">View pricing</LinkButton>
|
||||
<LinkButton href="/waitlist" size="lg">Request access</LinkButton>
|
||||
<LinkButton href="/login" variant="secondary" size="lg">Sign in</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,31 +99,21 @@ export default function MarketingHomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing teaser */}
|
||||
{/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */}
|
||||
<section className="border-t border-gray-800">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-10">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-display-sm text-white">Plans for one node or a whole region</h2>
|
||||
<p className="text-gray-400 mt-2">Start free. Upgrade when you add nodes or need longer retention.</p>
|
||||
<h2 className="text-display-sm text-white">Pricing is in development</h2>
|
||||
<p className="text-gray-400 mt-2 max-w-xl">
|
||||
We would rather talk to you about what you need than post a number we would have to
|
||||
walk back. Tell us about your coverage area and we will figure it out together.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/pricing" className="text-indigo-400 hover:text-indigo-300 text-sm font-mono transition-colors shrink-0">
|
||||
See full plan comparison →
|
||||
More on pricing →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
{PLANS.map((plan) => (
|
||||
<Card key={plan.id} padding="lg" highlighted={plan.highlighted}>
|
||||
{plan.highlighted && <Badge tone="brand" className="mb-3">Most popular</Badge>}
|
||||
<h3 className="text-white font-semibold">{plan.name}</h3>
|
||||
<p className="text-gray-500 text-xs mt-1">{plan.tagline}</p>
|
||||
<p className="text-white text-2xl font-bold font-mono mt-4">
|
||||
{plan.priceMonthlyUsd === null ? "Custom" : plan.priceMonthlyUsd === 0 ? "Free" : `$${plan.priceMonthlyUsd}`}
|
||||
{plan.priceMonthlyUsd !== null && plan.priceMonthlyUsd > 0 && <span className="text-gray-500 text-sm font-normal">/mo</span>}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -129,13 +122,28 @@ export default function MarketingHomePage() {
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
|
||||
<h2 className="text-display-sm text-white">Bring your first node online</h2>
|
||||
<p className="text-gray-400 mt-3 max-w-xl mx-auto">
|
||||
Sign in to create an account, add a node, and start seeing incidents within minutes of your first call.
|
||||
Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||
<LinkButton href="/waitlist" size="lg">Request access</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "/" is marketing for a signed-out visitor and Live (the map) for anyone
|
||||
* signed in — see UI_REDESIGN.md §3, "the map is the home screen". A user
|
||||
* with no org_id yet still sees marketing (unchanged — that's the
|
||||
* pre-redesign behaviour for an unprovisioned account, out of scope here;
|
||||
* ChromeSwitcher's own no-claim guard only fires off marketing paths).
|
||||
*/
|
||||
export default function HomePage() {
|
||||
const { user, loading, orgId } = useAuth();
|
||||
|
||||
if (loading) return null;
|
||||
if (user && orgId) return <LiveView />;
|
||||
return <MarketingHomePage />;
|
||||
}
|
||||
|
||||
@@ -1,99 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { PLANS, type BillingInterval } from "@/lib/billing";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="text-green-400 shrink-0 mt-0.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate A (BUSINESS_MODEL.md, board minutes #42, enforced by minutes #62):
|
||||
* no price may appear on a public surface until the pricing model is ratified
|
||||
* and the entitlements behind it exist. The previous version of this page
|
||||
* rendered the invented $0/$79/Custom catalog from lib/billing.ts with a
|
||||
* below-the-fold disclaimer — a false price anchor with a footnote is worse
|
||||
* than no price. Do not re-import PLANS here. Tracked: server-26#46.
|
||||
*/
|
||||
export default function PricingPage() {
|
||||
const [interval, setInterval] = useState<BillingInterval>("monthly");
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="text-center max-w-2xl mx-auto">
|
||||
<h1 className="text-display-sm md:text-display text-white">Simple, node-based pricing</h1>
|
||||
<h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1>
|
||||
<p className="text-gray-400 mt-4">
|
||||
Every plan includes the full incident pipeline — transcription, correlation, mapping, and the Discord relay.
|
||||
Plans differ in how many nodes and seats you get, and how far back your history goes.
|
||||
We are still working out what a fair price looks like for the people who actually use this.
|
||||
Rather than post a number we would have to walk back, we would rather talk to you about what
|
||||
you need and what it is worth.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Interval toggle */}
|
||||
<div className="flex items-center justify-center gap-1 mt-10 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit mx-auto">
|
||||
{(["monthly", "annual"] as BillingInterval[]).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setInterval(i)}
|
||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
||||
interval === i ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
{i === "annual" && <span className="ml-1.5 text-green-400 text-xs">save ~17%</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-10 items-stretch">
|
||||
{PLANS.map((plan) => {
|
||||
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
|
||||
const priceLabel =
|
||||
price === null ? "Custom" : price === 0 ? "Free" : `$${interval === "annual" ? Math.round(price / 12) : price}`;
|
||||
|
||||
return (
|
||||
<Card key={plan.id} padding="lg" highlighted={plan.highlighted} className="flex flex-col">
|
||||
{plan.highlighted && <Badge tone="brand" className="mb-3 w-fit">Most popular</Badge>}
|
||||
<h2 className="text-white text-lg font-bold">{plan.name}</h2>
|
||||
<p className="text-gray-500 text-sm mt-1.5 leading-relaxed">{plan.tagline}</p>
|
||||
|
||||
<Card padding="lg" className="mt-12 max-w-2xl mx-auto">
|
||||
<h2 className="text-white text-lg font-bold">What you get today</h2>
|
||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">
|
||||
The full incident pipeline — transcription, correlation into incidents, mapping, and the
|
||||
Discord relay. Node counts, seats, and history length are set per account while we work out
|
||||
the plan structure.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-white text-3xl font-bold font-mono">{priceLabel}</span>
|
||||
{price !== null && price > 0 && <span className="text-gray-500 text-sm">/mo</span>}
|
||||
{interval === "annual" && price !== null && price > 0 && (
|
||||
<p className="text-gray-600 text-xs mt-1">billed ${plan.priceAnnualUsd}/year</p>
|
||||
)}
|
||||
<LinkButton href="/waitlist" fullWidth>Request access</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LinkButton href="/login" variant={plan.highlighted ? "primary" : "secondary"} fullWidth>
|
||||
{plan.priceMonthlyUsd === null ? "Contact sales" : "Get started"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-2.5 flex-1">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
|
||||
<CheckIcon />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-600 text-xs font-mono mt-8">
|
||||
Prices shown are sample figures for this demo build — nothing here is connected to a live payment processor.
|
||||
</p>
|
||||
|
||||
<div className="text-center mt-16">
|
||||
<p className="text-gray-400">
|
||||
Questions about a plan?{" "}
|
||||
Questions?{" "}
|
||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
||||
{" "}or{" "}
|
||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to talk to us</Link>.
|
||||
<Link href="/waitlist" className="text-indigo-400 hover:text-indigo-300 transition-colors">get in touch</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { UnbuiltMarker } from "@/components/ui/UnbuiltMarker";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
@@ -68,6 +69,19 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate A / A1 (server-26#46). The plan cards below render taglines that claim
|
||||
* entitlements with no backend behind them. Those claims get marked unbuilt
|
||||
* inline, on this screen, next to the plan that makes them. This is a labelling
|
||||
* change only — it does not build any of these, and it must never grow into a
|
||||
* price or a checkout path (Gate B still bars charging anyone).
|
||||
*/
|
||||
const UNBUILT_CLAIMS: Partial<Record<PlanId, string[]>> = {
|
||||
free: ["Retention window"],
|
||||
pro: ["Retention window"],
|
||||
enterprise: ["Custom retention", "SSO / SAML", "Uptime SLA", "Data residency"],
|
||||
};
|
||||
|
||||
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
|
||||
paid: "success",
|
||||
open: "warning",
|
||||
@@ -169,6 +183,13 @@ export default function BillingSettingsPage() {
|
||||
>
|
||||
<p className="text-white font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
|
||||
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
|
||||
<UnbuiltMarker key={claim}>{claim} — not yet available</UnbuiltMarker>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-white text-lg font-bold font-mono mt-3">
|
||||
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
|
||||
</p>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !canAccess) router.replace("/dashboard");
|
||||
if (!loading && !canAccess) router.replace("/");
|
||||
}, [loading, canAccess, router]);
|
||||
|
||||
if (loading || !canAccess) return null;
|
||||
|
||||
@@ -39,6 +39,30 @@ function EnrollmentTokensPanel() {
|
||||
const [label, setLabel] = useState("");
|
||||
const [minting, setMinting] = useState(false);
|
||||
const [justMinted, setJustMinted] = useState<string | null>(null);
|
||||
const [cmdCopied, setCmdCopied] = useState(false);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
// The label the operator typed for the token that was just minted — used as
|
||||
// the node id in the install command below. Captured on mint because `label`
|
||||
// itself is cleared afterward.
|
||||
const [mintedLabel, setMintedLabel] = useState<string | null>(null);
|
||||
|
||||
// The paste-ready one-shot install command for a fresh Pi. The node id comes
|
||||
// from the label just entered (spaces → dashes; install.sh requires
|
||||
// [A-Za-z0-9_-]); if that yields nothing it falls back to a node-XXX
|
||||
// placeholder. The MQTT broker host is the documented mqtt.<domain> sibling
|
||||
// of the api host (install.sh header) — a DNS assumption the operator checks.
|
||||
const c2Url = (process.env.NEXT_PUBLIC_C2_URL ?? "https://api.example.net").replace(/\/$/, "");
|
||||
const mqttBroker = (() => {
|
||||
try { return `mqtt.${new URL(c2Url).hostname.replace(/^api\./, "")}`; }
|
||||
catch { return "mqtt.example.net"; }
|
||||
})();
|
||||
const nodeIdForCmd =
|
||||
(mintedLabel ?? "").trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_-]/g, "") || "node-XXX";
|
||||
const installCmd = justMinted
|
||||
? `curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \\
|
||||
| sudo bash -s -- --token ${justMinted} --node-id ${nodeIdForCmd} \\
|
||||
--c2-url ${c2Url} --mqtt-broker ${mqttBroker}`
|
||||
: "";
|
||||
|
||||
const load = useCallback(() => {
|
||||
c2api.listEnrollmentTokens()
|
||||
@@ -57,6 +81,7 @@ function EnrollmentTokensPanel() {
|
||||
try {
|
||||
const result = await c2api.mintEnrollmentToken(label.trim());
|
||||
setJustMinted(result.token);
|
||||
setMintedLabel(label.trim());
|
||||
setLabel("");
|
||||
load();
|
||||
} catch (err) {
|
||||
@@ -87,11 +112,43 @@ function EnrollmentTokensPanel() {
|
||||
<p className="text-xs text-indigo-200 font-mono mb-1">
|
||||
New token — copy it now, it won't be shown again:
|
||||
</p>
|
||||
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJustMinted(null)}
|
||||
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors"
|
||||
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
|
||||
setTokenCopied(true); setTimeout(() => setTokenCopied(false), 2000);
|
||||
})}
|
||||
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
|
||||
>
|
||||
{tokenCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-indigo-200 font-mono mt-3 mb-1">
|
||||
…or run this on a fresh Pi{" "}
|
||||
{nodeIdForCmd === "node-XXX"
|
||||
? <>(edit <span className="text-indigo-100">node-XXX</span> and check the broker host)</>
|
||||
: <>(check the broker host)</>}:
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
|
||||
setCmdCopied(true); setTimeout(() => setCmdCopied(false), 2000);
|
||||
})}
|
||||
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
|
||||
>
|
||||
{cmdCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setJustMinted(null); setMintedLabel(null); }}
|
||||
className="text-xs text-indigo-300 hover:text-indigo-200 mt-3 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -105,7 +162,7 @@ function EnrollmentTokensPanel() {
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Label, e.g. 'node-003 field kit'"
|
||||
placeholder="Node ID, e.g. node-003"
|
||||
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
||||
|
||||
@@ -5,7 +5,14 @@ import { useRouter } from "next/navigation";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import type {
|
||||
AreaContext,
|
||||
LocalKnowledgeEntry,
|
||||
SystemRecord,
|
||||
TalkgroupPending,
|
||||
VocabularyPendingTerm,
|
||||
} from "@/lib/types";
|
||||
|
||||
// ── P25 structured config types ───────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +20,87 @@ interface TalkgroupEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
// Local knowledge for the transcript corrector (server-26#36). Optional on
|
||||
// every talkgroup: unset means "inherit the system's", which is the whole
|
||||
// point of the scope rule — talkgroup narrows, it does not replace.
|
||||
vocabulary?: string[];
|
||||
area_context?: AreaContext;
|
||||
}
|
||||
|
||||
/** Comma-separated text field <-> string[], the shape the API stores. */
|
||||
const listToText = (v?: string[]) => (v ?? []).join(", ");
|
||||
const textToList = (v: string) =>
|
||||
v.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
/**
|
||||
* Local knowledge is one entry per line, `term — meaning`.
|
||||
*
|
||||
* A bare term is valid — half the information is still worth having, and
|
||||
* forcing a meaning would make people invent one. An em dash, a spaced hyphen
|
||||
* or an equals sign all separate; whichever comes first wins.
|
||||
*/
|
||||
const knowledgeToText = (v?: LocalKnowledgeEntry[]) =>
|
||||
(v ?? []).map((e) => (e.meaning ? `${e.term} — ${e.meaning}` : e.term)).join("\n");
|
||||
|
||||
function textToKnowledge(v: string): LocalKnowledgeEntry[] {
|
||||
const out: LocalKnowledgeEntry[] = [];
|
||||
for (const line of v.split("\n")) {
|
||||
const match = line.match(/^(.*?)\s*(?:—|–|\s-\s|=)\s*(.*)$/);
|
||||
const term = (match ? match[1] : line).trim();
|
||||
const meaning = match ? match[2].trim() : "";
|
||||
if (term) out.push(meaning ? { term, meaning } : { term });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the pre-#36 shape forward on load.
|
||||
*
|
||||
* `roads[]` and `landmarks[]` were the original fields and real systems still
|
||||
* have them stored. Showing them as local-knowledge lines means an operator
|
||||
* sees what they already entered instead of an empty box, and the next save
|
||||
* writes them in the new shape.
|
||||
*/
|
||||
function migrateArea(a?: AreaContext & { roads?: string[]; landmarks?: string[] }): AreaContext {
|
||||
if (!a) return {};
|
||||
const legacy = [...(a.roads ?? []), ...(a.landmarks ?? [])].map((term) => ({ term }));
|
||||
if (!legacy.length) return a;
|
||||
const seen = new Set((a.local_knowledge ?? []).map((e) => e.term.toLowerCase()));
|
||||
const { roads: _roads, landmarks: _landmarks, ...rest } = a;
|
||||
return {
|
||||
...rest,
|
||||
local_knowledge: [
|
||||
...(a.local_knowledge ?? []),
|
||||
...legacy.filter((e) => !seen.has(e.term.toLowerCase())),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an area_context whose every field is blank, so we don't store noise.
|
||||
*
|
||||
* Also strips the derived anchor: `center`/`radius_km`/`resolved_*` belong to
|
||||
* the backend, which geocodes them from the place and merges them back. Sending
|
||||
* them up would be the frontend deciding what is in a system document.
|
||||
*/
|
||||
function cleanArea(a?: AreaContext): AreaContext | undefined {
|
||||
if (!a) return undefined;
|
||||
const out: AreaContext = {};
|
||||
if (a.municipality?.trim()) out.municipality = a.municipality.trim();
|
||||
if (a.county?.trim()) out.county = a.county.trim();
|
||||
if (a.state?.trim()) out.state = a.state.trim();
|
||||
if (a.local_knowledge?.length) out.local_knowledge = a.local_knowledge;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
/** What the backend resolved this area to, in one line. */
|
||||
function anchorLabel(a?: AreaContext): string {
|
||||
if (!a) return "";
|
||||
const place = [a.municipality, a.county, a.state].filter(Boolean).join(", ");
|
||||
if (!place) return "no area set — location checking is off for this scope";
|
||||
if (a.center && a.radius_km) return `anchored to ${place}, ${Math.round(a.radius_km)} km radius`;
|
||||
if (a.resolved_from) return `${place} — too wide to check locations against, so that check is skipped`;
|
||||
return `${place} — not yet resolved`;
|
||||
}
|
||||
|
||||
interface P25Config {
|
||||
@@ -47,10 +135,15 @@ function recordToP25Config(c: Record<string, unknown>): P25Config {
|
||||
? (c.voice_channels as number[]).join(", ")
|
||||
: "",
|
||||
talkgroups: Array.isArray(c.talkgroups)
|
||||
? (c.talkgroups as Array<{ id: number; name: string; tag: string }>).map((tg) => ({
|
||||
? (c.talkgroups as Array<{
|
||||
id: number; name: string; tag: string;
|
||||
vocabulary?: string[]; area_context?: AreaContext;
|
||||
}>).map((tg) => ({
|
||||
id: String(tg.id),
|
||||
name: tg.name,
|
||||
tag: tg.tag ?? "other",
|
||||
vocabulary: tg.vocabulary ?? [],
|
||||
area_context: migrateArea(tg.area_context),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
@@ -70,7 +163,19 @@ function p25ConfigToRecord(p: P25Config): Record<string, unknown> {
|
||||
voice_channels: parseFreqs(p.voice_channels),
|
||||
talkgroups: p.talkgroups
|
||||
.filter((tg) => tg.id && tg.name)
|
||||
.map((tg) => ({ id: parseInt(tg.id, 10), name: tg.name, tag: tg.tag })),
|
||||
.map((tg) => {
|
||||
const area = cleanArea(tg.area_context);
|
||||
const vocab = (tg.vocabulary ?? []).filter(Boolean);
|
||||
return {
|
||||
id: parseInt(tg.id, 10),
|
||||
name: tg.name,
|
||||
tag: tg.tag,
|
||||
// Omitted rather than written empty: an absent key is what
|
||||
// resolve_context() reads as "inherit from the system".
|
||||
...(vocab.length ? { vocabulary: vocab } : {}),
|
||||
...(area ? { area_context: area } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -316,6 +421,50 @@ function RRImportModal({
|
||||
);
|
||||
}
|
||||
|
||||
/** True when this talkgroup overrides anything, so the row can say so. */
|
||||
function hasLocalKnowledge(tg: TalkgroupEntry): boolean {
|
||||
return Boolean((tg.vocabulary ?? []).length || cleanArea(tg.area_context));
|
||||
}
|
||||
|
||||
/** One labelled field in the local-knowledge grid. */
|
||||
function LocalKnowledgeField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
multiline,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
const cls =
|
||||
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs text-gray-500 font-sans">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={4}
|
||||
className={`${cls} font-mono resize-y`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cls}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Talkgroup table editor ────────────────────────────────────────────────────
|
||||
|
||||
function TalkgroupEditor({
|
||||
@@ -329,12 +478,34 @@ function TalkgroupEditor({
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
const [rrSystem, setRrSystem] = useState<RRSystem | null>(null);
|
||||
const [rrError, setRrError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
const rrInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function addRow() {
|
||||
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
|
||||
}
|
||||
|
||||
function updateArea(i: number, field: "municipality" | "county" | "state", value: string) {
|
||||
const updated = [...talkgroups];
|
||||
updated[i] = { ...updated[i], area_context: { ...updated[i].area_context, [field]: value } };
|
||||
onChange(updated);
|
||||
}
|
||||
|
||||
function updateAreaKnowledge(i: number, value: string) {
|
||||
const updated = [...talkgroups];
|
||||
updated[i] = {
|
||||
...updated[i],
|
||||
area_context: { ...updated[i].area_context, local_knowledge: textToKnowledge(value) },
|
||||
};
|
||||
onChange(updated);
|
||||
}
|
||||
|
||||
function updateRowList(i: number, field: "vocabulary", value: string) {
|
||||
const updated = [...talkgroups];
|
||||
updated[i] = { ...updated[i], [field]: textToList(value) };
|
||||
onChange(updated);
|
||||
}
|
||||
|
||||
function removeRow(i: number) {
|
||||
onChange(talkgroups.filter((_, idx) => idx !== i));
|
||||
}
|
||||
@@ -478,7 +649,8 @@ function TalkgroupEditor({
|
||||
</thead>
|
||||
<tbody>
|
||||
{talkgroups.map((tg, i) => (
|
||||
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800/30">
|
||||
<Fragment key={i}>
|
||||
<tr className="border-t border-gray-800 hover:bg-gray-800/30">
|
||||
<td className="px-2 py-1">
|
||||
<input
|
||||
value={tg.id}
|
||||
@@ -507,6 +679,16 @@ function TalkgroupEditor({
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-1 text-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Local knowledge for the transcript corrector"
|
||||
onClick={() => setExpanded(expanded === i ? null : i)}
|
||||
className={`transition-colors font-bold mr-2 ${
|
||||
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{expanded === i ? "▾" : "▸"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(i)}
|
||||
@@ -516,6 +698,60 @@ function TalkgroupEditor({
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Local knowledge, collapsed by default: a system can carry 125
|
||||
talkgroups and most inherit the system's context rather than
|
||||
override it. */}
|
||||
{expanded === i && (
|
||||
<tr className="border-t border-gray-800 bg-gray-900/60">
|
||||
<td colSpan={4} className="px-3 py-3">
|
||||
<p className="text-xs text-gray-500 mb-2 font-sans">
|
||||
Given to the transcript corrector for this talkgroup only, ranked
|
||||
<span className="text-gray-300"> above</span> the system's own list.
|
||||
Leave blank to inherit the system's.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<LocalKnowledgeField
|
||||
label="Municipality"
|
||||
value={tg.area_context?.municipality ?? ""}
|
||||
onChange={(v) => updateArea(i, "municipality", v)}
|
||||
placeholder="Ossining"
|
||||
/>
|
||||
<LocalKnowledgeField
|
||||
label="County"
|
||||
value={tg.area_context?.county ?? ""}
|
||||
onChange={(v) => updateArea(i, "county", v)}
|
||||
placeholder="Westchester"
|
||||
/>
|
||||
<LocalKnowledgeField
|
||||
label="State"
|
||||
value={tg.area_context?.state ?? ""}
|
||||
onChange={(v) => updateArea(i, "state", v)}
|
||||
placeholder="New York"
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<LocalKnowledgeField
|
||||
label="Local knowledge — one per line, term — what it is"
|
||||
multiline
|
||||
value={knowledgeToText(tg.area_context?.local_knowledge)}
|
||||
onChange={(v) => updateAreaKnowledge(i, v)}
|
||||
placeholder={
|
||||
"Snowden Avenue — residential street\nSing Sing — state prison\n11-X-ray — MTA PD patrol unit"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<LocalKnowledgeField
|
||||
label="Unit call signs & local terms"
|
||||
value={listToText(tg.vocabulary)}
|
||||
onChange={(v) => updateRowList(i, "vocabulary", v)}
|
||||
placeholder="Post 4, 11-X-ray, Car 7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -988,7 +1224,11 @@ function SourceCallPlayer({ callId }: { callId: string }) {
|
||||
<p className="text-gray-600 italic">No audio</p>
|
||||
)}
|
||||
{transcript && (
|
||||
<>
|
||||
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
|
||||
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
|
||||
<MachineOutputNotice variant="inline" className="text-[10px]" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -996,8 +1236,260 @@ function SourceCallPlayer({ callId }: { callId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Area context panel ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* System-wide ground truth for the transcript corrector (server-26#36).
|
||||
*
|
||||
* This is the fallback every talkgroup inherits. A talkgroup that covers one
|
||||
* municipality inside a multi-county system overrides it from the talkgroup
|
||||
* table in the edit form, and its entries rank above these.
|
||||
*/
|
||||
function AreaContextPanel({ systemId }: { systemId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [area, setArea] = useState<AreaContext | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function toggle() {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (!next || area !== null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await c2api.getAreaContext(systemId);
|
||||
setArea(migrateArea(data.area_context));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!area) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// The response carries the anchor this edit produced, so the readout
|
||||
// below reflects what the backend actually resolved rather than what was
|
||||
// typed.
|
||||
const saved = await c2api.updateAreaContext(systemId, area);
|
||||
setArea(saved.area_context ?? area);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filled = area
|
||||
? [area.municipality, area.county, area.state, area.local_knowledge?.length].filter(Boolean).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{open ? "▲" : "▼"}</span>
|
||||
<span>
|
||||
Local Area
|
||||
{area !== null && (
|
||||
<span className="text-gray-600 ml-1">
|
||||
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-3 text-xs">
|
||||
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
||||
{area && (
|
||||
<>
|
||||
<p className="text-gray-500">
|
||||
Given to the transcript corrector for every talkgroup on this system.
|
||||
Whisper mishears local names constantly — naming them here is what lets
|
||||
them be put back.
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
Fill this in <span className="text-gray-300">only if it is true of every
|
||||
talkgroup</span> on the system. One town, one department — fill it once here.
|
||||
A system spanning several counties — leave it blank and describe each
|
||||
talkgroup in the edit form instead.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<LocalKnowledgeField
|
||||
label="Municipality"
|
||||
value={area.municipality ?? ""}
|
||||
onChange={(v) => setArea({ ...area, municipality: v })}
|
||||
placeholder="Ossining"
|
||||
/>
|
||||
<LocalKnowledgeField
|
||||
label="County"
|
||||
value={area.county ?? ""}
|
||||
onChange={(v) => setArea({ ...area, county: v })}
|
||||
placeholder="Westchester"
|
||||
/>
|
||||
<LocalKnowledgeField
|
||||
label="State"
|
||||
value={area.state ?? ""}
|
||||
onChange={(v) => setArea({ ...area, state: v })}
|
||||
placeholder="New York"
|
||||
/>
|
||||
</div>
|
||||
<LocalKnowledgeField
|
||||
label="Local knowledge — one per line, term — what it is"
|
||||
multiline
|
||||
value={knowledgeToText(area.local_knowledge)}
|
||||
onChange={(v) => setArea({ ...area, local_knowledge: textToKnowledge(v) })}
|
||||
placeholder={
|
||||
"Route 9 — main north-south highway\nPhelps — Phelps Hospital, Sleepy Hollow\nthe flats — low-lying area by the river"
|
||||
}
|
||||
/>
|
||||
{/* The anchor is the backend's answer to what was typed above, and
|
||||
it decides whether location checking runs at all — so it is
|
||||
worth showing rather than leaving as invisible state. */}
|
||||
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save area"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="text-red-400 font-mono">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Terms the place verifier and the induction loop proposed, per talkgroup.
|
||||
*
|
||||
* Approval is always a person's decision and always lands on the talkgroup that
|
||||
* proposed it — nothing here promotes a term to the system (server-26#37). A
|
||||
* wrong term on one channel misleads one channel; the same term system-wide
|
||||
* misleads every channel on it, including one on the far side of a statewide
|
||||
* system.
|
||||
*/
|
||||
function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rows, setRows] = useState<TalkgroupPending[] | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await c2api.getTalkgroupPending(systemId);
|
||||
setRows(data.talkgroups ?? []);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (next && rows === null) await load();
|
||||
}
|
||||
|
||||
async function act(talkgroupId: number, term: string, approve: boolean) {
|
||||
setBusy(`${talkgroupId}:${term}`);
|
||||
setError(null);
|
||||
try {
|
||||
if (approve) await c2api.approveTalkgroupTerm(systemId, talkgroupId, term);
|
||||
else await c2api.dismissTalkgroupTerm(systemId, talkgroupId, term);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{open ? "▲" : "▼"}</span>
|
||||
<span>
|
||||
Proposed Terms
|
||||
{rows !== null && (
|
||||
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}>
|
||||
({total > 0 ? `${total} awaiting review` : "none"})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-3 text-xs">
|
||||
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
||||
{rows !== null && total === 0 && (
|
||||
<p className="text-gray-600 italic">
|
||||
Nothing proposed yet. Terms appear here when a corrected place name
|
||||
turns out to be real nearby, or when the induction loop spots one in
|
||||
recent traffic.
|
||||
</p>
|
||||
)}
|
||||
{(rows ?? []).map((row) => (
|
||||
<div key={row.talkgroup_id}>
|
||||
<p className="text-gray-400 font-mono mb-1">
|
||||
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{row.pending.map((p) => (
|
||||
<li
|
||||
key={p.term}
|
||||
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1"
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="text-white font-mono">{p.term}</span>
|
||||
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>}
|
||||
<span className="text-gray-700 ml-2">{p.source}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
||||
onClick={() => act(row.talkgroup_id, p.term, true)}
|
||||
className="text-emerald-400 hover:text-emerald-300 disabled:opacity-40 font-semibold"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
||||
onClick={() => act(row.talkgroup_id, p.term, false)}
|
||||
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold"
|
||||
>
|
||||
Drop
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{error && <p className="text-red-400 font-mono">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
const [vocab, setVocab] = useState<string[] | null>(null);
|
||||
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
|
||||
@@ -1185,7 +1677,7 @@ export default function SystemsPage() {
|
||||
const { systems, loading } = useSystems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
|
||||
}, [authLoading, isAdmin, isOperator, router]);
|
||||
|
||||
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||||
@@ -1291,6 +1783,8 @@ export default function SystemsPage() {
|
||||
</div>
|
||||
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
|
||||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||
<AreaContextPanel systemId={s.system_id} />
|
||||
<TalkgroupPendingPanel systemId={s.system_id} />
|
||||
<VocabularyPanel systemId={s.system_id} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function TokensPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
|
||||
}, [authLoading, isAdmin, isOperator, router]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// The nav's "Watch" destination (components/Nav.tsx). The redesign renamed
|
||||
// Alerts to Watch but never added the route, so the nav link 404'd. The screen
|
||||
// itself is unchanged — it still lives in app/alerts/page.tsx, which stays
|
||||
// reachable so old links and bookmarks keep working.
|
||||
export { default } from "../alerts/page";
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One entry in an incident's call spine — UI_REDESIGN.md §5.2. Replaces
|
||||
* CallRow for this context (CallRow stays in use for the Archive table
|
||||
* until chunk 12). `stopNumber` is the same index the map's path stops use
|
||||
* (see IncidentPathLayer in MapView.tsx) — that shared index is the one
|
||||
* memorable thing per §2.4: "where was he when he said that" is answered
|
||||
* by looking, not cross-referencing timestamps.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
function fmtClock(s: number): string {
|
||||
if (!Number.isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const r = Math.floor(s % 60);
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
if (!hasAudio) return null;
|
||||
|
||||
async function ensureUrl() {
|
||||
if (url || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const full = await c2api.getCall(callId);
|
||||
setUrl(full.audio_url ?? null);
|
||||
if (!full.audio_url) setError(true);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (!url) {
|
||||
await ensureUrl();
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
if (playing) el.pause();
|
||||
else el.play();
|
||||
}
|
||||
|
||||
// Once the URL lands, autoplay (the click that fetched it was the play intent).
|
||||
useEffect(() => {
|
||||
if (url && audioRef.current) audioRef.current.play().catch(() => {});
|
||||
}, [url]);
|
||||
|
||||
if (error) {
|
||||
return <span className="text-xs text-sev-major">Audio unavailable</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
|
||||
title={playing ? "Pause" : "Play"}
|
||||
>
|
||||
{loading ? "…" : playing ? "❚❚" : "▶"}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
value={current}
|
||||
onChange={(e) => {
|
||||
const t = Number(e.target.value);
|
||||
if (audioRef.current) audioRef.current.currentTime = t;
|
||||
setCurrent(t);
|
||||
}}
|
||||
className="flex-1 h-1 accent-accent"
|
||||
disabled={!url}
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
||||
{fmtClock(current)}
|
||||
</span>
|
||||
{url && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={url}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||
className="hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
|
||||
if (!stopNumber) {
|
||||
return <span className="w-6 h-6 shrink-0 rounded-full border border-line-strong" />;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[11px] font-semibold text-page"
|
||||
style={{ background: color }}
|
||||
>
|
||||
{stopNumber}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallSpineEntry({
|
||||
call,
|
||||
stopNumber,
|
||||
isAdmin,
|
||||
}: {
|
||||
call: CallRecord;
|
||||
stopNumber?: number;
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
||||
const transcript = call.transcript_corrected || call.transcript;
|
||||
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
|
||||
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
|
||||
|
||||
const units = call.units ?? [];
|
||||
const clearedInCall = call.cleared_units ?? [];
|
||||
|
||||
if (!substantive) {
|
||||
// Thin/routine calls collapse to a single line.
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5 text-xs text-ink-muted">
|
||||
<span className="w-6 shrink-0" />
|
||||
<span className="font-mono tabular-nums shrink-0">{fmtTime(call.started_at)}</span>
|
||||
<span className="truncate">TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"} · status only</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 py-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<StopMarker stopNumber={stopNumber} color={color} />
|
||||
<span className="flex-1 w-px bg-line mt-1" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-ink-muted">
|
||||
<span className="font-mono tabular-nums">{fmtTime(call.started_at)}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"}</span>
|
||||
<span className="font-mono">{call.node_id}</span>
|
||||
</div>
|
||||
|
||||
{hasAudio && (
|
||||
<div className="mt-1.5">
|
||||
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcript && (
|
||||
<p
|
||||
className={`text-sm text-ink leading-relaxed mt-1.5 ${!expanded ? "line-clamp-2" : ""}`}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{transcript}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
{call.location && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-raised text-ink-2">{call.location}</span>
|
||||
)}
|
||||
{units.map((u) => (
|
||||
<span key={u} className="text-xs px-2 py-0.5 rounded-full bg-raised text-ink-2 font-mono">{u}</span>
|
||||
))}
|
||||
{clearedInCall.map((u) => (
|
||||
<span key={u} className="text-xs px-2 py-0.5 rounded-full bg-transparent border border-line text-ink-muted font-mono line-through">{u}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAdmin && call.corr_path && (
|
||||
<p className="text-[10px] font-mono text-ink-muted mt-1">corr: {call.corr_path}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,13 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
||||
router.replace("/onboarding");
|
||||
}, [loading, user, orgId, pathname, router]);
|
||||
|
||||
if (MARKETING_PATHS.has(pathname)) {
|
||||
// "/" is marketing for a signed-out visitor, but the moment someone is
|
||||
// signed in it's Live — the map is the home screen (UI_REDESIGN.md §3),
|
||||
// not a marketing page. Every other marketing path stays marketing
|
||||
// regardless of auth state.
|
||||
const showMarketingChrome = MARKETING_PATHS.has(pathname) && !(pathname === "/" && user);
|
||||
|
||||
if (showMarketingChrome) {
|
||||
return (
|
||||
<>
|
||||
<MarketingHeader />
|
||||
@@ -55,6 +61,17 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Live ("/") is full-bleed under its own top bar, not the padded
|
||||
// max-width container the rest of the app uses.
|
||||
if (pathname === "/" && user) {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="h-[calc(100vh-3.75rem)]">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Live — the default landing for every authenticated user (UI_REDESIGN.md
|
||||
* §3, §5.1). Full-bleed map with the incident rail/legend MapView already
|
||||
* renders (chunk 5), plus a time-density scrubber strip below it.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||
import { TimeScrubber } from "@/components/TimeScrubber";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||
|
||||
function timeAgo(date: Date): string {
|
||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
return `${Math.floor(s / 3600)}h ago`;
|
||||
}
|
||||
|
||||
export function LiveView() {
|
||||
const { nodes, loading: nodesLoading } = useNodes();
|
||||
const activeCalls = useActiveCalls();
|
||||
const activeIncidents = useActiveIncidents();
|
||||
// Wide-ish recent window: feeds both the incident-path polyline (chunk 5)
|
||||
// and the scrubber's density bars. Not a fixture — real recent calls.
|
||||
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodesLoading) setLastUpdated(new Date());
|
||||
}, [nodes, activeCalls, activeIncidents, nodesLoading]);
|
||||
|
||||
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0;
|
||||
const mostRecentSeen = configuredButQuiet
|
||||
? nodes
|
||||
.map((n) => n.last_seen)
|
||||
.filter((s): s is string => !!s)
|
||||
.sort()
|
||||
.at(-1)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{nodesLoading || callsLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">
|
||||
Loading map…
|
||||
</div>
|
||||
) : (
|
||||
<MapView
|
||||
nodes={nodes}
|
||||
activeCalls={activeCalls}
|
||||
incidents={activeIncidents}
|
||||
calls={recentCalls}
|
||||
lastUpdated={lastUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
||||
Activation screen handles the zero-node case). A node that's
|
||||
online but has heard nothing is NOT the same empty state as an
|
||||
org with no equipment. */}
|
||||
{configuredButQuiet && (
|
||||
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
||||
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TimeScrubber calls={recentCalls} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+281
-116
@@ -6,12 +6,15 @@ import {
|
||||
LayersControl,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
useMap,
|
||||
} from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { CallRecord, IncidentRecord, NodeRecord } from "@/lib/types";
|
||||
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";
|
||||
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
@@ -21,48 +24,72 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
|
||||
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||
const INCIDENT_COLORS: Record<string, string> = {
|
||||
fire: "#ef4444",
|
||||
police: "#3b82f6",
|
||||
ems: "#eab308",
|
||||
accident: "#f97316",
|
||||
other: "#6b7280",
|
||||
};
|
||||
// ── Basemap tiles ─────────────────────────────────────────────────────────────
|
||||
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
|
||||
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
|
||||
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
|
||||
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
|
||||
const MAP_TILE_URL =
|
||||
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
|
||||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||
const MAP_TILE_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>';
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === "online") return "#4ade80";
|
||||
if (status === "recording") return "#fb923c";
|
||||
if (status === "unconfigured") return "#818cf8";
|
||||
return "#6b7280";
|
||||
// ── Colour ────────────────────────────────────────────────────────────────────
|
||||
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
||||
// type is carried by the glyph knocked out of the pin, never by colour, and
|
||||
// node state is carried by the diamond's weight (filled/hollow/dashed), not
|
||||
// by colour either. No green appears anywhere here any more.
|
||||
function severityColor(severity: string | null | undefined): string {
|
||||
return isKnownSeverity(severity) ? SEVERITY_COLORS[severity] : "var(--ink-muted)";
|
||||
}
|
||||
|
||||
// ── Single-node icon (with optional pulsing ring for recording) ───────────────
|
||||
function nodeIcon(status: string): L.DivIcon {
|
||||
// Inline SVG paths matching components/marks/TypeGlyph.tsx, duplicated here
|
||||
// (rather than rendered through the React component) because Leaflet marker
|
||||
// icons are raw HTML strings, not React nodes.
|
||||
const TYPE_GLYPH_PATHS: Record<string, string> = {
|
||||
fire: '<path d="M12 2c1 3-2 4-2 7a3 3 0 0 0 6 0c1.5 1.5 2 3.5 2 5a6 6 0 0 1-12 0c0-3 1.5-4.5 3-6.5C10 5.5 11 4 12 2Z"/>',
|
||||
police: '<path d="M12 2 4 5v6c0 5 3.4 8.7 8 9 4.6-.3 8-4 8-9V5l-8-3Z"/><path d="M9 12l2 2 4-4"/>',
|
||||
ems: '<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M12 7v10M7 12h10"/>',
|
||||
collision: '<path d="M3 16l3-7 4 2 2-5 4 3 3-2 2 6"/><path d="M3 16h18M6 16v3M18 16v3"/>',
|
||||
other:
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 4.7-1.2c.5.9.2 1.6-.7 2.3-.9.7-1.5 1.2-1.5 2.4"/><circle cx="12" cy="16.5" r="0.6" fill="currentColor" stroke="none"/>',
|
||||
};
|
||||
function typeGlyphSvg(type: string | null | undefined, color: string, size = 13): string {
|
||||
const key = type === "accident" ? "collision" : type && TYPE_GLYPH_PATHS[type] ? type : "other";
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${TYPE_GLYPH_PATHS[key]}</svg>`;
|
||||
}
|
||||
|
||||
// ── Node diamond icon — matches components/marks/NodeMark.tsx ────────────────
|
||||
function nodeDiamondSvg(status: NodeStatus, size: number): string {
|
||||
const half = size / 2;
|
||||
const pts = `${half},1 ${size - 1},${half} ${half},${size - 1} 1,${half}`;
|
||||
if (status === "recording") {
|
||||
return `<polygon points="${pts}" fill="var(--ink)" stroke="var(--accent)" stroke-width="1.5"/>`;
|
||||
}
|
||||
if (status === "online") {
|
||||
return `<polygon points="${pts}" fill="var(--ink)"/>`;
|
||||
}
|
||||
if (status === "unconfigured") {
|
||||
return `<polygon points="${pts}" fill="none" stroke="var(--ink-muted)" stroke-width="1.5" stroke-dasharray="2.5 2"/>`;
|
||||
}
|
||||
return `<polygon points="${pts}" fill="none" stroke="var(--ink-muted)" stroke-width="1.5"/>`;
|
||||
}
|
||||
|
||||
function nodeIcon(status: NodeStatus): L.DivIcon {
|
||||
const size = 14;
|
||||
const isRec = status === "recording";
|
||||
const color = statusColor(status);
|
||||
const ring = isRec
|
||||
? `<div class="node-pulse-ring" style="position:absolute;width:28px;height:28px;border-radius:50%;border:2px solid #fb923c;top:-7px;left:-7px;pointer-events:none;"></div>`
|
||||
? `<div class="node-pulse-ring" style="position:absolute;width:${size * 2}px;height:${size * 2}px;border-radius:50%;border:2px solid var(--accent);top:-${size / 2}px;left:-${size / 2}px;pointer-events:none;"></div>`
|
||||
: "";
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:relative;width:14px;height:14px">${ring}<div style="width:14px;height:14px;border-radius:50%;background:${color};border:2px solid #111827;box-shadow:0 0 6px ${isRec ? "#fb923c" : "transparent"};"></div></div>`,
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
html: `<div style="position:relative;width:${size}px;height:${size}px">${ring}<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${nodeDiamondSvg(status, size)}</svg></div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function incidentIcon(type: string | null): L.DivIcon {
|
||||
const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="width:16px;height:16px;border-radius:3px;background:${color};border:2px solid #111827;display:flex;align-items:center;justify-content:center;font-size:9px;color:#fff;font-weight:bold;line-height:1;">!</div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fan / hand-of-cards icons for clustered markers ───────────────────────────
|
||||
function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
||||
const n = members.length;
|
||||
const CARD = 13;
|
||||
@@ -74,7 +101,7 @@ function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
||||
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
||||
const rot = ratio * maxRot;
|
||||
const left = i * STEP;
|
||||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:3px;background:${statusColor(m.status)};border:1.5px solid #111827;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.7);"></div>`;
|
||||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;"><svg width="${CARD}" height="${CARD}" viewBox="0 0 ${CARD} ${CARD}">${nodeDiamondSvg(m.status, CARD)}</svg></div>`;
|
||||
})
|
||||
.join("");
|
||||
return L.divIcon({
|
||||
@@ -85,10 +112,28 @@ function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Incident pin — teardrop filled by severity, type glyph knocked out ───────
|
||||
// Minor/routine (no alarm colour) render hollow with an ink stroke so the map
|
||||
// doesn't imply urgency that isn't there.
|
||||
function incidentIcon(type: string | null, severity: string | null | undefined): L.DivIcon {
|
||||
const color = severityColor(severity);
|
||||
const hollow = !isKnownSeverity(severity) || severity === "minor" || severity === "routine";
|
||||
const glyphColor = hollow ? color : "var(--page)";
|
||||
const fill = hollow ? "var(--surface)" : color;
|
||||
const stroke = hollow ? color : "var(--page)";
|
||||
const svg = `
|
||||
<svg width="28" height="36" viewBox="0 0 28 36">
|
||||
<path d="M14 1C6.8 1 1 6.8 1 14c0 9.5 13 21 13 21s13-11.5 13-21C27 6.8 21.2 1 14 1Z"
|
||||
fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
||||
<g transform="translate(7.5,7.5)">${typeGlyphSvg(type, glyphColor, 13)}</g>
|
||||
</svg>`;
|
||||
return L.divIcon({ className: "", html: svg, iconSize: [28, 36], iconAnchor: [14, 34] });
|
||||
}
|
||||
|
||||
function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
||||
const n = members.length;
|
||||
const CARD = 14;
|
||||
const STEP = 8;
|
||||
const CARD = 16;
|
||||
const STEP = 9;
|
||||
const totalW = CARD + (n - 1) * STEP;
|
||||
const maxRot = Math.min(28, n * 7);
|
||||
const cards = members
|
||||
@@ -96,8 +141,9 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
||||
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
||||
const rot = ratio * maxRot;
|
||||
const left = i * STEP;
|
||||
const color = INCIDENT_COLORS[m.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:2px;background:${color};border:1.5px solid #111827;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;font-size:8px;color:#fff;font-weight:bold;">!</div>`;
|
||||
const color = severityColor(m.severity);
|
||||
const hollow = !isKnownSeverity(m.severity) || m.severity === "minor" || m.severity === "routine";
|
||||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:3px;background:${hollow ? "var(--surface)" : color};border:1.5px solid ${color};left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;">${typeGlyphSvg(m.type, hollow ? color : "var(--page)", 10)}</div>`;
|
||||
})
|
||||
.join("");
|
||||
return L.divIcon({
|
||||
@@ -108,6 +154,24 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Incident path stop marker — numbered, shared index with the call spine ───
|
||||
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon {
|
||||
const size = 22;
|
||||
const fill = isFirst ? "var(--surface)" : color;
|
||||
const textColor = isFirst ? color : "var(--page)";
|
||||
const halo = isLast ? `<circle cx="11" cy="11" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<svg width="${size}" height="${size}" viewBox="0 0 22 22">
|
||||
${halo}
|
||||
<circle cx="11" cy="11" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
|
||||
<text x="11" y="14.5" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
|
||||
</svg>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fan cluster grouping ──────────────────────────────────────────────────────
|
||||
const CLUSTER_PX = 32;
|
||||
|
||||
@@ -146,7 +210,6 @@ function computeGroups<T extends { id: string; lat: number; lng: number }>(
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// ── MapRefCapture — exposes L.Map instance to parent ─────────────────────────
|
||||
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
|
||||
const map = useMap();
|
||||
@@ -196,16 +259,16 @@ function FanNodeLayer({
|
||||
position={[rep.lat, rep.lon]}
|
||||
icon={members.length > 1 ? nodeFanIcon(members) : nodeIcon(rep.status)}
|
||||
>
|
||||
<Popup className="font-mono" minWidth={160}>
|
||||
<div className="text-gray-900 space-y-2">
|
||||
<Popup minWidth={160}>
|
||||
<div className="space-y-2">
|
||||
{members.map((node, idx) => (
|
||||
<div
|
||||
key={node.node_id}
|
||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||
>
|
||||
<p className="font-bold text-sm">{node.name}</p>
|
||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
||||
<p className="text-xs capitalize">{node.status}</p>
|
||||
<p className="font-semibold text-sm text-gray-900">{node.name}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
|
||||
<p className="text-xs capitalize text-gray-700">{node.status}</p>
|
||||
{activeByNode[node.node_id] && (
|
||||
<p className="text-xs text-orange-600 mt-0.5">
|
||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||
@@ -273,22 +336,21 @@ function FanIncidentLayer({
|
||||
<Marker
|
||||
key={repId}
|
||||
position={[repPlot.lat, repPlot.lng]}
|
||||
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type)}
|
||||
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)}
|
||||
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
|
||||
>
|
||||
<Popup className="font-mono" minWidth={180}>
|
||||
<div className="text-gray-900 space-y-2">
|
||||
{members.map((inc, idx) => (
|
||||
<Popup minWidth={180}>
|
||||
<div className="space-y-2">
|
||||
{members.map((inc, idx) => {
|
||||
const color = severityColor(inc.severity);
|
||||
return (
|
||||
<div
|
||||
key={inc.incident_id}
|
||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||
>
|
||||
<p className="font-bold text-sm">{inc.title ?? "Incident"}</p>
|
||||
<p
|
||||
className="text-xs capitalize"
|
||||
style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}
|
||||
>
|
||||
{inc.type ?? "other"}
|
||||
<p className="font-semibold text-sm text-gray-900">{inc.title ?? "Incident"}</p>
|
||||
<p className="text-xs capitalize font-medium" style={{ color }}>
|
||||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
|
||||
</p>
|
||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||||
<a
|
||||
@@ -299,7 +361,10 @@ function FanIncidentLayer({
|
||||
View incident →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{/* Gate A / A2 (server-26#46) — same screen as the output. */}
|
||||
<MachineOutputNotice variant="popup" />
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
@@ -309,6 +374,62 @@ function FanIncidentLayer({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Incident path layer — the flagship feature: a pursuit as a polyline ──────
|
||||
// Per UI_REDESIGN.md §2.4/§5.1: an incident's map stops use the SAME index as
|
||||
// its call timeline, so "where was he when he said that" is answered by
|
||||
// looking, not cross-referencing timestamps. Needs no backend — per-call
|
||||
// location_coords are already written by intelligence.py.
|
||||
function IncidentPathLayer({
|
||||
incidents,
|
||||
callsByIncident,
|
||||
}: {
|
||||
incidents: IncidentRecord[];
|
||||
callsByIncident: Map<string, CallRecord[]>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{incidents.map((inc) => {
|
||||
const calls = (callsByIncident.get(inc.incident_id) ?? [])
|
||||
.filter((c) => c.location_coords)
|
||||
.slice()
|
||||
.sort((a, b) => a.started_at.localeCompare(b.started_at));
|
||||
if (calls.length < 2) return null;
|
||||
const color = severityColor(inc.severity);
|
||||
const positions = calls.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as [number, number]);
|
||||
return (
|
||||
<FeatureGroup key={inc.incident_id}>
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{ color, weight: 3, opacity: 0.85, lineJoin: "round" }}
|
||||
/>
|
||||
{calls.map((c, i) => (
|
||||
<Marker
|
||||
key={c.call_id}
|
||||
position={[c.location_coords!.lat, c.location_coords!.lng]}
|
||||
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color)}
|
||||
>
|
||||
<Popup minWidth={160}>
|
||||
<div className="text-gray-900">
|
||||
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
|
||||
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
|
||||
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
||||
View incident →
|
||||
</a>
|
||||
{/* Gate A / A2 (server-26#46) — the stop location and its
|
||||
ordering come from the transcript, not from GPS. */}
|
||||
<MachineOutputNotice variant="popup" />
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</FeatureGroup>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function timeAgo(date: Date): string {
|
||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
@@ -322,10 +443,18 @@ interface Props {
|
||||
nodes: NodeRecord[];
|
||||
activeCalls: CallRecord[];
|
||||
incidents?: IncidentRecord[];
|
||||
/**
|
||||
* Calls to draw incident paths from — needs `location_coords` and
|
||||
* `incident_ids`/`incident_id`. Grouped internally by incident. Pass the
|
||||
* broadest set of recently-loaded calls the caller has (e.g. from
|
||||
* useCalls); an incident with fewer than 2 geocoded calls in this set
|
||||
* simply draws no path.
|
||||
*/
|
||||
calls?: CallRecord[];
|
||||
lastUpdated?: Date | null;
|
||||
}
|
||||
|
||||
export default function MapView({ nodes, activeCalls, incidents = [], lastUpdated }: Props) {
|
||||
export default function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated }: Props) {
|
||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agoClock, setAgoClock] = useState(0);
|
||||
@@ -345,7 +474,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
|
||||
// Live clock for TOC situational awareness
|
||||
useEffect(() => {
|
||||
const id = setInterval(() =>
|
||||
@@ -358,6 +486,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||
|
||||
const callsByIncident = useMemo(() => {
|
||||
const map = new Map<string, CallRecord[]>();
|
||||
for (const c of calls) {
|
||||
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
|
||||
for (const id of ids) {
|
||||
if (!map.has(id)) map.set(id, []);
|
||||
map.get(id)!.push(c);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [calls]);
|
||||
|
||||
const allPositions = useMemo(
|
||||
() => [
|
||||
...nodes.map((n) => [n.lat, n.lon] as [number, number]),
|
||||
@@ -402,8 +542,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ background: "#111827" }}
|
||||
className="w-full h-full"
|
||||
style={{ background: "var(--map-bg)" }}
|
||||
>
|
||||
<MapRefCapture onReady={onMapReady} />
|
||||
|
||||
@@ -411,14 +551,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
{/* Base layers */}
|
||||
<LayersControl.BaseLayer checked name="Dark">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
url={MAP_TILE_URL}
|
||||
attribution={MAP_TILE_ATTRIBUTION}
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Light">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
attribution={MAP_TILE_ATTRIBUTION}
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Streets">
|
||||
@@ -442,6 +582,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Incident paths — the flagship feature */}
|
||||
<LayersControl.Overlay checked name="Incident Paths">
|
||||
<FeatureGroup>
|
||||
<IncidentPathLayer incidents={incidents} callsByIncident={callsByIncident} />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
||||
<LayersControl.Overlay name="Weather Radar">
|
||||
<TileLayer
|
||||
@@ -451,28 +598,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
opacity={0.65}
|
||||
/>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: News / RSS alerts — placeholder for future integration */}
|
||||
<LayersControl.Overlay name="News Alerts">
|
||||
<FeatureGroup />
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: ADS-B — placeholder for future integration */}
|
||||
<LayersControl.Overlay name="ADS-B">
|
||||
<FeatureGroup />
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Meshtastic — placeholder for future integration */}
|
||||
<LayersControl.Overlay name="Meshtastic">
|
||||
<FeatureGroup />
|
||||
</LayersControl.Overlay>
|
||||
</LayersControl>
|
||||
</MapContainer>
|
||||
|
||||
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
|
||||
{ago && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||
<span className="bg-gray-950/90 border border-gray-700 rounded-full px-3 py-1 text-xs font-mono text-green-400 whitespace-nowrap">
|
||||
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-accent whitespace-nowrap">
|
||||
● Live · {ago}
|
||||
</span>
|
||||
</div>
|
||||
@@ -484,7 +616,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
<button
|
||||
onClick={handleFitAll}
|
||||
title="Fit all markers in view"
|
||||
className="w-8 h-8 bg-gray-950/90 border border-gray-700 rounded text-white text-base leading-none hover:bg-gray-800 transition-colors flex items-center justify-center select-none"
|
||||
className="w-8 h-8 bg-surface/90 border border-line rounded text-ink text-base leading-none hover:bg-raised transition-colors flex items-center justify-center select-none"
|
||||
>
|
||||
⤢
|
||||
</button>
|
||||
@@ -492,61 +624,91 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
</div>
|
||||
|
||||
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
|
||||
<div className="absolute bottom-8 left-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 pointer-events-none">
|
||||
<span className="text-white text-sm font-mono tabular-nums">{clockStr}</span>
|
||||
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2 pointer-events-none">
|
||||
<span className="text-ink text-sm font-mono tabular-nums">{clockStr}</span>
|
||||
</div>
|
||||
|
||||
{/* ── Legend — bottom-right to avoid incident panel on left ────────────── */}
|
||||
<div className="absolute bottom-8 right-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1">
|
||||
<div className="flex items-center gap-2"><span className="text-green-400">●</span> Online</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</div>
|
||||
<div className="flex items-center gap-2"><span className="text-indigo-400">●</span> Unconfigured</div>
|
||||
<div className="flex items-center gap-2"><span className="text-gray-500">●</span> Offline</div>
|
||||
<div className="border-t border-gray-800 my-0.5" />
|
||||
<div className="flex items-center gap-2"><span className="text-red-500">■</span> Fire</div>
|
||||
<div className="flex items-center gap-2"><span className="text-blue-500">■</span> Police</div>
|
||||
<div className="flex items-center gap-2"><span className="text-yellow-500">■</span> EMS</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
||||
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
||||
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
||||
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
||||
<div key={sev} className="flex items-center gap-2">
|
||||
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
|
||||
{sev === "major" && (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16"><polygon points="8,1.5 14.5,14.5 1.5,14.5" fill={SEVERITY_COLORS.major} /></svg>
|
||||
)}
|
||||
{sev === "moderate" && (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16"><polygon points="8,1.5 14.5,14.5 1.5,14.5" fill="none" stroke={SEVERITY_COLORS.moderate} strokeWidth={1.75} /></svg>
|
||||
)}
|
||||
{sev === "minor" && (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke={SEVERITY_COLORS.minor} strokeWidth={1.75} /></svg>
|
||||
)}
|
||||
{sev === "routine" && (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" style={{ opacity: 0.6 }}><circle cx="8" cy="8" r="6" fill="none" stroke={SEVERITY_COLORS.routine} strokeWidth={1.25} /></svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-ink-2">{SEVERITY_LABEL[sev]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-line pt-1.5 space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
||||
{([
|
||||
["recording", "Recording"],
|
||||
["online", "Online"],
|
||||
["offline", "Offline"],
|
||||
["unconfigured", "Unconfigured"],
|
||||
] as [NodeStatus, string][]).map(([status, label]) => (
|
||||
<div key={status} className="flex items-center gap-2">
|
||||
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" dangerouslySetInnerHTML={{ __html: nodeDiamondSvg(status, 11) }} />
|
||||
</span>
|
||||
<span className="text-ink-2">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||
{incidents.length > 0 && (
|
||||
<>
|
||||
{/* Desktop: left sidebar — starts below zoom controls + fit-all button */}
|
||||
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5 overflow-y-auto">
|
||||
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5">
|
||||
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
|
||||
unit counts are pipeline output. Pinned above the scroll area
|
||||
so it cannot be scrolled off the screen it qualifies. */}
|
||||
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0">
|
||||
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-y-auto">
|
||||
{incidents.map((inc) => {
|
||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
const color = severityColor(inc.severity);
|
||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||
const unitCount = inc.units?.length ?? 0;
|
||||
const baseClass = "w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all";
|
||||
const unitCount = inc.units_active?.length ?? inc.units?.length ?? 0;
|
||||
const baseClass = "w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all";
|
||||
const cardBody = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span
|
||||
className="uppercase tracking-wide font-semibold text-[10px]"
|
||||
style={{ color }}
|
||||
>
|
||||
{inc.type ?? "other"}
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: TYPE_GLYPH_PATHS[inc.type === "accident" ? "collision" : (inc.type && TYPE_GLYPH_PATHS[inc.type] ? inc.type : "other")] }} />
|
||||
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
|
||||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold leading-snug truncate">
|
||||
<p className="text-ink font-semibold leading-snug truncate">
|
||||
{inc.title ?? "Incident"}
|
||||
</p>
|
||||
{inc.location && (
|
||||
<p className="text-gray-500 truncate mt-0.5">{inc.location}</p>
|
||||
<p className="text-ink-muted truncate mt-0.5">{inc.location}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{age && <span className="text-gray-600">{age}</span>}
|
||||
{age && <span className="text-ink-muted font-mono">{age}</span>}
|
||||
{unitCount > 0 && (
|
||||
<span className="text-gray-600">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
||||
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
{!inc.location_coords && (
|
||||
<p className="text-[10px] text-blue-700 mt-1">View details →</p>
|
||||
<p className="text-[10px] text-accent mt-1">View details →</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -574,27 +736,30 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: bottom drawer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
|
||||
<button
|
||||
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
||||
className="w-full bg-gray-950/95 border-t border-gray-800 px-4 py-2 text-xs font-mono text-gray-300 flex items-center justify-between"
|
||||
className="w-full bg-surface/95 border-t border-line px-4 py-2 text-xs text-ink-2 flex items-center justify-between"
|
||||
>
|
||||
<span>Incidents ({incidents.length})</span>
|
||||
<span>{drawerOpen ? "▼" : "▲"}</span>
|
||||
</button>
|
||||
{drawerOpen && (
|
||||
<div className="bg-gray-950/95 border-t border-gray-800 max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||
{/* Gate A / A2 (server-26#46) */}
|
||||
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
|
||||
{incidents.map((inc) => {
|
||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
const color = severityColor(inc.severity);
|
||||
const label = (
|
||||
<>
|
||||
<span className="font-semibold" style={{ color }}>
|
||||
{inc.type ?? "other"}
|
||||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
||||
</span>
|
||||
{" — "}
|
||||
<span className="text-white">{inc.title ?? "Incident"}</span>
|
||||
<span className="text-ink">{inc.title ?? "Incident"}</span>
|
||||
</>
|
||||
);
|
||||
if (inc.location_coords) {
|
||||
@@ -605,7 +770,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
setDrawerOpen(false);
|
||||
handleIncidentSelect(inc);
|
||||
}}
|
||||
className="w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
|
||||
className="w-full text-left border rounded px-2 py-1.5 text-xs"
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{label}
|
||||
@@ -616,7 +781,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
||||
<a
|
||||
key={inc.incident_id}
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
className="block w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
|
||||
className="block w-full text-left border rounded px-2 py-1.5 text-xs"
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -9,32 +9,20 @@ import { useAuth } from "@/components/AuthProvider";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
|
||||
|
||||
// Links visible to all authenticated roles (viewer+)
|
||||
const viewerLinks = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/calls", label: "Calls" },
|
||||
// The five destinations, per UI_REDESIGN.md §3. Everything else (Settings,
|
||||
// Admin, Trips, Profile) lives behind the avatar — it's operator plumbing,
|
||||
// not a peer of Incidents.
|
||||
const productLinks = [
|
||||
{ href: "/", label: "Live" },
|
||||
{ href: "/incidents", label: "Incidents" },
|
||||
{ href: "/map", label: "Map" },
|
||||
{ href: "/alerts", label: "Alerts" },
|
||||
{ href: "/calls", label: "Archive" },
|
||||
{ href: "/watch", label: "Watch" },
|
||||
];
|
||||
|
||||
// Trips is an internal utility feature, not a tenant-scoped product surface
|
||||
// (see [[trips-feature-intentional]] and SAAS_PLAN.md B7) — shown only to
|
||||
// the founding org, matching routers/trips.py's own gating.
|
||||
const tripsLink = { href: "/trips", label: "Trips" };
|
||||
|
||||
// Additional links for operators and admins
|
||||
const operatorLinks = [
|
||||
{ href: "/nodes", label: "Nodes" },
|
||||
{ href: "/systems", label: "Systems" },
|
||||
{ href: "/tokens", label: "Tokens" },
|
||||
];
|
||||
|
||||
// Platform-admin-only link. Settings is handled separately below — it's
|
||||
// customer-facing for org owners too, not admin-only (SAAS_PLAN.md B7).
|
||||
const adminLinks = [
|
||||
{ href: "/admin", label: "Admin" },
|
||||
];
|
||||
// Network (nodes/systems/enrollment/bot tokens — "my equipment") stays
|
||||
// scoped to operators and admins, matching the write-access boundary the
|
||||
// pages behind it have always had.
|
||||
const networkLink = { href: "/network", label: "Network" };
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
@@ -92,38 +80,39 @@ export function Nav() {
|
||||
}
|
||||
}
|
||||
|
||||
const allLinks = [
|
||||
...viewerLinks,
|
||||
...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []),
|
||||
...(isAdmin || isOperator ? operatorLinks : []),
|
||||
...(isAdmin ? adminLinks : []),
|
||||
...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []),
|
||||
];
|
||||
const navLinks = [...productLinks, ...(isAdmin || isOperator ? [networkLink] : [])];
|
||||
const showTrips = orgId === FOUNDING_ORG_ID || isAdmin;
|
||||
const showSettings = isAdmin || isOrgOwner;
|
||||
|
||||
function isActive(href: string) {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname.startsWith(href);
|
||||
}
|
||||
|
||||
function navLinkClass(href: string) {
|
||||
return `text-sm font-mono transition-colors shrink-0 ${
|
||||
pathname.startsWith(href) ? "text-white" : "text-gray-500 hover:text-gray-300"
|
||||
return `text-sm font-medium transition-colors shrink-0 ${
|
||||
isActive(href) ? "text-ink" : "text-ink-muted hover:text-ink-2"
|
||||
}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
|
||||
<nav className="sticky top-0 z-40 border-b border-line bg-page/95 backdrop-blur">
|
||||
{/* Main bar */}
|
||||
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
|
||||
<span className="font-mono font-bold text-white tracking-tight shrink-0">DRB</span>
|
||||
<Link href="/" className="font-semibold text-ink tracking-tight shrink-0">DRB</Link>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
|
||||
{allLinks.map(({ href, label }) => (
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<Link key={href} href={href} className={navLinkClass(href)}>
|
||||
{label}
|
||||
{label === "Nodes" && pending.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||
{label === "Network" && pending.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-sev-moderate text-page text-xs font-bold">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||
{label === "Watch" && unackedAlerts.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-sev-major text-white text-xs font-bold px-1">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -135,20 +124,20 @@ export function Nav() {
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
className="text-ink-muted hover:text-ink-2 transition-colors"
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
|
||||
{/* Profile avatar + dropdown (desktop) */}
|
||||
{/* Profile avatar + dropdown (desktop) — Settings/Admin/Trips/Profile live here now, not the nav bar */}
|
||||
<div className="hidden md:block relative">
|
||||
<button
|
||||
onClick={() => setProfileMenuOpen((v) => !v)}
|
||||
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
|
||||
pathname.startsWith("/profile") || profileMenuOpen
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||
? "bg-accent text-white"
|
||||
: "bg-raised text-ink-2 hover:brightness-110"
|
||||
}`}
|
||||
title="Account"
|
||||
>
|
||||
@@ -159,26 +148,53 @@ export function Nav() {
|
||||
<>
|
||||
{/* Click-away backdrop */}
|
||||
<div className="fixed inset-0 z-40" onClick={() => setProfileMenuOpen(false)} />
|
||||
<div className="absolute right-0 mt-2 w-48 bg-gray-900 border border-gray-800 rounded-lg shadow-lg z-50 py-1 font-mono text-sm">
|
||||
<div className="absolute right-0 mt-2 w-48 bg-surface border border-line rounded-lg shadow-lg z-50 py-1 text-sm">
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setProfileMenuOpen(false)}
|
||||
className="block px-3 py-2 text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
className="block px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
{showSettings && (
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setProfileMenuOpen(false)}
|
||||
className="block px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{showTrips && (
|
||||
<Link
|
||||
href="/trips"
|
||||
onClick={() => setProfileMenuOpen(false)}
|
||||
className="block px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors"
|
||||
>
|
||||
Trips
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setProfileMenuOpen(false)}
|
||||
className="block px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors"
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRefreshClaims}
|
||||
disabled={refreshing}
|
||||
className="w-full text-left px-3 py-2 text-gray-300 hover:bg-gray-800 hover:text-white transition-colors disabled:opacity-50"
|
||||
className="w-full text-left px-3 py-2 text-ink-2 hover:bg-raised hover:text-ink transition-colors disabled:opacity-50"
|
||||
title="Pick up a role or org change made server-side, without signing out"
|
||||
>
|
||||
{refreshing ? "Refreshing…" : "Refresh access"}
|
||||
</button>
|
||||
<div className="border-t border-gray-800 my-1" />
|
||||
<div className="border-t border-line my-1" />
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full text-left px-3 py-2 text-red-500 hover:bg-gray-800 hover:text-red-400 transition-colors"
|
||||
className="w-full text-left px-3 py-2 text-sev-major hover:bg-raised transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
@@ -190,7 +206,7 @@ export function Nav() {
|
||||
{/* Hamburger (mobile) */}
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="md:hidden text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||
className="md:hidden text-ink-2 hover:text-ink transition-colors p-1"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
@@ -208,49 +224,64 @@ export function Nav() {
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
|
||||
{allLinks.map(({ href, label }) => (
|
||||
<div className="md:hidden border-t border-line bg-page px-4 py-3 flex flex-col gap-1">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||
pathname.startsWith(href) ? "text-white" : "text-gray-500"
|
||||
className={`py-2 text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||
isActive(href) ? "text-ink" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{label === "Nodes" && pending.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||
{label === "Network" && pending.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-sev-moderate text-page text-xs font-bold">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||
{label === "Watch" && unackedAlerts.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-sev-major text-white text-xs font-bold px-1">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-1 flex flex-col gap-1">
|
||||
<div className="border-t border-line pt-3 mt-1 flex flex-col gap-1">
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
|
||||
className={`py-2 text-sm font-medium transition-colors ${
|
||||
pathname.startsWith("/profile") ? "text-ink" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
{showSettings && (
|
||||
<Link href="/settings" onClick={() => setMobileOpen(false)} className="py-2 text-sm font-medium text-ink-muted">
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{showTrips && (
|
||||
<Link href="/trips" onClick={() => setMobileOpen(false)} className="py-2 text-sm font-medium text-ink-muted">
|
||||
Trips
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/admin" onClick={() => setMobileOpen(false)} className="py-2 text-sm font-medium text-ink-muted">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setMobileOpen(false); handleRefreshClaims(); }}
|
||||
disabled={refreshing}
|
||||
className="py-2 text-sm font-mono text-gray-500 text-left disabled:opacity-50"
|
||||
className="py-2 text-sm text-ink-muted text-left disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? "Refreshing…" : "Refresh access"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMobileOpen(false); handleSignOut(); }}
|
||||
className="py-2 text-sm font-mono text-red-500 text-left"
|
||||
className="py-2 text-sm text-sev-major text-left"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Live's time scrubber — call-density bars over the selected window, tinted
|
||||
* by the worst severity in each bucket, playhead pinned to NOW. Range
|
||||
* presets change which window is densitized; the playhead itself does not
|
||||
* move in this build — history scrub needs `resolved_at` on incidents,
|
||||
* which doesn't exist yet (UI_REDESIGN.md chunk 13, blocked on backend,
|
||||
* tracked in DEFERRED.md). This is real call density from live data, not a
|
||||
* fixture — only the scrub interaction is deferred.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
import { SEVERITY_COLORS, severityRank } from "@/lib/severity";
|
||||
|
||||
const RANGES = [
|
||||
{ label: "1h", ms: 60 * 60 * 1000 },
|
||||
{ label: "6h", ms: 6 * 60 * 60 * 1000 },
|
||||
{ label: "24h", ms: 24 * 60 * 60 * 1000 },
|
||||
{ label: "7d", ms: 7 * 24 * 60 * 60 * 1000 },
|
||||
] as const;
|
||||
|
||||
const BUCKETS = 48;
|
||||
|
||||
export function TimeScrubber({ calls }: { calls: CallRecord[] }) {
|
||||
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
|
||||
|
||||
const range = RANGES[rangeIdx];
|
||||
|
||||
const buckets = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const start = now - range.ms;
|
||||
const bucketMs = range.ms / BUCKETS;
|
||||
const counts = Array.from({ length: BUCKETS }, () => ({ count: 0, worstRank: -1 }));
|
||||
for (const c of calls) {
|
||||
const t = new Date(c.started_at).getTime();
|
||||
if (Number.isNaN(t) || t < start || t > now) continue;
|
||||
const idx = Math.min(BUCKETS - 1, Math.floor((t - start) / bucketMs));
|
||||
counts[idx].count += 1;
|
||||
const rank = severityRank(c.severity);
|
||||
if (rank > counts[idx].worstRank) counts[idx].worstRank = rank;
|
||||
}
|
||||
return counts;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [calls, rangeIdx]);
|
||||
|
||||
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
|
||||
|
||||
return (
|
||||
<div className="h-[84px] bg-surface/90 border-t border-line px-4 flex items-center gap-4">
|
||||
<div className="flex-1 h-12 flex items-end gap-[2px]">
|
||||
{buckets.map((b, i) => {
|
||||
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
||||
const color =
|
||||
b.worstRank === 3
|
||||
? SEVERITY_COLORS.major
|
||||
: b.worstRank === 2
|
||||
? SEVERITY_COLORS.moderate
|
||||
: b.worstRank >= 0
|
||||
? "var(--ink-2)"
|
||||
: "var(--line-strong)";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
title={b.count > 0 ? `${b.count} call${b.count !== 1 ? "s" : ""}` : undefined}
|
||||
style={{ height: `${heightPct}%`, background: color, flex: 1, borderRadius: 1, minWidth: 2 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Playhead — pinned to NOW; doesn't move yet (see file header) */}
|
||||
<div className="relative w-0">
|
||||
<div className="absolute right-0 -top-14 bottom-0 w-[2px] bg-accent" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{RANGES.map((r, i) => (
|
||||
<button
|
||||
key={r.label}
|
||||
onClick={() => setRangeIdx(i)}
|
||||
className={`px-2 py-1 rounded text-xs font-mono transition-colors ${
|
||||
i === rangeIdx ? "bg-accent text-white" : "text-ink-muted hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-2 text-xs text-ink-muted font-mono">NOW</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function MarketingHeader() {
|
||||
|
||||
<div className="ml-auto hidden md:flex items-center gap-3">
|
||||
{!loading && user ? (
|
||||
<LinkButton href="/dashboard" size="md">Go to dashboard</LinkButton>
|
||||
<LinkButton href="/" size="md">Go to dashboard</LinkButton>
|
||||
) : (
|
||||
<>
|
||||
<LinkButton href="/login" variant="ghost" size="md">Sign in</LinkButton>
|
||||
@@ -81,7 +81,7 @@ export function MarketingHeader() {
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-2 flex flex-col gap-2">
|
||||
{!loading && user ? (
|
||||
<LinkButton href="/dashboard" size="md" fullWidth>Go to dashboard</LinkButton>
|
||||
<LinkButton href="/" size="md" fullWidth>Go to dashboard</LinkButton>
|
||||
) : (
|
||||
<>
|
||||
<LinkButton href="/login" variant="secondary" size="md" fullWidth>Sign in</LinkButton>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Gate A, condition A2 (board minutes #42 / #79, tracked at server-26#46).
|
||||
*
|
||||
* Transcripts, incident summaries, titles, locations and extracted entities are
|
||||
* all produced by the AI pipeline (transcription -> scene/entity extraction ->
|
||||
* correlation -> summary). None of it is reviewed by a human before a reader
|
||||
* sees it, and entity-name accuracy has never been measured (server-26#48).
|
||||
*
|
||||
* Gate A therefore requires that machine-generated content is labelled as
|
||||
* machine-generated and unverified ON THE SAME SCREEN as the content itself —
|
||||
* a note on another page does not satisfy the condition. This is the single
|
||||
* element that does that; render it beside every AI-derived surface.
|
||||
*
|
||||
* Copy rules (do not "improve" these away):
|
||||
* - the words "machine-generated" and "unverified" must both appear
|
||||
* - it must not promise accuracy
|
||||
* - it must not name a model or a vendor
|
||||
* - it must not state or imply a price
|
||||
*
|
||||
* Variants exist only because the surfaces differ in space, not because the
|
||||
* claim differs. All three say the same thing.
|
||||
* block — full-width strip above/below a body of AI output (default)
|
||||
* inline — one compact line, for dense list headers and table captions
|
||||
* popup — for a Leaflet popup, which is stock-white in BOTH themes, so it
|
||||
* uses fixed grays instead of the ink/surface tokens
|
||||
*/
|
||||
|
||||
type Variant = "block" | "inline" | "popup";
|
||||
|
||||
function InfoGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="8" cy="8" r="6.5" />
|
||||
<path d="M8 7.25v4" />
|
||||
<path d="M8 4.75h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const LEAD = "Machine-generated and unverified";
|
||||
|
||||
interface Props {
|
||||
variant?: Variant;
|
||||
/** Overrides the trailing sentence. The lead ("Machine-generated and unverified") is fixed. */
|
||||
detail?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MachineOutputNotice({ variant = "block", detail, className }: Props) {
|
||||
const body =
|
||||
detail ??
|
||||
"transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them.";
|
||||
const shortBody = detail ?? "automated output, may contain errors.";
|
||||
|
||||
if (variant === "popup") {
|
||||
// Leaflet popups render on a white wrapper regardless of theme, so this
|
||||
// deliberately does not use the ink/surface tokens.
|
||||
return (
|
||||
<p
|
||||
role="note"
|
||||
className={["text-[10px] leading-snug text-gray-500 mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{LEAD} — {shortBody}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<p
|
||||
role="note"
|
||||
className={["flex items-center gap-1.5 text-xs text-ink-muted", className ?? ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
<InfoGlyph className="shrink-0" />
|
||||
<span>
|
||||
<span className="text-ink-2 font-medium">{LEAD}</span> — {shortBody}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
className={[
|
||||
"flex items-start gap-2 rounded-lg border border-line bg-raised/60 px-3 py-2",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<InfoGlyph className="mt-0.5 shrink-0 text-ink-muted" />
|
||||
<p className="text-xs leading-relaxed text-ink-muted">
|
||||
<span className="text-ink-2 font-medium">{LEAD}</span> — {body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Gate A, condition A1 (board minutes #42, tracked at server-26#46).
|
||||
*
|
||||
* Gate A blocks putting an unbuilt entitlement claim in front of a reader
|
||||
* without saying, inline and on the same screen, that it is not built. Known
|
||||
* unbuilt claims today:
|
||||
* - retention windows (7 / 90 / 365 days) — no TTL and no deletion sweep
|
||||
* exists anywhere in the product (server-26#44, DEFERRED.md)
|
||||
* - Enterprise SSO / SAML — no backend at all
|
||||
* - uptime SLA — none offered or measured
|
||||
* - custom data residency — no backend at all
|
||||
*
|
||||
* This marks the claim. It does NOT build the feature, and nothing here may
|
||||
* grow into a price or a checkout path (Gate B still bars charging anyone).
|
||||
*/
|
||||
export function UnbuiltMarker({
|
||||
children = "Not yet available",
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"inline-flex items-center whitespace-nowrap rounded-full border border-line-strong",
|
||||
"px-1.5 py-0.5 align-middle text-[10px] font-medium uppercase tracking-wide text-ink-muted",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -72,10 +72,26 @@ export interface UsageSummary {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan catalog — this is real UI copy (safe to ship), just not wired to a
|
||||
// live pricing table. In a real integration this would likely be fetched
|
||||
// from the processor (Stripe Prices API) instead of hardcoded here so price
|
||||
// changes don't require a frontend deploy.
|
||||
// Plan catalog — NOT SAFE TO SHIP. Do not put these on a public surface.
|
||||
//
|
||||
// These prices are INVENTED. BUSINESS_MODEL.md §3.1 (ratified in structure by
|
||||
// board minutes #42, 2026-08-23) marks $0/$79/custom as superseded, and the
|
||||
// board ruled that pricing comes OFF the public site entirely — replaced with
|
||||
// "Pricing in development — contact us" — rather than kept behind a
|
||||
// below-the-fold disclaimer. A false price anchor with a footnote is worse
|
||||
// than no price. Tracked in server-26#46 (Gate A, due 2026-09-13).
|
||||
//
|
||||
// Also unbacked by any implementation, and each is its own claim-vs-reality
|
||||
// gap if displayed:
|
||||
// - retentionDays 7/90/365 — no TTL and no deletion sweep exists anywhere
|
||||
// in drb-c2-core. server-26#44 (Gate B4).
|
||||
// - SSO/SAML, uptime SLA, custom data residency — no backend at all.
|
||||
// There are also zero backend billing routes; createCheckoutSession() and
|
||||
// createBillingPortalSession() always throw. See ADMIN_BILLING_AUDIT.md §2.
|
||||
//
|
||||
// If pricing is ever wired for real, fetch it from the processor (Stripe
|
||||
// Prices API) rather than hardcoding here, so price changes don't require a
|
||||
// frontend deploy.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PLANS: PlanDefinition[] = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { auth } from "@/lib/firebase";
|
||||
import type { AreaContext, TalkgroupPending } from "@/lib/types";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -67,6 +68,33 @@ export const c2api = {
|
||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||
return request<unknown[]>(`/calls${qs}`);
|
||||
},
|
||||
/**
|
||||
* Paged, filterable call archive — backs the /calls page. Distinct from
|
||||
* getCalls(), which returns every call unordered and cannot page.
|
||||
* `next_cursor` is null when the scan reached the end of the collection.
|
||||
*/
|
||||
searchCalls: (params: {
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
system_id?: string;
|
||||
node_id?: string;
|
||||
talkgroup_id?: number;
|
||||
link?: "any" | "orphan" | "linked";
|
||||
transcript?: "any" | "yes" | "no";
|
||||
q?: string;
|
||||
}) => {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
||||
}
|
||||
return request<{
|
||||
calls: import("@/lib/types").CallRecord[];
|
||||
next_cursor: string | null;
|
||||
scanned: number;
|
||||
matched: number;
|
||||
window_exhausted: boolean;
|
||||
}>(`/calls/search?${qs.toString()}`);
|
||||
},
|
||||
patchTranscript: (callId: string, transcript: string) =>
|
||||
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
||||
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
||||
@@ -85,7 +113,11 @@ export const c2api = {
|
||||
deleteIncident: (id: string) =>
|
||||
request(`/incidents/${id}`, { method: "DELETE" }),
|
||||
linkCallToIncident: (incidentId: string, callId: string) =>
|
||||
request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
||||
request<{ ok: boolean; incident_ids: string[] }>(
|
||||
`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
||||
unlinkCallFromIncident: (incidentId: string, callId: string) =>
|
||||
request<{ ok: boolean; incident_emptied: boolean }>(
|
||||
`/incidents/${incidentId}/calls/${callId}`, { method: "DELETE" }),
|
||||
summarizeIncident: (id: string) =>
|
||||
request(`/incidents/${id}/summarize`, { method: "POST" }),
|
||||
|
||||
@@ -114,6 +146,43 @@ export const c2api = {
|
||||
updateTenCodes: (systemId: string, ten_codes: Record<string, string>) =>
|
||||
request(`/systems/${systemId}/ten-codes`, { method: "PUT", body: JSON.stringify({ ten_codes }) }),
|
||||
|
||||
// Area context — ground truth for the transcript corrector (server-26#36).
|
||||
// Its own routes rather than fields on updateSystem(), which sends only
|
||||
// {name, type, config} and would otherwise wipe them on every save.
|
||||
getAreaContext: (systemId: string) =>
|
||||
request<{ area_context: AreaContext }>(`/systems/${systemId}/area-context`),
|
||||
// Only the operator-set fields go up. center/radius_km/resolved_* are the
|
||||
// backend's — it geocodes them from the place and merges them back, and the
|
||||
// response carries the anchor its edit produced.
|
||||
updateAreaContext: (systemId: string, area: AreaContext) =>
|
||||
request<{ ok: boolean; area_context: AreaContext }>(
|
||||
`/systems/${systemId}/area-context`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
municipality: area.municipality ?? null,
|
||||
county: area.county ?? null,
|
||||
state: area.state ?? null,
|
||||
local_knowledge: area.local_knowledge ?? [],
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
// Talkgroup-level pending local knowledge (server-26#37). Proposals land on
|
||||
// the talkgroup and are never promoted to the system automatically.
|
||||
getTalkgroupPending: (systemId: string) =>
|
||||
request<{ talkgroups: TalkgroupPending[] }>(`/systems/${systemId}/talkgroup-pending`),
|
||||
approveTalkgroupTerm: (systemId: string, talkgroupId: number, term: string) =>
|
||||
request(`/systems/${systemId}/talkgroup-pending/approve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ talkgroup_id: talkgroupId, term }),
|
||||
}),
|
||||
dismissTalkgroupTerm: (systemId: string, talkgroupId: number, term: string) =>
|
||||
request(`/systems/${systemId}/talkgroup-pending/dismiss`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ talkgroup_id: talkgroupId, term }),
|
||||
}),
|
||||
|
||||
// Vocabulary
|
||||
getVocabulary: (systemId: string) =>
|
||||
request<{ vocabulary: string[]; vocabulary_pending: { term: string; source: "induction" | "correction"; added_at: string }[]; vocabulary_bootstrapped: boolean }>(
|
||||
|
||||
@@ -226,3 +226,51 @@ export interface AlertEvent {
|
||||
triggered_at: string;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground truth about the area a system or talkgroup covers, handed to the
|
||||
* transcript corrector so it can recognise local street, town and business
|
||||
* names that Whisper mangles (server-26#36).
|
||||
*
|
||||
* Set on the system, and optionally narrowed per talkgroup inside
|
||||
* `config.talkgroups[]` — the talkgroup's entries rank ABOVE the system's, so
|
||||
* a multi-county system can be specific per channel without its wider list
|
||||
* burying the detail.
|
||||
*/
|
||||
export interface LocalKnowledgeEntry {
|
||||
term: string;
|
||||
meaning?: string | null;
|
||||
}
|
||||
|
||||
export interface AreaContext {
|
||||
// Set by an operator. Every field nullable on purpose: which SCOPE gets
|
||||
// filled is the declaration of how homogeneous the system is. A one-town
|
||||
// system is described once here and inherited by every talkgroup; a
|
||||
// statewide one is left empty here and described per talkgroup.
|
||||
municipality?: string;
|
||||
county?: string;
|
||||
state?: string;
|
||||
local_knowledge?: LocalKnowledgeEntry[];
|
||||
// Written by the backend, read-only here. Absent means the place is unset or
|
||||
// too wide to discriminate, and location verification skips entirely
|
||||
// (server-26#37) — never send these back.
|
||||
center?: { lat: number; lng: number };
|
||||
radius_km?: number;
|
||||
resolved_from?: string;
|
||||
resolved_at?: string;
|
||||
}
|
||||
|
||||
/** A term the verifier or the induction loop proposed for one talkgroup. */
|
||||
export interface PendingLocalTerm {
|
||||
term: string;
|
||||
meaning?: string | null;
|
||||
source: string;
|
||||
added_at: string;
|
||||
source_call_ids?: string[];
|
||||
}
|
||||
|
||||
export interface TalkgroupPending {
|
||||
talkgroup_id: number;
|
||||
talkgroup_name?: string;
|
||||
pending: PendingLocalTerm[];
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (pathname === "/login") {
|
||||
if (session) return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||
if (session) return NextResponse.redirect(new URL("/", request.url));
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,14 @@ GEMINI_API_KEY={{ vault_gemini_api_key }}
|
||||
SERVICE_KEY={{ vault_service_key }}
|
||||
ENROLLMENT_TOKEN={{ vault_enrollment_token }}
|
||||
|
||||
# Agent/automation key for the unattended work session's headless routes
|
||||
# (GET/PUT /admin/features). MUST NOT equal vault_service_key: that one is the
|
||||
# Discord bot's, and one shared value would make the bot and the agent the same
|
||||
# unattributable principal in audit_log (server-26#64). default('') so a vault
|
||||
# that predates this key still templates instead of failing the play; blank
|
||||
# just leaves the agent path closed.
|
||||
AGENT_SERVICE_KEY={{ vault_agent_service_key | default('') }}
|
||||
|
||||
# Bare domain, not app.<domain>: the frontend is served on {{ domain }} itself
|
||||
# (see Caddyfile.j2 — only api. and the bare name have DNS records). This said
|
||||
# app.{{ domain }} while the browser origin was https://{{ domain }}, so every
|
||||
|
||||
@@ -22,7 +22,11 @@ vault_mqtt_c2_pass: "CHANGE_ME"
|
||||
vault_mqtt_dynsec_admin_pass: "CHANGE_ME" # openssl rand -hex 32 — must be >=12 chars, plugin-enforced minimum
|
||||
|
||||
# ── C2 Core ───────────────────────────────────────────────────────────────────
|
||||
vault_service_key: "" # openssl rand -hex 32
|
||||
vault_service_key: "" # openssl rand -hex 32 — the Discord bot's key
|
||||
# The work-session agent's own key for GET/PUT /admin/features. Generate a
|
||||
# SEPARATE value — never a copy of vault_service_key, or a flag flip cannot be
|
||||
# attributed to the agent vs the bot (server-26#64).
|
||||
vault_agent_service_key: "" # openssl rand -hex 32
|
||||
vault_enrollment_token: "" # openssl rand -hex 32 — fleet-wide, shared by every node's POST /nodes/enroll
|
||||
vault_openai_api_key: ""
|
||||
vault_google_maps_api_key: ""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"//": "Deploy target for firestore.rules / firestore.indexes.json only — this is not a Firebase Hosting project config. Run from this directory: firebase deploy --only firestore:rules,firestore:indexes --project <project-id>. No project id is pinned here deliberately (see [[self-hosted-infra-pointers]] for where that value lives) — pass --project explicitly or run `firebase use <project-id>` once first.",
|
||||
"firestore": {
|
||||
"//": "database MUST be pinned. This project does not use the (default) Firestore database — drb-c2-core reads FIRESTORE_DATABASE and drb-frontend reads NEXT_PUBLIC_FIRESTORE_DATABASE, both 'c2-server' in production, and the index-required errors the browser prints name /databases/c2-server/ explicitly. Without this key the CLI deploys rules and indexes to (default), reports success, and changes nothing the app can see.",
|
||||
"database": "c2-server",
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"//": "Composite indexes required once drb-frontend's lib/use*.ts hooks add a where(\"org_id\",\"==\",orgId) equality filter alongside an existing range/orderBy/array-contains clause. Firestore auto-indexes single-field lookups and equality-only compound queries, but org_id==X combined with an inequality, orderBy on a different field, or array-contains needs an explicit composite index or the query fails at runtime with a FAILED_PRECONDITION 'index required' error (see SAAS_PLAN.md 2.8/B2 and the URL Firestore prints in that error, which is the fastest way to double-check this list against the live query shapes). Deploy with: firebase deploy --only firestore:indexes --project <project-id>",
|
||||
"//": "Composite indexes for the c2-server database. Firestore auto-indexes single-field lookups and equality-only compound queries; an equality filter combined with an inequality, an orderBy on a different field, or array-contains needs an explicit composite index or the query fails at runtime with FAILED_PRECONDITION. Deploy with: firebase deploy --only firestore:indexes --project <project-id> (firebase.json pins database c2-server — without that key the CLI targets (default) and changes nothing the app can see).",
|
||||
"//direction": "Every index here is declared ASCENDING. Firestore scans an index in either direction, so org_id+started_at ASC serves orderBy(started_at, 'desc') as well — which is what every frontend hook actually asks for. Declaring only the ASC form keeps one index per query shape instead of a matched pair.",
|
||||
"//drift-2026-08-23": "Reconciled against `gcloud firestore indexes composite list --database=c2-server` (server-26#33). The file had drifted four indexes behind the live database, and a deploy against the stale file then added ASC copies of indexes that already existed as DESC. The next deploy will offer to delete three live indexes that are deliberately not declared here — answer YES to all three: calls(org_id ASC, started_at DESC) and incidents(org_id ASC, started_at DESC) are duplicates of the ASC entries below, and alert_events(acknowledged ASC, triggered_at DESC) predates tenancy and is superseded by the org-scoped entry below. Nothing else may be deleted.",
|
||||
"indexes": [
|
||||
{
|
||||
"//": "drb-frontend lib/useCalls.ts — org-scoped call list, orderBy started_at desc.",
|
||||
"collectionGroup": "calls",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
@@ -10,6 +13,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"//": "drb-frontend lib/useCalls.ts — the calls attached to one incident.",
|
||||
"collectionGroup": "calls",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
@@ -18,6 +22,34 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"//": "c2-core internal/recorrelation_sweep.py:45 — status == 'ended' AND ended_at >= cutoff. Backend only; was live but undeclared until 2026-08-23.",
|
||||
"collectionGroup": "calls",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "ended_at", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"//": "c2-core internal/dedup.py:84 — system_id == X AND started_at within a +/- window. Was live-failing on essentially every inbound call (server-26#84): dedup caught the FAILED_PRECONDITION and degraded to \"not a duplicate\", so double-heard transmissions were stored twice and would have been transcribed and correlated twice the moment an AI window opened. Created directly on c2-server 2026-08-28.",
|
||||
"collectionGroup": "calls",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "system_id", "order": "ASCENDING" },
|
||||
{ "fieldPath": "started_at", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"//": "c2-core internal/vocabulary_learner.py:290 — system_id == X AND ended_at >= cutoff. Backend only; was live but undeclared until 2026-08-23.",
|
||||
"collectionGroup": "calls",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "system_id", "order": "ASCENDING" },
|
||||
{ "fieldPath": "ended_at", "order": "ASCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"//": "drb-frontend lib/useIncidents.ts — org-scoped incident browse, orderBy started_at desc.",
|
||||
"collectionGroup": "incidents",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
@@ -26,6 +58,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"//": "drb-frontend lib/useAlerts.ts — org-scoped alert feed, orderBy triggered_at desc.",
|
||||
"collectionGroup": "alert_events",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
@@ -34,6 +67,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"//": "drb-frontend lib/useAlerts.ts useUnacknowledgedAlerts — the nav badge.",
|
||||
"collectionGroup": "alert_events",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
|
||||
Reference in New Issue
Block a user