Enhance AI Gap Fill Logic and Progression Path Handling
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 45s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s

- Integrated `try_suggest_ai_stage_step` to suggest AI-generated gap fill steps based on user input, improving the automation of the planning process.
- Updated `_enrich_roadmap_unfilled_gap_offers` to conditionally include AI gap fill proposals, enhancing the offer generation logic.
- Implemented `_merge_gap_fill_offers_from_steps` to consolidate gap fill offers from various steps, ensuring a comprehensive list of available offers.
- Modified `ProgressionGraphEditor` to utilize the new merging logic for gap fill offers, improving the user experience in managing offers.
- Enhanced utility functions to streamline the collection and filtering of gap fill offers from API responses.
- Bumped version to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-13 08:36:53 +02:00
parent 3f130aa8ad
commit 89c6780294
3 changed files with 77 additions and 22 deletions

View File

@ -47,6 +47,7 @@ from planning_exercise_path_ai_fill import (
apply_gap_fill_after_qa, apply_gap_fill_after_qa,
build_gap_fill_offer, build_gap_fill_offer,
collect_gap_fill_specs, collect_gap_fill_specs,
try_suggest_ai_stage_step,
) )
from planning_exercise_retrieval import run_multistage_planning_retrieval from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_semantics import ( from planning_exercise_semantics import (
@ -1534,6 +1535,15 @@ def _enrich_roadmap_unfilled_gap_offers(
"KI-Entwurf für diese Stufe." "KI-Entwurf für diese Stufe."
), ),
} }
proposal = None
if body.include_ai_gap_fill:
proposal = try_suggest_ai_stage_step(
cur,
goal_query=goal_query,
brief=semantic_brief,
spec=spec,
steps=steps,
)
offer = build_gap_fill_offer( offer = build_gap_fill_offer(
spec=spec, spec=spec,
steps=steps, steps=steps,
@ -2117,8 +2127,8 @@ def _run_evaluate_only_path_qa(
gap_specs, gap_specs,
goal_query=goal_query, goal_query=goal_query,
brief=semantic_brief, brief=semantic_brief,
include_ai_calls=False, include_ai_calls=bool(body.include_ai_gap_fill),
max_ai_proposals=0, max_ai_proposals=3,
auto_insert_proposals=False, auto_insert_proposals=False,
roadmap_snapshot=path_roadmap_snapshot, roadmap_snapshot=path_roadmap_snapshot,
) )
@ -2394,6 +2404,28 @@ def _evaluate_steps_for_compare_qa(
return suggest_progression_path(cur, tenant=tenant, body=eval_body) return suggest_progression_path(cur, tenant=tenant, body=eval_body)
def _merge_gap_fill_offers_from_steps(
steps: Sequence[Mapping[str, Any]],
offers: Sequence[Mapping[str, Any]],
) -> List[Dict[str, Any]]:
"""Gap-Angebote aus Schritt-gap_offer + Top-Level-Liste vereinigen."""
merged: List[Dict[str, Any]] = [dict(o) for o in offers or [] if isinstance(o, dict)]
seen = {o.get("offer_id") for o in merged if o.get("offer_id")}
for raw in steps or []:
if not isinstance(raw, dict):
continue
go = raw.get("gap_offer")
if not isinstance(go, dict):
continue
oid = go.get("offer_id")
if oid and oid in seen:
continue
if oid:
seen.add(oid)
merged.append(dict(go))
return merged
def _build_progression_compare_response( def _build_progression_compare_response(
baseline: Mapping[str, Any], baseline: Mapping[str, Any],
proposed: Mapping[str, Any], proposed: Mapping[str, Any],
@ -2414,6 +2446,10 @@ def _build_progression_compare_response(
) )
actionable_diffs = _actionable_slot_diffs(slot_diffs) actionable_diffs = _actionable_slot_diffs(slot_diffs)
apply_steps = list(proposed_steps) apply_steps = list(proposed_steps)
gap_fill_offers = _merge_gap_fill_offers_from_steps(
apply_steps,
proposed.get("gap_fill_offers") or [],
)
return { return {
**dict(proposed), **dict(proposed),
"comparison_mode": True, "comparison_mode": True,
@ -2423,6 +2459,7 @@ def _build_progression_compare_response(
"proposed_steps_pipeline": proposed_steps, "proposed_steps_pipeline": proposed_steps,
"proposed_path_qa": fair_qa, "proposed_path_qa": fair_qa,
"proposed_path_qa_pipeline": pipeline_qa, "proposed_path_qa_pipeline": pipeline_qa,
"gap_fill_offers": gap_fill_offers,
"slot_diffs": slot_diffs, "slot_diffs": slot_diffs,
"slot_diffs_actionable": actionable_diffs, "slot_diffs_actionable": actionable_diffs,
"slot_diff_count": len(actionable_diffs), "slot_diff_count": len(actionable_diffs),
@ -2967,8 +3004,8 @@ def suggest_progression_path(
gap_specs, gap_specs,
goal_query=goal_query, goal_query=goal_query,
brief=semantic_brief, brief=semantic_brief,
include_ai_calls=False, include_ai_calls=bool(body.include_ai_gap_fill),
max_ai_proposals=0, max_ai_proposals=3,
auto_insert_proposals=False, auto_insert_proposals=False,
roadmap_snapshot=path_roadmap_snapshot, roadmap_snapshot=path_roadmap_snapshot,
) )

View File

@ -32,6 +32,7 @@ import {
collectGapOffersFromApiResponse, collectGapOffersFromApiResponse,
dedupeGapOffersBySlot, dedupeGapOffersBySlot,
filterGapOffersForUnfilledSlots, filterGapOffersForUnfilledSlots,
mergeGapOffersForDraft,
pathQaQualityPercent, pathQaQualityPercent,
applyResolvedStructuredToDraft, applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft, buildPlanningArtifactFromDraft,
@ -500,11 +501,14 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
return res return res
} }
const gapOffersFromMatchResponse = (synced, res) => const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
filterGapOffersForUnfilledSlots( const res = await fetchMatchCompare(synced)
synced, const evalRes = await fetchPathEvaluate(synced)
dedupeGapOffersBySlot(collectGapOffersFromApiResponse(res), synced), setGapFillOffers(mergeGapOffersForDraft(synced, res, evalRes))
) presentMatchCompare(res, { source })
setPathQa(evalRes?.path_qa || res?.baseline_path_qa || null)
return res
}
const presentMatchCompare = (res, { source = 'manual' } = {}) => { const presentMatchCompare = (res, { source = 'manual' } = {}) => {
setSemanticBrief(res?.semantic_brief_summary || null) setSemanticBrief(res?.semantic_brief_summary || null)
@ -523,22 +527,17 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
let notice = let notice =
diffCount > 0 diffCount > 0
? `Match: ${diffCount} Slot-Vorschlag/Vorschläge — bitte im Dialog prüfen und auswählen.` ? `Match: ${diffCount} Slot-Vorschlag/Vorschläge — bitte im Dialog prüfen und auswählen.`
: 'Match: Keine abweichenden Slot-Vorschläge — Dialog zur Kontrolle geöffnet.' : 'Match: Keine abweichenden Bibliotheks-Slots — Dialog zur Kontrolle geöffnet.'
const gapCount = collectGapOffersFromApiResponse(res).length
if (gapCount > 0) {
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`
}
if (bPct != null && pPct != null && pPct !== bPct) { if (bPct != null && pPct != null && pPct !== bPct) {
notice += ` Pfad-QS Vorschlag fair bewertet: ${bPct} % → ${pPct} %.` notice += ` Pfad-QS Vorschlag fair bewertet: ${bPct} % → ${pPct} %.`
} }
setMatchNotice(notice) setMatchNotice(notice)
} }
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
const res = await fetchMatchCompare(synced)
setGapFillOffers(gapOffersFromMatchResponse(synced, res))
presentMatchCompare(res, { source })
const evalRes = await fetchPathEvaluate(synced)
setPathQa(evalRes?.path_qa || res?.baseline_path_qa || null)
return res
}
const runMatch = async () => { const runMatch = async () => {
const q = (draft?.goalQuery || '').trim() const q = (draft?.goalQuery || '').trim()
if (q.length < 3) { if (q.length < 3) {
@ -601,7 +600,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const evalRes = await fetchPathEvaluate(syncedNext) const evalRes = await fetchPathEvaluate(syncedNext)
const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes) const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes)
setDraft({ ...evaluated, dirty: true }) setDraft({ ...evaluated, dirty: true })
setGapFillOffers(remainingOffers) const mergedOffers = mergeGapOffersForDraft(evaluated, comparePayload, evalRes)
setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers)
setProposedPathQa(null) setProposedPathQa(null)
setCompareOpen(false) setCompareOpen(false)
setComparePayload(null) setComparePayload(null)
@ -632,7 +632,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const res = await fetchPathEvaluate(synced) const res = await fetchPathEvaluate(synced)
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res) const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res)
setDraft(evaluated) setDraft(evaluated)
setGapFillOffers(remainingOffers) const mergedOffers = mergeGapOffersForDraft(evaluated, res)
setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers)
} catch (e) { } catch (e) {
setActionErr(e.message || 'Bewertung fehlgeschlagen') setActionErr(e.message || 'Bewertung fehlgeschlagen')
} finally { } finally {

View File

@ -387,12 +387,29 @@ export function collectGapOffersFromApiResponse(res) {
} }
for (const offer of res?.gap_fill_offers || []) add(offer) for (const offer of res?.gap_fill_offers || []) add(offer)
for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer) for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer)
for (const step of res?.steps || []) { const stepSources = [
...(res?.steps || []),
...(res?.proposed_steps || []),
...(res?.proposed_steps_pipeline || []),
]
for (const step of stepSources) {
if (step?.gap_offer) add(step.gap_offer) if (step?.gap_offer) add(step.gap_offer)
} }
return out return out
} }
/** KI-Angebote aus einer oder mehreren Planungs-Antworten für leere Slots sammeln. */
export function mergeGapOffersForDraft(draft, ...responses) {
const collected = []
for (const res of responses) {
if (res) collected.push(...collectGapOffersFromApiResponse(res))
}
return filterGapOffersForUnfilledSlots(
draft,
dedupeGapOffersBySlot(collected, draft),
)
}
/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */ /** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
export function dedupeGapOffersBySlot(offers, draft) { export function dedupeGapOffersBySlot(offers, draft) {
const bySlot = new Map() const bySlot = new Map()