Ki und Admin Feature #53

Merged
Lars merged 2 commits from develop into main 2026-06-06 18:01:11 +02:00
15 changed files with 2169 additions and 91 deletions

View File

@ -38,12 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
| admin_user_content | `/api/admin/user-content/*` (Meta, Nutzer-Summary, Items, PATCH, DELETE) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; Moderation nutzerangelegter Inhalte inkl. privat; kein TenantContext |
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review).
Letzte Änderung: 2026-06-06 — Superadmin `/api/admin/user-content/*` (Nutzer-Inhalte Moderation).
---

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -203,6 +203,7 @@ app.include_router(clubs.router)
app.include_router(club_memberships.router)
app.include_router(club_join_requests.router)
app.include_router(admin_users.router)
app.include_router(admin_user_content.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)

View File

@ -0,0 +1,85 @@
-- Migration 077: Planungs-Pfad-QA — strukturierte Neuanlage-Vorschläge (Phase E3)
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
"phase": "vertiefung",
"insert_after_step_index": 2,
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
}
]
}$t$,
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
"phase": "vertiefung",
"insert_after_step_index": 2,
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
}
]
}$t$,
output_schema = '{"type":"object","required":["overall_ok"],"properties":{"overall_ok":{"type":"boolean"},"quality_score":{"type":"number"},"issues":{"type":"array"},"sequence_notes":{"type":"array"},"recommendations":{"type":"array"},"ordered_step_indices":{"type":"array"},"suggested_new_exercises":{"type":"array"}}}'::jsonb
WHERE slug = 'planning_exercise_path_qa';

View File

@ -1,16 +1,17 @@
"""
Planungs-KI Phase E2: KI-Neuanlage-Vorschläge für unüberbrückbare Pfad-Lücken.
Planungs-KI Phase E2/E3: KI-Neuanlage für Pfad-Lücken + strukturierte Angebote für die UI.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional
import uuid
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_path_qa import find_step_pair_index
from planning_exercise_semantics import PlanningSemanticBrief
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
@ -23,19 +24,27 @@ def _build_gap_ai_context(
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
title_hint: Optional[str] = None,
sketch_hint: Optional[str] = None,
) -> ExerciseFormAiPromptContext:
topic = (brief.primary_topic or "Technik").strip()
phase = gap.get("expected_phase") or "vertiefung"
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
title = f"Brücke {topic} ({phase})"
goal = (
f"Planungsziel: {goal_query}\n\n"
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.\n"
f"Phase: {phase}. Thema: {topic}. "
f"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor."
)
title = (title_hint or f"Brücke {topic} ({phase})").strip()[:280]
sketch = (sketch_hint or "").strip()
goal_parts = [
f"Planungsziel: {goal_query}",
"",
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.",
f"Phase: {phase}. Thema: {topic}.",
"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor.",
]
if sketch:
goal_parts.extend(["", f"Hinweis: {sketch}"])
goal = "\n".join(goal_parts)
focus_hint = topic if brief.topic_type == "technique" else None
if brief.must_phrases:
focus_hint = ", ".join(brief.must_phrases[:2])
@ -81,8 +90,8 @@ def ai_proposal_to_path_step(
"is_ai_proposal": True,
"ai_suggestion": dict(ai_payload),
"bridge_for_gap": {
"from_exercise_id": int(step_a["exercise_id"]),
"to_exercise_id": int(step_b["exercise_id"]),
"from_exercise_id": step_a.get("exercise_id"),
"to_exercise_id": step_b.get("exercise_id"),
"gap_score": gap.get("gap_score"),
"expected_phase": gap.get("expected_phase"),
},
@ -97,6 +106,8 @@ def try_suggest_ai_bridge_step(
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
title_hint: Optional[str] = None,
sketch_hint: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
ctx = _build_gap_ai_context(
@ -105,6 +116,8 @@ def try_suggest_ai_bridge_step(
step_a=step_a,
step_b=step_b,
gap=gap,
title_hint=title_hint,
sketch_hint=sketch_hint,
)
g_plain = strip_html_to_plain(ctx.goal)
if not g_plain.strip() and not (ctx.title or "").strip():
@ -132,6 +145,217 @@ def try_suggest_ai_bridge_step(
)
def _default_sketch(
*,
goal_query: str,
brief: PlanningSemanticBrief,
step_a: Optional[Mapping[str, Any]],
step_b: Optional[Mapping[str, Any]],
phase: str,
rationale: str = "",
) -> str:
topic = (brief.primary_topic or "Technik").strip()
from_t = (step_a or {}).get("title") or "vorherigem Schritt"
to_t = (step_b or {}).get("title") or "nächstem Schritt"
parts = [
f"Planungsziel: {goal_query}",
f"Zwischenschritt für {topic} ({phase}) zwischen „{from_t}“ und „{to_t}“.",
]
if rationale:
parts.append(rationale)
return " ".join(parts)[:1200]
def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
return (
spec.get("source"),
int(spec.get("insert_after_index") or 0),
str(spec.get("title_hint") or "")[:48],
)
def collect_gap_fill_specs(
*,
steps: Sequence[Mapping[str, Any]],
unfilled_gaps: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
llm_specs: Sequence[Mapping[str, Any]],
brief: PlanningSemanticBrief,
goal_query: str,
) -> List[Dict[str, Any]]:
"""Sammelt alle Lücken, für die ein KI-Anlege-Angebot sinnvoll ist."""
topic = (brief.primary_topic or "Technik").strip()
specs: List[Dict[str, Any]] = []
seen: set = set()
def add(spec: Dict[str, Any]) -> None:
key = _spec_dedupe_key(spec)
if key in seen:
return
seen.add(key)
specs.append(spec)
for gap in unfilled_gaps:
idx = find_step_pair_index(
steps,
int(gap["from_exercise_id"]),
int(gap["to_exercise_id"]),
)
if idx is None:
continue
phase = gap.get("expected_phase") or "vertiefung"
add(
{
"source": "unfilled_gap",
"insert_after_index": idx,
"gap": dict(gap),
"phase": phase,
"title_hint": f"{topic}{phase}",
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
step_a=steps[idx],
step_b=steps[idx + 1],
phase=str(phase),
rationale="Bibliothek enthält keine passende Brücke.",
),
"rationale": "Lücke zwischen benachbarten Schritten — keine passende Bibliotheks-Übung.",
}
)
for ot in off_topic_steps:
idx = int(ot.get("step_index") or 0)
if idx <= 0 or idx >= len(steps) - 1:
continue
phase = ot.get("expected_phase") or "vertiefung"
add(
{
"source": "off_topic",
"insert_after_index": idx - 1,
"replace_step_index": idx,
"gap": {
"expected_phase": phase,
"off_topic_title": ot.get("title"),
"off_topic_exercise_id": ot.get("exercise_id"),
},
"phase": phase,
"title_hint": f"{topic}{phase} (Ersatz für themenfremden Schritt)",
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
step_a=steps[idx - 1],
step_b=steps[idx + 1],
phase=str(phase),
rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.",
),
"rationale": f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema.",
}
)
for spec in llm_specs:
add(dict(spec))
return specs[:5]
def build_gap_fill_offer(
*,
spec: Mapping[str, Any],
steps: Sequence[Mapping[str, Any]],
proposal: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
idx = int(spec.get("insert_after_index") or 0)
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
offer: Dict[str, Any] = {
"offer_id": offer_id,
"source": spec.get("source"),
"insert_after_index": idx,
"replace_step_index": spec.get("replace_step_index"),
"title_hint": spec.get("title_hint"),
"sketch": spec.get("sketch"),
"phase": spec.get("phase"),
"rationale": spec.get("rationale"),
"has_ai_payload": False,
"from_title": (steps[idx].get("title") if idx < len(steps) else None),
"to_title": (steps[idx + 1].get("title") if idx + 1 < len(steps) else None),
}
if proposal:
offer["has_ai_payload"] = True
offer["proposal_key"] = proposal.get("proposal_key")
offer["ai_suggestion"] = proposal.get("ai_suggestion")
offer["proposal_title"] = proposal.get("title")
offer["proposal_summary"] = proposal.get("summary")
return offer
def apply_gap_fill_after_qa(
cur,
steps: List[Dict[str, Any]],
specs: Sequence[Mapping[str, Any]],
*,
goal_query: str,
brief: PlanningSemanticBrief,
include_ai_calls: bool = True,
max_ai_proposals: int = 3,
auto_insert_proposals: bool = False,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
Returns: (steps, ai_proposals, gap_fill_offers)
"""
if not specs:
return steps, [], []
out = list(steps)
proposals: List[Dict[str, Any]] = []
offers: List[Dict[str, Any]] = []
for spec in specs:
idx = int(spec.get("insert_after_index") or 0)
if idx < 0 or idx >= len(out) - 1:
continue
step_a = out[idx]
step_b = out[idx + 1]
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=None)
offers.append(offer)
continue
gap = dict(spec.get("gap") or {})
if not gap.get("expected_phase"):
gap["expected_phase"] = spec.get("phase") or "vertiefung"
proposal: Optional[Dict[str, Any]] = None
if include_ai_calls and len(proposals) < max_ai_proposals:
proposal = try_suggest_ai_bridge_step(
cur,
goal_query=goal_query,
brief=brief,
step_a=step_a,
step_b=step_b,
gap=gap,
title_hint=str(spec.get("title_hint") or ""),
sketch_hint=str(spec.get("sketch") or ""),
)
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=proposal)
offers.append(offer)
if proposal and auto_insert_proposals:
out.insert(idx + 1, proposal)
proposals.append(
{
"inserted_after_index": idx,
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"gap": gap,
"offer_id": offer.get("offer_id"),
}
)
return out, proposals, offers
def insert_ai_proposals_for_gaps(
cur,
steps: list,
@ -141,56 +365,32 @@ def insert_ai_proposals_for_gaps(
brief: PlanningSemanticBrief,
max_proposals: int = 2,
) -> tuple[list, list]:
"""Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
if not unfilled_gaps:
return steps, []
out = list(steps)
proposals: list = []
gap_by_pair = {
(int(g["from_exercise_id"]), int(g["to_exercise_id"])): g for g in unfilled_gaps
}
i = 0
while i < len(out) - 1 and len(proposals) < max_proposals:
a = out[i]
b = out[i + 1]
if a.get("is_ai_proposal") or b.get("is_ai_proposal"):
i += 1
continue
key = (int(a["exercise_id"]), int(b["exercise_id"]))
gap = gap_by_pair.get(key)
if not gap:
i += 1
continue
proposal = try_suggest_ai_bridge_step(
cur,
goal_query=goal_query,
brief=brief,
step_a=a,
step_b=b,
gap=gap,
)
if not proposal:
i += 1
continue
out.insert(i + 1, proposal)
proposals.append(
{
"inserted_after_index": i,
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"gap": gap,
}
)
i += 2
"""Legacy: Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=unfilled_gaps,
off_topic_steps=[],
llm_specs=[],
brief=brief,
goal_query=goal_query,
)
out, proposals, _offers = apply_gap_fill_after_qa(
cur,
steps,
specs,
goal_query=goal_query,
brief=brief,
include_ai_calls=True,
max_ai_proposals=max_proposals,
auto_insert_proposals=True,
)
return out, proposals
__all__ = [
"apply_gap_fill_after_qa",
"build_gap_fill_offer",
"collect_gap_fill_specs",
"insert_ai_proposals_for_gaps",
"try_suggest_ai_bridge_step",
]

