Implement Primary Topic Resolution in Path Logic
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 43s
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 1m22s

- Introduced `resolve_path_primary_topic` function to enhance the determination of primary topics from goal queries and semantic briefs, improving exercise relevance.
- Updated `_match_roadmap_slot` and `detect_off_topic_steps` functions to utilize the new primary topic resolution logic, ensuring accurate topic identification.
- Enhanced tests to validate the functionality of primary topic resolution and its impact on exercise selection and off-topic detection.
- Improved handling of primary topics in the `ExerciseProgressionPathBuilder` and related components for better integration with the overall path-building process.
This commit is contained in:
Lars 2026-06-11 11:06:38 +02:00
parent f63b09fc9c
commit 044ce2ee60
7 changed files with 128 additions and 16 deletions

View File

@ -46,6 +46,7 @@ from planning_exercise_semantics import (
enrich_brief_with_path_constraints, enrich_brief_with_path_constraints,
enrich_target_with_semantic_expectations, enrich_target_with_semantic_expectations,
resolve_path_anti_patterns, resolve_path_anti_patterns,
resolve_path_primary_topic,
exercise_passes_path_semantic_gate, exercise_passes_path_semantic_gate,
pick_best_path_hit, pick_best_path_hit,
resolve_semantic_skill_weights, resolve_semantic_skill_weights,
@ -631,9 +632,17 @@ def _match_roadmap_slot(
extra_context=path_context_note, extra_context=path_context_note,
) )
stage_anti = list(dict.fromkeys([*(stage_spec.anti_patterns or []), *path_anti])) stage_anti = list(dict.fromkeys([*(stage_spec.anti_patterns or []), *path_anti]))
path_primary = (semantic_brief.primary_topic or "").strip() path_primary = (
resolve_path_primary_topic(
goal_query,
semantic_brief,
stage_learning_goal=stage_goal,
extra_context=path_context_note,
)
or ""
).strip()
path_tech_excludes = list(semantic_brief.exclude_phrases or []) path_tech_excludes = list(semantic_brief.exclude_phrases or [])
if semantic_brief.topic_type == "technique" and path_primary: if path_primary:
from planning_exercise_semantics import technique_sibling_excludes from planning_exercise_semantics import technique_sibling_excludes
for item in technique_sibling_excludes(path_primary): for item in technique_sibling_excludes(path_primary):

View File

