"""Alph-Feedback-Modul โ€” schwebende PummelHummel sammelt strukturiertes Schueler-Feedback und liefert Claude-Code-freundliche Exports. Schnittstellen-Dokumentation: siehe README.md im selben Verzeichnis. Einbindung in eine Flask-App: from feedback_modul import init_feedback_modul init_feedback_modul( app, get_db=get_db, # Funktion: () -> sqlite3.Connection app_name="arduino-werkstatt", # Identifikator dieser App ist_admin=lambda: session.get("admin"), user_kontext=lambda: {...}, # Optional, wird zu jedem Feedback gespeichert widget_aktiv=lambda: True, # Optional, kann pro Request entscheiden ) Im Layout-Template: {% if alph_widget_aktiv %} {{ alph_widget_snippet|safe }} {% endif %} Die ganze JS+CSS+SVG ist in static/alph.js self-contained โ€” andere Sites binden einfach ein. """ import hashlib import json import time from datetime import datetime from collections import defaultdict, OrderedDict from flask import (Blueprint, request, jsonify, render_template, abort, redirect, url_for, Response, current_app) from markupsafe import escape as _html_escape from .schema import DDL, KATEGORIEN # Konfiguration wird beim init_feedback_modul gesetzt: _config = { "get_db": None, "app_name": "unknown", "ist_admin": lambda: False, "user_kontext": lambda: None, "widget_aktiv": lambda: True, # Optional: Callable () -> Response, wenn Admin-Routen ohne Login # aufgerufen werden. Default: 403. Arduino-App nutzt z.B. einen # Redirect zur /admin/login. "admin_unauthorized": None, # Optional: Name eines Parent-Templates fuer admin_liste.html. # Wenn gesetzt, macht das Template {% extends parent_template %}. # Default: None -> standalone HTML mit eigenem minimalen CSS. "parent_template": None, } # Rate-Limit pro IP: max N pro Minute _rate_buckets = {} # ip -> list[timestamp] _RATE_LIMIT_PER_MIN = 10 def _rate_limited(ip): now = time.time() bucket = _rate_buckets.setdefault(ip, []) bucket[:] = [t for t in bucket if now - t < 60] if len(bucket) >= _RATE_LIMIT_PER_MIN: return True bucket.append(now) return False def _cluster_hash(url_pfad, kategorie, text): """Hash fuer Duplikat-Erkennung. Erste 30 Zeichen normalisierten Texts.""" norm = (text or "").lower().strip()[:30] raw = f"{url_pfad}|{kategorie}|{norm}" return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] # Blueprint bp = Blueprint( "feedback_modul", __name__, static_folder="static", static_url_path="/static/feedback", template_folder="templates", ) # === API: Feedback empfangen ========================================= @bp.route("/api/feedback", methods=["POST"]) def api_feedback_post(): """Nimmt Alph-Feedback entgegen. Erwartet JSON: { "kategorie": "code"|"link"|"verstehen"|"fehlt"|"sonstiges", "text": "...", "url_pfad": "/lektion/2", "seitentitel": "Lektion 2: Ampel", "element_selector": "main > .card:nth-child(3) pre" (optional), "element_text": "digitalWrite(LED, HIGH);" (optional), "viewport": "375x800" (optional), "sprache": "de" (optional) } user_kontext (Tier/Kasten) wird vom Server gesetzt aus user_kontext-Callback. """ if _rate_limited(request.remote_addr or "unknown"): return jsonify({"ok": False, "error": "rate_limit"}), 429 data = request.get_json(silent=True) or {} kategorie = (data.get("kategorie") or "").strip().lower() text = (data.get("text") or "").strip() url_pfad = (data.get("url_pfad") or "").strip()[:500] valid_kats = {k[0] for k in KATEGORIEN} if kategorie not in valid_kats: return jsonify({"ok": False, "error": "kategorie_ungueltig"}), 400 if len(text) < 3: return jsonify({"ok": False, "error": "text_zu_kurz"}), 400 if len(text) > 2000: text = text[:2000] if not url_pfad: return jsonify({"ok": False, "error": "url_pfad_fehlt"}), 400 seitentitel = (data.get("seitentitel") or "")[:200] element_selector = (data.get("element_selector") or "")[:300] element_text = (data.get("element_text") or "")[:500] # Server-Kontext hat Vorrang. Im Drop-In-Modus (kein Server-Callback) # darf der Client einen user_kontext mitschicken (data-user am Script-Tag). user_kontext = _config["user_kontext"]() or {} if not user_kontext: client_user = data.get("user_kontext") if isinstance(client_user, dict): # Auf simple Werte beschneiden, kein verschachteltes JSON erlauben user_kontext = { str(k)[:40]: (str(v)[:120] if not isinstance(v, (int, float, bool)) else v) for k, v in list(client_user.items())[:12] } technik_kontext = { "viewport": (data.get("viewport") or "")[:20], "sprache": (data.get("sprache") or "")[:10], "ua": (request.headers.get("User-Agent") or "")[:300], "referer": (request.headers.get("Referer") or "")[:500], } cluster = _cluster_hash(url_pfad, kategorie, text) conn = _config["get_db"]() conn.execute( """INSERT INTO feedback (app_name, kategorie, text, url_pfad, seitentitel, user_kontext, technik_kontext, element_selector, element_text, cluster_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (_config["app_name"], kategorie, text, url_pfad, seitentitel, json.dumps(user_kontext, ensure_ascii=False), json.dumps(technik_kontext, ensure_ascii=False), element_selector, element_text, cluster), ) conn.commit() conn.close() return jsonify({"ok": True}) # === Admin-Views ===================================================== def _admin_required(): """Prueft Admin-Status. Liefert ggf. eine Response (Redirect), sonst None. Aufrufer muessen das Ergebnis weiterreichen.""" if _config["ist_admin"](): return None handler = _config.get("admin_unauthorized") if callable(handler): return handler() abort(403) @bp.route("/admin/feedback") def admin_feedback_liste(): _resp = _admin_required() if _resp is not None: return _resp status_filter = request.args.get("status", "offen") kategorie_filter = request.args.get("kategorie", "") where = ["app_name = ?"] params = [_config["app_name"]] if status_filter == "offen": where.append("status NOT IN ('erledigt', 'wontfix', 'spam')") elif status_filter and status_filter != "alle": where.append("status = ?") params.append(status_filter) if kategorie_filter: where.append("kategorie = ?") params.append(kategorie_filter) sql = ( "SELECT * FROM feedback WHERE " + " AND ".join(where) + " ORDER BY status='neu' DESC, erstellt_am DESC" ) conn = _config["get_db"]() rows = conn.execute(sql, params).fetchall() eintraege = [dict(r) for r in rows] for e in eintraege: try: e["user"] = json.loads(e["user_kontext"] or "{}") except Exception: e["user"] = {} try: e["technik"] = json.loads(e["technik_kontext"] or "{}") except Exception: e["technik"] = {} # Statistik stats = OrderedDict() for status in ["neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"]: stats[status] = conn.execute( "SELECT COUNT(*) FROM feedback WHERE app_name=? AND status=?", (_config["app_name"], status), ).fetchone()[0] conn.close() return render_template( "feedback_modul/admin_liste.html", eintraege=eintraege, kategorien=KATEGORIEN, stats=stats, status_filter=status_filter, kategorie_filter=kategorie_filter, parent_template=_config.get("parent_template"), app_name=_config["app_name"], ) @bp.route("/admin/feedback//status", methods=["POST"]) def admin_feedback_status(feedback_id): _resp = _admin_required() if _resp is not None: return _resp neuer_status = request.form.get("status", "") notiz = request.form.get("notiz", "") erlaubt = {"neu", "triagiert", "in_arbeit", "erledigt", "wontfix", "spam"} if neuer_status not in erlaubt: abort(400) conn = _config["get_db"]() conn.execute( """UPDATE feedback SET status = ?, bearbeitet_notiz = ?, aktualisiert_am = CURRENT_TIMESTAMP WHERE id = ?""", (neuer_status, notiz or None, feedback_id), ) conn.commit() conn.close() return redirect(request.referrer or url_for("feedback_modul.admin_feedback_liste")) # === Exports ========================================================= def _hole_offene_feedbacks(): conn = _config["get_db"]() rows = conn.execute( """SELECT * FROM feedback WHERE app_name = ? AND status NOT IN ('erledigt', 'wontfix', 'spam') ORDER BY url_pfad, kategorie, erstellt_am""", (_config["app_name"],), ).fetchall() conn.close() return [dict(r) for r in rows] @bp.route("/admin/feedback/export.json") def admin_feedback_export_json(): _resp = _admin_required() if _resp is not None: return _resp eintraege = _hole_offene_feedbacks() for e in eintraege: try: e["user_kontext"] = json.loads(e["user_kontext"] or "{}") except Exception: pass try: e["technik_kontext"] = json.loads(e["technik_kontext"] or "{}") except Exception: pass return jsonify({ "app": _config["app_name"], "exportiert_am": datetime.utcnow().isoformat() + "Z", "anzahl": len(eintraege), "feedbacks": eintraege, }) @bp.route("/admin/feedback/export.md") def admin_feedback_export_md(): _resp = _admin_required() if _resp is not None: return _resp eintraege = _hole_offene_feedbacks() cluster = defaultdict(list) for e in eintraege: cluster[(e["url_pfad"], e["kategorie"], e["cluster_hash"])].append(e) kat_label = {k[0]: k[1] for k in KATEGORIEN} kat_emoji = {"code": "๐Ÿ›", "link": "๐Ÿ”—", "verstehen": "โ“", "fehlt": "โŒ", "sonstiges": "๐Ÿ’ฌ"} nach_kategorie = defaultdict(list) for key, eintrags in cluster.items(): nach_kategorie[key[1]].append((key, eintrags)) out = [] out.append(f"# Alph-Feedback {_config['app_name']} โ€” Stand {datetime.now().strftime('%Y-%m-%d %H:%M')}") out.append("") out.append(f"**{len(eintraege)} offene Feedbacks** in {len(cluster)} Clustern.") out.append("") for kat in ["code", "link", "verstehen", "fehlt", "sonstiges"]: clusterliste = nach_kategorie.get(kat, []) if not clusterliste: continue emoji = kat_emoji.get(kat, "โ€ข") out.append(f"## {emoji} {kat_label.get(kat, kat)} ({sum(len(c[1]) for c in clusterliste)})") out.append("") for key, eintrags in clusterliste: url, _, _ = key n = len(eintrags) mehrfach = f" โ€” **{n}ร— gemeldet**" if n > 1 else "" out.append(f"### `{url}`{mehrfach}") out.append("") for e in eintrags: user = {} try: user = json.loads(e["user_kontext"] or "{}") except Exception: pass tech = {} try: tech = json.loads(e["technik_kontext"] or "{}") except Exception: pass team = user.get("tier", "?") kasten = user.get("kasten", "?") rolle = user.get("rolle", "") rolle_str = f" ({rolle})" if rolle else "" viewport = tech.get("viewport", "") out.append(f"- **{team} / Kasten {kasten}**{rolle_str} โ€” *\"{e['text']}\"*") meta = [e["erstellt_am"]] if viewport: meta.append(f"viewport {viewport}") if e.get("element_selector"): meta.append(f"@ `{e['element_selector']}`") out.append(f" {' ยท '.join(meta)}") if e.get("element_text"): out.append(f" Element-Text: `{e['element_text'][:120]}`") if e.get("seitentitel"): out.append(f" Seitentitel: {e['seitentitel']}") out.append("") return Response("\n".join(out), mimetype="text/markdown; charset=utf-8") @bp.route("/admin/feedback/claude-prompt.md") def admin_feedback_claude_prompt(): """Direkt-an-Claude-Code-Brief: Anweisung + strukturierte Feedbacks.""" _resp = _admin_required() if _resp is not None: return _resp eintraege = _hole_offene_feedbacks() # Wir rufen den Markdown-Builder inline auf โ€” gleiche Request-Lifecycle md_response = admin_feedback_export_md() md_body = md_response.get_data(as_text=True) prompt = [] prompt.append(f"# Aufgabe fuer Claude Code: Alphatest-Feedback auswerten") prompt.append("") prompt.append(f"Du arbeitest im Repo der App **{_config['app_name']}**. Unten " f"findest du {len(eintraege)} offene Feedbacks von Schuelern aus " f"dem Alphatest, gesammelt vom Alph-PummelHummel-Widget.") prompt.append("") prompt.append("## Bitte arbeite das so ab:") prompt.append("") prompt.append("1. **Cluster lesen.** Mehrfach-Reports am gleichen Pfad sind Prioritaet.") prompt.append("2. **Quelldateien anschauen.** URL `/lektion/2` -> `templates/lektion_detail.html` mit Lektion-Inhalt aus DB. URL `/bauteile` -> `templates/bauteile.html`. Etc.") prompt.append("3. **Symptom vs. Ursache.** Schueler beschreiben oft nur das, was sie sehen. Identifiziere die Ursache im Code, nicht nur den Schein.") prompt.append("4. **Pro Cluster ein Vorschlag:** Datei + Zeile + konkrete Aenderung. Bei Inhalts-Texten: konkreter Re-Wording-Vorschlag.") prompt.append("5. **Unklare Reports markieren.** Lieber zum Nachfragen flaggen als raten.") prompt.append("6. **Spam erkennen.** Quatsch-Reports markiere mit STATUS:spam fuer den Lehrer zum Wegklicken.") prompt.append("") prompt.append("## Antwortformat:") prompt.append("") prompt.append("```") prompt.append("[ID|Cluster] Kategorie ยท Pfad") prompt.append(" Diagnose: ...") prompt.append(" Fix: Datei foo.html Zeile 42 โ€” alt: '...' neu: '...'") prompt.append(" Status-Vorschlag: in_arbeit | wontfix | spam | nachfragen") prompt.append("```") prompt.append("") prompt.append("---") prompt.append("") prompt.append(md_body) return Response("\n".join(prompt), mimetype="text/markdown; charset=utf-8") # === Widget-Snippet fuer base.html =================================== WIDGET_SNIPPET = """\ """ def render_widget_snippet(): """Liefert den HTML-Snippet, der ins Layout-Template eingebunden wird. Wird als Context-Processor jedem Template als alph_widget_snippet zur Verfuegung gestellt. """ if not _config["widget_aktiv"](): return "" user = _config["user_kontext"]() or {} if not user: # Widget nur zeigen wenn ein User-Kontext da ist (eingeloggt oder Admin) return "" user_json_raw = json.dumps(user, ensure_ascii=False) # Volle HTML-Attribut-Escape-Behandlung (auch <, >, &, ') user_json = str(_html_escape(user_json_raw)) app_name = str(_html_escape(_config["app_name"])) static_url = url_for("feedback_modul.static", filename="alph.js") endpoint = url_for("feedback_modul.api_feedback_post") return WIDGET_SNIPPET.format( static_url=static_url, endpoint=endpoint, app_name=app_name, user_json=user_json, ) # === Init ============================================================ def init_feedback_modul(app, *, get_db, app_name, ist_admin=None, user_kontext=None, widget_aktiv=None, admin_unauthorized=None, parent_template=None): """Registriert das Modul in einer Flask-App. Args: app: Flask-Instanz get_db: Callable () -> sqlite3.Connection (mit row_factory!) app_name: Identifier (z.B. "arduino-werkstatt") ist_admin: Callable () -> bool (default: False) user_kontext: Callable () -> dict | None Beispiel: {"tier": "otter", "kasten": 7, "kurs": "2026-T1", "rolle": "schueler"} Widget wird nur angezeigt, wenn dies einen Wert liefert. widget_aktiv: Callable () -> bool (default: True) admin_unauthorized: Callable () -> Response (default: 403) Wird gerufen, wenn ein Admin-Endpoint ohne Login getroffen wird. Typisch: redirect zu /admin/login der Host-App. parent_template: Optional[str] (default: None) Wenn gesetzt, macht das Admin-Template `{% extends parent_template %}`. Das Parent-Template muss einen `{% block content %}` definieren. Sonst standalone. """ _config["get_db"] = get_db _config["app_name"] = app_name if ist_admin is not None: _config["ist_admin"] = ist_admin if user_kontext is not None: _config["user_kontext"] = user_kontext if widget_aktiv is not None: _config["widget_aktiv"] = widget_aktiv if admin_unauthorized is not None: _config["admin_unauthorized"] = admin_unauthorized if parent_template is not None: _config["parent_template"] = parent_template # Schema sicherstellen (idempotent) conn = get_db() conn.executescript(DDL) conn.commit() conn.close() app.register_blueprint(bp) # Context-Processor: alph_widget_snippet im Template verfuegbar machen @app.context_processor def _alph_ctx(): return {"alph_widget_snippet": render_widget_snippet()}