82 Commits

Author SHA1 Message Date
Logan 3defdf18dc stale calls fix 2026-06-22 00:06:10 -04:00
Logan 1f17b6c0d2 feat: add role-based user management, audit log, and session tracking
Introduces a full user management system with three roles (admin, operator,
viewer), an audit log, and per-session login history.

Backend:
- app/internal/audit.py: write_audit() helper → audit_log Firestore collection
- app/internal/auth.py: get_role() helper; require_admin_token accepts both
  legacy admin:true claim and new role:"admin" claim for backward compat
- app/routers/users.py: CRUD under /admin/users — list, create (returns
  one-time invite link), get (with sessions), patch role/nodes/name,
  disable, enable, delete; operator role requires ≥1 owned node
- app/routers/links.py: POST /auth/session records sign-in events to
  user_sessions Firestore collection
- app/routers/admin.py: GET /admin/audit paginated endpoint
- app/main.py: register users router

Frontend:
- AuthProvider: exposes role, isAdmin, isOperator, ownedNodeIds from claims
- Nav: role-gated links — viewers get dashboard/calls/incidents/map/alerts/
  trips; operators add nodes/systems/tokens; admins add admin
- admin/page.tsx: new Users tab (list table, create modal, inline edit panel
  with role/nodes editor, disable/enable/delete, login history) and Audit
  Log tab (paginated, color-coded actions)
- login/page.tsx: calls recordSession() on email and Google sign-in
- nodes, systems, tokens pages: role guards redirect viewers to dashboard
- profile/page.tsx: shows accurate role badge and label
- lib/types.ts: UserRole, UserRecord, UserSession, AuditEntry types
- lib/c2api.ts: user management methods + recordSession

Firestore collections added: user_profiles, audit_log, user_sessions
Firebase custom claims schema: { role, owned_node_ids, admin (legacy) }
2026-06-22 00:02:09 -04:00
Logan 961cc6f36e add button to clear stale 'active' calls 2026-06-21 23:45:28 -04:00
Logan 6ae4d398f8 add trips permissions 2026-06-21 20:00:48 -04:00
Logan 981f03ac06 allow overlap (note) tags 2026-06-21 15:52:15 -04:00
Logan 4dd3343026 add event editing 2026-06-21 15:35:57 -04:00
Logan fce189d8c9 assistant updates 2026-06-21 15:11:30 -04:00
Logan 3fb3bca034 add tags
Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool.
Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards.
AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag.
Discord: tags shown as inline code blocks under each event in /trip view.
Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
2026-06-21 15:00:37 -04:00
Logan 21d15d0426 assistant markdown update 2026-06-21 14:38:53 -04:00
Logan 21268ab477 fix: migrate Places and Routes to new GCP APIs
Switch from legacy Places textsearch and Directions APIs (disabled on
this project) to Places API (New) and Routes API (New). Both places.py
and the assistant's _places_search helper updated. Also fixes uid()
recursive self-call in trips page and adds Places API response logging.
2026-06-21 14:35:12 -04:00
Logan 522748f07a debugging for trips assistant 2026-06-21 14:31:26 -04:00
Logan 18d96193ab Security fixes
auth.py

secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py

send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py

add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py

All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py

POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py

join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py

Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py

_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py

Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
2026-06-21 13:40:08 -04:00
Logan 7b9aefbcc5 Add UI to trips 2026-06-21 10:12:33 -04:00
Logan fb096d582d feat: add /trip slash commands + add trips & itinerary system
New /trips router with full CRUD, attendee management, and nested
events. Events validate date is within parent trip range and inherit
trip location when not explicitly set. Leaving a trip cascades
removal from all its events.

New TripCommands cog with /trip create, list, view, delete, join,
leave and /trip event add, remove, join, leave. Event autocomplete
is scoped to the selected trip. Enforces must-be-on-trip rule for
event joins with a clear error message.
2026-06-20 23:25:08 -04:00
Logan 4e0e0fc79f Backend (incident_correlator.py):
- Create path (line ~1274): title only uses "at {location}" when location_coords is also set
- Update path (line ~1226): same guard — best_coords must be truthy alongside best_location

