Ask across databases and name the source of each citation
`ask(sources=[…])` scopes a question to some of the configured databases, carried on the capability state so its search tool searches those. `Citation.source` names the database a cited chunk came from, resolved from the search results the model saw, which already carry it. Context expansion routes each result through the database it came from: a federating client has no repositories of its own. The cite fallback, which looks up an id absent from this run's results, searches only the selected databases. A chunk id says nothing about which database holds it, so placing one means asking, and asking outside the selection would let a question scoped to some databases cite another. The loosely-specced client mocks in the capability tests now say they stand in for a single-database client. A bare AsyncMock answers any attribute with a truthy Mock, so `_federated` sent the fallback down the multi-database branch, and `_source` reached a validated field.
This commit is contained in:
parent
397b553528
commit
f33b789a31
9 changed files with 314 additions and 15 deletions
|
|
@ -39,6 +39,7 @@
|
||||||
- The `haiku.rag` package declares the `jina` extra, so `provider: jina-local` is supported by declaration rather than through `cross-encoder`'s transitive `transformers` and `torch`. Raises the full package's torch floor to 2.0.
|
- The `haiku.rag` package declares the `jina` extra, so `provider: jina-local` is supported by declaration rather than through `cross-encoder`'s transitive `transformers` and `torch`. Raises the full package's torch floor to 2.0.
|
||||||
- `providers.docling_serve.timeout` (default 300 seconds), forwarded to the docling-serve client's per-request timeout.
|
- `providers.docling_serve.timeout` (default 300 seconds), forwarded to the docling-serve client's per-request timeout.
|
||||||
- `lancedb.databases`: a name-to-location mapping for searching several databases at once, mutually exclusive with `lancedb.uri`. `client.search(..., sources=[...])` selects which to search, `sources=None` searches all of them, and `SearchResult.source` carries the configured name a result came from. Candidates are fused by the configured reranker over the union, or by reciprocal rank fusion when none is configured.
|
- `lancedb.databases`: a name-to-location mapping for searching several databases at once, mutually exclusive with `lancedb.uri`. `client.search(..., sources=[...])` selects which to search, `sources=None` searches all of them, and `SearchResult.source` carries the configured name a result came from. Candidates are fused by the configured reranker over the union, or by reciprocal rank fusion when none is configured.
|
||||||
|
- `client.ask(..., sources=[...])` asks across the selected databases, and `Citation.source` names the one a cited chunk came from. The cite fallback for an id absent from the run's results looks only in the selected databases, so a question scoped to some cannot cite another.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ from haiku.rag.capabilities._tools import (
|
||||||
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord, EvidenceRef
|
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord, EvidenceRef
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.store.models.chunk import SearchResult
|
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
||||||
from haiku.rag.store.models.citation import Citation, resolve_citations
|
from haiku.rag.store.models.citation import Citation, resolve_citations
|
||||||
from haiku.rag.tools.search import build_image_content_from_results
|
from haiku.rag.tools.search import build_image_content_from_results
|
||||||
|
|
||||||
|
|
@ -91,6 +91,7 @@ class EvidenceState(BaseModel):
|
||||||
citations: list[str] = Field(default_factory=list)
|
citations: list[str] = Field(default_factory=list)
|
||||||
evidence: CapabilityEvidenceRecord = Field(default_factory=CapabilityEvidenceRecord)
|
evidence: CapabilityEvidenceRecord = Field(default_factory=CapabilityEvidenceRecord)
|
||||||
document_filter: str | None = None
|
document_filter: str | None = None
|
||||||
|
sources: list[str] | None = None
|
||||||
searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
|
searches: dict[str, list[SearchResult]] = Field(default_factory=dict)
|
||||||
|
|
||||||
def begin_invocation(self) -> None:
|
def begin_invocation(self) -> None:
|
||||||
|
|
@ -101,8 +102,8 @@ class EvidenceState(BaseModel):
|
||||||
leaves a later citation unable to resolve against the expanded result the
|
leaves a later citation unable to resolve against the expanded result the
|
||||||
model saw, recording no provenance for it.
|
model saw, recording no provenance for it.
|
||||||
|
|
||||||
`document_filter` scopes the conversation, and `evidence` carries question
|
`document_filter` and `sources` scope the conversation, and `evidence`
|
||||||
identity, so neither is working evidence.
|
carries question identity, so none of them is working evidence.
|
||||||
"""
|
"""
|
||||||
self.citations.clear()
|
self.citations.clear()
|
||||||
self.searches.clear()
|
self.searches.clear()
|
||||||
|
|
@ -136,6 +137,21 @@ def _called_own_tool(messages: list[ModelMessage], tool_names: frozenset[str]) -
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _first_holding(
|
||||||
|
clients: "list[HaikuRAG]", chunk_id: str
|
||||||
|
) -> "tuple[HaikuRAG, Chunk] | None":
|
||||||
|
"""The first client holding this chunk, and the chunk.
|
||||||
|
|
||||||
|
A chunk id says nothing about which database holds it, so the only way to
|
||||||
|
place one is to ask. Returns None when none of them has it.
|
||||||
|
"""
|
||||||
|
for client in clients:
|
||||||
|
chunk = await client.get_chunk_by_id(chunk_id)
|
||||||
|
if chunk is not None:
|
||||||
|
return client, chunk
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
||||||
db_path: Path
|
db_path: Path
|
||||||
|
|
@ -468,6 +484,7 @@ class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
||||||
query,
|
query,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
document_filter=self.state.document_filter,
|
document_filter=self.state.document_filter,
|
||||||
|
sources=self.state.sources,
|
||||||
)
|
)
|
||||||
state = self.state
|
state = self.state
|
||||||
# A model can search the same query twice with different limits, and the
|
# A model can search the same query twice with different limits, and the
|
||||||
|
|
@ -504,20 +521,35 @@ class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
||||||
if missing:
|
if missing:
|
||||||
async with self.rag_lock:
|
async with self.rag_lock:
|
||||||
rag = await self._ensure_rag()
|
rag = await self._ensure_rag()
|
||||||
|
# A chunk id says nothing about which database holds it, so a
|
||||||
|
# federating client looks through the ones it covers.
|
||||||
|
if rag._federated:
|
||||||
|
lookups = await rag.clients_for(
|
||||||
|
getattr(self.state, "sources", None) or list(rag._federated)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lookups = [rag]
|
||||||
synthetic: list[SearchResult] = []
|
synthetic: list[SearchResult] = []
|
||||||
documents: dict[str, Any] = {}
|
documents: dict[tuple[str | None, str], Any] = {}
|
||||||
for chunk_id in missing:
|
for chunk_id in missing:
|
||||||
chunk = await rag.get_chunk_by_id(chunk_id)
|
found = await _first_holding(lookups, chunk_id)
|
||||||
if chunk is None or not chunk.document_id:
|
if found is None:
|
||||||
continue
|
continue
|
||||||
document = documents.get(chunk.document_id)
|
owner, chunk = found
|
||||||
if chunk.document_id not in documents:
|
if not chunk.document_id:
|
||||||
document = await rag.get_document_by_id(chunk.document_id)
|
continue
|
||||||
documents[chunk.document_id] = document
|
key = (owner._source, chunk.document_id)
|
||||||
|
if key not in documents:
|
||||||
|
documents[key] = await owner.get_document_by_id(
|
||||||
|
chunk.document_id
|
||||||
|
)
|
||||||
|
document = documents[key]
|
||||||
chunk.document_uri = document.uri if document else None
|
chunk.document_uri = document.uri if document else None
|
||||||
chunk.document_title = document.title if document else None
|
chunk.document_title = document.title if document else None
|
||||||
chunk.document_meta = document.metadata if document else {}
|
chunk.document_meta = document.metadata if document else {}
|
||||||
synthetic.append(SearchResult.from_chunk(chunk, score=1.0))
|
result = SearchResult.from_chunk(chunk, score=1.0)
|
||||||
|
result.source = owner._source
|
||||||
|
synthetic.append(result)
|
||||||
citations.extend(resolve_citations(missing, synthetic))
|
citations.extend(resolve_citations(missing, synthetic))
|
||||||
|
|
||||||
if not citations:
|
if not citations:
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,12 @@ async def search_corpus(
|
||||||
query: str,
|
query: str,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
document_filter: str | None = None,
|
document_filter: str | None = None,
|
||||||
|
sources: list[str] | None = None,
|
||||||
) -> tuple[str, list[SearchResult]]:
|
) -> tuple[str, list[SearchResult]]:
|
||||||
"""Search and context-expand results for a capability tool."""
|
"""Search and context-expand results for a capability tool."""
|
||||||
results = await rag.search(query, limit=limit, filter=document_filter)
|
results = await rag.search(
|
||||||
|
query, limit=limit, filter=document_filter, sources=sources
|
||||||
|
)
|
||||||
results = await rag.expand_context(results)
|
results = await rag.expand_context(results)
|
||||||
formatted = "\n\n---\n\n".join(
|
formatted = "\n\n---\n\n".join(
|
||||||
result.format_for_agent(rank=index + 1, total=len(results))
|
result.format_for_agent(rank=index + 1, total=len(results))
|
||||||
|
|
|
||||||
|
|
@ -635,10 +635,11 @@ class HaikuRAG:
|
||||||
question: str,
|
question: str,
|
||||||
filter: str | None = None,
|
filter: str | None = None,
|
||||||
images: Sequence[bytes] | None = None,
|
images: Sequence[bytes] | None = None,
|
||||||
|
sources: list[str] | None = None,
|
||||||
) -> "tuple[str, list[Citation]]":
|
) -> "tuple[str, list[Citation]]":
|
||||||
from haiku.rag.client.agents import ask
|
from haiku.rag.client.agents import ask
|
||||||
|
|
||||||
return await ask(self, question, filter, images)
|
return await ask(self, question, filter, images, sources)
|
||||||
|
|
||||||
async def analyze(
|
async def analyze(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ async def ask(
|
||||||
question: str,
|
question: str,
|
||||||
filter: str | None = None,
|
filter: str | None = None,
|
||||||
images: Sequence[bytes] | None = None,
|
images: Sequence[bytes] | None = None,
|
||||||
|
sources: list[str] | None = None,
|
||||||
) -> "tuple[str, list[Citation]]":
|
) -> "tuple[str, list[Citation]]":
|
||||||
"""Ask a question against the knowledge base via the RAG capability.
|
"""Ask a question against the knowledge base via the RAG capability.
|
||||||
|
|
||||||
|
|
@ -61,13 +62,17 @@ async def ask(
|
||||||
from haiku.rag.utils import get_model
|
from haiku.rag.utils import get_model
|
||||||
|
|
||||||
capability = create_capability(
|
capability = create_capability(
|
||||||
db_path=client.store.db_path,
|
db_path=None if client._federated else client.store.db_path,
|
||||||
config=client._config,
|
config=client._config,
|
||||||
rag=client,
|
rag=client,
|
||||||
defer_loading=False,
|
defer_loading=False,
|
||||||
)
|
)
|
||||||
deps = _AgentDeps(
|
deps = _AgentDeps(
|
||||||
state={"rag": RAGState(document_filter=filter).model_dump(mode="json")}
|
state={
|
||||||
|
"rag": RAGState(document_filter=filter, sources=sources).model_dump(
|
||||||
|
mode="json"
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
user_prompt = _build_user_prompt(question, images, client._config.qa.model)
|
user_prompt = _build_user_prompt(question, images, client._config.qa.model)
|
||||||
model = get_model(client._config.qa.model, client._config)
|
model = get_model(client._config.qa.model, client._config)
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,28 @@ async def expand_context(
|
||||||
chunks were created without docling metadata (e.g., custom chunks passed
|
chunks were created without docling metadata (e.g., custom chunks passed
|
||||||
to import_document).
|
to import_document).
|
||||||
"""
|
"""
|
||||||
|
# A federating client has no repositories of its own, so each result expands
|
||||||
|
# through the database it came from.
|
||||||
|
if client._federated:
|
||||||
|
by_source: dict[str, list[SearchResult]] = {}
|
||||||
|
unsourced: list[SearchResult] = []
|
||||||
|
for result in search_results:
|
||||||
|
if result.source:
|
||||||
|
by_source.setdefault(result.source, []).append(result)
|
||||||
|
else:
|
||||||
|
unsourced.append(result)
|
||||||
|
owners = await client.clients_for(list(by_source))
|
||||||
|
expanded_groups = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
expand_context(owner, by_source[owner._source])
|
||||||
|
for owner in owners
|
||||||
|
if owner._source
|
||||||
|
)
|
||||||
|
)
|
||||||
|
merged = unsourced + [r for group in expanded_groups for r in group]
|
||||||
|
merged.sort(key=lambda r: r.score, reverse=True)
|
||||||
|
return merged
|
||||||
|
|
||||||
from haiku.rag.context import expand_with_items, window_for
|
from haiku.rag.context import expand_with_items, window_for
|
||||||
|
|
||||||
max_chars = client._config.search.max_context_chars
|
max_chars = client._config.search.max_context_chars
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ class Citation(BaseModel):
|
||||||
``chunk_ids`` lists the ids of all chunks whose expansion ranges merged
|
``chunk_ids`` lists the ids of all chunks whose expansion ranges merged
|
||||||
into the cited result (always includes ``chunk_id``).
|
into the cited result (always includes ``chunk_id``).
|
||||||
|
|
||||||
|
``source`` names the configured database the cited chunk came from, and is
|
||||||
|
None when only one is configured. It is the name from ``lancedb.databases``,
|
||||||
|
never a path or URI.
|
||||||
|
|
||||||
``doc_item_refs`` are the ``self_ref`` values of every item in the cited
|
``doc_item_refs`` are the ``self_ref`` values of every item in the cited
|
||||||
content — the exact items the model saw. Visual grounding resolves bounding
|
content — the exact items the model saw. Visual grounding resolves bounding
|
||||||
boxes from them so the rendered pages match the citation precisely.
|
boxes from them so the rendered pages match the citation precisely.
|
||||||
|
|
@ -37,6 +41,7 @@ class Citation(BaseModel):
|
||||||
|
|
||||||
index: int | None = None
|
index: int | None = None
|
||||||
document_id: str
|
document_id: str
|
||||||
|
source: str | None = None
|
||||||
chunk_id: str
|
chunk_id: str
|
||||||
chunk_ids: list[str] = Field(default_factory=list)
|
chunk_ids: list[str] = Field(default_factory=list)
|
||||||
chunk_meta: dict = Field(default_factory=dict)
|
chunk_meta: dict = Field(default_factory=dict)
|
||||||
|
|
@ -69,6 +74,7 @@ def resolve_citations(
|
||||||
citations.append(
|
citations.append(
|
||||||
Citation(
|
Citation(
|
||||||
document_id=r.document_id or "",
|
document_id=r.document_id or "",
|
||||||
|
source=r.source,
|
||||||
chunk_id=chunk_id,
|
chunk_id=chunk_id,
|
||||||
chunk_ids=r.chunk_ids or [chunk_id],
|
chunk_ids=r.chunk_ids or [chunk_id],
|
||||||
chunk_meta=r.chunk_meta,
|
chunk_meta=r.chunk_meta,
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,10 @@ async def test_capability_isolated_per_run_and_round_trips_state(temp_db_path):
|
||||||
async def test_run_error_closes_resources_and_propagates(temp_db_path):
|
async def test_run_error_closes_resources_and_propagates(temp_db_path):
|
||||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
# Stands in for a single-database client: a bare AsyncMock's auto
|
||||||
|
# attributes are truthy Mocks, and `_source` reaches a validated field.
|
||||||
|
client._federated = {}
|
||||||
|
client._source = None
|
||||||
capability.rag = client
|
capability.rag = client
|
||||||
error = RuntimeError("model failed")
|
error = RuntimeError("model failed")
|
||||||
|
|
||||||
|
|
@ -385,6 +389,10 @@ async def test_cite_resolves_direct_chunk_ids_and_reuses_document_lookup(temp_db
|
||||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
capability.state = RAGState(evidence=CapabilityEvidenceRecord(question=0))
|
capability.state = RAGState(evidence=CapabilityEvidenceRecord(question=0))
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
# Stands in for a single-database client: a bare AsyncMock's auto
|
||||||
|
# attributes are truthy Mocks, and `_source` reaches a validated field.
|
||||||
|
client._federated = {}
|
||||||
|
client._source = None
|
||||||
client.get_chunk_by_id.side_effect = [
|
client.get_chunk_by_id.side_effect = [
|
||||||
Chunk(id="chunk-1", document_id="doc-1", content="first"),
|
Chunk(id="chunk-1", document_id="doc-1", content="first"),
|
||||||
Chunk(id="chunk-2", document_id="doc-1", content="second"),
|
Chunk(id="chunk-2", document_id="doc-1", content="second"),
|
||||||
|
|
@ -411,6 +419,10 @@ async def test_cite_reports_unresolved_ids_on_partial_success(temp_db_path):
|
||||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
capability.state = RAGState(evidence=CapabilityEvidenceRecord(question=0))
|
capability.state = RAGState(evidence=CapabilityEvidenceRecord(question=0))
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
# Stands in for a single-database client: a bare AsyncMock's auto
|
||||||
|
# attributes are truthy Mocks, and `_source` reaches a validated field.
|
||||||
|
client._federated = {}
|
||||||
|
client._source = None
|
||||||
client.get_chunk_by_id.side_effect = [
|
client.get_chunk_by_id.side_effect = [
|
||||||
Chunk(id="chunk-1", document_id="doc-1", content="first"),
|
Chunk(id="chunk-1", document_id="doc-1", content="first"),
|
||||||
None,
|
None,
|
||||||
|
|
@ -455,6 +467,10 @@ async def test_cite_repairs_chunk_ids_damaged_in_transcription(temp_db_path):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
# Stands in for a single-database client: a bare AsyncMock's auto
|
||||||
|
# attributes are truthy Mocks, and `_source` reaches a validated field.
|
||||||
|
client._federated = {}
|
||||||
|
client._source = None
|
||||||
client.get_chunk_by_id.return_value = None
|
client.get_chunk_by_id.return_value = None
|
||||||
capability.rag = client
|
capability.rag = client
|
||||||
|
|
||||||
|
|
@ -1214,6 +1230,10 @@ async def test_citing_without_searching_grounds_the_question(temp_db_path):
|
||||||
return ModelResponse(parts=next(calls))
|
return ModelResponse(parts=next(calls))
|
||||||
|
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
# Stands in for a single-database client: a bare AsyncMock's auto
|
||||||
|
# attributes are truthy Mocks, and `_source` reaches a validated field.
|
||||||
|
client._federated = {}
|
||||||
|
client._source = None
|
||||||
client.get_chunk_by_id.return_value = Chunk(
|
client.get_chunk_by_id.return_value = Chunk(
|
||||||
id="chunk-1", document_id="doc-1", content="evidence"
|
id="chunk-1", document_id="doc-1", content="evidence"
|
||||||
)
|
)
|
||||||
|
|
@ -1315,6 +1335,10 @@ async def test_a_capability_fetches_its_own_evidences_pictures(temp_db_path):
|
||||||
"""Compaction rehydrates through the owner, which already holds the connection."""
|
"""Compaction rehydrates through the owner, which already holds the connection."""
|
||||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
# Stands in for a single-database client: a bare AsyncMock's auto
|
||||||
|
# attributes are truthy Mocks, and `_source` reaches a validated field.
|
||||||
|
client._federated = {}
|
||||||
|
client._source = None
|
||||||
client.document_item_repository.get_picture_bytes.return_value = b"picture-bytes"
|
client.document_item_repository.get_picture_bytes.return_value = b"picture-bytes"
|
||||||
capability.rag = client
|
capability.rag = client
|
||||||
|
|
||||||
|
|
@ -1354,6 +1378,10 @@ async def test_citing_nothing_after_citing_something_keeps_it_grounded(temp_db_p
|
||||||
capability.state = RAGState(evidence=CapabilityEvidenceRecord(question=0))
|
capability.state = RAGState(evidence=CapabilityEvidenceRecord(question=0))
|
||||||
capability.epoch = 5
|
capability.epoch = 5
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
# Stands in for a single-database client: a bare AsyncMock's auto
|
||||||
|
# attributes are truthy Mocks, and `_source` reaches a validated field.
|
||||||
|
client._federated = {}
|
||||||
|
client._source = None
|
||||||
client.get_chunk_by_id.return_value = Chunk(
|
client.get_chunk_by_id.return_value = Chunk(
|
||||||
id="chunk-1", document_id="doc-1", content="evidence"
|
id="chunk-1", document_id="doc-1", content="evidence"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
201
tests/test_multi_db_ask.py
Normal file
201
tests/test_multi_db_ask.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import pytest
|
||||||
|
from pydantic_ai import ModelRetry
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.rag import RAGState, create_capability
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.store.models import SearchResult
|
||||||
|
from haiku.rag.store.models.citation import resolve_citations
|
||||||
|
from tests.test_multi_db import _config, _seed
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpansionRouting:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expansion_routes_each_result_to_its_database(self, tmp_path):
|
||||||
|
"""A federating client has no repositories of its own, so expansion has
|
||||||
|
to go through the database each result came from."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
results = await rag.search("cats", search_type="fts", limit=10)
|
||||||
|
expanded = await rag.expand_context(results)
|
||||||
|
|
||||||
|
assert {r.source for r in expanded} == {"alpha", "beta"}
|
||||||
|
for r in expanded:
|
||||||
|
assert r.source is not None
|
||||||
|
assert r.source in r.content
|
||||||
|
|
||||||
|
|
||||||
|
class TestCitationSource:
|
||||||
|
def test_a_citation_carries_the_result_source(self):
|
||||||
|
result = SearchResult(
|
||||||
|
content="body",
|
||||||
|
score=0.9,
|
||||||
|
source="alpha",
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://alpha/one",
|
||||||
|
)
|
||||||
|
|
||||||
|
[citation] = resolve_citations(["c1"], [result])
|
||||||
|
|
||||||
|
assert citation.source == "alpha"
|
||||||
|
|
||||||
|
def test_a_single_database_citation_has_no_source(self):
|
||||||
|
result = SearchResult(
|
||||||
|
content="body",
|
||||||
|
score=0.9,
|
||||||
|
chunk_id="c1",
|
||||||
|
document_id="d1",
|
||||||
|
document_uri="test://one",
|
||||||
|
)
|
||||||
|
|
||||||
|
[citation] = resolve_citations(["c1"], [result])
|
||||||
|
|
||||||
|
assert citation.source is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAskAcrossDatabases:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_capability_searches_the_selected_databases(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
capability.state = RAGState(sources=["alpha"])
|
||||||
|
|
||||||
|
formatted = await capability._search("cats", limit=10)
|
||||||
|
|
||||||
|
assert isinstance(formatted, str)
|
||||||
|
assert "alpha" in formatted
|
||||||
|
assert "beta document" not in formatted
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_searching_all_databases_reaches_both(self, tmp_path):
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
capability.state = RAGState()
|
||||||
|
|
||||||
|
formatted = await capability._search("cats", limit=10)
|
||||||
|
|
||||||
|
assert isinstance(formatted, str)
|
||||||
|
assert "alpha document" in formatted
|
||||||
|
assert "beta document" in formatted
|
||||||
|
|
||||||
|
|
||||||
|
class TestCiteFallback:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_id_from_a_selected_database_resolves_with_its_source(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""The fallback exists for a real id this run's searches did not return.
|
||||||
|
Across databases it looks through the selected ones and records which
|
||||||
|
held it."""
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(
|
||||||
|
config, "alpha", ["alpha document about cats", "alpha on aardvarks"]
|
||||||
|
)
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
alpha = (await rag.clients_for(["alpha"]))[0]
|
||||||
|
chunks = await alpha.chunk_repository.list_all()
|
||||||
|
[aardvark] = [c for c in chunks if "aardvark" in c.content]
|
||||||
|
assert aardvark.id is not None
|
||||||
|
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
deps = Deps(
|
||||||
|
state={"rag": RAGState(sources=["alpha"]).model_dump(mode="json")}
|
||||||
|
)
|
||||||
|
run = await capability.for_run(make_context(deps))
|
||||||
|
# The search returns the cats chunk, never the aardvark one.
|
||||||
|
await run._search("cats", limit=10)
|
||||||
|
|
||||||
|
await run._cite([aardvark.id])
|
||||||
|
|
||||||
|
assert run.state is not None
|
||||||
|
[citation] = list(run.state.citation_index.values())
|
||||||
|
assert citation.chunk_id == aardvark.id
|
||||||
|
assert citation.source == "alpha"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_id_outside_the_selected_databases_does_not_resolve(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""A question scoped to one database must not produce a citation from
|
||||||
|
another: the fallback looks only where the question looked."""
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about dogs"])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
beta = (await rag.clients_for(["beta"]))[0]
|
||||||
|
[outside] = await beta.chunk_repository.list_all(limit=1)
|
||||||
|
assert outside.id is not None
|
||||||
|
|
||||||
|
capability = create_capability(config=config, rag=rag, defer_loading=False)
|
||||||
|
deps = Deps(
|
||||||
|
state={"rag": RAGState(sources=["alpha"]).model_dump(mode="json")}
|
||||||
|
)
|
||||||
|
run = await capability.for_run(make_context(deps))
|
||||||
|
await run._search("cats", limit=10)
|
||||||
|
|
||||||
|
with pytest.raises(ModelRetry):
|
||||||
|
await run._cite([outside.id])
|
||||||
|
|
||||||
|
|
||||||
|
class TestFederatedEdges:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expansion_passes_through_results_without_a_source(self, tmp_path):
|
||||||
|
"""A caller can hand `expand_context` results it built itself. Those name
|
||||||
|
no database, so there is nowhere to expand them from."""
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
await _seed(config, "beta", ["beta document about cats"])
|
||||||
|
|
||||||
|
handmade = SearchResult(content="handmade", score=0.4, doc_item_refs=[])
|
||||||
|
|
||||||
|
async with HaikuRAG(config=config) as rag:
|
||||||
|
found = await rag.search("cats", search_type="fts", limit=10)
|
||||||
|
expanded = await rag.expand_context([*found, handmade])
|
||||||
|
|
||||||
|
assert "handmade" in [r.content for r in expanded]
|
||||||
|
scores = [r.score for r in expanded]
|
||||||
|
assert scores == sorted(scores, reverse=True), "merged in score order"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_chunk_without_a_document_is_not_cited(self, tmp_path):
|
||||||
|
"""`Chunk.document_id` is optional, and a citation without a document has
|
||||||
|
nothing to point at."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from haiku.rag.capabilities.rag import RAGCapability
|
||||||
|
from haiku.rag.store.models import Chunk
|
||||||
|
from tests.capabilities.test_capabilities import Deps, make_context
|
||||||
|
|
||||||
|
config = _config(tmp_path, ["alpha", "beta"])
|
||||||
|
await _seed(config, "alpha", ["alpha document about cats"])
|
||||||
|
|
||||||
|
orphan = AsyncMock()
|
||||||
|
orphan._federated = {}
|
||||||
|
orphan._source = None
|
||||||
|
orphan.get_chunk_by_id.return_value = Chunk(
|
||||||
|
id="orphan", document_id=None, content="no document"
|
||||||
|
)
|
||||||
|
|
||||||
|
capability = create_capability(config=config, defer_loading=False)
|
||||||
|
run = await capability.for_run(make_context(Deps()))
|
||||||
|
with patch.object(RAGCapability, "_ensure_rag", AsyncMock(return_value=orphan)):
|
||||||
|
with pytest.raises(ModelRetry):
|
||||||
|
await run._cite(["orphan"])
|
||||||
Loading…
Reference in a new issue