Spaces:
Running
Running
| """Format WitGymResponse traces for Gradio β styled HTML transcript.""" | |
| import html | |
| import json | |
| import re | |
| from dataclasses import dataclass, field | |
| from typing import List, Tuple, Any, Optional | |
| from witgym.schemas import WitGymResponse, PipelineEvent, ComedyMetadata, CandidateResponse, TranscriptScene | |
| from witgym.avatars import char_avatar_url | |
| # Maps character first-name or full-name β title string | |
| _CHAR_TITLES: dict[str, str] = { | |
| "michael": "Regional Manager", | |
| "dwight": "Assistant (to the) Reg. Manager", | |
| "jim": "Sales Representative", | |
| "pam": "Receptionist", | |
| "kevin": "Accountant", | |
| "andy": "Sales Representative", | |
| "stanley": "Sales Representative", | |
| "angela": "Head of Accounting", | |
| "ryan": "Temp β VP β Temp", | |
| "kelly": "Customer Service Rep", | |
| "michael scott": "Regional Manager", | |
| "dwight schrute": "Asst. (to the) Reg. Manager", | |
| "jim halpert": "Sales Representative", | |
| "pam beesly": "Receptionist", | |
| "kevin malone": "Accountant", | |
| "andy bernard": "Sales Representative", | |
| "stanley hudson": "Sales Representative", | |
| "angela martin": "Head of Accounting", | |
| "ryan howard": "Temp β VP β Temp", | |
| "kelly kapoor": "Customer Service Rep", | |
| } | |
| def _avatar_url(character: str) -> str: | |
| return char_avatar_url(character) | |
| def _char_title(character: str) -> str: | |
| key = character.lower().strip() | |
| return _CHAR_TITLES.get(key, "The Office") | |
| def _esc(text: str) -> str: | |
| return html.escape(text or "") | |
| def _fmt_chip(value: str) -> str: | |
| return value.replace("_", " ").upper() | |
| # Succinct definitions for each comedy structural property β shown on chip click | |
| _CHIP_DEFS: dict[str, tuple[str, str]] = { | |
| # Archetypes | |
| "status_assertion": ("ARCHETYPE Β· STATUS ASSERTION", "What it is: someone is trying to claim authority, status, or competence. Why it matters: the joke works by exposing the gap between the claim and the reality."), | |
| "self_delusion": ("ARCHETYPE Β· SELF-DELUSION", "What it is: someone believes a flattering story about themselves that reality cannot support. Why it matters: comedy lands when the fantasy finally runs into the facts."), | |
| "power_inversion": ("ARCHETYPE Β· POWER INVERSION", "What it is: the expected pecking order flips. Why it matters: surprise grows when the person who should control the room suddenly loses it."), | |
| "anxiety_escalation": ("ARCHETYPE Β· ANXIETY ESCALATION", "What it is: a small worry spirals into a full disaster movie. Why it matters: the joke gets sharper as the reaction becomes bigger than the actual problem."), | |
| "social_fail": ("ARCHETYPE Β· SOCIAL PERFORMANCE FAIL", "What it is: someone tries to look normal, smooth, or impressive and fumbles it. Why it matters: comedy comes from watching the performance collapse in public."), | |
| "misplaced_conf": ("ARCHETYPE Β· MISPLACED CONFIDENCE", "What it is: someone sounds certain without having the facts. Why it matters: the joke exposes confidence that has outrun competence."), | |
| # Tensions | |
| "social_embarrass": ("TENSION Β· SOCIAL EMBARRASSMENT", "What it is: the fear of looking foolish in front of other people. Why it matters: embarrassment raises the emotional stakes, so even a short line can hit hard."), | |
| "existential": ("TENSION Β· EXISTENTIAL ANXIETY", "What it is: the moment quietly touches identity, meaning, or dread. Why it matters: deeper fear gives the joke weight instead of making it feel throwaway."), | |
| "status_threat": ("TENSION Β· STATUS THREAT", "What it is: someone's rank, credibility, or belonging feels under pressure. Why it matters: status danger creates friction, and friction gives the line bite."), | |
| "identity_expose": ("TENSION Β· IDENTITY EXPOSURE", "What it is: the person's mask is slipping. Why it matters: jokes get stronger when they reveal the truth the speaker was trying to hide."), | |
| "logic_collapse": ("TENSION Β· LOGIC COLLAPSE", "What it is: the person's explanation breaks under its own rules. Why it matters: once the logic collapses, the line can simply point at the wreckage."), | |
| # Distances | |
| "mild": ("SHARPNESS Β· MILD VIOLATION", "What it is: a safe, lightly subversive joke. Why it matters: this keeps the line playful instead of confrontational."), | |
| "moderate": ("SHARPNESS Β· MODERATE VIOLATION", "What it is: a joke with some edge, but still recoverable. Why it matters: this is often the sweet spot for sounding bold without losing the room."), | |
| "sharp": ("SHARPNESS Β· SHARP VIOLATION", "What it is: a line that cuts close to the bone. Why it matters: sharper jokes can win bigger laughs, but they also raise the risk of silence."), | |
| } | |
| _PERSONA_DEFS: dict[str, tuple[str, str]] = { | |
| "cynic": ("PERSONA Β· CYNIC", "This lens says the quiet part out loud. It spots the real motive, the hidden cost, or the ugly truth underneath the situation."), | |
| "conviction": ("PERSONA Β· CONVICTION", "This lens commits completely to a wrong belief. It is funny because the certainty exposes something true about the speaker."), | |
| "absurdist": ("PERSONA Β· ABSURDIST", "This lens follows the situation's logic farther than a normal person would. It makes the joke by treating the spiral as perfectly reasonable."), | |
| "bisociate": ("PERSONA Β· BISOCIATE", "This lens jumps to a different but structurally matching world. It works when the same comic pattern suddenly shows up somewhere unexpected."), | |
| } | |
| def _jstr(text: str) -> str: | |
| """JSON-encode a string for safe embedding in an HTML onclick attribute.""" | |
| return json.dumps(text) | |
| _MODE_BADGES = { | |
| "banter": '<span class="wg-mode-badge wg-mode-banter">β‘ BANTER</span>', | |
| "quick_wit": '<span class="wg-mode-badge wg-mode-wit">π― QUICK WIT</span>', | |
| "coaching": '<span class="wg-mode-badge wg-mode-coach">π COACHING</span>', | |
| "humour": '<span class="wg-mode-badge wg-mode-wit">π― QUICK WIT</span>', | |
| "smalltalk": '<span class="wg-mode-badge wg-mode-banter">β‘ BANTER</span>', | |
| } | |
| def _mode_badge_html(route: str) -> str: | |
| return _MODE_BADGES.get(route, _MODE_BADGES["quick_wit"]) | |
| def _coach_header_html(selected_char: str) -> str: | |
| """Render 'JIM SAYS:' header with tiny avatar when a character is selected.""" | |
| if not selected_char or selected_char == "AI": | |
| return '<div class="wg-coach-reply-header">Your humor coach</div>' | |
| av = _avatar_url(selected_char) | |
| name = selected_char.upper() | |
| return ( | |
| f'<div class="wg-coach-reply-header wg-coach-reply-header--char">' | |
| f'<img src="{_esc(av)}" alt="{_esc(selected_char)}" class="wg-coach-reply-avatar" ' | |
| f'onerror="this.style.display=\'none\'"/>' | |
| f'{_esc(name)} SAYS:' | |
| f'</div>' | |
| ) | |
| def _reply_actions_html() -> str: | |
| return ( | |
| '<div class="wg-reply-actions">' | |
| '<button class="wg-action-btn wg-copy-btn" onclick="wgCopy(this)" title="Copy to clipboard" ' | |
| 'aria-label="Copy line to clipboard" data-icon="copy">' | |
| '<svg viewBox="0 0 20 20" class="wg-action-icon" aria-hidden="true">' | |
| '<rect x="7" y="4" width="8" height="10" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/>' | |
| '<path d="M5 7H4.5A1.5 1.5 0 0 0 3 8.5v7A1.5 1.5 0 0 0 4.5 17h5A1.5 1.5 0 0 0 11 15.5V15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' | |
| '</svg></button>' | |
| '</div>' | |
| ) | |
| def _trace_payload_from_result(result: WitGymResponse) -> dict[str, Any]: | |
| metadata = result.metadata.model_dump(mode="json") | |
| scenes = [scene.model_dump(mode="json") for scene in result.retrieved_scenes] | |
| candidates = [candidate.model_dump(mode="json") for candidate in result.candidates] | |
| logs = [ | |
| {"step": "metadata", "status": "ok", "detail": f"twist={metadata.get('twist_potential')} archetype={metadata.get('archetype')}"}, | |
| {"step": "retrieval", "status": "ok", "detail": ", ".join(f"{s['character']}:{s['archetype']}" for s in scenes) or "no precedent scenes"}, | |
| {"step": "candidate_generation", "status": "ok", "detail": ", ".join(f"{c['persona']}:{len(c['text'].split())}w" for c in candidates) or "no candidates"}, | |
| {"step": "ranking", "status": "ok", "detail": result.winning_persona or "none"}, | |
| {"step": "compression", "status": "ok", "detail": "selected line finalized"}, | |
| ] | |
| return { | |
| "route": result.route, | |
| "winning_persona": result.winning_persona, | |
| "selected": result.selected, | |
| "metadata": metadata, | |
| "retrieved_scenes": scenes, | |
| "candidates": candidates, | |
| "explanation": result.explanation, | |
| "logs": logs, | |
| } | |
| def _trace_payload_from_stream(state: "StreamingTurnState", selected_text: str) -> dict[str, Any]: | |
| metadata = state.metadata.model_dump(mode="json") if state.metadata else None | |
| scenes = [scene.model_dump(mode="json") for scene in state.scenes] | |
| candidates = [candidate.model_dump(mode="json") for candidate in state.candidates] | |
| logs = [] | |
| if metadata: | |
| logs.append({"step": "metadata", "status": "ok", "detail": f"twist={metadata.get('twist_potential')} archetype={metadata.get('archetype')}"}) | |
| if scenes: | |
| logs.append({"step": "retrieval", "status": "ok", "detail": ", ".join(f"{s['character']}:{s['archetype']}" for s in scenes)}) | |
| if candidates: | |
| logs.append({"step": "candidate_generation", "status": "ok", "detail": ", ".join(f"{c['persona']}:{len(c['text'].split())}w" for c in candidates)}) | |
| if state.winning_persona: | |
| logs.append({"step": "ranking", "status": "ok", "detail": state.winning_persona}) | |
| if state.streaming_final or state.final_text: | |
| logs.append({"step": "compression", "status": "running" if state.streaming_final else "ok", "detail": "polishing final line" if state.streaming_final else "selected line finalized"}) | |
| return { | |
| "route": state.route, | |
| "winning_persona": state.winning_persona, | |
| "selected": selected_text or state.selected, | |
| "final_text": state.final_text or None, | |
| "metadata": metadata, | |
| "retrieved_scenes": scenes, | |
| "candidates": candidates, | |
| "active_candidate": ( | |
| {"persona": state.active_persona, "partial_text": state.active_candidate_text} | |
| if state.active_persona and state.active_candidate_text else None | |
| ), | |
| "streaming_final": state.streaming_final, | |
| "logs": logs, | |
| } | |
| def _compact_reply_html(route: str, selected: str, *, coaching_hint: str = "", selected_char: str = "AI") -> str: | |
| hint = f'<div class="wg-dim-italic" style="margin-top:.35rem;font-size:.85rem">{_esc(coaching_hint)}</div>' if coaching_hint else "" | |
| return ( | |
| f'{_mode_badge_html(route)}' | |
| f'<div class="wg-coach-reply wg-coach-reply--compact" ' | |
| f'data-char="{_esc(selected_char or "AI")}">' | |
| f'{_reply_actions_html()}' | |
| f'{_coach_header_html(selected_char)}' | |
| f'<div class="wg-coach-reply-body">{_esc(selected)}</div>' | |
| f'{hint}' | |
| '</div>' | |
| ) | |
| def _explanation_panel_html(explanation: str) -> str: | |
| return ( | |
| '<div class="wg-panel wg-panel-blue" style="margin-top:.5rem">' | |
| '<div class="wg-panel-title">WHY IT WORKS</div>' | |
| f'<div>{_esc(explanation)}</div>' | |
| '</div>' | |
| ) | |
| _THINKING_ICON = ( | |
| '<svg class="wg-thinking-icon" viewBox="0 0 24 24" width="18" height="18" ' | |
| 'aria-hidden="true" fill="none" xmlns="http://www.w3.org/2000/svg">' | |
| '<circle cx="12" cy="12" r="9" stroke="#2d6a4f" stroke-width="2" opacity="0.3"/>' | |
| '<path d="M12 3a9 9 0 0 1 9 9" stroke="#2d6a4f" stroke-width="2.5" stroke-linecap="round"/>' | |
| '</svg>' | |
| ) | |
| _STABLE_LOADING_LINE = "Checking Dunder Mifflin playbookβ¦" | |
| def _sanitize_streaming_reply_text(text: str) -> str: | |
| raw = (text or "").strip() | |
| if not raw: | |
| return "" | |
| json_match = re.search(r'"compressed_line"\s*:\s*"((?:[^"\\]|\\.)*)"', raw) | |
| if json_match: | |
| try: | |
| return json.loads(f'"{json_match.group(1)}"').strip() | |
| except Exception: | |
| return json_match.group(1).strip() | |
| if raw.startswith("{"): | |
| try: | |
| obj = json.loads(raw) | |
| value = next(iter(obj.values())) if obj else "" | |
| return str(value).strip() | |
| except Exception: | |
| return "" | |
| return raw | |
| def thinking_turn_html(user_input: str) -> str: | |
| return ( | |
| '<div class="wg-turn wg-turn--thinking">' | |
| f'<div class="wg-user"><span class="wg-label">You</span> {_esc(user_input)}</div>' | |
| f'<div class="wg-thinking">{_THINKING_ICON}' | |
| f'<span>{_esc(_STABLE_LOADING_LINE)}</span></div>' | |
| '</div>' | |
| ) | |
| def _chip_onclick(value: str) -> str: | |
| title, defn = _CHIP_DEFS.get(value, (_fmt_chip(value), "A comedy structural property.")) | |
| return f"wgOpenChip({_jstr(title)},{_jstr(defn)})" | |
| def _persona_onclick(value: str) -> str: | |
| title, defn = _PERSONA_DEFS.get(value, (f"PERSONA Β· {_fmt_chip(value)}", "This is the comic lens the coach used to write the line.")) | |
| return f"wgOpenChip({_jstr(title)},{_jstr(defn)})" | |
| def _insight_button(label: str, onclick: str) -> str: | |
| return ( | |
| f'<button class="wg-insight-btn" type="button" onclick="{_esc(onclick)}">' | |
| f'{_esc(label)}' | |
| '</button>' | |
| ) | |
| def _coach_notes_html(result: WitGymResponse) -> str: | |
| meta = result.metadata | |
| buttons = [ | |
| _insight_button("situation pattern", _chip_onclick(meta.archetype.value)), | |
| _insight_button("social pressure", _chip_onclick(meta.tension_type.value)), | |
| _insight_button( | |
| f"complexity {meta.twist_potential}/10", | |
| f"wgOpenChip({_jstr('COMEDY COMPLEXITY')},{_jstr('What it is: how many moving parts this moment has. Why it matters: lower scores usually want one clean observation, while higher scores can support a twistier line.')})", | |
| ), | |
| _insight_button( | |
| f"retrieved context Β· {len(result.retrieved_scenes)}", | |
| f"wgOpenChip({_jstr('RETRIEVED CONTEXT')},{_jstr('These are Office scenes with a similar comedy pattern. They are reference cases for how the joke works, not lines the coach is copying.')})", | |
| ), | |
| ] | |
| if result.winning_persona: | |
| buttons.append(_insight_button("persona lens", _persona_onclick(result.winning_persona))) | |
| return ( | |
| '<div class="wg-insight-strip">' | |
| '<div class="wg-insight-title">How WitGym built this line</div>' | |
| '<div class="wg-insight-sub">Tap any note for a quick explanation.</div>' | |
| '<div class="wg-insight-buttons">' | |
| + "".join(buttons) + | |
| '</div>' | |
| '</div>' | |
| ) | |
| def _meta_pass1_html(meta) -> str: | |
| arc_click = _chip_onclick(meta.archetype.value) | |
| ten_click = _chip_onclick(meta.tension_type.value) | |
| vio_click = _chip_onclick(meta.violation_distance.value) | |
| _connector_onclick = "wgOpenChip(" + _jstr("CONNECTOR") + "," + _jstr("A word or phrase with two simultaneous readings. When it lands, both meanings hit at once β thatβs the mechanism.") + ")" | |
| connector_chip = ( | |
| f'<div class="wg-chip-row">' | |
| f'<span class="wg-chip-label">connector</span>' | |
| f'<span class="wg-chip wg-chip-green wg-chip-clickable"' | |
| f' onclick="{_esc(_connector_onclick)}"' | |
| f' title="what is a connector?">{_esc(meta.connector)}</span>' | |
| f'</div>' | |
| ) if meta.connector else "" | |
| return ( | |
| '<div class="wg-panel wg-panel-yellow">' | |
| '<div class="wg-panel-title">Neurology of Comedy</div>' | |
| '<div class="wg-chip-row">' | |
| f'<span class="wg-chip wg-chip-cyan wg-chip-clickable" onclick="{_esc(arc_click)}" title="what is this archetype?">{_esc(_fmt_chip(meta.archetype.value))}</span>' | |
| f'<span class="wg-chip wg-chip-purple wg-chip-clickable" onclick="{_esc(ten_click)}" title="what is this tension?">{_esc(_fmt_chip(meta.tension_type.value))}</span>' | |
| f'<span class="wg-chip wg-chip-orange wg-chip-clickable" onclick="{_esc(vio_click)}" title="what is this distance?">{_esc(_fmt_chip(meta.violation_distance.value))}</span>' | |
| '</div>' | |
| f'{connector_chip}' | |
| f'<div class="wg-avoided"><span class="wg-dim">avoided β</span> ' | |
| f'<span class="wg-dim-italic">{_esc(meta.obvious_response)}</span></div>' | |
| '<div class="wg-capsule wg-capsule--new">' | |
| '<div class="wg-capsule-head">SUBTEXT <span class="wg-cap-chev">βΆ</span></div>' | |
| f'<div class="wg-capsule-body wg-collapsed">{_esc(meta.subtext)}</div>' | |
| '</div>' | |
| '<div class="wg-capsule wg-capsule--new">' | |
| '<div class="wg-capsule-head">POWER DYNAMIC <span class="wg-cap-chev">βΆ</span></div>' | |
| f'<div class="wg-capsule-body wg-collapsed">{_esc(meta.power_dynamic)}</div>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| def _debug_panels_html(result: WitGymResponse) -> str: | |
| trace = _trace_payload_from_result(result) | |
| trace_json = json.dumps(trace, indent=2, ensure_ascii=True) | |
| return ( | |
| '<div class="wg-trace-block">' | |
| '<div class="wg-trace-title">TRACE JSON</div>' | |
| f'<pre class="wg-trace-json">{_esc(trace_json)}</pre>' | |
| '</div>' | |
| ) | |
| class StreamingTurnState: | |
| user_input: str | |
| metadata: Optional[ComedyMetadata] = None | |
| scenes: List[TranscriptScene] = field(default_factory=list) | |
| candidates: List[CandidateResponse] = field(default_factory=list) | |
| selected: str = "" | |
| winning_persona: Optional[str] = None | |
| route: str = "humour" | |
| active_persona: Optional[str] = None | |
| active_candidate_text: str = "" | |
| streaming_final: bool = False | |
| final_text: str = "" | |
| def apply_stream_event(state: StreamingTurnState, event: PipelineEvent) -> None: | |
| if event.phase == "banter" and event.response: | |
| state.route = "banter" | |
| state.selected = event.response.selected | |
| return | |
| if event.phase == "coaching_ask" and event.response: | |
| state.route = "coaching" | |
| state.selected = event.response.selected | |
| return | |
| if event.phase == "smalltalk" and event.response: | |
| state.route = "banter" | |
| state.selected = event.response.selected | |
| return | |
| if event.metadata is not None: | |
| state.metadata = event.metadata | |
| if event.scenes is not None: | |
| state.scenes = event.scenes | |
| if event.candidates is not None: | |
| state.candidates = event.candidates | |
| if event.phase == "candidate_start": | |
| state.active_persona = event.persona | |
| state.active_candidate_text = "" | |
| elif event.phase == "candidate_token": | |
| state.active_persona = event.persona | |
| state.active_candidate_text = event.partial_text | |
| elif event.phase == "candidate_done": | |
| state.active_persona = None | |
| state.active_candidate_text = "" | |
| elif event.phase == "ranked": | |
| state.selected = event.selected or "" | |
| state.winning_persona = event.winning_persona | |
| state.streaming_final = False | |
| state.final_text = state.selected | |
| elif event.phase == "final_start": | |
| state.streaming_final = True | |
| state.final_text = event.partial_text or state.selected | |
| elif event.phase == "final_token": | |
| state.streaming_final = True | |
| state.final_text = event.partial_text | |
| elif event.phase == "done" and event.response: | |
| state.final_text = event.response.selected | |
| state.selected = event.response.selected | |
| state.candidates = event.response.candidates | |
| state.route = event.response.route | |
| state.streaming_final = False | |
| def _streaming_debug_panels_html(state: StreamingTurnState, selected_text: str) -> str: | |
| if state.metadata is None: | |
| return "" | |
| trace = _trace_payload_from_stream(state, selected_text) | |
| trace_json = json.dumps(trace, indent=2, ensure_ascii=True) | |
| return ( | |
| '<div class="wg-trace-block">' | |
| '<div class="wg-trace-title">TRACE JSON</div>' | |
| f'<pre class="wg-trace-json">{_esc(trace_json)}</pre>' | |
| '</div>' | |
| ) | |
| def format_streaming_turn_html(state: StreamingTurnState, show_debug: bool = True) -> str: | |
| parts = [ | |
| '<div class="wg-turn wg-turn--thinking">', | |
| f'<div class="wg-user"><span class="wg-label">You</span> {_esc(state.user_input)}</div>', | |
| ] | |
| if state.route in ("banter", "smalltalk") and state.selected: | |
| parts += [ | |
| _compact_reply_html("banter", state.selected), | |
| '</div>', | |
| ] | |
| return "".join(parts) | |
| if state.route == "coaching" and state.selected and state.metadata is None: | |
| parts += [ | |
| _compact_reply_html( | |
| "coaching", | |
| state.selected, | |
| coaching_hint="coaching mode β waiting for your answer", | |
| ), | |
| '</div>', | |
| ] | |
| return "".join(parts) | |
| if state.metadata is None: | |
| parts += [ | |
| f'<div class="wg-thinking">{_THINKING_ICON}<span>{_esc(_STABLE_LOADING_LINE)}</span></div></div>', | |
| ] | |
| return "".join(parts) | |
| collapsed_cls = "" if show_debug else " wg-collapsed" | |
| chevron = "βΌ" if show_debug else "βΆ" | |
| display_text = _sanitize_streaming_reply_text(state.final_text or state.selected) | |
| persona_label = ( | |
| f' Β· <em class="wg-persona-label">{_esc(state.winning_persona)}</em>' | |
| if state.winning_persona else "" | |
| ) | |
| # Guard: don't display raw JSON tokens or mixed text+JSON tails from compression streaming | |
| raw_display_text = (state.final_text or state.selected or "").strip() | |
| has_json_tail = '"compressed_line"' in raw_display_text or ( | |
| "{" in raw_display_text and not raw_display_text.lstrip().startswith("{") | |
| ) | |
| is_raw_json = bool(raw_display_text and raw_display_text.lstrip().startswith("{")) | |
| # ββ Hero reply FIRST (above fold) ββββββββββββββββββββββββββββββββββββββ | |
| parts.append(_mode_badge_html(state.route)) | |
| if display_text and not is_raw_json and not has_json_tail: | |
| polish = ' Β· <span class="wg-dim-italic">polishingβ¦</span>' if state.streaming_final else "" | |
| parts += [ | |
| '<div class="wg-coach-reply wg-coach-reply--new">', | |
| f'<div class="wg-coach-reply-header">Your humor coach{persona_label}{polish}</div>', | |
| f'<div class="wg-coach-reply-body">{_esc(display_text)}</div>', | |
| '</div>', | |
| ] | |
| elif state.streaming_final or is_raw_json or has_json_tail: | |
| # Compression pass running β show stable placeholder | |
| parts += [ | |
| '<div class="wg-coach-reply wg-coach-reply--new">', | |
| f'<div class="wg-coach-reply-header">Your humor coach{persona_label} Β· <span class="wg-dim-italic">polishingβ¦</span></div>', | |
| f'<div class="wg-coach-reply-body wg-dim-italic">Finding the sharpest versionβ¦</div>', | |
| '</div>', | |
| ] | |
| elif not state.active_persona: | |
| parts += [ | |
| f'<div class="wg-thinking">{_THINKING_ICON}' | |
| '<span>drafting candidatesβ¦</span></div>', | |
| ] | |
| parts.append('<div class="wg-rule"></div></div>') | |
| return "".join(parts) | |
| def format_transcript_with_streaming( | |
| traces: List[Tuple[str, Any]], | |
| stream_state: Optional[StreamingTurnState], | |
| show_debug: bool = True, | |
| max_turns: int = 5, | |
| selected_char: str = "AI", | |
| ) -> str: | |
| if not traces and stream_state is None: | |
| return format_transcript_html([], show_debug=show_debug, selected_char=selected_char) | |
| recent = traces[-max_turns:] | |
| body = "".join( | |
| format_trace_html( | |
| r if isinstance(r, WitGymResponse) else WitGymResponse.model_validate(r), | |
| user_input, | |
| show_debug=show_debug, | |
| is_last=False, | |
| selected_char=selected_char, | |
| ) | |
| for user_input, r in recent | |
| ) | |
| if stream_state is not None: | |
| body += format_streaming_turn_html(stream_state, show_debug=show_debug) | |
| if stream_state is not None and stream_state.metadata is not None: | |
| trace_payload = _trace_payload_from_stream(stream_state, stream_state.selected) | |
| elif recent: | |
| last_result = recent[-1][1] if isinstance(recent[-1][1], WitGymResponse) else WitGymResponse.model_validate(recent[-1][1]) | |
| trace_payload = _trace_payload_from_result(last_result) | |
| else: | |
| trace_payload = None | |
| trace_attr = _esc(json.dumps(trace_payload, ensure_ascii=True)) if trace_payload else "" | |
| return f'<div class="wg-transcript" data-latest-trace="{trace_attr}">{body}</div>{_PAGE_JS}' | |
| def _twist_meter_html(twist_potential: int) -> str: | |
| pct = twist_potential * 10 | |
| return ( | |
| f'<div class="wg-twist-meter">' | |
| f'<span class="wg-twist-label">COMEDY COMPLEXITY</span>' | |
| f'<div class="wg-twist-bar"><div class="wg-twist-fill" style="width:{pct}%"></div></div>' | |
| f'<span class="wg-twist-score">{twist_potential}/10</span>' | |
| f'</div>' | |
| ) | |
| def format_trace_html(result: WitGymResponse, user_input: str, show_debug: bool = True, is_last: bool = False, selected_char: str = "AI") -> str: | |
| parts = [ | |
| '<div class="wg-turn">', | |
| f'<div class="wg-user"><span class="wg-label">You</span> {_esc(user_input)}</div>', | |
| ] | |
| if result.route in ("banter", "smalltalk"): | |
| parts += [ | |
| _compact_reply_html("banter", result.selected, selected_char=selected_char), | |
| '</div>', | |
| ] | |
| return "".join(parts) | |
| if result.route == "coaching" and result.coaching_question and not result.candidates: | |
| parts += [ | |
| _compact_reply_html( | |
| "coaching", | |
| result.selected, | |
| coaching_hint="coaching mode β waiting for your answer", | |
| selected_char=selected_char, | |
| ), | |
| '</div>', | |
| ] | |
| return "".join(parts) | |
| collapsed_cls = "" if show_debug else " wg-collapsed" | |
| chevron = "βΌ" if show_debug else "βΆ" | |
| # Alternate candidates (non-selected) for client-side "another take" | |
| alts = [{"persona": c.persona, "text": c.text} for c in result.candidates if c.text != result.selected] | |
| alts_json = html.escape(json.dumps(alts)) | |
| # Winning persona label β use pre-compression value stored in response | |
| winning_persona = result.winning_persona | |
| persona_label = ( | |
| f' Β· <button class="wg-persona-label" type="button" data-persona="{_esc(winning_persona)}">{_esc(winning_persona)}</button>' | |
| if winning_persona else "" | |
| ) | |
| # Another take button (only if alternatives exist) | |
| another_take_btn = ( | |
| '<span class="wg-another-take" onclick="wgAnotherTake(this)" title="Try another candidate">β» another take</span>' | |
| if alts else "" | |
| ) | |
| # New-turn reveal class for animation | |
| new_cls = " wg-coach-reply--new" if is_last else "" | |
| beckon_cls = " wg-debug-toggle--new" if is_last else "" | |
| # ββ Hero reply FIRST (above fold) ββββββββββββββββββββββββββββββββββββββ | |
| coach_hdr = ( | |
| f'<div class="wg-coach-reply-header wg-coach-reply-header--char">' | |
| + (f'<img src="{_esc(_avatar_url(selected_char))}" alt="{_esc(selected_char)}" class="wg-coach-reply-avatar" onerror="this.style.display=\'none\'"/>' | |
| if selected_char and selected_char != "AI" else "") | |
| + (f'{_esc(selected_char.upper())} SAYS:' if selected_char and selected_char != "AI" else f'Your humor coach{persona_label}') | |
| + f'{another_take_btn}</div>' | |
| ) | |
| parts += [ | |
| _mode_badge_html(result.route), | |
| ( | |
| f'<div class="wg-coach-reply{new_cls}" data-alts="{alts_json}" data-alt-idx="0" ' | |
| f'data-char="{_esc(selected_char or "AI")}">' | |
| ), | |
| f'{_reply_actions_html()}', | |
| coach_hdr, | |
| f'<div class="wg-coach-reply-body">{_esc(result.selected)}</div>', | |
| '</div>', | |
| ] | |
| if result.explanation: | |
| parts.append(_explanation_panel_html(result.explanation)) | |
| parts.append(_coach_notes_html(result)) | |
| # ββ Drill chips (coaching follow-ups) ββββββββββββββββββββββββββββββββββ | |
| if is_last: | |
| parts.append( | |
| '<div class="wg-drill-chips">' | |
| '<span class="wg-drill-chip" onclick="wgDrill(\'Make that line sharper and more cutting\')">sharpen it β</span>' | |
| '<span class="wg-drill-chip" onclick="wgDrill(\'Give me a completely different angle on the same situation\')">different angle β</span>' | |
| '<span class="wg-drill-chip" onclick="wgDrill(\'Explain why that line works β what comedy principle does it use?\')">why it works β</span>' | |
| '</div>' | |
| ) | |
| parts.append('<div class="wg-rule"></div></div>') | |
| return "".join(parts) | |
| # All JS is in _GLOBAL_JS (app.py, injected via head=) β scripts in gr.HTML value | |
| # are not executed by Gradio 6.x (set via innerHTML). See app.py _GLOBAL_JS. | |
| _PAGE_JS = "" | |
| def format_transcript_html( | |
| traces: List[Tuple[str, Any]], | |
| max_turns: int = 5, | |
| append_html: str = "", | |
| show_debug: bool = True, | |
| selected_char: str = "AI", | |
| ) -> str: | |
| if not traces and not append_html: | |
| body = ( | |
| '<div class="wg-empty">' | |
| '<div class="wg-empty-icon">π€</div>' | |
| '<div class="wg-empty-text" style="font-size:1.05rem;font-weight:600;font-style:normal;color:inherit">Drop the situation.</div>' | |
| '<div class="wg-empty-text" style="margin-top:0.15rem">Your coach will find the line that lands.</div>' | |
| '<div class="wg-empty-text" style="margin-top:0.6rem;font-size:0.8rem;opacity:0.55;font-style:italic">Awkward, delusional, or painfully relatable β all valid.</div>' | |
| '</div>' | |
| ) | |
| else: | |
| recent = traces[-max_turns:] | |
| if recent: | |
| last_trace_payload = _trace_payload_from_result( | |
| recent[-1][1] if isinstance(recent[-1][1], WitGymResponse) else WitGymResponse.model_validate(recent[-1][1]) | |
| ) | |
| trace_attr = _esc(json.dumps(last_trace_payload, ensure_ascii=True)) | |
| else: | |
| trace_attr = "" | |
| body = "".join( | |
| format_trace_html( | |
| r if isinstance(r, WitGymResponse) else WitGymResponse.model_validate(r), | |
| user_input, | |
| show_debug=show_debug, | |
| is_last=(i == len(recent) - 1), | |
| selected_char=selected_char, | |
| ) | |
| for i, (user_input, r) in enumerate(recent) | |
| ) + append_html | |
| return f'<div class="wg-transcript" data-latest-trace="{trace_attr}">{body}</div>{_PAGE_JS}' | |
| return f'<div class="wg-transcript" data-latest-trace="">{body}</div>{_PAGE_JS}' | |
| # Legacy helpers | |
| def format_trace(result: WitGymResponse, user_input: str) -> str: | |
| lines = [f"**You:** {user_input}", f"**WitGym:** {result.selected}", ""] | |
| if result.route not in ("banter", "smalltalk"): | |
| meta = result.metadata | |
| lines.append(f"_archetype={meta.archetype.value}, tension={meta.tension_type.value}_") | |
| return "\n".join(lines) | |
| def format_logs(traces: List[Tuple[str, Any]], max_turns: int = 5) -> str: | |
| return format_transcript_html(traces, max_turns) | |