Frontend (MapView.tsx):
- Desktop sidebar: cards with location_coords → <button> fly-to; cards without → <a href> that navigates to the incident page with "View details →" text
- Mobile drawer: same split — with coords fly-to+close, without coords navigate via <a>
- Removed the "no coords" italic placeholder text; the card behavior itself makes it clear
2026-06-07 03:34:15 -04:00
Logan 9842b18799 Fix correlation false-merge, switch STT to whisper-1 without vocab prompt
- correlator: unit_overlap on dispatch channels now applies content
  divergence check when the call has geocoded coords but the incident
  doesn't; previously this gap caused unrelated calls to merge into
  stale incidents (e.g. patrol officer at a second scene 70 min later)
- STT: switch default model from gpt-4o-transcribe to whisper-1, which
  faithfully transcribes all exchanges in multi-PTT recordings; gpt-4o
  was silently dropping utterances, starving the correlation engine
- STT: remove vocabulary from the Whisper prompt; whisper-1 echoes
  prompted terms into noise/silence, skewing extracted incident data;
  vocabulary context is now applied exclusively in the GPT extraction
  step (build_gpt_vocab_block) where it is used as reference only
2026-06-03 00:51:25 -04:00
Logan fe6bf55c0e Fix fetch failure 2026-06-03 00:19:12 -04:00
Logan 913fe0cbee Add source call audio playback to vocabulary suggestions
When the induction loop proposes a new vocabulary term, it now records
which sampled call(s) most likely produced the suggestion. Admins see
a collapsible "▶ source" player under each pending term showing the
audio clip and transcript, so they can hear what was actually said
before approving or dismissing.

- vocabulary_learner: track sampled call docs, attach source_call_ids
  to each pending term via word-overlap search with fallback
- types: VocabularyPendingTerm.source_call_ids?: string[]
- c2api: add getCall(id) using existing GET /calls/{call_id} endpoint
- VocabularyPanel: SourceCallPlayer component — lazy-loads call on
  first expand, shows audio controls + transcript snippet
2026-06-01 01:45:03 -04:00
Logan 032eef311f Fix vocabulary induction loop running too late
The loop slept 24h before its first pass, so suggestions would never
appear unless the server was up for a full day. Move the sleep to the
end so the first induction pass runs ~30s after startup.
2026-06-01 01:26:54 -04:00
Logan 3d51db80d0 Improve extraction accuracy with speaker role inference
Add a SPEAKER ROLES section to the GPT-4o-mini prompt teaching it to
distinguish dispatch voice (names a unit then gives assignment + address)
from unit voice (opens with own callsign + brief status). Applied to
location attribution (dispatch-provided address beats unit position report)
and unit extraction (dispatched units vs. acknowledging units). No extra
API calls — purely prompt-level reasoning on the existing transcript.
2026-06-01 01:17:49 -04:00
Logan 683b05beb1 Silence ERROR log for status messages from deleted nodes
_handle_status was calling doc_update unconditionally, which throws a 404
when a node has been deleted from the UI but is still running and sending
heartbeats. Catch the "No document to update" error and log at info level
instead of bubbling up to the dispatch error handler.
2026-06-01 01:06:49 -04:00
Logan cbcc85f7b1 Add consensus correlator: rules + Gemini LLM with smart tiebreaker
Refactor incident_correlator.py to a decision/commit split (preview_correlation
/ apply_correlation) so the rules engine and LLM can both produce decisions before
anything is written to Firestore.

Add llm_correlator.py: cheap Gemini Flash first-pass + Gemini Pro tiebreaker.
Wire _correlate_with_consensus in upload.py — rules-only fallback when key is
absent or call is thin; agreed/tiebreak consensus written to corr_debug.
2026-06-01 00:56:11 -04:00
Logan 6bf4333b72 Make correlation conservative: no time_fallback, pursuit-aware proximity, tiered thin path
- Remove time_fallback from _call_fits_incident: a substantive call with no
  matching signals (unit/vehicle/location) is now always orphaned on dispatch
  channels rather than attached by recency alone
- Pursuit-mode location: incidents tagged as vehicle-pursuit/pursuit/chase use
  a 20km expanded radius with speed-sanity validation (distance ÷ elapsed time
  must be ≤ 8 km/min) — location change is a positive signal for moving incidents
- Non-pursuit incidents: strict 0.5km proximity unchanged — location change = reject
- Thin path two-tier: ≤30s → attach to most-recent regardless of candidate count
  (direct conversational reply); 30s–10min → single candidate required
