diff --git a/backend/catalog_prompt_slots.py b/backend/catalog_prompt_slots.py index 5a25d36..de7e993 100644 --- a/backend/catalog_prompt_slots.py +++ b/backend/catalog_prompt_slots.py @@ -15,6 +15,7 @@ from planning_catalog_context import ( PlanningCatalogContextItem, catalog_context_has_items, ) +from catalog_slot_fallbacks import merge_stored_slots_with_fallbacks # --------------------------------------------------------------------------- # Dimensionen (Prioritätsreihenfolge) @@ -196,6 +197,20 @@ def _fallback_slot_type_rows() -> List[Dict[str, Any]]: return rows +def _resolve_entry_slot_values( + stored: Mapping[str, str], + row: Mapping[str, Any], + catalog_kind: str, +) -> Dict[str, str]: + """DB → Namens-Fallback → Stammdaten-Beschreibung (nur description).""" + return merge_stored_slots_with_fallbacks( + stored, + catalog_kind=catalog_kind, + name=str(row.get("name") or ""), + stammdaten_description=str(row.get("description") or ""), + ) + + def get_catalog_entry_slots(cur, catalog_kind: str, catalog_id: int) -> Dict[str, Any]: cfg = _KIND_BY_NAME.get((catalog_kind or "").strip()) if not cfg: @@ -204,17 +219,13 @@ def get_catalog_entry_slots(cur, catalog_kind: str, catalog_id: int) -> Dict[str if not row: raise LookupError("Katalog-Eintrag nicht gefunden") stored = _load_slots_for_entry(cur, cfg.kind, catalog_id) - merged: Dict[str, str] = {} - for slot in SLOT_KEYS: - if slot == "description": - merged[slot] = stored.get("description") or row.get("description") or "" - else: - merged[slot] = stored.get(slot, "") + merged = _resolve_entry_slot_values(stored, row, cfg.kind) return { "catalog_kind": cfg.kind, "catalog_id": int(catalog_id), "name": row["name"], "slots": merged, + "stored_slots": {k: stored.get(k, "") for k in SLOT_KEYS}, } @@ -330,15 +341,12 @@ def resolve_catalog_prompt_variables( if not row: continue stored = _load_slots_for_entry(cur, cfg.kind, row["id"]) if _slot_types_table_ready(cur) else {} - slot_values: Dict[str, str] = {} + slot_values = _resolve_entry_slot_values(stored, row, cfg.kind) for sk in SLOT_KEYS: - if sk == "description": - slot_values[sk] = stored.get("description") or row.get("description") or "" - else: - slot_values[sk] = stored.get(sk, "") pk = placeholder_key(cfg.kind, sk) - variables[pk] = slot_values[sk] - if slot_values[sk].strip() and sk in LLM_SLOT_KEYS: + text = slot_values.get(sk, "") + variables[pk] = text + if text.strip() and sk in LLM_SLOT_KEYS: has_any = True active_slots.append(pk) @@ -349,6 +357,7 @@ def resolve_catalog_prompt_variables( "is_primary": bool(active.is_primary), "weight": float(active.weight), "filled_slots": [k for k in LLM_SLOT_KEYS if slot_values.get(k, "").strip()], + "stored_slots": [k for k in SLOT_KEYS if (stored.get(k) or "").strip()], } section = _render_dimension_section(cfg.label_de, row["name"], slot_values, slot_keys=profile) @@ -379,7 +388,11 @@ def get_rematch_guard_for_catalog( if not active: continue stored = _load_slots_for_entry(cur, cfg.kind, active.id) - guard = (stored.get("rematch_guard") or "").strip() + row = _load_catalog_row(cur, cfg.table, active.id) + if not row: + continue + slot_values = _resolve_entry_slot_values(stored, row, cfg.kind) + guard = (slot_values.get("rematch_guard") or "").strip() if guard: return guard return None diff --git a/backend/catalog_slot_fallbacks.py b/backend/catalog_slot_fallbacks.py new file mode 100644 index 0000000..ecc9323 --- /dev/null +++ b/backend/catalog_slot_fallbacks.py @@ -0,0 +1,284 @@ +""" +Namensbasierte Fallback-Slots — bis Admin/DB befüllt sind (H1-Registry-Inhalt). + +DB-Werte in catalog_prompt_slots haben immer Vorrang. Fallbacks füllen nur leere Slot-Keys. +""" +from __future__ import annotations + +import re +import unicodedata +from typing import Dict, Mapping, Optional, Sequence, Tuple + +_UMLAUT_MAP = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss", "Ä": "ae", "Ö": "oe", "Ü": "ue"}) + +SlotPack = Dict[str, str] + +# (catalog_kind, name_pattern_lower) — erste passende Regel gewinnt; * = Default pro Kind +_FALLBACK_RULES: Tuple[Tuple[str, str, SlotPack], ...] = ( + # --- focus_area --- + ( + "focus_area", + "gewaltschutz", + { + "description": ( + "Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — " + "nicht auf Wettkampf-Perfektion oder Technik-Show." + ), + "hints_on_progression": ( + "Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; " + "keine Kumite-Perfektionsstufen erzwingen." + ), + "hints_on_exercise": ( + "Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug." + ), + "hints_on_path_qa": ( + "Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; " + "„Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten." + ), + "anti_patterns": "Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.", + }, + ), + ( + "focus_area", + "selbstverteidigung", + { + "description": ( + "Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und " + "anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata." + ), + "hints_on_progression": ( + "Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung." + ), + "hints_on_exercise": "Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.", + "hints_on_path_qa": ( + "Lücken bei Szenario- oder Sicherheitsstufen sind relevant; " + "fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel." + ), + "anti_patterns": "Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.", + }, + ), + ( + "focus_area", + "fitness", + { + "description": ( + "Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; " + "Technikbezug nur wo fachlich sinnvoll." + ), + "hints_on_progression": "Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.", + "hints_on_path_qa": ( + "Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; " + "Belastungssteigerung ohne Technikbezug abwerten." + ), + "anti_patterns": "Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.", + }, + ), + ( + "focus_area", + "karate", + { + "description": ( + "Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression " + "mit klaren Qualitätsankern (Stand, Hüfte, Kime)." + ), + "hints_on_progression": ( + "Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; " + "Grundlagen vor Perfektion." + ), + "hints_on_exercise": ( + "Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung." + ), + "hints_on_path_qa": ( + "Kohärente Progression Grundlagen → Anwendung → Vertiefung; " + "Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten." + ), + "anti_patterns": "Keine pauschale Perfektions-Stufe verlangen, wenn Kontext Breitensport ist.", + }, + ), + ( + "focus_area", + "*", + { + "description": "Technik- oder Themen-Curriculum mit didaktisch aufeinander aufbauenden Stufen.", + "hints_on_progression": "Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.", + "hints_on_path_qa": ( + "Kohärente Progression zum Anfrage-Thema; " + "Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen." + ), + "hints_on_exercise": "Übungen mit klarem Bezug zum Pfad-Thema und zur Stufe.", + }, + ), + # --- training_type --- + ( + "training_type", + "breitensport", + { + "description": ( + "Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung." + ), + "hints_on_progression": "Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.", + "hints_on_path_qa": ( + "Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; " + "„Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke." + ), + "rematch_guard": "Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.", + }, + ), + ( + "training_type", + "leistungssport", + { + "description": "Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.", + "hints_on_progression": "Belastungs- und Kombinationsprogressionen sind erwünscht.", + "hints_on_path_qa": ( + "Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein." + ), + }, + ), + ( + "training_type", + "wettkampf", + { + "description": ( + "Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen." + ), + "hints_on_progression": "Anwendungs- und Druckphasen zeitig einplanen.", + "hints_on_path_qa": ( + "Spezialisierung, Kombination und Belastung unter Druck sind relevant; " + "Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein." + ), + }, + ), + ( + "training_type", + "*", + { + "hints_on_path_qa": "Didaktische Kohärenz wichtiger als maximale Spezialisierung — Kontext beachten.", + }, + ), + # --- target_group --- + ( + "target_group", + "kinder", + { + "description": ( + "Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität." + ), + "hints_on_progression": "Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.", + "hints_on_path_qa": ( + "Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; " + "Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe." + ), + "anti_patterns": "Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.", + }, + ), + ( + "target_group", + "leistungssportler", + { + "description": "Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.", + "hints_on_progression": "Anspruchskurve und Spezialisierung dürfen steiler sein.", + "hints_on_path_qa": ( + "Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; " + "Lücken in Spezialisierung können echte Hinweise sein." + ), + }, + ), + ( + "target_group", + "breitensportler", + { + "description": "Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.", + "hints_on_path_qa": ( + "Moderate Progression; Perfektions-Lücken sind selten echte Mängel." + ), + "anti_patterns": "Keine Leistungssport-Perfektion als Pflicht-Kriterium.", + }, + ), + ( + "target_group", + "*", + { + "hints_on_path_qa": "Zielgruppe im Tempo und in der Komplexität berücksichtigen.", + }, + ), + # --- style_direction --- + ( + "style_direction", + "shotokan", + { + "description": ( + "Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker." + ), + "hints_on_progression": "Nuancen in Stellung und Hüfttechnik; kein neuer Planungstyp.", + "hints_on_path_qa": "Konsistenz von Stand, Hüfte und Kime entlang des Pfads bewerten.", + }, + ), + ( + "style_direction", + "*", + { + "hints_on_progression": ( + "Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen." + ), + }, + ), +) + + +def normalize_catalog_name_key(name: str) -> str: + s = unicodedata.normalize("NFKD", (name or "").translate(_UMLAUT_MAP)) + s = s.encode("ascii", "ignore").decode("ascii").lower() + s = re.sub(r"[^a-z0-9]+", "_", s).strip("_") + return s or "unknown" + + +def get_fallback_slots_for_entry(catalog_kind: str, name: str) -> SlotPack: + kind = (catalog_kind or "").strip().lower() + norm = normalize_catalog_name_key(name) + default: SlotPack = {} + for rule_kind, pattern, pack in _FALLBACK_RULES: + if rule_kind != kind: + continue + if pattern == "*": + default = dict(pack) + continue + if pattern in norm or norm.startswith(pattern) or pattern in (name or "").lower(): + return dict(pack) + return default + + +def merge_stored_slots_with_fallbacks( + stored: Mapping[str, str], + *, + catalog_kind: str, + name: str, + stammdaten_description: str = "", +) -> Dict[str, str]: + """DB + Stammdaten-Beschreibung + Namens-Fallback.""" + fallbacks = get_fallback_slots_for_entry(catalog_kind, name) + out: Dict[str, str] = {} + for key in ( + "description", + "hints_on_progression", + "hints_on_exercise", + "hints_on_path_qa", + "anti_patterns", + "rematch_guard", + ): + if key == "description": + out[key] = ( + (stored.get(key) or "").strip() + or (fallbacks.get(key) or "").strip() + or (stammdaten_description or "").strip() + ) + else: + out[key] = (stored.get(key) or "").strip() or (fallbacks.get(key) or "").strip() + return out + + +__all__ = [ + "get_fallback_slots_for_entry", + "merge_stored_slots_with_fallbacks", + "normalize_catalog_name_key", +] diff --git a/backend/migrations/094_catalog_prompt_slots_full_seed.sql b/backend/migrations/094_catalog_prompt_slots_full_seed.sql new file mode 100644 index 0000000..ad675cd --- /dev/null +++ b/backend/migrations/094_catalog_prompt_slots_full_seed.sql @@ -0,0 +1,167 @@ +-- Migration 094: Vollständige Befüllung catalog_prompt_slots (H1-Inhalte + Defaults für alle Stammdaten) + +CREATE TEMP TABLE IF NOT EXISTS _catalog_slot_seed ( + catalog_kind VARCHAR(32) NOT NULL, + name_pattern TEXT NOT NULL, + slot_key VARCHAR(64) NOT NULL, + content TEXT NOT NULL +); + +TRUNCATE _catalog_slot_seed; + +-- Primärfokus Karate (häufigster Technik-Pfad) +INSERT INTO _catalog_slot_seed (catalog_kind, name_pattern, slot_key, content) VALUES +('focus_area', 'Karate', 'description', + 'Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression mit klaren Qualitätsankern (Stand, Hüfte, Kime).'), +('focus_area', 'Karate', 'hints_on_progression', + 'Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; Grundlagen vor Perfektion.'), +('focus_area', 'Karate', 'hints_on_exercise', + 'Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung.'), +('focus_area', 'Karate', 'hints_on_path_qa', + 'Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.'), +('focus_area', 'Karate', 'anti_patterns', + 'Keine pauschale Perfektions-Stufe verlangen, wenn der Trainingsstil Breitensport ist.'); + +-- Selbstverteidigung +INSERT INTO _catalog_slot_seed VALUES +('focus_area', 'Selbstverteidigung', 'description', + 'Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata.'), +('focus_area', 'Selbstverteidigung', 'hints_on_progression', + 'Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung.'), +('focus_area', 'Selbstverteidigung', 'hints_on_exercise', + 'Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.'), +('focus_area', 'Selbstverteidigung', 'hints_on_path_qa', + 'Lücken bei Szenario- oder Sicherheitsstufen sind relevant; fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel.'), +('focus_area', 'Selbstverteidigung', 'anti_patterns', + 'Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.'); + +-- Gewaltschutz (ergänzt 092) +INSERT INTO _catalog_slot_seed VALUES +('focus_area', 'Gewaltschutz', 'hints_on_progression', + 'Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; keine Kumite-Perfektionsstufen erzwingen.'), +('focus_area', 'Gewaltschutz', 'hints_on_exercise', + 'Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug.'); + +-- Fitness (falls vorhanden) +INSERT INTO _catalog_slot_seed VALUES +('focus_area', 'Fitness', 'description', + 'Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; Technikbezug nur wo fachlich sinnvoll.'), +('focus_area', 'Fitness', 'hints_on_progression', + 'Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.'), +('focus_area', 'Fitness', 'hints_on_path_qa', + 'Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; Belastungssteigerung ohne Technikbezug abwerten.'), +('focus_area', 'Fitness', 'anti_patterns', + 'Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.'); + +-- Trainingsstile (global) +INSERT INTO _catalog_slot_seed VALUES +('training_type', 'Breitensport', 'hints_on_progression', + 'Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.'), +('training_type', 'Breitensport', 'anti_patterns', + 'Keine Leistungssport-Perfektion als Pflicht-Lücke.'), +('training_type', 'Leistungssport', 'description', + 'Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.'), +('training_type', 'Leistungssport', 'hints_on_progression', + 'Belastungs- und Kombinationsprogressionen sind erwünscht.'), +('training_type', 'Leistungssport', 'hints_on_path_qa', + 'Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein.'), +('training_type', 'Wettkampf', 'hints_on_progression', + 'Anwendungs- und Druckphasen zeitig einplanen.'); + +-- Zielgruppen +INSERT INTO _catalog_slot_seed VALUES +('target_group', 'Breitensportler', 'description', + 'Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.'), +('target_group', 'Breitensportler', 'hints_on_path_qa', + 'Moderate Progression; Perfektions-Lücken sind selten echte Mängel.'), +('target_group', 'Breitensportler', 'anti_patterns', + 'Keine Leistungssport-Perfektion als Pflicht-Kriterium.'), +('target_group', 'Kinder', 'hints_on_progression', + 'Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.'), +('target_group', 'Leistungssportler', 'hints_on_progression', + 'Anspruchskurve und Spezialisierung dürfen steiler sein.'); + +-- Stilrichtungen (generisch + Shotokan-Details via 092) +INSERT INTO _catalog_slot_seed VALUES +('style_direction', 'Goju-Ryu', 'hints_on_progression', + 'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'), +('style_direction', 'Wado-Ryu', 'hints_on_progression', + 'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'), +('style_direction', 'Shito-Ryu', 'hints_on_progression', + 'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'), +('style_direction', 'Kyokushin', 'hints_on_progression', + 'Stil-Nuancen (Stand, Belastung, Kime) einbeziehen — kein Stilwechsel erzwingen.'); + +-- Fokusbereiche: aus Seed-Tabelle +INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content) +SELECT s.catalog_kind, fa.id, s.slot_key, s.content +FROM _catalog_slot_seed s +JOIN focus_areas fa ON fa.name ILIKE s.name_pattern +WHERE s.catalog_kind = 'focus_area' +ON CONFLICT (catalog_kind, catalog_id, slot_key) +DO UPDATE SET content = EXCLUDED.content, updated_at = NOW(); + +INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content) +SELECT s.catalog_kind, tt.id, s.slot_key, s.content +FROM _catalog_slot_seed s +JOIN training_types tt ON tt.name ILIKE s.name_pattern +WHERE s.catalog_kind = 'training_type' +ON CONFLICT (catalog_kind, catalog_id, slot_key) +DO UPDATE SET content = EXCLUDED.content, updated_at = NOW(); + +INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content) +SELECT s.catalog_kind, tg.id, s.slot_key, s.content +FROM _catalog_slot_seed s +JOIN target_groups tg ON tg.name ILIKE s.name_pattern +WHERE s.catalog_kind = 'target_group' +ON CONFLICT (catalog_kind, catalog_id, slot_key) +DO UPDATE SET content = EXCLUDED.content, updated_at = NOW(); + +INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content) +SELECT s.catalog_kind, sd.id, s.slot_key, s.content +FROM _catalog_slot_seed s +JOIN style_directions sd ON sd.name ILIKE s.name_pattern +WHERE s.catalog_kind = 'style_direction' +ON CONFLICT (catalog_kind, catalog_id, slot_key) +DO UPDATE SET content = EXCLUDED.content, updated_at = NOW(); + +-- Default-Technik-Pack für Fokusbereiche ohne hints_on_path_qa (außer Gewaltschutz/Fitness) +INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content) +SELECT 'focus_area', fa.id, 'hints_on_path_qa', + 'Kohärente Progression zum Anfrage-Thema; Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen.' +FROM focus_areas fa +WHERE fa.name NOT ILIKE 'Gewaltschutz' + AND fa.name NOT ILIKE 'Fitness' + AND NOT EXISTS ( + SELECT 1 FROM catalog_prompt_slots cps + WHERE cps.catalog_kind = 'focus_area' AND cps.catalog_id = fa.id AND cps.slot_key = 'hints_on_path_qa' + AND TRIM(cps.content) <> '' + ) +ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING; + +INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content) +SELECT 'focus_area', fa.id, 'hints_on_progression', + 'Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.' +FROM focus_areas fa +WHERE fa.name NOT ILIKE 'Gewaltschutz' + AND fa.name NOT ILIKE 'Fitness' + AND NOT EXISTS ( + SELECT 1 FROM catalog_prompt_slots cps + WHERE cps.catalog_kind = 'focus_area' AND cps.catalog_id = fa.id AND cps.slot_key = 'hints_on_progression' + AND TRIM(cps.content) <> '' + ) +ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING; + +-- Stilrichtungen ohne Eintrag: generischer Progressions-Hinweis +INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content) +SELECT 'style_direction', sd.id, 'hints_on_progression', + 'Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen.' +FROM style_directions sd +WHERE NOT EXISTS ( + SELECT 1 FROM catalog_prompt_slots cps + WHERE cps.catalog_kind = 'style_direction' AND cps.catalog_id = sd.id AND cps.slot_key = 'hints_on_progression' + AND TRIM(cps.content) <> '' + ) +ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING; + +DROP TABLE IF EXISTS _catalog_slot_seed; diff --git a/backend/tests/test_catalog_slot_fallbacks.py b/backend/tests/test_catalog_slot_fallbacks.py new file mode 100644 index 0000000..89bb78d --- /dev/null +++ b/backend/tests/test_catalog_slot_fallbacks.py @@ -0,0 +1,38 @@ +"""Tests Namens-Fallback für Katalog-Prompt-Slots.""" +from catalog_slot_fallbacks import get_fallback_slots_for_entry, merge_stored_slots_with_fallbacks +from catalog_prompt_slots import _resolve_entry_slot_values + + +def test_karate_fallback_has_path_qa(): + pack = get_fallback_slots_for_entry("focus_area", "Karate") + assert "Kohärente Progression" in pack.get("hints_on_path_qa", "") + + +def test_db_value_overrides_fallback(): + merged = merge_stored_slots_with_fallbacks( + {"hints_on_path_qa": "Eigener QS-Text."}, + catalog_kind="focus_area", + name="Karate", + stammdaten_description="Traditionelles Karate", + ) + assert merged["hints_on_path_qa"] == "Eigener QS-Text." + + +def test_empty_db_uses_karate_fallback(): + merged = _resolve_entry_slot_values( + {}, + {"name": "Karate", "description": "Traditionelles Karate"}, + "focus_area", + ) + assert "Kihon-Progression" in merged["description"] or "Technik-Curriculum" in merged["description"] + assert "Kohärente Progression" in merged["hints_on_path_qa"] + + +def test_gewaltschutz_fallback_no_kumite(): + merged = _resolve_entry_slot_values( + {}, + {"name": "Gewaltschutz", "description": "Gewaltprävention"}, + "focus_area", + ) + assert "Deeskalation" in merged["hints_on_path_qa"] + assert "Kumite-Tiefe" in merged["anti_patterns"] diff --git a/backend/tests/test_planning_catalog_prompt_snippets.py b/backend/tests/test_planning_catalog_prompt_snippets.py index 1a18bde..ad2f323 100644 --- a/backend/tests/test_planning_catalog_prompt_snippets.py +++ b/backend/tests/test_planning_catalog_prompt_snippets.py @@ -90,13 +90,13 @@ def test_granular_placeholder_focus_area_hints_on_path_qa(): assert resolved["has_catalog_guidance"] == "true" -def test_description_fallback_from_stammdaten(): +def test_unknown_focus_uses_default_description_pack(): cur = _mock_cur( rows_by_table={ "focus_areas": { 4: { - "name": "Gewaltschutz", - "description": "Gewaltprävention und Deeskalation", + "name": "Sonderfokus Alpha", + "description": "Kurze Stammdaten-Beschreibung", } } }, @@ -106,7 +106,9 @@ def test_description_fallback_from_stammdaten(): focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)], ) resolved = resolve_catalog_prompt_variables(cur, catalog) - assert resolved[placeholder_key("focus_area", "description")] == "Gewaltprävention und Deeskalation" + desc = resolved[placeholder_key("focus_area", "description")] + assert "Technik- oder Themen-Curriculum" in desc + assert resolved[placeholder_key("focus_area", "hints_on_path_qa")] def test_empty_without_catalog(): @@ -116,15 +118,15 @@ def test_empty_without_catalog(): assert out["catalog_guidance_block"] == "" -def test_unknown_entry_no_guidance_block(): +def test_unknown_entry_gets_default_technique_fallback(): cur = _mock_cur(rows_by_table={"focus_areas": {99: {"name": "Unbekannter Fokus XYZ"}}}) catalog = ProgressionPlanningCatalogContext( focus_areas=[PlanningCatalogContextItem(id=99, is_primary=True)], ) out = build_catalog_guidance_for_prompt(cur, catalog) - assert out["has_catalog_guidance"] is False - assert out["catalog_guidance_block"] == "" + assert out["has_catalog_guidance"] is True assert "Unbekannter Fokus XYZ" in out["catalog_context_json"] + assert "Zwischenstufen" in out["catalog_guidance_block"] or "Progression" in out["catalog_guidance_block"] def test_merge_planning_prompt_variables_granular_keys(): diff --git a/backend/version.py b/backend/version.py index 355d0d3..b81f759 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.236" +APP_VERSION = "0.8.237" BUILD_DATE = "2026-05-22" -DB_SCHEMA_VERSION = "20260607093" +DB_SCHEMA_VERSION = "20260607094" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -53,6 +53,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.237", + "date": "2026-05-22", + "changes": [ + "Migration 094: catalog_prompt_slots vollständig befüllt (Karate, SV, alle Trainingsstile/Zielgruppen).", + "catalog_slot_fallbacks: Namens-Fallback bis Admin-Override — gleiche Qualität wie H1-Registry.", + ], + }, { "version": "0.8.236", "date": "2026-05-22", diff --git a/frontend/src/components/admin/CatalogPromptSlotsEditor.jsx b/frontend/src/components/admin/CatalogPromptSlotsEditor.jsx index 26c5cf8..fd097b0 100644 --- a/frontend/src/components/admin/CatalogPromptSlotsEditor.jsx +++ b/frontend/src/components/admin/CatalogPromptSlotsEditor.jsx @@ -18,6 +18,7 @@ export default function CatalogPromptSlotsEditor({ catalogKind, catalogId, entry const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [loaded, setLoaded] = useState(false) + const [storedSlots, setStoredSlots] = useState({}) const applicableTypes = useMemo(() => { const kind = (catalogKind || '').trim() @@ -38,6 +39,9 @@ export default function CatalogPromptSlotsEditor({ catalogKind, catalogId, entry ]) setSlotTypes(Array.isArray(typesRes?.slot_types) ? typesRes.slot_types : []) setSlots(slotsRes?.slots && typeof slotsRes.slots === 'object' ? { ...slotsRes.slots } : {}) + setStoredSlots( + slotsRes?.stored_slots && typeof slotsRes.stored_slots === 'object' ? { ...slotsRes.stored_slots } : {} + ) setLoaded(true) } catch (e) { setError(e.message || String(e)) @@ -109,10 +113,17 @@ export default function CatalogPromptSlotsEditor({ catalogKind, catalogId, entry const key = st.slot_key const ph = `{{${catalogKind}_${key}}}` const isCodeOnly = st.for_code && !st.for_llm + const fromFallback = + !(storedSlots[key] || '').trim() && (slots[key] || '').trim() && key !== 'description' return (