merge: chore/cleanup-remove-bloat-and-secrets into main

This commit is contained in:
Crypto Rug Munch 2026-07-02 01:24:22 +07:00
commit bde2f3a97d
1173 changed files with 437609 additions and 0 deletions

View file

@ -0,0 +1,5 @@
"""T29 Reports — thin HTTP layer."""
from .router import router
__all__ = ["router"]

View file

@ -0,0 +1,315 @@
"""T05 — RAG Citation Validator.
Per RMIV5 §T05 (G05 FIX). After an LLM generates a report section from
retrieved RAG chunks, every claim in the output must cite a source by
number [1], [2], etc. This module enforces that.
Pipeline:
1. LLM produces text constrained to retrieved chunks (in generator.py)
2. This validator parses every [N] citation in the text
3. Verifies that:
a. N is a valid index into the retrieved chunks list
b. The cited sentence/paragraph is supported by source N
(substring match on key terms)
4. Returns a citation report:
{
"validated_text": str with unciteable claims replaced by
"[Data not available]" or removed,
"citations": [{"claim": str, "source_idx": int, "source_text": str}],
"unciteable_count": int,
"validation_rate": float # 0.0-1.0
}
Why this exists:
Reports that hallucinate destroy trust. Every claim in a $5 report
must be backed by a source we can show. If we can't find support,
we say so explicitly rather than fabricating.
"""
from __future__ import annotations
import re
from typing import Any
# ── Regexes ──────────────────────────────────────────────────────────
# Match inline citations like "...some claim [1]..." or "[2, 3]" or "[1-3]"
_CITATION_RE = re.compile(r"\[(\d+(?:\s*[,\-]\s*\d+)*)\]")
# Match sentences (rough — splits on .!? followed by whitespace + uppercase)
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z\d])")
# Key term extraction (rough): words with 4+ chars, lowercase, no stopwords
_STOPWORDS = frozenset(
{
"the",
"and",
"for",
"are",
"but",
"not",
"you",
"all",
"can",
"had",
"her",
"was",
"one",
"our",
"out",
"day",
"get",
"has",
"him",
"his",
"how",
"its",
"may",
"new",
"now",
"old",
"see",
"two",
"way",
"who",
"boy",
"did",
"use",
"what",
"when",
"this",
"that",
"with",
"from",
"have",
"been",
"will",
"they",
"their",
"which",
"would",
"there",
"could",
"about",
"other",
"into",
"than",
"more",
"some",
"very",
"most",
"only",
"over",
"such",
"also",
"after",
"before",
"should",
"because",
"where",
"these",
"those",
"being",
"through",
}
)
def _key_terms(text: str) -> set[str]:
"""Extract key terms (lowercase, 4+ chars, not stopwords) from text."""
words = re.findall(r"\b[a-zA-Z]{4,}\b", text.lower())
return {w for w in words if w not in _STOPWORDS}
def _extract_citation_indices(citation_str: str, max_index: int) -> list[int]:
"""Parse '1', '1,2,3', or '1-3' into [1, 2, 3] (1-indexed).
Out-of-range indices are silently dropped (we'll flag them as
invalid in the citation report).
"""
result = []
for part in citation_str.split(","):
part = part.strip()
if "-" in part:
try:
lo, hi = part.split("-", 1)
lo_i, hi_i = int(lo.strip()), int(hi.strip())
for n in range(lo_i, hi_i + 1):
if 1 <= n <= max_index:
result.append(n)
except ValueError:
continue
else:
try:
n = int(part)
if 1 <= n <= max_index:
result.append(n)
except ValueError:
continue
return result
def _split_sentences_with_citations(text: str) -> list[tuple[str, list[int]]]:
"""Split text into sentences, each annotated with its [N] citations.
A citation belongs to the sentence that contains it (not the
preceding one). Returns [(sentence, [citation_indices])].
"""
sentences: list[tuple[str, list[int]]] = []
for sent in _SENTENCE_SPLIT_RE.split(text.strip()):
sent = sent.strip()
if not sent:
continue
# Find all citations in this sentence
matches = _CITATION_RE.findall(sent)
indices: list[int] = []
for m in matches:
indices.extend(_extract_citation_indices(m, max_index=10_000))
sentences.append((sent, sorted(set(indices))))
return sentences
def _claim_supported_by_source(claim: str, source_text: str, threshold: float = 0.4) -> bool:
"""Check if the claim's key terms appear in the source text.
Uses Jaccard-like overlap on key terms (4+ chars, non-stopwords).
A claim is "supported" if at least `threshold` of its key terms
appear in the source. Threshold 0.4 = 40% overlap required.
This is a heuristic it catches obvious fabrications (where the
LLM cites a source but the claim isn't in it) without being so
strict that paraphrased but accurate claims get flagged.
"""
claim_terms = _key_terms(claim)
if not claim_terms:
# No key terms (very short sentence) — assume supported
return True
source_terms = _key_terms(source_text)
if not source_terms:
return False
overlap = len(claim_terms & source_terms)
return (overlap / len(claim_terms)) >= threshold
def validate_section(
text: str,
sources: list[str],
*,
min_support_overlap: float = 0.4,
on_unciteable: str = "strip",
) -> dict[str, Any]:
"""Validate that every claim in `text` cites a real source.
Args:
text: The LLM-generated section text.
sources: List of source texts the LLM was supposed to use.
Index 0 in this list = citation [1], etc.
min_support_overlap: Minimum fraction of claim key terms that
must appear in source for claim to be
considered supported (default 0.4 = 40%).
on_unciteable: What to do with unsupported claims.
"strip" (default) replace with [Data not available]
"keep" leave as-is, flag in citations report
"drop" remove the sentence entirely
Returns:
{
"validated_text": str,
"citations": [{"claim": str, "source_idx": int,
"source_text": str, "supported": bool}],
"unciteable_count": int,
"validation_rate": float, # supported/total
}
"""
if not sources:
# No sources provided — every claim is unciteable
return {
"validated_text": ("[Data not available — no RAG sources retrieved]" if on_unciteable == "strip" else text),
"citations": [],
"unciteable_count": _count_sentences(text),
"validation_rate": 0.0,
}
sentences = _split_sentences_with_citations(text)
validated_sentences: list[str] = []
citations: list[dict[str, Any]] = []
unciteable_count = 0
for sent, indices in sentences:
if not indices:
# No citations at all — unciteable
unciteable_count += 1
citations.append({"claim": sent, "source_idx": 0, "source_text": "", "supported": False})
if on_unciteable == "strip":
validated_sentences.append("[Data not available]")
elif on_unciteable == "keep":
validated_sentences.append(sent)
# "drop" — add nothing
continue
# Filter out out-of-range indices (defensive — extract_citation_indices
# already does this, but defense-in-depth for malformed input)
valid_indices = [i for i in indices if 1 <= i <= len(sources)]
if not valid_indices:
# All citations were out of range — unciteable
unciteable_count += 1
citations.append(
{
"claim": sent,
"source_idx": 0,
"source_text": "",
"supported": False,
}
)
if on_unciteable == "strip":
validated_sentences.append("[Data not available]")
elif on_unciteable == "keep":
validated_sentences.append(sent)
continue
# We have at least one valid citation — check each one
best_source_idx = valid_indices[0]
source_text = sources[best_source_idx - 1] # [1] = sources[0]
supported = _claim_supported_by_source(sent, source_text, threshold=min_support_overlap)
# If first citation isn't supported, try the others
if not supported and len(valid_indices) > 1:
for idx in valid_indices[1:]:
candidate = sources[idx - 1]
if _claim_supported_by_source(sent, candidate, threshold=min_support_overlap):
best_source_idx = idx
source_text = candidate
supported = True
break
citations.append(
{
"claim": sent,
"source_idx": best_source_idx,
"source_text": source_text[:200] + ("..." if len(source_text) > 200 else ""),
"supported": supported,
}
)
if not supported:
unciteable_count += 1
if on_unciteable == "strip":
validated_sentences.append("[Data not available]")
elif on_unciteable == "keep":
validated_sentences.append(sent)
# "drop" — add nothing
else:
validated_sentences.append(sent)
validated_text = " ".join(validated_sentences).strip()
total = len(citations)
validation_rate = (total - unciteable_count) / total if total > 0 else 0.0
return {
"validated_text": validated_text,
"citations": citations,
"unciteable_count": unciteable_count,
"validation_rate": validation_rate,
}
def _count_sentences(text: str) -> int:
"""Rough sentence count for empty-sources case."""
return max(1, len([s for s in _SENTENCE_SPLIT_RE.split(text.strip()) if s.strip()]))

