Enhance exercise_ai and openrouter_chat modules with AI debugging and improved content handling
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / build-frontend (push) Waiting to run
Test Suite / k6 /health Baseline (push) Waiting to run
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Has been cancelled

- Introduced detailed logging for AI operations in the `exercise_ai` and `openrouter_chat` modules, activated by the `SHINKAN_AI_DEBUG` environment variable, to aid in debugging and performance monitoring.
- Updated the `run_exercise_ai_suggestion` function to log prompt lengths, response sizes, and JSON parsing errors, enhancing transparency in AI interactions.
- Improved the `_flatten_message_content` function to handle nested content structures more effectively, ensuring compatibility with various AI response formats.
- Incremented the application version to 0.8.157 and updated the changelog to reflect these enhancements, including new logging features and content handling improvements.
This commit is contained in:
Lars 2026-05-22 10:19:31 +02:00
parent a28a9d399a
commit 1942585546
5 changed files with 169 additions and 39 deletions

View File

@ -8,7 +8,9 @@ from __future__ import annotations
import copy
import json
import logging
import math
import os
import re
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
_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"})
_LEGACY_SKILL_LEVEL_SLUG = {
"einsteiger": "basis",
@ -672,6 +681,20 @@ def run_exercise_ai_suggestion(
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:
prow = _load_prompt_row(cur, "exercise_summary")
if not prow:
@ -683,10 +706,18 @@ def run_exercise_ai_suggestion(
"exercise_execution": e_plain or "-",
}
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:
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
if _ai_debug_on():
_LOGGER.warning("AI_DEBUG exercise_ai summary response_chars=%s", len(raw or ""))
text = (raw or "").strip()
if not text:
raise HTTPException(
@ -720,6 +751,14 @@ def run_exercise_ai_suggestion(
"skills_catalog": catalog,
}
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 = (
"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:
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()
if not body:
raise HTTPException(
@ -745,11 +786,21 @@ def run_exercise_ai_suggestion(
try:
parsed = _extract_json_array(body)
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(
status_code=502,
detail="KI lieferte kein verwertbares JSON fuer Skills.",
) from e
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
return result

View File

@ -4,16 +4,53 @@ Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MOD
from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, List, Optional
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:
"""
OpenAI-kompatibles Chat-Completion kann `content` als String oder als Liste
strukturierter Blöcke liefern (z.B. Anthropic über OpenRouter/Bedrock).
Chat-Completion: `content` als String oder als Liste strukturierter Blöcke
(Anthropic Claude über OpenRouter/Bedrock, teils verschachtelt).
"""
if content is None:
return ""
@ -23,25 +60,31 @@ def _flatten_message_content(content: Any) -> str:
parts: List[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
bits = _coerce_nested_text(block)
if bits:
parts.append(bits)
elif isinstance(block, dict):
t = block.get("type")
txt = block.get("text")
if txt is None and t == "text":
txt = block.get("content")
if isinstance(txt, str):
parts.append(txt)
elif txt is not None:
parts.append(str(txt))
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")
if txt is None:
txt = block.get("content")
else:
# unbekannten Typ weiter versuchen (Provider-Varianten), aber tool-use überspringen
low = ts
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()
if isinstance(content, dict):
txt = content.get("text")
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 _coerce_nested_text(content)
return str(content).strip()
@ -118,18 +161,42 @@ def openrouter_chat_completion(
msg0 = choices[0] if choices else {}
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)
return content.strip()
blobs: List[Any] = []
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]:

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.156"
APP_VERSION = "0.8.157"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260529068"
@ -23,7 +23,7 @@ MODULE_VERSIONS = {
"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
"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_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -38,6 +38,15 @@ MODULE_VERSIONS = {
}
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",
"date": "2026-05-22",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**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**.
@ -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`)
- **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
- **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, KategorieAnteilCaps (~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`)
- **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`
- **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`)
- **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`**
---

View File

@ -992,6 +992,8 @@ function ExerciseFormPageRoot() {
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)
try {
const res = await api.suggestExerciseAi({
@ -1541,7 +1543,7 @@ function ExerciseFormPageRoot() {
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy || !!aiSuggestionPreview}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('summary')}
>
KI: Kurzfassung
@ -2216,7 +2218,7 @@ function ExerciseFormPageRoot() {
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy || !!aiSuggestionPreview}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('skills')}
>
KI: Fähigkeiten
@ -2225,7 +2227,7 @@ function ExerciseFormPageRoot() {
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy || !!aiSuggestionPreview}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('both')}
>
KI: Kurzfassung und Fähigkeiten