Spaces:
Sleeping
Sleeping
| # -*- coding: utf-8 -*- | |
| # app.py — Gluco (Vollausbau Option 2) | |
| # ------------------------------------------------------------ | |
| # Features: | |
| # - Live Nightscout Monitor + Trend/Delta + Ampel (80–180) | |
| # - 1x Begrüßung pro Session: "Hey, ich bin Gluco ..." + Status + Leitplanken-Empfehlung | |
| # - Audio: | |
| # * Sidebar Toggle: Audio AN/AUS | |
| # * STT robust via whisper-1 + tempfile (fix "Transkription fehlgeschlagen") | |
| # * TTS via gpt-4o-mini-tts (fallback tts-1) -> Audio-Player unter letzter Bot-Antwort | |
| # - Agent: | |
| # * LLM NLU (Intent/Entities): carb_calc | situation | what_if | other | |
| # * What-if: erkennt auch Zahlwörter (neunzig, siebzig, hundert) | |
| # * Guidance Resolver: aus Sheet-Tab "guidances" | |
| # - escalation nur guarded (Unsicherheit / Daten fehlen / Daten alt) | |
| # - relaxed Trend-Match ab >=95 mg/dl | |
| # - Kategorien meal/insulin/device nur wenn der Text dazu passt | |
| # - wenn keine Regel passt: klar markierte "Allgemeine Hinweise (außerhalb Leitplanken)" | |
| # - Kohlenhydrate: | |
| # * known_dishes (name, kh_per_100g) in separatem Sheet | |
| # * Alias-Lernen in optionalem Tab "aliases" (alias -> name) | |
| # * Disambiguation Buttons bei mehreren Treffern | |
| # ------------------------------------------------------------ | |
| # Secrets / ENV: | |
| # Public: | |
| # NIGHTSCOUT_BASE_URL | |
| # GSHEETS_AGENT_SHEET_ID (oder AGENT_SHEET_ID) | |
| # GSHEETS_EXISTING_SHEET_ID (known_dishes sheet) | |
| # Private: | |
| # NIGHTSCOUT_TOKEN | |
| # OPENAI_API_KEY | |
| # gcp_service_account (Service Account JSON als TEXT) | |
| # | |
| # Optional Model Overrides: | |
| # OPENAI_MODEL_NLU (default: gpt-4o-mini) | |
| # OPENAI_MODEL_REPLY (default: gpt-4.1-mini) | |
| # OPENAI_STT_MODEL (default: whisper-1) | |
| # OPENAI_TTS_VOICE (default: alloy) | |
| # ------------------------------------------------------------ | |
| from __future__ import annotations | |
| import difflib | |
| import json | |
| import os | |
| import re | |
| import string | |
| import tempfile | |
| import time | |
| from dataclasses import dataclass | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import pandas as pd | |
| import requests | |
| import streamlit as st | |
| from speiseplan.auto_review import start_auto_review_from_trigger | |
| from speiseplan.trigger import parse_speiseplan_trigger | |
| try: | |
| import gspread | |
| from google.oauth2.service_account import Credentials | |
| except Exception: | |
| gspread = None # type: ignore | |
| Credentials = None # type: ignore | |
| try: | |
| from openai import OpenAI | |
| except Exception: | |
| OpenAI = None # type: ignore | |
| # ---------------------------- | |
| # Secrets helpers | |
| # ---------------------------- | |
| def get_secret(name: str, default: str = "") -> str: | |
| if name in st.secrets: | |
| v = st.secrets[name] | |
| return str(v).strip() | |
| return os.getenv(name, default).strip() | |
| NIGHTSCOUT_BASE_URL = get_secret("NIGHTSCOUT_BASE_URL").rstrip("/") | |
| NIGHTSCOUT_TOKEN = get_secret("NIGHTSCOUT_TOKEN") | |
| AGENT_SHEET_ID = get_secret("AGENT_SHEET_ID") or get_secret("GSHEETS_AGENT_SHEET_ID") | |
| DISHES_SHEET_ID = get_secret("GSHEETS_EXISTING_SHEET_ID") | |
| SA_JSON_TEXT = get_secret("gcp_service_account") or get_secret("GSHEETS_SA_JSON") | |
| OPENAI_API_KEY = get_secret("OPENAI_API_KEY") | |
| OPENAI_MODEL_NLU = get_secret("OPENAI_MODEL_NLU", "gpt-4o-mini") | |
| OPENAI_MODEL_REPLY = get_secret("OPENAI_MODEL_REPLY", "gpt-4.1-mini") | |
| OPENAI_STT_MODEL = get_secret("OPENAI_STT_MODEL", "whisper-1") | |
| OPENAI_TTS_VOICE = get_secret("OPENAI_TTS_VOICE", "alloy") | |
| # ---------------------------- | |
| # Basic utils | |
| # ---------------------------- | |
| def _to_float(x: Any, default: float = 0.0) -> float: | |
| if x is None or (isinstance(x, float) and pd.isna(x)): | |
| return float(default) | |
| if isinstance(x, (int, float)): | |
| return float(x) | |
| s = str(x).strip().replace(",", ".") | |
| if not s: | |
| return float(default) | |
| try: | |
| return float(s) | |
| except Exception: | |
| return float(default) | |
| def _norm_str(x: Any, default: str = "") -> str: | |
| if x is None or (isinstance(x, float) and pd.isna(x)): | |
| return default | |
| s = str(x).strip() | |
| return s if s else default | |
| def normalize_food(s: str) -> str: | |
| s = (s or "").lower().strip() | |
| s = ( | |
| s.replace("ä", "ae") | |
| .replace("ö", "oe") | |
| .replace("ü", "ue") | |
| .replace("ß", "ss") | |
| ) | |
| s = s.translate(str.maketrans("", "", string.punctuation)) | |
| s = re.sub(r"\s+", " ", s).strip() | |
| return s | |
| def clamp_text(text: str, max_len: int = 1200) -> str: | |
| t = (text or "").strip() | |
| if len(t) <= max_len: | |
| return t | |
| return t[:max_len].rstrip() + " ..." | |
| def make_tts_text(answer_markdown: str) -> str: | |
| """Kurz & sprechbar.""" | |
| if not answer_markdown: | |
| return "" | |
| t = answer_markdown | |
| t = re.sub(r"```.*?```", " ", t, flags=re.DOTALL) | |
| t = re.sub(r"[*_`#>\-]", " ", t) | |
| t = re.sub(r"\s+", " ", t).strip() | |
| if len(t) > 320: | |
| t = t[:320].rsplit(" ", 1)[0].strip() | |
| if not t.endswith((".", "!", "?")): | |
| t += "." | |
| return t | |
| # ---------------------------- | |
| # Intent helpers (keywords) | |
| # ---------------------------- | |
| def user_mentions_meal(text: str) -> bool: | |
| low = (text or "").lower() | |
| return any(k in low for k in [ | |
| "essen", "mahlzeit", "snack", "fruehst", "frühst", "mittag", "abend", | |
| "kohlenhydrat", "kohlenhydrate", "kh", "eingeben", | |
| "banane", "reis", "brot", "saft", "obst", "kuchen", "breze", "brezel", "apfel" | |
| ]) | |
| def user_mentions_insulin(text: str) -> bool: | |
| low = (text or "").lower() | |
| return any(k in low for k in ["insulin", "bolus", "korrekt", "korr", "pumpe", "basal"]) | |
| def user_mentions_device(text: str) -> bool: | |
| low = (text or "").lower() | |
| return any(k in low for k in ["signal", "verbindung", "sensor", "bluetooth", "handy", "app", "auto-modus", "automodus"]) | |
| def user_mentions_uncertainty(text: str) -> bool: | |
| low = (text or "").lower() | |
| return any(k in low for k in [ | |
| "unsicher", "unklar", "komisch", "schlecht", | |
| "symptom", "symptome", "bewusstlos", "hilfe", "notfall", | |
| "krampf", "erbricht", "erbrechen" | |
| ]) | |
| def is_target_range_question(text: str) -> bool: | |
| low = (text or "").lower() | |
| return ("zielbereich" in low) or ("im ziel" in low) or ("in range" in low) or ("im bereich" in low) | |
| def validate_glucose_candidate(v: Any) -> Optional[float]: | |
| try: | |
| if v is None: | |
| return None | |
| f = float(v) | |
| if 40 <= f <= 400: | |
| return f | |
| return None | |
| except Exception: | |
| return None | |
| # ---------------------------- | |
| # Zahlwörter -> Zahlen (WHAT-IF) | |
| # ---------------------------- | |
| GERMAN_NUMBERS: Dict[str, int] = { | |
| "null": 0, | |
| "eins": 1, "ein": 1, "eine": 1, "einen": 1, | |
| "zwei": 2, "drei": 3, "vier": 4, | |
| "fuenf": 5, "fünf": 5, | |
| "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, | |
| "zehn": 10, "elf": 11, | |
| "zwoelf": 12, "zwölf": 12, | |
| "dreizehn": 13, "vierzehn": 14, | |
| "fuenfzehn": 15, "fünfzehn": 15, | |
| "sechzehn": 16, "siebzehn": 17, "achtzehn": 18, "neunzehn": 19, | |
| "zwanzig": 20, "dreissig": 30, "dreißig": 30, | |
| "vierzig": 40, | |
| "fuenfzig": 50, "fünfzig": 50, | |
| "sechzig": 60, "siebzig": 70, "achtzig": 80, | |
| "neunzig": 90, "hundert": 100, | |
| } | |
| def extract_german_number_word(text: str) -> Optional[int]: | |
| t = (text or "").lower() | |
| m = re.search(r"\b(\d{2,3})\b", t) | |
| if m: | |
| v = int(m.group(1)) | |
| return v if 40 <= v <= 400 else None | |
| for w, val in GERMAN_NUMBERS.items(): | |
| if re.search(rf"\b{re.escape(w)}\b", t): | |
| if 40 <= val <= 400: | |
| return val | |
| if val == 100: | |
| return 100 | |
| return None | |
| # ============================================================ | |
| # OpenAI client + Audio STT/TTS | |
| # ============================================================ | |
| def _openai_client() -> Optional[Any]: | |
| if not OPENAI_API_KEY or OpenAI is None: | |
| return None | |
| return OpenAI(api_key=OPENAI_API_KEY) | |
| def transcribe_audio_openai(uploaded_audio) -> str: | |
| """ | |
| Robust STT: | |
| - always writes bytes to tempfile | |
| - uses whisper-1 (most stable across environments) | |
| """ | |
| client = _openai_client() | |
| if client is None or uploaded_audio is None: | |
| return "" | |
| audio_bytes = uploaded_audio.getvalue() | |
| if not audio_bytes: | |
| return "" | |
| # Determine extension | |
| filename = getattr(uploaded_audio, "name", None) or "audio.webm" | |
| ext = filename.split(".")[-1].lower() if "." in filename else "webm" | |
| if ext not in ("webm", "m4a", "mp3", "wav", "ogg"): | |
| ext = "webm" | |
| try: | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp: | |
| tmp.write(audio_bytes) | |
| tmp_path = tmp.name | |
| with open(tmp_path, "rb") as f: | |
| res = client.audio.transcriptions.create( | |
| model=OPENAI_STT_MODEL or "whisper-1", | |
| file=f, | |
| ) | |
| text = (getattr(res, "text", "") or "").strip() | |
| return text | |
| except Exception: | |
| return "" | |
| def synthesize_speech_openai(text: str, voice: str = "alloy") -> Optional[bytes]: | |
| client = _openai_client() | |
| if client is None: | |
| return None | |
| t = clamp_text(text, max_len=1200) | |
| if not t: | |
| return None | |
| try: | |
| try: | |
| audio = client.audio.speech.create( | |
| model="gpt-4o-mini-tts", | |
| voice=voice, | |
| format="mp3", | |
| input=t, | |
| ) | |
| return audio.read() | |
| except Exception: | |
| audio = client.audio.speech.create( | |
| model="tts-1", | |
| voice=voice, | |
| format="mp3", | |
| input=t, | |
| ) | |
| return audio.read() | |
| except Exception: | |
| return None | |
| def should_speak(input_was_voice: bool, speak_always: bool) -> bool: | |
| if not st.session_state.get("audio_enabled", True): | |
| return False | |
| return bool(input_was_voice or speak_always) | |
| # ============================================================ | |
| # Google Sheets (gspread) | |
| # ============================================================ | |
| def _parse_sa_info(sa_json_text: str) -> dict: | |
| if not sa_json_text: | |
| raise ValueError("Service Account JSON fehlt (gcp_service_account / GSHEETS_SA_JSON).") | |
| return json.loads(sa_json_text) | |
| def get_gspread_client(sa_json_text: str, readonly: bool = True): | |
| if gspread is None or Credentials is None: | |
| raise RuntimeError("gspread/google-auth nicht installiert.") | |
| sa_info = _parse_sa_info(sa_json_text) | |
| scopes = ( | |
| [ | |
| "https://www.googleapis.com/auth/spreadsheets.readonly", | |
| "https://www.googleapis.com/auth/drive.readonly", | |
| ] if readonly else | |
| [ | |
| "https://www.googleapis.com/auth/spreadsheets", | |
| "https://www.googleapis.com/auth/drive", | |
| ] | |
| ) | |
| creds = Credentials.from_service_account_info(sa_info, scopes=scopes) | |
| return gspread.authorize(creds) | |
| def load_tab_from_sheet(sheet_id: str, sa_json_text: str, tab: str) -> pd.DataFrame: | |
| if not sheet_id: | |
| return pd.DataFrame() | |
| gc = get_gspread_client(sa_json_text, readonly=True) | |
| sh = gc.open_by_key(sheet_id) | |
| ws = sh.worksheet(tab) | |
| values = ws.get_all_values() | |
| if not values or len(values) < 2: | |
| return pd.DataFrame() | |
| header = values[0] | |
| rows = values[1:] | |
| return pd.DataFrame(rows, columns=header) | |
| def load_tab_optional(sheet_id: str, sa_json_text: str, tab: str) -> pd.DataFrame: | |
| try: | |
| return load_tab_from_sheet(sheet_id, sa_json_text, tab) | |
| except Exception: | |
| return pd.DataFrame() | |
| def ensure_aliases_tab(sheet_id: str, sa_json_text: str, tab_name: str = "aliases"): | |
| gc = get_gspread_client(sa_json_text, readonly=False) | |
| sh = gc.open_by_key(sheet_id) | |
| titles = [w.title for w in sh.worksheets()] | |
| if tab_name in titles: | |
| return sh.worksheet(tab_name) | |
| ws = sh.add_worksheet(title=tab_name, rows=200, cols=3) | |
| ws.update("A1:C1", [["alias", "name", "updated_at"]]) | |
| return ws | |
| def save_alias(dishes_sheet_id: str, sa_json_text: str, alias_raw: str, canonical_name: str) -> bool: | |
| alias_norm = normalize_food(alias_raw) | |
| canonical = (canonical_name or "").strip() | |
| if not alias_norm or not canonical: | |
| return False | |
| try: | |
| ws = ensure_aliases_tab(dishes_sheet_id, sa_json_text, "aliases") | |
| values = ws.get_all_values() | |
| now = time.strftime("%Y-%m-%d %H:%M:%S") | |
| if not values or len(values) < 2: | |
| ws.append_row([alias_norm, canonical, now]) | |
| return True | |
| for idx, row in enumerate(values[1:], start=2): | |
| existing_alias = normalize_food(row[0]) if len(row) > 0 else "" | |
| if existing_alias == alias_norm: | |
| ws.update(f"A{idx}:C{idx}", [[alias_norm, canonical, now]]) | |
| return True | |
| ws.append_row([alias_norm, canonical, now]) | |
| return True | |
| except Exception: | |
| return False | |
| # ============================================================ | |
| # known_dishes + aliases | |
| # ============================================================ | |
| def prepare_known_dishes(df: pd.DataFrame) -> pd.DataFrame: | |
| if df is None or len(df) == 0: | |
| return pd.DataFrame(columns=["name", "kh_per_100g", "name_norm"]) | |
| if "name" not in df.columns or "kh_per_100g" not in df.columns: | |
| cols = {c.lower().strip(): c for c in df.columns} | |
| n = cols.get("name") | |
| k = cols.get("kh_per_100g") | |
| if not n or not k: | |
| return pd.DataFrame(columns=["name", "kh_per_100g", "name_norm"]) | |
| df = df.rename(columns={n: "name", k: "kh_per_100g"}) | |
| out = df[["name", "kh_per_100g"]].copy() | |
| out["name"] = out["name"].astype(str).str.strip() | |
| out["kh_per_100g"] = out["kh_per_100g"].apply(lambda x: _to_float(x, default=-1)) | |
| out = out[(out["name"] != "") & (out["kh_per_100g"] >= 0)].copy() | |
| out["name_norm"] = out["name"].apply(normalize_food) | |
| return out.reset_index(drop=True) | |
| def prepare_aliases(df: pd.DataFrame) -> pd.DataFrame: | |
| if df is None or len(df) == 0: | |
| return pd.DataFrame(columns=["alias_norm", "name"]) | |
| if "alias" not in df.columns or "name" not in df.columns: | |
| cols = {c.lower().strip(): c for c in df.columns} | |
| a = cols.get("alias") | |
| n = cols.get("name") | |
| if not a or not n: | |
| return pd.DataFrame(columns=["alias_norm", "name"]) | |
| df = df.rename(columns={a: "alias", n: "name"}) | |
| out = df[["alias", "name"]].copy() | |
| out["alias_norm"] = out["alias"].astype(str).apply(normalize_food) | |
| out["name"] = out["name"].astype(str).str.strip() | |
| out = out[(out["alias_norm"] != "") & (out["name"] != "")].copy() | |
| return out.reset_index(drop=True) | |
| def resolve_alias(aliases_df: pd.DataFrame, food_query: str) -> str: | |
| if aliases_df is None or len(aliases_df) == 0: | |
| return food_query | |
| qn = normalize_food(food_query) | |
| if not qn: | |
| return food_query | |
| hit = aliases_df[aliases_df["alias_norm"] == qn] | |
| if len(hit): | |
| return str(hit.iloc[0]["name"]).strip() | |
| return food_query | |
| def find_food_kh( | |
| known_df: pd.DataFrame, | |
| aliases_df: pd.DataFrame, | |
| food_query: str, | |
| n_suggestions: int = 5 | |
| ) -> Tuple[Optional[dict], List[str]]: | |
| if known_df is None or len(known_df) == 0: | |
| return None, [] | |
| fq = resolve_alias(aliases_df, food_query) | |
| q = normalize_food(fq) | |
| if not q: | |
| return None, [] | |
| exact = known_df[known_df["name_norm"] == q] | |
| if len(exact) == 1: | |
| r = exact.iloc[0] | |
| return {"name": r["name"], "kh_per_100g": float(r["kh_per_100g"])}, [] | |
| contains = known_df[known_df["name_norm"].str.contains(re.escape(q), na=False)] | |
| if len(contains) == 1: | |
| r = contains.iloc[0] | |
| return {"name": r["name"], "kh_per_100g": float(r["kh_per_100g"])}, [] | |
| if len(contains) > 1: | |
| return None, contains["name"].head(n_suggestions).tolist() | |
| choices = known_df["name_norm"].tolist() | |
| close_norm = difflib.get_close_matches(q, choices, n=n_suggestions, cutoff=0.6) | |
| if close_norm: | |
| sug = known_df[known_df["name_norm"].isin(close_norm)]["name"].head(n_suggestions).tolist() | |
| return None, sug | |
| return None, [] | |
| # ============================================================ | |
| # Guidances resolver | |
| # ============================================================ | |
| ALLOWED_TRENDS = {"any", "stable", "rising", "falling", "double_falling"} | |
| def _norm_trend(x: Any) -> str: | |
| s = _norm_str(x, default="any").lower() | |
| mapping = { | |
| "": "any", | |
| "any": "any", | |
| "stable": "stable", | |
| "stabil": "stable", | |
| "flat": "stable", | |
| "rising": "rising", | |
| "steigend": "rising", | |
| "up": "rising", | |
| "falling": "falling", | |
| "fallend": "falling", | |
| "down": "falling", | |
| "double_falling": "double_falling", | |
| "doppelpfeil": "double_falling", | |
| "doubledown": "double_falling", | |
| } | |
| s = mapping.get(s, s) | |
| return s if s in ALLOWED_TRENDS else "any" | |
| def prepare_guidances_df(df: pd.DataFrame) -> pd.DataFrame: | |
| if df is None or len(df) == 0: | |
| return pd.DataFrame() | |
| required = [ | |
| "guidance_id", "category", "priority", | |
| "glucose_min_mgdl", "glucose_max_mgdl", | |
| "trend", "condition_note", "action", | |
| "carbs_g", "food_examples", "follow_up", "source" | |
| ] | |
| for c in required: | |
| if c not in df.columns: | |
| df[c] = "" | |
| out = df[required].copy() | |
| out["guidance_id"] = out["guidance_id"].apply(lambda x: _norm_str(x, "")) | |
| out["category"] = out["category"].apply(lambda x: _norm_str(x, "")) | |
| out["priority"] = out["priority"].apply(lambda x: int(_to_float(x, 9999))) | |
| out["glucose_min_mgdl"] = out["glucose_min_mgdl"].apply(lambda x: _to_float(x, 0)) | |
| out["glucose_max_mgdl"] = out["glucose_max_mgdl"].apply(lambda x: _to_float(x, 999)) | |
| out["trend"] = out["trend"].apply(_norm_trend) | |
| for c in ["condition_note", "action", "carbs_g", "food_examples", "follow_up", "source"]: | |
| out[c] = out[c].apply(lambda x: _norm_str(x, "")) | |
| out = out[~((out["action"] == "") & (out["guidance_id"] == ""))].copy() | |
| out["__range_width"] = (out["glucose_max_mgdl"] - out["glucose_min_mgdl"]).abs() | |
| out = out.sort_values(["priority", "__range_width"], ascending=[True, True]).drop(columns="__range_width").reset_index(drop=True) | |
| return out | |
| class GuidanceMatch: | |
| rule: Dict[str, Any] | |
| matched: bool | |
| reason: str | |
| def resolve_guidance( | |
| guidances_df: pd.DataFrame, | |
| glucose_mgdl: Optional[float], | |
| trend: str, | |
| user_text: str, | |
| data_age_min: Optional[int] = None, | |
| ) -> GuidanceMatch: | |
| """ | |
| - escalation NICHT automatisch verwenden | |
| - relaxed match wenn glucose>=95 und kein Trend-match | |
| - escalation nur wenn (unsicher oder Daten fehlen/alt) UND keine andere Regel passt | |
| - category meal/insulin/device nur wenn Text dazu passt | |
| """ | |
| if guidances_df is None or len(guidances_df) == 0: | |
| return GuidanceMatch( | |
| rule={"guidance_id": "NO_GUIDANCES", "action": "Es sind keine Regeln geladen (Tab 'guidances')."}, | |
| matched=False, | |
| reason="guidances_df empty", | |
| ) | |
| t = _norm_trend(trend) | |
| user_uncertain = user_mentions_uncertainty(user_text) | |
| data_stale_or_missing = (glucose_mgdl is None) or (data_age_min is not None and data_age_min >= 15) | |
| df_main = guidances_df[guidances_df["category"].astype(str).str.lower() != "escalation"].copy() | |
| df_escalation = guidances_df[guidances_df["category"].astype(str).str.lower() == "escalation"].copy() | |
| meal_ok = user_mentions_meal(user_text) | |
| insulin_ok = user_mentions_insulin(user_text) | |
| device_ok = user_mentions_device(user_text) | |
| def _category_allowed(cat: str) -> bool: | |
| c = (cat or "").lower().strip() | |
| if c == "meal": | |
| return meal_ok | |
| if c == "insulin": | |
| return insulin_ok | |
| if c == "device": | |
| return device_ok | |
| return True | |
| def _match(df: pd.DataFrame, g: Optional[float], trend_to_use: str, ignore_trend: bool = False) -> Optional[dict]: | |
| if df is None or len(df) == 0: | |
| return None | |
| if g is None: | |
| # Only rules that don't require glucose range? Here: pick first allowed | |
| for _, r in df.iterrows(): | |
| if not _category_allowed(_norm_str(r.get("category"))): | |
| continue | |
| r_tr = _norm_trend(r.get("trend")) | |
| if not ignore_trend and r_tr not in ("any", trend_to_use): | |
| continue | |
| if _norm_str(r.get("action")) == "": | |
| continue | |
| return r.to_dict() | |
| return None | |
| gv = float(g) | |
| for _, r in df.iterrows(): | |
| if not _category_allowed(_norm_str(r.get("category"))): | |
| continue | |
| r_min = float(r.get("glucose_min_mgdl", 0)) | |
| r_max = float(r.get("glucose_max_mgdl", 999)) | |
| if not (r_min <= gv <= r_max): | |
| continue | |
| r_tr = _norm_trend(r.get("trend")) | |
| if not ignore_trend and r_tr != "any" and r_tr != trend_to_use: | |
| continue | |
| if _norm_str(r.get("action")) == "": | |
| continue | |
| return r.to_dict() | |
| return None | |
| rule = _match(df_main, glucose_mgdl, t, ignore_trend=False) | |
| if rule: | |
| return GuidanceMatch(rule=rule, matched=True, reason=f"match {rule.get('guidance_id')} strict") | |
| if glucose_mgdl is not None and float(glucose_mgdl) >= 95: | |
| rule = _match(df_main, glucose_mgdl, t, ignore_trend=True) | |
| if rule: | |
| return GuidanceMatch(rule=rule, matched=True, reason=f"match {rule.get('guidance_id')} relaxed_trend") | |
| if (user_uncertain or data_stale_or_missing) and len(df_escalation) > 0: | |
| rule = _match(df_escalation, glucose_mgdl, t, ignore_trend=True) | |
| if rule: | |
| return GuidanceMatch(rule=rule, matched=True, reason=f"match {rule.get('guidance_id')} escalation_guarded") | |
| return GuidanceMatch( | |
| rule={"guidance_id": "NO_MATCH", "action": "Ich finde keine passende Regel. Wenn du unsicher bist: bitte Kontaktperson anrufen."}, | |
| matched=False, | |
| reason=f"no match trend={t}, glucose={glucose_mgdl}", | |
| ) | |
| # ============================================================ | |
| # Nightscout | |
| # ============================================================ | |
| def _ns_headers(token: str, mode: str) -> dict: | |
| h = {"Accept": "application/json"} | |
| if token: | |
| if mode == "bearer": | |
| h["Authorization"] = f"Bearer {token}" | |
| elif mode == "api-secret": | |
| h["api-secret"] = token | |
| return h | |
| def direction_to_trend(direction: str) -> str: | |
| d = (direction or "").lower() | |
| if "double" in d and "down" in d: | |
| return "double_falling" | |
| if "down" in d: | |
| return "falling" | |
| if "up" in d: | |
| return "rising" | |
| if "flat" in d: | |
| return "stable" | |
| return "any" | |
| def fetch_nightscout_latest(ns_url: str, token: str) -> dict: | |
| if not ns_url: | |
| return {"ok": False, "error": "NIGHTSCOUT_BASE_URL fehlt."} | |
| endpoint = f"{ns_url}/api/v1/entries.json" | |
| params = {"count": 1} | |
| r = requests.get(endpoint, params=params, headers=_ns_headers(token, "bearer"), timeout=10) | |
| if r.status_code in (401, 403) and token: | |
| r = requests.get(endpoint, params=params, headers=_ns_headers(token, "api-secret"), timeout=10) | |
| if r.status_code != 200: | |
| return {"ok": False, "error": f"HTTP {r.status_code}: {r.text[:200]}"} | |
| data = r.json() | |
| if not isinstance(data, list) or not data: | |
| return {"ok": False, "error": "Keine entries erhalten (leere Liste)."} | |
| e = data[0] | |
| sgv = e.get("sgv") | |
| direction = e.get("direction") or "" | |
| date_ms = e.get("date") | |
| ts = (int(date_ms) / 1000.0) if isinstance(date_ms, (int, float)) else None | |
| age_min = int((time.time() - ts) / 60) if ts else None | |
| return {"ok": True, "sgv": sgv, "direction": direction, "timestamp": ts, "age_min": age_min, "raw": e} | |
| def fetch_nightscout_history(ns_url: str, token: str, minutes: int = 180) -> dict: | |
| if not ns_url: | |
| return {"ok": False, "error": "NIGHTSCOUT_BASE_URL fehlt."} | |
| endpoint = f"{ns_url}/api/v1/entries.json" | |
| params = {"count": 400} | |
| cutoff = time.time() - minutes * 60 | |
| r = requests.get(endpoint, params=params, headers=_ns_headers(token, "bearer"), timeout=10) | |
| if r.status_code in (401, 403) and token: | |
| r = requests.get(endpoint, params=params, headers=_ns_headers(token, "api-secret"), timeout=10) | |
| if r.status_code != 200: | |
| return {"ok": False, "error": f"HTTP {r.status_code}: {r.text[:200]}"} | |
| data = r.json() | |
| if not isinstance(data, list) or not data: | |
| return {"ok": False, "error": "Keine entries erhalten (leere Liste)."} | |
| rows = [] | |
| for e in data: | |
| date_ms = e.get("date") | |
| if not isinstance(date_ms, (int, float)): | |
| continue | |
| ts = int(date_ms) / 1000.0 | |
| if ts < cutoff: | |
| continue | |
| rows.append({ | |
| "time": pd.to_datetime(ts, unit="s"), | |
| "sgv": e.get("sgv"), | |
| "direction": e.get("direction") or "" | |
| }) | |
| if not rows: | |
| return {"ok": False, "error": "Keine Daten im Zeitraum."} | |
| df = pd.DataFrame(rows).dropna(subset=["sgv"]).sort_values("time") | |
| return {"ok": True, "df": df} | |
| def calc_delta_last_minutes(hist_df: pd.DataFrame, minutes: int = 30) -> Optional[float]: | |
| if hist_df is None or len(hist_df) < 2: | |
| return None | |
| df = hist_df.dropna(subset=["sgv"]).copy() | |
| if len(df) < 2: | |
| return None | |
| cutoff = df["time"].max() - pd.Timedelta(minutes=minutes) | |
| window = df[df["time"] >= cutoff] | |
| if len(window) < 2: | |
| return None | |
| return float(window["sgv"].iloc[-1]) - float(window["sgv"].iloc[0]) | |
| def traffic_light(sgv: Optional[float], trend: str, delta_30m: Optional[float]) -> Tuple[str, str]: | |
| if sgv is None: | |
| return "orange", "🟠" | |
| try: | |
| v = float(sgv) | |
| except Exception: | |
| return "orange", "🟠" | |
| if v < 70: | |
| return "red", "🔴" | |
| if v < 80: | |
| return "orange", "🟠" | |
| if 80 <= v <= 180: | |
| if trend in ("double_falling",) or (delta_30m is not None and delta_30m <= -40): | |
| return "orange", "🟠" | |
| return "green", "🟢" | |
| return "orange", "🟠" | |
| # ============================================================ | |
| # LLM NLU: intent + entities | |
| # ============================================================ | |
| def fallback_grams_and_food_hint(text: str) -> Tuple[Optional[float], Optional[str]]: | |
| t = (text or "").strip() | |
| m = re.search(r"(\d+(?:[.,]\d+)?)\s*(g|gr)\b", t.lower()) | |
| if not m: | |
| return None, None | |
| grams = float(m.group(1).replace(",", ".")) | |
| tail = t[m.end():].strip() | |
| words = re.findall(r"[A-Za-zÄÖÜäöüß\-]+", tail) | |
| food = " ".join(words[:4]).strip() if words else None | |
| return grams, food | |
| def heuristics_what_if_trend(text: str) -> Optional[str]: | |
| low = (text or "").lower() | |
| if any(k in low for k in ["doppelpfeil", "schnell", "rasch", "stuerzt", "stürzt"]): | |
| return "double_falling" | |
| if any(k in low for k in ["faellt", "fällt", "fallend", "unter", "absack", "nach unten"]): | |
| return "falling" | |
| if any(k in low for k in ["steigt", "steigend", "nach oben"]): | |
| return "rising" | |
| if any(k in low for k in ["stabil", "flat"]): | |
| return "stable" | |
| return None | |
| def extract_intent_and_entities(user_text: str) -> dict: | |
| client = _openai_client() | |
| base = { | |
| "intent": "other", | |
| "grams": None, | |
| "food": None, | |
| "candidates": [], | |
| "what_if_glucose_mgdl": None, | |
| "what_if_trend": None, | |
| } | |
| if client is None: | |
| # Minimal heuristics fallback | |
| g2, food2 = fallback_grams_and_food_hint(user_text) | |
| if g2 is not None: | |
| base["intent"] = "carb_calc" | |
| base["grams"] = g2 | |
| base["food"] = food2 | |
| return base | |
| prompt = f""" | |
| Extrahiere Felder aus Nutzertext (Deutsch). Antworte NUR mit JSON. | |
| Text: {user_text} | |
| Schema: | |
| {{ | |
| "intent": "carb_calc" | "situation" | "what_if" | "other", | |
| "grams": number | null, | |
| "food": string | null, | |
| "candidates": [string], | |
| "what_if_glucose_mgdl": number | null, | |
| "what_if_trend": "any" | "stable" | "rising" | "falling" | "double_falling" | null | |
| }} | |
| Regeln: | |
| - Wenn eine Grammangabe vorkommt (g/gr), ist intent IMMER "carb_calc". | |
| - intent="carb_calc" bei Essen/KH/Eingeben (auch ohne Gramm, aber wenn Gramm vorhanden dann sicher). | |
| - intent="what_if" bei Szenarien/Planung ("was waere wenn", "wenn er unter", "falls", "was tun wenn"), | |
| und es ist ein Wert/Schwelle genannt (Zahl oder Zahlwort wie "neunzig"). | |
| - intent="situation" bei Status/Verlauf/Empfehlung JETZT. | |
| - food: nur Speisename (kurz). candidates: 0-5 Alternativen. | |
| """ | |
| intent = "other" | |
| grams: Optional[float] = None | |
| food: Optional[str] = None | |
| candidates: List[str] = [] | |
| what_if_glucose: Optional[float] = None | |
| what_if_trend: Optional[str] = None | |
| try: | |
| res = client.chat.completions.create( | |
| model=OPENAI_MODEL_NLU, | |
| messages=[ | |
| {"role": "system", "content": "Gib ausschliesslich valides JSON gemaess Schema aus."}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| temperature=0.0, | |
| ) | |
| data = json.loads((res.choices[0].message.content or "").strip()) | |
| raw_intent = data.get("intent") | |
| if raw_intent in ("carb_calc", "situation", "what_if", "other"): | |
| intent = raw_intent | |
| g = data.get("grams") | |
| if g is not None: | |
| try: | |
| grams = float(g) | |
| except Exception: | |
| grams = None | |
| f = data.get("food") | |
| if isinstance(f, str): | |
| f = re.split(r"[.?!,\n;:()]+", f.strip(), maxsplit=1)[0].strip() | |
| food = f if f else None | |
| c = data.get("candidates") or [] | |
| if isinstance(c, list): | |
| cleaned: List[str] = [] | |
| for x in c[:5]: | |
| s = re.split(r"[.?!,\n;:()]+", str(x).strip(), maxsplit=1)[0].strip() | |
| if s: | |
| cleaned.append(s) | |
| candidates = cleaned[:5] | |
| w = data.get("what_if_glucose_mgdl") | |
| what_if_glucose = validate_glucose_candidate(w) | |
| wt = data.get("what_if_trend") | |
| if wt is not None: | |
| wt = str(wt).strip().lower() | |
| what_if_trend = wt if wt in ALLOWED_TRENDS else None | |
| except Exception: | |
| pass | |
| # HARD OVERRIDE: Gramm => carb_calc | |
| g2, food2 = fallback_grams_and_food_hint(user_text) | |
| if grams is None and g2 is not None: | |
| grams = g2 | |
| if (not food) and food2: | |
| food2_clean = re.split(r"(?i)\b(kohlenhydrat|kohlenhydrate|kh|wieviel|wie viele|eingeben)\b", food2)[0].strip() | |
| food = food2_clean if food2_clean else food2 | |
| if g2 is not None: | |
| intent = "carb_calc" | |
| # If what-if value missing: use digit or number word | |
| if intent == "what_if" and what_if_glucose is None: | |
| wv = extract_german_number_word(user_text) | |
| what_if_glucose = validate_glucose_candidate(wv) if wv is not None else None | |
| # If trend missing in what-if: heuristics | |
| if intent == "what_if" and what_if_trend is None: | |
| ht = heuristics_what_if_trend(user_text) | |
| if ht in ALLOWED_TRENDS: | |
| what_if_trend = ht | |
| return { | |
| "intent": intent, | |
| "grams": grams, | |
| "food": food, | |
| "candidates": candidates, | |
| "what_if_glucose_mgdl": what_if_glucose, | |
| "what_if_trend": what_if_trend, | |
| } | |
| # ============================================================ | |
| # Replies (Guidance-first, Coaching only in explanation) | |
| # ============================================================ | |
| def build_general_tips_block() -> str: | |
| return ( | |
| "\n\n---\n" | |
| "### Allgemeine Hinweise (außerhalb eurer Leitplanken)\n" | |
| "_Ich finde dazu gerade keine passende Regel in euren Guidances. " | |
| "Die folgenden Punkte sind allgemeine Tipps und **keine** guidance-basierte Empfehlung._\n\n" | |
| "- Wenn Werte sich schnell veraendern: Verlauf engmaschiger beobachten.\n" | |
| "- Bei Symptomen, Unsicherheit oder unklarer Lage: lieber frueh Kontaktperson einbinden.\n" | |
| "- Wenn Essen/Sport ansteht: Kontext hilft (was, wieviel, wann), dann kann ich besser einordnen.\n" | |
| ) | |
| def deterministic_guidance_text(action: str, why_line: str, bullets: List[str], add_general_tips: bool = False) -> str: | |
| parts: List[str] = [] | |
| parts.append(f"**Kurz gesagt:** {action}") | |
| parts.append(f"**Warum:** {why_line}" if why_line else "**Warum:** (keine Zusatzinfo)") | |
| if bullets: | |
| parts.append("**Was jetzt sinnvoll ist:**\n- " + "\n- ".join(bullets[:2])) | |
| if add_general_tips: | |
| parts.append(build_general_tips_block()) | |
| return "\n\n".join(parts) | |
| def llm_coach_only(user_text: str, why_line: str, bullets: List[str], max_bullets: int = 2) -> Tuple[str, List[str]]: | |
| """ | |
| LLM darf nur WHY + Bullets sprachlich verbessern, | |
| KEINE neuen Handlungen, KEINE Dosis, KEINE Prognosezahlen. | |
| """ | |
| client = _openai_client() | |
| if client is None: | |
| return why_line, bullets[:max_bullets] | |
| payload = { | |
| "user_text": user_text, | |
| "why_line": why_line, | |
| "bullets": bullets[:max_bullets], | |
| "constraints": { | |
| "no_new_actions": True, | |
| "no_insulin_dosing": True, | |
| "no_numeric_predictions": True, | |
| "tone": "ruhig, alltagsnah, fuer Sprachausgabe", | |
| }, | |
| } | |
| schema = """ | |
| Gib NUR JSON zurueck: | |
| { | |
| "why": "string", | |
| "bullets": ["string", "string"] | |
| } | |
| Regeln: | |
| - Verbessere nur Formulierung (why + bullets). | |
| - Erfinde keine neuen Aktionen. | |
| - Keine Insulin-Dosis. | |
| - Keine Vorhersagezahlen. | |
| - max 2 bullets. | |
| """ | |
| try: | |
| resp = client.chat.completions.create( | |
| model=OPENAI_MODEL_REPLY, | |
| messages=[ | |
| {"role": "system", "content": "Du formulierst kurz, klar und sicher. Antworte ausschliesslich als JSON."}, | |
| {"role": "system", "content": schema}, | |
| {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}, | |
| ], | |
| temperature=0.3, | |
| ) | |
| data = json.loads((resp.choices[0].message.content or "").strip()) | |
| out_why = str(data.get("why") or "").strip() or why_line | |
| out_bullets = data.get("bullets") or [] | |
| if not isinstance(out_bullets, list): | |
| return why_line, bullets[:max_bullets] | |
| cleaned: List[str] = [] | |
| for b in out_bullets[:max_bullets]: | |
| s = str(b).strip() | |
| if s: | |
| cleaned.append(s) | |
| return out_why, (cleaned if cleaned else bullets[:max_bullets]) | |
| except Exception: | |
| return why_line, bullets[:max_bullets] | |
| def _target_range_status(sgv: Optional[float]) -> Optional[str]: | |
| if sgv is None: | |
| return None | |
| try: | |
| v = float(sgv) | |
| except Exception: | |
| return None | |
| if 80 <= v <= 180: | |
| return "Ja – aktuell im Zielbereich." | |
| if v < 80: | |
| return "Aktuell unter dem Zielbereich." | |
| return "Aktuell ueber dem Zielbereich." | |
| def llm_smart_reply_situation(user_text: str, latest: dict, delta_30m: Optional[float], matched_rule: dict) -> str: | |
| sgv = latest.get("sgv") if latest.get("ok") else None | |
| direction = latest.get("direction", "") if latest.get("ok") else "" | |
| age = latest.get("age_min") if latest.get("ok") else None | |
| trend_simple = direction_to_trend(direction) | |
| trend_word = { | |
| "falling": "fallend", | |
| "double_falling": "schnell fallend", | |
| "rising": "steigend", | |
| "stable": "stabil" | |
| }.get(trend_simple, "unklar") | |
| if is_target_range_question(user_text): | |
| status = _target_range_status(sgv) | |
| if status is None: | |
| return "**Kurz gesagt:** Ich kann den aktuellen Wert gerade nicht sicher lesen.\n\n**Was jetzt sinnvoll ist:**\n- Nightscout-Verbindung/Sensor kurz pruefen." | |
| return f"**Kurz gesagt:** {status}\n\n**Aktuell:** {sgv} mg/dl, Trend wirkt {trend_word}." | |
| guidance_id = (matched_rule.get("guidance_id") or "").upper() | |
| no_rule = guidance_id in ("NO_MATCH", "NO_GUIDANCES", "NO_GLUCOSE") | |
| action = (_norm_str(matched_rule.get("action"), "")).strip() or "Bitte beobachten." | |
| if sgv is not None and age is not None: | |
| why_line = f"Aktuell {sgv} mg/dl, Trend wirkt {trend_word}." | |
| if delta_30m is not None: | |
| # keep it qualitative | |
| if delta_30m <= -30: | |
| why_line += " In den letzten ~30 min ging es deutlich nach unten." | |
| elif delta_30m >= 30: | |
| why_line += " In den letzten ~30 min ging es deutlich nach oben." | |
| else: | |
| why_line = "Nightscout-Daten sind gerade nicht sicher verfuegbar." | |
| bullets: List[str] = [] | |
| fu = (_norm_str(matched_rule.get("follow_up"), "")).strip() | |
| cg = (_norm_str(matched_rule.get("carbs_g"), "")).strip() | |
| if fu: | |
| bullets.append(fu) | |
| if cg and cg != "0": | |
| bullets.append(f"Richtwert: {cg} g KH (laut eurer Regel)") | |
| if no_rule: | |
| return deterministic_guidance_text(action=action, why_line=why_line, bullets=bullets, add_general_tips=True) | |
| why2, bullets2 = llm_coach_only(user_text=user_text, why_line=why_line, bullets=bullets, max_bullets=2) | |
| return deterministic_guidance_text(action=action, why_line=why2, bullets=bullets2, add_general_tips=False) | |
| def llm_smart_reply_what_if(user_text: str, what_if_glucose: float, what_if_trend: str, matched_rule: dict) -> str: | |
| guidance_id = (matched_rule.get("guidance_id") or "").upper() | |
| no_rule = guidance_id in ("NO_MATCH", "NO_GUIDANCES", "NO_GLUCOSE") | |
| action = (_norm_str(matched_rule.get("action"), "")).strip() or "Bitte beobachten." | |
| cn = (_norm_str(matched_rule.get("condition_note"), "")).strip() | |
| why_line = cn if cn else f"Szenario: {int(what_if_glucose)} mg/dl ({what_if_trend})." | |
| bullets: List[str] = [] | |
| cg = (_norm_str(matched_rule.get("carbs_g"), "")).strip() | |
| fe = (_norm_str(matched_rule.get("food_examples"), "")).strip() | |
| fu = (_norm_str(matched_rule.get("follow_up"), "")).strip() | |
| if cg and cg != "0": | |
| bullets.append(f"Richtwert: {cg} g KH") | |
| if fe: | |
| bullets.append(f"Beispiele: {fe}") | |
| if fu: | |
| bullets.append(f"Dann: {fu}") | |
| if no_rule: | |
| header = f"**Kurz gesagt:** Fuer das Szenario ({int(what_if_glucose)} mg/dl, {what_if_trend}) finde ich keine passende Regel." | |
| return header + build_general_tips_block() | |
| why2, bullets2 = llm_coach_only(user_text=user_text, why_line=why_line, bullets=bullets, max_bullets=2) | |
| parts: List[str] = [] | |
| parts.append(f"**Kurz gesagt:** Wenn er bei **{int(what_if_glucose)} mg/dl** liegt ({what_if_trend}), wuerdet ihr: **{action}**.") | |
| parts.append(f"**Warum:** {why2}") | |
| if bullets2: | |
| parts.append("**Was jetzt sinnvoll ist:**\n- " + "\n- ".join(bullets2[:2])) | |
| return "\n\n".join(parts) | |
| # ============================================================ | |
| # Agent functions (situation / what-if / carbs) | |
| # ============================================================ | |
| def compute_kh_answer(grams: float, canonical_name: str, kh_per_100g: float) -> str: | |
| kh = float(grams) * float(kh_per_100g) / 100.0 | |
| kh_round = round(kh, 1) | |
| return ( | |
| f"Bei **{float(grams):.0f} g {canonical_name}** sind das etwa **{kh_round:.1f} g Kohlenhydrate**.\n\n" | |
| f"Wenn ihr das gerade esst: diesen KH-Wert wuerde ich so eingeben." | |
| ) | |
| def try_match_food_any( | |
| known_df: pd.DataFrame, | |
| aliases_df: pd.DataFrame, | |
| food: str, | |
| candidates: List[str] | |
| ) -> Tuple[Optional[dict], List[str], Optional[str]]: | |
| if food: | |
| hit, suggestions = find_food_kh(known_df, aliases_df, food) | |
| if hit or suggestions: | |
| return hit, suggestions, food | |
| for c in candidates or []: | |
| hit, suggestions = find_food_kh(known_df, aliases_df, c) | |
| if hit or suggestions: | |
| return hit, suggestions, c | |
| return None, [], food or (candidates[0] if candidates else None) | |
| def run_agent_situation(user_text: str, latest: dict, hist: dict, guidances_df: pd.DataFrame) -> str: | |
| glucose = latest.get("sgv") if latest.get("ok") else None | |
| trend = direction_to_trend(latest.get("direction", "")) if latest.get("ok") else "any" | |
| age_min = latest.get("age_min") if latest.get("ok") else None | |
| match = resolve_guidance(guidances_df, glucose, trend, user_text, data_age_min=age_min) | |
| rule = match.rule | |
| delta_30m = None | |
| if hist.get("ok") and hist.get("df") is not None: | |
| delta_30m = calc_delta_last_minutes(hist["df"], minutes=30) | |
| return llm_smart_reply_situation(user_text, latest, delta_30m, rule) | |
| def run_agent_what_if(user_text: str, guidances_df: pd.DataFrame, what_if_glucose: Optional[float], what_if_trend: Optional[str]) -> str: | |
| if what_if_glucose is None: | |
| return ( | |
| "**Kurz gesagt:** Klar – ich kann das als Szenario durchspielen.\n\n" | |
| "**Sag mir bitte:** einen Beispielwert (z.B. 70 oder 'siebzig') und optional ob er stabil/fallend/steigend ist." | |
| ) | |
| tr = what_if_trend if (what_if_trend in ALLOWED_TRENDS) else "any" | |
| match = resolve_guidance(guidances_df, float(what_if_glucose), tr, user_text, data_age_min=None) | |
| return llm_smart_reply_what_if(user_text, float(what_if_glucose), tr, match.rule) | |
| def make_start_message(latest: dict, hist: dict, guidances_df: pd.DataFrame) -> str: | |
| sgv = latest.get("sgv") if latest.get("ok") else None | |
| direction = latest.get("direction", "") if latest.get("ok") else "" | |
| trend = direction_to_trend(direction) if latest.get("ok") else "any" | |
| age = latest.get("age_min") if latest.get("ok") else None | |
| delta_30m = None | |
| if hist.get("ok") and hist.get("df") is not None: | |
| delta_30m = calc_delta_last_minutes(hist["df"], minutes=30) | |
| _, emoji = traffic_light(sgv, trend, delta_30m) | |
| match = resolve_guidance(guidances_df, sgv, trend, user_text="start", data_age_min=age) | |
| action = (_norm_str(match.rule.get("action"), "")).strip() or "Beobachten." | |
| range_label = "im Zielbereich" if (isinstance(sgv, (int, float)) and 80 <= float(sgv) <= 180) else "außerhalb des Zielbereichs" | |
| return ( | |
| f"Hey, ich bin **Gluco** 👋\n\n" | |
| f"**Aktueller Wert:** {sgv if sgv is not None else '-'} mg/dl, Trend: {direction or '-'}.\n\n" | |
| f"**Status:** {emoji} {range_label}.\n\n" | |
| f"**Leitplanken-Empfehlung:** {action}\n\n" | |
| f"Womit kann ich dir helfen? (z.B. *„Sind wir im Zielbereich?“*, *„Was waere wenn er auf neunzig faellt?“*, *„Wieviele KH sind in 30g Apfel?“*)" | |
| ) | |
| # ============================================================ | |
| # Streamlit UI | |
| # ============================================================ | |
| st.set_page_config(page_title="Gluco – Diabetes Begleiter", page_icon="🟢", layout="wide") | |
| # Hard requirements | |
| if not AGENT_SHEET_ID: | |
| st.error("Agent Sheet-ID fehlt (AGENT_SHEET_ID / GSHEETS_AGENT_SHEET_ID).") | |
| st.stop() | |
| if not DISHES_SHEET_ID: | |
| st.error("Gerichte Sheet-ID fehlt (GSHEETS_EXISTING_SHEET_ID).") | |
| st.stop() | |
| if not SA_JSON_TEXT: | |
| st.error("Service Account JSON fehlt (gcp_service_account / GSHEETS_SA_JSON).") | |
| st.stop() | |
| # Session state | |
| if "chat" not in st.session_state: | |
| st.session_state.chat = [] | |
| if "pending_food_choice" not in st.session_state: | |
| st.session_state.pending_food_choice = None | |
| if "pending_alias" not in st.session_state: | |
| st.session_state.pending_alias = None | |
| if "last_tts_audio" not in st.session_state: | |
| st.session_state.last_tts_audio = None | |
| if "aliases_df" not in st.session_state: | |
| st.session_state.aliases_df = pd.DataFrame(columns=["alias_norm", "name"]) | |
| if "last_input_was_voice" not in st.session_state: | |
| st.session_state.last_input_was_voice = False | |
| if "greeted" not in st.session_state: | |
| st.session_state.greeted = False | |
| if "audio_enabled" not in st.session_state: | |
| st.session_state.audio_enabled = True | |
| if "active_speiseplan_trigger" not in st.session_state: | |
| st.session_state.active_speiseplan_trigger = None | |
| # Sidebar | |
| with st.sidebar: | |
| st.subheader("Gluco Einstellungen") | |
| st.session_state.audio_enabled = st.toggle( | |
| "Audioausgabe (Gluco spricht)", | |
| value=st.session_state.audio_enabled | |
| ) | |
| speak_always = st.checkbox("Antworten immer vorlesen", value=False) | |
| st.caption("Hinweis: Autoplay ist im Browser meist blockiert – bitte ▶ Play drücken.") | |
| st.divider() | |
| st.subheader("Live Monitoring") | |
| live_refresh = st.checkbox("Live-Refresh aktiv", value=True) | |
| refresh_interval = st.selectbox("Refresh Intervall (Sek.)", [15, 30, 60], index=1) | |
| st.divider() | |
| st.subheader("Debug") | |
| nlu_debug = st.checkbox("NLU Debug anzeigen", value=False) | |
| st.divider() | |
| if st.button("Chat zurücksetzen"): | |
| st.session_state.chat = [] | |
| st.session_state.pending_food_choice = None | |
| st.session_state.pending_alias = None | |
| st.session_state.last_tts_audio = None | |
| st.session_state.greeted = False | |
| st.rerun() | |
| # Auto refresh (optional) | |
| try: | |
| from streamlit_autorefresh import st_autorefresh # type: ignore | |
| except Exception: | |
| st_autorefresh = None # type: ignore | |
| if live_refresh and st_autorefresh: | |
| st_autorefresh(interval=int(refresh_interval * 1000), key="ns_autorefresh") | |
| # Load sheets | |
| raw_guidances = load_tab_from_sheet(AGENT_SHEET_ID, SA_JSON_TEXT, "guidances") | |
| guidances = prepare_guidances_df(raw_guidances) | |
| raw_known = load_tab_from_sheet(DISHES_SHEET_ID, SA_JSON_TEXT, "known_dishes") | |
| known_df = prepare_known_dishes(raw_known) | |
| raw_aliases = load_tab_optional(DISHES_SHEET_ID, SA_JSON_TEXT, "aliases") | |
| st.session_state.aliases_df = prepare_aliases(raw_aliases) | |
| # Header | |
| st.title("🟢 Gluco – Live Diabetes Begleiter") | |
| # Nightscout Monitor | |
| with st.container(border=True): | |
| st.subheader("Live Monitor (Nightscout)") | |
| if not NIGHTSCOUT_BASE_URL: | |
| st.warning("NIGHTSCOUT_BASE_URL fehlt.") | |
| latest = {"ok": False, "error": "NIGHTSCOUT_BASE_URL fehlt"} | |
| hist = {"ok": False} | |
| else: | |
| latest = fetch_nightscout_latest(NIGHTSCOUT_BASE_URL, NIGHTSCOUT_TOKEN) | |
| hist = fetch_nightscout_history(NIGHTSCOUT_BASE_URL, NIGHTSCOUT_TOKEN, minutes=180) | |
| delta_30m = None | |
| if hist.get("ok") and hist.get("df") is not None and len(hist["df"]) > 2: | |
| delta_30m = calc_delta_last_minutes(hist["df"], minutes=30) | |
| c1, c2, c3, c4 = st.columns([1.2, 1, 1, 1.6]) | |
| if not latest.get("ok"): | |
| c1.metric("Glukose (mg/dl)", "-") | |
| c2.metric("Trend", "-") | |
| c3.metric("Update", "-") | |
| c4.error(f"Nightscout: {latest.get('error', 'nicht verfuegbar')}") | |
| else: | |
| sgv = latest.get("sgv") | |
| direction = latest.get("direction", "") | |
| age = latest.get("age_min") | |
| c1.metric("Glukose (mg/dl)", sgv if sgv is not None else "-") | |
| c2.metric("Trend", direction or "-") | |
| c3.metric("Update", f"vor {age} min" if age is not None else "-") | |
| trend_norm = direction_to_trend(direction) | |
| amp_level, amp_emoji = traffic_light(sgv, trend_norm, delta_30m) | |
| if amp_level == "green": | |
| c4.success(f"Ampel: {amp_emoji} (OK)") | |
| elif amp_level == "red": | |
| c4.error(f"Ampel: {amp_emoji} (kritisch)") | |
| else: | |
| c4.warning(f"Ampel: {amp_emoji} (aufmerksam)") | |
| if hist.get("ok") and hist.get("df") is not None and len(hist["df"]) > 2: | |
| st.line_chart(hist["df"].set_index("time")["sgv"]) | |
| if delta_30m is not None: | |
| st.caption(f"Verlauf ~30 min: {delta_30m:+.0f} mg/dl") | |
| col_a, col_b = st.columns([1, 3]) | |
| with col_a: | |
| if st.button("Status aktualisieren"): | |
| msg = make_start_message(latest, hist, guidances) | |
| st.session_state.chat.append({"role": "assistant", "content": msg}) | |
| if st.session_state.get("audio_enabled", True): | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(msg), voice=OPENAI_TTS_VOICE) | |
| else: | |
| st.session_state.last_tts_audio = None | |
| st.rerun() | |
| with col_b: | |
| st.caption("Tipp: Du kannst direkt sprechen oder schreiben – Gluco versteht beides.") | |
| st.divider() | |
| # Start greeting (1x) | |
| if not st.session_state.greeted: | |
| start_msg = make_start_message(latest, hist, guidances) | |
| st.session_state.chat.append({"role": "assistant", "content": start_msg}) | |
| if st.session_state.get("audio_enabled", True): | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(start_msg), voice=OPENAI_TTS_VOICE) | |
| else: | |
| st.session_state.last_tts_audio = None | |
| st.session_state.greeted = True | |
| st.rerun() | |
| # Chat render | |
| st.subheader("Chat mit Gluco (Text oder Sprache)") | |
| for i, m in enumerate(st.session_state.chat): | |
| with st.chat_message(m["role"]): | |
| st.markdown(m["content"]) | |
| # Audio player under last assistant message | |
| if ( | |
| m["role"] == "assistant" | |
| and i == len(st.session_state.chat) - 1 | |
| and st.session_state.get("last_tts_audio") | |
| ): | |
| st.audio(st.session_state["last_tts_audio"], format="audio/mp3") | |
| # Pending food disambiguation | |
| pending = st.session_state.get("pending_food_choice") | |
| if pending: | |
| st.info(f"Meintest du bei **{pending['food_raw']}** eines davon?") | |
| cols = st.columns(min(5, len(pending["suggestions"]))) | |
| for idx, name in enumerate(pending["suggestions"][:5]): | |
| with cols[idx]: | |
| if st.button(name, key=f"pick_food_{idx}"): | |
| st.session_state.pending_alias = { | |
| "alias": pending["food_raw"], | |
| "chosen_name": name, | |
| "grams": pending["grams"], | |
| } | |
| st.session_state.pending_food_choice = None | |
| st.rerun() | |
| # If choice made -> compute + learn alias | |
| pa = st.session_state.get("pending_alias") | |
| if pa: | |
| chosen = pa["chosen_name"] | |
| grams = float(pa["grams"]) | |
| row = known_df[known_df["name"] == chosen] | |
| if len(row): | |
| kh100 = float(row.iloc[0]["kh_per_100g"]) | |
| answer = compute_kh_answer(grams, chosen, kh100) | |
| ok = save_alias(DISHES_SHEET_ID, SA_JSON_TEXT, pa["alias"], chosen) | |
| if ok: | |
| answer += f"\n\nAlias gemerkt: '{pa['alias']}' → {chosen}" | |
| load_tab_optional.clear() | |
| st.session_state.aliases_df = prepare_aliases(load_tab_optional(DISHES_SHEET_ID, SA_JSON_TEXT, "aliases")) | |
| else: | |
| answer += "\n\nHinweis: Alias konnte nicht gespeichert werden (Schreibrechte?)." | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| if st.session_state.get("audio_enabled", True): | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(answer), voice=OPENAI_TTS_VOICE) | |
| else: | |
| st.session_state.last_tts_audio = None | |
| st.session_state.pending_alias = None | |
| st.rerun() | |
| # Inputs | |
| left, right = st.columns([3, 2]) | |
| with left: | |
| text_in = st.chat_input( | |
| "Frag z.B. 'Sind wir im Zielbereich?' | 'Was waere wenn er auf neunzig faellt?' | 'Wieviele KH sind in 30gr Apfel?'" | |
| ) | |
| with right: | |
| st.caption("Spracheingabe") | |
| audio = st.audio_input("Sprechen") | |
| voice_in = "" | |
| if audio is not None: | |
| if not OPENAI_API_KEY: | |
| st.warning("OPENAI_API_KEY fehlt (keine Transkription).") | |
| else: | |
| with st.spinner("Transkribiere Audio ..."): | |
| voice_in = transcribe_audio_openai(audio) | |
| if voice_in: | |
| st.caption("Transkription: " + voice_in) | |
| else: | |
| st.warning("Transkription fehlgeschlagen.") | |
| input_was_voice = bool(voice_in) and not bool(text_in) | |
| st.session_state.last_input_was_voice = input_was_voice | |
| incoming = text_in or voice_in | |
| if incoming: | |
| st.session_state.chat.append({"role": "user", "content": incoming}) | |
| # SPEISEPLAN AUTO-TRIGGER | |
| speiseplan_trigger = parse_speiseplan_trigger(incoming) | |
| if speiseplan_trigger: | |
| review_start = start_auto_review_from_trigger(speiseplan_trigger) | |
| st.session_state.active_speiseplan_trigger = speiseplan_trigger.to_dict() | |
| st.session_state.active_speiseplan_review = review_start.review_id | |
| answer = review_start.message | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| st.session_state.last_tts_audio = synthesize_speech_openai( | |
| make_tts_text(answer), | |
| voice=OPENAI_TTS_VOICE | |
| ) if should_speak(input_was_voice, speak_always) else None | |
| st.rerun() | |
| # fresh snapshot | |
| latest_now = fetch_nightscout_latest(NIGHTSCOUT_BASE_URL, NIGHTSCOUT_TOKEN) if NIGHTSCOUT_BASE_URL else {"ok": False, "error": "NIGHTSCOUT_BASE_URL fehlt."} | |
| hist_now = fetch_nightscout_history(NIGHTSCOUT_BASE_URL, NIGHTSCOUT_TOKEN, minutes=180) if NIGHTSCOUT_BASE_URL else {"ok": False} | |
| ext = extract_intent_and_entities(incoming) | |
| if nlu_debug: | |
| st.sidebar.json(ext) | |
| # CARB FLOW | |
| if ext.get("intent") == "carb_calc": | |
| grams = ext.get("grams") | |
| food = ext.get("food") | |
| candidates = ext.get("candidates") or [] | |
| if grams is None: | |
| answer = "Nenne bitte die Menge in Gramm, z.B. '50 g'." | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(answer), voice=OPENAI_TTS_VOICE) if should_speak(input_was_voice, speak_always) else None | |
| st.rerun() | |
| if not food and not candidates: | |
| answer = "Welches Lebensmittel ist es? (z.B. 'Apfel' oder 'Reis')" | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(answer), voice=OPENAI_TTS_VOICE) if should_speak(input_was_voice, speak_always) else None | |
| st.rerun() | |
| hit, suggestions, used_query = try_match_food_any(known_df, st.session_state.aliases_df, food or "", candidates) | |
| if hit: | |
| answer = compute_kh_answer(float(grams), hit["name"], float(hit["kh_per_100g"])) | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(answer), voice=OPENAI_TTS_VOICE) if should_speak(input_was_voice, speak_always) else None | |
| elif suggestions: | |
| st.session_state.pending_food_choice = { | |
| "grams": float(grams), | |
| "food_raw": used_query or (food or "Speise"), | |
| "suggestions": suggestions, | |
| } | |
| answer = "Ich bin mir nicht sicher, welche Speise du meinst. Bitte waehle eine Option:" | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(answer), voice=OPENAI_TTS_VOICE) if should_speak(input_was_voice, speak_always) else None | |
| else: | |
| answer = f"Ich finde '{food or (candidates[0] if candidates else 'das Lebensmittel')}' nicht in eurer Tabelle." | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(answer), voice=OPENAI_TTS_VOICE) if should_speak(input_was_voice, speak_always) else None | |
| st.rerun() | |
| # WHAT-IF FLOW | |
| if ext.get("intent") == "what_if": | |
| answer = run_agent_what_if( | |
| incoming, | |
| guidances, | |
| ext.get("what_if_glucose_mgdl"), | |
| ext.get("what_if_trend"), | |
| ) | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(answer), voice=OPENAI_TTS_VOICE) if should_speak(input_was_voice, speak_always) else None | |
| st.rerun() | |
| # Default: situation | |
| answer = run_agent_situation(incoming, latest_now, hist_now, guidances) | |
| st.session_state.chat.append({"role": "assistant", "content": answer}) | |
| st.session_state.last_tts_audio = synthesize_speech_openai(make_tts_text(answer), voice=OPENAI_TTS_VOICE) if should_speak(input_was_voice, speak_always) else None | |
| st.rerun() | |