Spaces:
Running
Running
Upload 2 files
Browse files- app.py +457 -0
- guidance_resolver.py +249 -0
app.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
# Streamlit App – Diabetes Guidance Chat + Regelauflösung aus Google-Sheet Tab "guidances"
|
| 3 |
+
#
|
| 4 |
+
# Voraussetzungen:
|
| 5 |
+
# - Google Sheet mit Tab "guidances" (Header wie besprochen)
|
| 6 |
+
# - Service Account Credentials entweder über:
|
| 7 |
+
# A) Streamlit Secrets: st.secrets["gcp_service_account"] (dict)
|
| 8 |
+
# B) JSON-Datei-Pfad in ENV: GOOGLE_APPLICATION_CREDENTIALS=/path/sa.json
|
| 9 |
+
# - Sheet-ID als ENV oder Secrets:
|
| 10 |
+
# st.secrets["AGENT_SHEET_ID"] oder ENV: AGENT_SHEET_ID
|
| 11 |
+
#
|
| 12 |
+
# Start:
|
| 13 |
+
# streamlit run app.py
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from typing import Any, Dict, Optional
|
| 21 |
+
|
| 22 |
+
import pandas as pd
|
| 23 |
+
import streamlit as st
|
| 24 |
+
|
| 25 |
+
# ----------------------------
|
| 26 |
+
# Optional: guidance_resolver import (falls Datei vorhanden)
|
| 27 |
+
# ----------------------------
|
| 28 |
+
try:
|
| 29 |
+
from guidance_resolver import prepare_guidances_df, resolve_guidance # type: ignore
|
| 30 |
+
except Exception:
|
| 31 |
+
prepare_guidances_df = None
|
| 32 |
+
resolve_guidance = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ----------------------------
|
| 36 |
+
# Fallback: Resolver direkt in app.py (robust gegen leere Trends/Typen)
|
| 37 |
+
# ----------------------------
|
| 38 |
+
ALLOWED_TRENDS = {"any", "stable", "rising", "falling", "double_falling"}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _to_float(x: Any, default: float) -> float:
|
| 42 |
+
if x is None or (isinstance(x, float) and pd.isna(x)) or (isinstance(x, str) and x.strip() == ""):
|
| 43 |
+
return float(default)
|
| 44 |
+
if isinstance(x, (int, float)):
|
| 45 |
+
return float(x)
|
| 46 |
+
if isinstance(x, str):
|
| 47 |
+
s = x.strip().replace(",", ".")
|
| 48 |
+
for token in ["mg/dl", "mgdl", " ", "\u00A0"]:
|
| 49 |
+
s = s.replace(token, "")
|
| 50 |
+
try:
|
| 51 |
+
return float(s)
|
| 52 |
+
except ValueError:
|
| 53 |
+
return float(default)
|
| 54 |
+
return float(default)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _to_int(x: Any, default: int) -> int:
|
| 58 |
+
try:
|
| 59 |
+
return int(round(_to_float(x, default=float(default))))
|
| 60 |
+
except Exception:
|
| 61 |
+
return int(default)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _norm_str(x: Any, default: str = "") -> str:
|
| 65 |
+
if x is None or (isinstance(x, float) and pd.isna(x)):
|
| 66 |
+
return default
|
| 67 |
+
s = str(x).strip()
|
| 68 |
+
return s if s else default
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _norm_trend(x: Any) -> str:
|
| 72 |
+
s = _norm_str(x, default="any").lower()
|
| 73 |
+
mapping = {
|
| 74 |
+
"": "any",
|
| 75 |
+
"any": "any",
|
| 76 |
+
"egal": "any",
|
| 77 |
+
"stable": "stable",
|
| 78 |
+
"stabil": "stable",
|
| 79 |
+
"rising": "rising",
|
| 80 |
+
"steigend": "rising",
|
| 81 |
+
"up": "rising",
|
| 82 |
+
"falling": "falling",
|
| 83 |
+
"fallend": "falling",
|
| 84 |
+
"down": "falling",
|
| 85 |
+
"double_falling": "double_falling",
|
| 86 |
+
"doppelpfeil": "double_falling",
|
| 87 |
+
"↓↓": "double_falling",
|
| 88 |
+
"stark fallend": "double_falling",
|
| 89 |
+
}
|
| 90 |
+
s = mapping.get(s, s)
|
| 91 |
+
return s if s in ALLOWED_TRENDS else "any"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
| 95 |
+
col_map = {
|
| 96 |
+
"guidance_id": ["guidance_id", "id", "rule_id", "regel_id"],
|
| 97 |
+
"category": ["category", "kategorie"],
|
| 98 |
+
"priority": ["priority", "prio", "priorität", "prioritaet"],
|
| 99 |
+
"glucose_min_mgdl": ["glucose_min_mgdl", "min", "min_mgdl", "bg_min", "untergrenze", "low"],
|
| 100 |
+
"glucose_max_mgdl": ["glucose_max_mgdl", "max", "max_mgdl", "bg_max", "obergrenze", "high"],
|
| 101 |
+
"trend": ["trend", "pfeil", "arrow", "tendenz"],
|
| 102 |
+
"condition_note": ["condition_note", "condition", "bedingung", "note", "beschreibung"],
|
| 103 |
+
"action": ["action", "empfehlung", "handlung", "aktion"],
|
| 104 |
+
"carbs_g": ["carbs_g", "kh_g", "carbs", "kohlenhydrate_g", "kh"],
|
| 105 |
+
"food_examples": ["food_examples", "beispiele", "foods"],
|
| 106 |
+
"follow_up": ["follow_up", "next", "nachfolge", "kontrolle"],
|
| 107 |
+
"source": ["source", "quelle"],
|
| 108 |
+
}
|
| 109 |
+
reverse: Dict[str, str] = {}
|
| 110 |
+
lower_cols = {c.lower(): c for c in df.columns}
|
| 111 |
+
for canonical, variants in col_map.items():
|
| 112 |
+
for v in variants:
|
| 113 |
+
if v.lower() in lower_cols:
|
| 114 |
+
reverse[lower_cols[v.lower()]] = canonical
|
| 115 |
+
break
|
| 116 |
+
df = df.rename(columns=reverse)
|
| 117 |
+
for c in col_map.keys():
|
| 118 |
+
if c not in df.columns:
|
| 119 |
+
df[c] = None
|
| 120 |
+
return df
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@dataclass
|
| 124 |
+
class GuidanceMatch:
|
| 125 |
+
rule: Optional[Dict[str, Any]]
|
| 126 |
+
matched: bool
|
| 127 |
+
reason: str
|
| 128 |
+
considered: int
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _prepare_guidances_df_fallback(df: pd.DataFrame) -> pd.DataFrame:
|
| 132 |
+
df = _normalize_columns(df).copy()
|
| 133 |
+
|
| 134 |
+
df["guidance_id"] = df["guidance_id"].apply(lambda x: _norm_str(x, default=""))
|
| 135 |
+
df["category"] = df["category"].apply(lambda x: _norm_str(x, default=""))
|
| 136 |
+
df["priority"] = df["priority"].apply(lambda x: _to_int(x, default=9999))
|
| 137 |
+
|
| 138 |
+
df["glucose_min_mgdl"] = df["glucose_min_mgdl"].apply(lambda x: _to_float(x, default=0))
|
| 139 |
+
df["glucose_max_mgdl"] = df["glucose_max_mgdl"].apply(lambda x: _to_float(x, default=999))
|
| 140 |
+
|
| 141 |
+
df["trend"] = df["trend"].apply(_norm_trend)
|
| 142 |
+
|
| 143 |
+
for c in ["condition_note", "action", "carbs_g", "food_examples", "follow_up", "source"]:
|
| 144 |
+
df[c] = df[c].apply(lambda x: _norm_str(x, default=""))
|
| 145 |
+
|
| 146 |
+
df = df[~((df["action"] == "") & (df["guidance_id"] == ""))].copy()
|
| 147 |
+
df["__range_width"] = (df["glucose_max_mgdl"] - df["glucose_min_mgdl"]).abs()
|
| 148 |
+
df = df.sort_values(["priority", "__range_width"], ascending=[True, True]).reset_index(drop=True)
|
| 149 |
+
df = df.drop(columns=["__range_width"])
|
| 150 |
+
return df
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _resolve_guidance_fallback(
|
| 154 |
+
guidances_df: pd.DataFrame,
|
| 155 |
+
glucose_mgdl: Optional[float],
|
| 156 |
+
trend: Optional[str] = None,
|
| 157 |
+
*,
|
| 158 |
+
require_action: bool = True,
|
| 159 |
+
) -> GuidanceMatch:
|
| 160 |
+
if guidances_df is None or len(guidances_df) == 0:
|
| 161 |
+
return GuidanceMatch(
|
| 162 |
+
rule=None,
|
| 163 |
+
matched=False,
|
| 164 |
+
reason="Guidances sind leer (Tab 'guidances' nicht geladen oder keine Zeilen).",
|
| 165 |
+
considered=0,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
t = _norm_trend(trend)
|
| 169 |
+
|
| 170 |
+
if glucose_mgdl is None:
|
| 171 |
+
for _, r in guidances_df.iterrows():
|
| 172 |
+
r_trend = _norm_trend(r.get("trend"))
|
| 173 |
+
if r_trend in ("any", t):
|
| 174 |
+
return GuidanceMatch(rule=r.to_dict(), matched=True, reason="Kein Wert → generische Regel gewählt.", considered=1)
|
| 175 |
+
return GuidanceMatch(rule=None, matched=False, reason="Kein Wert und keine generische Regel gefunden.", considered=len(guidances_df))
|
| 176 |
+
|
| 177 |
+
g = float(glucose_mgdl)
|
| 178 |
+
considered = 0
|
| 179 |
+
|
| 180 |
+
for _, r in guidances_df.iterrows():
|
| 181 |
+
considered += 1
|
| 182 |
+
r_min = float(r.get("glucose_min_mgdl", 0))
|
| 183 |
+
r_max = float(r.get("glucose_max_mgdl", 999))
|
| 184 |
+
r_trend = _norm_trend(r.get("trend"))
|
| 185 |
+
|
| 186 |
+
if not (r_min <= g <= r_max):
|
| 187 |
+
continue
|
| 188 |
+
if r_trend != "any" and r_trend != t:
|
| 189 |
+
continue
|
| 190 |
+
if require_action and _norm_str(r.get("action")) == "":
|
| 191 |
+
continue
|
| 192 |
+
|
| 193 |
+
return GuidanceMatch(
|
| 194 |
+
rule=r.to_dict(),
|
| 195 |
+
matched=True,
|
| 196 |
+
reason=f"Match: {r.get('guidance_id','(ohne id)')} (glucose={g}, trend={t})",
|
| 197 |
+
considered=considered,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
fallback = {
|
| 201 |
+
"guidance_id": "DEFAULT_NO_MATCH",
|
| 202 |
+
"category": "fallback",
|
| 203 |
+
"priority": 9999,
|
| 204 |
+
"glucose_min_mgdl": 0,
|
| 205 |
+
"glucose_max_mgdl": 999,
|
| 206 |
+
"trend": "any",
|
| 207 |
+
"condition_note": "Keine passende Regel gefunden.",
|
| 208 |
+
"action": "⚠️ Keine passende Handlungsempfehlung gefunden. Bitte Trend/Wert prüfen oder Kontaktperson anrufen.",
|
| 209 |
+
"carbs_g": "",
|
| 210 |
+
"food_examples": "",
|
| 211 |
+
"follow_up": "",
|
| 212 |
+
"source": "system",
|
| 213 |
+
}
|
| 214 |
+
return GuidanceMatch(rule=fallback, matched=False, reason=f"Kein Match (glucose={g}, trend={t}). Fallback.", considered=considered)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# Choose resolver implementation
|
| 218 |
+
def _prepare_guidances(df: pd.DataFrame) -> pd.DataFrame:
|
| 219 |
+
if prepare_guidances_df is not None:
|
| 220 |
+
return prepare_guidances_df(df) # type: ignore
|
| 221 |
+
return _prepare_guidances_df_fallback(df)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _resolve_guidance(df: pd.DataFrame, glucose: Optional[float], trend: Optional[str]) -> GuidanceMatch:
|
| 225 |
+
if resolve_guidance is not None:
|
| 226 |
+
# returns GuidanceMatch-like? (in our earlier file it returns GuidanceMatch)
|
| 227 |
+
return resolve_guidance(df, glucose_mgdl=glucose, trend=trend) # type: ignore
|
| 228 |
+
return _resolve_guidance_fallback(df, glucose_mgdl=glucose, trend=trend)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ----------------------------
|
| 232 |
+
# Google Sheets loader (gspread)
|
| 233 |
+
# ----------------------------
|
| 234 |
+
def _get_sheet_id() -> str:
|
| 235 |
+
# Priority: secrets > env
|
| 236 |
+
if "AGENT_SHEET_ID" in st.secrets:
|
| 237 |
+
return st.secrets["AGENT_SHEET_ID"]
|
| 238 |
+
sid = os.getenv("AGENT_SHEET_ID", "").strip()
|
| 239 |
+
if not sid:
|
| 240 |
+
st.error("AGENT_SHEET_ID fehlt. Bitte in st.secrets oder ENV setzen.")
|
| 241 |
+
st.stop()
|
| 242 |
+
return sid
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@st.cache_data(show_spinner=False)
|
| 246 |
+
def load_guidances_from_sheet(sheet_id: str, tab: str = "guidances") -> pd.DataFrame:
|
| 247 |
+
try:
|
| 248 |
+
import gspread
|
| 249 |
+
from google.oauth2.service_account import Credentials
|
| 250 |
+
except Exception as e:
|
| 251 |
+
st.error("Fehlende Abhängigkeiten: gspread / google-auth. Bitte installieren.")
|
| 252 |
+
st.stop()
|
| 253 |
+
|
| 254 |
+
scopes = [
|
| 255 |
+
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
| 256 |
+
"https://www.googleapis.com/auth/drive.readonly",
|
| 257 |
+
]
|
| 258 |
+
|
| 259 |
+
# Credentials: prefer Streamlit secrets
|
| 260 |
+
creds = None
|
| 261 |
+
if "gcp_service_account" in st.secrets:
|
| 262 |
+
creds = Credentials.from_service_account_info(st.secrets["gcp_service_account"], scopes=scopes)
|
| 263 |
+
else:
|
| 264 |
+
# fallback: GOOGLE_APPLICATION_CREDENTIALS env path
|
| 265 |
+
sa_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "").strip()
|
| 266 |
+
if not sa_path:
|
| 267 |
+
st.error(
|
| 268 |
+
"Keine Service Account Credentials gefunden. "
|
| 269 |
+
"Nutze st.secrets['gcp_service_account'] oder ENV GOOGLE_APPLICATION_CREDENTIALS."
|
| 270 |
+
)
|
| 271 |
+
st.stop()
|
| 272 |
+
creds = Credentials.from_service_account_file(sa_path, scopes=scopes)
|
| 273 |
+
|
| 274 |
+
gc = gspread.authorize(creds)
|
| 275 |
+
sh = gc.open_by_key(sheet_id)
|
| 276 |
+
ws = sh.worksheet(tab)
|
| 277 |
+
|
| 278 |
+
values = ws.get_all_values()
|
| 279 |
+
if not values or len(values) < 2:
|
| 280 |
+
return pd.DataFrame()
|
| 281 |
+
|
| 282 |
+
header = values[0]
|
| 283 |
+
rows = values[1:]
|
| 284 |
+
df = pd.DataFrame(rows, columns=header)
|
| 285 |
+
return df
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ----------------------------
|
| 289 |
+
# NLP light: Frage -> (glucose, trend)
|
| 290 |
+
# ----------------------------
|
| 291 |
+
def extract_glucose_and_trend(user_text: str) -> tuple[Optional[float], str]:
|
| 292 |
+
"""
|
| 293 |
+
Extract a glucose value (mg/dl) and trend from free text.
|
| 294 |
+
Trend defaults to 'any' if not found.
|
| 295 |
+
"""
|
| 296 |
+
text = (user_text or "").strip()
|
| 297 |
+
if not text:
|
| 298 |
+
return None, "any"
|
| 299 |
+
|
| 300 |
+
# value extraction: first number between 20 and 500
|
| 301 |
+
m = re.search(r"(\d{2,3})\s*(mg\/dl|mgdl)?", text.lower())
|
| 302 |
+
glucose = None
|
| 303 |
+
if m:
|
| 304 |
+
v = int(m.group(1))
|
| 305 |
+
if 20 <= v <= 500:
|
| 306 |
+
glucose = float(v)
|
| 307 |
+
|
| 308 |
+
# trend extraction
|
| 309 |
+
t = "any"
|
| 310 |
+
# arrows / keywords
|
| 311 |
+
if "↓↓" in text or "doppelpfeil" in text.lower() or "stark fall" in text.lower():
|
| 312 |
+
t = "double_falling"
|
| 313 |
+
elif any(k in text.lower() for k in ["fallend", "sink", "runter", "down", "↓"]):
|
| 314 |
+
t = "falling"
|
| 315 |
+
elif any(k in text.lower() for k in ["steigend", "stieg", "hoch", "up", "↑"]):
|
| 316 |
+
t = "rising"
|
| 317 |
+
elif any(k in text.lower() for k in ["stabil", "gleich", "stable"]):
|
| 318 |
+
t = "stable"
|
| 319 |
+
|
| 320 |
+
return glucose, t
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# ----------------------------
|
| 324 |
+
# Streamlit UI
|
| 325 |
+
# ----------------------------
|
| 326 |
+
st.set_page_config(page_title="Diabetes Guidance Agent", page_icon="🩺", layout="wide")
|
| 327 |
+
|
| 328 |
+
st.title("🩺 Diabetes Guidance Agent")
|
| 329 |
+
st.caption("Regelbasierte Handlungsempfehlungen aus dem Agent-Sheet (Tab: guidances).")
|
| 330 |
+
|
| 331 |
+
sheet_id = _get_sheet_id()
|
| 332 |
+
|
| 333 |
+
with st.sidebar:
|
| 334 |
+
st.subheader("Datenquelle")
|
| 335 |
+
st.write("Sheet-ID:", sheet_id)
|
| 336 |
+
tab_name = st.text_input("Guidances-Tab", value="guidances")
|
| 337 |
+
reload_btn = st.button("🔄 Neu laden")
|
| 338 |
+
|
| 339 |
+
if reload_btn:
|
| 340 |
+
load_guidances_from_sheet.clear()
|
| 341 |
+
|
| 342 |
+
raw_df = load_guidances_from_sheet(sheet_id, tab=tab_name)
|
| 343 |
+
guidances = _prepare_guidances(raw_df)
|
| 344 |
+
|
| 345 |
+
# top status
|
| 346 |
+
c1, c2, c3 = st.columns([1, 1, 2])
|
| 347 |
+
with c1:
|
| 348 |
+
st.metric("Guidance-Regeln geladen", int(len(guidances)))
|
| 349 |
+
with c2:
|
| 350 |
+
st.metric("Tab", tab_name)
|
| 351 |
+
with c3:
|
| 352 |
+
if len(guidances) == 0:
|
| 353 |
+
st.warning("Keine Regeln geladen. Bitte Tab-Name/Sheet prüfen.")
|
| 354 |
+
else:
|
| 355 |
+
st.success("Regeln sind verfügbar.")
|
| 356 |
+
|
| 357 |
+
tabs = st.tabs(["💬 Guidance-Chat", "🧪 Manuelle Eingabe", "🔍 Debug / Regeln"])
|
| 358 |
+
|
| 359 |
+
# --- Chat tab ---
|
| 360 |
+
with tabs[0]:
|
| 361 |
+
st.subheader("💬 Frage eingeben")
|
| 362 |
+
|
| 363 |
+
user_q = st.text_area(
|
| 364 |
+
"Beispiele: „Was tun bei 105 mg/dl?“ / „Wert 110, fallend“ / „92 mg/dl ↓↓“",
|
| 365 |
+
height=90,
|
| 366 |
+
placeholder="Deine Frage…",
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
colA, colB = st.columns([1, 1])
|
| 370 |
+
with colA:
|
| 371 |
+
ask_btn = st.button("Antwort anzeigen", type="primary", use_container_width=True)
|
| 372 |
+
with colB:
|
| 373 |
+
debug_mode = st.checkbox("Debug anzeigen", value=False)
|
| 374 |
+
|
| 375 |
+
if ask_btn:
|
| 376 |
+
glucose, trend = extract_glucose_and_trend(user_q)
|
| 377 |
+
|
| 378 |
+
match = _resolve_guidance(guidances, glucose, trend)
|
| 379 |
+
rule = match.rule
|
| 380 |
+
|
| 381 |
+
if rule is None:
|
| 382 |
+
st.error("⚠️ Keine Regel verfügbar (guidances leer oder nicht geladen).")
|
| 383 |
+
else:
|
| 384 |
+
# ALWAYS show something (even fallback)
|
| 385 |
+
st.info(rule.get("action", ""))
|
| 386 |
+
if rule.get("carbs_g"):
|
| 387 |
+
st.caption(f"🍎 KH: {rule.get('carbs_g')}")
|
| 388 |
+
if rule.get("food_examples"):
|
| 389 |
+
st.caption(f"Beispiele: {rule.get('food_examples')}")
|
| 390 |
+
if rule.get("follow_up"):
|
| 391 |
+
st.caption(f"Weiter: {rule.get('follow_up')}")
|
| 392 |
+
|
| 393 |
+
if debug_mode:
|
| 394 |
+
st.divider()
|
| 395 |
+
st.write("**Extraktion**")
|
| 396 |
+
st.write({"glucose_mgdl": glucose, "trend": trend})
|
| 397 |
+
st.write("**Resolver**")
|
| 398 |
+
st.write({"matched": match.matched, "reason": match.reason, "considered_rows": match.considered})
|
| 399 |
+
st.write("**Rule**")
|
| 400 |
+
st.json(rule)
|
| 401 |
+
|
| 402 |
+
# --- Manual input tab ---
|
| 403 |
+
with tabs[1]:
|
| 404 |
+
st.subheader("🧪 Manuelle Eingabe (ohne NLP)")
|
| 405 |
+
if len(guidances) == 0:
|
| 406 |
+
st.warning("Keine Regeln geladen.")
|
| 407 |
+
else:
|
| 408 |
+
glucose = st.slider("Blutzucker (mg/dl)", min_value=40, max_value=250, value=110, step=1)
|
| 409 |
+
trend = st.selectbox("Trend", ["any", "stable", "rising", "falling", "double_falling"], index=0)
|
| 410 |
+
|
| 411 |
+
match = _resolve_guidance(guidances, glucose, trend)
|
| 412 |
+
rule = match.rule
|
| 413 |
+
|
| 414 |
+
st.info(rule.get("action", ""))
|
| 415 |
+
cols = st.columns(3)
|
| 416 |
+
cols[0].metric("Matched", "Ja" if match.matched else "Nein")
|
| 417 |
+
cols[1].metric("Considered", match.considered)
|
| 418 |
+
cols[2].metric("Rule", rule.get("guidance_id", ""))
|
| 419 |
+
|
| 420 |
+
st.caption(match.reason)
|
| 421 |
+
if rule.get("carbs_g"):
|
| 422 |
+
st.caption(f"🍎 KH: {rule.get('carbs_g')}")
|
| 423 |
+
if rule.get("food_examples"):
|
| 424 |
+
st.caption(f"Beispiele: {rule.get('food_examples')}")
|
| 425 |
+
if rule.get("follow_up"):
|
| 426 |
+
st.caption(f"Weiter: {rule.get('follow_up')}")
|
| 427 |
+
|
| 428 |
+
# --- Debug tab ---
|
| 429 |
+
with tabs[2]:
|
| 430 |
+
st.subheader("🔍 Debug / Regeln")
|
| 431 |
+
if len(guidances) == 0:
|
| 432 |
+
st.warning("Keine Regeln geladen. Prüfe Sheet-ID/Tab/Credentials.")
|
| 433 |
+
else:
|
| 434 |
+
st.write("Regeln (nach Priority sortiert):")
|
| 435 |
+
st.dataframe(guidances, use_container_width=True)
|
| 436 |
+
|
| 437 |
+
st.divider()
|
| 438 |
+
st.write("Schnellprüfung:")
|
| 439 |
+
issues = []
|
| 440 |
+
|
| 441 |
+
if guidances["guidance_id"].duplicated().any():
|
| 442 |
+
issues.append("Duplizierte guidance_id gefunden.")
|
| 443 |
+
|
| 444 |
+
bad_trends = sorted(set(guidances["trend"].unique()) - ALLOWED_TRENDS)
|
| 445 |
+
if bad_trends:
|
| 446 |
+
issues.append(f"Ungültige trend-Werte: {bad_trends}")
|
| 447 |
+
|
| 448 |
+
if (guidances["action"].fillna("").str.strip() == "").any():
|
| 449 |
+
issues.append("Mindestens eine Regel hat leere action.")
|
| 450 |
+
|
| 451 |
+
if issues:
|
| 452 |
+
for i in issues:
|
| 453 |
+
st.error(i)
|
| 454 |
+
else:
|
| 455 |
+
st.success("✅ Grundprüfung ok (IDs, Trends, actions).")
|
| 456 |
+
|
| 457 |
+
st.sidebar.caption("Tipp: Wenn „keine Rückmeldung“, aktiviere Debug und prüfe, ob Regeln geladen sind und ob Trend/Wert extrahiert werden.")
|
guidance_resolver.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Dict, Optional, Tuple, List
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ALLOWED_TRENDS = {"any", "stable", "rising", "falling", "double_falling"}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# --- Helpers -----------------------------------------------------------------
|
| 13 |
+
|
| 14 |
+
def _to_float(x: Any, default: float) -> float:
|
| 15 |
+
"""
|
| 16 |
+
Coerce sheet values into float.
|
| 17 |
+
Accepts numbers, numeric strings, and strings with commas.
|
| 18 |
+
"""
|
| 19 |
+
if x is None or (isinstance(x, float) and pd.isna(x)) or (isinstance(x, str) and x.strip() == ""):
|
| 20 |
+
return float(default)
|
| 21 |
+
if isinstance(x, (int, float)):
|
| 22 |
+
return float(x)
|
| 23 |
+
if isinstance(x, str):
|
| 24 |
+
s = x.strip().replace(",", ".")
|
| 25 |
+
# strip common non-numeric tokens
|
| 26 |
+
for token in ["mg/dl", "mgdl", " ", "\u00A0"]:
|
| 27 |
+
s = s.replace(token, "")
|
| 28 |
+
try:
|
| 29 |
+
return float(s)
|
| 30 |
+
except ValueError:
|
| 31 |
+
return float(default)
|
| 32 |
+
return float(default)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _to_int(x: Any, default: int) -> int:
|
| 36 |
+
try:
|
| 37 |
+
return int(round(_to_float(x, default=float(default))))
|
| 38 |
+
except Exception:
|
| 39 |
+
return int(default)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _norm_str(x: Any, default: str = "") -> str:
|
| 43 |
+
if x is None or (isinstance(x, float) and pd.isna(x)):
|
| 44 |
+
return default
|
| 45 |
+
s = str(x).strip()
|
| 46 |
+
return s if s else default
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _norm_trend(x: Any) -> str:
|
| 50 |
+
"""
|
| 51 |
+
Normalize trend input from sheet or upstream code.
|
| 52 |
+
Accepts: any, stable, rising, falling, double_falling
|
| 53 |
+
Also maps common variants.
|
| 54 |
+
"""
|
| 55 |
+
s = _norm_str(x, default="any").lower()
|
| 56 |
+
|
| 57 |
+
mapping = {
|
| 58 |
+
"": "any",
|
| 59 |
+
"na": "any",
|
| 60 |
+
"n/a": "any",
|
| 61 |
+
"any": "any",
|
| 62 |
+
"egal": "any",
|
| 63 |
+
"stabil": "stable",
|
| 64 |
+
"stable": "stable",
|
| 65 |
+
"up": "rising",
|
| 66 |
+
"rising": "rising",
|
| 67 |
+
"steigend": "rising",
|
| 68 |
+
"down": "falling",
|
| 69 |
+
"falling": "falling",
|
| 70 |
+
"fallend": "falling",
|
| 71 |
+
"↓↓": "double_falling",
|
| 72 |
+
"doppelpfeil": "double_falling",
|
| 73 |
+
"double_falling": "double_falling",
|
| 74 |
+
"stark fallend": "double_falling",
|
| 75 |
+
}
|
| 76 |
+
s = mapping.get(s, s)
|
| 77 |
+
return s if s in ALLOWED_TRENDS else "any"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
|
| 81 |
+
"""
|
| 82 |
+
Map common column names to the canonical schema.
|
| 83 |
+
This makes the app resilient if the sheet headers vary slightly.
|
| 84 |
+
"""
|
| 85 |
+
col_map = {
|
| 86 |
+
# canonical: variants
|
| 87 |
+
"guidance_id": ["guidance_id", "id", "rule_id", "regel_id"],
|
| 88 |
+
"category": ["category", "kategorie"],
|
| 89 |
+
"priority": ["priority", "prio", "priorität", "prioritaet"],
|
| 90 |
+
"glucose_min_mgdl": ["glucose_min_mgdl", "min", "min_mgdl", "bg_min", "untergrenze", "low"],
|
| 91 |
+
"glucose_max_mgdl": ["glucose_max_mgdl", "max", "max_mgdl", "bg_max", "obergrenze", "high"],
|
| 92 |
+
"trend": ["trend", "pfeil", "arrow", "tendenz"],
|
| 93 |
+
"condition_note": ["condition_note", "condition", "bedingung", "note", "beschreibung"],
|
| 94 |
+
"action": ["action", "empfehlung", "handlung", "aktion"],
|
| 95 |
+
"carbs_g": ["carbs_g", "kh_g", "carbs", "kohlenhydrate_g", "kh"],
|
| 96 |
+
"food_examples": ["food_examples", "beispiele", "foods"],
|
| 97 |
+
"follow_up": ["follow_up", "next", "nachfolge", "kontrolle"],
|
| 98 |
+
"source": ["source", "quelle"],
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# Create reverse lookup
|
| 102 |
+
reverse: Dict[str, str] = {}
|
| 103 |
+
lower_cols = {c.lower(): c for c in df.columns}
|
| 104 |
+
|
| 105 |
+
for canonical, variants in col_map.items():
|
| 106 |
+
for v in variants:
|
| 107 |
+
if v.lower() in lower_cols:
|
| 108 |
+
reverse[lower_cols[v.lower()]] = canonical
|
| 109 |
+
break
|
| 110 |
+
|
| 111 |
+
df = df.rename(columns=reverse)
|
| 112 |
+
|
| 113 |
+
# Ensure all canonical columns exist
|
| 114 |
+
for c in col_map.keys():
|
| 115 |
+
if c not in df.columns:
|
| 116 |
+
df[c] = None
|
| 117 |
+
|
| 118 |
+
return df
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@dataclass
|
| 122 |
+
class GuidanceMatch:
|
| 123 |
+
rule: Optional[Dict[str, Any]]
|
| 124 |
+
matched: bool
|
| 125 |
+
reason: str
|
| 126 |
+
considered: int
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# --- Public API ---------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
def prepare_guidances_df(df: pd.DataFrame) -> pd.DataFrame:
|
| 132 |
+
"""
|
| 133 |
+
Normalize, type-coerce, and sort guidances.
|
| 134 |
+
Call this right after loading the sheet tab.
|
| 135 |
+
"""
|
| 136 |
+
df = _normalize_columns(df).copy()
|
| 137 |
+
|
| 138 |
+
# Type coercion with safe defaults
|
| 139 |
+
df["guidance_id"] = df["guidance_id"].apply(lambda x: _norm_str(x, default=""))
|
| 140 |
+
df["category"] = df["category"].apply(lambda x: _norm_str(x, default=""))
|
| 141 |
+
df["priority"] = df["priority"].apply(lambda x: _to_int(x, default=9999))
|
| 142 |
+
|
| 143 |
+
# glucose bounds: default wide if missing
|
| 144 |
+
df["glucose_min_mgdl"] = df["glucose_min_mgdl"].apply(lambda x: _to_float(x, default=0))
|
| 145 |
+
df["glucose_max_mgdl"] = df["glucose_max_mgdl"].apply(lambda x: _to_float(x, default=999))
|
| 146 |
+
|
| 147 |
+
df["trend"] = df["trend"].apply(_norm_trend)
|
| 148 |
+
|
| 149 |
+
# Text fields
|
| 150 |
+
for c in ["condition_note", "action", "carbs_g", "food_examples", "follow_up", "source"]:
|
| 151 |
+
df[c] = df[c].apply(lambda x: _norm_str(x, default=""))
|
| 152 |
+
|
| 153 |
+
# Drop rows that are unusable (no action and no id)
|
| 154 |
+
df = df[~((df["action"] == "") & (df["guidance_id"] == ""))].copy()
|
| 155 |
+
|
| 156 |
+
# Sort by priority (ascending), then by specificity (narrower range first)
|
| 157 |
+
df["__range_width"] = (df["glucose_max_mgdl"] - df["glucose_min_mgdl"]).abs()
|
| 158 |
+
df = df.sort_values(["priority", "__range_width"], ascending=[True, True]).reset_index(drop=True)
|
| 159 |
+
df = df.drop(columns=["__range_width"])
|
| 160 |
+
|
| 161 |
+
return df
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def resolve_guidance(
|
| 165 |
+
guidances_df: pd.DataFrame,
|
| 166 |
+
glucose_mgdl: Optional[float],
|
| 167 |
+
trend: Optional[str] = None,
|
| 168 |
+
*,
|
| 169 |
+
require_action: bool = True,
|
| 170 |
+
debug: bool = False,
|
| 171 |
+
) -> GuidanceMatch:
|
| 172 |
+
"""
|
| 173 |
+
Returns the first matching rule by priority.
|
| 174 |
+
- glucose_mgdl can be None -> fallback to best 'any' rule or default.
|
| 175 |
+
- trend can be None/unknown -> normalized to 'any'
|
| 176 |
+
"""
|
| 177 |
+
if guidances_df is None or len(guidances_df) == 0:
|
| 178 |
+
return GuidanceMatch(
|
| 179 |
+
rule=None,
|
| 180 |
+
matched=False,
|
| 181 |
+
reason="Guidances sind leer (Tab 'guidances' nicht geladen oder keine Zeilen).",
|
| 182 |
+
considered=0,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
t = _norm_trend(trend)
|
| 186 |
+
|
| 187 |
+
# glucose missing: match the first rule that allows full range OR any
|
| 188 |
+
if glucose_mgdl is None:
|
| 189 |
+
# choose highest-priority generic
|
| 190 |
+
for _, r in guidances_df.iterrows():
|
| 191 |
+
if _norm_trend(r.get("trend")) in ("any", t):
|
| 192 |
+
rule = r.to_dict()
|
| 193 |
+
return GuidanceMatch(
|
| 194 |
+
rule=rule,
|
| 195 |
+
matched=True,
|
| 196 |
+
reason="Kein Blutzuckerwert übergeben → generische Regel gewählt.",
|
| 197 |
+
considered=1,
|
| 198 |
+
)
|
| 199 |
+
return GuidanceMatch(rule=None, matched=False, reason="Kein Wert und keine generische Regel gefunden.", considered=len(guidances_df))
|
| 200 |
+
|
| 201 |
+
g = float(glucose_mgdl)
|
| 202 |
+
|
| 203 |
+
considered = 0
|
| 204 |
+
for _, r in guidances_df.iterrows():
|
| 205 |
+
considered += 1
|
| 206 |
+
|
| 207 |
+
r_min = float(r.get("glucose_min_mgdl", 0))
|
| 208 |
+
r_max = float(r.get("glucose_max_mgdl", 999))
|
| 209 |
+
r_trend = _norm_trend(r.get("trend"))
|
| 210 |
+
|
| 211 |
+
if not (r_min <= g <= r_max):
|
| 212 |
+
continue
|
| 213 |
+
|
| 214 |
+
if r_trend != "any" and r_trend != t:
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
# If require_action: skip empty actions
|
| 218 |
+
if require_action and _norm_str(r.get("action")) == "":
|
| 219 |
+
continue
|
| 220 |
+
|
| 221 |
+
rule = r.to_dict()
|
| 222 |
+
return GuidanceMatch(
|
| 223 |
+
rule=rule,
|
| 224 |
+
matched=True,
|
| 225 |
+
reason=f"Match gefunden: {rule.get('guidance_id','(ohne id)')} (trend={t}, glucose={g}).",
|
| 226 |
+
considered=considered,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Fallback: show something instead of silence
|
| 230 |
+
fallback = {
|
| 231 |
+
"guidance_id": "DEFAULT_NO_MATCH",
|
| 232 |
+
"category": "fallback",
|
| 233 |
+
"priority": 9999,
|
| 234 |
+
"glucose_min_mgdl": 0,
|
| 235 |
+
"glucose_max_mgdl": 999,
|
| 236 |
+
"trend": "any",
|
| 237 |
+
"condition_note": "Keine passende Regel gefunden.",
|
| 238 |
+
"action": "⚠️ Keine passende Handlungsempfehlung gefunden. Bitte Trend/Wert prüfen oder Kontaktperson anrufen.",
|
| 239 |
+
"carbs_g": "",
|
| 240 |
+
"food_examples": "",
|
| 241 |
+
"follow_up": "",
|
| 242 |
+
"source": "system",
|
| 243 |
+
}
|
| 244 |
+
return GuidanceMatch(
|
| 245 |
+
rule=fallback,
|
| 246 |
+
matched=False,
|
| 247 |
+
reason=f"Kein Match (trend={t}, glucose={g}). Fallback ausgegeben.",
|
| 248 |
+
considered=considered,
|
| 249 |
+
)
|