shinkan-jinkendo/backend/planning_progression_roadmap.py
Lars f074a8bef0
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
Implement Roadmap Review Features and Enhance Progression Path Management
- Added support for editable major steps in the roadmap, allowing users to modify phase, learning goals, and order before exercise matching.
- Introduced a new `roadmap_override` feature to facilitate customized retrieval without re-invoking the roadmap AI.
- Updated the `ExerciseProgressionPathBuilder` component to incorporate these new features, enhancing user interaction and flexibility.
- Incremented application version to 0.8.207 to reflect these changes.
2026-06-08 14:59:24 +02:00

754 lines
26 KiB
Python

"""
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",
]