shinkan-jinkendo/backend/exercise_ai.py
Lars e4451e1362
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00

321 lines
10 KiB
Python

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