attach picture bytes through skill search and document the schema
This commit is contained in:
parent
46b81f3fa1
commit
49274ab51b
5 changed files with 223 additions and 5 deletions
|
|
@ -11,7 +11,7 @@ description: {{ description }}
|
||||||
{% if "search" in tool_names %}
|
{% if "search" in tool_names %}
|
||||||
|
|
||||||
### search
|
### search
|
||||||
Search the knowledge base using hybrid search (vector + full-text). Returns ranked results with context-expanded content. Use for answering questions, finding passages, exploring topics.
|
Search the knowledge base using hybrid search (vector + full-text). Returns ranked results with context-expanded content. Use for answering questions, finding passages, exploring topics. Each result has a `Type:` (paragraph, table, code, list_item, picture). When the Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text — use it directly to answer questions about figures, diagrams, charts, screenshots.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if "list_documents" in tool_names %}
|
{% if "list_documents" in tool_names %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import base64
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic_ai import RunContext
|
from pydantic_ai import RunContext
|
||||||
|
from pydantic_ai.messages import BinaryContent, ToolReturn
|
||||||
|
|
||||||
from haiku.rag.agents.research.models import Citation
|
from haiku.rag.agents.research.models import Citation
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
@ -167,10 +169,14 @@ def create_skill_tools(
|
||||||
|
|
||||||
async def search(
|
async def search(
|
||||||
ctx: RunContext[RAGRunDeps], query: str, limit: int | None = None
|
ctx: RunContext[RAGRunDeps], query: str, limit: int | None = None
|
||||||
) -> str:
|
) -> str | ToolReturn:
|
||||||
"""Search the knowledge base using hybrid search (vector + full-text).
|
"""Search the knowledge base using hybrid search (vector + full-text).
|
||||||
|
|
||||||
Returns ranked results with content and metadata.
|
Returns ranked results with content and metadata. When picture
|
||||||
|
content is in the result set and the configured QA model is
|
||||||
|
vision-capable (``qa.model.vision = true``), picture bytes are
|
||||||
|
attached as ``BinaryContent`` parts so the model sees figures
|
||||||
|
alongside text.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The search query.
|
query: The search query.
|
||||||
|
|
@ -192,6 +198,29 @@ def create_skill_tools(
|
||||||
)
|
)
|
||||||
if state:
|
if state:
|
||||||
state.searches[query] = results
|
state.searches[query] = results
|
||||||
|
|
||||||
|
if not config.qa.model.vision:
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
binary_parts: list[BinaryContent] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for result in results:
|
||||||
|
if not result.image_data:
|
||||||
|
continue
|
||||||
|
for self_ref, b64 in result.image_data.items():
|
||||||
|
if self_ref in seen:
|
||||||
|
continue
|
||||||
|
binary_parts.append(
|
||||||
|
BinaryContent(
|
||||||
|
data=base64.b64decode(b64),
|
||||||
|
media_type="image/png",
|
||||||
|
identifier=self_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen.add(self_ref)
|
||||||
|
|
||||||
|
if binary_parts:
|
||||||
|
return ToolReturn(return_value=formatted, content=binary_parts)
|
||||||
return formatted
|
return formatted
|
||||||
|
|
||||||
tools["search"] = search
|
tools["search"] = search
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ Available modules: `json`, `re`, `math`, `pathlib`
|
||||||
Not supported: class definitions, generators/yield, match statements, decorators, `with` statements
|
Not supported: class definitions, generators/yield, match statements, decorators, `with` statements
|
||||||
|
|
||||||
### search
|
### search
|
||||||
Search the knowledge base directly (outside code execution). Use for initial exploration before writing code.
|
Search the knowledge base directly (outside code execution). Use for initial exploration before writing code. Each result has a `Type:` (paragraph, table, code, list_item, picture). When the Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text — use it directly to answer questions about figures, diagrams, charts, screenshots.
|
||||||
|
|
||||||
### list_documents
|
### list_documents
|
||||||
List available documents. Use to discover what's in the knowledge base.
|
List available documents. Use to discover what's in the knowledge base.
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@ Search the knowledge base using hybrid search (vector + full-text). Returns rank
|
||||||
Each result includes:
|
Each result includes:
|
||||||
- `chunk_id` in brackets and rank position (rank 1 = most relevant)
|
- `chunk_id` in brackets and rank position (rank 1 = most relevant)
|
||||||
- Source: document title and section hierarchy
|
- Source: document title and section hierarchy
|
||||||
- Type: content type (paragraph, table, code, list_item)
|
- Type: content type (paragraph, table, code, list_item, picture)
|
||||||
- Content: the actual text
|
- Content: the actual text
|
||||||
|
|
||||||
|
When a result's Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text. Use the image directly to answer questions about figures, diagrams, charts, screenshots.
|
||||||
|
|
||||||
### list_documents
|
### list_documents
|
||||||
List available documents in the knowledge base. Use when the user wants to browse what's available.
|
List available documents in the knowledge base. Use when the user wants to browse what's available.
|
||||||
|
|
||||||
|
|
|
||||||
187
tests/test_skill_tools.py
Normal file
187
tests/test_skill_tools.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
"""Tests for skill tool closures from ``haiku.rag.skills._tools.create_skill_tools``.
|
||||||
|
|
||||||
|
These cover the vision toggle on the skill ``search`` tool: when the configured
|
||||||
|
QA model is vision-capable, picture bytes from search results must reach the
|
||||||
|
sub-agent as ``BinaryContent`` parts (so a vision model can read figures).
|
||||||
|
When the QA model is not vision-capable, the same search must return plain
|
||||||
|
text only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic_ai import RunContext
|
||||||
|
from pydantic_ai.messages import BinaryContent, ToolReturn
|
||||||
|
from pydantic_ai.models.test import TestModel
|
||||||
|
from pydantic_ai.usage import RunUsage
|
||||||
|
|
||||||
|
from haiku.rag.config import AppConfig
|
||||||
|
from haiku.rag.skills._deps import RAGRunDeps
|
||||||
|
from haiku.rag.skills._tools import create_skill_tools
|
||||||
|
from haiku.rag.skills.rag import RAGState
|
||||||
|
from haiku.rag.store.models.chunk import SearchResult
|
||||||
|
|
||||||
|
PICTURE_BYTES = b"\x89PNG\r\n\x1a\nfake-picture-bytes"
|
||||||
|
PICTURE_B64 = base64.b64encode(PICTURE_BYTES).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _picture_result() -> SearchResult:
|
||||||
|
return SearchResult(
|
||||||
|
content="A diagram of the layout",
|
||||||
|
score=1.0,
|
||||||
|
chunk_id="chunk-1",
|
||||||
|
document_id="doc-1",
|
||||||
|
doc_item_refs=["#/pictures/0"],
|
||||||
|
labels=["picture"],
|
||||||
|
image_data={"#/pictures/0": PICTURE_B64},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text_result() -> SearchResult:
|
||||||
|
return SearchResult(
|
||||||
|
content="Some surrounding paragraph text",
|
||||||
|
score=0.9,
|
||||||
|
chunk_id="chunk-2",
|
||||||
|
document_id="doc-1",
|
||||||
|
doc_item_refs=["#/texts/3"],
|
||||||
|
labels=["paragraph"],
|
||||||
|
image_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ctx(rag, state: RAGState) -> RunContext[RAGRunDeps]:
|
||||||
|
deps = RAGRunDeps(state=state, rag=rag)
|
||||||
|
return RunContext(
|
||||||
|
deps=deps,
|
||||||
|
model=TestModel(),
|
||||||
|
usage=RunUsage(),
|
||||||
|
run_id="run-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_search_tool(config: AppConfig):
|
||||||
|
tools = create_skill_tools(
|
||||||
|
db_path=Path("/tmp/unused.lancedb"),
|
||||||
|
config=config,
|
||||||
|
state_type=RAGState,
|
||||||
|
tool_names=["search"],
|
||||||
|
)
|
||||||
|
return tools["search"]
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_rag(results: list[SearchResult]) -> AsyncMock:
|
||||||
|
rag = AsyncMock()
|
||||||
|
rag.search = AsyncMock(return_value=results)
|
||||||
|
rag.expand_context = AsyncMock(return_value=results)
|
||||||
|
return rag
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_search_attaches_binary_content_when_vision_capable():
|
||||||
|
"""vision=True + picture in results → ToolReturn carries BinaryContent."""
|
||||||
|
config = AppConfig()
|
||||||
|
config.qa.model.vision = True
|
||||||
|
|
||||||
|
search = _build_search_tool(config)
|
||||||
|
rag = _fake_rag([_picture_result()])
|
||||||
|
ctx = _make_ctx(rag, RAGState())
|
||||||
|
|
||||||
|
result = await search(ctx, "diagram")
|
||||||
|
|
||||||
|
assert isinstance(result, ToolReturn)
|
||||||
|
assert isinstance(result.return_value, str)
|
||||||
|
assert "rank 1" in result.return_value
|
||||||
|
assert result.content is not None
|
||||||
|
assert len(result.content) == 1
|
||||||
|
part = result.content[0]
|
||||||
|
assert isinstance(part, BinaryContent)
|
||||||
|
assert part.data == PICTURE_BYTES
|
||||||
|
assert part.media_type == "image/png"
|
||||||
|
assert part.identifier == "#/pictures/0"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_search_returns_plain_string_when_not_vision_capable():
|
||||||
|
"""vision=False (the default) + picture in results → plain text only.
|
||||||
|
The picture bytes must not reach a text-only model — providers behave
|
||||||
|
inconsistently with image content (Ollama silently accepts and the
|
||||||
|
model hallucinates)."""
|
||||||
|
config = AppConfig()
|
||||||
|
assert config.qa.model.vision is False
|
||||||
|
|
||||||
|
search = _build_search_tool(config)
|
||||||
|
rag = _fake_rag([_picture_result()])
|
||||||
|
ctx = _make_ctx(rag, RAGState())
|
||||||
|
|
||||||
|
result = await search(ctx, "diagram")
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "rank 1" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_search_returns_plain_string_when_no_pictures():
|
||||||
|
"""vision=True + no pictures in results → no ToolReturn wrapper, just
|
||||||
|
text. The wrapper is only needed when there's actually image content
|
||||||
|
to carry."""
|
||||||
|
config = AppConfig()
|
||||||
|
config.qa.model.vision = True
|
||||||
|
|
||||||
|
search = _build_search_tool(config)
|
||||||
|
rag = _fake_rag([_text_result()])
|
||||||
|
ctx = _make_ctx(rag, RAGState())
|
||||||
|
|
||||||
|
result = await search(ctx, "paragraph")
|
||||||
|
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "rank 1" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_search_records_results_into_state():
|
||||||
|
"""Whether or not the QA model is vision-capable, the SearchResult
|
||||||
|
list must land in state.searches[query] so cite/visualize_chunk can
|
||||||
|
look chunks up later."""
|
||||||
|
config = AppConfig()
|
||||||
|
|
||||||
|
search = _build_search_tool(config)
|
||||||
|
rag = _fake_rag([_picture_result(), _text_result()])
|
||||||
|
state = RAGState()
|
||||||
|
ctx = _make_ctx(rag, state)
|
||||||
|
|
||||||
|
await search(ctx, "anything")
|
||||||
|
|
||||||
|
assert "anything" in state.searches
|
||||||
|
assert len(state.searches["anything"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skill_search_dedups_picture_bytes_by_self_ref():
|
||||||
|
"""When two search results reference the same picture self_ref (e.g. a
|
||||||
|
text chunk and a synthetic picture chunk), the BinaryContent list
|
||||||
|
must include that picture exactly once. Otherwise the model receives
|
||||||
|
duplicate image content and pays double the image-token cost."""
|
||||||
|
config = AppConfig()
|
||||||
|
config.qa.model.vision = True
|
||||||
|
|
||||||
|
other = SearchResult(
|
||||||
|
content="Surrounding text mentioning the figure",
|
||||||
|
score=0.8,
|
||||||
|
chunk_id="chunk-3",
|
||||||
|
document_id="doc-1",
|
||||||
|
doc_item_refs=["#/texts/2", "#/pictures/0"],
|
||||||
|
labels=["paragraph", "picture"],
|
||||||
|
image_data={"#/pictures/0": PICTURE_B64},
|
||||||
|
)
|
||||||
|
search = _build_search_tool(config)
|
||||||
|
rag = _fake_rag([_picture_result(), other])
|
||||||
|
ctx = _make_ctx(rag, RAGState())
|
||||||
|
|
||||||
|
result = await search(ctx, "figure")
|
||||||
|
|
||||||
|
assert isinstance(result, ToolReturn)
|
||||||
|
assert result.content is not None
|
||||||
|
assert len(result.content) == 1
|
||||||
|
assert result.content[0].identifier == "#/pictures/0" # type: ignore[attr-defined]
|
||||||
Loading…
Reference in a new issue