Progression enhancement 3 QS stages #56

Merged
Lars merged 8 commits from develop into main 2026-06-12 12:24:54 +02:00
17 changed files with 1913 additions and 183 deletions

View File

@ -0,0 +1,147 @@
"""
Katalog-Kontext für Progressionsgraph-Planung Fokusbereich, Stil, Trainingsstil, Zielgruppe.
Explizite Trainer-Auswahl ergänzt Freitext/LLM; ersetzt kein Roadmap-Didaktik-Modell.
"""
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence
from pydantic import BaseModel, Field
from planning_exercise_profiles import PlanningTargetProfile, _normalize_weight_map
from planning_exercise_target_pipeline import (
SCENARIO_FREE_SEARCH,
merge_query_overlay_into_target,
)
from planning_exercise_text_signals import resolve_planning_text_to_catalog_weights
class PlanningCatalogContextItem(BaseModel):
id: int = Field(..., ge=1)
is_primary: bool = False
weight: float = Field(default=1.0, ge=0.1, le=1.0)
class ProgressionPlanningCatalogContext(BaseModel):
focus_areas: List[PlanningCatalogContextItem] = Field(default_factory=list)
style_directions: List[PlanningCatalogContextItem] = Field(default_factory=list)
training_types: List[PlanningCatalogContextItem] = Field(default_factory=list)
target_groups: List[PlanningCatalogContextItem] = Field(default_factory=list)
def catalog_context_has_items(catalog: Optional[ProgressionPlanningCatalogContext]) -> bool:
if catalog is None:
return False
return bool(
catalog.focus_areas
or catalog.style_directions
or catalog.training_types
or catalog.target_groups
)
def catalog_items_to_weight_map(
items: Sequence[PlanningCatalogContextItem],
*,
primary_weight: float = 0.95,
secondary_weight: float = 0.78,
) -> Dict[int, float]:
out: Dict[int, float] = {}
for item in items or []:
base = primary_weight if item.is_primary else secondary_weight
w = base * float(item.weight)
iid = int(item.id)
out[iid] = max(out.get(iid, 0.0), w)
return _normalize_weight_map(out) if out else out
def merge_catalog_context_into_target(
target: PlanningTargetProfile,
catalog: Optional[ProgressionPlanningCatalogContext],
*,
emphasis: str = "replace",
) -> PlanningTargetProfile:
"""Trainer-Katalog-Kontext ins Erwartungsprofil — beeinflusst Retrieval-Scoring."""
if not catalog_context_has_items(catalog):
return target
focus = catalog_items_to_weight_map(catalog.focus_areas)
style = catalog_items_to_weight_map(catalog.style_directions, primary_weight=0.9, secondary_weight=0.72)
tt = catalog_items_to_weight_map(catalog.training_types, primary_weight=0.9, secondary_weight=0.72)
tg = catalog_items_to_weight_map(catalog.target_groups, primary_weight=0.88, secondary_weight=0.7)
merged = merge_query_overlay_into_target(
target,
focus=focus,
style=style,
tt=tt,
tg=tg,
skills={},
emphasis=emphasis,
scenario=SCENARIO_FREE_SEARCH,
)
sources = list(merged.sources or [])
if "catalog_context" not in sources:
sources.append("catalog_context")
merged.sources = sources
return merged
def enrich_target_from_planning_text_blobs(
cur,
target: PlanningTargetProfile,
*text_blobs: Optional[str],
) -> PlanningTargetProfile:
"""Additive Katalog-Signale aus Freitext (Anfrage, Start/Ziel, Notizen)."""
combined = " ".join(str(t or "").strip() for t in text_blobs if (t or "").strip())
if len(combined) < 4:
return target
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(cur, combined)
if not (focus or style or tt or tg or skills):
return target
merged = merge_query_overlay_into_target(
target,
focus=focus,
style=style,
tt=tt,
tg=tg,
skills=skills,
emphasis="additive",
scenario=SCENARIO_FREE_SEARCH,
)
sources = list(merged.sources or [])
if "text_catalog_signals" not in sources:
sources.append("text_catalog_signals")
merged.sources = sources
return merged
def catalog_context_from_mapping(raw: Any) -> Optional[ProgressionPlanningCatalogContext]:
if not raw or not isinstance(raw, Mapping):
return None
try:
ctx = ProgressionPlanningCatalogContext.model_validate(dict(raw))
except Exception:
return None
return ctx if catalog_context_has_items(ctx) else None
def load_catalog_context_from_graph_row(
planning_roadmap: Any,
) -> Optional[ProgressionPlanningCatalogContext]:
if not isinstance(planning_roadmap, dict):
return None
return catalog_context_from_mapping(planning_roadmap.get("planning_catalog_context"))
__all__ = [
"PlanningCatalogContextItem",
"ProgressionPlanningCatalogContext",
"catalog_context_from_mapping",
"catalog_context_has_items",
"catalog_items_to_weight_map",
"enrich_target_from_planning_text_blobs",
"load_catalog_context_from_graph_row",
"merge_catalog_context_into_target",
]

View File

@ -425,9 +425,22 @@ def collect_gap_fill_specs(
step_a, step_b = _step_neighbors_at_index(steps, idx)
phase = ot.get("expected_phase") or "vertiefung"
insert_after = max(idx - 1, -1)
stage_goal = str(ot.get("roadmap_learning_goal") or "").strip()
if str(ot.get("issue") or "") == "stage_mismatch" and stage_goal:
title_hint = stage_goal[:120]
rationale = (
f"Keine passende Bibliotheks-Übung für Stufen-Lernziel „{stage_goal[:100]}“."
)
sketch_rationale = (
f"Slot braucht Übung passend zu: {stage_goal[:200]}"
)
else:
title_hint = f"{topic}{phase} (Ersatz für themenfremden Schritt)"
rationale = f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema."
sketch_rationale = f"Ersetzt themenfremden Schritt „{ot.get('title')}“."
add(
{
"source": "off_topic",
"source": "off_topic" if ot.get("issue") != "stage_mismatch" else "stage_mismatch",
"insert_after_index": insert_after,
"replace_step_index": idx,
"roadmap_major_step_index": major_idx,
@ -435,18 +448,19 @@ def collect_gap_fill_specs(
"expected_phase": phase,
"off_topic_title": ot.get("title"),
"off_topic_exercise_id": ot.get("exercise_id"),
"roadmap_learning_goal": stage_goal or None,
},
"phase": phase,
"title_hint": f"{topic}{phase} (Ersatz für themenfremden Schritt)",
"title_hint": title_hint,
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
step_a=step_a,
step_b=step_b,
phase=str(phase),
rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.",
rationale=sketch_rationale,
),
"rationale": f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema.",
"rationale": rationale,
}
)

View File

