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
- 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.
197 lines
5.5 KiB
Python
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",
|
|
]
|