From 3450a9296a165e724e0e3ccc1819111b720ab739 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 23 May 2026 12:59:46 +0200 Subject: [PATCH 1/2] Enhance Planning Exercise Path AI and UI Integration - Updated the AI gap filling logic to include structured offers for unfilled gaps, improving the user experience in the Exercise Progression Path Builder. - Introduced new functions for detecting off-topic steps and parsing LLM-suggested exercises, enhancing the contextual relevance of exercise suggestions. - Enhanced the frontend components to support new AI proposal features, including quick creation modals for newly suggested exercises. - Incremented version to 0.8.190 and updated changelog to reflect these improvements in planning AI functionality. --- ...77_ai_prompt_planning_path_qa_gap_fill.sql | 85 +++++ backend/planning_exercise_path_ai_fill.py | 314 ++++++++++++++--- backend/planning_exercise_path_builder.py | 47 ++- backend/planning_exercise_path_qa.py | 145 +++++++- .../test_planning_exercise_path_ai_fill.py | 64 ++++ backend/version.py | 15 +- .../ExerciseProgressionPathBuilder.jsx | 333 +++++++++++++++++- 7 files changed, 915 insertions(+), 88 deletions(-) create mode 100644 backend/migrations/077_ai_prompt_planning_path_qa_gap_fill.sql create mode 100644 backend/tests/test_planning_exercise_path_ai_fill.py 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/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/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 37f11e1..3dc6833 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -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({

KI: Pfad zum Ziel

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.

@@ -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} />
@@ -263,9 +467,9 @@ export default function ExerciseProgressionPathBuilder({ {pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.

) : null} - {Number(pathQa.ai_proposal_count) > 0 ? ( -

- {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} - +
+ ) : null} + + {gapFillOffers.length > 0 ? ( +
+ Fehlende Schritte — mit KI anlegen +

+ Die QS hat Lücken erkannt. Vorschlag prüfen, als Übung anlegen und in den Pfad einfügen. +

+
+ {gapFillOffers.map((offer) => ( +
+
+
+ + {OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'} + {offer.phase ? ` · ${offer.phase}` : ''} + +
{offer.title_hint}
+ {offer.rationale ? ( +

{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} +
+ +
+
))} +
) : null} @@ -290,7 +546,7 @@ export default function ExerciseProgressionPathBuilder({
{pathSteps.map((step, idx) => (
@@ -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({
) : null} + + 0} + busy={quickSaving} + error={quickAiError} + onRunAi={runQuickCreateAiSuggest} + /> + + { + 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} + />
) } -- 2.43.0 From bd5a409fa7f0b7aba1db6d2939df6ecf0f891dab Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 6 Jun 2026 17:53:25 +0200 Subject: [PATCH 2/2] Add Admin User Content Management Features - Introduced a new admin user content management endpoint for superadmins, allowing for moderation of user-generated content. - Updated the backend to include new API functions for retrieving, patching, and deleting user content items. - Enhanced the frontend with a new Admin User Content page and navigation link for easy access to user content management. - Updated access layer documentation to reflect the new endpoint and its exempt status. - Incremented version to 0.8.191 and updated changelog to document these additions in admin functionality. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 3 +- backend/main.py | 3 +- backend/routers/admin_user_content.py | 519 +++++++++++++ backend/scripts/check_access_layer_hints.py | 1 + frontend/src/App.jsx | 9 + frontend/src/components/AdminPageNav.jsx | 3 +- frontend/src/pages/AdminUserContentPage.jsx | 681 ++++++++++++++++++ frontend/src/utils/api.js | 38 + 8 files changed, 1254 insertions(+), 3 deletions(-) create mode 100644 backend/routers/admin_user_content.py create mode 100644 frontend/src/pages/AdminUserContentPage.jsx 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/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/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: ( + + + + ), + }, { path: 'trainer-contexts', element: }, ], }, diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index a189b4b..f2afc9c 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -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 }, diff --git a/frontend/src/pages/AdminUserContentPage.jsx b/frontend/src/pages/AdminUserContentPage.jsx new file mode 100644 index 0000000..b740f85 --- /dev/null +++ b/frontend/src/pages/AdminUserContentPage.jsx @@ -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 ( +
+
+

Inhalt bearbeiten

+

+ {item.type_label} · #{item.id} +

+

{item.title || '—'}

+ + {typeMeta?.has_status ? ( +
+ + +
+ ) : null} + + {typeMeta?.has_visibility ? ( +
+ + +
+ ) : null} + + {item.content_type === 'media_asset' ? ( +
+ + +
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ) +} + +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 + + return ( +
+ +
+

Nutzer-Inhalte

+

+ Aktivitäten aller Nutzer einsehen — inklusive privater Inhalte. Status setzen oder Inhalte + löschen (nur Superadmin). +

+
+ + {error ? ( +
+

{error}

+
+ ) : null} + + {loading ? ( +
+ ) : ( + <> +
+

Aktivität je Nutzer

+ {userSummary.length === 0 ? ( +

+ Noch keine nutzerangelegten Inhalte. +

+ ) : ( +
+ + + + + + {(meta?.content_types || []).map((t) => ( + + ))} + + + + + {userSummary.map((u) => ( + + + + {(meta?.content_types || []).map((t) => ( + + ))} + + + ))} + +
NutzerGesamt{t.label}
+
{u.name || `Profil #${u.id}`}
+
+ {u.email || '—'} +
+
+ {u.total_count} + {u.counts_by_type?.[t.id] ?? 0} + +
+
+ )} +
+ +
+

Inhalte

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && applyFilters()} + placeholder="Titel oder Dateiname…" + /> + +
+
+
+ + {itemsLoading ? ( +
+ ) : items.length === 0 ? ( +

Keine Inhalte für die aktuellen Filter.

+ ) : ( +
+ + + + + + + + + + + + + + {items.map((item) => { + const href = contentLink(item) + return ( + + + + + + + + + + ) + })} + +
TypTitelNutzerSichtbarkeitStatusAktualisiert
{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)} +
+ + +
+
+
+ )} + + {total > limit ? ( +
+ + {total} Einträge · Seite {Math.floor(offset / limit) + 1} von{' '} + {Math.ceil(total / limit)} + +
+ + +
+
+ ) : null} +
+ + )} + + setEditItem(null)} + onSaved={async () => { + await Promise.all([loadItems(), loadSummary()]) + }} + /> +
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 29c2745..57ed2bb 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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, -- 2.43.0