@ -16,6 +16,13 @@ from tenant_context import (
library_content_visibility_for_progression_graph_sql,
library_content_visibility_sql,
)
from planning_catalog_context import (
ProgressionPlanningCatalogContext,
catalog_context_has_items,
enrich_target_from_planning_text_blobs,
load_catalog_context_from_graph_row,
merge_catalog_context_into_target,
)
from planning_exercise_profiles import PlanningTargetProfile
from planning_path_qa_pipeline import run_multistage_path_qa
from planning_path_rematch import (
@ -23,6 +30,7 @@ from planning_path_rematch import (
prune_stripped_after_rematch,
rematch_roadmap_slots,
)
from planning_path_refine_stage import apply_stage_spec_refinements, collect_refine_stage_targets
from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target
from planning_exercise_path_qa import (
apply_llm_path_reorder,
@ -52,6 +60,8 @@ from planning_exercise_semantics import (
resolve_path_anti_patterns,
resolve_path_primary_topic,
exercise_passes_path_semantic_gate,
exercise_passes_stage_fit,
exercise_title_matches_peer_stage_goal,
pick_best_path_hit,
resolve_semantic_skill_weights,
step_phase_for_index,
@ -108,7 +118,8 @@ class ProgressionPathSuggestRequest(BaseModel):
include_llm_intent: bool = True
include_path_qa: bool = True
auto_rematch_after_qa: bool = True
max_rematch_rounds: int = Field(default=2, ge=0, le=3)
auto_refine_stage_spec: bool = True
max_rematch_rounds: int = Field(default=3, ge=0, le=4)
include_llm_path_qa: bool = True
include_path_reorder: bool = True
include_ai_gap_fill: bool = True
@ -129,6 +140,27 @@ class ProgressionPathSuggestRequest(BaseModel):
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None
planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None
def _resolve_planning_catalog_context(
cur,
body: ProgressionPathSuggestRequest,
) -> Optional[ProgressionPlanningCatalogContext]:
"""Request-Kontext oder gespeichertes Graph-Artefakt."""
if body.planning_catalog_context and catalog_context_has_items(body.planning_catalog_context):
return body.planning_catalog_context
gid = body.progression_graph_id
if not gid or int(gid) < 1:
return None
cur.execute(
"SELECT planning_roadmap FROM exercise_progression_graphs WHERE id = %s",
(int(gid),),
)
row = cur.fetchone()
if not row:
return None
return load_catalog_context_from_graph_row(row.get("planning_roadmap"))
def _roadmap_gap_snapshot_for_spec(
@ -199,6 +231,78 @@ def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Option
)
def _peer_stage_learning_goals(
roadmap_ctx: ProgressionRoadmapContext,
*,
current_major_index: int,
) -> List[str]:
goals: List[str] = []
for spec in roadmap_ctx.stage_specs or []:
if int(spec.major_step_index) == int(current_major_index):
continue
lg = (spec.learning_goal or "").strip()
if lg and lg not in goals:
goals.append(lg)
return goals
def _filter_learning_goal_candidate_ids(
cur,
*,
tenant: TenantContext,
progression_graph_id: Optional[int],
candidate_ids: Sequence[int],
stage_goal: str,
stage_match_brief: PlanningSemanticBrief,
stage_anti: Optional[List[str]],
path_primary: str,
path_tech_excludes: Optional[List[str]],
peer_learning_goals: Sequence[str],
) -> List[int]:
"""Learning-Goal-Kandidaten nur, wenn sie Stufen-Gate und Peer-Check bestehen."""
if not candidate_ids:
return []
vis_sql, vis_params = _planning_visibility_sql(cur, tenant, progression_graph_id)
rows = _load_supplemental_exercise_rows(
cur,
tenant=tenant,
progression_graph_id=progression_graph_id,
exercise_ids=list(candidate_ids),
vis_sql=vis_sql,
vis_params=vis_params,
)
out: List[int] = []
for row in rows:
try:
eid = int(row.get("id") or 0)
except (TypeError, ValueError):
continue
if eid <= 0:
continue
title = str(row.get("title") or "")
if peer_learning_goals and exercise_title_matches_peer_stage_goal(
title,
current_learning_goal=stage_goal,
peer_learning_goals=peer_learning_goals,
):
continue
summary = str(row.get("summary") or "")
goal_text = str(row.get("goal") or row.get("exercise_goal") or "")
if exercise_passes_stage_fit(
learning_goal=stage_goal,
title=title,
summary=summary,
goal=goal_text,
stage_brief=stage_match_brief,
anti_patterns=stage_anti,
path_primary_topic=path_primary or None,
path_technique_excludes=path_tech_excludes,
relaxed=False,
):
out.append(eid)
return out
def _pick_best_path_hit(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
@ -210,6 +314,7 @@ def _pick_best_path_hit(
stage_match_brief: Optional[PlanningSemanticBrief] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[List[str]] = None,
peer_learning_goals: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
return pick_best_path_hit(
hits,
@ -221,6 +326,7 @@ def _pick_best_path_hit(
stage_match_brief=stage_match_brief,
path_primary_topic=path_primary_topic,
path_technique_excludes=path_technique_excludes,
peer_learning_goals=peer_learning_goals,
)
@ -230,8 +336,12 @@ def _build_path_target_profile(
goal_query: str,
semantic_brief: PlanningSemanticBrief,
include_llm_intent: bool,
start_situation: Optional[str] = None,
target_state: Optional[str] = None,
roadmap_notes: Optional[str] = None,
catalog_context: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[PlanningTargetProfile, Dict[str, Any], str]:
"""Einmaliges Erwartungsprofil für den gesamten Pfad (Query + Semantik + Skills)."""
"""Einmaliges Erwartungsprofil für den gesamten Pfad (Query + Semantik + Katalog)."""
empty_unit = {
"id": None,
"framework_slot_id": None,
@ -267,6 +377,20 @@ def _build_path_target_profile(
)
skill_weights = resolve_semantic_skill_weights(cur, semantic_brief)
target = enrich_target_with_semantic_expectations(target, skill_weights=skill_weights)
target = enrich_target_from_planning_text_blobs(
cur,
target,
goal_query,
start_situation,
target_state,
roadmap_notes,
)
if catalog_context and catalog_context_has_items(catalog_context):
target = merge_catalog_context_into_target(
target,
catalog_context,
emphasis="replace",
)
return target, query_intent_summary, intent
@ -364,13 +488,12 @@ def _fetch_learning_goal_library_candidate_ids(
learning_goal: str,
limit: int = 24,
) -> List[int]:
"""Sichtbare Übungen, deren Titel/Volltext zum Stufen-Lernziel passt."""
"""Sichtbare Übungen mit exakt passendem Titel oder Volltext-Treffer (kein breites LIKE)."""
lg = (learning_goal or "").strip()
if len(lg) < 3:
return []
vis_sql, vis_params = _planning_visibility_sql(cur, tenant, progression_graph_id)
tsq = _safe_tsquery_fragment(lg)
like_pat = f"%{lg[:100].lower()}%"
try:
cur.execute(
f"""
@ -380,7 +503,6 @@ def _fetch_learning_goal_library_candidate_ids(
AND COALESCE(e.status, '') <> %s
AND (
lower(trim(e.title)) = lower(trim(%s))
OR lower(e.title) LIKE %s
OR (%s <> '' AND e.search_vector @@ plainto_tsquery('german', %s))
)
ORDER BY
@ -393,7 +515,6 @@ def _fetch_learning_goal_library_candidate_ids(
*vis_params,
"archived",
lg,
like_pat,
tsq,
tsq,
lg,
@ -409,14 +530,11 @@ def _fetch_learning_goal_library_candidate_ids(
FROM exercises e
WHERE ({vis_sql})
AND COALESCE(e.status, '') <> %s
AND (
lower(trim(e.title)) = lower(trim(%s))
OR lower(e.title) LIKE %s
)
ORDER BY CASE WHEN lower(trim(e.title)) = lower(trim(%s)) THEN 0 ELSE 1 END, e.id ASC
AND lower(trim(e.title)) = lower(trim(%s))
ORDER BY e.id ASC
LIMIT %s
""",
[*vis_params, "archived", lg, like_pat, lg, int(limit)],
[*vis_params, "archived", lg, int(limit)],
)
out: List[int] = []
for row in cur.fetchall() or []:
@ -979,7 +1097,7 @@ def _stage_validation_context_for_spec(
or ""
).strip()
path_tech_excludes = list(semantic_brief.exclude_phrases or [])
if path_primary:
if path_primary and semantic_brief.topic_type == "technique":
from planning_exercise_semantics import technique_sibling_excludes
for item in technique_sibling_excludes(path_primary):
@ -1090,14 +1208,30 @@ def _match_roadmap_slot(
major_step=major,
)
step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
peer_goals = _peer_stage_learning_goals(
roadmap_ctx,
current_major_index=int(stage_spec.major_step_index),
)
supplemental_ids = _supplemental_exercise_ids_from_body(cur, body)
lg_candidates = _fetch_learning_goal_library_candidate_ids(
lg_candidates_raw = _fetch_learning_goal_library_candidate_ids(
cur,
tenant=tenant,
progression_graph_id=body.progression_graph_id,
learning_goal=stage_goal,
)
lg_candidates = _filter_learning_goal_candidate_ids(
cur,
tenant=tenant,
progression_graph_id=body.progression_graph_id,
candidate_ids=lg_candidates_raw,
stage_goal=stage_goal,
stage_match_brief=stage_match_brief,
stage_anti=stage_anti,
path_primary=path_primary,
path_tech_excludes=path_tech_excludes,
peer_learning_goals=peer_goals,
)
supplemental_ids = list(
dict.fromkeys(
int(x)
@ -1161,6 +1295,7 @@ def _match_roadmap_slot(
stage_match_brief=stage_match_brief,
path_primary_topic=path_primary or None,
path_technique_excludes=path_tech_excludes or None,
peer_learning_goals=peer_goals,
)
if not hit:
@ -1183,9 +1318,35 @@ def _match_roadmap_slot(
else:
step["slot_status"] = "matched"
step["roadmap_match_source"] = "stage_spec"
if step.get("roadmap_match_source") != "slot_best_match" and not _roadmap_step_passes_post_match_gate(
cur,
step,
goal_query=goal_query,
semantic_brief=semantic_brief,
):
return None, stage_spec
return step, None
def _roadmap_step_passes_post_match_gate(
cur,
step: Dict[str, Any],
*,
goal_query: str,
semantic_brief: PlanningSemanticBrief,
) -> bool:
"""Abgleich mit Pfad-QA — kein Rematch-Treffer, der sofort wieder stage_mismatch wäre."""
if step.get("exercise_id") is None:
return False
issues = detect_off_topic_steps(
cur,
[step],
brief=semantic_brief,
goal_query=goal_query,
)
return not issues
def _normalize_roadmap_steps_coverage(
steps: List[Dict[str, Any]],
*,
@ -1233,6 +1394,164 @@ def _normalize_roadmap_steps_coverage(
return out
def _purge_stage_mismatch_roadmap_slots(
cur,
*,
steps: List[Dict[str, Any]],
roadmap_ctx: ProgressionRoadmapContext,
goal_query: str,
semantic_brief: PlanningSemanticBrief,
) -> Tuple[List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
"""Leert Slots mit persistentem stage_mismatch — KI-Gap statt schlechter Bibliotheks-Übung."""
issues = detect_off_topic_steps(
cur,
steps,
brief=semantic_brief,
goal_query=goal_query,
)
purge_majors: Set[int] = set()
for item in issues:
if str(item.get("issue") or "") != "stage_mismatch":
continue
midx = item.get("roadmap_major_step_index")
if midx is None:
continue
try:
purge_majors.add(int(midx))
except (TypeError, ValueError):
continue
if not purge_majors:
return steps, []
stage_specs = list(roadmap_ctx.stage_specs or [])
spec_by_major = {int(s.major_step_index): s for s in stage_specs}
major_by_index: Dict[int, MajorStep] = {}
if roadmap_ctx.roadmap:
major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
new_unfilled: List[Tuple[int, StageSpecArtifact]] = []
out: List[Dict[str, Any]] = []
for raw in steps:
step = dict(raw)
midx = step.get("roadmap_major_step_index")
if midx is None or int(midx) not in purge_majors:
out.append(step)
continue
major_idx = int(midx)
spec = spec_by_major.get(major_idx)
if spec is None:
out.append(step)
continue
step_index = next(
(i for i, sp in enumerate(stage_specs) if int(sp.major_step_index) == major_idx),
major_idx,
)
major = major_by_index.get(major_idx)
goal = (spec.learning_goal or step.get("roadmap_learning_goal") or "").strip()
out.append(
{
"exercise_id": None,
"variant_id": None,
"title": goal or f"Slot {major_idx + 1}",
"is_ai_proposal": False,
"roadmap_major_step_index": major_idx,
"roadmap_phase": major.phase if major else step.get("roadmap_phase"),
"roadmap_learning_goal": goal or None,
"roadmap_match_source": "unfilled",
"slot_status": "unfilled",
"reasons": ["Keine passende Bibliotheks-Übung für Stufen-Lernziel"],
}
)
new_unfilled.append((step_index, spec))
return out, new_unfilled
def _enrich_roadmap_unfilled_gap_offers(
cur,
*,
steps: List[Dict[str, Any]],
gap_fill_offers: List[Dict[str, Any]],
body: ProgressionPathSuggestRequest,
roadmap_ctx: ProgressionRoadmapContext,
goal_query: str,
semantic_brief: PlanningSemanticBrief,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""KI-Lücken-Angebote für alle leeren Roadmap-Slots (nach Rematch/Normalize)."""
if not body.include_ai_gap_fill:
return steps, gap_fill_offers
seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers if o.get("offer_id")}
out_steps: List[Dict[str, Any]] = []
offers = list(gap_fill_offers)
for raw in steps:
step = dict(raw)
if step.get("exercise_id") is not None:
out_steps.append(step)
continue
try:
major_idx = int(step["roadmap_major_step_index"])
except (TypeError, ValueError, KeyError):
out_steps.append(step)
continue
if step.get("gap_offer") and step.get("proposal_key"):
oid = step["gap_offer"].get("offer_id")
if oid and oid not in seen_offer_ids:
offers.append(dict(step["gap_offer"]))
seen_offer_ids.add(oid)
out_steps.append(step)
continue
stage_spec = next(
(
s
for s in (roadmap_ctx.stage_specs or [])
if int(s.major_step_index) == major_idx
),
None,
)
learning_goal = (
(stage_spec.learning_goal if stage_spec else None)
or step.get("roadmap_learning_goal")
or step.get("title")
or ""
).strip()
spec = {
"source": "roadmap_unfilled",
"insert_after_index": max(major_idx - 1, -1),
"roadmap_major_step_index": major_idx,
"phase": (step.get("roadmap_phase") or "vertiefung").strip().lower(),
"title_hint": (learning_goal or f"Slot {major_idx + 1}")[:120],
"sketch": learning_goal,
"rationale": (
f"Slot {major_idx + 1} — keine passende Bibliotheks-Übung; "
"KI-Entwurf für diese Stufe."
),
}
offer = build_gap_fill_offer(
spec=spec,
steps=steps,
goal_query=goal_query,
brief=semantic_brief,
proposal=None,
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
cur,
roadmap_ctx,
spec,
goal_query=goal_query,
semantic_brief=semantic_brief,
),
)
step["gap_offer"] = offer
step["proposal_key"] = offer.get("offer_id")
step["slot_status"] = "unfilled"
if offer.get("offer_id") and offer.get("offer_id") not in seen_offer_ids:
offers.append(offer)
seen_offer_ids.add(offer.get("offer_id"))
out_steps.append(step)
return out_steps, offers
def _merge_rematch_unfilled(
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]],
rematch_new_unfilled: List[Tuple[int, StageSpecArtifact]],
@ -1245,6 +1564,31 @@ def _merge_rematch_unfilled(
return kept
def _prune_filled_from_roadmap_unfilled(
steps: Sequence[Mapping[str, Any]],
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]],
) -> List[Tuple[int, StageSpecArtifact]]:
"""Entfernt Stufen mit Bibliotheks-Treffer — verhindert veraltete roadmap_unfilled-Hinweise."""
filled_majors: Set[int] = set()
for raw in steps:
if raw.get("exercise_id") is None:
continue
midx = raw.get("roadmap_major_step_index")
if midx is None:
continue
try:
filled_majors.add(int(midx))
except (TypeError, ValueError):
continue
if not filled_majors:
return roadmap_unfilled
return [
item
for item in roadmap_unfilled
if int(item[1].major_step_index) not in filled_majors
]
def _run_roadmap_rematch_loop(
cur,
*,
@ -1268,9 +1612,11 @@ def _run_roadmap_rematch_loop(
List[Dict[str, Any]],
int,
List[Tuple[int, StageSpecArtifact]],
List[Dict[str, Any]],
]:
"""Phase A/B: Rematch-Schleife aus Strip, unfilled Slots und optimization_hints."""
"""Phase A/B/C: Rematch-Schleife mit optionaler Stufen-Spec-Verfeinerung."""
rematch_log: List[Dict[str, Any]] = []
refine_log: List[Dict[str, Any]] = []
rematch_rounds = 0
max_rounds = int(body.max_rematch_rounds or 0)
if not body.auto_rematch_after_qa or max_rounds <= 0 or not roadmap_ctx.stage_specs:
@ -1280,11 +1626,46 @@ def _run_roadmap_rematch_loop(
brief=semantic_brief,
goal_query=goal_query,
)
return steps, rematch_log, stripped_off_topic, off_topic_steps, rematch_rounds, roadmap_unfilled
return (
steps,
rematch_log,
stripped_off_topic,
off_topic_steps,
rematch_rounds,
roadmap_unfilled,
refine_log,
)
current_stripped = list(stripped_off_topic or [])
use_initial_off_topic = not current_stripped
off_topic_steps: List[Dict[str, Any]] = []
rejected_by_major: Dict[int, Set[int]] = {}
def _track_rejected(items: Sequence[Mapping[str, Any]]) -> None:
for item in items or []:
if not isinstance(item, dict):
continue
eid = item.get("exercise_id")
midx = item.get("roadmap_major_step_index")
if eid is None or midx is None:
continue
try:
rejected_by_major.setdefault(int(midx), set()).add(int(eid))
except (TypeError, ValueError):
continue
_track_rejected(off_topic_before_strip)
_track_rejected(current_stripped)
slot_assignment_history: Dict[int, Set[int]] = {}
for raw in steps:
midx = raw.get("roadmap_major_step_index")
eid = raw.get("exercise_id")
if midx is None or eid is None:
continue
try:
slot_assignment_history.setdefault(int(midx), set()).add(int(eid))
except (TypeError, ValueError):
continue
for round_idx in range(max_rounds):
mini_qa = run_multistage_path_qa(
@ -1297,6 +1678,20 @@ def _run_roadmap_rematch_loop(
)
optimization_hints = list(mini_qa.get("optimization_hints") or [])
if body.auto_refine_stage_spec:
_, round_refine = apply_stage_spec_refinements(
roadmap_ctx,
optimization_hints=optimization_hints,
off_topic_steps=off_topic_steps if round_idx > 0 else off_topic_before_strip,
goal_query=goal_query,
semantic_brief=semantic_brief,
)
if round_refine:
for entry in round_refine:
tagged = dict(entry)
tagged["round"] = rematch_rounds + 1
refine_log.append(tagged)
slot_indices, rematch_reasons = collect_rematch_slot_indices(
stripped_off_topic=current_stripped if round_idx == 0 else [],
off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [],
@ -1304,6 +1699,16 @@ def _run_roadmap_rematch_loop(
stage_specs=roadmap_ctx.stage_specs,
roadmap_unfilled=roadmap_unfilled,
)
if body.auto_refine_stage_spec:
refine_targets = collect_refine_stage_targets(
optimization_hints=optimization_hints,
off_topic_steps=off_topic_steps if round_idx > 0 else off_topic_before_strip,
stage_specs=roadmap_ctx.stage_specs,
)
for midx in refine_targets:
slot_indices.add(int(midx))
if int(midx) not in rematch_reasons:
rematch_reasons[int(midx)] = "refine_stage_spec"
if not slot_indices:
break
@ -1321,15 +1726,35 @@ def _run_roadmap_rematch_loop(
slot_indices=slot_indices,
rematch_reasons=rematch_reasons,
match_slot_fn=_match_roadmap_slot,
rejected_by_major=rejected_by_major,
slot_assignment_history=slot_assignment_history,
)
rematch_rounds += 1
for entry in round_log:
tagged = dict(entry)
tagged["round"] = rematch_rounds
rematch_log.append(tagged)
rid = entry.get("replaced_exercise_id")
midx = entry.get("roadmap_major_step_index")
if rid is not None and midx is not None:
try:
rejected_by_major.setdefault(int(midx), set()).add(int(rid))
except (TypeError, ValueError):
pass
new_eid = entry.get("new_exercise_id")
if (
str(entry.get("action") or "") == "replaced"
and new_eid is not None
and midx is not None
):
try:
slot_assignment_history.setdefault(int(midx), set()).add(int(new_eid))
except (TypeError, ValueError):
pass
current_stripped = prune_stripped_after_rematch(current_stripped, round_log)
roadmap_unfilled = _merge_rematch_unfilled(roadmap_unfilled, rematch_new_unfilled)
roadmap_unfilled = _prune_filled_from_roadmap_unfilled(steps, roadmap_unfilled)
use_initial_off_topic = False
off_topic_steps = detect_off_topic_steps(
@ -1338,6 +1763,7 @@ def _run_roadmap_rematch_loop(
brief=semantic_brief,
goal_query=goal_query,
)
_track_rejected(off_topic_steps)
if round_idx + 1 >= max_rounds:
break
if not off_topic_steps and not roadmap_unfilled:
@ -1351,6 +1777,24 @@ def _run_roadmap_rematch_loop(
goal_query=goal_query,
)
steps, purged_unfilled = _purge_stage_mismatch_roadmap_slots(
cur,
steps=steps,
roadmap_ctx=roadmap_ctx,
goal_query=goal_query,
semantic_brief=semantic_brief,
)
if purged_unfilled:
roadmap_unfilled = _merge_rematch_unfilled(roadmap_unfilled, purged_unfilled)
off_topic_steps = detect_off_topic_steps(
cur,
steps,
brief=semantic_brief,
goal_query=goal_query,
)
roadmap_unfilled = _prune_filled_from_roadmap_unfilled(steps, roadmap_unfilled)
return (
steps,
rematch_log,
@ -1358,6 +1802,7 @@ def _run_roadmap_rematch_loop(
off_topic_steps,
rematch_rounds,
roadmap_unfilled,
refine_log,
)
@ -1840,6 +2285,10 @@ def suggest_progression_path(
goal_query=goal_query,
semantic_brief=semantic_brief,
include_llm_intent=body.include_llm_intent,
start_situation=body.start_situation,
target_state=body.target_state,
roadmap_notes=body.roadmap_notes,
catalog_context=_resolve_planning_catalog_context(cur, body),
)
path_skill_expectations: Optional[Dict[str, Any]] = None
if roadmap_ctx and roadmap_ctx.goal_analysis:
@ -1967,6 +2416,7 @@ def suggest_progression_path(
off_topic_steps: List[Dict[str, Any]] = []
stripped_off_topic: List[Dict[str, Any]] = []
rematch_log: List[Dict[str, Any]] = []
refine_log: List[Dict[str, Any]] = []
rematch_rounds = 0
llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False
@ -2009,7 +2459,7 @@ def suggest_progression_path(
elif gaps and roadmap_first:
unfilled_gaps = list(gaps)
if body.include_llm_path_qa:
if body.include_llm_path_qa and not roadmap_first:
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
goal_query=goal_query,
@ -2062,6 +2512,7 @@ def suggest_progression_path(
rematch_off_topic,
rematch_rounds,
roadmap_unfilled,
refine_log,
) = _run_roadmap_rematch_loop(
cur,
tenant=tenant,
@ -2087,6 +2538,22 @@ def suggest_progression_path(
roadmap_first=roadmap_first,
)
if body.include_llm_path_qa and roadmap_first:
gaps = detect_path_gaps(
cur,
steps,
brief=semantic_brief,
roadmap_first=roadmap_first,
)
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
goal_query=goal_query,
brief=semantic_brief,
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
)
llm_gap_specs = parse_llm_suggested_new_exercises(
llm_qa,
brief=semantic_brief,
@ -2136,6 +2603,22 @@ def suggest_progression_path(
if offer.get("offer_id") not in seen_offer_ids:
gap_fill_offers.append(offer)
if roadmap_first and roadmap_ctx is not None:
steps = _normalize_roadmap_steps_coverage(
steps,
roadmap_ctx=roadmap_ctx,
max_steps=max_steps,
)
steps, gap_fill_offers = _enrich_roadmap_unfilled_gap_offers(
cur,
steps=steps,
gap_fill_offers=gap_fill_offers,
body=body,
roadmap_ctx=roadmap_ctx,
goal_query=goal_query,
semantic_brief=semantic_brief,
)
multistage_qa = run_multistage_path_qa(
off_topic_steps=off_topic_steps,
stripped_off_topic=stripped_off_topic,
@ -2162,71 +2645,10 @@ def suggest_progression_path(
path_qa["rematch_applied"] = True
path_qa["rematch_log"] = rematch_log
path_qa["rematch_rounds"] = rematch_rounds
if roadmap_first and roadmap_ctx is not None:
steps = _normalize_roadmap_steps_coverage(
steps,
roadmap_ctx=roadmap_ctx,
max_steps=max_steps,
)
if body.include_ai_gap_fill:
seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers if o.get("offer_id")}
for step in steps:
if step.get("exercise_id") is not None:
continue
try:
major_idx = int(step["roadmap_major_step_index"])
except (TypeError, ValueError, KeyError):
continue
if step.get("gap_offer") and step.get("proposal_key"):
oid = step["gap_offer"].get("offer_id")
if oid and oid not in seen_offer_ids:
gap_fill_offers.append(dict(step["gap_offer"]))
seen_offer_ids.add(oid)
continue
stage_spec = next(
(
s
for s in (roadmap_ctx.stage_specs or [])
if int(s.major_step_index) == major_idx
),
None,
)
learning_goal = (
(stage_spec.learning_goal if stage_spec else None)
or step.get("roadmap_learning_goal")
or step.get("title")
or ""
).strip()
spec = {
"source": "roadmap_unfilled",
"insert_after_index": max(major_idx - 1, -1),
"roadmap_major_step_index": major_idx,
"phase": (step.get("roadmap_phase") or "vertiefung").strip().lower(),
"title_hint": (learning_goal or f"Slot {major_idx + 1}")[:120],
"sketch": learning_goal,
"rationale": f"Slot {major_idx + 1} — keine passende Bibliotheks-Übung; KI-Entwurf für diese Stufe.",
}
offer = build_gap_fill_offer(
spec=spec,
steps=steps,
goal_query=goal_query,
brief=semantic_brief,
proposal=None,
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
cur,
roadmap_ctx,
spec,
goal_query=goal_query,
semantic_brief=semantic_brief,
),
)
step["gap_offer"] = offer
step["proposal_key"] = offer.get("offer_id")
step["slot_status"] = "unfilled"
if offer.get("offer_id") and offer.get("offer_id") not in seen_offer_ids:
gap_fill_offers.append(offer)
seen_offer_ids.add(offer.get("offer_id"))
if refine_log:
path_qa["refine_applied"] = True
path_qa["refine_log"] = refine_log
path_qa["refine_count"] = len(refine_log)
filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None)
match_summary = {
@ -2261,6 +2683,8 @@ def suggest_progression_path(
retrieval_parts.append("roadmap_unfilled")
if rematch_log:
retrieval_parts.append("path_rematch")
if refine_log:
retrieval_parts.append("stage_spec_refine")
return {
"goal_query": goal_query,

View File

@ -21,12 +21,15 @@ from planning_exercise_semantics import (
_blob_from_fields,
_blob_matches_stage_excludes,
brief_to_summary_dict,
build_stage_match_brief,
exercise_passes_path_semantic_gate,
exercise_passes_stage_learning_goal_gate,
exercise_passes_technique_path_scope,
merge_stage_exclude_phrases,
resolve_path_anti_patterns,
resolve_path_primary_topic,
score_exercise_semantic_relevance,
score_exercise_stage_fit,
semantic_brief_for_stage,
step_phase_for_index,
technique_sibling_excludes,
@ -442,8 +445,13 @@ def detect_off_topic_steps(
bundle["goal"],
bundle["variant_names"],
)
step_anti = list(step.get("roadmap_anti_patterns") or []) + path_anti
if step_anti and _blob_matches_stage_excludes(blob, step_anti):
step_anti_raw = list(step.get("roadmap_anti_patterns") or [])
stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
exclude_phrases = merge_stage_exclude_phrases(
stage_goal_pre,
[*step_anti_raw, *path_anti],
)
if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
off_topic.append(
_with_roadmap_major_index(
step,
@ -459,16 +467,15 @@ def detect_off_topic_steps(
)
)
continue
stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
primary = (
resolve_path_primary_topic(
goal_query or "",
brief,
stage_learning_goal=stage_goal_pre or None,
stage_learning_goal=None,
)
or ""
).strip()
if primary:
if primary and brief.topic_type == "technique":
siblings = technique_sibling_excludes(primary)
if not exercise_passes_technique_path_scope(
primary_topic=primary,
@ -512,6 +519,26 @@ def detect_off_topic_steps(
step_phase=phase,
)
stage_anti = list(step.get("roadmap_anti_patterns") or [])
stage_match_brief = (
build_stage_match_brief(
learning_goal=stage_goal,
anti_patterns=stage_anti or None,
phase=phase or None,
)
if stage_goal
else None
)
stage_sem = 0.0
stage_reasons: List[str] = []
if stage_match_brief:
stage_sem, stage_reasons = score_exercise_stage_fit(
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
variant_names=bundle["variant_names"],
stage_brief=stage_match_brief,
step_phase=phase,
)
if stage_goal and not exercise_passes_stage_learning_goal_gate(
learning_goal=stage_goal,
title=bundle["title"],
@ -520,6 +547,15 @@ def detect_off_topic_steps(
semantic_score=sem,
anti_patterns=stage_anti or None,
):
reasons = [
r
for r in stage_reasons
if r and r != "Kern-Thema der Anfrage im Übungstext"
]
if not reasons:
reasons = [
f"Stufen-Fit zu schwach ({stage_sem:.2f}) für „{stage_goal[:80]}"
]
off_topic.append(
_with_roadmap_major_index(
step,
@ -527,11 +563,11 @@ def detect_off_topic_steps(
"step_index": idx,
"exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"],
"semantic_score": round(sem, 4),
"semantic_score": round(stage_sem, 4),
"expected_phase": phase,
"issue": "stage_mismatch",
"roadmap_learning_goal": stage_goal,
"reasons": sem_reasons[:3],
"reasons": reasons[:3],
},
)
)

View File

@ -813,6 +813,112 @@ def _expand_stage_exclude_phrase(phrase: str) -> List[str]:
return out[:12]
def is_trainer_stage_anti_marker(raw: str) -> bool:
"""Trainer-/QS-Marker — nicht als Negationsphrase parsen."""
norm = _normalize_phrase(str(raw or ""))
if not norm:
return False
stripped = re.sub(r"[„“\"'«»]", "", norm)
stripped = re.sub(r"\s+", " ", stripped).strip()
if stripped.startswith("keine übung wie") or stripped.startswith("keine uebung wie"):
return True
return stripped.startswith("qs-hinweis")
def merge_stage_exclude_phrases(
learning_goal: str,
anti_patterns: Optional[Sequence[str]] = None,
) -> List[str]:
"""
Ausschlussphrasen für Stufen-Gates Negationen nur aus dem Lernziel expandieren,
explizite anti_patterns unverändert (ohne Trainer-Marker erneut zu parsen).
"""
lg = (learning_goal or "").strip()
exclude: List[str] = []
if len(lg) >= 3:
for item in parse_stage_goal_constraints(lg).exclude_phrases:
if item and item not in exclude:
exclude.append(item)
markers: List[str] = []
for raw in anti_patterns or []:
s = str(raw or "").strip()
if not s:
continue
if is_trainer_stage_anti_marker(s):
if s not in markers:
markers.append(s[:200])
continue
norm = _normalize_phrase(s)
if norm and norm not in exclude:
exclude.append(norm)
for marker in markers:
if marker not in exclude:
exclude.append(marker)
return exclude[:16]
def stage_focus_phrases_from_learning_goal(learning_goal: str) -> List[str]:
"""Mehrwort-Schwerpunkte aus Stufen-Lernziel für Fit-Scoring."""
lg = (learning_goal or "").strip()
if len(lg) < 3:
return []
tokens = _significant_stage_tokens(lg, strip_negated=True)
phrases: List[str] = []
norm_lg = _normalize_phrase(lg)
tech_hit = _find_technique_in_text(norm_lg)
if tech_hit:
primary = tech_hit[0]
if primary not in phrases:
phrases.append(primary)
if len(norm_lg) >= 8:
phrases.append(norm_lg[:120])
for i in range(len(tokens) - 1):
pair = f"{tokens[i]} {tokens[i + 1]}"
if len(pair) >= 8 and pair not in phrases:
phrases.append(pair)
for tok in tokens:
if len(tok) >= 6 and tok not in phrases:
phrases.append(tok)
return phrases[:8]
def stage_refinement_criteria_from_learning_goal(learning_goal: str) -> List[str]:
"""Erfolgskriterien für Phase C — nur aussagekräftige Mehrwort-Phrasen."""
lg = (learning_goal or "").strip()
if len(lg) < 3:
return []
norm_lg = _normalize_phrase(lg)
out: List[str] = []
if len(norm_lg) >= 15:
out.append(norm_lg[:120])
tokens = _significant_stage_tokens(lg, strip_negated=True)
for i in range(len(tokens) - 1):
a, b = tokens[i], tokens[i + 1]
if len(a) < 5 or len(b) < 5:
continue
pair = f"{a} {b}"
if len(pair) >= 12 and pair not in out:
out.append(pair)
return out[:3]
def exercise_title_matches_peer_stage_goal(
title: str,
*,
current_learning_goal: str,
peer_learning_goals: Sequence[str],
) -> bool:
"""Titel passt zum Lernziel einer anderen Roadmap-Stufe (Cross-Slot-Kollision)."""
current = (current_learning_goal or "").strip()
for peer in peer_learning_goals or []:
plg = (peer or "").strip()
if len(plg) < 3 or plg == current:
continue
if exercise_title_equivalent_to_stage_goal(title, plg):
return True
return False
def _significant_stage_tokens(learning_goal: str, *, strip_negated: bool = True) -> List[str]:
"""Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter, ohne Negationssegmente)."""
text = _normalize_phrase(learning_goal)
@ -850,9 +956,11 @@ def parse_stage_goal_constraints(
exclude.extend(_expand_stage_exclude_phrase(chunk))
for raw in anti_patterns or []:
if is_trainer_stage_anti_marker(str(raw or "")):
continue
s = _normalize_phrase(str(raw or ""))
if s:
exclude.extend(_expand_stage_exclude_phrase(s))
if s and s not in exclude:
exclude.append(s)
positive = _significant_stage_tokens(lg, strip_negated=True)
focus_hits = [t for t in positive if t in _STAGE_FOCUS_TOKENS]
@ -997,9 +1105,12 @@ def build_stage_match_brief(
for expanded in _expand_stage_exclude_phrase(str(raw or "")):
if expanded and expanded not in merged_anti:
merged_anti.append(expanded)
constraints = parse_stage_goal_constraints(lg, merged_anti)
constraints = parse_stage_goal_constraints(lg)
must: List[str] = []
norm_lg = _normalize_phrase(lg)
tech_hit = _find_technique_in_text(norm_lg)
if tech_hit and tech_hit[0] not in must:
must.insert(0, tech_hit[0])
if primary_path and primary_path not in must:
must.insert(0, primary_path[:120])
for token in constraints.positive_tokens:
@ -1031,11 +1142,13 @@ def build_stage_match_brief(
if ph:
arc.append(ph)
exclude_phrases = merge_stage_exclude_phrases(lg, merged_anti)
return PlanningSemanticBrief(
primary_topic="",
topic_type="focus",
must_phrases=must[:12],
exclude_phrases=list(constraints.exclude_phrases)[:12],
exclude_phrases=exclude_phrases[:12],
development_arc=arc[:4],
retrieval_query=" ".join(p for p in retrieval_parts if p)[:500],
semantic_strength=0.78,
@ -1062,19 +1175,49 @@ def score_exercise_stage_fit(
step_phase=step_phase,
)
blob = _blob_from_fields(title, summary, goal, variant_names or [])
focus_tokens = [
t
for t in (stage_brief.must_phrases or [])
if t and " " not in t and len(t) >= 4
][:6]
if focus_tokens:
hits = sum(1 for t in focus_tokens if _phrase_in_blob(t, blob))
ratio = hits / len(focus_tokens)
bonus = 0.28 * ratio
lg_hint = ""
for part in (stage_brief.retrieval_query or "").split("|"):
part = part.strip()
if part.lower().startswith("lernziel:"):
lg_hint = part.split(":", 1)[-1].strip()
break
if not lg_hint:
lg_hint = (stage_brief.retrieval_query or "").split("|")[0].strip()
if not lg_hint:
for mp in stage_brief.must_phrases or []:
if mp and len(_normalize_phrase(mp)) >= 8:
lg_hint = mp
break
focus_phrases = stage_focus_phrases_from_learning_goal(lg_hint) if lg_hint else []
tech_hit = _find_technique_in_text(_normalize_phrase(lg_hint)) if lg_hint else None
if not focus_phrases:
focus_phrases = [
t
for t in (stage_brief.must_phrases or [])
if t and len(_normalize_phrase(t)) >= 5
][:6]
if focus_phrases:
hits = sum(1 for p in focus_phrases if _phrase_in_blob(p, blob))
ratio = hits / len(focus_phrases)
bonus = 0.32 * ratio
if bonus > 0:
score = min(1.0, score + bonus)
if hits >= max(1, len(focus_tokens) // 2):
if hits >= max(1, len(focus_phrases) // 2):
reasons = ["Stufen-Schwerpunkte im Übungstext", *reasons]
non_tech = [
p
for p in focus_phrases
if not tech_hit or _normalize_phrase(p) != tech_hit[0]
]
specific_hits = sum(1 for p in non_tech if _phrase_in_blob(p, blob))
if tech_hit and _phrase_in_blob(tech_hit[0], blob) and specific_hits == 0:
score = min(score, 0.16)
if "Nur Technik-Bezug" not in reasons:
reasons = ["Nur Technik-Bezug, Stufen-Schwerpunkte fehlen", *reasons]
learning_goal_for_equiv = lg_hint or (stage_brief.must_phrases[0] if stage_brief.must_phrases else "")
if learning_goal_for_equiv and exercise_title_equivalent_to_stage_goal(title, learning_goal_for_equiv):
score = max(score, 0.42)
reasons = ["Titel entspricht Stufen-Lernziel", *reasons]
return max(0.0, min(1.0, round(score, 4))), reasons[:4]
@ -1099,11 +1242,13 @@ def exercise_passes_stage_fit(
return True
blob = _blob_from_fields(title, summary, goal, [])
constraints = parse_stage_goal_constraints(lg, anti_patterns)
if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases):
exclude_phrases = merge_stage_exclude_phrases(lg, anti_patterns)
if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
return False
title_equiv = exercise_title_equivalent_to_stage_goal(title, learning_goal or lg)
if title_equiv:
return True
primary_path = (path_primary_topic or "").strip()
if not primary_path and lg:
@ -1130,15 +1275,13 @@ def exercise_passes_stage_fit(
learning_goal=lg,
anti_patterns=anti_patterns,
)
stage_sem = stage_semantic_score
if stage_sem is None:
stage_sem, _ = score_exercise_stage_fit(
title=title,
summary=summary,
goal=goal,
stage_brief=brief,
step_phase=step_phase,
)
stage_sem, _ = score_exercise_stage_fit(
title=title,
summary=summary,
goal=goal,
stage_brief=brief,
step_phase=step_phase,
)
if relaxed:
threshold = _MIN_STAGE_FIT_RELAXED
@ -1146,7 +1289,19 @@ def exercise_passes_stage_fit(
threshold = _MIN_TITLE_EQUIV_SEMANTIC
else:
threshold = min_stage_semantic
return float(stage_sem or 0.0) >= threshold
if float(stage_sem or 0.0) >= threshold:
return True
if relaxed and not title_equiv:
focus = stage_focus_phrases_from_learning_goal(lg)
tech = _find_technique_in_text(_normalize_phrase(lg))
non_tech = [p for p in focus if not tech or _normalize_phrase(p) != tech[0]]
specific_hits = sum(1 for p in non_tech if _phrase_in_blob(p, blob))
if specific_hits >= 2 and float(stage_sem or 0.0) >= 0.14:
return True
return False
def apply_stage_match_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, float]:
@ -1269,17 +1424,23 @@ def _pick_roadmap_rank_fallback(
stage_anti_patterns: Optional[Sequence[str]] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[Sequence[str]] = None,
stage_match_brief: Optional[PlanningSemanticBrief] = None,
peer_learning_goals: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, Any]]:
"""
Roadmap-Notfall: bester Treffer nach Stufen-Ranking, wenn striktes Gate leer läuft.
Filtert weiterhin Ausschlüsse und Technik-Scope (Kumite etc.), aber ohne
Mindest-Semantik-Schwelle so finden auch wortnahe Bibliotheks-Übungen den Slot.
Weiterhin mit relaxed stage_fit kein blindes Ranking ohne Stufen-Passung.
"""
stage_goal = (stage_learning_goal or "").strip()
if not stage_goal or not hits:
return None
stage_brief = stage_match_brief or build_stage_match_brief(
learning_goal=stage_goal,
anti_patterns=stage_anti_patterns,
)
best: Optional[Dict[str, Any]] = None
best_key: Tuple[float, float] = (-1.0, -1.0)
for hit in hits:
@ -1292,35 +1453,31 @@ def _pick_roadmap_rank_fallback(
title = str(hit.get("title") or "")
summary = str(hit.get("summary") or "")
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
blob = _blob_from_fields(title, summary, goal_text, [])
constraints = parse_stage_goal_constraints(stage_goal, stage_anti_patterns)
if constraints.exclude_phrases and _blob_matches_stage_excludes(
blob, constraints.exclude_phrases
if peer_learning_goals and exercise_title_matches_peer_stage_goal(
title,
current_learning_goal=stage_goal,
peer_learning_goals=peer_learning_goals,
):
continue
title_equiv = exercise_title_equivalent_to_stage_goal(title, stage_goal)
primary = (path_primary_topic or "").strip()
if primary and not title_equiv:
tech_excludes = list(path_technique_excludes or [])
for item in technique_sibling_excludes(primary):
if item not in tech_excludes:
tech_excludes.append(item)
if not exercise_passes_technique_path_scope(
primary_topic=primary,
title=title,
summary=summary,
goal=goal_text,
learning_goal=stage_goal,
sibling_excludes=tech_excludes,
relaxed=True,
):
continue
rank_sem = float(
hit.get("stage_rank_semantic")
or hit.get("stage_semantic_score")
or hit.get("semantic_score")
or 0.0
)
if not exercise_passes_stage_fit(
learning_goal=stage_goal,
title=title,
summary=summary,
goal=goal_text,
stage_brief=stage_brief,
stage_semantic_score=rank_sem,
anti_patterns=stage_anti_patterns,
path_primary_topic=path_primary_topic,
path_technique_excludes=path_technique_excludes,
relaxed=True,
):
continue
score = float(hit.get("score") or 0.0)
key = (rank_sem, score)
if key > best_key:
@ -1342,6 +1499,7 @@ def pick_best_path_hit(
stage_match_brief: Optional[PlanningSemanticBrief] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[Sequence[str]] = None,
peer_learning_goals: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, Any]]:
"""Gestufte Auswahl: strikt → relaxed → optional Notfall-Fallback."""
if not hits:
@ -1366,6 +1524,12 @@ def pick_best_path_hit(
title = str(hit.get("title") or "")
summary = str(hit.get("summary") or "")
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
if peer_learning_goals and exercise_title_matches_peer_stage_goal(
title,
current_learning_goal=stage_goal,
peer_learning_goals=peer_learning_goals,
):
continue
sem = float(hit.get("semantic_score") or 0.0)
stage_sem = float(
hit.get("stage_rank_semantic")
@ -1414,14 +1578,7 @@ def pick_best_path_hit(
chosen = _scan(strict=False)
if chosen:
return chosen
return _pick_roadmap_rank_fallback(
hits,
used_exercise_ids,
stage_learning_goal=stage_goal,
stage_anti_patterns=stage_anti_patterns,
path_primary_topic=path_primary_topic,
path_technique_excludes=path_technique_excludes,
)
return None
chosen = _scan(strict=False)
if chosen:
@ -1461,12 +1618,17 @@ __all__ = [
"build_stage_match_brief",
"enrich_brief_with_path_constraints",
"exercise_passes_stage_fit",
"exercise_title_matches_peer_stage_goal",
"exercise_title_equivalent_to_stage_goal",
"resolve_path_primary_topic",
"resolve_path_anti_patterns",
"exercise_passes_stage_learning_goal_gate",
"is_trainer_stage_anti_marker",
"merge_semantic_brief_llm",
"merge_stage_exclude_phrases",
"parse_stage_goal_constraints",
"stage_focus_phrases_from_learning_goal",
"stage_refinement_criteria_from_learning_goal",
"pick_best_path_hit",
"exercise_passes_technique_path_scope",
"score_exercise_stage_fit",

View File

@ -0,0 +1,222 @@
"""
Phase C: Stufen-Spec verfeinern nach stage_mismatch, dann Rematch.
Deterministisch keine LLM-Ratelosigkeit. Schärft anti_patterns / success_criteria
aus QS-Finding, schließt abgelehnte Übung aus.
"""
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from planning_exercise_semantics import (
is_trainer_stage_anti_marker,
merge_stage_exclude_phrases,
parse_stage_goal_constraints,
stage_refinement_criteria_from_learning_goal,
)
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
def _resolve_major_index(
item: Mapping[str, Any],
stage_specs: Sequence[StageSpecArtifact],
) -> Optional[int]:
raw = item.get("roadmap_major_step_index")
if raw is not None:
return int(raw)
si = item.get("step_index")
if si is not None:
pos = int(si)
specs = list(stage_specs or [])
if 0 <= pos < len(specs):
return int(specs[pos].major_step_index)
return None
def collect_refine_stage_targets(
*,
optimization_hints: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
stage_specs: Sequence[StageSpecArtifact],
) -> Dict[int, Mapping[str, Any]]:
"""Major-Step-Indizes mit stage_mismatch / refine_stage_spec + Quell-Finding."""
targets: Dict[int, Mapping[str, Any]] = {}
def _register(midx: int, source: Mapping[str, Any]) -> None:
if midx not in targets:
targets[int(midx)] = dict(source)
for hint in optimization_hints or []:
if not isinstance(hint, dict):
continue
if str(hint.get("action") or "") != "refine_stage_spec":
continue
midx = _resolve_major_index(hint, stage_specs)
if midx is not None:
_register(midx, hint)
for item in off_topic_steps or []:
if not isinstance(item, dict):
continue
if str(item.get("issue") or "") != "stage_mismatch":
continue
midx = _resolve_major_index(item, stage_specs)
if midx is not None:
_register(midx, item)
return targets
def _append_unique_strings(dest: List[str], items: Sequence[str], *, limit: int = 14) -> List[str]:
out = list(dest or [])
for raw in items:
s = str(raw or "").strip()
if not s or s in out:
continue
out.append(s[:200])
if len(out) >= limit:
break
return out
def _rejected_exercise_marker(title: str) -> str:
return f"keine Übung wie „{title[:120]}"
def refine_stage_spec_artifact(
spec: StageSpecArtifact,
*,
finding: Mapping[str, Any],
goal_query: str = "",
semantic_brief: Optional[Any] = None,
path_anti_patterns: Optional[Sequence[str]] = None,
) -> Tuple[StageSpecArtifact, List[str]]:
"""
Schärft eine StageSpec aus QS-Finding. Returns (neue Spec, Änderungsliste).
Pfad-Ausschlüsse werden beim Match separat gemerged nicht in stage_spec duplizieren.
"""
del goal_query, semantic_brief, path_anti_patterns
learning_goal = (
str(finding.get("roadmap_learning_goal") or spec.learning_goal or "").strip()
or spec.learning_goal
)
anti = [a for a in list(spec.anti_patterns or []) if not is_trainer_stage_anti_marker(a)]
success = list(spec.success_criteria or [])
changes: List[str] = []
rejected_title = str(finding.get("title") or "").strip()
if rejected_title:
marker = _rejected_exercise_marker(rejected_title)
if marker not in anti:
anti.append(marker)
changes.append(f"Ausschluss abgelehnter Übung: {rejected_title[:80]}")
goal_excludes = parse_stage_goal_constraints(learning_goal).exclude_phrases
for phrase in goal_excludes or []:
if phrase and phrase not in anti:
anti.append(phrase)
changes.append(f"Ausschluss aus Lernziel: {phrase[:60]}")
for phrase in stage_refinement_criteria_from_learning_goal(learning_goal):
crit = f"Bezug zu Stufen-Lernziel: {phrase[:100]}"
if crit not in success:
success.append(crit)
changes.append(f"Erfolgskriterium: {phrase[:60]}")
for raw in finding.get("reasons") or []:
r = str(raw or "").strip()
if len(r) < 8:
continue
if r == "Kern-Thema der Anfrage im Übungstext":
continue
crit = f"QS-Hinweis: {r[:120]}"
if crit not in success:
success.append(crit)
if len(changes) < 6:
changes.append(f"Kriterium aus QS: {r[:60]}")
if len(success) >= 8:
break
if not changes:
return spec, []
refined = StageSpecArtifact(
major_step_index=spec.major_step_index,
learning_goal=learning_goal or spec.learning_goal,
start_state=spec.start_state,
target_state=spec.target_state,
load_profile=list(spec.load_profile or []),
exercise_type=spec.exercise_type,
success_criteria=success[:8],
anti_patterns=merge_stage_exclude_phrases(learning_goal, anti)[:14],
)
return refined, changes
def apply_stage_spec_refinements(
roadmap_ctx: ProgressionRoadmapContext,
*,
optimization_hints: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
goal_query: str,
semantic_brief: Optional[Any] = None,
) -> Tuple[List[StageSpecArtifact], List[Dict[str, Any]]]:
"""
Wendet refine_stage_spec auf betroffene Slots an (mutiert stage_specs in ctx).
Returns: (stage_specs, refine_log)
"""
del goal_query, semantic_brief
stage_specs = list(roadmap_ctx.stage_specs or [])
if not stage_specs:
return stage_specs, []
targets = collect_refine_stage_targets(
optimization_hints=optimization_hints,
off_topic_steps=off_topic_steps,
stage_specs=stage_specs,
)
if not targets:
return stage_specs, []
spec_by_major = {int(s.major_step_index): s for s in stage_specs}
refine_log: List[Dict[str, Any]] = []
for midx in sorted(targets):
spec = spec_by_major.get(int(midx))
if spec is None:
continue
refined_spec, changes = refine_stage_spec_artifact(
spec,
finding=targets[midx],
)
if not changes:
continue
spec_by_major[int(midx)] = refined_spec
rejected_id = targets[midx].get("exercise_id")
refine_log.append(
{
"roadmap_major_step_index": int(midx),
"action": "refined",
"issue": "stage_mismatch",
"rejected_title": targets[midx].get("title"),
"rejected_exercise_id": int(rejected_id) if rejected_id else None,
"changes": changes[:6],
"reason": (changes[0] if changes else "refine_stage_spec")[:400],
}
)
if not refine_log:
return stage_specs, []
ordered = [spec_by_major[int(s.major_step_index)] for s in stage_specs]
roadmap_ctx.stage_specs = ordered
return ordered, refine_log
__all__ = [
"apply_stage_spec_refinements",
"collect_refine_stage_targets",
"refine_stage_spec_artifact",
]

View File

@ -115,6 +115,8 @@ def rematch_roadmap_slots(
slot_indices: Set[int],
rematch_reasons: Mapping[int, str],
match_slot_fn,
rejected_by_major: Optional[Mapping[int, Set[int]]] = None,
slot_assignment_history: Optional[Mapping[int, Set[int]]] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
"""
Ersetzt nur betroffene Slots; andere Schritte und used-Set bleiben konsistent.
@ -152,6 +154,9 @@ def rematch_roadmap_slots(
}
if old and old.get("exercise_id") is not None:
used.add(int(old["exercise_id"]))
for rejected_id in rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set():
if rejected_id > 0:
used.add(int(rejected_id))
planned_ids, anchor_id, anchor_variant_id = _context_before_major(
steps_by_major, int(major_idx)
)
@ -176,6 +181,18 @@ def rematch_roadmap_slots(
)
reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
if new_step:
try:
new_eid = int(new_step.get("exercise_id") or 0)
except (TypeError, ValueError):
new_eid = 0
hist = (
slot_assignment_history.get(int(major_idx), set())
if slot_assignment_history
else set()
)
if new_eid > 0 and new_eid in hist:
new_step = None
if new_step:
steps_by_major[int(major_idx)] = new_step
rematch_log.append(
@ -190,8 +207,29 @@ def rematch_roadmap_slots(
}
)
else:
goal = (stage_spec.learning_goal or "").strip()
major = None
if roadmap_ctx.roadmap:
major = next(
(m for m in roadmap_ctx.roadmap.major_steps if int(m.index) == int(major_idx)),
None,
)
steps_by_major[int(major_idx)] = {
"exercise_id": None,
"variant_id": None,
"title": goal or f"Slot {major_idx + 1}",
"is_ai_proposal": False,
"roadmap_major_step_index": int(major_idx),
"roadmap_phase": major.phase if major else None,
"roadmap_learning_goal": goal or None,
"roadmap_match_source": "unfilled",
"slot_status": "unfilled",
"reasons": ["Keine passende Übung für Roadmap-Stufe"],
}
if unfilled_spec is not None:
new_unfilled.append((step_index, unfilled_spec))
elif stage_spec is not None:
new_unfilled.append((step_index, stage_spec))
rematch_log.append(
{
"roadmap_major_step_index": int(major_idx),

View File

@ -37,8 +37,9 @@ class GraphPlanningRoadmapArtifact(BaseModel):
path_skill_expectations: Optional[Dict[str, Any]] = None
slot_contents: Optional[List[SlotContentEntry]] = None
last_findings: Optional[Dict[str, Any]] = None
planning_catalog_context: Optional[Dict[str, Any]] = None
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", mode="before")
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before")
@classmethod
def _empty_dict_to_none(cls, v):
if v == {}:

View File

@ -0,0 +1,47 @@
"""Tests Katalog-Kontext für Progressionsgraph-Matching."""
from planning_catalog_context import (
ProgressionPlanningCatalogContext,
PlanningCatalogContextItem,
catalog_context_has_items,
merge_catalog_context_into_target,
)
from planning_exercise_profiles import PlanningTargetProfile
def test_catalog_context_has_items():
assert not catalog_context_has_items(None)
assert not catalog_context_has_items(ProgressionPlanningCatalogContext())
assert catalog_context_has_items(
ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=3, is_primary=True)],
)
)
def test_merge_catalog_context_into_target_sets_focus():
base = PlanningTargetProfile(sources=["query_only"])
merged = merge_catalog_context_into_target(
base,
ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=7, is_primary=True)],
training_types=[PlanningCatalogContextItem(id=2, is_primary=True)],
),
)
assert merged.focus_area_ids.get(7, 0) > 0.5
assert merged.training_type_ids.get(2, 0) > 0.5
assert "catalog_context" in merged.sources
def test_normalize_planning_roadmap_with_catalog_context():
from progression_graph_planning_artifact import normalize_planning_roadmap_payload
out = normalize_planning_roadmap_payload(
{
"goal_query": "Deeskalation Kinder",
"planning_catalog_context": {
"focus_areas": [{"id": 4, "is_primary": True}],
"target_groups": [{"id": 9, "is_primary": True}],
},
}
)
assert out["planning_catalog_context"]["focus_areas"][0]["id"] == 4

View File

@ -0,0 +1,100 @@
"""Tests Phase C — refine_stage_spec nach stage_mismatch."""
from planning_path_refine_stage import (
apply_stage_spec_refinements,
collect_refine_stage_targets,
refine_stage_spec_artifact,
)
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
def _spec(major=1, goal="Koordination Absprung ohne Tritttechnik"):
return StageSpecArtifact(
major_step_index=major,
learning_goal=goal,
load_profile=["koordination"],
exercise_type="kihon_einzel",
)
def test_collect_refine_stage_targets_from_hint_and_off_topic():
specs = [_spec(0, "A"), _spec(1, "B"), _spec(2, "C")]
hints = [
{
"action": "refine_stage_spec",
"roadmap_major_step_index": 1,
"reason": "Passt nicht zum Stufen-Lernziel",
}
]
off_topic = [
{
"issue": "stage_mismatch",
"step_index": 2,
"roadmap_major_step_index": 2,
"title": "Kumite Drill",
}
]
targets = collect_refine_stage_targets(
optimization_hints=hints,
off_topic_steps=off_topic,
stage_specs=specs,
)
assert targets.keys() == {1, 2}
def test_refine_stage_spec_adds_rejected_title_and_criteria():
spec = _spec()
finding = {
"title": "Mawashi Trittpräzision",
"roadmap_learning_goal": spec.learning_goal,
"reasons": ["Semantik zu schwach für Stufen-Lernziel"],
}
refined, changes = refine_stage_spec_artifact(
spec,
finding=finding,
)
assert changes
assert any("Mawashi" in a and "Tritt" in a for a in refined.anti_patterns)
assert refined.success_criteria
assert not any("anderetechnikals" in a.replace(" ", "") for a in refined.anti_patterns)
def test_apply_stage_spec_refinements_mutates_context():
specs = [_spec(0, "Stand"), _spec(1, "Sprungkoordination")]
ctx = ProgressionRoadmapContext(
goal_query="Mawashi Geri",
max_steps=2,
stage_specs=specs,
)
_, log = apply_stage_spec_refinements(
ctx,
optimization_hints=[],
off_topic_steps=[
{
"issue": "stage_mismatch",
"roadmap_major_step_index": 1,
"title": "Yoko Geri",
"roadmap_learning_goal": "Sprungkoordination",
}
],
goal_query="Mawashi Geri",
)
assert len(log) == 1
assert log[0]["action"] == "refined"
assert ctx.stage_specs[1].anti_patterns
assert any("Yoko Geri" in a for a in ctx.stage_specs[1].anti_patterns)
def test_refine_no_op_when_no_finding_data():
spec = StageSpecArtifact(
major_step_index=1,
learning_goal="",
load_profile=[],
exercise_type="kihon_einzel",
)
refined, changes = refine_stage_spec_artifact(
spec,
finding={"issue": "stage_mismatch"},
goal_query="x",
)
assert changes == []
assert refined is spec

View File

@ -183,3 +183,55 @@ def test_rematch_excludes_replaced_exercise_from_used():
match_slot_fn=_fake_match,
)
assert 99 in seen_used[0]
def test_rematch_unfilled_leaves_placeholder_step():
specs = _stage_specs()
ctx = ProgressionRoadmapContext(
goal_query="Mae Geri",
max_steps=3,
stage_specs=specs,
)
steps = [
{"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
{"exercise_id": 99, "title": "Falsch", "roadmap_major_step_index": 1},
]
def _no_match(cur, *, stage_spec, **kwargs):
return None, stage_spec
ordered, log, unfilled = rematch_roadmap_slots(
None,
tenant=None,
body=None,
goal_query="Mae Geri",
max_steps=3,
semantic_brief=None,
path_target_profile=None,
path_intent="",
roadmap_ctx=ctx,
steps=steps,
slot_indices={1},
rematch_reasons={1: "stage_mismatch"},
match_slot_fn=_no_match,
)
assert len(ordered) == 2
slot1 = ordered[1]
assert slot1["exercise_id"] is None
assert slot1["slot_status"] == "unfilled"
assert slot1["roadmap_match_source"] == "unfilled"
assert log[0]["action"] == "rematch_unfilled"
assert len(unfilled) == 1
def test_prune_filled_from_roadmap_unfilled():
from planning_exercise_path_builder import _prune_filled_from_roadmap_unfilled
spec = StageSpecArtifact(major_step_index=5, learning_goal="Zielgenauigkeit")
steps = [{"exercise_id": 99, "roadmap_major_step_index": 5}]
kept = _prune_filled_from_roadmap_unfilled(steps, [(4, spec)])
assert kept == []
unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}]
kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)])
assert len(kept2) == 1

View File

@ -270,8 +270,8 @@ def test_pick_roadmap_relaxed_for_non_technique_stage():
{
"id": 11,
"title": "Adduktoren Dehnung am Boden",
"summary": "Flexibilität Hüfte",
"goal": "Mobilität",
"summary": "Flexibilität Hüfte, Adduktoren dehnen",
"goal": "Mobilität — Adduktoren dehnen",
"score": 0.68,
"semantic_score": 0.22,
"stage_semantic_score": 0.22,

View File

@ -0,0 +1,121 @@
"""Tests für Stufen-Ausschlüsse und Anti-Pattern-Sanitizer."""
from planning_exercise_semantics import (
exercise_passes_stage_fit,
is_trainer_stage_anti_marker,
merge_stage_exclude_phrases,
parse_stage_goal_constraints,
score_exercise_stage_fit,
build_stage_match_brief,
)
def test_trainer_anti_marker_not_reparsed_as_negation():
marker = 'keine Übung wie „One Leg Squat“'
assert is_trainer_stage_anti_marker(marker)
excludes = merge_stage_exclude_phrases(
"Gleichgewichtstritt Mae-Geri",
[marker, "kumite"],
)
assert "onelegsquat" not in "".join(excludes).replace(" ", "")
assert "kumite" in excludes
def test_parse_stage_goal_constraints_skips_trainer_markers_in_anti():
marker = 'keine Übung wie „Erlernen des Mae Geri aus zusammengesetzten Einzelbewegungen“'
result = parse_stage_goal_constraints(
"Koordination ohne Kumite",
[marker],
)
joined = " ".join(result.exclude_phrases)
assert "keine uebung wie" not in joined
assert "kumite" in joined
def test_title_equivalent_passes_stage_fit_despite_low_semantic():
goal = "Gleichgewichtstritt Mae-Geri"
assert exercise_passes_stage_fit(
learning_goal=goal,
title="Gleichgewichtstritt Mae-Geri",
summary="Balance und Treffpunkt variieren.",
goal="Mae Geri aus Stand.",
stage_semantic_score=0.05,
)
def test_stage_focus_scoring_rewards_learning_goal_tokens():
goal = "Erlernen des Mae Geri aus zusammengesetzten Einzelbewegungen"
brief = build_stage_match_brief(learning_goal=goal)
score, reasons = score_exercise_stage_fit(
title="Mae Geri aus Einzelteilen",
summary="Zusammensetzung aus Schritt und Armschwingung.",
goal="Einzelbewegungen verbinden.",
stage_brief=brief,
)
assert score >= 0.25
def test_rank_fallback_requires_relaxed_stage_fit():
goal_a = "Grundlegende Körperhaltung und erste Mae Geri Bewegung"
goal_b = "Präzise Trefferfläche und variable Distanzen"
hits = [
{
"id": 1,
"title": "Gleichgewichtstritt Mae-Geri",
"summary": "Balance",
"goal": "Mae Geri",
"stage_rank_semantic": 0.04,
"score": 0.5,
},
{
"id": 2,
"title": "Erlernen des Mae Geri aus zusammengesetzten Einzelbewegungen",
"summary": "Teile verbinden",
"goal": "Zusammensetzung",
"stage_rank_semantic": 0.03,
"score": 0.48,
},
]
from planning_exercise_semantics import pick_best_path_hit
brief_a = build_stage_match_brief(learning_goal=goal_a)
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=goal_a,
roadmap_stage_match=True,
stage_match_brief=brief_a,
peer_learning_goals=[goal_b],
)
assert chosen is None
def test_peer_stage_title_blocked_for_wrong_slot():
goal_a = "Grundlegende Körperhaltung und erste Mae Geri Bewegung"
goal_b = "Gleichgewichtstritt Mae-Geri"
from planning_exercise_semantics import exercise_title_matches_peer_stage_goal, pick_best_path_hit
assert exercise_title_matches_peer_stage_goal(
"Gleichgewichtstritt Mae-Geri",
current_learning_goal=goal_a,
peer_learning_goals=[goal_b],
)
hits = [
{
"id": 10,
"title": "Gleichgewichtstritt Mae-Geri",
"summary": "Balance auf einem Bein",
"goal": "Mae Geri aus Gleichgewicht",
"stage_rank_semantic": 0.35,
"score": 0.6,
}
]
brief_a = build_stage_match_brief(learning_goal=goal_a)
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=goal_a,
roadmap_stage_match=True,
stage_match_brief=brief_a,
peer_learning_goals=[goal_b],
)
assert chosen is None

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.226"
APP_VERSION = "0.8.233"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090"
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.23.1", # Phase B: Rematch-Schleife mit optimization_hints + roadmap_unfilled
"planning_exercise_suggest": "0.23.8", # Progressionsgraph: Katalog-Kontext (Fokus/Stil/TT/ZG) im Match-Profil
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung

View File

@ -6,6 +6,11 @@ import {
offerCanExpandSlots,
offerNeedsNewSlot,
offerSourceLabel,
optimizationHintActionLabel,
formatRematchLogEntry,
formatRefineLogEntry,
hasRematchSlotHints,
resolveHintSlotIndex,
resolveOfferSlotIndex,
} from '../utils/progressionGraphDraft'
@ -153,10 +158,17 @@ export default function ProgressionFindingsPanel({
onApplyGapOffer,
onInsertGapSlot,
onGenerateGapAi,
onRematchSlots = null,
rematchBusy = false,
generatingOfferId = null,
aiBusy = false,
evaluateDisabled = false,
}) {
const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : []
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function'
return (
<div className="card" style={{ position: 'sticky', top: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
@ -221,6 +233,89 @@ export default function ProgressionFindingsPanel({
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
</p>
) : null}
{pathQa.rematch_applied && rematchLog.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
Auto-Rematch
{pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''}
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
{rematchLog.map((entry, i) => (
<li key={`rematch-${i}-${entry.roadmap_major_step_index}-${entry.action}`}>
{formatRematchLogEntry(entry)}
</li>
))}
</ul>
</>
) : null}
{pathQa.refine_applied && refineLog.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
Stufen-Spec verfeinert ({refineLog.length})
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
{refineLog.map((entry, i) => (
<li key={`refine-${i}-${entry.roadmap_major_step_index}`}>
{formatRefineLogEntry(entry)}
</li>
))}
</ul>
</>
) : null}
{optimizationHints.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
Optimierungspotenziale ({optimizationHints.length})
</p>
<ul
style={{
margin: 0,
padding: 0,
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{optimizationHints.slice(0, 8).map((hint, i) => {
const slotIdx = resolveHintSlotIndex(hint, draft)
return (
<li
key={`hint-${i}-${hint.action}-${hint.issue}-${slotIdx ?? 'x'}`}
style={{
padding: '8px 10px',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '11px',
color: 'var(--text2)',
}}
>
<span className="exercise-tag" style={{ marginBottom: '4px', display: 'inline-block' }}>
{optimizationHintActionLabel(hint.action)}
{slotIdx != null ? ` · Slot ${slotIdx + 1}` : ''}
</span>
{hint.title ? (
<div style={{ fontWeight: 600, color: 'var(--text1)' }}>{hint.title}</div>
) : null}
{hint.reason ? <p style={{ margin: '4px 0 0' }}>{hint.reason}</p> : null}
</li>
)
})}
</ul>
{showRematchAction ? (
<button
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: '8px', fontSize: '12px' }}
disabled={rematchBusy || evaluateDisabled}
onClick={onRematchSlots}
>
{rematchBusy ? 'Match läuft…' : 'Betroffene Slots neu matchen'}
</button>
) : null}
</>
) : null}
</div>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>

View File

@ -45,6 +45,10 @@ import {
slotsToSlotAssignments,
syncProgressionRoadmapFromSlots,
syncSlotPhasesFromRoadmap,
EMPTY_PLANNING_CATALOG_CONTEXT,
getCatalogSelectId,
planningCatalogContextToApi,
setCatalogSelectItems,
} from '../utils/progressionGraphDraft'
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
@ -86,6 +90,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [semanticBrief, setSemanticBrief] = useState(null)
const [targetSummary, setTargetSummary] = useState(null)
const [focusAreas, setFocusAreas] = useState([])
const [styleDirections, setStyleDirections] = useState([])
const [trainingTypes, setTrainingTypes] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [skillsCatalog, setSkillsCatalog] = useState([])
const [activeOffer, setActiveOffer] = useState(null)
@ -144,16 +151,25 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
let cancelled = false
Promise.all([
api.listFocusAreas({ status: 'active' }),
api.listStyleDirections({ status: 'active' }),
api.listTrainingTypes({ status: 'active' }),
api.listTargetGroups({ status: 'active' }),
api.listSkillsCatalog({ status: 'active' }),
])
.then(([fa, sk]) => {
.then(([fa, sd, tt, tg, sk]) => {
if (cancelled) return
setFocusAreas(Array.isArray(fa) ? fa : [])
setStyleDirections(Array.isArray(sd) ? sd : [])
setTrainingTypes(Array.isArray(tt) ? tt : [])
setTargetGroups(Array.isArray(tg) ? tg : [])
setSkillsCatalog(Array.isArray(sk) ? sk : [])
})
.catch(() => {
if (!cancelled) {
setFocusAreas([])
setStyleDirections([])
setTrainingTypes([])
setTargetGroups([])
setSkillsCatalog([])
}
})
@ -280,6 +296,24 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
}, [draft?.slots])
const catalogCtx = draft?.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT
const patchCatalogDimension = (key, value) => {
patchDraft((d) => ({
...d,
dirty: true,
planningCatalogContext: {
...(d.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT),
[key]: setCatalogSelectItems(d.planningCatalogContext?.[key], value),
},
}))
}
const catalogApiPayload = useMemo(
() => planningCatalogContextToApi(catalogCtx),
[catalogCtx],
)
const runAnalyzeStartTarget = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
@ -303,6 +337,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
start_target_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
})
const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort')
@ -346,6 +381,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
roadmap_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
})
const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
@ -420,6 +456,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
})
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{
@ -435,10 +472,22 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setPathQa(res?.path_qa || null)
setGapFillOffers(remainingOffers)
const ms = res?.match_summary
const rematchLog = res?.path_qa?.rematch_log
const rematchRounds = res?.path_qa?.rematch_rounds
if (ms) {
setMatchNotice(
const parts = [
`Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`,
)
]
if (rematchRounds > 0 && Array.isArray(rematchLog) && rematchLog.length > 0) {
parts.push(
`Auto-Rematch: ${rematchLog.length} Anpassung(en) in ${rematchRounds} Runde(n).`,
)
}
const refineLog = res?.path_qa?.refine_log
if (Array.isArray(refineLog) && refineLog.length > 0) {
parts.push(`Stufen-Spec: ${refineLog.length} Slot(s) verfeinert.`)
}
setMatchNotice(parts.join(' '))
}
try {
await saveProgressionGraphDraft(api, graphId, {
@ -481,6 +530,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
roadmap_override: override,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
})
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
@ -859,6 +909,83 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
/>
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '10px',
marginTop: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Primärfokus</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.focusAreas)}
onChange={(e) => patchCatalogDimension('focusAreas', e.target.value)}
>
<option value=""> optional </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Stilrichtung</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.styleDirections)}
onChange={(e) => patchCatalogDimension('styleDirections', e.target.value)}
>
<option value=""> optional </option>
{(styleDirections || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.trainingTypes)}
onChange={(e) => patchCatalogDimension('trainingTypes', e.target.value)}
>
<option value=""> optional </option>
{(trainingTypes || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Zielgruppe</label>
<select
className="form-input"
disabled={busy}
value={getCatalogSelectId(catalogCtx.targetGroups)}
onChange={(e) => patchCatalogDimension('targetGroups', e.target.value)}
>
<option value=""> optional </option>
{(targetGroups || []).map((row) => (
<option key={row.id} value={String(row.id)}>
{row.name}
</option>
))}
</select>
</div>
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) unabhängig
von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert.
</p>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.4 }}>
Optional zuerst Start/Ziel analysieren, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
@ -954,6 +1081,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
onApplyGapOffer={handleApplyGapOffer}
onInsertGapSlot={handleInsertGapSlot}
onGenerateGapAi={openGapFillPrep}
onRematchSlots={runMatch}
rematchBusy={matching}
generatingOfferId={generatingOfferId}
aiBusy={gapAiBusy}
evaluateDisabled={busy || !draft.goalQuery?.trim()}

