# -*- 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 "" @st.cache_data(ttl=3600, show_spinner=False) 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) @st.cache_data(show_spinner=False) 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) @st.cache_data(show_spinner=False) 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 @dataclass 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" @st.cache_data(ttl=30, show_spinner=False) 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} @st.cache_data(ttl=60, show_spinner=False) 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()