mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-07-15 21:11:05 +02:00
Addresses items 9-13 and a/b/c/f from the max-effort review of PR #272. - pdf.py: image colorspace check was `pix.n > 4`, which treats CMYK-without- alpha (n==4, same as RGBA) as not needing RGB conversion; pix.save() as .png then raises "unsupported colorspace", silently dropped by the surrounding except. Fixed to `pix.n - pix.alpha >= 4` (correctly converts CMYK, leaves RGBA untouched). - pipeline.py: detect_strategy([]) (an empty/whitespace-only source file) returned "content_based", routing into the PDF-oriented TOC-detection pipeline -- wasting a real LLM call before raising IndexingError. Empty node lists now route to level_based, whose build_tree_from_levels([]) returns an empty structure instantly with zero LLM calls. - page_index.py (shim): pageindex/__init__.py binds the canonical `page_index` function as the package attribute, but this file is ALSO a real submodule of the same name -- importing it anywhere (import machinery, unconditional) overwrites that attribute with the module object, breaking `from pageindex import page_index; page_index(x)` for the rest of the process. Made the shim module itself callable (delegates to the real function via a ModuleType subclass), so whichever object ends up in that slot is callable regardless of import order. - storage/sqlite.py: create_collection let a raw sqlite3.IntegrityError escape on a duplicate name (new CollectionAlreadyExistsError); the collections table's CHECK constraint only validated the name's first character (GLOB '*' is a wildcard, not a regex quantifier over the preceding class) -- fixed to validate the whole string, and SQLiteStorage now also validates in Python (it's a public StorageEngine usable directly, bypassing LocalBackend's own check). - tests/test_review_fixes_2.py: two tests used a ContentNode with no `level` set, so build_index took the content_based path and made real (retried, slow, and -- with a valid key -- billable) LLM calls instead of testing the text-stripping logic they claimed to. Mocked out _content_based_pipeline. - retrieve.py: _parse_pages/_get_pdf_page_content were independent copies of the canonical parse_pages/get_pdf_page_content that had already drifted (missing the p>=1 filter and 1000-page DoS cap) -- delegate to canonical now, so the legacy pageindex.get_page_content path can't silently regress again. - parser/markdown.py: a leading UTF-8 BOM broke first-header detection (not whitespace, .strip() doesn't remove it) -- decode utf-8-sig. Only backtick fences were recognized as code blocks, so a '#'-prefixed line inside a ~~~-fenced block (valid CommonMark) was misparsed as a heading -- recognize both fence styles. - run_pageindex.py: --if-thinning wasn't migrated to the bare-flag + legacy-yes/no convention the other four --if-add-* flags got; bare usage raised an argparse error and it never went through the shared coercion. - types.py: DocumentDetail's `structure` field was inside the class's total=False body, so TypedDict rules made it optional even though every backend always populates it. Split into a required base class. Adds regression tests for all of the above. Full suite: 244 passed, 2 skipped (one pre-existing, unrelated flaky cloud-streaming test). Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
221 lines
9.4 KiB
Python
221 lines
9.4 KiB
Python
import json
|
|
import re
|
|
import sqlite3
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
from ..errors import CollectionAlreadyExistsError, PageIndexError
|
|
|
|
# Mirrors LocalBackend's own collection-name rule. SQLiteStorage enforces this
|
|
# itself (not just relying on LocalBackend's pre-check or the schema's CHECK
|
|
# constraint below) because it's a public StorageEngine that can be used
|
|
# directly, bypassing LocalBackend entirely.
|
|
_COLLECTION_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,128}$')
|
|
|
|
|
|
def _validate_collection_name(name: str) -> None:
|
|
if not _COLLECTION_NAME_RE.match(name):
|
|
raise PageIndexError(f"Invalid collection name: {name!r}. Must be 1-128 chars of [a-zA-Z0-9_-].")
|
|
|
|
|
|
class SQLiteStorage:
|
|
def __init__(self, db_path: str):
|
|
self._db_path = Path(db_path).expanduser()
|
|
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._local = threading.local()
|
|
self._connections: list[sqlite3.Connection] = []
|
|
self._conn_lock = threading.Lock()
|
|
# Serializes the (fast) write operations within this process so
|
|
# concurrent indexing threads don't collide on WAL's single writer
|
|
# ("database is locked"). Reads stay concurrent; the expensive LLM
|
|
# indexing runs outside this lock. busy_timeout above covers the
|
|
# cross-process case.
|
|
self._write_lock = threading.Lock()
|
|
self._init_schema()
|
|
|
|
def _get_conn(self) -> sqlite3.Connection:
|
|
"""Return a thread-local SQLite connection."""
|
|
if not hasattr(self._local, "conn"):
|
|
# Each thread gets its own connection (threading.local), so
|
|
# statements never race. check_same_thread=False exists solely so
|
|
# close() can close every tracked connection from whichever thread
|
|
# calls it — with the default True those closes raise
|
|
# ProgrammingError and the connections leak.
|
|
# isolation_level=None -> autocommit: a plain SELECT (e.g. the
|
|
# dedup hash lookup) never leaves a lingering read snapshot that a
|
|
# later write on the same connection would conflict with
|
|
# (SQLITE_BUSY_SNAPSHOT, which busy_timeout can't retry). Each
|
|
# statement is its own transaction, so busy_timeout can actually
|
|
# wait for the WAL single-writer lock under concurrency.
|
|
conn = sqlite3.connect(str(self._db_path), check_same_thread=False,
|
|
isolation_level=None)
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
conn.execute("PRAGMA busy_timeout=10000")
|
|
self._local.conn = conn
|
|
with self._conn_lock:
|
|
self._connections.append(conn)
|
|
return self._local.conn
|
|
|
|
def _init_schema(self):
|
|
conn = self._get_conn()
|
|
conn.execute("PRAGMA user_version = 1")
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS collections (
|
|
-- GLOB '*' is "any characters", not a regex quantifier over the
|
|
-- preceding class — '[a-zA-Z0-9_-]*' alone only constrains the
|
|
-- FIRST character. The second GLOB (NOT ... '*[^...]*') checks
|
|
-- every remaining character too, so this is real defense-in-depth
|
|
-- for direct SQLiteStorage use (bypassing _validate_collection_name
|
|
-- above), not just a first-character gate.
|
|
name TEXT PRIMARY KEY CHECK(
|
|
length(name) BETWEEN 1 AND 128
|
|
AND name GLOB '[a-zA-Z0-9_-]*'
|
|
AND name NOT GLOB '*[^a-zA-Z0-9_-]*'
|
|
),
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|
doc_id TEXT PRIMARY KEY,
|
|
collection_name TEXT NOT NULL REFERENCES collections(name) ON DELETE CASCADE,
|
|
doc_name TEXT,
|
|
doc_description TEXT,
|
|
file_path TEXT,
|
|
file_hash TEXT,
|
|
doc_type TEXT NOT NULL,
|
|
structure JSON,
|
|
pages JSON,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(collection_name, file_hash)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_docs_collection ON documents(collection_name);
|
|
CREATE INDEX IF NOT EXISTS idx_docs_hash ON documents(collection_name, file_hash);
|
|
""")
|
|
conn.commit()
|
|
|
|
def create_collection(self, name: str) -> None:
|
|
_validate_collection_name(name)
|
|
with self._write_lock:
|
|
conn = self._get_conn()
|
|
try:
|
|
conn.execute("INSERT INTO collections (name) VALUES (?)", (name,))
|
|
except sqlite3.IntegrityError as e:
|
|
raise CollectionAlreadyExistsError(f"Collection '{name}' already exists") from e
|
|
conn.commit()
|
|
|
|
def get_or_create_collection(self, name: str) -> None:
|
|
_validate_collection_name(name)
|
|
with self._write_lock:
|
|
conn = self._get_conn()
|
|
conn.execute("INSERT OR IGNORE INTO collections (name) VALUES (?)", (name,))
|
|
conn.commit()
|
|
|
|
def list_collections(self) -> list[str]:
|
|
conn = self._get_conn()
|
|
rows = conn.execute("SELECT name FROM collections ORDER BY name").fetchall()
|
|
return [r[0] for r in rows]
|
|
|
|
def delete_collection(self, name: str) -> None:
|
|
with self._write_lock:
|
|
conn = self._get_conn()
|
|
conn.execute("DELETE FROM collections WHERE name = ?", (name,))
|
|
conn.commit()
|
|
|
|
def save_document(self, collection: str, doc_id: str, doc: dict) -> None:
|
|
# Plain INSERT (doc_id is a fresh uuid, never pre-existing). A duplicate
|
|
# (collection_name, file_hash) raises sqlite3.IntegrityError, which the
|
|
# caller uses to resolve a concurrent add-of-same-file race.
|
|
with self._write_lock:
|
|
conn = self._get_conn()
|
|
conn.execute(
|
|
"""INSERT INTO documents
|
|
(doc_id, collection_name, doc_name, doc_description, file_path, file_hash, doc_type, structure, pages)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(doc_id, collection, doc.get("doc_name"), doc.get("doc_description"),
|
|
doc.get("file_path"), doc.get("file_hash"), doc["doc_type"],
|
|
json.dumps(doc.get("structure", [])),
|
|
json.dumps(doc.get("pages")) if doc.get("pages") else None),
|
|
)
|
|
conn.commit()
|
|
|
|
def find_document_by_hash(self, collection: str, file_hash: str) -> str | None:
|
|
conn = self._get_conn()
|
|
row = conn.execute(
|
|
"SELECT doc_id FROM documents WHERE collection_name = ? AND file_hash = ?",
|
|
(collection, file_hash),
|
|
).fetchone()
|
|
return row[0] if row else None
|
|
|
|
def get_document(self, collection: str, doc_id: str) -> dict:
|
|
conn = self._get_conn()
|
|
row = conn.execute(
|
|
"SELECT doc_id, doc_name, doc_description, file_path, doc_type FROM documents WHERE doc_id = ? AND collection_name = ?",
|
|
(doc_id, collection),
|
|
).fetchone()
|
|
if not row:
|
|
return {}
|
|
return {"doc_id": row[0], "doc_name": row[1], "doc_description": row[2],
|
|
"file_path": row[3], "doc_type": row[4]}
|
|
|
|
def get_document_structure(self, collection: str, doc_id: str) -> list:
|
|
conn = self._get_conn()
|
|
row = conn.execute(
|
|
"SELECT structure FROM documents WHERE doc_id = ? AND collection_name = ?",
|
|
(doc_id, collection),
|
|
).fetchone()
|
|
if not row:
|
|
return []
|
|
return json.loads(row[0])
|
|
|
|
def get_pages(self, collection: str, doc_id: str) -> list | None:
|
|
"""Return cached page content, or None if not cached."""
|
|
conn = self._get_conn()
|
|
row = conn.execute(
|
|
"SELECT pages FROM documents WHERE doc_id = ? AND collection_name = ?",
|
|
(doc_id, collection),
|
|
).fetchone()
|
|
if not row or not row[0]:
|
|
return None
|
|
return json.loads(row[0])
|
|
|
|
def list_documents(self, collection: str) -> list[dict]:
|
|
conn = self._get_conn()
|
|
rows = conn.execute(
|
|
"SELECT doc_id, doc_name, doc_description, doc_type FROM documents WHERE collection_name = ? ORDER BY created_at",
|
|
(collection,),
|
|
).fetchall()
|
|
return [{"doc_id": r[0], "doc_name": r[1], "doc_description": r[2] or "", "doc_type": r[3]} for r in rows]
|
|
|
|
def delete_document(self, collection: str, doc_id: str) -> None:
|
|
with self._write_lock:
|
|
conn = self._get_conn()
|
|
conn.execute(
|
|
"DELETE FROM documents WHERE doc_id = ? AND collection_name = ?",
|
|
(doc_id, collection),
|
|
)
|
|
conn.commit()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.close()
|
|
return False
|
|
|
|
def close(self) -> None:
|
|
"""Close all tracked SQLite connections across all threads."""
|
|
with self._conn_lock:
|
|
for conn in self._connections:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
self._connections.clear()
|
|
if hasattr(self._local, "conn"):
|
|
del self._local.conn
|
|
|
|
def __del__(self):
|
|
try:
|
|
self.close()
|
|
except Exception:
|
|
pass
|