| from __future__ import annotations
|
|
|
| import json
|
| import os
|
| import queue
|
| import threading
|
| import time
|
| from collections.abc import Iterable
|
| from dataclasses import dataclass
|
|
|
| import gradio as gr
|
| import httpx
|
|
|
| from sovereign_bench.engine import JUDGE_NAME, JUROR_PERSONAS, stream_trial
|
| from sovereign_bench.cases import CASES, get_case
|
| from sovereign_bench.models import CasePacket, EvidenceItem, TrialEvent, TrialRequest
|
|
|
|
|
| def _load_env_file() -> None:
|
| path = ".env"
|
| if not os.path.exists(path):
|
| return
|
| with open(path, encoding="utf-8") as handle:
|
| for line in handle:
|
| stripped = line.strip()
|
| if not stripped or stripped.startswith("#") or "=" not in stripped:
|
| continue
|
| key, value = stripped.split("=", 1)
|
| os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
|
|
|
|
| _load_env_file()
|
|
|
| CASE_OPTIONS = {
|
| "Trial of Socrates": "socrates",
|
| "Greg Heffley vs Mom": "greg",
|
| "Custom": "custom",
|
| }
|
|
|
| DEFAULT_MODAL_TRIAL_URL = "https://ali-j-iqbal24--trial-stream.modal.run"
|
| MIN_READ_SECONDS = 2.2
|
| WORDS_PER_SECOND = 3.2
|
| READ_BUFFER_SECONDS = 0.8
|
| MAX_READ_SECONDS = 8.5
|
|
|
| PHASE_GLYPHS = {
|
| "pretrial": "00",
|
| "intake": "01",
|
| "claims": "02",
|
| "opening": "03",
|
| "evidence": "04",
|
| "questions": "05",
|
| "deliberation": "06",
|
| "verdict": "07",
|
| "appeal": "08",
|
| }
|
|
|
| TRIAL_PROGRESS_STAGES = (
|
| ("pretrial", "Pretrial"),
|
| ("intake", "Intake"),
|
| ("claims", "Claims"),
|
| ("opening", "Opening"),
|
| ("evidence", "Evidence"),
|
| ("questions", "Questions"),
|
| ("deliberation", "Deliberation"),
|
| ("verdict", "Verdict"),
|
| )
|
|
|
| VERDICT_LABELS = {
|
| "liable": "Guilty",
|
| "not_liable": "Not Guilty",
|
| "mixed": "Mixed",
|
| "uncertain": "Uncertain",
|
| }
|
|
|
| AUDIO_PATHS = {
|
| "score": "/gradio_api/file=assets/audio/courtroom.ogg",
|
| "judgement": "/gradio_api/file=assets/audio/Judgement.ogg",
|
| "crowd": "/gradio_api/file=assets/audio/crowd_shouting.ogg",
|
| "gavel": "/gradio_api/file=assets/audio/wood_hammer_01.ogg",
|
| "wood": "/gradio_api/file=assets/audio/wood_hit_03.ogg",
|
| "steps": "/gradio_api/file=assets/audio/steps_in_wood_floor.wav",
|
| "paper": "/gradio_api/file=assets/audio/paper_sound_1.mp3",
|
| "paper_long": "/gradio_api/file=assets/audio/paper_sound_4.mp3",
|
| "select": "/gradio_api/file=assets/audio/select_001.ogg",
|
| }
|
|
|
| CSS = """
|
| :root {
|
| --ink: #23170e;
|
| --paper: #f3dfb7;
|
| --paper-dark: #c79455;
|
| --gold: #d9b060;
|
| --mahogany: #4b2119;
|
| --shadow: rgba(10, 5, 2, .6);
|
| --red: #8f2e2d;
|
| --green: #2f6f5e;
|
| --blue: #254f7a;
|
| }
|
|
|
| body,
|
| .gradio-container {
|
| margin: 0;
|
| background: #141413 !important;
|
| background-color: #141413 !important;
|
| color: var(--ink);
|
| font-family: Georgia, "Times New Roman", serif;
|
| }
|
|
|
| .gradio-container {
|
| max-width: none !important;
|
| padding: 0 !important;
|
| }
|
|
|
| .main,
|
| .contain {
|
| max-width: none !important;
|
| padding: 0 !important;
|
| background: transparent !important;
|
| }
|
|
|
| .gradio-container main,
|
| .gradio-container .wrap,
|
| .gradio-container .app,
|
| .gradio-container .html-container {
|
| background: transparent !important;
|
| padding-left: 0 !important;
|
| padding-right: 0 !important;
|
| }
|
|
|
| .docket-book-controls {
|
| position: fixed;
|
| left: 50%;
|
| top: 72px;
|
| z-index: 9999;
|
| width: min(760px, calc(100vw - 160px));
|
| max-width: none;
|
| margin: 0;
|
| padding: 0;
|
| transform: translateX(-50%) rotate(-1deg);
|
| border: 0 !important;
|
| border-radius: 0 !important;
|
| background: transparent !important;
|
| box-shadow: none !important;
|
| color: #321d10;
|
| transition: opacity .32s ease, transform .65s ease;
|
| }
|
|
|
| body.trial-has-started .docket-book-controls {
|
| opacity: 0;
|
| pointer-events: none;
|
| transform: translateX(-50%) rotateX(56deg) rotate(-1deg) scale(.45);
|
| }
|
|
|
| .docket-book-controls::before {
|
| content: none;
|
| }
|
|
|
| .docket-book-controls,
|
| .docket-book-controls > *,
|
| .docket-book-controls .form,
|
| .docket-book-controls .block,
|
| .docket-book-controls .gap,
|
| .docket-book-controls .wrap {
|
| background: transparent !important;
|
| border: 0 !important;
|
| box-shadow: none !important;
|
| }
|
|
|
| .docket-book-controls .docket-book-controls {
|
| position: static !important;
|
| left: auto !important;
|
| top: auto !important;
|
| width: 100% !important;
|
| transform: none !important;
|
| opacity: 1;
|
| pointer-events: auto;
|
| }
|
|
|
| body.trial-has-started .docket-book-controls .docket-book-controls {
|
| pointer-events: none;
|
| }
|
|
|
| .book-control-heading {
|
| margin: 0 0 6px;
|
| color: #694019;
|
| font: 900 12px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| letter-spacing: .08em;
|
| text-transform: uppercase;
|
| }
|
|
|
| .docket-book-controls label,
|
| .docket-book-controls span,
|
| .docket-book-controls .prose {
|
| color: #321d10 !important;
|
| }
|
|
|
| .docket-book-controls label {
|
| font-size: 11px !important;
|
| font-weight: 800 !important;
|
| }
|
|
|
| .docket-book-controls input,
|
| .docket-book-controls textarea,
|
| .docket-book-controls [role="combobox"],
|
| .docket-book-controls .wrap-inner {
|
| border-color: rgba(90, 50, 20, .24) !important;
|
| border-radius: 4px !important;
|
| background: rgba(255, 243, 207, .58) !important;
|
| color: #241509 !important;
|
| box-shadow: inset 0 1px 0 rgba(255,255,255,.24) !important;
|
| }
|
|
|
| .docket-book-controls textarea {
|
| min-height: 42px !important;
|
| }
|
|
|
| .docket-book-controls button.primary {
|
| min-height: 42px;
|
| border: 1px solid rgba(44, 21, 10, .42) !important;
|
| border-radius: 5px !important;
|
| background: #1c130d !important;
|
| color: #fff3d2 !important;
|
| box-shadow: inset 0 1px 0 rgba(255,255,255,.12), 0 8px 18px rgba(40, 18, 9, .28);
|
| }
|
|
|
| .docket-book-controls .book-status p {
|
| margin: 0 !important;
|
| color: #5a3519 !important;
|
| font-size: 12px;
|
| line-height: 1.25;
|
| }
|
|
|
| .court-episode-stage {
|
| --spot-x: 50%;
|
| --spot-y: 36%;
|
| position: relative;
|
| min-height: min(880px, calc(100vh - 112px));
|
| height: min(880px, calc(100vh - 112px));
|
| margin: 0;
|
| width: 100%;
|
| max-width: none;
|
| overflow: hidden;
|
| isolation: auto;
|
| color: #fff0d2;
|
| border: 0;
|
| border-radius: 0;
|
| background: transparent;
|
| box-shadow: none;
|
| }
|
|
|
| .court-episode-stage::before {
|
| content: "";
|
| display: none;
|
| }
|
|
|
| .court-episode-stage::after {
|
| content: "";
|
| display: none;
|
| }
|
|
|
| .court-episode-stage > * {
|
| position: relative;
|
| z-index: 4;
|
| }
|
|
|
| .trial-progress {
|
| position: fixed;
|
| top: 0;
|
| left: 0;
|
| right: 0;
|
| z-index: 70;
|
| display: grid;
|
| grid-template-columns: repeat(8, minmax(0, 1fr));
|
| gap: 1px;
|
| padding: 3px clamp(10px, 2vw, 24px) 4px;
|
| border-bottom: 1px solid rgba(217, 176, 96, .2);
|
| background: rgba(23, 13, 8, .58);
|
| backdrop-filter: blur(8px);
|
| box-shadow: 0 8px 18px rgba(8, 4, 2, .22);
|
| pointer-events: none;
|
| }
|
|
|
| .trial-progress-segment {
|
| position: relative;
|
| min-width: 0;
|
| padding-top: 5px;
|
| overflow: hidden;
|
| color: rgba(244, 213, 143, .38);
|
| font: 800 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| letter-spacing: .04em;
|
| text-align: center;
|
| text-transform: uppercase;
|
| white-space: nowrap;
|
| }
|
|
|
| .trial-progress-segment::before {
|
| content: "";
|
| position: absolute;
|
| left: 3px;
|
| right: 3px;
|
| top: 0;
|
| height: 2px;
|
| border-radius: 999px;
|
| background: rgba(217, 176, 96, .18);
|
| }
|
|
|
| .trial-progress-segment.complete {
|
| color: rgba(217, 176, 96, .68);
|
| }
|
|
|
| .trial-progress-segment.complete::before {
|
| background: rgba(217, 176, 96, .48);
|
| }
|
|
|
| .trial-progress-segment.current {
|
| color: #ffe6a6;
|
| text-shadow: 0 0 10px rgba(255, 211, 116, .52);
|
| }
|
|
|
| .trial-progress-segment.current::before {
|
| height: 3px;
|
| background: #ffd675;
|
| box-shadow: 0 0 12px rgba(255, 214, 117, .68);
|
| }
|
|
|
| .trial-progress-abbrev {
|
| display: none;
|
| }
|
|
|
| .episode-room {
|
| position: absolute;
|
| inset: 0;
|
| z-index: 3;
|
| background:
|
| url('/gradio_api/file=assets/background/CourtRoom.png') center center / 100% 100% no-repeat,
|
| #26120b;
|
| filter: none;
|
| transform: none;
|
| }
|
|
|
| .trial-started .episode-room,
|
| .phase-intake .episode-room,
|
| .phase-claims .episode-room,
|
| .phase-opening .episode-room,
|
| .phase-evidence .episode-room,
|
| .phase-questions .episode-room,
|
| .phase-deliberation .episode-room,
|
| .phase-verdict .episode-room,
|
| .phase-appeal .episode-room {
|
| filter: none;
|
| transform: none;
|
| }
|
|
|
| .phase-intake,
|
| .phase-appeal { --spot-x: 50%; --spot-y: 30%; }
|
| .phase-claims,
|
| .phase-opening { --spot-x: 43%; --spot-y: 66%; }
|
| .phase-evidence { --spot-x: 70%; --spot-y: 56%; }
|
| .phase-questions,
|
| .phase-verdict { --spot-x: 50%; --spot-y: 34%; }
|
| .phase-deliberation { --spot-x: 79%; --spot-y: 60%; }
|
|
|
| .episode-title {
|
| position: absolute;
|
| left: 26px;
|
| top: 22px;
|
| z-index: 9;
|
| max-width: min(780px, calc(100% - 330px));
|
| text-shadow: 0 3px 18px rgba(0, 0, 0, .75);
|
| }
|
|
|
| .episode-kicker,
|
| .prop-label,
|
| .caption-phase,
|
| .tooltip-meta,
|
| .drawer-kicker {
|
| color: #f4d58f;
|
| font: 800 11px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| letter-spacing: .06em;
|
| text-transform: uppercase;
|
| }
|
|
|
| .episode-title h1 {
|
| margin: 4px 0 6px;
|
| max-width: 780px;
|
| color: #fff4d7;
|
| font-size: clamp(28px, 4.2vw, 58px);
|
| line-height: .98;
|
| letter-spacing: 0;
|
| }
|
|
|
| .episode-title p {
|
| margin: 0;
|
| max-width: 720px;
|
| color: #f8dcaa;
|
| font-size: 15px;
|
| line-height: 1.38;
|
| }
|
|
|
| .audio-deck {
|
| display: none;
|
| }
|
|
|
| .sound-toggle {
|
| position: fixed;
|
| left: 18px;
|
| bottom: 18px;
|
| z-index: 80;
|
| width: 46px;
|
| height: 46px;
|
| border: 1px solid rgba(255, 226, 154, .48);
|
| border-radius: 50%;
|
| background: rgba(22, 11, 7, .82);
|
| box-shadow: 0 12px 28px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255, 255, 255, .12);
|
| cursor: pointer;
|
| }
|
|
|
| .sound-toggle:hover,
|
| .sound-toggle:focus-visible {
|
| outline: none;
|
| border-color: rgba(255, 226, 154, .82);
|
| background: rgba(41, 20, 12, .92);
|
| }
|
|
|
| .sound-toggle .sound-icon {
|
| position: absolute;
|
| left: 13px;
|
| top: 15px;
|
| width: 10px;
|
| height: 16px;
|
| border-radius: 2px 0 0 2px;
|
| background: #ffe5a6;
|
| }
|
|
|
| .sound-toggle .sound-icon::before {
|
| content: "";
|
| position: absolute;
|
| left: 7px;
|
| top: -3px;
|
| width: 14px;
|
| height: 22px;
|
| border: 3px solid #ffe5a6;
|
| border-left: 0;
|
| border-radius: 0 18px 18px 0;
|
| }
|
|
|
| .sound-toggle .sound-icon::after {
|
| content: "";
|
| position: absolute;
|
| left: 20px;
|
| top: -9px;
|
| width: 3px;
|
| height: 34px;
|
| border-radius: 4px;
|
| background: #d64d45;
|
| opacity: 0;
|
| transform: rotate(42deg);
|
| transform-origin: center;
|
| }
|
|
|
| .sound-toggle.muted .sound-icon::after {
|
| opacity: 1;
|
| }
|
|
|
| .episode-book {
|
| position: absolute;
|
| left: 50%;
|
| top: 122px;
|
| z-index: 14;
|
| width: min(980px, calc(100% - 32px));
|
| aspect-ratio: 3 / 2;
|
| transform: translateX(-50%) rotateX(0) rotateZ(-1deg);
|
| transform-origin: center bottom;
|
| color: #2b1b10;
|
| filter: drop-shadow(0 34px 36px rgba(0, 0, 0, .48));
|
| pointer-events: none;
|
| transition: top .85s ease, width .85s ease, transform .85s ease, filter .85s ease, opacity .85s ease;
|
| }
|
|
|
| .episode-book.custom-book {
|
| pointer-events: auto;
|
| }
|
|
|
| .book-art {
|
| position: absolute;
|
| inset: 0;
|
| width: 100%;
|
| height: 100%;
|
| object-fit: contain;
|
| pointer-events: none;
|
| user-select: none;
|
| transition: opacity .36s ease;
|
| }
|
|
|
| .book-art.closed-art {
|
| opacity: 0;
|
| }
|
|
|
| .episode-book.closed {
|
| top: 50%;
|
| width: min(163px, 20vw);
|
| transform: translateX(-50%) rotateX(56deg) rotateZ(1deg);
|
| opacity: .92;
|
| filter: drop-shadow(0 18px 18px rgba(0, 0, 0, .45));
|
| }
|
|
|
| .episode-book.closed .open-art {
|
| opacity: 0;
|
| }
|
|
|
| .episode-book.closed .closed-art {
|
| opacity: 1;
|
| }
|
|
|
| .episode-book.closed .book-open-content {
|
| opacity: 0;
|
| pointer-events: none;
|
| }
|
|
|
| .book-open-content {
|
| position: absolute;
|
| inset: 15% 9% 12%;
|
| z-index: 2;
|
| display: grid;
|
| grid-template-columns: 1fr 1fr;
|
| gap: 82px;
|
| padding: 0 20px;
|
| transition: opacity .35s ease;
|
| }
|
|
|
| .book-open-content h2 {
|
| margin: 0 0 8px;
|
| color: #4c2a12;
|
| font-size: 28px;
|
| letter-spacing: 0;
|
| }
|
|
|
| .book-open-content p,
|
| .book-entry {
|
| color: #3c2615;
|
| font-size: 14px;
|
| line-height: 1.28;
|
| }
|
|
|
| .book-entry {
|
| margin: 8px 0;
|
| padding-left: 12px;
|
| border-left: 3px solid rgba(111, 61, 23, .36);
|
| }
|
|
|
| .book-context {
|
| margin-top: 8px;
|
| }
|
|
|
| .book-case-title {
|
| margin: 0 0 6px;
|
| color: #4c2a12;
|
| font-weight: 800;
|
| }
|
|
|
| .book-evidence-columns {
|
| display: grid;
|
| grid-template-columns: 1fr 1fr;
|
| gap: 12px;
|
| }
|
|
|
| .book-evidence-column h3 {
|
| margin: 0 0 6px;
|
| color: #4c2a12;
|
| font-size: 15px;
|
| line-height: 1.12;
|
| }
|
|
|
| .book-evidence-list {
|
| margin: 0;
|
| padding: 0;
|
| list-style: none;
|
| }
|
|
|
| .book-evidence-list li {
|
| margin: 0 0 6px;
|
| padding-left: 9px;
|
| border-left: 2px solid rgba(111, 61, 23, .32);
|
| color: #3c2615;
|
| font-size: 12px;
|
| line-height: 1.2;
|
| }
|
|
|
| .book-field {
|
| width: 100%;
|
| min-height: 42px;
|
| resize: none;
|
| border: 1px solid rgba(90, 50, 20, .34);
|
| border-radius: 4px;
|
| background: rgba(255, 247, 224, .7);
|
| color: #2b1b10;
|
| font: 12px/1.22 Georgia, "Times New Roman", serif;
|
| box-shadow: inset 0 1px 2px rgba(59, 29, 10, .16);
|
| pointer-events: auto;
|
| }
|
|
|
| .book-context-field {
|
| min-height: 138px;
|
| font-size: 13px;
|
| }
|
|
|
| .judge-dais {
|
| position: absolute;
|
| left: 50%;
|
| top: 27%;
|
| z-index: 6;
|
| width: min(360px, 32vw);
|
| min-width: 230px;
|
| transform: translateX(-50%);
|
| text-align: center;
|
| }
|
|
|
| .bench-front {
|
| display: none;
|
| }
|
|
|
| .gavel {
|
| display: none;
|
| }
|
|
|
| .gavel::before {
|
| content: "";
|
| display: none;
|
| }
|
|
|
| .phase-verdict .gavel {
|
| animation: gavel-hit .55s ease-out both;
|
| }
|
|
|
| .counsel-table {
|
| position: absolute;
|
| bottom: 19%;
|
| z-index: 6;
|
| width: min(255px, 22vw);
|
| height: 84px;
|
| border: 0;
|
| background: transparent;
|
| box-shadow: none;
|
| }
|
|
|
| .counsel-table.left { left: 17%; }
|
| .counsel-table.right { right: 17%; }
|
|
|
| .trial-floor-mark {
|
| display: none;
|
| }
|
|
|
| .witness-area {
|
| position: absolute;
|
| right: 8.5%;
|
| bottom: 28%;
|
| z-index: 6;
|
| width: min(190px, 18vw);
|
| height: 98px;
|
| border: 0;
|
| background: transparent;
|
| box-shadow: none;
|
| }
|
|
|
| .jury-benches {
|
| position: absolute;
|
| top: 43%;
|
| z-index: 7;
|
| width: min(220px, 16vw);
|
| min-width: 150px;
|
| display: grid;
|
| gap: 6px;
|
| }
|
|
|
| .jury-benches.left {
|
| left: 1%;
|
| }
|
|
|
| .jury-benches.right {
|
| right: 1%;
|
| }
|
|
|
| .jury-benches.left .jury-row {
|
| transform: rotate(-7deg) skewY(-3deg);
|
| }
|
|
|
| .jury-benches.right .jury-row {
|
| transform: rotate(7deg) skewY(3deg);
|
| }
|
|
|
| .jury-rail {
|
| display: none;
|
| }
|
|
|
| .jury-row {
|
| display: grid;
|
| grid-template-columns: repeat(3, 1fr);
|
| gap: 8px;
|
| align-items: end;
|
| }
|
|
|
| .gallery-benches {
|
| display: none;
|
| }
|
|
|
| .gallery-benches div {
|
| display: none;
|
| }
|
|
|
| .prop-label {
|
| display: none;
|
| }
|
|
|
| .foreground-props {
|
| position: absolute;
|
| inset: 0;
|
| z-index: 13;
|
| pointer-events: none;
|
| }
|
|
|
| .foreground-fence,
|
| .judge-table-foreground {
|
| position: absolute;
|
| display: block;
|
| max-width: none;
|
| height: auto;
|
| filter: none;
|
| opacity: 1;
|
| pointer-events: none;
|
| user-select: none;
|
| }
|
|
|
| .foreground-fence {
|
| bottom: -6.5%;
|
| width: 47%;
|
| }
|
|
|
| .foreground-fence.fence-left {
|
| left: 0;
|
| transform: translateX(-2%);
|
| }
|
|
|
| .foreground-fence.fence-right {
|
| right: 0;
|
| transform: translateX(2%);
|
| }
|
|
|
| .judge-table-foreground {
|
| left: 50%;
|
| top: 20%;
|
| z-index: 1;
|
| width: 39.1%;
|
| transform: translateX(-50%);
|
| }
|
|
|
| .puppet {
|
| --skin: #c99257;
|
| --robe: #282128;
|
| --accent: #8a2f2f;
|
| --portrait-width: 74px;
|
| --portrait-top: -14px;
|
| position: absolute;
|
| z-index: 8;
|
| width: 72px;
|
| height: 128px;
|
| transform: translate(-50%, -100%);
|
| transform-origin: center bottom;
|
| filter: none;
|
| color: inherit;
|
| text-decoration: none;
|
| }
|
|
|
| .puppet.small {
|
| width: 50px;
|
| height: 94px;
|
| --portrait-width: 54px;
|
| --portrait-top: -8px;
|
| }
|
|
|
| .puppet.active {
|
| animation: puppet-breathe 1.45s ease-in-out infinite;
|
| }
|
|
|
| .puppet.walking {
|
| animation: lawyer-walk 1.9s ease-in-out infinite;
|
| }
|
|
|
| .puppet.judge {
|
| left: 50%;
|
| top: calc(40% + 156px);
|
| --skin: #c38a55;
|
| --robe: #1b1b20;
|
| --accent: #79242a;
|
| --portrait-width: 96px;
|
| --portrait-top: -28px;
|
| }
|
|
|
| .puppet.clerk {
|
| left: 43%;
|
| top: 66%;
|
| z-index: 14;
|
| --skin: #b77b52;
|
| --robe: #365548;
|
| --accent: #2f6f5e;
|
| }
|
|
|
| .puppet.auric {
|
| left: 24%;
|
| top: 87%;
|
| --skin: #c9975d;
|
| --robe: #5b2719;
|
| --accent: #a45c25;
|
| }
|
|
|
| .speaker-auric .puppet.auric {
|
| left: 43%;
|
| top: 87%;
|
| }
|
|
|
| .puppet.sable {
|
| left: 75%;
|
| top: 87%;
|
| --skin: #a86d4a;
|
| --robe: #1d3045;
|
| --accent: #254f7a;
|
| }
|
|
|
| .speaker-sable .puppet.sable {
|
| left: 75%;
|
| top: 87%;
|
| }
|
|
|
| .puppet-portrait {
|
| position: absolute;
|
| left: 50%;
|
| top: var(--portrait-top);
|
| z-index: 3;
|
| width: var(--portrait-width);
|
| height: auto;
|
| max-height: 118px;
|
| transform: translateX(-50%);
|
| object-fit: contain;
|
| pointer-events: none;
|
| }
|
|
|
| .puppet::before {
|
| content: "";
|
| position: absolute;
|
| left: 50%;
|
| top: 0;
|
| width: 44px;
|
| height: 44px;
|
| transform: translateX(-50%);
|
| border: 2px solid rgba(255, 232, 174, .58);
|
| border-radius: 50%;
|
| background:
|
| radial-gradient(circle at 34% 32%, rgba(255,255,255,.38), transparent 22%),
|
| radial-gradient(circle at 36% 42%, #1b120c 0 2px, transparent 2.5px),
|
| radial-gradient(circle at 62% 42%, #1b120c 0 2px, transparent 2.5px),
|
| linear-gradient(180deg, var(--skin), #8b5638);
|
| }
|
|
|
| .puppet::after {
|
| content: "";
|
| position: absolute;
|
| left: 50%;
|
| top: 48px;
|
| width: 58px;
|
| height: 70px;
|
| transform: translateX(-50%);
|
| border: 1px solid rgba(255, 232, 174, .22);
|
| border-radius: 24px 24px 8px 8px;
|
| background:
|
| linear-gradient(90deg, transparent 46%, rgba(255, 226, 154, .14) 49%, transparent 52%),
|
| linear-gradient(180deg, var(--accent), var(--robe) 52%, #130a07);
|
| }
|
|
|
| .puppet.judge::before,
|
| .puppet.judge::after {
|
| display: none;
|
| }
|
|
|
| .puppet .mouth {
|
| position: absolute;
|
| left: 50%;
|
| top: 27px;
|
| z-index: 2;
|
| width: 15px;
|
| height: 7px;
|
| transform: translateX(-50%);
|
| border-bottom: 2px solid #28150c;
|
| border-radius: 0 0 18px 18px;
|
| }
|
|
|
| .puppet.judge .mouth {
|
| display: none;
|
| }
|
|
|
| .puppet.active .mouth,
|
| .puppet.walking .mouth {
|
| animation: speak-mouth .5s ease-in-out infinite;
|
| }
|
|
|
| .speech-bubble.active-dialogue {
|
| position: absolute;
|
| left: 50%;
|
| top: 43%;
|
| bottom: auto;
|
| z-index: 30;
|
| width: min(500px, calc(100vw - 44px));
|
| max-height: 34vh;
|
| overflow: visible;
|
| transform: translate(-50%, -100%);
|
| padding: 10px 13px 11px;
|
| border: 2px solid #141413;
|
| border-radius: 20px;
|
| background: rgba(255, 253, 247, .97);
|
| color: #141413 !important;
|
| box-shadow: 0 12px 24px rgba(0, 0, 0, .32);
|
| font-size: 12px;
|
| font-weight: 650;
|
| line-height: 1.32;
|
| pointer-events: none;
|
| }
|
|
|
| .speech-bubble.active-dialogue,
|
| .speech-bubble.active-dialogue * {
|
| color: #141413 !important;
|
| }
|
|
|
| .speech-bubble.active-dialogue::before,
|
| .speech-bubble.active-dialogue::after {
|
| content: "";
|
| position: absolute;
|
| left: var(--bubble-tail-x, 50%);
|
| display: block;
|
| transform: translateX(-50%) rotate(45deg);
|
| }
|
|
|
| .speech-bubble.active-dialogue::before {
|
| bottom: -13px;
|
| width: 22px;
|
| height: 22px;
|
| background: #141413;
|
| border-radius: 0 0 5px 0;
|
| }
|
|
|
| .speech-bubble.active-dialogue::after {
|
| bottom: -9px;
|
| width: 16px;
|
| height: 16px;
|
| transform: translateX(-50%) rotate(45deg);
|
| background: rgba(255, 253, 247, .97);
|
| border-radius: 0 0 3px 0;
|
| }
|
|
|
| .speech-bubble.active-dialogue.pending {
|
| opacity: .82;
|
| }
|
|
|
| .dialogue-meta {
|
| display: flex;
|
| align-items: baseline;
|
| gap: 6px;
|
| margin-bottom: 5px;
|
| font: 800 9px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| text-transform: uppercase;
|
| }
|
|
|
| .dialogue-meta strong {
|
| font-size: 10px;
|
| }
|
|
|
| .dialogue-text {
|
| max-height: calc(34vh - 42px);
|
| overflow: auto;
|
| white-space: pre-wrap;
|
| }
|
|
|
| .speech-bubble.active-dialogue.speaker-clerk { left: 43%; top: 62%; }
|
| .speech-bubble.active-dialogue.speaker-judge { left: 50%; top: 43%; }
|
| .speech-bubble.active-dialogue.speaker-auric { left: 43%; top: 78%; }
|
| .speech-bubble.active-dialogue.speaker-sable { left: 75%; top: 78%; }
|
| .speech-bubble.active-dialogue.juror-dialogue { left: 50%; top: 57%; }
|
| .speech-bubble.active-dialogue.juror-dialogue {
|
| top: 42%;
|
| width: min(340px, calc(50vw - 24px));
|
| }
|
|
|
| .speech-bubble.active-dialogue.speaker-karl-marx,
|
| .speech-bubble.active-dialogue.speaker-john-stuart-mill,
|
| .speech-bubble.active-dialogue.speaker-confucius {
|
| left: 1.5%;
|
| transform: translateY(-100%);
|
| }
|
|
|
| .speech-bubble.active-dialogue.speaker-cleopatra-vii,
|
| .speech-bubble.active-dialogue.speaker-niccolo-machiavelli,
|
| .speech-bubble.active-dialogue.speaker-jensen-huang {
|
| right: 1.5%;
|
| left: auto;
|
| transform: translateY(-100%);
|
| }
|
|
|
| .speech-bubble.active-dialogue.speaker-karl-marx,
|
| .speech-bubble.active-dialogue.speaker-cleopatra-vii {
|
| --bubble-tail-x: 19%;
|
| }
|
|
|
| .speech-bubble.active-dialogue.speaker-john-stuart-mill,
|
| .speech-bubble.active-dialogue.speaker-niccolo-machiavelli {
|
| --bubble-tail-x: 50%;
|
| }
|
|
|
| .speech-bubble.active-dialogue.speaker-confucius,
|
| .speech-bubble.active-dialogue.speaker-jensen-huang {
|
| --bubble-tail-x: 81%;
|
| }
|
|
|
| .verdict-popup {
|
| position: absolute;
|
| left: 50%;
|
| top: 54%;
|
| z-index: 42;
|
| width: min(460px, calc(100vw - 44px));
|
| transform: translate(-50%, -50%);
|
| padding: 18px 22px 20px;
|
| border: 2px solid rgba(255, 235, 178, .94);
|
| border-radius: 8px;
|
| background: rgba(20, 12, 7, .95);
|
| color: #fff4d6;
|
| text-align: center;
|
| box-shadow: 0 28px 58px rgba(0, 0, 0, .5);
|
| animation: verdict-pop .34s ease-out both;
|
| }
|
|
|
| .verdict-popup-kicker {
|
| display: block;
|
| margin-bottom: 7px;
|
| color: #d9b060;
|
| font: 800 11px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| letter-spacing: 0;
|
| text-transform: uppercase;
|
| }
|
|
|
| .verdict-popup-finding {
|
| display: block;
|
| color: #fff8e6;
|
| font: 900 clamp(28px, 5vw, 48px)/1.02 Georgia, serif;
|
| }
|
|
|
| .verdict-popup-decree {
|
| margin: 10px auto 0;
|
| max-width: 38ch;
|
| color: rgba(255, 244, 214, .86);
|
| font-size: 13px;
|
| line-height: 1.35;
|
| }
|
|
|
| .tooltip {
|
| position: absolute;
|
| left: 50%;
|
| bottom: calc(100% + 10px);
|
| z-index: 20;
|
| width: 320px;
|
| max-width: min(360px, calc(100vw - 32px));
|
| transform: translateX(-50%) translateY(6px);
|
| opacity: 0;
|
| pointer-events: none;
|
| padding: 8px 10px;
|
| border: 1px solid rgba(255, 226, 154, .34);
|
| border-radius: 5px;
|
| background: rgba(17, 9, 5, .88);
|
| color: #fff0d2;
|
| box-shadow: 0 12px 24px rgba(0,0,0,.36);
|
| transition: opacity .18s ease, transform .18s ease;
|
| }
|
|
|
| .puppet:hover .tooltip,
|
| .puppet:focus-within .tooltip,
|
| .juror:hover .tooltip,
|
| .juror:focus-within .tooltip {
|
| opacity: 1;
|
| transform: translateX(-50%) translateY(0);
|
| }
|
|
|
| .tooltip strong {
|
| display: block;
|
| color: #fff6df;
|
| font-size: 13px;
|
| }
|
|
|
| .tooltip p {
|
| margin: 6px 0 0;
|
| color: #f5dfb5;
|
| font-size: 11px;
|
| line-height: 1.28;
|
| white-space: normal;
|
| }
|
|
|
| .tooltip-meta {
|
| margin-top: 3px;
|
| color: #f4d58f;
|
| font-size: 10px;
|
| }
|
|
|
| .tooltip-io-label,
|
| .thread-label {
|
| display: block;
|
| margin-top: 7px;
|
| color: #f4d58f;
|
| font: 800 10px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| text-transform: uppercase;
|
| }
|
|
|
| .ai-thread-modal {
|
| display: none;
|
| position: fixed;
|
| inset: max(18px, 4vh) max(18px, 5vw);
|
| z-index: 120;
|
| overflow: auto;
|
| padding: 20px;
|
| border: 1px solid rgba(255, 226, 154, .42);
|
| border-radius: 8px;
|
| background: rgba(18, 9, 5, .96);
|
| color: #fff0d2;
|
| box-shadow: 0 24px 70px rgba(0, 0, 0, .58);
|
| }
|
|
|
| .ai-thread-modal:target {
|
| display: block;
|
| }
|
|
|
| .thread-close {
|
| position: sticky;
|
| top: 0;
|
| float: right;
|
| padding: 7px 10px;
|
| border: 1px solid rgba(255, 226, 154, .38);
|
| border-radius: 4px;
|
| background: rgba(255, 226, 154, .12);
|
| color: #fff0d2;
|
| text-decoration: none;
|
| font: 800 11px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| }
|
|
|
| .thread-title {
|
| margin: 0 0 4px;
|
| color: #fff6df;
|
| font-size: 22px;
|
| }
|
|
|
| .thread-subtitle {
|
| margin: 0 0 16px;
|
| color: #f4d58f;
|
| font: 800 12px/1.3 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| text-transform: uppercase;
|
| }
|
|
|
| .thread-turn {
|
| margin: 0 0 18px;
|
| padding-bottom: 16px;
|
| border-bottom: 1px solid rgba(255, 226, 154, .16);
|
| }
|
|
|
| .thread-turn:last-child {
|
| border-bottom: 0;
|
| }
|
|
|
| .thread-block {
|
| margin: 7px 0 0;
|
| white-space: pre-wrap;
|
| color: #f8dfaa;
|
| font: 12px/1.42 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| }
|
|
|
| .juror {
|
| --face: #c89259;
|
| --juror-image: none;
|
| position: relative;
|
| height: 72px;
|
| transform-origin: center bottom;
|
| filter: none;
|
| color: inherit;
|
| text-decoration: none;
|
| }
|
|
|
| .juror.active {
|
| animation: juror-react .82s ease-in-out infinite alternate;
|
| }
|
|
|
| .juror-face {
|
| position: absolute;
|
| left: 50%;
|
| top: 0;
|
| width: 30px;
|
| height: 30px;
|
| transform: translateX(-50%);
|
| border: 2px solid rgba(255, 232, 174, .5);
|
| border-radius: 50%;
|
| background:
|
| radial-gradient(circle at 35% 40%, #1d1109 0 2px, transparent 2.5px),
|
| radial-gradient(circle at 64% 40%, #1d1109 0 2px, transparent 2.5px),
|
| linear-gradient(180deg, var(--face), #835235);
|
| }
|
|
|
| .juror-face::after {
|
| content: "";
|
| position: absolute;
|
| left: 10px;
|
| bottom: 9px;
|
| width: 14px;
|
| height: 7px;
|
| border-bottom: 2px solid #25140c;
|
| border-radius: 0 0 18px 18px;
|
| }
|
|
|
| .juror-portrait {
|
| position: absolute;
|
| left: 50%;
|
| top: -19px;
|
| z-index: 3;
|
| width: 58px;
|
| height: 74px;
|
| transform: translateX(-50%);
|
| object-fit: contain;
|
| pointer-events: none;
|
| }
|
|
|
| .juror-body {
|
| position: absolute;
|
| left: 50%;
|
| top: 32px;
|
| width: 36px;
|
| height: 36px;
|
| transform: translateX(-50%);
|
| border-radius: 20px 20px 7px 7px;
|
| border: 1px solid rgba(255, 232, 174, .18);
|
| background: linear-gradient(180deg, #5b496f, #211726);
|
| }
|
|
|
| .phase-deliberation .juror:nth-child(odd) {
|
| animation-delay: .18s;
|
| }
|
|
|
| .evidence-props {
|
| position: absolute;
|
| left: 55%;
|
| right: 11%;
|
| bottom: 36%;
|
| z-index: 9;
|
| display: flex;
|
| flex-wrap: wrap;
|
| gap: 8px;
|
| justify-content: center;
|
| pointer-events: auto;
|
| }
|
|
|
| .evidence-sheet {
|
| width: 96px;
|
| min-height: 72px;
|
| padding: 8px;
|
| transform: rotate(var(--tilt));
|
| border: 1px solid rgba(56, 32, 15, .22);
|
| border-radius: 3px;
|
| background:
|
| linear-gradient(135deg, transparent 0 84%, rgba(64, 38, 20, .18) 85%),
|
| #fff6df;
|
| color: #372212;
|
| box-shadow: 0 10px 20px rgba(0,0,0,.28);
|
| opacity: .18;
|
| transition: transform .25s ease, opacity .25s ease;
|
| }
|
|
|
| .phase-evidence .evidence-sheet,
|
| .phase-questions .evidence-sheet,
|
| .phase-deliberation .evidence-sheet,
|
| .phase-verdict .evidence-sheet,
|
| .phase-appeal .evidence-sheet {
|
| opacity: .96;
|
| animation: paper-land .55s ease-out both;
|
| }
|
|
|
| .evidence-sheet:hover {
|
| transform: rotate(0) translateY(-8px) scale(1.08);
|
| z-index: 15;
|
| }
|
|
|
| .evidence-sheet strong {
|
| display: block;
|
| margin-bottom: 4px;
|
| color: #254f7a;
|
| font: 800 12px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| }
|
|
|
| .evidence-sheet span {
|
| display: block;
|
| font-size: 11px;
|
| line-height: 1.2;
|
| }
|
|
|
| .trial-caption {
|
| position: absolute;
|
| left: 50%;
|
| bottom: 108px;
|
| z-index: 14;
|
| width: min(870px, calc(100% - 44px));
|
| transform: translateX(-50%);
|
| padding: 12px 16px 13px;
|
| border: 1px solid rgba(255, 226, 154, .34);
|
| border-radius: 6px;
|
| background: rgba(13, 7, 4, .78);
|
| backdrop-filter: blur(12px);
|
| box-shadow: 0 18px 36px rgba(0,0,0,.38);
|
| }
|
|
|
| .caption-title {
|
| margin-top: 3px;
|
| color: #fff3d7;
|
| font-size: 20px;
|
| font-weight: 800;
|
| }
|
|
|
| .caption-body {
|
| margin-top: 5px;
|
| color: #f8dfaa;
|
| font-size: 14px;
|
| line-height: 1.36;
|
| white-space: pre-wrap;
|
| }
|
|
|
| .decree-ribbon {
|
| position: absolute;
|
| right: 26px;
|
| top: 22px;
|
| z-index: 10;
|
| max-width: 230px;
|
| padding: 9px 11px;
|
| border: 1px solid rgba(255, 226, 154, .26);
|
| border-radius: 5px;
|
| background: rgba(18, 9, 5, .68);
|
| color: #ffe6ae;
|
| font: 800 11px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| text-transform: uppercase;
|
| }
|
|
|
| .phase-verdict .judge-dais,
|
| .phase-questions .judge-dais {
|
| animation: bench-lean .9s ease-in-out infinite alternate;
|
| }
|
|
|
| .phase-deliberation .jury-benches {
|
| animation: jury-murmur .7s ease-in-out infinite alternate;
|
| }
|
|
|
| .stage-prop-link {
|
| cursor: help;
|
| }
|
|
|
| .drawer-shell {
|
| max-width: 1500px;
|
| margin: 12px auto 0;
|
| }
|
|
|
| .drawer-grid {
|
| display: grid;
|
| grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| gap: 18px 28px;
|
| }
|
|
|
| .drawer-text-stack {
|
| color: var(--ink);
|
| line-height: 1.42;
|
| }
|
|
|
| .drawer-text-block {
|
| color: var(--ink);
|
| }
|
|
|
| .drawer-text-block h4 {
|
| margin: 5px 0 7px;
|
| }
|
|
|
| .drawer-text-block p,
|
| .drawer-empty {
|
| margin: 0 0 8px;
|
| line-height: 1.38;
|
| white-space: pre-wrap;
|
| }
|
|
|
| .vote-liable { color: var(--red); font-weight: 800; }
|
| .vote-not_liable { color: var(--green); font-weight: 800; }
|
| .vote-uncertain { color: var(--blue); font-weight: 800; }
|
|
|
| .mind-text {
|
| max-height: 340px;
|
| overflow: auto;
|
| color: var(--ink);
|
| font: 12px/1.42 ui-monospace, SFMono-Regular, Consolas, monospace;
|
| white-space: pre-wrap;
|
| }
|
|
|
| @keyframes puppet-breathe {
|
| 0%, 100% { transform: translate(-50%, -100%) translateY(0); }
|
| 50% { transform: translate(-50%, -100%) translateY(-4px); }
|
| }
|
|
|
| @keyframes lawyer-walk {
|
| 0%, 100% { transform: translate(-50%, -100%) translateY(0) rotate(-1deg); }
|
| 25% { transform: translate(-50%, -100%) translateY(-7px) rotate(2deg); }
|
| 50% { transform: translate(-50%, -100%) translateY(0) rotate(1deg); }
|
| 75% { transform: translate(-50%, -100%) translateY(-5px) rotate(-2deg); }
|
| }
|
|
|
| @keyframes speak-mouth {
|
| 0%, 100% { height: 5px; border-radius: 0 0 18px 18px; }
|
| 50% { height: 10px; border-radius: 50%; border: 2px solid #28150c; }
|
| }
|
|
|
| @keyframes juror-react {
|
| from { transform: translateY(0) rotate(-1deg); }
|
| to { transform: translateY(-5px) rotate(2deg); }
|
| }
|
|
|
| @keyframes evidence-focus {
|
| 0%, 100% { transform: translate(-50%, -100%) translateY(0) scale(1); }
|
| 50% { transform: translate(-50%, -100%) translateY(-6px) scale(1.035); }
|
| }
|
|
|
| @keyframes paper-land {
|
| from { transform: rotate(var(--tilt)) translateY(-18px); opacity: 0; }
|
| to { transform: rotate(var(--tilt)) translateY(0); opacity: .96; }
|
| }
|
|
|
| @keyframes bench-lean {
|
| from { transform: translateX(-50%) translateY(0); }
|
| to { transform: translateX(-50%) translateY(-5px); }
|
| }
|
|
|
| @keyframes jury-murmur {
|
| from { transform: translateX(0); }
|
| to { transform: translateX(-3px); }
|
| }
|
|
|
| @keyframes gavel-hit {
|
| 0% { transform: rotate(-18deg) translateY(0); }
|
| 45% { transform: rotate(21deg) translateY(18px); }
|
| 100% { transform: rotate(-18deg) translateY(0); }
|
| }
|
|
|
| @keyframes verdict-pop {
|
| 0% {
|
| opacity: 0;
|
| transform: translate(-50%, -46%) scale(.94);
|
| }
|
| 100% {
|
| opacity: 1;
|
| transform: translate(-50%, -50%) scale(1);
|
| }
|
| }
|
|
|
| @media (max-width: 820px) {
|
| .docket-book-controls {
|
| position: fixed;
|
| top: 130px;
|
| width: calc(100vw - 52px);
|
| transform: translateX(-50%) rotate(-1deg);
|
| }
|
|
|
| .trial-progress {
|
| grid-template-columns: repeat(8, minmax(24px, 1fr));
|
| padding: 2px 5px 3px;
|
| }
|
|
|
| .trial-progress-segment {
|
| font-size: 9px;
|
| letter-spacing: 0;
|
| }
|
|
|
| .trial-progress-label {
|
| display: none;
|
| }
|
|
|
| .trial-progress-abbrev {
|
| display: inline;
|
| }
|
|
|
| .court-episode-stage {
|
| height: 1280px;
|
| min-height: 1280px;
|
| }
|
|
|
| .episode-room {
|
| background-position: center top;
|
| }
|
|
|
| .episode-title {
|
| left: 16px;
|
| right: 16px;
|
| max-width: none;
|
| }
|
|
|
| .decree-ribbon {
|
| top: 164px;
|
| left: 16px;
|
| right: auto;
|
| max-width: calc(100% - 32px);
|
| }
|
|
|
| .speech-bubble.active-dialogue,
|
| .speech-bubble.active-dialogue.speaker-clerk,
|
| .speech-bubble.active-dialogue.speaker-judge,
|
| .speech-bubble.active-dialogue.speaker-auric,
|
| .speech-bubble.active-dialogue.speaker-sable,
|
| .speech-bubble.active-dialogue.juror-dialogue {
|
| left: 50%;
|
| top: 218px;
|
| width: calc(100% - 28px);
|
| max-height: 260px;
|
| transform: translateX(-50%);
|
| }
|
|
|
| .speech-bubble.active-dialogue::after {
|
| display: none;
|
| }
|
|
|
| .speech-bubble.active-dialogue.juror-dialogue,
|
| .speech-bubble.active-dialogue.speaker-karl-marx,
|
| .speech-bubble.active-dialogue.speaker-john-stuart-mill,
|
| .speech-bubble.active-dialogue.speaker-confucius,
|
| .speech-bubble.active-dialogue.speaker-cleopatra-vii,
|
| .speech-bubble.active-dialogue.speaker-niccolo-machiavelli,
|
| .speech-bubble.active-dialogue.speaker-jensen-huang {
|
| top: 500px;
|
| width: min(320px, calc(100vw - 28px));
|
| transform: translateY(-100%);
|
| }
|
|
|
| .speech-bubble.active-dialogue.speaker-karl-marx,
|
| .speech-bubble.active-dialogue.speaker-john-stuart-mill,
|
| .speech-bubble.active-dialogue.speaker-confucius {
|
| left: 14px;
|
| right: auto;
|
| }
|
|
|
| .speech-bubble.active-dialogue.speaker-cleopatra-vii,
|
| .speech-bubble.active-dialogue.speaker-niccolo-machiavelli,
|
| .speech-bubble.active-dialogue.speaker-jensen-huang {
|
| right: 14px;
|
| left: auto;
|
| }
|
|
|
| .episode-book {
|
| top: 218px;
|
| width: min(680px, calc(100% - 20px));
|
| }
|
|
|
| .episode-book.closed {
|
| top: 640px;
|
| width: 140px;
|
| }
|
|
|
| .book-open-content {
|
| grid-template-columns: 1fr;
|
| gap: 10px;
|
| inset: 15% 11% 13%;
|
| padding: 0 16px;
|
| }
|
|
|
| .book-open-content h2 {
|
| font-size: 22px;
|
| margin-bottom: 5px;
|
| }
|
|
|
| .book-open-content p,
|
| .book-entry {
|
| font-size: 12px;
|
| line-height: 1.22;
|
| }
|
|
|
| .book-entry {
|
| margin: 5px 0;
|
| }
|
|
|
| .book-evidence-columns {
|
| grid-template-columns: 1fr 1fr;
|
| gap: 8px;
|
| }
|
|
|
| .book-evidence-list li {
|
| font-size: 10px;
|
| line-height: 1.12;
|
| }
|
|
|
| .book-field {
|
| min-height: 34px;
|
| font-size: 10px;
|
| }
|
|
|
| .book-context-field {
|
| min-height: 84px;
|
| }
|
|
|
| .judge-dais {
|
| top: 390px;
|
| width: 280px;
|
| }
|
|
|
| .counsel-table.left {
|
| left: 7%;
|
| bottom: 470px;
|
| }
|
|
|
| .counsel-table.right {
|
| right: 7%;
|
| bottom: 470px;
|
| }
|
|
|
| .counsel-table {
|
| width: 154px;
|
| }
|
|
|
| .puppet.auric {
|
| left: 20%;
|
| top: 970px;
|
| }
|
|
|
| .puppet.sable {
|
| left: 80%;
|
| top: 970px;
|
| }
|
|
|
| .speaker-auric .puppet.auric {
|
| left: 42%;
|
| top: 970px;
|
| }
|
|
|
| .speaker-sable .puppet.sable {
|
| left: 80%;
|
| top: 970px;
|
| }
|
|
|
| .puppet.clerk {
|
| left: 35%;
|
| top: 880px;
|
| }
|
|
|
| .witness-area {
|
| right: 5%;
|
| bottom: 580px;
|
| width: 138px;
|
| }
|
|
|
| .jury-benches {
|
| top: 520px;
|
| width: 126px;
|
| min-width: 126px;
|
| }
|
|
|
| .jury-benches.left {
|
| left: .5%;
|
| }
|
|
|
| .jury-benches.right {
|
| right: .5%;
|
| }
|
|
|
| .foreground-fence {
|
| bottom: -66px;
|
| width: 64%;
|
| }
|
|
|
| .foreground-fence.fence-left {
|
| left: -17%;
|
| }
|
|
|
| .foreground-fence.fence-right {
|
| right: -17%;
|
| }
|
|
|
| .judge-table-foreground {
|
| top: 213px;
|
| width: 646px;
|
| }
|
|
|
| .evidence-props {
|
| left: 8%;
|
| right: 8%;
|
| bottom: 410px;
|
| }
|
|
|
| .trial-caption {
|
| bottom: 105px;
|
| }
|
|
|
| .gallery-benches {
|
| bottom: 42px;
|
| grid-template-columns: repeat(3, 1fr);
|
| }
|
| }
|
| """
|
|
|
| APP_JS = f"""
|
| () => {{
|
| const paths = {json.dumps(AUDIO_PATHS)};
|
| const SCORE_BASE_VOLUME = 0.16;
|
| const SCORE_QUIET_VOLUME = 0.035;
|
| const SCORE_BREATH_INTERVAL_MS = 20000;
|
| const SCORE_BREATH_DURATION_MS = 5000;
|
| const make = (name, volume = 1, loop = false) => {{
|
| const audio = new Audio(paths[name]);
|
| audio.preload = 'auto';
|
| audio.volume = volume;
|
| audio.loop = loop;
|
| return audio;
|
| }};
|
|
|
| if (!window.SovereignCourtAudio) {{
|
| const controller = {{
|
| unlocked: false,
|
| lastPhase: null,
|
| muted: false,
|
| scoreVolume: SCORE_BASE_VOLUME,
|
| crowdVolume: 0.0,
|
| fadeFrame: null,
|
| breathTimer: null,
|
| score: make('score', SCORE_BASE_VOLUME, true),
|
| crowd: make('crowd', 0.0, true),
|
| begin() {{
|
| this.unlocked = true;
|
| this.ensureLooping();
|
| this.startBreathing();
|
| this.play('select', 0.26);
|
| window.setTimeout(() => this.play('paper_long', 0.45), 120);
|
| window.setTimeout(() => this.play('gavel', 0.72), 520);
|
| this.observePhase();
|
| this.updateToggle();
|
| }},
|
| ensureLooping() {{
|
| if (!this.unlocked || this.muted) return;
|
| this.applyLoopVolumes();
|
| this.score.play().catch(() => {{}});
|
| this.crowd.play().catch(() => {{}});
|
| }},
|
| applyLoopVolumes() {{
|
| this.score.volume = this.muted ? 0 : this.scoreVolume;
|
| this.crowd.volume = this.muted ? 0 : this.crowdVolume;
|
| }},
|
| play(name, volume = 1) {{
|
| if (!this.unlocked || this.muted) return;
|
| const cue = make(name, volume, false);
|
| cue.play().catch(() => {{}});
|
| }},
|
| setCrowd(volume) {{
|
| this.crowdVolume = volume;
|
| this.applyLoopVolumes();
|
| }},
|
| fadeScore(toVolume, duration, onComplete) {{
|
| if (this.fadeFrame) window.cancelAnimationFrame(this.fadeFrame);
|
| const fromVolume = this.scoreVolume;
|
| const started = window.performance.now();
|
| const step = (now) => {{
|
| const progress = Math.min(1, (now - started) / duration);
|
| this.scoreVolume = fromVolume + ((toVolume - fromVolume) * progress);
|
| this.applyLoopVolumes();
|
| if (progress < 1) {{
|
| this.fadeFrame = window.requestAnimationFrame(step);
|
| }} else {{
|
| this.fadeFrame = null;
|
| if (onComplete) onComplete();
|
| }}
|
| }};
|
| this.fadeFrame = window.requestAnimationFrame(step);
|
| }},
|
| breatheScore() {{
|
| if (!this.unlocked) return;
|
| const halfDuration = SCORE_BREATH_DURATION_MS / 2;
|
| this.fadeScore(SCORE_QUIET_VOLUME, halfDuration, () => {{
|
| this.fadeScore(SCORE_BASE_VOLUME, halfDuration);
|
| }});
|
| }},
|
| startBreathing() {{
|
| if (this.breathTimer) return;
|
| this.breathTimer = window.setInterval(() => this.breatheScore(), SCORE_BREATH_INTERVAL_MS);
|
| }},
|
| toggleMuted() {{
|
| this.muted = !this.muted;
|
| if (this.muted) {{
|
| this.applyLoopVolumes();
|
| this.score.pause();
|
| this.crowd.pause();
|
| }} else {{
|
| this.ensureLooping();
|
| }}
|
| this.updateToggle();
|
| }},
|
| updateToggle() {{
|
| document.querySelectorAll('.sound-toggle').forEach((button) => {{
|
| button.classList.toggle('muted', this.muted);
|
| button.setAttribute('aria-pressed', String(this.muted));
|
| button.setAttribute('title', this.muted ? 'Sound off' : 'Sound on');
|
| }});
|
| }},
|
| cuePhase(phase) {{
|
| if (!this.unlocked || !phase || phase === this.lastPhase) return;
|
| this.lastPhase = phase;
|
| if (phase === 'intake') {{
|
| this.setCrowd(0.08);
|
| this.play('paper', 0.45);
|
| this.play('wood', 0.42);
|
| }} else if (phase === 'claims' || phase === 'opening') {{
|
| this.setCrowd(0.045);
|
| this.play('steps', 0.33);
|
| }} else if (phase === 'evidence') {{
|
| this.setCrowd(0.035);
|
| this.play('paper_long', 0.52);
|
| }} else if (phase === 'questions') {{
|
| this.setCrowd(0.02);
|
| this.play('wood', 0.34);
|
| }} else if (phase === 'deliberation') {{
|
| this.setCrowd(0.18);
|
| }} else if (phase === 'verdict') {{
|
| this.setCrowd(0.0);
|
| this.play('judgement', 0.66);
|
| window.setTimeout(() => this.play('gavel', 0.9), 650);
|
| }} else if (phase === 'appeal') {{
|
| this.setCrowd(0.035);
|
| this.play('paper_long', 0.5);
|
| }}
|
| }},
|
| observePhase() {{
|
| const stage = document.querySelector('.court-episode-stage');
|
| if (stage) this.cuePhase(stage.dataset.phase);
|
| this.updateToggle();
|
| }}
|
| }};
|
|
|
| window.SovereignCourtAudio = controller;
|
|
|
| const observer = new MutationObserver(() => controller.observePhase());
|
| observer.observe(document.body, {{ childList: true, subtree: true, attributes: true, attributeFilter: ['data-phase'] }});
|
|
|
| document.addEventListener('click', (event) => {{
|
| const toggle = event.target.closest('.sound-toggle');
|
| if (toggle) {{
|
| event.preventDefault();
|
| controller.toggleMuted();
|
| return;
|
| }}
|
| if (event.target.closest('.docket-book-controls')) {{
|
| controller.play('select', 0.22);
|
| }}
|
| }}, true);
|
| }}
|
| }}
|
| """
|
|
|
| APP_HEAD = f"""
|
| <script>
|
| (function() {{
|
| const installCourtAudio = {APP_JS.strip()};
|
| if (document.readyState === 'loading') {{
|
| document.addEventListener('DOMContentLoaded', installCourtAudio, {{ once: true }});
|
| }} else {{
|
| installCourtAudio();
|
| }}
|
| }})();
|
| </script>
|
| """
|
|
|
| START_JS = """
|
| (case_label, search_query, hypothetical, custom_payload, speed, mind_layer) => {
|
| const book = document.querySelector('.episode-book.custom-book');
|
| const collect = (selector) => Array.from(document.querySelectorAll(selector)).map((node) => node.value || '');
|
| const payload = book ? JSON.stringify({
|
| context: document.querySelector('.book-context-field')?.value || '',
|
| claimant_evidence: collect('.book-claimant-field'),
|
| respondent_evidence: collect('.book-respondent-field')
|
| }) : (custom_payload || '');
|
| if (book) {
|
| const data = JSON.parse(payload);
|
| const hasContext = data.context.trim().length > 0;
|
| const hasClaimant = data.claimant_evidence.some((value) => value.trim().length > 0);
|
| const hasRespondent = data.respondent_evidence.some((value) => value.trim().length > 0);
|
| if (!hasContext || !hasClaimant || !hasRespondent) {
|
| return [case_label, search_query, hypothetical, payload, speed, mind_layer];
|
| }
|
| }
|
| document.body.classList.add('trial-has-started');
|
| if (window.SovereignCourtAudio) {
|
| window.SovereignCourtAudio.begin();
|
| }
|
| return [case_label, search_query, hypothetical, payload, speed, mind_layer];
|
| }
|
| """
|
|
|
| CHARACTERS = {
|
| JUDGE_NAME: {
|
| "class": "judge",
|
| "name": JUDGE_NAME,
|
| "role": "Stoic presiding judge",
|
| "model": "gpt-oss-20b",
|
| "image": "/gradio_api/file=assets/characters/marcus-aurelius.png",
|
| },
|
| "Clerk Meridian": {
|
| "class": "clerk",
|
| "name": "Clerk Meridian",
|
| "role": "Court clerk",
|
| "model": "AgentCPM-Explore",
|
| },
|
| "Mike OSS": {
|
| "class": "auric",
|
| "name": "Mike OSS",
|
| "role": "Claimant advocate",
|
| "model": "gpt-oss-20b",
|
| },
|
| "Harvey Vector": {
|
| "class": "sable",
|
| "name": "Harvey Vector",
|
| "role": "Respondent advocate",
|
| "model": "gpt-oss-20b",
|
| },
|
| "Nemotron Jury": {
|
| "class": "jury",
|
| "name": "Nemotron Jury",
|
| "role": "Jury panel",
|
| "model": "Nemotron-Orchestrator-8B",
|
| },
|
| }
|
|
|
| JUROR_FACES = {
|
| "Karl Marx": "#d0b79c",
|
| "John Stuart Mill": "#c99b72",
|
| "Confucius": "#c49a64",
|
| "Cleopatra VII": "#b98755",
|
| "Niccolo Machiavelli": "#b88963",
|
| "Jensen Huang": "#b37758",
|
| }
|
|
|
| JUROR_IMAGES = {
|
| "Karl Marx": "/gradio_api/file=assets/characters/karl-marx.png",
|
| "John Stuart Mill": "/gradio_api/file=assets/characters/john-stuart-mill.png",
|
| "Confucius": "/gradio_api/file=assets/characters/confucius.png",
|
| "Cleopatra VII": "/gradio_api/file=assets/characters/cleopatra-vii.png",
|
| "Niccolo Machiavelli": "/gradio_api/file=assets/characters/niccolo-machiavelli.png",
|
| "Jensen Huang": "/gradio_api/file=assets/characters/jensen-huang.png",
|
| }
|
|
|
| TRIAL_TURN_ORDER = (
|
| "Clerk Meridian",
|
| JUDGE_NAME,
|
| "Mike OSS",
|
| "Harvey Vector",
|
| JUDGE_NAME,
|
| "Mike OSS",
|
| "Harvey Vector",
|
| "Nemotron Jury",
|
| *JUROR_PERSONAS.keys(),
|
| JUDGE_NAME,
|
| )
|
|
|
| PHASE_AGENTS = {
|
| "pretrial": ["Clerk Meridian"],
|
| }
|
|
|
|
|
| @dataclass(frozen=True)
|
| class SpeakerCue:
|
| name: str
|
| role: str
|
| text: str
|
| pending: bool = False
|
|
|
|
|
| _EVENT_STREAM_DONE = object()
|
|
|
|
|
| def _remote_events(request: TrialRequest) -> Iterable[TrialEvent] | None:
|
| endpoint = os.getenv("MODAL_TRIAL_URL", DEFAULT_MODAL_TRIAL_URL).strip()
|
| if not endpoint:
|
| return None
|
|
|
| def iterator() -> Iterable[TrialEvent]:
|
| with httpx.stream("POST", endpoint, json=request.model_dump(), timeout=900.0) as response:
|
| response.raise_for_status()
|
| for line in response.iter_lines():
|
| if line:
|
| yield TrialEvent.model_validate_json(line)
|
|
|
| return iterator()
|
|
|
|
|
| def get_events(request: TrialRequest, delay: float | None = None) -> Iterable[TrialEvent]:
|
| remote = _remote_events(request)
|
| if remote is not None:
|
| yield from remote
|
| return
|
| stream_delay = {"swift": 1.4, "measured": 2.4, "ceremonial": 3.4}[request.speed] if delay is None else delay
|
| yield from stream_trial(request, delay=stream_delay)
|
|
|
|
|
| def _escape(value: str) -> str:
|
| return (
|
| value.replace("&", "&")
|
| .replace("<", "<")
|
| .replace(">", ">")
|
| .replace('"', """)
|
| )
|
|
|
|
|
| def _latest_packet_title(events: list[TrialEvent]) -> tuple[str, str]:
|
| if not events:
|
| return (
|
| "Judge-GPT",
|
| "The gallery doors open on an AI-native courtroom. Choose a case from the docket book and begin the proceeding.",
|
| )
|
| lines = events[0].body.splitlines()
|
| title = lines[0] if lines else "Judge-GPT"
|
| subtitle = lines[1] if len(lines) > 1 else events[0].title
|
| return title, subtitle
|
|
|
|
|
| def _active_agents_for(event: TrialEvent | None) -> set[str]:
|
| if event is None:
|
| return set(PHASE_AGENTS["pretrial"])
|
| if not event.turns:
|
| return set()
|
| return {event.turns[0].agent}
|
|
|
|
|
| def _active_speaker_for(event: TrialEvent | None) -> str:
|
| if event is None:
|
| return "Clerk Meridian"
|
| if not event.turns:
|
| return ""
|
| return event.turns[0].agent
|
|
|
|
|
| def _role_for_speaker(name: str, event: TrialEvent | None = None) -> str:
|
| if event is not None:
|
| turn = next((turn for turn in event.turns if turn.agent == name), None)
|
| if turn is not None:
|
| return turn.role
|
| if name in CHARACTERS:
|
| return CHARACTERS[name]["role"]
|
| if name in JUROR_FACES:
|
| return "juror"
|
| return "speaker"
|
|
|
|
|
| def _expected_next_speaker(events: list[TrialEvent]) -> SpeakerCue | None:
|
| if len(events) >= len(TRIAL_TURN_ORDER):
|
| return None
|
| name = TRIAL_TURN_ORDER[len(events)]
|
| role = _role_for_speaker(name)
|
| return SpeakerCue(name=name, role=role, text=f"{name} is preparing a response.", pending=True)
|
|
|
|
|
| def _speaker_class_for(speaker: str) -> str:
|
| if not speaker:
|
| return ""
|
| if speaker in CHARACTERS:
|
| return f" speaker-{CHARACTERS[speaker]['class']}"
|
| return " speaker-" + "".join(ch.lower() if ch.isalnum() else "-" for ch in speaker).strip("-")
|
|
|
|
|
| def _latest_turn_text(event: TrialEvent | None, agent: str) -> str:
|
| if event is None:
|
| return ""
|
| turn = next((turn for turn in event.turns if turn.agent == agent), None)
|
| if turn is None:
|
| return ""
|
| return _short_text(turn.content, 210)
|
|
|
|
|
| def _active_speaker_cue(event: TrialEvent | None, pending_speaker: SpeakerCue | None = None) -> SpeakerCue | None:
|
| if pending_speaker is not None:
|
| return pending_speaker
|
| if event is None or not event.turns:
|
| return None
|
| turn = event.turns[0]
|
| text = turn.content.strip()
|
| if not text:
|
| return None
|
| return SpeakerCue(name=turn.agent, role=turn.role, text=text)
|
|
|
|
|
| def _reading_duration(text: str) -> float:
|
| word_count = len(text.split())
|
| return min(MAX_READ_SECONDS, max(MIN_READ_SECONDS, (word_count / WORDS_PER_SECOND) + READ_BUFFER_SECONDS))
|
|
|
|
|
| def _event_dialogue_text(event: TrialEvent) -> str:
|
| if event.turns:
|
| return event.turns[0].content
|
| return event.body
|
|
|
|
|
| def _event_status(event: TrialEvent, step: int) -> str:
|
| if event.turns:
|
| return f"Step {step}: {event.turns[0].agent} - {event.title}"
|
| return f"Step {step}: {event.title}"
|
|
|
|
|
| def _pending_status(cue: SpeakerCue | None) -> str:
|
| if cue is None:
|
| return "The court is preparing the next turn."
|
| return f"{cue.name} is preparing their response."
|
|
|
|
|
| def _start_event_producer(request: TrialRequest) -> queue.Queue[object]:
|
| events: queue.Queue[object] = queue.Queue()
|
|
|
| def produce() -> None:
|
| try:
|
| try:
|
| stream = get_events(request, delay=0.0)
|
| except TypeError:
|
| stream = get_events(request)
|
| for event in stream:
|
| events.put(event)
|
| except Exception as exc:
|
| events.put(exc)
|
| finally:
|
| events.put(_EVENT_STREAM_DONE)
|
|
|
| threading.Thread(target=produce, name="trial-event-producer", daemon=True).start()
|
| return events
|
|
|
|
|
| def _thread_id(name: str) -> str:
|
| return "ai-thread-" + "".join(ch.lower() if ch.isalnum() else "-" for ch in name).strip("-")
|
|
|
|
|
| def _turns_for_agent(events: list[TrialEvent], agent: str) -> list[dict[str, str]]:
|
| turns = []
|
| for event in events:
|
| for turn in event.turns:
|
| if turn.agent == agent:
|
| turns.append(
|
| {
|
| "phase": event.phase,
|
| "title": event.title,
|
| "role": turn.role,
|
| "model": turn.model,
|
| "confidence": f"{turn.confidence:.2f}",
|
| "input": turn.input or "Prompt unavailable for this turn.",
|
| "output": turn.content or "No output captured yet.",
|
| }
|
| )
|
| return turns
|
|
|
|
|
| def _thread_for_character(events: list[TrialEvent], agent: str) -> list[dict[str, str]]:
|
| if agent in JUROR_FACES:
|
| turns = _turns_for_agent(events, agent)
|
| vote = next((vote for event in reversed(events) for vote in event.votes if vote.juror == agent), None)
|
| if not turns:
|
| turns = _turns_for_agent(events, "Nemotron Jury")
|
| if vote and turns:
|
| turns = [
|
| dict(
|
| turn,
|
| output=f"{turn['output']}\n\n{agent} persona: {vote.persona}\n{agent} vote: {vote.vote}\nReason: {vote.reason}",
|
| )
|
| for turn in turns
|
| ]
|
| return turns
|
| return _turns_for_agent(events, agent)
|
|
|
|
|
| def _short_text(value: str, limit: int = 170) -> str:
|
| squashed = " ".join(value.split())
|
| return squashed if len(squashed) <= limit else squashed[: limit - 1].rstrip() + "..."
|
|
|
|
|
| def _tooltip(name: str, role: str, model: str, turns: list[dict[str, str]]) -> str:
|
| latest = turns[-1] if turns else None
|
| input_preview = _short_text(latest["input"] if latest else "Waiting for this model to receive its first prompt.")
|
| output_preview = _short_text(latest["output"] if latest else "No output has been emitted yet.")
|
| return (
|
| "<span class='tooltip'>"
|
| f"<strong>{_escape(name)}</strong>"
|
| f"{_escape(role)}"
|
| f"<span class='tooltip-meta'>{_escape(model)}</span>"
|
| "<span class='tooltip-io-label'>Input</span>"
|
| f"<p>{_escape(input_preview)}</p>"
|
| "<span class='tooltip-io-label'>Output</span>"
|
| f"<p>{_escape(output_preview)}</p>"
|
| "<span class='tooltip-meta'>Click to open full thread</span>"
|
| "</span>"
|
| )
|
|
|
|
|
| def _thread_modal(name: str, role: str, model: str, turns: list[dict[str, str]]) -> str:
|
| body = (
|
| "".join(
|
| "<section class='thread-turn'>"
|
| f"<div class='thread-subtitle'>{_escape(turn['phase'])} / {_escape(turn['role'])} / confidence {turn['confidence']}</div>"
|
| "<span class='thread-label'>Input</span>"
|
| f"<div class='thread-block'>{_escape(turn['input'])}</div>"
|
| "<span class='thread-label'>Output</span>"
|
| f"<div class='thread-block'>{_escape(turn['output'])}</div>"
|
| "</section>"
|
| for turn in turns
|
| )
|
| or "<section class='thread-turn'><div class='thread-block'>Waiting for this model thread to appear.</div></section>"
|
| )
|
| return (
|
| f"<aside id='{_escape(_thread_id(name))}' class='ai-thread-modal'>"
|
| "<a class='thread-close' href='#court-stage'>Close</a>"
|
| f"<h2 class='thread-title'>{_escape(name)}</h2>"
|
| f"<div class='thread-subtitle'>{_escape(role)} / {_escape(model)}</div>"
|
| f"{body}</aside>"
|
| )
|
|
|
|
|
| def _active_dialogue(cue: SpeakerCue | None) -> str:
|
| if cue is None:
|
| return ""
|
| speaker_cls = _speaker_class_for(cue.name).strip()
|
| classes = ["speech-bubble", "active-dialogue"]
|
| if speaker_cls:
|
| classes.append(speaker_cls)
|
| if cue.name in JUROR_FACES:
|
| classes.append("juror-dialogue")
|
| if cue.pending:
|
| classes.append("pending")
|
| pending_attr = " data-pending='true'" if cue.pending else ""
|
| return (
|
| f"<div class='{' '.join(classes)}' data-speaker='{_escape(cue.name)}'{pending_attr}>"
|
| "<div class='dialogue-meta'>"
|
| f"<strong>{_escape(cue.name)}</strong>"
|
| f"<span>{_escape(cue.role)}</span>"
|
| "</div>"
|
| f"<div class='dialogue-text'>{_escape(cue.text)}</div>"
|
| "</div>"
|
| )
|
|
|
|
|
| def _verdict_popup(events: list[TrialEvent], show: bool) -> str:
|
| if not show:
|
| return ""
|
| verdict = next((event.verdict for event in reversed(events) if event.verdict is not None), None)
|
| if verdict is None:
|
| return ""
|
| finding = VERDICT_LABELS.get(verdict.finding, verdict.finding.replace("_", " ").title())
|
| return (
|
| f"<div class='verdict-popup' role='alert' aria-live='assertive' data-finding='{_escape(verdict.finding)}'>"
|
| "<span class='verdict-popup-kicker'>Verdict</span>"
|
| f"<strong class='verdict-popup-finding'>Verdict: {_escape(finding)}</strong>"
|
| f"<p class='verdict-popup-decree'>{_escape(verdict.decree)}</p>"
|
| "</div>"
|
| )
|
|
|
|
|
| def _puppet(agent: str, active_agents: set[str], phase: str, events: list[TrialEvent], latest: TrialEvent | None) -> str:
|
| meta = CHARACTERS[agent]
|
| active = " active" if agent in active_agents else ""
|
| walking = " walking" if agent in {"Mike OSS", "Harvey Vector"} and agent in active_agents else ""
|
| small = " small" if agent == "Clerk Meridian" else ""
|
| turns = _thread_for_character(events, agent)
|
| portrait = ""
|
| if meta.get("image"):
|
| portrait = (
|
| f"<img class='puppet-portrait' src='{_escape(meta['image'])}' "
|
| f"alt='{_escape(meta['name'])} bust' onerror=\"this.style.display='none'\">"
|
| )
|
| return (
|
| f"<a class='puppet {meta['class']}{active}{walking}{small}' href='#{_escape(_thread_id(agent))}' aria-label='Open {_escape(agent)} model thread'>"
|
| f"{portrait}"
|
| "<span class='mouth'></span>"
|
| f"{_tooltip(meta['name'], meta['role'], meta['model'], turns)}"
|
| "</a>"
|
| )
|
|
|
|
|
| def _juror(name: str, active: bool, events: list[TrialEvent] | None = None, latest: TrialEvent | None = None) -> str:
|
| face = JUROR_FACES.get(name, "#c89259")
|
| image = JUROR_IMAGES.get(name, "")
|
| active_cls = " active" if active else ""
|
| turns = _thread_for_character(events or [], name)
|
| portrait = (
|
| f"<img class='juror-portrait' src='{_escape(image)}' alt='{_escape(name)} bust' "
|
| "onerror=\"this.style.display='none'\">"
|
| if image
|
| else ""
|
| )
|
| fallback_art = "" if image else "<span class='juror-face'></span><span class='juror-body'></span>"
|
| return (
|
| f"<a class='juror{active_cls}' href='#{_escape(_thread_id(name))}' style='--face: {face}' aria-label='Open {_escape(name)} model thread'>"
|
| f"{portrait}"
|
| f"{fallback_art}"
|
| f"{_tooltip(name, 'HF-style juror', 'Nemotron panel', turns)}"
|
| "</a>"
|
| )
|
|
|
|
|
| def _packet_for_label(case_label: str) -> CasePacket:
|
| return get_case(CASE_OPTIONS.get(case_label, "socrates"))
|
|
|
|
|
| def _split_evidence(packet: CasePacket) -> tuple[list[EvidenceItem], list[EvidenceItem]]:
|
| claimant = [item for item in packet.evidence if item.supports == "claimant"]
|
| respondent = [item for item in packet.evidence if item.supports == "respondent"]
|
| if len(claimant) < 3:
|
| claimant.extend(item for item in packet.evidence if item.supports in {"mixed", "context"} and item not in claimant)
|
| if len(respondent) < 3:
|
| respondent.extend(item for item in packet.evidence if item.supports in {"mixed", "context"} and item not in respondent)
|
| return claimant[:3], respondent[:3]
|
|
|
|
|
| def _book_evidence_column(title: str, items: list[EvidenceItem]) -> str:
|
| entries = "".join(
|
| "<li>"
|
| f"<strong>{_escape(item.title)}</strong><br>"
|
| f"{_escape(item.note)}"
|
| "</li>"
|
| for item in items
|
| )
|
| return (
|
| "<section class='book-evidence-column'>"
|
| f"<h3>{_escape(title)}</h3>"
|
| f"<ul class='book-evidence-list'>{entries}</ul>"
|
| "</section>"
|
| )
|
|
|
|
|
| def _custom_evidence_fields(class_name: str, label: str) -> str:
|
| fields = "".join(
|
| f"<textarea class='book-field {class_name}' aria-label='{_escape(label)} {index}' "
|
| f"placeholder='{_escape(label)} {index}'></textarea>"
|
| for index in range(1, 4)
|
| )
|
| return f"<section class='book-evidence-column'><h3>{_escape(label)}</h3>{fields}</section>"
|
|
|
|
|
| def _book(open_book: bool, packet: CasePacket | None = None, custom_mode: bool = False) -> str:
|
| closed = "" if open_book else " closed"
|
| custom_class = " custom-book" if custom_mode and open_book else ""
|
| hidden_attr = "" if custom_mode and open_book else " aria-hidden='true'"
|
| packet = packet or get_case("socrates")
|
| if custom_mode and open_book:
|
| left_page = (
|
| "<section><h2>Trial details</h2>"
|
| "<textarea class='book-field book-context-field' aria-label='Custom trial details' "
|
| "placeholder='Write a short paragraph describing what happened and why the court is hearing it.'></textarea>"
|
| "</section>"
|
| )
|
| right_page = (
|
| "<section><h2>Evidence</h2><div class='book-evidence-columns'>"
|
| f"{_custom_evidence_fields('book-claimant-field', 'Evidence for Claimant')}"
|
| f"{_custom_evidence_fields('book-respondent-field', 'Evidence against Claimant')}"
|
| "</div></section>"
|
| )
|
| else:
|
| claimant_evidence, respondent_evidence = _split_evidence(packet)
|
| left_page = (
|
| "<section><h2>Trial details</h2>"
|
| f"<p class='book-case-title'>{_escape(packet.title)}</p>"
|
| f"<p class='book-context'>{_escape(packet.context or packet.setting)}</p>"
|
| f"<div class='book-entry'><strong>{_escape(packet.claimant)}</strong><br>{_escape(packet.claimant_claim)}</div>"
|
| f"<div class='book-entry'><strong>{_escape(packet.respondent)}</strong><br>{_escape(packet.respondent_claim)}</div>"
|
| "</section>"
|
| )
|
| right_page = (
|
| "<section><h2>Evidence</h2><div class='book-evidence-columns'>"
|
| f"{_book_evidence_column(f'Evidence for {packet.claimant}', claimant_evidence)}"
|
| f"{_book_evidence_column(f'Evidence for {packet.respondent}', respondent_evidence)}"
|
| "</div></section>"
|
| )
|
| return (
|
| f"<div class='episode-book{closed}{custom_class}'>"
|
| "<img class='book-art open-art' src='/gradio_api/file=assets/book/docket-book-open.png' alt='Open docket book'>"
|
| "<img class='book-art closed-art' src='/gradio_api/file=assets/book/docket-book-closed.png' alt='Closed docket book'>"
|
| f"<div class='book-open-content'{hidden_attr}>"
|
| f"{left_page}"
|
| f"{right_page}"
|
| "</div>"
|
| "</div>"
|
| )
|
|
|
|
|
| def _caption(event: TrialEvent | None, phase: str) -> tuple[str, str, str]:
|
| if event is None:
|
| return (
|
| "PRETRIAL",
|
| "The Courtroom Is Ready",
|
| "The docket is open, the room is dimmed, and the clerk is waiting for the first call.",
|
| )
|
| body = event.body.splitlines()[0] if event.body.splitlines() else event.body
|
| return (f"{PHASE_GLYPHS[phase]} / {phase.upper()}", event.title, body)
|
|
|
|
|
| def _evidence_props(events: list[TrialEvent]) -> str:
|
| evidence = next((event.evidence for event in reversed(events) if event.evidence), [])
|
| if not evidence:
|
| return ""
|
| tilts = ["-5deg", "3deg", "-2deg", "5deg"]
|
| sheets = []
|
| for idx, item in enumerate(evidence[:4]):
|
| sheets.append(
|
| f"<div class='evidence-sheet stage-prop-link' style='--tilt: {tilts[idx % len(tilts)]}' title='{_escape(item.note)}'>"
|
| f"<strong>{_escape(item.id)}</strong>"
|
| f"<span>{_escape(item.title)}</span></div>"
|
| )
|
| return f"<div class='evidence-props'>{''.join(sheets)}</div>"
|
|
|
|
|
| def _foreground_props() -> str:
|
| fence = "/gradio_api/file=assets/foreground/foregroundFence.png"
|
| judge_table = "/gradio_api/file=assets/foreground/JudgeTable.png"
|
| return (
|
| "<div class='foreground-props' aria-hidden='true'>"
|
| f"<img class='foreground-fence fence-left' src='{fence}' alt=''>"
|
| f"<img class='foreground-fence fence-right' src='{fence}' alt=''>"
|
| f"<img class='judge-table-foreground' src='{judge_table}' alt=''>"
|
| "</div>"
|
| )
|
|
|
|
|
| def _trial_progress(events: list[TrialEvent]) -> str:
|
| latest = events[-1] if events else None
|
| current_phase = latest.phase if latest else "pretrial"
|
| stage_keys = [key for key, _label in TRIAL_PROGRESS_STAGES]
|
| current_index = stage_keys.index(current_phase) if current_phase in stage_keys else None
|
| segments = []
|
| for index, (key, label) in enumerate(TRIAL_PROGRESS_STAGES):
|
| classes = ["trial-progress-segment"]
|
| attrs = [f"data-phase='{_escape(key)}'"]
|
| if current_index is not None and index < current_index:
|
| classes.append("complete")
|
| if current_index == index:
|
| classes.append("current")
|
| attrs.append("aria-current='step'")
|
| if key == "verdict":
|
| classes.append("complete")
|
| abbrev = label[:3]
|
| segments.append(
|
| f"<span class='{' '.join(classes)}' {' '.join(attrs)}>"
|
| f"<span class='trial-progress-label'>{_escape(label)}</span>"
|
| f"<span class='trial-progress-abbrev' aria-hidden='true'>{_escape(abbrev)}</span>"
|
| "</span>"
|
| )
|
| return (
|
| "<nav class='trial-progress' aria-label='Trial progress'>"
|
| + "".join(segments)
|
| + "</nav>"
|
| )
|
|
|
|
|
| def _courtroom_juror_names(votes: list) -> list[str]:
|
| names = list(JUROR_FACES)
|
| names.extend(vote.juror for vote in votes if vote.juror not in names)
|
| return names[:6]
|
|
|
|
|
| def _latest_votes(events: list[TrialEvent]) -> list:
|
| by_juror = {}
|
| for event in events:
|
| for vote in event.votes:
|
| by_juror[vote.juror] = vote
|
| ordered = [by_juror[name] for name in JUROR_FACES if name in by_juror]
|
| ordered.extend(vote for juror, vote in by_juror.items() if juror not in JUROR_FACES)
|
| return ordered
|
|
|
|
|
| def render_court(
|
| events: list[TrialEvent],
|
| started: bool = False,
|
| pending_speaker: SpeakerCue | None = None,
|
| show_verdict_popup: bool = False,
|
| pretrial_case: CasePacket | None = None,
|
| custom_mode: bool = False,
|
| ) -> str:
|
| latest = events[-1] if events else None
|
| phase = latest.phase if latest else "pretrial"
|
| title, subtitle = _latest_packet_title(events)
|
| active_cue = _active_speaker_cue(latest, pending_speaker)
|
| active_speaker = active_cue.name if active_cue is not None else _active_speaker_for(latest)
|
| active_agents = {active_speaker} if active_speaker else _active_agents_for(latest)
|
| speaker_cls = _speaker_class_for(active_speaker)
|
| caption_phase, caption_title, caption_body = _caption(latest, phase)
|
| latest_votes = _latest_votes(events)
|
| juror_names = _courtroom_juror_names(latest_votes)
|
| started_cls = " trial-started" if started or events else ""
|
| book_open = not started and not events
|
| puppets = "".join(
|
| _puppet(agent, active_agents, phase, events, latest)
|
| for agent in [JUDGE_NAME, "Clerk Meridian", "Mike OSS", "Harvey Vector"]
|
| )
|
| left_jurors = "".join(_juror(name, name == active_speaker, events, latest) for name in juror_names[:3])
|
| right_jurors = "".join(_juror(name, name == active_speaker, events, latest) for name in juror_names[3:6])
|
| evidence_props = _evidence_props(events)
|
| thread_modals = "".join(
|
| _thread_modal(meta["name"], meta["role"], meta["model"], _thread_for_character(events, agent))
|
| for agent, meta in CHARACTERS.items()
|
| ) + "".join(
|
| _thread_modal(name, "HF-style juror", "Nemotron panel", _thread_for_character(events, name))
|
| for name in juror_names
|
| )
|
| return (
|
| f"<section id='court-stage' class='court-episode-stage phase-{_escape(phase)}{_escape(speaker_cls)}{started_cls}' data-phase='{_escape(phase)}'>"
|
| f"{_trial_progress(events)}"
|
| "<div class='episode-room'></div>"
|
| "<div class='audio-deck' aria-hidden='true'>"
|
| + "".join(f"<audio preload='auto' src='{_escape(src)}'></audio>" for src in AUDIO_PATHS.values())
|
| + "</div>"
|
| "<button class='sound-toggle' type='button' aria-label='Toggle sound' aria-pressed='false' title='Sound on'>"
|
| "<span class='sound-icon' aria-hidden='true'></span></button>"
|
| "<div class='episode-title'>"
|
| "<div class='episode-kicker'>Judge-GPT Virtual Courtroom</div>"
|
| f"<h1>{_escape(title)}</h1>"
|
| f"<p>{_escape(subtitle)}</p></div>"
|
| f"<div class='decree-ribbon'>Step {len(events) if events else 0}: {caption_title}<br>Hover characters for agent and model details</div>"
|
| f"{_book(book_open, pretrial_case, custom_mode)}"
|
| f"<div class='judge-dais'><div class='prop-label'>{_escape(JUDGE_NAME)}</div><div class='bench-front'></div><span class='gavel'></span></div>"
|
| "<div class='counsel-table left'><div class='prop-label'>Claimant Table</div></div>"
|
| "<div class='counsel-table right'><div class='prop-label'>Respondent Table</div></div>"
|
| "<div class='trial-floor-mark'></div>"
|
| "<div class='witness-area'><div class='prop-label'>Evidence Stand</div></div>"
|
| "<div class='jury-benches left'><div class='prop-label'>Jury Box</div><div class='jury-row'>"
|
| f"{left_jurors}</div><div class='jury-rail'></div></div>"
|
| "<div class='jury-benches right'><div class='prop-label'>Jury Box</div><div class='jury-row'>"
|
| f"{right_jurors}</div><div class='jury-rail'></div></div>"
|
| f"{puppets}"
|
| f"{evidence_props}"
|
| f"{_foreground_props()}"
|
| f"{_active_dialogue(active_cue)}"
|
| f"{_verdict_popup(events, show_verdict_popup)}"
|
| "<div class='gallery-benches'><div></div><div></div><div></div><div></div><div></div><div></div></div>"
|
| "<div class='trial-caption'>"
|
| f"<div class='caption-phase'>Live Trial Feed / {_escape(caption_phase)}</div>"
|
| f"<div class='caption-title'>{_escape(caption_title)}</div>"
|
| f"<div class='caption-body'>{_escape(caption_body)}</div>"
|
| "</div>"
|
| f"{thread_modals}</section>"
|
| )
|
|
|
|
|
| def render_evidence(events: list[TrialEvent]) -> str:
|
| evidence = next((event.evidence for event in reversed(events) if event.evidence), [])
|
| if not evidence:
|
| return "<div class='drawer-empty'>The exhibit drawer is closed until the clerk opens the docket.</div>"
|
| return (
|
| "<div class='drawer-grid drawer-text-stack'>"
|
| + "".join(
|
| "<section class='drawer-text-block'>"
|
| f"<div class='drawer-kicker'>{_escape(item.id)} / {item.reliability:.2f}</div>"
|
| f"<h4>{_escape(item.title)}</h4>"
|
| f"<p>{_escape(item.excerpt)}</p>"
|
| f"<p><strong>Direction:</strong> {_escape(item.supports)}</p>"
|
| f"<p>{_escape(item.note)}</p></section>"
|
| for item in evidence
|
| )
|
| + "</div>"
|
| )
|
|
|
|
|
| def render_jurors(events: list[TrialEvent]) -> str:
|
| votes = _latest_votes(events)
|
| if not votes:
|
| sleepers = "".join(_juror(name, False) for name in JUROR_FACES)
|
| return (
|
| "<section class='drawer-text-block drawer-text-stack'><div class='drawer-kicker'>Jury Box</div>"
|
| f"<div class='jury-row'>{sleepers}</div>"
|
| "<p>The jurors are seated and silent.</p></section>"
|
| )
|
| return (
|
| "<div class='drawer-grid drawer-text-stack'>"
|
| + "".join(
|
| "<section class='drawer-text-block'>"
|
| f"<div class='drawer-kicker'>{_escape(vote.juror)}</div>"
|
| f"<p><strong>Persona:</strong> {_escape(vote.persona)}</p>"
|
| f"<p class='vote-{vote.vote}'>{_escape(vote.vote.replace('_', ' '))}</p>"
|
| f"<p>{_escape(vote.reason)}</p>"
|
| f"<p><strong>Evidence:</strong> {_escape(', '.join(vote.evidence_ids))}</p></section>"
|
| for vote in votes
|
| )
|
| + "</div>"
|
| )
|
|
|
|
|
| def render_mind(events: list[TrialEvent], enabled: bool) -> str:
|
| if not enabled:
|
| return "<div class='mind-text'>Mind Layer hidden.</div>"
|
| if not events:
|
| return "<div class='mind-text'>Awaiting trace.</div>"
|
| compact = [
|
| {
|
| "phase": event.phase,
|
| "title": event.title,
|
| "turns": [turn.model_dump() for turn in event.turns],
|
| "trace": event.trace,
|
| }
|
| for event in events
|
| ]
|
| return f"<pre class='mind-text'>{_escape(json.dumps(compact, indent=2))}</pre>"
|
|
|
|
|
| def _clean_custom_items(values: list[str]) -> list[str]:
|
| return [" ".join(value.split()) for value in values if " ".join(value.split())]
|
|
|
|
|
| def _custom_case_from_payload(payload: str) -> CasePacket:
|
| try:
|
| data = json.loads(payload or "{}")
|
| except json.JSONDecodeError as exc:
|
| raise ValueError("Custom case details could not be read from the docket book.") from exc
|
| context = " ".join(str(data.get("context", "")).split())
|
| claimant_items = _clean_custom_items([str(value) for value in data.get("claimant_evidence", [])])
|
| respondent_items = _clean_custom_items([str(value) for value in data.get("respondent_evidence", [])])
|
| if not context:
|
| raise ValueError("Custom requires a trial details paragraph.")
|
| if not claimant_items or not respondent_items:
|
| raise ValueError("Custom requires at least one evidence item for each side.")
|
| evidence = [
|
| EvidenceItem(
|
| id=f"CUS-F{index}",
|
| title=f"Claimant Evidence {index}",
|
| source="Custom docket entry",
|
| excerpt=item,
|
| supports="claimant",
|
| reliability=0.65,
|
| note=item,
|
| )
|
| for index, item in enumerate(claimant_items[:3], start=1)
|
| ]
|
| evidence.extend(
|
| EvidenceItem(
|
| id=f"CUS-A{index}",
|
| title=f"Respondent Evidence {index}",
|
| source="Custom docket entry",
|
| excerpt=item,
|
| supports="respondent",
|
| reliability=0.65,
|
| note=item,
|
| )
|
| for index, item in enumerate(respondent_items[:3], start=1)
|
| )
|
| return CasePacket(
|
| id="custom",
|
| title="Custom Trial",
|
| subtitle="A custom docket assembled in the opening book.",
|
| claimant="Claimant",
|
| respondent="Respondent",
|
| charge="Whether the custom record supports the claimant or the respondent.",
|
| setting="A custom courtroom packet entered by the user.",
|
| context=context,
|
| claimant_claim="The claimant says the custom context and supporting evidence justify a favorable finding.",
|
| respondent_claim="The respondent says the custom context is incomplete, overread, or answered by contrary evidence.",
|
| source_note="Custom user-entered case packet from the docket book.",
|
| evidence=evidence,
|
| )
|
|
|
|
|
| def render_case_preview(case_label: str) -> str:
|
| case_id = CASE_OPTIONS.get(case_label, "socrates")
|
| return render_court(
|
| [],
|
| pretrial_case=get_case(case_id) if case_id != "custom" else None,
|
| custom_mode=case_id == "custom",
|
| )
|
|
|
|
|
| def run_ui(
|
| case_label: str,
|
| search_query: str,
|
| hypothetical: str,
|
| custom_payload: str,
|
| speed: str,
|
| mind_layer: bool,
|
| ):
|
| case_id = CASE_OPTIONS.get(case_label, "socrates")
|
| try:
|
| custom_case = _custom_case_from_payload(custom_payload) if case_id == "custom" else None
|
| except ValueError as exc:
|
| yield (
|
| render_court([], pretrial_case=None, custom_mode=True),
|
| render_evidence([]),
|
| render_jurors([]),
|
| render_mind([], mind_layer),
|
| str(exc),
|
| )
|
| return
|
| request = TrialRequest(
|
| case_id=case_id,
|
| search_query=search_query or "",
|
| hypothetical=hypothetical or "",
|
| custom_case=custom_case,
|
| speed=speed or "swift",
|
| mind_layer=bool(mind_layer),
|
| )
|
| events: list[TrialEvent] = []
|
| produced_events = _start_event_producer(request)
|
| pending_speaker = _expected_next_speaker(events)
|
| yield (
|
| render_court(events, started=True, pending_speaker=pending_speaker),
|
| render_evidence(events),
|
| render_jurors(events),
|
| render_mind(events, mind_layer),
|
| _pending_status(pending_speaker),
|
| )
|
| try:
|
| while True:
|
| item = produced_events.get()
|
| if item is _EVENT_STREAM_DONE:
|
| break
|
| if isinstance(item, Exception):
|
| raise item
|
| event = item
|
| events.append(event)
|
| yield (
|
| render_court(events, started=True),
|
| render_evidence(events),
|
| render_jurors(events),
|
| render_mind(events, mind_layer),
|
| _event_status(event, len(events)),
|
| )
|
| duration = _reading_duration(_event_dialogue_text(event))
|
| if duration > 0:
|
| time.sleep(duration)
|
| pending_speaker = _expected_next_speaker(events)
|
| if pending_speaker is not None and produced_events.empty():
|
| yield (
|
| render_court(events, started=True, pending_speaker=pending_speaker),
|
| render_evidence(events),
|
| render_jurors(events),
|
| render_mind(events, mind_layer),
|
| _pending_status(pending_speaker),
|
| )
|
| except Exception as exc:
|
| yield (
|
| render_court(events, started=True),
|
| render_evidence(events),
|
| render_jurors(events),
|
| render_mind(events, mind_layer),
|
| f"Model response required. Trial stopped: {exc}",
|
| )
|
| return
|
| yield (
|
| render_court(events, started=True, show_verdict_popup=True),
|
| render_evidence(events),
|
| render_jurors(events),
|
| render_mind(events, mind_layer),
|
| "Verdict sealed.",
|
| )
|
|
|
|
|
| def build_app() -> gr.Blocks:
|
| with gr.Blocks(title="Judge-GPT") as demo:
|
| with gr.Group(elem_classes=["docket-book-controls"]):
|
| gr.HTML("<div class='book-control-heading'>DATA TRIAL:</div>")
|
| with gr.Row():
|
| case = gr.Dropdown(
|
| label="Case entry",
|
| choices=list(CASE_OPTIONS.keys()),
|
| value="Trial of Socrates",
|
| scale=2,
|
| )
|
| start = gr.Button("Begin Trial", variant="primary", scale=1)
|
| status = gr.Markdown("Ready.", elem_classes=["book-status"])
|
| courtroom = gr.HTML(render_case_preview("Trial of Socrates"), label="Live courtroom")
|
| search = gr.State("")
|
| hypo = gr.State("")
|
| custom_payload = gr.State("")
|
| speed = gr.State("swift")
|
| mind = gr.State(True)
|
| with gr.Row(elem_classes=["drawer-shell"]):
|
| with gr.Column(scale=1):
|
| with gr.Tab("Evidence Drawer"):
|
| evidence = gr.HTML(render_evidence([]))
|
| with gr.Tab("Juror Panel"):
|
| jurors = gr.HTML(render_jurors([]))
|
| mind_html = gr.HTML(render_mind([], True), visible=False)
|
| case.change(
|
| render_case_preview,
|
| inputs=[case],
|
| outputs=[courtroom],
|
| )
|
| start.click(
|
| run_ui,
|
| inputs=[case, search, hypo, custom_payload, speed, mind],
|
| outputs=[courtroom, evidence, jurors, mind_html, status],
|
| js=START_JS,
|
| )
|
| return demo
|
|
|
|
|
| demo = build_app()
|
|
|
| if __name__ == "__main__":
|
| demo.queue().launch(
|
| show_error=True,
|
| allowed_paths=["assets"],
|
| css=CSS,
|
| head=APP_HEAD,
|
| theme=gr.themes.Soft(),
|
| )
|
|
|