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:
James Brunton
2026-06-03 11:52:11 +00:00
committed by GitHub
parent 71633861d0
commit 1264f4cfed
48 changed files with 2039 additions and 411 deletions
@@ -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
+24 -17
View File
@@ -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)