Every scene of a multi-scene call correlates independently in upload.py's scene loop, but every scene's corr_debug was written flat onto the same shared call doc — scene 2's write silently clobbered scene 1's corr_path/corr_consensus/etc (#96), and summarizer.py read the whole call's raw transcript per linked call, mixing text from scenes the incident had nothing to do with, while ignoring transcript_corrected entirely (#114). Fix: thread a scene_index from both `for scene in scenes:` loops in upload.py down through _correlate_with_consensus -> incident_correlator.preview_correlation/correlate_call -> _build_context -> ctx["scene_index"]. incident_correlator._apply_and_log now writes, in the same Firestore call: - the existing flat corr_* fields, unchanged (last-scene-wins, the safe backward-compatible default for any reader that doesn't know about `scenes` yet) - a new nested `scenes.<scene_index>` entry with {transcript, incident_id, corr_debug}, via doc_set(..., merge=True). Firestore's DocumentReference.set(data, merge=True) recursively merges nested map fields by key (documented SDK behaviour, not assumed) — a write to scenes.1 merges alongside an existing scenes.0 instead of replacing the whole `scenes` map. scene_index defaults to 0 for every caller with no scene concept (the recorrelation sweep, the no-scenes-extracted orphan-check path), so a plain single-scene call still gets a one-entry `scenes` map equivalent to reading its flat fields today. admin.py's _call_summary exposes the new `scenes` list per call (each entry carrying the same corr_* field names as the flat fields, so the two shapes are interchangeable to the tally); the summary tally now iterates each call's scenes-if-present, else its own flat fields, so a 2-scene call with two different corr_path values counts as two data points instead of one blend. New `scene_decision_count` sits next to `linked_call_count` to make that distinction visible. summarizer.py's _scene_text_for_incident reads a linked call's `scenes` map to find the scene(s) whose corr_debug recorded a link into the specific incident being summarized, joining more than one if several scenes landed in the same incident. Falls back to transcript_corrected-or-transcript for a call doc with no `scenes` field (predates this change) — the one-liner half of #114, worth doing regardless since it stops raw-transcript summaries even for old-schema docs. Does not touch #80/#95/#102's existing ctx-threading fixes (embedding/severity/coords/LLM-prompt-transcript) — correct as-is, out of scope here. Tests: 14 new (test_per_scene_call_doc.py, test_summarizer_scene_transcript.py, additions to test_admin_debug_correlation.py) covering the merge shape, last-scene-wins flat-field backward compat, the admin tally's per-scene vs per-call counting (including old-schema fallback), and the summarizer's scene-specific text selection (including old-schema fallback). Full sandboxed suite: 364 -> 378 passed, all green. Fixes server-26#96, server-26#114 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b7701b6d49
commit
fae84a45c3
@@ -89,3 +89,75 @@ async def test_debug_correlation_llm_fields_absent_when_rules_only():
|
||||
assert detail["corr_consensus"] == "rules_only"
|
||||
assert detail["corr_llm_reasoning"] is None
|
||||
assert detail["corr_llm_action"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# server-26#96 — the summary tally must count per-scene decisions, not the
|
||||
# one blended flat record a multi-scene call used to leave behind.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_correlation_exposes_scenes_and_tallies_each_as_its_own_datapoint():
|
||||
"""A 2-scene call: the flat fields still show last-scene-wins (unchanged
|
||||
behaviour for old readers), but the summary tally must see two distinct
|
||||
corr_path/corr_consensus data points, not one blend."""
|
||||
call = {
|
||||
"call_id": "call-1",
|
||||
# Flat fields — last scene wins, kept as-is for backward compat.
|
||||
"corr_path": "slow",
|
||||
"corr_consensus": "tiebreak",
|
||||
"scenes": {
|
||||
"0": {
|
||||
"transcript": "scene zero",
|
||||
"incident_id": "inc-1",
|
||||
"corr_debug": {"corr_path": "new", "corr_consensus": "agreed"},
|
||||
},
|
||||
"1": {
|
||||
"transcript": "scene one",
|
||||
"incident_id": "inc-1",
|
||||
"corr_debug": {"corr_path": "slow", "corr_consensus": "tiebreak"},
|
||||
},
|
||||
},
|
||||
}
|
||||
result = await _run([_incident(["call-1"])], {"call-1": call})
|
||||
|
||||
detail = result["incidents"][0]["calls_detail"][0]
|
||||
assert detail["corr_path"] == "slow" # flat field: last scene wins
|
||||
assert len(detail["scenes"]) == 2
|
||||
assert detail["scenes"][0]["corr_path"] == "new"
|
||||
assert detail["scenes"][1]["corr_path"] == "slow"
|
||||
|
||||
summary = result["summary"]
|
||||
assert summary["linked_call_count"] == 1 # still one CALL
|
||||
assert summary["scene_decision_count"] == 2 # but two DECISIONS
|
||||
assert summary["corr_path"] == {"new": 1, "slow": 1}
|
||||
assert summary["corr_consensus"] == {"agreed": 1, "tiebreak": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_correlation_tally_falls_back_for_single_scene_call():
|
||||
"""A plain single-scene call has no `scenes` field at all — the tally
|
||||
must fall back to its flat fields as one data point, same as pre-#96."""
|
||||
call = {"call_id": "call-2", "corr_path": "fast/single", "corr_consensus": "rules_only"}
|
||||
result = await _run([_incident(["call-2"])], {"call-2": call})
|
||||
|
||||
detail = result["incidents"][0]["calls_detail"][0]
|
||||
assert detail["scenes"] is None
|
||||
|
||||
summary = result["summary"]
|
||||
assert summary["linked_call_count"] == 1
|
||||
assert summary["scene_decision_count"] == 1
|
||||
assert summary["corr_path"] == {"fast/single": 1}
|
||||
assert summary["corr_consensus"] == {"rules_only": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_correlation_tally_handles_old_schema_call_with_no_scenes_field():
|
||||
"""A call doc written before server-26#96 has never heard of `scenes` —
|
||||
must behave identically to the single-scene case, not error."""
|
||||
old_call = {"call_id": "call-3", "corr_path": "cross-tg", "corr_consensus": "agreed"}
|
||||
result = await _run([_incident(["call-3"])], {"call-3": old_call})
|
||||
|
||||
summary = result["summary"]
|
||||
assert summary["scene_decision_count"] == 1
|
||||
assert summary["corr_path"] == {"cross-tg": 1}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
server-26#96 — every scene of a multi-scene call writes corr_debug onto the
|
||||
SAME call doc via incident_correlator._apply_and_log, last-scene-wins. The
|
||||
fix additionally nests each scene's corr_debug/transcript/incident_id under
|
||||
scenes.<scene_index> on the call doc, keyed so Firestore's
|
||||
`set(merge=True)` (a recursive merge of nested map fields — this is the
|
||||
behaviour these tests assume and pin) lands each scene in its own map entry
|
||||
instead of colliding.
|
||||
|
||||
Firestore itself isn't available in this sandbox (see tests/conftest.py), so
|
||||
`_fake_doc_set` below implements that documented recursive-merge semantics by
|
||||
hand and is used as the fstore stand-in — these tests both exercise
|
||||
_apply_and_log's write shape AND pin the merge behaviour it depends on.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.internal import incident_correlator
|
||||
|
||||
|
||||
def _merge(dst: dict, src: dict) -> None:
|
||||
"""Firestore DocumentReference.set(data, merge=True) semantics: nested
|
||||
map fields are merged recursively by key, not replaced wholesale."""
|
||||
for k, v in src.items():
|
||||
if isinstance(v, dict) and isinstance(dst.get(k), dict):
|
||||
_merge(dst[k], v)
|
||||
else:
|
||||
dst[k] = v
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiscene_call_lands_each_scene_distinctly_and_flat_fields_last_write_wins():
|
||||
docs: dict[tuple, dict] = {}
|
||||
|
||||
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||
docs.setdefault((collection, doc_id), {})
|
||||
_merge(docs[(collection, doc_id)], data)
|
||||
|
||||
decision0 = {
|
||||
"action": "orphan", "matched_incident": None, "incident_type": None,
|
||||
"corr_debug": {"corr_path": "new", "corr_consensus": "agreed"},
|
||||
}
|
||||
ctx0 = {"call_id": "call-1", "scene_index": 0, "scene_transcript": "scene zero text"}
|
||||
|
||||
decision1 = {
|
||||
"action": "orphan", "matched_incident": None, "incident_type": None,
|
||||
"corr_debug": {"corr_path": "slow", "corr_consensus": "tiebreak"},
|
||||
}
|
||||
ctx1 = {"call_id": "call-1", "scene_index": 1, "scene_transcript": "scene one text"}
|
||||
|
||||
with patch.object(incident_correlator, "fstore") as mock_fstore:
|
||||
mock_fstore.doc_set = fake_doc_set
|
||||
await incident_correlator._apply_and_log(decision0, ctx0)
|
||||
await incident_correlator._apply_and_log(decision1, ctx1)
|
||||
|
||||
doc = docs[("calls", "call-1")]
|
||||
|
||||
# Flat top-level fields: unchanged behaviour, last scene's write wins —
|
||||
# the safe backward-compatible default for any reader that doesn't yet
|
||||
# know about `scenes`.
|
||||
assert doc["corr_path"] == "slow"
|
||||
assert doc["corr_consensus"] == "tiebreak"
|
||||
|
||||
# New `scenes` map: both scenes present, distinct, uncorrupted by the
|
||||
# second write.
|
||||
assert set(doc["scenes"].keys()) == {"0", "1"}
|
||||
assert doc["scenes"]["0"]["corr_debug"]["corr_path"] == "new"
|
||||
assert doc["scenes"]["0"]["corr_debug"]["corr_consensus"] == "agreed"
|
||||
assert doc["scenes"]["0"]["transcript"] == "scene zero text"
|
||||
assert doc["scenes"]["1"]["corr_debug"]["corr_path"] == "slow"
|
||||
assert doc["scenes"]["1"]["corr_debug"]["corr_consensus"] == "tiebreak"
|
||||
assert doc["scenes"]["1"]["transcript"] == "scene one text"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scene_entry_records_which_incident_it_resolved_to():
|
||||
"""summarizer.py (#114) needs this to pick the right scene per incident."""
|
||||
docs: dict[tuple, dict] = {}
|
||||
|
||||
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||
docs.setdefault((collection, doc_id), {})
|
||||
_merge(docs[(collection, doc_id)], data)
|
||||
|
||||
with patch.object(incident_correlator, "fstore") as mock_fstore, \
|
||||
patch.object(incident_correlator, "_apply_decision", return_value="inc-42"):
|
||||
mock_fstore.doc_set = fake_doc_set
|
||||
decision = {
|
||||
"action": "new", "matched_incident": None, "incident_type": "fire",
|
||||
"corr_debug": {"corr_path": "new"},
|
||||
}
|
||||
ctx = {"call_id": "call-2", "scene_index": 0, "scene_transcript": "structure fire"}
|
||||
incident_id = await incident_correlator._apply_and_log(decision, ctx)
|
||||
|
||||
assert incident_id == "inc-42"
|
||||
assert docs[("calls", "call-2")]["scenes"]["0"]["incident_id"] == "inc-42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_scene_call_still_gets_a_scenes_map_equivalent_to_flat_fields():
|
||||
"""scene_index defaults to 0 for every caller with no scene concept, so a
|
||||
plain single-scene call is one entry in `scenes` — equivalent to reading
|
||||
the flat fields, not a behaviour change for that population."""
|
||||
docs: dict[tuple, dict] = {}
|
||||
|
||||
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||
docs.setdefault((collection, doc_id), {})
|
||||
_merge(docs[(collection, doc_id)], data)
|
||||
|
||||
decision = {
|
||||
"action": "orphan", "matched_incident": None, "incident_type": None,
|
||||
"corr_debug": {"corr_path": "fast/thin", "corr_consensus": "rules_only"},
|
||||
}
|
||||
ctx = {"call_id": "call-3", "scene_transcript": "10-4"} # no scene_index key at all
|
||||
|
||||
with patch.object(incident_correlator, "fstore") as mock_fstore:
|
||||
mock_fstore.doc_set = fake_doc_set
|
||||
await incident_correlator._apply_and_log(decision, ctx)
|
||||
|
||||
doc = docs[("calls", "call-3")]
|
||||
assert doc["corr_path"] == "fast/thin"
|
||||
assert doc["scenes"] == {
|
||||
"0": {
|
||||
"transcript": "10-4",
|
||||
"incident_id": None,
|
||||
"corr_debug": {"corr_path": "fast/thin", "corr_consensus": "rules_only"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_corr_debug_writes_nothing_same_as_before():
|
||||
"""Preserve the pre-#96 short-circuit: no corr_debug means no write at
|
||||
all, flat or nested."""
|
||||
with patch.object(incident_correlator, "fstore") as mock_fstore, \
|
||||
patch.object(incident_correlator, "_apply_decision", return_value=None):
|
||||
mock_fstore.doc_set = None # would raise TypeError if ever called
|
||||
decision = {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": {}}
|
||||
ctx = {"call_id": "call-4", "scene_index": 0, "scene_transcript": "x"}
|
||||
result = await incident_correlator._apply_and_log(decision, ctx)
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
server-26#114 — the incident summarizer used to read doc["transcript"] (the
|
||||
WHOLE call, raw) for every linked call, so a multi-scene call contributed
|
||||
text from scenes it wasn't part of into an incident's summary, and
|
||||
transcript_corrected was never consulted at all.
|
||||
|
||||
Fix: _scene_text_for_incident reads the server-26#96 `scenes` map to find the
|
||||
scene(s) that actually resolved into a given incident_id, and falls back to
|
||||
transcript_corrected-or-transcript for a call doc with no `scenes` field
|
||||
(predates #96).
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.internal import summarizer
|
||||
from app.internal.summarizer import _scene_text_for_incident
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scene_text_for_incident — pure function, no Firestore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_picks_the_scene_that_linked_to_this_incident():
|
||||
doc = {
|
||||
"transcript": "whole raw transcript blend",
|
||||
"transcript_corrected": "whole corrected transcript blend",
|
||||
"scenes": {
|
||||
"0": {"transcript": "scene zero text", "incident_id": "inc-A", "corr_debug": {}},
|
||||
"1": {"transcript": "scene one text", "incident_id": "inc-B", "corr_debug": {}},
|
||||
},
|
||||
}
|
||||
assert _scene_text_for_incident(doc, "inc-A") == "scene zero text"
|
||||
assert _scene_text_for_incident(doc, "inc-B") == "scene one text"
|
||||
|
||||
|
||||
def test_joins_multiple_scenes_linked_to_the_same_incident_in_scene_order():
|
||||
doc = {
|
||||
"scenes": {
|
||||
"1": {"transcript": "second", "incident_id": "inc-A"},
|
||||
"0": {"transcript": "first", "incident_id": "inc-A"},
|
||||
},
|
||||
}
|
||||
assert _scene_text_for_incident(doc, "inc-A") == "first\nsecond"
|
||||
|
||||
|
||||
def test_old_schema_doc_falls_back_to_transcript_corrected_over_transcript():
|
||||
doc = {"transcript": "raw", "transcript_corrected": "corrected"}
|
||||
assert _scene_text_for_incident(doc, "inc-A") == "corrected"
|
||||
|
||||
|
||||
def test_old_schema_doc_with_only_raw_transcript_still_returns_it():
|
||||
doc = {"transcript": "raw only"}
|
||||
assert _scene_text_for_incident(doc, "inc-A") == "raw only"
|
||||
|
||||
|
||||
def test_scenes_present_but_none_match_falls_back_defensively():
|
||||
"""Should not happen for a call_id genuinely in this incident's call_ids,
|
||||
but silently dropping the call's contribution would be worse than a
|
||||
whole-call fallback."""
|
||||
doc = {
|
||||
"transcript": "raw",
|
||||
"transcript_corrected": "corrected",
|
||||
"scenes": {"0": {"transcript": "x", "incident_id": "inc-OTHER"}},
|
||||
}
|
||||
assert _scene_text_for_incident(doc, "inc-A") == "corrected"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _summarize_incident — end to end with fstore/Gemini mocked
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarize_incident_uses_scene_specific_text_for_a_multiscene_call():
|
||||
"""
|
||||
call-1 is a 2-scene call: scene 0 linked into inc-OTHER, scene 1 linked
|
||||
into inc-1 (the incident being summarized). Only scene 1's text may reach
|
||||
the model.
|
||||
"""
|
||||
call_1 = {
|
||||
"call_id": "call-1",
|
||||
"transcript": "scene zero text scene one text", # the old, wrong, whole-call blend
|
||||
"scenes": {
|
||||
"0": {"transcript": "scene zero text", "incident_id": "inc-OTHER"},
|
||||
"1": {"transcript": "scene one text", "incident_id": "inc-1"},
|
||||
},
|
||||
}
|
||||
|
||||
async def fake_doc_get(collection, doc_id):
|
||||
assert collection == "calls"
|
||||
return call_1 if doc_id == "call-1" else None
|
||||
|
||||
with patch("app.internal.feature_flags.get_flags",
|
||||
AsyncMock(return_value={"summaries_enabled": True})), \
|
||||
patch.object(summarizer, "fstore") as fs, \
|
||||
patch.object(summarizer, "_sync_summarize", return_value="a summary") as sync:
|
||||
fs.doc_get = AsyncMock(side_effect=fake_doc_get)
|
||||
fs.doc_set = AsyncMock()
|
||||
await summarizer._summarize_incident({"incident_id": "inc-1", "call_ids": ["call-1"]})
|
||||
|
||||
sync.assert_called_once()
|
||||
_inc_arg, transcripts_arg = sync.call_args.args
|
||||
assert transcripts_arg == ["scene one text"]
|
||||
assert "scene zero text scene one text" not in transcripts_arg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarize_incident_falls_back_for_old_schema_call_doc():
|
||||
"""A call doc with no `scenes` field at all — summarizer must still work,
|
||||
using transcript_corrected over raw transcript."""
|
||||
call_1 = {"call_id": "call-1", "transcript": "raw", "transcript_corrected": "corrected"}
|
||||
|
||||
async def fake_doc_get(collection, doc_id):
|
||||
return call_1 if doc_id == "call-1" else None
|
||||
|
||||
with patch("app.internal.feature_flags.get_flags",
|
||||
AsyncMock(return_value={"summaries_enabled": True})), \
|
||||
patch.object(summarizer, "fstore") as fs, \
|
||||
patch.object(summarizer, "_sync_summarize", return_value="a summary") as sync:
|
||||
fs.doc_get = AsyncMock(side_effect=fake_doc_get)
|
||||
fs.doc_set = AsyncMock()
|
||||
await summarizer._summarize_incident({"incident_id": "inc-1", "call_ids": ["call-1"]})
|
||||
|
||||
_inc_arg, transcripts_arg = sync.call_args.args
|
||||
assert transcripts_arg == ["corrected"]
|
||||
Reference in New Issue
Block a user