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
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:
parent
f63b09fc9c
commit
044ce2ee60
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user