Hermes commited on
Commit
24dd239
·
1 Parent(s): fa20a11

feat(t29): research report generator

Browse files
backend/app/domain/reports/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """T29 Reports — thin HTTP layer."""
2
+ from .router import router
3
+
4
+ __all__ = ["router"]
backend/app/domain/reports/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (268 Bytes). View file
 
backend/app/domain/reports/__pycache__/generator.cpython-311.pyc ADDED
Binary file (26.8 kB). View file
 
backend/app/domain/reports/__pycache__/router.cpython-311.pyc ADDED
Binary file (7.8 kB). View file
 
backend/app/domain/reports/generator.py ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T29 Research Report Generator.
2
+
3
+ Per v4.0 §T29. Given a token or wallet, compose a Markdown report
4
+ from every data source, sold via x402 at $5/report.
5
+
6
+ Sections (parallel-composable):
7
+ - executive_summary (LLM)
8
+ - onchain (catalog + RAG)
9
+ - deployer (Neo4j + reputation)
10
+ - news_sentiment (news_items + LLM summary)
11
+ - rag_findings (RAG engine)
12
+ - social_signals (placeholder v1)
13
+ - risk_assessment (deterministic, from catalog.reputation weights)
14
+ - recommendation (LLM, based on all sections)
15
+
16
+ Deterministic risk score (no LLM). LLM only for narrative text.
17
+ Falls back to templated content if LiteLLM is unreachable.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ import time
24
+ from typing import Any
25
+ from uuid import uuid4
26
+
27
+ from pydantic import BaseModel, Field
28
+
29
+ from app.catalog.llm_router import LLMRouter
30
+ from app.catalog.models import (
31
+ RiskTier,
32
+ ScanReport,
33
+ utcnow,
34
+ )
35
+ from app.catalog.reputation import WEIGHTS as REP_WEIGHTS
36
+ from app.catalog.service import get_catalog
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+
41
+ # ── Section prompts (v4.0 §T29) ─────────────────────────────────────
42
+ REPORT_PROMPTS: dict[str, str] = {
43
+ "executive_summary": """You are an analyst at RugMunch Intelligence, a crypto scam-detection platform.
44
+ Write a 2-3 paragraph executive summary for a research report on this asset.
45
+
46
+ Subject type: {subject_type}
47
+ Subject ID: {subject_id}
48
+ Risk score: {risk_score}/100 ({risk_tier})
49
+ Key risk factors: {risk_factors}
50
+
51
+ Be concise. An analyst should be able to read this in 30 seconds and decide whether to dig deeper.
52
+ Use plain English. No hedging. State the verdict clearly.""",
53
+
54
+ "onchain": """Write a 2-paragraph on-chain analysis for:
55
+
56
+ Subject: {subject_id}
57
+ Data: {data}
58
+
59
+ Cover: deployment, holders, liquidity, volume, contract characteristics.
60
+ If data is missing, say so explicitly. No speculation.""",
61
+
62
+ "deployer": """Write a 2-paragraph deployer analysis for:
63
+
64
+ Deployer wallet: {deployer}
65
+ Reputation: {reputation_score}/100
66
+ Rug count: {rug_count}
67
+ Prior deployments: {deployments}
68
+
69
+ Cover: track record, prior rugs, longevity, news signals. Verdict on whether the deployer is trustworthy.""",
70
+
71
+ "news_sentiment": """Write a 1-paragraph news sentiment summary for:
72
+
73
+ Subject: {subject_id}
74
+ Recent news count: {news_count}
75
+ Average sentiment: {avg_sentiment}
76
+ Top headline: {top_headline}
77
+
78
+ Verdict: bullish, bearish, or risk-elevating.""",
79
+
80
+ "rag_findings": """Write a 1-paragraph RAG findings summary for:
81
+
82
+ Subject: {subject_id}
83
+ Findings: {findings}
84
+
85
+ Focus on the highest-confidence cross-references between news, on-chain, and social.""",
86
+
87
+ "social_signals": """Write a 1-paragraph social signals summary for:
88
+
89
+ Subject: {subject_id}
90
+ Twitter mentions: {twitter_mentions}
91
+ Telegram groups: {telegram_groups}
92
+ Discord present: {discord_present}
93
+
94
+ Verdict on community strength and authenticity.""",
95
+
96
+ "recommendation": """Based on the full report:
97
+
98
+ Subject: {subject_id}
99
+ Risk score: {risk_score}/100 ({risk_tier})
100
+ Top factors: {risk_factors}
101
+
102
+ Write a 1-paragraph RECOMMENDATION. Be direct: AVOID / CAUTION / NEUTRAL / OPPORTUNITY.
103
+ Justify in 2 sentences. If the asset is a serial rugger, say so clearly.""",
104
+ }
105
+
106
+
107
+ # ── Data gathering (fan-out from catalog) ─────────────────────────
108
+ async def _gather_token(catalog, chain: str, address: str) -> dict:
109
+ """Gather all data sources for a token."""
110
+ from app.catalog.models import Chain
111
+
112
+ try:
113
+ c = Chain(chain)
114
+ except ValueError:
115
+ return {"error": f"unknown chain: {chain}"}
116
+ token_id = f"{chain}:{address}"
117
+ token, deployer, news, rag_findings, _risk = await asyncio.gather(
118
+ catalog.get_token(c, address),
119
+ catalog.get_wallet(c, address) if False else asyncio.sleep(0, result=None), # placeholder
120
+ _fetch_news(catalog, token_id, since_hours=720),
121
+ catalog.rag_search(query=token_id, collection="scam_intel", top_k=10),
122
+ catalog.get_token_risk(c, address),
123
+ )
124
+ deployer = None
125
+ if token and token.deployer_wallet_id:
126
+ try:
127
+ deployer = await catalog.get_wallet_by_id(token.deployer_wallet_id)
128
+ except Exception:
129
+ pass
130
+ return {
131
+ "token": token,
132
+ "deployer": deployer,
133
+ "news": news,
134
+ "rag_findings": rag_findings,
135
+ "risk": _risk,
136
+ }
137
+
138
+
139
+ async def _gather_wallet(catalog, chain: str, address: str) -> dict:
140
+ """Gather data for a wallet report."""
141
+ from app.catalog.models import Chain
142
+
143
+ try:
144
+ c = Chain(chain)
145
+ except ValueError:
146
+ return {"error": f"unknown chain: {chain}"}
147
+ wallet_id = f"{chain}:{address}"
148
+ wallet, news, rag_findings, entity = await asyncio.gather(
149
+ catalog.get_wallet(c, address),
150
+ _fetch_news(catalog, wallet_id, since_hours=720),
151
+ catalog.rag_search(query=wallet_id, collection="wallet_labels", top_k=10),
152
+ catalog.resolve_entity(wallet_id),
153
+ )
154
+ return {
155
+ "wallet": wallet,
156
+ "news": news,
157
+ "rag_findings": rag_findings,
158
+ "entity": entity,
159
+ }
160
+
161
+
162
+ async def _fetch_news(catalog, subject_id: str, since_hours: int = 720) -> list:
163
+ """Fetch news mentioning this subject."""
164
+ if not catalog._health.postgres:
165
+ return []
166
+ try:
167
+ async with catalog._pg_pool.acquire() as conn:
168
+ rows = await conn.fetch(
169
+ """SELECT news_id, title, summary, source, published_at, sentiment_score
170
+ FROM news_items
171
+ WHERE $1 = ANY(tokens_mentioned)
172
+ OR $1 = ANY(wallets_mentioned)
173
+ OR title ILIKE $2
174
+ ORDER BY published_at DESC LIMIT 20""",
175
+ subject_id, f"%{subject_id.split(':')[-1][:8]}%",
176
+ )
177
+ from app.domain.news.router import _adapt_legacy_row as _adapt_news_row
178
+ return [_adapt_news_row(dict(r)) for r in rows]
179
+ except Exception as e:
180
+ log.warning(f"fetch_news_fail: {e}")
181
+ return []
182
+
183
+
184
+ # ── Risk scoring (deterministic) ───────────────────────────────────
185
+ def _compute_risk_token(token_data: dict) -> tuple[int, list[str], RiskTier]:
186
+ """Deterministic 0-100 risk score from token data."""
187
+ score = 0
188
+ factors = []
189
+ token = token_data.get("token")
190
+ deployer = token_data.get("deployer")
191
+ if token:
192
+ if token.is_honeypot:
193
+ score += 50
194
+ factors.append("honeypot")
195
+ if token.is_mintable:
196
+ score += 20
197
+ factors.append("mintable")
198
+ if token.is_proxy:
199
+ score += 10
200
+ factors.append("proxy")
201
+ if token.tax_buy_bps and token.tax_buy_bps > 1000: # >10%
202
+ score += 15
203
+ factors.append(f"high_buy_tax_{token.tax_buy_bps}bps")
204
+ if token.tax_sell_bps and token.tax_sell_bps > 1000:
205
+ score += 15
206
+ factors.append(f"high_sell_tax_{token.tax_sell_bps}bps")
207
+ if token.risk_factors:
208
+ score += min(len(token.risk_factors) * 5, 25)
209
+ if deployer and hasattr(deployer, "rug_count"):
210
+ if deployer.rug_count > 0:
211
+ score += 30 * min(deployer.rug_count, 3)
212
+ factors.append(f"deployer_{deployer.rug_count}_prior_rugs")
213
+ if deployer.reputation_score and deployer.reputation_score < 30:
214
+ score += 20
215
+ factors.append("low_deployer_reputation")
216
+ news = token_data.get("news", [])
217
+ if news:
218
+ bearish = [n for n in news if (n.sentiment_score or 0) < -0.3]
219
+ if bearish:
220
+ score += 15
221
+ factors.append(f"bearish_news_{len(bearish)}")
222
+ score = min(score, 100)
223
+ if score < 25:
224
+ tier = RiskTier.LOW
225
+ elif score < 50:
226
+ tier = RiskTier.MEDIUM
227
+ elif score < 75:
228
+ tier = RiskTier.HIGH
229
+ else:
230
+ tier = RiskTier.CRITICAL
231
+ return score, factors, tier
232
+
233
+
234
+ def _compute_risk_wallet(wallet_data: dict) -> tuple[int, list[str], RiskTier]:
235
+ score = 0
236
+ factors = []
237
+ wallet = wallet_data.get("wallet")
238
+ entity = wallet_data.get("entity", {})
239
+ if entity and entity.get("wallets"):
240
+ if len(entity["wallets"]) > 2:
241
+ score += 15
242
+ factors.append(f"cross_chain_{len(entity['wallets'])}")
243
+ if wallet and wallet.is_suspicious:
244
+ score += 30
245
+ factors.append("flagged_suspicious")
246
+ if wallet and wallet.tx_count > 10000:
247
+ score += 10
248
+ factors.append("high_tx_volume")
249
+ news = wallet_data.get("news", [])
250
+ bearish = [n for n in news if (n.sentiment_score or 0) < -0.3]
251
+ if bearish:
252
+ score += 15
253
+ factors.append(f"bearish_news_{len(bearish)}")
254
+ score = min(score, 100)
255
+ if score < 25:
256
+ tier = RiskTier.LOW
257
+ elif score < 50:
258
+ tier = RiskTier.MEDIUM
259
+ elif score < 75:
260
+ tier = RiskTier.HIGH
261
+ else:
262
+ tier = RiskTier.CRITICAL
263
+ return score, factors, tier
264
+
265
+
266
+ # ── Report generation ──────────────────────────────────────────────
267
+ async def generate_token_report(
268
+ catalog, chain: str, address: str, model: str = "deepseek-v3"
269
+ ) -> ScanReport:
270
+ """Generate a research report for a token. Falls back to templated
271
+ sections if LLM is unreachable."""
272
+ start = time.monotonic()
273
+ data = await _gather_token(catalog, chain, address)
274
+ if "error" in data:
275
+ raise ValueError(data["error"])
276
+ risk_score, risk_factors, risk_tier = _compute_risk_token(data)
277
+ risk_factors_str = ", ".join(risk_factors) if risk_factors else "none detected"
278
+ token = data.get("token")
279
+ deployer = data.get("deployer")
280
+ news = data.get("news", [])
281
+ rag = data.get("rag_findings", [])
282
+
283
+ avg_sent = (
284
+ sum(n.sentiment_score or 0 for n in news) / len(news) if news else 0
285
+ )
286
+ top_headline = news[0].title if news else "no recent news"
287
+
288
+ sections_ctx: dict[str, dict[str, Any]] = {
289
+ "executive_summary": {
290
+ "subject_type": "token", "subject_id": f"{chain}:{address}",
291
+ "risk_score": risk_score, "risk_tier": risk_tier.value,
292
+ "risk_factors": risk_factors_str,
293
+ },
294
+ "onchain": {
295
+ "subject_id": f"{chain}:{address}",
296
+ "data": (
297
+ f"Symbol={token.symbol if token else '?'}, "
298
+ f"Decimals={token.decimals if token else '?'}, "
299
+ f"Deployed={token.deployed_at.isoformat() if token else '?'}, "
300
+ f"honeypot={token.is_honeypot if token else '?'}, "
301
+ f"mintable={token.is_mintable if token else '?'}, "
302
+ f"tax_buy={token.tax_buy_bps if token else '?'}bps, "
303
+ f"tax_sell={token.tax_sell_bps if token else '?'}bps"
304
+ ),
305
+ },
306
+ "deployer": {
307
+ "deployer": deployer.wallet_id if deployer else "unknown",
308
+ "reputation_score": deployer.reputation_score if deployer else 50,
309
+ "rug_count": deployer.rug_count if deployer else 0,
310
+ "deployments": len(deployer.deployments) if deployer else 0,
311
+ },
312
+ "news_sentiment": {
313
+ "subject_id": f"{chain}:{address}",
314
+ "news_count": len(news),
315
+ "avg_sentiment": f"{avg_sent:.2f}",
316
+ "top_headline": top_headline,
317
+ },
318
+ "rag_findings": {
319
+ "subject_id": f"{chain}:{address}",
320
+ "findings": [
321
+ r.get("text", "")[:200] for r in rag[:5]
322
+ ],
323
+ },
324
+ "social_signals": {
325
+ "subject_id": f"{chain}:{address}",
326
+ "twitter_mentions": 0,
327
+ "telegram_groups": 0,
328
+ "discord_present": False,
329
+ },
330
+ "recommendation": {
331
+ "subject_id": f"{chain}:{address}",
332
+ "risk_score": risk_score,
333
+ "risk_tier": risk_tier.value,
334
+ "risk_factors": risk_factors_str,
335
+ },
336
+ }
337
+
338
+ # Run LLM sections in parallel
339
+ llm = LLMRouter()
340
+
341
+ async def _section(name: str, prompt: str) -> str:
342
+ try:
343
+ r = await llm.chat(prompt, model=model, max_tokens=400)
344
+ return r if r else _template_fallback(name, sections_ctx[name])
345
+ except Exception as e:
346
+ log.warning(f"section_{name}_llm_fail: {e}")
347
+ return _template_fallback(name, sections_ctx[name])
348
+
349
+ tasks = [
350
+ _section(name, REPORT_PROMPTS[name].format(**ctx))
351
+ for name, ctx in sections_ctx.items()
352
+ ]
353
+ section_texts = await asyncio.gather(*tasks)
354
+ sections = dict(zip(sections_ctx.keys(), section_texts))
355
+
356
+ # Build report
357
+ report_id = uuid4().hex
358
+ subject_id = f"{chain}:{address}"
359
+ report = ScanReport(
360
+ report_id=report_id,
361
+ subject_type="token",
362
+ subject_id=subject_id,
363
+ generated_at=utcnow(),
364
+ generated_by_model=model,
365
+ risk_score=risk_score,
366
+ risk_tier=risk_tier,
367
+ sections=sections,
368
+ )
369
+ log.info(
370
+ "report_generated type=token subject=%s risk=%d factors=%d took_ms=%d",
371
+ subject_id, risk_score, len(risk_factors), int((time.monotonic() - start) * 1000),
372
+ )
373
+ return report
374
+
375
+
376
+ async def generate_wallet_report(
377
+ catalog, chain: str, address: str, model: str = "deepseek-v3"
378
+ ) -> ScanReport:
379
+ """Generate a research report for a wallet."""
380
+ data = await _gather_wallet(catalog, chain, address)
381
+ if "error" in data:
382
+ raise ValueError(data["error"])
383
+ risk_score, risk_factors, risk_tier = _compute_risk_wallet(data)
384
+ risk_factors_str = ", ".join(risk_factors) if risk_factors else "none detected"
385
+ news = data.get("news", [])
386
+ rag = data.get("rag_findings", [])
387
+ avg_sent = sum(n.sentiment_score or 0 for n in news) / len(news) if news else 0
388
+
389
+ sections_ctx = {
390
+ "executive_summary": {
391
+ "subject_type": "wallet", "subject_id": f"{chain}:{address}",
392
+ "risk_score": risk_score, "risk_tier": risk_tier.value,
393
+ "risk_factors": risk_factors_str,
394
+ },
395
+ "onchain": {
396
+ "subject_id": f"{chain}:{address}",
397
+ "data": f"tx_count={data.get('wallet').tx_count if data.get('wallet') else '?'}, "
398
+ f"is_known_exchange={data.get('wallet').is_known_exchange if data.get('wallet') else '?'}",
399
+ },
400
+ "deployer": {"deployer": "n/a (wallet report)", "reputation_score": 50, "rug_count": 0, "deployments": 0},
401
+ "news_sentiment": {
402
+ "subject_id": f"{chain}:{address}",
403
+ "news_count": len(news), "avg_sentiment": f"{avg_sent:.2f}",
404
+ "top_headline": news[0].title if news else "no recent news",
405
+ },
406
+ "rag_findings": {
407
+ "subject_id": f"{chain}:{address}",
408
+ "findings": [r.get("text", "")[:200] for r in rag[:5]],
409
+ },
410
+ "social_signals": {"subject_id": f"{chain}:{address}", "twitter_mentions": 0, "telegram_groups": 0, "discord_present": False},
411
+ "recommendation": {
412
+ "subject_id": f"{chain}:{address}", "risk_score": risk_score,
413
+ "risk_tier": risk_tier.value, "risk_factors": risk_factors_str,
414
+ },
415
+ }
416
+
417
+ llm = LLMRouter()
418
+
419
+ async def _section(name, prompt):
420
+ try:
421
+ r = await llm.chat(prompt, model=model, max_tokens=400)
422
+ return r if r else _template_fallback(name, sections_ctx[name])
423
+ except Exception:
424
+ return _template_fallback(name, sections_ctx[name])
425
+
426
+ tasks = [
427
+ _section(n, REPORT_PROMPTS[n].format(**ctx))
428
+ for n, ctx in sections_ctx.items()
429
+ ]
430
+ section_texts = await asyncio.gather(*tasks)
431
+ sections = dict(zip(sections_ctx.keys(), section_texts))
432
+
433
+ report_id = uuid4().hex
434
+ subject_id = f"{chain}:{address}"
435
+ return ScanReport(
436
+ report_id=report_id,
437
+ subject_type="wallet",
438
+ subject_id=subject_id,
439
+ generated_at=utcnow(),
440
+ generated_by_model=model,
441
+ risk_score=risk_score,
442
+ risk_tier=risk_tier,
443
+ sections=sections,
444
+ )
445
+
446
+
447
+ def _template_fallback(name: str, ctx: dict) -> str:
448
+ """Templated content for when LLM is unreachable."""
449
+ sid = ctx.get("subject_id", "unknown")
450
+ rs = ctx.get("risk_score", "?")
451
+ rt = ctx.get("risk_tier", "?")
452
+ rf = ctx.get("risk_factors", "n/a")
453
+ if name == "executive_summary":
454
+ return (
455
+ f"## Executive Summary\n\n"
456
+ f"Subject {sid} has a risk score of {rs}/100 (tier: {rt}). "
457
+ f"Key risk factors: {rf}. "
458
+ f"This is a templated fallback (LLM unavailable). For full analysis, ensure LiteLLM is reachable."
459
+ )
460
+ if name == "onchain":
461
+ return f"## On-Chain Activity\n\n{ctx.get('data', 'no data')}"
462
+ if name == "deployer":
463
+ return f"## Deployer Analysis\n\nDeployer: {ctx.get('deployer', 'unknown')}\nReputation: {ctx.get('reputation_score', '?')}/100"
464
+ if name == "news_sentiment":
465
+ return f"## News Sentiment\n\n{ctx.get('news_count', 0)} recent articles. Avg sentiment: {ctx.get('avg_sentiment', 0)}"
466
+ if name == "rag_findings":
467
+ return f"## RAG Findings\n\n{len(ctx.get('findings', []))} findings (templated)"
468
+ if name == "social_signals":
469
+ return "## Social Signals\n\nTemplated (no real data)"
470
+ if name == "recommendation":
471
+ verdict = "AVOID" if rs >= 75 else "CAUTION" if rs >= 50 else "NEUTRAL" if rs >= 25 else "OPPORTUNITY"
472
+ return f"## Recommendation\n\n**{verdict}** (risk {rs}/100). Templated fallback."
473
+ return f"## {name.title()}\n\n(Templated fallback)"
474
+
475
+
476
+ # ── Save to Postgres + MinIO ────────────────────────────────────────
477
+ async def save_report(catalog, report: ScanReport) -> bool:
478
+ """Persist report metadata to Postgres + markdown to MinIO."""
479
+ if not catalog._health.postgres:
480
+ return False
481
+ try:
482
+ async with catalog._pg_pool.acquire() as conn:
483
+ import json as _json
484
+ await conn.execute(
485
+ """INSERT INTO scan_reports
486
+ (report_id, subject_type, subject_id, generated_at, generated_by_model,
487
+ risk_score, risk_tier, sections, markdown_url, paid_via_x402)
488
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
489
+ ON CONFLICT (report_id) DO UPDATE SET
490
+ sections=EXCLUDED.sections,
491
+ risk_score=EXCLUDED.risk_score,
492
+ risk_tier=EXCLUDED.risk_tier""",
493
+ report.report_id, report.subject_type, report.subject_id,
494
+ report.generated_at, report.generated_by_model,
495
+ report.risk_score, report.risk_tier.value,
496
+ _json.dumps(report.sections), str(report.markdown_url) if report.markdown_url else None,
497
+ report.paid_via_x402,
498
+ )
499
+ # Try MinIO upload (graceful if not available)
500
+ if catalog._health.minio:
501
+ try:
502
+ import httpx
503
+ # MinIO upload is complex; skip for v1, store markdown in Postgres instead
504
+ # Future: use boto3 or httpx PUT to minio with signed URL
505
+ pass
506
+ except Exception as e:
507
+ log.debug(f"minio_upload_skip: {e}")
508
+ return True
509
+ except Exception as e:
510
+ log.warning(f"save_report_fail: {e}")
511
+ return False
backend/app/domain/reports/router.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T29 Research Report Generator — HTTP routes.
2
+
3
+ Per v4.0 §T29. POST /api/v1/reports/generate composes a research report
4
+ from every data source, sold via x402 at $5/report.
5
+
6
+ Pricing tiers (v4.0):
7
+ Single report: $5
8
+ Bulk batch 20: $50 (bulk discount)
9
+ Subscription: $500/mo (unlimited)
10
+
11
+ x402 payment gate is enforced by the middleware in app/domain/x402/middleware.py
12
+ when an X-Payment header is required. For the open-source public preview, the
13
+ endpoint is callable without payment but the response includes paid_via_x402=null
14
+ so the caller can decide whether to integrate the payment flow.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Optional
20
+
21
+ from fastapi import APIRouter, HTTPException
22
+ from pydantic import BaseModel, Field
23
+
24
+ from app.catalog.service import get_catalog
25
+ from app.domain.reports.generator import (
26
+ generate_token_report,
27
+ generate_wallet_report,
28
+ save_report,
29
+ )
30
+
31
+ router = APIRouter(prefix="/api/v1/reports", tags=["reports"])
32
+
33
+
34
+ class GenerateRequest(BaseModel):
35
+ subject_type: str = Field(..., pattern="^(token|wallet)$")
36
+ subject_id: str = Field(..., description='"chain:address"')
37
+ model: str = "deepseek-v3"
38
+ save: bool = True
39
+
40
+
41
+ class GenerateResponse(BaseModel):
42
+ report_id: str
43
+ subject_type: str
44
+ subject_id: str
45
+ risk_score: int
46
+ risk_tier: str
47
+ risk_factors: list[str] = Field(default_factory=list)
48
+ generated_by_model: str
49
+ generated_at: str
50
+ sections: dict[str, str] = Field(default_factory=dict)
51
+ markdown: str
52
+ paid_via_x402: Optional[str] = None
53
+ error: Optional[str] = None
54
+
55
+
56
+ @router.post("/generate", response_model=GenerateResponse)
57
+ async def generate_report(req: GenerateRequest) -> GenerateResponse:
58
+ """Generate a research report for a token or wallet.
59
+
60
+ Composes 7 sections in parallel via LiteLLM. Falls back to templated
61
+ content if LLM is unreachable. Saves to Postgres on success.
62
+ """
63
+ catalog = get_catalog()
64
+ await catalog._init_stores()
65
+ if ":" not in req.subject_id:
66
+ raise HTTPException(400, "subject_id must be 'chain:address'")
67
+ chain, address = req.subject_id.split(":", 1)
68
+ try:
69
+ if req.subject_type == "token":
70
+ report = await generate_token_report(catalog, chain, address, model=req.model)
71
+ else:
72
+ report = await generate_wallet_report(catalog, chain, address, model=req.model)
73
+ except ValueError as e:
74
+ raise HTTPException(400, str(e))
75
+ except Exception as e:
76
+ raise HTTPException(500, f"report_generation_failed: {e}")
77
+ if req.save:
78
+ await save_report(catalog, report)
79
+ # Derive risk_factors from sections (parse them back if needed)
80
+ risk_factors = _extract_risk_factors(report.sections.get("executive_summary", ""))
81
+ return GenerateResponse(
82
+ report_id=report.report_id,
83
+ subject_type=report.subject_type,
84
+ subject_id=report.subject_id,
85
+ risk_score=report.risk_score,
86
+ risk_tier=report.risk_tier.value,
87
+ risk_factors=risk_factors,
88
+ generated_by_model=report.generated_by_model,
89
+ generated_at=report.generated_at.isoformat(),
90
+ sections=report.sections,
91
+ markdown=report.to_markdown(),
92
+ paid_via_x402=report.paid_via_x402,
93
+ )
94
+
95
+
96
+ def _extract_risk_factors(exec_summary: str) -> list[str]:
97
+ """Heuristically extract risk factor names from the exec summary."""
98
+ if not exec_summary:
99
+ return []
100
+ keywords = [
101
+ "honeypot", "mintable", "proxy", "high_buy_tax", "high_sell_tax",
102
+ "deployer_rugs", "low_deployer_reputation", "bearish_news",
103
+ "cross_chain", "flagged_suspicious", "high_tx_volume",
104
+ ]
105
+ text_l = exec_summary.lower()
106
+ return [k for k in keywords if k in text_l]
107
+
108
+
109
+ @router.get("/{report_id}")
110
+ async def get_report(report_id: str) -> dict:
111
+ """Retrieve a previously generated report from Postgres."""
112
+ catalog = get_catalog()
113
+ await catalog._init_stores()
114
+ if not catalog._health.postgres:
115
+ raise HTTPException(503, "postgres unavailable")
116
+ try:
117
+ import json as _json
118
+ async with catalog._pg_pool.acquire() as conn:
119
+ r = await conn.fetchrow(
120
+ "SELECT * FROM scan_reports WHERE report_id=$1", report_id
121
+ )
122
+ if not r:
123
+ raise HTTPException(404, "report not found")
124
+ d = dict(r)
125
+ if isinstance(d.get("sections"), str):
126
+ d["sections"] = _json.loads(d["sections"])
127
+ d["generated_at"] = d["generated_at"].isoformat()
128
+ return d
129
+ except HTTPException:
130
+ raise
131
+ except Exception as e:
132
+ raise HTTPException(500, f"get_report_fail: {e}")
backend/main.py CHANGED
@@ -218,6 +218,7 @@ def _try_mount_v1_routers() -> int:
218
  "app.api.v1.catalog",
219
  "app.domain.news",
220
  "app.domain.news.admin_router",
 
221
  ]
222
 
223
  for module_path in v1_modules:
 
218
  "app.api.v1.catalog",
219
  "app.domain.news",
220
  "app.domain.news.admin_router",
221
+ "app.domain.reports",
222
  ]
223
 
224
  for module_path in v1_modules: