feat: news ticker and map API contracts
This commit is contained in:
parent
cbc573b830
commit
5b23578302
3 changed files with 230 additions and 12 deletions
102
app/main.py
102
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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue