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

192 lines
7.9 KiB
Python

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, OwnerId, PrincipalId
logger = logging.getLogger(__name__)
PAGE_NUMBER_METADATA_KEY = "page_number"
CONTENT_TYPE_METADATA_KEY = "content_type"
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, owner_id)`` pair:
* **Vector chunks** for RAG-style semantic retrieval (``search``).
* **Ordered pages** for whole-document reading (``read_pages``).
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:
self._embedder = embedder
self._store = store
self._default_top_k = default_top_k
async def ingest(
self,
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.
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.
"""
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, owner_id)
await self._store.grant_read(collection, owner_id, read_principals)
chunks: list[Document] = []
for page in pages:
if not page.text.strip():
continue
chunks.extend(
self._embedder.chunk_and_prepare(
text=page.text,
source=f"{source}:page:{page.page_number}",
base_metadata={
PAGE_NUMBER_METADATA_KEY: str(page.page_number),
CONTENT_TYPE_METADATA_KEY: PAGE_TEXT_CONTENT_TYPE,
},
)
)
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, 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 collections readable by ``principals``.
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, principals):
return []
return await self._store.search(collection, query_embedding, k, principals)
# 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, 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,
)
# 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`` if any principal can read it."""
return await self._store.read_pages(collection, page_range, principals)
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.delete_collection(collection, owner_id)
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 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 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."""
await self._store.close()