Skip to content

Commit 5af7e53

Browse files
authored
fix(backend): drop only the ungrounded memory candidate, not the whole run (#11685) (#11688)
Canonical L1 extraction raised ValueError as soon as any single candidate's evidence quotes failed to bind to exactly one transcript segment. That verdict is per candidate, but raising it aborted the entire run — and, because _extract_memories runs synchronously inside process_conversation, the rest of conversation finalization with it (action items, goal progress, the deferred flag reset, the desktop meeting Chat receipt). In prod over 2026-08-16 03:00Z-15:30Z that was 27 of 39 failed lazy enrichments (42 completed in the same window), plus a continuous stream on backend-sync where the merged conversation just keeps its stale enrichment. Grounding is not systematically broken there: 332 conversations saved >=1 canonical memory in that window and 209 of them saved >=2, so candidates fail grounding individually. Skip the ungrounded candidate instead. The capture fence is unchanged — nothing unbound is ever written — and the run's grounded siblings survive. Every candidate failing to ground still raises: replace_conversation_memories is called unconditionally, so continuing there would submit an empty replacement and retract the source's existing memories on the strength of a run we just decided not to trust. Failure-Class: none
1 parent 925fd2f commit 5af7e53

2 files changed

Lines changed: 90 additions & 1 deletion

File tree

backend/tests/unit/test_memory_replace_policy.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,70 @@ def test_canonical_capture_preserves_prior_state_when_candidate_has_any_unground
225225
mock_service.replace_conversation_memories.assert_not_called()
226226

227227

228+
def test_canonical_capture_drops_only_the_ungrounded_candidate(monkeypatch):
229+
"""One ungrounded candidate must not discard its grounded siblings."""
230+
pc = _load_process_conversation()
231+
from models.conversation import Conversation
232+
from models.conversation_enums import CategoryEnum, ConversationSource
233+
from models.structured import Structured
234+
from models.transcript_segment import TranscriptSegment
235+
236+
mock_service = MagicMock()
237+
monkeypatch.setattr(pc, "MemoryService", lambda db_client: mock_service)
238+
monkeypatch.setattr(
239+
pc,
240+
"extract_canonical_l1_memory_candidates",
241+
MagicMock(
242+
return_value=[
243+
SimpleNamespace(
244+
content="The user works at Acme.",
245+
evidence_quotes=["I work at Acme"],
246+
speaker_label="SPEAKER_00",
247+
speaker_scope="session-local",
248+
about="the user",
249+
risk_flags=[],
250+
archive_class="general",
251+
),
252+
SimpleNamespace(
253+
content="The user was diagnosed with condition X.",
254+
evidence_quotes=["I was diagnosed with condition X"],
255+
speaker_label="SPEAKER_00",
256+
speaker_scope="session-local",
257+
about="the user",
258+
risk_flags=[],
259+
archive_class="general",
260+
),
261+
]
262+
),
263+
)
264+
monkeypatch.setattr(pc.users_db, "get_user_language_preference", lambda uid: "en")
265+
266+
conversation = Conversation(
267+
id="conv-partially-grounded",
268+
created_at=datetime(2026, 6, 1, tzinfo=timezone.utc),
269+
started_at=datetime(2026, 6, 1, tzinfo=timezone.utc),
270+
finished_at=datetime(2026, 6, 1, 1, tzinfo=timezone.utc),
271+
source=ConversationSource.omi,
272+
structured=Structured(title="Test", overview="Overview", category=CategoryEnum.personal),
273+
transcript_segments=[
274+
TranscriptSegment(
275+
text="I work at Acme and we talked about the grocery list.",
276+
speaker="SPEAKER_00",
277+
is_user=True,
278+
start=0.0,
279+
end=4.0,
280+
)
281+
],
282+
)
283+
284+
result = pc._extract_memories_canonical("uid-partial-grounding", conversation, db_client=MagicMock())
285+
286+
assert result.count == 1
287+
replacement_payloads = mock_service.replace_conversation_memories.call_args.args[2]
288+
assert len(replacement_payloads) == 1
289+
assert replacement_payloads[0]["content"] == "The user works at Acme."
290+
291+
228292
@pytest.mark.parametrize(
229293
"matched_segments",
230294
[

backend/utils/conversations/process_conversation.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -909,13 +909,19 @@ def _extract_memories_canonical(
909909
language=language,
910910
strict=True,
911911
)
912+
ungrounded_candidates = 0
912913
for candidate in extracted_candidates:
913914
evidence_quotes = _grounded_l1_evidence_quotes(
914915
candidate.evidence_quotes,
915916
conversation.transcript_segments,
916917
)
917918
if not evidence_quotes:
918-
raise ValueError("canonical memory extraction returned evidence without a unique source binding")
919+
# A quote that binds to no single segment is a verdict on this
920+
# candidate, not on the run. Dropping it keeps the capture fence
921+
# intact — nothing unbound is written — without discarding the
922+
# grounded siblings and the rest of conversation finalization.
923+
ungrounded_candidates += 1
924+
continue
919925
subject_entity_id, subject_attribution, subject_kind = _l1_candidate_subject(
920926
source_id=conversation.id,
921927
about=candidate.about,
@@ -943,6 +949,25 @@ def _extract_memories_canonical(
943949
True,
944950
)
945951
)
952+
if extracted_candidates and not capture_candidates:
953+
# Every candidate failed grounding: the run itself is untrustworthy,
954+
# so it must not submit the empty replacement that would retract the
955+
# source's existing memories.
956+
raise ValueError("canonical memory extraction returned evidence without a unique source binding")
957+
if ungrounded_candidates:
958+
logger.warning(
959+
"canonical memory extraction dropped %s of %s ungrounded candidates conversation=%s",
960+
ungrounded_candidates,
961+
len(extracted_candidates),
962+
conversation.id,
963+
)
964+
record_fallback(
965+
component='other',
966+
from_mode='canonical_memory_extraction',
967+
to_mode='grounded_candidates_only',
968+
reason='other',
969+
outcome='degraded',
970+
)
946971

947972
is_locked = conversation.is_locked
948973
parsed_memories: List[Tuple[MemoryDB, List[str], str, List[str]]] = []

0 commit comments

Comments
 (0)