mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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.
This commit is contained in:
@@ -15,9 +15,12 @@ import pytest
|
||||
from stirling.agents.shared import ChunkedReasoner, ChunkNotes, WholeDocReaderCapability
|
||||
from stirling.contracts import AiFile, PageText
|
||||
from stirling.documents import Document, DocumentService, SqliteVecStore
|
||||
from stirling.models import FileId
|
||||
from stirling.models import FileId, OwnerId, PrincipalId
|
||||
from stirling.services.runtime import AppRuntime
|
||||
|
||||
OWNER = OwnerId("test-user")
|
||||
PRINCIPALS = [PrincipalId("test-user")]
|
||||
|
||||
|
||||
class StubEmbedder:
|
||||
"""Deterministic embeddings so tests don't need a real provider."""
|
||||
@@ -75,7 +78,9 @@ async def test_read_full_document_returns_formatted_notes_for_single_file(
|
||||
PageText(page_number=1, text="Chapter one prose."),
|
||||
PageText(page_number=2, text="Chapter two prose."),
|
||||
]
|
||||
await runtime_with_stub_docs.documents.ingest(FileId("doc-id"), pages, source="doc.pdf")
|
||||
await runtime_with_stub_docs.documents.ingest(
|
||||
FileId("doc-id"), pages, source="doc.pdf", owner_id=OWNER, read_principals=PRINCIPALS, expires_at=None
|
||||
)
|
||||
|
||||
reasoner = ChunkedReasoner(runtime_with_stub_docs)
|
||||
canned_notes = [ChunkNotes(pages=[1, 2], summary="overview", facts=["fact-A"])]
|
||||
@@ -83,6 +88,7 @@ async def test_read_full_document_returns_formatted_notes_for_single_file(
|
||||
capability = WholeDocReaderCapability(
|
||||
runtime=runtime_with_stub_docs,
|
||||
files=[_ai_file("doc-id", "doc.pdf")],
|
||||
principals=PRINCIPALS,
|
||||
reasoner=reasoner,
|
||||
)
|
||||
result = await capability._read_full_document("what is in the document?")
|
||||
@@ -106,6 +112,9 @@ async def test_read_full_document_iterates_multiple_files(runtime_with_stub_docs
|
||||
FileId(cid),
|
||||
[PageText(page_number=1, text=f"contents of {cid}")],
|
||||
source=source,
|
||||
owner_id=OWNER,
|
||||
read_principals=PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
reasoner = ChunkedReasoner(runtime_with_stub_docs)
|
||||
@@ -121,6 +130,7 @@ async def test_read_full_document_iterates_multiple_files(runtime_with_stub_docs
|
||||
capability = WholeDocReaderCapability(
|
||||
runtime=runtime_with_stub_docs,
|
||||
files=[_ai_file("doc-a", "a.pdf"), _ai_file("doc-b", "b.pdf")],
|
||||
principals=PRINCIPALS,
|
||||
reasoner=reasoner,
|
||||
)
|
||||
result = await capability._read_full_document("compare them")
|
||||
@@ -140,6 +150,9 @@ async def test_read_full_document_skips_files_without_pages(runtime_with_stub_do
|
||||
FileId("present"),
|
||||
[PageText(page_number=1, text="real content")],
|
||||
source="present.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
# 'missing' is never ingested -> read_pages returns [].
|
||||
|
||||
@@ -149,6 +162,7 @@ async def test_read_full_document_skips_files_without_pages(runtime_with_stub_do
|
||||
capability = WholeDocReaderCapability(
|
||||
runtime=runtime_with_stub_docs,
|
||||
files=[_ai_file("missing", "missing.pdf"), _ai_file("present", "present.pdf")],
|
||||
principals=PRINCIPALS,
|
||||
reasoner=reasoner,
|
||||
)
|
||||
result = await capability._read_full_document("anything")
|
||||
@@ -167,6 +181,7 @@ async def test_read_full_document_returns_empty_message_when_no_pages_anywhere(
|
||||
capability = WholeDocReaderCapability(
|
||||
runtime=runtime_with_stub_docs,
|
||||
files=[_ai_file("nope", "nope.pdf")],
|
||||
principals=PRINCIPALS,
|
||||
reasoner=reasoner,
|
||||
)
|
||||
result = await capability._read_full_document("anything")
|
||||
@@ -186,12 +201,16 @@ async def test_read_full_document_budget_hides_tool_when_exhausted(
|
||||
FileId("doc-id"),
|
||||
[PageText(page_number=1, text="content")],
|
||||
source="doc.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
reasoner = ChunkedReasoner(runtime_with_stub_docs)
|
||||
with patch.object(reasoner, "gather_notes", AsyncMock(return_value=[ChunkNotes(pages=[1], summary="s")])):
|
||||
capability = WholeDocReaderCapability(
|
||||
runtime=runtime_with_stub_docs,
|
||||
files=[_ai_file("doc-id", "doc.pdf")],
|
||||
principals=PRINCIPALS,
|
||||
reasoner=reasoner,
|
||||
max_reads=1,
|
||||
)
|
||||
@@ -212,6 +231,7 @@ async def test_instructions_mention_attached_files(runtime_with_stub_docs: AppRu
|
||||
capability = WholeDocReaderCapability(
|
||||
runtime=runtime_with_stub_docs,
|
||||
files=[_ai_file("doc-a", "alpha.pdf"), _ai_file("doc-b", "beta.pdf")],
|
||||
principals=PRINCIPALS,
|
||||
)
|
||||
text = capability.instructions
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ from stirling.contracts.contradiction import (
|
||||
ContradictionReport,
|
||||
ContradictionSeverity,
|
||||
)
|
||||
from stirling.models import FileId
|
||||
from stirling.models import FileId, PrincipalId
|
||||
from stirling.services.runtime import AppRuntime
|
||||
|
||||
PRINCIPALS = [PrincipalId("test-user")]
|
||||
|
||||
|
||||
def _file(file_id: str, name: str) -> AiFile:
|
||||
return AiFile(id=FileId(file_id), name=name)
|
||||
@@ -58,7 +60,7 @@ async def test_find_contradictions_returns_formatted_text(runtime: AppRuntime) -
|
||||
canned = _canned_report()
|
||||
detector.detect = AsyncMock(return_value=canned)
|
||||
|
||||
capability = ContradictionCapability(detector=detector, files=[_file("doc-a", "a.pdf")])
|
||||
capability = ContradictionCapability(detector=detector, files=[_file("doc-a", "a.pdf")], principals=PRINCIPALS)
|
||||
result = await capability._find_contradictions("are there inconsistent deadlines?")
|
||||
|
||||
detector.detect.assert_awaited_once()
|
||||
@@ -84,6 +86,7 @@ async def test_budget_gate_hides_tool_after_first_audit(runtime: AppRuntime) ->
|
||||
capability = ContradictionCapability(
|
||||
detector=detector,
|
||||
files=[_file("doc-a", "a.pdf")],
|
||||
principals=PRINCIPALS,
|
||||
max_audits=1,
|
||||
)
|
||||
# A real, minimal ToolDefinition — the prepare callback returns this
|
||||
@@ -107,7 +110,7 @@ async def test_budget_gate_hides_tool_after_first_audit(runtime: AppRuntime) ->
|
||||
async def test_find_contradictions_with_no_files_returns_message(runtime: AppRuntime) -> None:
|
||||
detector = ContradictionDetector(runtime)
|
||||
detector.detect = AsyncMock(return_value=_canned_report())
|
||||
capability = ContradictionCapability(detector=detector, files=[])
|
||||
capability = ContradictionCapability(detector=detector, files=[], principals=PRINCIPALS)
|
||||
|
||||
result = await capability._find_contradictions("anything")
|
||||
|
||||
@@ -120,6 +123,7 @@ def test_instructions_mention_attached_files(runtime: AppRuntime) -> None:
|
||||
capability = ContradictionCapability(
|
||||
detector=detector,
|
||||
files=[_file("doc-a", "alpha.pdf"), _file("doc-b", "beta.pdf")],
|
||||
principals=PRINCIPALS,
|
||||
)
|
||||
|
||||
text = capability.instructions
|
||||
@@ -149,6 +153,7 @@ def test_instructions_escape_filename_injection_attempt(runtime: AppRuntime) ->
|
||||
capability = ContradictionCapability(
|
||||
detector=detector,
|
||||
files=[_file("doc-evil", evil_name)],
|
||||
principals=PRINCIPALS,
|
||||
)
|
||||
|
||||
text = capability.instructions
|
||||
|
||||
@@ -27,7 +27,7 @@ from stirling.agents.shared.chunked_mapper import ChunkOutput
|
||||
from stirling.contracts import AiFile
|
||||
from stirling.contracts.contradiction import ContradictionSeverity
|
||||
from stirling.contracts.documents import Page, PageRange
|
||||
from stirling.models import FileId
|
||||
from stirling.models import FileId, PrincipalId
|
||||
from stirling.services.runtime import AppRuntime
|
||||
|
||||
|
||||
@@ -58,10 +58,17 @@ def pages_a() -> list[Page]:
|
||||
]
|
||||
|
||||
|
||||
PRINCIPALS = [PrincipalId("test-user")]
|
||||
|
||||
|
||||
def _install_documents_stub(runtime: AppRuntime, pages_by_id: dict[FileId, list[Page]]) -> None:
|
||||
"""Patch ``runtime.documents.read_pages`` to return canned pages per file."""
|
||||
|
||||
async def _read(collection: FileId, page_range: PageRange | None = None) -> list[Page]:
|
||||
async def _read(
|
||||
collection: FileId,
|
||||
principals: list[PrincipalId],
|
||||
page_range: PageRange | None = None,
|
||||
) -> list[Page]:
|
||||
return pages_by_id.get(collection, [])
|
||||
|
||||
# AppRuntime is frozen; monkey-patch the documents service.
|
||||
@@ -76,7 +83,7 @@ async def test_no_pages_returns_clean_empty_report(runtime: AppRuntime, file_a:
|
||||
_install_documents_stub(runtime, {file_a.id: []})
|
||||
detector = ContradictionDetector(runtime)
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
assert report.contradictions == []
|
||||
assert report.pages_examined == []
|
||||
@@ -126,7 +133,7 @@ async def test_happy_path_finds_contradiction_across_two_pages(
|
||||
)
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("Examined 2 pages; found 1 contradiction."))
|
||||
|
||||
report = await detector.detect([file_a], query="check the deadline")
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS, query="check the deadline")
|
||||
|
||||
assert len(report.contradictions) == 1
|
||||
c = report.contradictions[0]
|
||||
@@ -150,12 +157,12 @@ async def test_zero_claims_returns_clean_report(runtime: AppRuntime, file_a: AiF
|
||||
return_value=[ChunkOutput(pages=[1, 2], output=_ExtractedClaims(claims=[]), label="pages=1-2")]
|
||||
)
|
||||
# Stubbing the summary agent is unavoidable (the production code calls
|
||||
# it on every detect()); we just don't assert on what it returns —
|
||||
# asserting on the canned value here would only re-prove that AsyncMock
|
||||
# it on every detect()); we just don't assert on what it returns.
|
||||
# Asserting on the canned value here would only re-prove that AsyncMock
|
||||
# works.
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("any text"))
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
assert report.contradictions == []
|
||||
assert report.clean is True
|
||||
@@ -204,7 +211,7 @@ async def test_canonicaliser_accepts_empty_alias_list(runtime: AppRuntime, file_
|
||||
)
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
assert len(report.contradictions) == 1
|
||||
|
||||
|
||||
@@ -332,7 +339,7 @@ async def test_canonicaliser_failure_falls_back_to_lexical_keys(
|
||||
)
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
# Lexical key collapses both subjects so the bucket still forms.
|
||||
assert len(report.contradictions) == 1
|
||||
@@ -389,7 +396,7 @@ async def test_same_page_contradiction_is_surfaced(runtime: AppRuntime, file_a:
|
||||
)
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
assert len(report.contradictions) == 1
|
||||
assert report.contradictions[0].severity == ContradictionSeverity.ERROR
|
||||
@@ -426,7 +433,7 @@ async def test_identical_quote_pair_is_still_dropped(runtime: AppRuntime, file_a
|
||||
)
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
assert report.contradictions == []
|
||||
|
||||
@@ -455,7 +462,7 @@ async def test_summary_falls_back_to_deterministic_when_llm_unavailable(
|
||||
)
|
||||
detector._summary_agent.run = AsyncMock(side_effect=failure)
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
assert "No contradictions" in report.summary
|
||||
assert report.clean is True
|
||||
@@ -499,7 +506,7 @@ async def test_detector_chunk_timeout_falls_through(runtime: AppRuntime, file_a:
|
||||
detector._pair_detector.run = AsyncMock(side_effect=TimeoutError("simulated"))
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
# Detector timed out so no pairs come back. Crucially: the pipeline
|
||||
# reached the summary stage rather than crashing earlier, so
|
||||
@@ -533,7 +540,7 @@ async def test_empty_chunk_with_substantial_content_logs_warning(
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("ok"))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="stirling.agents.contradiction.detector"):
|
||||
await detector.detect([file_a])
|
||||
await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
assert any(
|
||||
"produced 0 claims" in record.getMessage() and "pages=1" in record.getMessage() for record in caplog.records
|
||||
@@ -580,7 +587,7 @@ async def test_pages_examined_includes_every_attempted_page(runtime: AppRuntime,
|
||||
detector._pair_detector.run = AsyncMock(return_value=_stub_result(_BucketContradictions(pairs=[])))
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
# Every page the extractor ran against is reported, even page 2
|
||||
# (which produced no claim).
|
||||
@@ -655,7 +662,7 @@ async def test_oversized_bucket_windows_translate_indices_globally(runtime: AppR
|
||||
detector._pair_detector.run = _stub_detector # type: ignore[method-assign]
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
|
||||
|
||||
report = await detector.detect([file_a])
|
||||
report = await detector.detect([file_a], principals=PRINCIPALS)
|
||||
|
||||
# Both windows produced one valid pair each; dedup by global (i, j)
|
||||
# leaves exactly two contradictions.
|
||||
@@ -791,7 +798,7 @@ async def test_multi_file_pages_dont_collide_in_validation(runtime: AppRuntime)
|
||||
)
|
||||
detector._summary_agent.run = AsyncMock(return_value=_stub_result("ok"))
|
||||
|
||||
report = await detector.detect([file_a, file_b])
|
||||
report = await detector.detect([file_a, file_b], principals=PRINCIPALS)
|
||||
|
||||
# Both claims validated as verbatim — each against the right file's
|
||||
# page text. A collision bug would have produced "paraphrased" for at
|
||||
|
||||
@@ -9,6 +9,7 @@ detector when invoked.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
@@ -22,10 +23,24 @@ from stirling.contracts import (
|
||||
)
|
||||
from stirling.contracts.contradiction import Claim
|
||||
from stirling.documents import DocumentService, SqliteVecStore
|
||||
from stirling.models import FileId
|
||||
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)
|
||||
@@ -69,6 +84,9 @@ async def test_run_answer_agent_builds_agent_with_three_toolsets(
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,7 @@ contradiction and the right cross-references and anchor handling.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import replace
|
||||
from typing import Literal
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -27,11 +28,25 @@ from stirling.contracts import (
|
||||
)
|
||||
from stirling.contracts.contradiction import Claim
|
||||
from stirling.documents import DocumentService, SqliteVecStore
|
||||
from stirling.models import FileId, ToolEndpoint
|
||||
from stirling.models import FileId, OwnerId, PrincipalId, ToolEndpoint, UserId
|
||||
from stirling.models.tool_models import AddCommentsParams
|
||||
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)
|
||||
@@ -88,6 +103,9 @@ async def test_localiser_prompt_escapes_verdict_tag_injection(
|
||||
file.id,
|
||||
[PageText(page_number=1, text="x")],
|
||||
source=file.name,
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
agent = PdfReviewAgent(runtime_with_stub_docs)
|
||||
@@ -159,6 +177,9 @@ async def test_contradiction_intent_emits_add_comments_plan(
|
||||
file.id,
|
||||
[PageText(page_number=1, text="ignored"), PageText(page_number=5, text="ignored")],
|
||||
source=file.name,
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
agent = PdfReviewAgent(runtime_with_stub_docs)
|
||||
@@ -264,6 +285,9 @@ async def test_contradiction_takes_precedence_over_math(
|
||||
file.id,
|
||||
[PageText(page_number=1, text="x")],
|
||||
source=file.name,
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
agent = PdfReviewAgent(runtime_with_stub_docs)
|
||||
|
||||
+343
-53
@@ -8,7 +8,17 @@ from stirling.documents.rag_capability import RagCapability
|
||||
from stirling.documents.service import DocumentService
|
||||
from stirling.documents.sqlite_vec_store import SqliteVecStore
|
||||
from stirling.documents.store import Document, SearchResult
|
||||
from stirling.models import FileId
|
||||
from stirling.models import FileId, OwnerId, PrincipalId
|
||||
|
||||
# Personal-doc tests reuse the same opaque string in all three roles — keeps the
|
||||
# defaults of ingest() exercised and tests honest about what the same id means
|
||||
# (caller, tenant, single ACL grantee). Org-doc tests construct ad-hoc owners
|
||||
# and principal sets inline.
|
||||
OWNER = OwnerId("test-user")
|
||||
OWNER_PRINCIPALS = [PrincipalId("test-user")]
|
||||
OTHER_OWNER = OwnerId("other-user")
|
||||
OTHER_OWNER_PRINCIPALS = [PrincipalId("other-user")]
|
||||
|
||||
|
||||
# chunk_text
|
||||
|
||||
@@ -56,7 +66,8 @@ class TestSqliteVecStore:
|
||||
@pytest.mark.anyio
|
||||
async def test_add_and_search(self) -> None:
|
||||
store = SqliteVecStore.ephemeral()
|
||||
await store.ensure_collection("test-col", "test.pdf")
|
||||
await store.ensure_collection("test-col", "test.pdf", OWNER, None)
|
||||
await store.grant_read("test-col", OWNER, OWNER_PRINCIPALS)
|
||||
docs = [
|
||||
Document(id="1", text="Python is a programming language", metadata={"source": "test"}),
|
||||
Document(id="2", text="Java is another programming language", metadata={"source": "test"}),
|
||||
@@ -67,9 +78,9 @@ class TestSqliteVecStore:
|
||||
[0.9, 0.1, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
]
|
||||
await store.add_documents("test-col", docs, embeddings)
|
||||
await store.add_documents("test-col", docs, embeddings, OWNER)
|
||||
|
||||
results = await store.search("test-col", [1.0, 0.05, 0.0], top_k=2)
|
||||
results = await store.search("test-col", [1.0, 0.05, 0.0], top_k=2, principals=OWNER_PRINCIPALS)
|
||||
assert len(results) == 2
|
||||
assert isinstance(results[0], SearchResult)
|
||||
assert results[0].document.id == "1"
|
||||
@@ -78,33 +89,36 @@ class TestSqliteVecStore:
|
||||
@pytest.mark.anyio
|
||||
async def test_list_and_has_collection(self) -> None:
|
||||
store = SqliteVecStore.ephemeral()
|
||||
await store.ensure_collection("my-collection", "test.pdf")
|
||||
await store.ensure_collection("my-collection", "test.pdf", OWNER, None)
|
||||
await store.grant_read("my-collection", OWNER, OWNER_PRINCIPALS)
|
||||
docs = [Document(id="1", text="test", metadata={})]
|
||||
await store.add_documents("my-collection", docs, [[1.0, 0.0]])
|
||||
await store.add_documents("my-collection", docs, [[1.0, 0.0]], OWNER)
|
||||
|
||||
collections = await store.list_collections()
|
||||
collections = await store.list_collections(OWNER_PRINCIPALS)
|
||||
assert "my-collection" in collections
|
||||
assert await store.has_collection("my-collection") is True
|
||||
assert await store.has_collection("nonexistent") is False
|
||||
assert await store.has_collection("my-collection", OWNER_PRINCIPALS) is True
|
||||
assert await store.has_collection("nonexistent", OWNER_PRINCIPALS) is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_collection(self) -> None:
|
||||
store = SqliteVecStore.ephemeral()
|
||||
await store.ensure_collection("to-delete", "test.pdf")
|
||||
await store.ensure_collection("to-delete", "test.pdf", OWNER, None)
|
||||
await store.grant_read("to-delete", OWNER, OWNER_PRINCIPALS)
|
||||
docs = [Document(id="1", text="test", metadata={})]
|
||||
await store.add_documents("to-delete", docs, [[1.0]])
|
||||
await store.add_documents("to-delete", docs, [[1.0]], OWNER)
|
||||
|
||||
assert await store.has_collection("to-delete") is True
|
||||
await store.delete_collection("to-delete")
|
||||
assert await store.has_collection("to-delete") is False
|
||||
assert await store.has_collection("to-delete", OWNER_PRINCIPALS) is True
|
||||
await store.delete_collection("to-delete", OWNER)
|
||||
assert await store.has_collection("to-delete", OWNER_PRINCIPALS) is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_empty_collection(self) -> None:
|
||||
store = SqliteVecStore.ephemeral()
|
||||
await store.ensure_collection("empty-test", "test.pdf")
|
||||
await store.ensure_collection("empty-test", "test.pdf", OWNER, None)
|
||||
await store.grant_read("empty-test", OWNER, OWNER_PRINCIPALS)
|
||||
docs = [Document(id="1", text="test", metadata={})]
|
||||
await store.add_documents("empty-test", docs, [[1.0, 0.0]])
|
||||
results = await store.search("empty-test", [1.0, 0.0], top_k=5)
|
||||
await store.add_documents("empty-test", docs, [[1.0, 0.0]], OWNER)
|
||||
results = await store.search("empty-test", [1.0, 0.0], top_k=5, principals=OWNER_PRINCIPALS)
|
||||
assert len(results) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -112,7 +126,118 @@ class TestSqliteVecStore:
|
||||
store = SqliteVecStore.ephemeral()
|
||||
docs = [Document(id="1", text="test", metadata={})]
|
||||
with pytest.raises(ValueError, match="documents.*embeddings"):
|
||||
await store.add_documents("bad", docs, [[1.0], [2.0]])
|
||||
await store.add_documents("bad", docs, [[1.0], [2.0]], OWNER)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_collections_isolated_by_owner(self) -> None:
|
||||
"""Two owners can store the same collection id; reads stay scoped to ACL."""
|
||||
store = SqliteVecStore.ephemeral()
|
||||
await store.ensure_collection("shared-id", "alice.pdf", OWNER, None)
|
||||
await store.grant_read("shared-id", OWNER, OWNER_PRINCIPALS)
|
||||
await store.ensure_collection("shared-id", "bob.pdf", OTHER_OWNER, None)
|
||||
await store.grant_read("shared-id", OTHER_OWNER, OTHER_OWNER_PRINCIPALS)
|
||||
await store.add_documents(
|
||||
"shared-id",
|
||||
[Document(id="1", text="alice content", metadata={})],
|
||||
[[1.0, 0.0]],
|
||||
OWNER,
|
||||
)
|
||||
await store.add_documents(
|
||||
"shared-id",
|
||||
[Document(id="1", text="bob content", metadata={})],
|
||||
[[1.0, 0.0]],
|
||||
OTHER_OWNER,
|
||||
)
|
||||
|
||||
alice_results = await store.search("shared-id", [1.0, 0.0], top_k=5, principals=OWNER_PRINCIPALS)
|
||||
bob_results = await store.search("shared-id", [1.0, 0.0], top_k=5, principals=OTHER_OWNER_PRINCIPALS)
|
||||
assert [r.document.text for r in alice_results] == ["alice content"]
|
||||
assert [r.document.text for r in bob_results] == ["bob content"]
|
||||
assert await store.list_collections(OWNER_PRINCIPALS) == ["shared-id"]
|
||||
assert await store.list_collections(OTHER_OWNER_PRINCIPALS) == ["shared-id"]
|
||||
|
||||
await store.delete_collection("shared-id", OWNER)
|
||||
assert await store.has_collection("shared-id", OWNER_PRINCIPALS) is False
|
||||
assert await store.has_collection("shared-id", OTHER_OWNER_PRINCIPALS) is True
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_purge_owner_removes_only_that_owners_collections(self) -> None:
|
||||
"""Logout path: one owner's purge must not touch another owner's docs."""
|
||||
store = SqliteVecStore.ephemeral()
|
||||
for owner, principals, name in (
|
||||
(OWNER, OWNER_PRINCIPALS, "a.pdf"),
|
||||
(OWNER, OWNER_PRINCIPALS, "b.pdf"),
|
||||
(OTHER_OWNER, OTHER_OWNER_PRINCIPALS, "c.pdf"),
|
||||
):
|
||||
await store.ensure_collection(name, name, owner, None)
|
||||
await store.grant_read(name, owner, principals)
|
||||
await store.add_documents(name, [Document(id="1", text="x", metadata={})], [[1.0, 0.0]], owner)
|
||||
|
||||
deleted = await store.purge_owner(OWNER)
|
||||
assert deleted == 2
|
||||
assert await store.list_collections(OWNER_PRINCIPALS) == []
|
||||
assert await store.list_collections(OTHER_OWNER_PRINCIPALS) == ["c.pdf"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reap_expired_drops_collections_past_expires_at(self) -> None:
|
||||
"""TTL backstop: rows with ``expires_at`` in the past go away on reap."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
store = SqliteVecStore.ephemeral()
|
||||
now = datetime.now(UTC)
|
||||
await store.ensure_collection("fresh", "fresh.pdf", OWNER, now + timedelta(hours=1))
|
||||
await store.grant_read("fresh", OWNER, OWNER_PRINCIPALS)
|
||||
await store.ensure_collection("stale", "stale.pdf", OWNER, now - timedelta(seconds=1))
|
||||
await store.grant_read("stale", OWNER, OWNER_PRINCIPALS)
|
||||
|
||||
deleted = await store.reap_expired()
|
||||
assert deleted == 1
|
||||
assert await store.list_collections(OWNER_PRINCIPALS) == ["fresh"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reap_expired_keeps_persistent_and_unexpired(self) -> None:
|
||||
"""Rows with ``expires_at`` in the future stay. Rows with null
|
||||
``expires_at`` (org docs) are persistent and never touched, regardless
|
||||
of age."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
store = SqliteVecStore.ephemeral()
|
||||
await store.ensure_collection("session", "s.pdf", OWNER, datetime.now(UTC) + timedelta(hours=1))
|
||||
await store.grant_read("session", OWNER, OWNER_PRINCIPALS)
|
||||
await store.ensure_collection("persistent", "p.pdf", OTHER_OWNER, None)
|
||||
await store.grant_read("persistent", OTHER_OWNER, OTHER_OWNER_PRINCIPALS)
|
||||
|
||||
deleted = await store.reap_expired()
|
||||
assert deleted == 0
|
||||
assert await store.list_collections(OWNER_PRINCIPALS) == ["session"]
|
||||
assert await store.list_collections(OTHER_OWNER_PRINCIPALS) == ["persistent"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acl_grants_read_to_extra_principal(self) -> None:
|
||||
"""A principal without an ACL row can't read; once granted, they can."""
|
||||
store = SqliteVecStore.ephemeral()
|
||||
team_principal = PrincipalId("group:engineering")
|
||||
await store.ensure_collection("doc", "engineering-runbook.pdf", OWNER, None)
|
||||
await store.grant_read("doc", OWNER, OWNER_PRINCIPALS)
|
||||
await store.add_documents(
|
||||
"doc",
|
||||
[Document(id="1", text="how to deploy", metadata={})],
|
||||
[[1.0, 0.0]],
|
||||
OWNER,
|
||||
)
|
||||
|
||||
# Engineering can't see it yet.
|
||||
assert await store.has_collection("doc", [team_principal]) is False
|
||||
|
||||
# Owner grants engineering read access.
|
||||
await store.grant_read("doc", OWNER, [team_principal])
|
||||
assert await store.has_collection("doc", [team_principal]) is True
|
||||
|
||||
# Revoke kills it.
|
||||
await store.revoke("doc", OWNER, team_principal)
|
||||
assert await store.has_collection("doc", [team_principal]) is False
|
||||
# Owner still can.
|
||||
assert await store.has_collection("doc", OWNER_PRINCIPALS) is True
|
||||
|
||||
|
||||
# DocumentService (with stub embedder)
|
||||
@@ -163,39 +288,131 @@ class TestDocumentService:
|
||||
@pytest.mark.anyio
|
||||
async def test_ingest_and_search(self, documents: DocumentService) -> None:
|
||||
text = "Python is great for data science. It has many libraries like pandas and numpy."
|
||||
count = await documents.ingest(FileId("docs"), _pages(text), source="guide.pdf")
|
||||
count = await documents.ingest(
|
||||
FileId("docs"),
|
||||
_pages(text),
|
||||
source="guide.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
assert count > 0
|
||||
|
||||
results = await documents.search("Python libraries", collection=FileId("docs"))
|
||||
results = await documents.search("Python libraries", principals=OWNER_PRINCIPALS, collection=FileId("docs"))
|
||||
assert len(results) > 0
|
||||
assert results[0].document.text
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ingest_empty_text_returns_zero_chunks(self, documents: DocumentService) -> None:
|
||||
count = await documents.ingest(FileId("docs"), _pages(""), source="empty.pdf")
|
||||
count = await documents.ingest(
|
||||
FileId("docs"),
|
||||
_pages(""),
|
||||
source="empty.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_nonexistent_collection_returns_empty(self, documents: DocumentService) -> None:
|
||||
results = await documents.search("anything", collection=FileId("nonexistent"))
|
||||
results = await documents.search("anything", principals=OWNER_PRINCIPALS, collection=FileId("nonexistent"))
|
||||
assert results == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_all_collections(self, documents: DocumentService) -> None:
|
||||
await documents.ingest(FileId("col-a"), _pages("Machine learning overview."), source="ml.pdf")
|
||||
await documents.ingest(FileId("col-b"), _pages("Deep learning with neural networks."), source="dl.pdf")
|
||||
async def test_search_all_collections_for_principal(self, documents: DocumentService) -> None:
|
||||
await documents.ingest(
|
||||
FileId("col-a"),
|
||||
_pages("Machine learning overview."),
|
||||
source="ml.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
await documents.ingest(
|
||||
FileId("col-b"),
|
||||
_pages("Deep learning with neural networks."),
|
||||
source="dl.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
results = await documents.search("neural networks")
|
||||
results = await documents.search("neural networks", principals=OWNER_PRINCIPALS)
|
||||
assert len(results) > 0
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_does_not_cross_owner_boundary(self, documents: DocumentService) -> None:
|
||||
"""A search by one principal set never returns docs owned by an unrelated owner."""
|
||||
await documents.ingest(
|
||||
FileId("col-a"),
|
||||
_pages("Alice's private notes."),
|
||||
source="alice.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
await documents.ingest(
|
||||
FileId("col-b"),
|
||||
_pages("Bob's private notes."),
|
||||
source="bob.pdf",
|
||||
owner_id=OTHER_OWNER,
|
||||
read_principals=OTHER_OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
alice_results = await documents.search("notes", principals=OWNER_PRINCIPALS)
|
||||
bob_results = await documents.search("notes", principals=OTHER_OWNER_PRINCIPALS)
|
||||
alice_texts = [r.document.text for r in alice_results]
|
||||
bob_texts = [r.document.text for r in bob_results]
|
||||
assert any("Alice" in t for t in alice_texts)
|
||||
assert not any("Bob" in t for t in alice_texts)
|
||||
assert any("Bob" in t for t in bob_texts)
|
||||
assert not any("Alice" in t for t in bob_texts)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_org_doc_visible_only_to_granted_group(self, documents: DocumentService) -> None:
|
||||
"""Org-owned doc with explicit ACL: only granted principals see it."""
|
||||
org_owner = OwnerId("org:acme")
|
||||
eng_group = PrincipalId("group:engineering")
|
||||
hr_group = PrincipalId("group:hr")
|
||||
|
||||
await documents.ingest(
|
||||
FileId("runbook"),
|
||||
_pages("Production deploy steps."),
|
||||
source="runbook.pdf",
|
||||
owner_id=org_owner,
|
||||
read_principals=[eng_group],
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
# Engineering can read.
|
||||
eng_results = await documents.search("deploy", principals=[eng_group])
|
||||
assert len(eng_results) > 0
|
||||
|
||||
# HR cannot.
|
||||
hr_results = await documents.search("deploy", principals=[hr_group])
|
||||
assert hr_results == []
|
||||
|
||||
# Caller with both memberships (or org-wide principal) still sees it via eng.
|
||||
multi_results = await documents.search("deploy", principals=[hr_group, eng_group])
|
||||
assert len(multi_results) > 0
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_collection(self, documents: DocumentService) -> None:
|
||||
await documents.ingest(FileId("temp"), _pages("Temporary data."), source="tmp.pdf")
|
||||
collections = await documents.list_collections()
|
||||
await documents.ingest(
|
||||
FileId("temp"),
|
||||
_pages("Temporary data."),
|
||||
source="tmp.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
collections = await documents.list_collections(OWNER_PRINCIPALS)
|
||||
assert "temp" in collections
|
||||
|
||||
await documents.delete_collection(FileId("temp"))
|
||||
collections = await documents.list_collections()
|
||||
await documents.delete_collection(FileId("temp"), owner_id=OWNER)
|
||||
collections = await documents.list_collections(OWNER_PRINCIPALS)
|
||||
assert "temp" not in collections
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -205,9 +422,16 @@ class TestDocumentService:
|
||||
PageText(page_number=2, text="Second page text."),
|
||||
PageText(page_number=3, text="Third page text."),
|
||||
]
|
||||
await documents.ingest(FileId("ordered"), pages, source="ordered.pdf")
|
||||
await documents.ingest(
|
||||
FileId("ordered"),
|
||||
pages,
|
||||
source="ordered.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
stored = await documents.read_pages(FileId("ordered"))
|
||||
stored = await documents.read_pages(FileId("ordered"), principals=OWNER_PRINCIPALS)
|
||||
assert [p.page_number for p in stored] == [1, 2, 3]
|
||||
assert stored[0].text == "First page text."
|
||||
assert stored[0].char_count == len("First page text.")
|
||||
@@ -217,9 +441,13 @@ class TestDocumentService:
|
||||
from stirling.contracts import PageRange
|
||||
|
||||
pages = [PageText(page_number=i, text=f"page {i}") for i in range(1, 6)]
|
||||
await documents.ingest(FileId("ranged"), pages, source="r.pdf")
|
||||
await documents.ingest(
|
||||
FileId("ranged"), pages, source="r.pdf", owner_id=OWNER, read_principals=OWNER_PRINCIPALS, expires_at=None
|
||||
)
|
||||
|
||||
subset = await documents.read_pages(FileId("ranged"), PageRange(start=2, end=4))
|
||||
subset = await documents.read_pages(
|
||||
FileId("ranged"), principals=OWNER_PRINCIPALS, page_range=PageRange(start=2, end=4)
|
||||
)
|
||||
assert [p.page_number for p in subset] == [2, 3, 4]
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -228,14 +456,20 @@ class TestDocumentService:
|
||||
FileId("doc"),
|
||||
[PageText(page_number=1, text="old"), PageText(page_number=2, text="old2")],
|
||||
source="v1.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
await documents.ingest(
|
||||
FileId("doc"),
|
||||
[PageText(page_number=1, text="new")],
|
||||
source="v2.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
stored = await documents.read_pages(FileId("doc"))
|
||||
stored = await documents.read_pages(FileId("doc"), principals=OWNER_PRINCIPALS)
|
||||
assert [p.page_number for p in stored] == [1]
|
||||
assert stored[0].text == "new"
|
||||
|
||||
@@ -248,9 +482,16 @@ class TestDocumentService:
|
||||
PageText(page_number=2, text=" "),
|
||||
PageText(page_number=3, text="Real text on page 3."),
|
||||
]
|
||||
await documents.ingest(FileId("with-blanks"), pages, source="blanks.pdf")
|
||||
await documents.ingest(
|
||||
FileId("with-blanks"),
|
||||
pages,
|
||||
source="blanks.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
stored = await documents.read_pages(FileId("with-blanks"))
|
||||
stored = await documents.read_pages(FileId("with-blanks"), principals=OWNER_PRINCIPALS)
|
||||
assert [p.page_number for p in stored] == [1, 2, 3]
|
||||
assert stored[1].text.strip() == ""
|
||||
|
||||
@@ -270,22 +511,36 @@ async def _invoke_search_knowledge(capability: RagCapability, query: str, max_re
|
||||
|
||||
class TestRagCapability:
|
||||
def test_instructions_static_when_collections_pinned(self, documents: DocumentService) -> None:
|
||||
cap = RagCapability(documents, collections=[FileId("docs"), FileId("manuals")])
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS, collections=[FileId("docs"), FileId("manuals")])
|
||||
instructions = cap.instructions
|
||||
assert isinstance(instructions, str)
|
||||
assert "docs, manuals" in instructions
|
||||
assert "search_knowledge" in instructions
|
||||
|
||||
def test_instructions_dynamic_when_no_collections(self, documents: DocumentService) -> None:
|
||||
cap = RagCapability(documents)
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
|
||||
instructions = cap.instructions
|
||||
assert callable(instructions)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dynamic_instructions_list_available_collections(self, documents: DocumentService) -> None:
|
||||
await documents.ingest(FileId("col-a"), _pages("Alpha content."), source="a.pdf")
|
||||
await documents.ingest(FileId("col-b"), _pages("Beta content."), source="b.pdf")
|
||||
cap = RagCapability(documents)
|
||||
await documents.ingest(
|
||||
FileId("col-a"),
|
||||
_pages("Alpha content."),
|
||||
source="a.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
await documents.ingest(
|
||||
FileId("col-b"),
|
||||
_pages("Beta content."),
|
||||
source="b.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
|
||||
instructions_fn = cap.instructions
|
||||
assert callable(instructions_fn)
|
||||
text = await instructions_fn()
|
||||
@@ -294,7 +549,7 @@ class TestRagCapability:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dynamic_instructions_when_store_empty(self, documents: DocumentService) -> None:
|
||||
cap = RagCapability(documents)
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
|
||||
instructions_fn = cap.instructions
|
||||
assert callable(instructions_fn)
|
||||
text = await instructions_fn()
|
||||
@@ -302,14 +557,21 @@ class TestRagCapability:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_knowledge_returns_no_results_message_when_empty(self, documents: DocumentService) -> None:
|
||||
cap = RagCapability(documents)
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
|
||||
output = await _invoke_search_knowledge(cap, "anything")
|
||||
assert output == "No relevant results found in the knowledge base."
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_knowledge_formats_results_with_source_and_score(self, documents: DocumentService) -> None:
|
||||
await documents.ingest(FileId("docs"), _pages("Python is a programming language."), source="guide.pdf")
|
||||
cap = RagCapability(documents)
|
||||
await documents.ingest(
|
||||
FileId("docs"),
|
||||
_pages("Python is a programming language."),
|
||||
source="guide.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
|
||||
output = await _invoke_search_knowledge(cap, "Python")
|
||||
assert "[Result 1" in output
|
||||
assert "source: guide.pdf" in output
|
||||
@@ -318,10 +580,24 @@ class TestRagCapability:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_knowledge_restricts_to_pinned_collections(self, documents: DocumentService) -> None:
|
||||
await documents.ingest(FileId("pinned"), _pages("Pinned collection content."), source="pinned.pdf")
|
||||
await documents.ingest(FileId("other"), _pages("Content in another collection."), source="other.pdf")
|
||||
await documents.ingest(
|
||||
FileId("pinned"),
|
||||
_pages("Pinned collection content."),
|
||||
source="pinned.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
await documents.ingest(
|
||||
FileId("other"),
|
||||
_pages("Content in another collection."),
|
||||
source="other.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
cap = RagCapability(documents, collections=[FileId("pinned")])
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS, collections=[FileId("pinned")])
|
||||
output = await _invoke_search_knowledge(cap, "content")
|
||||
assert "pinned.pdf" in output
|
||||
assert "other.pdf" not in output
|
||||
@@ -329,9 +605,16 @@ class TestRagCapability:
|
||||
@pytest.mark.anyio
|
||||
async def test_search_knowledge_respects_max_results(self, documents: DocumentService) -> None:
|
||||
paragraphs = "\n\n".join(f"Paragraph {i} about topic." for i in range(10))
|
||||
await documents.ingest(FileId("bulk"), _pages(paragraphs), source="bulk.pdf")
|
||||
await documents.ingest(
|
||||
FileId("bulk"),
|
||||
_pages(paragraphs),
|
||||
source="bulk.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
cap = RagCapability(documents)
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
|
||||
output = await _invoke_search_knowledge(cap, "topic", max_results=2)
|
||||
assert "[Result 1" in output
|
||||
assert "[Result 2" in output
|
||||
@@ -341,8 +624,15 @@ class TestRagCapability:
|
||||
async def test_search_knowledge_tool_is_hidden_after_budget_exhausted(self, documents: DocumentService) -> None:
|
||||
"""The prepare callback must return None once max_searches has been reached
|
||||
so the agent can no longer call the tool on subsequent turns."""
|
||||
await documents.ingest(FileId("docs"), _pages("Some content."), source="x.pdf")
|
||||
cap = RagCapability(documents, max_searches=2)
|
||||
await documents.ingest(
|
||||
FileId("docs"),
|
||||
_pages("Some content."),
|
||||
source="x.pdf",
|
||||
owner_id=OWNER,
|
||||
read_principals=OWNER_PRINCIPALS,
|
||||
expires_at=None,
|
||||
)
|
||||
cap = RagCapability(documents, principals=OWNER_PRINCIPALS, max_searches=2)
|
||||
tool_def = _dummy_tool_def()
|
||||
|
||||
assert await cap._prepare_search_knowledge(None, tool_def) is tool_def # type: ignore[arg-type]
|
||||
|
||||
@@ -8,7 +8,11 @@ from fastapi.testclient import TestClient
|
||||
from stirling.api import app
|
||||
from stirling.api.dependencies import get_document_service
|
||||
from stirling.documents import Document, DocumentService, SqliteVecStore
|
||||
from stirling.models import FileId
|
||||
from stirling.models import FileId, PrincipalId, UserId
|
||||
|
||||
USER = UserId("test-user")
|
||||
USER_PRINCIPALS = [PrincipalId("test-user")]
|
||||
HEADERS = {"X-User-Id": USER}
|
||||
|
||||
|
||||
class StubEmbedder:
|
||||
@@ -78,7 +82,11 @@ def test_ingest_document_indexes_page_text(client: TestClient, service: Document
|
||||
{"pageNumber": 1, "text": "The introduction covers the main topic."},
|
||||
{"pageNumber": 2, "text": "The conclusion summarises the findings."},
|
||||
],
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
@@ -94,7 +102,11 @@ async def test_ingest_document_replaces_existing_content(client: TestClient, ser
|
||||
"documentId": "replace-me",
|
||||
"source": "replace-me.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "Original content that existed before."}],
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
# Second ingest with different content should replace the first entirely
|
||||
response = client.post(
|
||||
@@ -103,11 +115,15 @@ async def test_ingest_document_replaces_existing_content(client: TestClient, ser
|
||||
"documentId": "replace-me",
|
||||
"source": "replace-me.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "New content that replaced the old."}],
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
results = await service.search("New content", collection=FileId("replace-me"), top_k=5)
|
||||
results = await service.search("New content", principals=USER_PRINCIPALS, collection=FileId("replace-me"), top_k=5)
|
||||
texts = [r.document.text for r in results]
|
||||
assert any("New content" in t for t in texts)
|
||||
assert not any("Original content" in t for t in texts)
|
||||
@@ -123,14 +139,28 @@ def test_ingest_document_skips_empty_pages(client: TestClient) -> None:
|
||||
{"pageNumber": 1, "text": " "},
|
||||
{"pageNumber": 2, "text": "Real content on page 2."},
|
||||
],
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["chunksIndexed"] >= 1
|
||||
|
||||
|
||||
def test_ingest_document_with_no_content_returns_zero(client: TestClient) -> None:
|
||||
response = client.post("/api/v1/documents", json={"documentId": "empty", "source": "empty.pdf"})
|
||||
response = client.post(
|
||||
"/api/v1/documents",
|
||||
json={
|
||||
"documentId": "empty",
|
||||
"source": "empty.pdf",
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["chunksIndexed"] == 0
|
||||
|
||||
@@ -139,6 +169,7 @@ def test_ingest_document_rejects_empty_id(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/documents",
|
||||
json={"documentId": "", "source": "x.pdf", "pageText": [{"pageNumber": 1, "text": "something"}]},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -147,6 +178,7 @@ def test_ingest_document_rejects_missing_source(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/documents",
|
||||
json={"documentId": "doc-1", "pageText": [{"pageNumber": 1, "text": "something"}]},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -155,6 +187,7 @@ def test_ingest_document_rejects_empty_source(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/documents",
|
||||
json={"documentId": "doc-1", "source": "", "pageText": [{"pageNumber": 1, "text": "something"}]},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -166,11 +199,61 @@ def test_ingest_document_rejects_non_positive_page_number(client: TestClient) ->
|
||||
"documentId": "bad-page",
|
||||
"source": "bad-page.pdf",
|
||||
"pageText": [{"pageNumber": 0, "text": "something"}],
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_ingest_document_rejects_missing_owner_id(client: TestClient) -> None:
|
||||
"""ownerId is required — never derived from the caller. Forgetting it must 422,
|
||||
not silently fall back to personal-doc semantics."""
|
||||
response = client.post(
|
||||
"/api/v1/documents",
|
||||
json={
|
||||
"documentId": "no-owner",
|
||||
"source": "no-owner.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "something"}],
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_ingest_document_rejects_empty_read_principals(client: TestClient) -> None:
|
||||
"""readPrincipals is required and must not be empty — every doc needs at least one reader."""
|
||||
response = client.post(
|
||||
"/api/v1/documents",
|
||||
json={
|
||||
"documentId": "no-readers",
|
||||
"source": "no-readers.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "something"}],
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [],
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_ingest_document_rejects_missing_user_header(client: TestClient) -> None:
|
||||
"""The route must refuse to write per-user data when the caller didn't identify themselves."""
|
||||
response = client.post(
|
||||
"/api/v1/documents",
|
||||
json={
|
||||
"documentId": "doc-1",
|
||||
"source": "x.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "something"}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ── DELETE /documents/{id} ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -181,15 +264,19 @@ def test_delete_document_reports_deleted_true_when_existed(client: TestClient) -
|
||||
"documentId": "to-delete",
|
||||
"source": "to-delete.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "Text."}],
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
response = client.delete("/api/v1/documents/to-delete")
|
||||
response = client.delete("/api/v1/documents/by-id/to-delete", headers=HEADERS)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"documentId": "to-delete", "deleted": True}
|
||||
|
||||
|
||||
def test_delete_document_is_idempotent(client: TestClient) -> None:
|
||||
response = client.delete("/api/v1/documents/never-existed")
|
||||
response = client.delete("/api/v1/documents/by-id/never-existed", headers=HEADERS)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"documentId": "never-existed", "deleted": False}
|
||||
|
||||
@@ -198,8 +285,86 @@ def test_delete_document_is_idempotent(client: TestClient) -> None:
|
||||
async def test_delete_document_removes_collection(client: TestClient, service: DocumentService) -> None:
|
||||
client.post(
|
||||
"/api/v1/documents",
|
||||
json={"documentId": "gone", "source": "gone.pdf", "pageText": [{"pageNumber": 1, "text": "Text."}]},
|
||||
json={
|
||||
"documentId": "gone",
|
||||
"source": "gone.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "Text."}],
|
||||
"ownerId": USER,
|
||||
"readPrincipals": [USER],
|
||||
"expiresAt": None,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
assert await service.has_collection(FileId("gone"))
|
||||
client.delete("/api/v1/documents/gone")
|
||||
assert not await service.has_collection(FileId("gone"))
|
||||
assert await service.has_collection(FileId("gone"), principals=USER_PRINCIPALS)
|
||||
client.delete("/api/v1/documents/by-id/gone", headers=HEADERS)
|
||||
assert not await service.has_collection(FileId("gone"), principals=USER_PRINCIPALS)
|
||||
|
||||
|
||||
def test_delete_document_rejects_missing_user_header(client: TestClient) -> None:
|
||||
response = client.delete("/api/v1/documents/by-id/anything")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_purge_by_owner_removes_only_callers_collections(client: TestClient, service: DocumentService) -> None:
|
||||
"""Logout path: DELETE /api/v1/documents/by-owner purges only the caller's docs."""
|
||||
alice = {
|
||||
"documentId": "alice-doc",
|
||||
"source": "alice.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "alice content"}],
|
||||
"ownerId": "alice",
|
||||
"readPrincipals": ["alice"],
|
||||
"expiresAt": None,
|
||||
}
|
||||
bob = {
|
||||
"documentId": "bob-doc",
|
||||
"source": "bob.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "bob content"}],
|
||||
"ownerId": "bob",
|
||||
"readPrincipals": ["bob"],
|
||||
"expiresAt": None,
|
||||
}
|
||||
client.post("/api/v1/documents", json=alice, headers={"X-User-Id": "alice"})
|
||||
client.post("/api/v1/documents", json=bob, headers={"X-User-Id": "bob"})
|
||||
|
||||
response = client.delete("/api/v1/documents/by-owner", headers={"X-User-Id": "alice"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ownerId": "alice", "deleted": 1}
|
||||
|
||||
# Alice gone, Bob still there.
|
||||
assert await service.has_collection(FileId("alice-doc"), principals=[PrincipalId("alice")]) is False
|
||||
assert await service.has_collection(FileId("bob-doc"), principals=[PrincipalId("bob")]) is True
|
||||
|
||||
|
||||
def test_purge_by_owner_is_idempotent(client: TestClient) -> None:
|
||||
"""Calling purge with no docs is fine — deleted=0 and no error."""
|
||||
response = client.delete("/api/v1/documents/by-owner", headers=HEADERS)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ownerId": USER, "deleted": 0}
|
||||
|
||||
|
||||
def test_purge_by_owner_rejects_missing_user_header(client: TestClient) -> None:
|
||||
response = client.delete("/api/v1/documents/by-owner")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_delete_document_only_affects_calling_user(client: TestClient) -> None:
|
||||
"""Two users with the same document id: one user's delete must not remove the other's."""
|
||||
alice_body = {
|
||||
"documentId": "shared",
|
||||
"source": "shared.pdf",
|
||||
"pageText": [{"pageNumber": 1, "text": "x"}],
|
||||
"ownerId": "alice",
|
||||
"readPrincipals": ["alice"],
|
||||
"expiresAt": None,
|
||||
}
|
||||
bob_body = {**alice_body, "ownerId": "bob", "readPrincipals": ["bob"], "expiresAt": None}
|
||||
client.post("/api/v1/documents", json=alice_body, headers={"X-User-Id": "alice"})
|
||||
client.post("/api/v1/documents", json=bob_body, headers={"X-User-Id": "bob"})
|
||||
|
||||
alice_delete = client.delete("/api/v1/documents/by-id/shared", headers={"X-User-Id": "alice"})
|
||||
assert alice_delete.json() == {"documentId": "shared", "deleted": True}
|
||||
|
||||
# Bob's copy is still there
|
||||
bob_delete = client.delete("/api/v1/documents/by-id/shared", headers={"X-User-Id": "bob"})
|
||||
assert bob_delete.json() == {"documentId": "shared", "deleted": True}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
@@ -19,9 +20,25 @@ from stirling.contracts import (
|
||||
SupportedCapability,
|
||||
)
|
||||
from stirling.documents import Document, DocumentService, SqliteVecStore
|
||||
from stirling.models import FileId
|
||||
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."""
|
||||
@@ -94,6 +111,9 @@ async def test_reports_only_missing_files(runtime_with_stub_rag: AppRuntime) ->
|
||||
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)
|
||||
|
||||
@@ -111,6 +131,9 @@ async def test_returns_grounded_answer_when_all_files_ingested(runtime_with_stub
|
||||
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,
|
||||
@@ -142,6 +165,9 @@ async def test_returns_not_found_when_answer_not_in_doc(runtime_with_stub_rag: A
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user