2026-06-01 00:08:19 -04:00
Logan b77d2cce36 Fix over-correlation: geocoding precision, thin path ambiguity, skip_reason propagation
- Geocoding: reject GEOMETRIC_CENTER/APPROXIMATE results — vague location strings
  (regions, city centroids) were resolving to node-area coords and creating false
  proximity matches that merged unrelated incidents
- Thin path: on dispatch channels with multiple active incidents, skip attachment
  rather than guessing — "10-4" with 3 active incidents is genuinely ambiguous
- Short transcripts (≤5 words) now write skip_reason="transcript_too_short" to
  the call doc, matching garbage transcript behavior
- upload.py no-scenes fallback now checks skip_reason before running correlation —
  flagged calls (garbage, too short) no longer attach via thin path
- Update Server README to reflect current project purpose, goals, and pipeline
2026-05-31 23:51:46 -04:00
Logan f774be12b8 Fix correlation over-merge, thin-call hallucination, and geocoding accuracy
- Cap unit-continuity path at 20 min idle (unit_continuity_max_idle_minutes)
- Block time_fallback and unit-continuity matching on reassignment calls
- Expand reassignment detection to cover unit-initiated self-reassignment
- Skip GPT extraction entirely for transcripts ≤5 words (prevents hallucinated tags/units)
- Reduce geocode_max_km from 75 to 40 to reject far-out-of-area results
- Include county in geocoding query for tighter jurisdiction anchoring
2026-05-26 02:20:15 -04:00
Logan 5eed4e08ce Implement delete node function 2026-05-25 20:20:50 -04:00
Logan c5932165d8 Bug for new nodes 2026-05-25 16:29:20 -04:00
Logan 84ab72442f Correlator bugfix 2026-05-25 15:57:59 -04:00
Logan adf10244b4 Bug hunting for correlator 2026-05-25 15:41:43 -04:00
Logan 7d6e97fd4a fix: improve geocoding specificity and increase distance threshold for repeater systems
geocode_max_km: 25 → 75 km. The node is a physical receiver, not the system boundary;
digital repeaters extend coverage well beyond 25km (North White Plains at 35.5km from
the Yorktown node is a legitimate Westchester County location).

Query now fully qualified: "High Street" → "High Street, Yorktown, New York".
Added _get_node_state() which reverse-geocodes the node position once (cached) using
Google Maps to get the state name, appended alongside the municipality.
Generic street names (High Street, Main Street) no longer resolve to wrong-country results.
2026-05-25 14:49:02 -04:00
Logan ef8e0d1bfa revert: remove leaflet.gridlayer.googlemutant — incompatible with Next.js 15 bundler
The package consistently throws 'L.GridLayer.GoogleMutant is not a constructor'
due to L-instance conflicts in the webpack bundle, despite multiple workaround
attempts. Removed package, transpilePackages entry, type stub, env var, and all
related component code. Traffic overlay dropped; geocoding (backend) unaffected.
2026-05-25 14:19:21 -04:00
Logan 0279a82b10 feat: replace Nominatim geocoding with Google Maps API; add TOC map improvements
Switch geocoding from Nominatim to Google Maps Geocoding API for accurate
local place name resolution (bounds-biased, with 25km distance rejection guard).
Remove the now-unused _get_node_place reverse-geocoder and _node_place_cache.

Map page (TOC improvements):
- Weather radar tiles auto-refresh every 5 minutes via radarEpoch key cycling
- Google Maps traffic overlay added to LayersControl
- Live 24h clock overlay at bottom-left for situational awareness
- Incident sidebar cards now show age (time since dispatch) and unit count
2026-05-25 13:27:19 -04:00
Logan 0db09d6bf7 fix: reject geocode results outside node jurisdiction
Nominatim's viewbox is advisory (bounded=0), so ambiguous place names like
"Pinebrook" can resolve to locations 30-40km away in the wrong town. Added
a post-geocode distance gate: results farther than geocode_max_km (default
25km) from the node are discarded with a warning log rather than written to
the incident.

Also logs distance on successful geocodes for easier audit.

New config setting: geocode_max_km (float, default 25.0)
2026-05-25 13:09:10 -04:00
Logan 4b7d9dd49a feat: enrich correlation debug with fit_signal and orphan breakdown
_call_fits_incident now returns (bool, signal_str) so each correlation
decision records exactly what evidence fired: unit_overlap, vehicle_overlap,
location_proximity, time_fallback, tactical_default, or the corresponding
false-return variants (unit_loc_conflict, content_divergence, etc.).

