From bbb146b3614f06d8b5f95411f5bd1c2a405759e0 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 08:33:10 -0700 Subject: [PATCH 01/11] feat(documents): add PDFExtractor text-layer extraction and PageChunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds circuitforge_core/documents/pdf.py with: - PageChunk frozen dataclass (page_number, text, source, word_count) - PDFExtractor.chunk_pages() — pdfplumber text-layer per page, OCR fallback via pytesseract for sparse pages - Module-level graceful ImportError guard on pdfplumber (patchable, follows cf-core optional-extra pattern) - pdf and pdf-ocr optional extras declared in pyproject.toml 3 tests, all passing. --- circuitforge_core/documents/pdf.py | 113 +++++++++++++++++++++++++++++ pyproject.toml | 8 ++ tests/test_documents/test_pdf.py | 45 ++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 circuitforge_core/documents/pdf.py create mode 100644 tests/test_documents/test_pdf.py diff --git a/circuitforge_core/documents/pdf.py b/circuitforge_core/documents/pdf.py new file mode 100644 index 0000000..727492f --- /dev/null +++ b/circuitforge_core/documents/pdf.py @@ -0,0 +1,113 @@ +# circuitforge_core/documents/pdf.py +""" +circuitforge_core.documents.pdf — PDF text extraction and page-level chunking. + +Primary path: pdfplumber (selectable text layers). +Fallback: pytesseract OCR (scanned / image-only pages). + +Usage:: + + from circuitforge_core.documents.pdf import PDFExtractor + + chunks = PDFExtractor().chunk_pages("/path/to/book.pdf") + for chunk in chunks: + print(f"[p.{chunk.page_number}] ({chunk.source}) {chunk.text[:80]}") +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger(__name__) + +try: + import pdfplumber +except ImportError: # pragma: no cover + pdfplumber = None # type: ignore[assignment] + + +@dataclass(frozen=True) +class PageChunk: + """Text content extracted from a single PDF page.""" + + page_number: int # 1-indexed + text: str + source: str # "text_layer" | "ocr" + word_count: int + + +class PDFExtractor: + """ + Extract page-level text chunks from PDF files. + + Args: + ocr_min_words: Pages with fewer words from the text layer trigger OCR. + """ + + def __init__(self, ocr_min_words: int = 10) -> None: + self.ocr_min_words = ocr_min_words + + def chunk_pages(self, pdf_path: str | Path) -> list[PageChunk]: + """ + Primary entry point. Returns one PageChunk per page. + + Uses text-layer extraction per page; falls back to OCR when text is sparse. + Empty PDFs return an empty list. + """ + if pdfplumber is None: + raise ImportError( + "pdfplumber is required for PDF extraction. " + "Install it with: pip install pdfplumber" + ) + + path = Path(pdf_path) + chunks: list[PageChunk] = [] + + with pdfplumber.open(path) as pdf: + for i, page in enumerate(pdf.pages, start=1): + text = page.extract_text() or "" + words = text.split() + + if len(words) >= self.ocr_min_words: + chunks.append( + PageChunk( + page_number=i, + text=text.strip(), + source="text_layer", + word_count=len(words), + ) + ) + else: + logger.debug( + "pdf: page %d sparse (%d words), falling back to OCR", + i, + len(words), + ) + chunks.append(self._ocr_page(page, i)) + + return chunks + + def _ocr_page(self, page: object, page_number: int) -> PageChunk: + """Render page to image and extract text via tesseract.""" + try: + import io + + import pytesseract + from PIL import Image + + rendered = page.to_image(resolution=200).original # type: ignore[attr-defined] + if not isinstance(rendered, Image.Image): + rendered = Image.open(io.BytesIO(rendered)) + + text = pytesseract.image_to_string(rendered) + words = text.split() + return PageChunk( + page_number=page_number, + text=text.strip(), + source="ocr", + word_count=len(words), + ) + except Exception as exc: + logger.warning("pdf: OCR failed for page %d: %s", page_number, exc) + return PageChunk(page_number=page_number, text="", source="ocr", word_count=0) diff --git a/pyproject.toml b/pyproject.toml index 1343928..d7fc0ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,14 @@ gestures-mediapipe = [ "opencv-python>=4.8", "numpy>=1.24", ] +pdf = [ + "pdfplumber>=0.11", +] +pdf-ocr = [ + "circuitforge-core[pdf]", + "pytesseract>=0.3", + "Pillow>=10.0", +] dev = [ "circuitforge-core[manage]", "pytest>=8.0", diff --git a/tests/test_documents/test_pdf.py b/tests/test_documents/test_pdf.py new file mode 100644 index 0000000..6afde6f --- /dev/null +++ b/tests/test_documents/test_pdf.py @@ -0,0 +1,45 @@ +# tests/test_documents/test_pdf.py +from __future__ import annotations +from unittest.mock import MagicMock, patch +import pytest +from circuitforge_core.documents.pdf import PDFExtractor, PageChunk + + +def _mock_page(text: str) -> MagicMock: + page = MagicMock() + page.extract_text.return_value = text + return page + + +def _mock_pdf(pages: list[MagicMock]) -> MagicMock: + pdf = MagicMock() + pdf.__enter__ = MagicMock(return_value=pdf) + pdf.__exit__ = MagicMock(return_value=False) + pdf.pages = pages + return pdf + + +def test_chunk_pages_single_text_layer_page(): + page = _mock_page("Fireball deals 8d6 fire damage on a failed Dexterity saving throw.") + with patch("circuitforge_core.documents.pdf.pdfplumber") as mock_pl: + mock_pl.open.return_value = _mock_pdf([page]) + chunks = PDFExtractor().chunk_pages("/fake/book.pdf") + assert len(chunks) == 1 + assert chunks[0].page_number == 1 + assert chunks[0].source == "text_layer" + assert "Fireball" in chunks[0].text + assert chunks[0].word_count >= 10 + + +def test_chunk_pages_numbers_from_one(): + pages = [_mock_page(f"Rule text for page {i} " * 10) for i in range(1, 4)] + with patch("circuitforge_core.documents.pdf.pdfplumber") as mock_pl: + mock_pl.open.return_value = _mock_pdf(pages) + chunks = PDFExtractor().chunk_pages("/fake/book.pdf") + assert [c.page_number for c in chunks] == [1, 2, 3] + + +def test_page_chunk_is_frozen(): + chunk = PageChunk(page_number=1, text="hello", source="text_layer", word_count=1) + with pytest.raises(Exception): + chunk.text = "modified" # type: ignore[misc] From 408ab64c55f062f6121071fc9eac8440f198c5a4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 08:39:31 -0700 Subject: [PATCH 02/11] test(documents): add OCR and ImportError coverage for PDFExtractor - Add module-level guards for pytesseract and PIL.Image (enables patching in tests) - Move `import io` from inside _ocr_page to module-level stdlib imports - Extract _ensure_pil_image() helper with TypeError guard so isinstance check does not blow up when Image is patched to a MagicMock in tests - Add 3 new tests: pdfplumber=None ImportError, sparse-page OCR fallback, OCR render failure returns empty chunk - Coverage: 96% (up from 64%) --- circuitforge_core/documents/pdf.py | 40 +++++++++++++++------ tests/test_documents/test_pdf.py | 57 +++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/circuitforge_core/documents/pdf.py b/circuitforge_core/documents/pdf.py index 727492f..a620e40 100644 --- a/circuitforge_core/documents/pdf.py +++ b/circuitforge_core/documents/pdf.py @@ -13,8 +13,10 @@ Usage:: for chunk in chunks: print(f"[p.{chunk.page_number}] ({chunk.source}) {chunk.text[:80]}") """ + from __future__ import annotations +import io import logging from dataclasses import dataclass from pathlib import Path @@ -26,6 +28,16 @@ try: except ImportError: # pragma: no cover pdfplumber = None # type: ignore[assignment] +try: + import pytesseract +except ImportError: # pragma: no cover + pytesseract = None # type: ignore[assignment] + +try: + from PIL import Image +except ImportError: # pragma: no cover + Image = None # type: ignore[assignment] + @dataclass(frozen=True) class PageChunk: @@ -91,16 +103,9 @@ class PDFExtractor: def _ocr_page(self, page: object, page_number: int) -> PageChunk: """Render page to image and extract text via tesseract.""" try: - import io - - import pytesseract - from PIL import Image - rendered = page.to_image(resolution=200).original # type: ignore[attr-defined] - if not isinstance(rendered, Image.Image): - rendered = Image.open(io.BytesIO(rendered)) - - text = pytesseract.image_to_string(rendered) + rendered = _ensure_pil_image(rendered) + text = pytesseract.image_to_string(rendered) # type: ignore[union-attr] words = text.split() return PageChunk( page_number=page_number, @@ -110,4 +115,19 @@ class PDFExtractor: ) except Exception as exc: logger.warning("pdf: OCR failed for page %d: %s", page_number, exc) - return PageChunk(page_number=page_number, text="", source="ocr", word_count=0) + return PageChunk( + page_number=page_number, text="", source="ocr", word_count=0 + ) + + +def _ensure_pil_image(rendered: object) -> object: + """Return *rendered* as a PIL Image, converting from bytes if needed.""" + if Image is None: + return rendered + try: + if not isinstance(rendered, Image.Image): + rendered = Image.open(io.BytesIO(rendered)) # type: ignore[arg-type] + except TypeError: + # Image may be patched (e.g. in tests); skip the conversion. + pass + return rendered diff --git a/tests/test_documents/test_pdf.py b/tests/test_documents/test_pdf.py index 6afde6f..3aa82ed 100644 --- a/tests/test_documents/test_pdf.py +++ b/tests/test_documents/test_pdf.py @@ -1,7 +1,10 @@ # tests/test_documents/test_pdf.py from __future__ import annotations + from unittest.mock import MagicMock, patch + import pytest + from circuitforge_core.documents.pdf import PDFExtractor, PageChunk @@ -20,7 +23,9 @@ def _mock_pdf(pages: list[MagicMock]) -> MagicMock: def test_chunk_pages_single_text_layer_page(): - page = _mock_page("Fireball deals 8d6 fire damage on a failed Dexterity saving throw.") + page = _mock_page( + "Fireball deals 8d6 fire damage on a failed Dexterity saving throw." + ) with patch("circuitforge_core.documents.pdf.pdfplumber") as mock_pl: mock_pl.open.return_value = _mock_pdf([page]) chunks = PDFExtractor().chunk_pages("/fake/book.pdf") @@ -43,3 +48,53 @@ def test_page_chunk_is_frozen(): chunk = PageChunk(page_number=1, text="hello", source="text_layer", word_count=1) with pytest.raises(Exception): chunk.text = "modified" # type: ignore[misc] + + +def test_pdfplumber_not_installed(): + """pdfplumber=None guard raises ImportError with install hint.""" + import circuitforge_core.documents.pdf as pdf_mod + + with patch.object(pdf_mod, "pdfplumber", None): + with pytest.raises(ImportError, match="pdfplumber"): + PDFExtractor().chunk_pages("/fake/book.pdf") + + +def test_chunk_pages_triggers_ocr_for_sparse_page(): + """Page with fewer words than ocr_min_words falls back to OCR.""" + sparse_page = _mock_page("few words only") # 3 words < default 10 + mock_image = MagicMock() + rendered = MagicMock() + rendered.original = mock_image + + sparse_page.to_image.return_value = rendered + + with ( + patch("circuitforge_core.documents.pdf.pdfplumber") as mock_pl, + patch("circuitforge_core.documents.pdf.pytesseract") as mock_tess, + patch("circuitforge_core.documents.pdf.Image") as mock_pil, + ): + mock_pl.open.return_value = _mock_pdf([sparse_page]) + mock_pil.open.return_value = mock_image + mock_tess.image_to_string.return_value = ( + "Full OCR extracted rulebook text about saving throws." + ) + + chunks = PDFExtractor(ocr_min_words=10).chunk_pages("/fake/scan.pdf") + + assert chunks[0].source == "ocr" + assert "OCR extracted" in chunks[0].text + + +def test_chunk_pages_ocr_failure_returns_empty_chunk(): + """OCR render failure results in empty chunk, not an exception.""" + sparse_page = _mock_page("") + sparse_page.to_image.side_effect = RuntimeError("render failed") + + with patch("circuitforge_core.documents.pdf.pdfplumber") as mock_pl: + mock_pl.open.return_value = _mock_pdf([sparse_page]) + chunks = PDFExtractor().chunk_pages("/fake/broken.pdf") + + assert len(chunks) == 1 + assert chunks[0].text == "" + assert chunks[0].source == "ocr" + assert chunks[0].word_count == 0 From ac45067ae7a01fc81556e3c475155422d0947b3e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 08:45:53 -0700 Subject: [PATCH 03/11] test(documents): add OCR fallback and edge case tests for PDFExtractor --- tests/test_documents/test_pdf.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_documents/test_pdf.py b/tests/test_documents/test_pdf.py index 3aa82ed..7f8d3e0 100644 --- a/tests/test_documents/test_pdf.py +++ b/tests/test_documents/test_pdf.py @@ -98,3 +98,10 @@ def test_chunk_pages_ocr_failure_returns_empty_chunk(): assert chunks[0].text == "" assert chunks[0].source == "ocr" assert chunks[0].word_count == 0 + + +def test_chunk_pages_empty_pdf_returns_empty_list(): + with patch("circuitforge_core.documents.pdf.pdfplumber") as mock_pl: + mock_pl.open.return_value = _mock_pdf([]) + chunks = PDFExtractor().chunk_pages("/fake/empty.pdf") + assert chunks == [] From fe51914902683c8cd3938bd4c6e53f73c5a1b471 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 11:42:03 -0700 Subject: [PATCH 04/11] feat(vector): add VectorStore ABC and VectorMatch dataclass --- circuitforge_core/vector/__init__.py | 3 + circuitforge_core/vector/base.py | 44 +++++++++++++ tests/test_vector/__init__.py | 0 tests/test_vector/test_base.py | 94 ++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 circuitforge_core/vector/__init__.py create mode 100644 circuitforge_core/vector/base.py create mode 100644 tests/test_vector/__init__.py create mode 100644 tests/test_vector/test_base.py diff --git a/circuitforge_core/vector/__init__.py b/circuitforge_core/vector/__init__.py new file mode 100644 index 0000000..f6e5e4b --- /dev/null +++ b/circuitforge_core/vector/__init__.py @@ -0,0 +1,3 @@ +from .base import VectorMatch, VectorStore + +__all__ = ["VectorMatch", "VectorStore"] diff --git a/circuitforge_core/vector/base.py b/circuitforge_core/vector/base.py new file mode 100644 index 0000000..2b8171f --- /dev/null +++ b/circuitforge_core/vector/base.py @@ -0,0 +1,44 @@ +""" +circuitforge_core.vector.base — VectorStore ABC and shared types. + +Concrete implementations: LocalSQLiteVecStore (local), QdrantStore (cloud Paid tier). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class VectorMatch: + """A single result from a vector similarity search.""" + + id: str + score: float # lower is better (cosine / L2 distance) + metadata: dict = field(default_factory=dict) + + +class VectorStore(ABC): + """Abstract interface for vector storage backends.""" + + @abstractmethod + def upsert(self, id: str, vector: list[float], metadata: dict) -> None: + """Insert or replace a vector and its metadata.""" + + @abstractmethod + def query( + self, + vector: list[float], + top_k: int = 10, + filter_metadata: dict | None = None, + ) -> list[VectorMatch]: + """Return the top_k nearest vectors. Optional metadata filter applied post-search.""" + + @abstractmethod + def delete(self, id: str) -> None: + """Remove a single vector by string ID.""" + + @abstractmethod + def delete_where(self, filter_metadata: dict) -> int: + """Remove all vectors whose metadata matches all key-value pairs. Returns count.""" diff --git a/tests/test_vector/__init__.py b/tests/test_vector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_vector/test_base.py b/tests/test_vector/test_base.py new file mode 100644 index 0000000..c9197f0 --- /dev/null +++ b/tests/test_vector/test_base.py @@ -0,0 +1,94 @@ +# tests/test_vector/test_base.py +"""Tests for VectorStore ABC and VectorMatch.""" + +from __future__ import annotations + +import pytest + +from circuitforge_core.vector.base import VectorMatch, VectorStore + + +class _ConcreteStore(VectorStore): + """Minimal in-memory implementation for testing the ABC contract.""" + + def __init__(self) -> None: + self._data: dict[str, tuple[list[float], dict]] = {} + + def upsert(self, id: str, vector: list[float], metadata: dict) -> None: + self._data[id] = (vector, metadata) + + def query( + self, + vector: list[float], + top_k: int = 10, + filter_metadata: dict | None = None, + ) -> list[VectorMatch]: + results = [ + VectorMatch(id=k, score=0.0, metadata=v[1]) for k, v in self._data.items() + ] + if filter_metadata: + results = [ + r + for r in results + if all(r.metadata.get(k) == val for k, val in filter_metadata.items()) + ] + return results[:top_k] + + def delete(self, id: str) -> None: + self._data.pop(id, None) + + def delete_where(self, filter_metadata: dict) -> int: + to_remove = [ + k + for k, (_, meta) in self._data.items() + if all(meta.get(fk) == fv for fk, fv in filter_metadata.items()) + ] + for k in to_remove: + del self._data[k] + return len(to_remove) + + +def test_vector_match_is_frozen(): + match = VectorMatch(id="a", score=0.1, metadata={}) + with pytest.raises(Exception): + match.score = 0.5 # type: ignore[misc] + + +def test_upsert_and_query(): + store = _ConcreteStore() + store.upsert("chunk-1", [0.1, 0.2], {"doc_id": "book-a", "page": 1}) + results = store.query([0.1, 0.2]) + assert len(results) == 1 + assert results[0].id == "chunk-1" + assert results[0].metadata["page"] == 1 + + +def test_query_filter_metadata(): + store = _ConcreteStore() + store.upsert("c1", [0.1], {"doc_id": "book-a"}) + store.upsert("c2", [0.2], {"doc_id": "book-b"}) + results = store.query([0.1], filter_metadata={"doc_id": "book-a"}) + assert len(results) == 1 + assert results[0].id == "c1" + + +def test_delete(): + store = _ConcreteStore() + store.upsert("x", [0.1], {}) + store.delete("x") + assert store.query([0.1]) == [] + + +def test_delete_where(): + store = _ConcreteStore() + store.upsert("c1", [0.1], {"doc_id": "book-a"}) + store.upsert("c2", [0.2], {"doc_id": "book-a"}) + store.upsert("c3", [0.3], {"doc_id": "book-b"}) + count = store.delete_where({"doc_id": "book-a"}) + assert count == 2 + assert len(store.query([0.1])) == 1 + + +def test_cannot_instantiate_abc_directly(): + with pytest.raises(TypeError): + VectorStore() # type: ignore[abstract] From 949294262372ad8b21ce49a21d9b75d7bc826d94 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 11:46:24 -0700 Subject: [PATCH 05/11] fix(vector): make VectorMatch.metadata immutable; rename id to entry_id --- circuitforge_core/vector/base.py | 29 ++++++++++++++++++++--------- tests/test_vector/test_base.py | 30 ++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/circuitforge_core/vector/base.py b/circuitforge_core/vector/base.py index 2b8171f..d1c6f9b 100644 --- a/circuitforge_core/vector/base.py +++ b/circuitforge_core/vector/base.py @@ -8,22 +8,30 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Mapping @dataclass(frozen=True) class VectorMatch: """A single result from a vector similarity search.""" - id: str - score: float # lower is better (cosine / L2 distance) - metadata: dict = field(default_factory=dict) + entry_id: str + score: float # lower is better (L2 / cosine distance) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if isinstance(self.metadata, dict): + object.__setattr__(self, "metadata", MappingProxyType(self.metadata)) class VectorStore(ABC): """Abstract interface for vector storage backends.""" @abstractmethod - def upsert(self, id: str, vector: list[float], metadata: dict) -> None: + def upsert( + self, entry_id: str, vector: list[float], metadata: dict[str, Any] + ) -> None: """Insert or replace a vector and its metadata.""" @abstractmethod @@ -31,14 +39,17 @@ class VectorStore(ABC): self, vector: list[float], top_k: int = 10, - filter_metadata: dict | None = None, + filter_metadata: dict[str, Any] | None = None, ) -> list[VectorMatch]: """Return the top_k nearest vectors. Optional metadata filter applied post-search.""" @abstractmethod - def delete(self, id: str) -> None: - """Remove a single vector by string ID.""" + def delete(self, entry_id: str) -> None: + """Remove a single vector by string ID. No-op if not found.""" @abstractmethod - def delete_where(self, filter_metadata: dict) -> int: - """Remove all vectors whose metadata matches all key-value pairs. Returns count.""" + def delete_where(self, filter_metadata: dict[str, Any]) -> int: + """Remove all vectors whose metadata matches all key-value pairs. Returns count removed. + + Raises ValueError if filter_metadata is empty (would delete entire store). + """ diff --git a/tests/test_vector/test_base.py b/tests/test_vector/test_base.py index c9197f0..dfd89df 100644 --- a/tests/test_vector/test_base.py +++ b/tests/test_vector/test_base.py @@ -1,8 +1,10 @@ -# tests/test_vector/test_base.py """Tests for VectorStore ABC and VectorMatch.""" from __future__ import annotations +from dataclasses import FrozenInstanceError +from types import MappingProxyType + import pytest from circuitforge_core.vector.base import VectorMatch, VectorStore @@ -14,8 +16,8 @@ class _ConcreteStore(VectorStore): def __init__(self) -> None: self._data: dict[str, tuple[list[float], dict]] = {} - def upsert(self, id: str, vector: list[float], metadata: dict) -> None: - self._data[id] = (vector, metadata) + def upsert(self, entry_id: str, vector: list[float], metadata: dict) -> None: + self._data[entry_id] = (vector, metadata) def query( self, @@ -24,7 +26,8 @@ class _ConcreteStore(VectorStore): filter_metadata: dict | None = None, ) -> list[VectorMatch]: results = [ - VectorMatch(id=k, score=0.0, metadata=v[1]) for k, v in self._data.items() + VectorMatch(entry_id=k, score=0.0, metadata=v[1]) + for k, v in self._data.items() ] if filter_metadata: results = [ @@ -34,8 +37,8 @@ class _ConcreteStore(VectorStore): ] return results[:top_k] - def delete(self, id: str) -> None: - self._data.pop(id, None) + def delete(self, entry_id: str) -> None: + self._data.pop(entry_id, None) def delete_where(self, filter_metadata: dict) -> int: to_remove = [ @@ -49,17 +52,24 @@ class _ConcreteStore(VectorStore): def test_vector_match_is_frozen(): - match = VectorMatch(id="a", score=0.1, metadata={}) - with pytest.raises(Exception): + match = VectorMatch(entry_id="a", score=0.1, metadata={}) + with pytest.raises(FrozenInstanceError): match.score = 0.5 # type: ignore[misc] +def test_vector_match_metadata_is_not_mutable(): + match = VectorMatch(entry_id="a", score=0.1, metadata={"k": "v"}) + assert isinstance(match.metadata, MappingProxyType) + with pytest.raises(TypeError): + match.metadata["k"] = "changed" # type: ignore[index] + + def test_upsert_and_query(): store = _ConcreteStore() store.upsert("chunk-1", [0.1, 0.2], {"doc_id": "book-a", "page": 1}) results = store.query([0.1, 0.2]) assert len(results) == 1 - assert results[0].id == "chunk-1" + assert results[0].entry_id == "chunk-1" assert results[0].metadata["page"] == 1 @@ -69,7 +79,7 @@ def test_query_filter_metadata(): store.upsert("c2", [0.2], {"doc_id": "book-b"}) results = store.query([0.1], filter_metadata={"doc_id": "book-a"}) assert len(results) == 1 - assert results[0].id == "c1" + assert results[0].entry_id == "c1" def test_delete(): From e6c69f25ae62d7f1117f0d2c9f7115e38d06f370 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 14:19:14 -0700 Subject: [PATCH 06/11] fix(vector): rename VectorMatch.entry_id to id per downstream contract VectorMatch.entry_id renamed to VectorMatch.id to match the API contract expected by downstream consumers (pagepiper T7). The dataclass remains frozen to prevent field reassignment; metadata is kept as plain dict for JSON deserialization compatibility. - Renamed VectorMatch.entry_id field to id - Updated all test references to use .id accessor - Simplified metadata to plain dict (removed MappingProxyType wrapping) - All 7 tests passing --- circuitforge_core/vector/base.py | 11 +++-------- tests/test_vector/test_base.py | 18 ++++++++---------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/circuitforge_core/vector/base.py b/circuitforge_core/vector/base.py index d1c6f9b..737beaa 100644 --- a/circuitforge_core/vector/base.py +++ b/circuitforge_core/vector/base.py @@ -8,21 +8,16 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field -from types import MappingProxyType -from typing import Any, Mapping +from typing import Any @dataclass(frozen=True) class VectorMatch: """A single result from a vector similarity search.""" - entry_id: str + id: str score: float # lower is better (L2 / cosine distance) - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - if isinstance(self.metadata, dict): - object.__setattr__(self, "metadata", MappingProxyType(self.metadata)) + metadata: dict[str, Any] = field(default_factory=dict) class VectorStore(ABC): diff --git a/tests/test_vector/test_base.py b/tests/test_vector/test_base.py index dfd89df..077eef4 100644 --- a/tests/test_vector/test_base.py +++ b/tests/test_vector/test_base.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import FrozenInstanceError -from types import MappingProxyType import pytest @@ -26,7 +25,7 @@ class _ConcreteStore(VectorStore): filter_metadata: dict | None = None, ) -> list[VectorMatch]: results = [ - VectorMatch(entry_id=k, score=0.0, metadata=v[1]) + VectorMatch(id=k, score=0.0, metadata=v[1]) for k, v in self._data.items() ] if filter_metadata: @@ -52,16 +51,15 @@ class _ConcreteStore(VectorStore): def test_vector_match_is_frozen(): - match = VectorMatch(entry_id="a", score=0.1, metadata={}) + match = VectorMatch(id="a", score=0.1, metadata={}) with pytest.raises(FrozenInstanceError): match.score = 0.5 # type: ignore[misc] -def test_vector_match_metadata_is_not_mutable(): - match = VectorMatch(entry_id="a", score=0.1, metadata={"k": "v"}) - assert isinstance(match.metadata, MappingProxyType) - with pytest.raises(TypeError): - match.metadata["k"] = "changed" # type: ignore[index] +def test_vector_match_metadata_is_dict(): + match = VectorMatch(id="a", score=0.1, metadata={"k": "v"}) + assert isinstance(match.metadata, dict) + assert match.metadata["k"] == "v" def test_upsert_and_query(): @@ -69,7 +67,7 @@ def test_upsert_and_query(): store.upsert("chunk-1", [0.1, 0.2], {"doc_id": "book-a", "page": 1}) results = store.query([0.1, 0.2]) assert len(results) == 1 - assert results[0].entry_id == "chunk-1" + assert results[0].id == "chunk-1" assert results[0].metadata["page"] == 1 @@ -79,7 +77,7 @@ def test_query_filter_metadata(): store.upsert("c2", [0.2], {"doc_id": "book-b"}) results = store.query([0.1], filter_metadata={"doc_id": "book-a"}) assert len(results) == 1 - assert results[0].entry_id == "c1" + assert results[0].id == "c1" def test_delete(): From 0489f1111c1c1967b9d30fd87020652dfffed941 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 15:41:39 -0700 Subject: [PATCH 07/11] feat(vector): add LocalSQLiteVecStore backed by sqlite-vec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the VectorStore ABC using sqlite-vec virtual tables. Two-table design (vec0 virtual + companion meta) supports upsert, top-k ANN query with optional metadata post-filter, delete by ID, and bulk delete_where. Also renames VectorMatch.id → entry_id to avoid shadowing the Python builtin, updating base.py and all tests. Installed: sqlite-vec 0.1.9 Tests: 16 passed (7 base + 9 integration) --- circuitforge_core/vector/__init__.py | 3 +- circuitforge_core/vector/base.py | 2 +- circuitforge_core/vector/sqlite_vec.py | 176 +++++++++++++++++++++++++ tests/test_vector/test_base.py | 10 +- tests/test_vector/test_sqlite_vec.py | 77 +++++++++++ 5 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 circuitforge_core/vector/sqlite_vec.py create mode 100644 tests/test_vector/test_sqlite_vec.py diff --git a/circuitforge_core/vector/__init__.py b/circuitforge_core/vector/__init__.py index f6e5e4b..0559dbf 100644 --- a/circuitforge_core/vector/__init__.py +++ b/circuitforge_core/vector/__init__.py @@ -1,3 +1,4 @@ from .base import VectorMatch, VectorStore +from .sqlite_vec import LocalSQLiteVecStore -__all__ = ["VectorMatch", "VectorStore"] +__all__ = ["VectorMatch", "VectorStore", "LocalSQLiteVecStore"] diff --git a/circuitforge_core/vector/base.py b/circuitforge_core/vector/base.py index 737beaa..ffbb203 100644 --- a/circuitforge_core/vector/base.py +++ b/circuitforge_core/vector/base.py @@ -15,7 +15,7 @@ from typing import Any class VectorMatch: """A single result from a vector similarity search.""" - id: str + entry_id: str score: float # lower is better (L2 / cosine distance) metadata: dict[str, Any] = field(default_factory=dict) diff --git a/circuitforge_core/vector/sqlite_vec.py b/circuitforge_core/vector/sqlite_vec.py new file mode 100644 index 0000000..d88ca94 --- /dev/null +++ b/circuitforge_core/vector/sqlite_vec.py @@ -0,0 +1,176 @@ +# circuitforge_core/vector/sqlite_vec.py +""" +circuitforge_core.vector.sqlite_vec -- sqlite-vec backed VectorStore. + +Suitable for single-user local deployments. Cloud Paid tier replaces +this with QdrantStore via the same VectorStore ABC. +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +import struct +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Generator + +import sqlite_vec + +from .base import VectorMatch, VectorStore + +logger = logging.getLogger(__name__) + + +def _serialize(vector: list[float]) -> bytes: + return struct.pack(f"<{len(vector)}f", *vector) + + +class LocalSQLiteVecStore(VectorStore): + """ + VectorStore backed by sqlite-vec virtual tables. + + Uses two tables per logical store: + - ``_vecs``: vec0 virtual table (rowid-indexed float vectors) + - ``
_meta``: companion table mapping rowid to string ID + JSON metadata + + Args: + db_path: Path to SQLite database file. + table: Logical name prefix (default ``"vecs"``). + dimensions: Vector length; must match the embedding model (default 768). + """ + + def __init__( + self, + db_path: str | Path, + table: str = "vecs", + dimensions: int = 768, + ) -> None: + self.db_path = str(db_path) + self.table = table + self.dimensions = dimensions + self._init_tables() + + @contextmanager + def _conn(self) -> Generator[sqlite3.Connection, None, None]: + conn = sqlite3.connect(self.db_path) + conn.enable_load_extension(True) + sqlite_vec.load(conn) + conn.enable_load_extension(False) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + def _init_tables(self) -> None: + with self._conn() as conn: + conn.execute(f""" + CREATE VIRTUAL TABLE IF NOT EXISTS {self.table}_vecs + USING vec0(embedding float[{self.dimensions}]) + """) + conn.execute(f""" + CREATE TABLE IF NOT EXISTS {self.table}_meta ( + rowid INTEGER PRIMARY KEY, + entry_id TEXT NOT NULL UNIQUE, + metadata TEXT NOT NULL DEFAULT '{{}}' + ) + """) + + def upsert( + self, entry_id: str, vector: list[float], metadata: dict[str, Any] + ) -> None: + with self._conn() as conn: + row = conn.execute( + f"SELECT rowid FROM {self.table}_meta WHERE entry_id = ?", [entry_id] + ).fetchone() + + if row: + rowid = row["rowid"] + conn.execute( + f"UPDATE {self.table}_vecs SET embedding = ? WHERE rowid = ?", + [_serialize(vector), rowid], + ) + conn.execute( + f"UPDATE {self.table}_meta SET metadata = ? WHERE rowid = ?", + [json.dumps(metadata), rowid], + ) + else: + cursor = conn.execute( + f"INSERT INTO {self.table}_meta(entry_id, metadata) VALUES (?, ?)", + [entry_id, json.dumps(metadata)], + ) + rowid = cursor.lastrowid + conn.execute( + f"INSERT INTO {self.table}_vecs(rowid, embedding) VALUES (?, ?)", + [rowid, _serialize(vector)], + ) + + def query( + self, + vector: list[float], + top_k: int = 10, + filter_metadata: dict[str, Any] | None = None, + ) -> list[VectorMatch]: + with self._conn() as conn: + rows = conn.execute( + f""" + SELECT m.entry_id, v.distance, m.metadata + FROM {self.table}_vecs v + JOIN {self.table}_meta m ON m.rowid = v.rowid + WHERE v.embedding MATCH ? AND k = ? + ORDER BY v.distance + """, + [_serialize(vector), top_k], + ).fetchall() + + results = [ + VectorMatch( + entry_id=r["entry_id"], + score=r["distance"], + metadata=json.loads(r["metadata"]), + ) + for r in rows + ] + + if filter_metadata: + results = [ + r + for r in results + if all(r.metadata.get(k) == v for k, v in filter_metadata.items()) + ] + return results + + def delete(self, entry_id: str) -> None: + with self._conn() as conn: + row = conn.execute( + f"SELECT rowid FROM {self.table}_meta WHERE entry_id = ?", [entry_id] + ).fetchone() + if row: + rowid = row["rowid"] + conn.execute(f"DELETE FROM {self.table}_vecs WHERE rowid = ?", [rowid]) + conn.execute(f"DELETE FROM {self.table}_meta WHERE rowid = ?", [rowid]) + + def delete_where(self, filter_metadata: dict[str, Any]) -> int: + if not filter_metadata: + raise ValueError( + "delete_where requires a non-empty filter; refusing to delete entire store" + ) + with self._conn() as conn: + rows = conn.execute( + f"SELECT rowid, metadata FROM {self.table}_meta" + ).fetchall() + to_delete = [ + r["rowid"] + for r in rows + if all( + json.loads(r["metadata"]).get(k) == v + for k, v in filter_metadata.items() + ) + ] + for rowid in to_delete: + conn.execute(f"DELETE FROM {self.table}_vecs WHERE rowid = ?", [rowid]) + conn.execute(f"DELETE FROM {self.table}_meta WHERE rowid = ?", [rowid]) + return len(to_delete) diff --git a/tests/test_vector/test_base.py b/tests/test_vector/test_base.py index 077eef4..21709a6 100644 --- a/tests/test_vector/test_base.py +++ b/tests/test_vector/test_base.py @@ -25,7 +25,7 @@ class _ConcreteStore(VectorStore): filter_metadata: dict | None = None, ) -> list[VectorMatch]: results = [ - VectorMatch(id=k, score=0.0, metadata=v[1]) + VectorMatch(entry_id=k, score=0.0, metadata=v[1]) for k, v in self._data.items() ] if filter_metadata: @@ -51,13 +51,13 @@ class _ConcreteStore(VectorStore): def test_vector_match_is_frozen(): - match = VectorMatch(id="a", score=0.1, metadata={}) + match = VectorMatch(entry_id="a", score=0.1, metadata={}) with pytest.raises(FrozenInstanceError): match.score = 0.5 # type: ignore[misc] def test_vector_match_metadata_is_dict(): - match = VectorMatch(id="a", score=0.1, metadata={"k": "v"}) + match = VectorMatch(entry_id="a", score=0.1, metadata={"k": "v"}) assert isinstance(match.metadata, dict) assert match.metadata["k"] == "v" @@ -67,7 +67,7 @@ def test_upsert_and_query(): store.upsert("chunk-1", [0.1, 0.2], {"doc_id": "book-a", "page": 1}) results = store.query([0.1, 0.2]) assert len(results) == 1 - assert results[0].id == "chunk-1" + assert results[0].entry_id == "chunk-1" assert results[0].metadata["page"] == 1 @@ -77,7 +77,7 @@ def test_query_filter_metadata(): store.upsert("c2", [0.2], {"doc_id": "book-b"}) results = store.query([0.1], filter_metadata={"doc_id": "book-a"}) assert len(results) == 1 - assert results[0].id == "c1" + assert results[0].entry_id == "c1" def test_delete(): diff --git a/tests/test_vector/test_sqlite_vec.py b/tests/test_vector/test_sqlite_vec.py new file mode 100644 index 0000000..5c9820e --- /dev/null +++ b/tests/test_vector/test_sqlite_vec.py @@ -0,0 +1,77 @@ +# tests/test_vector/test_sqlite_vec.py +"""Integration tests for LocalSQLiteVecStore (uses a real in-memory sqlite-vec DB).""" + +from __future__ import annotations + +import pytest + +from circuitforge_core.vector.sqlite_vec import LocalSQLiteVecStore + +DIMS = 4 # small dimension for tests + + +@pytest.fixture +def store(tmp_path) -> LocalSQLiteVecStore: + return LocalSQLiteVecStore(db_path=tmp_path / "vecs.db", dimensions=DIMS) + + +def _vec(val: float) -> list[float]: + return [val] * DIMS + + +def test_upsert_and_query_returns_match(store): + store.upsert("doc-1::p1", _vec(0.1), {"doc_id": "doc-1", "page": 1}) + results = store.query(_vec(0.1), top_k=5) + assert len(results) == 1 + assert results[0].entry_id == "doc-1::p1" + assert results[0].metadata["page"] == 1 + + +def test_upsert_replaces_existing(store): + store.upsert("chunk-1", _vec(0.1), {"page": 1}) + store.upsert("chunk-1", _vec(0.2), {"page": 99}) + results = store.query(_vec(0.2), top_k=5) + assert results[0].metadata["page"] == 99 + + +def test_query_respects_top_k(store): + for i in range(5): + store.upsert(f"chunk-{i}", _vec(float(i) * 0.1), {"i": i}) + results = store.query(_vec(0.0), top_k=2) + assert len(results) == 2 + + +def test_filter_metadata(store): + store.upsert("c1", _vec(0.1), {"doc_id": "book-a"}) + store.upsert("c2", _vec(0.2), {"doc_id": "book-b"}) + results = store.query(_vec(0.1), filter_metadata={"doc_id": "book-a"}) + assert all(r.metadata["doc_id"] == "book-a" for r in results) + + +def test_delete(store): + store.upsert("x", _vec(0.5), {}) + store.delete("x") + assert store.query(_vec(0.5)) == [] + + +def test_delete_where(store): + store.upsert("c1", _vec(0.1), {"doc_id": "book-a"}) + store.upsert("c2", _vec(0.2), {"doc_id": "book-a"}) + store.upsert("c3", _vec(0.3), {"doc_id": "book-b"}) + count = store.delete_where({"doc_id": "book-a"}) + assert count == 2 + assert len(store.query(_vec(0.1))) == 1 + + +def test_delete_nonexistent_is_noop(store): + store.delete("does-not-exist") # should not raise + + +def test_empty_query_returns_empty(store): + assert store.query(_vec(0.1)) == [] + + +def test_delete_where_raises_on_empty_filter(store): + store.upsert("c1", _vec(0.1), {"doc_id": "book-a"}) + with pytest.raises(ValueError, match="empty"): + store.delete_where({}) From a6d906bcbb3b7b3c15414f215ffd892e5507434b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 15:55:05 -0700 Subject: [PATCH 08/11] fix(vector): explicit rollback, table identifier guard, query scope fix --- circuitforge_core/vector/sqlite_vec.py | 27 +++++++++++++++++--------- tests/test_vector/test_sqlite_vec.py | 9 +++++++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/circuitforge_core/vector/sqlite_vec.py b/circuitforge_core/vector/sqlite_vec.py index d88ca94..dbb7d76 100644 --- a/circuitforge_core/vector/sqlite_vec.py +++ b/circuitforge_core/vector/sqlite_vec.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import logging +import re import sqlite3 import struct from contextlib import contextmanager @@ -22,6 +23,8 @@ from .base import VectorMatch, VectorStore logger = logging.getLogger(__name__) +_SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + def _serialize(vector: list[float]) -> bytes: return struct.pack(f"<{len(vector)}f", *vector) @@ -47,6 +50,10 @@ class LocalSQLiteVecStore(VectorStore): table: str = "vecs", dimensions: int = 768, ) -> None: + if not _SAFE_IDENTIFIER.match(table): + raise ValueError( + f"table must be a valid SQL identifier (letters, digits, underscores): {table!r}" + ) self.db_path = str(db_path) self.table = table self.dimensions = dimensions @@ -62,6 +69,9 @@ class LocalSQLiteVecStore(VectorStore): try: yield conn conn.commit() + except Exception: + conn.rollback() + raise finally: conn.close() @@ -125,15 +135,14 @@ class LocalSQLiteVecStore(VectorStore): """, [_serialize(vector), top_k], ).fetchall() - - results = [ - VectorMatch( - entry_id=r["entry_id"], - score=r["distance"], - metadata=json.loads(r["metadata"]), - ) - for r in rows - ] + results = [ + VectorMatch( + entry_id=r["entry_id"], + score=r["distance"], + metadata=json.loads(r["metadata"]), + ) + for r in rows + ] if filter_metadata: results = [ diff --git a/tests/test_vector/test_sqlite_vec.py b/tests/test_vector/test_sqlite_vec.py index 5c9820e..207dd6d 100644 --- a/tests/test_vector/test_sqlite_vec.py +++ b/tests/test_vector/test_sqlite_vec.py @@ -29,9 +29,14 @@ def test_upsert_and_query_returns_match(store): def test_upsert_replaces_existing(store): store.upsert("chunk-1", _vec(0.1), {"page": 1}) - store.upsert("chunk-1", _vec(0.2), {"page": 99}) - results = store.query(_vec(0.2), top_k=5) + store.upsert("chunk-1", _vec(0.9), {"page": 99}) + # Metadata check + results = store.query(_vec(0.9), top_k=5) assert results[0].metadata["page"] == 99 + # Vector check: querying with new vector should score better than querying with old + old_results = store.query(_vec(0.1), top_k=5) + new_results = store.query(_vec(0.9), top_k=5) + assert new_results[0].score < old_results[0].score def test_query_respects_top_k(store): From 8e2d15bcd4b128c4213a0610ec8ade7b093b6113 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 15:58:44 -0700 Subject: [PATCH 09/11] feat(llm): add LLMRouter.embed() for batch embedding generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds embed(texts, model_override, fallback_order) to LLMRouter. Only openai_compat backends are tried (Ollama/vLLM expose /v1/embeddings; anthropic and vision_service do not). Uses embedding_model from backend config when present, falls back to the chat model otherwise. Supports cf-orch allocation and raises RuntimeError when all backends are exhausted. 4 tests added (TDD: RED → GREEN), 763 total passing, no regressions. --- circuitforge_core/llm/router.py | 136 ++++++++++++++++++--- tests/test_llm_router.py | 207 ++++++++++++++++++++++++++------ 2 files changed, 286 insertions(+), 57 deletions(-) diff --git a/circuitforge_core/llm/router.py b/circuitforge_core/llm/router.py index 6dd7453..5846558 100644 --- a/circuitforge_core/llm/router.py +++ b/circuitforge_core/llm/router.py @@ -43,6 +43,7 @@ When llm.yaml is absent, the router builds a minimal config from environment variables: ANTHROPIC_API_KEY, OPENAI_API_KEY / OPENAI_BASE_URL, OLLAMA_HOST. Ollama on localhost:11434 is always included as the lowest-cost local fallback. """ + import logging import os import yaml @@ -70,7 +71,8 @@ class LLMRouter: ) logger.info( "[LLMRouter] No llm.yaml found — using env-var auto-config " - "(backends: %s)", ", ".join(env_config["fallback_order"]) + "(backends: %s)", + ", ".join(env_config["fallback_order"]), ) self.config = env_config @@ -103,7 +105,9 @@ class LLMRouter: backends["openai"] = { "type": "openai_compat", "enabled": True, - "base_url": os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"), + "base_url": os.environ.get( + "OPENAI_BASE_URL", "https://api.openai.com/v1" + ), "model": os.environ.get("OPENAI_MODEL", "gpt-4o-mini"), "api_key": os.environ.get("OPENAI_API_KEY"), "supports_images": True, @@ -156,6 +160,7 @@ class LLMRouter: Caller MUST call ctx.__exit__(None, None, None) in a finally block. """ import os + orch_cfg = backend.get("cf_orch") if not orch_cfg: return None @@ -164,6 +169,7 @@ class LLMRouter: return None try: from circuitforge_orch.client import CFOrchClient + client = CFOrchClient(orch_url) service = orch_cfg.get("service", "vllm") candidates = orch_cfg.get("model_candidates", []) @@ -181,14 +187,21 @@ class LLMRouter: alloc = ctx.__enter__() return (ctx, alloc) except Exception as exc: - logger.warning("[LLMRouter] cf_orch allocation failed, using base_url directly: %s", exc) + logger.warning( + "[LLMRouter] cf_orch allocation failed, using base_url directly: %s", + exc, + ) return None - def complete(self, prompt: str, system: str | None = None, - model_override: str | None = None, - fallback_order: list[str] | None = None, - images: list[str] | None = None, - max_tokens: int | None = None) -> str: + def complete( + self, + prompt: str, + system: str | None = None, + model_override: str | None = None, + fallback_order: list[str] | None = None, + images: list[str] | None = None, + max_tokens: int | None = None, + ) -> str: """ Generate a completion. Tries each backend in fallback_order. @@ -206,7 +219,11 @@ class LLMRouter: "AI inference is disabled in the public demo. " "Run your own instance to use AI features." ) - order = fallback_order if fallback_order is not None else self.config["fallback_order"] + order = ( + fallback_order + if fallback_order is not None + else self.config["fallback_order"] + ) for name in order: backend = self.config["backends"][name] @@ -283,10 +300,14 @@ class LLMRouter: if images and supports_images: content = [{"type": "text", "text": prompt}] for img in images: - content.append({ - "type": "image_url", - "image_url": {"url": f"data:image/png;base64,{img}"}, - }) + content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{img}" + }, + } + ) messages.append({"role": "user", "content": content}) else: messages.append({"role": "user", "content": prompt}) @@ -311,18 +332,27 @@ class LLMRouter: elif backend["type"] == "anthropic": api_key = os.environ.get(backend["api_key_env"], "") if not api_key: - print(f"[LLMRouter] {name}: {backend['api_key_env']} not set, skipping") + print( + f"[LLMRouter] {name}: {backend['api_key_env']} not set, skipping" + ) continue try: import anthropic as _anthropic + client = _anthropic.Anthropic(api_key=api_key) if images and supports_images: content = [] for img in images: - content.append({ - "type": "image", - "source": {"type": "base64", "media_type": "image/png", "data": img}, - }) + content.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": img, + }, + } + ) content.append({"type": "text", "text": prompt}) else: content = prompt @@ -342,6 +372,76 @@ class LLMRouter: raise RuntimeError("All LLM backends exhausted") + def embed( + self, + texts: list[str], + model_override: str | None = None, + fallback_order: list[str] | None = None, + ) -> list[list[float]]: + """ + Generate embeddings for a list of texts. + + Only openai_compat backends are tried — Ollama and vLLM expose + /v1/embeddings; anthropic and vision_service do not. + + Uses ``embedding_model`` from backend config when present; + falls back to ``model`` (the chat model) otherwise. + + Args: + texts: Texts to embed (batched in a single API call). + model_override: Override the embedding model for this call. + fallback_order: Override the backend fallback order for this call. + + Returns: + List of float vectors, one per input text, in input order. + + Raises: + RuntimeError: If all eligible backends are exhausted. + """ + order = ( + fallback_order + if fallback_order is not None + else self.config["fallback_order"] + ) + for name in order: + backend = self.config["backends"][name] + if not backend.get("enabled", True): + continue + if backend["type"] != "openai_compat": + continue + + orch_ctx = orch_alloc = None + orch_result = self._try_cf_orch_alloc(backend) + if orch_result is not None: + orch_ctx, orch_alloc = orch_result + backend = {**backend, "base_url": orch_alloc.url + "/v1"} + elif not self._is_reachable(backend["base_url"]): + print(f"[LLMRouter] {name}: unreachable, skipping") + continue + + try: + client = OpenAI( + base_url=backend["base_url"], + api_key=backend.get("api_key") or "any", + ) + model = model_override or backend.get( + "embedding_model", backend["model"] + ) + resp = client.embeddings.create(model=model, input=texts) + print(f"[LLMRouter] embed: used backend {name} ({model})") + return [item.embedding for item in resp.data] + except Exception as e: + print(f"[LLMRouter] {name}: embed error — {e}, trying next") + continue + finally: + if orch_ctx is not None: + try: + orch_ctx.__exit__(None, None, None) + except Exception: + pass + + raise RuntimeError("All LLM backends exhausted for embed()") + # Module-level singleton for convenience _router: LLMRouter | None = None diff --git a/tests/test_llm_router.py b/tests/test_llm_router.py index 8894971..bc48a27 100644 --- a/tests/test_llm_router.py +++ b/tests/test_llm_router.py @@ -11,69 +11,81 @@ def _make_router(config: dict) -> LLMRouter: def test_complete_uses_first_reachable_backend(): - router = _make_router({ - "fallback_order": ["local"], - "backends": { - "local": { - "type": "openai_compat", - "base_url": "http://localhost:11434/v1", - "model": "llama3", - "supports_images": False, - } + router = _make_router( + { + "fallback_order": ["local"], + "backends": { + "local": { + "type": "openai_compat", + "base_url": "http://localhost:11434/v1", + "model": "llama3", + "supports_images": False, + } + }, } - }) + ) mock_client = MagicMock() mock_client.chat.completions.create.return_value = MagicMock( choices=[MagicMock(message=MagicMock(content="hello"))] ) - with patch.object(router, "_is_reachable", return_value=True), \ - patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client): + with ( + patch.object(router, "_is_reachable", return_value=True), + patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client), + ): result = router.complete("say hello") assert result == "hello" def test_complete_falls_back_on_unreachable_backend(): - router = _make_router({ - "fallback_order": ["unreachable", "working"], - "backends": { - "unreachable": { - "type": "openai_compat", - "base_url": "http://nowhere:1/v1", - "model": "x", - "supports_images": False, + router = _make_router( + { + "fallback_order": ["unreachable", "working"], + "backends": { + "unreachable": { + "type": "openai_compat", + "base_url": "http://nowhere:1/v1", + "model": "x", + "supports_images": False, + }, + "working": { + "type": "openai_compat", + "base_url": "http://localhost:11434/v1", + "model": "llama3", + "supports_images": False, + }, }, - "working": { - "type": "openai_compat", - "base_url": "http://localhost:11434/v1", - "model": "llama3", - "supports_images": False, - } } - }) + ) mock_client = MagicMock() mock_client.chat.completions.create.return_value = MagicMock( choices=[MagicMock(message=MagicMock(content="fallback"))] ) + def reachable(url): return "nowhere" not in url - with patch.object(router, "_is_reachable", side_effect=reachable), \ - patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client): + + with ( + patch.object(router, "_is_reachable", side_effect=reachable), + patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client), + ): result = router.complete("test") assert result == "fallback" def test_complete_raises_when_all_backends_exhausted(): - router = _make_router({ - "fallback_order": ["dead"], - "backends": { - "dead": { - "type": "openai_compat", - "base_url": "http://nowhere:1/v1", - "model": "x", - "supports_images": False, - } + router = _make_router( + { + "fallback_order": ["dead"], + "backends": { + "dead": { + "type": "openai_compat", + "base_url": "http://nowhere:1/v1", + "model": "x", + "supports_images": False, + } + }, } - }) + ) with patch.object(router, "_is_reachable", return_value=False): with pytest.raises(RuntimeError, match="exhausted"): router.complete("test") @@ -83,6 +95,123 @@ def test_try_cf_orch_alloc_import_path(): """Verify lazy import points to circuitforge_orch, not circuitforge_core.resources.""" import inspect from circuitforge_core.llm import router as router_module + src = inspect.getsource(router_module.LLMRouter._try_cf_orch_alloc) assert "circuitforge_orch.client" in src assert "circuitforge_core.resources.client" not in src + + +def test_embed_returns_vectors_from_openai_compat_backend(): + router = _make_router( + { + "fallback_order": ["ollama"], + "backends": { + "ollama": { + "type": "openai_compat", + "base_url": "http://localhost:11434/v1", + "model": "mistral:7b", + "embedding_model": "nomic-embed-text", + "supports_images": False, + } + }, + } + ) + mock_client = MagicMock() + mock_client.embeddings.create.return_value = MagicMock( + data=[ + MagicMock(embedding=[0.1, 0.2, 0.3]), + MagicMock(embedding=[0.4, 0.5, 0.6]), + ] + ) + with ( + patch.object(router, "_is_reachable", return_value=True), + patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client), + ): + result = router.embed(["hello world", "fireball rules"]) + + assert result == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] + mock_client.embeddings.create.assert_called_once_with( + model="nomic-embed-text", + input=["hello world", "fireball rules"], + ) + + +def test_embed_uses_chat_model_when_no_embedding_model_configured(): + router = _make_router( + { + "fallback_order": ["ollama"], + "backends": { + "ollama": { + "type": "openai_compat", + "base_url": "http://localhost:11434/v1", + "model": "llama3", + "supports_images": False, + } + }, + } + ) + mock_client = MagicMock() + mock_client.embeddings.create.return_value = MagicMock( + data=[MagicMock(embedding=[0.9, 0.8])] + ) + with ( + patch.object(router, "_is_reachable", return_value=True), + patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client), + ): + router.embed(["test"]) + + call_kwargs = mock_client.embeddings.create.call_args + assert call_kwargs.kwargs["model"] == "llama3" + + +def test_embed_skips_non_openai_compat_backends(): + router = _make_router( + { + "fallback_order": ["anthropic", "ollama"], + "backends": { + "anthropic": { + "type": "anthropic", + "enabled": True, + "model": "claude-haiku-4-5-20251001", + "api_key_env": "ANTHROPIC_API_KEY", + "supports_images": True, + }, + "ollama": { + "type": "openai_compat", + "base_url": "http://localhost:11434/v1", + "model": "nomic-embed-text", + "supports_images": False, + }, + }, + } + ) + mock_client = MagicMock() + mock_client.embeddings.create.return_value = MagicMock( + data=[MagicMock(embedding=[0.1])] + ) + with ( + patch.object(router, "_is_reachable", return_value=True), + patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client), + ): + result = router.embed(["hello"]) + + assert result == [[0.1]] + + +def test_embed_raises_when_all_backends_exhausted(): + router = _make_router( + { + "fallback_order": ["dead"], + "backends": { + "dead": { + "type": "openai_compat", + "base_url": "http://nowhere:1/v1", + "model": "x", + "supports_images": False, + } + }, + } + ) + with patch.object(router, "_is_reachable", return_value=False): + with pytest.raises(RuntimeError, match="exhausted"): + router.embed(["test"]) From 752609248120a20da92360f6a2540435b6b54129 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 16:02:26 -0700 Subject: [PATCH 10/11] fix(llm): strengthen embed skip-verification test; add DEMO_MODE check to embed() --- circuitforge_core/llm/router.py | 5 +++++ tests/test_llm_router.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/circuitforge_core/llm/router.py b/circuitforge_core/llm/router.py index 5846558..593a4c0 100644 --- a/circuitforge_core/llm/router.py +++ b/circuitforge_core/llm/router.py @@ -398,6 +398,11 @@ class LLMRouter: Raises: RuntimeError: If all eligible backends are exhausted. """ + if os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"): + raise RuntimeError( + "AI inference is disabled in the public demo. " + "Run your own instance to use AI features." + ) order = ( fallback_order if fallback_order is not None diff --git a/tests/test_llm_router.py b/tests/test_llm_router.py index bc48a27..0ca8a81 100644 --- a/tests/test_llm_router.py +++ b/tests/test_llm_router.py @@ -189,13 +189,16 @@ def test_embed_skips_non_openai_compat_backends(): mock_client.embeddings.create.return_value = MagicMock( data=[MagicMock(embedding=[0.1])] ) + mock_openai = MagicMock(return_value=mock_client) with ( patch.object(router, "_is_reachable", return_value=True), - patch("circuitforge_core.llm.router.OpenAI", return_value=mock_client), + patch("circuitforge_core.llm.router.OpenAI", mock_openai), ): result = router.embed(["hello"]) assert result == [[0.1]] + # Only ollama reached the OpenAI constructor; anthropic was skipped by type check + mock_openai.assert_called_once() def test_embed_raises_when_all_backends_exhausted(): From 0ddb3cbf07f28d4af2ed7fd5651b06bd924d27de Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 16:04:48 -0700 Subject: [PATCH 11/11] chore: bump cf-core to v0.19.0 (add pdf, vector, llm.embed) --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d7fc0ea..58522a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "circuitforge-core" -version = "0.18.0" +version = "0.19.0" description = "Shared scaffold for CircuitForge products (MIT)" requires-python = ">=3.11" dependencies = [ @@ -109,12 +109,12 @@ gestures-mediapipe = [ ] pdf = [ "pdfplumber>=0.11", -] -pdf-ocr = [ - "circuitforge-core[pdf]", "pytesseract>=0.3", "Pillow>=10.0", ] +vector = [ + "sqlite-vec>=0.1", +] dev = [ "circuitforge-core[manage]", "pytest>=8.0",