From 49f84a1aad886ea7d56a8b439632b98c2efc18d5 Mon Sep 17 00:00:00 2001 From: LLLin000 <809867916@qq.com> Date: Thu, 9 Jul 2026 17:15:19 +0800 Subject: [PATCH] feat(#31): OpenAICompatibleProvider uses openai SDK with requests fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite embedding provider to use openai.OpenAI client instead of raw requests.post. Old code preserved in requests_fallback.py, selectable via VECTOR_DB_PROVIDER_TYPE env/setting. Changes: - paperforge/embedding/providers/openai_compatible.py: rewrite to SDK - paperforge/embedding/providers/requests_fallback.py: NEW old impl - paperforge/embedding/_config.py: add get_provider_type() - tests/test_openai_compatible.py: 6 new tests (encode, timeout, provider_type switch, no-key error, custom base url) feat(#33): E2E test fixtures — 3 synthetic PDFs + expected outputs 3 synthetic papers generated with PyMuPDF, each 3 pages: - paper_a: The Effect of Machine Learning on Clinical Outcomes - paper_b: A Randomized Trial of Remimazolam vs Propofol (with tables) - paper_c: Deep Learning for Medical Image Segmentation (with figures) Changes: - tests/fixtures/papers/: 3 PDFs - tests/fixtures/expected_outputs/: expected blocks + body units - tests/conftest.py: e2e_fixture_dir + synthetic_paper_paths fixtures feat(#26): sqlite-vec schema — vec0 virtual tables + build_state Schema version bumped 4→5. Adds: - 3 vec0 virtual tables: vec_fulltext, vec_body, vec_objects (1536-dim) - 3 companion metadata tables: vec_*_meta (paper_id, chunk_index, text) - build_state table (key/value/updated_at) - ensure_vec_extension() in db.py for extension loading Changes: - paperforge/memory/schema.py: schema v5, new tables in ensure_schema() - paperforge/memory/db.py: ensure_vec_extension() helper - tests/test_vector_schema.py: 5 new tests (tables, virtual, idempotent, v5) --- paperforge/embedding/_config.py | 5 + .../embedding/providers/openai_compatible.py | 38 ++++--- .../embedding/providers/requests_fallback.py | 43 ++++++++ paperforge/memory/db.py | 14 +++ paperforge/memory/schema.py | 67 +++++++++++- tests/conftest.py | 16 +++ .../paper_a/expected_blocks.json | 17 +++ .../paper_a/expected_body_units.txt | 33 ++++++ .../paper_b/expected_blocks.json | 19 ++++ .../paper_b/expected_body_units.txt | 28 +++++ .../paper_c/expected_blocks.json | 19 ++++ .../paper_c/expected_body_units.txt | 29 ++++++ tests/fixtures/papers/paper_a.pdf | Bin 0 -> 9121 bytes tests/fixtures/papers/paper_b.pdf | Bin 0 -> 9936 bytes tests/fixtures/papers/paper_c.pdf | Bin 0 -> 9848 bytes tests/test_openai_compatible.py | 95 +++++++++++++++++ tests/test_vector_schema.py | 97 ++++++++++++++++++ 17 files changed, 502 insertions(+), 18 deletions(-) create mode 100644 paperforge/embedding/providers/requests_fallback.py create mode 100644 tests/fixtures/expected_outputs/paper_a/expected_blocks.json create mode 100644 tests/fixtures/expected_outputs/paper_a/expected_body_units.txt create mode 100644 tests/fixtures/expected_outputs/paper_b/expected_blocks.json create mode 100644 tests/fixtures/expected_outputs/paper_b/expected_body_units.txt create mode 100644 tests/fixtures/expected_outputs/paper_c/expected_blocks.json create mode 100644 tests/fixtures/expected_outputs/paper_c/expected_body_units.txt create mode 100644 tests/fixtures/papers/paper_a.pdf create mode 100644 tests/fixtures/papers/paper_b.pdf create mode 100644 tests/fixtures/papers/paper_c.pdf create mode 100644 tests/test_openai_compatible.py create mode 100644 tests/test_vector_schema.py diff --git a/paperforge/embedding/_config.py b/paperforge/embedding/_config.py index 9c0bda99..7890971c 100644 --- a/paperforge/embedding/_config.py +++ b/paperforge/embedding/_config.py @@ -37,3 +37,8 @@ def get_api_base_url(vault: Path) -> str: def get_api_model(vault: Path) -> str: settings = _read_plugin_settings(vault) return os.environ.get("VECTOR_DB_API_MODEL", "") or settings.get("vector_db_api_model", "text-embedding-3-small") + + +def get_provider_type(vault: Path) -> str: + settings = _read_plugin_settings(vault) + return os.environ.get("VECTOR_DB_PROVIDER_TYPE", "") or settings.get("vector_db_provider_type", "") or "openai_sdk" diff --git a/paperforge/embedding/providers/openai_compatible.py b/paperforge/embedding/providers/openai_compatible.py index 3a18c930..dfcd8436 100644 --- a/paperforge/embedding/providers/openai_compatible.py +++ b/paperforge/embedding/providers/openai_compatible.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging from pathlib import Path -import requests +import openai -from paperforge.embedding._config import get_api_base_url, get_api_key, get_api_model +from paperforge.embedding._config import get_api_base_url, get_api_key, get_api_model, get_provider_type from paperforge.embedding.providers.base import EmbeddingProvider logger = logging.getLogger(__name__) @@ -13,26 +13,34 @@ logger = logging.getLogger(__name__) class OpenAICompatibleProvider(EmbeddingProvider): def __init__(self, vault: Path): - self._api_key = get_api_key(vault) - if not self._api_key: + provider_type = get_provider_type(vault) + if provider_type == "requests": + from paperforge.embedding.providers.requests_fallback import OpenAICompatibleProvider as Fallback + + self._delegate = Fallback(vault) + return + + api_key = get_api_key(vault) + if not api_key: raise ValueError( "No API key configured for embedding. " "Set VECTOR_DB_API_KEY or OPENAI_API_KEY in .env or plugin settings." ) self._model = get_api_model(vault) - self._base_url = (get_api_base_url(vault) or "https://api.openai.com/v1").rstrip("/") - logger.info("Embedding provider: model=%s, base_url=%s", self._model, self._base_url) + base_url = (get_api_base_url(vault) or "https://api.openai.com/v1").rstrip("/") + self._client = openai.OpenAI( + api_key=api_key, + base_url=base_url, + timeout=30.0, + max_retries=2, + ) + logger.info("Embedding provider: model=%s, base_url=%s", self._model, base_url) def encode(self, texts: list[str]) -> list[list[float]]: - resp = requests.post( - f"{self._base_url}/embeddings", - headers={"Authorization": f"Bearer {self._api_key}"}, - json={"model": self._model, "input": texts}, - timeout=60.0, - ) - resp.raise_for_status() - return [d["embedding"] for d in resp.json()["data"]] + if hasattr(self, "_delegate"): + return self._delegate.encode(texts) + response = self._client.embeddings.create(model=self._model, input=texts) + return [d.embedding for d in response.data] def encode_single(self, text: str) -> list[float]: return self.encode([text])[0] - diff --git a/paperforge/embedding/providers/requests_fallback.py b/paperforge/embedding/providers/requests_fallback.py new file mode 100644 index 00000000..0b096efe --- /dev/null +++ b/paperforge/embedding/providers/requests_fallback.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +import requests + +from paperforge.embedding._config import get_api_base_url, get_api_key, get_api_model +from paperforge.embedding.providers.base import EmbeddingProvider + +logger = logging.getLogger(__name__) + + +class OpenAICompatibleProvider(EmbeddingProvider): + """Requests-based fallback provider. + + Drop-in replacement for the SDK-based OpenAICompatibleProvider. + Activated by setting VECTOR_DB_PROVIDER_TYPE=requests. + """ + + def __init__(self, vault: Path): + self._api_key = get_api_key(vault) + if not self._api_key: + raise ValueError( + "No API key configured for embedding. " + "Set VECTOR_DB_API_KEY or OPENAI_API_KEY in .env or plugin settings." + ) + self._model = get_api_model(vault) + self._base_url = (get_api_base_url(vault) or "https://api.openai.com/v1").rstrip("/") + logger.info("Embedding provider (requests fallback): model=%s, base_url=%s", self._model, self._base_url) + + def encode(self, texts: list[str]) -> list[list[float]]: + resp = requests.post( + f"{self._base_url}/embeddings", + headers={"Authorization": f"Bearer {self._api_key}"}, + json={"model": self._model, "input": texts}, + timeout=60.0, + ) + resp.raise_for_status() + return [d["embedding"] for d in resp.json()["data"]] + + def encode_single(self, text: str) -> list[float]: + return self.encode([text])[0] diff --git a/paperforge/memory/db.py b/paperforge/memory/db.py index 146df734..28e74fe9 100644 --- a/paperforge/memory/db.py +++ b/paperforge/memory/db.py @@ -33,3 +33,17 @@ def get_connection(db_path: Path, read_only: bool = False) -> sqlite3.Connection conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA foreign_keys=ON;") return conn + + +def ensure_vec_extension(conn: sqlite3.Connection) -> None: + """Load the sqlite-vec extension if available. + + Enables vec0 virtual table support for vector similarity search. + Gracefully no-ops when the extension is not installed. + """ + try: + conn.enable_load_extension(True) + conn.load("vec0") + conn.enable_load_extension(False) + except (sqlite3.OperationalError, AttributeError): + pass # extension not available diff --git a/paperforge/memory/schema.py b/paperforge/memory/schema.py index 4d6e3e7f..776f7683 100644 --- a/paperforge/memory/schema.py +++ b/paperforge/memory/schema.py @@ -5,7 +5,7 @@ import sqlite3 logger = logging.getLogger(__name__) -CURRENT_SCHEMA_VERSION = 4 # Bump from 3 for section_path_json, section_level, section_title, part_ordinal +CURRENT_SCHEMA_VERSION = 5 # Bump from 4 for vec0 vector tables, companion meta, build_state CREATE_META = """ CREATE TABLE IF NOT EXISTS meta ( @@ -224,8 +224,56 @@ CREATE TABLE IF NOT EXISTS object_units ( ); """ +CREATE_VEC_FULLTEXT = """ +CREATE VIRTUAL TABLE IF NOT EXISTS vec_fulltext USING vec0(embedding float[1536]); +""" -ALL_TABLES = ["body_units", "body_units_fts", "object_units", "paper_fts", "reading_log", "project_log", "paper_events", "paper_assets", "paper_aliases", "papers", "meta"] +CREATE_VEC_BODY = """ +CREATE VIRTUAL TABLE IF NOT EXISTS vec_body USING vec0(embedding float[1536]); +""" + +CREATE_VEC_OBJECTS = """ +CREATE VIRTUAL TABLE IF NOT EXISTS vec_objects USING vec0(embedding float[1536]); +""" + +CREATE_VEC_FULLTEXT_META = """ +CREATE TABLE IF NOT EXISTS vec_fulltext_meta ( + rowid INTEGER PRIMARY KEY, + paper_id TEXT NOT NULL, + chunk_index INTEGER, + text TEXT +); +""" + +CREATE_VEC_BODY_META = """ +CREATE TABLE IF NOT EXISTS vec_body_meta ( + rowid INTEGER PRIMARY KEY, + paper_id TEXT NOT NULL, + chunk_index INTEGER, + text TEXT +); +""" + +CREATE_VEC_OBJECTS_META = """ +CREATE TABLE IF NOT EXISTS vec_objects_meta ( + rowid INTEGER PRIMARY KEY, + paper_id TEXT NOT NULL, + chunk_index INTEGER, + text TEXT +); +""" + +CREATE_BUILD_STATE = """ +CREATE TABLE IF NOT EXISTS build_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +""" + + +ALL_TABLES = ["body_units", "body_units_fts", "object_units", "paper_fts", "reading_log", "project_log", "paper_events", "paper_assets", "paper_aliases", "papers", "meta", "vec_fulltext_meta", "vec_body_meta", "vec_objects_meta", "build_state"] +VEC_TABLES = ["vec_fulltext", "vec_body", "vec_objects"] def ensure_schema(conn: sqlite3.Connection) -> None: @@ -258,6 +306,19 @@ def ensure_schema(conn: sqlite3.Connection) -> None: except Exception: pass # column may already exist + if current_version < 5: + logger.info("Migrating schema v%s -> v5: adding vec0 vector tables, companion meta, and build_state", current_version) + try: + conn.execute(CREATE_VEC_FULLTEXT) + conn.execute(CREATE_VEC_BODY) + conn.execute(CREATE_VEC_OBJECTS) + except sqlite3.OperationalError: + logger.warning("sqlite-vec extension not available, skipping vector virtual tables") + conn.execute(CREATE_VEC_FULLTEXT_META) + conn.execute(CREATE_VEC_BODY_META) + conn.execute(CREATE_VEC_OBJECTS_META) + conn.execute(CREATE_BUILD_STATE) + conn.execute(CREATE_BODY_UNITS) conn.execute(CREATE_BODY_UNITS_FTS) conn.execute(CREATE_OBJECT_UNITS) @@ -274,7 +335,7 @@ def ensure_schema(conn: sqlite3.Connection) -> None: def drop_all_tables(conn: sqlite3.Connection) -> None: """Drop all Memory Layer tables (for rebuild).""" - for table in ALL_TABLES: + for table in ALL_TABLES + VEC_TABLES: logger.info("Dropping table: %s", table) conn.execute(f"DROP TABLE IF EXISTS {table};") conn.commit() diff --git a/tests/conftest.py b/tests/conftest.py index 8e598ec9..d168850f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -180,3 +180,19 @@ def test_vault_preserved() -> Generator[Path, None, None]: """Pytest fixture providing a test vault without automatic cleanup.""" vault = create_test_vault() yield vault + +@pytest.fixture(scope="session") +def e2e_fixture_dir() -> Path: + """Return the path to E2E test fixture directories.""" + return REPO_ROOT / "tests" / "fixtures" + + +@pytest.fixture(scope="session") +def synthetic_paper_paths(e2e_fixture_dir: Path) -> list[tuple[str, Path]]: + """Return list of (paper_id, pdf_path) for synthetic E2E test papers.""" + papers_dir = e2e_fixture_dir / "papers" + return [ + ("paper_a", papers_dir / "paper_a.pdf"), + ("paper_b", papers_dir / "paper_b.pdf"), + ("paper_c", papers_dir / "paper_c.pdf"), + ] diff --git a/tests/fixtures/expected_outputs/paper_a/expected_blocks.json b/tests/fixtures/expected_outputs/paper_a/expected_blocks.json new file mode 100644 index 00000000..047a7b2e --- /dev/null +++ b/tests/fixtures/expected_outputs/paper_a/expected_blocks.json @@ -0,0 +1,17 @@ +[ + {"role": "title", "approx_page": 0}, + {"role": "author", "approx_page": 0}, + {"role": "abstract", "approx_page": 0}, + {"role": "heading", "approx_page": 1}, + {"role": "text", "approx_page": 1}, + {"role": "heading", "approx_page": 1}, + {"role": "text", "approx_page": 1}, + {"role": "heading", "approx_page": 2}, + {"role": "text", "approx_page": 2}, + {"role": "heading", "approx_page": 2}, + {"role": "text", "approx_page": 2}, + {"role": "heading", "approx_page": 2}, + {"role": "reference", "approx_page": 2}, + {"role": "reference", "approx_page": 2}, + {"role": "reference", "approx_page": 2} +] diff --git a/tests/fixtures/expected_outputs/paper_a/expected_body_units.txt b/tests/fixtures/expected_outputs/paper_a/expected_body_units.txt new file mode 100644 index 00000000..b6d95a74 --- /dev/null +++ b/tests/fixtures/expected_outputs/paper_a/expected_body_units.txt @@ -0,0 +1,33 @@ +The Effect of Machine Learning on Clinical Outcomes + +John Smith, Alice Brown, Robert Chen +Department of Biomedical Informatics, Stanford University + +Abstract + +Background: Machine learning (ML) has shown promise in improving clinical outcomes across various medical domains. However, the magnitude and consistency of these improvements remain poorly characterized. Methods: We conducted a systematic review and meta-analysis of 45 randomized controlled trials evaluating ML-based interventions in clinical settings. Results: ML interventions were associated with a 12% improvement in diagnostic accuracy (p < 0.01) and a significant reduction in time-to-diagnosis (mean difference -3.2 hours, 95% CI -4.1 to -2.3). Conclusion: ML-based clinical interventions demonstrate measurable improvements in patient outcomes, though effect sizes vary considerably across application domains. + +1. Introduction + +The integration of machine learning into clinical practice has accelerated dramatically over the past decade. From diagnostic imaging to predictive analytics, ML algorithms now assist clinicians in nearly every medical specialty. Despite widespread adoption, the evidence base supporting clinical improvements from ML remains fragmented. + +2. Methods + +We searched PubMed, Embase, and Cochrane databases from January 2015 to December 2024 for randomized controlled trials comparing ML-based interventions against standard care. Two reviewers independently screened titles and abstracts. + +3. Results + +The systematic review included 45 RCTs with a total of 127,543 patients. ML interventions were categorized into three domains: diagnostic (n=22), prognostic (n=14), and treatment optimization (n=9). Pooled analysis showed significant heterogeneity (I-squared = 67%). + +4. Discussion + +Our findings suggest that ML-based interventions can meaningfully improve clinical outcomes, particularly in diagnostic applications. However, the evidence is limited by publication bias and variability in study design. + +References + +[1] Topol EJ. High-performance medicine: the convergence of human and artificial intelligence. + Nat Med. 2019;25(1):44-56. +[2] Rajpurkar P, Chen E, Banerjee O, Topol EJ. AI in health and medicine. Nat Med. + 2022;28(1):31-38. +[3] Esteva A, Kuprel B, Novoa RA, et al. Dermatologist-level classification of skin cancer + with deep neural networks. Nature. 2017;542(7639):115-118. diff --git a/tests/fixtures/expected_outputs/paper_b/expected_blocks.json b/tests/fixtures/expected_outputs/paper_b/expected_blocks.json new file mode 100644 index 00000000..53e79b1d --- /dev/null +++ b/tests/fixtures/expected_outputs/paper_b/expected_blocks.json @@ -0,0 +1,19 @@ +[ + {"role": "title", "approx_page": 0}, + {"role": "author", "approx_page": 0}, + {"role": "abstract", "approx_page": 0}, + {"role": "heading", "approx_page": 1}, + {"role": "text", "approx_page": 1}, + {"role": "heading", "approx_page": 1}, + {"role": "text", "approx_page": 1}, + {"role": "table", "approx_page": 1}, + {"role": "table", "approx_page": 1}, + {"role": "heading", "approx_page": 2}, + {"role": "text", "approx_page": 2}, + {"role": "heading", "approx_page": 2}, + {"role": "text", "approx_page": 2}, + {"role": "heading", "approx_page": 2}, + {"role": "reference", "approx_page": 2}, + {"role": "reference", "approx_page": 2}, + {"role": "reference", "approx_page": 2} +] diff --git a/tests/fixtures/expected_outputs/paper_b/expected_body_units.txt b/tests/fixtures/expected_outputs/paper_b/expected_body_units.txt new file mode 100644 index 00000000..4a629fa6 --- /dev/null +++ b/tests/fixtures/expected_outputs/paper_b/expected_body_units.txt @@ -0,0 +1,28 @@ +A Randomized Trial of Remimazolam vs Propofol for Sedation in Elderly Patients + +Wong M, Johnson S, Kim D +Anesthesiology, Mayo Clinic + +Abstract + +Background: Remimazolam may offer advantages over propofol for sedation in elderly patients. Methods: 120 patients (age >=65) undergoing colonoscopy were randomized. Results: Recovery time shorter with remimazolam (8.2 vs 12.7 min, p=0.003). Hypotension less frequent (8% vs 22%, p=0.01). Conclusion: Remimazolam provides faster recovery and fewer hemodynamic events. + +1. Introduction + +Sedation in elderly is challenging. Propofol has narrow therapeutic window and hemodynamic effects. Remimazolam is a novel ultra-short-acting benzodiazepine. + +2. Methods + +Single-center double-blind RCT. Inclusion: age >=65, ASA I-III, colonoscopy. + +Table 1. Baseline + +Table 2. Events + +3. Results + +All 120 patients completed. Primary outcome: recovery time shorter with remimazolam (8.2+/-2.1 vs 12.7+/-3.4 min; p=0.003). Patient satisfaction favored remimazolam. + +4. Discussion + +Remimazolam provides faster recovery and fewer complications vs propofol in elderly. diff --git a/tests/fixtures/expected_outputs/paper_c/expected_blocks.json b/tests/fixtures/expected_outputs/paper_c/expected_blocks.json new file mode 100644 index 00000000..aca1c104 --- /dev/null +++ b/tests/fixtures/expected_outputs/paper_c/expected_blocks.json @@ -0,0 +1,19 @@ +[ + {"role": "title", "approx_page": 0}, + {"role": "author", "approx_page": 0}, + {"role": "abstract", "approx_page": 0}, + {"role": "heading", "approx_page": 1}, + {"role": "text", "approx_page": 1}, + {"role": "heading", "approx_page": 1}, + {"role": "text", "approx_page": 1}, + {"role": "figure", "approx_page": 1}, + {"role": "heading", "approx_page": 2}, + {"role": "text", "approx_page": 2}, + {"role": "figure", "approx_page": 2}, + {"role": "heading", "approx_page": 2}, + {"role": "text", "approx_page": 2}, + {"role": "heading", "approx_page": 2}, + {"role": "reference", "approx_page": 2}, + {"role": "reference", "approx_page": 2}, + {"role": "reference", "approx_page": 2} +] diff --git a/tests/fixtures/expected_outputs/paper_c/expected_body_units.txt b/tests/fixtures/expected_outputs/paper_c/expected_body_units.txt new file mode 100644 index 00000000..d1e97c25 --- /dev/null +++ b/tests/fixtures/expected_outputs/paper_c/expected_body_units.txt @@ -0,0 +1,29 @@ +Deep Learning for Medical Image Segmentation +A Benchmark Evaluation of U-Net Variants + +Emily Zhang, James Lee, Priya Patel +School of Computing, University of Toronto + +Abstract + +Medical image segmentation is a critical task in computer-aided diagnosis. Deep learning models, particularly U-Net and its variants, have achieved state-of-the-art performance. We systematically evaluate six U-Net variants on three public segmentation benchmarks (BraTS, ISIC, and Kvasir-SEG). Attention U-Net achieves the highest Dice score (0.913) on the BraTS dataset, while nnU-Net shows the best generalizability across all three tasks. + +1. Introduction + +Accurate segmentation of anatomical structures and pathological regions is fundamental to many clinical applications. In recent years, convolutional neural network architectures have dramatically advanced the state of the art in medical image segmentation. + +2. Methods + +We implemented six U-Net variants: standard U-Net, Attention U-Net, Residual U-Net, Dense U-Net, UNet++, and nnU-Net. Each model was trained on identical train/validation splits using the Adam optimizer with a learning rate of 1e-4. + +Figure 1: Schematic of the U-Net architecture showing encoder-decoder pathway with skip connections. + +3. Results + +Table 3 summarizes the Dice scores across all datasets. Attention U-Net consistently outperformed the standard U-Net, particularly on challenging boundary regions. + +Figure 2: Qualitative segmentation results on the BraTS validation set. + +4. Discussion and Conclusion + +Our comprehensive benchmark provides practical guidance for model selection in medical image segmentation tasks. diff --git a/tests/fixtures/papers/paper_a.pdf b/tests/fixtures/papers/paper_a.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7279edd61b1a47590c9ab082ce9ceefbcab3d58f GIT binary patch literal 9121 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 bool: + """Return True if sqlite-vec extension can be loaded.""" + try: + conn.enable_load_extension(True) + conn.load("vec0") + conn.enable_load_extension(False) + return True + except (sqlite3.OperationalError, AttributeError): + return False + + +def test_ensure_schema_creates_vec_tables(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_schema(conn) + + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + tables = {row["name"] for row in cursor.fetchall()} + + # Companion tables + build_state always created (plain tables) + assert "vec_fulltext_meta" in tables + assert "vec_body_meta" in tables + assert "vec_objects_meta" in tables + assert "build_state" in tables + + # Vec0 virtual tables only created when extension is available + if _vec_available(conn): + assert "vec_fulltext" in tables + assert "vec_body" in tables + assert "vec_objects" in tables + else: + assert "vec_fulltext" not in tables + assert "vec_body" not in tables + assert "vec_objects" not in tables + conn.close() + + +def test_vec_tables_are_virtual(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_schema(conn) + + if not _vec_available(conn): + conn.close() + return # Skip virtual-table check when extension not loaded + + cursor = conn.execute( + "SELECT name, type FROM sqlite_master WHERE type='virtual' ORDER BY name" + ) + virtual_tables = {row["name"] for row in cursor.fetchall()} + assert "vec_fulltext" in virtual_tables + assert "vec_body" in virtual_tables + assert "vec_objects" in virtual_tables + conn.close() + + +def test_build_state_table_exists(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_schema(conn) + + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='build_state'" + ) + assert cursor.fetchone() is not None + conn.close() + + +def test_schema_creation_is_idempotent(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_schema(conn) + ensure_schema(conn) # second call must not raise + conn.close() + + +def test_schema_version_is_5(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_schema(conn) + assert get_schema_version(conn) == 5 + assert CURRENT_SCHEMA_VERSION == 5 + conn.close()