shinkan-jinkendo/backend/planning_exercise_path_ai_fill.py
Lars c2c736dafc
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 51s
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 1m20s
Implement Phase E2 Enhancements for Planning Exercise Suggestion
- Introduced path reordering functionality using LLM with `ordered_step_indices`, allowing for dynamic adjustment of exercise progression paths.
- Added AI gap filling capabilities, enabling the system to propose new exercises when unbridgeable gaps are detected.
- Updated the backend to support new request parameters for path reordering and AI gap filling.
- Enhanced frontend components to reflect these new features, including alerts for AI proposals and adjustments in exercise display.
- Incremented version to 0.8.187 and updated changelog to document these significant enhancements in planning AI functionality.
2026-05-23 12:32:14 +02:00

197 lines
5.5 KiB
Python

"""
Planungs-KI Phase E2: KI-Neuanlage-Vorschläge für unüberbrückbare Pfad-Lücken.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional
import uuid
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_semantics import PlanningSemanticBrief
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
def _build_gap_ai_context(
*,
goal_query: str,
brief: PlanningSemanticBrief,
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
) -> ExerciseFormAiPromptContext:
topic = (brief.primary_topic or "Technik").strip()
phase = gap.get("expected_phase") or "vertiefung"
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
title = f"Brücke {topic} ({phase})"
goal = (
f"Planungsziel: {goal_query}\n\n"
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.\n"
f"Phase: {phase}. Thema: {topic}. "
f"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor."
)
focus_hint = topic if brief.topic_type == "technique" else None
if brief.must_phrases:
focus_hint = ", ".join(brief.must_phrases[:2])
return ExerciseFormAiPromptContext(
title=title[:280],
goal=goal[:8000],
execution=None,
focus_hint=focus_hint,
)
def ai_proposal_to_path_step(
*,
ai_payload: Mapping[str, Any],
ctx_title: str,
gap: Mapping[str, Any],
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
) -> Dict[str, Any]:
summary_text = ""
summary_obj = ai_payload.get("summary")
if isinstance(summary_obj, dict):
summary_text = str(summary_obj.get("text") or "").strip()
elif isinstance(summary_obj, str):
summary_text = summary_obj.strip()
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
title = (ctx_title or "").strip() or "KI-Vorschlag (Brücke)"
reasons = ["KI-Neuanlage-Vorschlag — Lücke ohne passende Bibliotheks-Übung"]
return {
"exercise_id": None,
"proposal_key": proposal_key,
"variant_id": None,
"title": title,
"summary": summary_text or None,
"score": None,
"semantic_score": None,
"reasons": reasons,
"variants": [],
"is_bridge": True,
"is_ai_proposal": True,
"ai_suggestion": dict(ai_payload),
"bridge_for_gap": {
"from_exercise_id": int(step_a["exercise_id"]),
"to_exercise_id": int(step_b["exercise_id"]),
"gap_score": gap.get("gap_score"),
"expected_phase": gap.get("expected_phase"),
},
}
def try_suggest_ai_bridge_step(
cur,
*,
goal_query: str,
brief: PlanningSemanticBrief,
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
) -> Optional[Dict[str, Any]]:
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
ctx = _build_gap_ai_context(
goal_query=goal_query,
brief=brief,
step_a=step_a,
step_b=step_b,
gap=gap,
)
g_plain = strip_html_to_plain(ctx.goal)
if not g_plain.strip() and not (ctx.title or "").strip():
return None
try:
payload = run_exercise_form_ai_suggestion(
cur,
ctx,
want_summary=True,
want_skills=True,
want_instructions=False,
)
except Exception as exc:
_logger.warning("KI-Lückenfüller fehlgeschlagen: %s", exc)
return None
if not payload:
return None
return ai_proposal_to_path_step(
ai_payload=payload,
ctx_title=ctx.title or "",
gap=gap,
step_a=step_a,
step_b=step_b,
)
def insert_ai_proposals_for_gaps(
cur,
steps: list,
unfilled_gaps: list,
*,
goal_query: str,
brief: PlanningSemanticBrief,
max_proposals: int = 2,
) -> tuple[list, list]:
"""Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
if not unfilled_gaps:
return steps, []
out = list(steps)
proposals: list = []
gap_by_pair = {
(int(g["from_exercise_id"]), int(g["to_exercise_id"])): g for g in unfilled_gaps
}
i = 0
while i < len(out) - 1 and len(proposals) < max_proposals:
a = out[i]
b = out[i + 1]
if a.get("is_ai_proposal") or b.get("is_ai_proposal"):
i += 1
continue
key = (int(a["exercise_id"]), int(b["exercise_id"]))
gap = gap_by_pair.get(key)
if not gap:
i += 1
continue
proposal = try_suggest_ai_bridge_step(
cur,
goal_query=goal_query,
brief=brief,
step_a=a,
step_b=b,
gap=gap,
)
if not proposal:
i += 1
continue
out.insert(i + 1, proposal)
proposals.append(
{
"inserted_after_index": i,
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"gap": gap,
}
)
i += 2
return out, proposals
__all__ = [
"insert_ai_proposals_for_gaps",
"try_suggest_ai_bridge_step",
]