Refactor Catalog Prompt Slot Management and Enhance Fallback Logic
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s

- Introduced a new function `_resolve_entry_slot_values` to streamline the merging of stored slot values with fallbacks, improving code clarity and maintainability.
- Updated `get_catalog_entry_slots` and `resolve_catalog_prompt_variables` functions to utilize the new fallback logic, enhancing the handling of catalog entries.
- Enhanced the `CatalogPromptSlotsEditor` component to display fallback information for slots, improving user experience in managing catalog prompts.
- Incremented version numbers and updated changelog to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-15 15:18:00 +02:00
parent 53f2b027cc
commit 7e5ef4561a
7 changed files with 546 additions and 23 deletions

View File

@ -15,6 +15,7 @@ from planning_catalog_context import (
PlanningCatalogContextItem, PlanningCatalogContextItem,
catalog_context_has_items, catalog_context_has_items,
) )
from catalog_slot_fallbacks import merge_stored_slots_with_fallbacks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dimensionen (Prioritätsreihenfolge) # Dimensionen (Prioritätsreihenfolge)
@ -196,6 +197,20 @@ def _fallback_slot_type_rows() -> List[Dict[str, Any]]:
return rows 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]: def get_catalog_entry_slots(cur, catalog_kind: str, catalog_id: int) -> Dict[str, Any]:
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip()) cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
if not cfg: if not cfg:
@ -204,17 +219,13 @@ def get_catalog_entry_slots(cur, catalog_kind: str, catalog_id: int) -> Dict[str
if not row: if not row:
raise LookupError("Katalog-Eintrag nicht gefunden") raise LookupError("Katalog-Eintrag nicht gefunden")
stored = _load_slots_for_entry(cur, cfg.kind, catalog_id) stored = _load_slots_for_entry(cur, cfg.kind, catalog_id)
merged: Dict[str, str] = {} merged = _resolve_entry_slot_values(stored, row, cfg.kind)
for slot in SLOT_KEYS:
if slot == "description":
merged[slot] = stored.get("description") or row.get("description") or ""
else:
merged[slot] = stored.get(slot, "")
return { return {
"catalog_kind": cfg.kind, "catalog_kind": cfg.kind,
"catalog_id": int(catalog_id), "catalog_id": int(catalog_id),
"name": row["name"], "name": row["name"],
"slots": merged, "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: if not row:
continue continue
stored = _load_slots_for_entry(cur, cfg.kind, row["id"]) if _slot_types_table_ready(cur) else {} 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: 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) pk = placeholder_key(cfg.kind, sk)
variables[pk] = slot_values[sk] text = slot_values.get(sk, "")
if slot_values[sk].strip() and sk in LLM_SLOT_KEYS: variables[pk] = text
if text.strip() and sk in LLM_SLOT_KEYS:
has_any = True has_any = True
active_slots.append(pk) active_slots.append(pk)
@ -349,6 +357,7 @@ def resolve_catalog_prompt_variables(
"is_primary": bool(active.is_primary), "is_primary": bool(active.is_primary),
"weight": float(active.weight), "weight": float(active.weight),
"filled_slots": [k for k in LLM_SLOT_KEYS if slot_values.get(k, "").strip()], "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) 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: if not active:
continue continue
stored = _load_slots_for_entry(cur, cfg.kind, active.id) 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: if guard:
return guard return guard
return None return None

View File

@ -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",
]

View File

@ -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;

View File

@ -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"]

View File

@ -90,13 +90,13 @@ def test_granular_placeholder_focus_area_hints_on_path_qa():
assert resolved["has_catalog_guidance"] == "true" assert resolved["has_catalog_guidance"] == "true"
def test_description_fallback_from_stammdaten(): def test_unknown_focus_uses_default_description_pack():
cur = _mock_cur( cur = _mock_cur(
rows_by_table={ rows_by_table={
"focus_areas": { "focus_areas": {
4: { 4: {
"name": "Gewaltschutz", "name": "Sonderfokus Alpha",
"description": "Gewaltprävention und Deeskalation", "description": "Kurze Stammdaten-Beschreibung",
} }
} }
}, },
@ -106,7 +106,9 @@ def test_description_fallback_from_stammdaten():
focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)], focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)],
) )
resolved = resolve_catalog_prompt_variables(cur, catalog) 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(): def test_empty_without_catalog():
@ -116,15 +118,15 @@ def test_empty_without_catalog():
assert out["catalog_guidance_block"] == "" 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"}}}) cur = _mock_cur(rows_by_table={"focus_areas": {99: {"name": "Unbekannter Fokus XYZ"}}})
catalog = ProgressionPlanningCatalogContext( catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=99, is_primary=True)], focus_areas=[PlanningCatalogContextItem(id=99, is_primary=True)],
) )
out = build_catalog_guidance_for_prompt(cur, catalog) out = build_catalog_guidance_for_prompt(cur, catalog)
assert out["has_catalog_guidance"] is False assert out["has_catalog_guidance"] is True
assert out["catalog_guidance_block"] == ""
assert "Unbekannter Fokus XYZ" in out["catalog_context_json"] 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(): def test_merge_planning_prompt_variables_granular_keys():

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.236" APP_VERSION = "0.8.237"
BUILD_DATE = "2026-05-22" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607093" DB_SCHEMA_VERSION = "20260607094"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -53,6 +53,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.236",
"date": "2026-05-22", "date": "2026-05-22",

