All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s
- Introduced `_purge_stage_mismatch_roadmap_slots` to clear slots with persistent stage mismatches, improving the relevance of exercise suggestions. - Updated `collect_gap_fill_specs` to handle stage mismatch issues more effectively, providing clearer rationale and title hints for off-topic exercises. - Modified `_filter_learning_goal_candidate_ids` to enforce stricter filtering criteria, ensuring only relevant candidates are considered. - Enhanced `rematch_roadmap_slots` to incorporate slot assignment history, preventing conflicts with previously assigned exercises. - Bumped version to 0.8.230 to reflect the new features and improvements.
789 lines
27 KiB
Python
789 lines
27 KiB
Python
"""
|
|
Planungs-KI Phase E2/E3: KI-Neuanlage für Pfad-Lücken + strukturierte Angebote für die UI.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
|
|
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_path_qa import find_step_pair_index
|
|
from planning_exercise_form_context import (
|
|
build_progression_entry_state,
|
|
build_progression_gap_snapshot,
|
|
enrich_gap_snapshot_with_entry_state,
|
|
prior_path_steps_before_major,
|
|
)
|
|
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
|
|
|
|
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
|
|
|
|
|
def _resolve_neighbor_steps_by_major_index(
|
|
steps: Sequence[Mapping[str, Any]],
|
|
major_idx: int,
|
|
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
|
"""Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position)."""
|
|
step_before: Optional[Mapping[str, Any]] = None
|
|
step_after: Optional[Mapping[str, Any]] = None
|
|
for step in steps:
|
|
raw = step.get("roadmap_major_step_index")
|
|
if raw is None:
|
|
continue
|
|
try:
|
|
mi = int(raw)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if mi < major_idx:
|
|
step_before = step
|
|
elif mi > major_idx and step_after is None:
|
|
step_after = step
|
|
return step_before, step_after
|
|
|
|
|
|
def _build_stage_ai_context(
|
|
*,
|
|
goal_query: str,
|
|
brief: PlanningSemanticBrief,
|
|
spec: Mapping[str, Any],
|
|
step_before: Optional[Mapping[str, Any]] = None,
|
|
step_after: Optional[Mapping[str, Any]] = None,
|
|
prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
start_situation: Optional[str] = None,
|
|
) -> ExerciseFormAiPromptContext:
|
|
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
|
|
gap = dict(spec.get("gap") or {})
|
|
phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung"
|
|
topic = (brief.primary_topic or "Technik").strip()
|
|
learning_goal = (
|
|
gap.get("learning_goal")
|
|
or spec.get("title_hint")
|
|
or spec.get("sketch")
|
|
or ""
|
|
).strip()
|
|
title = (spec.get("title_hint") or f"{topic} — {phase}").strip()[:280]
|
|
major_idx = spec.get("roadmap_major_step_index")
|
|
entry: Dict[str, Any] = {}
|
|
if prior_steps is not None and major_idx is not None:
|
|
entry = build_progression_entry_state(
|
|
major_step_index=major_idx,
|
|
prior_steps=prior_steps,
|
|
start_situation=start_situation,
|
|
)
|
|
|
|
goal_parts = [
|
|
f"Planungsziel: {goal_query}",
|
|
f"Roadmap-Stufe ({phase}): {learning_goal}",
|
|
"Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
|
|
]
|
|
if entry.get("entry_state"):
|
|
goal_parts.append(
|
|
f"Eingangszustand (erreichte Voraussetzungen): {entry['entry_state']}"
|
|
)
|
|
if entry.get("entry_state_detail") and entry.get("entry_state_detail") != entry.get("entry_state"):
|
|
goal_parts.append(f"Bisheriger Pfad:\n{entry['entry_state_detail']}")
|
|
if step_before:
|
|
goal_parts.append(
|
|
f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}“"
|
|
)
|
|
if step_after:
|
|
goal_parts.append(
|
|
f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}“"
|
|
)
|
|
sketch = (spec.get("sketch") or "").strip()
|
|
if sketch and sketch != learning_goal:
|
|
goal_parts.extend(["", f"Kontext: {sketch}"])
|
|
goal = "\n".join(goal_parts)
|
|
|
|
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 try_suggest_ai_stage_step(
|
|
cur,
|
|
*,
|
|
goal_query: str,
|
|
brief: PlanningSemanticBrief,
|
|
spec: Mapping[str, Any],
|
|
steps: Sequence[Mapping[str, Any]],
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""KI-Vorschlag für leere Roadmap-Stufe."""
|
|
major_idx = spec.get("roadmap_major_step_index")
|
|
if major_idx is None:
|
|
return None
|
|
try:
|
|
mi = int(major_idx)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
|
|
prior_steps = prior_path_steps_before_major(steps, mi)
|
|
gap = dict(spec.get("gap") or {})
|
|
if not gap.get("expected_phase"):
|
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
|
gap["roadmap_major_step_index"] = mi
|
|
if not gap.get("learning_goal"):
|
|
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
|
|
|
|
ctx = _build_stage_ai_context(
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
spec=spec,
|
|
step_before=step_before,
|
|
step_after=step_after,
|
|
prior_steps=prior_steps,
|
|
)
|
|
try:
|
|
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
|
|
except Exception:
|
|
_logger.exception("roadmap_unfilled AI suggest failed")
|
|
return None
|
|
if not ai_payload:
|
|
return None
|
|
|
|
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 spec.get("title_hint") or "KI-Vorschlag").strip()
|
|
return {
|
|
"exercise_id": None,
|
|
"proposal_key": proposal_key,
|
|
"variant_id": None,
|
|
"title": title,
|
|
"summary": summary_text or None,
|
|
"score": None,
|
|
"semantic_score": None,
|
|
"reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"],
|
|
"variants": [],
|
|
"is_bridge": False,
|
|
"is_ai_proposal": True,
|
|
"ai_suggestion": dict(ai_payload),
|
|
"roadmap_major_step_index": mi,
|
|
"roadmap_phase": gap.get("expected_phase"),
|
|
"roadmap_learning_goal": gap.get("learning_goal"),
|
|
}
|
|
|
|
|
|
def _build_gap_ai_context(
|
|
*,
|
|
goal_query: str,
|
|
brief: PlanningSemanticBrief,
|
|
step_a: Mapping[str, Any],
|
|
step_b: Mapping[str, Any],
|
|
gap: Mapping[str, Any],
|
|
title_hint: Optional[str] = None,
|
|
sketch_hint: Optional[str] = None,
|
|
) -> 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 = (title_hint or f"Brücke {topic} ({phase})").strip()[:280]
|
|
sketch = (sketch_hint or "").strip()
|
|
goal_parts = [
|
|
f"Planungsziel: {goal_query}",
|
|
"",
|
|
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.",
|
|
f"Phase: {phase}. Thema: {topic}.",
|
|
"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor.",
|
|
]
|
|
if sketch:
|
|
goal_parts.extend(["", f"Hinweis: {sketch}"])
|
|
goal = "\n".join(goal_parts)
|
|
|
|
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": step_a.get("exercise_id"),
|
|
"to_exercise_id": step_b.get("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],
|
|
title_hint: Optional[str] = None,
|
|
sketch_hint: Optional[str] = None,
|
|
) -> 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,
|
|
title_hint=title_hint,
|
|
sketch_hint=sketch_hint,
|
|
)
|
|
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 _default_sketch(
|
|
*,
|
|
goal_query: str,
|
|
brief: PlanningSemanticBrief,
|
|
step_a: Optional[Mapping[str, Any]],
|
|
step_b: Optional[Mapping[str, Any]],
|
|
phase: str,
|
|
rationale: str = "",
|
|
) -> str:
|
|
topic = (brief.primary_topic or "Technik").strip()
|
|
from_t = (step_a or {}).get("title") or "vorherigem Schritt"
|
|
to_t = (step_b or {}).get("title") or "nächstem Schritt"
|
|
parts = [
|
|
f"Planungsziel: {goal_query}",
|
|
f"Zwischenschritt für {topic} ({phase}) zwischen „{from_t}“ und „{to_t}“.",
|
|
]
|
|
if rationale:
|
|
parts.append(rationale)
|
|
return " ".join(parts)[:1200]
|
|
|
|
|
|
def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
|
|
return (
|
|
spec.get("source"),
|
|
int(spec.get("insert_after_index") or 0),
|
|
str(spec.get("title_hint") or "")[:48],
|
|
)
|
|
|
|
|
|
def _step_neighbors_at_index(
|
|
steps: Sequence[Mapping[str, Any]],
|
|
idx: int,
|
|
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
|
"""Vorheriger/nächster Pfadschritt ohne IndexError (Rand-Slots, leere Stufen)."""
|
|
if idx < 0 or idx >= len(steps):
|
|
return None, None
|
|
step_a = steps[idx - 1] if idx > 0 else None
|
|
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
|
return step_a, step_b
|
|
|
|
|
|
def collect_gap_fill_specs(
|
|
*,
|
|
steps: Sequence[Mapping[str, Any]],
|
|
unfilled_gaps: Sequence[Mapping[str, Any]],
|
|
off_topic_steps: Sequence[Mapping[str, Any]],
|
|
llm_specs: Sequence[Mapping[str, Any]],
|
|
brief: PlanningSemanticBrief,
|
|
goal_query: str,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Sammelt alle Lücken, für die ein KI-Anlege-Angebot sinnvoll ist."""
|
|
topic = (brief.primary_topic or "Technik").strip()
|
|
specs: List[Dict[str, Any]] = []
|
|
seen: set = set()
|
|
|
|
def add(spec: Dict[str, Any]) -> None:
|
|
key = _spec_dedupe_key(spec)
|
|
if key in seen:
|
|
return
|
|
seen.add(key)
|
|
specs.append(spec)
|
|
|
|
for gap in unfilled_gaps:
|
|
idx = find_step_pair_index(
|
|
steps,
|
|
int(gap["from_exercise_id"]),
|
|
int(gap["to_exercise_id"]),
|
|
)
|
|
if idx is None or idx + 1 >= len(steps):
|
|
continue
|
|
step_a = steps[idx]
|
|
step_b = steps[idx + 1]
|
|
phase = gap.get("expected_phase") or "vertiefung"
|
|
add(
|
|
{
|
|
"source": "unfilled_gap",
|
|
"insert_after_index": idx,
|
|
"gap": dict(gap),
|
|
"phase": phase,
|
|
"title_hint": f"{topic} — {phase}",
|
|
"sketch": _default_sketch(
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
step_a=step_a,
|
|
step_b=step_b,
|
|
phase=str(phase),
|
|
rationale="Bibliothek enthält keine passende Brücke.",
|
|
),
|
|
"rationale": "Lücke zwischen benachbaren Schritten — keine passende Bibliotheks-Übung.",
|
|
}
|
|
)
|
|
|
|
for ot in off_topic_steps:
|
|
major_idx = ot.get("roadmap_major_step_index")
|
|
idx: Optional[int] = None
|
|
if major_idx is not None:
|
|
try:
|
|
mi = int(major_idx)
|
|
except (TypeError, ValueError):
|
|
mi = None
|
|
if mi is not None:
|
|
idx = next(
|
|
(
|
|
i
|
|
for i, s in enumerate(steps)
|
|
if s.get("roadmap_major_step_index") is not None
|
|
and int(s["roadmap_major_step_index"]) == mi
|
|
),
|
|
None,
|
|
)
|
|
if idx is None:
|
|
idx = int(ot.get("step_index") or 0)
|
|
if idx < 0 or idx >= len(steps):
|
|
continue
|
|
step_a, step_b = _step_neighbors_at_index(steps, idx)
|
|
phase = ot.get("expected_phase") or "vertiefung"
|
|
insert_after = max(idx - 1, -1)
|
|
stage_goal = str(ot.get("roadmap_learning_goal") or "").strip()
|
|
if str(ot.get("issue") or "") == "stage_mismatch" and stage_goal:
|
|
title_hint = stage_goal[:120]
|
|
rationale = (
|
|
f"Keine passende Bibliotheks-Übung für Stufen-Lernziel „{stage_goal[:100]}“."
|
|
)
|
|
sketch_rationale = (
|
|
f"Slot braucht Übung passend zu: {stage_goal[:200]}"
|
|
)
|
|
else:
|
|
title_hint = f"{topic} — {phase} (Ersatz für themenfremden Schritt)"
|
|
rationale = f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema."
|
|
sketch_rationale = f"Ersetzt themenfremden Schritt „{ot.get('title')}“."
|
|
add(
|
|
{
|
|
"source": "off_topic" if ot.get("issue") != "stage_mismatch" else "stage_mismatch",
|
|
"insert_after_index": insert_after,
|
|
"replace_step_index": idx,
|
|
"roadmap_major_step_index": major_idx,
|
|
"gap": {
|
|
"expected_phase": phase,
|
|
"off_topic_title": ot.get("title"),
|
|
"off_topic_exercise_id": ot.get("exercise_id"),
|
|
"roadmap_learning_goal": stage_goal or None,
|
|
},
|
|
"phase": phase,
|
|
"title_hint": title_hint,
|
|
"sketch": _default_sketch(
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
step_a=step_a,
|
|
step_b=step_b,
|
|
phase=str(phase),
|
|
rationale=sketch_rationale,
|
|
),
|
|
"rationale": rationale,
|
|
}
|
|
)
|
|
|
|
for spec in llm_specs:
|
|
add(dict(spec))
|
|
|
|
return specs[:5]
|
|
|
|
|
|
def build_gap_fill_goal_text(
|
|
*,
|
|
goal_query: str,
|
|
brief: PlanningSemanticBrief,
|
|
spec: Mapping[str, Any],
|
|
step_a: Optional[Mapping[str, Any]] = None,
|
|
step_b: Optional[Mapping[str, Any]] = None,
|
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
|
) -> str:
|
|
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
|
|
topic = (brief.primary_topic or "Technik").strip()
|
|
phase = spec.get("phase") or "vertiefung"
|
|
from_title = (step_a or {}).get("title") or spec.get("from_title") or "vorherigem Schritt"
|
|
to_title = (step_b or {}).get("title") or spec.get("to_title") or "nächstem Schritt"
|
|
arc = ", ".join(brief.development_arc or []) or "einstieg → grundlage → vertiefung → anwendung → perfektion"
|
|
snap = dict(roadmap_snapshot or {})
|
|
if not snap:
|
|
snap = build_progression_gap_snapshot(semantic_brief=brief_to_summary_dict(brief))
|
|
|
|
parts = [
|
|
f"Planungsziel (gesamter Pfad): {goal_query}",
|
|
f"Hauptthema: {snap.get('primary_topic') or topic}",
|
|
]
|
|
if snap.get("entry_state"):
|
|
parts.append(
|
|
f"Eingangszustand (erreichte Voraussetzungen aus Vorstufen): {snap['entry_state']}"
|
|
)
|
|
if snap.get("entry_state_detail") and snap.get("entry_state_detail") != snap.get("entry_state"):
|
|
parts.append(f"Bisheriger Pfad:\n{snap['entry_state_detail']}")
|
|
if snap.get("start_situation") and not snap.get("entry_state"):
|
|
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
|
|
elif snap.get("start_situation") and snap.get("prior_steps"):
|
|
parts.append(f"Ausgangsbasis des gesamten Pfads: {snap['start_situation']}")
|
|
if snap.get("target_state"):
|
|
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
|
|
if snap.get("roadmap_notes"):
|
|
parts.append(f"Ergänzender Kontext: {snap['roadmap_notes']}")
|
|
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
|
|
if stage_goal:
|
|
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
|
|
parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
|
|
parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
|
|
if spec.get("source") == "roadmap_unfilled":
|
|
parts.append(
|
|
"Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund."
|
|
)
|
|
if step_a:
|
|
parts.append(f"Vorherige Stufe: „{from_title}“")
|
|
if step_b:
|
|
parts.append(f"Nächste Stufe: „{to_title}“")
|
|
else:
|
|
parts.append(
|
|
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“."
|
|
)
|
|
if snap.get("stage_load_profile"):
|
|
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
|
|
if snap.get("stage_success_criteria"):
|
|
parts.append(
|
|
"Erfolgskriterien dieser Stufe: "
|
|
+ "; ".join(str(x) for x in snap["stage_success_criteria"][:4])
|
|
)
|
|
if snap.get("stage_anti_patterns"):
|
|
parts.append(
|
|
"Vermeiden: " + "; ".join(str(x) for x in snap["stage_anti_patterns"][:3])
|
|
)
|
|
if snap.get("skill_hints"):
|
|
parts.append(
|
|
"Fähigkeiten-/Fokus-Hinweise: "
|
|
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
|
|
)
|
|
expected = snap.get("expected_skills") or []
|
|
if expected:
|
|
names = [
|
|
str(s.get("skill_name") or "").strip()
|
|
for s in expected[:5]
|
|
if str(s.get("skill_name") or "").strip()
|
|
]
|
|
if names:
|
|
parts.append(
|
|
"Erwartete Fähigkeiten (Scoring): " + ", ".join(names)
|
|
)
|
|
if spec.get("rationale"):
|
|
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
|
|
if spec.get("sketch"):
|
|
parts.append(f"Skizze: {spec['sketch']}")
|
|
parts.append(
|
|
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
|
|
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
|
|
"Konkrete Durchführung, Ziel und Trainerhinweise ausformulieren."
|
|
)
|
|
return "\n\n".join(parts)[:8000]
|
|
|
|
|
|
def build_gap_fill_offer(
|
|
*,
|
|
spec: Mapping[str, Any],
|
|
steps: Sequence[Mapping[str, Any]],
|
|
goal_query: str = "",
|
|
brief: Optional[PlanningSemanticBrief] = None,
|
|
proposal: Optional[Mapping[str, Any]] = None,
|
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
source = spec.get("source")
|
|
idx = int(spec.get("insert_after_index") or 0)
|
|
major_idx = spec.get("roadmap_major_step_index")
|
|
if source == "roadmap_unfilled" and major_idx is not None:
|
|
try:
|
|
mi = int(major_idx)
|
|
except (TypeError, ValueError):
|
|
mi = idx
|
|
step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi)
|
|
idx = mi
|
|
else:
|
|
step_a = steps[idx] if idx < len(steps) else None
|
|
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
|
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
|
enriched_snapshot = dict(roadmap_snapshot) if roadmap_snapshot else {}
|
|
major_raw = spec.get("roadmap_major_step_index")
|
|
if major_raw is not None:
|
|
enriched_snapshot = enrich_gap_snapshot_with_entry_state(
|
|
enriched_snapshot,
|
|
steps=steps,
|
|
major_step_index=major_raw,
|
|
)
|
|
goal_for_ai = ""
|
|
if brief and goal_query:
|
|
goal_for_ai = build_gap_fill_goal_text(
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
spec=spec,
|
|
step_a=step_a,
|
|
step_b=step_b,
|
|
roadmap_snapshot=enriched_snapshot or None,
|
|
)
|
|
ctx_preview = enriched_snapshot or None
|
|
offer: Dict[str, Any] = {
|
|
"offer_id": offer_id,
|
|
"source": spec.get("source"),
|
|
"insert_after_index": idx,
|
|
"replace_step_index": spec.get("replace_step_index"),
|
|
"title_hint": spec.get("title_hint"),
|
|
"sketch": spec.get("sketch"),
|
|
"goal_for_ai": goal_for_ai or spec.get("sketch"),
|
|
"context_preview": ctx_preview,
|
|
"phase": spec.get("phase"),
|
|
"rationale": spec.get("rationale"),
|
|
"has_ai_payload": False,
|
|
"from_title": (step_a or {}).get("title"),
|
|
"to_title": (step_b or {}).get("title"),
|
|
"primary_topic": (brief.primary_topic if brief else None),
|
|
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
|
}
|
|
if proposal:
|
|
offer["has_ai_payload"] = True
|
|
offer["proposal_key"] = proposal.get("proposal_key")
|
|
offer["ai_suggestion"] = proposal.get("ai_suggestion")
|
|
offer["proposal_title"] = proposal.get("title")
|
|
offer["proposal_summary"] = proposal.get("summary")
|
|
return offer
|
|
|
|
|
|
def apply_gap_fill_after_qa(
|
|
cur,
|
|
steps: List[Dict[str, Any]],
|
|
specs: Sequence[Mapping[str, Any]],
|
|
*,
|
|
goal_query: str,
|
|
brief: PlanningSemanticBrief,
|
|
include_ai_calls: bool = True,
|
|
max_ai_proposals: int = 3,
|
|
auto_insert_proposals: bool = False,
|
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""
|
|
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
|
|
Returns: (steps, ai_proposals, gap_fill_offers)
|
|
"""
|
|
if not specs:
|
|
return steps, [], []
|
|
|
|
out = list(steps)
|
|
proposals: List[Dict[str, Any]] = []
|
|
offers: List[Dict[str, Any]] = []
|
|
|
|
for spec in specs:
|
|
source = spec.get("source")
|
|
|
|
if source == "roadmap_unfilled":
|
|
proposal: Optional[Dict[str, Any]] = None
|
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
|
proposal = try_suggest_ai_stage_step(
|
|
cur,
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
spec=spec,
|
|
steps=out,
|
|
)
|
|
offer = build_gap_fill_offer(
|
|
spec=spec,
|
|
steps=out,
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
proposal=proposal,
|
|
roadmap_snapshot=roadmap_snapshot,
|
|
)
|
|
offers.append(offer)
|
|
if proposal and auto_insert_proposals:
|
|
proposals.append(
|
|
{
|
|
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
|
"proposal_key": proposal.get("proposal_key"),
|
|
"proposal_title": proposal.get("title"),
|
|
"offer_id": offer.get("offer_id"),
|
|
}
|
|
)
|
|
continue
|
|
|
|
idx = int(spec.get("insert_after_index") or 0)
|
|
if idx < 0 or idx >= len(out) - 1:
|
|
continue
|
|
step_a = out[idx]
|
|
step_b = out[idx + 1]
|
|
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
|
|
offer = build_gap_fill_offer(
|
|
spec=spec,
|
|
steps=out,
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
proposal=None,
|
|
roadmap_snapshot=roadmap_snapshot,
|
|
)
|
|
offers.append(offer)
|
|
continue
|
|
|
|
gap = dict(spec.get("gap") or {})
|
|
if not gap.get("expected_phase"):
|
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
|
|
|
proposal = None
|
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
|
proposal = try_suggest_ai_bridge_step(
|
|
cur,
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
step_a=step_a,
|
|
step_b=step_b,
|
|
gap=gap,
|
|
title_hint=str(spec.get("title_hint") or ""),
|
|
sketch_hint=str(spec.get("sketch") or ""),
|
|
)
|
|
|
|
offer = build_gap_fill_offer(
|
|
spec=spec,
|
|
steps=out,
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
proposal=proposal,
|
|
roadmap_snapshot=roadmap_snapshot,
|
|
)
|
|
offers.append(offer)
|
|
|
|
if proposal and auto_insert_proposals:
|
|
out.insert(idx + 1, proposal)
|
|
proposals.append(
|
|
{
|
|
"inserted_after_index": idx,
|
|
"proposal_key": proposal.get("proposal_key"),
|
|
"proposal_title": proposal.get("title"),
|
|
"gap": gap,
|
|
"offer_id": offer.get("offer_id"),
|
|
}
|
|
)
|
|
|
|
return out, proposals, offers
|
|
|
|
|
|
def insert_ai_proposals_for_gaps(
|
|
cur,
|
|
steps: list,
|
|
unfilled_gaps: list,
|
|
*,
|
|
goal_query: str,
|
|
brief: PlanningSemanticBrief,
|
|
max_proposals: int = 2,
|
|
) -> tuple[list, list]:
|
|
"""Legacy: Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
|
|
specs = collect_gap_fill_specs(
|
|
steps=steps,
|
|
unfilled_gaps=unfilled_gaps,
|
|
off_topic_steps=[],
|
|
llm_specs=[],
|
|
brief=brief,
|
|
goal_query=goal_query,
|
|
)
|
|
out, proposals, _offers = apply_gap_fill_after_qa(
|
|
cur,
|
|
steps,
|
|
specs,
|
|
goal_query=goal_query,
|
|
brief=brief,
|
|
include_ai_calls=True,
|
|
max_ai_proposals=max_proposals,
|
|
auto_insert_proposals=True,
|
|
)
|
|
return out, proposals
|
|
|
|
|
|
__all__ = [
|
|
"apply_gap_fill_after_qa",
|
|
"build_gap_fill_goal_text",
|
|
"build_gap_fill_offer",
|
|
"collect_gap_fill_specs",
|
|
"insert_ai_proposals_for_gaps",
|
|
"try_suggest_ai_bridge_step",
|
|
"try_suggest_ai_stage_step",
|
|
]
|