Hermes commited on
Commit
cb060b7
·
1 Parent(s): 680b3ba

feat(catalog+t28): wire all 5 stores + news intelligence product

Browse files
Files changed (37) hide show
  1. backend/app/catalog/__pycache__/llm_router.cpython-311.pyc +0 -0
  2. backend/app/catalog/__pycache__/models.cpython-311.pyc +0 -0
  3. backend/app/catalog/__pycache__/service.cpython-311.pyc +0 -0
  4. backend/app/catalog/init_schema.py +172 -0
  5. backend/app/catalog/service.py +24 -9
  6. backend/app/domain/__init__.py +1 -15
  7. backend/app/domain/__pycache__/__init__.cpython-311.pyc +0 -0
  8. backend/app/domain/alerts/__pycache__/__init__.cpython-311.pyc +0 -0
  9. backend/app/domain/alerts/__pycache__/broadcaster.cpython-311.pyc +0 -0
  10. backend/app/domain/alerts/__pycache__/models.cpython-311.pyc +0 -0
  11. backend/app/domain/alerts/__pycache__/repository.cpython-311.pyc +0 -0
  12. backend/app/domain/alerts/__pycache__/service.cpython-311.pyc +0 -0
  13. backend/app/domain/news/__init__.py +4 -0
  14. backend/app/domain/news/__pycache__/__init__.cpython-311.pyc +0 -0
  15. backend/app/domain/news/__pycache__/router.cpython-311.pyc +0 -0
  16. backend/app/domain/news/router.py +375 -0
  17. backend/app/domain/scanner/__pycache__/__init__.cpython-311.pyc +0 -0
  18. backend/app/domain/scanner/__pycache__/models.cpython-311.pyc +0 -0
  19. backend/app/domain/scanner/__pycache__/service.cpython-311.pyc +0 -0
  20. backend/app/domain/token/__pycache__/__init__.cpython-311.pyc +0 -0
  21. backend/app/domain/token/__pycache__/analyzer.cpython-311.pyc +0 -0
  22. backend/app/domain/token/__pycache__/models.cpython-311.pyc +0 -0
  23. backend/app/domain/token/__pycache__/repository.cpython-311.pyc +0 -0
  24. backend/app/domain/token/__pycache__/service.cpython-311.pyc +0 -0
  25. backend/app/domain/token/repository.py +0 -5
  26. backend/app/domain/token/service.py +0 -3
  27. backend/app/domain/wallet/__pycache__/__init__.cpython-311.pyc +0 -0
  28. backend/app/domain/wallet/__pycache__/analyzer.cpython-311.pyc +0 -0
  29. backend/app/domain/wallet/__pycache__/models.cpython-311.pyc +0 -0
  30. backend/app/domain/wallet/__pycache__/repository.cpython-311.pyc +0 -0
  31. backend/app/domain/wallet/__pycache__/service.cpython-311.pyc +0 -0
  32. backend/app/domain/wallet/repository.py +0 -7
  33. backend/app/domain/wallet/service.py +0 -3
  34. backend/app/domain/x402/__pycache__/__init__.cpython-311.pyc +0 -0
  35. backend/app/domain/x402/__pycache__/models.cpython-311.pyc +0 -0
  36. backend/app/domain/x402/__pycache__/service.cpython-311.pyc +0 -0
  37. backend/main.py +1 -0
backend/app/catalog/__pycache__/llm_router.cpython-311.pyc ADDED
Binary file (5.75 kB). View file
 
backend/app/catalog/__pycache__/models.cpython-311.pyc CHANGED
Binary files a/backend/app/catalog/__pycache__/models.cpython-311.pyc and b/backend/app/catalog/__pycache__/models.cpython-311.pyc differ
 
backend/app/catalog/__pycache__/service.cpython-311.pyc CHANGED
Binary files a/backend/app/catalog/__pycache__/service.cpython-311.pyc and b/backend/app/catalog/__pycache__/service.cpython-311.pyc differ
 
