diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
index 8c42a97..5f75854 100644
--- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
+++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
@@ -38,12 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
| 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).
---
diff --git a/backend/main.py b/backend/main.py
index 3fe7898..93b1e36 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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)
diff --git a/backend/migrations/077_ai_prompt_planning_path_qa_gap_fill.sql b/backend/migrations/077_ai_prompt_planning_path_qa_gap_fill.sql
new file mode 100644
index 0000000..a6d23a4
--- /dev/null
+++ b/backend/migrations/077_ai_prompt_planning_path_qa_gap_fill.sql
@@ -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';
diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py
index 32775fd..a1bd294 100644
--- a/backend/planning_exercise_path_ai_fill.py
+++ b/backend/planning_exercise_path_ai_fill.py
@@ -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",
]
diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py
index bbb6cb6..42455c1 100644
--- a/backend/planning_exercise_path_builder.py
+++ b/backend/planning_exercise_path_builder.py
@@ -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),
}
diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py
index 8df530f..8ea7c8f 100644
--- a/backend/planning_exercise_path_qa.py
+++ b/backend/planning_exercise_path_qa.py
@@ -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",
]
diff --git a/backend/routers/admin_user_content.py b/backend/routers/admin_user_content.py
new file mode 100644
index 0000000..4e7f51b
--- /dev/null
+++ b/backend/routers/admin_user_content.py
@@ -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}
diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py
index 356dd58..7edd264 100644
--- a/backend/scripts/check_access_layer_hints.py
+++ b/backend/scripts/check_access_layer_hints.py
@@ -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",
diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py
new file mode 100644
index 0000000..a94a4f8
--- /dev/null
+++ b/backend/tests/test_planning_exercise_path_ai_fill.py
@@ -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
diff --git a/backend/version.py b/backend/version.py
index 3a5b275..08a140f 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -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",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 666aa1a..ba38f7f 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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([
),
},
+ {
+ path: 'admin/user-content',
+ element: (
+
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.
- {pathQa.ai_proposal_count} KI-Neuanlage-Vorschlag/Vorschläge — vor dem Speichern als Übung anlegen. + {Number(pathQa.off_topic_count) > 0 ? ( +
+ {pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — siehe Lücken-Angebote unten.
) : null} {pathQa.reorder_applied ? ( @@ -276,12 +480,64 @@ export default function ExerciseProgressionPathBuilder({ : ''} ) : null} - {Array.isArray(targetSummary?.top_skills) && - targetSummary.top_skills.slice(0, 2).map((sk) => ( - - {sk.name} - ++ Die QS hat Lücken erkannt. Vorschlag prüfen, als Übung anlegen und in den Pfad einfügen. +
+{offer.rationale}
+ ) : null} + {offer.from_title && offer.to_title ? ( ++ Zwischen „{offer.from_title}“ und „{offer.to_title}“ + {offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''} +
+ ) : null} ++ {item.type_label} · #{item.id} +
+{item.title || '—'}
+ + {typeMeta?.has_status ? ( ++ {error} +
+ ) : null} + ++ Aktivitäten aller Nutzer einsehen — inklusive privater Inhalte. Status setzen oder Inhalte + löschen (nur Superadmin). +
+{error}
++ Noch keine nutzerangelegten Inhalte. +
+ ) : ( +| Nutzer | +Gesamt | + {(meta?.content_types || []).map((t) => ( +{t.label} | + ))} ++ |
|---|---|---|---|
|
+ {u.name || `Profil #${u.id}`}
+
+ {u.email || '—'}
+
+ |
+ + {u.total_count} + | + {(meta?.content_types || []).map((t) => ( +{u.counts_by_type?.[t.id] ?? 0} | + ))} ++ + | +
Keine Inhalte für die aktuellen Filter.
+ ) : ( +| Typ | +Titel | +Nutzer | +Sichtbarkeit | +Status | +Aktualisiert | ++ |
|---|---|---|---|---|---|---|
| {item.type_label} | +
+
+ {href ? (
+ {item.title || '—'}
+ ) : (
+ item.title || '—'
+ )}
+
+
+ #{item.id}
+ {item.club_name ? ` · ${item.club_name}` : ''}
+
+ |
+
+ {item.profile_name || '—'}
+
+ {item.profile_email || (item.profile_id ? `#${item.profile_id}` : '—')}
+
+ |
+ + {item.visibility ? ( + + {VISIBILITY_LABEL[item.visibility] || item.visibility} + + ) : ( + '—' + )} + | +
+ {item.status ? STATUS_LABELS[item.status] || item.status : '—'}
+ {item.extra_status && item.extra_status !== 'active' ? (
+
+ {LIFECYCLE_LABELS[item.extra_status] || item.extra_status}
+
+ ) : null}
+ |
+ {formatDate(item.updated_at)} | +
+
+
+
+
+ |
+