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)
# -------------------------------------------------
@app.on_event("startup")
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", '')
parts = s.split('')
parts = [html_escape(p, quote=False) for p in parts]
safe = ''.join(parts)
return f"""
{safe}
"""
# -------------------------------------------------
# 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
# -------------------------------------------------
@app.get("/")
def root():
return {"ok": True, "service": "gluco-api"}
@app.get("/health")
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)
# -------------------------------------------------
@app.get("/glucose/plot")
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
# -------------------------------------------------
@app.post("/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
# -------------------------------------------------
@app.get("/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)
# -------------------------------------------------
@app.get("/debug/kb_search")
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)
# -------------------------------------------------
@app.get("/debug/kb_files")
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
# -------------------------------------------------
@app.post("/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}