shinkan-jinkendo/backend/planning_progression_roadmap.py
Lars 044ce2ee60
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s
Implement Primary Topic Resolution in Path Logic
- Introduced `resolve_path_primary_topic` function to enhance the determination of primary topics from goal queries and semantic briefs, improving exercise relevance.
- Updated `_match_roadmap_slot` and `detect_off_topic_steps` functions to utilize the new primary topic resolution logic, ensuring accurate topic identification.
- Enhanced tests to validate the functionality of primary topic resolution and its impact on exercise selection and off-topic detection.
- Improved handling of primary topics in the `ExerciseProgressionPathBuilder` and related components for better integration with the overall path-building process.
2026-06-11 11:06:38 +02:00

1341 lines
47 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_START_TARGET = "planning_progression_start_target"
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 = ""
"""Soll-Start dieser Stufe (= Zielzustand der vorherigen Stufe / Pfad-Start)."""
start_state: str = ""
"""Zielzustand dieser Stufe (= Soll für den nächsten Schritt)."""
target_state: 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 RoadmapStructuredInput(BaseModel):
"""Optionale Strukturierung: Start, Ziel, Ergänzungen (Progressionsgraph, kein Gruppen-Tracking)."""
start_situation: Optional[str] = Field(default=None, max_length=2000)
target_state: Optional[str] = Field(default=None, max_length=2000)
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
class StartTargetExtractArtifact(BaseModel):
"""LLM-Ergebnis: dedizierte Beschreibung von Ausgang, Ziel und Ergänzungen."""
primary_topic: str = ""
start_situation: str = ""
target_state: str = ""
roadmap_notes: str = ""
extraction_notes: str = ""
class StartTargetResolveMeta(BaseModel):
"""Herkunft der aufgelösten Felder (user > llm > regex)."""
start_source: str = "none"
target_source: str = "none"
notes_source: str = "none"
topic_source: str = "none"
llm_start_target_applied: bool = False
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
_GENERIC_START_MARKER = "Voraussetzungen der Zielgruppe werden im Progressionsgraphen nicht analysiert"
class ProgressionRoadmapContext(BaseModel):
goal_query: str
max_steps: int = Field(ge=2, le=10, default=5)
semantic_brief: Optional[Dict[str, Any]] = None
resolved_structured: Optional[RoadmapStructuredInput] = None
start_target_extract: Optional[StartTargetExtractArtifact] = None
start_target_resolve: Optional[StartTargetResolveMeta] = None
goal_analysis: Optional[GoalAnalysisArtifact] = None
roadmap: Optional[RoadmapArtifact] = None
stage_specs: List[StageSpecArtifact] = Field(default_factory=list)
pipeline_phase: str = "roadmap_v1"
llm_start_target_applied: bool = False
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_start_target_extract(
cur,
*,
goal_query: str,
brief: PlanningSemanticBrief,
user_notes: str = "",
) -> Tuple[Optional[StartTargetExtractArtifact], bool]:
obj = _run_prompt_json(
cur,
PROMPT_SLUG_START_TARGET,
{
"goal_query": goal_query or "",
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
"user_notes": (user_notes or "").strip(),
},
)
if not obj:
return None, False
try:
return StartTargetExtractArtifact.model_validate(obj), True
except ValidationError as exc:
_logger.warning("Start/Ziel-Extraktion JSON ungültig: %s", exc)
return None, False
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],
intent_context: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> 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),
"intent_context_json": json.dumps(dict(intent_context or {}), ensure_ascii=False),
"semantic_brief_json": json.dumps(
brief_to_summary_dict(semantic_brief) if semantic_brief else {},
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()
_PHASE_TOPIC_WORDS = frozenset(
{"einstieg", "grundlage", "vertiefung", "anwendung", "perfektion", "technik"}
)
def _extract_topic_from_goal_query(goal_query: str, brief: PlanningSemanticBrief) -> str:
q = (goal_query or "").strip()
m = re.match(r"^(.+?)\s+von\s+(?:der|die|dem|das|einer?|einem)\s+", q, flags=re.IGNORECASE)
if m:
cand = m.group(1).strip().rstrip(".,;")
if len(cand) >= 3:
return cand
topic = _topic_label(brief)
if topic and topic.lower() not in _PHASE_TOPIC_WORDS and len(topic) >= 4:
return topic
return topic or "Technik"
def _merge_roadmap_notes(*parts: Optional[str]) -> Optional[str]:
seen: set[str] = set()
lines: List[str] = []
for raw in parts:
s = (raw or "").strip()
if not s:
continue
key = s.lower()
if key in seen:
continue
seen.add(key)
lines.append(s)
return "\n".join(lines) if lines else None
def resolve_roadmap_structured_input(
goal_query: str,
structured: Optional[RoadmapStructuredInput],
*,
brief: PlanningSemanticBrief,
cur=None,
include_llm: bool = False,
) -> Tuple[RoadmapStructuredInput, StartTargetResolveMeta, Optional[StartTargetExtractArtifact]]:
"""Priorität je Feld: Trainer-Eingabe > LLM-Extraktion > Regex (von … bis …)."""
user = structured or RoadmapStructuredInput()
user_start = (user.start_situation or "").strip()
user_target = (user.target_state or "").strip()
user_notes = (user.roadmap_notes or "").strip()
llm_extract: Optional[StartTargetExtractArtifact] = None
llm_ok = False
if include_llm and cur is not None:
llm_extract, llm_ok = try_llm_start_target_extract(
cur,
goal_query=goal_query,
brief=brief,
user_notes=user_notes,
)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
meta = StartTargetResolveMeta(llm_start_target_applied=llm_ok)
if user_start:
start = user_start
meta.start_source = "user"
elif llm_ok and (llm_extract.start_situation or "").strip():
start = llm_extract.start_situation.strip()
meta.start_source = "llm"
elif parsed_start:
start = parsed_start
meta.start_source = "regex"
else:
start = ""
if user_target:
target = user_target
meta.target_source = "user"
elif llm_ok and (llm_extract.target_state or "").strip():
target = llm_extract.target_state.strip()
meta.target_source = "llm"
elif parsed_target:
target = parsed_target
meta.target_source = "regex"
else:
target = ""
llm_notes = (llm_extract.roadmap_notes or "").strip() if llm_ok and llm_extract else ""
if user_notes and llm_notes:
notes = _merge_roadmap_notes(user_notes, llm_notes) or ""
meta.notes_source = "merged"
elif user_notes:
notes = user_notes
meta.notes_source = "user"
elif llm_notes:
notes = llm_notes
meta.notes_source = "llm"
else:
notes = ""
meta.notes_source = "none"
if llm_ok and (llm_extract.primary_topic or "").strip():
meta.topic_source = "llm"
else:
meta.topic_source = "heuristic"
resolved = RoadmapStructuredInput(
start_situation=start or None,
target_state=target or None,
roadmap_notes=notes or None,
)
return resolved, meta, llm_extract if llm_ok else None
def parse_start_target_from_goal_query(goal_query: str) -> Tuple[Optional[str], Optional[str]]:
"""„von … bis …“ aus Freitext (z. B. Kumite Beinarbeit von X bis Y)."""
q = (goal_query or "").strip()
if not q:
return None, None
m = re.search(
r"\bvon\s+((?:(?:der|die|dem|das|einer?|einem)\s+)?.+?)\s+bis\s+"
r"(?:zur?|zum|zu der|zu einem)?\s*(.+?)\s*$",
q,
flags=re.IGNORECASE | re.DOTALL,
)
if not m:
return None, None
start = m.group(1).strip().rstrip(".,;")
target = m.group(2).strip().rstrip(".,;")
if len(start) < 4 or len(target) < 4:
return None, None
return start[:800], target[:800]
def _roadmap_llm_goal_block(
goal_query: str,
*,
structured: Optional[RoadmapStructuredInput] = None,
parsed_start: Optional[str] = None,
parsed_target: Optional[str] = None,
) -> str:
"""Reicher Kontext für Roadmap-LLM ohne zwingend neue Prompt-Migration."""
lines = [f"Gesamtanfrage: {(goal_query or '').strip()}"]
start = (structured.start_situation if structured else None) or parsed_start
target = (structured.target_state if structured else None) or parsed_target
notes = structured.roadmap_notes if structured else None
if start:
lines.append(f"Ausgangslage/Startpunkt: {start.strip()}")
if target:
lines.append(f"Zielzustand: {target.strip()}")
if notes and notes.strip():
lines.append(f"Ergänzungen (Fokus, Gruppe, Besonderheiten): {notes.strip()}")
return "\n".join(lines)
def build_goal_analysis(
goal_query: str,
brief: PlanningSemanticBrief,
*,
structured: Optional[RoadmapStructuredInput] = None,
topic_override: Optional[str] = None,
) -> GoalAnalysisArtifact:
"""Phase A — aus Anfrage, optionalen Feldern und Semantic Brief."""
topic = (topic_override or "").strip() or _extract_topic_from_goal_query(goal_query, brief)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
start = (structured.start_situation if structured else None) or parsed_start
target = (structured.target_state if structured else None) or parsed_target
notes = (structured.roadmap_notes if structured else None) or ""
if not target:
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"
if start:
start_assumption = start.strip()
else:
start_assumption = (
f"Einstieg auf Niveau „{start_phase}“ — {_GENERIC_START_MARKER} "
"(erst Trainingsplanung)."
)
criteria: List[str] = []
if brief.must_phrases:
criteria.extend(brief.must_phrases[:3])
if topic:
criteria.append(f"klarer Bezug zu {topic}")
if start and target:
criteria.append(f"nachvollziehbarer Übergang von „{start[:80]}“ zu „{target[:80]}")
if notes.strip():
criteria.append(f"Berücksichtigung: {notes.strip()[:200]}")
from planning_intent_context import extract_explicit_exclusions
constraints: Dict[str, Any] = {"partner_required": False, "group_analysis": False}
if notes.strip():
constraints["trainer_notes"] = notes.strip()[:500]
excluded = extract_explicit_exclusions(goal_query, notes or None)
if excluded:
constraints["excluded_themes"] = excluded
return GoalAnalysisArtifact(
primary_topic=topic,
start_assumption=start_assumption,
target_state=target.strip(),
success_criteria=criteria or [f"sichere Entwicklung Richtung {target_phase}"],
constraints=constraints,
)
def _has_specific_start_target(goal_analysis: GoalAnalysisArtifact) -> bool:
start = (goal_analysis.start_assumption or "").strip()
target = (goal_analysis.target_state or "").strip()
if _GENERIC_START_MARKER in start:
return False
return len(start) >= 6 and len(target) >= 6 and start != target
def _target_facets(target: str) -> List[str]:
parts = re.split(r"\s+und\s+|\s+mit\s+|,\s*", target, flags=re.IGNORECASE)
out: List[str] = []
for p in parts:
s = p.strip().rstrip(".,;")
if len(s) >= 5 and s.lower() not in {x.lower() for x in out}:
out.append(s[:200])
return out[:6]
def develop_micro_objectives_from_start_target(
goal_analysis: GoalAnalysisArtifact,
*,
min_count: int,
) -> List[MicroObjective]:
"""Zwischenziele entlang Start → Ziel (heuristisch, themenspezifisch)."""
topic = goal_analysis.primary_topic or "Technik"
start = goal_analysis.start_assumption.strip()
target = goal_analysis.target_state.strip()
facets = _target_facets(target)
phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"]
titles: List[str] = [f"{topic}: Ausgang — {start}"]
n_middle = max(0, min_count - 2)
for i in range(n_middle):
if facets and i < len(facets):
titles.append(f"{topic}: {facets[i]} — schrittweise einführen")
else:
titles.append(
f"{topic}: Übergangsschritt {i + 1} — Annäherung vom Ausgang zum Ziel"
)
titles.append(f"{topic}: Ziel — {target}")
while len(titles) < min_count:
titles.insert(max(1, len(titles) - 1), f"{topic}: Vertiefung vor Zielerreichung")
titles = titles[:max(min_count, 2)]
micro: List[MicroObjective] = []
for i, title in enumerate(titles):
phase = phases[min(i, len(phases) - 1)]
micro.append(
MicroObjective(
id=f"m{i + 1}",
phase=phase,
title=title,
weight=0.9 if i in (0, len(titles) - 1) else 0.85,
depends_on=[f"m{i}"] if i > 0 else [],
)
)
return micro
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 (Start→Ziel oder development_arc-Fallback)."""
if _has_specific_start_target(goal_analysis):
return develop_micro_objectives_from_start_target(goal_analysis, min_count=min_count)
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,
goal_analysis: Optional[GoalAnalysisArtifact] = None,
resolved_structured: Optional[RoadmapStructuredInput] = None,
) -> 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 resolved_structured and (resolved_structured.start_situation or "").strip():
sketch_parts.append(f"Ausgangslage (Pfad): {resolved_structured.start_situation.strip()}")
elif goal_analysis and (goal_analysis.start_assumption or "").strip():
sketch_parts.append(f"Ausgangslage (Pfad): {goal_analysis.start_assumption.strip()}")
if resolved_structured and (resolved_structured.target_state or "").strip():
sketch_parts.append(f"Gesamtziel (Pfad): {resolved_structured.target_state.strip()}")
elif goal_analysis and (goal_analysis.target_state or "").strip():
sketch_parts.append(f"Gesamtziel (Pfad): {goal_analysis.target_state.strip()}")
if stage_spec.success_criteria:
sketch_parts.append(f"Erfolgskriterien: {', '.join(stage_spec.success_criteria[:3])}")
if stage_spec.load_profile:
sketch_parts.append(f"Belastung: {', '.join(stage_spec.load_profile[:4])}")
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,
goal_query: str = "",
semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> List[StageSpecArtifact]:
"""Phase C — Stufenspezifikation je Major Step (heuristisch)."""
from planning_exercise_semantics import resolve_path_anti_patterns
topic = goal_analysis.primary_topic or "Technik"
path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief)
specs: List[StageSpecArtifact] = []
for step in major_steps:
phase = (step.phase or "vertiefung").lower()
anti = [
"reine Kraftübung ohne Technikbezug",
f"andere Technik als {topic}" if topic else "themenfremde Übung",
]
for item in path_anti:
if item not in anti:
anti.append(item)
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=anti[:14],
)
)
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,
structured: Optional[RoadmapStructuredInput] = None,
) -> 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, structured=structured)
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(),
start_state=(spec.start_state or "").strip(),
target_state=(spec.target_state or "").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,
goal_query=goal_query.strip(),
semantic_brief=semantic_brief,
)
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,
goal_query=goal_query.strip(),
semantic_brief=semantic_brief,
)
from planning_exercise_semantics import enrich_brief_with_path_constraints
from planning_intent_context import (
build_planning_intent_context,
finalize_stage_specs_with_intent,
)
enriched_brief = enrich_brief_with_path_constraints(
semantic_brief,
goal_query.strip(),
extra_context=_merge_roadmap_notes(
structured.roadmap_notes if structured else None,
structured.start_situation if structured else None,
structured.target_state if structured else None,
),
)
intent = build_planning_intent_context(
goal_query.strip(),
semantic_brief=enriched_brief,
goal_analysis=goal_analysis.model_dump(),
extra_context=_merge_roadmap_notes(
structured.roadmap_notes if structured else None,
structured.start_situation if structured else None,
structured.target_state if structured else None,
),
primary_topic=goal_analysis.primary_topic,
)
stage_specs = finalize_stage_specs_with_intent(
stage_specs,
majors,
intent=intent,
fallback_specs=build_stage_specs(
majors,
goal_analysis=goal_analysis,
goal_query=goal_query.strip(),
semantic_brief=enriched_brief,
),
)
from planning_stage_context import derive_stage_specs_transition_states, resolve_path_start_target
path_start, path_target = resolve_path_start_target(
structured=structured,
goal_analysis=goal_analysis,
)
stage_specs = derive_stage_specs_transition_states(
stage_specs,
majors,
path_start=path_start,
path_target=path_target,
goal_analysis=goal_analysis,
)
return ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=effective_max,
semantic_brief=brief_to_summary_dict(semantic_brief),
resolved_structured=structured,
goal_analysis=goal_analysis,
roadmap=RoadmapArtifact(major_steps=majors),
stage_specs=stage_specs,
pipeline_phase="roadmap_v1_edited",
)
def _merge_structured_into_goal_analysis(
llm_ga: GoalAnalysisArtifact,
*,
goal_query: str,
brief: PlanningSemanticBrief,
structured: Optional[RoadmapStructuredInput],
) -> GoalAnalysisArtifact:
ga_struct = build_goal_analysis(goal_query, brief, structured=structured, topic_override=None)
if not _has_specific_start_target(ga_struct):
return llm_ga
merged_criteria = list(
dict.fromkeys((llm_ga.success_criteria or []) + (ga_struct.success_criteria or []))
)[:6]
merged_constraints = {**(llm_ga.constraints or {}), **(ga_struct.constraints or {})}
return llm_ga.model_copy(
update={
"primary_topic": ga_struct.primary_topic or llm_ga.primary_topic,
"start_assumption": ga_struct.start_assumption,
"target_state": ga_struct.target_state,
"success_criteria": merged_criteria,
"constraints": merged_constraints,
}
)
def run_start_target_resolve_only(
goal_query: str,
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
cur=None,
include_llm_start_target: bool = True,
structured: Optional[RoadmapStructuredInput] = None,
) -> ProgressionRoadmapContext:
"""Nur Start/Ziel/Ergänzungen auflösen — ohne Roadmap-Stufen (Review vor Major Steps)."""
brief = semantic_brief or build_semantic_brief(goal_query)
resolved, resolve_meta, llm_extract = resolve_roadmap_structured_input(
goal_query,
structured,
brief=brief,
cur=cur,
include_llm=include_llm_start_target,
)
topic_override = None
if llm_extract and (llm_extract.primary_topic or "").strip():
topic_override = llm_extract.primary_topic.strip()
goal_analysis = build_goal_analysis(
goal_query,
brief,
structured=resolved,
topic_override=topic_override,
)
ctx = ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=2,
semantic_brief=brief_to_summary_dict(brief),
resolved_structured=resolved,
start_target_extract=llm_extract,
start_target_resolve=resolve_meta,
goal_analysis=goal_analysis,
llm_start_target_applied=resolve_meta.llm_start_target_applied,
pipeline_phase="start_target_only",
)
if resolve_meta.llm_start_target_applied:
ctx.prompt_slugs.append(PROMPT_SLUG_START_TARGET)
return ctx
def run_progression_roadmap_pipeline(
goal_query: str,
*,
max_steps: int = 5,
semantic_brief: Optional[PlanningSemanticBrief] = None,
cur=None,
include_llm_roadmap: bool = False,
include_llm_start_target: bool = False,
structured: Optional[RoadmapStructuredInput] = None,
) -> ProgressionRoadmapContext:
"""Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback."""
brief = semantic_brief or build_semantic_brief(goal_query)
resolved, resolve_meta, llm_extract = resolve_roadmap_structured_input(
goal_query,
structured,
brief=brief,
cur=cur,
include_llm=include_llm_start_target,
)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
llm_goal_query = _roadmap_llm_goal_block(
goal_query,
structured=resolved,
parsed_start=parsed_start,
parsed_target=parsed_target,
)
ctx = ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=max_steps,
semantic_brief=brief_to_summary_dict(brief),
resolved_structured=resolved,
start_target_extract=llm_extract,
start_target_resolve=resolve_meta,
llm_start_target_applied=resolve_meta.llm_start_target_applied,
)
if resolve_meta.llm_start_target_applied:
ctx.prompt_slugs.append(PROMPT_SLUG_START_TARGET)
topic_override = None
if llm_extract and (llm_extract.primary_topic or "").strip():
topic_override = llm_extract.primary_topic.strip()
goal_analysis = build_goal_analysis(
goal_query,
brief,
structured=resolved,
topic_override=topic_override,
)
if include_llm_roadmap and cur is not None:
llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=llm_goal_query, brief=brief)
if ga_ok and llm_ga:
goal_analysis = _merge_structured_into_goal_analysis(
llm_ga,
goal_query=goal_query,
brief=brief,
structured=resolved,
)
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=llm_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
from planning_exercise_semantics import enrich_brief_with_path_constraints
from planning_intent_context import (
build_planning_intent_context,
finalize_stage_specs_with_intent,
)
brief = enrich_brief_with_path_constraints(
brief,
goal_query,
extra_context=_merge_roadmap_notes(
resolved.roadmap_notes,
resolved.start_situation,
resolved.target_state,
),
)
intent = build_planning_intent_context(
goal_query,
semantic_brief=brief,
goal_analysis=goal_analysis.model_dump(),
extra_context=_merge_roadmap_notes(
resolved.roadmap_notes,
resolved.start_situation,
resolved.target_state,
),
primary_topic=goal_analysis.primary_topic,
)
heuristic_specs = build_stage_specs(
roadmap.major_steps,
goal_analysis=goal_analysis,
goal_query=goal_query,
semantic_brief=brief,
)
stage_specs = list(heuristic_specs)
if include_llm_roadmap and cur is not None:
llm_specs, spec_ok = try_llm_stage_specs(
cur,
goal_query=llm_goal_query,
goal_analysis=goal_analysis,
major_steps=roadmap.major_steps,
intent_context=intent.to_api_dict(),
semantic_brief=brief,
)
if spec_ok and llm_specs:
stage_specs = list(llm_specs)
ctx.llm_stage_spec_applied = True
ctx.prompt_slugs.append(PROMPT_SLUG_STAGE_SPEC)
ctx.stage_specs = finalize_stage_specs_with_intent(
stage_specs,
roadmap.major_steps,
intent=intent,
fallback_specs=heuristic_specs,
)
from planning_stage_context import derive_stage_specs_transition_states, resolve_path_start_target
path_start, path_target = resolve_path_start_target(
structured=resolved,
goal_analysis=goal_analysis,
)
ctx.stage_specs = derive_stage_specs_transition_states(
ctx.stage_specs,
roadmap.major_steps,
path_start=path_start,
path_target=path_target,
goal_analysis=goal_analysis,
)
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]:
resolve = ctx.start_target_resolve
return {
"goal_analysis": ctx.goal_analysis.model_dump() if ctx.goal_analysis else None,
"resolved_structured": (
ctx.resolved_structured.model_dump() if ctx.resolved_structured else None
),
"start_target_extract": (
ctx.start_target_extract.model_dump() if ctx.start_target_extract else None
),
"start_target_sources": (
{
"start": resolve.start_source,
"target": resolve.target_source,
"notes": resolve.notes_source,
"topic": resolve.topic_source,
}
if resolve
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_start_target_applied": ctx.llm_start_target_applied,
"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": {
"start_target": PROMPT_SLUG_START_TARGET,
"goal_analysis": PROMPT_SLUG_GOAL_ANALYSIS,
"roadmap": PROMPT_SLUG_ROADMAP,
"stage_spec": PROMPT_SLUG_STAGE_SPEC,
},
}
__all__ = [
"PROMPT_SLUG_START_TARGET",
"PROMPT_SLUG_GOAL_ANALYSIS",
"PROMPT_SLUG_ROADMAP",
"PROMPT_SLUG_STAGE_SPEC",
"GoalAnalysisArtifact",
"MajorStep",
"MicroObjective",
"ProgressionRoadmapContext",
"RoadmapArtifact",
"RoadmapOverridePayload",
"RoadmapStructuredInput",
"StartTargetExtractArtifact",
"StartTargetResolveMeta",
"normalize_major_steps_for_override",
"parse_start_target_from_goal_query",
"resolve_roadmap_structured_input",
"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_start_target_resolve_only",
"run_progression_roadmap_pipeline",
"try_llm_start_target_extract",
"try_llm_goal_analysis",
"try_llm_roadmap",
"try_llm_stage_specs",
]