KI Implementierung (MVP) auf Übungen #46
|
|
@ -8,7 +8,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple
|
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
|
@ -16,6 +18,13 @@ from fastapi import HTTPException
|
||||||
|
|
||||||
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger("shinkan.exercise_ai")
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_debug_on() -> bool:
|
||||||
|
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
|
||||||
|
|
||||||
|
|
||||||
_CANONICAL_SKILL_LEVELS = frozenset({"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"})
|
_CANONICAL_SKILL_LEVELS = frozenset({"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"})
|
||||||
_LEGACY_SKILL_LEVEL_SLUG = {
|
_LEGACY_SKILL_LEVEL_SLUG = {
|
||||||
"einsteiger": "basis",
|
"einsteiger": "basis",
|
||||||
|
|
@ -672,6 +681,20 @@ def run_exercise_ai_suggestion(
|
||||||
|
|
||||||
result: Dict[str, Any] = {"model": model}
|
result: Dict[str, Any] = {"model": model}
|
||||||
|
|
||||||
|
if _ai_debug_on():
|
||||||
|
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
|
||||||
|
_LOGGER.warning(
|
||||||
|
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s "
|
||||||
|
"exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
|
||||||
|
want_summary,
|
||||||
|
want_skills,
|
||||||
|
len(t_title),
|
||||||
|
len(g_plain),
|
||||||
|
len(e_plain),
|
||||||
|
len(focus),
|
||||||
|
fid_list,
|
||||||
|
)
|
||||||
|
|
||||||
if want_summary:
|
if want_summary:
|
||||||
prow = _load_prompt_row(cur, "exercise_summary")
|
prow = _load_prompt_row(cur, "exercise_summary")
|
||||||
if not prow:
|
if not prow:
|
||||||
|
|
@ -683,10 +706,18 @@ def run_exercise_ai_suggestion(
|
||||||
"exercise_execution": e_plain or "-",
|
"exercise_execution": e_plain or "-",
|
||||||
}
|
}
|
||||||
prompt = _render_template(str(prow["template"]), ctx)
|
prompt = _render_template(str(prow["template"]), ctx)
|
||||||
|
if _ai_debug_on():
|
||||||
|
_LOGGER.warning(
|
||||||
|
"AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s unreplaced_mustache_pairs=%s",
|
||||||
|
len(prompt),
|
||||||
|
prompt.count("{{"),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
||||||
except OpenRouterError as e:
|
except OpenRouterError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||||
|
if _ai_debug_on():
|
||||||
|
_LOGGER.warning("AI_DEBUG exercise_ai summary response_chars=%s", len(raw or ""))
|
||||||
text = (raw or "").strip()
|
text = (raw or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -720,6 +751,14 @@ def run_exercise_ai_suggestion(
|
||||||
"skills_catalog": catalog,
|
"skills_catalog": catalog,
|
||||||
}
|
}
|
||||||
prompt = _render_template(str(srow["template"]), ctx)
|
prompt = _render_template(str(srow["template"]), ctx)
|
||||||
|
if _ai_debug_on():
|
||||||
|
_LOGGER.warning(
|
||||||
|
"AI_DEBUG exercise_ai skills prompt_slug=exercise_skill_suggestions catalog_chars=%s prompt_chars=%s "
|
||||||
|
"template_has_skills_placeholder=%s",
|
||||||
|
len(catalog),
|
||||||
|
len(prompt),
|
||||||
|
"{{skills_catalog}}" in str(srow.get("template") or ""),
|
||||||
|
)
|
||||||
sys_hint = (
|
sys_hint = (
|
||||||
"Du antwortest nur mit validem JSON (Array). Keine Kommentare, keine Erklaerungen ausserhalb des JSON."
|
"Du antwortest nur mit validem JSON (Array). Keine Kommentare, keine Erklaerungen ausserhalb des JSON."
|
||||||
)
|
)
|
||||||
|
|
@ -733,6 +772,8 @@ def run_exercise_ai_suggestion(
|
||||||
)
|
)
|
||||||
except OpenRouterError as e:
|
except OpenRouterError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||||
|
if _ai_debug_on():
|
||||||
|
_LOGGER.warning("AI_DEBUG exercise_ai skills response_chars=%s", len(raw or ""))
|
||||||
body = (raw or "").strip()
|
body = (raw or "").strip()
|
||||||
if not body:
|
if not body:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -745,11 +786,21 @@ def run_exercise_ai_suggestion(
|
||||||
try:
|
try:
|
||||||
parsed = _extract_json_array(body)
|
parsed = _extract_json_array(body)
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
if _ai_debug_on():
|
||||||
|
_LOGGER.warning(
|
||||||
|
"AI_DEBUG exercise_ai skills JSON parse_failed err=%s head=%s",
|
||||||
|
e,
|
||||||
|
(body.replace("\r", "").replace("\n", " ").strip())[:400],
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=502,
|
status_code=502,
|
||||||
detail="KI lieferte kein verwertbares JSON fuer Skills.",
|
detail="KI lieferte kein verwertbares JSON fuer Skills.",
|
||||||
) from e
|
) from e
|
||||||
skills = _sanitize_skill_entries(cur, parsed)
|
skills = _sanitize_skill_entries(cur, parsed)
|
||||||
|
if _ai_debug_on():
|
||||||
|
cand_n = len(parsed) if isinstance(parsed, list) else -1
|
||||||
|
_LOGGER.warning("AI_DEBUG exercise_ai skills parsed_len=%s sanitized_kept=%s", cand_n, len(skills))
|
||||||
|
|
||||||
result["skills"] = skills
|
result["skills"] = skills
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,53 @@ Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MOD
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
_logger = logging.getLogger("shinkan.openrouter")
|
||||||
|
|
||||||
|
_SKIP_ANTHROPIC_BLOCK_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"thinking",
|
||||||
|
"redacted_thinking",
|
||||||
|
"reasoning",
|
||||||
|
"tool_use",
|
||||||
|
"tool_calls",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shinkan_ai_debug() -> bool:
|
||||||
|
return os.getenv("SHINKAN_AI_DEBUG", "").strip().lower() in ("1", "true", "yes", "full")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_nested_text(val: Any) -> str:
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(val, str):
|
||||||
|
return val.strip()
|
||||||
|
if isinstance(val, bool) or isinstance(val, (int, float)):
|
||||||
|
return str(val).strip()
|
||||||
|
if isinstance(val, list):
|
||||||
|
return "".join(_coerce_nested_text(x) for x in val).strip()
|
||||||
|
if isinstance(val, dict):
|
||||||
|
# OpenRouter/Anthropic: verschachtelte text/content-Hüllen
|
||||||
|
for key in ("text", "content", "value"):
|
||||||
|
if key in val:
|
||||||
|
nested = _coerce_nested_text(val.get(key))
|
||||||
|
if nested:
|
||||||
|
return nested
|
||||||
|
return ""
|
||||||
|
return str(val).strip()
|
||||||
|
|
||||||
|
|
||||||
def _flatten_message_content(content: Any) -> str:
|
def _flatten_message_content(content: Any) -> str:
|
||||||
"""
|
"""
|
||||||
OpenAI-kompatibles Chat-Completion kann `content` als String oder als Liste
|
Chat-Completion: `content` als String oder als Liste strukturierter Blöcke
|
||||||
strukturierter Blöcke liefern (z. B. Anthropic über OpenRouter/Bedrock).
|
(Anthropic Claude über OpenRouter/Bedrock, teils verschachtelt).
|
||||||
"""
|
"""
|
||||||
if content is None:
|
if content is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -23,25 +60,31 @@ def _flatten_message_content(content: Any) -> str:
|
||||||
parts: List[str] = []
|
parts: List[str] = []
|
||||||
for block in content:
|
for block in content:
|
||||||
if isinstance(block, str):
|
if isinstance(block, str):
|
||||||
parts.append(block)
|
bits = _coerce_nested_text(block)
|
||||||
|
if bits:
|
||||||
|
parts.append(bits)
|
||||||
elif isinstance(block, dict):
|
elif isinstance(block, dict):
|
||||||
t = block.get("type")
|
t_raw = block.get("type")
|
||||||
|
ts = str(t_raw or "").strip().lower()
|
||||||
|
if ts and (ts in _SKIP_ANTHROPIC_BLOCK_TYPES or ts.endswith("_thinking")):
|
||||||
|
continue
|
||||||
|
txt = None
|
||||||
|
if ts in ("text", "output_text", ""):
|
||||||
txt = block.get("text")
|
txt = block.get("text")
|
||||||
if txt is None and t == "text":
|
if txt is None:
|
||||||
txt = block.get("content")
|
txt = block.get("content")
|
||||||
if isinstance(txt, str):
|
else:
|
||||||
parts.append(txt)
|
# unbekannten Typ weiter versuchen (Provider-Varianten), aber tool-use überspringen
|
||||||
elif txt is not None:
|
low = ts
|
||||||
parts.append(str(txt))
|
if "tool_use" in low or low.startswith("tool_"):
|
||||||
|
continue
|
||||||
|
txt = block.get("text") if block.get("text") is not None else block.get("content")
|
||||||
|
bits = _coerce_nested_text(txt)
|
||||||
|
if bits:
|
||||||
|
parts.append(bits)
|
||||||
return "".join(parts).strip()
|
return "".join(parts).strip()
|
||||||
if isinstance(content, dict):
|
if isinstance(content, dict):
|
||||||
txt = content.get("text")
|
return _coerce_nested_text(content)
|
||||||
if txt is None:
|
|
||||||
txt = content.get("content")
|
|
||||||
if isinstance(txt, str):
|
|
||||||
return txt.strip()
|
|
||||||
if isinstance(txt, list):
|
|
||||||
return _flatten_message_content(txt)
|
|
||||||
return str(content).strip()
|
return str(content).strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,18 +161,42 @@ def openrouter_chat_completion(
|
||||||
|
|
||||||
msg0 = choices[0] if choices else {}
|
msg0 = choices[0] if choices else {}
|
||||||
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
||||||
raw: Any = None
|
|
||||||
if isinstance(inner, dict):
|
|
||||||
raw = inner.get("content")
|
|
||||||
if raw is None and inner.get("refusal") is not None:
|
|
||||||
raw = inner.get("refusal")
|
|
||||||
elif isinstance(inner, str):
|
|
||||||
raw = inner
|
|
||||||
if raw is None and isinstance(msg0, dict):
|
|
||||||
raw = msg0.get("content")
|
|
||||||
|
|
||||||
content = _flatten_message_content(raw)
|
blobs: List[Any] = []
|
||||||
return content.strip()
|
if isinstance(inner, dict):
|
||||||
|
if inner.get("content") is not None:
|
||||||
|
blobs.append(inner.get("content"))
|
||||||
|
if inner.get("refusal") is not None:
|
||||||
|
blobs.append(inner.get("refusal"))
|
||||||
|
elif isinstance(inner, str):
|
||||||
|
blobs.append(inner)
|
||||||
|
if isinstance(msg0, dict) and msg0.get("content") is not None and msg0.get("content") not in blobs:
|
||||||
|
blobs.append(msg0.get("content"))
|
||||||
|
|
||||||
|
pieces = [_flatten_message_content(b).strip() for b in blobs if b is not None]
|
||||||
|
joined = ("\n".join(p for p in pieces if p)).strip()
|
||||||
|
|
||||||
|
if _shinkan_ai_debug():
|
||||||
|
fr = str(msg0.get("finish_reason") or "") if isinstance(msg0, dict) else ""
|
||||||
|
fu = data.get("usage") if isinstance(data.get("usage"), dict) else {}
|
||||||
|
pu = str(fu.get("prompt_tokens") or "")
|
||||||
|
pc = str(fu.get("completion_tokens") or "")
|
||||||
|
pt = str(fu.get("total_tokens") or "")
|
||||||
|
raw_cls = type(blobs[0]).__name__ if blobs else "none"
|
||||||
|
cc = str(len(joined))
|
||||||
|
_logger.warning(
|
||||||
|
"[AI_DEBUG/openrouter] model=%s finish_reason=%s usage_prompt=%s usage_completion=%s usage_total=%s "
|
||||||
|
"raw_content_cls=%s out_chars=%s",
|
||||||
|
model,
|
||||||
|
fr,
|
||||||
|
pu,
|
||||||
|
pc,
|
||||||
|
pt,
|
||||||
|
raw_cls,
|
||||||
|
cc,
|
||||||
|
)
|
||||||
|
|
||||||
|
return joined
|
||||||
|
|
||||||
|
|
||||||
def normalize_openrouter_env() -> tuple[str, str]:
|
def normalize_openrouter_env() -> tuple[str, str]:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.156"
|
APP_VERSION = "0.8.157"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260529068"
|
DB_SCHEMA_VERSION = "20260529068"
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ MODULE_VERSIONS = {
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.30.2", # OpenRouter strukturierte content-Bloecke (Claude4/Bedrock); exercise_ai Robustheit + klare 502 wenn leeres Modell JSON; Retrieval-Admin Formular Gewichte/Unterkategorien
|
"exercises": "2.30.3", # Frontend KI ohne Modal-Grausperre; Anthropic/OpenRouter verschachtelte Textbloecke; SHINKAN_AI_DEBUG Warn-Logs exercise_ai/OpenRouter
|
||||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -38,6 +38,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.157",
|
||||||
|
"date": "2026-05-22",
|
||||||
|
"changes": [
|
||||||
|
"Übungsformular-KI: Buttons nur noch bei Busy deaktiviert; Vorschau wird vor neuem Aufruf geschlossen (kein Permanent-Grauschalter durch Modal-Zustand)",
|
||||||
|
"OpenRouter: verschachtelte Textbloecke/Content-Huellen fuer Anthropic; refusal optional mitgenommen",
|
||||||
|
"exercise_ai: SHINKAN_AI_DEBUG=1 — detaillierte WARN-Logs (Prompt-Laengen, parse-Fehler, Sanitize-Anzahl)",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.156",
|
"version": "0.8.156",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-29
|
**Stand:** 2026-05-29
|
||||||
**App-Version / DB-Schema:** App **`0.8.156`** (KI Retrieval-Admin Formular · OpenRouter Inhaltsbloecke Fix), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
|
**App-Version / DB-Schema:** App **`0.8.157`** (KI Übungen: UX-Flow + AI_DEBUG Logs), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
|
||||||
|
|
||||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||||
|
|
||||||
|
|
@ -88,13 +88,14 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||||
|
|
||||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.156**)
|
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.157**)
|
||||||
|
|
||||||
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
||||||
- **DB:** Migration **`068`** – Tabelle **`ai_skill_retrieval_profiles`** (Konfig **`config`**) mit Seed „Standard“ + „Gewaltschutz“ (wenn Focus `Gewaltschutz` in `focus_areas` existiert)
|
- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
|
||||||
- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
|
- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
|
||||||
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** verwendet gespeicherte `exercise_focus_areas` automatisch für dieselbe Retrieval-Logik — **Pflege:** Superadmin **`GET/POST/PUT/DELETE /api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`)
|
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`)
|
||||||
- **Frontend:** `ExerciseFormPageRoot.jsx` übergibt `focus_areas_context` aus Einordnung; KI-Übernahmedialog nach API-Antwort — **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`** (Nav „KI Retrieval“, nur Superadmin): **Gewichte pro Haupt- und Unterkategorie ohne JSON-Editor**; OpenRouter-Parser für Claude/Bedrock-Inhaltsbloecke in `openrouter_chat.py`
|
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang
|
||||||
|
- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -992,6 +992,8 @@ function ExerciseFormPageRoot() {
|
||||||
return a.focus_area_id - b.focus_area_id
|
return a.focus_area_id - b.focus_area_id
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/* Vor jedem neuen Aufruf: Vorschau schließen; sonst bleiben die KI-Buttons wegen Modal-Zustand dauerhaft deaktiviert. */
|
||||||
|
setAiSuggestionPreview(null)
|
||||||
setAiSuggestBusy(true)
|
setAiSuggestBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.suggestExerciseAi({
|
const res = await api.suggestExerciseAi({
|
||||||
|
|
@ -1541,7 +1543,7 @@ function ExerciseFormPageRoot() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ fontSize: '12px' }}
|
style={{ fontSize: '12px' }}
|
||||||
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
disabled={aiSuggestBusy}
|
||||||
onClick={() => runExerciseAiSuggestion('summary')}
|
onClick={() => runExerciseAiSuggestion('summary')}
|
||||||
>
|
>
|
||||||
KI: Kurzfassung
|
KI: Kurzfassung
|
||||||
|
|
@ -2216,7 +2218,7 @@ function ExerciseFormPageRoot() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ fontSize: '12px' }}
|
style={{ fontSize: '12px' }}
|
||||||
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
disabled={aiSuggestBusy}
|
||||||
onClick={() => runExerciseAiSuggestion('skills')}
|
onClick={() => runExerciseAiSuggestion('skills')}
|
||||||
>
|
>
|
||||||
KI: Fähigkeiten
|
KI: Fähigkeiten
|
||||||
|
|
@ -2225,7 +2227,7 @@ function ExerciseFormPageRoot() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ fontSize: '12px' }}
|
style={{ fontSize: '12px' }}
|
||||||
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
disabled={aiSuggestBusy}
|
||||||
onClick={() => runExerciseAiSuggestion('both')}
|
onClick={() => runExerciseAiSuggestion('both')}
|
||||||
>
|
>
|
||||||
KI: Kurzfassung und Fähigkeiten
|
KI: Kurzfassung und Fähigkeiten
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user