backend/app/catalog/init_schema.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Catalog database schema — initial migration.
2
+
3
+ Creates the tables referenced by the v4.0 catalog models.
4
+ Idempotent: safe to run multiple times.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import os
10
+
11
+ import asyncpg
12
+
13
+
14
+ SCHEMA = """
15
+ -- Tokens (T27)
16
+ CREATE TABLE IF NOT EXISTS tokens (
17
+ token_id TEXT PRIMARY KEY,
18
+ chain TEXT NOT NULL,
19
+ address TEXT NOT NULL,
20
+ symbol TEXT,
21
+ name TEXT,
22
+ decimals INT,
23
+ deployer_wallet_id TEXT,
24
+ deployed_at TIMESTAMPTZ NOT NULL,
25
+ initial_supply BIGINT,
26
+ current_supply BIGINT,
27
+ is_honeypot BOOLEAN,
28
+ is_mintable BOOLEAN,
29
+ is_proxy BOOLEAN,
30
+ tax_buy_bps INT,
31
+ tax_sell_bps INT,
32
+ risk_tier TEXT,
33
+ risk_score INT,
34
+ risk_factors TEXT[],
35
+ rag_embedding_id TEXT,
36
+ updated_at TIMESTAMPTZ DEFAULT NOW()
37
+ );
38
+ CREATE INDEX IF NOT EXISTS idx_tokens_chain ON tokens(chain);
39
+ CREATE INDEX IF NOT EXISTS idx_tokens_deployer ON tokens(deployer_wallet_id);
40
+ CREATE INDEX IF NOT EXISTS idx_tokens_risk ON tokens(risk_score DESC) WHERE risk_score IS NOT NULL;
41
+
42
+ -- Alerts
43
+ CREATE TABLE IF NOT EXISTS alerts (
44
+ alert_id TEXT PRIMARY KEY,
45
+ token_id TEXT,
46
+ wallet_id TEXT,
47
+ chain TEXT,
48
+ alert_type TEXT NOT NULL,
49
+ severity TEXT NOT NULL,
50
+ title TEXT NOT NULL,
51
+ description TEXT,
52
+ evidence JSONB DEFAULT '{}'::jsonb,
53
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
54
+ resolved_at TIMESTAMPTZ
55
+ );
56
+ CREATE INDEX IF NOT EXISTS idx_alerts_token ON alerts(token_id);
57
+ CREATE INDEX IF NOT EXISTS idx_alerts_wallet ON alerts(wallet_id);
58
+ CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at DESC);
59
+ CREATE INDEX IF NOT EXISTS idx_alerts_severity ON alerts(severity);
60
+
61
+ -- News items (T28)
62
+ CREATE TABLE IF NOT EXISTS news_items (
63
+ news_id TEXT PRIMARY KEY,
64
+ url TEXT NOT NULL,
65
+ title TEXT NOT NULL,
66
+ summary TEXT,
67
+ body_markdown TEXT,
68
+ source TEXT NOT NULL,
69
+ published_at TIMESTAMPTZ NOT NULL,
70
+ ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
71
+ chains_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[],
72
+ tokens_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[],
73
+ wallets_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[],
74
+ sentiment_score REAL,
75
+ ai_analysis TEXT,
76
+ rag_embedding_id TEXT,
77
+ body_tsv tsvector
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_news_published ON news_items(published_at DESC);
80
+ CREATE INDEX IF NOT EXISTS idx_news_source ON news_items(source);
81
+ CREATE INDEX IF NOT EXISTS idx_news_sentiment ON news_items(sentiment_score);
82
+ CREATE INDEX IF NOT EXISTS idx_news_body_tsv ON news_items USING gin(body_tsv);
83
+
84
+ -- Auto-update the body_tsv column on insert/update
85
+ CREATE OR REPLACE FUNCTION news_items_tsv_update() RETURNS trigger AS $$
86
+ BEGIN
87
+ NEW.body_tsv := to_tsvector('english', COALESCE(NEW.title, '') || ' ' ||
88
+ COALESCE(NEW.summary, '') || ' ' ||
89
+ COALESCE(NEW.body_markdown, ''));
90
+ RETURN NEW;
91
+ END
92
+ $$ LANGUAGE plpgsql;
93
+
94
+ DROP TRIGGER IF EXISTS news_items_tsv_trigger ON news_items;
95
+ CREATE TRIGGER news_items_tsv_trigger
96
+ BEFORE INSERT OR UPDATE ON news_items
97
+ FOR EACH ROW EXECUTE FUNCTION news_items_tsv_update();
98
+
99
+ -- Scan reports (T29)
100
+ CREATE TABLE IF NOT EXISTS scan_reports (
101
+ report_id TEXT PRIMARY KEY,
102
+ subject_type TEXT NOT NULL,
103
+ subject_id TEXT NOT NULL,
104
+ generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
105
+ generated_by_model TEXT NOT NULL,
106
+ risk_score INT NOT NULL,
107
+ risk_tier TEXT NOT NULL,
108
+ sections JSONB DEFAULT '{}'::jsonb,
109
+ markdown_url TEXT,
110
+ paid_via_x402 TEXT
111
+ );
112
+ CREATE INDEX IF NOT EXISTS idx_reports_subject ON scan_reports(subject_type, subject_id);
113
+ CREATE INDEX IF NOT EXISTS idx_reports_generated ON scan_reports(generated_at DESC);
114
+
115
+ -- RAG findings metadata (Qdrant has the vectors; this is metadata for query)
116
+ CREATE TABLE IF NOT EXISTS rag_findings (
117
+ finding_id TEXT PRIMARY KEY,
118
+ source_type TEXT NOT NULL,
119
+ source_url TEXT,
120
+ source_token_id TEXT,
121
+ source_wallet_id TEXT,
122
+ claim TEXT NOT NULL,
123
+ confidence REAL NOT NULL,
124
+ extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
125
+ qdrant_point_id TEXT NOT NULL
126
+ );
127
+ CREATE INDEX IF NOT EXISTS idx_findings_token ON rag_findings(source_token_id);
128
+ CREATE INDEX IF NOT EXISTS idx_findings_wallet ON rag_findings(source_wallet_id);
129
+
130
+ -- x402 receipts (T34)
131
+ CREATE TABLE IF NOT EXISTS x402_receipts (
132
+ tx_hash TEXT PRIMARY KEY,
133
+ agent_id TEXT,
134
+ tool TEXT NOT NULL,
135
+ amount_usd REAL NOT NULL,
136
+ chain TEXT,
137
+ paid_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
138
+ tier TEXT
139
+ );
140
+ CREATE INDEX IF NOT EXISTS idx_x402_paid_at ON x402_receipts(paid_at DESC);
141
+ CREATE INDEX IF NOT EXISTS idx_x402_tool ON x402_receipts(tool);
142
+ CREATE INDEX IF NOT EXISTS idx_x402_agent ON x402_receipts(agent_id);
143
+ """
144
+
145
+
146
+ async def main():
147
+ """Run the schema migration."""
148
+ cfg = {
149
+ "host": os.getenv("POSTGRES_HOST", "rmi-postgres"),
150
+ "port": int(os.getenv("POSTGRES_PORT", "5432")),
151
+ "user": os.getenv("POSTGRES_USER", "rmi"),
152
+ "password": os.getenv("POSTGRES_PASSWORD", "RMI_PROD_POSTGRES_2026"),
153
+ "database": os.getenv("POSTGRES_DB", "rmi"),
154
+ }
155
+ print(f"Connecting to postgres at {cfg['host']}:{cfg['port']} db={cfg['database']}")
156
+ conn = await asyncpg.connect(**cfg)
157
+ try:
158
+ await conn.execute(SCHEMA)
159
+ print("✓ Schema applied")
160
+ # Verify
161
+ tables = await conn.fetch(
162
+ "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
163
+ )
164
+ print(f"Tables ({len(tables)}):")
165
+ for t in tables:
166
+ print(f" {t['tablename']}")
167
+ finally:
168
+ await conn.close()
169
+
170
+
171
+ if __name__ == "__main__":
172
+ asyncio.run(main())
backend/app/catalog/service.py CHANGED
@@ -155,23 +155,38 @@ class CatalogService:
155
  log.warning("catalog_postgres_fail: %s", e)
156
  self._health.postgres = False
157
 
158
- # Neo4j
159
  try:
160
  from neo4j import GraphDatabase
161
 
162
  cfg = self.config["neo4j"]
163
- if cfg["password"]:
164
- self._neo_driver = GraphDatabase.driver(
165
- cfg["uri"], auth=(cfg["user"], cfg["password"])
166
- )
167
- with self._neo_driver.session() as s:
168
- s.run("RETURN 1").consume()
169
- self._health.neo4j = True
170
- log.info("catalog_neo4j_ok")
171
  except Exception as e:
172
  log.warning("catalog_neo4j_fail: %s", e)
173
  self._health.neo4j = False
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  # Qdrant (HTTP)
176
  try:
177
  cfg = self.config["qdrant"]
 
155
  log.warning("catalog_postgres_fail: %s", e)
156
  self._health.postgres = False
157
 
158
+ # Neo4j (NEO4J_AUTH=none means no password)
159
  try:
160
  from neo4j import GraphDatabase
161
 
162
  cfg = self.config["neo4j"]
163
+ auth = (
164
+ (cfg["user"], cfg["password"]) if cfg["password"] else None
165
+ )
166
+ self._neo_driver = GraphDatabase.driver(cfg["uri"], auth=auth)
167
+ with self._neo_driver.session() as s:
168
+ s.run("RETURN 1").consume()
169
+ self._health.neo4j = True
170
+ log.info("catalog_neo4j_ok")
171
  except Exception as e:
172
  log.warning("catalog_neo4j_fail: %s", e)
173
  self._health.neo4j = False
174
 
