Progression enhancement 3 QS stages #56
147
backend/planning_catalog_context.py
Normal file
147
backend/planning_catalog_context.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
222
backend/planning_path_refine_stage.py
Normal file
222
backend/planning_path_refine_stage.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 == {}:
|
||||
|
|
|
|||
47
backend/tests/test_planning_catalog_context.py
Normal file
47
backend/tests/test_planning_catalog_context.py
Normal 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
|
||||
100
backend/tests/test_planning_path_refine_stage.py
Normal file
100
backend/tests/test_planning_path_refine_stage.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
121
backend/tests/test_planning_stage_anti_patterns.py
Normal file
121
backend/tests/test_planning_stage_anti_patterns.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user