mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-07-24 21:41:04 +02:00
The server's chat/completions agent identifies documents by doc_name (api.py:3108-3112), not doc_id. The local agent tools used UUID-based doc_id, forcing the LLM to copy opaque strings and making same-name documents indistinguishable. - Agent tools now take doc_name (doc_id accepted as fallback) - _resolve: name→id within scope, ambiguity returns candidates - _sanitize_doc_name: mirrors server sanitize_filename (NFKC, whitespace collapse, 180-byte truncation with md5 suffix) - _dedupe_doc_name: a.pdf → a_1.pdf on collision, matching the server's _find_unique_key suffix scheme - _defang_delimiters: also collapses whitespace to block newline injection in the <docs> prompt block - System prompts and wrap_with_doc_context updated to name-first
456 lines
21 KiB
Python
456 lines
21 KiB
Python
# pageindex/backend/local.py
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import unicodedata
|
|
import uuid
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from ..parser.protocol import DocumentParser, ParsedDocument
|
|
from ..parser.pdf import PdfParser
|
|
from ..parser.markdown import MarkdownParser
|
|
from ..storage.protocol import StorageEngine
|
|
from ..index.pipeline import build_index
|
|
from ..index.utils import parse_pages, get_pdf_page_content, remove_fields
|
|
from ..backend.protocol import AgentTools
|
|
from ..errors import (FileTypeError, DocumentNotFoundError, CollectionNotFoundError,
|
|
IndexingError)
|
|
from .._validation import validate_collection_name
|
|
|
|
# Collections created by older SDK versions used their names directly as safe
|
|
# directory names. Preserve that layout for backward compatibility; names newly
|
|
# allowed by the cloud-compatible contract use an opaque directory instead.
|
|
_LEGACY_COLLECTION_DIR_RE = re.compile(r'[a-zA-Z0-9_-]{1,128}')
|
|
|
|
|
|
class _DocResolveError(Exception):
|
|
"""Agent-tool doc_name resolution failure; str() is the agent-facing error JSON."""
|
|
|
|
|
|
class LocalBackend:
|
|
def __init__(self, storage: StorageEngine, files_dir: str, model: str = None,
|
|
retrieve_model: str = None, index_config=None):
|
|
self._storage = storage
|
|
self._files_dir = Path(files_dir)
|
|
self._model = model
|
|
self._retrieve_model = retrieve_model or model
|
|
self._index_config = index_config
|
|
self._parsers: list[DocumentParser] = [PdfParser(), MarkdownParser()]
|
|
|
|
def register_parser(self, parser: DocumentParser) -> None:
|
|
self._parsers.insert(0, parser) # user parsers checked first
|
|
|
|
def get_retrieve_model(self) -> str | None:
|
|
return self._retrieve_model
|
|
|
|
def _resolve_parser(self, file_path: str) -> DocumentParser:
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
for parser in self._parsers:
|
|
if ext in parser.supported_extensions():
|
|
return parser
|
|
raise FileTypeError(f"No parser for extension: {ext}")
|
|
|
|
# Collection management
|
|
def _validate_collection_name(self, name: str) -> None:
|
|
validate_collection_name(name)
|
|
|
|
def _collection_dir(self, name: str) -> Path:
|
|
"""Return a stable, contained directory for a logical collection name.
|
|
|
|
Legacy-safe names retain their historical ``files/{name}`` layout. Any
|
|
other cloud-valid name is hashed so spaces, Unicode, slashes, ``..``, or
|
|
platform-specific path characters can never escape ``files_dir``.
|
|
"""
|
|
if _LEGACY_COLLECTION_DIR_RE.fullmatch(name):
|
|
return self._files_dir / name
|
|
digest = hashlib.sha256(name.encode("utf-8")).hexdigest()
|
|
return self._files_dir / ".collections" / digest
|
|
|
|
def create_collection(self, name: str) -> None:
|
|
self._validate_collection_name(name)
|
|
self._storage.create_collection(name)
|
|
|
|
def get_or_create_collection(self, name: str) -> None:
|
|
self._validate_collection_name(name)
|
|
self._storage.get_or_create_collection(name)
|
|
|
|
def list_collections(self) -> list[str]:
|
|
return self._storage.list_collections()
|
|
|
|
def delete_collection(self, name: str) -> None:
|
|
self._validate_collection_name(name)
|
|
self._storage.delete_collection(name)
|
|
col_dir = self._collection_dir(name)
|
|
if col_dir.exists():
|
|
shutil.rmtree(col_dir)
|
|
|
|
@staticmethod
|
|
def _file_hash(file_path: str) -> str:
|
|
"""Compute SHA-256 hash of a file."""
|
|
h = hashlib.sha256()
|
|
with open(file_path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
@staticmethod
|
|
def _sanitize_doc_name(doc_name: str, max_bytes: int = 180) -> str:
|
|
"""Match the cloud upload pipeline's sanitize_filename: NFKC-normalize
|
|
and collapse whitespace so names are reproducible by the agent (and a
|
|
newline in a filename can't forge extra entries in the <docs> prompt
|
|
block), then truncate over-long names with a stable hash suffix."""
|
|
name = re.sub(r"\s+", " ", unicodedata.normalize("NFKC", doc_name)).strip()
|
|
if len(name.encode("utf-8")) <= max_bytes:
|
|
return name
|
|
stem, ext = os.path.splitext(name)
|
|
suffix = "_" + hashlib.md5(name.encode("utf-8")).hexdigest()[:8]
|
|
max_stem = max_bytes - len(ext.encode("utf-8")) - len(suffix.encode("utf-8"))
|
|
while len(stem.encode("utf-8")) > max_stem and stem:
|
|
stem = stem[:-1]
|
|
return stem + suffix + ext
|
|
|
|
def _dedupe_doc_name(self, collection: str, doc_name: str) -> str:
|
|
"""Uniquify a colliding doc_name with a numeric suffix (a.pdf ->
|
|
a_1.pdf), matching the cloud upload contract — names stay unique per
|
|
collection so the name-based agent tools resolve unambiguously."""
|
|
existing = {d["doc_name"] for d in self._storage.list_documents(collection)}
|
|
if doc_name not in existing:
|
|
return doc_name
|
|
stem, ext = os.path.splitext(doc_name)
|
|
num = 1
|
|
while f"{stem}_{num}{ext}" in existing:
|
|
num += 1
|
|
return f"{stem}_{num}{ext}"
|
|
|
|
# Document management
|
|
def add_document(self, collection: str, file_path: str) -> str:
|
|
file_path = os.path.realpath(file_path)
|
|
if not os.path.isfile(file_path):
|
|
# Missing path is a file-not-found error, not an unsupported-type one.
|
|
raise FileNotFoundError(f"No such file: {file_path}")
|
|
# Fail fast before the expensive parse + LLM indexing if the collection
|
|
# doesn't exist — otherwise the FK constraint only trips at save time,
|
|
# after the LLM work (and its cost) is already spent.
|
|
if collection not in self._storage.list_collections():
|
|
raise CollectionNotFoundError(
|
|
f"Collection '{collection}' does not exist; "
|
|
f"create it first (e.g. client.collection('{collection}'))."
|
|
)
|
|
parser = self._resolve_parser(file_path)
|
|
|
|
# Dedup is content-only — same file is reused regardless of IndexConfig
|
|
# changes. If you've changed IndexConfig and need a fresh tree, delete
|
|
# the existing doc first or use a new collection.
|
|
file_hash = self._file_hash(file_path)
|
|
existing_id = self._storage.find_document_by_hash(collection, file_hash)
|
|
if existing_id:
|
|
return existing_id
|
|
|
|
doc_id = str(uuid.uuid4())
|
|
|
|
# Copy file to managed directory
|
|
ext = os.path.splitext(file_path)[1]
|
|
col_dir = self._collection_dir(collection)
|
|
col_dir.mkdir(parents=True, exist_ok=True)
|
|
managed_path = col_dir / f"{doc_id}{ext}"
|
|
shutil.copy2(file_path, managed_path)
|
|
|
|
try:
|
|
# Store images alongside the managed document directory.
|
|
images_dir = str(col_dir / doc_id / "images")
|
|
parsed = parser.parse(file_path, model=self._model, images_dir=images_dir)
|
|
result = build_index(parsed, model=self._model, opt=self._index_config)
|
|
|
|
# Cache page text for fast retrieval (avoids re-reading files) and to
|
|
# reconstruct node text on demand (get_document(include_text=True),
|
|
# get_page_content fallback) independent of whether IndexConfig kept
|
|
# text in the stored structure. build_index() already applies
|
|
# if_add_node_text to result["structure"] for every strategy, so no
|
|
# extra stripping is needed here.
|
|
pages = [{"page": n.index, "content": n.content,
|
|
**({"images": n.images} if n.images else {})}
|
|
for n in parsed.nodes if n.content]
|
|
|
|
doc_name = self._dedupe_doc_name(
|
|
collection, self._sanitize_doc_name(parsed.doc_name))
|
|
self._storage.save_document(collection, doc_id, {
|
|
"doc_name": doc_name,
|
|
"doc_description": result.get("doc_description", ""),
|
|
"file_path": str(managed_path),
|
|
"file_hash": file_hash,
|
|
"doc_type": ext.lstrip("."),
|
|
"status": "completed",
|
|
**(parsed.metadata or {}), # parser-reported, e.g. page_count / line_count
|
|
"structure": result["structure"],
|
|
"pages": pages,
|
|
})
|
|
except sqlite3.IntegrityError as e:
|
|
# Lost a concurrent add of the same content (UNIQUE collection+hash).
|
|
# Discard our managed files and return the winner's doc_id.
|
|
managed_path.unlink(missing_ok=True)
|
|
doc_dir = col_dir / doc_id
|
|
if doc_dir.exists():
|
|
shutil.rmtree(doc_dir)
|
|
existing_id = self._storage.find_document_by_hash(collection, file_hash)
|
|
if existing_id:
|
|
return existing_id
|
|
# No winner — likely an FK violation from a concurrent collection delete.
|
|
if collection not in self._storage.list_collections():
|
|
raise CollectionNotFoundError(
|
|
f"Collection '{collection}' was deleted while indexing {file_path}"
|
|
) from e
|
|
raise IndexingError(f"Failed to index {file_path}: {e}") from e
|
|
except Exception as e:
|
|
managed_path.unlink(missing_ok=True)
|
|
doc_dir = col_dir / doc_id
|
|
if doc_dir.exists():
|
|
shutil.rmtree(doc_dir)
|
|
raise IndexingError(f"Failed to index {file_path}: {e}") from e
|
|
|
|
return doc_id
|
|
|
|
def _require_document(self, collection: str, doc_id: str) -> dict:
|
|
"""Return the document's storage row, or raise DocumentNotFoundError.
|
|
|
|
Single source of truth for "does this doc exist" — every public method
|
|
and agent tool below goes through this, so a missing doc always
|
|
surfaces the same way instead of each caller re-implementing its own
|
|
(and potentially inconsistent) existence check.
|
|
"""
|
|
doc = self._storage.get_document(collection, doc_id)
|
|
if not doc:
|
|
raise DocumentNotFoundError(f"Document {doc_id} not found")
|
|
return doc
|
|
|
|
def get_document(self, collection: str, doc_id: str, include_text: bool = False) -> dict:
|
|
"""Get document metadata with structure.
|
|
|
|
Args:
|
|
include_text: If True, populate each structure node's 'text' field
|
|
from cached page content. WARNING: may be very large — do NOT
|
|
use in agent/LLM contexts as it can exhaust the context window.
|
|
"""
|
|
doc = self._require_document(collection, doc_id)
|
|
doc["structure"] = self._storage.get_document_structure(collection, doc_id)
|
|
if include_text:
|
|
pages = self._storage.get_pages(collection, doc_id) or []
|
|
page_map = {p["page"]: p["content"] for p in pages}
|
|
self._fill_node_text(doc["structure"], page_map)
|
|
return doc
|
|
|
|
@staticmethod
|
|
def _fill_node_text(nodes: list, page_map: dict) -> None:
|
|
"""Recursively fill 'text' on structure nodes from cached page content.
|
|
|
|
Two node conventions, one per indexing strategy: content_based (PDF)
|
|
nodes span a start_index..end_index page range; level_based (Markdown)
|
|
nodes map 1:1 to a single page keyed by line_num. Handling only the
|
|
first would silently leave Markdown nodes with no text.
|
|
"""
|
|
for node in nodes:
|
|
start = node.get("start_index")
|
|
end = node.get("end_index")
|
|
if start is not None and end is not None:
|
|
node["text"] = "\n".join(
|
|
page_map.get(p, "") for p in range(start, end + 1)
|
|
)
|
|
elif "line_num" in node:
|
|
node["text"] = page_map.get(node["line_num"], "")
|
|
if "nodes" in node:
|
|
LocalBackend._fill_node_text(node["nodes"], page_map)
|
|
|
|
def get_document_structure(self, collection: str, doc_id: str) -> list:
|
|
self._require_document(collection, doc_id)
|
|
return self._storage.get_document_structure(collection, doc_id)
|
|
|
|
def get_page_content(self, collection: str, doc_id: str, pages: str) -> list:
|
|
doc = self._require_document(collection, doc_id)
|
|
page_nums = parse_pages(pages)
|
|
|
|
# Try cached pages first (fast, no file I/O)
|
|
cached_pages = self._storage.get_pages(collection, doc_id)
|
|
if cached_pages:
|
|
return [p for p in cached_pages if p["page"] in page_nums]
|
|
|
|
# Fallback: re-derive from the source file, same as the PDF path below
|
|
# — never from the stored structure, whose 'text' field may have been
|
|
# stripped (if_add_node_text=False, the default). Reachable only for a
|
|
# custom StorageEngine that doesn't cache pages (the built-in
|
|
# SQLiteStorage always does).
|
|
if doc["doc_type"].lower() == "pdf":
|
|
return get_pdf_page_content(doc["file_path"], page_nums)
|
|
else:
|
|
parser = self._resolve_parser(doc["file_path"])
|
|
parsed = parser.parse(doc["file_path"], model=self._model)
|
|
page_map = {n.index: n.content for n in parsed.nodes}
|
|
return [{"page": p, "content": page_map[p]} for p in page_nums if p in page_map]
|
|
|
|
def list_documents(self, collection: str) -> list[dict]:
|
|
return self._storage.list_documents(collection)
|
|
|
|
def delete_document(self, collection: str, doc_id: str) -> None:
|
|
doc = self._require_document(collection, doc_id)
|
|
self._storage.delete_document(collection, doc_id)
|
|
if doc.get("file_path"):
|
|
Path(doc["file_path"]).unlink(missing_ok=True)
|
|
doc_dir = self._collection_dir(collection) / doc_id
|
|
if doc_dir.exists():
|
|
shutil.rmtree(doc_dir)
|
|
|
|
def get_agent_tools(self, collection: str, doc_ids: list[str] | None = None) -> AgentTools:
|
|
"""Build agent tools.
|
|
|
|
- doc_ids=None (open mode): includes ``list_documents``; agent picks docs itself.
|
|
- doc_ids=[...] (scoped mode): no ``list_documents``; the other tools
|
|
hard-enforce the whitelist and reject out-of-scope references.
|
|
|
|
Tools identify documents by ``doc_name``, matching the cloud
|
|
chat/completions agent contract (names are far more reliable for an
|
|
LLM to pass than UUIDs). A ``doc_id`` is accepted in the same
|
|
parameter as the tie-breaker when several documents share a name.
|
|
|
|
Note ``is not None``: an empty list is a scope of *nothing* (reject every
|
|
doc), NOT open mode. Using truthiness would let ``doc_ids=[]`` collapse to
|
|
``None`` and silently grant access to the whole collection.
|
|
"""
|
|
from agents import function_tool
|
|
import json
|
|
storage = self._storage
|
|
col_name = collection
|
|
backend = self
|
|
scope = set(doc_ids) if doc_ids is not None else None
|
|
|
|
def _resolve(doc_name: str) -> str:
|
|
"""Resolve a doc_name (or doc_id) to a doc_id within scope.
|
|
Raises _DocResolveError carrying an agent-facing error JSON on failure."""
|
|
rows = storage.list_documents(col_name)
|
|
if scope is not None:
|
|
rows = [r for r in rows if r["doc_id"] in scope]
|
|
for row in rows:
|
|
if row["doc_id"] == doc_name:
|
|
return doc_name
|
|
matches = [r for r in rows if r["doc_name"] == doc_name]
|
|
if len(matches) == 1:
|
|
return matches[0]["doc_id"]
|
|
if len(matches) > 1:
|
|
raise _DocResolveError(json.dumps({
|
|
"error": f"Multiple documents are named {doc_name!r} — "
|
|
"retry with one of these doc_ids.",
|
|
"candidates": [{"doc_id": r["doc_id"],
|
|
"doc_description": r["doc_description"]}
|
|
for r in matches],
|
|
}, ensure_ascii=False))
|
|
if scope is not None:
|
|
raise _DocResolveError(json.dumps({
|
|
"error": f"{doc_name!r} is not in scope.",
|
|
"allowed_documents": [{"doc_id": r["doc_id"],
|
|
"doc_name": r["doc_name"]}
|
|
for r in rows],
|
|
}, ensure_ascii=False))
|
|
raise _DocResolveError(json.dumps({
|
|
"error": f"Document {doc_name!r} not found.",
|
|
"available_doc_names": list(dict.fromkeys(r["doc_name"] for r in rows)),
|
|
}, ensure_ascii=False))
|
|
|
|
@function_tool
|
|
def get_document(doc_name: str) -> str:
|
|
"""Get document metadata. Pass the document's doc_name (a doc_id also works)."""
|
|
try:
|
|
# _require_document (not backend.get_document) deliberately:
|
|
# the metadata-only row, no 'structure' — keeps this tool's
|
|
# output small for the agent's context window.
|
|
doc = backend._require_document(col_name, _resolve(doc_name))
|
|
except _DocResolveError as e:
|
|
return str(e)
|
|
except DocumentNotFoundError:
|
|
return json.dumps({"error": f"Document {doc_name!r} not found."})
|
|
return json.dumps(doc)
|
|
|
|
@function_tool
|
|
def get_document_structure(doc_name: str) -> str:
|
|
"""Get document tree structure (without text). Pass the document's doc_name (a doc_id also works)."""
|
|
try:
|
|
structure = storage.get_document_structure(col_name, _resolve(doc_name))
|
|
except _DocResolveError as e:
|
|
return str(e)
|
|
return json.dumps(remove_fields(structure, fields=["text"]), ensure_ascii=False)
|
|
|
|
@function_tool
|
|
def get_page_content(doc_name: str, pages: str) -> str:
|
|
"""Get page content. Pass the document's doc_name (a doc_id also works). Use tight ranges: '5-7', '3,8', '12'."""
|
|
try:
|
|
result = backend.get_page_content(col_name, _resolve(doc_name), pages)
|
|
except _DocResolveError as e:
|
|
return str(e)
|
|
except DocumentNotFoundError:
|
|
return json.dumps({"error": f"Document {doc_name!r} not found."})
|
|
except (ValueError, AttributeError) as e:
|
|
# A malformed page spec ("all", "5-") is a recoverable bad tool
|
|
# argument: hand the model an actionable error it can correct
|
|
# (mirroring the legacy retrieval tool) rather than letting the
|
|
# ValueError surface as the agent SDK's generic tool-failure text.
|
|
return json.dumps({
|
|
"error": f"Invalid pages format: {pages!r}. Use '5-7', '3,8', or '12'. Error: {e}"
|
|
})
|
|
return json.dumps(result, ensure_ascii=False)
|
|
|
|
tools = [get_document, get_document_structure, get_page_content]
|
|
|
|
if scope is None:
|
|
@function_tool
|
|
def list_documents() -> str:
|
|
"""List all documents in the collection."""
|
|
return json.dumps(storage.list_documents(col_name))
|
|
tools.insert(0, list_documents)
|
|
|
|
return AgentTools(function_tools=tools)
|
|
|
|
def _scoped_docs(self, collection: str, doc_ids: list[str]) -> list[dict]:
|
|
"""Fetch metadata for the docs in scope; raise if any are missing."""
|
|
by_id = {d["doc_id"]: d for d in self._storage.list_documents(collection)}
|
|
missing = [did for did in doc_ids if did not in by_id]
|
|
if missing:
|
|
raise DocumentNotFoundError(
|
|
f"doc_ids not found in collection '{collection}': {missing}"
|
|
)
|
|
return [by_id[did] for did in doc_ids]
|
|
|
|
@staticmethod
|
|
def _normalize_doc_ids(doc_ids: str | list[str] | None) -> list[str] | None:
|
|
if isinstance(doc_ids, str):
|
|
return [doc_ids]
|
|
if doc_ids == []:
|
|
raise ValueError(
|
|
"doc_ids cannot be empty; pass None to query the whole collection"
|
|
)
|
|
return doc_ids
|
|
|
|
def query(self, collection: str, question: str,
|
|
doc_ids: str | list[str] | None = None) -> str:
|
|
from ..agent import AgentRunner, SCOPED_SYSTEM_PROMPT, wrap_with_doc_context
|
|
doc_ids = self._normalize_doc_ids(doc_ids)
|
|
tools = self.get_agent_tools(collection, doc_ids)
|
|
instructions = None
|
|
if doc_ids:
|
|
docs = self._scoped_docs(collection, doc_ids)
|
|
question = wrap_with_doc_context(docs, question)
|
|
instructions = SCOPED_SYSTEM_PROMPT
|
|
return AgentRunner(tools=tools, model=self._retrieve_model,
|
|
instructions=instructions).run(question)
|
|
|
|
async def query_stream(self, collection: str, question: str,
|
|
doc_ids: str | list[str] | None = None):
|
|
from ..agent import QueryStream, SCOPED_SYSTEM_PROMPT, wrap_with_doc_context
|
|
doc_ids = self._normalize_doc_ids(doc_ids)
|
|
tools = self.get_agent_tools(collection, doc_ids)
|
|
instructions = None
|
|
if doc_ids:
|
|
docs = self._scoped_docs(collection, doc_ids)
|
|
question = wrap_with_doc_context(docs, question)
|
|
instructions = SCOPED_SYSTEM_PROMPT
|
|
stream = QueryStream(tools=tools, question=question,
|
|
model=self._retrieve_model, instructions=instructions)
|
|
async for event in stream:
|
|
yield event
|