JulianTekles commited on
Commit
d92e19e
·
verified ·
1 Parent(s): 758bf4d

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +457 -0
  2. 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
+ )