Return "No results found." and accumulate repeated search results
This commit is contained in:
parent
2a7c6510d0
commit
97d9c6ed49
5 changed files with 80 additions and 10 deletions
|
|
@ -1,6 +1,11 @@
|
|||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- A capability search that matches nothing returns `No results found.` instead of an empty string.
|
||||
- Repeating a search query within a question accumulates the results of both calls instead of replacing the earlier ones.
|
||||
|
||||
## [0.76.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ from pydantic_ai.run import AgentRunResult
|
|||
from pydantic_ai.tools import ToolDefinition
|
||||
from pydantic_ai.toolsets import AgentToolset
|
||||
|
||||
from haiku.rag.capabilities._tools import CodeExecutionEntry, search_corpus
|
||||
from haiku.rag.capabilities._tools import (
|
||||
CodeExecutionEntry,
|
||||
merge_results,
|
||||
search_corpus,
|
||||
)
|
||||
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord, EvidenceRef
|
||||
from haiku.rag.client import HaikuRAG
|
||||
from haiku.rag.config.models import AppConfig
|
||||
|
|
@ -466,7 +470,9 @@ class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
|||
document_filter=self.state.document_filter,
|
||||
)
|
||||
state = self.state
|
||||
state.searches[query] = results
|
||||
# A model can search the same query twice with different limits, and the
|
||||
# narrower return must not drop what the wider one already showed it.
|
||||
merge_results(state.searches.setdefault(query, []), results)
|
||||
self._note_evidence()
|
||||
if self.vision and (parts := build_image_content_from_results(results)):
|
||||
return ToolReturn(return_value=formatted, content=parts)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from collections.abc import Iterable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from haiku.rag.client import HaikuRAG
|
||||
|
|
@ -24,10 +26,26 @@ async def search_corpus(
|
|||
result.format_for_agent(rank=index + 1, total=len(results))
|
||||
for index, result in enumerate(results)
|
||||
)
|
||||
return formatted, list(results)
|
||||
return formatted or "No results found.", list(results)
|
||||
|
||||
|
||||
def merge_results(
|
||||
existing: list[SearchResult], incoming: Iterable[SearchResult]
|
||||
) -> None:
|
||||
"""Add the results not already held.
|
||||
|
||||
Identity is the chunk id, which every stored chunk carries; results built by
|
||||
hand without one cannot be told apart and collapse to the first.
|
||||
"""
|
||||
seen = {result.chunk_id for result in existing}
|
||||
for result in incoming:
|
||||
if result.chunk_id not in seen:
|
||||
existing.append(result)
|
||||
seen.add(result.chunk_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CodeExecutionEntry",
|
||||
"merge_results",
|
||||
"search_corpus",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from haiku.rag.capabilities._base import (
|
|||
RAGCapabilityBase,
|
||||
resolve_db_path,
|
||||
)
|
||||
from haiku.rag.capabilities._tools import merge_results
|
||||
from haiku.rag.config.models import AppConfig
|
||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||
|
||||
|
|
@ -112,13 +113,10 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
|||
if result.success or result.stdout:
|
||||
self._note_evidence()
|
||||
if sandbox._search_results:
|
||||
existing = self.state.searches.get("_sandbox", [])
|
||||
seen = {item.chunk_id for item in existing}
|
||||
for item in sandbox._search_results:
|
||||
if item.chunk_id not in seen:
|
||||
existing.append(item)
|
||||
seen.add(item.chunk_id)
|
||||
self.state.searches["_sandbox"] = existing
|
||||
merge_results(
|
||||
self.state.searches.setdefault("_sandbox", []),
|
||||
sandbox._search_results,
|
||||
)
|
||||
self.state.executions.append(
|
||||
CodeExecutionEntry(
|
||||
code=code,
|
||||
|
|
|
|||
|
|
@ -314,6 +314,49 @@ async def test_a_spent_search_budget_fails_the_tool(temp_db_path):
|
|||
await capability._search("anything", None)
|
||||
|
||||
|
||||
def _stub_client(*batches: list[SearchResult]) -> AsyncMock:
|
||||
"""A client whose successive searches return the given result batches."""
|
||||
client = AsyncMock()
|
||||
client.search.side_effect = list(batches)
|
||||
client.expand_context.side_effect = lambda results: results
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_fruitless_search_says_so(temp_db_path):
|
||||
"""A blank tool return reads as a broken tool, not as an empty corpus."""
|
||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||
capability.state = RAGState()
|
||||
capability.borrowed_rag = _stub_client([])
|
||||
|
||||
assert await capability._search("nothing about this", None) == "No results found."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_narrower_repeat_keeps_what_the_wider_search_returned(temp_db_path):
|
||||
"""One query, two limits: the model can still cite the results it was shown."""
|
||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||
capability.state = RAGState()
|
||||
capability.borrowed_rag = _stub_client(
|
||||
[
|
||||
SearchResult(content="first", score=1.0, chunk_id="chunk-1"),
|
||||
SearchResult(content="second", score=0.9, chunk_id="chunk-2"),
|
||||
SearchResult(content="third", score=0.8, chunk_id="chunk-3"),
|
||||
],
|
||||
[SearchResult(content="first", score=1.0, chunk_id="chunk-1")],
|
||||
)
|
||||
|
||||
await capability._search("Figure 3-1", 20)
|
||||
await capability._search("Figure 3-1", None)
|
||||
|
||||
stored = capability.state.searches["Figure 3-1"]
|
||||
assert [result.chunk_id for result in stored] == [
|
||||
"chunk-1",
|
||||
"chunk-2",
|
||||
"chunk-3",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cite_resolves_direct_chunk_ids_and_reuses_document_lookup(temp_db_path):
|
||||
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||
|
|
|
|||
Loading…
Reference in a new issue