Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import os | |
| import re | |
| import traceback | |
| from html import escape as html_escape | |
| from pathlib import Path | |
| from typing import Optional, Any, Dict, List, Tuple | |
| from fastapi import FastAPI, HTTPException, Header, Query, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import Response, PlainTextResponse | |
| from pydantic import BaseModel | |
| from core.store import InMemorySessionStore | |
| from core.dialog import handle_turn | |
| from core.tools.build_glucose_plot import build_glucose_plot_png | |
| from core.kb import KB, KB_CACHE_DIR, KB_INDEX_PATH, KB_GDRIVE_FOLDER_ID | |
| # ✅ FIX: Speiseplan Agent liegt im Repo-Root (neben app.py), nicht in /api | |
| from speiseplan_agent import init_speiseplan_router | |
| # ------------------------------------------------- | |
| # App setup | |
| # ------------------------------------------------- | |
| app = FastAPI(title="Gluco API", version="2.4") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ------------------------------------------------- | |
| # Security / Secrets | |
| # ------------------------------------------------- | |
| HF_BEARER_TOKEN = os.getenv("HF_BEARER_TOKEN", "").strip() | |
| GLUCO_SHARED_KEY = os.getenv("GLUCO_SHARED_KEY", "").strip() | |
| # Optional: KB dir (falls euer KB-Modul env nutzt) | |
| KB_DIR = os.getenv("KB_DIR", "").strip() # z.B. "/data/kb" oder "data/kb" | |
| # ------------------------------------------------- | |
| # Session Store | |
| # ------------------------------------------------- | |
| store = InMemorySessionStore(ttl_seconds=60 * 60 * 24) | |
| # ------------------------------------------------- | |
| # Startup: KB initial reindex (safe with fingerprint skip) | |
| # ------------------------------------------------- | |
| def _startup_kb_reindex() -> None: | |
| """ | |
| Trigger KB reindex on Space restart / app start. | |
| With drive_fingerprint logic this should be fast when nothing changed. | |
| """ | |
| try: | |
| res = KB.reindex() | |
| if isinstance(res, dict) and res.get("skipped"): | |
| print("✅ KB reindex skipped (no changes in Drive).") | |
| else: | |
| print("✅ KB reindex done.") | |
| except Exception: | |
| print("❌ KB reindex on startup failed:") | |
| print(traceback.format_exc()) | |
| # ------------------------------------------------- | |
| # Models | |
| # ------------------------------------------------- | |
| class GlucoRequest(BaseModel): | |
| text: str | |
| session_id: Optional[str] = None | |
| lang: Optional[str] = "de" | |
| # ------------------------------------------------- | |
| # Helpers: Auth | |
| # ------------------------------------------------- | |
| def _auth_or_401( | |
| authorization: Optional[str], | |
| x_gluco_key: Optional[str], | |
| ) -> None: | |
| if GLUCO_SHARED_KEY: | |
| if not x_gluco_key or x_gluco_key != GLUCO_SHARED_KEY: | |
| raise HTTPException(status_code=401, detail="Invalid X-GLUCO-KEY") | |
| if HF_BEARER_TOKEN: | |
| if not authorization or not authorization.startswith("Bearer "): | |
| raise HTTPException(status_code=401, detail="Missing Authorization header") | |
| token = authorization.replace("Bearer", "").strip() | |
| if token != HF_BEARER_TOKEN: | |
| raise HTTPException(status_code=401, detail="Invalid Bearer token") | |
| # ✅ NEW: Router einhängen (nach Auth-Helper, damit injection funktioniert) | |
| app.include_router(init_speiseplan_router(_auth_or_401)) | |
| # ------------------------------------------------- | |
| # Helpers: Natürlich sprechen + Trend (DE) | |
| # ------------------------------------------------- | |
| def _strip_markdown(s: str) -> str: | |
| s = str(s or "") | |
| s = s.replace("**", "").replace("__", "").replace("`", "") | |
| s = re.sub(r"(?m)^\s*[-*•]\s+", "", s) | |
| s = re.sub(r"(?m)^\s*\d+\)\s+", "", s) | |
| s = re.sub(r"(?m)^\s*\d+\.\s+", "", s) | |
| return s | |
| def _humanize_units_de(s: str) -> str: | |
| s = str(s or "") | |
| s = s.replace("≈", "circa") | |
| s = s.replace(" / ", " pro ") | |
| s = re.sub(r"\bca\.\b", "circa", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bmg/dL\b", "Milligramm pro Deziliter", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bmmol/L\b", "Millimol pro Liter", s, flags=re.IGNORECASE) | |
| s = re.sub(r"(\d)\s*g\b", r"\1 Gramm", s) | |
| s = re.sub(r"(\d)g\b", r"\1 Gramm", s) | |
| s = re.sub(r"\bkg\b", "Kilogramm", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bml\b", "Milliliter", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bl\b", "Liter", s, flags=re.IGNORECASE) | |
| s = s.replace("%", " Prozent") | |
| # Quelle kurz: "Quelle: eigene Tabelle" -> "laut eurer Tabelle" | |
| s = re.sub( | |
| r",?\s*Quelle:\s*eigene Tabelle", | |
| ", laut eurer Tabelle", | |
| s, | |
| flags=re.IGNORECASE, | |
| ) | |
| # Redundanz entfernen: ", circa 15 Gramm pro 100 Gramm" (häufig doppelt) | |
| s = re.sub( | |
| r",?\s*circa\s*\d+(?:\s*Komma\s*\d+)?\s*Gramm\s*pro\s*100\s*Gramm", | |
| "", | |
| s, | |
| flags=re.IGNORECASE, | |
| ) | |
| return s | |
| def _de_number_speak(s: str) -> str: | |
| s = str(s or "") | |
| def repl(m: re.Match) -> str: | |
| num = m.group(0) | |
| if "." in num: | |
| a, b = num.split(".", 1) | |
| if set(b) == {"0"}: | |
| return a | |
| return f"{a} Komma {b}" | |
| return num | |
| return re.sub(r"\b\d+(?:\.\d+)?\b", repl, s) | |
| def _normalize_glucose_trend_de(s: str) -> str: | |
| s = s or "" | |
| s = s.replace("↘︎", " fallend ") | |
| s = s.replace("↘", " fallend ") | |
| s = s.replace("↓", " fallend ") | |
| s = s.replace("⇩", " fallend ") | |
| s = s.replace("⤵", " fallend ") | |
| s = s.replace("↗︎", " steigend ") | |
| s = s.replace("↗", " steigend ") | |
| s = s.replace("↑", " steigend ") | |
| s = s.replace("⇧", " steigend ") | |
| s = s.replace("→", " stabil ") | |
| s = re.sub(r"\bTrend:\s*fallend\b", "Trend ist fallend", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bTrend:\s*steigend\b", "Trend ist steigend", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bTrend:\s*stabil\b", "Trend ist stabil", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bflat\b", "stabil", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\brising\b", "steigend", s, flags=re.IGNORECASE) | |
| s = re.sub(r"\bfalling\b", "fallend", s, flags=re.IGNORECASE) | |
| return s | |
| def _natural_de_text(s: str) -> str: | |
| s = _strip_markdown(str(s or "")) | |
| s = _normalize_glucose_trend_de(s) | |
| s = _humanize_units_de(s) | |
| s = _de_number_speak(s) | |
| # "100 Gramm apfel" -> "100 Gramm Apfel" | |
| s = re.sub(r"(\d+\s+Gramm\s+)([a-zäöü])", lambda m: m.group(1) + m.group(2).upper(), s) | |
| s = s.replace("(", ", ").replace(")", "") | |
| s = re.sub(r"[ \t]+", " ", s) | |
| s = re.sub(r"\n{3,}", "\n\n", s).strip() | |
| return s | |
| def _to_ssml(s: str) -> str: | |
| s = (s or "").replace("\n", '<break time="500ms"/>') | |
| parts = s.split('<break time="500ms"/>') | |
| parts = [html_escape(p, quote=False) for p in parts] | |
| safe = '<break time="500ms"/>'.join(parts) | |
| return f"""<speak> | |
| <prosody rate="medium" pitch="+0%"> | |
| {safe} | |
| </prosody> | |
| </speak>""" | |
| # ------------------------------------------------- | |
| # Helpers: Critical Triage (Hypo/Warnbereich) | |
| # ------------------------------------------------- | |
| def _extract_glucose_and_trend(text: str) -> Tuple[Optional[int], Optional[str]]: | |
| if not text: | |
| return None, None | |
| t = text.lower() | |
| m = re.search(r"\b(\d{2,3})\s*mg/dl\b", t) | |
| value = int(m.group(1)) if m else None | |
| trend = None | |
| if re.search(r"\bfallend\b", t) or any(x in t for x in ["↘", "↓", "⇩", "⤵"]): | |
| trend = "fallend" | |
| elif re.search(r"\bsteigend\b", t) or any(x in t for x in ["↗", "↑", "⇧"]): | |
| trend = "steigend" | |
| elif re.search(r"\bstabil\b", t) or "→" in t: | |
| trend = "stabil" | |
| m2 = re.search(r"trend\s*[:=]\s*(fallend|steigend|stabil)", t) | |
| if m2: | |
| trend = m2.group(1) | |
| return value, trend | |
| def _triage_glucose_de(value: Optional[int], trend: Optional[str]) -> Optional[str]: | |
| """ | |
| Eure Vorgaben: | |
| <80 mg/dL = HYPO (out of range, zeitkritisch) | |
| 80–95 mg/dL = WARNBEREICH | |
| """ | |
| if value is None: | |
| return None | |
| if value < 80: | |
| if trend == "fallend": | |
| return ( | |
| f"🔴 Unterzucker: {value} mg/dL und fallend.\n" | |
| "Bitte jetzt sofort nach eurem Unterzucker-Handlungsplan vorgehen:\n" | |
| "1) Betreuung/Eltern informieren.\n" | |
| "2) Schnell wirksame Kohlenhydrate gemäß Plan geben.\n" | |
| "3) Engmaschig nach Plan kontrollieren.\n" | |
| "Bei schweren Symptomen (Bewusstseinsstörung/Krampf): Notfallplan/Notruf." | |
| ) | |
| return ( | |
| f"🔴 Unterzucker: {value} mg/dL.\n" | |
| "Bitte jetzt sofort nach eurem Unterzucker-Handlungsplan vorgehen:\n" | |
| "1) Betreuung/Eltern informieren.\n" | |
| "2) Schnell wirksame Kohlenhydrate gemäß Plan geben.\n" | |
| "3) Engmaschig nach Plan kontrollieren.\n" | |
| "Bei schweren Symptomen (Bewusstseinsstörung/Krampf): Notfallplan/Notruf." | |
| ) | |
| if 80 <= value <= 95: | |
| if trend == "fallend": | |
| return ( | |
| f"🟡 Warnbereich: {value} mg/dL und fallend.\n" | |
| "Bitte engmaschig beobachten und nach eurem Vorgehen bei fallendem Trend handeln " | |
| "(Betreuung informieren, Kontrolle vorbereiten)." | |
| ) | |
| return ( | |
| f"🟡 Warnbereich: {value} mg/dL.\n" | |
| "Bitte beobachten – bei Aktivität oder fallendem Trend engmaschiger kontrollieren." | |
| ) | |
| return None | |
| # ------------------------------------------------- | |
| # Root / Health | |
| # ------------------------------------------------- | |
| def root(): | |
| return {"ok": True, "service": "gluco-api"} | |
| def health(): | |
| return { | |
| "ok": True, | |
| "has_hf_bearer": bool(HF_BEARER_TOKEN), | |
| "has_gluco_shared_key": bool(GLUCO_SHARED_KEY), | |
| "kb_dir_env": KB_DIR or None, | |
| } | |
| # ------------------------------------------------- | |
| # Glucose Plot (PNG) | |
| # ------------------------------------------------- | |
| def glucose_plot( | |
| hours: int = 3, | |
| authorization: Optional[str] = Header(default=None), | |
| x_gluco_key: Optional[str] = Header(default=None), | |
| ): | |
| _auth_or_401(authorization, x_gluco_key) | |
| try: | |
| png = build_glucose_plot_png(hours=hours) | |
| return Response(content=png, media_type="image/png") | |
| except Exception as e: | |
| tb = traceback.format_exc() | |
| print("❌ GLUCO PLOT ERROR") | |
| print(tb) | |
| raise HTTPException( | |
| status_code=500, | |
| detail={"error": str(e), "exception": e.__class__.__name__, "traceback": tb}, | |
| ) | |
| # ------------------------------------------------- | |
| # Main endpoint: /gluco | |
| # ------------------------------------------------- | |
| def gluco( | |
| req: GlucoRequest, | |
| request: Request, | |
| format: str = Query(default="json"), # json | text | ssml | |
| authorization: Optional[str] = Header(default=None), | |
| x_gluco_key: Optional[str] = Header(default=None), | |
| ): | |
| _auth_or_401(authorization, x_gluco_key) | |
| try: | |
| # ---------- CRITICAL TRIAGE (pre-LLM) ---------- | |
| value, trend = _extract_glucose_and_trend(req.text or "") | |
| triage = _triage_glucose_de(value, trend) | |
| if triage: | |
| accept = (request.headers.get("accept") or "").lower() | |
| fmt = str(format).lower().strip() | |
| natural = _natural_de_text(triage) | |
| if fmt == "ssml" or "application/ssml+xml" in accept: | |
| return Response(content=_to_ssml(natural), media_type="application/ssml+xml", status_code=200) | |
| if fmt == "text" or "text/plain" in accept: | |
| return PlainTextResponse(natural, status_code=200) | |
| return { | |
| "reply": triage, | |
| "session_id": req.session_id or "voice-google", | |
| "sources": [{"type": "triage", "value_mgdl": value, "trend": trend}], | |
| } | |
| # ---------- Normal flow ---------- | |
| result = handle_turn( | |
| store=store, | |
| text=req.text, | |
| session_id=req.session_id, | |
| lang=req.lang or "de", | |
| ) | |
| if not isinstance(result, dict): | |
| raise RuntimeError("handle_turn did not return dict") | |
| reply = result.get("reply", "") | |
| reply_str = "" if reply is None else str(reply) | |
| accept = (request.headers.get("accept") or "").lower() | |
| fmt = str(format).lower().strip() | |
| natural = _natural_de_text(reply_str) | |
| if fmt == "ssml" or "application/ssml+xml" in accept: | |
| return Response(content=_to_ssml(natural), media_type="application/ssml+xml", status_code=200) | |
| if fmt == "text" or "text/plain" in accept: | |
| return PlainTextResponse(natural, status_code=200) | |
| return result | |
| except Exception as e: | |
| tb = traceback.format_exc() | |
| print("❌ GLUCO INTERNAL ERROR") | |
| print(tb) | |
| raise HTTPException( | |
| status_code=500, | |
| detail={"error": str(e), "exception": e.__class__.__name__, "traceback": tb}, | |
| ) | |
| # ------------------------------------------------- | |
| # DEBUG: KB Status | |
| # ------------------------------------------------- | |
| def debug_kb_status( | |
| authorization: Optional[str] = Header(default=None), | |
| x_gluco_key: Optional[str] = Header(default=None), | |
| ): | |
| _auth_or_401(authorization, x_gluco_key) | |
| try: | |
| st = KB.stats() | |
| return { | |
| "ok": True, | |
| "service": "kb", | |
| "enabled": st.enabled, | |
| "source": st.source, | |
| "documents": st.documents, | |
| "chunks": st.chunks, | |
| "last_index_ts": st.last_index_ts, | |
| "cache_dir": st.cache_dir, | |
| "index_path": st.index_path, | |
| "kb_dir_env": KB_DIR or None, | |
| "gdrive_folder_id_present": bool(KB_GDRIVE_FOLDER_ID), | |
| "sa_json_present": bool(os.getenv("GCP_SERVICE_ACCOUNT_JSON", "").strip()), | |
| } | |
| except Exception as e: | |
| tb = traceback.format_exc() | |
| return {"ok": False, "service": "kb", "error": str(e), "traceback": tb} | |
| # ------------------------------------------------- | |
| # DEBUG: KB Search (liefert Preview) | |
| # ------------------------------------------------- | |
| def debug_kb_search( | |
| q: str = Query(..., min_length=1), | |
| k: int = Query(5, ge=1, le=20), | |
| authorization: Optional[str] = Header(default=None), | |
| x_gluco_key: Optional[str] = Header(default=None), | |
| ): | |
| _auth_or_401(authorization, x_gluco_key) | |
| try: | |
| if hasattr(KB, "load"): | |
| KB.load() | |
| hits = KB.search(q, top_k=int(k)) | |
| out: List[Dict[str, Any]] = [] | |
| for h in hits or []: | |
| txt = getattr(h, "text", "") or "" | |
| out.append( | |
| { | |
| "doc_id": getattr(h, "doc_id", None), | |
| "doc_name": getattr(h, "doc_name", None), | |
| "chunk_id": getattr(h, "chunk_id", None), | |
| "preview": (txt[:260] + "…") if len(txt) > 260 else txt, | |
| } | |
| ) | |
| return {"ok": True, "query": q, "k": k, "hits": out} | |
| except Exception as e: | |
| tb = traceback.format_exc() | |
| return {"ok": False, "query": q, "k": k, "error": str(e), "traceback": tb} | |
| # ------------------------------------------------- | |
| # DEBUG: KB Files (Cache + Index) | |
| # ------------------------------------------------- | |
| def debug_kb_files( | |
| authorization: Optional[str] = Header(default=None), | |
| x_gluco_key: Optional[str] = Header(default=None), | |
| ): | |
| _auth_or_401(authorization, x_gluco_key) | |
| try: | |
| cache_dir = Path(KB_CACHE_DIR) | |
| idx_path = Path(KB_INDEX_PATH) | |
| files: List[Dict[str, Any]] = [] | |
| if cache_dir.exists(): | |
| for p in sorted(cache_dir.glob("*"))[:200]: | |
| if p.is_file(): | |
| files.append({"name": p.name, "bytes": p.stat().st_size}) | |
| return { | |
| "ok": True, | |
| "cache_dir": str(cache_dir), | |
| "cache_exists": cache_dir.exists(), | |
| "cache_files": files, | |
| "index_path": str(idx_path), | |
| "index_exists": idx_path.exists(), | |
| "index_bytes": idx_path.stat().st_size if idx_path.exists() else 0, | |
| } | |
| except Exception as e: | |
| tb = traceback.format_exc() | |
| return {"ok": False, "error": str(e), "traceback": tb} | |
| # ------------------------------------------------- | |
| # DEBUG: KB Reindex | |
| # ------------------------------------------------- | |
| def debug_kb_reindex( | |
| authorization: Optional[str] = Header(default=None), | |
| x_gluco_key: Optional[str] = Header(default=None), | |
| ): | |
| _auth_or_401(authorization, x_gluco_key) | |
| try: | |
| res = KB.reindex() | |
| return {"ok": True, "action": "reindex", "result": res} | |
| except Exception as e: | |
| tb = traceback.format_exc() | |
| return {"ok": False, "action": "reindex", "error": str(e), "traceback": tb} | |