rmi-backend/app/rag/service.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

171 lines
5.8 KiB
Python

"""RAG service - HTTP facade over the v3 RAG engine.
Provides a clean async Pydantic surface for search, ingest, feedback.
Delegates to the M3 engine in app.rag.engine (built per v3 unfuck plan).
"""
from __future__ import annotations
import time
from typing import Any
from app.core.logging import get_logger
from app.rag.engine import (
get_stats as engine_get_stats,
)
from app.rag.engine import (
ingest_document as engine_ingest_document,
)
from app.rag.engine import (
three_pillar_search as engine_three_pillar_search,
)
from app.rag.models import (
FeedbackRecord,
IngestRequest,
IngestResult,
SearchHit,
SearchRequest,
SearchResponse,
)
log = get_logger(__name__)
class RAGService:
"""Async facade over the v3 RAG engine (M3 deliverable)."""
async def search(self, req: SearchRequest) -> SearchResponse:
"""Search the RAG system. Returns a Pydantic response."""
log.info(
"rag_search_started",
collection=req.collection,
top_k=req.top_k,
query_len=len(req.query),
)
start = time.monotonic()
try:
raw = await engine_three_pillar_search(
query=req.query,
collection=req.collection,
top_k=req.top_k,
min_similarity=req.min_similarity,
filters=req.filters or None,
)
except Exception as e:
log.warning("rag_search_failed", error=str(e))
raw = []
took_ms = int((time.monotonic() - start) * 1000)
hits = [self._wrap_hit(h, req.collection) for h in (raw or [])]
return SearchResponse(
query=req.query,
hits=hits,
total=len(hits),
took_ms=took_ms,
collection=req.collection,
)
async def ingest(self, req: IngestRequest) -> IngestResult:
"""Ingest a document into the RAG system."""
log.info(
"rag_ingest_started",
collection=req.collection,
content_len=len(req.content),
)
try:
doc_id = req.doc_id or f"doc:{int(time.time()*1000)}"
r = await engine_ingest_document(
collection=req.collection,
doc_id=doc_id,
content=req.content,
metadata=req.metadata,
)
return IngestResult(
doc_id=doc_id,
collection=req.collection,
status=r.get("status", "ok"),
chunks=r.get("chunks", 0),
)
except Exception as e:
log.warning("rag_ingest_failed", error=str(e))
return IngestResult(
doc_id=req.doc_id or "",
collection=req.collection,
status="failed",
error=str(e),
)
async def stats(self) -> dict:
"""Return engine stats (collection counts + active embedder backend)."""
try:
return engine_get_stats()
except Exception as e:
log.warning("rag_stats_failed", error=str(e))
return {"total_docs": 0, "backend": "unknown", "collections": [], "error": str(e)}
async def record_feedback(self, record: FeedbackRecord) -> bool:
"""Record a scanner → RAG feedback (ingest a known scam)."""
log.info(
"rag_feedback_recorded",
address=record.token_address[:12],
action=record.action,
score=record.safety_score,
)
try:
content = (
f"Token: {record.token_address}\n"
f"Chain: {record.chain}\n"
f"Safety score: {record.safety_score}\n"
f"Risk flags: {', '.join(record.risk_flags)}"
)
metadata = {
"source": record.source,
"chain": record.chain,
"safety_score": record.safety_score,
"action": record.action,
}
req = IngestRequest(
collection="known_scams",
content=content,
doc_id=record.token_address,
metadata=metadata,
)
result = await self.ingest(req)
return result.status == "ok"
except Exception as e:
log.warning("rag_feedback_failed", error=str(e))
return False
@staticmethod
def _wrap_hit(raw: Any, collection: str) -> SearchHit:
"""Normalize a raw hit (dict or object) to Pydantic SearchHit."""
if isinstance(raw, dict):
return SearchHit(
content=raw.get("content", raw.get("text", "")),
score=float(raw.get("score", raw.get("similarity", 0)) or 0),
metadata=raw.get("metadata", {}) or {},
collection=raw.get("collection", collection),
doc_id=raw.get("id", raw.get("doc_id", "")),
)
return SearchHit(
content=getattr(raw, "content", getattr(raw, "text", "")),
score=float(getattr(raw, "score", 0) or 0),
metadata=getattr(raw, "metadata", {}) or {},
collection=getattr(raw, "collection", collection),
doc_id=getattr(raw, "id", ""),
)
async def init_rag() -> None:
"""Initialize the RAG system. Called from app.core.lifespan.
v3: no-op (the new engine is lazy - collections load on first search).
Kept for backward compat with lifespan hooks.
"""
log.info("rag_init_started", note="v3 engine is lazy - no warmup needed")
try:
# Touch the redis client to fail fast if misconfigured
from app.core.redis import get_redis
get_redis().ping()
log.info("rag_init_complete")
except Exception as e:
log.warning("rag_init_redis_probe_failed", error=str(e))