View file

@ -0,0 +1,576 @@
"""T29 Research Report Generator.
Per v4.0 §T29. Given a token or wallet, compose a Markdown report
from every data source, sold via x402 at $5/report.
Sections (parallel-composable):
- executive_summary (LLM)
- onchain (catalog + RAG)
- deployer (Neo4j + reputation)
- news_sentiment (news_items + LLM summary)
- rag_findings (RAG engine)
- social_signals (placeholder v1)
- risk_assessment (deterministic, from catalog.reputation weights)
- recommendation (LLM, based on all sections)
Deterministic risk score (no LLM). LLM only for narrative text.
Falls back to templated content if LiteLLM is unreachable.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
from typing import Any
from uuid import uuid4
from app.catalog.llm_router import LLMRouter
from app.catalog.models import (
RiskTier,
ScanReport,
utcnow,
)
from app.domain.reports.citation_validator import validate_section
log = logging.getLogger(__name__)
# ── Section prompts (v4.0 §T29) ─────────────────────────────────────
REPORT_PROMPTS: dict[str, str] = {
"executive_summary": """You are an analyst at RugMunch Intelligence, a crypto scam-detection platform.
Write a 2-3 paragraph executive summary for a research report on this asset.
Subject type: {subject_type}
Subject ID: {subject_id}
Risk score: {risk_score}/100 ({risk_tier})
Key risk factors: {risk_factors}
Be concise. An analyst should be able to read this in 30 seconds and decide whether to dig deeper.
Use plain English. No hedging. State the verdict clearly.""",
"onchain": """Write a 2-paragraph on-chain analysis for:
Subject: {subject_id}
Data: {data}
Cover: deployment, holders, liquidity, volume, contract characteristics.
If data is missing, say so explicitly. No speculation.""",
"deployer": """Write a 2-paragraph deployer analysis for:
Deployer wallet: {deployer}
Reputation: {reputation_score}/100
Rug count: {rug_count}
Prior deployments: {deployments}
Cover: track record, prior rugs, longevity, news signals. Verdict on whether the deployer is trustworthy.""",
"news_sentiment": """Write a 1-paragraph news sentiment summary for:
Subject: {subject_id}
Recent news count: {news_count}
Average sentiment: {avg_sentiment}
Top headline: {top_headline}
Verdict: bullish, bearish, or risk-elevating.""",
"rag_findings": """Write a 1-paragraph RAG findings summary for:
Subject: {subject_id}
Findings: {findings}
Focus on the highest-confidence cross-references between news, on-chain, and social.""",
"social_signals": """Write a 1-paragraph social signals summary for:
Subject: {subject_id}
Twitter mentions: {twitter_mentions}
Telegram groups: {telegram_groups}
Discord present: {discord_present}
Verdict on community strength and authenticity.""",
"recommendation": """Based on the full report:
Subject: {subject_id}
Risk score: {risk_score}/100 ({risk_tier})
Top factors: {risk_factors}
Write a 1-paragraph RECOMMENDATION. Be direct: AVOID / CAUTION / NEUTRAL / OPPORTUNITY.
Justify in 2 sentences. If the asset is a serial rugger, say so clearly.""",
}
# ── Data gathering (fan-out from catalog) ─────────────────────────
async def _gather_token(catalog, chain: str, address: str) -> dict:
"""Gather all data sources for a token."""
from app.catalog.models import Chain
try:
c = Chain(chain)
except ValueError:
return {"error": f"unknown chain: {chain}"}
token_id = f"{chain}:{address}"
token, deployer, news, rag_findings, _risk = await asyncio.gather(
catalog.get_token(c, address),
catalog.get_wallet(c, address) if False else asyncio.sleep(0, result=None), # placeholder
_fetch_news(catalog, token_id, since_hours=720),
catalog.rag_search(query=token_id, collection="scam_intel", top_k=10),
catalog.get_token_risk(c, address),
)
deployer = None
if token and token.deployer_wallet_id:
with contextlib.suppress(Exception):
deployer = await catalog.get_wallet_by_id(token.deployer_wallet_id)
return {
"token": token,
"deployer": deployer,
"news": news,
"rag_findings": rag_findings,
"risk": _risk,
}
async def _gather_wallet(catalog, chain: str, address: str) -> dict:
"""Gather data for a wallet report."""
from app.catalog.models import Chain
try:
c = Chain(chain)
except ValueError:
return {"error": f"unknown chain: {chain}"}
wallet_id = f"{chain}:{address}"
wallet, news, rag_findings, entity = await asyncio.gather(
catalog.get_wallet(c, address),
_fetch_news(catalog, wallet_id, since_hours=720),
catalog.rag_search(query=wallet_id, collection="wallet_labels", top_k=10),
catalog.resolve_entity(wallet_id),
)
return {
"wallet": wallet,
"news": news,
"rag_findings": rag_findings,
"entity": entity,
}
async def _fetch_news(catalog, subject_id: str, since_hours: int = 720) -> list:
"""Fetch news mentioning this subject."""
if not catalog._health.postgres:
return []
try:
async with catalog._pg_pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT news_id, title, summary, source, published_at, sentiment_score
FROM news_items
WHERE $1 = ANY(tokens_mentioned)
OR $1 = ANY(wallets_mentioned)
OR title ILIKE $2
ORDER BY published_at DESC LIMIT 20""",
subject_id,
f"%{subject_id.split(':')[-1][:8]}%",
)
from app.domain.news.router import _adapt_legacy_row as _adapt_news_row
return [_adapt_news_row(dict(r)) for r in rows]
except Exception as e:
log.warning(f"fetch_news_fail: {e}")
return []
# ── Risk scoring (deterministic) ───────────────────────────────────
def _compute_risk_token(token_data: dict) -> tuple[int, list[str], RiskTier]:
"""Deterministic 0-100 risk score from token data."""
score = 0
factors = []
token = token_data.get("token")
deployer = token_data.get("deployer")
if token:
if token.is_honeypot:
score += 50
factors.append("honeypot")
if token.is_mintable:
score += 20
factors.append("mintable")
if token.is_proxy:
score += 10
factors.append("proxy")
if token.tax_buy_bps and token.tax_buy_bps > 1000: # >10%
score += 15
factors.append(f"high_buy_tax_{token.tax_buy_bps}bps")
if token.tax_sell_bps and token.tax_sell_bps > 1000:
score += 15
factors.append(f"high_sell_tax_{token.tax_sell_bps}bps")
if token.risk_factors:
score += min(len(token.risk_factors) * 5, 25)
if deployer and hasattr(deployer, "rug_count"):
if deployer.rug_count > 0:
score += 30 * min(deployer.rug_count, 3)
factors.append(f"deployer_{deployer.rug_count}_prior_rugs")
if deployer.reputation_score and deployer.reputation_score < 30:
score += 20
factors.append("low_deployer_reputation")
news = token_data.get("news", [])
if news:
bearish = [n for n in news if (n.sentiment_score or 0) < -0.3]
if bearish:
score += 15
factors.append(f"bearish_news_{len(bearish)}")
score = min(score, 100)
if score < 25:
tier = RiskTier.LOW
elif score < 50:
tier = RiskTier.MEDIUM
elif score < 75:
tier = RiskTier.HIGH
else:
tier = RiskTier.CRITICAL
return score, factors, tier
def _compute_risk_wallet(wallet_data: dict) -> tuple[int, list[str], RiskTier]:
score = 0
factors = []
wallet = wallet_data.get("wallet")
entity = wallet_data.get("entity", {})
if entity and entity.get("wallets"):
if len(entity["wallets"]) > 2:
score += 15
factors.append(f"cross_chain_{len(entity['wallets'])}")
if wallet and wallet.is_suspicious:
score += 30
factors.append("flagged_suspicious")
if wallet and wallet.tx_count > 10000:
score += 10
factors.append("high_tx_volume")
news = wallet_data.get("news", [])
bearish = [n for n in news if (n.sentiment_score or 0) < -0.3]
if bearish:
score += 15
factors.append(f"bearish_news_{len(bearish)}")
score = min(score, 100)
if score < 25:
tier = RiskTier.LOW
elif score < 50:
tier = RiskTier.MEDIUM
elif score < 75:
tier = RiskTier.HIGH
else:
tier = RiskTier.CRITICAL
return score, factors, tier
# ── Report generation ──────────────────────────────────────────────
async def generate_token_report(catalog, chain: str, address: str, model: str = "deepseek-v3") -> ScanReport:
"""Generate a research report for a token. Falls back to templated
sections if LLM is unreachable."""
start = time.monotonic()
data = await _gather_token(catalog, chain, address)
if "error" in data:
raise ValueError(data["error"])
risk_score, risk_factors, risk_tier = _compute_risk_token(data)
risk_factors_str = ", ".join(risk_factors) if risk_factors else "none detected"
token = data.get("token")
deployer = data.get("deployer")
news = data.get("news", [])
rag = data.get("rag_findings", [])
avg_sent = sum(n.sentiment_score or 0 for n in news) / len(news) if news else 0
top_headline = news[0].title if news else "no recent news"
sections_ctx: dict[str, dict[str, Any]] = {
"executive_summary": {
"subject_type": "token",
"subject_id": f"{chain}:{address}",
"risk_score": risk_score,
"risk_tier": risk_tier.value,
"risk_factors": risk_factors_str,
},
"onchain": {
"subject_id": f"{chain}:{address}",
"data": (
f"Symbol={token.symbol if token else '?'}, "
f"Decimals={token.decimals if token else '?'}, "
f"Deployed={token.deployed_at.isoformat() if token else '?'}, "
f"honeypot={token.is_honeypot if token else '?'}, "
f"mintable={token.is_mintable if token else '?'}, "
f"tax_buy={token.tax_buy_bps if token else '?'}bps, "
f"tax_sell={token.tax_sell_bps if token else '?'}bps"
),
},
"deployer": {
"deployer": deployer.wallet_id if deployer else "unknown",
"reputation_score": deployer.reputation_score if deployer else 50,
"rug_count": deployer.rug_count if deployer else 0,
"deployments": len(deployer.deployments) if deployer else 0,
},
"news_sentiment": {
"subject_id": f"{chain}:{address}",
"news_count": len(news),
"avg_sentiment": f"{avg_sent:.2f}",
"top_headline": top_headline,
},
"rag_findings": {
"subject_id": f"{chain}:{address}",
"findings": [r.get("text", "")[:200] for r in rag[:5]],
},
"social_signals": {
"subject_id": f"{chain}:{address}",
"twitter_mentions": 0,
"telegram_groups": 0,
"discord_present": False,
},
"recommendation": {
"subject_id": f"{chain}:{address}",
"risk_score": risk_score,
"risk_tier": risk_tier.value,
"risk_factors": risk_factors_str,
},
}
# Run LLM sections in parallel
llm = LLMRouter()
async def _section(name: str, prompt: str) -> str:
try:
r = await llm.chat(prompt, model=model, max_tokens=400)
return r if r else _template_fallback(name, sections_ctx[name])
except Exception as e:
log.warning(f"section_{name}_llm_fail: {e}")
return _template_fallback(name, sections_ctx[name])
tasks = [_section(name, REPORT_PROMPTS[name].format(**ctx)) for name, ctx in sections_ctx.items()]
section_texts = await asyncio.gather(*tasks)
sections = dict(zip(sections_ctx.keys(), section_texts, strict=False))
# RAG-grounded validation: verify claims cite real sources
# Only validate rag_findings and sections that should be grounded in RAG
rag_sources = [r.get("text", "") for r in rag[:10]] # Top 10 RAG chunks as sources
validated_sections = dict(sections) # Copy for validation
if rag_sources:
# Validate rag_findings section against RAG sources
if "rag_findings" in validated_sections:
rag_findings = validated_sections["rag_findings"]
result = validate_section(rag_findings, rag_sources, on_unciteable="strip")
validated_sections["rag_findings"] = result["validated_text"]
log.info(
"rag_findings_validated validation_rate=%.2f unciteable=%d",
result["validation_rate"],
result["unciteable_count"],
)
# Validate executive_summary and recommendation if they mention RAG findings
for section_name in ["executive_summary", "recommendation"]:
if section_name in validated_sections:
section_text = validated_sections[section_name]
# Only validate if section has citations [N]
if "[" in section_text and "]" in section_text:
result = validate_section(section_text, rag_sources, on_unciteable="strip")
validated_sections[section_name] = result["validated_text"]
if result["unciteable_count"] > 0:
log.info(
"%s_validated unciteable=%d validation_rate=%.2f",
section_name,
result["unciteable_count"],
result["validation_rate"],
)
sections = validated_sections
# Build report
report_id = uuid4().hex
subject_id = f"{chain}:{address}"
report = ScanReport(
report_id=report_id,
subject_type="token",
subject_id=subject_id,
generated_at=utcnow(),
generated_by_model=model,
risk_score=risk_score,
risk_tier=risk_tier,
sections=sections,
)
log.info(
"report_generated type=token subject=%s risk=%d factors=%d took_ms=%d",
subject_id,
risk_score,
len(risk_factors),
int((time.monotonic() - start) * 1000),
)
return report
async def generate_wallet_report(catalog, chain: str, address: str, model: str = "deepseek-v3") -> ScanReport:
"""Generate a research report for a wallet."""
data = await _gather_wallet(catalog, chain, address)
if "error" in data:
raise ValueError(data["error"])
risk_score, risk_factors, risk_tier = _compute_risk_wallet(data)
risk_factors_str = ", ".join(risk_factors) if risk_factors else "none detected"
news = data.get("news", [])
rag = data.get("rag_findings", [])
avg_sent = sum(n.sentiment_score or 0 for n in news) / len(news) if news else 0
sections_ctx = {
"executive_summary": {
"subject_type": "wallet",
"subject_id": f"{chain}:{address}",
"risk_score": risk_score,
"risk_tier": risk_tier.value,
"risk_factors": risk_factors_str,
},
"onchain": {
"subject_id": f"{chain}:{address}",
"data": f"tx_count={data.get('wallet').tx_count if data.get('wallet') else '?'}, "
f"is_known_exchange={data.get('wallet').is_known_exchange if data.get('wallet') else '?'}",
},
"deployer": {"deployer": "n/a (wallet report)", "reputation_score": 50, "rug_count": 0, "deployments": 0},
"news_sentiment": {
"subject_id": f"{chain}:{address}",
"news_count": len(news),
"avg_sentiment": f"{avg_sent:.2f}",
"top_headline": news[0].title if news else "no recent news",
},
"rag_findings": {
"subject_id": f"{chain}:{address}",
"findings": [r.get("text", "")[:200] for r in rag[:5]],
},
"social_signals": {
"subject_id": f"{chain}:{address}",
"twitter_mentions": 0,
"telegram_groups": 0,
"discord_present": False,
},
"recommendation": {
"subject_id": f"{chain}:{address}",
"risk_score": risk_score,
"risk_tier": risk_tier.value,
"risk_factors": risk_factors_str,
},
}
llm = LLMRouter()
async def _section(name, prompt):
try:
r = await llm.chat(prompt, model=model, max_tokens=400)
return r if r else _template_fallback(name, sections_ctx[name])
except Exception:
return _template_fallback(name, sections_ctx[name])
tasks = [_section(n, REPORT_PROMPTS[n].format(**ctx)) for n, ctx in sections_ctx.items()]
section_texts = await asyncio.gather(*tasks)
sections = dict(zip(sections_ctx.keys(), section_texts, strict=False))
# RAG-grounded validation (same as token reports)
rag_sources = [r.get("text", "") for r in rag[:10]]
validated_sections = dict(sections)
if rag_sources:
if "rag_findings" in validated_sections:
rag_findings = validated_sections["rag_findings"]
result = validate_section(rag_findings, rag_sources, on_unciteable="strip")
validated_sections["rag_findings"] = result["validated_text"]
log.info(
"rag_findings_validated validation_rate=%.2f unciteable=%d",
result["validation_rate"],
result["unciteable_count"],
)
for section_name in ["executive_summary", "recommendation"]:
if section_name in validated_sections:
section_text = validated_sections[section_name]
if "[" in section_text and "]" in section_text:
result = validate_section(section_text, rag_sources, on_unciteable="strip")
validated_sections[section_name] = result["validated_text"]
if result["unciteable_count"] > 0:
log.info(
"%s_validated unciteable=%d validation_rate=%.2f",
section_name,
result["unciteable_count"],
result["validation_rate"],
)
sections = validated_sections
report_id = uuid4().hex
subject_id = f"{chain}:{address}"
return ScanReport(
report_id=report_id,
subject_type="wallet",
subject_id=subject_id,
generated_at=utcnow(),
generated_by_model=model,
risk_score=risk_score,
risk_tier=risk_tier,
sections=sections,
)
def _template_fallback(name: str, ctx: dict) -> str:
"""Templated content for when LLM is unreachable."""
sid = ctx.get("subject_id", "unknown")
rs = ctx.get("risk_score", "?")
rt = ctx.get("risk_tier", "?")
rf = ctx.get("risk_factors", "n/a")
if name == "executive_summary":
return (
f"## Executive Summary\n\n"
f"Subject {sid} has a risk score of {rs}/100 (tier: {rt}). "
f"Key risk factors: {rf}. "
f"This is a templated fallback (LLM unavailable). For full analysis, ensure LiteLLM is reachable."
)
if name == "onchain":
return f"## On-Chain Activity\n\n{ctx.get('data', 'no data')}"
if name == "deployer":
return f"## Deployer Analysis\n\nDeployer: {ctx.get('deployer', 'unknown')}\nReputation: {ctx.get('reputation_score', '?')}/100"
if name == "news_sentiment":
return f"## News Sentiment\n\n{ctx.get('news_count', 0)} recent articles. Avg sentiment: {ctx.get('avg_sentiment', 0)}"
if name == "rag_findings":
return f"## RAG Findings\n\n{len(ctx.get('findings', []))} findings (templated)"
if name == "social_signals":
return "## Social Signals\n\nTemplated (no real data)"
if name == "recommendation":
verdict = "AVOID" if rs >= 75 else "CAUTION" if rs >= 50 else "NEUTRAL" if rs >= 25 else "OPPORTUNITY"
return f"## Recommendation\n\n**{verdict}** (risk {rs}/100). Templated fallback."
return f"## {name.title()}\n\n(Templated fallback)"
# ── Save to Postgres + MinIO ────────────────────────────────────────
async def save_report(catalog, report: ScanReport) -> bool:
"""Persist report metadata to Postgres + markdown to MinIO."""
if not catalog._health.postgres:
return False
try:
async with catalog._pg_pool.acquire() as conn:
import json as _json
await conn.execute(
"""INSERT INTO scan_reports
(report_id, subject_type, subject_id, generated_at, generated_by_model,
risk_score, risk_tier, sections, markdown_url, paid_via_x402)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (report_id) DO UPDATE SET
sections=EXCLUDED.sections,
risk_score=EXCLUDED.risk_score,
risk_tier=EXCLUDED.risk_tier""",
report.report_id,
report.subject_type,
report.subject_id,
report.generated_at,
report.generated_by_model,
report.risk_score,
report.risk_tier.value,
_json.dumps(report.sections),
str(report.markdown_url) if report.markdown_url else None,
report.paid_via_x402,
)
# Try MinIO upload (graceful if not available)
if catalog._health.minio:
try:
# MinIO upload is complex; skip for v1, store markdown in Postgres instead
# Future: use boto3 or httpx PUT to minio with signed URL
pass
except Exception as e:
log.debug(f"minio_upload_skip: {e}")
return True
except Exception as e:
log.warning(f"save_report_fail: {e}")
return False

