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
|
# Changelog
|
||||||
## [Unreleased]
|
## [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
|
## [0.76.0] - 2026-08-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,11 @@ from pydantic_ai.run import AgentRunResult
|
||||||
from pydantic_ai.tools import ToolDefinition
|
from pydantic_ai.tools import ToolDefinition
|
||||||
from pydantic_ai.toolsets import AgentToolset
|
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.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
|
||||||
|
|
@ -466,7 +470,9 @@ class RAGCapabilityBase[StateT: EvidenceState](AbstractCapability[Any]):
|
||||||
document_filter=self.state.document_filter,
|
document_filter=self.state.document_filter,
|
||||||
)
|
)
|
||||||
state = self.state
|
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()
|
self._note_evidence()
|
||||||
if self.vision and (parts := build_image_content_from_results(results)):
|
if self.vision and (parts := build_image_content_from_results(results)):
|
||||||
return ToolReturn(return_value=formatted, content=parts)
|
return ToolReturn(return_value=formatted, content=parts)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
@ -24,10 +26,26 @@ async def search_corpus(
|
||||||
result.format_for_agent(rank=index + 1, total=len(results))
|
result.format_for_agent(rank=index + 1, total=len(results))
|
||||||
for index, result in enumerate(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__ = [
|
__all__ = [
|
||||||
"CodeExecutionEntry",
|
"CodeExecutionEntry",
|
||||||
|
"merge_results",
|
||||||
"search_corpus",
|
"search_corpus",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from haiku.rag.capabilities._base import (
|
||||||
RAGCapabilityBase,
|
RAGCapabilityBase,
|
||||||
resolve_db_path,
|
resolve_db_path,
|
||||||
)
|
)
|
||||||
|
from haiku.rag.capabilities._tools import merge_results
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
from haiku.rag.sandbox import AnalysisContext, Sandbox
|
||||||
|
|
||||||
|
|
@ -112,13 +113,10 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
|
||||||
if result.success or result.stdout:
|
if result.success or result.stdout:
|
||||||
self._note_evidence()
|
self._note_evidence()
|
||||||
if sandbox._search_results:
|
if sandbox._search_results:
|
||||||
existing = self.state.searches.get("_sandbox", [])
|
merge_results(
|
||||||
seen = {item.chunk_id for item in existing}
|
self.state.searches.setdefault("_sandbox", []),
|
||||||
for item in sandbox._search_results:
|
sandbox._search_results,
|
||||||
if item.chunk_id not in seen:
|
)
|
||||||
existing.append(item)
|
|
||||||
seen.add(item.chunk_id)
|
|
||||||
self.state.searches["_sandbox"] = existing
|
|
||||||
self.state.executions.append(
|
self.state.executions.append(
|
||||||
CodeExecutionEntry(
|
CodeExecutionEntry(
|
||||||
code=code,
|
code=code,
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,49 @@ async def test_a_spent_search_budget_fails_the_tool(temp_db_path):
|
||||||
await capability._search("anything", None)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cite_resolves_direct_chunk_ids_and_reuses_document_lookup(temp_db_path):
|
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())
|
capability = create_rag(db_path=temp_db_path, config=AppConfig())
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue