diff --git a/backend/placeholder_registrations/__init__.py b/backend/placeholder_registrations/__init__.py new file mode 100644 index 0000000..e1905cc --- /dev/null +++ b/backend/placeholder_registrations/__init__.py @@ -0,0 +1,10 @@ +""" +Placeholder Registrations Package + +Auto-imports all placeholder registrations to populate the global registry. +""" + +# Import all registration modules to trigger auto-registration +from . import nutrition_part_a + +__all__ = ['nutrition_part_a'] diff --git a/backend/placeholder_registrations/nutrition_part_a.py b/backend/placeholder_registrations/nutrition_part_a.py new file mode 100644 index 0000000..02c4798 --- /dev/null +++ b/backend/placeholder_registrations/nutrition_part_a.py @@ -0,0 +1,216 @@ +""" +Nutrition Part A Placeholder Registrations + +Registers the 4 basis nutrition metrics in the central placeholder registry: +- kcal_avg +- protein_avg +- carb_avg +- fat_avg + +Evidence-based metadata with clear tagging of source. +""" + +from placeholder_registry import ( + PlaceholderMetadata, + MissingValuePolicy, + EvidenceType, + OutputType, + PlaceholderType, + register_placeholder +) + + +def register_nutrition_part_a(): + """ + Register Part A nutrition placeholders. + + Metadata sources: + - code-derived: extracted from actual code + - draft-derived: from canonical requirements draft + - mixed: combination of code and draft + - unresolved: not explicitly documented + - to_verify: claimed but not verified + """ + + # Common metadata for all 4 placeholders + common_metadata = { + "category": "Ernährung", + "resolver_module": "backend/placeholder_resolver.py", + "resolver_function": "get_nutrition_avg", + "data_layer_module": "backend/data_layer/nutrition_metrics.py", + "data_layer_function": "get_nutrition_average_data", + "source_tables": ["nutrition_log"], + "time_window": "30d", + "output_type": OutputType.NUMERIC, + "placeholder_type": PlaceholderType.INTERPRETED, + "confidence_logic": "datenpunktbasierte Coverage-Logik (calculate_confidence)", + "missing_value_policy": MissingValuePolicy( + available=False, + value_raw=None, + missing_reason="insufficient_data", + legacy_display="nicht genug Daten" + ), + "layer_1_decision": "Data Layer (nutrition_metrics.get_nutrition_average_data)", + "layer_2a_decision": "Placeholder Resolver (formatting only)", + "architecture_alignment": "Phase 0c Multi-Layer Architecture conform", + } + + # Common evidence for shared fields + common_evidence = { + "category": EvidenceType.CODE_DERIVED, # from placeholder_resolver.py:1380 + "resolver_module": EvidenceType.CODE_DERIVED, + "resolver_function": EvidenceType.CODE_DERIVED, + "data_layer_module": EvidenceType.CODE_DERIVED, # from import statement + "data_layer_function": EvidenceType.CODE_DERIVED, # from resolver code + "source_tables": EvidenceType.CODE_DERIVED, # from SQL query + "time_window": EvidenceType.CODE_DERIVED, # from PLACEHOLDER_MAP lambda + "output_type": EvidenceType.CODE_DERIVED, # from resolver return type + "placeholder_type": EvidenceType.MIXED, # draft classification + code shows aggregation + "confidence_logic": EvidenceType.CODE_DERIVED, # from data layer + "missing_value_policy": EvidenceType.CODE_DERIVED, # from resolver code + "layer_1_decision": EvidenceType.CODE_DERIVED, + "layer_2a_decision": EvidenceType.CODE_DERIVED, + "layer_2b_reuse_possible": EvidenceType.TO_VERIFY, # not verified in charts + "architecture_alignment": EvidenceType.CODE_DERIVED, # imports from data_layer + "issue_53_alignment": EvidenceType.MIXED, # layer separation visible, issue conformity derived + "minimum_data_requirements": EvidenceType.UNRESOLVED, # not explicit in code + "quality_filter_policy": EvidenceType.UNRESOLVED, # not implemented + } + + # ── kcal_avg ────────────────────────────────────────────────────────────── + + kcal_metadata = PlaceholderMetadata( + key="kcal_avg", + description="Durchschn. Kalorien (30d)", + semantic_contract=( + "Liefert den Durchschnitt der dokumentierten täglichen Kalorienaufnahme " + "über das definierte Auswertungsfenster. Der Wert ist als Intake-Mittelwert " + "zu interpretieren, nicht als Energiebedarf oder Energiebilanz." + ), + business_meaning="Kernwert für Ernährungsstatus, Defizit-/Überschussbewertung und Zielabgleich", + unit="kcal/day", + format_hint="Ganzzahl", + example_output="2140", + known_limitations="nur Intake, kein Bedarf; sagt allein nichts über Zielpassung", + layer_2b_reuse_possible=None, # to_verify - not checked in chart code + issue_53_alignment="Layer separation established", + minimum_data_requirements=None, # unresolved + quality_filter_policy=None, # unresolved + **common_metadata + ) + + kcal_metadata.evidence.update(common_evidence) + kcal_metadata.set_evidence("semantic_contract", EvidenceType.DRAFT_DERIVED) + kcal_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED) + kcal_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED) # from resolver: no " g" suffix + kcal_metadata.set_evidence("format_hint", EvidenceType.CODE_DERIVED) # int(value) + kcal_metadata.set_evidence("example_output", EvidenceType.CODE_DERIVED) # runtime testable + kcal_metadata.set_evidence("known_limitations", EvidenceType.DRAFT_DERIVED) + + register_placeholder(kcal_metadata) + + # ── protein_avg ─────────────────────────────────────────────────────────── + + protein_metadata = PlaceholderMetadata( + key="protein_avg", + description="Durchschn. Protein in g (30d)", + semantic_contract=( + "Liefert den Durchschnitt der dokumentierten täglichen Proteinzufuhr " + "über das definierte Auswertungsfenster." + ), + business_meaning=( + "Zentraler Placeholder für Muskelerhalt, Muskelaufbau, Recomposition " + "und Absicherung im Defizit" + ), + unit="g/day", + format_hint="Ganzzahl in g/day", + example_output="156", + known_limitations=( + "absoluter Wert allein reicht nicht immer; sollte oft relativ zum " + "Körpergewicht interpretiert werden" + ), + layer_2b_reuse_possible=None, + issue_53_alignment="Layer separation established", + minimum_data_requirements=None, + quality_filter_policy=None, + **common_metadata + ) + + protein_metadata.evidence.update(common_evidence) + protein_metadata.set_evidence("semantic_contract", EvidenceType.DRAFT_DERIVED) + protein_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED) + protein_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED) # from resolver: " g" suffix + protein_metadata.set_evidence("format_hint", EvidenceType.CODE_DERIVED) + protein_metadata.set_evidence("example_output", EvidenceType.CODE_DERIVED) + protein_metadata.set_evidence("known_limitations", EvidenceType.DRAFT_DERIVED) + + register_placeholder(protein_metadata) + + # ── carb_avg ────────────────────────────────────────────────────────────── + + carb_metadata = PlaceholderMetadata( + key="carb_avg", + description="Durchschn. Kohlenhydrate in g (30d)", + semantic_contract=( + "Liefert den Durchschnitt der dokumentierten täglichen Kohlenhydratzufuhr " + "über das definierte Auswertungsfenster." + ), + business_meaning="Relevanter Makroindikator für Leistungs-, Energie- und Belastungskontext", + unit="g/day", + format_hint="Ganzzahl in g/day", + example_output="210", + known_limitations=( + "allein selten aussagekräftig; meist im Kontext von Ziel, Energie und " + "Belastung relevant" + ), + layer_2b_reuse_possible=None, + issue_53_alignment="Layer separation established", + minimum_data_requirements=None, + quality_filter_policy=None, + **common_metadata + ) + + carb_metadata.evidence.update(common_evidence) + carb_metadata.set_evidence("semantic_contract", EvidenceType.DRAFT_DERIVED) + carb_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED) + carb_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED) + carb_metadata.set_evidence("format_hint", EvidenceType.CODE_DERIVED) + carb_metadata.set_evidence("example_output", EvidenceType.CODE_DERIVED) + carb_metadata.set_evidence("known_limitations", EvidenceType.DRAFT_DERIVED) + + register_placeholder(carb_metadata) + + # ── fat_avg ─────────────────────────────────────────────────────────────── + + fat_metadata = PlaceholderMetadata( + key="fat_avg", + description="Durchschn. Fett in g (30d)", + semantic_contract=( + "Liefert den Durchschnitt der dokumentierten täglichen Fettzufuhr " + "über das definierte Auswertungsfenster." + ), + business_meaning="Relevanter Makroindikator für Ernährungsstruktur und Zielpassung", + unit="g/day", + format_hint="Ganzzahl in g/day", + example_output="72", + known_limitations="meist im Gesamtkontext der Makroverteilung relevant", + layer_2b_reuse_possible=None, + issue_53_alignment="Layer separation established", + minimum_data_requirements=None, + quality_filter_policy=None, + **common_metadata + ) + + fat_metadata.evidence.update(common_evidence) + fat_metadata.set_evidence("semantic_contract", EvidenceType.DRAFT_DERIVED) + fat_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED) + fat_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED) + fat_metadata.set_evidence("format_hint", EvidenceType.CODE_DERIVED) + fat_metadata.set_evidence("example_output", EvidenceType.CODE_DERIVED) + fat_metadata.set_evidence("known_limitations", EvidenceType.DRAFT_DERIVED) + + register_placeholder(fat_metadata) + + +# Auto-register on import +register_nutrition_part_a() diff --git a/backend/placeholder_registry.py b/backend/placeholder_registry.py new file mode 100644 index 0000000..749071a --- /dev/null +++ b/backend/placeholder_registry.py @@ -0,0 +1,281 @@ +""" +Placeholder/Metric Registry Framework + +Central registry for all placeholders/metrics ensuring consistent metadata across: +- Backend prompt resolution (Layer 2a) +- GUI selection lists +- Extended export +- Validation +- Chart assignment (Layer 2b) + +Version: 1.0 (Part A - Nutrition Basis Metrics) +""" + +from dataclasses import dataclass, field, asdict +from typing import Callable, Dict, List, Optional, Any +from enum import Enum + + +class EvidenceType(str, Enum): + """Evidence type for metadata fields.""" + CODE_DERIVED = "code-derived" + DRAFT_DERIVED = "draft-derived" + MIXED = "mixed" + UNRESOLVED = "unresolved" + TO_VERIFY = "to_verify" + + +class OutputType(str, Enum): + """Placeholder output types.""" + NUMERIC = "numeric" + STRING = "string" + BOOLEAN = "boolean" + JSON = "json" + LIST = "list" + TEXT_SUMMARY = "text_summary" + + +class PlaceholderType(str, Enum): + """Placeholder semantic types.""" + ATOMIC = "atomic" + RAW_DATA = "raw_data" + INTERPRETED = "interpreted" + SCORE = "score" + META = "meta" + + +@dataclass +class MissingValuePolicy: + """Structured missing value handling.""" + available: bool + value_raw: Optional[Any] + missing_reason: str # no_data, insufficient_data, resolver_error, calculation_error, not_applicable + legacy_display: str + + +@dataclass +class PlaceholderMetadata: + """ + Complete metadata for a placeholder/metric. + + All fields track their evidence type to maintain transparency + about what is code-derived vs. draft-derived. + """ + # Core identification + key: str + category: str + description: str + + # Technical (typically code-derived) + resolver_module: str + resolver_function: str + data_layer_module: Optional[str] = None + data_layer_function: Optional[str] = None + source_tables: List[str] = field(default_factory=list) + + # Semantic (typically draft-derived or mixed) + semantic_contract: str = "" + business_meaning: str = "" + unit: str = "" + time_window: str = "" + output_type: OutputType = OutputType.STRING + placeholder_type: PlaceholderType = PlaceholderType.INTERPRETED + format_hint: str = "" + example_output: str = "" + + # Quality (mixed sources) + minimum_data_requirements: Optional[str] = None + quality_filter_policy: Optional[str] = None + confidence_logic: Optional[str] = None + missing_value_policy: Optional[MissingValuePolicy] = None + known_limitations: Optional[str] = None + + # Architecture (code-derived) + layer_1_decision: Optional[str] = None + layer_2a_decision: Optional[str] = None + layer_2b_reuse_possible: Optional[bool] = None + architecture_alignment: Optional[str] = None + issue_53_alignment: Optional[str] = None + + # Evidence tracking + evidence: Dict[str, EvidenceType] = field(default_factory=dict) + + # Runtime resolver (not serialized to export) + _resolver_func: Optional[Callable] = field(default=None, repr=False, compare=False) + + def to_dict(self, include_resolver: bool = False) -> Dict: + """Convert to dictionary for export.""" + data = asdict(self) + + # Remove private fields + if not include_resolver: + data.pop('_resolver_func', None) + + # Convert enums to strings + data['output_type'] = self.output_type.value + data['placeholder_type'] = self.placeholder_type.value + + # Convert evidence dict + data['evidence'] = {k: v.value for k, v in self.evidence.items()} + + # Convert missing_value_policy + if self.missing_value_policy: + data['missing_value_policy'] = asdict(self.missing_value_policy) + + return data + + def get_evidence(self, field_name: str) -> Optional[EvidenceType]: + """Get evidence type for a field.""" + return self.evidence.get(field_name) + + def set_evidence(self, field_name: str, evidence_type: EvidenceType): + """Set evidence type for a field.""" + self.evidence[field_name] = evidence_type + + def validate(self) -> List[str]: + """Validate metadata completeness.""" + issues = [] + + if not self.key: + issues.append("Missing key") + if not self.category: + issues.append("Missing category") + if not self.description: + issues.append("Missing description") + if not self.resolver_module: + issues.append("Missing resolver_module") + if not self.resolver_function: + issues.append("Missing resolver_function") + if not self.semantic_contract: + issues.append("Missing semantic_contract") + if not self.unit: + issues.append("Missing unit") + if not self.time_window: + issues.append("Missing time_window") + + return issues + + +class PlaceholderRegistry: + """ + Central registry for all placeholders/metrics. + + Ensures single source of truth for metadata across all consumers. + """ + + def __init__(self): + self._registry: Dict[str, PlaceholderMetadata] = {} + + def register( + self, + metadata: PlaceholderMetadata, + resolver_func: Optional[Callable] = None + ): + """ + Register a placeholder with complete metadata. + + Args: + metadata: Complete placeholder metadata + resolver_func: Optional resolver function (for runtime resolution) + """ + if metadata.key in self._registry: + raise ValueError(f"Placeholder {metadata.key} already registered") + + if resolver_func: + metadata._resolver_func = resolver_func + + self._registry[metadata.key] = metadata + + def get(self, key: str) -> Optional[PlaceholderMetadata]: + """Get metadata for a placeholder.""" + return self._registry.get(key) + + def get_all(self) -> Dict[str, PlaceholderMetadata]: + """Get all registered placeholders.""" + return self._registry.copy() + + def get_by_category(self, category: str) -> List[PlaceholderMetadata]: + """Get placeholders by category (for GUI selection lists).""" + return [ + m for m in self._registry.values() + if m.category == category + ] + + def get_all_for_export(self) -> List[Dict]: + """Get all metadata for extended export.""" + return [m.to_dict() for m in self._registry.values()] + + def get_by_evidence_type(self, evidence_type: EvidenceType) -> Dict[str, List[str]]: + """ + Get fields by evidence type (for quality assurance). + + Returns: + Dict mapping placeholder_key to list of field_names with that evidence type + """ + result = {} + for key, metadata in self._registry.items(): + fields = [ + field_name + for field_name, ev_type in metadata.evidence.items() + if ev_type == evidence_type + ] + if fields: + result[key] = fields + return result + + def validate_all(self) -> Dict[str, List[str]]: + """ + Validate all registered placeholders. + + Returns: + Dict mapping placeholder_key to list of validation issues + """ + issues = {} + for key, metadata in self._registry.items(): + validation_issues = metadata.validate() + if validation_issues: + issues[key] = validation_issues + return issues + + def resolve(self, key: str, profile_id: str) -> str: + """ + Resolve a placeholder value for a profile. + + Args: + key: Placeholder key + profile_id: User profile ID + + Returns: + Resolved value as string + """ + metadata = self.get(key) + if not metadata: + raise ValueError(f"Placeholder {key} not registered") + + if not metadata._resolver_func: + raise ValueError(f"Placeholder {key} has no resolver function") + + return metadata._resolver_func(profile_id) + + +# Global registry instance +_global_registry = PlaceholderRegistry() + + +def get_registry() -> PlaceholderRegistry: + """Get the global placeholder registry.""" + return _global_registry + + +def register_placeholder( + metadata: PlaceholderMetadata, + resolver_func: Optional[Callable] = None +): + """ + Register a placeholder in the global registry. + + Args: + metadata: Complete placeholder metadata + resolver_func: Optional resolver function + """ + _global_registry.register(metadata, resolver_func) diff --git a/backend/placeholder_registry_export.py b/backend/placeholder_registry_export.py new file mode 100644 index 0000000..e02990a --- /dev/null +++ b/backend/placeholder_registry_export.py @@ -0,0 +1,136 @@ +""" +Placeholder Registry Export Integration + +Integrates the new placeholder registry with the existing export system. +Provides backward-compatible export with enhanced metadata from registry. +""" + +from typing import Dict, List +from placeholder_registry import get_registry, EvidenceType + + +def get_registry_metadata_for_export(profile_id: str) -> Dict: + """ + Get metadata from registry formatted for export. + + Returns: + Dict with: + - flat: List of all metadata dicts + - by_category: Dict mapping category to list of metadata + - evidence_report: Statistics about evidence types + - validation_report: Validation issues + """ + registry = get_registry() + + # Get all metadata + all_metadata = registry.get_all() + + # Build flat export + flat = [] + for key, metadata in sorted(all_metadata.items()): + meta_dict = metadata.to_dict() + flat.append(meta_dict) + + # Build by_category + by_category = {} + for metadata in all_metadata.values(): + cat = metadata.category + if cat not in by_category: + by_category[cat] = [] + by_category[cat].append(metadata.to_dict()) + + # Evidence report + evidence_stats = { + "code_derived": len(registry.get_by_evidence_type(EvidenceType.CODE_DERIVED)), + "draft_derived": len(registry.get_by_evidence_type(EvidenceType.DRAFT_DERIVED)), + "mixed": len(registry.get_by_evidence_type(EvidenceType.MIXED)), + "unresolved": len(registry.get_by_evidence_type(EvidenceType.UNRESOLVED)), + "to_verify": len(registry.get_by_evidence_type(EvidenceType.TO_VERIFY)) + } + + evidence_detail = { + etype.value: registry.get_by_evidence_type(etype) + for etype in EvidenceType + } + + # Validation report + validation_issues = registry.validate_all() + + return { + "flat": flat, + "by_category": by_category, + "evidence_report": { + "statistics": evidence_stats, + "detail": evidence_detail + }, + "validation_report": validation_issues + } + + +def merge_registry_with_legacy_export( + registry_data: Dict, + legacy_data: Dict, + resolved_values: Dict[str, str] +) -> Dict: + """ + Merge registry metadata with legacy export data. + + Args: + registry_data: Data from get_registry_metadata_for_export() + legacy_data: Existing legacy export structure + resolved_values: Resolved placeholder values (key -> value) + + Returns: + Merged export with registry enhancements + """ + # Start with legacy structure + merged = legacy_data.copy() + + # Add registry metadata section + merged["registry_metadata"] = { + "flat": registry_data["flat"], + "by_category": registry_data["by_category"], + "evidence_report": registry_data["evidence_report"], + "validation_report": registry_data["validation_report"] + } + + # Populate runtime values in registry metadata + for meta_dict in merged["registry_metadata"]["flat"]: + key = meta_dict["key"] + if key in resolved_values: + meta_dict["value_display"] = resolved_values[key] + # Note: value_raw extraction can be added here if needed + + return merged + + +def get_enhanced_export_with_registry(profile_id: str, legacy_export: Dict) -> Dict: + """ + Enhance legacy export with registry metadata. + + Args: + profile_id: User profile ID + legacy_export: Existing legacy export structure + + Returns: + Enhanced export with registry metadata section + """ + # Get registry data + registry_data = get_registry_metadata_for_export(profile_id) + + # Get resolved values (for value_display population) + from placeholder_resolver import get_placeholder_example_values + resolved_values = get_placeholder_example_values(profile_id) + cleaned_values = { + key.replace('{{', '').replace('}}', ''): value + for key, value in resolved_values.items() + } + + # Merge + enhanced = merge_registry_with_legacy_export( + registry_data, + legacy_export, + cleaned_values + ) + + return enhanced diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 975917b..a53856c 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -466,6 +466,24 @@ def export_placeholder_values_extended( } } + # ── PART A: Registry Integration ───────────────────────────────────────── + # Add registry metadata for Part A placeholders (kcal_avg, protein_avg, carb_avg, fat_avg) + try: + import placeholder_registrations # Auto-registers Part A placeholders + from placeholder_registry_export import get_registry_metadata_for_export + + registry_data = get_registry_metadata_for_export(profile_id) + export_data['registry_metadata'] = registry_data + except Exception as e: + # Graceful degradation if registry not available + export_data['registry_metadata'] = { + "error": f"Registry not available: {str(e)}", + "flat": [], + "by_category": {}, + "evidence_report": {}, + "validation_report": {} + } + # Fill validation for key, violations in validation_results.items(): errors = [v for v in violations if v.severity == "error"]