Spaces:
Sleeping
Sleeping
| """WitnessBox — Gradio Space entrypoint. | |
| Cross-examine Marcus Reid with your voice. Your *delivery* (perceived vocal | |
| confidence) steers him; surface three contradictions and his voice cracks. | |
| Boots anywhere: with WITNESSBOX_BACKEND unset it runs the offline mock end to | |
| end (type your questions). Set WITNESSBOX_BACKEND=modal + Modal Space secrets | |
| for live Whisper ASR / MiniCPM4.1-8B / VoxCPM2 and push-to-talk. | |
| The UI is a bespoke courtroom instrument panel — a custom SVG delivery gauge, | |
| three wax case-file seals that stamp as contradictions land, and a witness | |
| "dock" whose portrait visibly cracks as Reid breaks — built from custom | |
| HTML/CSS over Gradio's event layer (no stock-component look). | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import io | |
| import math | |
| import os | |
| import numpy as np | |
| import gradio as gr | |
| import config | |
| from witnessbox.backends import get_backends | |
| from witnessbox.engine import WitnessBoxEngine | |
| from witnessbox.witness import WITNESS_NAME, WITNESS_ROLE | |
| # --------------------------------------------------------------------------- # | |
| # baked-in assets (data URIs) — computed once at import, so app.py stays clean | |
| # --------------------------------------------------------------------------- # | |
| def _portrait_uri() -> str: | |
| """The witness portrait, downscaled to a light JPEG data URI for the dock.""" | |
| p = "assets/marcus_reid.png" | |
| if not os.path.exists(p): | |
| return "" | |
| try: | |
| from PIL import Image | |
| im = Image.open(p).convert("RGB") | |
| im.thumbnail((560, 560)) | |
| buf = io.BytesIO() | |
| im.save(buf, "JPEG", quality=82) | |
| return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() | |
| except Exception: | |
| try: | |
| return "data:image/png;base64," + base64.b64encode(open(p, "rb").read()).decode() | |
| except Exception: | |
| return "" | |
| PORTRAIT_DATA_URI = _portrait_uri() | |
| # A fracture overlay for the dock — dark cracks with a faint glass highlight. | |
| _CRACK_SVG = ( | |
| "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'>" | |
| "<g fill='none' stroke='#190d07' stroke-width='0.7' opacity='0.9' stroke-linejoin='round'>" | |
| "<path d='M53 -3 L49 17 L59 32 L46 49 L56 69 L45 103'/>" | |
| "<path d='M49 17 L30 23 M49 17 L70 10'/>" | |
| "<path d='M59 32 L82 38 M46 49 L24 55 M56 69 L80 75 M56 69 L61 90'/>" | |
| "<path d='M30 23 L16 18 M70 10 L84 4 M24 55 L9 61'/>" | |
| "</g>" | |
| "<g fill='none' stroke='#f3e6c6' stroke-width='0.32' opacity='0.45'>" | |
| "<path d='M53.7 -3 L49.7 17 L59.7 32 L46.7 49 L56.7 69 L45.7 103'/>" | |
| "</g></svg>" | |
| ) | |
| WB_CRACK_URI = "data:image/svg+xml;base64," + base64.b64encode(_CRACK_SVG.encode()).decode() | |
| # Subtle paper grain (fractal noise) laid over the parchment. | |
| _NOISE_SVG = ( | |
| "<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'>" | |
| "<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter>" | |
| "<rect width='100%' height='100%' filter='url(#n)'/></svg>" | |
| ) | |
| WB_NOISE_URI = "data:image/svg+xml;base64," + base64.b64encode(_NOISE_SVG.encode()).decode() | |
| # Fonts: a dramatic display serif (masthead/plates) + a typewriter mono (case-file | |
| # captions, seals, evidence). Falls back to a serif stack if the CDN is blocked. | |
| HEAD = ( | |
| "<link rel='preconnect' href='https://fonts.googleapis.com'>" | |
| "<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>" | |
| "<link rel='stylesheet' href='https://fonts.googleapis.com/css2?" | |
| "family=Playfair+Display:wght@600;700;800&family=Cutive+Mono&display=swap'>" | |
| ) | |
| CSS = """ | |
| /* ===== WitnessBox — a bespoke courtroom panel ============================ | |
| Palette: parchment · ink · oxblood · brass. Gradio's CSS variables are | |
| overridden in BOTH light and dark mode (no default indigo anywhere), and | |
| the signature pieces — delivery gauge, case-file seals, witness dock — are | |
| hand-built HTML/SVG, not stock components. */ | |
| .gradio-container, .gradio-container.dark { | |
| --wb-ink:#2b2014; --wb-ink-soft:#6d5a3a; | |
| --wb-card:#f5edd9; --wb-card-2:#efe4cb; | |
| --wb-border:#c7b083; --wb-border-2:#a98c58; | |
| --wb-oxblood:#7c1f25; --wb-oxblood-hi:#94333a; | |
| --wb-brass:#9c7a3c; --wb-walnut:#241a10; | |
| --body-background-fill: radial-gradient(125% 95% at 50% -12%, #f3e9d2 0%, #e7d7af 72%); | |
| --body-text-color: var(--wb-ink); | |
| --body-text-color-subdued: var(--wb-ink-soft); | |
| --background-fill-primary: var(--wb-card); | |
| --background-fill-secondary: var(--wb-card-2); | |
| --block-background-fill: var(--wb-card); | |
| --block-border-color: var(--wb-border); | |
| --block-label-background-fill: var(--wb-oxblood); | |
| --block-label-text-color: #f4e8c9; | |
| --block-title-text-color: var(--wb-ink); | |
| --block-info-text-color: var(--wb-ink-soft); | |
| --border-color-primary: var(--wb-border); | |
| --border-color-accent: var(--wb-brass); | |
| --button-primary-background-fill: linear-gradient(180deg,#8e2731,#6f1b21); | |
| --button-primary-background-fill-hover: linear-gradient(180deg,#9c333b,#7c1f25); | |
| --button-primary-text-color:#f6ecd2; | |
| --button-primary-border-color:#591319; | |
| --button-secondary-background-fill: linear-gradient(180deg,#ecdcb4,#dcc798); | |
| --button-secondary-background-fill-hover: linear-gradient(180deg,#f2e4c0,#e3d0a4); | |
| --button-secondary-text-color: var(--wb-ink); | |
| --button-secondary-border-color: var(--wb-border-2); | |
| --input-background-fill:#fbf6e8; | |
| --input-border-color: var(--wb-border); | |
| --input-placeholder-color:#9a8763; | |
| --color-accent: var(--wb-brass); | |
| --color-accent-soft:#ecdfbc; | |
| --link-text-color: var(--wb-oxblood); | |
| --link-text-color-hover: var(--wb-oxblood-hi); | |
| --block-shadow: 0 2px 9px rgba(60,40,15,.12); | |
| --block-radius:12px; | |
| font-family:'Iowan Old Style','Palatino Linotype','Palatino',Georgia,'Times New Roman',serif; | |
| } | |
| /* Frame the whole app like a document on a desk: parchment everywhere (no dark | |
| gutters), a centred column with inked edges, grain + a soft vignette. */ | |
| html, body, gradio-app {background:#e0cfa6 !important;} | |
| .gradio-container { | |
| background: var(--body-background-fill); | |
| max-width:1180px; margin:0 auto; | |
| border-left:1px solid var(--wb-border-2); border-right:1px solid var(--wb-border-2); | |
| box-shadow:0 0 70px rgba(40,25,8,.14); | |
| } | |
| .gradio-container::after {content:""; position:fixed; inset:0; pointer-events:none; z-index:2; | |
| background-image:url("%%NOISE%%"); background-size:140px; opacity:.045; mix-blend-mode:multiply;} | |
| .gradio-container::before {content:""; position:fixed; inset:0; pointer-events:none; z-index:2; | |
| background:radial-gradient(115% 85% at 50% 42%, transparent 56%, rgba(40,25,8,.22));} | |
| /* ---- masthead ---- */ | |
| #wb-title {text-align:center; padding:12px 0 4px; position:relative; z-index:3;} | |
| #wb-title .wb-crest {font-size:1.45rem; line-height:1; opacity:.82;} | |
| #wb-title h1 {font-family:'Playfair Display',Georgia,serif; color:var(--wb-ink)!important; | |
| font-weight:800; font-size:3rem; letter-spacing:1px; margin:.02em 0 .02em; line-height:1;} | |
| #wb-title .wb-case {font-family:'Cutive Mono',ui-monospace,monospace; text-transform:uppercase; | |
| letter-spacing:2.5px; font-size:.7rem; color:var(--wb-oxblood); margin-bottom:3px;} | |
| #wb-title .wb-sub {color:var(--wb-ink-soft)!important; font-size:1.04rem; letter-spacing:.2px;} | |
| #wb-title .wb-sub b {color:var(--wb-oxblood); font-weight:700;} | |
| #wb-title .wb-rule {width:240px; height:3px; margin:11px auto 0; | |
| border-top:1px solid var(--wb-brass); border-bottom:1px solid var(--wb-brass); opacity:.55;} | |
| /* ---- small-caps block labels everywhere ---- */ | |
| .gradio-container .block-label, .gradio-container label > span:first-child { | |
| font-variant:small-caps; letter-spacing:.6px; font-weight:700;} | |
| /* ---- witness dock: a framed mugshot with a brass plate that reacts to tier ---- */ | |
| .wb-dock {position:relative; z-index:3;} | |
| .wb-dock-frame {position:relative; border:2px solid var(--wb-border-2); border-radius:10px; | |
| overflow:hidden; background:#241a10; | |
| box-shadow:0 7px 20px rgba(40,25,8,.30), inset 0 0 0 4px rgba(0,0,0,.08);} | |
| .wb-mug {display:block; width:100%; height:auto; filter:saturate(.97) contrast(1.02);} | |
| .wb-dock-empty {aspect-ratio:1/1; display:flex; align-items:center; justify-content:center; | |
| color:#caa552; font-size:3rem; background:radial-gradient(circle at 50% 35%,#3a2c1a,#241a10);} | |
| .wb-wash {position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity .6s ease; | |
| mix-blend-mode:multiply;} | |
| .wb-crack {position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity .6s ease; | |
| background:url("%%CRACK%%") center/100% 100% no-repeat;} | |
| .wb-state-tag {position:absolute; left:8px; bottom:8px; font-family:'Cutive Mono',monospace; | |
| font-size:.7rem; letter-spacing:1.5px; text-transform:uppercase; color:#f4e8c9; | |
| background:rgba(36,18,10,.72); padding:2px 9px; border:1px solid var(--wb-brass); border-radius:4px;} | |
| .wb-tier-rattled .wb-wash {opacity:.30; background:radial-gradient(135% 115% at 50% 122%, rgba(150,42,30,.55), transparent 60%);} | |
| .wb-tier-cornered .wb-wash {opacity:.52; background:radial-gradient(140% 120% at 50% 122%, rgba(152,38,28,.72), transparent 62%);} | |
| .wb-tier-breaking .wb-wash {opacity:.78; background:radial-gradient(150% 130% at 50% 122%, rgba(172,30,24,.88), transparent 66%);} | |
| .wb-tier-cornered .wb-crack {opacity:.40;} | |
| .wb-tier-breaking .wb-crack {opacity:.94;} | |
| .wb-tier-breaking .wb-dock-frame {animation:wb-shake 1.7s ease-in-out infinite;} | |
| @keyframes wb-shake {0%,100%{transform:translate(0,0)} 14%{transform:translate(-1px,1px)} | |
| 28%{transform:translate(1px,-1px)} 42%{transform:translate(-1px,0)} 56%{transform:translate(1px,1px)} 70%{transform:translate(-1px,-1px)}} | |
| .wb-plate {margin-top:9px; text-align:center; border:1px solid #7d5f2c; border-radius:6px; | |
| padding:5px 8px; background:linear-gradient(180deg,#bd963f,#9c7a3c); | |
| box-shadow:inset 0 1px 0 #ffe7a8, 0 1px 3px rgba(40,25,8,.25);} | |
| .wb-plate b {display:block; font-family:'Playfair Display',serif; color:#1c1409; | |
| letter-spacing:2.5px; font-size:1.05rem;} | |
| .wb-plate i {font-style:normal; font-family:'Cutive Mono',monospace; color:#33260f; | |
| font-size:.68rem; letter-spacing:1.2px; opacity:.9;} | |
| /* ---- delivery gauge (custom SVG instrument) ---- */ | |
| .wb-instrument {background:var(--wb-card); border:1px solid var(--wb-border); border-radius:10px; | |
| padding:9px 12px 11px; text-align:center; box-shadow:var(--block-shadow); position:relative; z-index:3;} | |
| .wb-instrument-cap, .wb-casefile-cap {font-family:'Cutive Mono',ui-monospace,monospace; font-size:.68rem; | |
| letter-spacing:2.5px; text-transform:uppercase; color:var(--wb-oxblood); margin-bottom:2px;} | |
| .wb-instrument svg {width:100%; max-width:240px; height:auto;} | |
| .wb-gauge-read {display:flex; align-items:baseline; justify-content:center; gap:12px; margin-top:-8px;} | |
| .wb-gauge-tier {font-variant:small-caps; font-weight:800; letter-spacing:1px; font-size:1.3rem;} | |
| .wb-gauge-tier.wb-confident {color:#2f7d3b;} .wb-gauge-tier.wb-neutral {color:#9a7500;} .wb-gauge-tier.wb-hesitant {color:#9c3b2f;} | |
| .wb-gauge-num {font-family:'Cutive Mono',monospace; font-size:1.05rem; color:var(--wb-ink);} | |
| .wb-gauge-num i {font-style:normal; font-size:.66rem; opacity:.6;} | |
| .wb-disclaimer {font-size:10.5px; color:var(--wb-ink-soft); font-style:italic; margin-top:3px; line-height:1.3;} | |
| /* ---- case file: three wax seals + composure/standing meters ---- */ | |
| .wb-casefile {background:var(--wb-card); border:1px solid var(--wb-border); border-radius:10px; | |
| padding:12px 13px 13px; box-shadow:var(--block-shadow); position:relative; z-index:3;} | |
| .wb-seals {display:flex; gap:10px; justify-content:center; margin:7px 0 5px;} | |
| .wb-seal {width:50px; height:50px; border-radius:50%; display:flex; align-items:center; justify-content:center; | |
| font-family:'Playfair Display',serif; font-size:1.15rem; border:2px dashed var(--wb-border-2); | |
| color:var(--wb-border-2); background:#efe3c6;} | |
| .wb-seal.stamped {border:2px solid #5b141a; color:#f6e7c8; transform:rotate(-8deg); | |
| background:radial-gradient(circle at 38% 30%, #ad3f45, #7c1f25 58%, #5c1217); | |
| box-shadow:0 2px 6px rgba(60,10,10,.45), inset 0 1px 2px rgba(255,205,175,.55);} | |
| .wb-seals-cap {text-align:center; font-family:'Cutive Mono',monospace; font-size:.7rem; | |
| letter-spacing:1px; color:var(--wb-ink-soft); margin-bottom:4px;} | |
| .wb-meter {margin-top:11px;} | |
| .wb-meter-top {display:flex; justify-content:space-between; font-size:.8rem; font-variant:small-caps; | |
| letter-spacing:.5px; color:var(--wb-ink); margin-bottom:3px;} | |
| .wb-meter-top i {font-style:normal; font-family:'Cutive Mono',monospace; opacity:.7; font-size:.74rem;} | |
| .wb-meter-track {height:9px; background:#e0d3af; border:1px solid var(--wb-border); border-radius:6px; overflow:hidden;} | |
| .wb-meter-fill {height:100%; transition:width .5s ease;} | |
| /* ---- chatbot = a deposition transcript on aged paper ---- */ | |
| #wb-chat {background:var(--wb-card)!important; border:1px solid var(--wb-border)!important;} | |
| #wb-chat *, #wb-chat .message, #wb-chat .prose {color:var(--wb-ink)!important;} | |
| #wb-chat .message {font-size:1.02rem!important; line-height:1.5!important; border-radius:10px!important;} | |
| #wb-chat .user, #wb-chat [data-role="user"] {background:#efe3c6!important; border:1px solid var(--wb-border)!important;} | |
| #wb-chat .bot, #wb-chat [data-role="assistant"] {background:#fbf6e8!important; border-left:3px solid var(--wb-oxblood)!important;} | |
| #wb-chat .placeholder, #wb-chat .placeholder * {color:var(--wb-ink-soft)!important; opacity:.7;} | |
| /* ---- verdict banner (wax-stamp feel on win/lose) ---- */ | |
| .wb-banner {text-align:center; font-size:1.14rem; font-variant:small-caps; letter-spacing:.6px; | |
| padding:12px; border-radius:10px; border:1px solid var(--wb-border); position:relative; z-index:3;} | |
| .wb-banner-info {background:linear-gradient(180deg,#efe4c7,#e7d9b6); color:#5a4220;} | |
| .wb-banner-win {background:radial-gradient(circle at 50% -25%, #3c6c33, #234020); color:#eef7e6; | |
| border:2px solid #2f7d3b; font-weight:700; text-shadow:0 1px 0 rgba(0,0,0,.3);} | |
| .wb-banner-lose {background:radial-gradient(circle at 50% -25%, #80312f, #4f1d1d); color:#f6e6e6; | |
| border:2px solid #7c1f25; font-weight:700;} | |
| /* ---- evidence: a walnut 'exhibit' readout ---- */ | |
| #wb-evidence textarea {font-family:'Cutive Mono',ui-monospace,Menlo,monospace!important; | |
| background:var(--wb-walnut)!important; color:#ecdfae!important; | |
| border:1px solid var(--wb-brass)!important; border-radius:8px;} | |
| /* ---- component labels = the same oxblood plate (no stray pink/purple) ---- */ | |
| .gradio-container .block-label, .gradio-container .block-label * { | |
| background:var(--wb-oxblood)!important; color:#f4e8c9!important; opacity:1!important; | |
| border-color:var(--wb-border)!important;} | |
| .gradio-container .block-label svg, .gradio-container .block-label path {fill:#f4e8c9!important;} | |
| .gradio-container .label-wrap, .gradio-container .label-wrap > span { | |
| color:var(--wb-ink)!important; font-weight:700; font-variant:small-caps; letter-spacing:.5px;} | |
| .gradio-container .label-wrap .icon, .gradio-container .label-wrap svg {color:var(--wb-oxblood)!important; opacity:1;} | |
| /* primary action reads like a gavel strike */ | |
| .gradio-container button.primary, .gradio-container .primary {font-variant:small-caps; letter-spacing:1px; font-weight:700;} | |
| """ | |
| CSS = CSS.replace("%%CRACK%%", WB_CRACK_URI).replace("%%NOISE%%", WB_NOISE_URI) | |
| # --------------------------------------------------------------------------- # | |
| # render helpers — hand-built HTML/SVG instruments | |
| # --------------------------------------------------------------------------- # | |
| def _arc_points(cx: float, cy: float, r: float, a0: float, a1: float, n: int = 16) -> str: | |
| pts = [] | |
| for i in range(n + 1): | |
| a = a0 + (a1 - a0) * i / n | |
| pts.append(f"{cx + r * math.cos(a):.1f},{cy - r * math.sin(a):.1f}") | |
| return " ".join(pts) | |
| def _gauge_svg(confidence: float, tier: str) -> str: | |
| """A semicircular needle gauge: red (hesitant) · amber (neutral) · green (confident).""" | |
| cx, cy, R = 100.0, 98.0, 80.0 | |
| zones = [ | |
| ("hesitant", math.pi, 2 * math.pi / 3, "#9c3b2f"), | |
| ("neutral", 2 * math.pi / 3, math.pi / 3, "#b08900"), | |
| ("confident", math.pi / 3, 0.0, "#2f7d3b"), | |
| ] | |
| active = (tier or "").lower() | |
| seg = "" | |
| for name, a0, a1, col in zones: | |
| op = "1" if name == active else "0.22" | |
| seg += (f"<polyline points='{_arc_points(cx, cy, R, a0, a1)}' fill='none' " | |
| f"stroke='{col}' stroke-width='13' opacity='{op}'/>") | |
| conf = max(0.0, min(100.0, float(confidence))) | |
| a = math.pi - (conf / 100.0) * math.pi | |
| nx, ny = cx + 66 * math.cos(a), cy - 66 * math.sin(a) | |
| needle = (f"<line x1='{cx}' y1='{cy}' x2='{nx:.1f}' y2='{ny:.1f}' " | |
| f"stroke='#241a10' stroke-width='2.6' stroke-linecap='round'/>") | |
| hub = (f"<circle cx='{cx}' cy='{cy}' r='6' fill='#241a10'/>" | |
| f"<circle cx='{cx}' cy='{cy}' r='2.4' fill='#caa552'/>") | |
| return f"<svg viewBox='0 0 200 110' xmlns='http://www.w3.org/2000/svg'>{seg}{needle}{hub}</svg>" | |
| def _stance_html(stance) -> str: | |
| tier = stance.tier | |
| return ( | |
| "<div class='wb-instrument'>" | |
| "<div class='wb-instrument-cap'>Delivery Read</div>" | |
| f"{_gauge_svg(stance.confidence, tier)}" | |
| "<div class='wb-gauge-read'>" | |
| f"<span class='wb-gauge-tier wb-{tier.lower()}'>{tier.title()}</span>" | |
| f"<span class='wb-gauge-num'>{int(round(stance.confidence))}<i>%</i></span>" | |
| "</div>" | |
| "<div class='wb-disclaimer'>Perceived delivery — not a lie detector. " | |
| "Reads pace & pauses, not whether anything is true.</div>" | |
| "</div>" | |
| ) | |
| def _meter_html(label: str, pct: float, color: str) -> str: | |
| pct = max(0, min(100, int(round(pct)))) | |
| return ( | |
| "<div class='wb-meter'>" | |
| f"<div class='wb-meter-top'><span>{label}</span><i>{pct}</i></div>" | |
| f"<div class='wb-meter-track'><div class='wb-meter-fill' style='width:{pct}%;background:{color}'></div></div>" | |
| "</div>" | |
| ) | |
| def _seals_html(catches: int, need: int) -> str: | |
| roman = ["Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ", "Ⅴ"] | |
| seals = "" | |
| for i in range(need): | |
| if i < catches: | |
| seals += "<div class='wb-seal stamped'><span>⚖</span></div>" | |
| else: | |
| seals += f"<div class='wb-seal'><span>{roman[i] if i < len(roman) else i + 1}</span></div>" | |
| return ( | |
| f"<div class='wb-seals'>{seals}</div>" | |
| f"<div class='wb-seals-cap'>Contradictions on the record · {catches} / {need}</div>" | |
| ) | |
| def _counters_html(status: dict) -> str: | |
| return ( | |
| "<div class='wb-casefile'>" | |
| "<div class='wb-casefile-cap'>Case File</div>" | |
| f"{_seals_html(status['catches'], status['catches_to_win'])}" | |
| f"{_meter_html('Witness composure', status['composure'], '#7a4a2f')}" | |
| f"{_meter_html('Your standing with the bench', status['credibility'], '#43607f')}" | |
| "</div>" | |
| ) | |
| def _dock_html(tier: str) -> str: | |
| """The witness in the dock; `tier` (composed→rattled→cornered→breaking) drives the visual break.""" | |
| if PORTRAIT_DATA_URI: | |
| face = f"<img class='wb-mug' src='{PORTRAIT_DATA_URI}' alt='Marcus Reid'/>" | |
| else: | |
| face = "<div class='wb-dock-empty'>⚖️</div>" | |
| return ( | |
| f"<div class='wb-dock wb-tier-{tier}'>" | |
| "<div class='wb-dock-frame'>" | |
| f"{face}" | |
| "<div class='wb-wash'></div><div class='wb-crack'></div>" | |
| f"<div class='wb-state-tag'>{tier}</div>" | |
| "</div>" | |
| "<div class='wb-plate'><b>MARCUS REID</b><i>CFO · Halcyon Dynamics</i></div>" | |
| "</div>" | |
| ) | |
| def _banner(kind: str, text: str) -> str: | |
| kind = kind if kind in ("win", "lose", "info") else "info" | |
| return f"<div class='wb-banner wb-banner-{kind}'>{text}</div>" | |
| def _parse_mic(mic): | |
| if mic is None: | |
| return None, None | |
| sr, data = mic | |
| y = np.asarray(data) | |
| if y.dtype.kind in "iu": | |
| y = y.astype(np.float32) / max(1, np.iinfo(y.dtype).max) | |
| else: | |
| y = y.astype(np.float32) | |
| if y.ndim > 1: | |
| y = y.mean(axis=1) | |
| return y, int(sr) | |
| def _concat(a, b, sr): | |
| if a is None: | |
| return b | |
| if b is None: | |
| return a | |
| gap = np.zeros(int(0.5 * sr), dtype=np.float32) | |
| return np.concatenate([a.astype(np.float32), gap, b.astype(np.float32)]) | |
| # --------------------------------------------------------------------------- # | |
| # callbacks | |
| # --------------------------------------------------------------------------- # | |
| def on_start(engine): | |
| engine = WitnessBoxEngine(get_backends()) | |
| intro = engine.start() | |
| chat = [ | |
| {"role": "assistant", "content": f"⚖️ *The Court:* {intro['narration']}"}, | |
| {"role": "assistant", "content": f"**{WITNESS_NAME}:** {intro['opening_text']}"}, | |
| ] | |
| opening_audio = intro["opening_audio"] # (sr, np) or None | |
| footer = f"Backend: **{intro['backend']}** — {intro['backend_note']}" | |
| from witnessbox.stance import _neutral | |
| return ( | |
| engine, | |
| chat, | |
| gr.update(value=opening_audio), | |
| _stance_html(_neutral("awaiting your first question")), | |
| _counters_html(intro["status"]), | |
| _dock_html("composed"), | |
| gr.update(value="", visible=False), | |
| _banner("info", "Examination open. Mind how you say it — he listens for doubt."), | |
| footer, | |
| gr.update(interactive=True), # ask button | |
| gr.update(visible=False), # begin button | |
| gr.update(interactive=True), # mic | |
| gr.update(interactive=True), # typed | |
| ) | |
| def on_ask(engine, mic, typed): | |
| if engine is None: | |
| return (engine, gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), | |
| _banner("info", "Press “Call the witness” to begin."), gr.skip()) | |
| try: | |
| y, sr = _parse_mic(mic) | |
| result = engine.take_turn(audio=y, sr=sr, typed_text=typed) | |
| except Exception as exc: # surface the real cause in the UI, don't silently toast | |
| import traceback | |
| traceback.print_exc() | |
| return (engine, gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), | |
| _banner("lose", f"Turn failed — {type(exc).__name__}: {exc}"), gr.skip()) | |
| # Rebuild the chat from the transcript (engine keeps it consistent with what | |
| # is actually spoken, including the break line on the winning turn). | |
| chat = [] | |
| for rec in engine.state.transcript: | |
| tag = f"_[{rec.stance_tier.lower()}]_ " if rec.stance_tier != "NEUTRAL" else "" | |
| chat.append({"role": "user", "content": f"{tag}{rec.examiner_text}"}) | |
| chat.append({"role": "assistant", "content": f"**{WITNESS_NAME}:** {rec.witness_text}"}) | |
| # Terminal turns (e.g. no clear audio) don't enter the transcript — show them | |
| # anyway so the player always gets visible feedback. | |
| last_bot = chat[-1]["content"] if chat else "" | |
| if result.witness_text and result.witness_text not in last_bot: | |
| if result.examiner_text: | |
| chat.append({"role": "user", "content": result.examiner_text}) | |
| chat.append({"role": "assistant", "content": f"**{WITNESS_NAME}:** {result.witness_text}"}) | |
| # witness audio (+ epilogue concatenated on win/lose for a single dramatic play) | |
| audio_val = None | |
| if result.witness_audio is not None: | |
| merged = _concat(result.witness_audio, result.epilogue_audio, result.audio_sr) | |
| audio_val = (result.audio_sr, merged) | |
| # banner | |
| if result.events.won: | |
| banner = _banner("win", "🩻 He breaks. Three contradictions on the record — you win.") | |
| elif result.events.lost: | |
| banner = _banner("lose", "The bench excuses the witness. You’ve lost the room.") | |
| elif result.evidence: # a contradiction just landed (non-winning) — the gotcha moment | |
| banner = _banner("win", f"⚖ Contradiction admitted — {result.status['catches']} of {result.status['catches_to_win']} on the record.") | |
| elif result.events.near_miss: | |
| banner = _banner("info", "He flinched. You’re circling something — name the specific fact.") | |
| else: | |
| banner = _banner("info", f"Stance read: {result.stance.tier.title()}.") | |
| evidence_update = ( | |
| gr.update(value=result.evidence, visible=True) | |
| if result.evidence else gr.update() | |
| ) | |
| return ( | |
| engine, | |
| chat, | |
| gr.update(value=audio_val), | |
| _stance_html(result.stance), | |
| _counters_html(result.status), | |
| _dock_html(result.status["witness_tier"]), | |
| evidence_update, | |
| banner, | |
| gr.update(value=""), # clear typed box | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # layout | |
| # --------------------------------------------------------------------------- # | |
| def build() -> gr.Blocks: | |
| theme = gr.themes.Soft( | |
| primary_hue=gr.themes.colors.red, # oxblood, refined to exact hues in CSS | |
| secondary_hue=gr.themes.colors.amber, # brass | |
| neutral_hue=gr.themes.colors.stone, # warm paper greys, never blue-greys | |
| ) | |
| with gr.Blocks(css=CSS, head=HEAD, title="WitnessBox", theme=theme) as demo: | |
| engine_state = gr.State(None) | |
| gr.HTML( | |
| "<div id='wb-title'>" | |
| "<div class='wb-crest'>⚖️</div>" | |
| "<div class='wb-case'>Cross-Examination · The People v. Halcyon Dynamics</div>" | |
| "<h1>WitnessBox</h1>" | |
| f"<div class='wb-sub'>Cross-examine <b>{WITNESS_NAME}</b>, {WITNESS_ROLE}. " | |
| "Your <b>voice</b> is the weapon.</div>" | |
| "<div class='wb-rule'></div>" | |
| "</div>" | |
| ) | |
| banner = gr.HTML(_banner("info", "Call the witness to the stand.")) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| dock_html = gr.HTML(_dock_html("composed")) | |
| stance_html = gr.HTML(_stance_html(_neutral_seed())) | |
| with gr.Column(scale=4): | |
| chat = gr.Chatbot(type="messages", height=420, label="The Stand", | |
| elem_id="wb-chat") | |
| witness_audio = gr.Audio(label="Witness", autoplay=True, interactive=False) | |
| with gr.Column(scale=2): | |
| counters_html = gr.HTML(_counters_seed()) | |
| with gr.Accordion("🔎 Contradiction Engine (live verdict)", open=True): | |
| evidence = gr.Textbox( | |
| elem_id="wb-evidence", show_label=False, visible=False, lines=5, | |
| interactive=False, | |
| ) | |
| gr.Markdown( | |
| "_Catches are decided by a deterministic engine over three planted " | |
| "contradictions — the language model never grades itself, so the " | |
| "verdict is reproducible._" | |
| ) | |
| with gr.Row(): | |
| mic = gr.Audio(sources=["microphone"], type="numpy", label="Question (push to talk)", | |
| interactive=False) | |
| typed = gr.Textbox(label="…or type your question (primary in offline mock mode)", | |
| interactive=False, scale=2, | |
| placeholder="e.g. The wire cleared March 6th — before the board approved it on the 14th.") | |
| with gr.Row(): | |
| begin_btn = gr.Button("Call the witness to the stand", variant="primary") | |
| ask_btn = gr.Button("Put it to him", variant="secondary", interactive=False) | |
| footer = gr.Markdown("") | |
| outs_start = [engine_state, chat, witness_audio, stance_html, counters_html, | |
| dock_html, evidence, banner, footer, ask_btn, begin_btn, mic, typed] | |
| begin_btn.click(on_start, [engine_state], outs_start) | |
| outs_ask = [engine_state, chat, witness_audio, stance_html, counters_html, | |
| dock_html, evidence, banner, typed] | |
| ask_btn.click(on_ask, [engine_state, mic, typed], outs_ask) | |
| typed.submit(on_ask, [engine_state, mic, typed], outs_ask) | |
| return demo | |
| def _neutral_seed(): | |
| from witnessbox.stance import _neutral | |
| return _neutral("awaiting examination") | |
| def _counters_seed(): | |
| return _counters_html({ | |
| "catches": 0, "catches_to_win": getattr(config, "CATCHES_TO_WIN", 3), | |
| "composure": getattr(config, "COMPOSURE_START", 100), | |
| "credibility": getattr(config, "CREDIBILITY_START", 100), | |
| "witness_tier": "composed", | |
| }) | |
| demo = build() | |
| if __name__ == "__main__": | |
| # server_name=0.0.0.0 so the HF Space's startup self-check can reach the app. | |
| # ssr_mode=False: Gradio 5's experimental SSR layer caused flaky request errors | |
| # on the Space; the classic client-rendered path is reliable for this app. | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=int(os.environ.get("GRADIO_SERVER_PORT", 7860)), | |
| ssr_mode=False, | |
| ) | |