from __future__ import annotations import csv import io from pathlib import Path from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import ORJSONResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from backend.atlas import Atlas from backend.define import fetch_definitions from backend.game import apply_guess, build_round, next_round, pick_hint, score_guess from backend.models import TreeQuery ROOT = Path(__file__).resolve().parents[1] DIST = ROOT / "frontend" / "dist" atlas = Atlas() app = FastAPI(title="Reverse Etymology Atlas", default_response_class=ORJSONResponse) app.add_middleware(GZipMiddleware, minimum_size=800) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.on_event("startup") def _startup() -> None: atlas.load() @app.get("/api/health") def health() -> dict: return { "ok": True, "loaded": atlas.loaded, "nodes": atlas.n, "edges": atlas.meta.get("edges"), "hyperedges": atlas.meta.get("hyperedges"), "load_ms": getattr(atlas, "load_ms", None), "citation": atlas.meta.get("citation"), } @app.get("/api/meta") def meta() -> dict: m = atlas.meta return { "relations": m.get("relations"), "default_relations": m.get("default_relations"), "macroareas": m.get("macroareas"), "statuses": m.get("statuses"), "languages": m.get("languages_list"), "families": m.get("families"), "wals_features": m.get("wals_features"), "top_phonemes": m.get("top_phonemes"), "examples": m.get("examples"), "citation": m.get("citation"), "nodes": m.get("nodes"), "edges": m.get("edges"), "hyperedges": m.get("hyperedges"), } @app.get("/api/suggest") def suggest(q: str = "", limit: int = 20) -> dict: return {"hits": atlas.suggest(q, limit=limit)} @app.get("/api/examples") def examples() -> dict: return {"examples": atlas.meta.get("examples") or []} @app.get("/api/node") def node(term: str, lang: str, ety: str | None = None) -> dict: data = atlas.inspector(term, lang, ety) if data is None: raise HTTPException(404, "Unknown word") return data @app.get("/api/define") def define(term: str, lang: str = "", ety: str | None = None, iso: str | None = None) -> dict: """Short glosses from the local Kaikki dump (baked into atlas.sqlite).""" return fetch_definitions(term=term, lang=lang, ety=ety, iso_639_3=iso, atlas=atlas) @app.post("/api/tree") def tree(query: TreeQuery) -> dict: return atlas.walk(query) @app.get("/api/relate") def relate( a: str = Query(..., description="First term"), a_lang: str = Query(default="english"), b: str = Query(..., description="Second term"), b_lang: str = Query(default="spanish"), max_depth: int = Query(default=12, ge=2, le=16), ) -> dict: return atlas.relate(a, a_lang, b, b_lang, max_depth=max_depth) @app.get("/api/game/round") def game_round( en: str | None = None, es: str | None = None, avoid: str = Query(default="", description="Comma-separated round ids to skip"), ) -> dict: if en and es: rnd = build_round(atlas, en.strip(), es.strip(), source="custom") else: skip = {x.strip() for x in avoid.split(",") if x.strip()} rnd = next_round(atlas, avoid=skip) if not rnd.get("ok"): raise HTTPException(404, rnd.get("error") or "No round available") return rnd @app.post("/api/game/guess") def game_guess(body: dict) -> dict: """Redactle guess. Body: {round, word, revealed?}.""" rnd = body.get("round") or {} if not rnd.get("answer"): raise HTTPException(400, "round.answer required") return apply_guess(rnd, body.get("word") or "", body.get("revealed")) @app.post("/api/game/hint") def game_hint(body: dict) -> dict: """Reveal one hidden corpus token. Body: {round, revealed?}.""" rnd = body.get("round") or {} if not rnd.get("answer"): raise HTTPException(400, "round.answer required") return pick_hint(rnd, body.get("revealed")) @app.post("/api/game/score") def game_score(body: dict) -> dict: """Score end guesses. Body: {round, en?, es?, guess_count?}.""" rnd = body.get("round") or {} if not rnd.get("answer"): raise HTTPException(400, "round.answer required") return score_guess( rnd, body.get("en") or body.get("en_guess"), body.get("es") or body.get("es_guess"), guess_count=body.get("guess_count"), # Legacy clients branch_a_guess=body.get("branch_a"), branch_b_guess=body.get("branch_b"), bridge_guess=body.get("bridge"), chain_guess=body.get("chain"), ) @app.post("/api/export") def export_tree(query: TreeQuery, format: str = Query(default="csv")) -> Response: result = atlas.walk(query) nodes = [n for n in result.get("nodes", []) if n.get("kind") == "word"] if format == "json": return ORJSONResponse(nodes) buf = io.StringIO() writer = csv.writer(buf) writer.writerow( [ "term", "language", "language_display", "family", "macroarea", "relation", "confidence", "depth", "latitude", "longitude", ] ) for n in nodes: writer.writerow( [ n.get("term"), n.get("lang"), n.get("lang_display"), n.get("family_name"), n.get("macroarea"), n.get("relation"), n.get("confidence"), n.get("depth"), n.get("latitude"), n.get("longitude"), ] ) data = buf.getvalue().encode("utf-8") return StreamingResponse( iter([data]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=reverse-etymology.csv"}, ) if DIST.exists(): app.mount("/", StaticFiles(directory=str(DIST), html=True), name="spa") else: @app.get("/") def _missing_frontend() -> dict: return { "error": "frontend not built", "hint": "cd frontend && npm install && npm run build", "api": "/api/health", }