mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
# 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.
185 lines
6.2 KiB
Python
185 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from dataclasses import replace
|
|
|
|
import pytest
|
|
|
|
from stirling.agents import PdfQuestionAgent
|
|
from stirling.contracts import (
|
|
AiFile,
|
|
ExtractedFileText,
|
|
NeedIngestResponse,
|
|
PageText,
|
|
PdfContentType,
|
|
PdfQuestionAnswerResponse,
|
|
PdfQuestionNotFoundResponse,
|
|
PdfQuestionRequest,
|
|
PdfQuestionTerminalResponse,
|
|
PdfTextSelection,
|
|
SupportedCapability,
|
|
)
|
|
from stirling.documents import Document, DocumentService, SqliteVecStore
|
|
from stirling.models import FileId, OwnerId, PrincipalId, UserId
|
|
from stirling.services import current_user_id
|
|
from stirling.services.runtime import AppRuntime
|
|
|
|
USER = UserId("test-user")
|
|
OWNER = OwnerId("test-user")
|
|
OWNER_PRINCIPALS = [PrincipalId("test-user")]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _set_user_context() -> Iterator[None]:
|
|
"""Set current_user_id to a fixed test user for every test in this module so
|
|
agent code calling require_current_user_id() doesn't fail closed."""
|
|
token = current_user_id.set(USER)
|
|
try:
|
|
yield
|
|
finally:
|
|
current_user_id.reset(token)
|
|
|
|
|
|
class StubEmbedder:
|
|
"""Deterministic embeddings so RAG lookups work in tests without network."""
|
|
|
|
def __init__(self, dim: int = 8) -> None:
|
|
self._dim = dim
|
|
|
|
async def embed_query(self, text: str) -> list[float]:
|
|
h = hash(text) % 1000
|
|
return [(h + i) / 1000.0 for i in range(self._dim)]
|
|
|
|
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
return [await self.embed_query(t) for t in texts]
|
|
|
|
def chunk_and_prepare(
|
|
self,
|
|
text: str,
|
|
source: str = "",
|
|
base_metadata: dict[str, str] | None = None,
|
|
) -> list[Document]:
|
|
from stirling.documents.chunker import chunk_text
|
|
|
|
chunks = chunk_text(text, 100, 10)
|
|
docs: list[Document] = []
|
|
for i, chunk in enumerate(chunks):
|
|
meta = dict(base_metadata) if base_metadata else {}
|
|
meta["source"] = source
|
|
meta["chunk_index"] = str(i)
|
|
doc_id = f"{source}:chunk:{i}" if source else f"chunk:{i}"
|
|
docs.append(Document(id=doc_id, text=chunk, metadata=meta))
|
|
return docs
|
|
|
|
|
|
class StubPdfQuestionAgent(PdfQuestionAgent):
|
|
def __init__(self, runtime: AppRuntime, response: PdfQuestionTerminalResponse) -> None:
|
|
super().__init__(runtime)
|
|
self._response = response
|
|
|
|
async def _run_answer_agent(self, request: PdfQuestionRequest) -> PdfQuestionTerminalResponse:
|
|
return self._response
|
|
|
|
|
|
@pytest.fixture
|
|
def runtime_with_stub_rag(runtime: AppRuntime) -> AppRuntime:
|
|
"""A runtime whose document service uses a stub embedder + ephemeral store."""
|
|
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_requests_ingest_when_file_missing_from_rag(runtime_with_stub_rag: AppRuntime) -> None:
|
|
agent = PdfQuestionAgent(runtime_with_stub_rag)
|
|
|
|
missing_file = AiFile(id=FileId("missing-id"), name="missing.pdf")
|
|
response = await agent.handle(PdfQuestionRequest(question="What is the total?", files=[missing_file]))
|
|
|
|
assert isinstance(response, NeedIngestResponse)
|
|
assert response.resume_with == SupportedCapability.PDF_QUESTION
|
|
assert response.files_to_ingest == [missing_file]
|
|
assert PdfContentType.PAGE_TEXT in response.content_types
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_reports_only_missing_files(runtime_with_stub_rag: AppRuntime) -> None:
|
|
await runtime_with_stub_rag.documents.ingest(
|
|
FileId("present-id"),
|
|
[PageText(page_number=1, text="Invoice total: 120.00.")],
|
|
source="present.pdf",
|
|
owner_id=OWNER,
|
|
read_principals=OWNER_PRINCIPALS,
|
|
expires_at=None,
|
|
)
|
|
agent = PdfQuestionAgent(runtime_with_stub_rag)
|
|
|
|
present_file = AiFile(id=FileId("present-id"), name="present.pdf")
|
|
missing_file = AiFile(id=FileId("missing-id"), name="missing.pdf")
|
|
response = await agent.handle(PdfQuestionRequest(question="What is the total?", files=[present_file, missing_file]))
|
|
|
|
assert isinstance(response, NeedIngestResponse)
|
|
assert response.files_to_ingest == [missing_file]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_returns_grounded_answer_when_all_files_ingested(runtime_with_stub_rag: AppRuntime) -> None:
|
|
await runtime_with_stub_rag.documents.ingest(
|
|
FileId("invoice-id"),
|
|
[PageText(page_number=1, text="Invoice total: 120.00.")],
|
|
source="invoice.pdf",
|
|
owner_id=OWNER,
|
|
read_principals=OWNER_PRINCIPALS,
|
|
expires_at=None,
|
|
)
|
|
agent = StubPdfQuestionAgent(
|
|
runtime_with_stub_rag,
|
|
PdfQuestionAnswerResponse(
|
|
answer="The invoice total is 120.00.",
|
|
evidence=[
|
|
ExtractedFileText(
|
|
file_name="invoice.pdf",
|
|
pages=[PdfTextSelection(page_number=1, text="Invoice total: 120.00")],
|
|
)
|
|
],
|
|
),
|
|
)
|
|
|
|
response = await agent.handle(
|
|
PdfQuestionRequest(
|
|
question="What is the total?",
|
|
files=[AiFile(id=FileId("invoice-id"), name="invoice.pdf")],
|
|
)
|
|
)
|
|
|
|
assert isinstance(response, PdfQuestionAnswerResponse)
|
|
assert response.answer == "The invoice total is 120.00."
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_returns_not_found_when_answer_not_in_doc(runtime_with_stub_rag: AppRuntime) -> None:
|
|
await runtime_with_stub_rag.documents.ingest(
|
|
FileId("shipping-id"),
|
|
[PageText(page_number=1, text="This page contains only a shipping address.")],
|
|
source="shipping.pdf",
|
|
owner_id=OWNER,
|
|
read_principals=OWNER_PRINCIPALS,
|
|
expires_at=None,
|
|
)
|
|
agent = StubPdfQuestionAgent(
|
|
runtime_with_stub_rag,
|
|
PdfQuestionNotFoundResponse(reason="The answer is not present in the text."),
|
|
)
|
|
|
|
response = await agent.handle(
|
|
PdfQuestionRequest(
|
|
question="What is the total?",
|
|
files=[AiFile(id=FileId("shipping-id"), name="shipping.pdf")],
|
|
)
|
|
)
|
|
|
|
assert isinstance(response, PdfQuestionNotFoundResponse)
|