View file

@ -0,0 +1,137 @@
"""T29 Research Report Generator — HTTP routes.
Per v4.0 §T29. POST /api/v1/reports/generate composes a research report
from every data source, sold via x402 at $5/report.
Pricing tiers (v4.0):
Single report: $5
Bulk batch 20: $50 (bulk discount)
Subscription: $500/mo (unlimited)
x402 payment gate is enforced by the middleware in app/domain/x402/middleware.py
when an X-Payment header is required. For the open-source public preview, the
endpoint is callable without payment but the response includes paid_via_x402=null
so the caller can decide whether to integrate the payment flow.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.catalog.service import get_catalog
from app.domain.reports.generator import (
generate_token_report,
generate_wallet_report,
save_report,
)
router = APIRouter(prefix="/api/v1/reports", tags=["reports"])
class GenerateRequest(BaseModel):
subject_type: str = Field(..., pattern="^(token|wallet)$")
subject_id: str = Field(..., description='"chain:address"')
model: str = "deepseek-v3"
save: bool = True
class GenerateResponse(BaseModel):
report_id: str
subject_type: str
subject_id: str
risk_score: int
risk_tier: str
risk_factors: list[str] = Field(default_factory=list)
generated_by_model: str
generated_at: str
sections: dict[str, str] = Field(default_factory=dict)
markdown: str
paid_via_x402: str | None = None
error: str | None = None
@router.post("/generate", response_model=GenerateResponse)
async def generate_report(req: GenerateRequest) -> GenerateResponse:
"""Generate a research report for a token or wallet.
Composes 7 sections in parallel via LiteLLM. Falls back to templated
content if LLM is unreachable. Saves to Postgres on success.
"""
catalog = get_catalog()
await catalog._init_stores()
if ":" not in req.subject_id:
raise HTTPException(400, "subject_id must be 'chain:address'")
chain, address = req.subject_id.split(":", 1)
try:
if req.subject_type == "token":
report = await generate_token_report(catalog, chain, address, model=req.model)
else:
report = await generate_wallet_report(catalog, chain, address, model=req.model)
except ValueError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, f"report_generation_failed: {e}")
if req.save:
await save_report(catalog, report)
# Derive risk_factors from sections (parse them back if needed)
risk_factors = _extract_risk_factors(report.sections.get("executive_summary", ""))
return GenerateResponse(
report_id=report.report_id,
subject_type=report.subject_type,
subject_id=report.subject_id,
risk_score=report.risk_score,
risk_tier=report.risk_tier.value,
risk_factors=risk_factors,
generated_by_model=report.generated_by_model,
generated_at=report.generated_at.isoformat(),
sections=report.sections,
markdown=report.to_markdown(),
paid_via_x402=report.paid_via_x402,
)
def _extract_risk_factors(exec_summary: str) -> list[str]:
"""Heuristically extract risk factor names from the exec summary."""
if not exec_summary:
return []
keywords = [
"honeypot",
"mintable",
"proxy",
"high_buy_tax",
"high_sell_tax",
"deployer_rugs",
"low_deployer_reputation",
"bearish_news",
"cross_chain",
"flagged_suspicious",
"high_tx_volume",
]
text_l = exec_summary.lower()
return [k for k in keywords if k in text_l]
@router.get("/{report_id}")
async def get_report(report_id: str) -> dict:
"""Retrieve a previously generated report from Postgres."""
catalog = get_catalog()
await catalog._init_stores()
if not catalog._health.postgres:
raise HTTPException(503, "postgres unavailable")
try:
import json as _json
async with catalog._pg_pool.acquire() as conn:
r = await conn.fetchrow("SELECT * FROM scan_reports WHERE report_id=$1", report_id)
if not r:
raise HTTPException(404, "report not found")
d = dict(r)
if isinstance(d.get("sections"), str):
d["sections"] = _json.loads(d["sections"])
d["generated_at"] = d["generated_at"].isoformat()
return d
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, f"get_report_fail: {e}")