shinkan-jinkendo/backend/planning_progression_roadmap.py
Lars dd0fae4bf5
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 44s
Test Suite / playwright-tests (push) Successful in 1m15s
Enhance Planning AI with Roadmap-First Architecture and New Features
- Introduced a roadmap-first approach for the planning AI, allowing for a structured progression graph that aligns with the overall project roadmap.
- Added new functionality to strip off-topic steps from exercise paths, improving the relevance of generated exercise suggestions.
- Implemented a detailed goal text generation for AI proposals, enhancing the context provided for new exercises.
- Updated the ExerciseProgressionPathBuilder component to support new features, including roadmap previews and improved focus area handling.
- Incremented application version to 0.8.205 and updated database schema version to 20260606086 to reflect these changes.
2026-06-08 08:10:53 +02:00

565 lines
19 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, 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 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 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 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",
"StageSpecArtifact",
"build_goal_analysis",
"build_stage_specs",
"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",
]