All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
- Introduced multistage path quality assurance (QA) functionality to improve exercise relevance and feedback through structured tiers and optimization hints. - Updated stage specifications to include `start_state` and `target_state` for better contextualization in roadmap matching. - Enhanced semantic brief construction with technique sibling exclusions to refine exercise selection based on primary topics. - Improved path retrieval logic to incorporate new parameters for nuanced matching against learning goals. - Incremented application version to reflect these updates.
249 lines
8.7 KiB
Python
249 lines
8.7 KiB
Python
"""
|
|
Gemeinsame Intent-Anreicherung für Planungs-Retrieval.
|
|
|
|
Progressionsgraph (Roadmap stage_specs) und später Trainingsplanung
|
|
(Abschnitt/Slot) nutzen dieselben Bausteine:
|
|
Intent-Kontext bauen → Specs finalisieren → Matching-Gates.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
|
|
from planning_exercise_semantics import (
|
|
PlanningSemanticBrief,
|
|
resolve_path_anti_patterns,
|
|
technique_sibling_excludes,
|
|
)
|
|
|
|
_NEGATION_CLAUSE_RE = re.compile(
|
|
r"\b(?:ohne|kein(?:e|en|er|em)?|nicht)\s+[^,.;\n]+",
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def extract_explicit_exclusions(*texts: Optional[str]) -> List[str]:
|
|
"""Lesbare Negationsklauseln aus Freitext (ohne Themen-Raten)."""
|
|
out: List[str] = []
|
|
for raw in texts:
|
|
s = (raw or "").strip()
|
|
if not s:
|
|
continue
|
|
for m in _NEGATION_CLAUSE_RE.finditer(s):
|
|
clause = m.group(0).strip().rstrip(".,;")
|
|
if clause and clause.lower() not in {x.lower() for x in out}:
|
|
out.append(clause[:220])
|
|
return out[:12]
|
|
|
|
|
|
@dataclass
|
|
class PlanningIntentContext:
|
|
"""Pfad-/Abschnittsweiter Planungs-Intent — domänenneutral."""
|
|
|
|
source_query: str = ""
|
|
primary_topic: str = ""
|
|
path_anti_patterns: List[str] = field(default_factory=list)
|
|
path_success_criteria: List[str] = field(default_factory=list)
|
|
explicit_exclusions: List[str] = field(default_factory=list)
|
|
context_notes: str = ""
|
|
topic_type: str = "general"
|
|
technique_sibling_excludes: List[str] = field(default_factory=list)
|
|
|
|
def to_api_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"source_query": self.source_query,
|
|
"primary_topic": self.primary_topic,
|
|
"topic_type": self.topic_type,
|
|
"path_anti_patterns": self.path_anti_patterns[:16],
|
|
"path_success_criteria": self.path_success_criteria[:10],
|
|
"explicit_exclusions": self.explicit_exclusions[:10],
|
|
"technique_sibling_excludes": self.technique_sibling_excludes[:16],
|
|
"context_notes": self.context_notes[:1200] or None,
|
|
}
|
|
|
|
|
|
def build_planning_intent_context(
|
|
goal_query: str,
|
|
*,
|
|
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
|
goal_analysis: Optional[Mapping[str, Any]] = None,
|
|
extra_context: Optional[str] = None,
|
|
primary_topic: Optional[str] = None,
|
|
) -> PlanningIntentContext:
|
|
"""Intent aus Anfrage, Zielanalyse und optionalem Kontext — ohne Sonderregeln pro Thema."""
|
|
ga = dict(goal_analysis or {})
|
|
notes_parts = [extra_context or ""]
|
|
constraints = ga.get("constraints") if isinstance(ga.get("constraints"), dict) else {}
|
|
if isinstance(constraints, dict):
|
|
trainer_notes = str(constraints.get("trainer_notes") or "").strip()
|
|
if trainer_notes:
|
|
notes_parts.append(trainer_notes)
|
|
|
|
combined_notes = " ".join(p.strip() for p in notes_parts if p and p.strip())
|
|
explicit = extract_explicit_exclusions(goal_query, combined_notes or None)
|
|
ga_excluded = constraints.get("excluded_themes") if isinstance(constraints, dict) else None
|
|
if isinstance(ga_excluded, list):
|
|
for item in ga_excluded:
|
|
s = str(item or "").strip()
|
|
if s and s.lower() not in {x.lower() for x in explicit}:
|
|
explicit.append(s[:220])
|
|
|
|
path_anti = resolve_path_anti_patterns(
|
|
goal_query,
|
|
semantic_brief=semantic_brief,
|
|
extra_context=combined_notes or None,
|
|
)
|
|
path_success: List[str] = []
|
|
for item in ga.get("success_criteria") or []:
|
|
s = str(item or "").strip()
|
|
if s and s not in path_success:
|
|
path_success.append(s[:240])
|
|
target = str(ga.get("target_state") or "").strip()
|
|
if target and len(target) >= 8:
|
|
line = f"Zielzustand erreichbar: {target[:200]}"
|
|
if line not in path_success:
|
|
path_success.append(line)
|
|
|
|
topic = (primary_topic or ga.get("primary_topic") or "").strip()
|
|
topic_type = "general"
|
|
siblings: List[str] = []
|
|
if semantic_brief:
|
|
if not topic:
|
|
topic = (semantic_brief.primary_topic or "").strip()
|
|
topic_type = (semantic_brief.topic_type or "general").strip().lower()
|
|
if topic_type == "technique" and topic:
|
|
siblings = technique_sibling_excludes(topic)
|
|
for raw in semantic_brief.exclude_phrases or []:
|
|
s = str(raw or "").strip()
|
|
if s and s.lower() not in {x.lower() for x in siblings}:
|
|
siblings.append(s[:120])
|
|
|
|
if topic_type == "technique" and topic:
|
|
line = f"Haupttechnik {topic} in Kurzbeschreibung oder Übungsziel erkennbar"
|
|
if line not in path_success:
|
|
path_success.insert(0, line)
|
|
|
|
return PlanningIntentContext(
|
|
source_query=(goal_query or "").strip(),
|
|
primary_topic=topic,
|
|
topic_type=topic_type,
|
|
path_anti_patterns=path_anti,
|
|
path_success_criteria=path_success,
|
|
explicit_exclusions=explicit,
|
|
technique_sibling_excludes=siblings[:16],
|
|
context_notes=combined_notes[:1200],
|
|
)
|
|
|
|
|
|
def _dedupe_preserve(items: Sequence[str], *, limit: int = 14) -> List[str]:
|
|
out: List[str] = []
|
|
seen: set[str] = set()
|
|
for raw in items:
|
|
s = str(raw or "").strip()
|
|
if not s:
|
|
continue
|
|
key = s.lower()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(s[:240])
|
|
if len(out) >= limit:
|
|
break
|
|
return out
|
|
|
|
|
|
def finalize_stage_spec_artifact(
|
|
spec: "StageSpecArtifact",
|
|
*,
|
|
major_step: Optional["MajorStep"] = None,
|
|
intent: PlanningIntentContext,
|
|
) -> "StageSpecArtifact":
|
|
"""Pfad-Intent in eine Stufenspezifikation mergen (LLM oder heuristisch)."""
|
|
from planning_progression_roadmap import MajorStep, StageSpecArtifact
|
|
|
|
learning_goal = (spec.learning_goal or (major_step.learning_goal if major_step else "")).strip()
|
|
phase = (major_step.phase if major_step else "").strip().lower()
|
|
|
|
anti = _dedupe_preserve(
|
|
[
|
|
*(spec.anti_patterns or []),
|
|
*intent.explicit_exclusions,
|
|
*intent.path_anti_patterns,
|
|
*intent.technique_sibling_excludes,
|
|
(
|
|
f"andere Technik als {intent.primary_topic}"
|
|
if intent.topic_type == "technique" and intent.primary_topic
|
|
else ""
|
|
),
|
|
],
|
|
limit=14,
|
|
)
|
|
stage_start = (spec.start_state or "").strip()
|
|
stage_target = (spec.target_state or "").strip()
|
|
success = _dedupe_preserve(
|
|
[
|
|
*(spec.success_criteria or []),
|
|
*intent.path_success_criteria,
|
|
(f"Soll-Start der Stufe erreichbar: {stage_start[:180]}" if stage_start else ""),
|
|
(f"Stufen-Ziel erreichbar: {stage_target[:180]}" if stage_target else ""),
|
|
(
|
|
f"Übung liefert messbar: {learning_goal[:160]}"
|
|
if learning_goal
|
|
else ""
|
|
),
|
|
(
|
|
f"Kurzbeschreibung und Übungsziel passen zur Phase {phase}"
|
|
if phase
|
|
else "Kurzbeschreibung und Übungsziel passen zum Stufen-Lernziel"
|
|
),
|
|
],
|
|
limit=8,
|
|
)
|
|
|
|
idx = spec.major_step_index
|
|
if major_step is not None:
|
|
idx = major_step.index
|
|
|
|
return StageSpecArtifact(
|
|
major_step_index=idx,
|
|
learning_goal=learning_goal,
|
|
load_profile=list(spec.load_profile or []),
|
|
exercise_type=(spec.exercise_type or "").strip(),
|
|
success_criteria=success,
|
|
anti_patterns=anti,
|
|
)
|
|
|
|
|
|
def finalize_stage_specs_with_intent(
|
|
specs: Sequence["StageSpecArtifact"],
|
|
major_steps: Sequence["MajorStep"],
|
|
*,
|
|
intent: PlanningIntentContext,
|
|
fallback_specs: Optional[Sequence["StageSpecArtifact"]] = None,
|
|
) -> List["StageSpecArtifact"]:
|
|
"""Alle Stufen mit gleichem Pfad-Intent anreichern; fehlende Indizes aus Fallback."""
|
|
from planning_progression_roadmap import MajorStep, StageSpecArtifact
|
|
|
|
by_idx = {int(s.major_step_index): s for s in specs}
|
|
fallback_by_idx = {int(s.major_step_index): s for s in (fallback_specs or [])}
|
|
out: List[StageSpecArtifact] = []
|
|
for major in major_steps:
|
|
raw = by_idx.get(major.index) or fallback_by_idx.get(major.index)
|
|
if raw is None:
|
|
raw = StageSpecArtifact(
|
|
major_step_index=major.index,
|
|
learning_goal=major.learning_goal,
|
|
)
|
|
out.append(finalize_stage_spec_artifact(raw, major_step=major, intent=intent))
|
|
return out
|
|
|
|
|
|
__all__ = [
|
|
"PlanningIntentContext",
|
|
"build_planning_intent_context",
|
|
"extract_explicit_exclusions",
|
|
"finalize_stage_spec_artifact",
|
|
"finalize_stage_specs_with_intent",
|
|
]
|