| |
| """ |
| RAG System Test Suite |
| ===================== |
| Tests all core RAG modules: embeddings, ingestion, search, vector store, |
| entity extraction, temporal decay, contextual chunking, hallucination guard. |
| |
| NOTE: This suite uses a custom @test() decorator + run_tests() runner. |
| Do NOT run with pytest β it will produce false failures (pytest-asyncio |
| auto-collects these functions but they aren't standard pytest test items). |
| |
| Correct runner: |
| docker exec rmi-backend python tests/test_rag.py |
| |
| A pytest.ini in /root/backend/ disables auto-collection to prevent |
| accidental pytest runs from reporting failures. |
| """ |
|
|
| import asyncio |
| import os |
| import sys |
| from datetime import UTC |
|
|
| |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) |
|
|
|
|
| |
| |
| |
|
|
| _results = [] |
| _current = None |
|
|
|
|
| def test(name): |
| """Decorator to mark a test function.""" |
|
|
| def decorator(fn): |
| if asyncio.iscoroutinefunction(fn): |
| _results.append((name, fn, True)) |
| else: |
| _results.append((name, fn, False)) |
| return fn |
|
|
| return decorator |
|
|
|
|
| async def run_tests(): |
| """Run all registered tests and print results.""" |
| passed = 0 |
| failed = 0 |
| errors = [] |
|
|
| for name, fn, is_async in _results: |
| try: |
| if is_async: |
| await fn() |
| else: |
| fn() |
| passed += 1 |
| print(f" PASS {name}") |
| except Exception as e: |
| failed += 1 |
| errors.append((name, str(e))) |
| print(f" FAIL {name}: {e}") |
|
|
| print(f"\n{'=' * 60}") |
| print(f" Results: {passed} passed, {failed} failed, {passed + failed} total") |
| if errors: |
| print("\n Failures:") |
| for name, err in errors: |
| print(f" - {name}: {err[:80]}") |
| print(f"{'=' * 60}") |
| return failed == 0 |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("extract_contract_features: returns 128-dim vector") |
| def test_contract_features_dims(): |
| from app.crypto_embeddings import extract_contract_features |
|
|
| vec = extract_contract_features("pragma solidity; function mint() external onlyOwner {}") |
| assert len(vec) == 128, f"Expected 128 dims, got {len(vec)}" |
| assert vec.dtype.name.startswith("float32"), f"Expected float32, got {vec.dtype}" |
|
|
|
|
| @test("extract_contract_features: detects rug patterns") |
| def test_contract_features_rug_detection(): |
| from app.crypto_embeddings import extract_contract_features |
|
|
| rug_code = "function mint(address to, uint256 amount) external onlyOwner { maxTxAmount = 0; }" |
| vec = extract_contract_features(rug_code) |
| |
| rug_dims = [i for i, v in enumerate(vec[:56]) if v > 0] |
| |
| assert len(rug_dims) >= 1, "No rug pattern dims active in first 56 dims" |
| |
| struct_dims = [i for i in range(32, 56) if vec[i] > 0] |
| assert len(struct_dims) >= 1, f"No structural dims active: {struct_dims}" |
|
|
|
|
| @test("extract_contract_features: empty input returns zeros") |
| def test_contract_features_empty(): |
| from app.crypto_embeddings import extract_contract_features |
|
|
| vec = extract_contract_features("") |
| assert len(vec) == 128 |
| assert all(v == 0.0 for v in vec), "Empty input should produce zero vector" |
|
|
|
|
| @test("extract_transaction_features: returns 64-dim vector") |
| def test_transaction_features_dims(): |
| from app.crypto_embeddings import extract_transaction_features |
|
|
| vec = extract_transaction_features({"transactions": []}) |
| assert len(vec) == 64, f"Expected 64 dims, got {len(vec)}" |
|
|
|
|
| @test("extract_transaction_features: captures counterparty diversity") |
| def test_transaction_features_counterparty(): |
| from app.crypto_embeddings import extract_transaction_features |
|
|
| txs = [ |
| {"amount": 100, "from": "0xA", "to": "0xB", "timestamp": 1000}, |
| {"amount": 200, "from": "0xB", "to": "0xC", "timestamp": 2000}, |
| ] |
| vec = extract_transaction_features({"transactions": txs}) |
| |
| assert vec[20] > 0, "Counterparty diversity should be > 0" |
|
|
|
|
| @test("extract_wallet_features: returns 64-dim vector") |
| def test_wallet_features_dims(): |
| from app.crypto_embeddings import extract_wallet_features |
|
|
| vec = extract_wallet_features({"labels": ["scammer"], "balance_usd": 5000}) |
| assert len(vec) == 64, f"Expected 64 dims, got {len(vec)}" |
|
|
|
|
| @test("extract_wallet_features: detects risk labels") |
| def test_wallet_features_labels(): |
| from app.crypto_embeddings import extract_wallet_features |
|
|
| vec = extract_wallet_features({"labels": ["scammer", "rug_puller"]}) |
| |
| assert vec[20] == 1.0, "Scammer label not detected at dim 20" |
| assert vec[21] == 1.0, "Rug_puller label not detected at dim 21" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("cosine_similarity: identical vectors = 1.0") |
| def test_cosine_same(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| v = [1.0, 0.0, 0.5, 0.3] |
| sim = CryptoEmbedder.cosine_similarity(v, v) |
| assert abs(sim - 1.0) < 0.001, f"Same vector sim={sim}" |
|
|
|
|
| @test("cosine_similarity: orthogonal vectors = 0.0") |
| def test_cosine_orthogonal(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| a = [1.0, 0.0, 0.0] |
| b = [0.0, 1.0, 0.0] |
| sim = CryptoEmbedder.cosine_similarity(a, b) |
| assert abs(sim) < 0.001, f"Orthogonal sim={sim}" |
|
|
|
|
| @test("cosine_similarity: opposite vectors = -1.0") |
| def test_cosine_opposite(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| a = [1.0, 0.0] |
| b = [-1.0, 0.0] |
| sim = CryptoEmbedder.cosine_similarity(a, b) |
| assert abs(sim - (-1.0)) < 0.001, f"Opposite sim={sim}" |
|
|
|
|
| @test("cosine_similarity: zero vector = 0.0") |
| def test_cosine_zero(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| sim = CryptoEmbedder.cosine_similarity([0, 0, 0], [1, 0, 0]) |
| assert sim == 0.0, f"Zero vector sim={sim}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("hash_embed: returns 384-dim normalized vector") |
| def test_hash_embed(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| embedder = CryptoEmbedder() |
| vec = embedder._hash_embed("test wallet scam pattern") |
| assert len(vec) == 384, f"Expected 384 dims, got {len(vec)}" |
| |
| import numpy as np |
|
|
| norm = np.linalg.norm(vec) |
| assert abs(norm - 1.0) < 0.01 or norm == 0.0, f"Norm={norm}" |
|
|
|
|
| @test("hash_embed: deterministic (same input = same output)") |
| def test_hash_embed_deterministic(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| embedder = CryptoEmbedder() |
| v1 = embedder._hash_embed("rug pull honeypot") |
| v2 = embedder._hash_embed("rug pull honeypot") |
| assert v1 == v2, "Hash embedding not deterministic" |
|
|
|
|
| @test("hash_embed: different inputs = different outputs") |
| def test_hash_embed_unique(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| embedder = CryptoEmbedder() |
| v1 = embedder._hash_embed("rug pull") |
| v2 = embedder._hash_embed("wash trading") |
| assert v1 != v2, "Different inputs produced same hash embedding" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("embed_bundle_profile: returns 128-dim vector") |
| def test_bundle_profile(): |
| from app.bundle_cluster_rag import embed_bundle_profile |
|
|
| bundle = { |
| "confidence": 0.8, |
| "atomic_block_score": 0.9, |
| "common_funder_score": 0.7, |
| "wallets_in_earliest_block": 15, |
| "chain": "solana", |
| } |
| vec = embed_bundle_profile(bundle) |
| assert len(vec) == 128, f"Expected 128 dims, got {len(vec)}" |
|
|
|
|
| @test("embed_bundle_profile: metrics at dims 80-85 NOT overwritten by hash") |
| def test_bundle_metrics_not_clobbered(): |
| from app.bundle_cluster_rag import embed_bundle_profile |
|
|
| bundle = { |
| "confidence": 0.9, |
| "avg_buy_amount": 5000, |
| "max_buy_amount": 10000, |
| "profit_ratio": 5.0, |
| "chain": "ethereum", |
| } |
| vec = embed_bundle_profile(bundle) |
| |
| assert vec[80] > 0, f"Metric at dim 80 is zero (clobbered by hash): {vec[80]}" |
| assert vec[81] > 0, f"Metric at dim 81 is zero (clobbered by hash): {vec[81]}" |
|
|
|
|
| @test("embed_cluster_profile: returns 192-dim vector") |
| def test_cluster_profile(): |
| from app.bundle_cluster_rag import embed_cluster_profile |
|
|
| cluster = { |
| "size": 50, |
| "density": 0.8, |
| "total_volume_usd": 1000000, |
| "scam_probability": 0.9, |
| "active_chains": ["ethereum"], |
| } |
| vec = embed_cluster_profile(cluster) |
| assert len(vec) == 192, f"Expected 192 dims, got {len(vec)}" |
|
|
|
|
| @test("embed_cluster_profile: risk scoring at dims 80-85") |
| def test_cluster_risk_scoring(): |
| from app.bundle_cluster_rag import embed_cluster_profile |
|
|
| cluster = {"scam_probability": 0.9, "rug_probability": 0.8} |
| vec = embed_cluster_profile(cluster) |
| assert abs(vec[80] - 0.9) < 0.01, f"Scam prob at dim 80 wrong: {vec[80]}" |
| assert abs(vec[81] - 0.8) < 0.01, f"Rug prob at dim 81 wrong: {vec[81]}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("extract_entities: EVM address detection") |
| def test_entity_evm(): |
| from app.entity_extraction import extract_entities |
|
|
| result = extract_entities("Send funds to 0xdAC17F958D2ee523a2206206994597C13D831ec7") |
| assert "0xdac17f958d2ee523a2206206994597c13d831ec7" in [a.lower() for a in result.evm_addresses], ( |
| f"EVM address not found: {result.evm_addresses}" |
| ) |
|
|
|
|
| @test("extract_entities: token symbol detection") |
| def test_entity_symbol(): |
| from app.entity_extraction import extract_entities |
|
|
| result = extract_entities("Price of $ETH and $SOL surging") |
| symbols_upper = [s.upper() for s in result.token_symbols] |
| assert "ETH" in symbols_upper or "$ETH" in result.token_symbols, f"ETH not found: {result.token_symbols}" |
| assert "SOL" in symbols_upper or "$SOL" in result.token_symbols, f"SOL not found: {result.token_symbols}" |
|
|
|
|
| @test("extract_entities: chain name detection") |
| def test_entity_chain(): |
| from app.entity_extraction import extract_entities |
|
|
| result = extract_entities("Bridged from Ethereum to Solana") |
| assert "ethereum" in result.chain_names, f"Ethereum not found: {result.chain_names}" |
| assert "solana" in result.chain_names, f"Solana not found: {result.chain_names}" |
|
|
|
|
| @test("extract_entities: scam keyword detection") |
| def test_entity_scam(): |
| from app.entity_extraction import extract_entities |
|
|
| result = extract_entities("This token is a rug pull honeypot") |
| assert "rug pull" in result.scam_keywords, f"rug pull not found: {result.scam_keywords}" |
|
|
|
|
| @test("extract_entities: ENS domain detection") |
| def test_entity_ens(): |
| from app.entity_extraction import extract_entities |
|
|
| result = extract_entities("Send to vitalik.eth") |
| assert "vitalik.eth" in result.ens_domains, f"ENS not found: {result.ens_domains}" |
|
|
|
|
| @test("extract_entities: empty input returns nothing") |
| def test_entity_empty(): |
| from app.entity_extraction import extract_entities |
|
|
| result = extract_entities("Hello world this is a test") |
| assert result.total_count == 0, f"Expected 0 entities, got {result.total_count}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("compute_decay: fresh content = 1.0") |
| def test_decay_fresh(): |
| from app.temporal_decay import compute_decay |
|
|
| assert compute_decay(0, 30) == 1.0 |
| assert compute_decay(0, 365) == 1.0 |
|
|
|
|
| @test("compute_decay: infinite half-life = 1.0 always") |
| def test_decay_infinite(): |
| from app.temporal_decay import compute_decay |
|
|
| assert compute_decay(1000, float("inf")) == 1.0 |
| assert compute_decay(0.1, float("inf")) == 1.0 |
|
|
|
|
| @test("compute_decay: at half-life, score ~= 0.5") |
| def test_decay_halflife(): |
| from app.temporal_decay import compute_decay |
|
|
| score = compute_decay(30.0, 30.0) |
| assert abs(score - 0.5) < 0.01, f"At half-life, expected ~0.5, got {score}" |
|
|
|
|
| @test("compute_decay: older = lower score") |
| def test_decay_monotonic(): |
| from app.temporal_decay import compute_decay |
|
|
| s1 = compute_decay(10, 30) |
| s2 = compute_decay(30, 30) |
| s3 = compute_decay(60, 30) |
| assert s1 > s2 > s3, f"Not monotonically decreasing: {s1} > {s2} > {s3}" |
|
|
|
|
| @test("apply_temporal_decay: adds decay fields to results") |
| def test_apply_decay_fields(): |
| from datetime import datetime |
|
|
| from app.temporal_decay import apply_temporal_decay |
|
|
| results = [ |
| { |
| "similarity": 0.8, |
| "collection": "news_articles", |
| "stored_at": datetime.now(UTC).isoformat(), |
| }, |
| { |
| "similarity": 0.7, |
| "collection": "forensic_reports", |
| "stored_at": "2023-01-01T00:00:00+00:00", |
| }, |
| ] |
| decayed = apply_temporal_decay(results) |
| assert "raw_similarity" in decayed[0], "Missing raw_similarity field" |
| assert "decay_factor" in decayed[0], "Missing decay_factor field" |
| assert "age_days" in decayed[0], "Missing age_days field" |
| |
| assert decayed[1]["decay_factor"] == 1.0, "Forensic report should never decay" |
|
|
|
|
| @test("get_half_life: forensic_reports = infinite") |
| def test_halflife_forensic(): |
| from app.temporal_decay import get_half_life |
|
|
| hl = get_half_life("forensic_reports") |
| assert hl == float("inf"), f"forensic_reports half-life should be inf, got {hl}" |
|
|
|
|
| @test("get_half_life: news_articles = 30 days") |
| def test_halflife_news(): |
| from app.temporal_decay import get_half_life |
|
|
| hl = get_half_life("news_articles") |
| assert hl == 30.0, f"news_articles half-life should be 30, got {hl}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("chunk_document: returns chunks with correct structure") |
| def test_chunk_document(): |
| from app.contextual_chunking import chunk_document |
|
|
| text = "First paragraph.\n\nSecond paragraph. " * 100 |
| chunks = chunk_document(text, chunk_size=500, overlap=50) |
| assert len(chunks) > 1, "Should produce multiple chunks" |
| for c in chunks: |
| assert hasattr(c, "content"), "Chunk missing content" |
| assert hasattr(c, "index"), "Chunk missing index" |
| assert len(c.content) > 0, "Chunk has empty content" |
|
|
|
|
| @test("chunk_document: single short doc produces one chunk") |
| def test_chunk_single(): |
| from app.contextual_chunking import chunk_document |
|
|
| chunks = chunk_document("Short text.", chunk_size=2500) |
| assert len(chunks) == 1, f"Short text should be 1 chunk, got {len(chunks)}" |
|
|
|
|
| @test("chunk_document: respects boundaries") |
| def test_chunk_boundaries(): |
| from app.contextual_chunking import chunk_document |
|
|
| text = "# Section 1\n\nFirst section content here. " * 50 |
| chunks = chunk_document(text, chunk_size=500, overlap=50, respect_boundaries=True) |
| |
| for _c in chunks: |
| |
| pass |
|
|
|
|
| @test("heuristic context: generates context without LLM") |
| def test_heuristic_context(): |
| from app.contextual_chunking import _generate_heuristic_context |
|
|
| ctx = _generate_heuristic_context("# DeFi Analysis\n\nFull document text...", "chunk about exploits", 0, 5) |
| assert "DeFi Analysis" in ctx, f"Title not in context: {ctx}" |
| assert "Chunk 1 of 5" in ctx, f"Position not in context: {ctx}" |
|
|
|
|
| @test("parent_child_chunk: creates parent+child chunks") |
| def test_parent_child(): |
| from app.contextual_chunking import parent_child_chunk |
|
|
| text = "First paragraph with enough text. " * 200 |
| chunks = parent_child_chunk(text, parent_size=500, child_size=200) |
| parents = [c for c in chunks if c.metadata.get("is_parent")] |
| children = [c for c in chunks if c.parent_id is not None] |
| assert len(parents) > 0, "No parent chunks created" |
| assert len(children) > 0, "No child chunks created" |
| assert all(c.parent_content for c in children), "Children missing parent content" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("embedding cache: _cache_key generates deterministic keys") |
| async def test_cache_key(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| embedder = CryptoEmbedder() |
| key1 = await embedder._cache_key("semantic", "test text") |
| key2 = await embedder._cache_key("semantic", "test text") |
| assert key1 == key2, f"Cache keys not deterministic: {key1} != {key2}" |
|
|
|
|
| @test("embedding cache: different heads generate different keys") |
| async def test_cache_key_different_heads(): |
| from app.crypto_embeddings import CryptoEmbedder |
|
|
| embedder = CryptoEmbedder() |
| key1 = await embedder._cache_key("semantic", "test") |
| key2 = await embedder._cache_key("code", "test") |
| assert key1 != key2, f"Different heads should have different cache keys: {key1} == {key2}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("_get_dim: auto-detects dimension from first embedding") |
| def test_get_dim_auto(): |
| from app.supabase_vector import SupabaseVectorStore |
|
|
| store = SupabaseVectorStore() |
| |
| dim = store._get_dim([0.1] * 384) |
| assert dim == 384, f"Auto-detect should return 384, got {dim}" |
|
|
|
|
| @test("_get_dim: caches resolved dimension") |
| def test_get_dim_caches(): |
| from app.supabase_vector import SupabaseVectorStore |
|
|
| store = SupabaseVectorStore() |
| dim1 = store._get_dim([0.1] * 512) |
| assert dim1 == 512, f"First call should return 512, got {dim1}" |
| dim2 = store._get_dim([0.1] * 384) |
| assert dim2 == 512, f"Should return cached 512, got {dim2}" |
|
|
|
|
| @test("_get_dim: fallback to 384 when no embedding provided") |
| def test_get_dim_fallback(): |
| from app.supabase_vector import SupabaseVectorStore |
|
|
| store = SupabaseVectorStore() |
| dim = store._get_dim() |
| assert dim == 384, f"Default should be 384, got {dim}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("KNOWN_SCAM_PATTERNS: has required fields") |
| def test_scam_patterns_structure(): |
| from app.crypto_embeddings import KNOWN_SCAM_PATTERNS |
|
|
| assert len(KNOWN_SCAM_PATTERNS) >= 10, f"Expected >= 10 patterns, got {len(KNOWN_SCAM_PATTERNS)}" |
| for p in KNOWN_SCAM_PATTERNS: |
| assert "name" in p, f"Pattern missing name: {p}" |
| assert "description" in p, f"Pattern missing description: {p}" |
| assert "severity" in p, f"Pattern missing severity: {p}" |
| assert p["severity"] in ("low", "medium", "high", "critical"), f"Invalid severity: {p['severity']}" |
|
|
|
|
| @test("CLUSTER_LABEL_TEMPLATES: has 10 templates") |
| def test_cluster_labels(): |
| from app.bundle_cluster_rag import CLUSTER_LABEL_TEMPLATES |
|
|
| assert len(CLUSTER_LABEL_TEMPLATES) == 10, f"Expected 10 templates, got {len(CLUSTER_LABEL_TEMPLATES)}" |
| labels = [t["label"] for t in CLUSTER_LABEL_TEMPLATES] |
| assert "insider_trading_ring" in labels, "Missing insider_trading_ring" |
| assert "wash_trading_farm" in labels, "Missing wash_trading_farm" |
| assert "mev_bot_network" in labels, "Missing mev_bot_network" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("TTL map: forensic_reports and scam_patterns have permanent TTL (0)") |
| def test_ttl_permanent(): |
| |
| |
| import inspect |
|
|
| from app.rag_service import ingest_document |
|
|
| source = inspect.getsource(ingest_document) |
| assert "forensic_reports" in source, "forensic_reports missing from TTL logic" |
| assert "scam_patterns" in source, "scam_patterns missing from TTL logic" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("Rerank and analysis models are env-configurable") |
| def test_model_config(): |
| from app.rag_agentic import ANALYSIS_MODEL, RERANK_MODEL |
|
|
| |
| assert RERANK_MODEL, "RERANK_MODEL should not be empty" |
| assert ANALYSIS_MODEL, "ANALYSIS_MODEL should not be empty" |
| |
| assert "claude-sonnet-4" not in ANALYSIS_MODEL or "20250514" in ANALYSIS_MODEL, ( |
| f"ANALYSIS_MODEL should use dated model, got: {ANALYSIS_MODEL}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("COLLECTIONS list includes all expected collections") |
| def test_collections(): |
| from app.crypto_embeddings import COLLECTIONS |
|
|
| expected = [ |
| "wallet_profiles", |
| "token_analysis", |
| "scam_patterns", |
| "forensic_reports", |
| "market_intel", |
| "contract_audits", |
| "known_scams", |
| "news_articles", |
| "transaction_patterns", |
| ] |
| for coll in expected: |
| assert coll in COLLECTIONS, f"Missing collection: {coll}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("ANNIndex: build_index creates in-memory index") |
| async def test_ann_build(): |
| from app.ann_index import ANNIndex |
|
|
| idx = ANNIndex() |
| meta = await idx.build_index("scam_patterns") |
| assert meta.get("status") == "built", f"Expected built, got {meta}" |
| assert meta.get("n", 0) > 0, f"Expected docs > 0, got {meta.get('n')}" |
|
|
|
|
| @test("ANNIndex: search returns hydrated results with content") |
| async def test_ann_search(): |
| from app.ann_index import ANNIndex |
| from app.crypto_embeddings import get_embedder |
|
|
| idx = ANNIndex() |
| await idx.build_index("scam_patterns") |
| embedder = await get_embedder() |
| query_vec = await embedder.embed_query("honeypot token") |
| results = await idx.search(query_vec, "scam_patterns", limit=3, min_similarity=0.3) |
| assert len(results) > 0, "No results returned" |
| first = results[0] |
| assert "id" in first, "Missing id" |
| assert "similarity" in first, "Missing similarity" |
| assert "content" in first, "Missing content (hydration failed)" |
|
|
|
|
| @test("ANNIndex: search is sub-second for small collections") |
| async def test_ann_speed(): |
| import time |
|
|
| from app.ann_index import ANNIndex |
| from app.crypto_embeddings import get_embedder |
|
|
| idx = ANNIndex() |
| await idx.build_index("scam_patterns") |
| embedder = await get_embedder() |
| query_vec = await embedder.embed_query("rug pull") |
| t0 = time.time() |
| await idx.search(query_vec, "scam_patterns", limit=5, min_similarity=0.3) |
| t1 = time.time() |
| assert (t1 - t0) < 1.0, f"Search took {t1 - t0:.2f}s, expected < 1s" |
|
|
|
|
| @test("ANNIndex: stats returns collection info") |
| async def test_ann_stats(): |
| from app.ann_index import ANNIndex |
|
|
| idx = ANNIndex() |
| await idx.build_index("scam_patterns") |
| s = idx.stats() |
| assert "scam_patterns" in s, f"scam_patterns not in stats: {s}" |
| assert s["scam_patterns"].get("n", 0) > 0, "No vectors in stats" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("SemanticCache: store and check returns hit") |
| async def test_semantic_cache_hit(): |
| from app.semantic_cache import SemanticCache |
|
|
| cache = SemanticCache() |
| vec = [0.1] * 384 |
| results = [{"id": "test1", "similarity": 0.9, "content": "test doc"}] |
| await cache.store(vec, results) |
| cached = await cache.check(vec) |
| assert cached is not None, "Expected cache hit, got None" |
| assert len(cached) == 1, f"Expected 1 cached result, got {len(cached)}" |
|
|
|
|
| @test("SemanticCache: different vector returns miss") |
| async def test_semantic_cache_miss(): |
| from app.semantic_cache import SemanticCache |
|
|
| cache = SemanticCache() |
| vec1 = [1.0] + [0.0] * 383 |
| await cache.store(vec1, [{"id": "x"}]) |
| vec2 = [0.0] + [1.0] * 1 + [0.0] * 382 |
| cached = await cache.check(vec2) |
| assert cached is None, f"Expected cache miss, got hit: {cached}" |
|
|
|
|
| @test("SemanticCache: stats returns hit rate") |
| async def test_semantic_cache_stats(): |
| from app.semantic_cache import SemanticCache |
|
|
| cache = SemanticCache() |
| s = await cache.stats() |
| assert "entries" in s, f"Missing entries in stats: {s}" |
| assert "hit_rate" in s, f"Missing hit_rate in stats: {s}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("three_pillar_search: returns results with pillar attribution") |
| async def test_three_pillar(): |
| from app.rag_service import three_pillar_search |
|
|
| result = await three_pillar_search( |
| "honeypot token with high sell tax", |
| collections=["scam_patterns"], |
| limit=5, |
| ) |
| assert "results" in result, f"Missing results key: {list(result.keys())}" |
| assert "pillar_summary" in result, "Missing pillar_summary" |
| ps = result["pillar_summary"] |
| assert "dense_hits" in ps, "Missing dense_hits" |
| assert "sparse_hits" in ps, "Missing sparse_hits" |
| assert "pillars_used" in ps, "Missing pillars_used" |
|
|
|
|
| @test("three_pillar_search: entity extraction from query") |
| async def test_three_pillar_entity(): |
| from app.rag_service import three_pillar_search |
|
|
| result = await three_pillar_search( |
| "token on solana and ethereum", |
| collections=["scam_patterns"], |
| limit=3, |
| ) |
| ee = result.get("entity_extraction") |
| if ee: |
| chains = ee.get("chain_names", []) |
| assert "solana" in chains or "ethereum" in chains, f"Expected chain names, got: {chains}" |
|
|
|
|
| @test("three_pillar_search: RRF fusion produces ranked results") |
| async def test_three_pillar_rrf(): |
| from app.rag_service import three_pillar_search |
|
|
| result = await three_pillar_search( |
| "rug pull honeypot", |
| collections=["scam_patterns"], |
| limit=5, |
| ) |
| results = result.get("results", []) |
| assert len(results) > 0, "No results from three-pillar search" |
| |
| for r in results[:2]: |
| assert "match_type" in r or "pillars" in r, f"Missing pillar attribution: {list(r.keys())}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("query_transform: expand adds crypto synonyms") |
| async def test_query_expand(): |
| from app.query_transform import expand_query |
|
|
| variants = await expand_query("rug pull token on ethereum") |
| assert len(variants) >= 3, f"Expected >=3 variants, got {len(variants)}: {variants}" |
| |
| variants_lower = [v.lower() for v in variants] |
| assert any("honeypot" in v or "liquidity drain" in v or "exit scam" in v for v in variants_lower), ( |
| f"No crypto synonyms found in: {variants}" |
| ) |
|
|
|
|
| @test("query_transform: step_back generalizes questions") |
| async def test_query_step_back(): |
| from app.query_transform import step_back_query |
|
|
| result = await step_back_query("Is $SOL a rug pull?") |
| |
| assert "rug pull" in result.lower() or "token" in result.lower(), f"Step-back didn't generalize: {result}" |
|
|
|
|
| @test("query_transform: auto router picks correct strategy") |
| async def test_query_auto_route(): |
| from app.query_transform import transform_query |
|
|
| |
| tq = await transform_query("0xdAC17F958D2ee523a2206206994597C13D831ec7 scam", strategy="auto") |
| assert tq.strategy != "none", f"Entity query should not be 'none', got {tq.strategy}" |
| assert len(tq.transformed_queries) >= 1, f"Should have transforms: {tq.transformed_queries}" |
|
|
|
|
| @test("query_transform: short factual queries pass through") |
| async def test_query_passthrough(): |
| from app.query_transform import transform_query |
|
|
| tq = await transform_query("rug pull", strategy="none") |
| assert tq.strategy == "none" |
| assert tq.transformed_queries == ["rug pull"] |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("ragas_eval: golden test set has entries") |
| def test_ragas_golden_set(): |
| from app.ragas_eval import GOLDEN_TEST_SET |
|
|
| assert len(GOLDEN_TEST_SET) >= 40, f"Expected >=40 golden test pairs, got {len(GOLDEN_TEST_SET)}" |
| for entry in GOLDEN_TEST_SET: |
| assert "query" in entry, f"Missing query field: {entry}" |
| assert "collection" in entry, f"Missing collection field: {entry}" |
|
|
|
|
| @test("ragas_eval: context_precision computes nDCG") |
| def test_ragas_ndcg(): |
| from app.ragas_eval import context_precision |
|
|
| |
| prec = context_precision(["a", "b", "c"], ["a", "b", "c"], k=5) |
| assert prec == 1.0, f"Perfect ranking should be 1.0, got {prec}" |
| |
| prec0 = context_precision([], ["a", "b"], k=5) |
| assert prec0 == 0.0, f"Empty results should be 0.0, got {prec0}" |
|
|
|
|
| @test("ragas_eval: hit_rate computes correctly") |
| def test_ragas_hit_rate(): |
| from app.ragas_eval import hit_rate |
|
|
| hr = hit_rate(["a", "b", "c"], ["b"]) |
| assert hr == 1.0, f"Hit should be 1.0 when relevant doc in results, got {hr}" |
| hr0 = hit_rate(["x", "y"], ["b"]) |
| assert hr0 == 0.0, f"Hit should be 0.0 when no relevant doc, got {hr0}" |
|
|
|
|
| @test("ragas_eval: mrr computes correctly") |
| def test_ragas_mrr(): |
| from app.ragas_eval import mrr |
|
|
| mr = mrr(["x", "b", "y"], ["b"]) |
| assert abs(mr - 0.5) < 0.01, f"MRR for rank-2 hit should be 0.5, got {mr}" |
| mr0 = mrr(["x", "y"], ["b"]) |
| assert mr0 == 0.0, f"MRR for no hit should be 0.0, got {mr0}" |
|
|
|
|
| |
| |
| |
|
|
|
|
| @test("scam pattern cache: pre_embed caches all 10 patterns") |
| async def test_pattern_cache(): |
| from app.crypto_embeddings import KNOWN_SCAM_PATTERNS |
| from app.rag_service import _pattern_cache, _preembed_scam_patterns |
|
|
| await _preembed_scam_patterns() |
| assert len(_pattern_cache) == len(KNOWN_SCAM_PATTERNS), ( |
| f"Expected {len(KNOWN_SCAM_PATTERNS)} cached, got {len(_pattern_cache)}" |
| ) |
|
|
|
|
| @test("scam pattern cache: cached patterns have embedding vectors") |
| async def test_pattern_cache_vectors(): |
| from app.rag_service import _pattern_cache, _preembed_scam_patterns |
|
|
| await _preembed_scam_patterns() |
| for name, result in _pattern_cache.items(): |
| assert hasattr(result, "vector"), f"Pattern {name} missing vector attr" |
| assert len(result.vector) > 0, f"Pattern {name} has empty vector" |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| print("=" * 60) |
| print(" RAG SYSTEM TEST SUITE") |
| print("=" * 60) |
| ok = asyncio.run(run_tests()) |
| sys.exit(0 if ok else 1) |
|
|