diff --git a/CHANGELOG.md b/CHANGELOG.md index a43426fe..dad2e86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ - `haiku-rag search`, `ask`, `analyze` and `chat` can cover a configured database set. Commands that access one database select it with `--db-name NAME` or `--db PATH`. -- Citation resolution rejects chunk IDs shared by multiple selected databases - with `AmbiguousCitationError`. +- Citation resolution rejects a chunk ID retrieved from, or previously cited + from, more than one selected database with `AmbiguousCitationError`. ### Fixed diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md index d3396877..9587a10a 100644 --- a/docs/configuration/storage.md +++ b/docs/configuration/storage.md @@ -260,10 +260,17 @@ on every result. #### Duplicate IDs IDs are unique within a database, not across databases. Copies of a database -therefore retain the same IDs. Citation resolution raises -`AmbiguousCitationError` when a cited chunk ID exists in more than one selected -database; a shared ID that nothing cites is ignored. The analysis sandbox -rejects shared document IDs because its mount path is `/documents/{id}/`. +therefore retain the same IDs. + +Citation ambiguity is evaluated against evidence available to the run. A cited +chunk ID is rejected with `AmbiguousCitationError` if search returned it from +multiple databases, or it was previously cited from another database. If only +one retrieved result has the ID, that result is cited. For an ID absent from +search results, the fallback checks every selected database and rejects +multiple holders. A shared ID that nothing cites is ignored. + +The analysis sandbox rejects shared document IDs because its mount path is +`/documents/{id}/`. The chat document filter selects by document ID and applies `id IN (...)` to every covered database, so selecting an ID that copies share matches the diff --git a/tests/multi_db/test_citations.py b/tests/multi_db/test_citations.py index 25188232..b538b92a 100644 --- a/tests/multi_db/test_citations.py +++ b/tests/multi_db/test_citations.py @@ -232,6 +232,41 @@ class TestSharedChunkIds: with pytest.raises(ModelRetry, match="another database"): await capability._cite(["c1"]) + @pytest.mark.asyncio + async def test_one_retrieved_copy_of_a_shared_id_cites_where_it_came_from( + self, tmp_path + ): + """Rejection is about evidence the run holds, not about what the other + databases contain. A copy the search never returned did not ground the + answer, so the retrieved one is the citation and its database is not a + guess. A later question that does retrieve the twin is refused by the + citation index.""" + from tests.capabilities.test_capabilities import Deps, make_context + + config = _config(tmp_path, ["alpha", "beta"]) + capability = create_capability(config=config, defer_loading=False) + deps = Deps(state={"rag": RAGState().model_dump(mode="json")}) + run = await capability.for_run(make_context(deps)) + assert run.state is not None + # What a run holds when fusion returned one copy of a shared id and + # truncated the other: the run never saw the twin. + run.state.searches["cats"] = [ + SearchResult( + content="alpha body", + score=0.9, + source="alpha", + chunk_id="c1", + document_id="d1", + document_uri="test://alpha/one", + ) + ] + + await run._cite(["c1"]) + + [cited] = run.state.citation_index.values() + assert cited.source == "alpha" + assert cited.content == "alpha body" + class TestCitationSource: def test_a_citation_carries_the_result_source(self):