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:
@@ -21,6 +21,7 @@ from pydantic_ai.toolsets import AbstractToolset
|
||||
from stirling.agents.contradiction.detector import ContradictionDetector
|
||||
from stirling.contracts import AiFile
|
||||
from stirling.contracts.contradiction import Claim, ContradictionReport
|
||||
from stirling.models import PrincipalId
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,6 +53,7 @@ class ContradictionCapability:
|
||||
self,
|
||||
detector: ContradictionDetector,
|
||||
files: list[AiFile],
|
||||
principals: list[PrincipalId],
|
||||
*,
|
||||
max_audits: int = DEFAULT_MAX_AUDITS,
|
||||
) -> None:
|
||||
@@ -59,6 +61,7 @@ class ContradictionCapability:
|
||||
raise ValueError("max_audits must be >= 1")
|
||||
self._detector = detector
|
||||
self._files = files
|
||||
self._principals = principals
|
||||
self._max_audits = max_audits
|
||||
self._audit_count = 0
|
||||
toolset: FunctionToolset[None] = FunctionToolset()
|
||||
@@ -121,7 +124,7 @@ class ContradictionCapability:
|
||||
if not self._files:
|
||||
return "No documents attached to audit."
|
||||
|
||||
report = await self._detector.detect(self._files, query=query)
|
||||
report = await self._detector.detect(self._files, principals=self._principals, query=query)
|
||||
formatted = self.format_report(report)
|
||||
logger.info(
|
||||
"[contradiction-capability] audit query=%r files=%d -> %d findings, %d chars",
|
||||
|
||||
@@ -41,6 +41,7 @@ from stirling.contracts.contradiction import (
|
||||
ContradictionSeverity,
|
||||
)
|
||||
from stirling.contracts.documents import Page
|
||||
from stirling.models import PrincipalId
|
||||
from stirling.services import AppRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -205,8 +206,13 @@ class ContradictionDetector:
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def detect(self, files: list[AiFile], query: str | None = None) -> ContradictionReport:
|
||||
"""Run the full pipeline over the supplied files.
|
||||
async def detect(
|
||||
self,
|
||||
files: list[AiFile],
|
||||
principals: list[PrincipalId],
|
||||
query: str | None = None,
|
||||
) -> ContradictionReport:
|
||||
"""Run the full pipeline over the supplied files for ``principals``.
|
||||
|
||||
``files`` must have already been ingested (the caller is
|
||||
responsible for the ``has_collection`` precheck — the question
|
||||
@@ -234,7 +240,7 @@ class ContradictionDetector:
|
||||
effective_query = query or "extract claims"
|
||||
|
||||
per_file_results = await asyncio.gather(
|
||||
*(self._extract_claims_for_file(file, effective_query) for file in files),
|
||||
*(self._extract_claims_for_file(file, effective_query, principals) for file in files),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
@@ -312,6 +318,7 @@ class ContradictionDetector:
|
||||
self,
|
||||
file: AiFile,
|
||||
query: str,
|
||||
principals: list[PrincipalId],
|
||||
) -> _FileExtractionResult:
|
||||
"""Run the per-chunk extractor over one file's pages.
|
||||
|
||||
@@ -324,7 +331,7 @@ class ContradictionDetector:
|
||||
``asyncio.gather`` and the mapper's internal semaphore — this
|
||||
helper itself awaits each step sequentially within one file.
|
||||
"""
|
||||
file_pages = await self._runtime.documents.read_pages(file.id)
|
||||
file_pages = await self._runtime.documents.read_pages(file.id, principals=principals)
|
||||
if not file_pages:
|
||||
logger.info(
|
||||
"[contradiction] no stored pages for %s (id=%s); skipping",
|
||||
|
||||
@@ -27,8 +27,9 @@ from stirling.contracts import (
|
||||
format_file_names,
|
||||
)
|
||||
from stirling.documents import RagCapability
|
||||
from stirling.models import PrincipalId
|
||||
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
|
||||
from stirling.services import AppRuntime
|
||||
from stirling.services import AppRuntime, require_current_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -161,9 +162,10 @@ class PdfQuestionAgent:
|
||||
)
|
||||
|
||||
async def _find_missing_files(self, files: list[AiFile]) -> list[AiFile]:
|
||||
principals = [PrincipalId(require_current_user_id())]
|
||||
missing: list[AiFile] = []
|
||||
for file in files:
|
||||
if not await self.runtime.documents.has_collection(file.id):
|
||||
if not await self.runtime.documents.has_collection(file.id, principals=principals):
|
||||
missing.append(file)
|
||||
return missing
|
||||
|
||||
@@ -175,8 +177,10 @@ class PdfQuestionAgent:
|
||||
upstream classifier keeps that judgement in the same call that writes
|
||||
the answer, and lets the agent mix tools when the question warrants it.
|
||||
"""
|
||||
principals = [PrincipalId(require_current_user_id())]
|
||||
rag = RagCapability(
|
||||
documents=self.runtime.documents,
|
||||
principals=principals,
|
||||
collections=[file.id for file in request.files],
|
||||
top_k=self.runtime.settings.rag_default_top_k,
|
||||
max_searches=self.runtime.settings.rag_max_searches,
|
||||
@@ -184,11 +188,13 @@ class PdfQuestionAgent:
|
||||
whole_doc = WholeDocReaderCapability(
|
||||
runtime=self.runtime,
|
||||
files=request.files,
|
||||
principals=principals,
|
||||
reasoner=self._chunked_reasoner,
|
||||
)
|
||||
contradiction = ContradictionCapability(
|
||||
detector=self._contradiction_detector,
|
||||
files=request.files,
|
||||
principals=principals,
|
||||
)
|
||||
agent = Agent(
|
||||
model=self.runtime.smart_model,
|
||||
|
||||
@@ -49,14 +49,14 @@ from stirling.contracts import (
|
||||
Verdict,
|
||||
)
|
||||
from stirling.contracts.ledger import Discrepancy
|
||||
from stirling.models import ApiModel, ToolEndpoint
|
||||
from stirling.models import ApiModel, PrincipalId, ToolEndpoint
|
||||
from stirling.models.agent_tool_models import (
|
||||
AgentToolId,
|
||||
MathAuditorAgentParams,
|
||||
PdfCommentAgentParams,
|
||||
)
|
||||
from stirling.models.tool_models import AddCommentsParams
|
||||
from stirling.services import AppRuntime
|
||||
from stirling.services import AppRuntime, require_current_user_id
|
||||
|
||||
# Fallback right-margin placement used when a finding has no usable
|
||||
# anchor text. A4/Letter portrait assumed.
|
||||
@@ -158,7 +158,11 @@ class PdfReviewAgent:
|
||||
files_to_ingest=missing,
|
||||
content_types=[PdfContentType.PAGE_TEXT],
|
||||
)
|
||||
report = await self._contradiction_detector.detect(request.files, query=request.user_message)
|
||||
report = await self._contradiction_detector.detect(
|
||||
request.files,
|
||||
principals=[PrincipalId(require_current_user_id())],
|
||||
query=request.user_message,
|
||||
)
|
||||
comments_json = await self._build_contradiction_comments_payload(request.user_message, report)
|
||||
return EditPlanResponse(
|
||||
summary="",
|
||||
@@ -193,9 +197,10 @@ class PdfReviewAgent:
|
||||
)
|
||||
|
||||
async def _find_missing_files(self, files: list[AiFile]) -> list[AiFile]:
|
||||
principals = [PrincipalId(require_current_user_id())]
|
||||
missing: list[AiFile] = []
|
||||
for file in files:
|
||||
if not await self.runtime.documents.has_collection(file.id):
|
||||
if not await self.runtime.documents.has_collection(file.id, principals=principals):
|
||||
missing.append(file)
|
||||
return missing
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from pydantic_ai.toolsets import AbstractToolset
|
||||
|
||||
from stirling.agents.shared.chunked_reasoner import ChunkedReasoner
|
||||
from stirling.contracts import AiFile
|
||||
from stirling.models import PrincipalId
|
||||
from stirling.services import AppRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,12 +48,14 @@ class WholeDocReaderCapability:
|
||||
self,
|
||||
runtime: AppRuntime,
|
||||
files: list[AiFile],
|
||||
principals: list[PrincipalId],
|
||||
*,
|
||||
reasoner: ChunkedReasoner | None = None,
|
||||
max_reads: int = DEFAULT_MAX_READS,
|
||||
) -> None:
|
||||
self._runtime = runtime
|
||||
self._files = files
|
||||
self._principals = principals
|
||||
self._reasoner = reasoner if reasoner is not None else ChunkedReasoner(runtime)
|
||||
self._max_reads = max_reads
|
||||
self._read_count = 0
|
||||
@@ -115,7 +118,7 @@ class WholeDocReaderCapability:
|
||||
|
||||
sections: list[str] = []
|
||||
for file in self._files:
|
||||
pages = await self._runtime.documents.read_pages(file.id)
|
||||
pages = await self._runtime.documents.read_pages(file.id, principals=self._principals)
|
||||
if not pages:
|
||||
logger.info(
|
||||
"[whole-doc-reader] no stored pages for %s (id=%s); skipping",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
@@ -29,8 +31,45 @@ from stirling.api.routes import (
|
||||
)
|
||||
from stirling.config import AppSettings, load_settings
|
||||
from stirling.contracts import HealthResponse
|
||||
from stirling.documents import DocumentService
|
||||
from stirling.services import build_runtime, setup_posthog_tracking
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _run_expired_doc_reaper(
|
||||
documents: DocumentService,
|
||||
interval_seconds: int,
|
||||
) -> None:
|
||||
"""Periodically delete documents whose ``expires_at`` has passed.
|
||||
|
||||
A reaped collection drops everything rooted at that document. Backstop
|
||||
for the explicit logout purge: catches sessions that ended without a
|
||||
clean logout (tab close, JWT expiry, engine restart). Persistent rows
|
||||
(``expires_at`` null, the shape we use for org-shared docs) are never
|
||||
touched. Runs until cancelled by the lifespan teardown.
|
||||
"""
|
||||
await _reap(documents)
|
||||
while True:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
await _reap(documents)
|
||||
|
||||
|
||||
async def _reap(documents: DocumentService) -> None:
|
||||
"""One reaper iteration. Logs the deleted count on success and the full
|
||||
exception with traceback on failure; never re-raises non-cancel errors so
|
||||
a bad iteration doesn't kill the loop. ``asyncio.CancelledError`` is
|
||||
re-raised so the lifespan teardown can cancel the task cleanly.
|
||||
"""
|
||||
try:
|
||||
deleted = await documents.reap_expired()
|
||||
if deleted:
|
||||
logger.info("Reaped %d expired document collection(s)", deleted)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Document reaper iteration failed; will retry on next interval")
|
||||
|
||||
|
||||
def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
|
||||
override = fast_api.dependency_overrides.get(load_settings)
|
||||
@@ -56,7 +95,19 @@ async def lifespan(fast_api: FastAPI):
|
||||
tracer_provider = setup_posthog_tracking(settings)
|
||||
if tracer_provider:
|
||||
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
|
||||
reaper_task = asyncio.create_task(
|
||||
_run_expired_doc_reaper(
|
||||
runtime.documents,
|
||||
interval_seconds=settings.documents_reaper_interval_seconds,
|
||||
),
|
||||
name="expired-document-reaper",
|
||||
)
|
||||
yield
|
||||
reaper_task.cancel()
|
||||
try:
|
||||
await reaper_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await runtime.documents.close()
|
||||
if tracer_provider:
|
||||
tracer_provider.shutdown()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from stirling.agents import (
|
||||
ExecutionPlanningAgent,
|
||||
@@ -12,7 +12,8 @@ from stirling.agents import (
|
||||
from stirling.agents.ledger import MathAuditorAgent
|
||||
from stirling.agents.pdf_comment import PdfCommentAgent
|
||||
from stirling.documents import DocumentService
|
||||
from stirling.services import AppRuntime
|
||||
from stirling.models import UserId
|
||||
from stirling.services import AppRuntime, current_user_id
|
||||
|
||||
|
||||
def get_runtime(request: Request) -> AppRuntime:
|
||||
@@ -49,3 +50,20 @@ def get_math_auditor_agent(request: Request) -> MathAuditorAgent:
|
||||
|
||||
def get_pdf_comment_agent(request: Request) -> PdfCommentAgent:
|
||||
return request.app.state.pdf_comment_agent
|
||||
|
||||
|
||||
def require_user_id() -> UserId:
|
||||
"""FastAPI dependency for routes that touch per-user storage.
|
||||
|
||||
Reads ``X-User-Id`` (already extracted into a ContextVar by ``UserIdMiddleware``)
|
||||
and returns it. Returns HTTP 401 if the caller didn't supply the header. Apply
|
||||
to any route that ingests, searches, reads, or deletes document content so
|
||||
the tenancy gate is enforced at the API boundary.
|
||||
"""
|
||||
user_id = current_user_id.get()
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="X-User-Id header is required",
|
||||
)
|
||||
return user_id
|
||||
|
||||
@@ -4,6 +4,7 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoin
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from stirling.models import UserId
|
||||
from stirling.services.tracking import current_user_id
|
||||
|
||||
_USER_ID_HEADER = "X-User-Id"
|
||||
@@ -15,7 +16,7 @@ class UserIdMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
user_id = request.headers.get(_USER_ID_HEADER)
|
||||
if user_id:
|
||||
token = current_user_id.set(user_id)
|
||||
token = current_user_id.set(UserId(user_id))
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
|
||||
@@ -1,49 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from stirling.api.dependencies import get_document_service
|
||||
from stirling.api.dependencies import get_document_service, require_user_id
|
||||
from stirling.contracts import (
|
||||
DeleteDocumentResponse,
|
||||
IngestDocumentRequest,
|
||||
IngestDocumentResponse,
|
||||
)
|
||||
from stirling.contracts.documents import PurgeOwnerResponse
|
||||
from stirling.documents import DocumentService
|
||||
from stirling.models import FileId
|
||||
from stirling.models import FileId, OwnerId, PrincipalId, UserId
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents", tags=["documents"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("", response_model=IngestDocumentResponse)
|
||||
async def ingest_document(
|
||||
request: IngestDocumentRequest,
|
||||
documents: Annotated[DocumentService, Depends(get_document_service)],
|
||||
_user_id: Annotated[UserId, Depends(require_user_id)],
|
||||
) -> IngestDocumentResponse:
|
||||
"""Replace-ingest a document's content under ``document_id``.
|
||||
"""Replace-ingest a document's content under ``(owner_id, read_principals)``.
|
||||
|
||||
Stores both representations in one shot:
|
||||
* embedded chunks for RAG search,
|
||||
* ordered page text for whole-document reading.
|
||||
Any previously-stored content for this document is removed first.
|
||||
``owner_id`` and ``read_principals`` are required on the request body and
|
||||
are stored verbatim.
|
||||
|
||||
``X-User-Id`` is still required so the caller is authenticated and
|
||||
PostHog tracks the right user, but the value is not used to constrain
|
||||
the body.
|
||||
"""
|
||||
pages = request.page_text or []
|
||||
chunks_indexed = await documents.ingest(
|
||||
collection=request.document_id,
|
||||
pages=pages,
|
||||
pages=request.page_text or [],
|
||||
source=request.source,
|
||||
owner_id=request.owner_id,
|
||||
read_principals=request.read_principals,
|
||||
expires_at=request.expires_at,
|
||||
)
|
||||
return IngestDocumentResponse(document_id=request.document_id, chunks_indexed=chunks_indexed)
|
||||
|
||||
|
||||
@router.delete("/{document_id}", response_model=DeleteDocumentResponse)
|
||||
@router.delete("/by-id/{document_id}", response_model=DeleteDocumentResponse)
|
||||
async def delete_document(
|
||||
document_id: FileId,
|
||||
documents: Annotated[DocumentService, Depends(get_document_service)],
|
||||
user_id: Annotated[UserId, Depends(require_user_id)],
|
||||
) -> DeleteDocumentResponse:
|
||||
"""Remove a document's content. Idempotent."""
|
||||
existed = await documents.has_collection(document_id)
|
||||
"""Remove the caller's copy of this document.
|
||||
|
||||
Owner is inferred from the caller - only personal-doc deletes go through
|
||||
here. Org-doc deletes will need an explicit owner_id once we add the
|
||||
admin endpoints for them; for now this route can't reach docs owned by
|
||||
a different principal.
|
||||
"""
|
||||
owner_id = OwnerId(user_id)
|
||||
principals = [PrincipalId(user_id)]
|
||||
existed = await documents.has_collection(document_id, principals=principals)
|
||||
if existed:
|
||||
await documents.delete_collection(document_id)
|
||||
await documents.delete_collection(document_id, owner_id=owner_id)
|
||||
return DeleteDocumentResponse(document_id=document_id, deleted=existed)
|
||||
|
||||
|
||||
@router.delete("/by-owner", response_model=PurgeOwnerResponse)
|
||||
async def purge_caller_documents(
|
||||
documents: Annotated[DocumentService, Depends(get_document_service)],
|
||||
user_id: Annotated[UserId, Depends(require_user_id)],
|
||||
) -> PurgeOwnerResponse:
|
||||
"""Delete every personal-doc collection owned by the caller.
|
||||
|
||||
Called by Java on logout so a user's document content disappears as soon as
|
||||
the session ends. Org-owned docs (where the caller is a reader but not
|
||||
the owner) are not touched - only collections whose ``owner_id`` matches
|
||||
the calling user are removed.
|
||||
"""
|
||||
deleted = await documents.purge_owner(OwnerId(user_id))
|
||||
logger.info("Purged %d collection(s) for owner=%s", deleted, user_id)
|
||||
return PurgeOwnerResponse(owner_id=OwnerId(user_id), deleted=deleted)
|
||||
|
||||
@@ -37,6 +37,10 @@ class AppSettings(BaseSettings):
|
||||
rag_chunk_overlap: int = Field(validation_alias="STIRLING_RAG_CHUNK_OVERLAP")
|
||||
rag_default_top_k: int = Field(validation_alias="STIRLING_RAG_TOP_K")
|
||||
rag_max_searches: int = Field(validation_alias="STIRLING_RAG_MAX_SEARCHES")
|
||||
documents_reaper_interval_seconds: int = Field(
|
||||
default=900,
|
||||
validation_alias="STIRLING_DOCUMENTS_REAPER_INTERVAL_SECONDS",
|
||||
)
|
||||
|
||||
# Chunked reasoner settings (whole-document map-reduce).
|
||||
chunked_reasoner_chars_per_slice: int = Field(validation_alias="STIRLING_CHUNKED_REASONER_CHARS_PER_SLICE")
|
||||
|
||||
@@ -42,6 +42,7 @@ from .documents import (
|
||||
Page,
|
||||
PageRange,
|
||||
PageText,
|
||||
PurgeOwnerResponse,
|
||||
)
|
||||
from .execution import (
|
||||
AgentExecutionRequest,
|
||||
@@ -137,6 +138,7 @@ __all__ = [
|
||||
"ContradictionSeverity",
|
||||
"ConversationMessage",
|
||||
"DeleteDocumentResponse",
|
||||
"PurgeOwnerResponse",
|
||||
"PdfToMarkdownCannotDoResponse",
|
||||
"PdfToMarkdownOrchestrateResponse",
|
||||
"PdfToMarkdownRequest",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from stirling.models import ApiModel
|
||||
from stirling.models import ApiModel, OwnerId, PrincipalId
|
||||
|
||||
from .common import FileId
|
||||
|
||||
@@ -35,20 +37,27 @@ class PageRange(ApiModel):
|
||||
|
||||
|
||||
class IngestDocumentRequest(ApiModel):
|
||||
"""Replace-ingest a document's content under the given ``document_id``.
|
||||
"""Replace-ingest a document's content under ``(document_id, owner_id)``.
|
||||
|
||||
Each call wipes any previously-stored content for the document and writes
|
||||
Each call wipes any previously-stored content for the pair and writes
|
||||
both the vector-chunk and ordered-page representations from the supplied
|
||||
pages.
|
||||
|
||||
``source`` is a human-readable label (typically the original filename)
|
||||
that flows into chunk metadata so search results are readable when
|
||||
``document_id`` is a hash.
|
||||
|
||||
``owner_id`` and ``read_principals`` are required: the engine never
|
||||
defaults them. Callers must declare ownership and access explicitly.
|
||||
"""
|
||||
|
||||
document_id: FileId = Field(min_length=1)
|
||||
source: str = Field(min_length=1)
|
||||
page_text: list[PageText] | None = None
|
||||
owner_id: OwnerId = Field(min_length=1)
|
||||
read_principals: list[PrincipalId] = Field(min_length=1)
|
||||
# When to delete this doc. ``None`` means "persistent" (keep until an explicit delete)
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
class IngestDocumentResponse(ApiModel):
|
||||
@@ -59,3 +68,10 @@ class IngestDocumentResponse(ApiModel):
|
||||
class DeleteDocumentResponse(ApiModel):
|
||||
document_id: FileId
|
||||
deleted: bool
|
||||
|
||||
|
||||
class PurgeOwnerResponse(ApiModel):
|
||||
"""Returned by ``DELETE /api/v1/documents/by-owner``."""
|
||||
|
||||
owner_id: OwnerId
|
||||
deleted: int = Field(ge=0)
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import psycopg
|
||||
from pgvector.psycopg import register_vector_async
|
||||
|
||||
from stirling.contracts.documents import Page, PageRange
|
||||
from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage
|
||||
from stirling.models import OwnerId, PrincipalId
|
||||
|
||||
_READ_PERMISSION = "read"
|
||||
|
||||
|
||||
class PgVectorStore(DocumentStore):
|
||||
"""PostgreSQL + pgvector backed store.
|
||||
"""PostgreSQL + pgvector backed store, scoped by owner with ACL-gated reads.
|
||||
|
||||
Connects to an external Postgres instance (DSN provided via config) and uses the
|
||||
`vector` extension for similarity search. The schema is created on first use.
|
||||
|
||||
Holds two tables under the same connection:
|
||||
Owned tables:
|
||||
|
||||
* ``documents_meta`` - parent row, one per ``(collection, owner_id)``.
|
||||
* ``rag_documents`` - vector chunks for RAG search.
|
||||
* ``document_pages`` - ordered page text for whole-document reading.
|
||||
* ``document_acl`` - principals (users, groups, orgs) granted permissions
|
||||
on a ``(collection, owner_id)`` pair. Read methods take the caller's
|
||||
principal set and join through this table.
|
||||
|
||||
Writes are owner-scoped; reads are ACL-scoped.
|
||||
"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
@@ -41,60 +51,141 @@ class PgVectorStore(DocumentStore):
|
||||
await cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS documents_meta (
|
||||
collection TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
PRIMARY KEY (collection, owner_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Partial index over rows that can actually expire keeps the reaper
|
||||
# scan tight even when most rows are persistent (org docs).
|
||||
await cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meta_expires_at "
|
||||
"ON documents_meta(expires_at) WHERE expires_at IS NOT NULL"
|
||||
)
|
||||
await cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS rag_documents (
|
||||
id TEXT NOT NULL,
|
||||
collection TEXT NOT NULL
|
||||
REFERENCES documents_meta(collection) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
embedding vector NOT NULL,
|
||||
PRIMARY KEY (id, collection)
|
||||
PRIMARY KEY (id, collection, owner_id),
|
||||
FOREIGN KEY (collection, owner_id)
|
||||
REFERENCES documents_meta(collection, owner_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await cur.execute("CREATE INDEX IF NOT EXISTS idx_rag_collection ON rag_documents(collection)")
|
||||
await cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_rag_collection_owner ON rag_documents(collection, owner_id)"
|
||||
)
|
||||
await cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS document_pages (
|
||||
collection TEXT NOT NULL
|
||||
REFERENCES documents_meta(collection) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
char_count INTEGER NOT NULL,
|
||||
PRIMARY KEY (collection, page_number)
|
||||
PRIMARY KEY (collection, owner_id, page_number),
|
||||
FOREIGN KEY (collection, owner_id)
|
||||
REFERENCES documents_meta(collection, owner_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await cur.execute("CREATE INDEX IF NOT EXISTS idx_pages_collection ON document_pages(collection)")
|
||||
await cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_collection_owner ON document_pages(collection, owner_id)"
|
||||
)
|
||||
await cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS document_acl (
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (collection, owner_id, principal_id, permission),
|
||||
FOREIGN KEY (collection, owner_id)
|
||||
REFERENCES documents_meta(collection, owner_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Hot path: every read joins ``WHERE principal_id = ANY(...) AND permission = ?``.
|
||||
await cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_acl_principal_permission ON document_acl(principal_id, permission)"
|
||||
)
|
||||
await conn.commit()
|
||||
self._initialized = True
|
||||
|
||||
async def ensure_collection(self, collection: str, source: str) -> None:
|
||||
# ── lifecycle of the (collection, owner_id) row ────────────────────────
|
||||
|
||||
async def ensure_collection(
|
||||
self,
|
||||
collection: str,
|
||||
source: str,
|
||||
owner_id: OwnerId,
|
||||
expires_at: datetime | None,
|
||||
) -> None:
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO documents_meta (collection, source)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (collection) DO UPDATE SET source = EXCLUDED.source
|
||||
INSERT INTO documents_meta (collection, owner_id, source, expires_at)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (collection, owner_id) DO UPDATE SET
|
||||
source = EXCLUDED.source,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
""",
|
||||
(collection, source),
|
||||
(collection, owner_id, source, expires_at),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
async def purge_owner(self, owner_id: OwnerId) -> int:
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM documents_meta WHERE owner_id = %s",
|
||||
(owner_id,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
await conn.commit()
|
||||
return deleted
|
||||
|
||||
async def reap_expired(self) -> int:
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("DELETE FROM documents_meta WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
||||
deleted = cur.rowcount
|
||||
await conn.commit()
|
||||
return deleted
|
||||
|
||||
async def delete_collection(self, collection: str, owner_id: OwnerId) -> bool:
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Cascade FKs handle rag_documents, document_pages, document_acl.
|
||||
await cur.execute(
|
||||
"DELETE FROM documents_meta WHERE collection = %s AND owner_id = %s",
|
||||
(collection, owner_id),
|
||||
)
|
||||
deleted = cur.rowcount > 0
|
||||
await conn.commit()
|
||||
return deleted
|
||||
|
||||
# ── write paths ────────────────────────────────────────────────────────
|
||||
|
||||
async def add_documents(
|
||||
self,
|
||||
collection: str,
|
||||
documents: list[Document],
|
||||
embeddings: list[list[float]],
|
||||
owner_id: OwnerId,
|
||||
) -> None:
|
||||
if len(documents) != len(embeddings):
|
||||
raise ValueError(f"Got {len(documents)} documents but {len(embeddings)} embeddings")
|
||||
@@ -107,36 +198,100 @@ class PgVectorStore(DocumentStore):
|
||||
for doc, emb in zip(documents, embeddings):
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO rag_documents (id, collection, text, metadata, embedding)
|
||||
VALUES (%s, %s, %s, %s::jsonb, %s)
|
||||
ON CONFLICT (id, collection)
|
||||
INSERT INTO rag_documents (id, collection, owner_id, text, metadata, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb, %s)
|
||||
ON CONFLICT (id, collection, owner_id)
|
||||
DO UPDATE SET
|
||||
text = EXCLUDED.text,
|
||||
metadata = EXCLUDED.metadata,
|
||||
embedding = EXCLUDED.embedding
|
||||
""",
|
||||
(doc.id, collection, doc.text, json.dumps(doc.metadata), emb),
|
||||
(doc.id, collection, owner_id, doc.text, json.dumps(doc.metadata), emb),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
async def add_pages(self, collection: str, pages: list[StoredPage], owner_id: OwnerId) -> None:
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM document_pages WHERE collection = %s AND owner_id = %s",
|
||||
(collection, owner_id),
|
||||
)
|
||||
if pages:
|
||||
await cur.executemany(
|
||||
"""
|
||||
INSERT INTO document_pages (collection, owner_id, page_number, text, char_count)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
[(collection, owner_id, p.page_number, p.text, p.char_count) for p in pages],
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# ── ACL management ─────────────────────────────────────────────────────
|
||||
|
||||
async def grant_read(
|
||||
self,
|
||||
collection: str,
|
||||
owner_id: OwnerId,
|
||||
principals: list[PrincipalId],
|
||||
) -> None:
|
||||
if not principals:
|
||||
return
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.executemany(
|
||||
"""
|
||||
INSERT INTO document_acl (collection, owner_id, principal_id, permission)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (collection, owner_id, principal_id, permission) DO NOTHING
|
||||
""",
|
||||
[(collection, owner_id, p, _READ_PERMISSION) for p in principals],
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
async def revoke(
|
||||
self,
|
||||
collection: str,
|
||||
owner_id: OwnerId,
|
||||
principal: PrincipalId,
|
||||
) -> None:
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM document_acl WHERE collection = %s AND owner_id = %s AND principal_id = %s",
|
||||
(collection, owner_id, principal),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# ── read paths (ACL-gated) ─────────────────────────────────────────────
|
||||
|
||||
async def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: list[float],
|
||||
top_k: int = 5,
|
||||
top_k: int,
|
||||
principals: list[PrincipalId],
|
||||
) -> list[SearchResult]:
|
||||
if not principals:
|
||||
return []
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
owner_id = await self._readable_owner_for(cur, collection, principals)
|
||||
if owner_id is None:
|
||||
return []
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT id, text, metadata, 1 - (embedding <=> %s) AS score
|
||||
FROM rag_documents
|
||||
WHERE collection = %s
|
||||
WHERE collection = %s AND owner_id = %s
|
||||
ORDER BY embedding <=> %s
|
||||
LIMIT %s
|
||||
""",
|
||||
(query_embedding, collection, query_embedding, top_k),
|
||||
(query_embedding, collection, owner_id, query_embedding, top_k),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
|
||||
@@ -148,71 +303,84 @@ class PgVectorStore(DocumentStore):
|
||||
for r in rows
|
||||
]
|
||||
|
||||
async def add_pages(self, collection: str, pages: list[StoredPage]) -> None:
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("DELETE FROM document_pages WHERE collection = %s", (collection,))
|
||||
if pages:
|
||||
await cur.executemany(
|
||||
"""
|
||||
INSERT INTO document_pages (collection, page_number, text, char_count)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
[(collection, p.page_number, p.text, p.char_count) for p in pages],
|
||||
)
|
||||
await conn.commit()
|
||||
@staticmethod
|
||||
async def _readable_owner_for(
|
||||
cur: psycopg.AsyncCursor,
|
||||
collection: str,
|
||||
principals: list[PrincipalId],
|
||||
) -> str | None:
|
||||
"""Resolve which owner_id this caller is reading. ``None`` means no access."""
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT owner_id FROM document_acl
|
||||
WHERE collection = %s
|
||||
AND permission = %s
|
||||
AND principal_id = ANY(%s)
|
||||
ORDER BY owner_id
|
||||
LIMIT 1
|
||||
""",
|
||||
(collection, _READ_PERMISSION, list(principals)),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
async def read_pages(
|
||||
self,
|
||||
collection: str,
|
||||
page_range: PageRange | None = None,
|
||||
page_range: PageRange | None,
|
||||
principals: list[PrincipalId],
|
||||
) -> list[Page]:
|
||||
if not principals:
|
||||
return []
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
owner_id = await self._readable_owner_for(cur, collection, principals)
|
||||
if owner_id is None:
|
||||
return []
|
||||
if page_range is None:
|
||||
await cur.execute(
|
||||
"SELECT page_number, text, char_count FROM document_pages "
|
||||
"WHERE collection = %s ORDER BY page_number",
|
||||
(collection,),
|
||||
"WHERE collection = %s AND owner_id = %s ORDER BY page_number",
|
||||
(collection, owner_id),
|
||||
)
|
||||
else:
|
||||
await cur.execute(
|
||||
"SELECT page_number, text, char_count FROM document_pages "
|
||||
"WHERE collection = %s AND page_number BETWEEN %s AND %s "
|
||||
"WHERE collection = %s AND owner_id = %s "
|
||||
"AND page_number BETWEEN %s AND %s "
|
||||
"ORDER BY page_number",
|
||||
(collection, page_range.start, page_range.end),
|
||||
(collection, owner_id, page_range.start, page_range.end),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [Page(page_number=r[0], text=r[1], char_count=r[2]) for r in rows]
|
||||
|
||||
async def delete_collection(self, collection: str) -> None:
|
||||
async def has_collection(self, collection: str, principals: list[PrincipalId]) -> bool:
|
||||
if not principals:
|
||||
return False
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Cascade FKs handle rag_documents and document_pages.
|
||||
await cur.execute("DELETE FROM documents_meta WHERE collection = %s", (collection,))
|
||||
await conn.commit()
|
||||
owner_id = await self._readable_owner_for(cur, collection, principals)
|
||||
return owner_id is not None
|
||||
|
||||
async def list_collections(self) -> list[str]:
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("SELECT collection FROM documents_meta ORDER BY collection")
|
||||
rows = await cur.fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
async def has_collection(self, collection: str) -> bool:
|
||||
async def list_collections(self, principals: list[PrincipalId]) -> list[str]:
|
||||
if not principals:
|
||||
return []
|
||||
await self._ensure_schema()
|
||||
async with await self._connect() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT 1 FROM documents_meta WHERE collection = %s",
|
||||
(collection,),
|
||||
"""
|
||||
SELECT DISTINCT collection FROM document_acl
|
||||
WHERE permission = %s
|
||||
AND principal_id = ANY(%s)
|
||||
ORDER BY collection
|
||||
""",
|
||||
(_READ_PERMISSION, list(principals)),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return row is not None
|
||||
rows = await cur.fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
async def close(self) -> None:
|
||||
# Connections are opened and closed per call, so nothing persistent to release.
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic_ai.toolsets import AbstractToolset
|
||||
|
||||
from stirling.documents.service import DocumentService
|
||||
from stirling.documents.store import SearchResult
|
||||
from stirling.models import FileId
|
||||
from stirling.models import FileId, PrincipalId
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,9 +16,13 @@ logger = logging.getLogger(__name__)
|
||||
class RagCapability:
|
||||
"""Bundles RAG instructions and the ``search_knowledge`` toolset for agent injection.
|
||||
|
||||
Agents consume this as::
|
||||
Agents construct one per run, scoped to the caller's principal set, as::
|
||||
|
||||
rag = runtime.rag_capability
|
||||
rag = RagCapability(
|
||||
documents=self.runtime.documents,
|
||||
principals=current_principals(),
|
||||
collections=[file.id for file in request.files],
|
||||
)
|
||||
Agent(
|
||||
...,
|
||||
instructions=rag.instructions,
|
||||
@@ -26,20 +30,22 @@ class RagCapability:
|
||||
)
|
||||
|
||||
When no collections are pinned, the instructions are generated dynamically at
|
||||
run time so the agent sees the current list of collections in the store.
|
||||
run time so the agent sees the collections the caller can read.
|
||||
|
||||
Lifecycle: a ``RagCapability`` instance is intended to live for the duration of a
|
||||
single agent run.
|
||||
single agent run and binds to one caller's principal set.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
documents: DocumentService,
|
||||
principals: list[PrincipalId],
|
||||
collections: list[FileId] | None = None,
|
||||
top_k: int = 5,
|
||||
max_searches: int = 5,
|
||||
) -> None:
|
||||
self._documents = documents
|
||||
self._principals = principals
|
||||
self._collections = collections
|
||||
self._top_k = top_k
|
||||
self._max_searches = max_searches
|
||||
@@ -74,7 +80,7 @@ class RagCapability:
|
||||
)
|
||||
|
||||
async def _dynamic_instructions(self) -> str:
|
||||
collections = await self._documents.list_collections()
|
||||
collections = await self._documents.list_collections(self._principals)
|
||||
if collections:
|
||||
names = ", ".join(collections)
|
||||
collection_desc = f"the following knowledge base collections: {names}"
|
||||
@@ -115,12 +121,12 @@ class RagCapability:
|
||||
if self._collections:
|
||||
all_results = []
|
||||
for col in self._collections:
|
||||
col_results = await self._documents.search(query, collection=col, top_k=k)
|
||||
col_results = await self._documents.search(query, principals=self._principals, collection=col, top_k=k)
|
||||
all_results.extend(col_results)
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
results = all_results[:k]
|
||||
else:
|
||||
results = await self._documents.search(query, top_k=k)
|
||||
results = await self._documents.search(query, principals=self._principals, top_k=k)
|
||||
|
||||
if not results:
|
||||
logger.info("[rag] search_knowledge query=%r -> 0 results", query)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from stirling.contracts.documents import Page, PageRange, PageText
|
||||
from stirling.documents.embedder import EmbeddingService
|
||||
from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage
|
||||
from stirling.models import FileId
|
||||
from stirling.models import FileId, OwnerId, PrincipalId
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,7 +18,8 @@ PAGE_TEXT_CONTENT_TYPE = "page_text"
|
||||
class DocumentService:
|
||||
"""Top-level facade for stored document content.
|
||||
|
||||
Holds two representations of every document under a single ``collection``:
|
||||
Holds two representations of every document under a single
|
||||
``(collection, owner_id)`` pair:
|
||||
|
||||
* **Vector chunks** for RAG-style semantic retrieval (``search``).
|
||||
* **Ordered pages** for whole-document reading (``read_pages``).
|
||||
@@ -25,6 +27,15 @@ class DocumentService:
|
||||
Both are populated by :meth:`ingest` from a single ``pages`` payload. Agents
|
||||
pick the strategy that fits the question; they don't need to know which
|
||||
storage they're hitting.
|
||||
|
||||
**Owner vs principal.** ``owner_id`` is the tenant (a user or an org).
|
||||
``principals`` is the caller's accessible principal set, matched against
|
||||
the ACL on every read. Personal-doc behaviour: ingest with
|
||||
``owner_id=user:bob`` and the route grants ``user:bob`` read access; Bob's
|
||||
later searches pass ``principals=[user:bob]`` and find his docs.
|
||||
Org-doc behaviour: ingest with ``owner_id=org:acme`` and grant read to
|
||||
whichever groups should see it (``group:engineering``, etc.); members'
|
||||
principal sets pick the doc up automatically.
|
||||
"""
|
||||
|
||||
def __init__(self, embedder: EmbeddingService, store: DocumentStore, default_top_k: int = 5) -> None:
|
||||
@@ -37,20 +48,27 @@ class DocumentService:
|
||||
collection: FileId,
|
||||
pages: list[PageText],
|
||||
source: str,
|
||||
owner_id: OwnerId,
|
||||
read_principals: list[PrincipalId],
|
||||
expires_at: datetime | None,
|
||||
) -> int:
|
||||
"""Replace-ingest a document. Returns the number of vector chunks indexed.
|
||||
|
||||
This wipes any previously-stored content for ``collection`` and writes
|
||||
both the vector-chunk and page-text representations from the same
|
||||
``pages`` payload. Pages with empty/whitespace-only text are skipped
|
||||
for chunking but still written to the page store so page numbering is
|
||||
preserved end-to-end.
|
||||
Wipes any previously-stored content for ``(collection, owner_id)`` and
|
||||
writes both the vector-chunk and page-text representations from the
|
||||
same ``pages`` payload. Pages with empty/whitespace-only text are
|
||||
skipped for chunking but still written to the page store so page
|
||||
numbering is preserved end-to-end.
|
||||
"""
|
||||
await self._store.delete_collection(collection)
|
||||
await self._store.ensure_collection(collection, source)
|
||||
if not read_principals:
|
||||
raise ValueError("read_principals must not be empty - every doc needs at least one reader")
|
||||
|
||||
await self._store.delete_collection(collection, owner_id)
|
||||
await self._store.ensure_collection(collection, source, owner_id, expires_at)
|
||||
|
||||
stored_pages = [StoredPage(page_number=p.page_number, text=p.text, char_count=len(p.text)) for p in pages]
|
||||
await self._store.add_pages(collection, stored_pages)
|
||||
await self._store.add_pages(collection, stored_pages, owner_id)
|
||||
await self._store.grant_read(collection, owner_id, read_principals)
|
||||
|
||||
chunks: list[Document] = []
|
||||
for page in pages:
|
||||
@@ -70,63 +88,103 @@ class DocumentService:
|
||||
if not chunks:
|
||||
return 0
|
||||
embeddings = await self._embedder.embed_documents([doc.text for doc in chunks])
|
||||
await self._store.add_documents(collection, chunks, embeddings)
|
||||
await self._store.add_documents(collection, chunks, embeddings, owner_id)
|
||||
return len(chunks)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
principals: list[PrincipalId],
|
||||
collection: FileId | None = None,
|
||||
top_k: int | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Embed query and search across one or all collections.
|
||||
"""Embed query and search collections readable by ``principals``.
|
||||
|
||||
If collection is None, searches all available collections and merges results.
|
||||
If ``collection`` is supplied, search only that collection (and only
|
||||
if at least one principal can read it). If ``None``, search every
|
||||
collection any principal in ``principals`` can read, merge, and
|
||||
return the top-k.
|
||||
"""
|
||||
k = top_k if top_k is not None else self._default_top_k
|
||||
query_embedding = await self._embedder.embed_query(query)
|
||||
|
||||
if collection is not None:
|
||||
if not await self._store.has_collection(collection):
|
||||
if not await self._store.has_collection(collection, principals):
|
||||
return []
|
||||
return await self._store.search(collection, query_embedding, k)
|
||||
return await self._store.search(collection, query_embedding, k, principals)
|
||||
|
||||
# Search all collections, skipping any that error (e.g. dimension mismatch)
|
||||
collections = await self._store.list_collections()
|
||||
# Search every collection the caller can read, skipping any that error
|
||||
# (e.g. dimension mismatch).
|
||||
collections = await self._store.list_collections(principals)
|
||||
all_results: list[SearchResult] = []
|
||||
for col_name in collections:
|
||||
try:
|
||||
results = await self._store.search(col_name, query_embedding, k)
|
||||
results = await self._store.search(col_name, query_embedding, k, principals)
|
||||
all_results.extend(results)
|
||||
except Exception: # noqa: BLE001 - any backend error on one collection should not stop the others
|
||||
logger.warning("Skipping collection %s during cross-collection search", col_name, exc_info=True)
|
||||
logger.warning(
|
||||
"Skipping collection %s during cross-collection search",
|
||||
col_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Sort by score descending, return top_k across all collections
|
||||
# Sort by score descending, return top_k across all the caller's collections
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
return all_results[:k]
|
||||
|
||||
async def read_pages(
|
||||
self,
|
||||
collection: FileId,
|
||||
principals: list[PrincipalId],
|
||||
page_range: PageRange | None = None,
|
||||
) -> list[Page]:
|
||||
"""Return ordered page text for ``collection``.
|
||||
"""Return ordered page text for ``collection`` if any principal can read it."""
|
||||
return await self._store.read_pages(collection, page_range, principals)
|
||||
|
||||
Empty list if the collection has no stored pages.
|
||||
async def delete_collection(self, collection: FileId, owner_id: OwnerId) -> bool:
|
||||
"""Remove a collection (chunks, pages, ACL).
|
||||
|
||||
Returns ``True`` if the collection was found and deleted, ``False`` if
|
||||
no row matched ``(collection, owner_id)``.
|
||||
"""
|
||||
return await self._store.read_pages(collection, page_range)
|
||||
return await self._store.delete_collection(collection, owner_id)
|
||||
|
||||
async def delete_collection(self, collection: FileId) -> None:
|
||||
"""Remove a collection's chunks and pages."""
|
||||
await self._store.delete_collection(collection)
|
||||
async def purge_owner(self, owner_id: OwnerId) -> int:
|
||||
"""Remove every collection ``owner_id`` owns, including vector chunks,
|
||||
page text, and ACL rows. Returns the number of collections purged."""
|
||||
return await self._store.purge_owner(owner_id)
|
||||
|
||||
async def has_collection(self, collection: FileId) -> bool:
|
||||
"""Check whether a collection exists."""
|
||||
return await self._store.has_collection(collection)
|
||||
async def reap_expired(self) -> int:
|
||||
"""Delete collections whose ``expires_at`` is set and in the past.
|
||||
Persistent collections (``expires_at=null``) are never touched.
|
||||
Returns the number of collections deleted."""
|
||||
return await self._store.reap_expired()
|
||||
|
||||
async def list_collections(self) -> list[FileId]:
|
||||
"""List all available collections."""
|
||||
return [FileId(name) for name in await self._store.list_collections()]
|
||||
async def has_collection(self, collection: FileId, principals: list[PrincipalId]) -> bool:
|
||||
"""Check whether at least one principal can read this collection."""
|
||||
return await self._store.has_collection(collection, principals)
|
||||
|
||||
async def list_collections(self, principals: list[PrincipalId]) -> list[FileId]:
|
||||
"""List collections readable by at least one of ``principals``."""
|
||||
return [FileId(name) for name in await self._store.list_collections(principals)]
|
||||
|
||||
async def grant_read(
|
||||
self,
|
||||
collection: FileId,
|
||||
owner_id: OwnerId,
|
||||
principals: list[PrincipalId],
|
||||
) -> None:
|
||||
"""Grant read access to additional principals on an existing doc."""
|
||||
await self._store.grant_read(collection, owner_id, principals)
|
||||
|
||||
async def revoke(
|
||||
self,
|
||||
collection: FileId,
|
||||
owner_id: OwnerId,
|
||||
principal: PrincipalId,
|
||||
) -> None:
|
||||
"""Revoke a principal's access on an existing doc."""
|
||||
await self._store.revoke(collection, owner_id, principal)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release the underlying store's resources."""
|
||||
|
||||
@@ -5,19 +5,35 @@ import json
|
||||
import math
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import sqlite_vec
|
||||
|
||||
from stirling.contracts.documents import Page, PageRange
|
||||
from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage
|
||||
from stirling.models import OwnerId, PrincipalId
|
||||
|
||||
_READ_PERMISSION = "read"
|
||||
# sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS``
|
||||
# so lexicographic comparison against ``datetime('now')`` matches chronological order.
|
||||
_SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
def _to_sqlite_utc(dt: datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(UTC).replace(tzinfo=None)
|
||||
return dt.strftime(_SQLITE_DATETIME_FMT)
|
||||
|
||||
|
||||
class SqliteVecStore(DocumentStore):
|
||||
"""sqlite-vec backed vector store. Single-file SQLite database, embedded, no server.
|
||||
|
||||
Each collection gets its own `vec0` virtual table with a fixed embedding dimension
|
||||
(detected on first insert). Document metadata lives in a regular table joined by rowid.
|
||||
Each ``(collection, owner_id)`` pair gets its own `vec0` virtual table with a
|
||||
fixed embedding dimension (detected on first insert). Document metadata lives
|
||||
in a regular table joined by rowid.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
@@ -51,17 +67,30 @@ class SqliteVecStore(DocumentStore):
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS documents_meta (
|
||||
collection TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
expires_at TIMESTAMP,
|
||||
PRIMARY KEY (collection, owner_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
# The reaper filters on ``expires_at IS NOT NULL AND expires_at < now`` so
|
||||
# a partial index over non-null rows keeps the scan tight even when most
|
||||
# rows are persistent (org docs).
|
||||
self._conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meta_expires_at ON documents_meta(expires_at) WHERE expires_at IS NOT NULL"
|
||||
)
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
name TEXT PRIMARY KEY REFERENCES documents_meta(collection) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
dim INTEGER NOT NULL,
|
||||
table_name TEXT NOT NULL
|
||||
table_name TEXT NOT NULL,
|
||||
PRIMARY KEY (collection, owner_id),
|
||||
FOREIGN KEY (collection, owner_id)
|
||||
REFERENCES documents_meta(collection, owner_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -69,47 +98,154 @@ class SqliteVecStore(DocumentStore):
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT NOT NULL,
|
||||
collection TEXT NOT NULL REFERENCES documents_meta(collection) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
vec_rowid INTEGER NOT NULL,
|
||||
PRIMARY KEY (id, collection)
|
||||
PRIMARY KEY (id, collection, owner_id),
|
||||
FOREIGN KEY (collection, owner_id)
|
||||
REFERENCES documents_meta(collection, owner_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_doc_collection ON documents(collection)")
|
||||
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_doc_collection_owner ON documents(collection, owner_id)")
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS document_pages (
|
||||
collection TEXT NOT NULL REFERENCES documents_meta(collection) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
char_count INTEGER NOT NULL,
|
||||
PRIMARY KEY (collection, page_number)
|
||||
PRIMARY KEY (collection, owner_id, page_number),
|
||||
FOREIGN KEY (collection, owner_id)
|
||||
REFERENCES documents_meta(collection, owner_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_collection ON document_pages(collection)")
|
||||
self._conn.commit()
|
||||
|
||||
async def ensure_collection(self, collection: str, source: str) -> None:
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_ensure_collection, collection, source)
|
||||
|
||||
def _sync_ensure_collection(self, collection: str, source: str) -> None:
|
||||
self._conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_collection_owner ON document_pages(collection, owner_id)"
|
||||
)
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO documents_meta(collection, source) VALUES (?, ?)
|
||||
ON CONFLICT(collection) DO UPDATE SET source = excluded.source
|
||||
""",
|
||||
(collection, source),
|
||||
CREATE TABLE IF NOT EXISTS document_acl (
|
||||
collection TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (collection, owner_id, principal_id, permission),
|
||||
FOREIGN KEY (collection, owner_id)
|
||||
REFERENCES documents_meta(collection, owner_id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Lookup by principal is the hot path for search/list (every read
|
||||
# joins through this index). Composite ordering matches the WHERE.
|
||||
self._conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_acl_principal_permission ON document_acl(principal_id, permission)"
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# ── lifecycle of the (collection, owner_id) row ────────────────────────
|
||||
|
||||
async def ensure_collection(
|
||||
self,
|
||||
collection: str,
|
||||
source: str,
|
||||
owner_id: OwnerId,
|
||||
expires_at: datetime | None,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_ensure_collection, collection, source, owner_id, expires_at)
|
||||
|
||||
def _sync_ensure_collection(
|
||||
self,
|
||||
collection: str,
|
||||
source: str,
|
||||
owner_id: OwnerId,
|
||||
expires_at: datetime | None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO documents_meta(collection, owner_id, source, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(collection, owner_id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
expires_at = excluded.expires_at
|
||||
""",
|
||||
(collection, owner_id, source, _to_sqlite_utc(expires_at)),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
async def delete_collection(self, collection: str, owner_id: OwnerId) -> bool:
|
||||
async with self._lock:
|
||||
return await asyncio.to_thread(self._sync_delete_collection, collection, owner_id)
|
||||
|
||||
def _sync_delete_collection(self, collection: str, owner_id: OwnerId) -> bool:
|
||||
# Drop the sqlite-vec virtual table first; FK cascade handles the regular tables
|
||||
# (collections, documents, document_pages, document_acl) when documents_meta is deleted.
|
||||
row = self._conn.execute(
|
||||
"SELECT table_name FROM collections WHERE collection = ? AND owner_id = ?",
|
||||
(collection, owner_id),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
self._conn.execute(f"DROP TABLE IF EXISTS {row[0]}")
|
||||
cursor = self._conn.execute(
|
||||
"DELETE FROM documents_meta WHERE collection = ? AND owner_id = ?",
|
||||
(collection, owner_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def purge_owner(self, owner_id: OwnerId) -> int:
|
||||
async with self._lock:
|
||||
return await asyncio.to_thread(self._sync_purge_owner, owner_id)
|
||||
|
||||
def _sync_purge_owner(self, owner_id: OwnerId) -> int:
|
||||
# Drop all vec0 virtual tables for this owner first (FK cascade can't reach them).
|
||||
vec_tables = [
|
||||
r[0]
|
||||
for r in self._conn.execute("SELECT table_name FROM collections WHERE owner_id = ?", (owner_id,)).fetchall()
|
||||
]
|
||||
for name in vec_tables:
|
||||
self._conn.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
cursor = self._conn.execute("DELETE FROM documents_meta WHERE owner_id = ?", (owner_id,))
|
||||
self._conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def reap_expired(self) -> int:
|
||||
async with self._lock:
|
||||
return await asyncio.to_thread(self._sync_reap_expired)
|
||||
|
||||
def _sync_reap_expired(self) -> int:
|
||||
# Drop vec0 virtual tables for expired collections first (FK cascade can't reach them).
|
||||
vec_tables = [
|
||||
r[0]
|
||||
for r in self._conn.execute(
|
||||
"""
|
||||
SELECT c.table_name FROM collections c
|
||||
JOIN documents_meta m
|
||||
ON m.collection = c.collection AND m.owner_id = c.owner_id
|
||||
WHERE m.expires_at IS NOT NULL AND m.expires_at < datetime('now')
|
||||
"""
|
||||
).fetchall()
|
||||
]
|
||||
for name in vec_tables:
|
||||
self._conn.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
cursor = self._conn.execute(
|
||||
"DELETE FROM documents_meta WHERE expires_at IS NOT NULL AND expires_at < datetime('now')"
|
||||
)
|
||||
self._conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
# ── write paths ────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_table_name(collection: str) -> str:
|
||||
safe = re.sub(r"[^a-zA-Z0-9_]", "_", collection)
|
||||
return f"vec_{safe}"
|
||||
def _sanitize_table_name(collection: str, owner_id: OwnerId) -> str:
|
||||
safe_col = re.sub(r"[^a-zA-Z0-9_]", "_", collection)
|
||||
safe_owner = re.sub(r"[^a-zA-Z0-9_]", "_", owner_id)
|
||||
return f"vec_{safe_owner}_{safe_col}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize(vector: list[float]) -> list[float]:
|
||||
@@ -123,6 +259,7 @@ class SqliteVecStore(DocumentStore):
|
||||
collection: str,
|
||||
documents: list[Document],
|
||||
embeddings: list[list[float]],
|
||||
owner_id: OwnerId,
|
||||
) -> None:
|
||||
if len(documents) != len(embeddings):
|
||||
raise ValueError(f"Got {len(documents)} documents but {len(embeddings)} embeddings")
|
||||
@@ -130,34 +267,40 @@ class SqliteVecStore(DocumentStore):
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_add, collection, documents, embeddings)
|
||||
await asyncio.to_thread(self._sync_add, collection, documents, embeddings, owner_id)
|
||||
|
||||
def _sync_add(
|
||||
self,
|
||||
collection: str,
|
||||
documents: list[Document],
|
||||
embeddings: list[list[float]],
|
||||
owner_id: OwnerId,
|
||||
) -> None:
|
||||
dim = len(embeddings[0])
|
||||
row = self._conn.execute("SELECT dim, table_name FROM collections WHERE name = ?", (collection,)).fetchone()
|
||||
row = self._conn.execute(
|
||||
"SELECT dim, table_name FROM collections WHERE collection = ? AND owner_id = ?",
|
||||
(collection, owner_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
table_name = self._sanitize_table_name(collection)
|
||||
table_name = self._sanitize_table_name(collection, owner_id)
|
||||
self._conn.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS {table_name} USING vec0(embedding float[{dim}])")
|
||||
self._conn.execute(
|
||||
"INSERT INTO collections(name, dim, table_name) VALUES (?, ?, ?)",
|
||||
(collection, dim, table_name),
|
||||
"INSERT INTO collections(collection, owner_id, dim, table_name) VALUES (?, ?, ?, ?)",
|
||||
(collection, owner_id, dim, table_name),
|
||||
)
|
||||
else:
|
||||
existing_dim, table_name = row
|
||||
if existing_dim != dim:
|
||||
raise ValueError(f"Collection {collection} has dim {existing_dim}, got embedding of dim {dim}")
|
||||
raise ValueError(
|
||||
f"Collection {collection} for owner {owner_id} has dim {existing_dim}, got embedding of dim {dim}"
|
||||
)
|
||||
|
||||
# Upsert: delete existing docs with matching IDs first
|
||||
ids = [doc.id for doc in documents]
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
existing = self._conn.execute(
|
||||
f"SELECT vec_rowid FROM documents WHERE collection = ? AND id IN ({placeholders})",
|
||||
(collection, *ids),
|
||||
f"SELECT vec_rowid FROM documents WHERE collection = ? AND owner_id = ? AND id IN ({placeholders})",
|
||||
(collection, owner_id, *ids),
|
||||
).fetchall()
|
||||
if existing:
|
||||
vec_rowids = [r[0] for r in existing]
|
||||
@@ -167,8 +310,8 @@ class SqliteVecStore(DocumentStore):
|
||||
vec_rowids,
|
||||
)
|
||||
self._conn.execute(
|
||||
f"DELETE FROM documents WHERE collection = ? AND id IN ({placeholders})",
|
||||
(collection, *ids),
|
||||
f"DELETE FROM documents WHERE collection = ? AND owner_id = ? AND id IN ({placeholders})",
|
||||
(collection, owner_id, *ids),
|
||||
)
|
||||
|
||||
for doc, emb in zip(documents, embeddings):
|
||||
@@ -179,27 +322,101 @@ class SqliteVecStore(DocumentStore):
|
||||
)
|
||||
vec_rowid = cursor.lastrowid
|
||||
self._conn.execute(
|
||||
"INSERT INTO documents(id, collection, text, metadata, vec_rowid) VALUES (?, ?, ?, ?, ?)",
|
||||
(doc.id, collection, doc.text, json.dumps(doc.metadata), vec_rowid),
|
||||
"INSERT INTO documents(id, collection, owner_id, text, metadata, vec_rowid) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(doc.id, collection, owner_id, doc.text, json.dumps(doc.metadata), vec_rowid),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
async def add_pages(self, collection: str, pages: list[StoredPage], owner_id: OwnerId) -> None:
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_add_pages, collection, pages, owner_id)
|
||||
|
||||
def _sync_add_pages(self, collection: str, pages: list[StoredPage], owner_id: OwnerId) -> None:
|
||||
self._conn.execute(
|
||||
"DELETE FROM document_pages WHERE collection = ? AND owner_id = ?",
|
||||
(collection, owner_id),
|
||||
)
|
||||
if pages:
|
||||
self._conn.executemany(
|
||||
"INSERT INTO document_pages(collection, owner_id, page_number, text, char_count) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
[(collection, owner_id, p.page_number, p.text, p.char_count) for p in pages],
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# ── ACL management ─────────────────────────────────────────────────────
|
||||
|
||||
async def grant_read(
|
||||
self,
|
||||
collection: str,
|
||||
owner_id: OwnerId,
|
||||
principals: list[PrincipalId],
|
||||
) -> None:
|
||||
if not principals:
|
||||
return
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_grant_read, collection, owner_id, principals)
|
||||
|
||||
def _sync_grant_read(
|
||||
self,
|
||||
collection: str,
|
||||
owner_id: OwnerId,
|
||||
principals: list[PrincipalId],
|
||||
) -> None:
|
||||
self._conn.executemany(
|
||||
"""
|
||||
INSERT INTO document_acl(collection, owner_id, principal_id, permission)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(collection, owner_id, principal_id, permission) DO NOTHING
|
||||
""",
|
||||
[(collection, owner_id, p, _READ_PERMISSION) for p in principals],
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
async def revoke(
|
||||
self,
|
||||
collection: str,
|
||||
owner_id: OwnerId,
|
||||
principal: PrincipalId,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_revoke, collection, owner_id, principal)
|
||||
|
||||
def _sync_revoke(self, collection: str, owner_id: OwnerId, principal: PrincipalId) -> None:
|
||||
self._conn.execute(
|
||||
"DELETE FROM document_acl WHERE collection = ? AND owner_id = ? AND principal_id = ?",
|
||||
(collection, owner_id, principal),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# ── read paths (ACL-gated) ─────────────────────────────────────────────
|
||||
|
||||
async def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: list[float],
|
||||
top_k: int = 5,
|
||||
top_k: int,
|
||||
principals: list[PrincipalId],
|
||||
) -> list[SearchResult]:
|
||||
async with self._lock:
|
||||
return await asyncio.to_thread(self._sync_search, collection, query_embedding, top_k)
|
||||
return await asyncio.to_thread(self._sync_search, collection, query_embedding, top_k, principals)
|
||||
|
||||
def _sync_search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: list[float],
|
||||
top_k: int,
|
||||
principals: list[PrincipalId],
|
||||
) -> list[SearchResult]:
|
||||
row = self._conn.execute("SELECT table_name, dim FROM collections WHERE name = ?", (collection,)).fetchone()
|
||||
if not principals:
|
||||
return []
|
||||
owner_id = self._readable_owner_for(collection, principals)
|
||||
if owner_id is None:
|
||||
return []
|
||||
row = self._conn.execute(
|
||||
"SELECT table_name, dim FROM collections WHERE collection = ? AND owner_id = ?",
|
||||
(collection, owner_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return []
|
||||
table_name, dim = row
|
||||
@@ -213,11 +430,14 @@ class SqliteVecStore(DocumentStore):
|
||||
f"""
|
||||
SELECT d.id, d.text, d.metadata, v.distance
|
||||
FROM {table_name} v
|
||||
JOIN documents d ON d.vec_rowid = v.rowid AND d.collection = ?
|
||||
JOIN documents d
|
||||
ON d.vec_rowid = v.rowid
|
||||
AND d.collection = ?
|
||||
AND d.owner_id = ?
|
||||
WHERE v.embedding MATCH ? AND k = ?
|
||||
ORDER BY v.distance
|
||||
""",
|
||||
(collection, query_blob, top_k),
|
||||
(collection, owner_id, query_blob, top_k),
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
@@ -233,77 +453,85 @@ class SqliteVecStore(DocumentStore):
|
||||
for r in results
|
||||
]
|
||||
|
||||
async def add_pages(self, collection: str, pages: list[StoredPage]) -> None:
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_add_pages, collection, pages)
|
||||
|
||||
def _sync_add_pages(self, collection: str, pages: list[StoredPage]) -> None:
|
||||
self._conn.execute("DELETE FROM document_pages WHERE collection = ?", (collection,))
|
||||
if pages:
|
||||
self._conn.executemany(
|
||||
"INSERT INTO document_pages(collection, page_number, text, char_count) VALUES (?, ?, ?, ?)",
|
||||
[(collection, p.page_number, p.text, p.char_count) for p in pages],
|
||||
)
|
||||
self._conn.commit()
|
||||
def _readable_owner_for(self, collection: str, principals: list[PrincipalId]) -> str | None:
|
||||
"""Resolve which owner_id this caller is reading. ``None`` means no access."""
|
||||
placeholders = ",".join("?" * len(principals))
|
||||
row = self._conn.execute(
|
||||
f"""
|
||||
SELECT owner_id FROM document_acl
|
||||
WHERE collection = ?
|
||||
AND permission = ?
|
||||
AND principal_id IN ({placeholders})
|
||||
ORDER BY owner_id
|
||||
LIMIT 1
|
||||
""",
|
||||
(collection, _READ_PERMISSION, *principals),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
async def read_pages(
|
||||
self,
|
||||
collection: str,
|
||||
page_range: PageRange | None = None,
|
||||
page_range: PageRange | None,
|
||||
principals: list[PrincipalId],
|
||||
) -> list[Page]:
|
||||
async with self._lock:
|
||||
return await asyncio.to_thread(self._sync_read_pages, collection, page_range)
|
||||
return await asyncio.to_thread(self._sync_read_pages, collection, page_range, principals)
|
||||
|
||||
def _sync_read_pages(
|
||||
self,
|
||||
collection: str,
|
||||
page_range: PageRange | None,
|
||||
principals: list[PrincipalId],
|
||||
) -> list[Page]:
|
||||
if not principals:
|
||||
return []
|
||||
owner_id = self._readable_owner_for(collection, principals)
|
||||
if owner_id is None:
|
||||
return []
|
||||
if page_range is None:
|
||||
rows = self._conn.execute(
|
||||
"SELECT page_number, text, char_count FROM document_pages WHERE collection = ? ORDER BY page_number",
|
||||
(collection,),
|
||||
"SELECT page_number, text, char_count FROM document_pages "
|
||||
"WHERE collection = ? AND owner_id = ? ORDER BY page_number",
|
||||
(collection, owner_id),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"SELECT page_number, text, char_count FROM document_pages "
|
||||
"WHERE collection = ? AND page_number BETWEEN ? AND ? ORDER BY page_number",
|
||||
(collection, page_range.start, page_range.end),
|
||||
"WHERE collection = ? AND owner_id = ? AND page_number BETWEEN ? AND ? "
|
||||
"ORDER BY page_number",
|
||||
(collection, owner_id, page_range.start, page_range.end),
|
||||
).fetchall()
|
||||
return [Page(page_number=r[0], text=r[1], char_count=r[2]) for r in rows]
|
||||
|
||||
async def delete_collection(self, collection: str) -> None:
|
||||
async def has_collection(self, collection: str, principals: list[PrincipalId]) -> bool:
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_delete_collection, collection)
|
||||
return await asyncio.to_thread(self._sync_has_collection, collection, principals)
|
||||
|
||||
def _sync_delete_collection(self, collection: str) -> None:
|
||||
# Drop the sqlite-vec virtual table first; FK cascade handles the regular tables
|
||||
# (collections, documents, document_pages) when documents_meta is deleted.
|
||||
row = self._conn.execute("SELECT table_name FROM collections WHERE name = ?", (collection,)).fetchone()
|
||||
if row is not None:
|
||||
self._conn.execute(f"DROP TABLE IF EXISTS {row[0]}")
|
||||
self._conn.execute("DELETE FROM documents_meta WHERE collection = ?", (collection,))
|
||||
self._conn.commit()
|
||||
def _sync_has_collection(self, collection: str, principals: list[PrincipalId]) -> bool:
|
||||
if not principals:
|
||||
return False
|
||||
return self._readable_owner_for(collection, principals) is not None
|
||||
|
||||
async def list_collections(self) -> list[str]:
|
||||
async def list_collections(self, principals: list[PrincipalId]) -> list[str]:
|
||||
async with self._lock:
|
||||
return await asyncio.to_thread(self._sync_list_collections)
|
||||
return await asyncio.to_thread(self._sync_list_collections, principals)
|
||||
|
||||
def _sync_list_collections(self) -> list[str]:
|
||||
rows = self._conn.execute("SELECT collection FROM documents_meta ORDER BY collection").fetchall()
|
||||
def _sync_list_collections(self, principals: list[PrincipalId]) -> list[str]:
|
||||
if not principals:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(principals))
|
||||
rows = self._conn.execute(
|
||||
f"""
|
||||
SELECT DISTINCT collection FROM document_acl
|
||||
WHERE permission = ?
|
||||
AND principal_id IN ({placeholders})
|
||||
ORDER BY collection
|
||||
""",
|
||||
(_READ_PERMISSION, *principals),
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
async def has_collection(self, collection: str) -> bool:
|
||||
async with self._lock:
|
||||
return await asyncio.to_thread(self._sync_has_collection, collection)
|
||||
|
||||
def _sync_has_collection(self, collection: str) -> bool:
|
||||
row = self._conn.execute(
|
||||
"SELECT 1 FROM documents_meta WHERE collection = ?",
|
||||
(collection,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self._lock:
|
||||
await asyncio.to_thread(self._sync_close)
|
||||
|
||||
@@ -2,8 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from stirling.contracts.documents import Page, PageRange
|
||||
from stirling.models import OwnerId, PrincipalId
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,73 +42,113 @@ class DocumentStore(ABC):
|
||||
* **Vector chunks** - small, embedded chunks used for RAG search.
|
||||
* **Ordered pages** - the original page text retained in document order,
|
||||
used for whole-document reading.
|
||||
|
||||
Both representations live under the same ``collection`` (file id) and are
|
||||
rooted at a single parent row in ``documents_meta``. Removing that parent
|
||||
row cascades to both child representations, so :meth:`delete_collection`
|
||||
is one logical delete.
|
||||
"""
|
||||
|
||||
# ── lifecycle of the (collection, owner_id) row ────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def ensure_collection(self, collection: str, source: str) -> None:
|
||||
"""Upsert the top-level ``documents_meta`` row for this collection.
|
||||
async def ensure_collection(
|
||||
self,
|
||||
collection: str,
|
||||
source: str,
|
||||
owner_id: OwnerId,
|
||||
expires_at: datetime | None,
|
||||
) -> None:
|
||||
"""Upsert the top-level ``documents_meta`` row for ``(collection, owner_id)``.
|
||||
|
||||
Must be called before :meth:`add_pages` or :meth:`add_documents`. Both
|
||||
of those write into child tables that hold a foreign key to the parent
|
||||
row, so it must exist first.
|
||||
write into child tables that hold a foreign key to the parent row, so
|
||||
it must exist first.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_collection(self, collection: str, owner_id: OwnerId) -> bool:
|
||||
"""Remove a collection's chunks, pages, and ACL rows.
|
||||
|
||||
Returns ``True`` when a row matched and was deleted, ``False`` when
|
||||
``(collection, owner_id)`` didn't exist.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def purge_owner(self, owner_id: OwnerId) -> int:
|
||||
"""Remove every collection (and ACL row) belonging to ``owner_id``.
|
||||
Returns the number of collections purged.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def reap_expired(self) -> int:
|
||||
"""Remove collections whose ``expires_at`` is non-null and in the past."""
|
||||
|
||||
# ── write paths (scoped by owner) ──────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def add_documents(
|
||||
self,
|
||||
collection: str,
|
||||
documents: list[Document],
|
||||
embeddings: list[list[float]],
|
||||
owner_id: OwnerId,
|
||||
) -> None:
|
||||
"""Store vector chunks with their embeddings in the named collection."""
|
||||
"""Store vector chunks with their embeddings under the owner's collection."""
|
||||
|
||||
@abstractmethod
|
||||
async def add_pages(self, collection: str, pages: list[StoredPage], owner_id: OwnerId) -> None:
|
||||
"""Replace the stored pages for ``(collection, owner_id)`` with the supplied pages."""
|
||||
|
||||
# ── ACL management ─────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def grant_read(
|
||||
self,
|
||||
collection: str,
|
||||
owner_id: OwnerId,
|
||||
principals: list[PrincipalId],
|
||||
) -> None:
|
||||
"""Grant read access on ``(collection, owner_id)`` to each principal."""
|
||||
|
||||
@abstractmethod
|
||||
async def revoke(
|
||||
self,
|
||||
collection: str,
|
||||
owner_id: OwnerId,
|
||||
principal: PrincipalId,
|
||||
) -> None:
|
||||
"""Remove every permission this principal has on ``(collection, owner_id)``."""
|
||||
|
||||
# ── read paths (scoped by ACL principal set) ───────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: list[float],
|
||||
top_k: int = 5,
|
||||
top_k: int,
|
||||
principals: list[PrincipalId],
|
||||
) -> list[SearchResult]:
|
||||
"""Return the top_k most similar vector chunks from the collection."""
|
||||
"""Top-k similar vector chunks from ``collection`` that any principal can read.
|
||||
|
||||
@abstractmethod
|
||||
async def add_pages(self, collection: str, pages: list[StoredPage]) -> None:
|
||||
"""Replace the stored pages for ``collection`` with the supplied pages.
|
||||
|
||||
Implementations must remove any previously-stored pages for the
|
||||
collection before writing, so callers can re-ingest by calling this
|
||||
method again.
|
||||
Returns an empty list when no principal in ``principals`` has a read
|
||||
ACL row for this collection (regardless of which owner backs it).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def read_pages(
|
||||
self,
|
||||
collection: str,
|
||||
page_range: PageRange | None = None,
|
||||
page_range: PageRange | None,
|
||||
principals: list[PrincipalId],
|
||||
) -> list[Page]:
|
||||
"""Return ordered pages for ``collection``.
|
||||
|
||||
If ``page_range`` is ``None`` all pages are returned. Otherwise only
|
||||
pages whose ``page_number`` falls within the inclusive range are
|
||||
returned. Pages are always ordered by ``page_number`` ascending.
|
||||
"""
|
||||
"""Return ordered pages for ``collection``. Empty list when no read ACL match."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_collection(self, collection: str) -> None:
|
||||
"""Remove a collection's chunks and pages."""
|
||||
async def has_collection(self, collection: str, principals: list[PrincipalId]) -> bool:
|
||||
"""Check whether any principal in ``principals`` can read this collection."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_collections(self) -> list[str]:
|
||||
"""Return names of all existing collections."""
|
||||
async def list_collections(self, principals: list[PrincipalId]) -> list[str]:
|
||||
"""Return collection names readable by at least one of ``principals``."""
|
||||
|
||||
@abstractmethod
|
||||
async def has_collection(self, collection: str) -> bool:
|
||||
"""Check whether a collection exists."""
|
||||
# ── lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from . import tool_models
|
||||
from .base import ApiModel, FileId
|
||||
from .base import ApiModel, FileId, OwnerId, PrincipalId, UserId
|
||||
from .tool_models import OPERATIONS, ParamToolModel, ToolEndpoint
|
||||
|
||||
__all__ = [
|
||||
"ApiModel",
|
||||
"FileId",
|
||||
"OPERATIONS",
|
||||
"OwnerId",
|
||||
"ParamToolModel",
|
||||
"PrincipalId",
|
||||
"ToolEndpoint",
|
||||
"UserId",
|
||||
"tool_models",
|
||||
]
|
||||
|
||||
@@ -5,11 +5,18 @@ from typing import NewType
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
# Stable, opaque identifier for a file supplied by the caller. Owned by the caller's
|
||||
# ID strategy (content hash, filesystem path, etc.) and used as the RAG collection key
|
||||
# throughout the engine.
|
||||
# Stable, opaque identifier for a file supplied by the caller
|
||||
FileId = NewType("FileId", str)
|
||||
|
||||
# Stable, opaque identifier for the calling user
|
||||
UserId = NewType("UserId", str)
|
||||
|
||||
# Tenant that owns a document
|
||||
OwnerId = NewType("OwnerId", str)
|
||||
|
||||
# An entity that can hold permissions on a document
|
||||
PrincipalId = NewType("PrincipalId", str)
|
||||
|
||||
|
||||
class ApiModel(BaseModel):
|
||||
"""Base for every contract model crossing a service boundary."""
|
||||
|
||||
@@ -7,14 +7,16 @@ from .progress import (
|
||||
set_progress_emitter,
|
||||
)
|
||||
from .runtime import AppRuntime, build_model_settings, build_runtime
|
||||
from .tracking import setup_posthog_tracking
|
||||
from .tracking import current_user_id, require_current_user_id, setup_posthog_tracking
|
||||
|
||||
__all__ = [
|
||||
"AppRuntime",
|
||||
"ProgressEmitter",
|
||||
"build_model_settings",
|
||||
"build_runtime",
|
||||
"current_user_id",
|
||||
"emit_progress",
|
||||
"require_current_user_id",
|
||||
"reset_progress_emitter",
|
||||
"set_progress_emitter",
|
||||
"setup_posthog_tracking",
|
||||
|
||||
@@ -16,7 +16,6 @@ from stirling.documents import (
|
||||
DocumentStore,
|
||||
EmbeddingService,
|
||||
PgVectorStore,
|
||||
RagCapability,
|
||||
SqliteVecStore,
|
||||
)
|
||||
|
||||
@@ -47,7 +46,6 @@ class AppRuntime:
|
||||
fast_model: Model
|
||||
smart_model: Model
|
||||
documents: DocumentService
|
||||
rag_capability: RagCapability
|
||||
|
||||
@property
|
||||
def fast_model_settings(self) -> ModelSettings:
|
||||
@@ -89,8 +87,8 @@ def _build_document_store(settings: AppSettings) -> DocumentStore:
|
||||
assert_never(settings.rag_backend)
|
||||
|
||||
|
||||
def _build_documents(settings: AppSettings) -> tuple[DocumentService, RagCapability]:
|
||||
"""Build the document service and the RAG-search capability that wraps it."""
|
||||
def _build_documents(settings: AppSettings) -> DocumentService:
|
||||
"""Build the document service used by per-request RAG capabilities."""
|
||||
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
|
||||
embedder = EmbeddingService(
|
||||
model_name=settings.rag_embedding_model,
|
||||
@@ -98,9 +96,7 @@ def _build_documents(settings: AppSettings) -> tuple[DocumentService, RagCapabil
|
||||
chunk_overlap=settings.rag_chunk_overlap,
|
||||
)
|
||||
store = _build_document_store(settings)
|
||||
service = DocumentService(embedder=embedder, store=store, default_top_k=settings.rag_default_top_k)
|
||||
capability = RagCapability(documents=service, top_k=settings.rag_default_top_k)
|
||||
return service, capability
|
||||
return DocumentService(embedder=embedder, store=store, default_top_k=settings.rag_default_top_k)
|
||||
|
||||
|
||||
def build_runtime(settings: AppSettings) -> AppRuntime:
|
||||
@@ -109,14 +105,11 @@ def build_runtime(settings: AppSettings) -> AppRuntime:
|
||||
validate_structured_output_support(fast_model, settings.fast_model_name)
|
||||
validate_structured_output_support(smart_model, settings.smart_model_name)
|
||||
|
||||
documents, rag_capability = _build_documents(settings)
|
||||
|
||||
return AppRuntime(
|
||||
settings=settings,
|
||||
fast_model=fast_model,
|
||||
smart_model=smart_model,
|
||||
documents=documents,
|
||||
rag_capability=rag_capability,
|
||||
documents=_build_documents(settings),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,10 +27,25 @@ from opentelemetry.trace import Span
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
from stirling.config import AppSettings
|
||||
from stirling.models import UserId
|
||||
|
||||
# Per-request user ID, set by middleware from the X-User-Id header.
|
||||
# When not set, PostHog generates a random ID and marks the event as personless.
|
||||
current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None)
|
||||
current_user_id: ContextVar[UserId | None] = ContextVar("current_user_id", default=None)
|
||||
|
||||
|
||||
def require_current_user_id() -> UserId:
|
||||
"""Return the request's user ID or raise if the X-User-Id header was missing.
|
||||
|
||||
Use at the boundary of any code path that touches per-user document
|
||||
storage (vector chunks, page text, ACL rows). Routes prefer the FastAPI dependency form
|
||||
(``Depends(require_user_id)``); agent internals that don't have a request
|
||||
object in scope call this helper directly to fail closed.
|
||||
"""
|
||||
user_id = current_user_id.get()
|
||||
if user_id is None:
|
||||
raise RuntimeError("X-User-Id is required for this operation but was not set on the request")
|
||||
return user_id
|
||||
|
||||
|
||||
class LRUSet:
|
||||
|
||||
@@ -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