diff --git a/backend/planning_catalog_context.py b/backend/planning_catalog_context.py new file mode 100644 index 0000000..d1dd143 --- /dev/null +++ b/backend/planning_catalog_context.py @@ -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", +] diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py index 81373bf..a679584 100644 --- a/backend/planning_exercise_path_ai_fill.py +++ b/backend/planning_exercise_path_ai_fill.py @@ -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, } ) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 58c08fe..6d3fcc7 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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, diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index d3e50a7..472ec35 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -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], }, ) ) diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 501c884..c0433f7 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -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", diff --git a/backend/planning_path_refine_stage.py b/backend/planning_path_refine_stage.py new file mode 100644 index 0000000..72e3d73 --- /dev/null +++ b/backend/planning_path_refine_stage.py @@ -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", +] diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py index 4bc19dd..200c3c7 100644 --- a/backend/planning_path_rematch.py +++ b/backend/planning_path_rematch.py @@ -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), diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py index 0a75480..3cffdaf 100644 --- a/backend/progression_graph_planning_artifact.py +++ b/backend/progression_graph_planning_artifact.py @@ -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 == {}: diff --git a/backend/tests/test_planning_catalog_context.py b/backend/tests/test_planning_catalog_context.py new file mode 100644 index 0000000..f525be6 --- /dev/null +++ b/backend/tests/test_planning_catalog_context.py @@ -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 diff --git a/backend/tests/test_planning_path_refine_stage.py b/backend/tests/test_planning_path_refine_stage.py new file mode 100644 index 0000000..5cbdaca --- /dev/null +++ b/backend/tests/test_planning_path_refine_stage.py @@ -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 diff --git a/backend/tests/test_planning_path_rematch.py b/backend/tests/test_planning_path_rematch.py index 78cc99f..9337c9e 100644 --- a/backend/tests/test_planning_path_rematch.py +++ b/backend/tests/test_planning_path_rematch.py @@ -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 diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py index 21ae1c2..442505d 100644 --- a/backend/tests/test_planning_roadmap_stage_match.py +++ b/backend/tests/test_planning_roadmap_stage_match.py @@ -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, diff --git a/backend/tests/test_planning_stage_anti_patterns.py b/backend/tests/test_planning_stage_anti_patterns.py new file mode 100644 index 0000000..a50d91f --- /dev/null +++ b/backend/tests/test_planning_stage_anti_patterns.py @@ -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 diff --git a/backend/version.py b/backend/version.py index b09870e..7f1310a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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 diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index ebfc1cb..e733693 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -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 (
+ Auto-Rematch + {pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''} +
++ Stufen-Spec verfeinert ({refineLog.length}) +
++ Optimierungspotenziale ({optimizationHints.length}) +
+{hint.reason}
: null} +diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index ecb7fd4..97cd1c5 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -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 /> +
+ Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig + von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert. +
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()} diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 4c57960..c654b17 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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 }) }