shinkan-jinkendo/backend/planning_exercise_target_pipeline.py
Lars 45e3b5f4f6
All checks were successful
Deploy Development / deploy (push) Successful in 43s
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 1m14s
Implement Phase 1 of Planning Exercise Suggestion with Scenario Pipeline and LLM Intent Overlay
- Introduced the Scenario Pipeline for planning exercises, allowing for more nuanced query handling and exercise suggestions based on user intent.
- Enhanced the `suggestPlanningExercises` API to include `include_llm_intent`, `scenario_kind`, and `query_intent_summary`, improving the context provided to the frontend.
- Updated the `ExercisePickerModal` to display new information related to query intent and scenario classification, enhancing user experience during exercise selection.
- Incremented application version to 0.8.171 and updated changelog to document the new features and improvements in the planning AI capabilities.
2026-05-22 22:15:19 +02:00

285 lines
9.4 KiB
Python

"""
Szenario-Routing und Erwartungsprofil-Pipeline für Planungs-Übungssuche (P1).
Ablauf:
1. Heuristik: Intent + Szenario-Klasse aus Query/Kontext
2. Optional LLM (planning_exercise_search_intent) bei komplexen Anfragen
3. Deterministisches Basis-Profil (Rahmen, Plan, Anker)
4. Query-Overlay mergen → PlanningTargetProfile für Vorselektion
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Mapping, Optional, Tuple
from planning_exercise_intent import (
PlanningQueryIntentParsed,
resolve_query_intent_catalog_ids,
try_parse_planning_query_intent,
)
from planning_exercise_profiles import (
PlanningTargetProfile,
_merge_weight_maps,
_normalize_weight_map,
build_planning_target_profile,
)
SCENARIO_PRESET_NEXT = "preset_next"
SCENARIO_PROGRESSION = "progression"
SCENARIO_DEEPEN = "deepen"
SCENARIO_CONTINUE_PLAN = "continue_plan"
SCENARIO_ADDITIVE = "additive_constraint"
SCENARIO_FREE_SEARCH = "free_search"
_SIMPLE_PRESET_PATTERNS = (
r"^(schlage?\s+(mir\s+)?(die\s+)?(n[aä]chste|naechste)\s+(sinnvolle\s+)?(übung|uebung)\s*(vor)?\.?)$",
r"^(n[aä]chste|naechste)\s+(übung|uebung)\s*(vorschlag|vorschlagen|empfehl\w*)?\.?$",
r"^(vorschlag|vorschlagen|empfehl\w*)\s*(für|fuer)?\s*(die\s+)?(n[aä]chste|naechste)?\s*(übung|uebung)?\.?$",
r"^n[aä]chste\s+übung$",
r"^n[aä]chste\s+uebung$",
)
_ADDITIVE_MARKERS = (
"zusätzlich",
"zusaetzlich",
"auch ",
" außerdem",
" ausserdem",
" dazu",
" extra",
" mehr ",
" und dabei",
" sowie ",
)
def _normalize_query(q: Optional[str]) -> str:
return re.sub(r"\s+", " ", (q or "").strip())
def is_simple_preset_query(query: Optional[str]) -> bool:
q = _normalize_query(query).lower()
if not q:
return True
for pat in _SIMPLE_PRESET_PATTERNS:
if re.match(pat, q, flags=re.IGNORECASE):
return True
return False
def classify_planning_scenario(
query: Optional[str],
heuristic_intent: str,
) -> str:
q = _normalize_query(query).lower()
if not q or is_simple_preset_query(q):
return SCENARIO_PRESET_NEXT
if heuristic_intent == "progression_next":
return SCENARIO_PROGRESSION
if heuristic_intent == "deepen_exercise":
return SCENARIO_DEEPEN
if any(m in f" {q} " for m in _ADDITIVE_MARKERS):
return SCENARIO_ADDITIVE
if heuristic_intent == "continue_plan_goal":
return SCENARIO_CONTINUE_PLAN
if heuristic_intent == "free_search":
return SCENARIO_FREE_SEARCH
if heuristic_intent == "suggest_next":
return SCENARIO_CONTINUE_PLAN
return SCENARIO_FREE_SEARCH
def should_run_llm_intent_pipeline(
query: Optional[str],
scenario: str,
*,
include_llm_intent: bool,
) -> bool:
if not include_llm_intent:
return False
if scenario == SCENARIO_PRESET_NEXT:
return False
return bool(_normalize_query(query))
def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile:
skill_target = _normalize_weight_map(dict(target.skill_weights))
skill_plan_norm = _normalize_weight_map(dict(target.skill_plan_weights))
skill_gap: Dict[int, float] = {}
for sid, tw in skill_target.items():
pw = skill_plan_norm.get(sid, 0.0)
gap = tw - pw * 0.85
if gap > 0.08:
skill_gap[sid] = gap
sources = list(target.sources)
if skill_gap and "skill_gap_vs_plan" not in sources:
sources.append("skill_gap_vs_plan")
elif not skill_gap:
sources = [s for s in sources if s != "skill_gap_vs_plan"]
return PlanningTargetProfile(
focus_area_ids=target.focus_area_ids,
style_direction_ids=target.style_direction_ids,
training_type_ids=target.training_type_ids,
target_group_ids=target.target_group_ids,
skill_weights=skill_target,
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else {},
skill_plan_weights=target.skill_plan_weights,
sources=sources,
)
def merge_query_overlay_into_target(
base: PlanningTargetProfile,
*,
focus: Dict[int, float],
style: Dict[int, float],
tt: Dict[int, float],
tg: Dict[int, float],
skills: Dict[int, float],
emphasis: str = "additive",
scenario: str,
) -> PlanningTargetProfile:
sources = list(base.sources)
if "query_intent" not in sources:
sources.append("query_intent")
if emphasis == "replace" or scenario == SCENARIO_FREE_SEARCH:
skill_w = _merge_weight_maps({}, skills, scale=1.0)
if skills:
skill_w = _normalize_weight_map(_merge_weight_maps(base.skill_weights, skills, scale=0.55))
if emphasis == "replace":
skill_w = _normalize_weight_map(skills)
focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.5 if emphasis == "replace" else 0.85)
style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.5)
tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.5)
tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.5)
else:
skill_scale = 1.0 if scenario == SCENARIO_ADDITIVE else 0.85
skill_w = _merge_weight_maps(base.skill_weights, skills, scale=skill_scale)
focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.9)
style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.75)
tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.75)
tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.75)
out = PlanningTargetProfile(
focus_area_ids=_normalize_weight_map(focus_w) if focus_w else focus_w,
style_direction_ids=_normalize_weight_map(style_w) if style_w else style_w,
training_type_ids=_normalize_weight_map(tt_w) if tt_w else tt_w,
target_group_ids=_normalize_weight_map(tg_w) if tg_w else tg_w,
skill_weights=_normalize_weight_map(skill_w) if skill_w else skill_w,
skill_gap_weights=dict(base.skill_gap_weights),
skill_plan_weights=dict(base.skill_plan_weights),
sources=sources,
)
return _recalculate_skill_gap(out)
def build_planning_target_with_query_pipeline(
cur,
*,
unit: Dict[str, Any],
planned_exercise_ids: List[int],
anchor_exercise_id: Optional[int],
query: Optional[str],
heuristic_intent: str,
include_llm_intent: bool,
context_summary: Mapping[str, Any],
) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]:
"""
Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict
"""
scenario = classify_planning_scenario(query, heuristic_intent)
resolved_intent = heuristic_intent
llm_applied = False
parsed: Optional[PlanningQueryIntentParsed] = None
resolved_skills: List[Dict[str, Any]] = []
base = build_planning_target_profile(
cur,
unit=unit,
planned_exercise_ids=planned_exercise_ids,
anchor_exercise_id=anchor_exercise_id,
intent=heuristic_intent,
)
base_summary = base.to_summary_dict(cur)
if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent):
parsed, llm_applied = try_parse_planning_query_intent(
cur,
query=_normalize_query(query),
heuristic_intent=heuristic_intent,
scenario_hint=scenario,
context_summary=context_summary,
target_profile_summary=base_summary,
)
target = base
if parsed and llm_applied:
if parsed.intent in {
"suggest_next",
"progression_next",
"deepen_exercise",
"continue_plan_goal",
"free_search",
}:
resolved_intent = parsed.intent
if parsed.scenario in VALID_SCENARIOS_SET:
scenario = parsed.scenario
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
if focus or style or tt or tg or skills:
target = merge_query_overlay_into_target(
base,
focus=focus,
style=style,
tt=tt,
tg=tg,
skills=skills,
emphasis=parsed.emphasis,
scenario=scenario,
)
query_intent_summary: Dict[str, Any] = {
"scenario": scenario,
"intent": resolved_intent,
"heuristic_intent": heuristic_intent,
"llm_applied": llm_applied,
"emphasis": parsed.emphasis if parsed else None,
"rationale": (parsed.rationale if parsed else None),
"skill_hints_resolved": resolved_skills,
"requires_partner": parsed.requires_partner if parsed else None,
}
return target, resolved_intent, scenario, query_intent_summary
VALID_SCENARIOS_SET = {
SCENARIO_PRESET_NEXT,
SCENARIO_PROGRESSION,
SCENARIO_DEEPEN,
SCENARIO_CONTINUE_PLAN,
SCENARIO_ADDITIVE,
SCENARIO_FREE_SEARCH,
}
def compose_retrieval_phase(*, query_intent: bool, llm_rank: bool) -> str:
parts = ["profile_v1"]
if query_intent:
parts.append("query_intent")
if llm_rank:
parts.append("llm_rank")
return "+".join(parts)
__all__ = [
"SCENARIO_ADDITIVE",
"SCENARIO_PRESET_NEXT",
"build_planning_target_with_query_pipeline",
"classify_planning_scenario",
"compose_retrieval_phase",
"is_simple_preset_query",
"merge_query_overlay_into_target",
"should_run_llm_intent_pipeline",
]