"""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 = (
""
)
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 = (
""
)
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 = (
""
""
""
)
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"