View File

@ -15,11 +15,13 @@ from planning_exercise_profiles import PlanningTargetProfile
from planning_exercise_path_qa import (
apply_llm_path_reorder,
build_path_qa_summary,
detect_off_topic_steps,
detect_path_gaps,
insert_bridge_exercises,
parse_llm_suggested_new_exercises,
try_llm_qa_progression_path,
)
from planning_exercise_path_ai_fill import insert_ai_proposals_for_gaps
from planning_exercise_path_ai_fill import apply_gap_fill_after_qa, collect_gap_fill_specs
from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_semantics import (
PlanningSemanticBrief,
@ -394,6 +396,8 @@ def suggest_progression_path(
gaps: List[Dict[str, Any]] = []
bridge_inserts: List[Dict[str, Any]] = []
ai_proposals: List[Dict[str, Any]] = []
gap_fill_offers: List[Dict[str, Any]] = []
off_topic_steps: List[Dict[str, Any]] = []
llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False
reorder_applied = False
@ -424,15 +428,6 @@ def suggest_progression_path(
bridge_search_fn=bridge_fn,
)
if body.include_ai_gap_fill and unfilled_gaps:
steps, ai_proposals = insert_ai_proposals_for_gaps(
cur,
steps,
unfilled_gaps,
goal_query=goal_query,
brief=semantic_brief,
)
if body.include_llm_path_qa:
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
@ -452,10 +447,39 @@ def suggest_progression_path(
if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45):
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
llm_gap_specs = parse_llm_suggested_new_exercises(
llm_qa,
brief=semantic_brief,
step_count=len(steps),
)
if body.include_ai_gap_fill:
gap_specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=unfilled_gaps,
off_topic_steps=off_topic_steps,
llm_specs=llm_gap_specs,
brief=semantic_brief,
goal_query=goal_query,
)
steps, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
cur,
steps,
gap_specs,
goal_query=goal_query,
brief=semantic_brief,
include_ai_calls=True,
max_ai_proposals=3,
auto_insert_proposals=False,
)
path_qa = build_path_qa_summary(
gaps=gaps,
bridge_inserts=bridge_inserts,
ai_proposals=ai_proposals,
gap_fill_offers=gap_fill_offers,
off_topic_steps=off_topic_steps,
llm_qa=llm_qa,
llm_applied=llm_qa_applied,
reorder_applied=reorder_applied,
@ -472,6 +496,8 @@ def suggest_progression_path(
retrieval_parts.append("path_reorder")
if ai_proposals:
retrieval_parts.append("ai_gap_fill")
if gap_fill_offers:
retrieval_parts.append("gap_fill_offers")
return {
"goal_query": goal_query,
@ -484,6 +510,7 @@ def suggest_progression_path(
"query_intent_summary": first_intent_summary,
"progression_graph_id": body.progression_graph_id,
"path_qa": path_qa,
"gap_fill_offers": gap_fill_offers,
"retrieval_phase": "+".join(retrieval_parts),
}

View File

@ -19,6 +19,7 @@ from openrouter_chat import (
from planning_exercise_semantics import (
PlanningSemanticBrief,
brief_to_summary_dict,
exercise_passes_path_semantic_gate,
score_exercise_semantic_relevance,
step_phase_for_index,
)
@ -230,6 +231,18 @@ def insert_bridge_exercises(
i += 1
continue
bridge_sem = float(bridge_hit.get("semantic_score") or 0.0)
if brief.semantic_strength >= 0.55 and not exercise_passes_path_semantic_gate(
semantic_score=bridge_sem,
title=str(bridge_hit.get("title") or ""),
summary=str(bridge_hit.get("summary") or ""),
brief=brief,
strict=True,
):
unfilled.append({**gap, "weak_bridge_rejected": True, "bridge_title": bridge_hit.get("title")})
i += 1
continue
bridge_step = {
"exercise_id": int(bridge_hit["id"]),
"variant_id": bridge_hit.get("suggested_variant_id"),
@ -351,16 +364,133 @@ def apply_llm_path_reorder(
return [steps[i] for i in indices], True, notes
_OFF_TOPIC_SEMANTIC_MAX = 0.10
def detect_off_topic_steps(
cur,
steps: Sequence[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
) -> List[Dict[str, Any]]:
"""Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri)."""
if brief.semantic_strength < 0.55 or len(steps) < 2:
return []
off_topic: List[Dict[str, Any]] = []
total = len(steps)
for idx, step in enumerate(steps):
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
continue
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
phase = step_phase_for_index(brief, idx, total)
sem, sem_reasons = score_exercise_semantic_relevance(
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
variant_names=bundle["variant_names"],
brief=brief,
step_phase=phase,
)
if exercise_passes_path_semantic_gate(
semantic_score=sem,
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
brief=brief,
strict=True,
):
continue
if sem > _OFF_TOPIC_SEMANTIC_MAX:
continue
off_topic.append(
{
"step_index": idx,
"exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"],
"semantic_score": round(sem, 4),
"expected_phase": phase,
"issue": "off_topic",
"reasons": sem_reasons[:3],
}
)
return off_topic
def parse_llm_suggested_new_exercises(
llm_qa: Optional[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
step_count: int,
) -> List[Dict[str, Any]]:
"""Strukturierte Neuanlage-Vorschläge aus LLM-Pfad-QS."""
if not llm_qa:
return []
raw = llm_qa.get("suggested_new_exercises")
if not isinstance(raw, list):
return []
topic = (brief.primary_topic or "Technik").strip()
out: List[Dict[str, Any]] = []
for item in raw[:5]:
if not isinstance(item, dict):
continue
title_hint = str(item.get("title_hint") or item.get("title") or "").strip()
if len(title_hint) < 3:
title_hint = f"{topic} — Zwischenschritt"
sketch = str(item.get("sketch") or item.get("goal_hint") or item.get("rationale") or "").strip()
phase = str(item.get("phase") or item.get("expected_phase") or "vertiefung").strip()
rationale = str(item.get("rationale") or "").strip()
insert_after = item.get("insert_after_step_index")
if insert_after is None:
insert_after = item.get("insert_after_index")
try:
insert_idx = int(insert_after) if insert_after is not None else max(0, step_count // 2 - 1)
except (TypeError, ValueError):
insert_idx = max(0, step_count // 2 - 1)
insert_idx = max(0, min(step_count - 2, insert_idx))
out.append(
{
"source": "llm_suggested",
"insert_after_index": insert_idx,
"title_hint": title_hint[:280],
"sketch": sketch[:1200],
"phase": phase,
"rationale": rationale[:500],
}
)
return out
def find_step_pair_index(
steps: Sequence[Mapping[str, Any]],
from_exercise_id: int,
to_exercise_id: int,
) -> Optional[int]:
for i in range(len(steps) - 1):
a = steps[i]
b = steps[i + 1]
if a.get("exercise_id") is None or b.get("exercise_id") is None:
continue
if int(a["exercise_id"]) == int(from_exercise_id) and int(b["exercise_id"]) == int(to_exercise_id):
return i
return None
def build_path_qa_summary(
*,
gaps: Sequence[Mapping[str, Any]],
bridge_inserts: Sequence[Mapping[str, Any]],
ai_proposals: Sequence[Mapping[str, Any]],
gap_fill_offers: Optional[Sequence[Mapping[str, Any]]] = None,
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
llm_qa: Optional[Mapping[str, Any]],
llm_applied: bool,
reorder_applied: bool = False,
reorder_notes: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
offers = list(gap_fill_offers or [])
off_topic = list(off_topic_steps or [])
summary: Dict[str, Any] = {
"gap_count": len(gaps),
"large_gaps": list(gaps),
@ -368,6 +498,10 @@ def build_path_qa_summary(
"bridge_inserts": list(bridge_inserts),
"ai_proposal_count": len(ai_proposals),
"ai_proposals": list(ai_proposals),
"gap_fill_offer_count": len(offers),
"gap_fill_offers": offers,
"off_topic_count": len(off_topic),
"off_topic_steps": off_topic,
"llm_qa_applied": llm_applied,
"reorder_applied": reorder_applied,
"reorder_notes": list(reorder_notes or []),
@ -379,20 +513,29 @@ def build_path_qa_summary(
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
summary["topic_coverage"] = llm_qa.get("topic_coverage")
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
else:
summary["overall_ok"] = len(gaps) == 0
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
summary["issues"] = [
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
for g in gaps
] if gaps else []
if off_topic:
summary["issues"] = list(summary["issues"]) + [
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
for o in off_topic
]
return summary
__all__ = [
"apply_llm_path_reorder",
"build_path_qa_summary",
"detect_off_topic_steps",
"detect_path_gaps",
"find_step_pair_index",
"insert_bridge_exercises",
"measure_step_transition_gap",
"parse_llm_suggested_new_exercises",
"try_llm_qa_progression_path",
]

View File

@ -0,0 +1,519 @@
"""
Superadmin API: Übersicht und Moderation nutzerangelegter Inhalte (inkl. private).
# ACCESS_LAYER exempt: Plattform-weites Superadmin-Werkzeug ohne TenantContext.
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
"""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_db, get_cursor, r2d
from media_lifecycle import superadmin_hard_delete_media_asset
router = APIRouter(prefix="/api/admin/user-content", tags=["admin_user_content"])
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
_VALID_EXERCISE_STATUS = frozenset({"draft", "in_review", "approved", "archived"})
_VALID_MATURITY_STATUS = frozenset({"draft", "active", "archived"})
_VALID_MEDIA_RIGHTS = frozenset({"legacy_unreviewed", "declared", "blocked"})
_VALID_MEDIA_LIFECYCLE = frozenset({"active", "trash_soft", "trash_hidden"})
_MAX_ITEMS_LIMIT = 100
ContentType = Literal[
"exercise",
"training_module",
"framework_program",
"progression_graph",
"plan_template",
"maturity_model",
"media_asset",
]
_CONTENT_SPECS: Dict[str, Dict[str, Any]] = {
"exercise": {
"label": "Übung",
"table": "exercises",
"creator_col": "created_by",
"title_col": "title",
"status_col": "status",
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": True,
"has_visibility": True,
"status_values": sorted(_VALID_EXERCISE_STATUS),
},
"training_module": {
"label": "Trainingsmodul",
"table": "training_modules",
"creator_col": "created_by",
"title_col": "title",
"status_col": None,
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": False,
"has_visibility": True,
"status_values": [],
},
"framework_program": {
"label": "Rahmenprogramm",
"table": "training_framework_programs",
"creator_col": "created_by",
"title_col": "title",
"status_col": None,
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": False,
"has_visibility": True,
"status_values": [],
},
"progression_graph": {
"label": "Progressionspfad",
"table": "exercise_progression_graphs",
"creator_col": "created_by",
"title_col": "name",
"status_col": None,
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": False,
"has_visibility": True,
"status_values": [],
},
"plan_template": {
"label": "Trainingsvorlage",
"table": "training_plan_templates",
"creator_col": "created_by",
"title_col": "name",
"status_col": None,
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": False,
"has_visibility": True,
"status_values": [],
},
"maturity_model": {
"label": "Reifegradmodell",
"table": "maturity_models",
"creator_col": "created_by",
"title_col": "name",
"status_col": "status",
"visibility_col": None,
"club_col": "club_id",
"has_status": True,
"has_visibility": False,
"status_values": sorted(_VALID_MATURITY_STATUS),
},
"media_asset": {
"label": "Medium",
"table": "media_assets",
"creator_col": "uploaded_by_profile_id",
"title_col": "original_filename",
"status_col": "rights_status",
"visibility_col": "visibility",
"club_col": "club_id",
"extra_col": "lifecycle_state",
"has_status": True,
"has_visibility": True,
"status_values": sorted(_VALID_MEDIA_RIGHTS),
},
}
def _require_superadmin(session: dict) -> dict:
role = (session.get("role") or "").strip().lower()
if not is_superadmin(role):
raise HTTPException(status_code=403, detail="Nur Superadmins")
return session
def _spec(content_type: str) -> Dict[str, Any]:
key = (content_type or "").strip().lower()
spec = _CONTENT_SPECS.get(key)
if not spec:
raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp")
return spec
def _types_for_filters(
content_type: Optional[str],
status: Optional[str],
) -> List[str]:
if content_type and content_type != "all":
return [content_type]
types = list(_CONTENT_SPECS.keys())
if status:
types = [t for t in types if _CONTENT_SPECS[t].get("has_status")]
return types
def _build_type_select(spec: Dict[str, Any], content_type: str) -> str:
title = spec["title_col"]
creator = spec["creator_col"]
status = spec.get("status_col")
visibility = spec.get("visibility_col")
club = spec.get("club_col")
extra = spec.get("extra_col")
status_sql = f"t.{status}" if status else "NULL"
vis_sql = f"t.{visibility}" if visibility else "NULL"
club_sql = f"t.{club}" if club else "NULL"
extra_sql = f"t.{extra}" if extra else "NULL"
return f"""
SELECT
'{content_type}' AS content_type,
t.id,
t.{title} AS title,
t.{creator} AS profile_id,
{status_sql} AS status,
{vis_sql} AS visibility,
{club_sql} AS club_id,
{extra_sql} AS extra_status,
t.created_at,
t.updated_at
FROM {spec['table']} t
"""
def _append_filters(
where: List[str],
params: List[Any],
*,
spec: Dict[str, Any],
profile_id: Optional[int],
visibility: Optional[str],
status: Optional[str],
search: Optional[str],
) -> None:
creator = spec["creator_col"]
if profile_id is not None:
where.append(f"t.{creator} = %s")
params.append(profile_id)
if visibility and visibility != "all":
vis_col = spec.get("visibility_col")
if vis_col:
where.append(f"t.{vis_col} = %s")
params.append(visibility)
if status:
st_col = spec.get("status_col")
if st_col:
where.append(f"t.{st_col} = %s")
params.append(status)
if search:
title_col = spec["title_col"]
where.append(f"t.{title_col} ILIKE %s")
params.append(f"%{search}%")
def _exercise_delete_usage_message(cur, exercise_id: int) -> str:
cur.execute(
"""
SELECT
(SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items,
(SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items,
(SELECT COUNT(*)::int FROM exercise_progression_edges
WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges
""",
(exercise_id, exercise_id, exercise_id, exercise_id),
)
row = r2d(cur.fetchone() or {})
parts = []
if int(row.get("block_items") or 0):
parts.append(f"{row['block_items']}× in Übungsblöcken")
if int(row.get("section_items") or 0):
parts.append(f"{row['section_items']}× in Trainingsplänen oder Rahmenabläufen")
if int(row.get("prog_edges") or 0):
parts.append(f"{row['prog_edges']}× in Progressionsgraphen (Kanten)")
if not parts:
return ""
return (
"Die Übung wird noch verwendet und kann nicht gelöscht werden. "
"Bitte auf „archiviert“ setzen. Verwendung: " + ", ".join(parts) + "."
)
class UserContentPatchBody(BaseModel):
status: Optional[str] = None
visibility: Optional[str] = None
lifecycle_state: Optional[str] = None
@model_validator(mode="after")
def at_least_one_field(self):
if self.status is None and self.visibility is None and self.lifecycle_state is None:
raise ValueError("Mindestens eines der Felder status, visibility oder lifecycle_state angeben")
return self
@router.get("/meta")
def get_user_content_meta(session: dict = Depends(require_auth)):
"""Metadaten zu unterstützten Inhaltstypen."""
_require_superadmin(session)
types = []
for key, spec in _CONTENT_SPECS.items():
types.append(
{
"id": key,
"label": spec["label"],
"has_status": spec["has_status"],
"has_visibility": spec["has_visibility"],
"status_values": spec.get("status_values") or [],
}
)
return {"content_types": types}
@router.get("/users-summary")
def list_users_content_summary(session: dict = Depends(require_auth)):
"""Anzahl angelegter Inhalte je Nutzer (alle Sichtbarkeiten)."""
_require_superadmin(session)
count_exprs: List[str] = []
for key, spec in _CONTENT_SPECS.items():
creator = spec["creator_col"]
count_exprs.append(
f"(SELECT COUNT(*)::int FROM {spec['table']} WHERE {creator} = p.id) AS {key}_count"
)
counts_sql = ",\n ".join(count_exprs)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"""
SELECT
p.id,
p.name,
p.email,
p.role,
p.created_at,
{counts_sql},
(
{" + ".join(f"COALESCE((SELECT COUNT(*)::int FROM {spec['table']} WHERE {spec['creator_col']} = p.id), 0)" for spec in _CONTENT_SPECS.values())}
) AS total_count
FROM profiles p
WHERE EXISTS (
SELECT 1 FROM exercises e WHERE e.created_by = p.id
UNION ALL SELECT 1 FROM training_modules tm WHERE tm.created_by = p.id
UNION ALL SELECT 1 FROM training_framework_programs fp WHERE fp.created_by = p.id
UNION ALL SELECT 1 FROM exercise_progression_graphs pg WHERE pg.created_by = p.id
UNION ALL SELECT 1 FROM training_plan_templates pt WHERE pt.created_by = p.id
UNION ALL SELECT 1 FROM maturity_models mm WHERE mm.created_by = p.id
UNION ALL SELECT 1 FROM media_assets ma WHERE ma.uploaded_by_profile_id = p.id
)
ORDER BY total_count DESC, COALESCE(lower(trim(p.email)), ''), p.id
"""
)
rows = []
for r in cur.fetchall():
d = r2d(r)
counts = {k: int(d.pop(f"{k}_count") or 0) for k in _CONTENT_SPECS}
d["counts_by_type"] = counts
d["total_count"] = int(d.get("total_count") or 0)
rows.append(d)
return rows
@router.get("/items")
def list_user_content_items(
session: dict = Depends(require_auth),
profile_id: Optional[int] = Query(default=None, ge=1),
content_type: str = Query(default="all"),
visibility: Optional[str] = Query(default="all"),
status: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None, max_length=200),
limit: int = Query(default=50, ge=1, le=_MAX_ITEMS_LIMIT),
offset: int = Query(default=0, ge=0),
):
"""Paginierte Inhaltsliste — Superadmin sieht auch private Inhalte."""
_require_superadmin(session)
ct_raw = (content_type or "all").strip().lower()
if ct_raw != "all" and ct_raw not in _CONTENT_SPECS:
raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp")
vis_raw = (visibility or "all").strip().lower()
if vis_raw not in ("all", *_VALID_VISIBILITY):
raise HTTPException(status_code=400, detail="Ungültiger Sichtbarkeits-Filter")
types = _types_for_filters(ct_raw if ct_raw != "all" else None, status)
if not types:
return {"items": [], "total": 0, "limit": limit, "offset": offset}
unions: List[str] = []
all_params: List[Any] = []
for tkey in types:
spec = _CONTENT_SPECS[tkey]
where: List[str] = ["TRUE"]
params: List[Any] = []
_append_filters(
where,
params,
spec=spec,
profile_id=profile_id,
visibility=vis_raw,
status=(status or "").strip().lower() or None,
search=(search or "").strip() or None,
)
unions.append(_build_type_select(spec, tkey) + " WHERE " + " AND ".join(where))
all_params.extend(params)
union_sql = " UNION ALL ".join(unions)
count_sql = f"SELECT COUNT(*)::int AS c FROM ({union_sql}) sub"
list_sql = f"""
SELECT sub.*,
p.name AS profile_name,
p.email AS profile_email,
c.name AS club_name
FROM ({union_sql}) sub
LEFT JOIN profiles p ON p.id = sub.profile_id
LEFT JOIN clubs c ON c.id = sub.club_id
ORDER BY sub.updated_at DESC NULLS LAST, sub.id DESC
LIMIT %s OFFSET %s
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(count_sql, tuple(all_params))
count_row = cur.fetchone()
total = int(r2d(count_row).get("c") or 0)
cur.execute(list_sql, tuple(all_params + [limit, offset]))
items = []
for r in cur.fetchall():
d = r2d(r)
d["type_label"] = _CONTENT_SPECS[d["content_type"]]["label"]
items.append(d)
return {"items": items, "total": total, "limit": limit, "offset": offset}
@router.patch("/items/{content_type}/{item_id}")
def patch_user_content_item(
content_type: ContentType,
item_id: int,
body: UserContentPatchBody,
session: dict = Depends(require_auth),
):
"""Status und/oder Sichtbarkeit setzen (Superadmin)."""
_require_superadmin(session)
spec = _spec(content_type)
table = spec["table"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(f"SELECT * FROM {table} WHERE id = %s", (item_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
current = r2d(row)
fields: List[str] = []
params: List[Any] = []
if body.status is not None:
st_col = spec.get("status_col")
if not st_col:
raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keinen Status")
st = body.status.strip().lower()
if content_type == "exercise" and st not in _VALID_EXERCISE_STATUS:
raise HTTPException(status_code=400, detail="Ungültiger Übungs-Status")
if content_type == "maturity_model" and st not in _VALID_MATURITY_STATUS:
raise HTTPException(status_code=400, detail="Ungültiger Modell-Status")
if content_type == "media_asset" and st not in _VALID_MEDIA_RIGHTS:
raise HTTPException(status_code=400, detail="Ungültiger Medien-Rechte-Status")
fields.append(f"{st_col} = %s")
params.append(st)
if body.visibility is not None:
vis_col = spec.get("visibility_col")
if not vis_col:
raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keine Sichtbarkeit")
vis = body.visibility.strip().lower()
if vis not in _VALID_VISIBILITY:
raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit")
if vis == "club" and not current.get(spec.get("club_col") or "club_id"):
raise HTTPException(
status_code=400,
detail="Vereins-Sichtbarkeit erfordert eine Vereinszuordnung (club_id).",
)
fields.append(f"{vis_col} = %s")
params.append(vis)
if body.lifecycle_state is not None:
if content_type != "media_asset":
raise HTTPException(status_code=400, detail="Lifecycle nur für Medien")
lc = body.lifecycle_state.strip().lower()
if lc not in _VALID_MEDIA_LIFECYCLE:
raise HTTPException(status_code=400, detail="Ungültiger Lifecycle-Status")
fields.append("lifecycle_state = %s")
params.append(lc)
if lc == "active":
fields.extend(
[
"trash_soft_at = NULL",
"trash_hidden_at = NULL",
"purge_after_at = NULL",
]
)
if not fields:
raise HTTPException(status_code=400, detail="Keine gültigen Änderungen")
fields.append("updated_at = NOW()")
params.append(item_id)
cur.execute(
f"UPDATE {table} SET {', '.join(fields)} WHERE id = %s RETURNING id",
tuple(params),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
conn.commit()
return {"ok": True, "content_type": content_type, "id": item_id}
@router.delete("/items/{content_type}/{item_id}")
def delete_user_content_item(
content_type: ContentType,
item_id: int,
session: dict = Depends(require_auth),
):
"""Inhalt endgültig löschen (Superadmin)."""
_require_superadmin(session)
spec = _spec(content_type)
table = spec["table"]
with get_db() as conn:
cur = get_cursor(conn)
if content_type == "exercise":
cur.execute("SELECT id FROM exercises WHERE id = %s", (item_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
usage_msg = _exercise_delete_usage_message(cur, item_id)
if usage_msg:
raise HTTPException(status_code=409, detail=usage_msg)
cur.execute("DELETE FROM exercises WHERE id = %s", (item_id,))
conn.commit()
return {"ok": True}
if content_type == "media_asset":
cur.execute("SELECT id FROM media_assets WHERE id = %s", (item_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
ok = superadmin_hard_delete_media_asset(cur, conn, item_id)
if not ok:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
return {"ok": True}
cur.execute(f"DELETE FROM {table} WHERE id = %s RETURNING id", (item_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
conn.commit()
return {"ok": True, "content_type": content_type, "id": item_id}

View File

@ -26,6 +26,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py",
"skills.py",
"maturity_models.py",

View File

@ -0,0 +1,64 @@
"""Tests Planungs-KI Phase E3 — Lücken-Angebote und Off-Topic."""
from planning_exercise_path_ai_fill import collect_gap_fill_specs
from planning_exercise_path_qa import parse_llm_suggested_new_exercises
from planning_exercise_semantics import build_semantic_brief
def test_parse_llm_suggested_new_exercises():
brief = build_semantic_brief("Mae Geri Perfektion")
llm_qa = {
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraft am Sandsack",
"sketch": "Kraft und Schnelligkeit",
"phase": "vertiefung",
"insert_after_step_index": 1,
"rationale": "Zwischenschritt",
}
]
}
specs = parse_llm_suggested_new_exercises(llm_qa, brief=brief, step_count=5)
assert len(specs) == 1
assert specs[0]["insert_after_index"] == 1
assert "Mae Geri" in specs[0]["title_hint"]
def test_collect_gap_fill_specs_off_topic_and_unfilled():
brief = build_semantic_brief("Mae Geri Perfektion")
steps = [
{"exercise_id": 1, "title": "Mae Geri Kihon"},
{"exercise_id": 2, "title": "Präzision"},
{"exercise_id": 3, "title": "One Leg Squat"},
{"exercise_id": 4, "title": "Gleichgewichtstritt"},
]
unfilled = [
{
"from_exercise_id": 2,
"to_exercise_id": 3,
"expected_phase": "vertiefung",
"from_title": "Präzision",
"to_title": "One Leg Squat",
}
]
off_topic = [
{
"step_index": 2,
"exercise_id": 3,
"title": "One Leg Squat",
"expected_phase": "vertiefung",
}
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=unfilled,
off_topic_steps=off_topic,
llm_specs=[],
brief=brief,
goal_query="Mae Geri Perfektion",
)
sources = {s["source"] for s in specs}
assert "unfilled_gap" in sources
assert "off_topic" in sources
off = next(s for s in specs if s["source"] == "off_topic")
assert off["replace_step_index"] == 2
assert off["insert_after_index"] == 1

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.189"
APP_VERSION = "0.8.190"
BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531074"
DB_SCHEMA_VERSION = "20260531077"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
"planning_exercise_suggest": "0.15.2", # Pfad-Gate: soft penalty + gestufter Fallback
"planning_exercise_suggest": "0.16.0", # E3: gap_fill_offers, Off-Topic, QA→KI-Pipeline
"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
@ -44,6 +44,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.190",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase E3: gap_fill_offers nach LLM-QS — Lücken, Off-Topic, QS-Neuanlage.",
"Pfad-Builder UI: „Fehlende Schritte — mit KI anlegen“ + ExerciseAiQuickCreateModal.",
"Schwache Bibliotheks-Brücken ablehnen; Migration 077 Pfad-QA suggested_new_exercises.",
],
},
{
"version": "0.8.189",
"date": "2026-05-23",

View File

@ -57,6 +57,7 @@ const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPa
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
const AdminUserContentPage = lazy(() => import('./pages/AdminUserContentPage'))
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
@ -327,6 +328,14 @@ const appRouter = createBrowserRouter([
</PlatformAdminRoute>
),
},
{
path: 'admin/user-content',
element: (
<PlatformAdminRoute>
<AdminUserContentPage />
</PlatformAdminRoute>
),
},
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
],
},

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles, Wand2 } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -8,6 +8,7 @@ export default function AdminPageNav() {
const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },

View File

@ -1,8 +1,15 @@
/**
* Planungs-KI Phase C3: Ziel Übungspfad vorschlagen in Progressionsgraph speichern.
* Planungs-KI Phase C3/E3: Ziel Übungspfad vorschlagen Lücken mit KI anlegen in Graph speichern.
*/
import React, { useCallback, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
} from '../utils/exerciseAiQuickCreate'
function emptyPathStep() {
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
@ -27,9 +34,32 @@ function mapApiStepToRow(step) {
isAiProposal,
aiSuggestion: step?.ai_suggestion || null,
semanticScore: step?.semantic_score,
isOffTopic: false,
}
}
function mapCreatedExerciseToRow(ex, offer) {
return {
exerciseId: Number(ex.id),
proposalKey: null,
exerciseTitle: (ex.title || offer?.title_hint || '').trim() || `Übung #${ex.id}`,
variantId: null,
variants: [],
reasons: ['Neu angelegt zur Schließung einer Pfad-Lücke'],
isBridge: true,
isAiProposal: false,
aiSuggestion: null,
semanticScore: null,
isOffTopic: false,
}
}
const OFFER_SOURCE_LABELS = {
unfilled_gap: 'Lücke',
off_topic: 'Themenfremd',
llm_suggested: 'QS-Empfehlung',
}
export default function ExerciseProgressionPathBuilder({
graphId,
disabled = false,
@ -45,6 +75,32 @@ export default function ExerciseProgressionPathBuilder({
const [semanticBrief, setSemanticBrief] = useState(null)
const [pathQa, setPathQa] = useState(null)
const [pathSteps, setPathSteps] = useState([])
const [gapFillOffers, setGapFillOffers] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [quickCreateOpen, setQuickCreateOpen] = useState(false)
const [activeOffer, setActiveOffer] = useState(null)
const [quickTitle, setQuickTitle] = useState('')
const [quickSketch, setQuickSketch] = useState('')
const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
useEffect(() => {
let cancelled = false
api
.listFocusAreas({ status: 'active' })
.then((rows) => {
if (!cancelled) setFocusAreas(Array.isArray(rows) ? rows : [])
})
.catch(() => {
if (!cancelled) setFocusAreas([])
})
return () => {
cancelled = true
}
}, [])
const patchStep = useCallback((idx, patch) => {
setPathSteps((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
@ -66,6 +122,139 @@ export default function ExerciseProgressionPathBuilder({
})
}, [])
const applyOffTopicFlags = (rows, qa) => {
const off = Array.isArray(qa?.off_topic_steps) ? qa.off_topic_steps : []
const indices = new Set(off.map((o) => Number(o.step_index)).filter(Number.isFinite))
return rows.map((row, idx) => ({ ...row, isOffTopic: indices.has(idx) }))
}
const insertExerciseFromOffer = useCallback((created, offer) => {
const row = mapCreatedExerciseToRow(created, offer)
setPathSteps((prev) => {
let next = [...prev]
const afterIdx = Number(offer?.insert_after_index)
const replaceIdx =
offer?.replace_step_index != null ? Number(offer.replace_step_index) : null
if (Number.isFinite(replaceIdx) && replaceIdx >= 0 && replaceIdx < next.length) {
next.splice(replaceIdx, 1, row)
} else if (Number.isFinite(afterIdx) && afterIdx >= 0 && afterIdx < next.length) {
next.splice(afterIdx + 1, 0, row)
} else {
next.push(row)
}
return applyOffTopicFlags(next, pathQa)
})
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
}, [pathQa])
const closeQuickCreate = () => {
if (quickSaving) return
setQuickCreateOpen(false)
setActiveOffer(null)
setQuickCreateDraft(null)
setQuickAiError('')
}
const openOfferQuickCreate = (offer) => {
setActiveOffer(offer)
setQuickTitle((offer?.title_hint || '').trim())
setQuickSketch((offer?.sketch || '').trim())
setQuickFocusAreaId('')
setQuickCreateDraft(null)
setQuickAiError('')
if (offer?.has_ai_payload && offer?.ai_suggestion) {
const preview = buildQuickCreateAiPreview(offer.ai_suggestion, {
sketchPlain: (offer?.sketch || '').trim(),
})
if (preview.hasSummaryProposal || preview.hasSkillChoices || preview.hasInstructionChoices) {
const focusId = focusAreas[0]?.id ? String(focusAreas[0].id) : ''
setQuickFocusAreaId(focusId)
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, {
title: (offer?.title_hint || '').trim(),
focusAreaId: focusId ? Number(focusId) : '',
sketchPlain: (offer?.sketch || '').trim(),
}),
)
setQuickCreateOpen(false)
return
}
}
setQuickCreateOpen(true)
}
const runQuickCreateAiSuggest = async () => {
const title = (quickTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const sketch = (quickSketch || '').trim()
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || '').trim()
setQuickAiError('')
setQuickCreateDraft(null)
setQuickSaving(true)
try {
const aiRes = await api.suggestExerciseAi({
title,
goal: sketch || undefined,
execution: '',
preparation: '',
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
include_summary: true,
include_skills: true,
include_instructions: true,
})
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
)
setQuickCreateOpen(false)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'KI-Vorschlag fehlgeschlagen')
} finally {
setQuickSaving(false)
}
}
const applyQuickCreateDraft = async () => {
if (!quickCreateDraft || !activeOffer) return
setQuickSaving(true)
setQuickAiError('')
try {
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
insertExerciseFromOffer(created, activeOffer)
setQuickCreateDraft(null)
setActiveOffer(null)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'Übung konnte nicht angelegt werden')
} finally {
setQuickSaving(false)
}
}
const suggestPath = async () => {
const q = (goalQuery || '').trim()
if (q.length < 3) {
@ -85,16 +274,29 @@ export default function ExerciseProgressionPathBuilder({
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: true,
include_ai_gap_fill: true,
progression_graph_id: Number(graphId),
})
const rows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
const qa = res?.path_qa || null
const rows = applyOffTopicFlags(
(Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow),
qa,
)
if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.')
}
setPathSteps(rows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
setPathQa(qa)
setGapFillOffers(
Array.isArray(res?.gap_fill_offers)
? res.gap_fill_offers
: Array.isArray(qa?.gap_fill_offers)
? qa.gap_fill_offers
: [],
)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
} catch (e) {
console.error(e)
@ -103,6 +305,7 @@ export default function ExerciseProgressionPathBuilder({
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
} finally {
setLoading(false)
}
@ -119,7 +322,7 @@ export default function ExerciseProgressionPathBuilder({
alert(
skippedAi > 0
? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.'
: 'Mindestens zwei Schritte mit Übung nötig.'
: 'Mindestens zwei Schritte mit Übung nötig.',
)
return
}
@ -145,6 +348,7 @@ export default function ExerciseProgressionPathBuilder({
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
@ -170,7 +374,7 @@ export default function ExerciseProgressionPathBuilder({
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Ziel in Freitext formulieren die Planungs-KI schlägt eine semantisch passende, aufbauende Reihenfolge vor,
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Nach Review in den Graph speichern.
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Fehlende Schritte können mit KI als Übung angelegt werden.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
@ -179,7 +383,7 @@ export default function ExerciseProgressionPathBuilder({
className="form-input"
value={goalQuery}
onChange={(e) => setGoalQuery(e.target.value)}
placeholder="z. B. sichere Reaktion im Partnertraining aufbauen …"
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
disabled={disabled || loading || saving}
/>
</div>
@ -263,9 +467,9 @@ export default function ExerciseProgressionPathBuilder({
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
</p>
) : null}
{Number(pathQa.ai_proposal_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{pathQa.ai_proposal_count} KI-Neuanlage-Vorschlag/Vorschläge vor dem Speichern als Übung anlegen.
{Number(pathQa.off_topic_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema siehe Lücken-Angebote unten.
</p>
) : null}
{pathQa.reorder_applied ? (
@ -276,12 +480,64 @@ export default function ExerciseProgressionPathBuilder({
: ''}
</p>
) : null}
{Array.isArray(targetSummary?.top_skills) &&
targetSummary.top_skills.slice(0, 2).map((sk) => (
<span key={sk.skill_id} className="exercise-tag">
{sk.name}
</span>
</div>
) : null}
{gapFillOffers.length > 0 ? (
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 40%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 5%, var(--surface))',
}}
>
<strong style={{ fontSize: '13px' }}>Fehlende Schritte mit KI anlegen</strong>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
Die QS hat Lücken erkannt. Vorschlag prüfen, als Übung anlegen und in den Pfad einfügen.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{gapFillOffers.map((offer) => (
<div
key={offer.offer_id}
style={{
padding: '10px 12px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-start' }}>
<div style={{ flex: '1 1 200px' }}>
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
{OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'}
{offer.phase ? ` · ${offer.phase}` : ''}
</span>
<div style={{ fontSize: '13px', fontWeight: 600 }}>{offer.title_hint}</div>
{offer.rationale ? (
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '4px 0 0' }}>{offer.rationale}</p>
) : null}
{offer.from_title && offer.to_title ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
Zwischen {offer.from_title} und {offer.to_title}
{offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
</p>
) : null}
</div>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', flexShrink: 0 }}
disabled={quickSaving}
onClick={() => openOfferQuickCreate(offer)}
>
Mit KI anlegen
</button>
</div>
</div>
))}
</div>
</div>
) : null}
@ -290,7 +546,7 @@ export default function ExerciseProgressionPathBuilder({
<div style={{ marginTop: '14px' }}>
{pathSteps.map((step, idx) => (
<div
key={`${step.exerciseId}-${idx}`}
key={`${step.exerciseId}-${step.proposalKey || ''}-${idx}`}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
@ -299,13 +555,19 @@ export default function ExerciseProgressionPathBuilder({
marginBottom: '12px',
paddingBottom: '12px',
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
background: step.isOffTopic
? 'color-mix(in srgb, var(--danger) 6%, transparent)'
: undefined,
borderRadius: step.isOffTopic ? '8px' : undefined,
padding: step.isOffTopic ? '8px' : undefined,
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
Schritt {idx + 1}
{step.isOffTopic ? ' (themenfremd)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
{!step.isAiProposal && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && !step.isOffTopic && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
</label>
<div style={{ fontSize: '13px' }}>
@ -397,6 +659,9 @@ export default function ExerciseProgressionPathBuilder({
onClick={() => {
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
}}
>
Vorschlag verwerfen
@ -404,6 +669,40 @@ export default function ExerciseProgressionPathBuilder({
</div>
</>
) : null}
<ExerciseAiQuickCreateModal
open={quickCreateOpen}
onClose={closeQuickCreate}
searchLabel={activeOffer?.title_hint || goalQuery}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={focusAreas}
catalogsReady={focusAreas.length > 0}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
/>
<ExerciseAiSuggestPreviewModal
draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft}
onDiscard={() => {
setQuickCreateDraft(null)
if (activeOffer) setQuickCreateOpen(true)
}}
onApply={applyQuickCreateDraft}
focusAreas={focusAreas}
skillsCatalog={[]}
dialogTitle="Pfad-Lücke — KI-Entwurf bearbeiten"
hint="Texte anpassen, dann als Übung speichern und in den Pfad einfügen."
applyLabel={quickSaving ? 'Wird angelegt …' : 'Anlegen und in Pfad einfügen'}
applyDisabled={quickSaving}
zIndex={2100}
/>
</div>
)
}

View File

@ -0,0 +1,681 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
const VISIBILITY_OPTIONS = [
{ value: 'all', label: 'Alle Sichtbarkeiten' },
{ value: 'private', label: 'Privat' },
{ value: 'club', label: 'Verein' },
{ value: 'official', label: 'Offiziell' },
]
const VISIBILITY_LABEL = {
private: 'Privat',
club: 'Verein',
official: 'Offiziell',
}
const STATUS_LABELS = {
draft: 'Entwurf',
in_review: 'In Prüfung',
approved: 'Freigegeben',
archived: 'Archiviert',
active: 'Aktiv',
legacy_unreviewed: 'Rechte ungeprüft',
declared: 'Rechte erklärt',
blocked: 'Gesperrt',
}
const LIFECYCLE_LABELS = {
active: 'Aktiv',
trash_soft: 'Papierkorb (soft)',
trash_hidden: 'Papierkorb (hidden)',
}
function formatDate(value) {
if (!value) return '—'
try {
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return String(value)
}
}
function contentLink(item) {
const id = item.id
switch (item.content_type) {
case 'exercise':
return `/exercises/${id}`
case 'training_module':
return `/planning/training-modules/${id}`
case 'framework_program':
return `/planning/framework-programs/${id}`
case 'plan_template':
return `/planning/plan-templates/${id}`
case 'maturity_model':
return '/admin/maturity-models'
case 'media_asset':
return '/media'
default:
return null
}
}
function statusOptionsForType(meta, contentType) {
const t = meta?.content_types?.find((x) => x.id === contentType)
return (t?.status_values || []).map((v) => ({
value: v,
label: STATUS_LABELS[v] || v,
}))
}
function EditModal({ open, item, meta, onClose, onSaved }) {
const [status, setStatus] = useState('')
const [visibility, setVisibility] = useState('')
const [lifecycle, setLifecycle] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!item) return
setStatus(item.status || '')
setVisibility(item.visibility || '')
setLifecycle(item.extra_status || 'active')
setError('')
}, [item])
if (!open || !item) return null
const typeMeta = meta?.content_types?.find((x) => x.id === item.content_type)
const statusOpts = statusOptionsForType(meta, item.content_type)
const submit = async () => {
setSaving(true)
setError('')
try {
const body = {}
if (typeMeta?.has_status && status && status !== item.status) body.status = status
if (typeMeta?.has_visibility && visibility && visibility !== item.visibility) {
body.visibility = visibility
}
if (item.content_type === 'media_asset' && lifecycle && lifecycle !== item.extra_status) {
body.lifecycle_state = lifecycle
}
if (!Object.keys(body).length) {
onClose()
return
}
await api.patchAdminUserContentItem(item.content_type, item.id, body)
await onSaved()
onClose()
} catch (e) {
setError(e.message || String(e))
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" role="dialog" aria-modal="true">
<div className="card modal-card" style={{ maxWidth: 480, width: '100%' }}>
<h2 style={{ marginTop: 0 }}>Inhalt bearbeiten</h2>
<p className="text-muted" style={{ marginTop: 0 }}>
{item.type_label} · #{item.id}
</p>
<p style={{ fontWeight: 600 }}>{item.title || '—'}</p>
{typeMeta?.has_status ? (
<div className="form-row" style={{ marginTop: 16 }}>
<label className="form-label" htmlFor="uc-status">
Status
</label>
<select
id="uc-status"
className="form-input"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
{statusOpts.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
) : null}
{typeMeta?.has_visibility ? (
<div className="form-row">
<label className="form-label" htmlFor="uc-vis">
Sichtbarkeit
</label>
<select
id="uc-vis"
className="form-input"
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
>
{VISIBILITY_OPTIONS.filter((o) => o.value !== 'all').map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
) : null}
{item.content_type === 'media_asset' ? (
<div className="form-row">
<label className="form-label" htmlFor="uc-lc">
Lifecycle
</label>
<select
id="uc-lc"
className="form-input"
value={lifecycle}
onChange={(e) => setLifecycle(e.target.value)}
>
{Object.entries(LIFECYCLE_LABELS).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
</div>
) : null}
{error ? (
<p style={{ color: 'var(--danger)', marginTop: 12 }} role="alert">
{error}
</p>
) : null}
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
{saving ? 'Speichern…' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}
export default function AdminUserContentPage() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [meta, setMeta] = useState(null)
const [userSummary, setUserSummary] = useState([])
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [itemsLoading, setItemsLoading] = useState(false)
const [error, setError] = useState('')
const [profileId, setProfileId] = useState('')
const [contentType, setContentType] = useState('all')
const [visibility, setVisibility] = useState('all')
const [status, setStatus] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const limit = 50
const [editItem, setEditItem] = useState(null)
const contentTypeOptions = useMemo(() => {
const base = [{ value: 'all', label: 'Alle Typen' }]
for (const t of meta?.content_types || []) {
base.push({ value: t.id, label: t.label })
}
return base
}, [meta])
const statusFilterOptions = useMemo(() => {
if (contentType === 'all') {
return [
{ value: '', label: 'Beliebiger Status' },
{ value: 'draft', label: STATUS_LABELS.draft },
{ value: 'in_review', label: STATUS_LABELS.in_review },
{ value: 'approved', label: STATUS_LABELS.approved },
{ value: 'archived', label: STATUS_LABELS.archived },
{ value: 'active', label: STATUS_LABELS.active },
{ value: 'legacy_unreviewed', label: STATUS_LABELS.legacy_unreviewed },
]
}
return [
{ value: '', label: 'Beliebiger Status' },
...statusOptionsForType(meta, contentType),
]
}, [contentType, meta])
const loadSummary = useCallback(async () => {
const [m, s] = await Promise.all([api.getAdminUserContentMeta(), api.listAdminUserContentSummary()])
setMeta(m)
setUserSummary(Array.isArray(s) ? s : [])
}, [])
const loadItems = useCallback(async (forcedOffset) => {
setItemsLoading(true)
try {
const params = {
content_type: contentType,
visibility,
limit,
offset: forcedOffset ?? offset,
}
if (profileId) params.profile_id = Number(profileId)
if (status) params.status = status
if (search.trim()) params.search = search.trim()
const res = await api.listAdminUserContentItems(params)
setItems(Array.isArray(res?.items) ? res.items : [])
setTotal(Number(res?.total) || 0)
} finally {
setItemsLoading(false)
}
}, [contentType, visibility, status, search, profileId, offset])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
setLoading(true)
setError('')
try {
await loadSummary()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, loadSummary])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
try {
await loadItems()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, loadItems])
const applyFilters = () => {
setOffset(0)
loadItems(0)
}
const handleDelete = async (item) => {
const label = item.title || `${item.type_label} #${item.id}`
if (
!confirm(
`${label}" wirklich endgültig löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.`,
)
) {
return
}
try {
await api.deleteAdminUserContentItem(item.content_type, item.id)
await Promise.all([loadItems(), loadSummary()])
} catch (e) {
alert(e.message || String(e))
}
}
if (!isSuperadmin) return <Navigate to="/" replace />
return (
<div className="page" style={{ paddingBottom: 96 }}>
<AdminPageNav />
<header style={{ marginBottom: 20 }}>
<h1 style={{ margin: '0 0 8px' }}>Nutzer-Inhalte</h1>
<p className="text-muted" style={{ margin: 0 }}>
Aktivitäten aller Nutzer einsehen inklusive privater Inhalte. Status setzen oder Inhalte
löschen (nur Superadmin).
</p>
</header>
{error ? (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
<p style={{ margin: 0, color: 'var(--danger)' }}>{error}</p>
</div>
) : null}
{loading ? (
<div className="spinner" aria-label="Laden" />
) : (
<>
<section className="card" style={{ marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Aktivität je Nutzer</h2>
{userSummary.length === 0 ? (
<p className="text-muted" style={{ margin: 0 }}>
Noch keine nutzerangelegten Inhalte.
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%', minWidth: 640 }}>
<thead>
<tr>
<th>Nutzer</th>
<th>Gesamt</th>
{(meta?.content_types || []).map((t) => (
<th key={t.id}>{t.label}</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{userSummary.map((u) => (
<tr key={u.id}>
<td>
<div style={{ fontWeight: 600 }}>{u.name || `Profil #${u.id}`}</div>
<div className="text-muted" style={{ fontSize: '0.85rem' }}>
{u.email || '—'}
</div>
</td>
<td>
<strong>{u.total_count}</strong>
</td>
{(meta?.content_types || []).map((t) => (
<td key={t.id}>{u.counts_by_type?.[t.id] ?? 0}</td>
))}
<td>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.85rem', padding: '4px 10px' }}
onClick={() => {
setProfileId(String(u.id))
setOffset(0)
}}
>
Filtern
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="card">
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Inhalte</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
gap: 12,
marginBottom: 16,
}}
>
<div>
<label className="form-label" htmlFor="uc-user">
Nutzer
</label>
<select
id="uc-user"
className="form-input"
value={profileId}
onChange={(e) => {
setProfileId(e.target.value)
setOffset(0)
}}
>
<option value="">Alle Nutzer</option>
{userSummary.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email || `#${u.id}`}
</option>
))}
</select>
</div>
<div>
<label className="form-label" htmlFor="uc-type">
Typ
</label>
<select
id="uc-type"
className="form-input"
value={contentType}
onChange={(e) => {
setContentType(e.target.value)
setStatus('')
setOffset(0)
}}
>
{contentTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="form-label" htmlFor="uc-vis-filter">
Sichtbarkeit
</label>
<select
id="uc-vis-filter"
className="form-input"
value={visibility}
onChange={(e) => {
setVisibility(e.target.value)
setOffset(0)
}}
>
{VISIBILITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="form-label" htmlFor="uc-status-filter">
Status
</label>
<select
id="uc-status-filter"
className="form-input"
value={status}
onChange={(e) => {
setStatus(e.target.value)
setOffset(0)
}}
>
{statusFilterOptions.map((o) => (
<option key={o.value || '_any'} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div style={{ gridColumn: 'span 2' }}>
<label className="form-label" htmlFor="uc-search">
Suche (Titel)
</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
id="uc-search"
className="form-input"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
placeholder="Titel oder Dateiname…"
/>
<button type="button" className="btn btn-primary" onClick={applyFilters}>
Suchen
</button>
</div>
</div>
</div>
{itemsLoading ? (
<div className="spinner" aria-label="Inhalte laden" />
) : items.length === 0 ? (
<p className="text-muted">Keine Inhalte für die aktuellen Filter.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%', minWidth: 900 }}>
<thead>
<tr>
<th>Typ</th>
<th>Titel</th>
<th>Nutzer</th>
<th>Sichtbarkeit</th>
<th>Status</th>
<th>Aktualisiert</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const href = contentLink(item)
return (
<tr key={`${item.content_type}-${item.id}`}>
<td>{item.type_label}</td>
<td>
<div style={{ fontWeight: 600, maxWidth: 280 }}>
{href ? (
<Link to={href}>{item.title || '—'}</Link>
) : (
item.title || '—'
)}
</div>
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
#{item.id}
{item.club_name ? ` · ${item.club_name}` : ''}
</div>
</td>
<td>
<div>{item.profile_name || '—'}</div>
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
{item.profile_email || (item.profile_id ? `#${item.profile_id}` : '—')}
</div>
</td>
<td>
{item.visibility ? (
<span
style={{
color:
item.visibility === 'private' ? 'var(--text2)' : 'var(--accent)',
}}
>
{VISIBILITY_LABEL[item.visibility] || item.visibility}
</span>
) : (
'—'
)}
</td>
<td>
{item.status ? STATUS_LABELS[item.status] || item.status : '—'}
{item.extra_status && item.extra_status !== 'active' ? (
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
{LIFECYCLE_LABELS[item.extra_status] || item.extra_status}
</div>
) : null}
</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(item.updated_at)}</td>
<td>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.8rem', padding: '4px 8px' }}
onClick={() => setEditItem(item)}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-secondary"
style={{
fontSize: '0.8rem',
padding: '4px 8px',
color: 'var(--danger)',
}}
onClick={() => handleDelete(item)}
>
Löschen
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{total > limit ? (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
}}
>
<span className="text-muted" style={{ fontSize: '0.9rem' }}>
{total} Einträge · Seite {Math.floor(offset / limit) + 1} von{' '}
{Math.ceil(total / limit)}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-secondary"
disabled={offset <= 0}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Zurück
</button>
<button
type="button"
className="btn btn-secondary"
disabled={offset + limit >= total}
onClick={() => setOffset(offset + limit)}
>
Weiter
</button>
</div>
</div>
) : null}
</section>
</>
)}
<EditModal
open={!!editItem}
item={editItem}
meta={meta}
onClose={() => setEditItem(null)}
onSaved={async () => {
await Promise.all([loadItems(), loadSummary()])
}}
/>
</div>
)
}

View File

@ -50,6 +50,39 @@ export async function listAdminUsers() {
return request('/api/admin/users')
}
/** Superadmin: Metadaten zu nutzerangelegten Inhaltstypen. */
export async function getAdminUserContentMeta() {
return request('/api/admin/user-content/meta')
}
/** Superadmin: Aktivitätsübersicht je Nutzer (Anzahl Inhalte). */
export async function listAdminUserContentSummary() {
return request('/api/admin/user-content/users-summary')
}
/** Superadmin: Inhalte aller Nutzer (inkl. privat) — filterbar. */
export async function listAdminUserContentItems(params = {}) {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
}
const q = qs.toString()
return request(`/api/admin/user-content/items${q ? `?${q}` : ''}`)
}
/** Superadmin: Status/Sichtbarkeit eines Inhalts setzen. */
export async function patchAdminUserContentItem(contentType, itemId, body) {
return request(`/api/admin/user-content/items/${contentType}/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
/** Superadmin: Inhalt löschen. */
export async function deleteAdminUserContentItem(contentType, itemId) {
return request(`/api/admin/user-content/items/${contentType}/${itemId}`, { method: 'DELETE' })
}
/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */
export async function getPlatformMediaStorage() {
return request('/api/admin/platform-media-storage')
@ -819,6 +852,11 @@ export const api = {
getCurrentProfile,
listProfiles,
listAdminUsers,
getAdminUserContentMeta,
listAdminUserContentSummary,
listAdminUserContentItems,
patchAdminUserContentItem,
deleteAdminUserContentItem,
updateProfile,
managementPasswordReset,
changePassword,