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 database import async_session, init_extensions
|
||||||
from models import (
|
from models import (
|
||||||
alerts, documents, entities, entity_events, events, feed_sources, fires,
|
alerts, documents, entities, entity_events, events, feed_sources, fires,
|
||||||
articles, article_summaries,
|
articles, article_summaries, news_items,
|
||||||
)
|
)
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AlertCreate, AlertOut, AlertSeverity, AlertType, AlertUpdate,
|
AlertCreate, AlertOut, AlertSeverity, AlertType, AlertUpdate,
|
||||||
DashboardSummary, EntityCreate, EntityKind, EntityOut,
|
DashboardSummary, EntityCreate, EntityKind, EntityOut,
|
||||||
EventCreate, EventOut, FireOut, NewsArticleOut, NewsSummaryOut,
|
EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut,
|
||||||
|
NewsSummaryOut, NewsTickerItemOut,
|
||||||
FeedSourceCreate, FeedSourceOut,
|
FeedSourceCreate, FeedSourceOut,
|
||||||
KeyOut, KeyValueIn,
|
KeyOut, KeyValueIn,
|
||||||
SearchResult, SentimentSummary, SourceType,
|
SearchResult, SentimentSummary, SourceType,
|
||||||
|
|
@ -1128,7 +1129,102 @@ async def list_news_summaries(
|
||||||
return [
|
return [
|
||||||
NewsSummaryOut(
|
NewsSummaryOut(
|
||||||
id=r["id"], summary_text=r["summary_text"],
|
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
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,33 @@ class NewsSummaryOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
summary_text: str
|
summary_text: str
|
||||||
batch_timestamp: datetime
|
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 ────────────────────────────────────────────────────────
|
# ─── 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
|
DB-backed: marked `requires_db` and auto-skip when the test database is
|
||||||
unreachable (see tests/conftest.py). Seeding writes directly to the shared
|
unreachable (see tests/conftest.py). Seeding writes directly to the shared
|
||||||
`articles` / `article_summaries` tables, exactly as the scraper + summarizer
|
`articles` / `article_summaries` / `news_items` tables, exactly as the scraper
|
||||||
services would.
|
+ summarizer services would.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -37,7 +37,9 @@ def _truncate() -> None:
|
||||||
async def run():
|
async def run():
|
||||||
conn = await asyncpg.connect(**_conn_kwargs())
|
conn = await asyncpg.connect(**_conn_kwargs())
|
||||||
try:
|
try:
|
||||||
await conn.execute("TRUNCATE articles, article_summaries")
|
await conn.execute(
|
||||||
|
"TRUNCATE articles, article_summaries, news_items CASCADE"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
@ -66,14 +68,45 @@ def _seed_article(title: str, url: str, domain: str, ts: str, content: str = "bo
|
||||||
asyncio.run(run())
|
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():
|
async def run():
|
||||||
conn = await asyncpg.connect(**_conn_kwargs())
|
conn = await asyncpg.connect(**_conn_kwargs())
|
||||||
try:
|
try:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"INSERT INTO article_summaries (summary_text, batch_timestamp) "
|
"INSERT INTO news_items "
|
||||||
"VALUES ($1, $2)",
|
"(summary_id, kind, headline, importance, location_name, "
|
||||||
text, datetime.fromisoformat(ts),
|
" 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:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
@ -131,7 +164,7 @@ def test_api_news_summaries_contract(clean_news):
|
||||||
assert isinstance(body, list)
|
assert isinstance(body, list)
|
||||||
assert len(body) == 1
|
assert len(body) == 1
|
||||||
s = body[0]
|
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["summary_text"] == "master summary markdown…"
|
||||||
assert s["batch_timestamp"].startswith("2026-08-24T18:05")
|
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):
|
def test_api_news_empty(clean_news):
|
||||||
assert _get("/api/news").json() == []
|
assert _get("/api/news").json() == []
|
||||||
assert _get("/api/news/summaries").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