175
+ # MinIO (HTTP health probe)
176
+ try:
177
+ import socket
178
+
179
+ host, port = self.config["minio"]["endpoint"].rsplit(":", 1)
180
+ s = socket.socket()
181
+ s.settimeout(3)
182
+ s.connect((host, int(port)))
183
+ s.close()
184
+ self._health.minio = True
185
+ log.info("catalog_minio_ok")
186
+ except Exception as e:
187
+ log.warning("catalog_minio_fail: %s", e)
188
+ self._health.minio = False
189
+
190
  # Qdrant (HTTP)
191
  try:
192
  cfg = self.config["qdrant"]
backend/app/domain/__init__.py CHANGED
@@ -1,15 +1 @@
1
- """Pure business logic. NO FastAPI imports allowed.
2
-
3
- Per-domain packages (each follows: models.py + service.py + helpers):
4
- - scanner: token scanning, honeypot, rugcheck, holders, contract
5
- - wallet: wallet analysis, labels, behavior
6
- - token: token discovery, supply, metadata
7
- - rag: embeddings, search, ingest, firehose, feedback, agentic
8
- - x402: facilitator, settlement, enforcement
9
- - intel: feeds, narratives, graph
10
- - scam: classifier, patterns
11
- - databus: client, chain registry (96 chains)
12
- - bulletin: user-generated content board
13
-
14
- Migration order: alerts (smoke test) → wallet → token → scanner → x402 → rag.
15
- """
 
