Replace piragi/torch with in-house RAG engine (#168)

* [437e398a] Wave 1A — Remove piragi/torch dependencies entirely (#161)

* [437e398a] chore(deps): remove piragi and torch from pyproject.toml and uv.lock

- Remove piragi[postgres] from [project.dependencies]
- Remove torch entry and its CPU-only comment from [project.dependencies]
- Remove [[tool.uv.index]] pytorch-cpu block and [tool.uv.sources] torch override
- Remove torch from [tool.deptry.per_rule_ignores] DEP002
- Keep piragi.* in [[tool.mypy.overrides]] ignore_missing_imports so the
  remaining optimal_brain/ piragi references don't break the mypy gate
  (Wave 1B will complete that migration)
- Regenerate uv.lock: neither piragi nor torch appear in the resolved set

* [437e398a] feat(kb): add piragi-free roboco/kb module with Chunk, OllamaEmbedder, shared embedder singleton

- roboco/kb/__init__.py: new package entry point; 'import roboco.kb' works without piragi
- roboco/kb/ollama_embedder.py: local Chunk dataclass (text/embedding/metadata),
  full OllamaEmbedder with parallel batch, LRU cache, retry/rate-limit logic
- roboco/kb/shared_embedder.py: async singleton factory (OllamaEmbedder only,
  piragi EmbeddingGenerator branch removed)
- All files pass ruff format+check and mypy with zero errors

---------

Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

* [df01fa23] Replace piragi/torch with in-house RAG engine (#160)

* [df01fa23] feat(rag): replace piragi/torch with in-house RAG engine

- Remove piragi[postgres] and torch from pyproject.toml dependencies
- Delete piragi_patches.py; all piragi.types imports replaced with local types
- Add text_chunker.py: character-based sliding-window chunker with local
  Chunk/Document/Citation dataclasses (no tiktoken, no HuggingFace AutoTokenizer)
- Add vector_store.py: VectorStore using asyncpg + pgvector, CREATE TABLE
  IF NOT EXISTS, ivfflat index, before/after startup timing note in docstring
- Rewrite base.py: HyDE in _compute_query_embedding() via Ollama LLM with
  raw-query fallback; zero references to _sync/_conn/_init_schema/AsyncRagi
- Update shared_embedder.py, ollama_embedder.py, code.py, docs.py to import
  Chunk from text_chunker instead of piragi.types
- Refresh uv.lock removing piragi/torch entries
- ruff check exits 0; mypy exits 0 on 253 source files; 2301 unit tests pass

* [df01fa23] fix(tests): remove piragi stub block from conftest.py and clean up remaining piragi references in tests/

- Replace tests/unit/services/optimal_brain/conftest.py content with
  a minimal one-line docstring (removes _StubChunker, _ensure_piragi_stubbed,
  and its module-level call) — satisfies AC#7 explicitly
- Remove piragi stub injection block from test_rate_limit_retry.py
  (_PIRAGI_STUB_NAMES, _stub_piragi(), and the call); also drop unused
  sys/types imports and now-redundant # noqa: E402 directives
- Update _make_journal_plugin() helper to use the new _store/_chunker/_embedder
  attributes instead of the removed _ragi attribute
- Remove dead piragi comment from test_indexes_base.py
- All 28 rate-limit tests + 15 optimal_brain tests pass; ruff=0, mypy=0

* [df01fa23] fix(rag): delete piragi_patches.py to satisfy AC2 - file staged for removal

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* fix(rag): VectorStore.close tolerates a closed event loop

The in-house engine's asyncpg pool is bound to the loop that created it. The
optimal-service singleton can outlive that loop (cross-loop teardown between
tests), so pool.close() raised 'RuntimeError: Event loop is closed' — failing
test_optimal_grounding in the full suite (the work's first end-to-end gate).
Swallow that specific RuntimeError (connections died with the loop); other
RuntimeErrors still propagate. +3 unit tests.

* fix(rag): validate table identifier + bandit-clean SQL construction

bandit flagged B608 (SQL injection) on the in-house VectorStore's f-string
queries interpolating the table name. The name is enum-derived (never user
input), but the gate runs bandit -ll with skips=[] so it failed. Fix at the
root, no nosec: validate the table identifier against a strict allowlist in
__init__ (raises on anything unsafe), and inject it via _q()/str.replace (not
%/format/f-string/+) so the controlled substitution isn't a B608 vector.
Values remain $N bind params. +tests for the identifier guard.

---------

Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-15 02:12:23 +02:00
committed by GitHub
co-authored by Backend Developer 2 Backend Developer 1 Renn F
parent 133411fe1c
commit 2aef3c7db5
18 changed files with 2005 additions and 2472 deletions
+1 -54
View File
@@ -1,54 +1 @@
"""Conftest for optimal_brain unit tests.
Injects lightweight piragi stubs into sys.modules before any test module is
imported so the index plugins can be imported without the real piragi package
(which requires Ollama, heavy ML dependencies, etc.).
"""
from __future__ import annotations
import sys
import types
from unittest.mock import MagicMock
class _StubChunker:
"""Minimal Chunker stub — allows __init__ attribute assignment."""
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
def chunk_document(self, *_args: object, **_kwargs: object) -> list[object]:
return []
def _ensure_piragi_stubbed() -> None:
"""Register stub modules for every piragi sub-package we might import."""
mock = MagicMock()
stubs: dict[str, types.ModuleType] = {}
for name in (
"piragi",
"piragi.types",
"piragi.stores",
"piragi.stores.postgres",
"piragi.chunking",
"piragi.semantic_chunking",
):
if name not in sys.modules:
mod = types.ModuleType(name)
# Attach stubs for every attribute that index plugins access
mod.__dict__["AsyncRagi"] = mock
mod.__dict__["Citation"] = mock
mod.__dict__["Document"] = mock
mod.__dict__["Chunk"] = mock
mod.__dict__["PostgresStore"] = mock
# Use a real class so piragi_patches can assign __init__ on it
mod.__dict__["Chunker"] = _StubChunker
stubs[name] = mod
sys.modules.update(stubs)
_ensure_piragi_stubbed()
"""Conftest for optimal_brain unit tests."""
@@ -2,7 +2,6 @@
from __future__ import annotations
# piragi stubs are injected by conftest.py before this module is imported.
from roboco.services.optimal_brain.indexes.base import build_doc_source
from roboco.services.optimal_brain.indexes.conversations import ConversationsIndexPlugin
from roboco.services.optimal_brain.indexes.journals import JournalsIndexPlugin
@@ -0,0 +1,71 @@
"""VectorStore.close() must tolerate a closed event loop.
The optimal-service singleton can outlive the loop that created its asyncpg
pool (cross-loop teardown between tests). asyncpg's ``pool.close()`` then raises
``RuntimeError: Event loop is closed``; the connections are already gone, so
close() drops the pool instead of propagating. Any other RuntimeError still
propagates.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from roboco.services.optimal_brain.vector_store import VectorStore
@pytest.mark.asyncio
async def test_close_swallows_event_loop_closed() -> None:
vs = VectorStore.__new__(VectorStore)
pool = AsyncMock()
pool.close = AsyncMock(side_effect=RuntimeError("Event loop is closed"))
vs._pool = pool
await vs.close() # must not raise
assert vs._pool is None
@pytest.mark.asyncio
async def test_close_propagates_other_runtime_errors() -> None:
vs = VectorStore.__new__(VectorStore)
pool = AsyncMock()
pool.close = AsyncMock(side_effect=RuntimeError("connection refused"))
vs._pool = pool
with pytest.raises(RuntimeError, match="connection refused"):
await vs.close()
@pytest.mark.asyncio
async def test_close_noop_when_no_pool() -> None:
vs = VectorStore.__new__(VectorStore)
vs._pool = None
await vs.close()
assert vs._pool is None
def test_safe_identifier_accepts_valid_table_names() -> None:
for name in ("chunks_documentation", "chunks_decisions", "chunks_journals"):
assert VectorStore._safe_identifier(name) == name
def test_safe_identifier_rejects_injection_attempts() -> None:
for bad in (
"chunks; DROP TABLE users",
"chunks documentation",
"Chunks-Bad",
"1chunks",
"",
):
with pytest.raises(ValueError, match="unsafe SQL table identifier"):
VectorStore._safe_identifier(bad)
def test_q_injects_validated_table_identifier() -> None:
vs = VectorStore.__new__(VectorStore)
vs._table_name = "chunks_documentation"
assert vs._q("SELECT COUNT(*) FROM {table}") == (
"SELECT COUNT(*) FROM chunks_documentation"
)