# 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.
156 lines
6.1 KiB
Python
156 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from pydantic_ai import FunctionToolset, RunContext, ToolDefinition
|
|
from pydantic_ai.toolsets import AbstractToolset
|
|
|
|
from stirling.documents.service import DocumentService
|
|
from stirling.documents.store import SearchResult
|
|
from stirling.models import FileId, PrincipalId
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RagCapability:
|
|
"""Bundles RAG instructions and the ``search_knowledge`` toolset for agent injection.
|
|
|
|
Agents construct one per run, scoped to the caller's principal set, as::
|
|
|
|
rag = RagCapability(
|
|
documents=self.runtime.documents,
|
|
principals=current_principals(),
|
|
collections=[file.id for file in request.files],
|
|
)
|
|
Agent(
|
|
...,
|
|
instructions=rag.instructions,
|
|
toolsets=[rag.toolset],
|
|
)
|
|
|
|
When no collections are pinned, the instructions are generated dynamically at
|
|
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 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
|
|
self._search_count = 0
|
|
toolset: FunctionToolset[None] = FunctionToolset()
|
|
toolset.add_function(
|
|
self._search_knowledge,
|
|
name="search_knowledge",
|
|
prepare=self._prepare_search_knowledge,
|
|
)
|
|
self._toolset = toolset
|
|
|
|
@property
|
|
def instructions(self) -> str | Callable[[], Awaitable[str]]:
|
|
if self._collections:
|
|
return self._static_instructions_text(self._collections)
|
|
return self._dynamic_instructions
|
|
|
|
@property
|
|
def toolset(self) -> AbstractToolset[None]:
|
|
return self._toolset
|
|
|
|
@staticmethod
|
|
def _static_instructions_text(collections: list[FileId]) -> str:
|
|
collection_desc = f"collections: {', '.join(collections)}"
|
|
return (
|
|
"You have access to a knowledge base search tool called 'search_knowledge'. "
|
|
f"It searches {collection_desc} for relevant information. "
|
|
"Use it when the provided context is insufficient to answer the user's question, "
|
|
"or when you think additional background information would improve your answer. "
|
|
"You do not have to use it if the answer is already clear from the provided text."
|
|
)
|
|
|
|
async def _dynamic_instructions(self) -> str:
|
|
collections = await self._documents.list_collections(self._principals)
|
|
if collections:
|
|
names = ", ".join(collections)
|
|
collection_desc = f"the following knowledge base collections: {names}"
|
|
else:
|
|
collection_desc = "the knowledge base (currently empty — no collections indexed yet)"
|
|
return (
|
|
"You have access to a knowledge base search tool called 'search_knowledge'. "
|
|
f"It searches {collection_desc} for relevant information. "
|
|
"Use it when the provided context is insufficient to answer the user's question, "
|
|
"or when you think additional background information would improve your answer. "
|
|
"You do not have to use it if the answer is already clear from the provided text."
|
|
)
|
|
|
|
async def _prepare_search_knowledge(
|
|
self,
|
|
ctx: RunContext[None],
|
|
tool_def: ToolDefinition,
|
|
) -> ToolDefinition | None:
|
|
"""Remove the search tool from the agent's toolset once the per-run search
|
|
budget is exhausted. The agent then has no choice but to answer from what it
|
|
has already retrieved, which prevents runaway search loops."""
|
|
if self._search_count >= self._max_searches:
|
|
return None
|
|
return tool_def
|
|
|
|
async def _search_knowledge(self, query: str, max_results: int | None = None) -> str:
|
|
"""Search the knowledge base for information relevant to the query.
|
|
|
|
Args:
|
|
query: The search query describing what information you need.
|
|
max_results: Maximum number of results to return.
|
|
|
|
Returns:
|
|
Formatted text with the most relevant knowledge base excerpts.
|
|
"""
|
|
self._search_count += 1
|
|
k = max_results if max_results is not None else self._top_k
|
|
if self._collections:
|
|
all_results = []
|
|
for col in self._collections:
|
|
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, principals=self._principals, top_k=k)
|
|
|
|
if not results:
|
|
logger.info("[rag] search_knowledge query=%r -> 0 results", query)
|
|
return "No relevant results found in the knowledge base."
|
|
|
|
formatted = self._format_results(results)
|
|
logger.info(
|
|
"[rag] search_knowledge query=%r -> %d results, %d chars",
|
|
query,
|
|
len(results),
|
|
len(formatted),
|
|
)
|
|
logger.debug("[rag] search_knowledge query=%r returned:\n%s", query, formatted)
|
|
return formatted
|
|
|
|
@staticmethod
|
|
def _format_results(results: list[SearchResult]) -> str:
|
|
sections = []
|
|
for i, result in enumerate(results, 1):
|
|
source = result.document.metadata.get("source", "unknown")
|
|
chunk_idx = result.document.metadata.get("chunk_index", "?")
|
|
score = f"{result.score:.3f}"
|
|
sections.append(
|
|
f"[Result {i} | source: {source}, chunk: {chunk_idx}, relevance: {score}]\n{result.document.text}"
|
|
)
|
|
return "\n\n---\n\n".join(sections)
|