""" KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf. Keine persistente Aenderung an exercises — nur Response-DTO fuer das Frontend. """ from __future__ import annotations import json import re from typing import Any, Dict, List, Optional, Tuple from fastapi import HTTPException from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion _CANONICAL_SKILL_LEVELS = frozenset({"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}) _LEGACY_SKILL_LEVEL_SLUG = { "einsteiger": "basis", "experte": "optimierung", "1": "basis", "2": "grundlagen", "3": "aufbau", "4": "fortgeschritten", "5": "optimierung", } _ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"}) def _normalize_exercise_skill_level(value) -> Optional[str]: if value is None: return None s = str(value).strip().lower() if not s: return None if s in _CANONICAL_SKILL_LEVELS: return s return _LEGACY_SKILL_LEVEL_SLUG.get(s) def _normalize_exercise_skill_intensity(value) -> str: if value is None: return "mittel" key = str(value).strip().lower() if key in ("low",): return "niedrig" if key in ("medium",): return "mittel" if key in ("high",): return "hoch" if key in _ALLOWED_SKILL_INTENSITY: return key return "mittel" _TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE) _MAX_PLAIN_FIELD = 28_000 _MAX_SKILLS_CATALOG_LINES = 240 _MAX_SUMMARY_CHARS = 220 def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str: if not html: return "" t = _TAG_RE.sub(" ", str(html)) t = re.sub(r"\s+", " ", t).strip() if len(t) > max_len: t = t[: max_len - 1].rstrip() + "…" return t def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]: cur.execute( """ SELECT slug, display_name, template, output_format, active FROM ai_prompts WHERE slug = %s """, (slug,), ) row = cur.fetchone() if not row: return None d = dict(row) if not d.get("active", True): return None return d def _render_template(template: str, ctx: Dict[str, str]) -> str: out = template or "" for key, val in ctx.items(): placeholder = "{{" + key + "}}" out = out.replace(placeholder, val if val is not None else "") return out def _build_skills_catalog_block(cur) -> str: cur.execute( """ SELECT s.id, s.name, s.category, s.description, s.karate_relevance, s.relevance_level, sc.name AS subcategory_name FROM skills s LEFT JOIN skill_categories sc ON s.category_id = sc.id WHERE (s.status IS NULL OR s.status = 'active') ORDER BY s.importance DESC NULLS LAST, s.name LIMIT %s """, (_MAX_SKILLS_CATALOG_LINES,), ) lines: List[str] = [] for r in cur.fetchall(): rid = int(r["id"]) nm = (r.get("name") or "").strip() or f"Skill #{rid}" cat = (r.get("category") or "").strip() sub = (r.get("subcategory_name") or "").strip() dsc = strip_html_to_plain(r.get("description"), max_len=320) kr = strip_html_to_plain(r.get("karate_relevance"), max_len=200) rel = r.get("relevance_level") rel_s = "" if rel is not None: rel_s = str(rel) cats = " / ".join(x for x in (cat, sub) if x) blob = ( f"- id={rid} | name={nm} | kategorie={cats or '-'}" f" | beschreibung={dsc or '-'} | karate_relevanz={kr or '-'}" f" | relevanz_stufe={rel_s or '-'}" ) lines.append(blob) return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" def _extract_json_array(text: str) -> Any: s = text.strip() if s.startswith("```"): s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) if s.endswith("```"): s = s[:-3].strip() # array whole string if s.startswith("["): end = s.rfind("]") if end > 0: s = s[: end + 1] return json.loads(s) # object wrapping array if s.startswith("{"): obj = json.loads(s) if isinstance(obj, dict): for k in ("skills", "items", "data"): v = obj.get(k) if isinstance(v, list): return v raise ValueError("JSON-Objekt ohne Skills-Liste") return json.loads(s) def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]: if not isinstance(rows, list): return [] out: List[Dict[str, Any]] = [] for raw in rows: if not isinstance(raw, dict): continue sid = raw.get("skill_id") try: skill_id = int(sid) except (TypeError, ValueError): continue cur.execute( """ SELECT s.id, s.name, s.category, sc.name AS subcategory_name FROM skills s LEFT JOIN skill_categories sc ON s.category_id = sc.id WHERE s.id = %s AND (s.status IS NULL OR s.status = 'active') """, (skill_id,), ) sk = cur.fetchone() if not sk: continue req = _normalize_exercise_skill_level(raw.get("required_level")) or "grundlagen" tgt = _normalize_exercise_skill_level(raw.get("target_level")) or req if req not in _CANONICAL_SKILL_LEVELS: req = _LEGACY_SKILL_LEVEL_SLUG.get(str(raw.get("required_level") or "").strip().lower(), "grundlagen") if req not in _CANONICAL_SKILL_LEVELS: req = "grundlagen" if tgt not in _CANONICAL_SKILL_LEVELS: tgt = _LEGACY_SKILL_LEVEL_SLUG.get(str(raw.get("target_level") or "").strip().lower(), req) if tgt not in _CANONICAL_SKILL_LEVELS: tgt = req inten = _normalize_exercise_skill_intensity(raw.get("intensity")) is_primary = bool(raw.get("is_primary")) if raw.get("is_primary") is not None else len(out) == 0 cat = (sk.get("category") or "").strip() sub = (sk.get("subcategory_name") or "").strip() skill_category = " / ".join(x for x in (cat, sub) if x) or (cat or None) conf = raw.get("confidence") try: conf_f = float(conf) if conf is not None else None except (TypeError, ValueError): conf_f = None item: Dict[str, Any] = { "skill_id": skill_id, "skill_name": (sk.get("name") or "").strip() or f"Skill #{skill_id}", "required_level": req, "target_level": tgt, "intensity": inten, "is_primary": is_primary, } if skill_category: item["skill_category"] = skill_category if conf_f is not None: item["confidence"] = conf_f out.append(item) # max 5 return out[:5] def _require_openrouter() -> Tuple[str, str]: key, model = normalize_openrouter_env() if not key: raise HTTPException( status_code=503, detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).", ) return key, model def run_exercise_ai_suggestion( cur, *, title: Optional[str], goal: Optional[str], execution: Optional[str], focus_area_hint: Optional[str], want_summary: bool, want_skills: bool, ) -> Dict[str, Any]: key, model = _require_openrouter() g_plain = strip_html_to_plain(goal) e_plain = strip_html_to_plain(execution) if not (g_plain.strip() or e_plain.strip()): raise HTTPException( status_code=400, detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).", ) t_title = (title or "").strip() focus = (focus_area_hint or "").strip() result: Dict[str, Any] = {"model": model} if want_summary: prow = _load_prompt_row(cur, "exercise_summary") if not prow: raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.") ctx = { "exercise_title": t_title or "-", "exercise_focus_area": focus or "-", "exercise_goal": g_plain or "-", "exercise_execution": e_plain or "-", } prompt = _render_template(str(prow["template"]), ctx) try: raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt) except OpenRouterError as e: raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e text = (raw or "").strip() if len(text) > _MAX_SUMMARY_CHARS: text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…" result["summary"] = {"text": text, "ai_generated": True, "model": model} if want_skills: srow = _load_prompt_row(cur, "exercise_skill_suggestions") if not srow: raise HTTPException( status_code=503, detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.", ) catalog = _build_skills_catalog_block(cur) ctx = { "exercise_title": t_title or "-", "exercise_focus_area": focus or "-", "exercise_goal": g_plain or "-", "exercise_execution": e_plain or "-", "skills_catalog": catalog, } prompt = _render_template(str(srow["template"]), ctx) sys_hint = ( "Du antwortest nur mit validem JSON (Array). Keine Kommentare, keine Erklaerungen ausserhalb des JSON." ) try: raw = openrouter_chat_completion( api_key=key, model=model, user_content=prompt, system_content=sys_hint, temperature=0.15, ) except OpenRouterError as e: raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e try: parsed = _extract_json_array(raw) except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=502, detail="KI lieferte kein verwertbares JSON fuer Skills.", ) from e skills = _sanitize_skill_entries(cur, parsed) result["skills"] = skills return result