Spaces:
Sleeping
Sleeping
| 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=["*"], | |
| ) | |
| def _startup() -> None: | |
| atlas.load() | |
| 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"), | |
| } | |
| 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"), | |
| } | |
| def suggest(q: str = "", limit: int = 20) -> dict: | |
| return {"hits": atlas.suggest(q, limit=limit)} | |
| def examples() -> dict: | |
| return {"examples": atlas.meta.get("examples") or []} | |
| 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 | |
| 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) | |
| def tree(query: TreeQuery) -> dict: | |
| return atlas.walk(query) | |
| 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) | |
| 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 | |
| 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")) | |
| 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")) | |
| 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"), | |
| ) | |
| 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: | |
| def _missing_frontend() -> dict: | |
| return { | |
| "error": "frontend not built", | |
| "hint": "cd frontend && npm install && npm run build", | |
| "api": "/api/health", | |
| } | |