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",
|
||
|
|
]
|