rmi-backend/app/multimodal_rag.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

354 lines
12 KiB
Python

"""
Multi-Modal RAG - Vision + Text retrieval for crypto security.
Capabilities:
1. Token logo analysis - stolen artwork detection via perceptual hashing
2. Screenshot OCR - extract addresses, contracts, amounts from images
3. Chart pattern analysis - detect pump/dump, rug pull patterns in price charts
Uses:
- Perceptual hashing (pHash) for image similarity - zero API cost
- DeepSeek v4 Flash vision for description/captioning (via LLM_API_KEY)
- Text embedding via existing BGE-small for cross-modal retrieval
Designed to work with existing RAG pipeline: images → descriptions → embed → search.
"""
import hashlib
import logging
import os
from typing import Any
logger = logging.getLogger(__name__)
# ── Perceptual Hashing ───────────────────────────────────────────
def compute_image_hash(image_bytes: bytes, hash_size: int = 16) -> str:
"""
Compute perceptual hash (pHash) of an image.
Uses average hashing - resize to hash_sizexhash_size, convert to grayscale, # noqa: RUF002
compute average, threshold each pixel. Returns hex string.
Pure Python implementation - no OpenCV/pillow dependency.
"""
# Parse minimal BMP/PNG header to get pixel data
try:
# Use Python's built-in imghdr to detect type
import imghdr
img_type = imghdr.what(None, image_bytes)
if img_type == "png":
import struct
import zlib
# Minimal PNG parser for pHash
# Skip to IDAT chunks
pos = 8 # Skip PNG signature
width = height = 0
pixel_data = b""
while pos < len(image_bytes):
chunk_len = struct.unpack(">I", image_bytes[pos : pos + 4])[0]
chunk_type = image_bytes[pos + 4 : pos + 8].decode("ascii", errors="ignore")
pos += 8
if chunk_type == "IHDR":
width = struct.unpack(">I", image_bytes[pos : pos + 4])[0]
height = struct.unpack(">I", image_bytes[pos + 4 : pos + 8])[0]
elif chunk_type == "IDAT":
pixel_data += image_bytes[pos : pos + chunk_len]
elif chunk_type == "IEND":
break
pos += chunk_len + 4 # +4 for CRC
if pixel_data:
try:
decompressed = zlib.decompress(pixel_data)
# Convert to 16x16 grayscale by averaging blocks
return _hash_from_raw(decompressed, width, height, hash_size)
except zlib.error:
pass
# Fallback: hash raw bytes (file-level similarity)
return _hash_from_raw(image_bytes, 1, len(image_bytes), hash_size)
except Exception:
pass
# Ultimate fallback: SHA-based hash of raw bytes
return hashlib.sha256(image_bytes).hexdigest()[: hash_size * 2]
def _hash_from_raw(data: bytes, width: int, height: int, hash_size: int) -> str:
"""Compute average hash from raw pixel data."""
try:
list(data)
block_w = max(1, width // hash_size)
block_h = max(1, height // hash_size)
hash_bits = []
for y in range(hash_size):
for x in range(hash_size):
# Average pixels in this block
block_sum = 0
block_count = 0
for by in range(y * block_h, min((y + 1) * block_h, height)):
for bx in range(x * block_w, min((x + 1) * block_w, width)):
idx = (by * width + bx) * 3 # Assume RGB
if idx + 2 < len(data):
# Grayscale: 0.299*R + 0.587*G + 0.114*B
gray = int(0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2])
block_sum += gray
block_count += 1
block_avg = block_sum / max(block_count, 1)
hash_bits.append(block_avg)
# Compute average and threshold
total_avg = sum(hash_bits) / len(hash_bits)
bits = "".join("1" if b > total_avg else "0" for b in hash_bits)
# Convert to hex
return hex(int(bits, 2))[2:].zfill(hash_size * 2 // 4)
except Exception:
return hashlib.sha256(data).hexdigest()[: hash_size * 2]
def compare_image_hashes(hash1: str, hash2: str) -> float:
"""Compare two perceptual hashes. Returns similarity 0-1."""
if len(hash1) != len(hash2):
return 0.0
try:
h1 = int(hash1, 16)
h2 = int(hash2, 16)
xor = h1 ^ h2
# Count differing bits
diff_bits = bin(xor).count("1")
max_bits = len(hash1) * 4
return 1.0 - (diff_bits / max_bits)
except ValueError:
return 0.0
# ── Token Logo Database ───────────────────────────────────────────
class TokenLogoDB:
"""In-memory database of known token logos for stolen artwork detection."""
def __init__(self):
self._logos: dict[str, dict[str, Any]] = {}
def add_logo(
self,
token_symbol: str,
token_address: str,
chain: str,
image_hash: str,
source: str = "unknown",
) -> None:
"""Register a token logo hash."""
self._logos[token_address.lower()] = {
"symbol": token_symbol,
"address": token_address,
"chain": chain,
"hash": image_hash,
"source": source,
}
def find_similar(self, image_hash: str, threshold: float = 0.85) -> list[dict[str, Any]]:
"""Find logos similar to the given hash. Returns matches above threshold."""
matches = []
for _addr, logo in self._logos.items():
sim = compare_image_hashes(image_hash, logo["hash"])
if sim >= threshold:
matches.append({**logo, "similarity": round(sim, 3)})
matches.sort(key=lambda m: m["similarity"], reverse=True)
return matches
def check_theft(self, image_hash: str) -> dict[str, Any]:
"""
Check if a token logo appears to be stolen from a known project.
Returns analysis with confidence and matched original projects.
"""
matches = self.find_similar(image_hash, threshold=0.90)
very_high = [m for m in matches if m["similarity"] >= 0.95]
high = [m for m in matches if 0.90 <= m["similarity"] < 0.95]
if very_high:
return {
"suspected_theft": True,
"confidence": "high",
"matched_logos": very_high,
"summary": f"Logo appears stolen from {very_high[0]['symbol']} "
f"({very_high[0]['chain']}) - {very_high[0]['similarity']:.1%} similarity",
}
elif high:
return {
"suspected_theft": True,
"confidence": "medium",
"matched_logos": high,
"summary": f"Logo similar to {high[0]['symbol']} "
f"({high[0]['chain']}) - {high[0]['similarity']:.1%} similarity",
}
return {
"suspected_theft": False,
"confidence": "none",
"matched_logos": matches,
"summary": "No stolen artwork detected",
}
# ── Image Description (via DeepSeek Vision) ──────────────────────
async def describe_image(
image_url: str = "",
image_base64: str = "",
task: str = "describe",
) -> str:
"""
Generate a text description of an image using DeepSeek v4 Flash vision.
Uses the existing LLM_API_KEY configuration.
task: "describe" | "extract_addresses" | "analyze_chart" | "detect_scam"
"""
try:
from app.rag_agentic import AI_BASE, LLM_API_KEY
if not LLM_API_KEY:
logger.warning("No LLM key available for image description")
return "Image description unavailable - no LLM API key configured"
TASK_PROMPTS = {
"describe": "Describe this image in detail. What crypto-related content does it show?",
"extract_addresses": (
"Extract any cryptocurrency wallet addresses (0x... for EVM, "
"base58 for Solana, etc.), contract addresses, transaction hashes, "
"and token symbols visible in this image. Return them as a JSON list "
'with format: {"addresses": [...], "symbols": [...], "chains": [...]}'
),
"analyze_chart": (
"Analyze this price chart. Does it show signs of a pump-and-dump, "
"rug pull, or wash trading? Look for: sudden spikes, immediate crashes, "
"flat volume then massive spike, stair-step patterns."
),
"detect_scam": (
"Is this screenshot showing a potential crypto scam? Check for: "
"fake UI elements, too-good-to-be-true returns, impersonation of "
"legitimate platforms, urgency tactics, grammar errors."
),
}
prompt = TASK_PROMPTS.get(task, TASK_PROMPTS["describe"])
payload = {
"model": os.getenv("LLM_MODEL", "deepseek-v4-flash"),
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 300,
}
if image_url:
payload["messages"][0]["content"] = [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text", "text": prompt},
]
elif image_base64:
payload["messages"][0]["content"] = [
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image_base64}"},
},
{"type": "text", "text": prompt},
]
else:
return "No image provided"
import httpx
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
AI_BASE,
headers={
"Authorization": f"Bearer {LLM_API_KEY}",
"Content-Type": "application/json",
},
json=payload,
)
if resp.status_code == 200:
data = resp.json()
return data["choices"][0]["message"]["content"]
else:
logger.warning(f"Image description failed: HTTP {resp.status_code}")
return f"Image analysis error: HTTP {resp.status_code}"
except ImportError:
logger.debug("LLM module not available for vision")
return "Vision analysis unavailable"
except Exception as e:
logger.warning(f"Image description error: {e}")
return f"Image analysis failed: {e}"
# ── Screenshot OCR - lightweight text extraction ─────────────────
def extract_text_from_image_hints(image_bytes: bytes) -> dict[str, list[str]]:
"""
Lightweight heuristic text extraction from image bytes.
Searches for crypto address patterns, URLs, and keywords.
For full OCR, use describe_image(task='extract_addresses') with DeepSeek vision.
"""
text = ""
try:
# Try to extract printable ASCII from the raw bytes
text = "".join(chr(b) for b in image_bytes if 32 <= b < 127)
# Limit to first 10K chars
text = text[:10000]
except Exception:
pass
results = {
"eth_addresses": [],
"sol_addresses": [],
"urls": [],
"amounts": [],
}
import re
# ETH addresses
eth_matches = re.findall(r"0x[a-fA-F0-9]{40}", text)
results["eth_addresses"] = list(set(eth_matches))[:20]
# Solana addresses
sol_matches = re.findall(r"[1-9A-HJ-NP-Za-km-z]{32,44}", text)
results["sol_addresses"] = list(set(sol_matches))[:20]
# URLs
url_matches = re.findall(r"https?://[^\s]+", text)
results["urls"] = list(set(url_matches))[:10]
# Dollar amounts
amount_matches = re.findall(r"\$[\d,]+(?:\.\d+)?", text)
results["amounts"] = amount_matches[:10]
return results
# ── Singleton ─────────────────────────────────────────────────────
_logo_db: TokenLogoDB | None = None
def get_logo_db() -> TokenLogoDB:
global _logo_db
if _logo_db is None:
_logo_db = TokenLogoDB()
return _logo_db