- corr_fit_signal and corr_matched_units written to call docs for
  fast/single and fast/disambig paths
- Admin debug endpoint exposes the new fields in calls_detail
- Orphan section adds orphans_by_talkgroup summary (count, no-type count,
  sweep-exhausted count per TGID) and raises orphan limit 100 → 250
- Admin page shows corr_path and fit_signal distribution panels above raw
  JSON; time_fallback highlighted in yellow as a diagnostic marker

No correlation logic changed — diagnostic data only.
2026-05-25 12:54:34 -04:00
Logan 7dd090e8b2 fix: raise garbage-transcript threshold to avoid false positives on plate reads
Phonetic run threshold 5 → 12: a plate spellout ("Foxtrot Alpha Uniform Lima
Kilo...") produces 6–8 consecutive phonetic words, triggering false positives
and blocking intelligence extraction on legitimate calls. 12 is safely above
any real spellout (~8 max) while still catching the full-alphabet hallucination
(26 words). Also writes skip_reason="garbage_transcript" to the call doc and
surfaces it in the admin correlation debug endpoint.
2026-05-25 03:31:43 -04:00
Logan 92c9d8effc fix: garbage transcript detection, county geocoding, dispatch channel detection
- intelligence.py: detect Whisper phonetic-alphabet hallucinations before
  sending to GPT; skip extraction entirely to prevent fake units/tags
  corrupting correlation
- intelligence.py: upgrade node reverse-geocode from zoom=5 (state) to
  zoom=10 (county) and include county in address queries so common street
  names (e.g. "East Main Street") resolve to the correct county
- incident_correlator.py: add "patched" and "primary" to dispatch channel
  regex so patched trunking channels are treated as shared backbones
- incident_correlator.py: add 20-min idle gate for tactical channel default
  so a reused frequency can't absorb a new unrelated incident
2026-05-24 01:30:40 -04:00
Logan 1071bcd3e8 fix: map overlay clicks, layer overlap, fan spacing, geocoding radius
- Move incident panel to left side (was topright, conflicting with LayersControl)
- Move legend to bottom-right, raise auto-fit button to clear it
- Tighten fan card step 7→5px for closer grouping
- Geocoding: remove bounded=1 hard clip, widen bias radius 0.1°→0.5° (~55km)
  so addresses like "34 Carlton Drive" resolve outside the node's immediate area
2026-05-24 00:20:11 -04:00
Logan 6397e24035 Correlation updates 2026-05-23 22:55:50 -04:00
Logan 5a18a66d77 fix ppm bug 2026-05-23 18:22:47 -04:00
Logan 35ce8e911e audio fixes attempt 2026-05-23 14:59:51 -04:00
Logan 9d73fc52fa STT bugfix 2026-05-17 19:37:38 -04:00
Logan 97ed691cd2 correlation upgrades 2026-05-17 19:05:52 -04:00
Logan bcc3d3406d add debug in admin 2026-05-17 18:42:42 -04:00
Logan 8b660d8e10 feat: incident correlation overhaul, signal-based auto-resolve, token fixes
Correlator
- Raise fast-path idle gate 30 → 90 min (tg_fast_path_idle_minutes)
- Fix disambiguate always-commits bug: run _call_fits_incident on winner
  before committing; fall through to new-incident creation if it fails
- Add unit-continuity path (path 1.5): matches all_active by shared unit
  IDs with a reassignment guard, bridges calls past the idle gate
- Add tag-based incident_type inference (_TAG_TYPE_HINTS) as GPT fallback,
  rescuing tagged calls that would have been dropped (616 observed orphans)
- Add master/child incident model: _create_master_incident, _demote_to_child,
  _add_child_to_master; new incidents stamped incident_type="master"
- Add cross-system parent detection (_find_cross_system_parent): two-signal
  scoring (road overlap=0.4, embedding≥0.78=0.3, proximity=0.3, threshold=0.5)
  wired into create-if-new path; creates master shell on first cross-system match
- Add maybe_resolve_parent: auto-resolves master when all children close;
  called from upload pipeline (LLM closure) and summarizer stale sweep
- Add signal-based auto-resolve via units_active/units_cleared tracking:
  GPT now extracts cleared_units per scene; _update_incident moves units
  between active/cleared lists and resolves the incident when active empties;
  stored on call doc for re-correlation sweep reuse
- Add _create_incident initialization of units_active/units_cleared fields

Re-correlation sweep
- Add corr_sweep_count + MAX_SWEEP_ATTEMPTS=3: orphans get 3 attempts
  then are tombstoned as corr_path="unlinked", ending the re-sweep loop
  (previously hammering each orphan 29-31 times per shift)

Intelligence extraction
- Add cleared_units to GPT prompt schema and rules
- Extract and propagate cleared_units per scene; merge across scenes;
  store on call doc for re-correlation sweep

Token management
- Fix token release bug: remove release_token call on discord_connected=False
  in MQTT checkin (transient Discord drops were orphaning bots mid-shift)
- Add PUT /tokens/{id}/prefer/{system_id} endpoint: lock a bot token to a
  system; pass _none as system_id to clear; stored bidirectionally on both
  token and system documents
- discord_join handler resolves preferred_token_id from system doc and passes
  system_name in MQTT payload
2026-05-10 19:49:05 -04:00
Logan 7e1b01a275 Updates to reduce firestore calls to try and stay in free tier
### Firestore read reductions

**1. `doc_get_cached()` in `firestore.py` — new 5-min TTL cache**
One place, benefits everything. System and node config documents almost never change during a monitoring session.

**2. System doc: 4 reads → 1 per call**
| Before | After |
|---|---|
| `upload.py` — `doc_get("systems")` for ai_flags | `doc_get_cached` |
| `transcription.py` — `get_vocabulary()` → `doc_get("systems")` | cache hit |
| `intelligence.py` — `get_vocabulary()` → `doc_get("systems")` | cache hit |
| `intelligence.py` — `doc_get("systems")` again for ten_codes | eliminated (reads same cached doc) |

**3. Node doc: cached in `_on_call_start` and `intelligence.py`**
The node is read every call event to get `assigned_system_id` and lat/lon for geocoding. Both now use the cache — node assignments and positions essentially never change at runtime.

**4. Node sweeper: 30s → 90s interval**
The sweeper was doing a full node collection scan 3× more often than necessary — the offline threshold is already 90s. Cuts sweeper reads by 66%.

**5. Vocabulary induction: scans all-time calls → last 7 days**
Previously fetched every ended call for a system (could be thousands). Now scoped to the last 7 days.

> **Note:** The vocabulary induction query `(system_id == X, ended_at >= cutoff)` needs a Firestore
> composite index on `(system_id ASC, ended_at ASC)`. When the induction loop first fires it will log
> an error with a Firebase Console link to create it in one click.
2026-05-04 02:05:00 -04:00
Logan 97f4286810 Add debugging 2026-05-04 01:46:56 -04:00
Logan e704df1a62 # app/internal/incident_correlator.py
- *`correlate_call`* — added units and vehicles optional params; when provided (per-scene from intelligence extraction), they take priority over the merged call-document values, preventing multi-scene unit contamination
- *Cross-TGID correlation path (2.5)* — *new path between location and slow paths*: when a call shares 2+ unit IDs with a recent same-system, same-type incident AND embedding similarity ≥ 0.85, it links them — catches multi-talkgroup pursuits like the bicycle search that split across dispatch/tactical/geographic channels
# `app/internal/intelligence.py`
- *`reassignment` field* — added to the GPT-4o-mini prompt schema and rules; `true` when dispatch is actively pulling a unit to a new, different call (not a status update or en route acknowledgement); returned in every processed scene dict
- *Tag location rule* — added explicit instruction to the prompt: tags must describe what happened, not where; place names, road names, and talkgroup names are explicitly forbidden as tags
# `app/routers/upload.py`
- Both scene correlation call sites (`_run_extraction_pipeline` and `_run_intelligence_pipeline`) now pass `units=corr_units` where `corr_units = [] if scene.get("reassignment") else scene.get("units") `— suppresses unit overlap matching when a unit is being reassigned to a new call, preventing chaining into their previous incident
- Both sites also pass `vehicles=scene.get("vehicles")` (per-scene vehicles, from the multi-scene units fix)
# `app/config.py`
- `embedding_cross_tg_threshold: float = 0.85` — threshold for the new cross-TGID path
2026-05-04 01:33:03 -04:00
Logan f6897566f8 Fix tags, titles, and hallucinations 2026-05-04 01:13:18 -04:00
Logan 531ce64eeb Fix system AI flag bug 2026-04-27 00:58:05 -04:00
Logan f8a9cda27e update firestore to FieldFilter 2026-04-27 00:54:35 -04:00