tomoni.helps / app.py
ai-tomoni's picture
Update app.py
52d31dd verified
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()