@ -25,6 +25,7 @@ from planning_exercise_semantics import (
exercise_passes_stage_learning_goal_gate, exercise_passes_stage_learning_goal_gate,
exercise_passes_technique_path_scope, exercise_passes_technique_path_scope,
resolve_path_anti_patterns, resolve_path_anti_patterns,
resolve_path_primary_topic,
score_exercise_semantic_relevance, score_exercise_semantic_relevance,
semantic_brief_for_stage, semantic_brief_for_stage,
step_phase_for_index, step_phase_for_index,
@ -456,10 +457,17 @@ def detect_off_topic_steps(
) )
) )
continue continue
primary = (brief.primary_topic or "").strip() stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
if brief.topic_type == "technique" and primary: primary = (
resolve_path_primary_topic(
goal_query or "",
brief,
stage_learning_goal=stage_goal_pre or None,
)
or ""
).strip()
if primary:
siblings = technique_sibling_excludes(primary) siblings = technique_sibling_excludes(primary)
stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
if not exercise_passes_technique_path_scope( if not exercise_passes_technique_path_scope(
primary_topic=primary, primary_topic=primary,
title=bundle["title"], title=bundle["title"],

View File

@ -180,6 +180,28 @@ def _find_technique_in_text(q_lower: str) -> Optional[Tuple[str, Tuple[str, ...]
return None return None
def resolve_path_primary_topic(
goal_query: str,
semantic_brief: Optional[PlanningSemanticBrief] = None,
*,
stage_learning_goal: Optional[str] = None,
extra_context: Optional[str] = None,
) -> Optional[str]:
"""
Haupttechnik aus Anfrage, Kontext oder Stufen-Lernziel nicht nur aus goal_query.
"""
if semantic_brief:
primary = (semantic_brief.primary_topic or "").strip()
if primary:
return primary
parts = [goal_query or "", extra_context or "", stage_learning_goal or ""]
combined = _normalize_phrase(" ".join(p for p in parts if p))
if not combined:
return None
hit = _find_technique_in_text(combined.lower())
return hit[0] if hit else None
def technique_sibling_excludes(primary_topic: str) -> List[str]: def technique_sibling_excludes(primary_topic: str) -> List[str]:
"""Andere Techniken derselben Familie (z. B. Mae/Yoko bei Mawashi) — aus Katalog.""" """Andere Techniken derselben Familie (z. B. Mae/Yoko bei Mawashi) — aus Katalog."""
topic = _normalize_phrase(primary_topic) topic = _normalize_phrase(primary_topic)
@ -1038,13 +1060,22 @@ def exercise_passes_stage_fit(
return False return False
primary_path = (path_primary_topic or "").strip() primary_path = (path_primary_topic or "").strip()
if not primary_path and lg:
hit = _find_technique_in_text(_normalize_phrase(lg))
if hit:
primary_path = hit[0]
tech_excludes = list(path_technique_excludes or [])
if primary_path:
for item in technique_sibling_excludes(primary_path):
if item not in tech_excludes:
tech_excludes.append(item)
if primary_path and not exercise_passes_technique_path_scope( if primary_path and not exercise_passes_technique_path_scope(
primary_topic=primary_path, primary_topic=primary_path,
title=title, title=title,
summary=summary, summary=summary,
goal=goal, goal=goal,
learning_goal=lg, learning_goal=lg,
sibling_excludes=path_technique_excludes, sibling_excludes=tech_excludes,
relaxed=relaxed, relaxed=relaxed,
): ):
return False return False
@ -1295,6 +1326,7 @@ __all__ = [
"build_stage_match_brief", "build_stage_match_brief",
"enrich_brief_with_path_constraints", "enrich_brief_with_path_constraints",
"exercise_passes_stage_fit", "exercise_passes_stage_fit",
"resolve_path_primary_topic",
"resolve_path_anti_patterns", "resolve_path_anti_patterns",
"exercise_passes_stage_learning_goal_gate", "exercise_passes_stage_learning_goal_gate",
"merge_semantic_brief_llm", "merge_semantic_brief_llm",

View File

@ -1028,6 +1028,7 @@ def roadmap_context_from_override(
goal_query=goal_query.strip(), goal_query=goal_query.strip(),
max_steps=effective_max, max_steps=effective_max,
semantic_brief=brief_to_summary_dict(semantic_brief), semantic_brief=brief_to_summary_dict(semantic_brief),
resolved_structured=structured,
goal_analysis=goal_analysis, goal_analysis=goal_analysis,
roadmap=RoadmapArtifact(major_steps=majors), roadmap=RoadmapArtifact(major_steps=majors),
stage_specs=stage_specs, stage_specs=stage_specs,

View File

@ -8,6 +8,7 @@ from planning_exercise_semantics import (
exercise_passes_technique_path_scope, exercise_passes_technique_path_scope,
pick_best_path_hit, pick_best_path_hit,
resolve_path_anti_patterns, resolve_path_anti_patterns,
resolve_path_primary_topic,
score_exercise_stage_fit, score_exercise_stage_fit,
semantic_brief_for_stage, semantic_brief_for_stage,
technique_sibling_excludes, technique_sibling_excludes,
@ -231,12 +232,13 @@ def test_pick_best_skips_kumite_for_mawashi_athletic_path():
"id": 2, "id": 2,
"title": "Sprungkraft Plyometrie", "title": "Sprungkraft Plyometrie",
"summary": "Absprung und Landung", "summary": "Absprung und Landung",
"goal": "Sprungkraft für Tritttechnik", "goal": "Sprungkraft für Mawashi Geri Vorbereitung",
"score": 0.62, "score": 0.62,
"semantic_score": 0.38, "semantic_score": 0.38,
"stage_semantic_score": 0.38, "stage_semantic_score": 0.38,
}, },
] ]
primary = resolve_path_primary_topic(q, brief, stage_learning_goal=stage_goal)
chosen = pick_best_path_hit( chosen = pick_best_path_hit(
hits, hits,
set(), set(),
@ -244,6 +246,56 @@ def test_pick_best_skips_kumite_for_mawashi_athletic_path():
stage_anti_patterns=path_anti, stage_anti_patterns=path_anti,
roadmap_stage_match=True, roadmap_stage_match=True,
stage_match_brief=stage_brief, stage_match_brief=stage_brief,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"),
)
assert chosen is not None
assert int(chosen["id"]) == 2
def test_resolve_path_primary_topic_from_stage_learning_goal():
brief = build_semantic_brief("Trainingsprogression gesprungener Tritt")
primary = resolve_path_primary_topic(
"Trainingsprogression gesprungener Tritt",
brief,
stage_learning_goal="Perfektionierung der statischen Mawashi Geri Technik",
)
assert primary and "mawashi" in primary
def test_pick_rejects_kumite_when_primary_only_in_stage_goal():
brief = build_semantic_brief("Trainingsprogression")
stage_goal = "Perfektionierung der statischen Mawashi Geri Technik"
stage_brief = build_stage_match_brief(learning_goal=stage_goal)
primary = resolve_path_primary_topic("Trainingsprogression", brief, stage_learning_goal=stage_goal)
hits = [
{
"id": 4,
"title": "4 Kumite Reaktions Übungen",
"summary": "Partner",
"goal": "Kumite",
"score": 0.95,
"semantic_score": 0.4,
"stage_semantic_score": 0.35,
},
{
"id": 2,
"title": "Mawashi Geri Standtechnik",
"summary": "Rundtritt",
"goal": "Mawashi Geri Basis",
"score": 0.7,
"semantic_score": 0.5,
"stage_semantic_score": 0.48,
},
]
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=stage_goal,
roadmap_stage_match=True,
stage_match_brief=stage_brief,
path_primary_topic=primary,
path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"),
) )
assert chosen is not None assert chosen is not None
assert int(chosen["id"]) == 2 assert int(chosen["id"]) == 2

View File

@ -142,8 +142,10 @@ function normalizeTitleKey(text) {
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
} }
function mergeGraphIntoPathSteps(pathRows, graphNodes) { function mergeGraphIntoPathSteps(pathRows, graphNodes, { skipGraphMerge = false } = {}) {
if (!Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) return pathRows if (skipGraphMerge || !Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) {
return pathRows
}
return pathRows.map((row, i) => { return pathRows.map((row, i) => {
const node = graphNodes[i] const node = graphNodes[i]
if (!node?.exercise_id) return row if (!node?.exercise_id) return row
@ -1019,14 +1021,14 @@ export default function ExerciseProgressionPathBuilder({
} }
} }
const applyPathMatchResponse = (res, q) => { const applyPathMatchResponse = (res, q, { skipGraphMerge = true } = {}) => {
const qa = res?.path_qa || null const qa = res?.path_qa || null
const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow) const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
const rows = applyOffTopicFlags(rawRows, qa) const rows = applyOffTopicFlags(rawRows, qa)
const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes, { skipGraphMerge })
if (rows.length < 2) { if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.') throw new Error('Zu wenig Schritte im Vorschlag.')
} }
const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes)
const rawGaps = Array.isArray(res?.gap_fill_offers) const rawGaps = Array.isArray(res?.gap_fill_offers)
? res.gap_fill_offers ? res.gap_fill_offers
: Array.isArray(qa?.gap_fill_offers) : Array.isArray(qa?.gap_fill_offers)

View File

@ -785,7 +785,7 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
} }
/** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */ /** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */
export function applyGapOffersFromResponse(draft, res) { export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) {
let next = draft let next = draft
if (res?.progression_roadmap) { if (res?.progression_roadmap) {
next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap) next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap)
@ -798,8 +798,14 @@ export function applyGapOffersFromResponse(draft, res) {
const idx = resolveOfferSlotIndex(next, offer) const idx = resolveOfferSlotIndex(next, offer)
if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue
const primary = next.slots[idx]?.primary const primary = next.slots[idx]?.primary
if (primary?.kind === 'library' && primary.exerciseId != null) continue const isOffTopicReplace =
if (primary?.kind === 'proposal') continue replaceOffTopicSlots &&
offer?.source === 'off_topic' &&
offer?.replace_step_index != null
if (!isOffTopicReplace) {
if (primary?.kind === 'library' && primary.exerciseId != null) continue
if (primary?.kind === 'proposal') continue
}
next = applyGapOfferToSlot(next, idx, offer) next = applyGapOfferToSlot(next, idx, offer)
if (offer?.offer_id) placedIds.add(offer.offer_id) if (offer?.offer_id) placedIds.add(offer.offer_id)
@ -817,7 +823,7 @@ export function applyGapOffersFromResponse(draft, res) {
} }
/** Match-Antwort: Schritte + Lücken-Angebote direkt in Slots (wie früher im Pfad-Wizard sichtbar). */ /** Match-Antwort: Schritte + Lücken-Angebote direkt in Slots (wie früher im Pfad-Wizard sichtbar). */
export function applyMatchResponseToDraft(draft, res) { export function applyMatchResponseToDraft(draft, res, { replaceOffTopicSlots = true } = {}) {
let next = applyMatchStepsToSlots(draft, res?.steps) let next = applyMatchStepsToSlots(draft, res?.steps)
if (res?.progression_roadmap) { if (res?.progression_roadmap) {
next = { next = {
@ -825,7 +831,9 @@ export function applyMatchResponseToDraft(draft, res) {
pathSkillExpectations: res?.path_skill_expectations || next.pathSkillExpectations, pathSkillExpectations: res?.path_skill_expectations || next.pathSkillExpectations,
} }
} }
const { draft: withOffers, remainingOffers } = applyGapOffersFromResponse(next, res) const { draft: withOffers, remainingOffers } = applyGapOffersFromResponse(next, res, {
replaceOffTopicSlots,
})
return { draft: withOffers, remainingOffers } return { draft: withOffers, remainingOffers }
} }