1
+ """domain package — HTTP layer per v4.0 (thin routes, no business logic)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/domain/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (225 Bytes). View file
 
backend/app/domain/alerts/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (3.53 kB). View file
 
backend/app/domain/alerts/__pycache__/broadcaster.cpython-311.pyc ADDED
Binary file (1.77 kB). View file
 
backend/app/domain/alerts/__pycache__/models.cpython-311.pyc ADDED
Binary file (4.91 kB). View file
 
backend/app/domain/alerts/__pycache__/repository.cpython-311.pyc ADDED
Binary file (4.41 kB). View file
 
backend/app/domain/alerts/__pycache__/service.cpython-311.pyc ADDED
Binary file (7.11 kB). View file
 
backend/app/domain/news/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """T28 News Intelligence — thin HTTP layer."""
2
+ from .router import router
3
+
4
+ __all__ = ["router"]
backend/app/domain/news/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (275 Bytes). View file
 
backend/app/domain/news/__pycache__/router.cpython-311.pyc ADDED
Binary file (20.9 kB). View file
 
backend/app/domain/news/router.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T28 News Intelligence Product.
2
+
3
+ Per v4.0 §T28. Four endpoints surface the news pipeline as a product:
4
+ POST /api/v1/news list, paginated, filterable
5
+ GET /api/v1/news/trending time-decay weighted
6
+ GET /api/v1/news/{news_id} single item
7
+ POST /api/v1/news/{news_id}/analyze LLM analysis (LiteLLM)
8
+
9
+ Data sources:
10
+ - crypto_news (legacy table, 1750+ items from RSS feeds)
11
+ - news_items (new catalog table, populated by RSS ingest)
12
+ - Qdrant embeddings for semantic search (rag_embedding_id)
13
+
14
+ Time-decay scoring for /trending:
15
+ score = recency_decay * source_authority * social_velocity * abs(sentiment)
16
+ recency_decay = 0.5 ** (hours_old / 6) half-life 6h
17
+ source_authority: tier-1 (CoinDesk, The Block) = 1.0, tier-2 = 0.5, tier-3 = 0.2
18
+ social_velocity: tweets/shares in last hour (1 + n/100, capped at 2x)
19
+ abs(sentiment): polarizing news ranks higher
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ import os
25
+ from datetime import datetime, UTC, timedelta
26
+ from typing import Any, Optional
27
+
28
+ from fastapi import APIRouter, HTTPException, Query
29
+ from pydantic import BaseModel, ConfigDict, Field
30
+
31
+ from app.catalog.llm_router import LLMRouter
32
+ from app.catalog.models import utcnow
33
+ from app.catalog.service import get_catalog
34
+
35
+ router = APIRouter(prefix="/api/v1/news", tags=["news"])
36
+
37
+
38
+ # ── Time-decay scoring (per v4.0 §T28) ────────────────────────────
39
+ SOURCE_AUTHORITY: dict[str, float] = {
40
+ # Tier 1 — major crypto-native outlets
41
+ "coindesk": 1.0,
42
+ "the block": 1.0,
43
+ "decrypt": 1.0,
44
+ "cointelegraph": 1.0,
45
+ # Tier 2 — solid crypto coverage
46
+ "beincrypto": 0.7,
47
+ "u.today": 0.7,
48
+ "crypto.news": 0.7,
49
+ "blockworks": 0.8,
50
+ # Tier 3 — general / RSS aggregators
51
+ "google-crypto": 0.5,
52
+ "reddit-crypto": 0.4,
53
+ "twitter-crypto": 0.3,
54
+ }
55
+
56
+
57
+ def recency_decay(hours_old: float, half_life: float = 6.0) -> float:
58
+ """Exponential decay: 1.0 at 0h, 0.5 at half_life, 0.25 at 2*half_life."""
59
+ if hours_old < 0:
60
+ return 1.0
61
+ return 0.5 ** (hours_old / half_life)
62
+
63
+
64
+ def source_authority(source: str) -> float:
65
+ s = (source or "").lower().strip()
66
+ for key, val in SOURCE_AUTHORITY.items():
67
+ if key in s:
68
+ return val
69
+ return 0.3 # unknown source
70
+
71
+
72
+ def trend_score(
73
+ hours_old: float, source: str, sentiment: float | None, social_velocity: int = 0
74
+ ) -> float:
75
+ """Composite trending score per v4.0 formula."""
76
+ s_auth = source_authority(source)
77
+ s_vel = min(2.0, 1.0 + social_velocity / 100.0)
78
+ s_sent = 1.0 + abs(sentiment or 0.0)
79
+ return recency_decay(hours_old) * s_auth * s_vel * s_sent
80
+
81
+
82
+ # ── Response models ────────────────────────────────────────────────
83
+ class NewsItemOut(BaseModel):
84
+ model_config = ConfigDict(strict=False) # accept None for any field with default
85
+
86
+ news_id: Optional[str] = None
87
+ url: Optional[str] = None
88
+ title: Optional[str] = None
89
+ summary: Optional[str] = ""
90
+ source: Optional[str] = "unknown"
91
+ published_at: Optional[datetime] = None
92
+ chains_mentioned: list[str] = Field(default_factory=list)
93
+ tokens_mentioned: list[str] = Field(default_factory=list)
94
+ sentiment_score: Optional[float] = None
95
+ score: Optional[float] = None # only set for /trending
96
+
97
+
98
+ class NewsListResponse(BaseModel):
99
+ items: list[NewsItemOut]
100
+ total: int
101
+ offset: int
102
+
103
+
104
+ class NewsAnalysisResponse(BaseModel):
105
+ news_id: str
106
+ analysis: str | None
107
+ model: str | None = None
108
+ error: str | None = None
109
+
110
+
111
+ # ── Row adapter (legacy crypto_news → NewsItemOut) ───────────────
112
+ def _adapt_legacy_row(row: dict) -> NewsItemOut:
113
+ """Convert a crypto_news row to NewsItemOut shape."""
114
+ # published is a text field in legacy; try ISO parse
115
+ pub_str = row.get("published") or row.get("ingested_at")
116
+ pub_dt = utcnow()
117
+ if pub_str:
118
+ try:
119
+ if isinstance(pub_str, (int, float)):
120
+ pub_dt = datetime.fromtimestamp(float(pub_str), tz=UTC)
121
+ else:
122
+ # Try common formats
123
+ for fmt in (
124
+ "%Y-%m-%dT%H:%M:%S.%fZ",
125
+ "%Y-%m-%dT%H:%M:%SZ",
126
+ "%Y-%m-%dT%H:%M:%S",
127
+ "%Y-%m-%d %H:%M:%S",
128
+ ):
129
+ try:
130
+ pub_dt = datetime.strptime(str(pub_str)[:19], fmt).replace(tzinfo=UTC)
131
+ break
132
+ except ValueError:
133
+ continue
134
+ except Exception:
135
+ pass
136
+ return NewsItemOut(
137
+ news_id=row.get("id", ""),
138
+ url=row.get("url", ""),
139
+ title=row.get("title", ""),
140
+ summary=(row.get("content") or "")[:500],
141
+ source=row.get("source", "unknown"),
142
+ published_at=pub_dt,
143
+ chains_mentioned=[],
144
+ tokens_mentioned=row.get("tickers") or [],
145
+ sentiment_score=row.get("sentiment"),
146
+ )
147
+
148
+
149
+ # ── POST /api/v1/news (list with filters) ─────────────────────────
150
+ @router.post("", response_model=NewsListResponse)
151
+ async def list_news(
152
+ chain: str | None = None,
153
+ token: str | None = None,
154
+ category: str | None = None,
155
+ since_hours: int = Query(24, ge=1, le=720),
156
+ limit: int = Query(20, ge=1, le=200),
157
+ offset: int = Query(0, ge=0),
158
+ sort: str = Query("recency", pattern="^(recency|relevance|sentiment)$"),
159
+ ) -> NewsListResponse:
160
+ """List news items with filters. Reads from both news_items (new) and crypto_news (legacy)."""
161
+ catalog = get_catalog()
162
+ await catalog._init_stores()
163
+ items: list[NewsItemOut] = []
164
+
165
+ # New table
166
+ if catalog._health.postgres:
167
+ try:
168
+ cutoff = utcnow() - timedelta(hours=since_hours)
169
+ query = "SELECT news_id, url, title, summary, source, published_at, sentiment_score, chains_mentioned, tokens_mentioned FROM news_items WHERE published_at > $1"
170
+ params: list[Any] = [cutoff]
171
+ if chain:
172
+ query += f" AND ${len(params)+1} = ANY(chains_mentioned)"
173
+ params.append(chain)
174
+ if token:
175
+ query += f" AND ${len(params)+1} = ANY(tokens_mentioned)"
176
+ params.append(token)
177
+ if sort == "sentiment":
178
+ query += " ORDER BY sentiment_score ASC NULLS LAST"
179
+ else:
180
+ query += " ORDER BY published_at DESC"
181
+ query += f" LIMIT {limit} OFFSET {offset}"
182
+ async with catalog._pg_pool.acquire() as conn:
183
+ rows = await conn.fetch(query, *params)
184
+ for r in rows:
185
+ items.append(
186
+ NewsItemOut(
187
+ news_id=r["news_id"],
188
+ url=r["url"],
189
+ title=r["title"],
190
+ summary=r["summary"] or "",
191
+ source=r["source"],
192
+ published_at=r["published_at"],
193
+ chains_mentioned=list(r["chains_mentioned"] or []),
194
+ tokens_mentioned=list(r["tokens_mentioned"] or []),
195
+ sentiment_score=r["sentiment_score"],
196
+ )
197
+ )
198
+ except Exception as e:
199
+ import logging
200
+ logging.getLogger(__name__).warning(f"news_list_new_fail: {e}")
201
+
202
+ # Legacy fallback (crypto_news)
203
+ if not items and catalog._health.postgres:
204
+ try:
205
+ query = "SELECT id, title, content, url, source, sentiment, tickers, published, ingested_at FROM crypto_news WHERE 1=1"
206
+ params = []
207
+ if category:
208
+ query += f" AND category = ${len(params)+1}"
209
+ params.append(category)
210
+ query += " ORDER BY ingested_at DESC LIMIT $%d OFFSET $%d" % (len(params)+1, len(params)+2)
211
+ params.extend([limit, offset])
212
+ async with catalog._pg_pool.acquire() as conn:
213
+ rows = await conn.fetch(query, *params)
214
+ for r in rows:
215
+ items.append(_adapt_legacy_row(dict(r)))
216
+ except Exception as e:
217
+ import logging
218
+ logging.getLogger(__name__).warning(f"news_list_legacy_fail: {e}")
219
+
220
+ return NewsListResponse(items=items, total=len(items), offset=offset)
221
+
222
+
223
+ # ── GET /api/v1/news/trending ─────────────────────────────────────
224
+ @router.get("/trending", response_model=NewsListResponse)
225
+ async def trending_news(
226
+ window_hours: int = Query(168, ge=1, le=720),
227
+ limit: int = Query(20, ge=1, le=100),
228
+ ) -> NewsListResponse:
229
+ """Time-decay trending. Reads from crypto_news, scores, sorts by score."""
230
+ catalog = get_catalog()
231
+ await catalog._init_stores()
232
+ if not catalog._health.postgres:
233
+ return NewsListResponse(items=[], total=0, offset=0)
234
+ try:
235
+ cutoff_epoch = (utcnow() - timedelta(hours=window_hours)).timestamp()
236
+ async with catalog._pg_pool.acquire() as conn:
237
+ rows = await conn.fetch(
238
+ "SELECT id, title, content, url, source, sentiment, tickers, "
239
+ "published, ingested_at, category "
240
+ "FROM crypto_news "
241
+ "WHERE ingested_at > $1 "
242
+ "ORDER BY ingested_at DESC LIMIT 500",
243
+ cutoff_epoch,
244
+ )
245
+ now = utcnow()
246
+ items: list[NewsItemOut] = []
247
+ for r in rows:
248
+ d = dict(r)
249
+ # Estimate hours_old
250
+ hours_old = 0.0
251
+ try:
252
+ if d.get("ingested_at"):
253
+ hours_old = max(0, (now.timestamp() - float(d["ingested_at"])) / 3600)
254
+ except Exception:
255
+ pass
256
+ item = _adapt_legacy_row(d)
257
+ item.score = round(trend_score(hours_old, item.source, item.sentiment_score), 4)
258
+ items.append(item)
259
+ items.sort(key=lambda x: x.score or 0, reverse=True)
260
+ return NewsListResponse(items=items[:limit], total=len(items), offset=0)
261
+ except Exception as e:
262
+ import logging
263
+ logging.getLogger(__name__).warning(f"trending_fail: {e}")
264
+ return NewsListResponse(items=[], total=0, offset=0)
265
+
266
+
267
+ # ── GET /api/v1/news/{news_id} ────────────────────────────────────
268
+ @router.get("/{news_id}", response_model=NewsItemOut)
269
+ async def get_news(news_id: str) -> NewsItemOut:
270
+ """Single news item. Searches both news_items and crypto_news."""
271
+ catalog = get_catalog()
272
+ await catalog._init_stores()
273
+ if not catalog._health.postgres:
274
+ raise HTTPException(503, "postgres unavailable")
275
+ try:
276
+ async with catalog._pg_pool.acquire() as conn:
277
+ r = await conn.fetchrow(
278
+ "SELECT news_id, url, title, summary, source, published_at, "
279
+ "sentiment_score, chains_mentioned, tokens_mentioned "
280
+ "FROM news_items WHERE news_id=$1",
281
+ news_id,
282
+ )
283
+ if r:
284
+ return NewsItemOut(
285
+ news_id=r["news_id"],
286
+ url=r["url"],
287
+ title=r["title"],
288
+ summary=r["summary"] or "",
289
+ source=r["source"],
290
+ published_at=r["published_at"],
291
+ chains_mentioned=list(r["chains_mentioned"] or []),
292
+ tokens_mentioned=list(r["tokens_mentioned"] or []),
293
+ sentiment_score=r["sentiment_score"],
294
+ )
295
+ r2 = await conn.fetchrow(
296
+ "SELECT id, title, content, url, source, sentiment, tickers, "
297
+ "published, ingested_at, category "
298
+ "FROM crypto_news WHERE id=$1",
299
+ news_id,
300
+ )
301
+ if r2:
302
+ return _adapt_legacy_row(dict(r2))
303
+ raise HTTPException(404, "news item not found")
304
+ except HTTPException:
305
+ raise
306
+ except Exception as e:
307
+ raise HTTPException(500, f"news_get_fail: {e}")
308
+
309
+
310
+ # ── POST /api/v1/news/{news_id}/analyze ───────────────────────────
311
+ @router.post("/{news_id}/analyze", response_model=NewsAnalysisResponse)
312
+ async def analyze_news(news_id: str) -> NewsAnalysisResponse:
313
+ """Generate LLM analysis via LiteLLM. Falls back to None if LLM unreachable."""
314
+ catalog = get_catalog()
315
+ await catalog._init_stores()
316
+ if not catalog._health.postgres:
317
+ raise HTTPException(503, "postgres unavailable")
318
+ try:
319
+ # Fetch the news item
320
+ item: NewsItemOut | None = None
321
+ async with catalog._pg_pool.acquire() as conn:
322
+ r = await conn.fetchrow(
323
+ "SELECT news_id, url, title, summary, source, published_at, "
324
+ "sentiment_score, chains_mentioned, tokens_mentioned "
325
+ "FROM news_items WHERE news_id=$1",
326
+ news_id,
327
+ )
328
+ if r:
329
+ item = NewsItemOut(
330
+ news_id=r["news_id"],
331
+ url=r["url"],
332
+ title=r["title"],
333
+ summary=r["summary"] or "",
334
+ source=r["source"],
335
+ published_at=r["published_at"],
336
+ chains_mentioned=list(r["chains_mentioned"] or []),
337
+ tokens_mentioned=list(r["tokens_mentioned"] or []),
338
+ sentiment_score=r["sentiment_score"],
339
+ )
340
+ if not item:
341
+ r2 = await conn.fetchrow(
342
+ "SELECT id, title, content, url, source, sentiment, tickers, "
343
+ "published, ingested_at FROM crypto_news WHERE id=$1",
344
+ news_id,
345
+ )
346
+ if r2:
347
+ item = _adapt_legacy_row(dict(r2))
348
+ if not item:
349
+ raise HTTPException(404, "news item not found")
350
+ # Build a NewsItem for the LLM router
351
+ from app.catalog.models import Chain, NewsItem
352
+
353
+ ni = NewsItem(
354
+ news_id=item.news_id,
355
+ url=item.url or "https://unknown.local", # HttpUrl requires non-empty
356
+ title=item.title or "",
357
+ summary=item.summary or "",
358
+ body_markdown=item.summary or "",
359
+ source=item.source or "unknown",
360
+ published_at=item.published_at or utcnow(),
361
+ ingested_at=utcnow(),
362
+ )
363
+ llm = LLMRouter()
364
+ analysis = await llm.analyze_news(ni)
365
+ if analysis is None:
366
+ return NewsAnalysisResponse(
367
+ news_id=news_id, analysis=None, error="LLM router unavailable"
368
+ )
369
+ return NewsAnalysisResponse(
370
+ news_id=news_id, analysis=analysis, model="deepseek-v3"
371
+ )
372
+ except HTTPException:
373
+ raise
374
+ except Exception as e:
375
+ return NewsAnalysisResponse(news_id=news_id, analysis=None, error=str(e))
backend/app/domain/scanner/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (1.56 kB). View file
 
backend/app/domain/scanner/__pycache__/models.cpython-311.pyc ADDED
Binary file (5.12 kB). View file
 
backend/app/domain/scanner/__pycache__/service.cpython-311.pyc ADDED
Binary file (5.01 kB). View file
 
backend/app/domain/token/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (1.77 kB). View file
 
backend/app/domain/token/__pycache__/analyzer.cpython-311.pyc ADDED
Binary file (4.23 kB). View file
 
backend/app/domain/token/__pycache__/models.cpython-311.pyc ADDED
Binary file (7.17 kB). View file
 
backend/app/domain/token/__pycache__/repository.cpython-311.pyc ADDED
Binary file (6.79 kB). View file
 
backend/app/domain/token/__pycache__/service.cpython-311.pyc ADDED
Binary file (5.64 kB). View file
 
backend/app/domain/token/repository.py CHANGED
@@ -92,8 +92,3 @@ class TokenRepository:
92
  pools=raw.get("pools", []) or [],
93
  locked_percentage=float(raw.get("locked_percentage", 0) or 0),
94
  )
95
-
96
-
97
- def count(self) -> int:
98
- from app.core.redis import get_redis
99
- return get_redis().scard(TOKEN_INDEX)
 
92
  pools=raw.get("pools", []) or [],
93
  locked_percentage=float(raw.get("locked_percentage", 0) or 0),
94
  )
 
 
 
 
 
backend/app/domain/token/service.py CHANGED
@@ -104,6 +104,3 @@ class TokenService:
104
  risk_level=risk.risk_level.value,
105
  )
106
  return result
107
-
108
- def count_tokens(self) -> int:
109
- return self.repo.count()
 
104
  risk_level=risk.risk_level.value,
105
  )
106
  return result
 
 
 
backend/app/domain/wallet/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (1.85 kB). View file
 
backend/app/domain/wallet/__pycache__/analyzer.cpython-311.pyc ADDED
Binary file (6.4 kB). View file
 
backend/app/domain/wallet/__pycache__/models.cpython-311.pyc ADDED
Binary file (7.86 kB). View file
 
backend/app/domain/wallet/__pycache__/repository.cpython-311.pyc ADDED
Binary file (7.75 kB). View file
 
backend/app/domain/wallet/__pycache__/service.cpython-311.pyc ADDED
Binary file (5.35 kB). View file
 
backend/app/domain/wallet/repository.py CHANGED
@@ -110,10 +110,3 @@ class WalletRepository:
110
  timestamp=int(block_time or 0),
111
  direction=tx.get("direction"),
112
  )
113
-
114
-
115
- def count(self) -> int:
116
- from app.core.redis import get_redis
117
- r = get_redis()
118
- keys = r.keys("rmi:wallet:*")
119
- return len([k for k in keys if not k.endswith(":labels") and not k.endswith(":index")])
 
110
  timestamp=int(block_time or 0),
111
  direction=tx.get("direction"),
112
  )
 
 
 
 
 
 
 
backend/app/domain/wallet/service.py CHANGED
@@ -103,6 +103,3 @@ class WalletService:
103
  flag_count=len(result.flags),
104
  )
105
  return result
106
-
107
- def count_wallets(self) -> int:
108
- return self._repo.count()
 
103
  flag_count=len(result.flags),
104
  )
105
  return result
 
 
 
backend/app/domain/x402/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (1.73 kB). View file
 
backend/app/domain/x402/__pycache__/models.cpython-311.pyc ADDED
Binary file (4.9 kB). View file
 
backend/app/domain/x402/__pycache__/service.cpython-311.pyc ADDED
Binary file (8.16 kB). View file
 
backend/main.py CHANGED
@@ -216,6 +216,7 @@ def _try_mount_v1_routers() -> int:
216
  "app.api.v1.x402.payments",
217
  "app.api.v1.admin.alerts_webhook",
218
  "app.api.v1.catalog",
 
219
  ]
220
 
221
  for module_path in v1_modules:
 
216
  "app.api.v1.x402.payments",
217
  "app.api.v1.admin.alerts_webhook",
218
  "app.api.v1.catalog",
219
+ "app.domain.news",
220
  ]
221
 
222
  for module_path in v1_modules: