- 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>
171 lines
5.8 KiB
Python
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))
|