from pydantic_settings import BaseSettings from typing import Optional class Settings(BaseSettings): # MQTT mqtt_broker: str = "localhost" mqtt_port: int = 1883 mqtt_user: Optional[str] = None mqtt_pass: Optional[str] = None # mosquitto's built-in dynamic-security plugin (see app/internal/dynsec.py). # "admin" is hardcoded by the plugin itself on first boot — not actually # configurable — kept as a named setting rather than a literal for # readability. mqtt_dynsec_admin_pass must equal the mosquitto # container's own MOSQUITTO_DYNSEC_PASSWORD env var (root .env / # root.env.j2) or c2-core can't administer node credentials at all. mqtt_dynsec_admin_user: str = "admin" mqtt_dynsec_admin_pass: Optional[str] = None # GCP gcp_credentials_path: Optional[str] = None # None → uses ADC gcs_bucket: Optional[str] = None # None → audio upload disabled firestore_database: str = "(default)" # Node health node_offline_threshold: int = 90 # seconds without checkin before marking offline # OpenAI (STT + intelligence) openai_api_key: Optional[str] = None # The OpenAI transcription model. Used when OpenAI is the STT provider AND # as the fallback when another provider fails (server-26#297). stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe # ── STT provider (server-26#297) ────────────────────────────────────────── # Any OpenAI-compatible /audio/transcriptions endpoint. Groq serves # whisper-large-v3-turbo at $0.04/audio-hour against whisper-1's $0.36, same # API (min billed length 10s per request, prompt capped at 224 tokens -- # console.groq.com/docs/speech-to-text, checked 2026-10-27). # auto -- groq when GROQ_API_KEY is set, compat when STT_BASE_URL is # set, else openai. The default, so a deploy without either # behaves exactly as before. # openai -- OpenAI only (stt_model). # groq -- Groq (stt_compat_model), OpenAI fallback. # compat -- any other OpenAI-compatible server: stt_base_url + stt_api_key # + stt_compat_model (e.g. a self-hosted faster-whisper, #296). stt_provider: str = "auto" stt_base_url: Optional[str] = None # None -> the provider's own default stt_api_key: Optional[str] = None # None -> GROQ_API_KEY / OPENAI_API_KEY groq_api_key: Optional[str] = None stt_compat_model: str = "whisper-large-v3-turbo" # model on a non-OpenAI provider # Retry a failed non-OpenAI request once on OpenAI stt_model. Needs # OPENAI_API_KEY; without it a primary failure just means no transcript. stt_fallback_to_openai: bool = True # Domain vocabulary in the STT prompt (area places, local_knowledge terms, # vocabulary). Applied to the non-OpenAI provider only: whisper-1 echoed # vocabulary into silence before (see transcription.py), so the OpenAI # path -- including the fallback -- keeps today's fixed prompt. stt_vocab_prompt_enabled: bool = True # Whisper keeps only the LAST 224 prompt tokens and silently drops the # rest. The prompt is built against this estimated budget, with headroom. stt_prompt_max_tokens: int = 200 # AI request timeouts (server-26#153). Without these the OpenAI SDK waits # up to 600s per attempt and retries twice, and the google-genai client # waits forever, so a hung provider call had no upper bound at all — and # recorrelation_sweep.py's hold-back could only guess at one. Every OpenAI # client is built by app/internal/ai_clients.py and the one Gemini client # by app/internal/gemini.py; both read these. A timeout raises like any # other provider error, so every call site's existing fallback applies # (rules-only correlation, raw transcript, no summary, ...). # The timeout bounds each phase of one HTTP attempt (connect/write/read); # these endpoints are non-streaming, so in practice it bounds the attempt. stt_timeout_seconds: float = 120.0 # Whisper: uploads up to MAX_RECORDING_SECONDS of audio openai_timeout_seconds: float = 60.0 # chat completions + embeddings # Retries per OpenAI request (SDK default 2). The SDK retries timeouts too, # so each one multiplies the worst case by another full timeout. openai_max_retries: int = 1 # google-genai does not retry unless told to, so this is the whole bound. gemini_timeout_seconds: float = 60.0 # Scene extraction + embeddings (server-26#298). Were literals in # intelligence.py; any OpenAI chat model that supports JSON mode works. extraction_model: str = "gpt-4o-mini" embedding_model: str = "text-embedding-3-small" # Skip correction + extraction (+ embedding, geocoding) for transcripts # chatter_classifier.gate_chatter calls radio housekeeping. OFF by default: # a gated call carries no units, so it is thin and takes the no-fit-check # thin attach (mechanism A) instead of the fit-tested path or the #115 # orphan gate, and server-26#127 set "0 false positives in a live shadow # window" as the bar for going live. Read chatter_classifier_verdict off a # shadow window first, then set CHATTER_GATE_ENABLED=true. chatter_gate_enabled: bool = False # Google Maps (geocoding) google_maps_api_key: Optional[str] = None # In-process cache of Geocoding API answers (server-26#298). Dispatch # repeats the same address on every follow-up call of a job, and the # place verifier re-geocodes the same nouns. In-process rather than # Firestore: a Firestore read costs about what it saves, adds latency on # every miss, and would persist Maps content -- the Maps terms allow # caching lat/lng for at most 30 days, and an in-memory TTL can never # outlive that. Lost on restart, which only costs a few repeat lookups. geocode_cache_ttl_seconds: int = 24 * 60 * 60 geocode_cache_negative_ttl_seconds: int = 60 * 60 # ZERO_RESULTS etc. geocode_cache_max_entries: int = 5000 # 511NY developer key (server-26#183/#198). Issued with the NYSDOT Developer # Access Agreement grant; the keyless legacy API is gone since the 511x cutover. ny511_api_key: Optional[str] = None # Base URL for the 511NY feed. Defaults to the legacy API, which is dead since # the 511x cutover (server-26#198); set it to whatever the DAA grant documents. ny511_api_base: Optional[str] = None # Camera providers (app/internal/cameras). Keys only; every provider is still # OFF until an admin enables it, and licence-gated ones need an acknowledgement. ontario511_api_key: Optional[str] = None # Weather context (server-26#237). api.weather.gov requires a User-Agent that # names the app AND a contact (e.g. "DRB (ops@example.com)"); the placeholder # default must be overridden in every deploy -- NWS may block anonymous agents. nws_user_agent: str = "DRB scanner map (contact: set NWS_USER_AGENT)" # Comma list of NWS state/marine area codes for /alerts/active (the areas served). nws_area: str = "NY" # NWS alerts cache TTL; the issue asks 60-120 s (floored at 30 s in code). weather_alerts_ttl_s: int = 90 # Gemini (intelligence extraction, embeddings, incident summaries) gemini_api_key: Optional[str] = None # Correlation LLM (server-26#299). The rules engine decides every confident # case; this model is called at most ONCE per scene, and only when the rules # flag the decision as ambiguous (incident_correlator._ambiguity). There is # no second "smart" tier any more: the cheap/smart consensus tiebreak ran on # ~15% of scenes and resolved nearly every disagreement as "new" (#115). # The model ID was once retired by Google and 404'd on every call for days; # a failed LLM call falls back to the rules decision, so nothing broke # loudly. 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) # 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 # ── Alert confidence gate (server-26#300) ───────────────────────────────── # Nothing in the pipeline emits a confidence, so the alerter derives one: # how the rule matched (talkgroup 1.0 / extracted tag 0.8 / raw transcript # text only 0.5 -- the CrimeRadar "fire alarm" -> "firearm" failure was a # transcript match) scaled down when Whisper's own per-token probability # (calls.stt_confidence) is below alert_stt_reference. Below the floor -> # held, never pushed. High-severity matches also need the incident to # have alert_corroboration_min_calls calls, or a confidence at or above # alert_high_severity_min_confidence; held ones are released when a # later call corroborates the incident. Starting values, not measured. alert_min_confidence: float = 0.5 alert_high_severity_min_confidence: float = 0.85 alert_corroboration_min_calls: int = 2 # Low on purpose: exp(avg_logprob) on vocoded radio audio is often well # under 0.5 for speech that is right, and Groq/OpenAI logprobs are not on # a shared scale. Only clearly garbled audio should pull the score down. alert_stt_reference: float = 0.3 alert_high_severity_terms: list[str] = [ "shots-fired", "shots fired", "shooting", "shot", "gun", "firearm", "weapon", "stabbing", "stabbed", "officer-down", "officer down", "10-13", "officer-needs-assistance", "active-shooter", "hostage", "barricade", "explosion", "structure-fire", "structure fire", "working-fire", "entrapment", "cardiac-arrest", "vehicle-pursuit", "pursuit", ] # 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 embedding_no_location_threshold: float = 0.97 # slow-path: match without location (very high bar) embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units # server-26#86: the content-divergence veto on a unit-overlap link. This is # a DIVERGENCE bar, not a match bar, so it sits deliberately below # embedding_similarity_threshold: two calls about different jobs on one # dispatch channel still share ten-codes, unit IDs and address phrasing, so # "not a match" (< 0.93) is not evidence of "a different job". Below this # bar the link is vetoed; between the two bars the link is neither # confirmed nor refused, so it is AMBIGUOUS and goes to the LLM (#299) # instead of silently merging. 0.82 is the historical literal, unmeasured # against real score distributions. embedding_divergence_threshold: float = 0.82 # A unit-overlap link is only a confident one while the incident is fresh: # idle under this many minutes (the old hard-coded 15 of the divergence # veto) AND open under unit_link_confident_max_span_minutes. Past either, # shared units alone are mechanism B (CORRELATION_REVIEW_0820.md) — patrol # units work job after job on one channel — so the link is ambiguous. unit_link_fresh_idle_minutes: int = 15 unit_link_confident_max_span_minutes: int = 30 # server-26#189: an incident open this long accepts no more WEAK links # (unit overlap without location/content agreement, or an LLM link) — only # vehicle, location or embedding evidence, until the hard # incident_max_duration_minutes cap. The 09-27 "Rollover Entrapment" # sink absorbed unrelated Ch 1 jobs for 2h34m this way. Thin replies are # exempt: they never refresh updated_at, so they cannot keep a sink alive. incident_weak_link_max_span_minutes: int = 90 # server-26#189: an LLM link may not pull a call geocoded farther than # this from a non-pursuit incident's verified pin (the same 4x # location_proximity_km the slow path accepts). The LLM tier had no # distance check at all, which is how one incident drew a 37 km path. incident_max_spread_km: float = 2.0 # server-26#301: an upload arriving more than this long after the call # started is correlated as of its started_at (clock pin), not now. Well # above normal upload + STT lag so on-time calls are never back-dated. late_upload_anchor_minutes: int = 10 location_proximity_km: float = 0.5 # radius for location-proximity matching geocode_max_km: float = 40.0 # reject geocode results farther than this from the node incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls (major / unknown severity) # Most jobs never say 10-8 on the air (replay of 09-22, server-26#170: ~5 of # ~25 real incidents had an audible clear), so the quiet timer IS the close # for most of them, and one 90-minute timer kept a lockout or a plate check # "active" on the portal an hour after it ended. Scaled by the incident's # severity instead, and made provisional: a timer-closed incident stays # reopenable for incident_reopen_window_minutes, so a long quiet job that # comes back on the air rejoins its own incident rather than splitting. incident_auto_resolve_minutes_routine: int = 30 # routine / minor incident_auto_resolve_minutes_moderate: int = 60 incident_reopen_window_minutes: int = 90 # since last substantive call unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated # Tier-2 thin calls attach to a lone candidate idle < this, on every # channel (server-26#133/#134 removed the dispatch/tactical split — a # channel's name doesn't change how much scrutiny it gets). Was 10, which # is long enough for the channel to have moved on to something else: on # 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central # train meet 9.6 min later, and a status check absorbed a records lookup # at 9.7 min. Every correct thin attach in that dump was <= 3.4 min idle # and every wrong 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 # ── 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 vocabulary_induction_sample_tokens: int = 4000 # ~tokens of transcript text sampled per system # 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. enrollment_token: Optional[str] = None # Upload size limit — reject audio files larger than this (bytes). Default 100 MB. upload_max_bytes: int = 100 * 1024 * 1024 # Public origin this API is reachable on, e.g. "https://api.drb.example.com". # Only used to build absolute call-audio playback links: an