View File

@ -18,6 +18,7 @@ export default function CatalogPromptSlotsEditor({ catalogKind, catalogId, entry
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [storedSlots, setStoredSlots] = useState({})
const applicableTypes = useMemo(() => { const applicableTypes = useMemo(() => {
const kind = (catalogKind || '').trim() const kind = (catalogKind || '').trim()
@ -38,6 +39,9 @@ export default function CatalogPromptSlotsEditor({ catalogKind, catalogId, entry
]) ])
setSlotTypes(Array.isArray(typesRes?.slot_types) ? typesRes.slot_types : []) setSlotTypes(Array.isArray(typesRes?.slot_types) ? typesRes.slot_types : [])
setSlots(slotsRes?.slots && typeof slotsRes.slots === 'object' ? { ...slotsRes.slots } : {}) setSlots(slotsRes?.slots && typeof slotsRes.slots === 'object' ? { ...slotsRes.slots } : {})
setStoredSlots(
slotsRes?.stored_slots && typeof slotsRes.stored_slots === 'object' ? { ...slotsRes.stored_slots } : {}
)
setLoaded(true) setLoaded(true)
} catch (e) { } catch (e) {
setError(e.message || String(e)) setError(e.message || String(e))
@ -109,10 +113,17 @@ export default function CatalogPromptSlotsEditor({ catalogKind, catalogId, entry
const key = st.slot_key const key = st.slot_key
const ph = `{{${catalogKind}_${key}}}` const ph = `{{${catalogKind}_${key}}}`
const isCodeOnly = st.for_code && !st.for_llm const isCodeOnly = st.for_code && !st.for_llm
const fromFallback =
!(storedSlots[key] || '').trim() && (slots[key] || '').trim() && key !== 'description'
return ( return (
<div key={key} className="form-row"> <div key={key} className="form-row">
<label className="form-label"> <label className="form-label">
{st.display_name || key} {st.display_name || key}
{fromFallback ? (
<span style={{ marginLeft: '6px', fontSize: '12px', color: 'var(--accent)' }}>
(Standard-Vorlage)
</span>
) : null}
{isCodeOnly ? ( {isCodeOnly ? (
<span style={{ marginLeft: '6px', fontSize: '12px', color: 'var(--text3)' }}> <span style={{ marginLeft: '6px', fontSize: '12px', color: 'var(--text3)' }}>
(primär Code) (primär Code)