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
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:
parent
a28a9d399a
commit
1942585546
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`**
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user