shinkan-jinkendo/backend/planning_intent_context.py
Lars 4ef3f00e6b
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
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 1m13s
Enhance Planning Intent Context and Stage Specification Finalization
- Introduced `intent_context` and `semantic_brief` parameters in `try_llm_stage_specs` to improve context handling for stage specifications.
- Updated `build_goal_analysis` to extract explicit exclusions from goal queries, enhancing constraint management.
- Enhanced `roadmap_context_from_override` to enrich semantic briefs with path constraints and finalize stage specifications with intent context.
- Incremented application version to reflect these updates.
2026-06-11 08:47:26 +02:00

217 lines
7.2 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,
)
_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 = ""
def to_api_dict(self) -> Dict[str, Any]:
return {
"source_query": self.source_query,
"primary_topic": self.primary_topic,
"path_anti_patterns": self.path_anti_patterns[:16],
"path_success_criteria": self.path_success_criteria[:10],
"explicit_exclusions": self.explicit_exclusions[:10],
"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()
if semantic_brief and not topic:
topic = (semantic_brief.primary_topic or "").strip()
return PlanningIntentContext(
source_query=(goal_query or "").strip(),
primary_topic=topic,
path_anti_patterns=path_anti,
path_success_criteria=path_success,
explicit_exclusions=explicit,
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,
],
limit=14,
)
success = _dedupe_preserve(
[
*(spec.success_criteria or []),
*intent.path_success_criteria,
(
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",
]