shinkan-jinkendo/backend/planning_skill_expectations.py
Lars 1e7941f57b
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m22s
Enhance Gap Fill Goal Text and Skill Expectations Integration
- Updated `build_gap_fill_goal_text` to include expected skills in the generated text, improving clarity for users.
- Enhanced `_roadmap_gap_snapshot_for_spec` to incorporate skill expectations from the progression stage, enriching the roadmap context.
- Modified `_annotate_roadmap_step` to append skill expectations to the step reasons, providing additional insights.
- Updated tests to verify the inclusion of expected skills in the gap fill goal text.
- Incremented application version to 0.8.215 to reflect these changes.
2026-06-10 07:09:46 +02:00

335 lines
11 KiB
Python

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