| 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(), |
| ) |
|
|