All checks were successful
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s
Test Suite / pytest-backend (pull_request) Successful in 36s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m23s
- Implemented a maximum of 3 exercises per preview request to prevent Gateway-504 errors, improving the stability of the exercise enrichment process. - Adjusted batch sizes for applying exercises and previewing to optimize performance and resource management. - Updated the frontend to reflect changes in preview handling, including user notifications about chunk sizes and potential timeouts. - Incremented version to 0.8.180 and updated changelog to document these enhancements and fixes.
537 lines
19 KiB
Python
537 lines
19 KiB
Python
"""
|
|
Superadmin-Werkzeug: Übungs-Anreicherung per KI (Skills + optional Metadaten).
|
|
|
|
Wiederverwendet run_exercise_form_ai_suggestion / exercise_ai — keine neue OpenRouter-Pipeline.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Literal, Optional
|
|
|
|
from ai_prompt_context import ExerciseFormAiPromptContext
|
|
from ai_prompt_job import run_exercise_form_ai_suggestion
|
|
from exercise_ai import strip_html_to_plain
|
|
from exercise_rich_text import normalize_inline_exercise_media_markup
|
|
|
|
from routers.exercises import (
|
|
enrich_exercise_detail,
|
|
normalize_exercise_skill_intensity,
|
|
normalize_exercise_skill_level,
|
|
)
|
|
|
|
SkillMergeMode = Literal["additive", "replace_ai_only", "replace_all"]
|
|
|
|
SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"})
|
|
DEFAULT_SET_STATUS = "in_review"
|
|
# Max. IDs pro Apply-HTTP-Anfrage (kein LLM).
|
|
MAX_BATCH_EXERCISES = 50
|
|
# Preview: pro Request nur wenige Übungen — sonst Gateway-504 (Fritz!Box o.ä. ~60s).
|
|
MAX_PREVIEW_BATCH_EXERCISES = 3
|
|
|
|
_INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes")
|
|
_SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary")
|
|
|
|
|
|
def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]:
|
|
rows: list[tuple[int, bool]] = []
|
|
for row in exercise.get("focus_areas") or []:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
try:
|
|
fid = int(row.get("focus_area_id"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if fid < 1:
|
|
continue
|
|
rows.append((fid, bool(row.get("is_primary"))))
|
|
rows.sort(key=lambda x: (not x[1], x[0]))
|
|
return rows
|
|
|
|
|
|
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
|
|
parts: List[str] = []
|
|
for row in exercise.get("focus_areas") or []:
|
|
if isinstance(row, dict):
|
|
nm = (row.get("name") or "").strip()
|
|
if nm:
|
|
parts.append(nm)
|
|
txt = ", ".join(parts).strip()
|
|
if len(txt) > 900:
|
|
return txt[:899] + "…"
|
|
return txt
|
|
|
|
|
|
def build_form_context_from_exercise(exercise: Dict[str, Any]) -> ExerciseFormAiPromptContext:
|
|
focus = _focus_area_hint_from_detail(exercise)
|
|
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
|
return ExerciseFormAiPromptContext.from_focus_tuples(
|
|
title=str(exercise.get("title") or "").strip(),
|
|
goal=exercise.get("goal"),
|
|
execution=exercise.get("execution"),
|
|
preparation=exercise.get("preparation"),
|
|
trainer_notes=exercise.get("trainer_notes"),
|
|
focus_hint=focus or None,
|
|
focus_tuples=fctx or None,
|
|
)
|
|
|
|
|
|
def validate_exercise_for_enrichment(
|
|
exercise: Dict[str, Any],
|
|
*,
|
|
want_skills: bool = False,
|
|
want_summary: bool = False,
|
|
want_instructions: bool = False,
|
|
) -> Optional[str]:
|
|
title = str(exercise.get("title") or "").strip()
|
|
if not title:
|
|
return "Titel fehlt"
|
|
|
|
ctx = build_form_context_from_exercise(exercise)
|
|
g_plain = strip_html_to_plain(exercise.get("goal"))
|
|
e_plain = strip_html_to_plain(exercise.get("execution"))
|
|
|
|
if want_skills or want_summary:
|
|
if not (g_plain.strip() or e_plain.strip()):
|
|
return "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
|
|
|
|
if want_instructions and not ctx.has_instruction_source_text():
|
|
return "Für Anleitungs-Überarbeitung fehlt Ausgangstext (Titel oder Anleitungsfeld)"
|
|
|
|
if not (want_skills or want_summary or want_instructions):
|
|
return "Kein Anreicherungsmodus aktiv"
|
|
|
|
return None
|
|
|
|
|
|
def _normalize_skill_row(raw: Dict[str, Any], *, ai_suggested: bool) -> Dict[str, Any]:
|
|
return {
|
|
"skill_id": int(raw["skill_id"]),
|
|
"skill_name": (raw.get("skill_name") or "").strip() or f"Skill #{raw['skill_id']}",
|
|
"skill_category": raw.get("skill_category"),
|
|
"is_primary": bool(raw.get("is_primary")),
|
|
"intensity": normalize_exercise_skill_intensity(raw.get("intensity")),
|
|
"required_level": normalize_exercise_skill_level(raw.get("required_level")),
|
|
"target_level": normalize_exercise_skill_level(raw.get("target_level")),
|
|
"ai_suggested": ai_suggested,
|
|
}
|
|
|
|
|
|
def _skill_meta_differs(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
|
for k in _SKILL_COMPARE_KEYS:
|
|
av = a.get(k)
|
|
bv = b.get(k)
|
|
if k in ("required_level", "target_level"):
|
|
av = normalize_exercise_skill_level(av)
|
|
bv = normalize_exercise_skill_level(bv)
|
|
elif k == "intensity":
|
|
av = normalize_exercise_skill_intensity(av)
|
|
bv = normalize_exercise_skill_intensity(bv)
|
|
elif k == "is_primary":
|
|
av = bool(av)
|
|
bv = bool(bv)
|
|
if av != bv:
|
|
return True
|
|
return False
|
|
|
|
|
|
def merge_skills(
|
|
existing: List[Dict[str, Any]],
|
|
suggested: List[Dict[str, Any]],
|
|
mode: SkillMergeMode,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Merge-Modi: additive | replace_ai_only | replace_all (alle KI-Skills mit ai_suggested=true)."""
|
|
existing_norm = [_normalize_skill_row(s, ai_suggested=bool(s.get("ai_suggested"))) for s in existing]
|
|
suggested_norm = [_normalize_skill_row(s, ai_suggested=True) for s in suggested]
|
|
|
|
suggested_by_id = {int(s["skill_id"]): s for s in suggested_norm}
|
|
|
|
if mode == "replace_all":
|
|
return list(suggested_norm)
|
|
|
|
if mode == "replace_ai_only":
|
|
manual = [s for s in existing_norm if not s.get("ai_suggested")]
|
|
manual_ids = {int(s["skill_id"]) for s in manual}
|
|
result = list(manual)
|
|
for s in suggested_norm:
|
|
sid = int(s["skill_id"])
|
|
if sid in manual_ids:
|
|
continue
|
|
result.append(s)
|
|
return result
|
|
|
|
# additive
|
|
result: List[Dict[str, Any]] = []
|
|
seen: set[int] = set()
|
|
for s in existing_norm:
|
|
sid = int(s["skill_id"])
|
|
seen.add(sid)
|
|
if sid in suggested_by_id and s.get("ai_suggested"):
|
|
merged = {**s, **suggested_by_id[sid], "ai_suggested": True}
|
|
result.append(merged)
|
|
else:
|
|
result.append(dict(s))
|
|
for s in suggested_norm:
|
|
sid = int(s["skill_id"])
|
|
if sid not in seen:
|
|
result.append(s)
|
|
seen.add(sid)
|
|
return result
|
|
|
|
|
|
def compute_skill_diff(
|
|
before: List[Dict[str, Any]],
|
|
after: List[Dict[str, Any]],
|
|
) -> Dict[str, Any]:
|
|
before_ids = {int(s["skill_id"]): s for s in before}
|
|
after_ids = {int(s["skill_id"]): s for s in after}
|
|
added = [after_ids[i] for i in sorted(after_ids) if i not in before_ids]
|
|
removed = [before_ids[i] for i in sorted(before_ids) if i not in after_ids]
|
|
changed: List[Dict[str, Any]] = []
|
|
for sid in before_ids:
|
|
if sid in after_ids and _skill_meta_differs(before_ids[sid], after_ids[sid]):
|
|
changed.append(
|
|
{
|
|
"skill_id": sid,
|
|
"skill_name": after_ids[sid].get("skill_name") or before_ids[sid].get("skill_name"),
|
|
"before": before_ids[sid],
|
|
"after": after_ids[sid],
|
|
}
|
|
)
|
|
kept = [
|
|
before_ids[i]
|
|
for i in sorted(before_ids)
|
|
if i in after_ids and i not in {c["skill_id"] for c in changed}
|
|
]
|
|
return {"added": added, "removed": removed, "changed": changed, "kept": kept}
|
|
|
|
|
|
def _skills_from_ai_payload(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
rows = payload.get("skills")
|
|
if not isinstance(rows, list):
|
|
return []
|
|
return [_normalize_skill_row(r, ai_suggested=True) for r in rows if isinstance(r, dict) and r.get("skill_id")]
|
|
|
|
|
|
def _summary_from_ai_payload(payload: Dict[str, Any]) -> Optional[str]:
|
|
block = payload.get("summary")
|
|
if isinstance(block, dict):
|
|
text = (block.get("text") or "").strip()
|
|
return text or None
|
|
if isinstance(block, str) and block.strip():
|
|
return block.strip()
|
|
return None
|
|
|
|
|
|
def _instructions_from_ai_payload(payload: Dict[str, Any]) -> Dict[str, str]:
|
|
block = payload.get("instructions")
|
|
if not isinstance(block, dict):
|
|
return {}
|
|
fields = block.get("fields")
|
|
if not isinstance(fields, dict):
|
|
return {}
|
|
out: Dict[str, str] = {}
|
|
for key in _INSTRUCTION_FIELDS:
|
|
val = fields.get(key)
|
|
if val is not None and str(val).strip():
|
|
out[key] = str(val).strip()
|
|
return out
|
|
|
|
|
|
def _instruction_snapshot(exercise: Dict[str, Any]) -> Dict[str, str]:
|
|
out: Dict[str, str] = {}
|
|
for key in _INSTRUCTION_FIELDS:
|
|
raw = exercise.get(key)
|
|
plain = strip_html_to_plain(raw, max_len=400) if raw else ""
|
|
if plain.strip():
|
|
out[key] = plain.strip()
|
|
return out
|
|
|
|
|
|
def compute_instruction_diff(
|
|
before: Dict[str, str],
|
|
after: Dict[str, str],
|
|
) -> Dict[str, Any]:
|
|
changed: List[Dict[str, Any]] = []
|
|
added: List[str] = []
|
|
for key in _INSTRUCTION_FIELDS:
|
|
b = (before.get(key) or "").strip()
|
|
a = (after.get(key) or "").strip()
|
|
if not a:
|
|
continue
|
|
if not b:
|
|
added.append(key)
|
|
elif b != strip_html_to_plain(a, max_len=400).strip() and b != a:
|
|
changed.append({"field": key, "before_plain": b, "after_html": a})
|
|
return {"changed_fields": changed, "added_fields": added}
|
|
|
|
|
|
def preview_exercise_enrichment(
|
|
cur,
|
|
exercise_id: int,
|
|
*,
|
|
want_skills: bool = True,
|
|
want_summary: bool = False,
|
|
want_instructions: bool = False,
|
|
merge_mode: SkillMergeMode = "additive",
|
|
) -> Dict[str, Any]:
|
|
exercise = enrich_exercise_detail(exercise_id, cur)
|
|
if not exercise:
|
|
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
|
|
|
skip_reason = validate_exercise_for_enrichment(
|
|
exercise,
|
|
want_skills=want_skills,
|
|
want_summary=want_summary,
|
|
want_instructions=want_instructions,
|
|
)
|
|
if skip_reason:
|
|
return {
|
|
"exercise_id": exercise_id,
|
|
"ok": False,
|
|
"skipped": True,
|
|
"error": skip_reason,
|
|
"title": exercise.get("title"),
|
|
"status": exercise.get("status"),
|
|
}
|
|
|
|
existing = exercise.get("skills") or []
|
|
suggested: List[Dict[str, Any]] = []
|
|
ai_meta: Dict[str, Any] = {}
|
|
payload: Dict[str, Any] = {}
|
|
suggested_summary: Optional[str] = None
|
|
suggested_instructions: Dict[str, str] = {}
|
|
|
|
if want_skills or want_summary or want_instructions:
|
|
ctx = build_form_context_from_exercise(exercise)
|
|
payload = run_exercise_form_ai_suggestion(
|
|
cur,
|
|
ctx,
|
|
want_summary=want_summary,
|
|
want_skills=want_skills,
|
|
want_instructions=want_instructions,
|
|
)
|
|
if want_skills:
|
|
suggested = _skills_from_ai_payload(payload)
|
|
if want_summary:
|
|
suggested_summary = _summary_from_ai_payload(payload)
|
|
if want_instructions:
|
|
suggested_instructions = _instructions_from_ai_payload(payload)
|
|
ai_meta = {
|
|
"models": payload.get("models_by_slug") or {},
|
|
"llm_calls": sum([want_skills, want_summary, want_instructions]),
|
|
}
|
|
|
|
merged = merge_skills(existing, suggested, merge_mode) if want_skills else list(existing)
|
|
diff = compute_skill_diff(existing, merged) if want_skills else None
|
|
|
|
existing_summary = (exercise.get("summary") or "").strip() or None
|
|
instr_before = _instruction_snapshot(exercise)
|
|
instr_after_plain = {
|
|
k: strip_html_to_plain(v, max_len=400) for k, v in suggested_instructions.items()
|
|
}
|
|
instruction_diff = (
|
|
compute_instruction_diff(instr_before, instr_after_plain) if want_instructions else None
|
|
)
|
|
|
|
return {
|
|
"exercise_id": exercise_id,
|
|
"ok": True,
|
|
"title": exercise.get("title"),
|
|
"status": exercise.get("status"),
|
|
"visibility": exercise.get("visibility"),
|
|
"primary_focus_name": _primary_focus_from_exercise(exercise),
|
|
"existing_skills": existing,
|
|
"suggested_skills": suggested,
|
|
"merged_skills": merged,
|
|
"diff": diff,
|
|
"existing_summary": existing_summary,
|
|
"suggested_summary": suggested_summary,
|
|
"existing_instructions": instr_before,
|
|
"suggested_instructions": suggested_instructions,
|
|
"instruction_diff": instruction_diff,
|
|
"ai_meta": ai_meta,
|
|
}
|
|
|
|
|
|
def _primary_focus_from_exercise(exercise: Dict[str, Any]) -> Optional[str]:
|
|
for row in exercise.get("focus_areas") or []:
|
|
if isinstance(row, dict) and row.get("is_primary"):
|
|
return (row.get("name") or "").strip() or None
|
|
for row in exercise.get("focus_areas") or []:
|
|
if isinstance(row, dict):
|
|
nm = (row.get("name") or "").strip()
|
|
if nm:
|
|
return nm
|
|
return None
|
|
|
|
|
|
def persist_merged_skills(cur, exercise_id: int, merged: List[Dict[str, Any]], merge_mode: SkillMergeMode) -> None:
|
|
if merge_mode == "replace_all":
|
|
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
|
|
elif merge_mode == "replace_ai_only":
|
|
cur.execute(
|
|
"DELETE FROM exercise_skills WHERE exercise_id = %s AND ai_suggested = true",
|
|
(exercise_id,),
|
|
)
|
|
|
|
for sk in merged:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO exercise_skills
|
|
(exercise_id, skill_id, is_primary, intensity, required_level, target_level, ai_suggested)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
|
|
intensity = CASE
|
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
|
THEN exercise_skills.intensity ELSE EXCLUDED.intensity END,
|
|
required_level = CASE
|
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
|
THEN exercise_skills.required_level ELSE EXCLUDED.required_level END,
|
|
target_level = CASE
|
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
|
THEN exercise_skills.target_level ELSE EXCLUDED.target_level END,
|
|
is_primary = CASE
|
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
|
THEN exercise_skills.is_primary ELSE EXCLUDED.is_primary END,
|
|
ai_suggested = CASE
|
|
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
|
THEN exercise_skills.ai_suggested ELSE EXCLUDED.ai_suggested END
|
|
""",
|
|
(
|
|
exercise_id,
|
|
int(sk["skill_id"]),
|
|
bool(sk.get("is_primary")),
|
|
normalize_exercise_skill_intensity(sk.get("intensity")),
|
|
normalize_exercise_skill_level(sk.get("required_level")),
|
|
normalize_exercise_skill_level(sk.get("target_level")),
|
|
bool(sk.get("ai_suggested")),
|
|
merge_mode,
|
|
merge_mode,
|
|
merge_mode,
|
|
merge_mode,
|
|
merge_mode,
|
|
),
|
|
)
|
|
|
|
|
|
def _normalize_instruction_fields(fields: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
|
if not fields:
|
|
return {}
|
|
out: Dict[str, str] = {}
|
|
for key in _INSTRUCTION_FIELDS:
|
|
if key not in fields:
|
|
continue
|
|
raw = fields.get(key)
|
|
if raw is None or not str(raw).strip():
|
|
continue
|
|
out[key] = normalize_inline_exercise_media_markup(str(raw).strip())
|
|
return out
|
|
|
|
|
|
def apply_exercise_enrichment(
|
|
cur,
|
|
exercise_id: int,
|
|
*,
|
|
merged_skills: Optional[List[Dict[str, Any]]] = None,
|
|
merge_mode: SkillMergeMode = "additive",
|
|
set_status: Optional[str] = DEFAULT_SET_STATUS,
|
|
apply_skills: bool = False,
|
|
summary_text: Optional[str] = None,
|
|
apply_summary: bool = False,
|
|
instruction_fields: Optional[Dict[str, Any]] = None,
|
|
apply_instructions: bool = False,
|
|
) -> Dict[str, Any]:
|
|
exercise = enrich_exercise_detail(exercise_id, cur)
|
|
if not exercise:
|
|
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
|
|
|
skip_reason = validate_exercise_for_enrichment(
|
|
exercise,
|
|
want_skills=apply_skills,
|
|
want_summary=apply_summary,
|
|
want_instructions=apply_instructions,
|
|
)
|
|
if skip_reason:
|
|
return {
|
|
"exercise_id": exercise_id,
|
|
"ok": False,
|
|
"skipped": True,
|
|
"error": skip_reason,
|
|
}
|
|
|
|
skills_list = merged_skills or []
|
|
if apply_skills:
|
|
if not skills_list and merge_mode != "replace_all":
|
|
return {
|
|
"exercise_id": exercise_id,
|
|
"ok": False,
|
|
"error": "Keine Skills zum Anwenden",
|
|
}
|
|
persist_merged_skills(cur, exercise_id, skills_list, merge_mode)
|
|
|
|
sets: List[str] = []
|
|
vals: List[Any] = []
|
|
|
|
if apply_summary and summary_text is not None:
|
|
text = str(summary_text).strip()
|
|
if text:
|
|
sets.extend(["summary = %s", "summary_ai_generated = true"])
|
|
vals.append(text[:220])
|
|
|
|
if apply_instructions:
|
|
norm = _normalize_instruction_fields(instruction_fields)
|
|
for key, val in norm.items():
|
|
sets.append(f"{key} = %s")
|
|
vals.append(val)
|
|
|
|
new_status = (set_status or "").strip().lower() or None
|
|
if new_status:
|
|
if new_status == "approved":
|
|
return {
|
|
"exercise_id": exercise_id,
|
|
"ok": False,
|
|
"error": "Automatisches Freigeben (approved) ist nicht erlaubt",
|
|
}
|
|
if new_status not in ("draft", "in_review", "archived"):
|
|
return {"exercise_id": exercise_id, "ok": False, "error": "Ungültiger Ziel-Status"}
|
|
sets.append("status = %s")
|
|
vals.append(new_status)
|
|
|
|
if sets:
|
|
sets.append("updated_at = NOW()")
|
|
vals.append(exercise_id)
|
|
cur.execute(
|
|
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
|
|
tuple(vals),
|
|
)
|
|
elif not apply_skills:
|
|
return {"exercise_id": exercise_id, "ok": False, "error": "Nichts anzuwenden"}
|
|
|
|
return {
|
|
"exercise_id": exercise_id,
|
|
"ok": True,
|
|
"status": new_status or exercise.get("status"),
|
|
"skills_applied": len(skills_list) if apply_skills else 0,
|
|
"summary_applied": apply_summary and bool(summary_text and str(summary_text).strip()),
|
|
"instructions_applied": apply_instructions and bool(_normalize_instruction_fields(instruction_fields)),
|
|
}
|
|
|
|
|
|
def estimate_llm_calls(
|
|
*,
|
|
exercise_count: int,
|
|
want_skills: bool,
|
|
want_summary: bool,
|
|
want_instructions: bool = False,
|
|
) -> Dict[str, Any]:
|
|
per_skills = exercise_count if want_skills else 0
|
|
per_summary = exercise_count if want_summary else 0
|
|
per_instructions = exercise_count if want_instructions else 0
|
|
total = per_skills + per_summary + per_instructions
|
|
return {
|
|
"total": total,
|
|
"per_exercise": sum([want_skills, want_summary, want_instructions]),
|
|
"skills": per_skills,
|
|
"summary": per_summary,
|
|
"instructions": per_instructions,
|
|
}
|