diff --git a/app/main.py b/app/main.py index aad0663..6afd550 100644 --- a/app/main.py +++ b/app/main.py @@ -31,12 +31,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from database import async_session, init_extensions from models import ( alerts, documents, entities, entity_events, events, feed_sources, fires, - articles, article_summaries, + articles, article_summaries, news_items, ) from schemas import ( AlertCreate, AlertOut, AlertSeverity, AlertType, AlertUpdate, DashboardSummary, EntityCreate, EntityKind, EntityOut, - EventCreate, EventOut, FireOut, NewsArticleOut, NewsSummaryOut, + EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut, + NewsSummaryOut, NewsTickerItemOut, FeedSourceCreate, FeedSourceOut, KeyOut, KeyValueIn, SearchResult, SentimentSummary, SourceType, @@ -1128,7 +1129,102 @@ async def list_news_summaries( return [ NewsSummaryOut( id=r["id"], summary_text=r["summary_text"], - batch_timestamp=r["batch_timestamp"], + batch_timestamp=r["batch_timestamp"], model=r["model"], + ) + for r in rows + ] + + +_FLAGGED = ("critical", "high") + + +@app.get("/api/news/ticker", response_model=list[NewsTickerItemOut]) +async def list_news_ticker( + since: datetime | None = Query( + None, + description="Only ticker items created at/after this UTC instant.", + ), + limit: int = Query(20, ge=1, le=50), +): + """Flagged ticker rows (critical/high), newest first. No LLM required.""" + async with async_session() as session: + stmt = ( + select(news_items) + .where( + news_items.c.kind == "ticker", + news_items.c.importance.in_(_FLAGGED), + ) + .order_by(news_items.c.created_at.desc()) + ) + if since: + stmt = stmt.where(news_items.c.created_at >= since) + stmt = stmt.limit(limit) + rows = (await session.execute(stmt)).mappings().all() + return [ + NewsTickerItemOut( + id=r["id"], headline=r["headline"], importance=r["importance"], + location_name=r["location_name"], url=r["url"], + created_at=r["created_at"], + ) + for r in rows + ] + + +@app.get("/api/news/map", response_model=list[NewsMapItemOut]) +async def list_news_map( + bbox: str | None = Query( + None, + description="Comma-separated 'minlon,minlat,maxlon,maxlat' to bound the " + "result set by item coordinates. Omit for all flagged pins.", + ), + since: datetime | None = Query( + None, + description="Only map items created at/after this UTC instant. " + "Defaults to the last 24 hours.", + ), + limit: int = Query(200, ge=1, le=500), +): + """Flagged map pins (critical/high with coords). No zoom skip — world view.""" + if since is None: + since = datetime.now(timezone.utc) - timedelta(hours=24) + async with async_session() as session: + stmt = ( + select(news_items) + .where( + news_items.c.kind == "map", + news_items.c.lat.isnot(None), + news_items.c.lon.isnot(None), + news_items.c.importance.in_(_FLAGGED), + news_items.c.created_at >= since, + ) + .order_by(news_items.c.created_at.desc()) + ) + if bbox: + parts = [p.strip() for p in bbox.split(",")] + if len(parts) != 4: + raise HTTPException( + 422, "bbox must be 'minlon,minlat,maxlon,maxlat' (4 comma-separated values)" + ) + try: + minlon, minlat, maxlon, maxlat = (float(p) for p in parts) + except ValueError: + raise HTTPException( + 422, "bbox values must be floats: 'minlon,minlat,maxlon,maxlat'" + ) + stmt = stmt.where( + and_( + news_items.c.lon >= minlon, news_items.c.lon <= maxlon, + news_items.c.lat >= minlat, news_items.c.lat <= maxlat, + ) + ) + stmt = stmt.limit(limit) + rows = (await session.execute(stmt)).mappings().all() + return [ + NewsMapItemOut( + id=r["id"], headline=r["headline"], importance=r["importance"], + location_name=r["location_name"], lat=r["lat"], lon=r["lon"], + location_confidence=r["location_confidence"], category=r["category"], + url=r["url"], created_at=r["created_at"], ) for r in rows ] diff --git a/app/schemas.py b/app/schemas.py index e27b002..35bda45 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -273,6 +273,33 @@ class NewsSummaryOut(BaseModel): id: int summary_text: str batch_timestamp: datetime + model: Optional[str] = None + + +class NewsTickerItemOut(BaseModel): + """One flagged ticker row as exposed by GET /api/news/ticker.""" + + id: int + headline: str + importance: str + location_name: Optional[str] = None + url: Optional[str] = None + created_at: datetime + + +class NewsMapItemOut(BaseModel): + """One flagged map pin as exposed by GET /api/news/map.""" + + id: int + headline: str + importance: str + location_name: Optional[str] = None + lat: float + lon: float + location_confidence: Optional[str] = None + category: Optional[str] = None + url: Optional[str] = None + created_at: datetime # ─── Aggregations ──────────────────────────────────────────────────────── diff --git a/tests/test_api_news.py b/tests/test_api_news.py index 786d64e..340cbd0 100644 --- a/tests/test_api_news.py +++ b/tests/test_api_news.py @@ -1,9 +1,9 @@ -"""Integration tests for the news pipeline API (GET /api/news + summaries). +"""Integration tests for the news pipeline API (GET /api/news + summaries + intel). DB-backed: marked `requires_db` and auto-skip when the test database is unreachable (see tests/conftest.py). Seeding writes directly to the shared -`articles` / `article_summaries` tables, exactly as the scraper + summarizer -services would. +`articles` / `article_summaries` / `news_items` tables, exactly as the scraper ++ summarizer services would. """ from __future__ import annotations @@ -37,7 +37,9 @@ def _truncate() -> None: async def run(): conn = await asyncpg.connect(**_conn_kwargs()) try: - await conn.execute("TRUNCATE articles, article_summaries") + await conn.execute( + "TRUNCATE articles, article_summaries, news_items CASCADE" + ) finally: await conn.close() @@ -66,14 +68,45 @@ def _seed_article(title: str, url: str, domain: str, ts: str, content: str = "bo asyncio.run(run()) -def _seed_summary(text: str, ts: str) -> None: +def _seed_summary(text: str, ts: str, model: str | None = None) -> int: + async def run() -> int: + conn = await asyncpg.connect(**_conn_kwargs()) + try: + row = await conn.fetchrow( + "INSERT INTO article_summaries (summary_text, batch_timestamp, model) " + "VALUES ($1, $2, $3) RETURNING id", + text, datetime.fromisoformat(ts), model, + ) + return int(row["id"]) + finally: + await conn.close() + + return asyncio.run(run()) + + +def _seed_news_item( + summary_id: int, + kind: str, + headline: str, + importance: str, + *, + location_name: str | None = None, + lat: float | None = None, + lon: float | None = None, + location_confidence: str | None = None, + category: str | None = None, + url: str | None = None, +) -> None: async def run(): conn = await asyncpg.connect(**_conn_kwargs()) try: await conn.execute( - "INSERT INTO article_summaries (summary_text, batch_timestamp) " - "VALUES ($1, $2)", - text, datetime.fromisoformat(ts), + "INSERT INTO news_items " + "(summary_id, kind, headline, importance, location_name, " + " lat, lon, location_confidence, category, url) " + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + summary_id, kind, headline, importance, location_name, + lat, lon, location_confidence, category, url, ) finally: await conn.close() @@ -131,7 +164,7 @@ def test_api_news_summaries_contract(clean_news): assert isinstance(body, list) assert len(body) == 1 s = body[0] - assert set(s.keys()) == {"id", "summary_text", "batch_timestamp"} + assert set(s.keys()) == {"id", "summary_text", "batch_timestamp", "model"} assert s["summary_text"] == "master summary markdown…" assert s["batch_timestamp"].startswith("2026-08-24T18:05") @@ -140,3 +173,65 @@ def test_api_news_summaries_contract(clean_news): def test_api_news_empty(clean_news): assert _get("/api/news").json() == [] assert _get("/api/news/summaries").json() == [] + assert _get("/api/news/ticker").json() == [] + assert _get("/api/news/map").json() == [] + + +TICKER_KEYS = {"id", "headline", "importance", "location_name", "url", "created_at"} +MAP_KEYS = { + "id", "headline", "importance", "location_name", "lat", "lon", + "location_confidence", "category", "url", "created_at", +} + + +def _seed_flagged_items() -> None: + sid = _seed_summary("batch brief", "2026-08-27T18:05:00+00:00", "Hermes-4.3-36B") + _seed_news_item( + sid, "ticker", "Critical ticker", "critical", + location_name="Kyiv", url="https://example.com/ticker", + ) + _seed_news_item( + sid, "ticker", "Low ticker", "low", + location_name="Somewhere", url="https://example.com/low", + ) + _seed_news_item( + sid, "map", "Critical map", "critical", + location_name="Taipei", lat=25.03, lon=121.56, + location_confidence="high", category="conflict", + url="https://example.com/map", + ) + + +@requires_db +def test_api_news_ticker_returns_only_flagged(clean_news): + _seed_flagged_items() + resp = _get("/api/news/ticker") + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) == 1 + item = body[0] + assert set(item.keys()) == TICKER_KEYS + assert item["headline"] == "Critical ticker" + assert item["importance"] == "critical" + assert item["location_name"] == "Kyiv" + assert item["url"] == "https://example.com/ticker" + + +@requires_db +def test_api_news_map_returns_only_flagged_with_coords(clean_news): + _seed_flagged_items() + resp = _get("/api/news/map") + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) == 1 + item = body[0] + assert set(item.keys()) == MAP_KEYS + assert item["headline"] == "Critical map" + assert item["importance"] == "critical" + assert item["lat"] == 25.03 + assert item["lon"] == 121.56 + assert item["location_confidence"] == "high" + assert item["category"] == "conflict" + assert _get("/api/news/map?bbox=1,2,3").status_code == 422