Files
James BruntonandGitHub 1264f4cfed Set up document management for Stirling Engine (#6476)
# Description of Changes
Change Stirling Engine to support deleting documents automatically. This
happens both on user logout and after an amount of time specified by the
Java when ingesting a document (allowing for personal documents to have
short lifetimes but org documents to be left in the db with no expiry
date). Also sets up an [ACL
policy](https://en.wikipedia.org/wiki/Access-control_list) for the
documents so the database knows which users have access to which
documents. This is not fully implemented in the Java, so currently all
docs are treated as having a single owner, the uploader, but
theoretically when we need to support org storage, we shouldn't need to
change the db schema.
2026-06-03 11:52:11 +00:00

141 lines
4.8 KiB
Python

"""PdfQuestionAgent — contradiction capability wiring.
The smart-model agent picks the right tool based on the question; here
we don't drive the smart model — we directly verify that the agent
wires the contradiction capability into its toolset alongside RAG and
the whole-document reader, and that the capability dispatches to the
detector when invoked.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import replace
import pytest
from pydantic_ai.toolsets import FunctionToolset
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.contracts import (
AiFile,
PageText,
PdfQuestionRequest,
)
from stirling.contracts.contradiction import Claim
from stirling.documents import DocumentService, SqliteVecStore
from stirling.models import FileId, OwnerId, PrincipalId, UserId
from stirling.services import current_user_id
from stirling.services.runtime import AppRuntime
from tests.test_pdf_question_agent import StubEmbedder
USER = UserId("test-user")
OWNER = OwnerId("test-user")
OWNER_PRINCIPALS = [PrincipalId("test-user")]
@pytest.fixture(autouse=True)
def _set_user_context() -> Iterator[None]:
token = current_user_id.set(USER)
try:
yield
finally:
current_user_id.reset(token)
def _file(file_id: str, name: str) -> AiFile:
return AiFile(id=FileId(file_id), name=name)
def _claim(page: int, quote: str) -> Claim:
return Claim(
page=page,
subject="deadline",
polarity="assert",
text=f"paraphrase {page}",
quote=quote,
)
@pytest.fixture
def runtime_with_stub_docs(runtime: AppRuntime) -> AppRuntime:
stub = DocumentService(
embedder=StubEmbedder(), # type: ignore[arg-type]
store=SqliteVecStore.ephemeral(),
default_top_k=runtime.settings.rag_default_top_k,
)
return replace(runtime, documents=stub)
@pytest.mark.anyio
async def test_run_answer_agent_builds_agent_with_three_toolsets(
runtime_with_stub_docs: AppRuntime,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``_run_answer_agent`` constructs an ``Agent`` with all three retrieval
toolsets (rag, whole-doc, contradiction). We intercept the Agent
constructor and inspect what was wired.
Uses pytest's ``monkeypatch`` fixture rather than direct attribute
assignment so pyright sees the swap as a typed test-only operation
and restoration is automatic if the test raises.
"""
file = _file("doc-a", "a.pdf")
await runtime_with_stub_docs.documents.ingest(
file.id,
[PageText(page_number=1, text="content")],
source=file.name,
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
agent = PdfQuestionAgent(runtime_with_stub_docs)
captured: dict[str, object] = {}
import pydantic_ai
real_agent_init = pydantic_ai.Agent.__init__
# The Agent class is generic on deps/output types — its __init__ accepts
# arbitrary positional+keyword arguments depending on those parameters.
# We're monkey-patching the class itself for one test, so the bound
# method's signature is intentionally opaque here. Typing through Any
# is honest about that boundary ("we can't statically describe it")
# and avoids wallpapering the body with type-ignore directives.
from typing import Any
def _capture_init(self: Any, *args: Any, **kwargs: Any) -> None:
captured["toolsets"] = kwargs.get("toolsets")
captured["instructions"] = kwargs.get("instructions")
# Call the real init for safety.
real_agent_init(self, *args, **kwargs)
# Stub the agent's `.run` so we don't reach a real model.
async def _stub_run(self: Any, *args: Any, **kwargs: Any) -> object:
class _Result:
output = "stubbed"
return _Result()
monkeypatch.setattr(pydantic_ai.Agent, "__init__", _capture_init)
monkeypatch.setattr(pydantic_ai.Agent, "run", _stub_run)
await agent._run_answer_agent(PdfQuestionRequest(question="any conflicts?", files=[file]))
toolsets = captured.get("toolsets")
assert isinstance(toolsets, list)
assert len(toolsets) == 3
# Inspect the registered tool names. A regression that double-wired
# one capability (e.g. two ``rag.toolset`` and dropping
# ``contradiction.toolset``) would still satisfy ``len == 3`` but
# the union of tool names would not include ``find_contradictions``.
tool_names: set[str] = set()
for ts in toolsets:
assert isinstance(ts, FunctionToolset), f"expected FunctionToolset, got {type(ts).__name__}"
tool_names.update(ts.tools.keys())
assert tool_names == {"search_knowledge", "read_full_document", "find_contradictions"}, (
f"unexpected toolset wiring; tool names = {sorted(tool_names)}"
)