View File

@ -7,6 +7,73 @@ export const SLOT_MAX = 10
export const SLOT_MIN = 2
export const PLANNING_ARTIFACT_SCHEMA = 1
export const EMPTY_PLANNING_CATALOG_CONTEXT = {
focusAreas: [],
styleDirections: [],
trainingTypes: [],
targetGroups: [],
}
function mapCatalogItemsFromApi(list) {
if (!Array.isArray(list)) return []
return list
.map((row) => ({
id: Number(row?.id),
isPrimary: Boolean(row?.is_primary ?? row?.isPrimary),
weight: row?.weight != null ? Number(row.weight) : 1,
}))
.filter((row) => Number.isFinite(row.id) && row.id > 0)
}
export function parsePlanningCatalogContextFromArtifact(artifact) {
const raw = artifact?.planning_catalog_context
if (!raw || typeof raw !== 'object') return { ...EMPTY_PLANNING_CATALOG_CONTEXT }
return {
focusAreas: mapCatalogItemsFromApi(raw.focus_areas),
styleDirections: mapCatalogItemsFromApi(raw.style_directions),
trainingTypes: mapCatalogItemsFromApi(raw.training_types),
targetGroups: mapCatalogItemsFromApi(raw.target_groups),
}
}
export function getCatalogSelectId(items) {
const list = Array.isArray(items) ? items : []
const primary = list.find((x) => x.isPrimary) || list[0]
return primary?.id != null && Number.isFinite(Number(primary.id)) ? String(primary.id) : ''
}
export function setCatalogSelectItems(_items, id) {
const n = Number(id)
if (!Number.isFinite(n) || n < 1) return []
return [{ id: n, isPrimary: true, weight: 1 }]
}
export function planningCatalogContextToApi(ctx) {
const mapOut = (items) =>
(items || [])
.filter((row) => row?.id != null && Number(row.id) > 0)
.map((row) => ({
id: Number(row.id),
is_primary: Boolean(row.isPrimary),
weight: Number.isFinite(Number(row.weight)) ? Number(row.weight) : 1,
}))
const focus_areas = mapOut(ctx?.focusAreas)
const style_directions = mapOut(ctx?.styleDirections)
const training_types = mapOut(ctx?.trainingTypes)
const target_groups = mapOut(ctx?.targetGroups)
if (!focus_areas.length && !style_directions.length && !training_types.length && !target_groups.length) {
return {}
}
return {
planning_catalog_context: {
focus_areas,
style_directions,
training_types,
target_groups,
},
}
}
/** Start/Ziel/Ergänzungen aus KI-Roadmap-Antwort (resolved_structured). */
export function resolvedStructuredFromRoadmap(progressionRoadmap) {
const rs = progressionRoadmap?.resolved_structured
@ -62,6 +129,63 @@ export function offerSourceLabel(source) {
return OFFER_SOURCE_LABELS[source] || source || 'Angebot'
}
const OPTIMIZATION_ACTION_LABELS = {
rematch_slot: 'Slot neu matchen',
bridge_or_gap_fill: 'Brücke / KI-Angebot',
refine_stage_spec: 'Stufen-Spec verfeinern',
review_roadmap: 'Roadmap prüfen',
review: 'Prüfen',
}
export function optimizationHintActionLabel(action) {
return OPTIMIZATION_ACTION_LABELS[action] || action || 'Hinweis'
}
/** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */
export function resolveHintSlotIndex(hint, draft = null) {
if (!hint || typeof hint !== 'object') return null
const raw = hint.roadmap_major_step_index ?? hint.step_index
if (raw == null || !Number.isFinite(Number(raw))) return null
const idx = Number(raw)
const slotCount = draft?.slots?.length
if (slotCount != null && (idx < 0 || idx >= slotCount)) return null
return idx
}
export function formatRematchLogEntry(entry) {
if (!entry || typeof entry !== 'object') return ''
const slot = Number.isFinite(Number(entry.roadmap_major_step_index))
? `Slot ${Number(entry.roadmap_major_step_index) + 1}`
: 'Slot'
const round = entry.round != null ? ` (Runde ${entry.round})` : ''
if (entry.action === 'replaced') {
const from = entry.replaced_title || (entry.replaced_exercise_id ? `#${entry.replaced_exercise_id}` : '—')
const to = entry.new_title || (entry.new_exercise_id ? `#${entry.new_exercise_id}` : '—')
return `${slot}${round}: „${from}“ → „${to}`
}
if (entry.action === 'rematch_unfilled') {
return `${slot}${round}: kein passender Ersatz (${entry.reason || 'unfilled'})`
}
return `${slot}${round}: ${entry.reason || entry.action || 'Rematch'}`
}
export function formatRefineLogEntry(entry) {
if (!entry || typeof entry !== 'object') return ''
const slot = Number.isFinite(Number(entry.roadmap_major_step_index))
? `Slot ${Number(entry.roadmap_major_step_index) + 1}`
: 'Slot'
const round = entry.round != null ? ` (Runde ${entry.round})` : ''
const changes = Array.isArray(entry.changes) ? entry.changes.join('; ') : entry.reason
return `${slot}${round}: Stufen-Spec geschärft — ${changes || 'refine_stage_spec'}`
}
export function hasRematchSlotHints(pathQa) {
return (pathQa?.optimization_hints || []).some((h) => {
const action = h?.action
return action === 'rematch_slot' || action === 'refine_stage_spec'
})
}
function createEmptySlot(index) {
const phase = ROADMAP_PHASES[Math.min(index, ROADMAP_PHASES.length - 1)]
return {
@ -654,6 +778,7 @@ export function hydrateProgressionGraphDraft({
slots,
pathSkillExpectations: artifact?.path_skill_expectations || null,
progressionRoadmap: artifact?.progression_roadmap || null,
planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact),
lastFindings: artifact?.last_findings || null,
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
siblingEdgeIds: siblingEdges.map((e) => e.id),
@ -684,6 +809,11 @@ export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined
slot_contents,
}
const catalog = planningCatalogContextToApi(draft.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT)
if (catalog.planning_catalog_context) {
artifact.planning_catalog_context = catalog.planning_catalog_context
}
const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
if (findings) artifact.last_findings = findings
@ -799,21 +929,16 @@ export function slotsToEvaluateSteps(draft) {
export function applyMatchStepsToSlots(draft, apiSteps) {
const steps = Array.isArray(apiSteps) ? apiSteps : []
const nextSlots = (draft.slots || []).map((slot) => ({
...slot,
primary: { ...slot.primary },
siblings: [...(slot.siblings || [])],
}))
const touchedMajors = new Set()
const stepByMajor = new Map()
for (const step of steps) {
if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
continue
}
const idx = Number(step.roadmap_major_step_index)
if (idx < 0 || idx >= nextSlots.length) continue
touchedMajors.add(idx)
stepByMajor.set(Number(step.roadmap_major_step_index), step)
}
const mapStepToPrimary = (step, slot) => {
const midx = Number(slot.majorStepIndex)
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key)
const isUnfilledSlot =
@ -823,33 +948,50 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
Boolean(step.gap_offer)
if (isProposal && !hasAiPayload && isUnfilledSlot) {
const offer = step.gap_offer || {}
nextSlots[idx].primary = proposalSlotExercise({
return proposalSlotExercise({
title:
offer.title_hint ||
step.roadmap_learning_goal ||
step.title ||
nextSlots[idx].learning_goal ||
`Slot ${idx + 1}`,
proposalKey: offer.offer_id || step.proposal_key || `roadmap-unfilled-${idx}`,
slot.learning_goal ||
`Slot ${midx + 1}`,
proposalKey: offer.offer_id || step.proposal_key || `roadmap-unfilled-${midx}`,
aiSuggestion: offer.ai_suggestion || null,
})
} else if (isProposal && !hasAiPayload) {
nextSlots[idx].primary = emptySlotExercise()
} else if (isProposal) {
nextSlots[idx].primary = proposalSlotExercise({
title: step.title || nextSlots[idx].learning_goal,
}
if (isProposal && !hasAiPayload) {
return emptySlotExercise()
}
if (isProposal) {
return proposalSlotExercise({
title: step.title || slot.learning_goal,
proposalKey: step.proposal_key,
aiSuggestion: step.ai_suggestion,
})
} else {
nextSlots[idx].primary = librarySlotExercise({
exerciseId: step.exercise_id,
exerciseTitle: step.title || `Übung #${step.exercise_id}`,
variantId: step.variant_id,
})
}
return librarySlotExercise({
exerciseId: step.exercise_id,
exerciseTitle: step.title || `Übung #${step.exercise_id}`,
variantId: step.variant_id,
})
}
const nextSlots = (draft.slots || []).map((slot) => {
const base = {
...slot,
primary: { ...slot.primary },
siblings: [...(slot.siblings || [])],
}
const step = stepByMajor.get(Number(slot.majorStepIndex))
if (!step) {
return base
}
return {
...base,
primary: mapStepToPrimary(step, slot),
}
})
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
}