From 19425855462c076d1843194f54aacbe4e28c423f Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 10:19:31 +0200 Subject: [PATCH] Enhance exercise_ai and openrouter_chat modules with AI debugging and improved content handling - 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. --- backend/exercise_ai.py | 51 +++++++ backend/openrouter_chat.py | 125 ++++++++++++++---- backend/version.py | 13 +- docs/HANDOVER.md | 11 +- .../exercises/ExerciseFormPageRoot.jsx | 8 +- 5 files changed, 169 insertions(+), 39 deletions(-) diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index ef31606..bf7d714 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -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 diff --git a/backend/openrouter_chat.py b/backend/openrouter_chat.py index abb57fb..96a1d2f 100644 --- a/backend/openrouter_chat.py +++ b/backend/openrouter_chat.py @@ -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]: diff --git a/backend/version.py b/backend/version.py index f286dc3..deb6ea3 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 8e3f9d1..4eb3217 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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, 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`) -- **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`** --- diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 13f9329..0309506 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -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