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
- 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.
335 lines
11 KiB
Python
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",
|
|
]
|