15a9d1066670a5c5e32fc21f8ea82ee26697861e
32
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
15a9d10666 |
correlator: keep the tiebreak for typed / reassignment calls in the orphan gate (#115)
_call_is_substanceless mirrored has_event_substance but not the creation gate's type-resolved short-circuit, so a routine-severity fire/medical call with no coords/tags/vehicles — or a reassignment (unit pulled to a new job) — could be gated to orphan where rules would open an incident. Bail out of the gate on incident_type or reassignment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix |
||
|
|
dd426572fc |
correlator: fix consensus orphan-gate to test call substance, not empty corr_debug (#115)
The gate added in
|
||
|
|
ca1d8fbdae |
correlator: gate LLM-orphan against rules-new instead of escalating to tiebreak (#115)
CORRELATION_REVIEW_0907b.md measured that radio housekeeping (unit check-ins, roll call, 10-8/10-98 clearings) is being promoted to incidents. Every case reads corr_llm_action=orphan, corr_rules_action=new, corr_consensus=tiebreak -> new: the cheap LLM correctly reads "not an incident", the rules engine says `new` only because there is no incident to link to, and the smart tiebreaker then sides with rules ~21/21. Reframing the tiebreaker prompt (#116) did nothing. The fix is a consensus-logic gate, not another prompt. Fix 1 (routers/upload.py) - LLM-orphan gate in _correlate_with_consensus: when the cheap LLM says `orphan` and the rules engine says `new` with NO positive event signal, resolve to `orphan` and skip the tiebreak call entirely. "No positive signal" = the rules corr_debug carries neither a positive corr_path (unit-continuity / location / fast/disambig / fast/single) nor a positive corr_fit_signal (unit_overlap / location_proximity). When it does carry one, the existing escalation-to-tiebreak is kept so a genuine event the LLM misreads as orphan still gets the second look. The resolved outcome records corr_consensus="llm_orphan_gate" (greppable, distinct from "tiebreak") and keeps corr_llm_reasoning / corr_rules_action / corr_llm_action populated. Fix 2 (incident_correlator.py) - tighten corr_path=location: the location path linked on a bare sub-location_proximity_km (0.5 km) distance with no unit or content check, which stitched a vehicle lockout to a station-restroom slip and merged two different churches an hour apart. A location link now requires unit overlap with the candidate OR a distance under a tighter bar (_LOCATION_TIGHT_PROXIMITY_KM = 0.2 km). Pursuit incidents keep their movement-speed-validated wide radius. A surviving location link now also writes corr_fit_signal (unit_overlap | location_proximity), consistent with Fix 1's positive-signal set. Tests: new tests/test_consensus_gate.py (13 cases) - the gate resolves to orphan without calling tiebreak on a no-signal disagreement; a unit_overlap / location_proximity / unit-continuity / fast-disambig rules signal still escalates; llm=link vs rules=new still escalates; the location path drops a shared-area candidate with neither unit overlap nor tight proximity, links on unit overlap, and links on tight proximity alone. Full c2-core suite 309 -> 322 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix |
||
|
|
d60fef67ad |
c2-core: add CORS middleware so the browser can call the REST API (#110)
The Archive page's GET /calls/search failed its CORS preflight (OPTIONS -> 405, no Access-Control-* headers). Allow the app origin(s) explicitly for the standard methods and the authorization/content-type headers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix |
||
|
|
1a631d65d0 |
correlator: address #116 review — call talkgroup id in the prompt, sort candidates
drb-correlation-review: ship, with two bounds the low-bar link rule needs. 1. _call_block emitted only the talkgroup NAME while _inc_summary emits numeric tg ids, so the "same talkgroup" precondition in _RULES was unevaluable and the low link bar applied unconditionally. _call_block now prints "Talkgroup: <name> (id <n>)". 2. ctx["recent"] is an unordered Firestore slice with no order_by; a busy 2h window (~40 active incidents) showed the model an arbitrary half of the candidates. _prompt_incidents() sorts by updated_at desc before the [:20] cap — also makes each row's idle: field monotonic. +2 tests. Full c2-core suite green (sandboxed venv). Review follow-ups (not blockers): _parse_response demotes an unresolvable link to orphan (drops the call) rather than falling back to rules — now on rising link volume; the 45% tiebreak escalation rate / smart-model cost is untouched; _ROAD_RE swallows leading tokens so "10 Parker Street" still won't road-overlap "Parker St". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
3a944f35c1 |
correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (server-26#115)
The 2026-09-07 measurement window (CORRELATION_REVIEW_0907.md) showed the consensus tiebreaker was the dominant over-split driver: it ran on 45% of calls and resolved link/orphan disagreements as "new" ~24/25 of the time, shattering one Mohegan Park car-alarm job into 9 incidents and opening ~7 incidents from radio checks / roll calls. Two causes, two fixes: 1. `_inc_summary` gave the model `id|type|loc|units|tags|idle` — no title, no talkgroup. It literally could not see that two "car alarms, Mohegan Park Ave/Avenue" incidents on TG 9560 were the same. Now includes the incident title (the strongest same-event signal) and talkgroup. 2. `_RULES` told the model "orphan when in doubt — conservative is always correct". For a system that over-splits, that is backwards: a wrong link is cheap, a duplicate incident is the failure. Rewritten to: prefer link for a plausible same-talkgroup continuation (low bar), reserve "new" for a genuinely different event, and explicitly "orphan" non-incidents (radio checks, roll call, 10-8/10-98, mileage logs). Plus `_extract_road_ids` now canonicalises street-type synonyms (Avenue→ave, Street→st, Road→rd, ...), so "Mohegan Park Avenue" and "Mohegan Park Ave" share a road id — that one difference was splitting the car-alarm incident. +tests/test_correlator_115.py. Full c2-core suite green (sandboxed venv). Bigger levers deferred to follow-ups: the consensus escalation itself (should a cheap-LLM "orphan" ever reach a tiebreak?), a first-class road-overlap fit signal in _call_fits_incident, geocode coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
7189ba03e4 |
correlator: address #102 review — 0-based segment labels, never-empty slice
drb-correlation-review on the prior commit flagged two ways the per-scene transcript could silently fall back to the whole-call text: 1. _build_transcript_block numbered transmissions "1." while the prompt says "0-based indices" — a model echoing the labels it saw returned 1-based indices, shifting every scene's slice by one. Labels are now "0." to match the documented contract (also fixes the same latent skew in _build_scene_embed_text / #80). 2. An empty join (bad / out-of-range / non-int indices) hit `transcript or call_doc.get(...)` in _build_context and fell back to the whole-call transcript — re-opening the leak exactly when indices are wrong. The slice now falls back to this call's own whole transcript *before* _build_context sees it, so it is never "". Non-int and negative indices are rejected rather than raising. Slice logic extracted to `_scene_transcript_text` with a dedicated test file (4 cases: subset, corrected-wins, no-indices fallback, bad-indices fallback). Call-doc fallback kept (sweep / no-scene path) per the review. Also restored the `-> ` spacing lost in the prior commit's kwarg edit. Full c2-core suite green: 300 passed (sandboxed venv). Still DO NOT MERGE until the measurement window closes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
ef1e3d7f9d |
correlator: LLM tier reads the scene's transcript, not the whole call (server-26#102)
The last leg of the #80/#95 scene-context leak. llm_correlator._call_block read call_doc's whole-call transcript for every scene, so on a multi-scene call every scene's cheap-tier and tiebreaker decision was made against text that also contained the other scenes. - intelligence.py: each processed[] scene now carries its own "transcript" — transcript_corrected, else this scene's segments joined, else (single scene) the whole transcript. - _build_context / preview_correlation / correlate_call: take a `transcript` param; _build_context resolves ctx["scene_transcript"] from it, falling back to the call doc (sweep, single-scene, tests) — the fallback is kept here, unlike embedding/severity, because a scene always has real text. - upload.py: both scene loops pass scene["transcript"]. - llm_correlator._call_block: reads ctx["scene_transcript"] (call-doc fallback retained for test-built ctx). - recorrelation_sweep: passes the call doc's text explicitly. - +1 regression test. Full c2-core suite green (296 passed, sandboxed venv). NOT for merge until the running correlation measurement window closes and its dump is analysed — deploying a correlator change mid-window would mix old and new behaviour in the sample. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
a9d1d2475a |
correlator: judge each scene on its own embedding + severity (server-26#80, #95)
intelligence.py writes only the primary scene's embedding and severity to
calls/{id}. _build_context read them back off the call doc, so every
non-primary scene of a multi-scene call was correlated against scene 1's
semantic vector and severity rung: a scene about a different event scored
on the embedding path against the wrong incident, and could inherit a
minor/moderate/major severity it never had, clearing the creation gate on
borrowed weight. Same defect and same fix as the #87 coords leak.
- _build_context / preview_correlation / correlate_call: take embedding and
severity as params; drop the call_doc.get() fallbacks. A scene that
passes none has none, and is judged thin on its own signal.
- upload.py: both scene loops pass scene["embedding"] / scene["severity"];
_correlate_with_consensus forwards them. The no-scene unclassified branch
passes neither (correct: no scene, judged thin).
- recorrelation_sweep: passes the call doc's stored values explicitly
(whole-call re-link, link-only, so a borrowed severity cannot create).
- intelligence.py: SCENE DETECTION prompt tightened toward one scene
(server-26#5, partial) - MULTIPLE only for genuinely separate events,
"when unsure, one scene", plus a not-a-new-scene list.
- test_incident_identity.py: +2 regression tests mirroring the #87 test.
Full c2-core suite green (295 passed). #5 prompt change is unmeasured -
needs a scoped correlation-only window. Known remaining legs, tracked
separately: llm_correlator._call_block still reads the whole-call
transcript per scene; content-divergence veto skips on a None embedding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
8b6c170265 |
Close viewer-triggerable OpenAI spend on incident summarize (server-26#81)
POST /incidents/{id}/summarize was gated by require_service_or_firebase_token,
which accepts any authenticated Firebase user including role "viewer". That
route spends OpenAI credits via the background summarizer. The call-side
equivalent was already moved to require_admin_token; this brings the incident
side in line with it.
The frontend's two "summarize now" buttons on the incident detail page are
already gated behind isAdmin, so this backend change matches existing UI
behavior exactly and does not break any viewer/operator surface — it only
closes direct-API access for non-admins.
Swept every other route in incidents.py: list/get are reads with no spend and
correctly stay open to any signed-in user; create/update/delete/link/unlink
were already require_admin_token. No other sibling route needed changing.
Adds test_incident_summarize_auth.py pinning the dependency wiring directly
(the convention used in test_admin_feature_flags.py), so a future revert back
to the weak dependency fails a test immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
bdb57ae75a |
correlator: stop non-primary scenes inheriting the call doc's pin (#87)
_build_context fell back to call_doc.get("location_coords") whenever a
scene passed no coordinates of its own. One radio call can be split
into several scenes, but only the primary scene's geocode is ever
written to the call doc — so every non-primary scene silently
inherited the primary scene's pin. That fabricated location_proximity,
the strongest accept signal the correlator has, for a scene that had
no location at all, and drove it into the primary scene's incident on
a pin it never had.
Drop the fallback: coords = location_coords. A scene with no location
is now correctly judged thin, cannot win the location path, cannot
supply call_coords to _call_fits_incident, and cannot seed
_find_cross_system_parent.
recorrelation_sweep.py, the only other caller of correlate_call, was
verified to already pass both location and location_coords explicitly
from the call doc, so the fallback there was a no-op and this change
is behavior-preserving for that path.
Adds test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin
to test_incident_identity.py, pinning ctx["coords"] is None and
ctx["is_thin_call"] is True when location=None but the call doc
carries a location_coords.
Ref: server-26#87
|
||
|
|
865b5b4317 |
Close the /admin/features side-door that needed a container shell to flip AI spend
Board minutes #62 Decision 2 (server-26#64), due 2026-08-31. CTO draft #60 finding 1 and CISO draft #61 finding 3 reached this independently. GET/PUT /admin/features accepted only a Firebase admin token, so the unattended runbook had no headless path and SSHed into the c2-core container to write config/ai_features with the admin SDK. Moving a platform-wide AI cost switch required a full container shell, and set_flags() wrote no audit entry either way, so a flag flip was unattributable however it happened. - New agent_service_key (AGENT_SERVICE_KEY), deliberately separate from the Discord bot's service_key. Sharing one key would collapse two principals into a single unattributable identity in every log line, and the bot has no business flipping AI flags regardless. - require_agent_key_or_admin accepts the agent key or a Firebase admin, and rejects the Discord key. The "key is configured" guard is load-bearing: compare_digest("", "") is a match, so a deployment that never set the key would otherwise accept an empty credential. - set_flags() writes an audit_log entry with before/after values and the actor, wrapped so an audit failure cannot lose the flag write or 500 the route. - Cascade helper sets the global doc and every system carrying an ai_flags override in one call. A global False already beats everything, but a system False beats a global True, so turning AI *on* could half-apply and leave a radio system hot after shutoff. It scans for the override rather than hardcoding the two known system IDs, so a new system cannot silently defeat it. - cascade defaults to False. PUT /systems/{id}/ai-flags and the AiFlagsPanel toggle mean a per-system override is deliberate operator intent; cascading by default would erase it on any unrelated global flip. The runbook opts in. Issue items 5 and 6 (retiring the SSH path from drb-worksession.md) are NOT done here and the runbook is untouched. The credential does not exist in production yet, so the SSH path is still the only one that works; retiring it now would break the next unattended run. Owner activation is recorded on #64. Tests 273 -> 289. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0635de8dac |
Stop alert webhooks putting raw transcripts in a third-party channel
Alert dispatch attached a 200-character raw transcript snippet to the alert_events document and POSTed the same text to the org's Discord webhook, with no redaction of any kind. Board minutes #42 ratified that person names are suppressed on every surface until E&O is bound, and a Discord channel is the least recoverable surface there is: once the text lands we do not own it, cannot unsend it, and cannot audit who read it. Raw transcript text now requires two independent gates, both closed by default: 1. alert_transcript_snippet_enabled -- an operator switch in config, set from the environment. 2. alert_snippet_opt_in on the org document -- the customer's own explicit consent. Gate 1 is not redundant. The frontend reads and writes Firestore directly from the browser, so the org flag alone would let an org owner opt themselves into receiving person names lifted from live public-safety traffic. Capability is the operator's to grant; consent is the org's. The gate fails closed on a Firestore error and on a call with no org (a pre-tenancy node that has not been backfilled) -- a less informative alert is cheap, an unrecallable disclosure is not. Alerting itself is unchanged: the webhook still fires and still names the rule, the talkgroup and the matched keywords. This does not wait on the Gate B3 redactor (#43, 2026-09-30). The snippet was a convenience field and needed no redactor to withhold. Tests assert the person name in a sample transcript does not appear in either the outbound payload or the Firestore write, in every combination of the two gates. Closes server-26#85. Refs #42, #43, #48. |
||
|
|
e30d594eea |
Stop a back-dated call from silently disabling every recency gate (server-26#74)
_call_fits_incident measured incident idle with the signed helper while every other recency gate in the file uses the unsigned one. On the re-correlation sweep, `now` is the call's own started_at, which can precede the incident's last activity, so the value went negative. Negative idle made `idle_min >= 15` false, which meant the content-divergence veto never ran and unit overlap was accepted unconditionally -- on a shared dispatch backbone that is the feedback loop that lets one incident absorb a whole talkgroup. It also made `idle_min < 20.0` true at any back-dating, so a tactical channel returned tactical_default for every swept orphan out to the 90-minute bound. One variable feeds all four gates in the function, so this is a one-line change at the source. The signed value is untouched where it belongs: callers still compute corr_incident_idle_min themselves, so debug output keeps its meaning. Direction is toward more splitting, on the sweep path only, which is the point -- the bug was suppressing an over-merge veto. Forward-dated calls and anything inside the thresholds behave exactly as before. Two tests added alongside the existing idle-gate cases; both fail on the old line and pass on the new one. 266 pass, 0 fail. Refs server-26#74, #5, #80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d18e4f0743 |
Make "AI is off" true, and stop the transcript PATCH from destroying calls
config/ai_features was not the switch it was documented to be. Three paths spent money with it off, and one path read it wrong, so per-system opt-outs did not opt anything out. - Correlation in the ingest pipeline tested the raw global flag instead of the per-system resolution. With a system opted out, extraction was skipped but the no-scenes fallback still correlated the call with empty tags, taking the thin/recency path and attaching it to whatever incident was most recent on that system. The opt-out did not disable correlation, it disabled good correlation and left the worst kind running. (#75) - Transcript correction ran on every transcribed call gated only by an env var, spending Gemini tokens and a Places lookup per proposed location. An "STT-only" window was never STT-only and its cost could not be attributed. Now behind transcript_correction_enabled. (#76) - _run_extraction_pipeline and the vocabulary learner, both reachable from PATCH /calls/{id}/transcript, checked no flags at all. (#76, #81) The flag resolver now lives in feature_flags.resolve_flags() rather than as a local helper in upload.py. Three copies of that logic is how #75 happened. PATCH /calls/{id}/transcript now refuses with 409 when correlation is off. That route wipes tags, severity, location, units, embedding and unlinks the call from every incident before queueing re-extraction. Gating extraction alone would have made it destructive-only in the standing flags-off configuration: the call left blank and orphaned forever, with the route still answering 200. The wipe and the rebuild are one transaction in intent, so it refuses before the first write. Also: the summarizer's stale-incident sweep is no longer behind summaries_enabled. It is pure Firestore with no model call in it, and gating it meant nothing auto-resolved while AI was off - so every incident stayed active forever and the candidate set every correlation reads kept growing. transcript_correction_enabled is documented as NOT a pure cost lever. The corrector is also the noise gate that sets not_speech; with it off, recogniser noise reaches extraction as a real transcript, comes back thin, and auto-attaches. Never open an evaluation window with correction off and correlation on. 14 tests added covering flag precedence, both pipeline paths, the 409, the correction gate and the summarizer no-op. Suite: 264 passed. Refs #75, #76, #81, #45. |
||
|
|
cc038e6326 |
A unit call-sign is not a place
"Post 1-2" reached the geocoder, resolved against its talkgroup anchor and produced a confident pin in the right town for an event with no known location — while sitting in the same incident's `units` list the whole time. A plausible wrong pin is worse than no pin: nothing downstream can tell it is wrong. Extraction returns `location` and `units` from one pass, so a string in both is a misclassification, not two facts. Drop it before the geocoder sees it. Closes server-26#52. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
964343c819 |
area_context v2 + Maps place verification (server-26#36, #37)
#36 — the correction pass shipped in
|
||
|
|
58efdbd6eb |
Correct the transcript before anything reads it
Correction existed, but as a line in intelligence.py's EXTRACTION_PROMPT --
which put it in the wrong place twice over. The same model call that extracted
units, location and severity emitted the correction afterwards, so extraction
reasoned over text already known to be wrong; and it sat behind
correlation_enabled, so during a cost-controlled STT-only window nothing was
ever corrected at all. That is the normal state during development.
internal/transcript_correction.py is now its own pass, between the degenerate
filter and the Firestore write. It receives an already-produced transcript plus
a reference list, so unlike a Whisper prompt it has no series to extend -- the
distinction that keeps vocabulary out of the recogniser's prompt, where an
enumerated ten-code list once made it hallucinate ten-code runs.
Reference data is merged from the talkgroup and the system, TALKGROUP FIRST. A
system spanning several counties can have a talkgroup covering one
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. Area context is now SET --
municipality, county, roads, landmarks, on both scopes -- rather than guessed
from talkgroup names, which is what vocabulary_learner did and which is close
to useless across multiple counties.
Segments are corrected too, not just the joined text. extract_scenes builds its
prompt from numbered segments whenever there is more than one, so a correction
that only fixed the transcript would have been discarded on exactly the
multi-transmission calls carrying the most content. Alignment is enforced: an
array of the wrong length or type is dropped whole, because scenes map back to
transmissions by index and a shifted array would misattribute audio silently.
Whisper is also retried once on degenerate output. Call e49ea32c produced a
56-word ten-code counting run on one attempt and ordinary speech on the next --
same clip, same temperature=0 -- so a hallucination is a coin-flip, and
discarding on the first bad roll threw away a recoverable transcript.
Two things found on the way:
PUT /systems/{id} wiped ten_codes on every save. The systems form sends only
{name, type, config}, and model_dump() wrote every omitted field as its default
over the top. Now exclude_unset. area_context would have been the next victim,
which is why it gets its own route alongside ten-codes rather than a field on
that payload.
Closes server-26#36.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
039a06dc72 |
Let C2 name a talkgroup it already knows
84 of the 100 incidents in the 2026-08-23 dump were titled "Ems — TGID 9048"
or "Other — TGID 9600" -- the fallback, not a description. The title is the
incident's name everywhere it appears: list rows, map pins, Discord alerts.
_create_incident builds it from a content tag and a talkgroup label, and the
label was collapsing to "TGID {id}" because talkgroup_name arrived as None.
It is a plain form field on /upload, forwarded untouched into correlation, and
the node only sends it when OP25 had the name in its loaded tags file -- which
is exactly the case C2 can cover from its own systems collection, where all 125
talkgroup definitions live.
The lookup already existed, on the other path: mqtt_handler resolved it from
the system config on call_start. So the call document held the right name while
the pipeline that titles the incident ignored it. That asymmetry is the bug.
internal/talkgroups.py is now the one implementation -- caller's hint, then the
call document, then the system config -- and both paths use it.
_run_intelligence_pipeline resolves once at the funnel /upload and
/calls/{id}/reprocess share, so the dispatch-channel test, scene extraction and
the title all see a real name. When the call document was the thing missing it,
the resolved name is written back, so the archive and the orphan panel stop
showing a bare TGID too.
Also gives fast/thin a corr_fit_signal. It is 63% of all links and was the only
path writing none, so corr_fit_signal was absent on 295 of 309 calls and the
admin debug view's distribution panel read empty -- looking broken when it was
faithfully reporting that the dominant path records nothing. It now says
thin_recency, which is what actually decided it.
Closes server-26#34. Refs server-26#35 -- the tier's 3.5% invocation rate is a
cost/benefit question, not a bug, and stays open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
82c88379d4 |
Stop an incident lying about what it is and where it is
An incident header had two independently last-write-wins halves, and in the
2026-08-20 dump both were wrong at once. `b9b4f392` opened on a suspect search
at 80 Grasslands Road; it was labelled "100 South Mosher" (its third call),
pinned at `Westmed` (its second), and titled after the label. Five of six
incidents were pinned somewhere other than the place they claimed to be.
Location and pin are now one value
----------------------------------
`_resolve_location_pair()` computes `location`, `location_coords` and the new
`location_coords_source` together, and `_update_incident`/`_create_incident`/
`_create_master_incident` always write all three. There is no longer a code
path that can move one and leave another behind — including the cross-system
master, which used to take its label from the parent and its pin from the call.
The pin now carries the label it was geocoded from. `_verified_pin()` returns
it only when that source still matches the incident's current label; anything
else is dropped. That includes every pre-existing incident, whose pin has no
recorded source and therefore cannot be reconciled — which is the right
outcome, since the dump says 5 in 6 of those are wrong. A missing pin reads as
missing data; a wrong pin reads as fact, and this is a map people may act on.
An incident also keeps the first place it was given rather than the latest.
Later mentions still accumulate in `location_mentions` (what the map path is
drawn from); they just don't rename the incident's own location. The one
permitted change is filling in a pin the incident never had, from a later call
naming the exact same label — geocoding needs the node position, a quota and a
response, so the same address genuinely does fail once and resolve later.
"49" is not a place
-------------------
`clean_location()` rejects any string with no two-letter word in it, applied at
extraction (intelligence.py, before the geocoder and before the call document)
and again at the correlator's context boundary. `9d376ffe` carried
`location: "49"` from "Fire received. Flames from 49." — a box number — and its
summary asserted "A fire incident was reported at location 49". Nothing
validated that field at all, so it would have recurred.
Title: the founding event, escalation only
------------------------------------------
The title was re-derived from the newest classified call, so `f5190670` was
named after the thirteenth of its thirteen events. It now names the call that
opened the incident, recorded in `title_tag`/`title_severity`, and can only be
replaced by a call of strictly higher severity.
Three candidates were considered:
* Newest call (status quo) — rejected. The same incident has a different name
at different times, so a user who saw it in the rail cannot find it again,
and the name is decided by radio timing rather than by the event.
* Highest severity alone — rejected as the sole rule. Severity has four
levels and most traffic sits on one of them, so ties are the common case
and the tiebreak degrades to "newest" — the defect it was meant to fix.
* Founding event, escalated by strictly-greater severity — chosen. An
incident's identity is the event that opened it, so that is its default
name and it is stable for the incident's whole life. The single case where
the header MUST change is the one where the situation got worse: a check
condition that becomes a structure fire is a structure fire, and the
worst-first rail, the "Major only" filter and the map colour all exist so
that is never missed. Requiring strictly-greater makes it monotonic, the
same contract `_max_severity` already gives the severity field: routine
chatter can never take the name back.
A summary-level title regenerated as a whole was rejected outright: it needs an
LLM call per incident, AI flags are off in production, and every incident today
would have no title at all.
Two renames survive, because neither replaces an event name: filling in the
placeholder title of an incident that opened on a call with no content tags
("Police — Ch 1"), and re-rendering the same event once the incident learns its
address. Incidents created before this change have no `title_tag`, so their
existing title is treated as the founding one rather than handed to whichever
call links next.
Interaction with the caps from
|
||
|
|
c7be6416f2 |
Surface LLM correlation fields in debug view; fix unit-continuity path
/admin/debug/correlation stripped corr_consensus and the corr_llm_* fields
that upload.py's consensus correlator writes onto the call doc, making it
the one tool built to answer "is the LLM correlation tier alive" unable to
answer it (2026-08-19 dump had to infer LLM state from commit dates instead
of reading it off the data). admin.py's _call_summary() now includes
corr_consensus, corr_llm_reasoning, corr_llm_action, corr_rules_action.
The unit-continuity correlation path never wrote corr_matched_units, unlike
fast/single and fast/disambig, so the debug view showed null for a match
that was in fact unit-driven by construction. Now populated unconditionally
on that path (server-26#16).
Also traced the negative corr_incident_idle_min (-4.1 observed) to its root
cause: the re-correlation sweep anchors `now` to the linking call's own
started_at, and that back-dated value was being written straight into the
incident's updated_at, letting it land before the incident's own
started_at. Added _floor_at_started_at() so updated_at can never precede
started_at. (commit
|
||
|
|
8fbfe7d6de |
Make a failed deploy impossible to miss, and a wildcard CORS harmless
Two unrelated-looking problems with the same shape: a dangerous state that
looked fine from the outside.
DEPLOY (server-26#21). The Deploy job failed on fifteen consecutive pushes
between 2026-08-18 and 08-20 and nobody noticed for two days, because the
build job was green and a red run is only visible to someone who opens Gitea.
Production served 08-18 code the whole time -- including the entire frontend
redesign, chunks 2 through 8. Three changes:
* The health check now asserts WHICH build answered, not just that something
did. CI bakes the commit into the image (Dockerfile ARG/ENV GIT_SHA) and
/health reports it, so a deploy that "succeeds" while the previous
container keeps running now fails. Liveness alone could never have caught
this.
* The image pull retries once after a prune. The actual failure was
containerd unable to extract a layer -- "failed to Lchown ... no such file
or directory" -- a corrupted entry in the snapshot store, which a prune
clears. A second failure after pruning is a real problem (check the VM's
disk) and still stops the deploy.
* A notify-failure job POSTs to DEPLOY_ALERT_WEBHOOK when anything in the
workflow fails. Unset means skip quietly, not fail.
CORS (server-26#20). allow_origins=["*"] with allow_credentials=True is not
the permissive-but-harmless setting it reads as. Starlette does not reject the
pair -- it reflects the caller's Origin back and still sends
Access-Control-Allow-Credentials: true, so the effective policy is "any
origin, WITH credentials", the opposite of what a wildcard normally means.
Rather than trust every deployment to remember CORS_ORIGINS, the pair is now
unrepresentable: a wildcard forces allow_credentials off and logs an ERROR
naming the variable to set. Correctly configured deployments that name their
origins are unaffected and keep credentialed requests.
Severity honestly: low today. c2-core is bearer-auth, and browsers do not
attach bearer tokens cross-origin the way they attach cookies. This is a
misconfiguration waiting for the day something starts trusting a cookie.
Also adds firebase_admin.auth.UserRecord and the list/update/create/delete_user
names to the conftest stub. routers/users.py annotates with UserRecord at
import time, so without it importing app.main failed at collection -- which is
why nothing had ever tested anything wired at app level, CORS included.
Tests: 5 new in test_cors_policy.py, covering the pure policy function, the
middleware actually mounted on the app (so re-hardcoding allow_credentials=True
fails here), and the presence of the build stamp.
Closes logan/server-26#20
Closes logan/server-26#21
|
||
|
|
33a247d306 |
Stop thin calls fusing a work shift into one incident (server-26#22)
The 2026-08-20 production dump had 4 of 6 sampled incidents as junk chains,
the worst being f5190670: 68 calls over 4h09m, 44 units, 12 tags, at least
13 genuinely distinct events. 58 of 133 linked calls took the fast/thin
path, which is the one path that attaches a call with no fit test at all.
Three defects combined to produce that, and all three are fixed here.
1. What counted as thin was wrong.
is_thin_call was "not units and not vehicles and not coords". A real
dispatch qualified as thin whenever no unit ID parsed and the geocode
failed - six of them did in that dump, including "All units head over to
the powerhouse, 55 Hyman Hills Road ... she's 87 years old", a brand new
job that attached to the four-hour chain and then overwrote its location
and its title. A call is now substantive if it carries tags, a location
string, a severity above routine, or is a reassignment; only genuinely
content-free housekeeping ("10-4", "Copy") stays thin. Those calls now go
through _call_fits_incident like everything else, which on a dispatch
backbone with no positive signal means they open their own incident or
orphan rather than merging.
The reassignment clause closes a self-defeating guard: upload.py blanks
units when dispatch pulls a unit onto a NEW job, specifically to stop
unit-overlap chaining - and blanking units made the call thin, routing it
to the only path with no fit check. The guard produced the merge it
existed to prevent.
2. The thin path was bounded on dispatch channels only.
Every other talkgroup fell through to "thin_pool = tg_recent": any
incident idle up to tg_fast_path_idle_minutes (90), no single-candidate
requirement, no fit test. The 30-second tier-1 / single-candidate tier-2
structure now applies to all channels. Non-dispatch gets its own window,
TG_THIN_IDLE_MINUTES=15, rather than sharing the dispatch value: a
tactical channel really is dedicated to one scene so it earns longer, but
15 sits inside the 20-minute tactical-default window already used in
_call_fits_incident, so the no-evidence path is never more permissive than
the fit-tested path on the same channel.
Recency gates now compare the magnitude of the idle, not the signed value.
The re-correlation sweep anchors "now" to the call's own started_at, so
idle goes negative routinely - incident 9d376ffe recorded
corr_incident_idle_min: -4.1 - and every "idle <= window" test in this
module reads True for a negative number. Those gates had silently stopped
bounding anything for exactly the calls the sweep re-examines.
3. Nothing capped an incident's total size.
Every fit test in the correlator is pairwise: does this call belong with
that incident. Each of f5190670's 68 links was individually arguable; the
mistake was the accumulated shape, which no pairwise rule can see. Two
hard caps now remove an incident from the candidate pool entirely, before
any path can choose it - including the LLM tier, which reads the same
ctx lists.
INCIDENT_MAX_DURATION_MINUTES=120. The one incident in that dump that was
genuinely a single event ran 63 minutes (06:15 wrong-way driver to 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 a candidate older than that, and the fast path
was the only one exempt, so this removes an inconsistency rather than
inventing a number.
INCIDENT_MAX_CALLS=40. A backstop for a burst that fills up inside the
duration cap, not the primary bound. The worst chain averaged ~16
calls/hour while absorbing an entire dispatch backbone, so 40 calls in
under two hours means one incident is eating most of the channel. 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.
Capping is not truncation: the incident keeps every call it has and still
auto-resolves on the normal idle sweep. It just stops being a candidate.
Every ambiguous call here was resolved toward a separate incident rather
than a merge. 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, and poisons the AI
summary written from them. The cost is some acknowledgements orphaning
instead of riding along on an incident, which is a small, visible loss.
Deliberately NOT changed, since both push toward more merging while the
current failure mode is entirely over-merging (every incident in the dump
has exactly one "new" call; there is no over-splitting left to trade
against):
- unit-overlap positive feedback on shared dispatch channels, which is
now bounded by the caps rather than fixed at its root
- the sweep retry budget expiring before the target incident exists
Tests: 31 new cases in tests/test_correlator_merge_caps.py, including a
replay of the f5190670 night - 13 unrelated jobs at their real offsets,
plus roster unit traffic and acknowledgements every two minutes. Without
the caps that traffic still builds a 125-call incident spanning 244
minutes; with the old thinness test on top, 153 calls over 247 minutes in
3 incidents. With this commit it is 13 incidents, largest 40 calls over 80
minutes. Each new case was checked to fail when the behaviour it covers is
reverted. Suite: 138 passed.
No AI feature flag was touched; correlation stays off in production.
Closes logan/server-26#22
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a250c29e3c |
Add AI provider degradation registry and alerting (server-26#14)
Three AI dependency failures in one night (retired Gemini model IDs, depleted Gemini balance, unpayable OpenAI account) each surfaced only as a single ERROR log line that nobody was watching. Add app/internal/ai_health.py, a shared in-memory registry that transcription.py and llm_correlator.py report into on every call (success and failure), distinguishing permanent conditions (dead model, dead billing) which alert immediately from transient ones (rate limits, network blips) which only alert after they persist. Alerts POST once per degradation episode and once on recovery to an optional Discord webhook (AI_ALERT_WEBHOOK_URL), reusing alerter.py's httpx pattern. State is exposed unauthenticated at GET /health/ai alongside the existing /health. Closes logan/server-26#14 |
||
|
|
6dfa5bc66d |
fix: repair 10 stale tests in test_mqtt_handler.py and test_node_sweeper.py
All 10 failures were tests that had drifted behind the product code, not
regressions in it. Diagnosed each individually:
test_mqtt_handler.py:
- test_checkin_creates_new_node, test_checkin_new_node_defaults_lat_lon:
unpacked 4 positional args from doc_set.call_args[0], but
fstore.doc_set(collection, doc_id, data, merge=False) always passes
merge as a kwarg, so only 3 positional args are ever recorded. Fixed
the unpack to 3.
- test_call_start_creates_call_doc, test_call_start_uses_now_when_started_at_missing:
mocked fstore.doc_get, but _on_call_start looks the node up via the
cached fstore.doc_get_cached (added when Firestore reads were cut to
stay in the free tier). The unmocked doc_get_cached returned a bare
MagicMock, which isn't awaitable. Mocked doc_get_cached instead; also
fixed the same 4-vs-3 positional-arg unpack on doc_set's merge=False call.
- test_call_end_updates_status_and_times, test_call_end_sets_audio_url_when_present:
mocked fstore.doc_update, but _on_call_end now writes via
fstore.doc_set(merge=True) (see the "Fix Upload 404 warning" commit —
doc_update raised "No document to update" when call_end arrived before
call_start). Also calls doc_get_cached to stamp org_id. Mocked
doc_get_cached and asserted against doc_set instead of doc_update.
test_node_sweeper.py:
- test_stale_online_node_marked_offline, test_stale_recording_node_marked_offline,
test_tz_naive_last_seen_is_handled, test_only_stale_nodes_updated_in_batch:
_sweep() now calls app.routers.tokens.release_token(node_id) for every
node it marks offline (added in
|
||
|
|
70d63abeaa |
Re-evaluate incident severity on link, stamp resolved_at at every resolution site
#17: severity was written once at _create_incident and never touched again, so an incident that opened routine and escalated to a working fire stayed routine forever. _update_incident now merges call_severity into the incident via _max_severity() on every link. Severity is monotonic: it only ever rises, never falls. An incident briefly assessed "major" genuinely was major at that moment; a later, calmer-sounding call is evidence the situation is winding down, not that the earlier read was wrong. status/resolved_at exist to retire an incident — severity should stay as the high-water mark so the worst-first rail, "Major only" filter, and map colouring never bury a call that was genuinely major. See _max_severity's docstring in incident_correlator.py for the full argument. #18: none of the resolution sites wrote resolved_at, so an incident's lifespan couldn't be reconstructed for the history-scrub feature. Added resolved_at alongside status="resolved" at all six sites that flip it: - incident_correlator.py _update_incident (signal-based: units all cleared) - incident_correlator.py maybe_resolve_parent (master auto-resolve) - summarizer.py _stale_sweep (90-minute auto-resolve) - upload.py, both scene-resolution loops (single- and multi-scene) - calls.py reprocess/correction path (_update_incident's signal-resolve and maybe_resolve_parent's master-resolve weren't named in the issue's four call sites, but they set status the same way and were missing resolved_at too.) No backfill: existing resolved incidents keep resolved_at = null, which means "resolved before this field existed," not "never resolved." Backfilling from updated_at would be a guess dressed up as data. Tests: added to tests/test_correlator_gate.py, which needs no Firestore for the pure _max_severity cases and patches fstore for the _update_incident/ maybe_resolve_parent writes. Covers the escalation case (routine -> major), the no-downgrade case, and resolved_at on both the signal-resolve and master-resolve paths. 52/52 passing in that file; 83 passed / 10 pre-existing failures for drb-c2-core overall (baseline was 69/10 — the +14 is exactly the new tests, no regressions). Fixes #17, #18. |
||
|
|
c09cb72f66 |
Compare unit IDs by normalised key, not exact string
Dispatch audio names the same unit several ways within one conversation, and
every comparison in the correlator used exact string equality, so a follow-up
transmission from a unit already on an incident simply failed to find it. With
the creation gate no longer letting routine traffic open its own incident,
these stopped becoming junk incidents and started becoming orphans instead --
which is how they became visible. In the 01:05Z dump, five of eighteen orphans
were calls belonging to an incident that was open at that moment:
"K-9A2" vs "K-9-A-2" punctuation
"5-1-6" vs "516" digits read out individually
"37" vs "37th Post" ordinal plus role word
"11-Victor" vs "11 Victor" hyphen vs space
_normalize_unit lowercases, drops punctuation and role words (post/unit/car),
strips ordinal suffixes, and joins the remaining tokens, so each pair above
collapses to one key. All six comparison sites now go through it: the two
fast-path debug reporters, unit-continuity candidate selection and its
reassignment check, the cross-talkgroup 2+ shared-unit test, and the
disambiguation scorer.
What it deliberately does NOT do is match a bare district letter -- "Adam" is
not treated as "6-Adam". Every district has an Adam, and collapsing them would
merge unrelated incidents across districts. That leaves a couple of the
observed orphans unlinked, which is the right trade: a missed link leaves an
orphan the re-correlation sweep retries three times, while a false link
corrupts an incident permanently and nothing walks it back.
Two smaller things fall out of the shared helper. Matches are reported as the
original spoken strings rather than the normalised keys, so corr_matched_units
stays readable in the debug view. And a unit made only of role words ("Post")
would normalise to the empty string and then compare equal to every other such
unit, so it falls back to the raw text -- tested, because that failure would be
silent and would merge aggressively.
Adds 13 cases: each observed pair, five pairs that must stay distinct, the
empty-key guard, match reporting, and an end-to-end check that the K-9A2 call
now links where it previously orphaned. 38 pass.
No new environment variables, so CI deploys this without an ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7b5258cfdf |
Halve the tier-2 thin-call window, from 10 minutes to 5
With over-creation fixed, the incidents that remain are readable enough to judge, and the ones that still do not make sense all fail the same way. A content-free call attaches to the single active incident on its talkgroup if that incident has been idle under tg_dispatch_thin_idle_minutes, and at 10 minutes that is long enough for the channel to have moved on to something else. In the 00:30Z dump a "72 at Holland Station" incident absorbed a Grand Central train-crew meet 9.6 minutes later, and a status check absorbed a records lookup at 9.7. Being the only candidate is not evidence. It means the channel was quiet, which is exactly when guessing is weakest -- the single-candidate rule was meant to avoid picking wrongly among several, not to license a match no other signal supports. Every correct thin attach in that dump was <= 3.4 minutes idle and every wrong one was >= 8.2, so 5 separates them with room on both sides. Real back-and-forth is unaffected: it runs through the 30-second tier-1 path, and the observed conversational replies sit near zero. Tests pin both sides of the new boundary at 4.9 and 5.1 minutes so a later change to this number has to be deliberate. 23 pass. Also corrects a DEFERRED.md entry written earlier today. It claimed nothing ever closes an incident that goes quiet; summarizer.py has run a stale sweep at incident_auto_resolve_minutes (90) the whole time. The 37 open incidents were caused by over-creation, not by a missing sweeper, and 90 minutes may be fine now -- worth rechecking on a fully post-fix dump before changing it. No new environment variables: tg_dispatch_thin_idle_minutes is a config.py default and is not templated into any .env, so CI deploys this without an ansible run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
96625fabd0 |
Stop ambient radio chatter from opening incidents, and refill the map
The 23:46Z correlation dump confirmed the severity gate fixed the problem it
was written for -- orphans fell from 69 to 16, and only three of those are
after the deploy boundary, two of them deliberate skips. Nothing on TG 9048
absorbs the channel any more; the largest post-deploy incident is four calls
over nine minutes and is genuinely one event.
It overcorrected. 37 of 50 incidents were open, most a single routine call.
The cause was the gate's own substance test, which counted `units` and
`location`. Radio protocol puts a unit ID in essentially every transmission
and a place name in most of them, so has_substance was true almost always and
the severity check never actually ran -- "11-Victor, 72 at Holland Station"
became its own permanent incident. Substance is now a vehicle, a geocode or a
tag: things the extractor found beyond who was speaking and where they stood.
Severity still opens an incident on its own, so nothing real is lost.
incident_type is now validated against the enum the prompt offers rather than
trusted. It is written straight through to incident.type and rendered as the
title, so a model that answered the severity question in the type field
produced an incident titled "Routine -- TGID 9563". Unrecognised values become
None and fall to the tag/severity path, which is what "unknown" already did.
The map was empty for a separate reason: geocoding accepted only ROOFTOP and
RANGE_INTERPOLATED. Dispatch names places the way people speak, and Google
returns GEOMETRIC_CENTER for exactly those forms -- intersections ("Lake
Street and Veterans Memorial Drive") and named POIs ("Brewster Station").
Requiring a street address discarded nearly every real dispatch location and
left only numbered addresses plotted, which is why the July incidents have
coordinates and none since do. GEOMETRIC_CENTER is now accepted; APPROXIMATE
is still rejected, since a region centroid is what an ungeocodable string
degrades to. Note this is necessary but may not be sufficient -- if
GOOGLE_MAPS_API_KEY is unset on the host the map stays empty regardless, and
that has not been checked from here.
Two things found and deliberately not fixed, both in DEFERRED.md. One call can
still land in two incidents, because upload.py correlates each extracted scene
independently and the model over-split one conversation; multi-scene is
intentional, so that is prompt tuning rather than a code change. And nothing
closes an incident that merely goes quiet -- signal-resolution and master
auto-resolve both exist, but a one-call incident nobody clears stays active
forever. That wanted the over-creation fixed first so a time-based sweeper
would not just paper over it.
Gate tests updated: units and location alone must now orphan, and the case
that matters most is kept explicit -- units with a real severity still open an
incident. 17 pass. No new environment variables, so CI deploys this without an
ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6d5eb4c5f2 |
Let severity, not incident_type, decide what becomes an incident
The 2026-08-16 correlation dump showed two failures that looked unrelated and were the same bug. TG 9048 held one incident of 28 calls spanning 49 minutes -- a prisoner transport, a drone retrieval, a records lookup and a canvass, glued together -- while 32 other calls on that same channel stayed permanently orphaned. Creating an incident required a concrete incident_type. Nothing on a transit police channel produced one: the extraction prompt said to prefer "other" when uncertain, extraction then collapsed "other" to None, and the tag-based fallback had no tags to work with because administrative traffic carries none. So the channel could never open a SECOND incident. Every later call funnelled into whichever incident happened to exist first, and every call too substantial for the thin path had nowhere to go at all. The two symptoms were the same missing value seen from opposite ends. Severity now decides incident-worthiness. It is a better fit for the question being asked -- "is this a real event?" -- than a service label ever was, and unlike incident_type it is always present. The prompt defines four levels with no escape hatch (routine/minor/moderate/major, "unknown" is gone) and calls skipped for a too-short transcript are still recorded as routine, because downstream code reads a missing severity as "not processed yet" rather than "nothing happened". Anything above routine, or carrying any extracted content, opens an incident under the neutral "other" type. "other" is also kept as a real classification now -- rail operations and public works genuinely are not police, fire or EMS. Separately, thin calls no longer refresh updated_at; they write last_thin_at. updated_at drives every recency gate in the fast path, so each "10-4" was resetting the idle clock on whatever it attached to, keeping that incident inside the gate for as long as anyone kept acknowledging. An incident now ages from its last substantive call. This is what made the 49-minute incident possible even once buckets existed, so it is fixed independently rather than being left to the gate change. The re-correlation sweep also now honours skip_reason. /upload has always refused to correlate garbage and too-short transcripts, but the sweep did not apply the same filter, so those fragments came back minutes later through the thin path and attached to whatever was most recent -- a second, quieter route into the same over-merge. Adds tests/test_correlator_gate.py (15 cases), the first tests against incident_correlator.py in its 1,517-line history. tests/conftest.py stubs firebase-admin only when it is genuinely absent, so the container's real SDK is never shadowed; this is what makes the correlator importable in the dev venv. That stub also made test_mqtt_handler and test_node_sweeper collectable for the first time, revealing 10 pre-existing failures in them -- test-vs-code drift, untouched here and catalogued in DEFERRED.md. No new environment variables, so CI deploys this without an ansible run. |
||
|
|
97013e1505 |
Stop Whisper hallucinations and dedupe recordings across nodes
Two independent sources of garbage in the AI pipeline, both visible in the 2026-08-16 correlation dump. 1. Hallucinated transcripts. The Whisper prompt opened with an enumerated run of ten-codes: 10-4, 10-23, 10-20, 10-97 and so on. Whisper treats prompt text as preceding transcript, so on noisy or silent audio it continued the series, emitting transcripts that count upward from 10-4 to 10-99. The existing no_speech_prob filter could not catch these: the model is highly confident in text it invented by continuing a pattern. The prompt no longer contains a series to extend, and _is_degenerate() rejects the three shapes this failure takes: ascending ten-code runs, one phrase looping, and near-identical segments across a whole recording. Verified against 13 transcripts from production: all four known hallucinations rejected, all nine real ones kept, including terse traffic containing legitimate codes. 2. Duplicate recordings. node-002 and node-PI-2 both cover TG 9048 and both uploaded the same transmissions, ~1.1s apart. Nine pairs appeared in one dump. Each was transcribed, billed and correlated twice, and the resulting incident listed two units where there was one. Canonical selection is by earliest started_at, tie-broken on call_id, NOT by upload order: upload order varies with encode time and network latency, so it would make the authoritative recording non-deterministic. Call documents are created from MQTT call_start before uploads arrive, so both nodes independently reach the same verdict. The loser keeps its audio (it may be the cleaner capture) but is excluded from STT, correlation, the re-correlation sweep and the orphan debug view. Also fixes _sync_transcribe returning a bare None when OPENAI_API_KEY is missing, where the caller unpacks two values. A missing key surfaced as a misleading "Transcription failed" instead of the real warning. Adds tests/test_dedup.py (15 cases). dedup.py reaches Firestore through an injected callable so it stays importable without firebase-admin present. |
||
|
|
2f0597c81b |
Initial commit — DRB server stack
Includes c2-core (FastAPI/MQTT/Firestore), discord-bot (slash commands), frontend (Next.js admin UI), and mosquitto config. |