From 04e23d811592a0951ac929451002d4ed459158a4 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:22:27 +0200 Subject: [PATCH] feat: Enhance placeholder resolution and error handling - Updated `extract_value_raw` to improve JSON parsing and handle unavailable data more effectively. - Introduced new functions in `placeholder_resolver.py` for standardized responses when data is unavailable, enhancing clarity for users and AI. - Modified various data retrieval functions to utilize the new response format, providing detailed reasons for unavailability. - Improved availability checks in `export_placeholder_values_extended` to account for new response formats. These changes enhance the robustness of the placeholder system and improve user experience by providing clearer error messages and data handling. --- backend/placeholder_metadata_enhanced.py | 12 +- backend/placeholder_resolver.py | 316 ++++++++++++++++-- backend/routers/prompts.py | 10 +- backend/tests/test_placeholder_metadata_v2.py | 16 + 4 files changed, 320 insertions(+), 34 deletions(-) diff --git a/backend/placeholder_metadata_enhanced.py b/backend/placeholder_metadata_enhanced.py index 4837f97..6b16599 100644 --- a/backend/placeholder_metadata_enhanced.py +++ b/backend/placeholder_metadata_enhanced.py @@ -29,14 +29,22 @@ def extract_value_raw(value_display: str, output_type: OutputType, placeholder_t Returns: (raw_value, success) """ - if not value_display or value_display in ['nicht verfügbar', 'nicht genug Daten']: + s = (value_display or "").strip() + if ( + not s + or s in ['nicht verfügbar', 'nicht genug Daten'] + or s.startswith('nicht verfügbar —') + ): # V2 strict mode: missing/unavailable value is not a successful extraction return None, False # JSON output type if output_type == OutputType.JSON: try: - return json.loads(value_display), True + parsed = json.loads(value_display) + if isinstance(parsed, dict) and parsed.get('_available') is False: + return None, False + return parsed, True except (json.JSONDecodeError, TypeError): # Try to find JSON in string json_match = re.search(r'(\{.*\}|\[.*\])', value_display, re.DOTALL) diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 86d279c..4f83d0a 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -7,6 +7,7 @@ Used for prompt templates and preview functionality. Phase 0c: Refactored to use data_layer for structured data. This module now focuses on FORMATTING for AI consumption. """ +import json import re from datetime import datetime, timedelta from typing import Dict, List, Optional, Callable, Tuple @@ -57,6 +58,32 @@ def get_profile_data(profile_id: str) -> Dict: return r2d(cur.fetchone()) if cur.rowcount > 0 else {} +def pv_unavailable(reason: str, detail: Optional[str] = None) -> str: + """ + Standard-Antwort wenn kein Platzhalter-Wert lieferbar ist. + Grund ist für Nutzer und KI lesbar (ggf. Alternativen im Text). + """ + r = (reason or "Keine auswertbaren Daten").strip() + if detail: + d = str(detail).strip() + if d: + return f"nicht verfügbar — {r} ({d})" + return f"nicht verfügbar — {r}" + + +def pv_unavailable_json(reason: str, detail: Optional[str] = None) -> str: + """Strukturierte JSON-Antwort statt leeres {} (für KI / Clients).""" + payload: Dict[str, object] = { + "_available": False, + "_reason": (reason or "Keine auswertbaren Daten").strip(), + } + if detail: + d = str(detail).strip() + if d: + payload["_detail"] = d + return json.dumps(payload, ensure_ascii=False) + + def get_latest_weight(profile_id: str) -> Optional[str]: """ Get latest weight entry. @@ -67,7 +94,10 @@ def get_latest_weight(profile_id: str) -> Optional[str]: data = get_latest_weight_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Kein aktuelles Gewicht", + f"confidence={data.get('confidence')}, data_points={data.get('data_points',0)}", + ) return f"{data['weight']:.1f} kg" @@ -82,7 +112,10 @@ def get_weight_trend(profile_id: str, days: int = 28) -> str: data = get_weight_trend_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht genug Daten" + return pv_unavailable( + "Gewichtstrend nicht ermittelbar", + f"confidence={data.get('confidence')}, Fenster={days} Tage", + ) direction = data['direction'] delta = data['delta'] @@ -105,7 +138,10 @@ def get_latest_bf(profile_id: str) -> Optional[str]: data = get_body_composition_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Körperfett nicht ermittelbar", + f"confidence={data.get('confidence')} (keine ausreichenden Caliper-/Kompositionsdaten)", + ) return f"{data['body_fat_pct']:.1f}%" @@ -120,7 +156,10 @@ def get_nutrition_avg(profile_id: str, field: str, days: int = 30) -> str: data = get_nutrition_average_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Ernährungsmittelwert nicht ermittelbar", + f"confidence={data.get('confidence')}, Feld={field}, Fenster={days} Tage", + ) # Map field names to data keys field_map = { @@ -224,7 +263,10 @@ def get_protein_ziel_low(profile_id: str) -> str: data = get_protein_targets_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Proteinziel unten nicht ermittelbar", + f"confidence={data.get('confidence')} (Gewicht/Profil für g/kg)", + ) return f"{int(data['protein_target_low'])}" @@ -239,7 +281,10 @@ def get_protein_ziel_high(profile_id: str) -> str: data = get_protein_targets_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Proteinziel oben nicht ermittelbar", + f"confidence={data.get('confidence')} (Gewicht/Profil für g/kg)", + ) return f"{int(data['protein_target_high'])}" @@ -423,7 +468,12 @@ def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str: data = get_sleep_duration_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Schlafdauer (formatiert) nicht aus sleep_segments ableitbar", + f"confidence={data.get('confidence')}, " + f"nächte_mit_segmenten={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)}; " + f"Hinweis: {{sleep_avg_duration_7d}} nutzt duration_minutes und kann trotzdem Werte liefern", + ) return f"{data['avg_duration_hours']:.1f}h" @@ -438,7 +488,11 @@ def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str: data = get_sleep_quality_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Schlafqualität (Deep+REM) nicht aus sleep_segments ableitbar", + f"confidence={data.get('confidence')}, " + f"nächte_analysiert={data.get('nights_analyzed', 0)}/{data.get('days_analyzed', days)}", + ) return f"{data['quality_score']:.0f}% (Deep+REM)" @@ -464,7 +518,10 @@ def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str: data = get_resting_heart_rate_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Ruhepuls-Schnitt nicht ermittelbar", + f"confidence={data.get('confidence')}, Fenster={days} Tage (vitals_baseline)", + ) return f"{data['avg_rhr']} bpm" @@ -479,7 +536,10 @@ def get_vitals_avg_hrv(profile_id: str, days: int = 7) -> str: data = get_heart_rate_variability_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "HRV-Schnitt nicht ermittelbar", + f"confidence={data.get('confidence')}, Fenster={days} Tage (vitals_baseline)", + ) return f"{data['avg_hrv']} ms" @@ -494,11 +554,156 @@ def get_vitals_vo2_max(profile_id: str) -> str: data = get_vo2_max_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "VO2max nicht ermittelbar", + f"confidence={data.get('confidence')} (vitals_baseline)", + ) return f"{data['vo2_max']:.1f} ml/kg/min" +# Begründungen wenn Layer-2-Berechnung None liefert (für KI / Export) +_DEFAULT_NUMERIC_UNAVAILABLE = ( + "Numerische Berechnung liefert keinen Wert (Daten unzureichend oder Schwellen nicht erreicht)" +) +_DEFAULT_STR_UNAVAILABLE = ( + "Kein Wert ermittelbar (Daten unzureichend oder Schwellen nicht erreicht)" +) +_DEFAULT_JSON_UNAVAILABLE = ( + "Keine strukturierten JSON-Daten ermittelbar (Berechnung liefert None)" +) + +_SAFE_INT_NONE_REASON: Dict[str, str] = { + "goal_progress_score": ( + "Aggregierter Ziel-Fortschritt nicht berechenbar; Alternativen: {{active_goals_md}}, " + "{{data_quality_score}}" + ), + "body_progress_score": "Körper-Fortschritts-Score nicht berechenbar", + "nutrition_score": ( + "Ernährungs-Score nicht berechenbar (z. B. keine gewichteten Ernährungs-Fokusbereiche oder zu wenig Log-Daten)" + ), + "activity_score": ( + "Aktivitäts-Score nicht berechenbar (z. B. Score-Schwellen oder fehlende abilities-Zuordnung in activity_log)" + ), + "recovery_score_v2": "Recovery-Score v2 nicht berechenbar (Schlaf/Vitals/Last)", + "data_quality_score": "Datenqualitäts-Score nicht berechenbar", + "top_goal_progress_pct": ( + "Fortschritt % des Top-Ziels nicht ermittelbar (progress_pct fehlt oder Ziel nicht quantifizierbar); " + "Alternative: {{active_goals_json}}" + ), + "top_focus_area_progress": "Fortschritt % des Top-Fokusbereichs nicht ermittelbar", + "focus_cat_körper_progress": "Kategorie-Fortschritt „Körper“ nicht berechenbar", + "focus_cat_ernährung_progress": "Kategorie-Fortschritt „Ernährung“ nicht berechenbar", + "focus_cat_aktivität_progress": "Kategorie-Fortschritt „Aktivität“ nicht berechenbar", + "focus_cat_recovery_progress": "Kategorie-Fortschritt „Recovery“ nicht berechenbar", + "focus_cat_vitalwerte_progress": "Kategorie-Fortschritt „Vitalwerte“ nicht berechenbar", + "focus_cat_mental_progress": "Kategorie-Fortschritt „Mental“ nicht berechenbar", + "focus_cat_lebensstil_progress": "Kategorie-Fortschritt „Lebensstil“ nicht berechenbar", + "training_minutes_week": "Trainingsminuten/Woche nicht berechenbar", + "training_frequency_7d": "Trainingseinheiten (7 Tage) nicht berechenbar", + "quality_sessions_pct": "Anteil Qualitätssessions nicht berechenbar", + "ability_balance_strength": ( + "Fähigkeiten-Balance Kraft nicht berechenbar (zu wenig abilities-Daten in activity_log)" + ), + "ability_balance_endurance": ( + "Fähigkeiten-Balance Ausdauer nicht berechenbar (abilities in activity_log)" + ), + "ability_balance_mental": ( + "Fähigkeiten-Balance Mental nicht berechenbar (abilities in activity_log)" + ), + "ability_balance_coordination": ( + "Fähigkeiten-Balance Koordination nicht berechenbar (abilities in activity_log)" + ), + "ability_balance_mobility": ( + "Fähigkeiten-Balance Mobilität nicht berechenbar (abilities in activity_log)" + ), + "proxy_internal_load_7d": "Interne Last (7 Tage) nicht berechenbar", + "strain_score": "Strain-Score nicht berechenbar", + "rest_day_compliance": "Ruhetag-Compliance nicht berechenbar", + "protein_adequacy_28d": "Protein-Adequacy (28 Tage) nicht berechenbar", + "macro_consistency_score": "Makro-Konsistenz-Score nicht berechenbar", + "recent_load_balance_3d": "Load-Balance (3 Tage) nicht berechenbar", + "sleep_quality_7d": "Schlafqualität 7 Tage nicht berechenbar", +} + +_SAFE_FLOAT_NONE_REASON: Dict[str, str] = { + "weight_7d_median": ( + "Gewichts-Median 7 Tage: mindestens 4 Messungen im Fenster erforderlich" + ), + "weight_28d_slope": ( + "Gewichts-Trend 28 Tage: zu wenige Messpunkte (ca. 60 % Tagesabdeckung im Fenster)" + ), + "weight_90d_slope": ( + "Gewichts-Trend 90 Tage: zu wenige Messpunkte (ca. 60 % Tagesabdeckung im Fenster)" + ), + "fm_28d_change": "Fettmasse-Änderung 28 Tage nicht berechenbar (Serie Caliper/Gewicht)", + "lbm_28d_change": "Magermasse-Änderung 28 Tage nicht berechenbar (Serie Caliper/Gewicht)", + "waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)", + "hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar", + "chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar", + "arm_28d_delta": "Arm-Delta 28 Tage nicht berechenbar", + "thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar", + "waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar", + "energy_balance_7d": ( + "Energiebilanz 7 Tage nicht berechenbar (Intake oder TDEE/Gewicht fehlt)" + ), + "protein_g_per_kg": "Protein g/kg nicht berechenbar", + "monotony_score": "Monotonie-Score nicht berechenbar", + "vo2max_trend_28d": "VO2max-Trend 28 Tage nicht berechenbar", + "hrv_vs_baseline_pct": "HRV vs. Baseline nicht berechenbar (Baseline/Historie)", + "rhr_vs_baseline_pct": "Ruhepuls vs. Baseline nicht berechenbar", + "sleep_avg_duration_7d": "Schlafdauer 7 Tage nicht berechenbar (duration_minutes in sleep_log)", + "sleep_debt_hours": "Schlafschuld nicht berechenbar (mindestens ~10 Nächte mit Dauer)", + "sleep_regularity_proxy": "Schlaf-Regularität nicht berechenbar", + "focus_cat_körper_weight": "Kategorie-Gewichtung „Körper“ nicht berechenbar", + "focus_cat_ernährung_weight": "Kategorie-Gewichtung „Ernährung“ nicht berechenbar", + "focus_cat_aktivität_weight": "Kategorie-Gewichtung „Aktivität“ nicht berechenbar", + "focus_cat_recovery_weight": "Kategorie-Gewichtung „Recovery“ nicht berechenbar", + "focus_cat_vitalwerte_weight": "Kategorie-Gewichtung „Vitalwerte“ nicht berechenbar", + "focus_cat_mental_weight": "Kategorie-Gewichtung „Mental“ nicht berechenbar", + "focus_cat_lebensstil_weight": "Kategorie-Gewichtung „Lebensstil“ nicht berechenbar", +} + +_SAFE_STR_NONE_REASON: Dict[str, str] = { + "top_goal_name": "Kein priorisiertes Ziel ermittelbar", + "top_goal_status": "Status des Top-Ziels nicht ermittelbar", + "top_focus_area_name": "Kein Top-Fokusbereich ermittelbar", + "recomposition_quadrant": "Rekompositions-Quadrant nicht berechenbar (FM/LBM-Serie)", + "energy_deficit_surplus": "Defizit/Überschuss-Status nicht berechenbar", + "protein_days_in_target": "Protein-Tage im Ziel nicht berechenbar", + "intake_volatility": "Intake-Volatilität nicht berechenbar", + "active_goals_md": "Aktive Ziele (Markdown) nicht darstellbar", + "focus_areas_weighted_md": "Fokusbereiche (Markdown) nicht darstellbar", + "top_3_focus_areas": "Top-3-Fokusbereiche nicht darstellbar", + "top_3_goals_behind_schedule": "Ziele „hinter Zeitplan“ nicht darstellbar", + "top_3_goals_on_track": "Ziele „im Plan“ nicht darstellbar", +} + +_SAFE_JSON_NONE_REASON: Dict[str, str] = { + "training_sessions_recent_json": "Keine Session-Daten für JSON-Zeitfenster", + "correlation_energy_weight_lag": ( + "Korrelation Energiebilanz zu Gewicht: zu wenige gekoppelte Datenpunkte" + ), + "correlation_protein_lbm": ( + "Korrelation Protein zu Magermasse: zu wenige gekoppelte Datenpunkte" + ), + "correlation_load_hrv": ( + "Korrelation Trainingslast zu HRV: zu wenige gekoppelte Datenpunkte" + ), + "correlation_load_rhr": ( + "Korrelation Trainingslast zu Ruhepuls: zu wenige gekoppelte Datenpunkte" + ), + "correlation_sleep_recovery": ( + "Korrelation Schlaf zu Recovery: zu wenige gekoppelte Datenpunkte" + ), + "plateau_detected": "Plateau-Erkennung: keine auswertbare Serie", + "top_drivers": "Top-Treiber: keine auswertbare Korrelationsbasis", + "active_goals_json": "Aktive Ziele als JSON nicht ermittelbar", + "focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar", + "focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar", +} + + # ── Phase 0b Calculation Engine Integration ────────────────────────────────── def _safe_int(func_name: str, profile_id: str) -> str: @@ -510,7 +715,7 @@ def _safe_int(func_name: str, profile_id: str) -> str: profile_id: Profile ID Returns: - String representation of integer value or 'nicht verfügbar' + String representation of integer value oder pv_unavailable-Text mit Grund """ import traceback try: @@ -554,14 +759,24 @@ def _safe_int(func_name: str, profile_id: str) -> str: func = func_map.get(func_name) if not func: - return 'nicht verfügbar' + return pv_unavailable( + "Ungültiger Platzhalter (keine numerische Berechnung registriert)", + func_name, + ) result = func(profile_id) - return str(int(result)) if result is not None else 'nicht verfügbar' + if result is None: + return pv_unavailable( + _SAFE_INT_NONE_REASON.get(func_name, _DEFAULT_NUMERIC_UNAVAILABLE), + ) + return str(int(result)) except Exception as e: print(f"[ERROR] _safe_int({func_name}, {profile_id}): {type(e).__name__}: {e}") traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Berechnungsfehler bei numerischem Platzhalter", + f"{type(e).__name__}: {e}", + ) def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str: @@ -574,7 +789,7 @@ def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str: decimals: Number of decimal places Returns: - String representation of float value or 'nicht verfügbar' + String representation of float value oder pv_unavailable-Text mit Grund """ import traceback try: @@ -612,14 +827,24 @@ def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str: func = func_map.get(func_name) if not func: - return 'nicht verfügbar' + return pv_unavailable( + "Ungültiger Platzhalter (keine Float-Berechnung registriert)", + func_name, + ) result = func(profile_id) - return f"{result:.{decimals}f}" if result is not None else 'nicht verfügbar' + if result is None: + return pv_unavailable( + _SAFE_FLOAT_NONE_REASON.get(func_name, _DEFAULT_NUMERIC_UNAVAILABLE), + ) + return f"{result:.{decimals}f}" except Exception as e: print(f"[ERROR] _safe_float({func_name}, {profile_id}): {type(e).__name__}: {e}") traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Berechnungsfehler bei Float-Platzhalter", + f"{type(e).__name__}: {e}", + ) def _safe_str(func_name: str, profile_id: str) -> str: @@ -648,14 +873,24 @@ def _safe_str(func_name: str, profile_id: str) -> str: func = func_map.get(func_name) if not func: - return 'nicht verfügbar' + return pv_unavailable( + "Ungültiger Platzhalter (keine String-Berechnung registriert)", + func_name, + ) result = func(profile_id) - return str(result) if result is not None else 'nicht verfügbar' + if result is None: + return pv_unavailable( + _SAFE_STR_NONE_REASON.get(func_name, _DEFAULT_STR_UNAVAILABLE), + ) + return str(result) except Exception as e: print(f"[ERROR] _safe_str({func_name}, {profile_id}): {type(e).__name__}: {e}") traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Berechnungsfehler bei Text-Platzhalter", + f"{type(e).__name__}: {e}", + ) def _safe_json(func_name: str, profile_id: str) -> str: @@ -684,11 +919,16 @@ def _safe_json(func_name: str, profile_id: str) -> str: func = func_map.get(func_name) if not func: - return '{}' + return pv_unavailable_json( + "Ungültiger Platzhalter (keine JSON-Berechnung registriert)", + func_name, + ) result = func(profile_id) if result is None: - return '{}' + return pv_unavailable_json( + _SAFE_JSON_NONE_REASON.get(func_name, _DEFAULT_JSON_UNAVAILABLE), + ) # If already string, return it; otherwise convert to JSON if isinstance(result, str): @@ -698,7 +938,10 @@ def _safe_json(func_name: str, profile_id: str) -> str: except Exception as e: print(f"[ERROR] _safe_json({func_name}, {profile_id}): {type(e).__name__}: {e}") traceback.print_exc() - return '{}' + return pv_unavailable_json( + "Berechnungsfehler bei JSON-Platzhalter", + f"{type(e).__name__}: {e}", + ) def _get_active_goals_json(profile_id: str) -> str: @@ -840,8 +1083,11 @@ def _format_top_focus_areas(profile_id: str, n: int = 3) -> str: lines.append(f"{i}. {name} ({weight}%)") return ', '.join(lines) - except Exception: - return 'nicht verfügbar' + except Exception as e: + return pv_unavailable( + "Top-Fokusbereiche nicht darstellbar", + f"{type(e).__name__}: {e}", + ) def _format_goals_behind(profile_id: str, n: int = 3) -> str: @@ -1005,7 +1251,10 @@ def _format_goals_behind(profile_id: str, n: int = 3) -> str: print(f"[ERROR] _format_goals_behind: {e}") import traceback traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Ziele „hinter Zeitplan“ nicht darstellbar", + f"{type(e).__name__}: {e}", + ) def _format_goals_on_track(profile_id: str, n: int = 3) -> str: @@ -1166,7 +1415,10 @@ def _format_goals_on_track(profile_id: str, n: int = 3) -> str: print(f"[ERROR] _format_goals_on_track: {e}") import traceback traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Ziele „im Plan“ nicht darstellbar", + f"{type(e).__name__}: {e}", + ) # ── Placeholder Registry ────────────────────────────────────────────────────── @@ -1321,7 +1573,11 @@ def calculate_bmi(profile_id: str) -> str: data = get_bmi_data(profile_id) bmi = data.get("bmi") if bmi is None: - return "nicht verfügbar" + return pv_unavailable( + "BMI nicht berechenbar", + f"confidence={data.get('confidence')}; benötigt Profil-Größe (cm) und letztes Gewicht " + f"(weight_log); height_cm={data.get('height_cm')}, weight_kg={data.get('weight_kg')}", + ) return f"{bmi:.1f}" diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 8112ece..3e6288c 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -583,8 +583,14 @@ def export_placeholder_values_extended( if 'value_raw' not in metadata.unresolved_fields: metadata.unresolved_fields.append('value_raw') - # Check availability - if value in ['nicht verfügbar', 'nicht genug Daten', '[Fehler:', '[Nicht']: + # Check availability (Resolver liefert oft „nicht verfügbar — “) + sv = str(value) + if ( + sv in ['nicht verfügbar', 'nicht genug Daten'] + or sv.startswith('nicht verfügbar —') + or sv.startswith('[Fehler:') + or sv.startswith('[Nicht') + ): metadata.available = False metadata.missing_reason = value else: diff --git a/backend/tests/test_placeholder_metadata_v2.py b/backend/tests/test_placeholder_metadata_v2.py index 33f81a2..9851a16 100644 --- a/backend/tests/test_placeholder_metadata_v2.py +++ b/backend/tests/test_placeholder_metadata_v2.py @@ -44,6 +44,15 @@ def test_value_raw_json(): assert not success assert val is None + # Resolver-Fehlerhülle (kein verwertbares JSON für Charts) + val, success = extract_value_raw( + '{"_available": false, "_reason": "test"}', + OutputType.JSON, + PlaceholderType.RAW_DATA, + ) + assert not success + assert val is None + def test_value_raw_number(): """Numeric outputs must extract numbers without units.""" @@ -66,6 +75,13 @@ def test_value_raw_number(): val, success = extract_value_raw('nicht verfügbar', OutputType.NUMBER, PlaceholderType.ATOMIC) assert not success + val, success = extract_value_raw( + 'nicht verfügbar — Keine Messungen (detail)', + OutputType.NUMBER, + PlaceholderType.ATOMIC, + ) + assert not success + def test_value_raw_markdown(): """Markdown outputs keep as string."""