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

View File

@ -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]:

View File

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

View File

@ -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, KategorieAnteilCaps (~Token), Keyword-Patches aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung/Haltegriff) - **`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`) - **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`**
--- ---

View File

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