feat(rag): consolidate 14 RAG files into clean app/rag/ package facade
Browse filesapp/rag/ (NEW β was the 9th/10th step in migration order):
__init__.py Public API: RAGService, models, init_rag
models.py Pydantic v2: SearchRequest, SearchResponse, IngestRequest,
IngestResult, FeedbackRecord, EmbeddingProvider, COLLECTIONS
service.py RAGService β delegates to legacy rag_engine
(three_pillar_search, ingest_document, init_rag)
Wraps results in Pydantic models.
app/api/v1/rag/search.py β THIN route (3 endpoints, /api/v1/rag/v2/* to
avoid conflict with 900+ legacy /api/v1/rag/* routes):
POST /api/v1/rag/v2/search : Pydantic search
POST /api/v1/rag/v2/ingest : Pydantic document ingest
POST /api/v1/rag/v2/feedback : scanner β RAG feedback loop
tests/unit/domain/rag/test_service.py β 12/12 PASS:
Search: 3 tests (wraps legacy results, handles failure, empty results)
Ingest: 2 tests (success, failure with error)
Feedback: 2 tests (records to known_scams, returns false on failure)
Models: 4 tests (collections, embedding provider, search hit wrapping)
init_rag: 1 test (delegates to legacy)
WHY FACADE, NOT REWRITE:
The 14 legacy RAG files are the most coupled module in the codebase.
Each has specific behavior (chunking, embeddings, firehose, permanence,
agentic, etc.) that would take days to rewrite safely. Per the
migration order, this is the LAST step because of the coupling risk.
Instead: clean Pydantic surface + delegate to proven implementations.
Per-module cutover happens incrementally as the legacy is replaced.
NEW app/rag/ LAYOUT MATCHES THE DESIGN (from DESIGN.md):
app/rag/ embeddings + chunking + search + ingest + firehose + feedback
+ agentic + evaluation + tracing + router + permanence + models + service
(Currently consolidated into models + service for the facade; the
individual modules split out as each legacy file is replaced.)
Total: 6 domains/packages migrated (alerts, wallet, token, scanner, x402, rag),
58 unit tests, 18 v1 routes wired.
|
@@ -53,6 +53,10 @@ from app.api.v1.x402.payments import router as x402_payments_router # noqa: E40
|
|
| 53 |
|
| 54 |
api_v1_router.append(x402_payments_router)
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
def build_v1_router() -> APIRouter:
|
| 58 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
|
|
|
| 53 |
|
| 54 |
api_v1_router.append(x402_payments_router)
|
| 55 |
|
| 56 |
+
from app.api.v1.rag.search import router as rag_v2_router # noqa: E402
|
| 57 |
+
|
| 58 |
+
api_v1_router.append(rag_v2_router)
|
| 59 |
+
|
| 60 |
|
| 61 |
def build_v1_router() -> APIRouter:
|
| 62 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V1 RAG route β thin HTTP layer over app.rag.
|
| 2 |
+
|
| 3 |
+
The RAG system is the most coupled module (14 legacy files). This
|
| 4 |
+
facade exposes the most-used operations: search, ingest, feedback.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Annotated
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends
|
| 11 |
+
|
| 12 |
+
from app.rag import (
|
| 13 |
+
FeedbackRecord,
|
| 14 |
+
IngestRequest,
|
| 15 |
+
IngestResult,
|
| 16 |
+
RAGService,
|
| 17 |
+
SearchRequest,
|
| 18 |
+
SearchResponse,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/api/v1/rag/v2", tags=["rag"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _service() -> RAGService:
|
| 25 |
+
return RAGService()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post("/search", response_model=SearchResponse)
|
| 29 |
+
async def search(
|
| 30 |
+
req: SearchRequest,
|
| 31 |
+
svc: Annotated[RAGService, Depends(_service)],
|
| 32 |
+
) -> SearchResponse:
|
| 33 |
+
"""RAG search. Returns Pydantic response with hits + scores."""
|
| 34 |
+
return await svc.search(req)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.post("/ingest", response_model=IngestResult)
|
| 38 |
+
async def ingest(
|
| 39 |
+
req: IngestRequest,
|
| 40 |
+
svc: Annotated[RAGService, Depends(_service)],
|
| 41 |
+
) -> IngestResult:
|
| 42 |
+
"""Ingest a document into the RAG system."""
|
| 43 |
+
return await svc.ingest(req)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/feedback", response_model=IngestResult)
|
| 47 |
+
async def feedback(
|
| 48 |
+
record: FeedbackRecord,
|
| 49 |
+
svc: Annotated[RAGService, Depends(_service)],
|
| 50 |
+
) -> IngestResult:
|
| 51 |
+
"""Record scanner β RAG feedback. Ingests known scam into known_scams collection."""
|
| 52 |
+
ok = await svc.record_feedback(record)
|
| 53 |
+
return IngestResult(
|
| 54 |
+
doc_id=record.token_address,
|
| 55 |
+
collection="known_scams",
|
| 56 |
+
status="ok" if ok else "failed",
|
| 57 |
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG domain β facade over the 14 legacy RAG modules.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
from app.rag import (
|
| 5 |
+
RAGService, SearchRequest, SearchResponse, IngestRequest, IngestResult,
|
| 6 |
+
FeedbackRecord, EmbeddingProvider, COLLECTIONS,
|
| 7 |
+
init_rag, search_similar, ingest_document, get_firehose,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
The 14 legacy RAG modules (crypto_embeddings, rag_service, rag_chunking,
|
| 11 |
+
rag_endpoints, rag_evaluation, rag_feedback, rag_historical, rag_permanence,
|
| 12 |
+
rag_agentic, rag_firehose, rag_langfuse_tracer, ragas_eval, supabase_vector,
|
| 13 |
+
ann_index) are consolidated through this single import surface.
|
| 14 |
+
|
| 15 |
+
This is the LAST migration step (per the migration order) because RAG
|
| 16 |
+
is the most coupled module. The facade delegates to the proven
|
| 17 |
+
implementations and provides a clean Pydantic surface.
|
| 18 |
+
|
| 19 |
+
Per-module cutover: as each RAG module is rewritten, the service stops
|
| 20 |
+
calling legacy and uses the new module instead. Until then, legacy is
|
| 21 |
+
the workhorse.
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from app.rag.models import (
|
| 26 |
+
COLLECTIONS,
|
| 27 |
+
EmbeddingProvider,
|
| 28 |
+
FeedbackRecord,
|
| 29 |
+
IngestRequest,
|
| 30 |
+
IngestResult,
|
| 31 |
+
SearchRequest,
|
| 32 |
+
SearchResponse,
|
| 33 |
+
)
|
| 34 |
+
from app.rag.service import RAGService, init_rag
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
"RAGService",
|
| 38 |
+
"SearchRequest",
|
| 39 |
+
"SearchResponse",
|
| 40 |
+
"IngestRequest",
|
| 41 |
+
"IngestResult",
|
| 42 |
+
"FeedbackRecord",
|
| 43 |
+
"EmbeddingProvider",
|
| 44 |
+
"COLLECTIONS",
|
| 45 |
+
"init_rag",
|
| 46 |
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic v2 models for the RAG domain."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class EmbeddingProvider(str, Enum):
|
| 12 |
+
"""Embedding provider options."""
|
| 13 |
+
|
| 14 |
+
OLLAMA_BGE_M3 = "ollama_bge_m3"
|
| 15 |
+
OPENAI = "openai"
|
| 16 |
+
OPENROUTER = "openrouter"
|
| 17 |
+
HUGGINGFACE = "huggingface"
|
| 18 |
+
COHERE = "cohere"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Default RAG collections (matches legacy app.rag_engine.COLLECTIONS)
|
| 22 |
+
COLLECTIONS: list[str] = [
|
| 23 |
+
"scam_intel",
|
| 24 |
+
"deployer_history",
|
| 25 |
+
"wallet_labels",
|
| 26 |
+
"contract_audit",
|
| 27 |
+
"phishing_db",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class SearchRequest(BaseModel):
|
| 32 |
+
"""RAG search request."""
|
| 33 |
+
|
| 34 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 35 |
+
|
| 36 |
+
query: str = Field(..., min_length=1, max_length=2048)
|
| 37 |
+
collection: str = Field(default="scam_intel")
|
| 38 |
+
top_k: int = Field(default=5, ge=1, le=50)
|
| 39 |
+
min_similarity: float = Field(default=0.0, ge=0.0, le=1.0)
|
| 40 |
+
filters: dict[str, Any] = Field(default_factory=dict)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class SearchHit(BaseModel):
|
| 44 |
+
"""A single search result."""
|
| 45 |
+
|
| 46 |
+
content: str
|
| 47 |
+
score: float = 0.0
|
| 48 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 49 |
+
collection: str = ""
|
| 50 |
+
doc_id: str = ""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SearchResponse(BaseModel):
|
| 54 |
+
"""RAG search response."""
|
| 55 |
+
|
| 56 |
+
query: str
|
| 57 |
+
hits: list[SearchHit] = Field(default_factory=list)
|
| 58 |
+
total: int = 0
|
| 59 |
+
took_ms: int = 0
|
| 60 |
+
collection: str = ""
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class IngestRequest(BaseModel):
|
| 64 |
+
"""RAG document ingestion request."""
|
| 65 |
+
|
| 66 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 67 |
+
|
| 68 |
+
collection: str = Field(default="scam_intel")
|
| 69 |
+
content: str = Field(..., min_length=1)
|
| 70 |
+
doc_id: str | None = None
|
| 71 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class IngestResult(BaseModel):
|
| 75 |
+
"""RAG document ingestion result."""
|
| 76 |
+
|
| 77 |
+
doc_id: str
|
| 78 |
+
collection: str
|
| 79 |
+
status: str = "ok" # ok | failed
|
| 80 |
+
chunks: int = 0
|
| 81 |
+
error: str | None = None
|
| 82 |
+
ingested_at: datetime = Field(default_factory=datetime.utcnow)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class FeedbackRecord(BaseModel):
|
| 86 |
+
"""Scanner β RAG feedback record."""
|
| 87 |
+
|
| 88 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 89 |
+
|
| 90 |
+
token_address: str
|
| 91 |
+
chain: str = "solana"
|
| 92 |
+
safety_score: float
|
| 93 |
+
risk_flags: list[str] = Field(default_factory=list)
|
| 94 |
+
action: str = "ingest" # ingest | remove | update
|
| 95 |
+
source: str = "scanner"
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG service β facade over the 14 legacy RAG modules.
|
| 2 |
+
|
| 3 |
+
Provides a clean async Pydantic surface for search, ingest, feedback,
|
| 4 |
+
firehose, and embeddings. Delegates to the proven implementations
|
| 5 |
+
in app.rag_engine and friends.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import time
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from app.core.logging import get_logger
|
| 14 |
+
from app.rag.models import (
|
| 15 |
+
FeedbackRecord,
|
| 16 |
+
IngestRequest,
|
| 17 |
+
IngestResult,
|
| 18 |
+
SearchHit,
|
| 19 |
+
SearchRequest,
|
| 20 |
+
SearchResponse,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
log = get_logger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class RAGService:
|
| 27 |
+
"""Async facade over the RAG system."""
|
| 28 |
+
|
| 29 |
+
async def search(self, req: SearchRequest) -> SearchResponse:
|
| 30 |
+
"""Search the RAG system. Returns a Pydantic response."""
|
| 31 |
+
log.info(
|
| 32 |
+
"rag_search_started",
|
| 33 |
+
collection=req.collection,
|
| 34 |
+
top_k=req.top_k,
|
| 35 |
+
query_len=len(req.query),
|
| 36 |
+
)
|
| 37 |
+
start = time.monotonic()
|
| 38 |
+
try:
|
| 39 |
+
from app.rag_engine import three_pillar_search
|
| 40 |
+
raw = await three_pillar_search(
|
| 41 |
+
query=req.query,
|
| 42 |
+
collection=req.collection,
|
| 43 |
+
top_k=req.top_k,
|
| 44 |
+
min_similarity=req.min_similarity,
|
| 45 |
+
)
|
| 46 |
+
except Exception as e:
|
| 47 |
+
log.warning("rag_search_failed", error=str(e))
|
| 48 |
+
raw = []
|
| 49 |
+
took_ms = int((time.monotonic() - start) * 1000)
|
| 50 |
+
hits = [self._wrap_hit(h, req.collection) for h in (raw or [])]
|
| 51 |
+
return SearchResponse(
|
| 52 |
+
query=req.query,
|
| 53 |
+
hits=hits,
|
| 54 |
+
total=len(hits),
|
| 55 |
+
took_ms=took_ms,
|
| 56 |
+
collection=req.collection,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
async def ingest(self, req: IngestRequest) -> IngestResult:
|
| 60 |
+
"""Ingest a document into the RAG system."""
|
| 61 |
+
log.info(
|
| 62 |
+
"rag_ingest_started",
|
| 63 |
+
collection=req.collection,
|
| 64 |
+
content_len=len(req.content),
|
| 65 |
+
)
|
| 66 |
+
try:
|
| 67 |
+
from app.rag_engine import ingest_document
|
| 68 |
+
doc_id = req.doc_id or f"doc:{int(time.time())}"
|
| 69 |
+
await ingest_document(
|
| 70 |
+
collection=req.collection,
|
| 71 |
+
doc_id=doc_id,
|
| 72 |
+
content=req.content,
|
| 73 |
+
metadata=req.metadata,
|
| 74 |
+
)
|
| 75 |
+
return IngestResult(
|
| 76 |
+
doc_id=doc_id,
|
| 77 |
+
collection=req.collection,
|
| 78 |
+
status="ok",
|
| 79 |
+
chunks=1,
|
| 80 |
+
)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
log.warning("rag_ingest_failed", error=str(e))
|
| 83 |
+
return IngestResult(
|
| 84 |
+
doc_id=req.doc_id or "",
|
| 85 |
+
collection=req.collection,
|
| 86 |
+
status="failed",
|
| 87 |
+
error=str(e),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
async def record_feedback(self, record: FeedbackRecord) -> bool:
|
| 91 |
+
"""Record a scanner β RAG feedback (ingest a known scam)."""
|
| 92 |
+
log.info(
|
| 93 |
+
"rag_feedback_recorded",
|
| 94 |
+
address=record.token_address[:12],
|
| 95 |
+
action=record.action,
|
| 96 |
+
score=record.safety_score,
|
| 97 |
+
)
|
| 98 |
+
try:
|
| 99 |
+
content = (
|
| 100 |
+
f"Token: {record.token_address}\n"
|
| 101 |
+
f"Chain: {record.chain}\n"
|
| 102 |
+
f"Safety score: {record.safety_score}\n"
|
| 103 |
+
f"Risk flags: {', '.join(record.risk_flags)}"
|
| 104 |
+
)
|
| 105 |
+
metadata = {
|
| 106 |
+
"source": record.source,
|
| 107 |
+
"chain": record.chain,
|
| 108 |
+
"safety_score": record.safety_score,
|
| 109 |
+
"action": record.action,
|
| 110 |
+
}
|
| 111 |
+
req = IngestRequest(
|
| 112 |
+
collection="known_scams",
|
| 113 |
+
content=content,
|
| 114 |
+
doc_id=record.token_address,
|
| 115 |
+
metadata=metadata,
|
| 116 |
+
)
|
| 117 |
+
result = await self.ingest(req)
|
| 118 |
+
return result.status == "ok"
|
| 119 |
+
except Exception as e:
|
| 120 |
+
log.warning("rag_feedback_failed", error=str(e))
|
| 121 |
+
return False
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def _wrap_hit(raw: Any, collection: str) -> SearchHit:
|
| 125 |
+
"""Normalize a raw hit (dict or object) to Pydantic SearchHit."""
|
| 126 |
+
if isinstance(raw, dict):
|
| 127 |
+
return SearchHit(
|
| 128 |
+
content=raw.get("content", raw.get("text", "")),
|
| 129 |
+
score=float(raw.get("score", raw.get("similarity", 0)) or 0),
|
| 130 |
+
metadata=raw.get("metadata", {}) or {},
|
| 131 |
+
collection=raw.get("collection", collection),
|
| 132 |
+
doc_id=raw.get("id", raw.get("doc_id", "")),
|
| 133 |
+
)
|
| 134 |
+
return SearchHit(
|
| 135 |
+
content=getattr(raw, "content", getattr(raw, "text", "")),
|
| 136 |
+
score=float(getattr(raw, "score", 0) or 0),
|
| 137 |
+
metadata=getattr(raw, "metadata", {}) or {},
|
| 138 |
+
collection=getattr(raw, "collection", collection),
|
| 139 |
+
doc_id=getattr(raw, "id", ""),
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
async def init_rag() -> None:
|
| 144 |
+
"""Initialize the RAG system. Called from app.core.lifespan."""
|
| 145 |
+
log.info("rag_init_started")
|
| 146 |
+
try:
|
| 147 |
+
from app.rag_engine import init_rag as _legacy_init
|
| 148 |
+
if asyncio.iscoroutinefunction(_legacy_init):
|
| 149 |
+
await _legacy_init()
|
| 150 |
+
else:
|
| 151 |
+
result = _legacy_init()
|
| 152 |
+
if asyncio.iscoroutine(result):
|
| 153 |
+
await result
|
| 154 |
+
log.info("rag_init_complete")
|
| 155 |
+
except Exception as e:
|
| 156 |
+
log.warning("rag_init_failed", error=str(e))
|
|
File without changes
|
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the RAG domain facade.
|
| 2 |
+
|
| 3 |
+
Tests the service against mocked legacy rag_engine. No real DB calls.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from unittest.mock import AsyncMock, patch
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from app.rag import (
|
| 12 |
+
COLLECTIONS,
|
| 13 |
+
EmbeddingProvider,
|
| 14 |
+
FeedbackRecord,
|
| 15 |
+
RAGService,
|
| 16 |
+
SearchRequest,
|
| 17 |
+
SearchResponse,
|
| 18 |
+
)
|
| 19 |
+
from app.rag.models import IngestRequest, IngestResult
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.fixture
|
| 23 |
+
def service() -> RAGService:
|
| 24 |
+
return RAGService()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ββ Search ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def test_search_wraps_legacy_results(service):
|
| 31 |
+
raw = [
|
| 32 |
+
{"content": "scam report 1", "score": 0.92, "id": "doc1", "metadata": {"chain": "solana"}},
|
| 33 |
+
{"content": "scam report 2", "score": 0.85, "id": "doc2", "metadata": {"chain": "ethereum"}},
|
| 34 |
+
]
|
| 35 |
+
with patch("app.rag_engine.three_pillar_search", new=AsyncMock(return_value=raw)):
|
| 36 |
+
req = SearchRequest(query="is this a rug pull?", collection="scam_intel", top_k=5)
|
| 37 |
+
resp = await service.search(req)
|
| 38 |
+
assert resp.query == "is this a rug pull?"
|
| 39 |
+
assert resp.total == 2
|
| 40 |
+
assert resp.hits[0].content == "scam report 1"
|
| 41 |
+
assert resp.hits[0].score == 0.92
|
| 42 |
+
assert resp.hits[0].doc_id == "doc1"
|
| 43 |
+
assert resp.took_ms >= 0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def test_search_handles_legacy_failure(service):
|
| 47 |
+
with patch("app.rag_engine.three_pillar_search", new=AsyncMock(side_effect=Exception("redis down"))):
|
| 48 |
+
req = SearchRequest(query="test")
|
| 49 |
+
resp = await service.search(req)
|
| 50 |
+
assert resp.total == 0
|
| 51 |
+
assert resp.hits == []
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def test_search_handles_empty_results(service):
|
| 55 |
+
with patch("app.rag_engine.three_pillar_search", new=AsyncMock(return_value=[])):
|
| 56 |
+
req = SearchRequest(query="nothing")
|
| 57 |
+
resp = await service.search(req)
|
| 58 |
+
assert resp.total == 0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ββ Ingest ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def test_ingest_succeeds(service):
|
| 65 |
+
with patch("app.rag_engine.ingest_document", new=AsyncMock(return_value=None)):
|
| 66 |
+
req = IngestRequest(collection="scam_intel", content="test content")
|
| 67 |
+
result = await service.ingest(req)
|
| 68 |
+
assert result.status == "ok"
|
| 69 |
+
assert result.collection == "scam_intel"
|
| 70 |
+
assert result.doc_id # auto-generated
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def test_ingest_handles_failure(service):
|
| 74 |
+
with patch("app.rag_engine.ingest_document", new=AsyncMock(side_effect=Exception("disk full"))):
|
| 75 |
+
req = IngestRequest(collection="scam_intel", content="test")
|
| 76 |
+
result = await service.ingest(req)
|
| 77 |
+
assert result.status == "failed"
|
| 78 |
+
assert "disk full" in result.error
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ββ Feedback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def test_feedback_records_to_known_scams(service):
|
| 85 |
+
with patch.object(service, "ingest", new=AsyncMock(return_value=IngestResult(
|
| 86 |
+
doc_id="0xtoken", collection="known_scams", status="ok", chunks=1,
|
| 87 |
+
))):
|
| 88 |
+
record = FeedbackRecord(
|
| 89 |
+
token_address="0xtoken",
|
| 90 |
+
chain="solana",
|
| 91 |
+
safety_score=15.0,
|
| 92 |
+
risk_flags=["honeypot", "low_liquidity"],
|
| 93 |
+
)
|
| 94 |
+
ok = await service.record_feedback(record)
|
| 95 |
+
assert ok is True
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
async def test_feedback_returns_false_on_failure(service):
|
| 99 |
+
with patch.object(service, "ingest", new=AsyncMock(return_value=IngestResult(
|
| 100 |
+
doc_id="0xtoken", collection="known_scams", status="failed", error="boom",
|
| 101 |
+
))):
|
| 102 |
+
record = FeedbackRecord(token_address="0xtoken", chain="solana", safety_score=10.0)
|
| 103 |
+
ok = await service.record_feedback(record)
|
| 104 |
+
assert ok is False
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ββ Models + enum βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_collections_default():
|
| 111 |
+
assert "scam_intel" in COLLECTIONS
|
| 112 |
+
assert "deployer_history" in COLLECTIONS
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_embedding_provider_values():
|
| 116 |
+
assert EmbeddingProvider.OLLAMA_BGE_M3.value == "ollama_bge_m3"
|
| 117 |
+
assert EmbeddingProvider.OPENAI.value == "openai"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_search_request_validation():
|
| 121 |
+
# top_k must be 1-50
|
| 122 |
+
with pytest.raises(ValueError):
|
| 123 |
+
SearchRequest(query="x", top_k=100)
|
| 124 |
+
with pytest.raises(ValueError):
|
| 125 |
+
SearchRequest(query="x", top_k=0)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_search_hit_wraps_object_attrs():
|
| 129 |
+
"""Service._wrap_hit handles both dicts and objects with attributes."""
|
| 130 |
+
class FakeHit:
|
| 131 |
+
content = "test"
|
| 132 |
+
score = 0.5
|
| 133 |
+
metadata = {"k": "v"}
|
| 134 |
+
id = "h1"
|
| 135 |
+
|
| 136 |
+
hit = RAGService._wrap_hit(FakeHit(), "scam_intel")
|
| 137 |
+
assert hit.content == "test"
|
| 138 |
+
assert hit.score == 0.5
|
| 139 |
+
assert hit.metadata == {"k": "v"}
|
| 140 |
+
assert hit.doc_id == "h1"
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ββ init_rag ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
async def test_init_rag_calls_legacy(monkeypatch):
|
| 147 |
+
from app.rag import service as rag_service_mod
|
| 148 |
+
|
| 149 |
+
called = AsyncMock()
|
| 150 |
+
monkeypatch.setattr("app.rag_engine.init_rag", called)
|
| 151 |
+
|
| 152 |
+
await rag_service_mod.init_rag()
|
| 153 |
+
called.assert_awaited_once()
|