""" 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) add( { "source": "off_topic", "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"), }, "phase": phase, "title_hint": f"{topic} — {phase} (Ersatz für themenfremden Schritt)", "sketch": _default_sketch( goal_query=goal_query, brief=brief, step_a=step_a, step_b=step_b, phase=str(phase), rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.", ), "rationale": f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema.", } ) 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", ]