""" Planungs-KI Phase F: Roadmap-first Progressionsgraph-Pipeline (Workflow-lite). Ziel → Roadmap (micro → major) → Stufenspezifikation → danach Retrieval/KI (Phase D/E). Kein Gruppenkontext — siehe PLANNING_PROGRESSION_ROADMAP_SPEC.md. Prompt-Texte ausschließlich in ``ai_prompts`` (Admin konfigurierbar). Dieses Modul referenziert nur Slugs — siehe ``PROMPT_SLUG_*`` und Migrationen 078/079. """ from __future__ import annotations import json import logging import re from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple from pydantic import BaseModel, Field, ValidationError from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt from openrouter_chat import ( effective_openrouter_model_for_prompt_row, normalize_openrouter_env, openrouter_chat_completion, ) from planning_exercise_semantics import ( PlanningSemanticBrief, brief_to_summary_dict, build_semantic_brief, ) _logger = logging.getLogger("shinkan.planning_progression_roadmap") # Nur Slugs — Templates in DB (ai_prompts), bearbeitbar im Admin. PROMPT_SLUG_GOAL_ANALYSIS = "planning_progression_goal_analysis" PROMPT_SLUG_ROADMAP = "planning_progression_roadmap" PROMPT_SLUG_STAGE_SPEC = "planning_progression_stage_spec" _PHASE_ORDER = { "einstieg": 0, "grundlage": 1, "vertiefung": 2, "anwendung": 3, "perfektion": 4, } _DEFAULT_MICRO_TEMPLATES: Sequence[tuple[str, str, float]] = ( ("einstieg", "Einstieg und Orientierung zum Thema", 0.75), ("grundlage", "Grundstellung und Basisbewegung", 0.9), ("vertiefung", "Koordination und Präzision vertiefen", 0.85), ("vertiefung", "Kraft und Geschwindigkeit mit Technikbezug", 0.8), ("anwendung", "Anwendung und Kombination", 0.85), ("perfektion", "Perfektion und Qualitätssicherung", 0.7), ) _EXERCISE_TYPE_BY_PHASE = { "einstieg": "kihon_einzel", "grundlage": "kihon_einzel", "vertiefung": "kihon_einzel", "anwendung": "partner_drill", "perfektion": "kombination", } _LOAD_BY_PHASE = { "einstieg": ["koordination"], "grundlage": ["koordination", "gleichgewicht"], "vertiefung": ["präzision", "kraft", "geschwindigkeit"], "anwendung": ["timing", "distanz"], "perfektion": ["präzision", "kime"], } class GoalAnalysisArtifact(BaseModel): primary_topic: str = "" start_assumption: str = "" target_state: str = "" success_criteria: List[str] = Field(default_factory=list) constraints: Dict[str, Any] = Field(default_factory=dict) class MicroObjective(BaseModel): id: str phase: str title: str weight: float = Field(ge=0.0, le=1.0, default=0.8) depends_on: List[str] = Field(default_factory=list) class MajorStep(BaseModel): index: int = Field(ge=0) phase: str learning_goal: str consolidates: List[str] = Field(default_factory=list) rationale: str = "" class RoadmapArtifact(BaseModel): micro_objectives: List[MicroObjective] = Field(default_factory=list) major_steps: List[MajorStep] = Field(default_factory=list) consolidation_notes: List[str] = Field(default_factory=list) class StageSpecArtifact(BaseModel): major_step_index: int = Field(ge=0) learning_goal: str = "" load_profile: List[str] = Field(default_factory=list) exercise_type: str = "" success_criteria: List[str] = Field(default_factory=list) anti_patterns: List[str] = Field(default_factory=list) class RoadmapOverridePayload(BaseModel): """Vom Trainer bearbeitete Major Steps — steuert Retrieval ohne erneute Roadmap-KI.""" major_steps: List[MajorStep] = Field(..., min_length=2, max_length=10) stage_specs: Optional[List[StageSpecArtifact]] = None class ProgressionRoadmapContext(BaseModel): goal_query: str max_steps: int = Field(ge=2, le=10, default=5) semantic_brief: Optional[Dict[str, Any]] = None goal_analysis: Optional[GoalAnalysisArtifact] = None roadmap: Optional[RoadmapArtifact] = None stage_specs: List[StageSpecArtifact] = Field(default_factory=list) pipeline_phase: str = "roadmap_v1" llm_goal_analysis_applied: bool = False llm_roadmap_applied: bool = False llm_stage_spec_applied: bool = False prompt_slugs: List[str] = Field(default_factory=list) def _extract_json_object(text: str) -> Dict[str, Any]: s = (text or "").strip() if s.startswith("```"): s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) if s.endswith("```"): s = s[:-3].strip() start = s.find("{") end = s.rfind("}") if start < 0 or end <= start: raise ValueError("Kein JSON-Objekt in LLM-Antwort") obj = json.loads(s[start : end + 1]) if not isinstance(obj, dict): raise ValueError("LLM-Antwort ist kein JSON-Objekt") return obj def _run_prompt_json( cur, slug: str, variables: Dict[str, str], ) -> Optional[Dict[str, Any]]: api_key, _ = normalize_openrouter_env() if not api_key or cur is None: return None try: prow, rendered = load_and_render_ai_prompt(cur, slug, variables) model = effective_openrouter_model_for_prompt_row(prow) raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text) return _extract_json_object(raw) except AiPromptUnavailableError: return None except Exception as exc: _logger.warning("Roadmap-Prompt %s fehlgeschlagen: %s", slug, exc) return None def try_llm_goal_analysis( cur, *, goal_query: str, brief: PlanningSemanticBrief, ) -> Tuple[Optional[GoalAnalysisArtifact], bool]: obj = _run_prompt_json( cur, PROMPT_SLUG_GOAL_ANALYSIS, { "goal_query": goal_query or "", "semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False), }, ) if not obj: return None, False try: return GoalAnalysisArtifact.model_validate(obj), True except ValidationError as exc: _logger.warning("Zielanalyse-JSON ungültig: %s", exc) return None, False def try_llm_roadmap( cur, *, goal_query: str, brief: PlanningSemanticBrief, goal_analysis: GoalAnalysisArtifact, max_steps: int, ) -> Tuple[Optional[RoadmapArtifact], bool]: obj = _run_prompt_json( cur, PROMPT_SLUG_ROADMAP, { "goal_query": goal_query or "", "semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False), "goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False), "max_steps": str(int(max_steps)), }, ) if not obj: return None, False try: micro = [MicroObjective.model_validate(m) for m in (obj.get("micro_objectives") or [])] majors_raw = obj.get("major_steps") or [] majors = [MajorStep.model_validate(m) for m in majors_raw] if len(majors) != max_steps: majors, notes = consolidate_micro_to_major( micro or develop_micro_objectives(brief, goal_analysis=goal_analysis, min_count=max_steps + 1), max_steps=max_steps, ) obj["consolidation_notes"] = list(obj.get("consolidation_notes") or []) + notes for i, m in enumerate(majors): m.index = i return RoadmapArtifact( micro_objectives=micro, major_steps=majors, consolidation_notes=[str(n) for n in (obj.get("consolidation_notes") or []) if str(n).strip()], ), True except ValidationError as exc: _logger.warning("Roadmap-JSON ungültig: %s", exc) return None, False def try_llm_stage_specs( cur, *, goal_query: str, goal_analysis: GoalAnalysisArtifact, major_steps: Sequence[MajorStep], ) -> Tuple[Optional[List[StageSpecArtifact]], bool]: obj = _run_prompt_json( cur, PROMPT_SLUG_STAGE_SPEC, { "goal_query": goal_query or "", "goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False), "major_steps_json": json.dumps([m.model_dump() for m in major_steps], ensure_ascii=False), }, ) if not obj: return None, False raw_specs = obj.get("stage_specs") if not isinstance(raw_specs, list): return None, False try: specs = [StageSpecArtifact.model_validate(s) for s in raw_specs] return specs, True except ValidationError as exc: _logger.warning("Stufenspez-JSON ungültig: %s", exc) return None, False def _phase_sort_key(phase: str) -> int: return _PHASE_ORDER.get((phase or "").strip().lower(), 2) def _topic_label(brief: PlanningSemanticBrief) -> str: return (brief.primary_topic or brief.retrieval_query or "Technik").strip() def build_goal_analysis( goal_query: str, brief: PlanningSemanticBrief, ) -> GoalAnalysisArtifact: """Phase A — deterministisch aus Anfrage + Semantic Brief.""" topic = _topic_label(brief) target = goal_query.strip() or f"Entwicklung {topic}" arc = list(brief.development_arc or []) start_phase = arc[0] if arc else "grundlage" target_phase = arc[-1] if arc else "perfektion" criteria: List[str] = [] if brief.must_phrases: criteria.extend(brief.must_phrases[:3]) if topic: criteria.append(f"klarer Bezug zu {topic}") return GoalAnalysisArtifact( primary_topic=topic, start_assumption=( f"Einstieg auf Niveau „{start_phase}“ — Voraussetzungen der Zielgruppe werden im " "Progressionsgraphen nicht analysiert (erst Trainingsplanung)." ), target_state=target, success_criteria=criteria or [f"sichere Entwicklung Richtung {target_phase}"], constraints={"partner_required": False, "group_analysis": False}, ) def _micro_title_for_phase(phase: str, topic: str) -> str: p = (phase or "vertiefung").lower() labels = { "einstieg": f"Einstieg {topic}", "grundlage": f"{topic} — Grundstellung und Basis", "vertiefung": f"{topic} — Vertiefung", "anwendung": f"{topic} — Anwendung und Kombination", "perfektion": f"{topic} — Perfektion", } return labels.get(p, f"{topic} — {p}") def develop_micro_objectives( brief: PlanningSemanticBrief, *, goal_analysis: GoalAnalysisArtifact, min_count: int = 6, ) -> List[MicroObjective]: """Phase B1 — Zwischenziele (heuristisch aus development_arc).""" topic = goal_analysis.primary_topic or _topic_label(brief) arc = [str(p).lower() for p in (brief.development_arc or []) if str(p).strip()] seen_phases: set = set() micro: List[MicroObjective] = [] for i, phase in enumerate(arc): if phase in seen_phases: continue seen_phases.add(phase) mid = f"m{len(micro) + 1}" deps = [f"m{len(micro)}"] if micro else [] micro.append( MicroObjective( id=mid, phase=phase, title=_micro_title_for_phase(phase, topic), weight=0.85 if phase in {"grundlage", "vertiefung", "anwendung"} else 0.75, depends_on=deps, ) ) for phase, title_tpl, weight in _DEFAULT_MICRO_TEMPLATES: if len(micro) >= min_count: break if phase in seen_phases: continue seen_phases.add(phase) mid = f"m{len(micro) + 1}" deps = [micro[-1].id] if micro else [] title = title_tpl.replace("Thema", topic) if "Thema" in title_tpl else f"{topic} — {title_tpl}" micro.append( MicroObjective(id=mid, phase=phase, title=title, weight=weight, depends_on=deps) ) supplement_labels = ( ("vertiefung", "Präzision und Zielpunkt"), ("vertiefung", "Kraft und Schnelligkeit"), ("anwendung", "Kombination im Ablauf"), ) si = 0 while len(micro) < min_count and si < len(supplement_labels) * 3: phase, label = supplement_labels[si % len(supplement_labels)] si += 1 deps = [micro[-1].id] if micro else [] micro.append( MicroObjective( id=f"m{len(micro) + 1}", phase=phase, title=f"{topic} — {label}", weight=0.8, depends_on=deps, ) ) micro.sort(key=lambda m: _phase_sort_key(m.phase)) for i, m in enumerate(micro): m.id = f"m{i + 1}" m.depends_on = [f"m{i}"] if i > 0 else [] return micro def consolidate_micro_to_major( micro_objectives: Sequence[MicroObjective], *, max_steps: int, ) -> tuple[List[MajorStep], List[str]]: """Phase B2 — deterministische Konsolidierung auf N Major Steps.""" if not micro_objectives: return [], ["Keine Zwischenziele — Fallback leer"] n = max(2, min(10, int(max_steps))) notes: List[str] = [] if len(micro_objectives) <= n: majors = [ MajorStep( index=i, phase=m.phase, learning_goal=m.title, consolidates=[m.id], rationale=f"1:1 aus Zwischenziel {m.id}", ) for i, m in enumerate(micro_objectives) ] return majors, notes notes.append( f"{len(micro_objectives)} Zwischenziele auf {n} Major Steps reduziert (gleichmäßige Abdeckung des Bogens)." ) count = len(micro_objectives) majors: List[MajorStep] = [] for j in range(n): start = (j * count) // n end = ((j + 1) * count) // n chunk = list(micro_objectives[start:end]) if not chunk: continue phase = chunk[len(chunk) // 2].phase consolidates = [c.id for c in chunk] goal = chunk[0].title if len(chunk) == 1 else f"{chunk[0].title} → {chunk[-1].title}" majors.append( MajorStep( index=len(majors), phase=phase, learning_goal=goal, consolidates=consolidates, rationale=f"Konsolidiert {len(chunk)} Zwischenziele ({consolidates[0]}…{consolidates[-1]})", ) ) for i, step in enumerate(majors): step.index = i return majors, notes def _normalize_query(query: Optional[str]) -> str: return re.sub(r"\s+", " ", (query or "").strip()) def stage_spec_retrieval_query( *, semantic_brief: PlanningSemanticBrief, goal_query: str, stage_spec: StageSpecArtifact, major_step: Optional[MajorStep] = None, ) -> str: """Retrieval-Query für einen Roadmap-Major-Step (Phase F3).""" parts: List[str] = [] topic = (semantic_brief.primary_topic or semantic_brief.retrieval_query or goal_query).strip() if topic: parts.append(topic) learning_goal = (stage_spec.learning_goal or "").strip() if learning_goal: parts.append(learning_goal) phase = (major_step.phase if major_step else "").strip().lower() if phase: parts.append(phase) if stage_spec.load_profile: parts.extend(str(x).strip() for x in stage_spec.load_profile[:2] if str(x).strip()) exercise_type = (stage_spec.exercise_type or "").strip().lower() if exercise_type == "partner_drill": parts.append("partner") elif exercise_type == "kombination": parts.append("kombination") return _normalize_query(" ".join(parts)) or _normalize_query(goal_query) def stage_spec_exercise_kind_filter(stage_spec: StageSpecArtifact) -> Optional[List[str]]: """Mappt didaktischen exercise_type auf DB exercise_kind (simple/combination).""" et = (stage_spec.exercise_type or "").strip().lower() if et == "kombination": return ["combination"] if et in ("kihon_einzel", "partner_drill", "grundtechnik"): return ["simple"] return None def resolve_step_exercise_kind_filter( stage_spec: StageSpecArtifact, request_filter: Optional[Sequence[str]], ) -> Optional[List[str]]: """Schnittmenge aus Roadmap-Stufe und optionalem Request-Filter.""" stage_filter = stage_spec_exercise_kind_filter(stage_spec) if not request_filter: return stage_filter req = [str(x).strip().lower() for x in request_filter if str(x).strip()] if not stage_filter: return req or None merged = [k for k in stage_filter if k in req] return merged or req def build_roadmap_unfilled_gap_specs( *, unfilled_specs: Sequence[Tuple[int, StageSpecArtifact]], major_steps_by_index: Mapping[int, MajorStep], steps: Sequence[Mapping[str, Any]], brief: PlanningSemanticBrief, goal_query: str, ) -> List[Dict[str, Any]]: """Gap-Fill-Angebote für Roadmap-Stufen ohne Bibliothekstreffer.""" topic = (brief.primary_topic or "Technik").strip() specs: List[Dict[str, Any]] = [] for roadmap_idx, stage_spec in unfilled_specs: major = major_steps_by_index.get(stage_spec.major_step_index) phase = (major.phase if major else "vertiefung").strip().lower() insert_after = min(max(roadmap_idx - 1, -1), max(len(steps) - 1, -1)) title_hint = (stage_spec.learning_goal or f"{topic} — {phase}").strip()[:120] sketch_parts = [ f"Planungsziel: {goal_query}", f"Roadmap-Stufe {stage_spec.major_step_index + 1} ({phase}): {stage_spec.learning_goal}", ] if stage_spec.success_criteria: sketch_parts.append(f"Erfolgskriterien: {', '.join(stage_spec.success_criteria[:3])}") specs.append( { "source": "roadmap_unfilled", "insert_after_index": insert_after, "gap": { "expected_phase": phase, "roadmap_major_step_index": stage_spec.major_step_index, "learning_goal": stage_spec.learning_goal, }, "phase": phase, "title_hint": title_hint, "sketch": "\n".join(sketch_parts), "rationale": ( f"Keine passende Bibliotheks-Übung für Roadmap-Stufe " f"{stage_spec.major_step_index + 1} ({phase})." ), "roadmap_major_step_index": stage_spec.major_step_index, } ) return specs[:5] def build_stage_specs( major_steps: Sequence[MajorStep], *, goal_analysis: GoalAnalysisArtifact, ) -> List[StageSpecArtifact]: """Phase C — Stufenspezifikation je Major Step (heuristisch).""" topic = goal_analysis.primary_topic or "Technik" specs: List[StageSpecArtifact] = [] for step in major_steps: phase = (step.phase or "vertiefung").lower() specs.append( StageSpecArtifact( major_step_index=step.index, learning_goal=step.learning_goal, load_profile=list(_LOAD_BY_PHASE.get(phase, ["koordination"])), exercise_type=_EXERCISE_TYPE_BY_PHASE.get(phase, "kihon_einzel"), success_criteria=[ f"Bezug zu {topic}", f"Phase {phase} erkennbar im Übungsziel", ], anti_patterns=[ "reine Kraftübung ohne Technikbezug", f"andere Technik als {topic}" if topic else "themenfremde Übung", ], ) ) return specs def normalize_major_steps_for_override( major_steps: Sequence[MajorStep], *, max_steps: int, ) -> List[MajorStep]: """Indizes 0…n-1, mindestens 2, höchstens max_steps Major Steps.""" cleaned: List[MajorStep] = [] for raw in list(major_steps)[:max_steps]: goal = (raw.learning_goal or "").strip() phase = (raw.phase or "vertiefung").strip().lower() if not goal: continue cleaned.append( MajorStep( index=len(cleaned), phase=phase, learning_goal=goal, consolidates=list(raw.consolidates or []), rationale=(raw.rationale or "").strip(), ) ) if len(cleaned) < 2: raise ValueError("Mindestens zwei Major Steps mit Lernziel nötig") for i, step in enumerate(cleaned): step.index = i return cleaned def roadmap_context_from_override( goal_query: str, *, max_steps: int, semantic_brief: PlanningSemanticBrief, override: RoadmapOverridePayload, ) -> ProgressionRoadmapContext: """Phase F4: bearbeitete Roadmap → stage_specs → Retrieval (ohne LLM-Roadmap).""" majors = normalize_major_steps_for_override(override.major_steps, max_steps=max_steps) effective_max = len(majors) goal_analysis = build_goal_analysis(goal_query, semantic_brief) stage_specs: List[StageSpecArtifact] if override.stage_specs and len(override.stage_specs) >= effective_max: stage_specs = [] for i, spec in enumerate(override.stage_specs[:effective_max]): stage_specs.append( StageSpecArtifact( major_step_index=i, learning_goal=(spec.learning_goal or majors[i].learning_goal).strip(), load_profile=list(spec.load_profile or []), exercise_type=(spec.exercise_type or "").strip(), success_criteria=list(spec.success_criteria or []), anti_patterns=list(spec.anti_patterns or []), ) ) if not all(s.exercise_type for s in stage_specs): rebuilt = build_stage_specs(majors, goal_analysis=goal_analysis) for i, spec in enumerate(stage_specs): if not spec.exercise_type: spec.exercise_type = rebuilt[i].exercise_type if not spec.load_profile: spec.load_profile = list(rebuilt[i].load_profile) else: stage_specs = build_stage_specs(majors, goal_analysis=goal_analysis) return ProgressionRoadmapContext( goal_query=goal_query.strip(), max_steps=effective_max, semantic_brief=brief_to_summary_dict(semantic_brief), goal_analysis=goal_analysis, roadmap=RoadmapArtifact(major_steps=majors), stage_specs=stage_specs, pipeline_phase="roadmap_v1_edited", ) def run_progression_roadmap_pipeline( goal_query: str, *, max_steps: int = 5, semantic_brief: Optional[PlanningSemanticBrief] = None, cur=None, include_llm_roadmap: bool = False, ) -> ProgressionRoadmapContext: """Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback.""" brief = semantic_brief or build_semantic_brief(goal_query) ctx = ProgressionRoadmapContext( goal_query=goal_query.strip(), max_steps=max_steps, semantic_brief=brief_to_summary_dict(brief), ) goal_analysis = build_goal_analysis(goal_query, brief) if include_llm_roadmap and cur is not None: llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=goal_query, brief=brief) if ga_ok and llm_ga: goal_analysis = llm_ga ctx.llm_goal_analysis_applied = True ctx.prompt_slugs.append(PROMPT_SLUG_GOAL_ANALYSIS) ctx.goal_analysis = goal_analysis roadmap: Optional[RoadmapArtifact] = None if include_llm_roadmap and cur is not None: llm_rm, rm_ok = try_llm_roadmap( cur, goal_query=goal_query, brief=brief, goal_analysis=goal_analysis, max_steps=max_steps, ) if rm_ok and llm_rm: roadmap = llm_rm ctx.llm_roadmap_applied = True ctx.prompt_slugs.append(PROMPT_SLUG_ROADMAP) if roadmap is None: micro = develop_micro_objectives( brief, goal_analysis=goal_analysis, min_count=max(max_steps + 1, 6), ) majors, notes = consolidate_micro_to_major(micro, max_steps=max_steps) roadmap = RoadmapArtifact( micro_objectives=micro, major_steps=majors, consolidation_notes=notes, ) ctx.roadmap = roadmap stage_specs = build_stage_specs(roadmap.major_steps, goal_analysis=goal_analysis) if include_llm_roadmap and cur is not None: llm_specs, spec_ok = try_llm_stage_specs( cur, goal_query=goal_query, goal_analysis=goal_analysis, major_steps=roadmap.major_steps, ) if spec_ok and llm_specs: stage_specs = llm_specs ctx.llm_stage_spec_applied = True ctx.prompt_slugs.append(PROMPT_SLUG_STAGE_SPEC) ctx.stage_specs = stage_specs if ctx.llm_goal_analysis_applied or ctx.llm_roadmap_applied or ctx.llm_stage_spec_applied: ctx.pipeline_phase = "roadmap_v1_llm" return ctx def progression_roadmap_to_api_dict(ctx: ProgressionRoadmapContext) -> Dict[str, Any]: return { "goal_analysis": ctx.goal_analysis.model_dump() if ctx.goal_analysis else None, "roadmap": ctx.roadmap.model_dump() if ctx.roadmap else None, "stage_specs": [s.model_dump() for s in ctx.stage_specs], "pipeline_phase": ctx.pipeline_phase, "major_step_count": len(ctx.roadmap.major_steps) if ctx.roadmap else 0, "micro_objective_count": len(ctx.roadmap.micro_objectives) if ctx.roadmap else 0, "llm_goal_analysis_applied": ctx.llm_goal_analysis_applied, "llm_roadmap_applied": ctx.llm_roadmap_applied, "llm_stage_spec_applied": ctx.llm_stage_spec_applied, "prompt_slugs": list(ctx.prompt_slugs), "prompt_slug_catalog": { "goal_analysis": PROMPT_SLUG_GOAL_ANALYSIS, "roadmap": PROMPT_SLUG_ROADMAP, "stage_spec": PROMPT_SLUG_STAGE_SPEC, }, } __all__ = [ "PROMPT_SLUG_GOAL_ANALYSIS", "PROMPT_SLUG_ROADMAP", "PROMPT_SLUG_STAGE_SPEC", "GoalAnalysisArtifact", "MajorStep", "MicroObjective", "ProgressionRoadmapContext", "RoadmapArtifact", "RoadmapOverridePayload", "normalize_major_steps_for_override", "roadmap_context_from_override", "StageSpecArtifact", "build_goal_analysis", "build_roadmap_unfilled_gap_specs", "build_stage_specs", "resolve_step_exercise_kind_filter", "stage_spec_exercise_kind_filter", "stage_spec_retrieval_query", "consolidate_micro_to_major", "develop_micro_objectives", "progression_roadmap_to_api_dict", "run_progression_roadmap_pipeline", "try_llm_goal_analysis", "try_llm_roadmap", "try_llm_stage_specs", ]