shinkan-jinkendo/backend/planning_exercise_path_ai_fill.py
Lars d448c3191f
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
Enhance Stage Mismatch Handling and Roadmap Slot Purging
- 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.
2026-06-12 07:57:19 +02:00

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",
]