Update search(), ask() to use get_or_assign_index()
This commit is contained in:
parent
fa2072a720
commit
8f35033a42
4 changed files with 320 additions and 37 deletions
|
|
@ -97,22 +97,28 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
if not results:
|
if not results:
|
||||||
return ToolReturn(return_value="No results found.")
|
return ToolReturn(return_value="No results found.")
|
||||||
|
|
||||||
# Build citation infos for frontend display
|
# Build citation infos using stable registry indices
|
||||||
citation_infos = [
|
citation_infos = []
|
||||||
Citation(
|
for r in results:
|
||||||
index=i + 1,
|
chunk_id = r.chunk_id or ""
|
||||||
document_id=r.document_id or "",
|
if ctx.deps.session_state is not None and chunk_id:
|
||||||
chunk_id=r.chunk_id or "",
|
index = ctx.deps.session_state.get_or_assign_index(chunk_id)
|
||||||
document_uri=r.document_uri or "",
|
else:
|
||||||
document_title=r.document_title,
|
index = len(citation_infos) + 1
|
||||||
page_numbers=r.page_numbers or [],
|
citation_infos.append(
|
||||||
headings=r.headings,
|
Citation(
|
||||||
content=r.content,
|
index=index,
|
||||||
|
document_id=r.document_id or "",
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
document_uri=r.document_uri or "",
|
||||||
|
document_title=r.document_title,
|
||||||
|
page_numbers=r.page_numbers or [],
|
||||||
|
headings=r.headings,
|
||||||
|
content=r.content,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for i, r in enumerate(results)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Build new state with citations
|
# Build new state with citations and registry
|
||||||
session_id = ctx.deps.session_state.session_id if ctx.deps.session_state else ""
|
session_id = ctx.deps.session_state.session_id if ctx.deps.session_state else ""
|
||||||
new_state = ChatSessionState(
|
new_state = ChatSessionState(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
|
@ -126,20 +132,25 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
document_filter=(
|
document_filter=(
|
||||||
ctx.deps.session_state.document_filter if ctx.deps.session_state else []
|
ctx.deps.session_state.document_filter if ctx.deps.session_state else []
|
||||||
),
|
),
|
||||||
|
citation_registry=(
|
||||||
|
ctx.deps.session_state.citation_registry
|
||||||
|
if ctx.deps.session_state
|
||||||
|
else {}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return detailed results for the agent to present
|
# Return detailed results for the agent to present
|
||||||
result_lines = []
|
result_lines = []
|
||||||
for i, r in enumerate(results):
|
for c in citation_infos:
|
||||||
title = r.document_title or r.document_uri or "Unknown"
|
title = c.document_title or c.document_uri or "Unknown"
|
||||||
# Truncate content for display
|
# Truncate content for display
|
||||||
snippet = r.content[:300].replace("\n", " ").strip()
|
snippet = c.content[:300].replace("\n", " ").strip()
|
||||||
if len(r.content) > 300:
|
if len(c.content) > 300:
|
||||||
snippet += "..."
|
snippet += "..."
|
||||||
|
|
||||||
line = f"[{i + 1}] **{title}**"
|
line = f"[{c.index}] **{title}**"
|
||||||
if r.page_numbers:
|
if c.page_numbers:
|
||||||
line += f" (pages {', '.join(map(str, r.page_numbers))})"
|
line += f" (pages {', '.join(map(str, c.page_numbers))})"
|
||||||
line += f"\n {snippet}"
|
line += f"\n {snippet}"
|
||||||
result_lines.append(line)
|
result_lines.append(line)
|
||||||
|
|
||||||
|
|
@ -215,20 +226,26 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
|
|
||||||
result = await graph.run(state=state, deps=deps)
|
result = await graph.run(state=state, deps=deps)
|
||||||
|
|
||||||
# Build citation infos for frontend and history
|
# Build citation infos using stable registry indices
|
||||||
citation_infos = [
|
citation_infos = []
|
||||||
Citation(
|
for c in result.citations:
|
||||||
index=i + 1,
|
# Use registry for stable indices across calls
|
||||||
document_id=c.document_id,
|
if ctx.deps.session_state is not None:
|
||||||
chunk_id=c.chunk_id,
|
index = ctx.deps.session_state.get_or_assign_index(c.chunk_id)
|
||||||
document_uri=c.document_uri,
|
else:
|
||||||
document_title=c.document_title,
|
index = len(citation_infos) + 1
|
||||||
page_numbers=c.page_numbers,
|
citation_infos.append(
|
||||||
headings=c.headings,
|
Citation(
|
||||||
content=c.content,
|
index=index,
|
||||||
|
document_id=c.document_id,
|
||||||
|
chunk_id=c.chunk_id,
|
||||||
|
document_uri=c.document_uri,
|
||||||
|
document_title=c.document_title,
|
||||||
|
page_numbers=c.page_numbers,
|
||||||
|
headings=c.headings,
|
||||||
|
content=c.content,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for i, c in enumerate(result.citations)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Accumulate Q&A in session state with full citation metadata
|
# Accumulate Q&A in session state with full citation metadata
|
||||||
if ctx.deps.session_state is not None:
|
if ctx.deps.session_state is not None:
|
||||||
|
|
@ -260,7 +277,7 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
_summarization_tasks[session_id] = task
|
_summarization_tasks[session_id] = task
|
||||||
task.add_done_callback(lambda t: _summarization_tasks.pop(session_id, None))
|
task.add_done_callback(lambda t: _summarization_tasks.pop(session_id, None))
|
||||||
|
|
||||||
# Build new state with citations AND accumulated qa_history
|
# Build new state with citations, qa_history, and registry
|
||||||
new_state = ChatSessionState(
|
new_state = ChatSessionState(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
citations=citation_infos,
|
citations=citation_infos,
|
||||||
|
|
@ -273,12 +290,17 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
document_filter=(
|
document_filter=(
|
||||||
ctx.deps.session_state.document_filter if ctx.deps.session_state else []
|
ctx.deps.session_state.document_filter if ctx.deps.session_state else []
|
||||||
),
|
),
|
||||||
|
citation_registry=(
|
||||||
|
ctx.deps.session_state.citation_registry
|
||||||
|
if ctx.deps.session_state
|
||||||
|
else {}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format answer with citation references and confidence
|
# Format answer with citation references using stable indices
|
||||||
answer_text = result.answer
|
answer_text = result.answer
|
||||||
if citation_infos:
|
if citation_infos:
|
||||||
citation_refs = " ".join(f"[{i + 1}]" for i in range(len(citation_infos)))
|
citation_refs = " ".join(f"[{c.index}]" for c in citation_infos)
|
||||||
answer_text = f"{answer_text}\n\nSources: {citation_refs}"
|
answer_text = f"{answer_text}\n\nSources: {citation_refs}"
|
||||||
|
|
||||||
snapshot = new_state.model_dump()
|
snapshot = new_state.model_dump()
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,21 @@ class ChatSessionState(BaseModel):
|
||||||
qa_history: list[QAResponse] = []
|
qa_history: list[QAResponse] = []
|
||||||
session_context: SessionContext | None = None
|
session_context: SessionContext | None = None
|
||||||
document_filter: list[str] = []
|
document_filter: list[str] = []
|
||||||
|
citation_registry: dict[str, int] = {}
|
||||||
|
|
||||||
|
def get_or_assign_index(self, chunk_id: str) -> int:
|
||||||
|
"""Get or assign a stable citation index for a chunk_id.
|
||||||
|
|
||||||
|
Citation indices persist across tool calls within a session.
|
||||||
|
The first chunk gets index 1, subsequent new chunks get incrementing indices.
|
||||||
|
Same chunk_id always returns the same index.
|
||||||
|
"""
|
||||||
|
if chunk_id in self.citation_registry:
|
||||||
|
return self.citation_registry[chunk_id]
|
||||||
|
|
||||||
|
new_index = len(self.citation_registry) + 1
|
||||||
|
self.citation_registry[chunk_id] = new_index
|
||||||
|
return new_index
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -103,6 +118,8 @@ class ChatDeps:
|
||||||
self.session_state.document_filter = state_data.get(
|
self.session_state.document_filter = state_data.get(
|
||||||
"document_filter", []
|
"document_filter", []
|
||||||
)
|
)
|
||||||
|
if "citation_registry" in state_data:
|
||||||
|
self.session_state.citation_registry = state_data["citation_registry"]
|
||||||
# NOTE: session_context intentionally NOT updated from client
|
# NOTE: session_context intentionally NOT updated from client
|
||||||
# The agent owns this via server-side cache
|
# The agent owns this via server-side cache
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -677,3 +677,100 @@ async def test_search_agent_with_session_filter(allow_model_requests, temp_db_pa
|
||||||
assert "labels" in (r.document_uri or "").lower() or "Labels" in (
|
assert "labels" in (r.document_uri or "").lower() or "Labels" in (
|
||||||
r.document_title or ""
|
r.document_title or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_tool_citation_registry_logic():
|
||||||
|
"""Test the citation index assignment logic used by the ask tool.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
1. First chunk gets index 1
|
||||||
|
2. Second unique chunk gets index 2
|
||||||
|
3. Same chunk_id always gets same index
|
||||||
|
4. Indices don't reset between calls
|
||||||
|
"""
|
||||||
|
session_state = ChatSessionState(session_id="test-registry")
|
||||||
|
|
||||||
|
# Simulate first ask tool building citations
|
||||||
|
first_ask_chunks = ["chunk-a", "chunk-b"]
|
||||||
|
first_citations = []
|
||||||
|
for chunk_id in first_ask_chunks:
|
||||||
|
index = session_state.get_or_assign_index(chunk_id)
|
||||||
|
first_citations.append(
|
||||||
|
Citation(
|
||||||
|
index=index,
|
||||||
|
document_id="doc-1",
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
document_uri="test.md",
|
||||||
|
content="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first_citations[0].index == 1
|
||||||
|
assert first_citations[1].index == 2
|
||||||
|
|
||||||
|
# Simulate second ask tool - overlapping chunk_id should keep same index
|
||||||
|
second_ask_chunks = ["chunk-b", "chunk-c"] # chunk-b was in first ask
|
||||||
|
second_citations = []
|
||||||
|
for chunk_id in second_ask_chunks:
|
||||||
|
index = session_state.get_or_assign_index(chunk_id)
|
||||||
|
second_citations.append(
|
||||||
|
Citation(
|
||||||
|
index=index,
|
||||||
|
document_id="doc-1",
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
document_uri="test.md",
|
||||||
|
content="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# chunk-b should have same index as before
|
||||||
|
assert second_citations[0].index == 2
|
||||||
|
# chunk-c is new, gets next index
|
||||||
|
assert second_citations[1].index == 3
|
||||||
|
|
||||||
|
# Registry should have all three chunks
|
||||||
|
assert len(session_state.citation_registry) == 3
|
||||||
|
assert session_state.citation_registry == {"chunk-a": 1, "chunk-b": 2, "chunk-c": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_tool_citation_registry_logic():
|
||||||
|
"""Test the citation index assignment logic used by the search tool.
|
||||||
|
|
||||||
|
Verifies that search and ask tools share the same registry,
|
||||||
|
maintaining stable indices across different tool calls.
|
||||||
|
"""
|
||||||
|
session_state = ChatSessionState(session_id="test-registry")
|
||||||
|
|
||||||
|
# Simulate ask tool first (assigns indices 1, 2)
|
||||||
|
for chunk_id in ["chunk-a", "chunk-b"]:
|
||||||
|
session_state.get_or_assign_index(chunk_id)
|
||||||
|
|
||||||
|
# Simulate search tool returning overlapping + new chunks
|
||||||
|
search_chunks = ["chunk-b", "chunk-c", "chunk-d"] # chunk-b already exists
|
||||||
|
search_citations = []
|
||||||
|
for chunk_id in search_chunks:
|
||||||
|
index = session_state.get_or_assign_index(chunk_id)
|
||||||
|
search_citations.append(
|
||||||
|
Citation(
|
||||||
|
index=index,
|
||||||
|
document_id="doc-1",
|
||||||
|
chunk_id=chunk_id,
|
||||||
|
document_uri="test.md",
|
||||||
|
content="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# chunk-b should have same index as assigned by ask (2)
|
||||||
|
assert search_citations[0].index == 2
|
||||||
|
# New chunks get incrementing indices
|
||||||
|
assert search_citations[1].index == 3
|
||||||
|
assert search_citations[2].index == 4
|
||||||
|
|
||||||
|
# Registry should have all four chunks
|
||||||
|
assert len(session_state.citation_registry) == 4
|
||||||
|
assert session_state.citation_registry == {
|
||||||
|
"chunk-a": 1,
|
||||||
|
"chunk-b": 2,
|
||||||
|
"chunk-c": 3,
|
||||||
|
"chunk-d": 4,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,153 @@ def test_chat_deps_state_setter_ignores_session_context():
|
||||||
assert deps.session_state.session_context.summary == "Server-side context"
|
assert deps.session_state.session_context.summary == "Server-side context"
|
||||||
|
|
||||||
|
|
||||||
|
def test_citation_registry_get_or_assign_index_first_chunk():
|
||||||
|
"""Test get_or_assign_index assigns index 1 to first chunk."""
|
||||||
|
from haiku.rag.agents.chat.state import ChatSessionState
|
||||||
|
|
||||||
|
session_state = ChatSessionState(session_id="test")
|
||||||
|
index = session_state.get_or_assign_index("chunk-abc")
|
||||||
|
assert index == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_citation_registry_get_or_assign_index_second_chunk():
|
||||||
|
"""Test get_or_assign_index assigns incremental indices to new chunks."""
|
||||||
|
from haiku.rag.agents.chat.state import ChatSessionState
|
||||||
|
|
||||||
|
session_state = ChatSessionState(session_id="test")
|
||||||
|
index1 = session_state.get_or_assign_index("chunk-abc")
|
||||||
|
index2 = session_state.get_or_assign_index("chunk-def")
|
||||||
|
assert index1 == 1
|
||||||
|
assert index2 == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_citation_registry_get_or_assign_index_same_chunk():
|
||||||
|
"""Test get_or_assign_index returns same index for same chunk_id."""
|
||||||
|
from haiku.rag.agents.chat.state import ChatSessionState
|
||||||
|
|
||||||
|
session_state = ChatSessionState(session_id="test")
|
||||||
|
index1 = session_state.get_or_assign_index("chunk-abc")
|
||||||
|
index2 = session_state.get_or_assign_index("chunk-abc")
|
||||||
|
assert index1 == index2 == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_citation_registry_get_or_assign_index_stability():
|
||||||
|
"""Test citation indices are stable across multiple calls."""
|
||||||
|
from haiku.rag.agents.chat.state import ChatSessionState
|
||||||
|
|
||||||
|
session_state = ChatSessionState(session_id="test")
|
||||||
|
|
||||||
|
# First call assigns indices 1, 2, 3
|
||||||
|
idx_a = session_state.get_or_assign_index("chunk-a")
|
||||||
|
idx_b = session_state.get_or_assign_index("chunk-b")
|
||||||
|
idx_c = session_state.get_or_assign_index("chunk-c")
|
||||||
|
|
||||||
|
# Second round - existing chunks keep their indices
|
||||||
|
assert session_state.get_or_assign_index("chunk-b") == idx_b
|
||||||
|
assert session_state.get_or_assign_index("chunk-a") == idx_a
|
||||||
|
assert session_state.get_or_assign_index("chunk-c") == idx_c
|
||||||
|
|
||||||
|
# New chunk gets next index
|
||||||
|
idx_d = session_state.get_or_assign_index("chunk-d")
|
||||||
|
assert idx_d == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_citation_registry_serialization():
|
||||||
|
"""Test citation_registry is included in model_dump for AG-UI state."""
|
||||||
|
from haiku.rag.agents.chat.state import ChatSessionState
|
||||||
|
|
||||||
|
session_state = ChatSessionState(session_id="test")
|
||||||
|
session_state.get_or_assign_index("chunk-a")
|
||||||
|
session_state.get_or_assign_index("chunk-b")
|
||||||
|
|
||||||
|
state_dict = session_state.model_dump()
|
||||||
|
assert "citation_registry" in state_dict
|
||||||
|
assert state_dict["citation_registry"] == {"chunk-a": 1, "chunk-b": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_citation_registry_deserialization():
|
||||||
|
"""Test citation_registry is restored from dict."""
|
||||||
|
from haiku.rag.agents.chat.state import ChatSessionState
|
||||||
|
|
||||||
|
# Simulate state from AG-UI using model_validate (proper Pydantic deserialization)
|
||||||
|
session_state = ChatSessionState.model_validate(
|
||||||
|
{
|
||||||
|
"session_id": "test",
|
||||||
|
"citations": [],
|
||||||
|
"qa_history": [],
|
||||||
|
"citation_registry": {"chunk-a": 1, "chunk-b": 2},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Existing chunks should return their persisted indices
|
||||||
|
assert session_state.get_or_assign_index("chunk-a") == 1
|
||||||
|
assert session_state.get_or_assign_index("chunk-b") == 2
|
||||||
|
# New chunk should get next index
|
||||||
|
assert session_state.get_or_assign_index("chunk-c") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_deps_state_getter_includes_citation_registry():
|
||||||
|
"""Test ChatDeps.state getter includes citation_registry."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_config = MagicMock()
|
||||||
|
|
||||||
|
session_state = ChatSessionState(session_id="test")
|
||||||
|
session_state.get_or_assign_index("chunk-a")
|
||||||
|
|
||||||
|
deps = ChatDeps(
|
||||||
|
client=mock_client,
|
||||||
|
config=mock_config,
|
||||||
|
session_state=session_state,
|
||||||
|
state_key=AGUI_STATE_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = deps.state
|
||||||
|
assert state is not None
|
||||||
|
assert AGUI_STATE_KEY in state
|
||||||
|
assert state[AGUI_STATE_KEY]["citation_registry"] == {"chunk-a": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_deps_state_setter_restores_citation_registry():
|
||||||
|
"""Test ChatDeps.state setter restores citation_registry from incoming state."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from haiku.rag.agents.chat.state import AGUI_STATE_KEY, ChatDeps, ChatSessionState
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_config = MagicMock()
|
||||||
|
|
||||||
|
session_state = ChatSessionState(session_id="test")
|
||||||
|
deps = ChatDeps(
|
||||||
|
client=mock_client,
|
||||||
|
config=mock_config,
|
||||||
|
session_state=session_state,
|
||||||
|
state_key=AGUI_STATE_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate incoming AG-UI state with citation_registry
|
||||||
|
incoming_state = {
|
||||||
|
AGUI_STATE_KEY: {
|
||||||
|
"session_id": "test",
|
||||||
|
"qa_history": [],
|
||||||
|
"citations": [],
|
||||||
|
"citation_registry": {"chunk-x": 1, "chunk-y": 2},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.state = incoming_state
|
||||||
|
|
||||||
|
assert deps.session_state is not None
|
||||||
|
# Registry should be restored
|
||||||
|
assert deps.session_state.get_or_assign_index("chunk-x") == 1
|
||||||
|
assert deps.session_state.get_or_assign_index("chunk-y") == 2
|
||||||
|
# New chunk gets next index
|
||||||
|
assert deps.session_state.get_or_assign_index("chunk-z") == 3
|
||||||
|
|
||||||
|
|
||||||
def test_chat_deps_state_setter_restores_document_filter():
|
def test_chat_deps_state_setter_restores_document_filter():
|
||||||
"""Test ChatDeps.state setter restores document_filter from incoming state."""
|
"""Test ChatDeps.state setter restores document_filter from incoming state."""
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue