- 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>
354 lines
12 KiB
Python
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
|