NewsAtlas/backend/app/main.py

315 lines
9.6 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, 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)):
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
session = next(get_session())
try:
rows = session.execute(
select(MarketPrice)
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since)
.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,
"window_start": s.window_start.isoformat(),
"window_end": s.window_end.isoformat(),
"detected_at": s.detected_at.isoformat(),
"candidate_articles": articles,
}
)
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