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 .polymarket import get_latest_polymarket from .ingest import fetch_all from .markets import poll_markets, merge_spikes_into_incidents, has_fetch_error, FROZEN_SYMBOLS, 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() # ---- Polymarket --------------------------------------------------------- @app.get("/api/polymarket") def api_polymarket(): return get_latest_polymarket() # ---- 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(), "frozen": symbol in FROZEN_SYMBOLS, # FROZEN already explains a stale price for that one # documented instrument — don't also flag it ERROR. "error": symbol not in FROZEN_SYMBOLS and has_fetch_error(symbol), } ) 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