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_target_with_semantic_expectations,
|
||||
resolve_path_anti_patterns,
|
||||
resolve_path_primary_topic,
|
||||
exercise_passes_path_semantic_gate,
|
||||
pick_best_path_hit,
|
||||
resolve_semantic_skill_weights,
|
||||
|
|
@ -631,9 +632,17 @@ def _match_roadmap_slot(
|
|||
extra_context=path_context_note,
|
||||
)
|
||||
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 [])
|
||||
if semantic_brief.topic_type == "technique" and path_primary:
|
||||
if path_primary:
|
||||
from planning_exercise_semantics import technique_sibling_excludes
|
||||
|
||||
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_technique_path_scope,
|
||||
resolve_path_anti_patterns,
|
||||
resolve_path_primary_topic,
|
||||
score_exercise_semantic_relevance,
|
||||
semantic_brief_for_stage,
|
||||
step_phase_for_index,
|
||||
|
|
@ -456,10 +457,17 @@ def detect_off_topic_steps(
|
|||
)
|
||||
)
|
||||
continue
|
||||
primary = (brief.primary_topic or "").strip()
|
||||
if brief.topic_type == "technique" and primary:
|
||||
siblings = technique_sibling_excludes(primary)
|
||||
stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
|
||||
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)
|
||||
if not exercise_passes_technique_path_scope(
|
||||
primary_topic=primary,
|
||||
title=bundle["title"],
|
||||
|
|
|
|||
|
|
@ -180,6 +180,28 @@ def _find_technique_in_text(q_lower: str) -> Optional[Tuple[str, Tuple[str, ...]
|
|||
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]:
|
||||
"""Andere Techniken derselben Familie (z. B. Mae/Yoko bei Mawashi) — aus Katalog."""
|
||||
topic = _normalize_phrase(primary_topic)
|
||||
|
|
@ -1038,13 +1060,22 @@ def exercise_passes_stage_fit(
|
|||
return False
|
||||
|
||||
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(
|
||||
primary_topic=primary_path,
|
||||
title=title,
|
||||
summary=summary,
|
||||
goal=goal,
|
||||
learning_goal=lg,
|
||||
sibling_excludes=path_technique_excludes,
|
||||
sibling_excludes=tech_excludes,
|
||||
relaxed=relaxed,
|
||||
):
|
||||
return False
|
||||
|
|
@ -1295,6 +1326,7 @@ __all__ = [
|
|||
"build_stage_match_brief",
|
||||
"enrich_brief_with_path_constraints",
|
||||
"exercise_passes_stage_fit",
|
||||
"resolve_path_primary_topic",
|
||||
"resolve_path_anti_patterns",
|
||||
"exercise_passes_stage_learning_goal_gate",
|
||||
"merge_semantic_brief_llm",
|
||||
|
|
|
|||
|
|
@ -1028,6 +1028,7 @@ def roadmap_context_from_override(
|
|||
goal_query=goal_query.strip(),
|
||||
max_steps=effective_max,
|
||||
semantic_brief=brief_to_summary_dict(semantic_brief),
|
||||
resolved_structured=structured,
|
||||
goal_analysis=goal_analysis,
|
||||
roadmap=RoadmapArtifact(major_steps=majors),
|
||||
stage_specs=stage_specs,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from planning_exercise_semantics import (
|
|||
exercise_passes_technique_path_scope,
|
||||
pick_best_path_hit,
|
||||
resolve_path_anti_patterns,
|
||||
resolve_path_primary_topic,
|
||||
score_exercise_stage_fit,
|
||||
semantic_brief_for_stage,
|
||||
technique_sibling_excludes,
|
||||
|
|
@ -231,12 +232,13 @@ def test_pick_best_skips_kumite_for_mawashi_athletic_path():
|
|||
"id": 2,
|
||||
"title": "Sprungkraft Plyometrie",
|
||||
"summary": "Absprung und Landung",
|
||||
"goal": "Sprungkraft für Tritttechnik",
|
||||
"goal": "Sprungkraft für Mawashi Geri Vorbereitung",
|
||||
"score": 0.62,
|
||||
"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(
|
||||
hits,
|
||||
set(),
|
||||
|
|
@ -244,6 +246,56 @@ def test_pick_best_skips_kumite_for_mawashi_athletic_path():
|
|||
stage_anti_patterns=path_anti,
|
||||
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 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 int(chosen["id"]) == 2
|
||||
|
|
|
|||
|
|
@ -142,8 +142,10 @@ function normalizeTitleKey(text) {
|
|||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function mergeGraphIntoPathSteps(pathRows, graphNodes) {
|
||||
if (!Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) return pathRows
|
||||
function mergeGraphIntoPathSteps(pathRows, graphNodes, { skipGraphMerge = false } = {}) {
|
||||
if (skipGraphMerge || !Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) {
|
||||
return pathRows
|
||||
}
|
||||
return pathRows.map((row, i) => {
|
||||
const node = graphNodes[i]
|
||||
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 rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
|
||||
const rows = applyOffTopicFlags(rawRows, qa)
|
||||
const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes, { skipGraphMerge })
|
||||
if (rows.length < 2) {
|
||||
throw new Error('Zu wenig Schritte im Vorschlag.')
|
||||
}
|
||||
const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes)
|
||||
const rawGaps = Array.isArray(res?.gap_fill_offers)
|
||||
? res.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. */
|
||||
export function applyGapOffersFromResponse(draft, res) {
|
||||
export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) {
|
||||
let next = draft
|
||||
if (res?.progression_roadmap) {
|
||||
next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap)
|
||||
|
|
@ -798,8 +798,14 @@ export function applyGapOffersFromResponse(draft, res) {
|
|||
const idx = resolveOfferSlotIndex(next, offer)
|
||||
if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue
|
||||
const primary = next.slots[idx]?.primary
|
||||
const isOffTopicReplace =
|
||||
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)
|
||||
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). */
|
||||
export function applyMatchResponseToDraft(draft, res) {
|
||||
export function applyMatchResponseToDraft(draft, res, { replaceOffTopicSlots = true } = {}) {
|
||||
let next = applyMatchStepsToSlots(draft, res?.steps)
|
||||
if (res?.progression_roadmap) {
|
||||
next = {
|
||||
|
|
@ -825,7 +831,9 @@ export function applyMatchResponseToDraft(draft, res) {
|
|||
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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user