Files
server-26/drb-c2-core/app/config.py
T
Logan CusanoandClaude Opus 5.5 3e9ca4be06 Merge feat/297-cheaper-ai into feat/299-correlation-simplify
Resolve upload.py (no-scene fallback uses the new _correlate with #298's
non_event_chatter reason) and chatter_classifier docstring (gate built, off by
default). Fold intelligence._llm_bool into incident_correlator.parse_llm_bool (#88).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:31:54 -04:00

417 lines
26 KiB
Python

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 <audio src> is
# fetched by the browser directly, so a relative path would resolve against
# the frontend origin, not this one.
public_api_url: Optional[str] = None
# How long a minted call-audio playback link stays valid. Long enough for a
# browsing session, short enough that a copied link isn't durable access.
audio_link_ttl_seconds: int = 6 * 60 * 60
# Two nodes hearing the same transmission start recording within about a
# second of each other (measured across node-002/node-PI-2 on TG 9048).
# 10s is generous against clock skew while staying well under the gap
# between genuinely separate transmissions on a busy dispatch channel.
duplicate_window_seconds: int = 10
# Browser origins allowed to call this API cross-origin. The only browser
# caller is the frontend's Archive page (GET /calls/search) — every other
# page reads Firestore directly. The frontend is served on the BARE domain
# (see infra Caddyfile.j2 — only drb. and api. have DNS records), so the
# default is that origin, not app.<domain>. Override via CORS_ORIGINS (JSON
# list) if the frontend ever moves; keep infra/.../c2-core.env.j2 in sync.
#
# A "*" entry here still works for local dev but is refused a credentialed
# response: main.py never enables allow_credentials (auth is a Bearer
# header, not a cookie), and it logs a loud ERROR when it sees a wildcard
# in a deployment so a forgotten override is visible.
cors_origins: list[str] = ["https://drb.cusano.net"]
# 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 = ""
# --- Discord radio channels (server-26#203/#204, node-26#25) -----------
# Voice connections one node may carry at once (≈64 kbps upstream each —
# this is what protects an LTE node).
discord_node_max_streams: int = 4
# Radio channels one Discord server may have on at once.
discord_guild_max_streams: int = 4
# How often the reconciler re-places persistent streams / retries publishes.
discord_reconcile_interval_seconds: int = 30
# A node offline this long has its radio-bot tokens returned to the pool.
# MUST stay above the edge's DISCORD_MQTT_LOSS_STOP_SECONDS (default 300):
# by then the edge has logged every radio bot out by itself, so the same
# token can never end up logged in on two nodes.
discord_lease_offline_release_seconds: int = 600
# Org a Discord server that was never linked to an org (discord_guilds/{id})
# acts for. Default keeps today's single-org deployment working unchanged;
# set it to "" to require every server to be linked first. Do that once
# every server that should keep working has been linked (/drb link, or
# PUT /discord/guilds/{id}/link) — unlinked servers then get only
# /drb link and /help (server-26#213). A server an org explicitly unlinked
# never falls back to this org either way.
discord_unlinked_guild_org_id: str = "founding"
# --- Discord permission checks, REST client, OAuth linking (server-26#225)
# Every setting the #225 slices use lives here, added once (S2).
# Kill switch (S3): False restores token picking without the REST
# permission check — no Discord call before a bot is handed to a node.
# (The system's preferred bot is still tried first either way: that is
# a separate fix, plan finding 1, and makes no Discord call.)
discord_perm_check_enabled: bool = True
# Cache lifetimes for per-(bot, server) results (app/internal/discord_rest.py):
# a result that lets a bot in; denied / not-a-member / invalid token; and
# "couldn't tell" — short, but long enough that the reconciler's 30 s loop
# never becomes a REST loop against Discord.
discord_access_ttl_seconds: int = 120
discord_access_negative_ttl_seconds: int = 600
discord_access_unknown_ttl_seconds: int = 30
# S3: how many bots start_stream checks at once, and the total time it
# waits before failing open (unknown = allowed).
discord_perm_check_concurrency: int = 4
discord_perm_check_budget_seconds: float = 3.0
# Process-wide breaker: Discord bans the egress IP (shared with the main
# bot) at 10,000 401/403/429 responses per 10 min. Stop well short.
discord_rest_invalid_budget: int = 4000
discord_rest_invalid_window_seconds: int = 600
# A rate-limited request waits at most this long (one retry); longer = unknown.
discord_rest_max_retry_after_seconds: float = 3.0
discord_rest_timeout_seconds: float = 10.0
# S5: refresh a server's voice-channel list from a radio bot's REST view
# when the stored directory is older than this.
discord_directory_refresh_seconds: int = 300
# S6: per-bot OAuth "Connect a Discord server". Empty = OAuth off (routes
# 404, UI hides the button). Must match the redirect URI registered on
# each bot application exactly, e.g. https://drb.cusano.net/discord/connected
discord_oauth_redirect_uri: str = ""
discord_oauth_state_ttl_seconds: int = 600
# S6: time allowed for the permission check run right after a link.
discord_oauth_link_check_seconds: float = 5.0
# Pool bots: unverified Discord bots can't join more than 100 servers.
# Connect picks a pool bot under this cap; add bots when all are full
# (owner decision Q3, 2026-10-05).
discord_pool_bot_guild_cap: int = 90
class Config:
env_file = ".env"
settings = Settings()