feat: news ticker and map API contracts

This commit is contained in:
Sirius DevOps 2026-08-27 22:49:59 -04:00
parent cbc573b830
commit 5b23578302
3 changed files with 230 additions and 12 deletions

View file

@ -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
]

View file

@ -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 ────────────────────────────────────────────────────────

View file

@ -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