Author SHA1 Message Date
Logan CusanoandClaude Sonnet 5 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>
2026-09-07 00:12:56 -04:00
Logan CusanoandClaude Sonnet 5 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>
2026-09-07 00:06:37 -04:00
logan b430cf32f2 Merge pull request 'frontend: install command uses the node id from the mint form (node-26#4)' (#107) from feat/mint-panel-nodeid into main
Build & Deploy / Build & push images (push) Successful in 5m51s
Build & Deploy / Deploy to VM (push) Successful in 1m42s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #107
2026-09-06 20:15:10 -04:00
Logan CusanoandClaude Sonnet 5 93fa3a6054 frontend: install command uses the node id from the mint form (node-26#4)
The mint panel's copy command hard-coded --node-id node-XXX. Now the label
just entered (the operator types the node id there — placeholder relabeled
"Node ID, e.g. node-003") is captured on mint and interpolated into the
command: spaces → dashes, non [A-Za-z0-9_-] stripped (install.sh's rule),
falling back to node-XXX only if that yields nothing. The "edit node-XXX"
hint now only shows in the fallback case.

Not typechecked (no node/npm here); one useState<string|null>, one derived
string, a JSX conditional. `next build` in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:13:23 -04:00
logan de03f5bcaf Merge pull request 'frontend: mint panel shows the full one-shot install command (node-26#4)' (#106) from feat/mint-panel-install-command into main
Build & Deploy / Build & push images (push) Successful in 5m6s
Build & Deploy / Deploy to VM (push) Successful in 1m40s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #106
2026-09-06 19:31:34 -04:00
Logan CusanoandClaude Sonnet 5 0651bfe07a frontend: mint panel shows the full one-shot install command (node-26#4)
After a node enrollment token is minted, the panel now renders the
paste-ready `curl -fsSL .../install.sh | sudo bash -s -- --token <minted>
--node-id node-XXX --c2-url <derived> --mqtt-broker <derived>` line with a
Copy button, alongside the bare token (also kept, also now copyable).

- c2-url from NEXT_PUBLIC_C2_URL (same var lib/c2api.ts reads), fallback
  https://api.example.net
- mqtt-broker derived as mqtt.<api-host minus leading api.> — a DNS
  assumption; the panel text tells the operator to check it
- node id is a node-XXX placeholder; the panel collects none

Pairs with node-26's install.sh (feat/one-shot-install). The raw/tag/v1/
URL resolves once v1 is re-cut at that PR's merge.

NOT typechecked here (no node/npm in this environment); plain React, two
useState booleans + one computed string, reviewed by eye. `next build` in
the deploy workflow will catch a real type error before it ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 19:15:55 -04:00
logan c4656a9607 Merge pull request 'correlator: judge each scene on its own embedding + severity (#80, #95)' (#105) from fix/scene-context-leak-80-95 into main
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy to VM (push) Successful in 2m8s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #105
2026-09-06 17:49:23 -04:00
Logan CusanoandClaude Sonnet 5 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>
2026-09-06 13:01:59 -04:00
8 changed files with 299 additions and 10 deletions
@@ -668,10 +668,18 @@ async def correlate_call(
vehicles: Optional[list[str]] = None, vehicles: Optional[list[str]] = None,
cleared_units: Optional[list[str]] = None, cleared_units: Optional[list[str]] = None,
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None,
severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Link call_id to an existing incident or create a new one. Link call_id to an existing incident or create a new one.
Thin wrapper: builds context → runs rules decision → commits. Thin wrapper: builds context → runs rules decision → commits.
``embedding`` and ``severity`` are the SCENE's own values (server-26#80/#95).
Callers that re-correlate a whole call rather than a scene — the
recorrelation sweep — pass the call doc's stored values explicitly; they are
no longer read from the doc inside _build_context.
""" """
ctx = await _build_context( ctx = await _build_context(
call_id=call_id, units=units, vehicles=vehicles, cleared_units=cleared_units, call_id=call_id, units=units, vehicles=vehicles, cleared_units=cleared_units,
@@ -679,6 +687,7 @@ async def correlate_call(
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name, system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
reassignment=reassignment, create_if_new=create_if_new, reassignment=reassignment, create_if_new=create_if_new,
embedding=embedding, severity=severity, transcript=transcript,
) )
decision = _run_decision(ctx) decision = _run_decision(ctx)
return await _apply_and_log(decision, ctx) return await _apply_and_log(decision, ctx)
@@ -700,6 +709,9 @@ async def preview_correlation(
vehicles: Optional[list[str]] = None, vehicles: Optional[list[str]] = None,
cleared_units: Optional[list[str]] = None, cleared_units: Optional[list[str]] = None,
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None,
severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> dict: ) -> dict:
""" """
Run the rules engine and return the decision WITHOUT committing to Firestore. Run the rules engine and return the decision WITHOUT committing to Firestore.
@@ -720,6 +732,7 @@ async def preview_correlation(
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name, system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
reassignment=reassignment, create_if_new=create_if_new, reassignment=reassignment, create_if_new=create_if_new,
embedding=embedding, severity=severity, transcript=transcript,
) )
decision = _run_decision(ctx) decision = _run_decision(ctx)
return {"decision": decision, "ctx": ctx} return {"decision": decision, "ctx": ctx}
@@ -752,6 +765,9 @@ async def _build_context(
location: Optional[str], location: Optional[str],
reassignment: bool, reassignment: bool,
create_if_new: bool, create_if_new: bool,
embedding: Optional[list] = None,
severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> dict: ) -> dict:
now = reference_time or datetime.now(timezone.utc) now = reference_time or datetime.now(timezone.utc)
window = timedelta(hours=settings.correlation_window_hours) window = timedelta(hours=settings.correlation_window_hours)
@@ -777,11 +793,27 @@ async def _build_context(
all_active = _drop_capped(all_active, now) all_active = _drop_capped(all_active, now)
recent = [inc for inc in all_active if _within_window_of(inc, now, window)] recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
call_embedding = call_doc.get("embedding") # embedding and severity come from the SCENE being correlated, not the call
# doc — server-26#80 / #95. intelligence.py writes only the primary scene's
# embedding and severity to calls/{id}, so reading them back here handed
# every non-primary scene the primary scene's semantic vector and severity
# rung: a scene about a different event scored against the wrong incident on
# the embedding path (:1166/:1205/:1533) and could inherit a minor/moderate/
# major severity it never had, clearing the creation gate on borrowed
# weight. Same failure and same fix as the #87 coords leak directly below —
# a scene that passes none has none, and is judged thin on its own signal.
call_embedding = embedding
call_units = units if units is not None else (call_doc.get("units") or []) call_units = units if units is not None else (call_doc.get("units") or [])
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or []) call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or []) call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
call_severity = call_doc.get("severity") or "routine" call_severity = severity or "routine"
# The transcript the LLM correlation tier reasons over. Prefer the SCENE's
# own words (server-26#102) — passed by upload.py's scene loop — and fall
# back to the call doc only when no scene text was supplied (the
# recorrelation sweep, and single-scene calls where the two are identical).
# Without this, every non-primary scene of a multi-scene call was judged by
# the LLM against a transcript containing the OTHER scenes.
scene_transcript = transcript or call_doc.get("transcript_corrected") or call_doc.get("transcript")
# A string that is not a place is not a location anywhere downstream — not # A string that is not a place is not a location anywhere downstream — not
# in the fit tests, not in the thin-call test, not in the LLM prompt, and # in the fit tests, not in the thin-call test, not in the LLM prompt, and
# not on the incident. Its coordinates go with it: coords are geocoded # not on the incident. Its coordinates go with it: coords are geocoded
@@ -804,6 +836,7 @@ async def _build_context(
return { return {
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent, "call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
"call_doc": call_doc, "call_embedding": call_embedding, "call_doc": call_doc, "call_embedding": call_embedding,
"scene_transcript": scene_transcript,
"call_units": call_units, "call_vehicles": call_vehicles, "call_units": call_units, "call_vehicles": call_vehicles,
"call_cleared": call_cleared, "call_severity": call_severity, "call_cleared": call_cleared, "call_severity": call_severity,
"coords": coords, "is_thin_call": is_thin_call, "now": now, "coords": coords, "is_thin_call": is_thin_call, "now": now,
+55 -3
View File
@@ -24,7 +24,16 @@ from app.internal.incident_correlator import clean_location, location_is_unit
_PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio. _PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio.
SCENE DETECTION: SCENE DETECTION:
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Detect whether this recording contains ONE scene (all transmissions relate to a single event) or MULTIPLE scenes (clearly distinct dispatch conversations with different units being assigned, different locations, different event types). Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list. A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Your default is ONE scene. Return MULTIPLE scenes ONLY when the recording clearly contains two or more SEPARATE EVENTS — different incidents at different places, with no shared units, no shared subject, and no conversational thread connecting them.
These do NOT make a new scene — keep them in the same scene:
- a different unit or speaker joining the same event
- a follow-up transmission about the same job (records check, case number, tow/mileage, a unit clearing, an ETA, a location correction)
- the same subject or location being discussed again minutes later
- an administrative or status exchange that follows an event on the same channel
If you are unsure whether two exchanges are one event or two, treat them as ONE.
Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
Always respond with the scenes array, even for a single scene. Always respond with the scenes array, even for a single scene.
@@ -163,7 +172,7 @@ async def extract_scenes(
Each scene dict contains: Each scene dict contains:
tags, incident_type, location, location_coords, resolved, tags, incident_type, location, location_coords, resolved,
severity, vehicles, units, transcript_corrected, severity, vehicles, units, transcript, transcript_corrected,
segment_indices, embedding segment_indices, embedding
Side-effect: updates calls/{call_id} in Firestore with merged tags, Side-effect: updates calls/{call_id} in Firestore with merged tags,
@@ -328,6 +337,10 @@ async def extract_scenes(
) )
embedding = await asyncio.to_thread(_sync_embed, scene_text) embedding = await asyncio.to_thread(_sync_embed, scene_text)
scene_transcript = _scene_transcript_text(
transcript, segments, segment_indices, transcript_corrected
)
processed.append({ processed.append({
"tags": tags, "tags": tags,
"incident_type": incident_type, "incident_type": incident_type,
@@ -339,6 +352,7 @@ async def extract_scenes(
"severity": severity, "severity": severity,
"resolved": resolved, "resolved": resolved,
"reassignment": reassignment, "reassignment": reassignment,
"transcript": scene_transcript,
"transcript_corrected": transcript_corrected, "transcript_corrected": transcript_corrected,
"segment_indices": segment_indices, "segment_indices": segment_indices,
"embedding": embedding, "embedding": embedding,
@@ -562,11 +576,49 @@ def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str: def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
"""Format transcript as numbered transmissions if segments are available.""" """Format transcript as numbered transmissions if segments are available."""
if segments and len(segments) > 1: if segments and len(segments) > 1:
lines = [f"{i+1}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)] # 0-based labels, matching the prompt's "0-based indices into the
# numbered transmissions" — the model echoes these back as
# `segment_indices`, which _build_scene_embed_text and the per-scene
# `transcript` (server-26#102) then slice with directly.
lines = [f"{i}. [{s['start']}s] {s['text']}" for i, s in enumerate(segments)]
return f"Transmissions ({len(segments)}):\n" + "\n".join(lines) return f"Transmissions ({len(segments)}):\n" + "\n".join(lines)
return f"Transcript:\n{transcript}" return f"Transcript:\n{transcript}"
def _scene_transcript_text(
transcript: str,
segments: Optional[list[dict]],
segment_indices: Optional[list[int]],
transcript_corrected: Optional[str],
) -> str:
"""
This scene's own words, unprefixed — the segments it owns, joined.
server-26#102: the correlator's LLM tier reads this per scene instead of
the call doc's whole-call transcript, so on a multi-scene call scene N is
no longer judged against scenes 1..N-1's text.
Never returns "". Anything that would leave the slice empty — no
`segment_indices` (a single-segment call is never numbered by
`_build_transcript_block`), or indices that are out of range / not ints —
falls back to the whole-call transcript, which for a single-scene call is
the same text and for a mis-sliced multi-scene call is at least this
call's own words. `_sync_extract`'s prompt documents 0-based indices and
`_build_transcript_block` numbers to match, so no base normalisation here.
"""
if transcript_corrected:
return transcript_corrected
if segments and segment_indices:
joined = " ".join(
segments[i]["text"]
for i in segment_indices
if isinstance(i, int) and 0 <= i < len(segments)
)
if joined:
return joined
return transcript
def _build_scene_embed_text( def _build_scene_embed_text(
transcript: str, transcript: str,
segments: Optional[list[dict]], segments: Optional[list[dict]],
+7 -1
View File
@@ -61,7 +61,13 @@ def _inc_summary(inc: dict, now: datetime) -> str:
def _call_block(ctx: dict) -> str: def _call_block(ctx: dict) -> str:
lines = [] lines = []
call_doc = ctx["call_doc"] call_doc = ctx["call_doc"]
transcript = call_doc.get("transcript_corrected") or call_doc.get("transcript") # The SCENE's own transcript, resolved in _build_context (server-26#102).
# Falls back to the call doc for a ctx built without a scene (tests, sweep).
transcript = (
ctx.get("scene_transcript")
or call_doc.get("transcript_corrected")
or call_doc.get("transcript")
)
if transcript: if transcript:
lines.append(f"Transcript: {transcript[:700]}") lines.append(f"Transcript: {transcript[:700]}")
if ctx["tags"]: if ctx["tags"]:
@@ -90,6 +90,11 @@ async def _recorrelate_orphan(call: dict) -> bool:
return False return False
# All data needed for correlation was stored by the first-pass extraction. # All data needed for correlation was stored by the first-pass extraction.
# embedding/severity are no longer read from the call doc inside
# _build_context (server-26#80/#95) — the sweep re-links a whole call, not a
# scene, so it passes the call doc's stored (primary-scene) values here. It
# is link-only (create_if_new=False), so a borrowed severity cannot open a
# new incident off this path.
incident_id = await incident_correlator.correlate_call( incident_id = await incident_correlator.correlate_call(
call_id = call_id, call_id = call_id,
node_id = call.get("node_id", ""), node_id = call.get("node_id", ""),
@@ -101,6 +106,9 @@ async def _recorrelate_orphan(call: dict) -> bool:
location = call.get("location"), location = call.get("location"),
location_coords= call.get("location_coords"), location_coords= call.get("location_coords"),
cleared_units = call.get("cleared_units") or [], cleared_units = call.get("cleared_units") or [],
embedding = call.get("embedding"),
severity = call.get("severity"),
transcript = call.get("transcript_corrected") or call.get("transcript"),
reference_time = started_at, # anchor window to when the call happened reference_time = started_at, # anchor window to when the call happened
create_if_new = False, # never create — link-only create_if_new = False, # never create — link-only
) )
+10
View File
@@ -114,6 +114,9 @@ async def _correlate_with_consensus(
vehicles: Optional[list] = None, vehicles: Optional[list] = None,
cleared_units: Optional[list] = None, cleared_units: Optional[list] = None,
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None,
severity: Optional[str] = None,
transcript: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Consensus correlator: runs the rules engine and the cheap LLM in sequence. Consensus correlator: runs the rules engine and the cheap LLM in sequence.
@@ -131,6 +134,7 @@ async def _correlate_with_consensus(
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
location_coords=location_coords, units=units, vehicles=vehicles, location_coords=location_coords, units=units, vehicles=vehicles,
cleared_units=cleared_units, reassignment=reassignment, cleared_units=cleared_units, reassignment=reassignment,
embedding=embedding, severity=severity, transcript=transcript,
) )
ctx = preview["ctx"] ctx = preview["ctx"]
rules_decision = preview["decision"] rules_decision = preview["decision"]
@@ -221,6 +225,9 @@ async def _run_extraction_pipeline(
vehicles=scene.get("vehicles"), vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"), cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment, reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
transcript=scene.get("transcript"),
) )
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
@@ -336,6 +343,9 @@ async def _run_intelligence_pipeline(
vehicles=scene.get("vehicles"), vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"), cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment, reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
transcript=scene.get("transcript"),
) )
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
@@ -254,6 +254,89 @@ async def test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin():
assert ctx["is_thin_call"] is True assert ctx["is_thin_call"] is True
@pytest.mark.asyncio
async def test_a_scene_does_not_inherit_the_call_docs_embedding_or_severity():
"""
server-26#80 / #95. Same shape as the #87 coords leak above:
intelligence.py writes only the PRIMARY scene's embedding and severity to
calls/{id}. A non-primary scene being correlated must be judged on its own
embedding (or none) and its own severity — not the call doc's — or a scene
about a different event scores against the wrong incident on the embedding
path and can inherit a minor/moderate/major rung it never had, clearing the
creation gate on borrowed weight.
"""
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(
return_value={"embedding": [0.1] * 1536, "severity": "major"}
)
mock_fstore.collection_list = AsyncMock(return_value=[])
ctx = await _build_context(
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
embedding=None, severity=None,
)
assert ctx["call_embedding"] is None
assert ctx["call_severity"] == "routine"
assert ctx["is_thin_call"] is True
@pytest.mark.asyncio
async def test_a_scene_is_judged_on_its_own_embedding_and_severity():
"""The other half of #80/#95: the scene's own values are what land in ctx."""
scene_vec = [0.9] * 1536
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(
return_value={"embedding": [0.1] * 1536, "severity": "routine"}
)
mock_fstore.collection_list = AsyncMock(return_value=[])
ctx = await _build_context(
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
embedding=scene_vec, severity="major",
)
assert ctx["call_embedding"] == scene_vec
assert ctx["call_severity"] == "major"
@pytest.mark.asyncio
async def test_the_llm_tier_reads_the_scene_transcript_not_the_whole_call():
"""
server-26#102. intelligence.py writes only the primary scene's corrected
text to calls/{id}. _call_block (the LLM correlation prompt) must reason
over the SCENE being correlated, not a whole-call transcript that also
contains the other scenes. _build_context threads the scene's text in;
with no scene text it falls back to the call doc (sweep / single-scene).
"""
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(return_value={
"transcript": "scene one about a fire. scene two about a traffic stop.",
})
mock_fstore.collection_list = AsyncMock(return_value=[])
scene = await _build_context(
call_id="call-1", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
transcript="scene two about a traffic stop.",
)
fallback = await _build_context(
call_id="call-1", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
)
assert scene["scene_transcript"] == "scene two about a traffic stop."
assert fallback["scene_transcript"] == "scene one about a fire. scene two about a traffic stop."
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_bare_number_never_becomes_an_incident_location_or_title(): async def test_a_bare_number_never_becomes_an_incident_location_or_title():
inc = await _create(tags=["flames"], location="49", coords=None, inc = await _create(tags=["flames"], location="49", coords=None,
@@ -0,0 +1,40 @@
"""
server-26#102 — a scene is correlated on its OWN transcript, not the whole call.
_scene_transcript_text slices the segments a scene owns. It must never return
"" (an empty slice would let incident_correlator._build_context fall back to
the call doc's whole-call transcript, re-opening the leak in exactly the case
— bad indices — where it matters).
"""
from app.internal.intelligence import _scene_transcript_text
SEGS = [
{"text": "structure fire, 12 Main"},
{"text": "engine 4 responding"},
{"text": "traffic stop, plate ABC"},
{"text": "one occupant"},
]
WHOLE = "structure fire, 12 Main engine 4 responding traffic stop, plate ABC one occupant"
def test_scene_owns_a_subset_of_segments():
assert _scene_transcript_text(WHOLE, SEGS, [0, 1], None) == "structure fire, 12 Main engine 4 responding"
assert _scene_transcript_text(WHOLE, SEGS, [2, 3], None) == "traffic stop, plate ABC one occupant"
def test_corrected_text_wins_when_present():
assert _scene_transcript_text(WHOLE, SEGS, [0], "cleaned up text") == "cleaned up text"
def test_no_segment_indices_falls_back_to_whole_call():
# single-segment calls are never numbered by _build_transcript_block → null indices
assert _scene_transcript_text(WHOLE, SEGS, None, None) == WHOLE
assert _scene_transcript_text(WHOLE, None, [0, 1], None) == WHOLE
def test_out_of_range_or_nonint_indices_fall_back_never_empty():
assert _scene_transcript_text(WHOLE, SEGS, [9, 10], None) == WHOLE # all out of range
assert _scene_transcript_text(WHOLE, SEGS, ["1", "2"], None) == WHOLE # 1-based strings, rejected
assert _scene_transcript_text(WHOLE, SEGS, [-1], None) == WHOLE # negative
# partial validity: keep what's in range
assert _scene_transcript_text(WHOLE, SEGS, [3, 99], None) == "one occupant"
+61 -4
View File
@@ -39,6 +39,30 @@ function EnrollmentTokensPanel() {
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [minting, setMinting] = useState(false); const [minting, setMinting] = useState(false);
const [justMinted, setJustMinted] = useState<string | null>(null); const [justMinted, setJustMinted] = useState<string | null>(null);
const [cmdCopied, setCmdCopied] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
// The label the operator typed for the token that was just minted — used as
// the node id in the install command below. Captured on mint because `label`
// itself is cleared afterward.
const [mintedLabel, setMintedLabel] = useState<string | null>(null);
// The paste-ready one-shot install command for a fresh Pi. The node id comes
// from the label just entered (spaces → dashes; install.sh requires
// [A-Za-z0-9_-]); if that yields nothing it falls back to a node-XXX
// placeholder. The MQTT broker host is the documented mqtt.<domain> sibling
// of the api host (install.sh header) — a DNS assumption the operator checks.
const c2Url = (process.env.NEXT_PUBLIC_C2_URL ?? "https://api.example.net").replace(/\/$/, "");
const mqttBroker = (() => {
try { return `mqtt.${new URL(c2Url).hostname.replace(/^api\./, "")}`; }
catch { return "mqtt.example.net"; }
})();
const nodeIdForCmd =
(mintedLabel ?? "").trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_-]/g, "") || "node-XXX";
const installCmd = justMinted
? `curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \\
| sudo bash -s -- --token ${justMinted} --node-id ${nodeIdForCmd} \\
--c2-url ${c2Url} --mqtt-broker ${mqttBroker}`
: "";
const load = useCallback(() => { const load = useCallback(() => {
c2api.listEnrollmentTokens() c2api.listEnrollmentTokens()
@@ -57,6 +81,7 @@ function EnrollmentTokensPanel() {
try { try {
const result = await c2api.mintEnrollmentToken(label.trim()); const result = await c2api.mintEnrollmentToken(label.trim());
setJustMinted(result.token); setJustMinted(result.token);
setMintedLabel(label.trim());
setLabel(""); setLabel("");
load(); load();
} catch (err) { } catch (err) {
@@ -87,11 +112,43 @@ function EnrollmentTokensPanel() {
<p className="text-xs text-indigo-200 font-mono mb-1"> <p className="text-xs text-indigo-200 font-mono mb-1">
New token — copy it now, it won&apos;t be shown again: New token — copy it now, it won&apos;t be shown again:
</p> </p>
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
<div className="flex items-start gap-2">
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
<button <button
type="button" type="button"
onClick={() => setJustMinted(null)} onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors" setTokenCopied(true); setTimeout(() => setTokenCopied(false), 2000);
})}
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
>
{tokenCopied ? "Copied" : "Copy"}
</button>
</div>
<p className="text-xs text-indigo-200 font-mono mt-3 mb-1">
…or run this on a fresh Pi{" "}
{nodeIdForCmd === "node-XXX"
? <>(edit <span className="text-indigo-100">node-XXX</span> and check the broker host)</>
: <>(check the broker host)</>}:
</p>
<div className="flex items-start gap-2">
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
<button
type="button"
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
setCmdCopied(true); setTimeout(() => setCmdCopied(false), 2000);
})}
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
>
{cmdCopied ? "Copied" : "Copy"}
</button>
</div>
<button
type="button"
onClick={() => { setJustMinted(null); setMintedLabel(null); }}
className="text-xs text-indigo-300 hover:text-indigo-200 mt-3 transition-colors"
> >
Dismiss Dismiss
</button> </button>
@@ -105,7 +162,7 @@ function EnrollmentTokensPanel() {
<input <input
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="Label, e.g. 'node-003 field kit'" placeholder="Node ID, e.g. node-003"
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<Button type="submit" size="sm" disabled={minting || !label.trim()}> <Button type="submit" size="sm" disabled={minting || !label.trim()}>