""" Wiederverwendbare Fähigkeiten-Erwartungen für Planungs-KI. Domänen-Scopes (gleiches Input-/Output-Modell): - ``progression_stage`` — ein Major Step / stage_spec im Progressionsgraphen - ``progression_path`` — gesamter Pfad (Ziel + Start/Ziel) - ``training_section`` — Abschnitt einer Trainingseinheit (Phase G, später) - ``framework_slot`` — Rahmen-Session-Slot (Phase G, später) Konsumenten mergen ``skill_weights`` in ``PlanningTargetProfile`` (Retrieval) oder liefern ``expected_skills`` an Übungs-KI (planning_context). """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple from planning_exercise_profiles import _merge_weight_maps, _normalize_weight_map from planning_exercise_semantics import PlanningSemanticBrief, resolve_semantic_skill_weights from planning_exercise_text_signals import _load_skills_for_text_match, _match_skills_in_text # Scope-Strings — stabil für API/UI und spätere Trainingsplanung SCOPE_PROGRESSION_STAGE = "progression_stage" SCOPE_PROGRESSION_PATH = "progression_path" SCOPE_TRAINING_SECTION = "training_section" SCOPE_FRAMEWORK_SLOT = "framework_slot" _LOAD_PROFILE_SKILL_TERMS: Dict[str, Tuple[str, ...]] = { "koordination": ("Koordination",), "präzision": ("Präzision",), "praezision": ("Präzision",), "kraft": ("Kraft", "Kime"), "geschwindigkeit": ("Geschwindigkeit", "Schnelligkeit"), "schnelligkeit": ("Schnelligkeit", "Geschwindigkeit"), "timing": ("Timing", "Reaktion"), "reaktion": ("Reaktion", "Timing"), "distanz": ("Distanz",), "raum": ("Raum", "Distanz"), "gleichgewicht": ("Gleichgewicht",), "kime": ("Kime",), "ausdauer": ("Ausdauer",), "beweglichkeit": ("Beweglichkeit",), } @dataclass class PlanningSkillExpectationInput: scope: str = SCOPE_PROGRESSION_STAGE primary_topic: str = "" goal_query: str = "" start_situation: str = "" target_state: str = "" stage_learning_goal: str = "" roadmap_notes: str = "" load_profile: List[str] = field(default_factory=list) phase: str = "" skill_hints: List[str] = field(default_factory=list) @dataclass class PlanningSkillExpectationItem: skill_id: int skill_name: str weight: float source: str @dataclass class PlanningSkillExpectations: scope: str skill_weights: Dict[int, float] items: List[PlanningSkillExpectationItem] sources: List[str] def to_api_dict(self) -> Dict[str, Any]: return { "scope": self.scope, "sources": list(self.sources), "expected_skills": [ { "skill_id": it.skill_id, "skill_name": it.skill_name, "weight": round(it.weight, 4), "source": it.source, } for it in self.items ], } def _norm_load_key(s: str) -> str: return (s or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue") def _text_blob(inp: PlanningSkillExpectationInput) -> str: parts = [ inp.primary_topic, inp.goal_query, inp.start_situation, inp.target_state, inp.stage_learning_goal, inp.roadmap_notes, inp.phase, " ".join(inp.load_profile or []), " ".join(inp.skill_hints or []), ] return "\n".join(p for p in parts if (p or "").strip()).lower() def _resolve_skills_by_name_terms( cur, terms: Sequence[str], *, weight: float = 0.9, source: str, weights: Dict[int, float], items: Dict[int, PlanningSkillExpectationItem], ) -> bool: found = False for name in terms: if not name: continue cur.execute( """ SELECT id, name FROM skills WHERE (status IS NULL OR status = 'active') AND LOWER(name) LIKE %s ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END, LENGTH(name) ASC LIMIT 1 """, (f"%{name.lower()}%", name.lower(), f"{name.lower()}%"), ) row = cur.fetchone() if not row: continue sid = int(row["id"]) w = max(weights.get(sid, 0.0), weight) weights[sid] = w items[sid] = PlanningSkillExpectationItem( skill_id=sid, skill_name=str(row.get("name") or "").strip(), weight=w, source=source, ) found = True return found def _merge_weights_into( weights: Dict[int, float], items: Dict[int, PlanningSkillExpectationItem], incoming: Dict[int, float], *, source: str, skill_rows: Sequence[Tuple[int, str, int]], ) -> None: name_by_id = {sid: name for sid, name, _ in skill_rows} for sid, w in incoming.items(): if w <= 0: continue merged = max(weights.get(sid, 0.0), float(w)) weights[sid] = merged items[sid] = PlanningSkillExpectationItem( skill_id=sid, skill_name=name_by_id.get(sid, f"Skill #{sid}"), weight=merged, source=source, ) def build_planning_skill_expectations( cur, inp: PlanningSkillExpectationInput, *, semantic_brief: Optional[PlanningSemanticBrief] = None, ) -> PlanningSkillExpectations: """Deterministisch: Thema + Text + load_profile → skill_weights.""" weights: Dict[int, float] = {} items: Dict[int, PlanningSkillExpectationItem] = {} sources: List[str] = [] skill_rows = _load_skills_for_text_match(cur) if semantic_brief is not None: topic_weights = resolve_semantic_skill_weights(cur, semantic_brief) if topic_weights: sources.append("semantic_topic") _merge_weights_into( weights, items, topic_weights, source="semantic_topic", skill_rows=skill_rows ) blob = _text_blob(inp) if blob: text_weights = _match_skills_in_text(blob, skill_rows) if text_weights: sources.append("text_match") _merge_weights_into( weights, items, text_weights, source="text_match", skill_rows=skill_rows ) load_found = False for raw in inp.load_profile or []: key = _norm_load_key(str(raw)) terms = _LOAD_PROFILE_SKILL_TERMS.get(key, (str(raw).strip(),) if key else ()) if _resolve_skills_by_name_terms( cur, terms, weight=0.88, source="load_profile", weights=weights, items=items ): load_found = True if load_found and "load_profile" not in sources: sources.append("load_profile") normalized = _normalize_weight_map(weights) out_items = sorted( [ PlanningSkillExpectationItem( skill_id=sid, skill_name=items[sid].skill_name, weight=normalized[sid], source=items[sid].source, ) for sid in normalized ], key=lambda x: (-x.weight, x.skill_name.lower()), )[:8] return PlanningSkillExpectations( scope=inp.scope or SCOPE_PROGRESSION_STAGE, skill_weights=normalized, items=out_items, sources=sources, ) def expectation_input_from_progression_stage( *, goal_query: str, goal_analysis: Optional[Mapping[str, Any]] = None, resolved_structured: Optional[Mapping[str, Any]] = None, stage_spec: Optional[Mapping[str, Any]] = None, semantic_brief_summary: Optional[Mapping[str, Any]] = None, major_step: Optional[Mapping[str, Any]] = None, ) -> PlanningSkillExpectationInput: """Roadmap-Stufe → PlanningSkillExpectationInput (Progressionsgraph).""" ga = dict(goal_analysis or {}) rs = dict(resolved_structured or {}) spec = dict(stage_spec or {}) brief = dict(semantic_brief_summary or {}) major = dict(major_step or {}) skill_hints: List[str] = [] for item in (brief.get("must_phrases") or [])[:4]: s = str(item or "").strip() if s: skill_hints.append(s) return PlanningSkillExpectationInput( scope=SCOPE_PROGRESSION_STAGE, primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(), goal_query=(goal_query or "").strip(), start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(), target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(), stage_learning_goal=str( spec.get("learning_goal") or major.get("learning_goal") or "" ).strip(), roadmap_notes=str(rs.get("roadmap_notes") or "").strip(), load_profile=[str(x).strip() for x in (spec.get("load_profile") or []) if str(x).strip()], phase=str(spec.get("phase") or major.get("phase") or "").strip(), skill_hints=skill_hints, ) def expectation_input_from_progression_path( *, goal_query: str, goal_analysis: Optional[Mapping[str, Any]] = None, resolved_structured: Optional[Mapping[str, Any]] = None, semantic_brief_summary: Optional[Mapping[str, Any]] = None, ) -> PlanningSkillExpectationInput: """Gesamtpfad-Kontext (z. B. einmaliges Pfad-Profil).""" ga = dict(goal_analysis or {}) rs = dict(resolved_structured or {}) brief = dict(semantic_brief_summary or {}) skill_hints: List[str] = [] for item in (brief.get("must_phrases") or [])[:4]: s = str(item or "").strip() if s: skill_hints.append(s) return PlanningSkillExpectationInput( scope=SCOPE_PROGRESSION_PATH, primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(), goal_query=(goal_query or "").strip(), start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(), target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(), roadmap_notes=str(rs.get("roadmap_notes") or "").strip(), skill_hints=skill_hints, ) def apply_expectations_to_target(target, expectations: PlanningSkillExpectations): """Merge Erwartungs-Skills in PlanningTargetProfile (Retrieval).""" from planning_exercise_semantics import enrich_target_with_semantic_expectations if not expectations.skill_weights: return target return enrich_target_with_semantic_expectations( target, skill_weights=dict(expectations.skill_weights) ) def merge_expectation_skill_weights( base: Dict[int, float], extra: Dict[int, float], *, extra_scale: float = 1.0, ) -> Dict[int, float]: merged = _merge_weight_maps(base, extra, scale=extra_scale) return _normalize_weight_map(merged) __all__ = [ "SCOPE_FRAMEWORK_SLOT", "SCOPE_PROGRESSION_PATH", "SCOPE_PROGRESSION_STAGE", "SCOPE_TRAINING_SECTION", "PlanningSkillExpectationInput", "PlanningSkillExpectationItem", "PlanningSkillExpectations", "apply_expectations_to_target", "build_planning_skill_expectations", "expectation_input_from_progression_path", "expectation_input_from_progression_stage", "merge_expectation_skill_weights", ]