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
- 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.
273 lines
8.8 KiB
Python
273 lines
8.8 KiB
Python
"""
|
|
P1: LLM-Intent aus Planungs-Suchfrage → strukturiertes Query-Overlay für PlanningTargetProfile.
|
|
|
|
Prompt: planning_exercise_search_intent (Migration 073)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
|
from openrouter_chat import (
|
|
effective_openrouter_model_for_prompt_row,
|
|
normalize_openrouter_env,
|
|
openrouter_chat_completion,
|
|
)
|
|
|
|
_logger = logging.getLogger("shinkan.planning_exercise_intent")
|
|
|
|
VALID_PARSED_INTENTS = {
|
|
"suggest_next",
|
|
"progression_next",
|
|
"deepen_exercise",
|
|
"continue_plan_goal",
|
|
"free_search",
|
|
}
|
|
|
|
VALID_SCENARIOS = {
|
|
"preset_next",
|
|
"progression",
|
|
"deepen",
|
|
"continue_plan",
|
|
"additive_constraint",
|
|
"free_search",
|
|
}
|
|
|
|
VALID_EMPHASIS = {"additive", "replace", "neutral"}
|
|
|
|
|
|
class SkillHint(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=120)
|
|
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
|
|
|
|
|
class PlanningQueryIntentParsed(BaseModel):
|
|
intent: str = "free_search"
|
|
scenario: str = "free_search"
|
|
skill_hints: List[SkillHint] = Field(default_factory=list)
|
|
focus_hints: List[str] = Field(default_factory=list)
|
|
style_hints: List[str] = Field(default_factory=list)
|
|
training_type_hints: List[str] = Field(default_factory=list)
|
|
target_group_hints: List[str] = Field(default_factory=list)
|
|
requires_partner: Optional[bool] = None
|
|
emphasis: str = "additive"
|
|
rationale: Optional[str] = Field(default=None, max_length=400)
|
|
|
|
@field_validator("intent")
|
|
@classmethod
|
|
def _intent(cls, v: str) -> str:
|
|
s = (v or "").strip().lower()
|
|
return s if s in VALID_PARSED_INTENTS else "free_search"
|
|
|
|
@field_validator("scenario")
|
|
@classmethod
|
|
def _scenario(cls, v: str) -> str:
|
|
s = (v or "").strip().lower()
|
|
return s if s in VALID_SCENARIOS else "free_search"
|
|
|
|
@field_validator("emphasis")
|
|
@classmethod
|
|
def _emphasis(cls, v: str) -> str:
|
|
s = (v or "").strip().lower()
|
|
return s if s in VALID_EMPHASIS else "additive"
|
|
|
|
@field_validator("focus_hints", "style_hints", "training_type_hints", "target_group_hints", mode="before")
|
|
@classmethod
|
|
def _str_list(cls, v: Any) -> List[str]:
|
|
if not v:
|
|
return []
|
|
if isinstance(v, str):
|
|
return [v.strip()] if v.strip() else []
|
|
out: List[str] = []
|
|
for item in v:
|
|
s = str(item or "").strip()
|
|
if s and s not in out:
|
|
out.append(s[:120])
|
|
return out[:8]
|
|
|
|
|
|
def _extract_json_object(text: str) -> Dict[str, Any]:
|
|
s = (text or "").strip()
|
|
if s.startswith("```"):
|
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
|
if s.endswith("```"):
|
|
s = s[:-3].strip()
|
|
start = s.find("{")
|
|
end = s.rfind("}")
|
|
if start < 0 or end <= start:
|
|
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
|
obj = json.loads(s[start : end + 1])
|
|
if not isinstance(obj, dict):
|
|
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
|
return obj
|
|
|
|
|
|
def parse_planning_query_intent_response(text: str) -> PlanningQueryIntentParsed:
|
|
obj = _extract_json_object(text)
|
|
return PlanningQueryIntentParsed.model_validate(obj)
|
|
|
|
|
|
def _compact_json(obj: Any) -> str:
|
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _load_compact_catalog(cur, table: str, id_col: str, name_col: str = "name", limit: int = 80) -> List[Dict[str, Any]]:
|
|
cur.execute(
|
|
f"""
|
|
SELECT {id_col} AS id, {name_col} AS name
|
|
FROM {table}
|
|
ORDER BY {name_col} ASC NULLS LAST
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return [{"id": int(r["id"]), "name": str(r["name"] or "")[:80]} for r in cur.fetchall()]
|
|
|
|
|
|
def _load_skills_catalog_compact(cur, limit: int = 120) -> List[Dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, name, category
|
|
FROM skills
|
|
WHERE status IS NULL OR status = 'active'
|
|
ORDER BY name ASC
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return [
|
|
{
|
|
"id": int(r["id"]),
|
|
"name": str(r["name"] or "")[:80],
|
|
"category": str(r.get("category") or "")[:40],
|
|
}
|
|
for r in cur.fetchall()
|
|
]
|
|
|
|
|
|
def _resolve_name_hint(cur, table: str, hint: str, *, extra_where: str = "") -> Optional[int]:
|
|
h = (hint or "").strip()
|
|
if len(h) < 2:
|
|
return None
|
|
q = h.lower()
|
|
cur.execute(
|
|
f"""
|
|
SELECT id, name
|
|
FROM {table}
|
|
WHERE LOWER(name) LIKE %s {extra_where}
|
|
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
|
LENGTH(name) ASC
|
|
LIMIT 1
|
|
""",
|
|
(f"%{q}%", q, f"{q}%"),
|
|
)
|
|
row = cur.fetchone()
|
|
return int(row["id"]) if row else None
|
|
|
|
|
|
def resolve_query_intent_catalog_ids(
|
|
cur,
|
|
parsed: PlanningQueryIntentParsed,
|
|
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], List[Dict[str, Any]]]:
|
|
"""
|
|
Mappt Text-Hints auf Katalog-IDs. Returns (focus, style, tt, tg, skills, resolved_skills_meta).
|
|
"""
|
|
focus: Dict[int, float] = {}
|
|
style: Dict[int, float] = {}
|
|
tt: Dict[int, float] = {}
|
|
tg: Dict[int, float] = {}
|
|
skills: Dict[int, float] = {}
|
|
resolved_skills: List[Dict[str, Any]] = []
|
|
|
|
for hint in parsed.focus_hints:
|
|
fid = _resolve_name_hint(cur, "focus_areas", hint)
|
|
if fid:
|
|
focus[fid] = max(focus.get(fid, 0.0), 0.9)
|
|
|
|
for hint in parsed.style_hints:
|
|
sid = _resolve_name_hint(cur, "style_directions", hint)
|
|
if sid:
|
|
style[sid] = max(style.get(sid, 0.0), 0.85)
|
|
|
|
for hint in parsed.training_type_hints:
|
|
tid = _resolve_name_hint(cur, "training_types", hint)
|
|
if tid:
|
|
tt[tid] = max(tt.get(tid, 0.0), 0.85)
|
|
|
|
for hint in parsed.target_group_hints:
|
|
gid = _resolve_name_hint(cur, "target_groups", hint)
|
|
if gid:
|
|
tg[gid] = max(tg.get(gid, 0.0), 0.85)
|
|
|
|
for sh in parsed.skill_hints[:8]:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, name FROM skills
|
|
WHERE (status IS NULL OR status = 'active')
|
|
AND LOWER(name) LIKE %s
|
|
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
|
LENGTH(name) ASC
|
|
LIMIT 1
|
|
""",
|
|
(f"%{sh.name.lower()}%", sh.name.lower(), f"{sh.name.lower()}%"),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
sid = int(row["id"])
|
|
skills[sid] = max(skills.get(sid, 0.0), float(sh.weight))
|
|
resolved_skills.append({"skill_id": sid, "name": str(row["name"] or sh.name), "weight": skills[sid]})
|
|
|
|
return focus, style, tt, tg, skills, resolved_skills
|
|
|
|
|
|
def try_parse_planning_query_intent(
|
|
cur,
|
|
*,
|
|
query: str,
|
|
heuristic_intent: str,
|
|
scenario_hint: str,
|
|
context_summary: Mapping[str, Any],
|
|
target_profile_summary: Mapping[str, Any],
|
|
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
|
api_key, _ = normalize_openrouter_env()
|
|
if not api_key or not (query or "").strip():
|
|
return None, False
|
|
|
|
variables = {
|
|
"search_query": (query or "").strip(),
|
|
"heuristic_intent": heuristic_intent or "",
|
|
"scenario_hint": scenario_hint or "",
|
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
|
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
|
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
|
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
|
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
|
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
|
}
|
|
|
|
try:
|
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_intent", variables)
|
|
model = effective_openrouter_model_for_prompt_row(prow)
|
|
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
|
parsed = parse_planning_query_intent_response(raw)
|
|
return parsed, True
|
|
except AiPromptUnavailableError:
|
|
return None, False
|
|
except Exception as exc:
|
|
_logger.warning("Planungs-Intent-LLM fehlgeschlagen: %s", exc)
|
|
return None, False
|
|
|
|
|
|
__all__ = [
|
|
"PlanningQueryIntentParsed",
|
|
"parse_planning_query_intent_response",
|
|
"resolve_query_intent_catalog_ids",
|
|
"try_parse_planning_query_intent",
|
|
]
|