Review of #126 found: (1) the escape hatch applied tg_dispatch_thin_idle_minutes (5 min) unconditionally, but incident_correlator's own fast/thin path only uses that on dispatch channels and 15 min on tactical ones via _is_dispatch_channel -- mirrored the same selection here, plus a config.py note flagging the second consumer. (2) the docstring claimed a 'confirmed explanation' for 2 window-3 gate misses that was actually wrong (self-contradictory in its own text); replaced the guess with corr_gate_veto, written into corr_debug on every llm=orphan/rules=new disagreement that escalates, so window #4 can see *why* each one escaped instead of reconstructing it from the raw dump. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
212 lines
12 KiB
Python
212 lines
12 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
|
|
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
|
|
|
# Google Maps (geocoding)
|
|
google_maps_api_key: Optional[str] = None
|
|
|
|
# Gemini (intelligence extraction, embeddings, incident summaries)
|
|
gemini_api_key: Optional[str] = None
|
|
# Correlation consensus models
|
|
# corr_cheap_model — first-pass LLM correlator (runs on every call)
|
|
# corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree)
|
|
# Both IDs below were retired by Google and returned 404 on every call from
|
|
# some point before 2026-08-18 until they were corrected. Because a failed
|
|
# LLM call falls back to the rules decision, nothing broke loudly -- the
|
|
# entire LLM tier and the consensus tiebreak were simply dead in production
|
|
# while correlation behaviour was being tuned against rules-only output.
|
|
# 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
|
|
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
|
|
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
|
|
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
|
|
# Dispatch channels only: tier-2 thin calls attach to a lone candidate idle < this.
|
|
# 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.
|
|
# Across that dump every correct thin attach 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.
|
|
# Second consumer (server-26#115): routers/upload.py's LLM-orphan-gate escape
|
|
# hatch (_recent_incident_on_same_talkgroup) reuses this same value, selected
|
|
# the same way (dispatch vs tactical) via _is_dispatch_channel. Retuning this
|
|
# for fast/thin reasons moves that gate's behavior too — check both call
|
|
# sites before changing it.
|
|
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
|
|
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 = ""
|
|
|
|
class Config:
|
|
env_file = ".env"
|
|
|
|
|
|
settings = Settings()
|