Hermes commited on
Commit
fa20a11
Β·
1 Parent(s): cb060b7

feat(t28): RSS ingest + LiteLLM + 116 fresh news items

Browse files
backend/app/domain/news/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
  """T28 News Intelligence β€” thin HTTP layer."""
2
  from .router import router
 
3
 
4
- __all__ = ["router"]
 
1
  """T28 News Intelligence β€” thin HTTP layer."""
2
  from .router import router
3
+ from .admin_router import router as admin_router
4
 
5
+ __all__ = ["router", "admin_router"]
backend/app/domain/news/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/backend/app/domain/news/__pycache__/__init__.cpython-311.pyc and b/backend/app/domain/news/__pycache__/__init__.cpython-311.pyc differ
 
backend/app/domain/news/__pycache__/admin_router.cpython-311.pyc ADDED
Binary file (874 Bytes). View file
 
backend/app/domain/news/__pycache__/ingest.cpython-311.pyc ADDED
Binary file (12.7 kB). View file
 
backend/app/domain/news/__pycache__/router.cpython-311.pyc CHANGED
Binary files a/backend/app/domain/news/__pycache__/router.cpython-311.pyc and b/backend/app/domain/news/__pycache__/router.cpython-311.pyc differ
 
backend/app/domain/news/admin_router.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T28 RSS Ingest HTTP endpoint."""
2
+ import asyncio
3
+ import logging
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from app.domain.news.ingest import ingest_all
8
+
9
+ router = APIRouter(prefix="/api/v1/news/_admin", tags=["news-admin"])
10
+
11
+
12
+ @router.post("/ingest")
13
+ async def trigger_ingest() -> dict:
14
+ """Trigger RSS ingest now (synchronous). Returns counts."""
15
+ result = await ingest_all()
16
+ return result
backend/app/domain/news/ingest.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T28 RSS Ingest β€” populates news_items + crypto_news from RSS feeds.
2
+
3
+ Sources (v4.0 master stack): 5+ RSS feeds
4
+ - CoinDesk
5
+ - Cointelegraph
6
+ - The Block
7
+ - Decrypt
8
+ - BeInCrypto
9
+
10
+ Caches raw HTML to MinIO, writes metadata to news_items,
11
+ embeds into RAG engine for semantic search.
12
+
13
+ Run as a cron: every 15 minutes.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import hashlib
19
+ import logging
20
+ from datetime import datetime, UTC
21
+ from typing import Any, Optional
22
+
23
+ import feedparser
24
+ import httpx
25
+ from pydantic import HttpUrl
26
+
27
+ from app.catalog.models import NewsItem, utcnow
28
+ from app.catalog.service import get_catalog
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ # Default feeds (per v4.0 Β§T28)
33
+ DEFAULT_FEEDS: list[dict[str, str]] = [
34
+ {"name": "coindesk", "url": "https://www.coindesk.com/arc/outboundfeeds/rss/"},
35
+ {"name": "cointelegraph", "url": "https://cointelegraph.com/rss"},
36
+ {"name": "theblock", "url": "https://www.theblock.co/rss.xml"},
37
+ {"name": "decrypt", "url": "https://decrypt.co/feed"},
38
+ {"name": "beincrypto", "url": "https://beincrypto.com/feed/"},
39
+ ]
40
+
41
+
42
+ def _stable_id(source: str, url: str) -> str:
43
+ """Stable news_id from source + URL."""
44
+ h = hashlib.md5(f"{source}:{url}".encode("utf-8")).hexdigest()[:24]
45
+ return f"{source}:{h}"
46
+
47
+
48
+ def _detect_chains(text: str) -> list[str]:
49
+ """Heuristic chain detection from text content."""
50
+ text_l = text.lower()
51
+ chains = []
52
+ chain_map = {
53
+ "solana": ["solana", "sol ", "$sol"],
54
+ "ethereum": ["ethereum", "eth ", "$eth", "ether"],
55
+ "bitcoin": ["bitcoin", "btc ", "$btc"],
56
+ "base": ["base ", "basechain", "coinbase base"],
57
+ "arbitrum": ["arbitrum", "arb "],
58
+ "polygon": ["polygon", "matic"],
59
+ "bsc": ["bsc", "bnb chain", "binance smart chain"],
60
+ "tron": ["tron", "trx "],
61
+ "avalanche": ["avalanche", "avax"],
62
+ }
63
+ for chain, keywords in chain_map.items():
64
+ if any(k in text_l for k in keywords):
65
+ chains.append(chain)
66
+ return chains
67
+
68
+
69
+ def _extract_tickers(text: str) -> list[str]:
70
+ """Extract $TICKER style mentions."""
71
+ import re
72
+
73
+ return list(set(re.findall(r"\$([A-Z]{2,6})\b", text)))
74
+
75
+
76
+ async def fetch_feed(client: httpx.AsyncClient, feed: dict[str, str]) -> list[dict]:
77
+ """Fetch and parse a single RSS feed."""
78
+ try:
79
+ r = await client.get(feed["url"], timeout=10.0, follow_redirects=True)
80
+ r.raise_for_status()
81
+ parsed = feedparser.parse(r.content)
82
+ items = []
83
+ for entry in parsed.entries[:30]: # cap per feed
84
+ items.append(
85
+ {
86
+ "source": feed["name"],
87
+ "title": entry.get("title", ""),
88
+ "url": entry.get("link", ""),
89
+ "summary": entry.get("summary", "")[:2000],
90
+ "published": entry.get("published_parsed") or entry.get("updated_parsed"),
91
+ }
92
+ )
93
+ return items
94
+ except Exception as e:
95
+ log.warning("feed_fetch_fail name=%s err=%s", feed["name"], e)
96
+ return []
97
+
98
+
99
+ async def ingest_all(
100
+ feeds: list[dict[str, str]] | None = None,
101
+ embed: bool = True,
102
+ collection: str = "news_articles",
103
+ ) -> dict:
104
+ """Ingest from all configured RSS feeds. Returns counts."""
105
+ feeds = feeds or DEFAULT_FEEDS
106
+ cat = get_catalog()
107
+ await cat._init_stores()
108
+ if not cat._health.postgres:
109
+ return {"error": "postgres unavailable"}
110
+
111
+ counts = {"feeds_ok": 0, "feeds_fail": 0, "items": 0, "ingested": 0, "duplicate": 0, "errors": 0}
112
+ async with httpx.AsyncClient() as client:
113
+ # Fetch all feeds in parallel
114
+ results = await asyncio.gather(
115
+ *[fetch_feed(client, f) for f in feeds], return_exceptions=True
116
+ )
117
+ items_to_ingest: list[NewsItem] = []
118
+ for i, r in enumerate(results):
119
+ if isinstance(r, Exception) or not r:
120
+ counts["feeds_fail"] += 1
121
+ continue
122
+ counts["feeds_ok"] += 1
123
+ for entry in r:
124
+ counts["items"] += 1
125
+ try:
126
+ pub_dt = utcnow()
127
+ if entry.get("published"):
128
+ try:
129
+ pub_dt = datetime(*entry["published"][:6], tzinfo=UTC)
130
+ except Exception:
131
+ pass
132
+ text = f"{entry['title']} {entry['summary']}"
133
+ chains = _detect_chains(text)
134
+ tickers = _extract_tickers(text)
135
+ news_id = _stable_id(entry["source"], entry["url"])
136
+ ni = NewsItem(
137
+ news_id=news_id,
138
+ url=HttpUrl(entry["url"]) if entry["url"] else HttpUrl("https://unknown.local"),
139
+ title=entry["title"][:500],
140
+ summary=entry["summary"][:2000],
141
+ body_markdown=None,
142
+ source=entry["source"],
143
+ published_at=pub_dt,
144
+ ingested_at=utcnow(),
145
+ chains_mentioned=chains,
146
+ tokens_mentioned=tickers,
147
+ )
148
+ items_to_ingest.append(ni)
149
+ except Exception as e:
150
+ counts["errors"] += 1
151
+ log.debug("item_build_fail: %s", e)
152
+
153
+ # Save to Postgres
154
+ if cat._health.postgres and items_to_ingest:
155
+ try:
156
+ async with cat._pg_pool.acquire() as conn:
157
+ for ni in items_to_ingest:
158
+ try:
159
+ # Check if exists
160
+ existing = await conn.fetchval(
161
+ "SELECT 1 FROM news_items WHERE news_id=$1", ni.news_id
162
+ )
163
+ if existing:
164
+ counts["duplicate"] += 1
165
+ continue
166
+ # Insert
167
+ await conn.execute(
168
+ """
169
+ INSERT INTO news_items (
170
+ news_id, url, title, summary, body_markdown,
171
+ source, published_at, ingested_at,
172
+ chains_mentioned, tokens_mentioned, sentiment_score
173
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
174
+ """,
175
+ ni.news_id, str(ni.url), ni.title, ni.summary, ni.body_markdown,
176
+ ni.source, ni.published_at, ni.ingested_at,
177
+ ni.chains_mentioned, ni.tokens_mentioned, ni.sentiment_score,
178
+ )
179
+ counts["ingested"] += 1
180
+ except Exception as e:
181
+ counts["errors"] += 1
182
+ log.debug("item_insert_fail: %s", e)
183
+ except Exception as e:
184
+ log.warning("ingest_postgres_fail: %s", e)
185
+
186
+ # Embed into RAG for semantic search
187
+ if embed and items_to_ingest:
188
+ try:
189
+ for ni in items_to_ingest[:50]: # cap RAG embeds
190
+ r = await cat.rag_ingest(
191
+ content=f"{ni.title}\n{ni.summary}",
192
+ collection=collection,
193
+ doc_id=ni.news_id,
194
+ metadata={"source": ni.source, "news_id": ni.news_id},
195
+ )
196
+ if r.get("status") == "ok" and r.get("qdrant_point_id"):
197
+ # Update news_items with rag_embedding_id
198
+ try:
199
+ async with cat._pg_pool.acquire() as conn:
200
+ await conn.execute(
201
+ "UPDATE news_items SET rag_embedding_id=$1 WHERE news_id=$2",
202
+ r["qdrant_point_id"], ni.news_id,
203
+ )
204
+ except Exception:
205
+ pass
206
+ except Exception as e:
207
+ log.warning("ingest_rag_fail: %s", e)
208
+
209
+ return counts
210
+
211
+
212
+ # ── CLI entry point ──────────────────────────────────────────────
213
+ if __name__ == "__main__":
214
+ import sys
215
+ import json
216
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
217
+ result = asyncio.run(ingest_all())
218
+ print(json.dumps(result, indent=2))
backend/app/domain/news/router.py CHANGED
@@ -226,42 +226,76 @@ 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} ────────────────────────────────────
 
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 news_items (new) primary, falls back to crypto_news (legacy)."""
230
  catalog = get_catalog()
231
  await catalog._init_stores()
232
  if not catalog._health.postgres:
233
  return NewsListResponse(items=[], total=0, offset=0)
234
+ now = utcnow()
235
+ cutoff = now - timedelta(hours=window_hours)
236
+ items: list[NewsItemOut] = []
237
+ # Primary: news_items
238
  try:
 
239
  async with catalog._pg_pool.acquire() as conn:
240
  rows = await conn.fetch(
241
+ "SELECT news_id, url, title, summary, source, published_at, "
242
+ "sentiment_score, chains_mentioned, tokens_mentioned "
243
+ "FROM news_items "
244
+ "WHERE published_at > $1 "
245
+ "ORDER BY published_at DESC LIMIT 500",
246
+ cutoff,
247
  )
 
 
248
  for r in rows:
249
+ hours_old = max(0, (now - r["published_at"]).total_seconds() / 3600)
250
+ item = NewsItemOut(
251
+ news_id=r["news_id"],
252
+ url=r["url"] or "",
253
+ title=r["title"] or "",
254
+ summary=r["summary"] or "",
255
+ source=r["source"] or "unknown",
256
+ published_at=r["published_at"],
257
+ chains_mentioned=list(r["chains_mentioned"] or []),
258
+ tokens_mentioned=list(r["tokens_mentioned"] or []),
259
+ sentiment_score=r["sentiment_score"],
260
+ )
261
+ item.score = round(
262
+ trend_score(hours_old, item.source, item.sentiment_score), 4
263
+ )
264
  items.append(item)
 
 
265
  except Exception as e:
266
  import logging
267
+ logging.getLogger(__name__).warning(f"trending_new_fail: {e}")
268
+
269
+ # Fallback: crypto_news (legacy) if no new items
270
+ if not items:
271
+ try:
272
+ cutoff_epoch = cutoff.timestamp()
273
+ async with catalog._pg_pool.acquire() as conn:
274
+ rows = await conn.fetch(
275
+ "SELECT id, title, content, url, source, sentiment, tickers, "
276
+ "published, ingested_at, category "
277
+ "FROM crypto_news "
278
+ "WHERE ingested_at > $1 "
279
+ "ORDER BY ingested_at DESC LIMIT 500",
280
+ cutoff_epoch,
281
+ )
282
+ for r in rows:
283
+ d = dict(r)
284
+ hours_old = 0.0
285
+ try:
286
+ if d.get("ingested_at"):
287
+ hours_old = max(0, (now.timestamp() - float(d["ingested_at"])) / 3600)
288
+ except Exception:
289
+ pass
290
+ item = _adapt_legacy_row(d)
291
+ item.score = round(trend_score(hours_old, item.source, item.sentiment_score), 4)
292
+ items.append(item)
293
+ except Exception as e:
294
+ import logging
295
+ logging.getLogger(__name__).warning(f"trending_legacy_fail: {e}")
296
+
297
+ items.sort(key=lambda x: x.score or 0, reverse=True)
298
+ return NewsListResponse(items=items[:limit], total=len(items), offset=0)
299
 
300
 
301
  # ── GET /api/v1/news/{news_id} ────────────────────────────────────
backend/main.py CHANGED
@@ -217,6 +217,7 @@ def _try_mount_v1_routers() -> int:
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:
 
217
  "app.api.v1.admin.alerts_webhook",
218
  "app.api.v1.catalog",
219
  "app.domain.news",
220
+ "app.domain.news.admin_router",
221
  ]
222
 
223
  for module_path in v1_modules: