GlycoAgent / app_step2.py
JulianTekles's picture
Upload app_step2.py
8a7a828 verified
Raw
History Blame Contribute Delete
57 kB
# -*- 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()