NewsAtlas/backend/app/main.py

371 lines
12 KiB
Python

import datetime as dt
import json
import logging
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from sqlalchemy import select
from . import wikipedia
from .clustering import build_clusters
from .config import settings
from .db import get_session, init_db, SessionLocal
from .flights import get_latest_flights
from .ingest import fetch_all
from .markets import poll_markets, merge_spikes_into_incidents, INSTRUMENTS
from .conflict import enabled as conflict_enabled, poll_conflict_events
from .models import Article, ConflictEvent, MarketPrice, MarketSpike
from .scheduler import start_scheduler
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("newsatlas")
_scheduler = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global _scheduler
init_db()
_scheduler = start_scheduler()
yield
if _scheduler:
_scheduler.shutdown(wait=False)
app = FastAPI(title="NewsAtlas API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
def health():
return {"status": "ok", "time": dt.datetime.utcnow().isoformat()}
@app.get("/api/config")
def public_config():
"""Feature flags the frontend needs to decide what UI to show."""
return {
"weather_enabled": bool(settings.owm_api_key),
"conflict_enabled": conflict_enabled(),
"article_window_hours": settings.article_window_hours,
"flights_poll_seconds": settings.flights_poll_seconds,
}
# ---- Flights ----------------------------------------------------------
@app.get("/api/flights")
def api_flights():
return get_latest_flights()
# ---- Articles / clusters -----------------------------------------------
@app.get("/api/clusters")
def api_clusters():
session = next(get_session())
try:
return build_clusters(session)
finally:
session.close()
@app.get("/api/clusters/{cluster_key}/articles")
def api_cluster_articles(cluster_key: str):
session = next(get_session())
try:
rows = session.execute(
select(Article)
.where(Article.cluster_key == cluster_key)
.order_by(Article.published_at.desc())
).scalars().all()
return [_article_dict(a) for a in rows]
finally:
session.close()
@app.get("/api/articles")
def api_articles(limit: int = Query(100, le=500), q: str | None = None):
session = next(get_session())
try:
stmt = select(Article).order_by(Article.published_at.desc()).limit(limit)
if q:
like = f"%{q}%"
stmt = select(Article).where(Article.title.ilike(like)).order_by(
Article.published_at.desc()
).limit(limit)
rows = session.execute(stmt).scalars().all()
return [_article_dict(a) for a in rows]
finally:
session.close()
@app.post("/api/refresh")
def api_refresh():
"""Manually trigger an RSS poll (in addition to the scheduled interval)."""
session = SessionLocal()
try:
added = fetch_all(session)
return {"added": added}
finally:
session.close()
def _article_dict(a: Article) -> dict:
return {
"id": a.id,
"source": a.source,
"source_bias": a.source_bias,
"title": a.title,
"url": a.url,
"summary": a.summary,
"published_at": a.published_at.isoformat() if a.published_at else None,
"location_name": a.location_name,
"country": a.country,
"lat": a.lat,
"lon": a.lon,
}
# ---- Markets --------------------------------------------------------------
@app.get("/api/markets/latest")
def api_markets_latest():
session = next(get_session())
try:
out = []
for symbol, label, category, _currency in INSTRUMENTS:
row = session.execute(
select(MarketPrice)
.where(MarketPrice.symbol == symbol)
.order_by(MarketPrice.recorded_at.desc())
.limit(1)
).scalar_one_or_none()
if row:
out.append(
{
"symbol": row.symbol,
"label": row.label,
"category": row.category,
"price": row.price,
"currency": row.currency,
"change_pct": row.change_pct,
"recorded_at": row.recorded_at.isoformat(),
}
)
return out
finally:
session.close()
@app.get("/api/markets/history")
def api_markets_history(
symbol: str,
hours: int = Query(168, le=24 * 30),
start: str | None = None,
end: str | None = None,
):
"""`start`/`end` (ISO datetimes) fetch an explicit window — used to plot
an incident's own timeframe. Without them, falls back to the last
`hours` from now, as before."""
if start and end:
since = dt.datetime.fromisoformat(start)
until = dt.datetime.fromisoformat(end)
else:
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
until = dt.datetime.utcnow()
session = next(get_session())
try:
rows = session.execute(
select(MarketPrice)
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since, MarketPrice.recorded_at <= until)
.order_by(MarketPrice.recorded_at.asc())
).scalars().all()
return [{"price": r.price, "recorded_at": r.recorded_at.isoformat()} for r in rows]
finally:
session.close()
@app.get("/api/markets/spikes")
def api_markets_spikes(symbol: str | None = None, hours: int = Query(168, le=24 * 365)):
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
session = next(get_session())
try:
stmt = select(MarketSpike).where(MarketSpike.detected_at >= since).order_by(MarketSpike.detected_at.desc())
if symbol:
stmt = stmt.where(MarketSpike.symbol == symbol)
spikes = session.execute(stmt).scalars().all()
out = []
for s in spikes:
article_ids = json.loads(s.article_ids_json or "[]")
articles = []
if article_ids:
rows = session.execute(select(Article).where(Article.id.in_(article_ids))).scalars().all()
by_id = {a.id: a for a in rows}
articles = [_article_dict(by_id[i]) for i in article_ids if i in by_id]
out.append(
{
"id": s.id,
"symbol": s.symbol,
"label": s.label,
"from_price": s.from_price,
"to_price": s.to_price,
"pct_change": s.pct_change,
"baseline_volatility_pct": s.baseline_volatility_pct,
"window_start": s.window_start.isoformat(),
"window_end": s.window_end.isoformat(),
"detected_at": s.detected_at.isoformat(),
"candidate_articles": articles,
"top_keywords": json.loads(s.top_keywords_json or "[]"),
}
)
return out
finally:
session.close()
@app.get("/api/markets/incidents")
def api_markets_incidents(symbol: str | None = None, hours: int = Query(720, le=24 * 365)):
"""Spikes merged across instruments when they land within
MERGE_INCIDENT_HOURS of each other (see markets.merge_spikes_into_incidents)
— the comprehensive view behind the Economic Incident History UI.
`symbol`, if given, filters to incidents that include that instrument,
but still returns every instrument in the merged incident, not just it."""
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
session = next(get_session())
try:
spikes = session.execute(select(MarketSpike).where(MarketSpike.detected_at >= since)).scalars().all()
incidents = merge_spikes_into_incidents(spikes)
if symbol:
incidents = [i for i in incidents if any(instr["symbol"] == symbol for instr in i["instruments"])]
out = []
for inc in incidents:
article_ids = inc["article_ids"]
articles = []
if article_ids:
rows = session.execute(select(Article).where(Article.id.in_(article_ids))).scalars().all()
by_id = {a.id: a for a in rows}
articles = [_article_dict(by_id[i]) for i in article_ids if i in by_id]
out.append(
{
"instruments": [
{**instr, "detected_at": instr["detected_at"].isoformat()} for instr in inc["instruments"]
],
"window_start": inc["window_start"].isoformat(),
"window_end": inc["window_end"].isoformat(),
"detected_at": inc["detected_at"].isoformat(),
"latest_detected_at": inc["latest_detected_at"].isoformat(),
"candidate_articles": articles,
"top_keywords": inc["top_keywords"],
}
)
return out
finally:
session.close()
@app.post("/api/markets/refresh")
def api_markets_refresh():
session = SessionLocal()
try:
added = poll_markets(session)
return {"added": added}
finally:
session.close()
# ---- Conflict / military-movement overlay ---------------------------------
@app.get("/api/conflict-events")
def api_conflict_events(hours: int = Query(168, le=24 * 30)):
if not conflict_enabled():
return []
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
session = next(get_session())
try:
rows = session.execute(
select(ConflictEvent).where(ConflictEvent.event_date >= since)
).scalars().all()
return [
{
"id": r.id,
"event_type": r.event_type,
"actor1": r.actor1,
"actor2": r.actor2,
"fatalities": r.fatalities,
"notes": r.notes,
"location_name": r.location_name,
"country": r.country,
"lat": r.lat,
"lon": r.lon,
"event_date": r.event_date.isoformat(),
}
for r in rows
]
finally:
session.close()
@app.post("/api/conflict-events/refresh")
def api_conflict_refresh():
session = SessionLocal()
try:
added = poll_conflict_events(session)
return {"added": added}
finally:
session.close()
# ---- Weather tile proxy (keeps the OWM key server-side) -------------------
_OWM_LAYERS = {"clouds", "precipitation", "pressure", "wind", "temp"}
@app.get("/api/weather/tiles/{layer}/{z}/{x}/{y}.png")
async def weather_tile(layer: str, z: int, x: int, y: int):
if not settings.owm_api_key:
raise HTTPException(503, "OWM_API_KEY not configured")
if layer not in _OWM_LAYERS:
raise HTTPException(404, "unknown layer")
url = f"https://tile.openweathermap.org/map/{layer}_new/{z}/{x}/{y}.png"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, params={"appid": settings.owm_api_key})
if resp.status_code != 200:
raise HTTPException(resp.status_code, "upstream weather tile error")
return Response(content=resp.content, media_type="image/png", headers={"Cache-Control": "public, max-age=600"})
# ---- Wikipedia integration --------------------------------------------
@app.get("/api/wikipedia/summary")
async def api_wikipedia_summary(title: str):
result = await wikipedia.get_summary(title)
if not result:
raise HTTPException(404, "no Wikipedia summary found")
return result
@app.get("/api/wikipedia/search")
async def api_wikipedia_search(q: str, limit: int = Query(5, le=20)):
return await wikipedia.search(q, limit=limit)
@app.get("/api/wikipedia/parliament")
async def api_wikipedia_parliament(country: str):
result = await wikipedia.get_parliament(country)
if not result:
raise HTTPException(404, "no parliament diagram found for this country")
return result