Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import hashlib | |
| import random | |
| import re | |
| import html | |
| from datetime import datetime | |
| import gradio as gr | |
| print(gr.__version__) | |
| from scenes import SCENES | |
| #from collect_feedback import collect_feedback, push_csv_to_space | |
| #try: | |
| # df = collect_feedback() # pulls from dataset and writes all_logs.csv locally | |
| # if df is not None: | |
| # push_csv_to_space() # commits it into the Space repo so it appears next to app.py | |
| # else: | |
| # print("ℹ️ collect_feedback returned no data; skipping push.") | |
| #except Exception as e: | |
| # print("collect_feedback/push failed on startup:", e) | |
| #try: | |
| # collect_feedback() | |
| # print("collect_feedback ran on startup.") | |
| #except Exception as e: | |
| # print("collect_feedback failed on startup:", e) | |
| # ====== Settings ====== | |
| DATASET_REPO_ID = os.environ.get("HF_DATASET_REPO", "ai-tomoni/julia-dialog-logs") | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| # ====== HF Hub Imports (robust) ====== | |
| from huggingface_hub import create_commit, CommitOperationAdd, HfApi | |
| try: | |
| from huggingface_hub.errors import HfHubHTTPError | |
| except Exception: | |
| try: | |
| from huggingface_hub.utils._errors import HfHubHTTPError | |
| except Exception: | |
| HfHubHTTPError = Exception | |
| api = HfApi() | |
| LETTERS = list("ABCDEFGHI") | |
| # ====== Lernziele ====== | |
| GOAL_LABELS = { | |
| "L1": "Gefühle spiegeln statt bewerten", | |
| "L2": "Öffnende Fragen statt Lösungen", | |
| "L3": "Validieren ohne zu interpretieren", | |
| "L4": "Professionelle Begrenzung statt Retterrolle", | |
| "L5": "Krisen ernst nehmen ohne Schuld oder Verharmlosung", | |
| } | |
| GOAL_HINTS = { | |
| "L1": ["→ „Das klingt nach starkem Druck“"], | |
| "L2": ["→ „Was macht das für dich so schwer?“"], | |
| "L3": ["→ „Es klingt, als wäre es schwer auszuhalten“"], | |
| "L4": ["→ „Du kannst hier kurz erzählen, was dich bewegt“"], | |
| "L5": ["→ „Das macht mir Sorgen. Lass uns überlegen, was dir Halt gibt“"], | |
| } | |
| GOAL_ORDER = ["L1", "L2", "L3", "L4", "L5"] | |
| # ====== Logging ====== | |
| def normalize_entry(e: dict) -> dict: | |
| out = dict(e) | |
| out.setdefault("type", None) | |
| goals = out.pop("goals", {}) or {} | |
| for k in GOAL_ORDER: | |
| try: | |
| v = int(goals.get(k, 0)) | |
| except Exception: | |
| v = 0 | |
| out[f"goal_{k}"] = v | |
| return out | |
| def commit_log(entries: list): | |
| if not entries: | |
| return | |
| normed = [normalize_entry(e) for e in entries] | |
| content = "".join(json.dumps(e, ensure_ascii=False) + "\n" for e in normed).encode("utf-8") | |
| if not HF_TOKEN: | |
| os.makedirs("flat_logs", exist_ok=True) | |
| with open("flat_logs/local_fallback.ndjson", "ab") as f: | |
| f.write(content) | |
| return | |
| day = datetime.utcnow().strftime("%Y-%m-%d") | |
| suffix = hashlib.sha256(content).hexdigest()[:8] | |
| path = f"flat_logs/{day}/{datetime.utcnow().isoformat()}Z_{suffix}.ndjson" | |
| try: | |
| create_commit( | |
| repo_id=DATASET_REPO_ID, repo_type="dataset", | |
| commit_message=f"append {day}", | |
| operations=[CommitOperationAdd(path_in_repo=path, path_or_fileobj=content)], | |
| token=HF_TOKEN, | |
| ) | |
| except HfHubHTTPError as e: | |
| if getattr(e, "response", None) is not None and e.response.status_code == 404: | |
| api.create_repo(repo_id=DATASET_REPO_ID, repo_type="dataset", private=True, exist_ok=True, token=HF_TOKEN) | |
| create_commit( | |
| repo_id=DATASET_REPO_ID, repo_type="dataset", | |
| commit_message=f"append {day}", | |
| operations=[CommitOperationAdd(path_in_repo=path, path_or_fileobj=content)], | |
| token=HF_TOKEN, | |
| ) | |
| else: | |
| raise | |
| # ====== Render Helpers ====== | |
| def _render_scene_header(scene: dict) -> str: | |
| sid_raw = str(scene.get("id", "")) | |
| sid_display = "5" if sid_raw.startswith("5") else sid_raw | |
| sid = html.escape(sid_display) | |
| raw = (scene.get("prompt") or "").strip() | |
| # 1) optional: eine Zeile "Julia: ..." als Bubble erkennen | |
| m = re.match(r"^\s*Julia:\s*(.+)$", raw) | |
| if m: | |
| julia_line = html.escape(m.group(1).strip()) | |
| return f""" | |
| <div class="scene-card"> <!-- Wrapper START --> | |
| <div class="scenebox"> | |
| <div class="title">Szene {sid}</div> | |
| <div class="body"></div> | |
| </div> | |
| <div class="convbox"> | |
| <div class="conv-body"> | |
| <div class="conv-row from-julia"> | |
| <div class="conv-speaker">Julia</div> | |
| <div class="conv-text">{julia_line}</div> | |
| </div> | |
| </div> | |
| </div> | |
| """.strip() | |
| # 2) Standardfall: kein Julia:-Prefix -> normaler Prompt-Text | |
| prompt = html.escape(raw) | |
| return f""" | |
| <div class="scene-card"> <!-- Wrapper START --> | |
| <div class="scenebox"> | |
| <div class="title">Szene {sid}</div> | |
| <div class="body">{prompt}</div> | |
| </div> | |
| """.strip() | |
| def _render_scene_title_only(scene: dict) -> str: | |
| sid_raw = str(scene.get("id", "")) | |
| sid_display = "5" if sid_raw.startswith("5") else sid_raw | |
| sid = html.escape(sid_display) | |
| return f""" | |
| <div class="scenebox"> | |
| <div class="title">Szene {sid}</div> | |
| </div>""".strip() | |
| def _render_julia_only(text: str) -> str: | |
| ju = html.escape((text or "").strip()) | |
| return f""" | |
| <div class="convbox"> | |
| <div class="conv-body"> | |
| <div class="conv-row from-julia"> | |
| <div class="conv-speaker">Julia</div> | |
| <div class="conv-text">{ju}</div> | |
| </div> | |
| </div> | |
| </div>""".strip() | |
| def _render_conv_box(user_text, julia_text=None): | |
| du = html.escape((user_text or "").strip()) | |
| ju = html.escape((julia_text or "").strip()) if julia_text else None | |
| julia_row = f""" | |
| <div class="conv-row from-julia"> | |
| <div class="conv-speaker">Julia</div> | |
| <div class="conv-text">{ju}</div> | |
| </div>""" if ju else "" | |
| return f""" | |
| <div class="convbox"> | |
| <div class="conv-body"> | |
| <div class="conv-row from-du"> | |
| <div class="conv-speaker">Du</div> | |
| <div class="conv-text">{du}</div> | |
| </div>{julia_row} | |
| </div> | |
| </div>""".strip() | |
| def _first_sentence(text: str) -> str: | |
| t = (text or "").strip() | |
| for sep in [".", "!", "?"]: | |
| i = t.find(sep) | |
| if i != -1: | |
| return t[: i + 1] | |
| return t | |
| def _parse_summary_notes(summary: str) -> dict: | |
| notes = {} | |
| if not summary: | |
| return notes | |
| for raw in summary.splitlines(): | |
| line = re.sub(r'^[\-\*\•]\s*', '', raw.strip()) | |
| #m = re.match(r'^([A-I])\)?\s*[:\-]?\s*(.+)$', line, flags=re.IGNORECASE) | |
| m = re.match( | |
| r'^([A-I])\)?\s*(?:[:\-]\s*)?(?:\(.+?\)\s*[:\-]\s*)?(.+)$', | |
| line, | |
| flags=re.IGNORECASE | |
| ) | |
| if m: | |
| notes[m.group(1).upper()] = m.group(2).strip() | |
| return notes | |
| # ====== Qualitätsklassifikation ====== | |
| def _classify_quality(note: str, ok_flag: bool) -> str: | |
| if ok_flag: | |
| return "good" | |
| t = (note or "").lower() | |
| strong_neg = [ | |
| "vermeide","schlecht","falsch","kontrollierend","druck", | |
| "zu geschlossen","unangemessen","nicht gut", | |
| "verharmlos","schuldzuweisung","bagatellis" | |
| ] | |
| mid_terms = [ | |
| "auch gut","nett","besser mit","braucht","eng","kann","wäre besser", | |
| "ist ok","geht","icebreaker","direkt","passiv" | |
| ] | |
| pos_terms = ["guter einstieg","gut","offen","neutral","hilfreich","passt","einladung"] | |
| if any(k in t for k in strong_neg): | |
| return "bad" | |
| if any(k in t for k in mid_terms): | |
| return "mid" | |
| if any(k in t for k in pos_terms): | |
| return "good" | |
| return "bad" | |
| def _color_by_goals(option: dict) -> str: | |
| """ | |
| Grün: ok=True | |
| Gelb: ok=False und genau/ mindestens ein negatives Lernziel (-1) | |
| Rot: ok=False und zwei oder mehr negative Lernziele (-1) | |
| """ | |
| if option.get("ok", False): | |
| return "good" # Grün | |
| goals = option.get("goals") or {} | |
| negs = sum(1 for v in goals.values() if isinstance(v, int) and v < 0) | |
| if negs >= 2: | |
| return "bad" # Rot | |
| return "mid" # Gelb | |
| def _render_option_box(scene: dict, letter_map: dict, chosen_display: str) -> str: | |
| notes = _parse_summary_notes(scene.get("summary") or "") | |
| rows_html = [] | |
| for L in LETTERS: | |
| if L not in letter_map: | |
| continue | |
| key = letter_map[L] | |
| canon = str(key).strip().upper() | |
| opt = scene["options"][key] | |
| option_text = html.escape((opt.get("text") or "").strip()) | |
| raw_note = notes.get(canon) or _first_sentence(opt.get("feedback") or "") | |
| note_clean = _first_sentence(re.sub(r"\s*\(.*?\)", "", raw_note).strip()) | |
| note = html.escape(note_clean) | |
| #ok_flag = bool(opt.get("ok", False)) | |
| #quality = _classify_quality(note_clean, ok_flag) | |
| ok_flag = bool(opt.get("ok", False)) | |
| quality = _color_by_goals(opt) # <- NEU: goals-basierte Logik | |
| selected_cls = " selected" if L == chosen_display else "" | |
| quality_cls = f" {quality}" | |
| badge = "" | |
| if L == chosen_display and ok_flag: | |
| badge = '<div class="opt-badge">Deine Wahl</div>' | |
| elif ok_flag: | |
| badge = '<div class="opt-badge alt">Auch gut</div>' | |
| rows_html.append(f""" | |
| <div class="opt-row{quality_cls}{selected_cls}"> | |
| <div class="opt-letter"><strong>{L})</strong></div> | |
| <div class="opt-body"> | |
| <div class="opt-note"><strong>{note}</strong></div> | |
| <div class="opt-text">{option_text}</div> | |
| </div> | |
| {badge} | |
| </div>""") | |
| return f""" | |
| <div class="optionbox"> | |
| <div class="title">Auswertung der Optionen</div> | |
| <div class="opt-list"> | |
| {''.join(rows_html)} | |
| </div> | |
| </div> | |
| </div> <!-- Wrapper END: /.scene-card --> | |
| """.strip() | |
| # ====== Shuffle ====== | |
| SCENE_ID_TO_INDEX = {str(s["id"]): i for i, s in enumerate(SCENES)} | |
| def seed_for(session_id: str, scene_idx: int) -> int: | |
| base = f"{session_id}:{scene_idx}".encode("utf-8") | |
| return int(hashlib.sha256(base).hexdigest(), 16) % (2**31 - 1) | |
| def build_letter_map(session_id: str, scene_idx: int): | |
| scene = SCENES[scene_idx] | |
| canonical_keys = list(scene["options"].keys()) | |
| rnd = random.Random(seed_for(session_id, scene_idx)) | |
| rnd.shuffle(canonical_keys) | |
| return {LETTERS[i]: can_key for i, can_key in enumerate(canonical_keys[:len(LETTERS)])} | |
| def make_button_updates_with_map(scene_idx: int, session_id: str, letter_map: dict): | |
| scene = SCENES[scene_idx] | |
| updates = [] | |
| for L in LETTERS: | |
| if L in letter_map: | |
| text = scene["options"][letter_map[L]]["text"] | |
| updates.append(gr.update(value=f"{L}) {text}", visible=True)) | |
| else: | |
| updates.append(gr.update(visible=False)) | |
| return updates | |
| # ====== Gefilterte Updates: mehrere (alle bisher falschen) Display-Optionen ausblenden ====== | |
| def make_button_updates_with_map_filtered(scene_idx: int, session_id: str, letter_map: dict, blocked_displays): | |
| """ | |
| Blendet alle in `blocked_displays` enthaltenen Display-Buchstaben (A..I) aus. | |
| """ | |
| blocked = set(blocked_displays or []) | |
| scene = SCENES[scene_idx] | |
| updates = [] | |
| for L in LETTERS: | |
| if L in letter_map: | |
| if L in blocked: | |
| updates.append(gr.update(visible=False, value="")) | |
| else: | |
| text = scene["options"][letter_map[L]]["text"] | |
| updates.append(gr.update(value=f"{L}) {text}", visible=True)) | |
| else: | |
| updates.append(gr.update(visible=False, value="")) | |
| return updates | |
| def show_choice_buttons(session_id: str, scene_idx: int, letter_map: dict, blocked_choices): | |
| return make_button_updates_with_map_filtered(scene_idx, session_id, letter_map, blocked_choices) | |
| # ====== App Logic ====== | |
| def anon_session(request: gr.Request | None = None) -> str: | |
| try: | |
| ip = ((request and request.client and request.client.host) or "0.0.0.0").encode("utf-8") | |
| except Exception: | |
| ip = os.urandom(16) | |
| return hashlib.sha256(ip).hexdigest()[:12] | |
| # ====== Progress ====== | |
| TOTAL_STEPS = 5 # keep 5 to match "Schritt 0/5" | |
| def render_progress(step: int, total: int = TOTAL_STEPS) -> str: | |
| step = max(0, min(total, int(step))) | |
| pct = int(round(step * 100 / total)) if total else 0 | |
| return f""" | |
| <div class="progress-wrap"> | |
| <div id="progressbar" class="progress"> | |
| <div class="progress-fill" style="width:{pct}%"></div> | |
| </div> | |
| <div class="progress-count" id="progressCount">Schritt {step}/{total}</div> | |
| </div> | |
| """.strip() | |
| def start_session(request: gr.Request | None = None): | |
| session_id = anon_session(request) | |
| scene_idx = 0 | |
| prev_ok_choice = None | |
| transcript = [] | |
| eval_scores = {k: 0 for k in GOAL_ORDER} | |
| eval_counts = {k: {"pos": 0, "neg": 0} for k in GOAL_ORDER} | |
| # Szene 1: Titel + Prompt | |
| transcript.append(_render_scene_header(SCENES[0])) | |
| letter_map = build_letter_map(session_id, scene_idx) | |
| btn_updates = make_button_updates_with_map(scene_idx=scene_idx, session_id=session_id, letter_map=letter_map) | |
| # julia_deferred bleibt ungenutzt (leer); blocked_choices kumulativ für falsche Antworten | |
| julia_deferred = "" | |
| blocked_choices = [] | |
| return ( | |
| session_id, scene_idx, prev_ok_choice, transcript, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices, | |
| *btn_updates, | |
| gr.update(visible=False), # next_btn | |
| gr.update(visible=False), # status_md | |
| gr.update(visible=False), # results_panel | |
| gr.update(value=""), # score_md_res | |
| gr.update(value=""), # feedback_box_res | |
| gr.update(value=""), # email_box_res | |
| gr.update(visible=False), # send_feedback_res | |
| gr.update(value="", visible=False), # result_thanks | |
| gr.update(value=render_progress(1)), | |
| #gr.update(value=render_progress(TOTAL_STEPS)), | |
| ) | |
| def choose_and_feedback(session_id, scene_idx, prev_ok_choice, transcript, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices, chosen_display, request: gr.Request | None = None): | |
| scene = SCENES[scene_idx] | |
| if chosen_display not in letter_map: | |
| return (transcript, eval_scores, eval_counts, | |
| *make_button_updates_with_map_filtered(scene_idx, session_id, letter_map, blocked_choices), | |
| gr.update(visible=False), | |
| prev_ok_choice, letter_map, julia_deferred, blocked_choices, | |
| gr.update(value="Ungültige Auswahl.", visible=True), | |
| gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) | |
| choice_key = letter_map[chosen_display] | |
| choice = scene["options"][choice_key] | |
| ok = bool(choice.get("ok", False)) | |
| feedback_text = choice.get("feedback", "") | |
| # Nur "Du" anhängen; keine Julia-Optionen-Antwort | |
| transcript.append(_render_conv_box(choice['text'], None)) | |
| # Nur am Ende von Szene 5 (5A/5B/...) und nur bei richtiger Option | |
| if ok and str(scene["id"]).startswith("5") and choice.get("julia"): | |
| print("ok",ok) | |
| print("str(scene[id])",str(scene["id"])) | |
| print("choice.get(julia)",choice.get("julia")) | |
| transcript.append(_render_julia_only(choice["julia"])) | |
| if ok: | |
| state_cls = "ok" | |
| action_html = "" | |
| else: | |
| state_cls = "warn" | |
| action_html = ( | |
| '<button class="inline-retry" type="button" aria-label="Andere Option wählen">' | |
| 'Andere Option wählen</button>' | |
| ) | |
| transcript.append(f""" | |
| <div class="feedback {state_cls}"> | |
| <div class="feedback-icon">{'✅' if ok else '⚠️'}</div> | |
| <div class="feedback-body"> | |
| <div class="feedback-text">Rückmeldung: {feedback_text}</div> | |
| {action_html} | |
| </div> | |
| </div> | |
| """) | |
| for g, delta in (choice.get("goals", {}) or {}).items(): | |
| if g in eval_scores: | |
| eval_scores[g] += delta | |
| if delta > 0: | |
| eval_counts[g]["pos"] += 1 | |
| elif delta < 0: | |
| eval_counts[g]["neg"] += 1 | |
| # Per-choice Logging | |
| entry = { | |
| "ts": datetime.utcnow().isoformat() + "Z", | |
| "session": session_id, | |
| "scene_id": scene["id"], | |
| "display_choice": chosen_display, | |
| "choice_key": choice_key, | |
| "choice_text": choice["text"], | |
| "julia_reply": "", # nicht mehr benutzt | |
| "ok": ok, | |
| "feedback": feedback_text, | |
| "goals": choice.get("goals", {}), | |
| } | |
| try: | |
| commit_log([entry]) | |
| status = gr.update(visible=False) | |
| except Exception as e: | |
| status = gr.update(value=f"Speichern fehlgeschlagen: {e}", visible=True) | |
| if ok: | |
| # Szene geschafft -> Sperrliste leeren | |
| transcript.append(_render_option_box(scene, letter_map, chosen_display)) | |
| prev_ok_choice = choice_key | |
| blocked_choices = [] | |
| hide_all = [gr.update(visible=False, value="") for _ in LETTERS] | |
| return ( | |
| transcript, eval_scores, eval_counts, | |
| *hide_all, gr.update(visible=True, value="Weiter »"), | |
| prev_ok_choice, letter_map, julia_deferred, blocked_choices, status, | |
| gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update() | |
| ) | |
| else: | |
| # falsche Wahl -> kumulativ blockieren | |
| blocked = set(blocked_choices or []) | |
| blocked.add(chosen_display) | |
| blocked_choices = sorted(blocked) | |
| hide_all = [gr.update(visible=False, value="") for _ in LETTERS] | |
| return ( | |
| transcript, eval_scores, eval_counts, | |
| *hide_all, gr.update(visible=False), | |
| prev_ok_choice, letter_map, julia_deferred, blocked_choices, status, | |
| gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update() | |
| ) | |
| def _render_scorecard(eval_counts: dict) -> str: | |
| """ | |
| Professionelle, klare Zusammenfassung OHNE Formulierungsvorschläge. | |
| Erwartet: | |
| - GOAL_LABELS: z.B. {"L1": "...", "L2": "...", ...} | |
| - eval_counts: {"L1": {"pos": int, "neg": int}, ...} | |
| Logik: | |
| Stärken: attempts >= 1 und pct >= 80 | |
| Nächster Schritt: attempts == 0 ODER (attempts >= 1 und pct < 50) | |
| (keine 'Solide Basis' Sektion – bewusst schlank gehalten) | |
| """ | |
| # 1) Kennzahlen je Ziel berechnen | |
| stats = [] | |
| for k, label in GOAL_LABELS.items(): | |
| pos = int(eval_counts.get(k, {}).get("pos", 0)) | |
| neg = int(eval_counts.get(k, {}).get("neg", 0)) | |
| attempts = pos + neg | |
| pct = 0 if attempts == 0 else round(100 * pos / attempts) | |
| stats.append({ | |
| "key": k, | |
| "label": label, | |
| "pos": pos, | |
| "neg": neg, | |
| "attempts": attempts, | |
| "pct": pct, | |
| }) | |
| # 2) Gruppen bilden | |
| strengths = [g for g in stats if g["attempts"] >= 1 and g["pct"] >= 80] | |
| next_steps = ( | |
| [g for g in stats if g["attempts"] == 0] + | |
| [g for g in stats if g["attempts"] >= 1 and g["pct"] < 50] | |
| ) | |
| # Sortierungen: Stärken (pct↓, dann Label), Nächster Schritt (unbewertet zuerst, dann pct↑) | |
| strengths.sort(key=lambda x: (-x["pct"], x["label"])) | |
| next_steps.sort(key=lambda x: (x["attempts"] != 0, x["pct"], x["label"])) | |
| def fmt_stat(pos, attempts, pct): | |
| # NEU: klarer Text statt 0/0 | |
| if attempts == 0: | |
| return "(Noch nicht geprüft)" | |
| return f"{pos}/{attempts} · {pct}%" | |
| def render_row(g): | |
| # NEU: Klasse für nicht geprüfte Ziele, damit man sie optisch absetzen kann | |
| row_cls = "score-row not-checked" if g["attempts"] == 0 else "score-row" | |
| return f""" | |
| <div class="{row_cls}"> | |
| <div class="score-main"> | |
| <div class="score-label">{g['label']}</div> | |
| <div class="score-stat">{fmt_stat(g['pos'], g['attempts'], g['pct'])}</div> | |
| </div> | |
| <div class="bar"><div class="bar-fill" style="width:{g['pct']}%"></div></div> | |
| </div>""" | |
| # 4) Gesamteindruck | |
| evaluated = [g for g in stats if g["attempts"] >= 1] | |
| if evaluated: | |
| avg_pct = round(sum(g["pct"] for g in evaluated) / len(evaluated)) | |
| # „Am stärksten“: alle mit höchstem pct | |
| best_pct = max(g["pct"] for g in evaluated) | |
| best_labels = [g["label"] for g in evaluated if g["pct"] == best_pct] | |
| strongest_txt = ", ".join(best_labels) | |
| else: | |
| avg_pct = 0 | |
| strongest_txt = "—" | |
| # 5) HTML zusammenbauen (nur Fakten, keine Coaching-Sätze) | |
| parts = [] | |
| parts.append('<div class="evalbox">') | |
| parts.append('<div class="evalhead">Lernziel-Check</div>') | |
| # Stärken (wenn vorhanden) | |
| if strengths: | |
| parts.append('<div class="subhead">✨ Deine Stärken</div>') | |
| for g in strengths: | |
| parts.append(render_row(g)) | |
| # Nächster Schritt (wenn vorhanden) | |
| if next_steps: | |
| parts.append('<div class="subhead" style="margin-top:1rem;">💡 Dein nächster Schritt</div>') | |
| for g in next_steps: | |
| parts.append(render_row(g)) | |
| # Gesamteindruck | |
| #parts.append('<hr class="soft">') | |
| #parts.append('<div class="subhead">📊 Gesamteindruck</div>') | |
| #Ø {avg_pct}% über {len(evaluated)} bewertete Ziele<br> | |
| #parts.append(f""" | |
| # <div class="evallines"> | |
| # Am stärksten: {strongest_txt}<br> | |
| # Nächster Fokus: {"; ".join([g['label'] for g in next_steps]) if next_steps else "—"} | |
| # </div> | |
| #""") | |
| parts.append('</div>') # /evalbox | |
| return "\n".join(parts) | |
| def go_next(session_id, scene_idx, prev_ok_choice, transcript, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices, request: gr.Request | None = None): | |
| # Immer normal fortfahren – KEIN julia_deferred-Sonderweg | |
| current_id = str(SCENES[scene_idx]["id"]) | |
| target_idx = scene_idx + 1 | |
| if current_id == "4" and prev_ok_choice: | |
| desired = f"5{prev_ok_choice}" | |
| target_idx = SCENE_ID_TO_INDEX.get(desired, SCENE_ID_TO_INDEX.get("5A", target_idx)) | |
| prev_ok_choice = None | |
| if current_id.startswith("5") or target_idx >= len(SCENES): | |
| summary = {"ts": datetime.utcnow().isoformat() + "Z","session": session_id,"type": "session_summary","eval_counts": eval_counts} | |
| try: | |
| commit_log([summary]) | |
| except Exception: | |
| pass | |
| score_html = _render_scorecard(eval_counts) | |
| return ( | |
| transcript, *[gr.update(visible=False, value="") for _ in LETTERS], gr.update(visible=False), | |
| scene_idx, prev_ok_choice, eval_scores, eval_counts, letter_map, "", blocked_choices, | |
| gr.update(visible=True), gr.update(value=score_html), | |
| gr.update(visible=True), gr.update(visible=True), gr.update(visible=True), | |
| gr.update(value="", visible=False), | |
| gr.update(value=render_progress(TOTAL_STEPS)) | |
| ) | |
| next_scene = SCENES[target_idx] | |
| # Wie gewünscht: Titel + Prompt auch ab Szene 2 | |
| transcript.append(_render_scene_header(next_scene)) | |
| new_map = build_letter_map(session_id, target_idx) | |
| btn_updates = make_button_updates_with_map(target_idx, session_id, new_map) | |
| blocked_choices = [] # neue Szene → wieder alle Optionen erlauben | |
| return ( | |
| transcript, *btn_updates, gr.update(visible=False), | |
| target_idx, prev_ok_choice, eval_scores, eval_counts, new_map, "", blocked_choices, | |
| gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), | |
| gr.update(value=render_progress(target_idx + 1)) | |
| ) | |
| def submit_feedback(session_id: str, feedback_text: str, email_text: str): | |
| msg = (feedback_text or "").strip() | |
| email = (email_text or "").strip() | |
| # Validate email (or allow empty) | |
| if email and ("@" not in email or "." not in email.split("@")[-1]): | |
| return ( | |
| gr.update( | |
| value='<div class="thanks error">Bitte eine gültige E-Mail angeben (oder leer lassen).</div>', | |
| visible=True | |
| ), | |
| gr.update(), # no change to feedback textarea | |
| gr.update() # no change to email field | |
| ) | |
| entry = { | |
| "ts": datetime.utcnow().isoformat() + "Z", | |
| "session": session_id, | |
| "type": "user_feedback", | |
| "feedback": msg, | |
| "email": email | |
| } | |
| try: | |
| commit_log([entry]) | |
| return ( | |
| gr.update( | |
| value='<div class="thanks">✅ Vielen Dank für deine Rückmeldung. Wir haben sie erhalten und sie unterstützt uns dabei, die Anwendung kontinuierlich zu verbessern.</div>', | |
| visible=True | |
| ), | |
| gr.update(value=""), # clear textarea | |
| gr.update(value="") # clear email field | |
| ) | |
| except Exception as e: | |
| return ( | |
| gr.update( | |
| value=f'<div class="thanks error">Speichern fehlgeschlagen: {html.escape(str(e))}</div>', | |
| visible=True | |
| ), | |
| gr.update(), # keep textarea | |
| gr.update() # keep email field | |
| ) | |
| # ====== UI ====== | |
| #with gr.Blocks(theme=gr.themes.Soft()) as demo: | |
| with gr.Blocks(title="Gesprächstraining im Schulalltag", theme=gr.themes.Soft()) as demo: | |
| # Intro | |
| with gr.Column(scale=1, min_width=900) as intro_wrap: | |
| intro_md = gr.Markdown( | |
| f""" | |
| ## Gesprächstraining im Schulalltag – Multiple Choice | |
| **Deine Gesprächssituation:** | |
| Du bist Lehrkraft und sprichst mit Julia. Julia ist 14 Jahre alt. Dir ist aufgefallen, dass sie sich in letzter Zeit verändert hat. | |
| **Deine Aufgabe:** | |
| Du möchtest Julia ansprechen. Im Training wirst du durch einen typischen Gesprächsverlauf geführt. | |
| - In jeder Szene erhältst du mehrere Reaktionen. | |
| - **Wähle verschiedene Antworten. Der Sinn ist, mit den Optionen zu spielen und zu sehen, welche unterschiedlichen Reaktionen das Training zeigt. Es geht nicht darum, die eine richtige Antwort zu haben.** | |
| - Direkt im Anschluss bekommst du Feedback: Warum ist diese Reaktion hilfreich oder warum könnte sie problematisch sein? | |
| - Du kannst dieses Training so oft machen, wie du möchtest. | |
| **Am Ende des Trainings erfährst du:** | |
| - Was dir in der Gesprächsführung bereits gut gelungen ist. | |
| - Welche Punkte du noch weiterentwickeln kannst, um Gespräche mit Schülerinnen und Schülern professionell und unterstützend zu führen. | |
| In diesem Gespräch möchten wir mit dir teilen, wie du folgende Punkte in einem solchen Gespräch beachten kannst: | |
| - **L1:** Gefühle spiegeln statt bewerten {GOAL_HINTS['L1'][0]} | |
| - **L2:** Öffnende Fragen statt Lösungen {GOAL_HINTS['L2'][0]} | |
| - **L3:** Validieren ohne zu interpretieren {GOAL_HINTS['L3'][0]} | |
| - **L4:** Professionelle Begrenzung statt Retterrolle {GOAL_HINTS['L4'][0]} | |
| - **L5:** Krisen ernst nehmen ohne Schuld oder Verharmlosung {GOAL_HINTS['L5'][0]} | |
| **Farblegende (in der Auswertung)** | |
| - 🟢 Grün: passend / empfohlen | |
| - 🟠 Orange: teilweise okay, mit Verbesserungshinweis | |
| - 🔴 Rot: unpassend – sollte vermieden werden | |
| > **Hinweis:** Deine Auswahl wird anonymisiert gespeichert (Session-ID wird gehasht). Keine Freitexte. | |
| """, | |
| elem_classes=["intro-md"] | |
| ) | |
| start_btn = gr.Button("Training starten", variant="primary", elem_classes=["intro-start"]) | |
| # States | |
| session_id = gr.State() | |
| scene_idx = gr.State() | |
| prev_ok_choice = gr.State() | |
| transcript = gr.State([]) | |
| eval_scores = gr.State() | |
| eval_counts = gr.State() | |
| letter_map = gr.State({}) | |
| julia_deferred = gr.State("") # bleibt leer/ungenutzt | |
| blocked_choices = gr.State([]) # Liste aller bisher falsch gewählten Display-Buchstaben | |
| # Transcript & Controls | |
| progress_html = gr.HTML(render_progress(0)) | |
| transcript_md = gr.HTML(elem_id="transcript") | |
| with gr.Column(): | |
| btnA = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| btnB = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| btnC = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| btnD = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| btnE = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| btnF = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| btnG = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| btnH = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| btnI = gr.Button(visible=False, elem_classes=["choice-btn"]) | |
| #next_btn = gr.Button("Weiter »", visible=False) | |
| #restart_btn = gr.Button("Neu starten", variant="secondary", visible=False) | |
| next_btn = gr.Button("Weiter »", visible=False, elem_id="next_btn") | |
| restart_btn = gr.Button("Neu starten", variant="secondary", visible=False, elem_id="restart_btn") | |
| status_md = gr.Markdown(visible=False) | |
| # Trigger-Button für das erneute Anzeigen der (gefilterten) Wahl-Buttons | |
| show_choices = gr.Button("trigger", visible=True, elem_id="showChoices") | |
| #with gr.Column(visible=False) as results_panel: | |
| with gr.Column(visible=False, elem_id="feedback_card") as results_panel: | |
| score_md_res = gr.HTML() | |
| gr.HTML(""" | |
| <div class="fc-head"> | |
| <div class="fc-title">💬 Kurzes Feedback</div> | |
| <div class="fc-sub">Hättest du an irgendeiner Stelle gern mehr sagen wollen?</div> | |
| </div> | |
| """) | |
| # ⬇️ Felder OHNE Label (Labels in der Karte) | |
| feedback_box_res = gr.Textbox( | |
| placeholder="Was war hilfreich? Wo hättest du gern mehr sagen wollen?", | |
| lines=6, | |
| show_label=False, | |
| elem_id="feedback-box-res" | |
| ) | |
| gr.HTML(""" | |
| <div class="fc-head"> | |
| <div class="fc-title">📧 Optionale Kontaktangabe</div> | |
| <div class="fc-sub">Wenn du möchtest, kannst du deine E-Mail-Adresse hinterlassen, um mit uns in Kontakt zu bleiben.</div> | |
| </div> | |
| """) | |
| email_box_res = gr.Textbox( | |
| placeholder="z. B. dein.name@example.org", | |
| lines=1, | |
| show_label=False, | |
| elem_id="email_box_res" | |
| ) | |
| send_feedback_res = gr.Button("Feedback senden", variant="primary", elem_id="send_feedback_btn") | |
| #result_thanks = gr.Markdown(visible=False) | |
| result_thanks = gr.HTML(visible=False, elem_id="thanks_msg") | |
| # Styles + JS | |
| gr.HTML(""" | |
| <style> | |
| :root{ --bg:#fff; --text:#111; --muted:#6b7280; --border:#e5e7eb; --card:#fff; | |
| --shadow:0 1px 2px rgba(0,0,0,.04), 0 8px 24px rgba(0,0,0,.03); } | |
| html, body, .gradio-container { background:#fff !important; color:#111 !important; } | |
| body, .gradio-container { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; font-size:16px; line-height:1.6; } | |
| .intro-md { background:#fff; border:1px solid var(--border); border-radius:12px; padding:18px 20px; box-shadow:var(--shadow); } | |
| .intro-start { margin-top:14px !important; } | |
| .gradio-container button, .gr-button, .gr-button-primary, .gr-button-secondary { | |
| background:#fff !important; color:#111 !important; border:1px solid #111 !important; | |
| border-radius:10px !important; font-weight:700 !important; box-shadow:none !important; | |
| } | |
| .gradio-container button:hover{ background:#111 !important; color:#fff !important; } | |
| .scenebox, .convbox, .optionbox, .evalbox { background:#fff; border:1px solid var(--border); border-radius:12px; box-shadow:var(--shadow); } | |
| .scenebox{ margin: 12px 0 14px; } | |
| .scenebox .title{ font-weight:700; padding:12px 16px; border-bottom:1px solid var(--border); background:#fafafa; } | |
| .scenebox .body{ padding:12px 16px 16px; } | |
| .convbox{ margin: 12px 0 14px; } | |
| .convbox .conv-body{ padding:12px 14px 14px; } | |
| .conv-row{ display:flex; gap:.6rem; align-items:flex-start; padding:.55rem .7rem; border-radius:10px; margin:.45rem 0; background:#fafafa; border:1px solid #f3f4f6; } | |
| .conv-speaker{ font-weight:700; min-width:2.2rem; } | |
| .optionbox{ margin: 14px 0 20px; } | |
| .optionbox .title{ font-weight:700; padding:12px 16px; border-bottom:1px solid var(--border); background:#fafafa; } | |
| .optionbox .opt-list{ padding:10px 14px 14px; } | |
| .opt-row{ display:flex; gap:.6rem; align-items:flex-start; padding:.7rem .8rem; margin:.55rem 0; border:1px solid var(--border); border-radius:10px; background:#fbfbfb; position:relative; } | |
| .opt-row::before{ content:""; width:4px; border-radius:999px; align-self:stretch; position:absolute; left:-1px; top:-1px; bottom:-1px; background:#e5e7eb; } | |
| .opt-note { margin-top:2px; } | |
| .opt-text { font-weight:400; color:#111; } | |
| .opt-letter{ min-width:2.2rem; } | |
| .opt-row.good{ border-color:#bbf7d0; background:#f0fff7; } | |
| .opt-row.good::before{ background:#22c55e; } | |
| .opt-row.mid{ border-color:#fcd34d; background:#fffbeb; } | |
| .opt-row.mid::before{ background:#f59e0b; } | |
| .opt-row.bad{ border-color:#fecaca; background:#fff1f2; } | |
| .opt-row.bad::before{ background:#ef4444; } | |
| .opt-row.selected{ box-shadow: 0 0 0 2px rgba(17,17,17,.12) inset; } | |
| .opt-badge, .opt-badge.alt{ font-size:.82rem; font-weight:600; color:#111; background:#fff; border:1px solid #111; padding:.1rem .45rem; border-radius:999px; align-self:center; margin-left:auto; } | |
| .feedback{ margin:1rem 0; padding:.9rem 1rem; border-radius:10px; border:1px solid #d1d5db; background:#fff7ed; | |
| display:flex; gap:.6rem; align-items:flex-start; } | |
| .feedback-icon{ font-size:1.1rem; line-height:1; margin-top:.1rem; } | |
| .feedback .feedback-body{display:flex;flex-direction:column;gap:.6rem;flex: 1 1 0; /* füllt die verfügbare Breite */min-width: 0; /* verhindert Overflow */width: 100%; /* Sicherheitsgurt */} | |
| .inline-retry{ | |
| display:block; align-items:center; justify-content:center; gap:.5rem; | |
| padding:.9rem 1.2rem; min-height:48px; line-height:1.25; font-size:1rem; font-weight:700; | |
| color:#111; background:#fff; border:1px solid #111; border-radius:10px; white-space:normal; | |
| text-align:center; box-shadow:none; cursor:pointer; transition:background .15s, color .15s, transform .02s; | |
| margin-top:.25rem; width:100%; | |
| box-sizing:border-box; /* 100% inkl. Padding/Border */ | |
| padding:.9rem 1.2rem; /* deine bisherigen Werte beibehalten */ | |
| } | |
| .inline-retry:hover{ background:#111; color:#fff; } | |
| .inline-retry:active{ transform:translateY(1px); } | |
| .inline-retry:focus{ outline:3px solid transparent; box-shadow:0 0 0 3px rgba(17,17,17,.25); } | |
| #showChoices{ display:none !important; } | |
| .soft{ border:0; height:1px; background:#eee; margin:.8rem 0; } | |
| .evalbox{ margin:1rem 0 1.3rem; padding:1rem 1.15rem; } | |
| .evalhead{ font-weight:700; margin-bottom:.7rem; } | |
| .subhead{ font-weight:600; margin:1rem 0 .4rem; } | |
| .bar{ height:8px; background:#f3f4f6; border-radius:999px; overflow:hidden; margin-top:.45rem; } | |
| .bar-fill{ height:100%; background:#111; } | |
| .bar-fill{ height:100%; background:#111; } | |
| .score-row.not-checked .score-stat { color: var(--muted); } | |
| .score-row.not-checked .bar { opacity: .45; } | |
| /* Fortschrittsbalken oben */ | |
| .progress-wrap{ | |
| display:flex; align-items:center; gap:.6rem; | |
| margin: 8px 0 12px; | |
| } | |
| .progress{ | |
| flex:1; height:10px; background:#f3f4f6; | |
| border-radius:999px; position:relative; overflow:hidden; | |
| border:1px solid var(--border); | |
| } | |
| .progress-fill{ | |
| position:absolute; left:0; top:0; bottom:0; width:0%; | |
| background:#111; transition:width .25s ease; | |
| } | |
| .progress-count{ | |
| min-width:48px; text-align:right; font-weight:700; | |
| font-variant-numeric: tabular-nums; color:#111; | |
| } | |
| /* ==== Neutralisiere beide Wrapper, die Gradio für Markdown nutzt ==== */ | |
| .gradio-container :where(.prose, .gr-prose){ | |
| --tw-prose-body: #111; | |
| --tw-prose-headings: #111; | |
| --tw-prose-lead: #111; | |
| --tw-prose-links: #111; | |
| --tw-prose-bold: #111; | |
| --tw-prose-quotes: #111; | |
| --tw-prose-quote-borders: #e5e7eb; | |
| --tw-prose-counters: #111; | |
| --tw-prose-bullets: #111; | |
| } | |
| .gradio-container :where(.prose, .gr-prose, .prose *, .gr-prose *){ | |
| color:#111 !important; | |
| opacity:1 !important; | |
| } | |
| /* optional: Intro zusätzlich absichern */ | |
| .intro-md :where(.prose, .gr-prose, .prose *, .gr-prose *){ | |
| color:inherit !important; | |
| opacity:1 !important; | |
| } | |
| /* === Jede Szene optisch als EIN Block (gilt für alle Szenen) === */ | |
| /* Grund-Look aller Teilboxen angleichen */ | |
| .scenebox, .convbox, .feedback, .optionbox{ | |
| background:#fff; | |
| border:1px solid var(--border); | |
| border-radius:12px; | |
| } | |
| /* Abstand zwischen Szenen (nur die erste Box der Szene steuert den Außenabstand) */ | |
| .scenebox{ | |
| margin:16px 0 0; /* Abstand nach oben */ | |
| box-shadow:var(--shadow); /* Schatten nur auf der ersten Box der Szene */ | |
| } | |
| .convbox, .feedback, .optionbox{ | |
| box-shadow:none; /* keine Zusatzschatten innerhalb der Szene */ | |
| margin-top:12px; /* Default, wird gleich bei direkter Folge überlappt */ | |
| } | |
| /* Nahtloses Verschmelzen: jede Folge-Box überlappt die obere Kante */ | |
| .scenebox + .convbox, | |
| .scenebox + .feedback, | |
| .scenebox + .optionbox, | |
| .convbox + .feedback, | |
| .convbox + .optionbox, | |
| .feedback + .optionbox{ | |
| margin-top:-1px; /* Kante überlappen */ | |
| border-top:0; /* gemeinsame Außenlinie */ | |
| border-top-left-radius:0; | |
| border-top-right-radius:0; | |
| } | |
| /* Mittlere Boxen bekommen unten keine Rundung, wenn noch etwas folgt */ | |
| .scenebox:has(+ .convbox), | |
| .scenebox:has(+ .feedback), | |
| .scenebox:has(+ .optionbox), | |
| .convbox:has(+ .feedback), | |
| .convbox:has(+ .optionbox), | |
| .feedback:has(+ .optionbox){ | |
| border-bottom-left-radius:0; | |
| border-bottom-right-radius:0; | |
| box-shadow:none; | |
| } | |
| /* Eine klare, gemeinsame Box pro Szene */ | |
| .scene-card{ | |
| background:#fff; | |
| border:1px solid var(--border); | |
| border-radius:12px; | |
| box-shadow:var(--shadow); | |
| margin:16px 0; | |
| overflow:hidden; /* keine Lücken an den Kanten */ | |
| } | |
| /* Alle Teilboxen in der Scene-Card wirken wie Inhalt, nicht wie eigene Karten */ | |
| .scene-card > .scenebox, | |
| .scene-card > .convbox, | |
| .scene-card > .feedback, | |
| .scene-card > .optionbox{ | |
| border:0; | |
| border-radius:0; | |
| margin:0; | |
| box-shadow:none; | |
| } | |
| /* dezente interne Trennlinien (optional) */ | |
| .scene-card .scenebox .title{ border-bottom:1px solid var(--border); } | |
| .scene-card .convbox .conv-row{ border-color:#f0f2f5; background:#fafafa; } | |
| .scene-card .feedback{ border-top:1px solid #f0f2f5; background:#fff7ed; } | |
| .scene-card .optionbox .title{ border-top:1px solid #f0f2f5; background:#fafafa; } | |
| /* === Dialog-Bubbles in der Szene-Karte === */ | |
| .scene-card .conv-body{ display:flex; flex-direction:column; gap:10px; } | |
| /* Grundzeile entschlacken (wir stylen die Bubbles auf .conv-text) */ | |
| .scene-card .conv-row{ | |
| display:flex; align-items:flex-end; gap:.5rem; | |
| background:transparent; border:0; padding:0; margin:0; | |
| } | |
| /* Sprecher-Label dezent als Pill */ | |
| .scene-card .conv-speaker{ | |
| font-weight:600; font-size:.8rem; color:#6b7280; | |
| background:#eef2f7; border:1px solid #e5e7eb; border-radius:999px; | |
| padding:.15rem .5rem; | |
| } | |
| /* eigentliche Sprechblase */ | |
| .scene-card .conv-text{ | |
| max-width:78%; | |
| padding:.6rem .8rem; | |
| border:1px solid var(--border); | |
| border-radius:16px; | |
| position:relative; | |
| line-height:1.5; | |
| } | |
| /* Julia links */ | |
| .scene-card .from-julia{ justify-content:flex-start; } | |
| .scene-card .from-julia .conv-text{ | |
| background:#f8fafc; /* sehr helles Grau/Blau */ | |
| border-radius:16px 16px 16px 6px; /* kleiner Radius unten links */ | |
| } | |
| .scene-card .from-julia .conv-text::after{ | |
| content:""; position:absolute; left:-6px; bottom:6px; | |
| width:10px; height:10px; background:#f8fafc; | |
| border-left:1px solid var(--border); border-bottom:1px solid var(--border); | |
| transform:rotate(45deg); | |
| } | |
| /* Du rechts */ | |
| .scene-card .from-du{ justify-content:flex-end; } | |
| .scene-card .from-du .conv-speaker{ order:2; } /* Label rechts neben Bubble */ | |
| .scene-card .from-du .conv-text{ | |
| order:1; | |
| background:#eef6ff; /* sehr helles Blau */ | |
| border-radius:16px 16px 6px 16px; /* kleiner Radius unten rechts */ | |
| } | |
| .scene-card .from-du .conv-text::after{ | |
| content:""; position:absolute; right:-6px; bottom:6px; | |
| width:10px; height:10px; background:#eef6ff; | |
| border-right:1px solid var(--border); border-bottom:1px solid var(--border); | |
| transform:rotate(-45deg); | |
| } | |
| /* Mobile: Bubbles dürfen mehr Breite haben */ | |
| @media (max-width: 520px){ | |
| .scene-card .conv-text{ max-width: 92%; } | |
| } | |
| /* ===== Einheitliche Breite + Zentrierung (Rail) ab hier ===== */ | |
| :root{ | |
| --rail: 960px; /* gewünschte Maximalbreite */ | |
| } | |
| /* Zentraler Container von Gradio – kein Innen-Padding */ | |
| .gradio-container .block{ | |
| max-width: var(--rail) !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| padding-left: 0 !important; /* wichtig: kein extra Padding hier */ | |
| padding-right: 0 !important; | |
| box-sizing: border-box !important; | |
| } | |
| /* Szene-Karten, Ergebnis, Intro, Transcript, Progress – gleiche Breite */ | |
| #transcript, | |
| .scene-card, .optionbox, .evalbox, .intro-md, | |
| #results_panel, #score_md_res, | |
| #feedback-box-res, #email_box_res, | |
| .progress-wrap{ | |
| max-width: var(--rail) !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| width: 100% !important; | |
| box-sizing: border-box !important; | |
| /* kein zusätzliches left/right Padding hier */ | |
| } | |
| /* Antwort-Buttons + Weiter/Neu starten – exakt gleich breit + engerer Abstand */ | |
| .choice-btn, | |
| #next_btn, #restart_btn{ | |
| display: block !important; | |
| max-width: var(--rail) !important; | |
| width: 100% !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| padding-left: 0 !important; /* kein extra Padding */ | |
| padding-right: 0 !important; | |
| box-sizing: border-box !important; | |
| margin-top: 4px !important; /* engerer vertikaler Abstand */ | |
| margin-bottom: 4px !important; | |
| } | |
| /* Innen-Padding in Gradio-Spalten neutralisieren */ | |
| .gradio-container .gr-form, | |
| .gradio-container .gr-column, | |
| .gradio-container .gr-row{ | |
| padding-left: 0 !important; | |
| padding-right: 0 !important; | |
| } | |
| /* ===== Ende Rail ===== */ | |
| /* === FEEDBACK-KARTE: 1:1 wie Scene-Card & volle Railbreite ================= */ | |
| /* Gleiche Außenmaße wie alle Karten */ | |
| #results_panel, | |
| #score_md_res, | |
| #feedback-box-res, | |
| #email_box_res, | |
| #send_feedback_btn { | |
| max-width: var(--rail) !important; | |
| width: 100% !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| box-sizing: border-box !important; | |
| } | |
| /* Die ganze Feedback-Karte soll wie eine Scene-Card wirken */ | |
| .scene-card.feedback-card { | |
| background: #fff; | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| box-shadow: var(--shadow), 0 8px 28px rgba(17,17,17,.05); | |
| overflow: hidden; | |
| margin: 16px auto; /* gleiche Abstände wie Szenen */ | |
| } | |
| /* Kopf der Karte identisch stylen */ | |
| .scene-card.feedback-card .scenebox .title { | |
| background: #fafafa; /* statt kräftigem Lila – neutral wie Scenes */ | |
| border-bottom: 1px solid var(--border); | |
| font-weight: 700; | |
| } | |
| /* Textarea + E-Mail-Input im „Kartenstapel“-Look (nahtlos) */ | |
| #feedback-box-res textarea, | |
| #email_box_res input { | |
| width: 100%; | |
| border: 0; /* Grenzen übernimmt die Card */ | |
| border-top: 1px solid var(--border); /* feine Trennlinie zwischen Feldern */ | |
| padding: 12px 14px; | |
| font: inherit; | |
| background: #fff; | |
| border-radius: 0; /* keine eigenen Rundungen */ | |
| box-sizing: border-box; | |
| } | |
| /* first field ohne Top-Border, damit der Card-Rahmen greift */ | |
| #feedback-box-res textarea { border-top: 0; } | |
| /* Fokus konsistent zu Buttons/Textfeldern im App-Stil */ | |
| #feedback-box-res textarea:focus, | |
| #email_box_res input:focus { | |
| outline: none; | |
| border-color: #111; /* nur die innere Kante wird dunkler */ | |
| box-shadow: inset 0 0 0 1px #111; /* dezente, einheitliche Markierung */ | |
| } | |
| /* Sende-Button als Kartenfuß, bündig, volle Breite */ | |
| #send_feedback_btn { | |
| display: block !important; | |
| width: 100% !important; | |
| border: 0 !important; | |
| border-top: 1px solid var(--border) !important; | |
| border-radius: 0 0 12px 12px !important; /* schließt die Card unten ab */ | |
| background: #111 !important; | |
| color: #fff !important; | |
| font-weight: 800 !important; | |
| padding: .95rem 1.2rem !important; | |
| margin: 0 !important; /* keine Extra-Abstände */ | |
| } | |
| /* Hover wie bei deinen globalen Buttons */ | |
| #send_feedback_btn:hover { filter: brightness(.92); } | |
| /* Der ganze Results-Block soll keinen zusätzlichen Innenabstand einführen */ | |
| #results_panel > .gr-form, | |
| #results_panel > .gr-column, | |
| #results_panel > .gr-row { | |
| padding-left: 0 !important; | |
| padding-right: 0 !important; | |
| } | |
| /* Option: Abstand über/unter dem Feedback-Block harmonisieren */ | |
| #results_panel { margin-top: 8px; margin-bottom: 8px; } | |
| /* MOBILE: Felder dürfen etwas breiter atmen, aber rail bleibt leitend */ | |
| @media (max-width: 520px){ | |
| .scene-card.feedback-card { margin: 12px auto; } | |
| } | |
| /* Falls Gradio-Themes noch dazwischenfunken: Spezifität + !important erzwingen */ | |
| .scene-card.feedback-card { border:1px solid var(--border) !important; border-radius:12px !important; } | |
| .scene-card.feedback-card .scenebox .title { background:#fafafa !important; border-bottom:1px solid var(--border) !important; } | |
| /* Margin exakt wie bei Szenen sichern */ | |
| .scene-card.feedback-card { margin:16px auto !important; } | |
| /* Letzte Sicherheitsleine: Rail-Breite an allen Feedback-Teilen */ | |
| #results_panel, #score_md_res, #feedback-box-res, #email_box_res, #send_feedback_btn { | |
| max-width: var(--rail) !important; width:100% !important; margin-left:auto !important; margin-right:auto !important; | |
| } | |
| /* === Feedback: eine einzige hellblaue Karte als Hintergrund für ALLES === */ | |
| /* 1) Die Karte selbst */ | |
| .scene-card.feedback-card{ | |
| background:#f5f9ff !important; /* hellblau über die ganze Karte */ | |
| border:1px solid #d7e3ff !important; | |
| border-radius:12px !important; | |
| overflow:hidden; | |
| } | |
| /* 2) Kopf innerhalb der Karte (leicht abgesetzt, gleiche Familie) */ | |
| .scene-card.feedback-card .scenebox .title{ | |
| background:#eaf2ff !important; | |
| border-bottom:1px solid #d7e3ff !important; | |
| } | |
| /* 3) Alle inneren Gradio-Wrapper im Feedback-Panel unsichtbar machen */ | |
| #results_panel .gr-box, | |
| #results_panel .gr-panel, | |
| #results_panel .gr-group, | |
| #results_panel .gr-form, | |
| #results_panel .gr-textbox, | |
| #results_panel .gr-textbox > div, | |
| #results_panel .gr-input, | |
| #results_panel > div:has(.gr-block){ | |
| background: transparent !important; | |
| border: 0 !important; | |
| box-shadow: none !important; | |
| padding-left: 0 !important; | |
| padding-right: 0 !important; | |
| } | |
| /* 4) Felder: weiß auf blauem Kartenhintergrund, ohne eigene Rundungen */ | |
| #feedback-box-res textarea, | |
| #email_box_res input{ | |
| background:#fff !important; | |
| border:0 !important; /* Rahmen macht die Karte */ | |
| border-top:1px solid #d7e3ff !important; /* feine Trenner */ | |
| border-radius:0 !important; | |
| width:100% !important; | |
| box-sizing:border-box !important; | |
| padding:12px 14px !important; | |
| } | |
| #feedback-box-res textarea{ border-top:0 !important; } | |
| /* 5) Button bündig als Kartenfuß */ | |
| #send_feedback_btn{ | |
| display:block !important; | |
| width:100% !important; | |
| margin:0 !important; | |
| border:0 !important; | |
| border-top:1px solid #d7e3ff !important; | |
| border-radius:0 0 12px 12px !important; | |
| background:#111 !important; | |
| color:#fff !important; | |
| font-weight:800 !important; | |
| padding:.95rem 1.2rem !important; | |
| } | |
| /* 6) Breite konsistent zur Rail */ | |
| #results_panel, #score_md_res, #feedback-box-res, #email_box_res, #send_feedback_btn{ | |
| max-width:var(--rail) !important; | |
| width:100% !important; | |
| margin-left:auto !important; | |
| margin-right:auto !important; | |
| } | |
| /* 1) Karte als durchgehender, hellblauer Hintergrund */ | |
| .scene-card.feedback-card{ | |
| background:#f5f9ff !important; | |
| border:1px solid #d7e3ff !important; | |
| border-radius:12px !important; | |
| overflow:hidden; | |
| } | |
| .scene-card.feedback-card .scenebox .title{ | |
| background:#eaf2ff !important; | |
| border-bottom:1px solid #d7e3ff !important; | |
| } | |
| /* 2) Alle inneren Gradio-Wrapper im Feedback-Bereich transparent & randlos */ | |
| #results_panel :where(.gr-box,.gr-panel,.gr-group,.gr-form,.gr-textbox,.gr-input){ | |
| background:transparent !important; | |
| border:0 !important; | |
| box-shadow:none !important; | |
| padding-left:0 !important; | |
| padding-right:0 !important; | |
| } | |
| /* Fallback für evtl. zusätzliche Container */ | |
| #results_panel > div{ background:transparent !important; box-shadow:none !important; border:0 !important; } | |
| /* 3) Felder & Button bündig zur Karte (keine eigenen Hintergründe/Radien) */ | |
| #feedback-box-res textarea, | |
| #email_box_res input{ | |
| background:#fff !important; | |
| border:0 !important; | |
| border-top:1px solid #d7e3ff !important; | |
| border-radius:0 !important; | |
| width:100% !important; | |
| box-sizing:border-box !important; | |
| padding:12px 14px !important; | |
| } | |
| #feedback-box-res textarea{ border-top:0 !important; } | |
| #send_feedback_btn{ | |
| display:block !important; | |
| width:100% !important; | |
| margin:0 !important; | |
| border:0 !important; | |
| border-top:1px solid #d7e3ff !important; | |
| border-radius:0 0 12px 12px !important; | |
| background:#111 !important; | |
| color:#fff !important; | |
| font-weight:800 !important; | |
| padding:.95rem 1.2rem !important; | |
| } | |
| /* === Feedback: eine einzige hellblaue Karte hinter ALLEM ================== */ | |
| #feedback_card{ | |
| background:#f5f9ff !important; /* durchgehender Kartenhintergrund */ | |
| border:1px solid #d7e3ff !important; | |
| border-radius:12px !important; | |
| box-shadow: var(--shadow); | |
| padding:0 !important; /* wir steuern Innenabstände gezielt */ | |
| max-width:var(--rail) !important; | |
| margin:16px auto !important; | |
| overflow:hidden; /* bündige Kanten */ | |
| } | |
| /* Kopfbereich der Karte */ | |
| #feedback_card .fc-head{ | |
| background:#eaf2ff; | |
| border-bottom:1px solid #d7e3ff; | |
| padding:12px 16px; | |
| } | |
| #feedback_card .fc-title{ font-weight:700; } | |
| #feedback_card .fc-sub{ margin-top:6px; } | |
| /* Alle Gradio-Wrapper innen transparent & randlos */ | |
| #feedback_card :where(.gr-box,.gr-panel,.gr-group,.gr-form,.gr-textbox,.gr-input, .gr-block){ | |
| background:transparent !important; | |
| border:0 !important; | |
| box-shadow:none !important; | |
| padding-left:0 !important; | |
| padding-right:0 !important; | |
| } | |
| /* Felder als weißer „Stack“ in der blauen Karte */ | |
| #feedback_card #feedback-box-res textarea, | |
| #feedback_card #email_box_res input{ | |
| width:100% !important; | |
| box-sizing:border-box !important; | |
| background:#fff !important; | |
| border:0 !important; | |
| border-top:1px solid #d7e3ff !important; /* feine Trenner */ | |
| border-radius:0 !important; | |
| padding:12px 14px !important; | |
| } | |
| #feedback_card #feedback-box-res textarea{ border-top:0 !important; } | |
| /* Sende-Button bündig als Kartenfuß */ | |
| #feedback_card #send_feedback_btn{ | |
| display:block !important; | |
| width:100% !important; | |
| margin:0 !important; | |
| border:0 !important; | |
| border-top:1px solid #d7e3ff !important; | |
| border-radius:0 0 12px 12px !important; | |
| background:#111 !important; | |
| color:#fff !important; | |
| font-weight:800 !important; | |
| padding:.95rem 1.2rem !important; | |
| } | |
| /* Sicherheitsleine gegen graue Ränder links/rechts */ | |
| #feedback_card > div{ background:transparent !important; } | |
| /* Nur Abstand & klare Trennung – Rest bleibt gleich */ | |
| .evalbox{ | |
| margin-bottom: 24px !important; /* mehr Luft unter dem Lernziel-Block */ | |
| } | |
| #feedback_card{ | |
| margin-top: 24px !important; /* Abstand nach oben */ | |
| background:#f5f9ff !important; /* hellblauer Hintergrund beibehalten */ | |
| border:1px solid #d7e3ff !important; | |
| border-radius:12px !important; | |
| box-shadow:var(--shadow) !important; | |
| overflow:hidden; /* saubere Kanten */ | |
| } | |
| /* Thank-you / status box under the submit button */ | |
| #thanks_msg .thanks{ | |
| margin-top:10px; | |
| padding:.9rem 1rem; | |
| border:1px solid #bbf7d0; | |
| background:#f0fff7; | |
| border-radius:10px; | |
| font-weight:600; | |
| } | |
| #thanks_msg .thanks.error{ | |
| border-color:#fecaca; | |
| background:#fff1f2; | |
| } | |
| </style> | |
| """) | |
| gr.HTML(""" | |
| <script> | |
| (function () { | |
| // Find the hidden helper button (#showChoices) even if it's inside shadow DOM | |
| function findShowChoices() { | |
| const app = document.querySelector('gradio-app'); | |
| const roots = [document, app, app && app.shadowRoot].filter(Boolean); | |
| for (const root of roots) { | |
| const el = root.querySelector && root.querySelector('#showChoices, #showChoices button'); | |
| if (el) return el; | |
| } | |
| return null; | |
| } | |
| function handleRetry(e) { | |
| const target = e.target && (e.target.closest ? e.target.closest('.inline-retry') : null); | |
| if (!target) return; | |
| e.preventDefault(); | |
| const btn = findShowChoices(); | |
| if (btn && typeof btn.click === 'function') btn.click(); | |
| } | |
| function install(root) { | |
| if (!root || !root.addEventListener) return; | |
| root.addEventListener('click', handleRetry, { passive: false }); | |
| root.addEventListener('keydown', function (e) { | |
| const el = e.target && (e.target.closest ? e.target.closest('.inline-retry') : null); | |
| if (!el) return; | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| const btn = findShowChoices(); | |
| if (btn && typeof btn.click === 'function') btn.click(); | |
| } | |
| }, { passive: false }); | |
| } | |
| // Attach to document AND gradio-app shadow root (if present) | |
| install(document); | |
| const app = document.querySelector('gradio-app'); | |
| if (app && app.shadowRoot) install(app.shadowRoot); | |
| })(); | |
| </script> | |
| """) | |
| # Init | |
| def _init_empty(): | |
| return gr.update(value=""), gr.update(visible=True) | |
| demo.load(fn=_init_empty, inputs=None, outputs=[transcript_md, intro_wrap]) | |
| # Start | |
| start_btn.click( | |
| fn=start_session, inputs=None, | |
| outputs=[ | |
| session_id, scene_idx, prev_ok_choice, transcript, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices, | |
| btnA, btnB, btnC, btnD, btnE, btnF, btnG, btnH, btnI, | |
| next_btn, status_md, | |
| results_panel, score_md_res, feedback_box_res, email_box_res, send_feedback_res, result_thanks, | |
| progress_html | |
| ], | |
| ).then(lambda t: gr.update(value="\n".join(t)), inputs=transcript, outputs=transcript_md | |
| ).then(lambda: gr.update(visible=False), inputs=None, outputs=intro_wrap | |
| ).then(lambda: gr.update(visible=True), inputs=None, outputs=restart_btn) | |
| # Button-Handler | |
| def click_letter(display_letter: str): | |
| def _fn(session_id, scene_idx, prev_ok_choice, transcript, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices, request: gr.Request): | |
| return choose_and_feedback(session_id, scene_idx, prev_ok_choice, transcript, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices, display_letter, request) | |
| return _fn | |
| for button, letter in zip([btnA, btnB, btnC, btnD, btnE, btnF, btnG, btnH, btnI], LETTERS): | |
| button.click( | |
| fn=click_letter(letter), | |
| inputs=[session_id, scene_idx, prev_ok_choice, transcript, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices], | |
| outputs=[ | |
| transcript, eval_scores, eval_counts, | |
| btnA, btnB, btnC, btnD, btnE, btnF, btnG, btnH, btnI, | |
| next_btn, prev_ok_choice, letter_map, julia_deferred, blocked_choices, status_md, | |
| results_panel, score_md_res, feedback_box_res, email_box_res, send_feedback_res, result_thanks | |
| ], | |
| ).then(lambda t: gr.update(value="\n".join(t)), inputs=transcript, outputs=transcript_md) | |
| # Trigger zum Einblenden der (gefilterten) Options-Buttons | |
| show_choices.click( | |
| fn=show_choice_buttons, | |
| inputs=[session_id, scene_idx, letter_map, blocked_choices], | |
| outputs=[btnA, btnB, btnC, btnD, btnE, btnF, btnG, btnH, btnI] | |
| ) | |
| # Weiter | |
| next_btn.click( | |
| fn=go_next, | |
| inputs=[session_id, scene_idx, prev_ok_choice, transcript, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices], | |
| outputs=[ | |
| transcript, | |
| btnA, btnB, btnC, btnD, btnE, btnF, btnG, btnH, btnI, | |
| next_btn, scene_idx, prev_ok_choice, eval_scores, eval_counts, letter_map, julia_deferred, blocked_choices, | |
| results_panel, score_md_res, feedback_box_res, email_box_res, send_feedback_res, result_thanks, | |
| progress_html | |
| ], | |
| ).then(lambda t: gr.update(value="\n".join(t)), inputs=transcript, outputs=transcript_md) | |
| # Feedback unten | |
| send_feedback_res.click( | |
| fn=submit_feedback, | |
| inputs=[session_id, feedback_box_res, email_box_res], | |
| outputs=[result_thanks, feedback_box_res, email_box_res] | |
| ) | |
| # Neustart | |
| def reset_app(): | |
| # Transcript leeren, Intro wieder zeigen, Auswertung verstecken & leeren | |
| return ( | |
| gr.update(value=""), # transcript_md | |
| gr.update(visible=True), # intro_wrap | |
| gr.update(visible=False), # results_panel | |
| gr.update(value=""), # score_md_res | |
| gr.update(value=render_progress(0)) | |
| ) | |
| #restart_btn.click(fn=reset_app, inputs=None, outputs=[transcript_md, intro_wrap] | |
| restart_btn.click( | |
| fn=reset_app, | |
| inputs=None, | |
| outputs=[transcript_md, intro_wrap, results_panel, score_md_res, progress_html] | |
| ).then( | |
| lambda: gr.update(visible=False), inputs=None, outputs=restart_btn | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |