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
- 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.
321 lines
10 KiB
Python
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
|