diff --git a/bioscancast/stages/insight/text_extraction/chunk_extractor.py b/bioscancast/stages/insight/text_extraction/chunk_extractor.py index 4fa8d67..5434374 100644 --- a/bioscancast/stages/insight/text_extraction/chunk_extractor.py +++ b/bioscancast/stages/insight/text_extraction/chunk_extractor.py @@ -414,6 +414,20 @@ def extract_facts_from_chunk( # and content-insertion hallucinations. See ``_quote_matches`` for # the rationale and the layers. canonical_quote = _quote_matches(raw_quote, chunk.text) + source_chunk_id = chunk.chunk_id + if canonical_quote is None: + # Minimal fallback for chunk-routing misses: if retrieval sends a + # chunk adjacent to the one the model quoted, accept only when the + # quote appears verbatim in some other chunk of the same document. + for alt_chunk in document.chunks: + if alt_chunk.chunk_id == chunk.chunk_id: + continue + alt_quote = _quote_matches(raw_quote, alt_chunk.text) + if alt_quote is not None: + canonical_quote = alt_quote + source_chunk_id = alt_chunk.chunk_id + break + if canonical_quote is None: logger.warning( "Hallucination guard: dropping fact with non-matching quote. " @@ -456,7 +470,7 @@ def extract_facts_from_chunk( sources=[ ChunkReference( document_id=document.id, - chunk_id=chunk.chunk_id, + chunk_id=source_chunk_id, source_url=document.source_url, quote=canonical_quote[:200], ), diff --git a/bioscancast/tests/test_insight_chunk_extractor.py b/bioscancast/tests/test_insight_chunk_extractor.py index 09f43eb..025ad13 100644 --- a/bioscancast/tests/test_insight_chunk_extractor.py +++ b/bioscancast/tests/test_insight_chunk_extractor.py @@ -139,6 +139,45 @@ def test_hallucination_guard_passes_valid_quote(): assert len(records) == 2 +# --------------------------------------------------------------------------- +# Cross-chunk quote routing (retrieval-miss fallback) +# --------------------------------------------------------------------------- + + +def test_cross_chunk_quote_routing_recovers_fact_and_corrects_chunk_id(): + """If retrieval routes a chunk adjacent to the one the model actually + quoted, the fact should still be accepted — the quote is verbatim in a + sibling chunk of the same document — and its provenance chunk_id should + point at the chunk that truly contains the quote, not the routed one.""" + routed_chunk = DOC_WHO_SUDAN.chunks[0] # heading chunk — no case count + quoted_chunk = DOC_WHO_SUDAN.chunks[1] # prose chunk the quote lives in + + # Precondition: the prose quote is absent from the routed heading chunk + # but present in its sibling prose chunk. + quote_text = "9 confirmed cases including 3 deaths have been reported" + assert _normalize_whitespace(quote_text) not in _normalize_whitespace( + routed_chunk.text + ) + assert _normalize_whitespace(quote_text) in _normalize_whitespace( + quoted_chunk.text + ) + + client = FakeLLMClient([SUDAN_PROSE_RESPONSE]) + records, _ = extract_facts_from_chunk( + routed_chunk, DOC_WHO_SUDAN, QUESTION_SUDAN, client, model="gpt-4o-mini" + ) + + # The fact survives the hallucination guard via the sibling-chunk fallback. + assert len(records) == 2 + # Provenance is corrected to the chunk that actually contains the quote. + assert records[0].sources[0].chunk_id == quoted_chunk.chunk_id + assert records[0].sources[0].chunk_id != routed_chunk.chunk_id + # The recorded canonical quote is still a verbatim substring of that chunk. + assert _normalize_whitespace( + records[0].sources[0].quote + ) in _normalize_whitespace(quoted_chunk.text) + + # --------------------------------------------------------------------------- # Country code resolution # ---------------------------------------------------------------------------