Initialize Judge-GPT Space

#1
by AliIqbal05 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitattributes +26 -0
  2. .gitignore +10 -0
  3. README.md +96 -5
  4. app.py +2102 -0
  5. assets/ATTRIBUTION.md +10 -0
  6. assets/audio/ATTRIBUTION.md +51 -0
  7. assets/audio/Judgement.ogg +3 -0
  8. assets/audio/courtroom.ogg +3 -0
  9. assets/audio/crowd_shouting.ogg +3 -0
  10. assets/audio/paper_sound_1.mp3 +0 -0
  11. assets/audio/paper_sound_4.mp3 +0 -0
  12. assets/audio/select_001.ogg +0 -0
  13. assets/audio/steps_in_wood_floor.wav +3 -0
  14. assets/audio/wood_hammer_01.ogg +0 -0
  15. assets/audio/wood_hit_03.ogg +0 -0
  16. assets/background/CourtRoom.png +3 -0
  17. assets/book/README.md +14 -0
  18. assets/book/docket-book-closed-keyed.png +3 -0
  19. assets/book/docket-book-closed.png +3 -0
  20. assets/book/docket-book-open-keyed.png +3 -0
  21. assets/book/docket-book-open.png +3 -0
  22. assets/characters/cleopatra-vii.png +3 -0
  23. assets/characters/confucius.png +3 -0
  24. assets/characters/jensen-huang.png +3 -0
  25. assets/characters/john-stuart-mill.png +3 -0
  26. assets/characters/karl-marx.png +3 -0
  27. assets/characters/marcus-aurelius.png +3 -0
  28. assets/characters/niccolo-machiavelli.png +3 -0
  29. assets/characters/sources/cleopatra-vii-chroma.png +3 -0
  30. assets/characters/sources/confucius-chroma.png +3 -0
  31. assets/characters/sources/jensen-huang-chroma.png +3 -0
  32. assets/characters/sources/john-stuart-mill-chroma.png +3 -0
  33. assets/characters/sources/karl-marx-chroma.png +3 -0
  34. assets/characters/sources/marcus-aurelius-chroma.png +3 -0
  35. assets/characters/sources/niccolo-machiavelli-chroma.png +3 -0
  36. assets/courtroom-dickinson.jpg +3 -0
  37. assets/foreground/JudgeTable.png +3 -0
  38. assets/foreground/foregroundFence.png +3 -0
  39. data/README.md +5 -0
  40. data/agent_trace_sample.json +23 -0
  41. modal_app.py +193 -0
  42. requirements.txt +7 -0
  43. sovereign_bench/__init__.py +6 -0
  44. sovereign_bench/cases.py +141 -0
  45. sovereign_bench/engine.py +572 -0
  46. sovereign_bench/export.py +35 -0
  47. sovereign_bench/llm.py +209 -0
  48. sovereign_bench/models.py +86 -0
  49. sovereign_bench/retrieval.py +70 -0
  50. tests/test_cases.py +8 -0
.gitattributes CHANGED
@@ -33,3 +33,29 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/audio/courtroom.ogg filter=lfs diff=lfs merge=lfs -text
37
+ assets/audio/crowd_shouting.ogg filter=lfs diff=lfs merge=lfs -text
38
+ assets/audio/Judgement.ogg filter=lfs diff=lfs merge=lfs -text
39
+ assets/audio/steps_in_wood_floor.wav filter=lfs diff=lfs merge=lfs -text
40
+ assets/background/CourtRoom.png filter=lfs diff=lfs merge=lfs -text
41
+ assets/book/docket-book-closed-keyed.png filter=lfs diff=lfs merge=lfs -text
42
+ assets/book/docket-book-closed.png filter=lfs diff=lfs merge=lfs -text
43
+ assets/book/docket-book-open-keyed.png filter=lfs diff=lfs merge=lfs -text
44
+ assets/book/docket-book-open.png filter=lfs diff=lfs merge=lfs -text
45
+ assets/characters/cleopatra-vii.png filter=lfs diff=lfs merge=lfs -text
46
+ assets/characters/confucius.png filter=lfs diff=lfs merge=lfs -text
47
+ assets/characters/jensen-huang.png filter=lfs diff=lfs merge=lfs -text
48
+ assets/characters/john-stuart-mill.png filter=lfs diff=lfs merge=lfs -text
49
+ assets/characters/karl-marx.png filter=lfs diff=lfs merge=lfs -text
50
+ assets/characters/marcus-aurelius.png filter=lfs diff=lfs merge=lfs -text
51
+ assets/characters/niccolo-machiavelli.png filter=lfs diff=lfs merge=lfs -text
52
+ assets/characters/sources/cleopatra-vii-chroma.png filter=lfs diff=lfs merge=lfs -text
53
+ assets/characters/sources/confucius-chroma.png filter=lfs diff=lfs merge=lfs -text
54
+ assets/characters/sources/jensen-huang-chroma.png filter=lfs diff=lfs merge=lfs -text
55
+ assets/characters/sources/john-stuart-mill-chroma.png filter=lfs diff=lfs merge=lfs -text
56
+ assets/characters/sources/karl-marx-chroma.png filter=lfs diff=lfs merge=lfs -text
57
+ assets/characters/sources/marcus-aurelius-chroma.png filter=lfs diff=lfs merge=lfs -text
58
+ assets/characters/sources/niccolo-machiavelli-chroma.png filter=lfs diff=lfs merge=lfs -text
59
+ assets/courtroom-dickinson.jpg filter=lfs diff=lfs merge=lfs -text
60
+ assets/foreground/foregroundFence.png filter=lfs diff=lfs merge=lfs -text
61
+ assets/foreground/JudgeTable.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ __pycache__/
5
+ *.py[cod]
6
+ .venv/
7
+ venv/
8
+ .modal.toml
9
+ .cache/
10
+ artifacts/
README.md CHANGED
@@ -1,14 +1,105 @@
1
  ---
2
- title: JudgeGPT
3
- emoji: 🏢
4
  colorFrom: yellow
5
  colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  short_description: AI-native miniature trials under 32B.
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Judge-GPT
3
+ emoji: ⚖️
4
  colorFrom: yellow
5
  colorTo: red
6
  sdk: gradio
7
+ sdk_version: 6.17.3
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
  short_description: AI-native miniature trials under 32B.
12
  ---
13
 
14
+ # Judge-GPT
15
+
16
+ Judge-GPT is a cinematic Gradio Space for the Build Small Hackathon's Thousand Token Wood track. It runs two-minute AI-native miniature trials where small-model agents act as advocates, judge, jurors, clerk, and evidence auditor.
17
+
18
+ The app is built to stay under the 32B named-model budget:
19
+
20
+ - `openai/gpt-oss-20b` for primary legal reasoning.
21
+ - `openbmb/AgentCPM-Explore` for clerk/stage/verdict style.
22
+ - `nvidia/Nemotron-Orchestrator-8B` for juror and evidence-auditor review.
23
+
24
+ Total named budget: 32B parameters.
25
+
26
+ ## What the app can do
27
+
28
+ - Run cached trials for the Socrates and Barnaby demo cases without network search.
29
+ - Run the Live Search Tribunal path, which builds a search packet from a user query and stops if live material is too weak to support a trial.
30
+ - Add a hypothetical sidebar to shift the framing of a trial without editing cached case files.
31
+ - Switch trial pacing between swift, measured, and ceremonial speeds.
32
+ - Stage the courtroom with phase-specific visuals, agent puppets, evidence props, captions, and browser audio cues.
33
+ - Show the Mind Layer as a compact JSON trace of agent turns and phase metadata.
34
+ - Call a Modal streaming endpoint when `MODAL_TRIAL_URL` is configured. Endpoint or model failures stop the trial instead of substituting cached dialogue.
35
+ - Retain decree and agent-trace export helpers in `sovereign_bench/export.py` for future UI restoration.
36
+
37
+ ## Limitations
38
+
39
+ - Judge-GPT is not legal advice and should not be used for real legal decisions.
40
+ - Live search snippets are not independently verified by the app.
41
+ - Output quality depends on Modal GPU availability, token limits, and the configured Hugging Face models.
42
+ - Model, Modal, or live retrieval failures stop the current trial rather than returning substitute courtroom dialogue.
43
+ - Trial results are not persisted across sessions.
44
+ - Export generation remains in the codebase, but the visible download UI is currently hidden.
45
+
46
+ ## Run locally
47
+
48
+ ```powershell
49
+ python -m pip install -r requirements.txt
50
+ python app.py
51
+ ```
52
+
53
+ ## Modal backend
54
+
55
+ The Gradio app works locally without Modal. If `MODAL_TRIAL_URL` is set, the Space calls the Modal streaming endpoint and stops the trial if the endpoint is unavailable.
56
+
57
+ The deployed Modal endpoint runs each role prompt through a GPU-backed vLLM class on H100 by default. Traces mark successful GPU calls with `runtime: modal-gpu-vllm`, `provider: modal-gpu-vllm`, and `gpu: H100`. If a GPU/model load fails, the trial stops; the app does not substitute provider or cached dialogue.
58
+
59
+ ```powershell
60
+ python -m modal deploy modal_app.py
61
+ ```
62
+
63
+ Keep the deployed endpoint URL as a Hugging Face Space variable named `MODAL_TRIAL_URL`.
64
+
65
+ ## Project targets
66
+
67
+ Workspace connected to:
68
+
69
+ - GitHub: `https://github.com/aliiqbal24/BuildSmallfinal.git`
70
+ - Modal profile: `ali-j-iqbal24`
71
+ - Hugging Face user: `AliIqbal05`
72
+
73
+ ## Secrets
74
+
75
+ Credentials are not committed to this repo.
76
+
77
+ - Local Hugging Face CLI auth is stored in the Hugging Face cache.
78
+ - Modal auth is stored in the local Modal profile.
79
+ - Modal has a secret named `huggingface` with `HF_TOKEN`.
80
+
81
+ Use the Modal secret in functions like this:
82
+
83
+ ```python
84
+ @app.function(secrets=[modal.Secret.from_name("huggingface")])
85
+ def run_model():
86
+ token = os.getenv("HF_TOKEN")
87
+ ```
88
+
89
+ ## Developer guide
90
+
91
+ - `app.py`: Gradio UI, CSS, JavaScript audio hooks, HTML renderers, and Modal/local streaming switch.
92
+ - `sovereign_bench/engine.py`: trial phases, agent orchestration, verdict assembly, and trace construction.
93
+ - `sovereign_bench/llm.py`: Hugging Face calls, strict model error handling, and prompt building.
94
+ - `sovereign_bench/retrieval.py`: live search packet construction.
95
+ - `sovereign_bench/models.py`: Pydantic schemas for cases, evidence, events, turns, votes, and verdicts.
96
+ - `sovereign_bench/cases.py`: cached demo case packets.
97
+ - `sovereign_bench/export.py`: dormant decree and trace writers.
98
+ - `modal_app.py`: Modal deployment and GPU-backed streaming endpoint.
99
+ - `tests/`: engine, case, and rendering regression coverage.
100
+
101
+ ## Verify Modal to Hugging Face
102
+
103
+ ```powershell
104
+ python -m modal run modal_app.py
105
+ ```
app.py ADDED
@@ -0,0 +1,2102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import Iterable
6
+
7
+ import gradio as gr
8
+ import httpx
9
+
10
+ from sovereign_bench.engine import JUDGE_NAME, JUROR_PERSONAS, stream_trial
11
+ from sovereign_bench.models import TrialEvent, TrialRequest
12
+
13
+
14
+ def _load_env_file() -> None:
15
+ path = ".env"
16
+ if not os.path.exists(path):
17
+ return
18
+ with open(path, encoding="utf-8") as handle:
19
+ for line in handle:
20
+ stripped = line.strip()
21
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
22
+ continue
23
+ key, value = stripped.split("=", 1)
24
+ os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
25
+
26
+
27
+ _load_env_file()
28
+
29
+ CASE_OPTIONS = {
30
+ "Trial of Socrates": "socrates",
31
+ "The People v. Barnaby Buttons": "barnaby",
32
+ "Live Search Tribunal": "live",
33
+ }
34
+
35
+ PHASE_GLYPHS = {
36
+ "pretrial": "00",
37
+ "intake": "01",
38
+ "claims": "02",
39
+ "opening": "03",
40
+ "evidence": "04",
41
+ "questions": "05",
42
+ "deliberation": "06",
43
+ "verdict": "07",
44
+ "appeal": "08",
45
+ }
46
+
47
+ AUDIO_PATHS = {
48
+ "score": "/gradio_api/file=assets/audio/courtroom.ogg",
49
+ "judgement": "/gradio_api/file=assets/audio/Judgement.ogg",
50
+ "crowd": "/gradio_api/file=assets/audio/crowd_shouting.ogg",
51
+ "gavel": "/gradio_api/file=assets/audio/wood_hammer_01.ogg",
52
+ "wood": "/gradio_api/file=assets/audio/wood_hit_03.ogg",
53
+ "steps": "/gradio_api/file=assets/audio/steps_in_wood_floor.wav",
54
+ "paper": "/gradio_api/file=assets/audio/paper_sound_1.mp3",
55
+ "paper_long": "/gradio_api/file=assets/audio/paper_sound_4.mp3",
56
+ "select": "/gradio_api/file=assets/audio/select_001.ogg",
57
+ }
58
+
59
+ CSS = """
60
+ :root {
61
+ --ink: #23170e;
62
+ --paper: #f3dfb7;
63
+ --paper-dark: #c79455;
64
+ --gold: #d9b060;
65
+ --mahogany: #4b2119;
66
+ --shadow: rgba(10, 5, 2, .6);
67
+ --red: #8f2e2d;
68
+ --green: #2f6f5e;
69
+ --blue: #254f7a;
70
+ }
71
+
72
+ body,
73
+ .gradio-container {
74
+ margin: 0;
75
+ background: #141413 !important;
76
+ background-color: #141413 !important;
77
+ color: var(--ink);
78
+ font-family: Georgia, "Times New Roman", serif;
79
+ }
80
+
81
+ .gradio-container {
82
+ max-width: none !important;
83
+ padding: 0 !important;
84
+ }
85
+
86
+ .main,
87
+ .contain {
88
+ max-width: none !important;
89
+ padding: 0 !important;
90
+ background: transparent !important;
91
+ }
92
+
93
+ .gradio-container main,
94
+ .gradio-container .wrap,
95
+ .gradio-container .app,
96
+ .gradio-container .html-container {
97
+ background: transparent !important;
98
+ padding-left: 0 !important;
99
+ padding-right: 0 !important;
100
+ }
101
+
102
+ .docket-book-controls {
103
+ position: fixed;
104
+ left: 50%;
105
+ top: clamp(172px, 21vh, 212px);
106
+ z-index: 9999;
107
+ width: min(620px, calc(100vw - 160px));
108
+ max-width: none;
109
+ margin: 0;
110
+ padding: 0;
111
+ transform: translateX(-50%) rotate(-1deg);
112
+ border: 0 !important;
113
+ border-radius: 0 !important;
114
+ background: transparent !important;
115
+ box-shadow: none !important;
116
+ color: #321d10;
117
+ transition: opacity .32s ease, transform .65s ease;
118
+ }
119
+
120
+ body.trial-has-started .docket-book-controls {
121
+ opacity: 0;
122
+ pointer-events: none;
123
+ transform: translateX(-50%) rotateX(56deg) rotate(-1deg) scale(.45);
124
+ }
125
+
126
+ .docket-book-controls::before {
127
+ content: none;
128
+ }
129
+
130
+ .docket-book-controls,
131
+ .docket-book-controls > *,
132
+ .docket-book-controls .form,
133
+ .docket-book-controls .block,
134
+ .docket-book-controls .gap,
135
+ .docket-book-controls .wrap {
136
+ background: transparent !important;
137
+ border: 0 !important;
138
+ box-shadow: none !important;
139
+ }
140
+
141
+ .docket-book-controls .docket-book-controls {
142
+ position: static !important;
143
+ left: auto !important;
144
+ top: auto !important;
145
+ width: 100% !important;
146
+ transform: none !important;
147
+ opacity: 1;
148
+ pointer-events: auto;
149
+ }
150
+
151
+ body.trial-has-started .docket-book-controls .docket-book-controls {
152
+ pointer-events: none;
153
+ }
154
+
155
+ .book-control-heading {
156
+ margin: 0 0 6px;
157
+ color: #694019;
158
+ font: 900 12px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
159
+ letter-spacing: .08em;
160
+ text-transform: uppercase;
161
+ }
162
+
163
+ .docket-book-controls label,
164
+ .docket-book-controls span,
165
+ .docket-book-controls .prose {
166
+ color: #321d10 !important;
167
+ }
168
+
169
+ .docket-book-controls label {
170
+ font-size: 11px !important;
171
+ font-weight: 800 !important;
172
+ }
173
+
174
+ .docket-book-controls input,
175
+ .docket-book-controls textarea,
176
+ .docket-book-controls [role="combobox"],
177
+ .docket-book-controls .wrap-inner {
178
+ border-color: rgba(90, 50, 20, .24) !important;
179
+ border-radius: 4px !important;
180
+ background: rgba(255, 243, 207, .58) !important;
181
+ color: #241509 !important;
182
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.24) !important;
183
+ }
184
+
185
+ .docket-book-controls textarea {
186
+ min-height: 42px !important;
187
+ }
188
+
189
+ .docket-book-controls button.primary {
190
+ min-height: 42px;
191
+ border: 1px solid rgba(44, 21, 10, .42) !important;
192
+ border-radius: 5px !important;
193
+ background: #1c130d !important;
194
+ color: #fff3d2 !important;
195
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.12), 0 8px 18px rgba(40, 18, 9, .28);
196
+ }
197
+
198
+ .docket-book-controls .book-status p {
199
+ margin: 0 !important;
200
+ color: #5a3519 !important;
201
+ font-size: 12px;
202
+ line-height: 1.25;
203
+ }
204
+
205
+ .trial-options {
206
+ max-width: 1120px;
207
+ margin: 0 auto 14px;
208
+ border: 1px solid rgba(255, 226, 154, .18);
209
+ border-radius: 6px;
210
+ background: rgba(18, 9, 5, .78);
211
+ color: #f5dfb5;
212
+ }
213
+
214
+ .trial-options label,
215
+ .trial-options span,
216
+ .trial-options .prose {
217
+ color: #f5dfb5 !important;
218
+ }
219
+
220
+ .court-episode-stage {
221
+ --spot-x: 50%;
222
+ --spot-y: 36%;
223
+ position: relative;
224
+ min-height: min(880px, calc(100vh - 112px));
225
+ height: min(880px, calc(100vh - 112px));
226
+ margin: 0;
227
+ width: 100%;
228
+ max-width: none;
229
+ overflow: hidden;
230
+ isolation: auto;
231
+ color: #fff0d2;
232
+ border: 0;
233
+ border-radius: 0;
234
+ background: transparent;
235
+ box-shadow: none;
236
+ }
237
+
238
+ .court-episode-stage::before {
239
+ content: "";
240
+ display: none;
241
+ }
242
+
243
+ .court-episode-stage::after {
244
+ content: "";
245
+ display: none;
246
+ }
247
+
248
+ .court-episode-stage > * {
249
+ position: relative;
250
+ z-index: 4;
251
+ }
252
+
253
+ .episode-room {
254
+ position: absolute;
255
+ inset: 0;
256
+ z-index: 3;
257
+ background:
258
+ url('/gradio_api/file=assets/background/CourtRoom.png') center center / 100% 100% no-repeat,
259
+ #26120b;
260
+ filter: none;
261
+ transform: none;
262
+ }
263
+
264
+ .trial-started .episode-room,
265
+ .phase-intake .episode-room,
266
+ .phase-claims .episode-room,
267
+ .phase-opening .episode-room,
268
+ .phase-evidence .episode-room,
269
+ .phase-questions .episode-room,
270
+ .phase-deliberation .episode-room,
271
+ .phase-verdict .episode-room,
272
+ .phase-appeal .episode-room {
273
+ filter: none;
274
+ transform: none;
275
+ }
276
+
277
+ .phase-intake,
278
+ .phase-appeal { --spot-x: 50%; --spot-y: 30%; }
279
+ .phase-claims,
280
+ .phase-opening { --spot-x: 43%; --spot-y: 66%; }
281
+ .phase-evidence { --spot-x: 70%; --spot-y: 56%; }
282
+ .phase-questions,
283
+ .phase-verdict { --spot-x: 50%; --spot-y: 34%; }
284
+ .phase-deliberation { --spot-x: 79%; --spot-y: 60%; }
285
+
286
+ .episode-title {
287
+ position: absolute;
288
+ left: 26px;
289
+ top: 22px;
290
+ z-index: 9;
291
+ max-width: min(780px, calc(100% - 330px));
292
+ text-shadow: 0 3px 18px rgba(0, 0, 0, .75);
293
+ }
294
+
295
+ .episode-kicker,
296
+ .prop-label,
297
+ .caption-phase,
298
+ .tooltip-meta,
299
+ .drawer-kicker {
300
+ color: #f4d58f;
301
+ font: 800 11px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
302
+ letter-spacing: .06em;
303
+ text-transform: uppercase;
304
+ }
305
+
306
+ .episode-title h1 {
307
+ margin: 4px 0 6px;
308
+ max-width: 780px;
309
+ color: #fff4d7;
310
+ font-size: clamp(28px, 4.2vw, 58px);
311
+ line-height: .98;
312
+ letter-spacing: 0;
313
+ }
314
+
315
+ .episode-title p {
316
+ margin: 0;
317
+ max-width: 720px;
318
+ color: #f8dcaa;
319
+ font-size: 15px;
320
+ line-height: 1.38;
321
+ }
322
+
323
+ .audio-deck {
324
+ display: none;
325
+ }
326
+
327
+ .sound-toggle {
328
+ position: fixed;
329
+ left: 18px;
330
+ bottom: 18px;
331
+ z-index: 80;
332
+ width: 46px;
333
+ height: 46px;
334
+ border: 1px solid rgba(255, 226, 154, .48);
335
+ border-radius: 50%;
336
+ background: rgba(22, 11, 7, .82);
337
+ box-shadow: 0 12px 28px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255, 255, 255, .12);
338
+ cursor: pointer;
339
+ }
340
+
341
+ .sound-toggle:hover,
342
+ .sound-toggle:focus-visible {
343
+ outline: none;
344
+ border-color: rgba(255, 226, 154, .82);
345
+ background: rgba(41, 20, 12, .92);
346
+ }
347
+
348
+ .sound-toggle .sound-icon {
349
+ position: absolute;
350
+ left: 13px;
351
+ top: 15px;
352
+ width: 10px;
353
+ height: 16px;
354
+ border-radius: 2px 0 0 2px;
355
+ background: #ffe5a6;
356
+ }
357
+
358
+ .sound-toggle .sound-icon::before {
359
+ content: "";
360
+ position: absolute;
361
+ left: 7px;
362
+ top: -3px;
363
+ width: 14px;
364
+ height: 22px;
365
+ border: 3px solid #ffe5a6;
366
+ border-left: 0;
367
+ border-radius: 0 18px 18px 0;
368
+ }
369
+
370
+ .sound-toggle .sound-icon::after {
371
+ content: "";
372
+ position: absolute;
373
+ left: 20px;
374
+ top: -9px;
375
+ width: 3px;
376
+ height: 34px;
377
+ border-radius: 4px;
378
+ background: #d64d45;
379
+ opacity: 0;
380
+ transform: rotate(42deg);
381
+ transform-origin: center;
382
+ }
383
+
384
+ .sound-toggle.muted .sound-icon::after {
385
+ opacity: 1;
386
+ }
387
+
388
+ .episode-book {
389
+ position: absolute;
390
+ left: 50%;
391
+ top: 12%;
392
+ z-index: 12;
393
+ width: min(760px, calc(100% - 32px));
394
+ aspect-ratio: 3 / 2;
395
+ transform: translateX(-50%) rotateX(0) rotateZ(-1deg);
396
+ transform-origin: center bottom;
397
+ color: #2b1b10;
398
+ filter: drop-shadow(0 34px 36px rgba(0, 0, 0, .48));
399
+ pointer-events: none;
400
+ transition: top .85s ease, width .85s ease, transform .85s ease, filter .85s ease, opacity .85s ease;
401
+ }
402
+
403
+ .book-art {
404
+ position: absolute;
405
+ inset: 0;
406
+ width: 100%;
407
+ height: 100%;
408
+ object-fit: contain;
409
+ pointer-events: none;
410
+ user-select: none;
411
+ transition: opacity .36s ease;
412
+ }
413
+
414
+ .book-art.closed-art {
415
+ opacity: 0;
416
+ }
417
+
418
+ .episode-book.closed {
419
+ top: 36%;
420
+ width: min(245px, 30vw);
421
+ transform: translateX(-50%) rotateX(56deg) rotateZ(1deg);
422
+ opacity: .92;
423
+ filter: drop-shadow(0 18px 18px rgba(0, 0, 0, .45));
424
+ }
425
+
426
+ .episode-book.closed .open-art {
427
+ opacity: 0;
428
+ }
429
+
430
+ .episode-book.closed .closed-art {
431
+ opacity: 1;
432
+ }
433
+
434
+ .episode-book.closed .book-open-content {
435
+ opacity: 0;
436
+ pointer-events: none;
437
+ }
438
+
439
+ .book-open-content {
440
+ position: absolute;
441
+ inset: 17% 10% 13%;
442
+ z-index: 2;
443
+ display: grid;
444
+ grid-template-columns: 1fr 1fr;
445
+ gap: 72px;
446
+ padding: 0 28px;
447
+ transition: opacity .35s ease;
448
+ }
449
+
450
+ .book-open-content h2 {
451
+ margin: 0 0 10px;
452
+ color: #4c2a12;
453
+ font-size: 30px;
454
+ letter-spacing: 0;
455
+ }
456
+
457
+ .book-open-content p,
458
+ .book-entry {
459
+ color: #3c2615;
460
+ font-size: 15px;
461
+ line-height: 1.34;
462
+ }
463
+
464
+ .book-entry {
465
+ margin: 11px 0;
466
+ padding-left: 12px;
467
+ border-left: 3px solid rgba(111, 61, 23, .36);
468
+ }
469
+
470
+ .judge-dais {
471
+ position: absolute;
472
+ left: 50%;
473
+ top: 27%;
474
+ z-index: 6;
475
+ width: min(360px, 32vw);
476
+ min-width: 230px;
477
+ transform: translateX(-50%);
478
+ text-align: center;
479
+ }
480
+
481
+ .bench-front {
482
+ display: none;
483
+ }
484
+
485
+ .gavel {
486
+ display: none;
487
+ }
488
+
489
+ .gavel::before {
490
+ content: "";
491
+ display: none;
492
+ }
493
+
494
+ .phase-verdict .gavel {
495
+ animation: gavel-hit .55s ease-out both;
496
+ }
497
+
498
+ .counsel-table {
499
+ position: absolute;
500
+ bottom: 19%;
501
+ z-index: 6;
502
+ width: min(255px, 22vw);
503
+ height: 84px;
504
+ border: 0;
505
+ background: transparent;
506
+ box-shadow: none;
507
+ }
508
+
509
+ .counsel-table.left { left: 17%; }
510
+ .counsel-table.right { right: 17%; }
511
+
512
+ .trial-floor-mark {
513
+ display: none;
514
+ }
515
+
516
+ .witness-area {
517
+ position: absolute;
518
+ right: 8.5%;
519
+ bottom: 28%;
520
+ z-index: 6;
521
+ width: min(190px, 18vw);
522
+ height: 98px;
523
+ border: 0;
524
+ background: transparent;
525
+ box-shadow: none;
526
+ }
527
+
528
+ .jury-benches {
529
+ position: absolute;
530
+ top: 43%;
531
+ z-index: 7;
532
+ width: min(220px, 16vw);
533
+ min-width: 150px;
534
+ display: grid;
535
+ gap: 6px;
536
+ }
537
+
538
+ .jury-benches.left {
539
+ left: 4.5%;
540
+ }
541
+
542
+ .jury-benches.right {
543
+ right: 4.5%;
544
+ }
545
+
546
+ .jury-benches.left .jury-row {
547
+ transform: rotate(-7deg) skewY(-3deg);
548
+ }
549
+
550
+ .jury-benches.right .jury-row {
551
+ transform: rotate(7deg) skewY(3deg);
552
+ }
553
+
554
+ .jury-rail {
555
+ display: none;
556
+ }
557
+
558
+ .jury-row {
559
+ display: grid;
560
+ grid-template-columns: repeat(3, 1fr);
561
+ gap: 8px;
562
+ align-items: end;
563
+ }
564
+
565
+ .gallery-benches {
566
+ display: none;
567
+ }
568
+
569
+ .gallery-benches div {
570
+ display: none;
571
+ }
572
+
573
+ .prop-label {
574
+ display: none;
575
+ }
576
+
577
+ .foreground-props {
578
+ position: absolute;
579
+ inset: 0;
580
+ z-index: 13;
581
+ pointer-events: none;
582
+ }
583
+
584
+ .foreground-fence,
585
+ .judge-table-foreground {
586
+ position: absolute;
587
+ display: block;
588
+ max-width: none;
589
+ height: auto;
590
+ filter: none;
591
+ opacity: 1;
592
+ pointer-events: none;
593
+ user-select: none;
594
+ }
595
+
596
+ .foreground-fence {
597
+ bottom: -1.5%;
598
+ width: 47%;
599
+ }
600
+
601
+ .foreground-fence.fence-left {
602
+ left: 0;
603
+ transform: translateX(-2%);
604
+ }
605
+
606
+ .foreground-fence.fence-right {
607
+ right: 0;
608
+ transform: translateX(2%);
609
+ }
610
+
611
+ .judge-table-foreground {
612
+ left: 50%;
613
+ top: 35%;
614
+ z-index: 1;
615
+ width: 46%;
616
+ transform: translateX(-50%);
617
+ }
618
+
619
+ .puppet {
620
+ --skin: #c99257;
621
+ --robe: #282128;
622
+ --accent: #8a2f2f;
623
+ --portrait-width: 74px;
624
+ --portrait-top: -14px;
625
+ position: absolute;
626
+ z-index: 8;
627
+ width: 72px;
628
+ height: 128px;
629
+ transform: translate(-50%, -100%);
630
+ transform-origin: center bottom;
631
+ filter: none;
632
+ color: inherit;
633
+ text-decoration: none;
634
+ }
635
+
636
+ .puppet.small {
637
+ width: 50px;
638
+ height: 94px;
639
+ --portrait-width: 54px;
640
+ --portrait-top: -8px;
641
+ }
642
+
643
+ .puppet.active {
644
+ animation: puppet-breathe 1.45s ease-in-out infinite;
645
+ }
646
+
647
+ .puppet.walking {
648
+ animation: lawyer-walk 1.9s ease-in-out infinite;
649
+ }
650
+
651
+ .puppet.judge {
652
+ left: 50%;
653
+ top: 31%;
654
+ --skin: #c38a55;
655
+ --robe: #1b1b20;
656
+ --accent: #79242a;
657
+ --portrait-width: 96px;
658
+ --portrait-top: -28px;
659
+ }
660
+
661
+ .puppet.clerk {
662
+ left: 43%;
663
+ top: 41%;
664
+ --skin: #b77b52;
665
+ --robe: #365548;
666
+ --accent: #2f6f5e;
667
+ }
668
+
669
+ .puppet.auric {
670
+ left: 24%;
671
+ top: 62%;
672
+ --skin: #c9975d;
673
+ --robe: #5b2719;
674
+ --accent: #a45c25;
675
+ }
676
+
677
+ .speaker-auric .puppet.auric {
678
+ left: 43%;
679
+ top: 66%;
680
+ }
681
+
682
+ .puppet.sable {
683
+ left: 75%;
684
+ top: 62%;
685
+ --skin: #a86d4a;
686
+ --robe: #1d3045;
687
+ --accent: #254f7a;
688
+ }
689
+
690
+ .speaker-sable .puppet.sable {
691
+ left: 57%;
692
+ top: 66%;
693
+ }
694
+
695
+ .puppet.auditor {
696
+ left: 71%;
697
+ top: 55%;
698
+ --skin: #c6a65b;
699
+ --robe: #4b3d1b;
700
+ --accent: #8d6b1f;
701
+ }
702
+
703
+ .puppet-portrait {
704
+ position: absolute;
705
+ left: 50%;
706
+ top: var(--portrait-top);
707
+ z-index: 3;
708
+ width: var(--portrait-width);
709
+ height: auto;
710
+ max-height: 118px;
711
+ transform: translateX(-50%);
712
+ object-fit: contain;
713
+ pointer-events: none;
714
+ }
715
+
716
+ .phase-evidence .puppet.auditor {
717
+ animation: evidence-focus 1.35s ease-in-out infinite;
718
+ }
719
+
720
+ .puppet::before {
721
+ content: "";
722
+ position: absolute;
723
+ left: 50%;
724
+ top: 0;
725
+ width: 44px;
726
+ height: 44px;
727
+ transform: translateX(-50%);
728
+ border: 2px solid rgba(255, 232, 174, .58);
729
+ border-radius: 50%;
730
+ background:
731
+ radial-gradient(circle at 34% 32%, rgba(255,255,255,.38), transparent 22%),
732
+ radial-gradient(circle at 36% 42%, #1b120c 0 2px, transparent 2.5px),
733
+ radial-gradient(circle at 62% 42%, #1b120c 0 2px, transparent 2.5px),
734
+ linear-gradient(180deg, var(--skin), #8b5638);
735
+ }
736
+
737
+ .puppet::after {
738
+ content: "";
739
+ position: absolute;
740
+ left: 50%;
741
+ top: 48px;
742
+ width: 58px;
743
+ height: 70px;
744
+ transform: translateX(-50%);
745
+ border: 1px solid rgba(255, 232, 174, .22);
746
+ border-radius: 24px 24px 8px 8px;
747
+ background:
748
+ linear-gradient(90deg, transparent 46%, rgba(255, 226, 154, .14) 49%, transparent 52%),
749
+ linear-gradient(180deg, var(--accent), var(--robe) 52%, #130a07);
750
+ }
751
+
752
+ .puppet .mouth {
753
+ position: absolute;
754
+ left: 50%;
755
+ top: 27px;
756
+ z-index: 2;
757
+ width: 15px;
758
+ height: 7px;
759
+ transform: translateX(-50%);
760
+ border-bottom: 2px solid #28150c;
761
+ border-radius: 0 0 18px 18px;
762
+ }
763
+
764
+ .puppet.active .mouth,
765
+ .puppet.walking .mouth {
766
+ animation: speak-mouth .5s ease-in-out infinite;
767
+ }
768
+
769
+ .speech-bubble {
770
+ position: absolute;
771
+ left: 50%;
772
+ bottom: calc(100% + 12px);
773
+ z-index: 18;
774
+ width: 260px;
775
+ max-width: min(320px, calc(100vw - 32px));
776
+ transform: translateX(-50%);
777
+ padding: 10px 12px;
778
+ border: 1px solid rgba(255, 226, 154, .48);
779
+ border-radius: 6px;
780
+ background: rgba(255, 244, 215, .94);
781
+ color: #2d1b0d;
782
+ box-shadow: 0 14px 30px rgba(0, 0, 0, .34);
783
+ font-size: 12px;
784
+ font-weight: 700;
785
+ line-height: 1.3;
786
+ pointer-events: none;
787
+ }
788
+
789
+ .speech-bubble::after {
790
+ content: "";
791
+ position: absolute;
792
+ left: 50%;
793
+ bottom: -8px;
794
+ width: 14px;
795
+ height: 14px;
796
+ transform: translateX(-50%) rotate(45deg);
797
+ border-right: 1px solid rgba(255, 226, 154, .48);
798
+ border-bottom: 1px solid rgba(255, 226, 154, .48);
799
+ background: rgba(255, 244, 215, .94);
800
+ }
801
+
802
+ .tooltip {
803
+ position: absolute;
804
+ left: 50%;
805
+ bottom: calc(100% + 10px);
806
+ z-index: 20;
807
+ width: 320px;
808
+ max-width: min(360px, calc(100vw - 32px));
809
+ transform: translateX(-50%) translateY(6px);
810
+ opacity: 0;
811
+ pointer-events: none;
812
+ padding: 8px 10px;
813
+ border: 1px solid rgba(255, 226, 154, .34);
814
+ border-radius: 5px;
815
+ background: rgba(17, 9, 5, .88);
816
+ color: #fff0d2;
817
+ box-shadow: 0 12px 24px rgba(0,0,0,.36);
818
+ transition: opacity .18s ease, transform .18s ease;
819
+ }
820
+
821
+ .puppet:hover .tooltip,
822
+ .puppet:focus-within .tooltip,
823
+ .juror:hover .tooltip,
824
+ .juror:focus-within .tooltip {
825
+ opacity: 1;
826
+ transform: translateX(-50%) translateY(0);
827
+ }
828
+
829
+ .tooltip strong {
830
+ display: block;
831
+ color: #fff6df;
832
+ font-size: 13px;
833
+ }
834
+
835
+ .tooltip p {
836
+ margin: 6px 0 0;
837
+ color: #f5dfb5;
838
+ font-size: 11px;
839
+ line-height: 1.28;
840
+ white-space: normal;
841
+ }
842
+
843
+ .tooltip-meta {
844
+ margin-top: 3px;
845
+ color: #f4d58f;
846
+ font-size: 10px;
847
+ }
848
+
849
+ .tooltip-io-label,
850
+ .thread-label {
851
+ display: block;
852
+ margin-top: 7px;
853
+ color: #f4d58f;
854
+ font: 800 10px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
855
+ text-transform: uppercase;
856
+ }
857
+
858
+ .ai-thread-modal {
859
+ display: none;
860
+ position: fixed;
861
+ inset: max(18px, 4vh) max(18px, 5vw);
862
+ z-index: 120;
863
+ overflow: auto;
864
+ padding: 20px;
865
+ border: 1px solid rgba(255, 226, 154, .42);
866
+ border-radius: 8px;
867
+ background: rgba(18, 9, 5, .96);
868
+ color: #fff0d2;
869
+ box-shadow: 0 24px 70px rgba(0, 0, 0, .58);
870
+ }
871
+
872
+ .ai-thread-modal:target {
873
+ display: block;
874
+ }
875
+
876
+ .thread-close {
877
+ position: sticky;
878
+ top: 0;
879
+ float: right;
880
+ padding: 7px 10px;
881
+ border: 1px solid rgba(255, 226, 154, .38);
882
+ border-radius: 4px;
883
+ background: rgba(255, 226, 154, .12);
884
+ color: #fff0d2;
885
+ text-decoration: none;
886
+ font: 800 11px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
887
+ }
888
+
889
+ .thread-title {
890
+ margin: 0 0 4px;
891
+ color: #fff6df;
892
+ font-size: 22px;
893
+ }
894
+
895
+ .thread-subtitle {
896
+ margin: 0 0 16px;
897
+ color: #f4d58f;
898
+ font: 800 12px/1.3 ui-monospace, SFMono-Regular, Consolas, monospace;
899
+ text-transform: uppercase;
900
+ }
901
+
902
+ .thread-turn {
903
+ margin: 0 0 18px;
904
+ padding-bottom: 16px;
905
+ border-bottom: 1px solid rgba(255, 226, 154, .16);
906
+ }
907
+
908
+ .thread-turn:last-child {
909
+ border-bottom: 0;
910
+ }
911
+
912
+ .thread-block {
913
+ margin: 7px 0 0;
914
+ white-space: pre-wrap;
915
+ color: #f8dfaa;
916
+ font: 12px/1.42 ui-monospace, SFMono-Regular, Consolas, monospace;
917
+ }
918
+
919
+ .juror {
920
+ --face: #c89259;
921
+ --juror-image: none;
922
+ position: relative;
923
+ height: 72px;
924
+ transform-origin: center bottom;
925
+ filter: none;
926
+ color: inherit;
927
+ text-decoration: none;
928
+ }
929
+
930
+ .juror.active {
931
+ animation: juror-react .82s ease-in-out infinite alternate;
932
+ }
933
+
934
+ .juror .speech-bubble {
935
+ bottom: calc(100% + 6px);
936
+ width: 230px;
937
+ }
938
+
939
+ .juror-face {
940
+ position: absolute;
941
+ left: 50%;
942
+ top: 0;
943
+ width: 30px;
944
+ height: 30px;
945
+ transform: translateX(-50%);
946
+ border: 2px solid rgba(255, 232, 174, .5);
947
+ border-radius: 50%;
948
+ background:
949
+ radial-gradient(circle at 35% 40%, #1d1109 0 2px, transparent 2.5px),
950
+ radial-gradient(circle at 64% 40%, #1d1109 0 2px, transparent 2.5px),
951
+ linear-gradient(180deg, var(--face), #835235);
952
+ }
953
+
954
+ .juror-face::after {
955
+ content: "";
956
+ position: absolute;
957
+ left: 10px;
958
+ bottom: 9px;
959
+ width: 14px;
960
+ height: 7px;
961
+ border-bottom: 2px solid #25140c;
962
+ border-radius: 0 0 18px 18px;
963
+ }
964
+
965
+ .juror-portrait {
966
+ position: absolute;
967
+ left: 50%;
968
+ top: -19px;
969
+ z-index: 3;
970
+ width: 58px;
971
+ height: 74px;
972
+ transform: translateX(-50%);
973
+ object-fit: contain;
974
+ pointer-events: none;
975
+ }
976
+
977
+ .juror-body {
978
+ position: absolute;
979
+ left: 50%;
980
+ top: 32px;
981
+ width: 36px;
982
+ height: 36px;
983
+ transform: translateX(-50%);
984
+ border-radius: 20px 20px 7px 7px;
985
+ border: 1px solid rgba(255, 232, 174, .18);
986
+ background: linear-gradient(180deg, #5b496f, #211726);
987
+ }
988
+
989
+ .phase-deliberation .juror:nth-child(odd) {
990
+ animation-delay: .18s;
991
+ }
992
+
993
+ .evidence-props {
994
+ position: absolute;
995
+ left: 55%;
996
+ right: 11%;
997
+ bottom: 36%;
998
+ z-index: 9;
999
+ display: flex;
1000
+ flex-wrap: wrap;
1001
+ gap: 8px;
1002
+ justify-content: center;
1003
+ pointer-events: auto;
1004
+ }
1005
+
1006
+ .evidence-sheet {
1007
+ width: 96px;
1008
+ min-height: 72px;
1009
+ padding: 8px;
1010
+ transform: rotate(var(--tilt));
1011
+ border: 1px solid rgba(56, 32, 15, .22);
1012
+ border-radius: 3px;
1013
+ background:
1014
+ linear-gradient(135deg, transparent 0 84%, rgba(64, 38, 20, .18) 85%),
1015
+ #fff6df;
1016
+ color: #372212;
1017
+ box-shadow: 0 10px 20px rgba(0,0,0,.28);
1018
+ opacity: .18;
1019
+ transition: transform .25s ease, opacity .25s ease;
1020
+ }
1021
+
1022
+ .phase-evidence .evidence-sheet,
1023
+ .phase-questions .evidence-sheet,
1024
+ .phase-deliberation .evidence-sheet,
1025
+ .phase-verdict .evidence-sheet,
1026
+ .phase-appeal .evidence-sheet {
1027
+ opacity: .96;
1028
+ animation: paper-land .55s ease-out both;
1029
+ }
1030
+
1031
+ .evidence-sheet:hover {
1032
+ transform: rotate(0) translateY(-8px) scale(1.08);
1033
+ z-index: 15;
1034
+ }
1035
+
1036
+ .evidence-sheet strong {
1037
+ display: block;
1038
+ margin-bottom: 4px;
1039
+ color: #254f7a;
1040
+ font: 800 12px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
1041
+ }
1042
+
1043
+ .evidence-sheet span {
1044
+ display: block;
1045
+ font-size: 11px;
1046
+ line-height: 1.2;
1047
+ }
1048
+
1049
+ .trial-caption {
1050
+ position: absolute;
1051
+ left: 50%;
1052
+ bottom: 108px;
1053
+ z-index: 14;
1054
+ width: min(870px, calc(100% - 44px));
1055
+ transform: translateX(-50%);
1056
+ padding: 12px 16px 13px;
1057
+ border: 1px solid rgba(255, 226, 154, .34);
1058
+ border-radius: 6px;
1059
+ background: rgba(13, 7, 4, .78);
1060
+ backdrop-filter: blur(12px);
1061
+ box-shadow: 0 18px 36px rgba(0,0,0,.38);
1062
+ }
1063
+
1064
+ .caption-title {
1065
+ margin-top: 3px;
1066
+ color: #fff3d7;
1067
+ font-size: 20px;
1068
+ font-weight: 800;
1069
+ }
1070
+
1071
+ .caption-body {
1072
+ margin-top: 5px;
1073
+ color: #f8dfaa;
1074
+ font-size: 14px;
1075
+ line-height: 1.36;
1076
+ white-space: pre-wrap;
1077
+ }
1078
+
1079
+ .decree-ribbon {
1080
+ position: absolute;
1081
+ right: 26px;
1082
+ top: 22px;
1083
+ z-index: 10;
1084
+ max-width: 230px;
1085
+ padding: 9px 11px;
1086
+ border: 1px solid rgba(255, 226, 154, .26);
1087
+ border-radius: 5px;
1088
+ background: rgba(18, 9, 5, .68);
1089
+ color: #ffe6ae;
1090
+ font: 800 11px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace;
1091
+ text-transform: uppercase;
1092
+ }
1093
+
1094
+ .phase-verdict .judge-dais,
1095
+ .phase-questions .judge-dais {
1096
+ animation: bench-lean .9s ease-in-out infinite alternate;
1097
+ }
1098
+
1099
+ .phase-deliberation .jury-benches {
1100
+ animation: jury-murmur .7s ease-in-out infinite alternate;
1101
+ }
1102
+
1103
+ .stage-prop-link {
1104
+ cursor: help;
1105
+ }
1106
+
1107
+ .drawer-shell {
1108
+ max-width: 1500px;
1109
+ margin: 12px auto 0;
1110
+ }
1111
+
1112
+ .drawer-grid {
1113
+ display: grid;
1114
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
1115
+ gap: 18px 28px;
1116
+ }
1117
+
1118
+ .drawer-text-stack {
1119
+ color: var(--ink);
1120
+ line-height: 1.42;
1121
+ }
1122
+
1123
+ .drawer-text-block {
1124
+ color: var(--ink);
1125
+ }
1126
+
1127
+ .drawer-text-block h4 {
1128
+ margin: 5px 0 7px;
1129
+ }
1130
+
1131
+ .drawer-text-block p,
1132
+ .drawer-empty {
1133
+ margin: 0 0 8px;
1134
+ line-height: 1.38;
1135
+ white-space: pre-wrap;
1136
+ }
1137
+
1138
+ .vote-liable { color: var(--red); font-weight: 800; }
1139
+ .vote-not_liable { color: var(--green); font-weight: 800; }
1140
+ .vote-uncertain { color: var(--blue); font-weight: 800; }
1141
+
1142
+ .mind-text {
1143
+ max-height: 340px;
1144
+ overflow: auto;
1145
+ color: var(--ink);
1146
+ font: 12px/1.42 ui-monospace, SFMono-Regular, Consolas, monospace;
1147
+ white-space: pre-wrap;
1148
+ }
1149
+
1150
+ @keyframes puppet-breathe {
1151
+ 0%, 100% { transform: translate(-50%, -100%) translateY(0); }
1152
+ 50% { transform: translate(-50%, -100%) translateY(-4px); }
1153
+ }
1154
+
1155
+ @keyframes lawyer-walk {
1156
+ 0%, 100% { transform: translate(-50%, -100%) translateY(0) rotate(-1deg); }
1157
+ 25% { transform: translate(-50%, -100%) translateY(-7px) rotate(2deg); }
1158
+ 50% { transform: translate(-50%, -100%) translateY(0) rotate(1deg); }
1159
+ 75% { transform: translate(-50%, -100%) translateY(-5px) rotate(-2deg); }
1160
+ }
1161
+
1162
+ @keyframes speak-mouth {
1163
+ 0%, 100% { height: 5px; border-radius: 0 0 18px 18px; }
1164
+ 50% { height: 10px; border-radius: 50%; border: 2px solid #28150c; }
1165
+ }
1166
+
1167
+ @keyframes juror-react {
1168
+ from { transform: translateY(0) rotate(-1deg); }
1169
+ to { transform: translateY(-5px) rotate(2deg); }
1170
+ }
1171
+
1172
+ @keyframes evidence-focus {
1173
+ 0%, 100% { transform: translate(-50%, -100%) translateY(0) scale(1); }
1174
+ 50% { transform: translate(-50%, -100%) translateY(-6px) scale(1.035); }
1175
+ }
1176
+
1177
+ @keyframes paper-land {
1178
+ from { transform: rotate(var(--tilt)) translateY(-18px); opacity: 0; }
1179
+ to { transform: rotate(var(--tilt)) translateY(0); opacity: .96; }
1180
+ }
1181
+
1182
+ @keyframes bench-lean {
1183
+ from { transform: translateX(-50%) translateY(0); }
1184
+ to { transform: translateX(-50%) translateY(-5px); }
1185
+ }
1186
+
1187
+ @keyframes jury-murmur {
1188
+ from { transform: translateX(0); }
1189
+ to { transform: translateX(-3px); }
1190
+ }
1191
+
1192
+ @keyframes gavel-hit {
1193
+ 0% { transform: rotate(-18deg) translateY(0); }
1194
+ 45% { transform: rotate(21deg) translateY(18px); }
1195
+ 100% { transform: rotate(-18deg) translateY(0); }
1196
+ }
1197
+
1198
+ @media (max-width: 820px) {
1199
+ .docket-book-controls {
1200
+ position: fixed;
1201
+ top: 262px;
1202
+ width: calc(100vw - 52px);
1203
+ transform: translateX(-50%) rotate(-1deg);
1204
+ }
1205
+
1206
+ .court-episode-stage {
1207
+ height: 1280px;
1208
+ min-height: 1280px;
1209
+ }
1210
+
1211
+ .episode-room {
1212
+ background-position: center top;
1213
+ }
1214
+
1215
+ .episode-title {
1216
+ left: 16px;
1217
+ right: 16px;
1218
+ max-width: none;
1219
+ }
1220
+
1221
+ .decree-ribbon {
1222
+ top: 164px;
1223
+ left: 16px;
1224
+ right: auto;
1225
+ max-width: calc(100% - 32px);
1226
+ }
1227
+
1228
+ .episode-book {
1229
+ top: 220px;
1230
+ width: min(680px, calc(100% - 20px));
1231
+ }
1232
+
1233
+ .episode-book.closed {
1234
+ top: 430px;
1235
+ width: 210px;
1236
+ }
1237
+
1238
+ .book-open-content {
1239
+ grid-template-columns: 1fr;
1240
+ gap: 10px;
1241
+ inset: 17% 12% 14%;
1242
+ padding: 0 18px;
1243
+ }
1244
+
1245
+ .book-open-content h2 {
1246
+ font-size: 22px;
1247
+ margin-bottom: 5px;
1248
+ }
1249
+
1250
+ .book-open-content p,
1251
+ .book-entry {
1252
+ font-size: 12px;
1253
+ line-height: 1.22;
1254
+ }
1255
+
1256
+ .book-entry {
1257
+ margin: 5px 0;
1258
+ }
1259
+
1260
+ .judge-dais {
1261
+ top: 390px;
1262
+ width: 280px;
1263
+ }
1264
+
1265
+ .counsel-table.left {
1266
+ left: 7%;
1267
+ bottom: 470px;
1268
+ }
1269
+
1270
+ .counsel-table.right {
1271
+ right: 7%;
1272
+ bottom: 470px;
1273
+ }
1274
+
1275
+ .counsel-table {
1276
+ width: 154px;
1277
+ }
1278
+
1279
+ .puppet.auric {
1280
+ left: 20%;
1281
+ top: 650px;
1282
+ }
1283
+
1284
+ .puppet.sable {
1285
+ left: 80%;
1286
+ top: 650px;
1287
+ }
1288
+
1289
+ .speaker-auric .puppet.auric {
1290
+ left: 42%;
1291
+ top: 730px;
1292
+ }
1293
+
1294
+ .speaker-sable .puppet.sable {
1295
+ left: 58%;
1296
+ top: 730px;
1297
+ }
1298
+
1299
+ .puppet.clerk {
1300
+ left: 35%;
1301
+ top: 560px;
1302
+ }
1303
+
1304
+ .puppet.auditor {
1305
+ left: 78%;
1306
+ top: 540px;
1307
+ }
1308
+
1309
+ .witness-area {
1310
+ right: 5%;
1311
+ bottom: 580px;
1312
+ width: 138px;
1313
+ }
1314
+
1315
+ .jury-benches {
1316
+ top: 520px;
1317
+ width: 126px;
1318
+ min-width: 126px;
1319
+ }
1320
+
1321
+ .jury-benches.left {
1322
+ left: 5%;
1323
+ }
1324
+
1325
+ .jury-benches.right {
1326
+ right: 5%;
1327
+ }
1328
+
1329
+ .foreground-fence {
1330
+ bottom: -2px;
1331
+ width: 64%;
1332
+ }
1333
+
1334
+ .foreground-fence.fence-left {
1335
+ left: -17%;
1336
+ }
1337
+
1338
+ .foreground-fence.fence-right {
1339
+ right: -17%;
1340
+ }
1341
+
1342
+ .judge-table-foreground {
1343
+ top: 405px;
1344
+ width: 760px;
1345
+ }
1346
+
1347
+ .evidence-props {
1348
+ left: 8%;
1349
+ right: 8%;
1350
+ bottom: 410px;
1351
+ }
1352
+
1353
+ .trial-caption {
1354
+ bottom: 105px;
1355
+ }
1356
+
1357
+ .gallery-benches {
1358
+ bottom: 42px;
1359
+ grid-template-columns: repeat(3, 1fr);
1360
+ }
1361
+ }
1362
+ """
1363
+
1364
+ APP_JS = f"""
1365
+ () => {{
1366
+ const paths = {json.dumps(AUDIO_PATHS)};
1367
+ const SCORE_BASE_VOLUME = 0.16;
1368
+ const SCORE_QUIET_VOLUME = 0.035;
1369
+ const SCORE_BREATH_INTERVAL_MS = 20000;
1370
+ const SCORE_BREATH_DURATION_MS = 5000;
1371
+ const make = (name, volume = 1, loop = false) => {{
1372
+ const audio = new Audio(paths[name]);
1373
+ audio.preload = 'auto';
1374
+ audio.volume = volume;
1375
+ audio.loop = loop;
1376
+ return audio;
1377
+ }};
1378
+
1379
+ if (!window.SovereignCourtAudio) {{
1380
+ const controller = {{
1381
+ unlocked: false,
1382
+ lastPhase: null,
1383
+ muted: false,
1384
+ scoreVolume: SCORE_BASE_VOLUME,
1385
+ crowdVolume: 0.0,
1386
+ fadeFrame: null,
1387
+ breathTimer: null,
1388
+ score: make('score', SCORE_BASE_VOLUME, true),
1389
+ crowd: make('crowd', 0.0, true),
1390
+ begin() {{
1391
+ this.unlocked = true;
1392
+ this.ensureLooping();
1393
+ this.startBreathing();
1394
+ this.play('select', 0.26);
1395
+ window.setTimeout(() => this.play('paper_long', 0.45), 120);
1396
+ window.setTimeout(() => this.play('gavel', 0.72), 520);
1397
+ this.observePhase();
1398
+ this.updateToggle();
1399
+ }},
1400
+ ensureLooping() {{
1401
+ if (!this.unlocked || this.muted) return;
1402
+ this.applyLoopVolumes();
1403
+ this.score.play().catch(() => {{}});
1404
+ this.crowd.play().catch(() => {{}});
1405
+ }},
1406
+ applyLoopVolumes() {{
1407
+ this.score.volume = this.muted ? 0 : this.scoreVolume;
1408
+ this.crowd.volume = this.muted ? 0 : this.crowdVolume;
1409
+ }},
1410
+ play(name, volume = 1) {{
1411
+ if (!this.unlocked || this.muted) return;
1412
+ const cue = make(name, volume, false);
1413
+ cue.play().catch(() => {{}});
1414
+ }},
1415
+ setCrowd(volume) {{
1416
+ this.crowdVolume = volume;
1417
+ this.applyLoopVolumes();
1418
+ }},
1419
+ fadeScore(toVolume, duration, onComplete) {{
1420
+ if (this.fadeFrame) window.cancelAnimationFrame(this.fadeFrame);
1421
+ const fromVolume = this.scoreVolume;
1422
+ const started = window.performance.now();
1423
+ const step = (now) => {{
1424
+ const progress = Math.min(1, (now - started) / duration);
1425
+ this.scoreVolume = fromVolume + ((toVolume - fromVolume) * progress);
1426
+ this.applyLoopVolumes();
1427
+ if (progress < 1) {{
1428
+ this.fadeFrame = window.requestAnimationFrame(step);
1429
+ }} else {{
1430
+ this.fadeFrame = null;
1431
+ if (onComplete) onComplete();
1432
+ }}
1433
+ }};
1434
+ this.fadeFrame = window.requestAnimationFrame(step);
1435
+ }},
1436
+ breatheScore() {{
1437
+ if (!this.unlocked) return;
1438
+ const halfDuration = SCORE_BREATH_DURATION_MS / 2;
1439
+ this.fadeScore(SCORE_QUIET_VOLUME, halfDuration, () => {{
1440
+ this.fadeScore(SCORE_BASE_VOLUME, halfDuration);
1441
+ }});
1442
+ }},
1443
+ startBreathing() {{
1444
+ if (this.breathTimer) return;
1445
+ this.breathTimer = window.setInterval(() => this.breatheScore(), SCORE_BREATH_INTERVAL_MS);
1446
+ }},
1447
+ toggleMuted() {{
1448
+ this.muted = !this.muted;
1449
+ if (this.muted) {{
1450
+ this.applyLoopVolumes();
1451
+ this.score.pause();
1452
+ this.crowd.pause();
1453
+ }} else {{
1454
+ this.ensureLooping();
1455
+ }}
1456
+ this.updateToggle();
1457
+ }},
1458
+ updateToggle() {{
1459
+ document.querySelectorAll('.sound-toggle').forEach((button) => {{
1460
+ button.classList.toggle('muted', this.muted);
1461
+ button.setAttribute('aria-pressed', String(this.muted));
1462
+ button.setAttribute('title', this.muted ? 'Sound off' : 'Sound on');
1463
+ }});
1464
+ }},
1465
+ cuePhase(phase) {{
1466
+ if (!this.unlocked || !phase || phase === this.lastPhase) return;
1467
+ this.lastPhase = phase;
1468
+ if (phase === 'intake') {{
1469
+ this.setCrowd(0.08);
1470
+ this.play('paper', 0.45);
1471
+ this.play('wood', 0.42);
1472
+ }} else if (phase === 'claims' || phase === 'opening') {{
1473
+ this.setCrowd(0.045);
1474
+ this.play('steps', 0.33);
1475
+ }} else if (phase === 'evidence') {{
1476
+ this.setCrowd(0.035);
1477
+ this.play('paper_long', 0.52);
1478
+ }} else if (phase === 'questions') {{
1479
+ this.setCrowd(0.02);
1480
+ this.play('wood', 0.34);
1481
+ }} else if (phase === 'deliberation') {{
1482
+ this.setCrowd(0.18);
1483
+ }} else if (phase === 'verdict') {{
1484
+ this.setCrowd(0.0);
1485
+ this.play('judgement', 0.66);
1486
+ window.setTimeout(() => this.play('gavel', 0.9), 650);
1487
+ }} else if (phase === 'appeal') {{
1488
+ this.setCrowd(0.035);
1489
+ this.play('paper_long', 0.5);
1490
+ }}
1491
+ }},
1492
+ observePhase() {{
1493
+ const stage = document.querySelector('.court-episode-stage');
1494
+ if (stage) this.cuePhase(stage.dataset.phase);
1495
+ this.updateToggle();
1496
+ }}
1497
+ }};
1498
+
1499
+ window.SovereignCourtAudio = controller;
1500
+
1501
+ const observer = new MutationObserver(() => controller.observePhase());
1502
+ observer.observe(document.body, {{ childList: true, subtree: true, attributes: true, attributeFilter: ['data-phase'] }});
1503
+
1504
+ document.addEventListener('click', (event) => {{
1505
+ const toggle = event.target.closest('.sound-toggle');
1506
+ if (toggle) {{
1507
+ event.preventDefault();
1508
+ controller.toggleMuted();
1509
+ return;
1510
+ }}
1511
+ if (event.target.closest('.docket-book-controls')) {{
1512
+ controller.play('select', 0.22);
1513
+ }}
1514
+ }}, true);
1515
+ }}
1516
+ }}
1517
+ """
1518
+
1519
+ APP_HEAD = f"""
1520
+ <script>
1521
+ (function() {{
1522
+ const installCourtAudio = {APP_JS.strip()};
1523
+ if (document.readyState === 'loading') {{
1524
+ document.addEventListener('DOMContentLoaded', installCourtAudio, {{ once: true }});
1525
+ }} else {{
1526
+ installCourtAudio();
1527
+ }}
1528
+ }})();
1529
+ </script>
1530
+ """
1531
+
1532
+ START_JS = """
1533
+ (case_label, search_query, hypothetical, speed, mind_layer) => {
1534
+ document.body.classList.add('trial-has-started');
1535
+ if (window.SovereignCourtAudio) {
1536
+ window.SovereignCourtAudio.begin();
1537
+ }
1538
+ return [case_label, search_query, hypothetical, speed, mind_layer];
1539
+ }
1540
+ """
1541
+
1542
+ CHARACTERS = {
1543
+ JUDGE_NAME: {
1544
+ "class": "judge",
1545
+ "name": JUDGE_NAME,
1546
+ "role": "Stoic presiding judge",
1547
+ "model": "gpt-oss-20b",
1548
+ "image": "/gradio_api/file=assets/characters/marcus-aurelius.png",
1549
+ },
1550
+ "Clerk Meridian": {
1551
+ "class": "clerk",
1552
+ "name": "Clerk Meridian",
1553
+ "role": "Court clerk",
1554
+ "model": "AgentCPM-Explore",
1555
+ },
1556
+ "Advocate Auric": {
1557
+ "class": "auric",
1558
+ "name": "Advocate Auric",
1559
+ "role": "Claimant advocate",
1560
+ "model": "gpt-oss-20b",
1561
+ },
1562
+ "Counsel Sable": {
1563
+ "class": "sable",
1564
+ "name": "Counsel Sable",
1565
+ "role": "Respondent advocate",
1566
+ "model": "gpt-oss-20b",
1567
+ },
1568
+ "Auditor Prism": {
1569
+ "class": "auditor",
1570
+ "name": "Auditor Prism",
1571
+ "role": "Evidence auditor",
1572
+ "model": "Nemotron-Orchestrator-8B",
1573
+ },
1574
+ "Nemotron Jury": {
1575
+ "class": "jury",
1576
+ "name": "Nemotron Jury",
1577
+ "role": "Jury panel",
1578
+ "model": "Nemotron-Orchestrator-8B",
1579
+ },
1580
+ }
1581
+
1582
+ JUROR_FACES = {
1583
+ "Karl Marx": "#d0b79c",
1584
+ "John Stuart Mill": "#c99b72",
1585
+ "Confucius": "#c49a64",
1586
+ "Cleopatra VII": "#b98755",
1587
+ "Niccolo Machiavelli": "#b88963",
1588
+ "Jensen Huang": "#b37758",
1589
+ }
1590
+
1591
+ JUROR_IMAGES = {
1592
+ "Karl Marx": "/gradio_api/file=assets/characters/karl-marx.png",
1593
+ "John Stuart Mill": "/gradio_api/file=assets/characters/john-stuart-mill.png",
1594
+ "Confucius": "/gradio_api/file=assets/characters/confucius.png",
1595
+ "Cleopatra VII": "/gradio_api/file=assets/characters/cleopatra-vii.png",
1596
+ "Niccolo Machiavelli": "/gradio_api/file=assets/characters/niccolo-machiavelli.png",
1597
+ "Jensen Huang": "/gradio_api/file=assets/characters/jensen-huang.png",
1598
+ }
1599
+
1600
+ PHASE_AGENTS = {
1601
+ "pretrial": ["Clerk Meridian"],
1602
+ }
1603
+
1604
+
1605
+ def _remote_events(request: TrialRequest) -> Iterable[TrialEvent] | None:
1606
+ endpoint = os.getenv("MODAL_TRIAL_URL", "").strip()
1607
+ if not endpoint:
1608
+ return None
1609
+
1610
+ def iterator() -> Iterable[TrialEvent]:
1611
+ with httpx.stream("POST", endpoint, json=request.model_dump(), timeout=900.0) as response:
1612
+ response.raise_for_status()
1613
+ for line in response.iter_lines():
1614
+ if line:
1615
+ yield TrialEvent.model_validate_json(line)
1616
+
1617
+ return iterator()
1618
+
1619
+
1620
+ def get_events(request: TrialRequest) -> Iterable[TrialEvent]:
1621
+ remote = _remote_events(request)
1622
+ if remote is not None:
1623
+ yield from remote
1624
+ return
1625
+ delay = {"swift": 1.4, "measured": 2.4, "ceremonial": 3.4}[request.speed]
1626
+ yield from stream_trial(request, delay=delay)
1627
+
1628
+
1629
+ def _escape(value: str) -> str:
1630
+ return (
1631
+ value.replace("&", "&amp;")
1632
+ .replace("<", "&lt;")
1633
+ .replace(">", "&gt;")
1634
+ .replace('"', "&quot;")
1635
+ )
1636
+
1637
+
1638
+ def _latest_packet_title(events: list[TrialEvent]) -> tuple[str, str]:
1639
+ if not events:
1640
+ return (
1641
+ "Judge-GPT",
1642
+ "The gallery doors open on an AI-native courtroom. Choose a case from the docket book and begin the proceeding.",
1643
+ )
1644
+ lines = events[0].body.splitlines()
1645
+ title = lines[0] if lines else "Judge-GPT"
1646
+ subtitle = lines[1] if len(lines) > 1 else events[0].title
1647
+ return title, subtitle
1648
+
1649
+
1650
+ def _active_agents_for(event: TrialEvent | None) -> set[str]:
1651
+ if event is None:
1652
+ return set(PHASE_AGENTS["pretrial"])
1653
+ if not event.turns:
1654
+ return set()
1655
+ return {event.turns[0].agent}
1656
+
1657
+
1658
+ def _active_speaker_for(event: TrialEvent | None) -> str:
1659
+ if event is None:
1660
+ return "Clerk Meridian"
1661
+ if not event.turns:
1662
+ return ""
1663
+ return event.turns[0].agent
1664
+
1665
+
1666
+ def _speaker_class_for(speaker: str) -> str:
1667
+ if not speaker:
1668
+ return ""
1669
+ if speaker in CHARACTERS:
1670
+ return f" speaker-{CHARACTERS[speaker]['class']}"
1671
+ return " speaker-" + "".join(ch.lower() if ch.isalnum() else "-" for ch in speaker).strip("-")
1672
+
1673
+
1674
+ def _latest_turn_text(event: TrialEvent | None, agent: str) -> str:
1675
+ if event is None:
1676
+ return ""
1677
+ turn = next((turn for turn in event.turns if turn.agent == agent), None)
1678
+ if turn is None:
1679
+ return ""
1680
+ return _short_text(turn.content, 210)
1681
+
1682
+
1683
+ def _thread_id(name: str) -> str:
1684
+ return "ai-thread-" + "".join(ch.lower() if ch.isalnum() else "-" for ch in name).strip("-")
1685
+
1686
+
1687
+ def _turns_for_agent(events: list[TrialEvent], agent: str) -> list[dict[str, str]]:
1688
+ turns = []
1689
+ for event in events:
1690
+ for turn in event.turns:
1691
+ if turn.agent == agent:
1692
+ turns.append(
1693
+ {
1694
+ "phase": event.phase,
1695
+ "title": event.title,
1696
+ "role": turn.role,
1697
+ "model": turn.model,
1698
+ "confidence": f"{turn.confidence:.2f}",
1699
+ "input": turn.input or "Prompt unavailable for this turn.",
1700
+ "output": turn.content or "No output captured yet.",
1701
+ }
1702
+ )
1703
+ return turns
1704
+
1705
+
1706
+ def _thread_for_character(events: list[TrialEvent], agent: str) -> list[dict[str, str]]:
1707
+ if agent in JUROR_FACES:
1708
+ turns = _turns_for_agent(events, agent)
1709
+ vote = next((vote for event in reversed(events) for vote in event.votes if vote.juror == agent), None)
1710
+ if not turns:
1711
+ turns = _turns_for_agent(events, "Nemotron Jury")
1712
+ if vote and turns:
1713
+ turns = [
1714
+ dict(
1715
+ turn,
1716
+ output=f"{turn['output']}\n\n{agent} persona: {vote.persona}\n{agent} vote: {vote.vote}\nReason: {vote.reason}",
1717
+ )
1718
+ for turn in turns
1719
+ ]
1720
+ return turns
1721
+ return _turns_for_agent(events, agent)
1722
+
1723
+
1724
+ def _short_text(value: str, limit: int = 170) -> str:
1725
+ squashed = " ".join(value.split())
1726
+ return squashed if len(squashed) <= limit else squashed[: limit - 1].rstrip() + "..."
1727
+
1728
+
1729
+ def _tooltip(name: str, role: str, model: str, turns: list[dict[str, str]]) -> str:
1730
+ latest = turns[-1] if turns else None
1731
+ input_preview = _short_text(latest["input"] if latest else "Waiting for this model to receive its first prompt.")
1732
+ output_preview = _short_text(latest["output"] if latest else "No output has been emitted yet.")
1733
+ return (
1734
+ "<span class='tooltip'>"
1735
+ f"<strong>{_escape(name)}</strong>"
1736
+ f"{_escape(role)}"
1737
+ f"<span class='tooltip-meta'>{_escape(model)}</span>"
1738
+ "<span class='tooltip-io-label'>Input</span>"
1739
+ f"<p>{_escape(input_preview)}</p>"
1740
+ "<span class='tooltip-io-label'>Output</span>"
1741
+ f"<p>{_escape(output_preview)}</p>"
1742
+ "<span class='tooltip-meta'>Click to open full thread</span>"
1743
+ "</span>"
1744
+ )
1745
+
1746
+
1747
+ def _thread_modal(name: str, role: str, model: str, turns: list[dict[str, str]]) -> str:
1748
+ body = (
1749
+ "".join(
1750
+ "<section class='thread-turn'>"
1751
+ f"<div class='thread-subtitle'>{_escape(turn['phase'])} / {_escape(turn['role'])} / confidence {turn['confidence']}</div>"
1752
+ "<span class='thread-label'>Input</span>"
1753
+ f"<div class='thread-block'>{_escape(turn['input'])}</div>"
1754
+ "<span class='thread-label'>Output</span>"
1755
+ f"<div class='thread-block'>{_escape(turn['output'])}</div>"
1756
+ "</section>"
1757
+ for turn in turns
1758
+ )
1759
+ or "<section class='thread-turn'><div class='thread-block'>Waiting for this model thread to appear.</div></section>"
1760
+ )
1761
+ return (
1762
+ f"<aside id='{_escape(_thread_id(name))}' class='ai-thread-modal'>"
1763
+ "<a class='thread-close' href='#court-stage'>Close</a>"
1764
+ f"<h2 class='thread-title'>{_escape(name)}</h2>"
1765
+ f"<div class='thread-subtitle'>{_escape(role)} / {_escape(model)}</div>"
1766
+ f"{body}</aside>"
1767
+ )
1768
+
1769
+
1770
+ def _puppet(agent: str, active_agents: set[str], phase: str, events: list[TrialEvent], latest: TrialEvent | None) -> str:
1771
+ meta = CHARACTERS[agent]
1772
+ active = " active" if agent in active_agents else ""
1773
+ walking = " walking" if agent in {"Advocate Auric", "Counsel Sable"} and agent in active_agents else ""
1774
+ small = " small" if agent in {"Clerk Meridian", "Auditor Prism"} else ""
1775
+ turns = _thread_for_character(events, agent)
1776
+ bubble = ""
1777
+ if agent in active_agents:
1778
+ speech = _latest_turn_text(latest, agent)
1779
+ if speech:
1780
+ bubble = f"<span class='speech-bubble'>{_escape(speech)}</span>"
1781
+ portrait = ""
1782
+ if meta.get("image"):
1783
+ portrait = (
1784
+ f"<img class='puppet-portrait' src='{_escape(meta['image'])}' "
1785
+ f"alt='{_escape(meta['name'])} bust' onerror=\"this.style.display='none'\">"
1786
+ )
1787
+ return (
1788
+ f"<a class='puppet {meta['class']}{active}{walking}{small}' href='#{_escape(_thread_id(agent))}' aria-label='Open {_escape(agent)} model thread'>"
1789
+ f"{portrait}"
1790
+ "<span class='mouth'></span>"
1791
+ f"{bubble}"
1792
+ f"{_tooltip(meta['name'], meta['role'], meta['model'], turns)}"
1793
+ "</a>"
1794
+ )
1795
+
1796
+
1797
+ def _juror(name: str, active: bool, events: list[TrialEvent] | None = None, latest: TrialEvent | None = None) -> str:
1798
+ face = JUROR_FACES.get(name, "#c89259")
1799
+ image = JUROR_IMAGES.get(name, "")
1800
+ active_cls = " active" if active else ""
1801
+ turns = _thread_for_character(events or [], name)
1802
+ bubble = ""
1803
+ if active:
1804
+ vote = next((vote for vote in (latest.votes if latest else []) if vote.juror == name), None)
1805
+ speech = _latest_turn_text(latest, name)
1806
+ if vote:
1807
+ speech = f"{vote.vote.replace('_', ' ').title()}. {vote.reason}"
1808
+ if speech:
1809
+ bubble = f"<span class='speech-bubble'>{_escape(_short_text(speech, 190))}</span>"
1810
+ portrait = (
1811
+ f"<img class='juror-portrait' src='{_escape(image)}' alt='{_escape(name)} bust' "
1812
+ "onerror=\"this.style.display='none'\">"
1813
+ if image
1814
+ else ""
1815
+ )
1816
+ return (
1817
+ f"<a class='juror{active_cls}' href='#{_escape(_thread_id(name))}' style='--face: {face}' aria-label='Open {_escape(name)} model thread'>"
1818
+ f"{portrait}"
1819
+ "<span class='juror-face'></span><span class='juror-body'></span>"
1820
+ f"{bubble}"
1821
+ f"{_tooltip(name, 'HF-style juror', 'Nemotron panel', turns)}"
1822
+ "</a>"
1823
+ )
1824
+
1825
+
1826
+ def _book(open_book: bool) -> str:
1827
+ closed = "" if open_book else " closed"
1828
+ return (
1829
+ f"<div class='episode-book{closed}'>"
1830
+ "<img class='book-art open-art' src='/gradio_api/file=assets/book/docket-book-open.png' alt='Open docket book'>"
1831
+ "<img class='book-art closed-art' src='/gradio_api/file=assets/book/docket-book-closed.png' alt='Closed docket book'>"
1832
+ "</div>"
1833
+ )
1834
+
1835
+
1836
+ def _caption(event: TrialEvent | None, phase: str) -> tuple[str, str, str]:
1837
+ if event is None:
1838
+ return (
1839
+ "PRETRIAL",
1840
+ "The Courtroom Is Ready",
1841
+ "The docket is open, the room is dimmed, and the clerk is waiting for the first call.",
1842
+ )
1843
+ body = event.body.splitlines()[0] if event.body.splitlines() else event.body
1844
+ return (f"{PHASE_GLYPHS[phase]} / {phase.upper()}", event.title, body)
1845
+
1846
+
1847
+ def _evidence_props(events: list[TrialEvent]) -> str:
1848
+ evidence = next((event.evidence for event in reversed(events) if event.evidence), [])
1849
+ if not evidence:
1850
+ return ""
1851
+ tilts = ["-5deg", "3deg", "-2deg", "5deg"]
1852
+ sheets = []
1853
+ for idx, item in enumerate(evidence[:4]):
1854
+ sheets.append(
1855
+ f"<div class='evidence-sheet stage-prop-link' style='--tilt: {tilts[idx % len(tilts)]}' title='{_escape(item.note)}'>"
1856
+ f"<strong>{_escape(item.id)}</strong>"
1857
+ f"<span>{_escape(item.title)}</span></div>"
1858
+ )
1859
+ return f"<div class='evidence-props'>{''.join(sheets)}</div>"
1860
+
1861
+
1862
+ def _foreground_props() -> str:
1863
+ fence = "/gradio_api/file=assets/foreground/foregroundFence.png"
1864
+ judge_table = "/gradio_api/file=assets/foreground/JudgeTable.png"
1865
+ return (
1866
+ "<div class='foreground-props' aria-hidden='true'>"
1867
+ f"<img class='foreground-fence fence-left' src='{fence}' alt=''>"
1868
+ f"<img class='foreground-fence fence-right' src='{fence}' alt=''>"
1869
+ f"<img class='judge-table-foreground' src='{judge_table}' alt=''>"
1870
+ "</div>"
1871
+ )
1872
+
1873
+
1874
+ def _courtroom_juror_names(votes: list) -> list[str]:
1875
+ names = list(JUROR_FACES)
1876
+ names.extend(vote.juror for vote in votes if vote.juror not in names)
1877
+ return names[:6]
1878
+
1879
+
1880
+ def _latest_votes(events: list[TrialEvent]) -> list:
1881
+ by_juror = {}
1882
+ for event in events:
1883
+ for vote in event.votes:
1884
+ by_juror[vote.juror] = vote
1885
+ ordered = [by_juror[name] for name in JUROR_FACES if name in by_juror]
1886
+ ordered.extend(vote for juror, vote in by_juror.items() if juror not in JUROR_FACES)
1887
+ return ordered
1888
+
1889
+
1890
+ def render_court(events: list[TrialEvent], started: bool = False) -> str:
1891
+ latest = events[-1] if events else None
1892
+ phase = latest.phase if latest else "pretrial"
1893
+ title, subtitle = _latest_packet_title(events)
1894
+ active_agents = _active_agents_for(latest)
1895
+ active_speaker = _active_speaker_for(latest)
1896
+ speaker_cls = _speaker_class_for(active_speaker)
1897
+ caption_phase, caption_title, caption_body = _caption(latest, phase)
1898
+ latest_votes = _latest_votes(events)
1899
+ juror_names = _courtroom_juror_names(latest_votes)
1900
+ started_cls = " trial-started" if started or events else ""
1901
+ book_open = not started and not events
1902
+ puppets = "".join(
1903
+ _puppet(agent, active_agents, phase, events, latest)
1904
+ for agent in [JUDGE_NAME, "Clerk Meridian", "Advocate Auric", "Counsel Sable", "Auditor Prism"]
1905
+ )
1906
+ left_jurors = "".join(_juror(name, name == active_speaker, events, latest) for name in juror_names[:3])
1907
+ right_jurors = "".join(_juror(name, name == active_speaker, events, latest) for name in juror_names[3:6])
1908
+ evidence_props = _evidence_props(events)
1909
+ thread_modals = "".join(
1910
+ _thread_modal(meta["name"], meta["role"], meta["model"], _thread_for_character(events, agent))
1911
+ for agent, meta in CHARACTERS.items()
1912
+ ) + "".join(
1913
+ _thread_modal(name, "HF-style juror", "Nemotron panel", _thread_for_character(events, name))
1914
+ for name in juror_names
1915
+ )
1916
+ return (
1917
+ f"<section id='court-stage' class='court-episode-stage phase-{_escape(phase)}{_escape(speaker_cls)}{started_cls}' data-phase='{_escape(phase)}'>"
1918
+ "<div class='episode-room'></div>"
1919
+ "<div class='audio-deck' aria-hidden='true'>"
1920
+ + "".join(f"<audio preload='auto' src='{_escape(src)}'></audio>" for src in AUDIO_PATHS.values())
1921
+ + "</div>"
1922
+ "<button class='sound-toggle' type='button' aria-label='Toggle sound' aria-pressed='false' title='Sound on'>"
1923
+ "<span class='sound-icon' aria-hidden='true'></span></button>"
1924
+ "<div class='episode-title'>"
1925
+ "<div class='episode-kicker'>Judge-GPT Virtual Courtroom</div>"
1926
+ f"<h1>{_escape(title)}</h1>"
1927
+ f"<p>{_escape(subtitle)}</p></div>"
1928
+ f"<div class='decree-ribbon'>Step {len(events) if events else 0}: {caption_title}<br>Hover characters for agent and model details</div>"
1929
+ f"{_book(book_open)}"
1930
+ f"<div class='judge-dais'><div class='prop-label'>{_escape(JUDGE_NAME)}</div><div class='bench-front'></div><span class='gavel'></span></div>"
1931
+ "<div class='counsel-table left'><div class='prop-label'>Claimant Table</div></div>"
1932
+ "<div class='counsel-table right'><div class='prop-label'>Respondent Table</div></div>"
1933
+ "<div class='trial-floor-mark'></div>"
1934
+ "<div class='witness-area'><div class='prop-label'>Evidence Stand</div></div>"
1935
+ "<div class='jury-benches left'><div class='prop-label'>Jury Box</div><div class='jury-row'>"
1936
+ f"{left_jurors}</div><div class='jury-rail'></div></div>"
1937
+ "<div class='jury-benches right'><div class='prop-label'>Jury Box</div><div class='jury-row'>"
1938
+ f"{right_jurors}</div><div class='jury-rail'></div></div>"
1939
+ f"{puppets}"
1940
+ f"{evidence_props}"
1941
+ f"{_foreground_props()}"
1942
+ "<div class='gallery-benches'><div></div><div></div><div></div><div></div><div></div><div></div></div>"
1943
+ "<div class='trial-caption'>"
1944
+ f"<div class='caption-phase'>Live Trial Feed / {_escape(caption_phase)}</div>"
1945
+ f"<div class='caption-title'>{_escape(caption_title)}</div>"
1946
+ f"<div class='caption-body'>{_escape(caption_body)}</div>"
1947
+ "</div>"
1948
+ f"{thread_modals}</section>"
1949
+ )
1950
+
1951
+
1952
+ def render_evidence(events: list[TrialEvent]) -> str:
1953
+ evidence = next((event.evidence for event in reversed(events) if event.evidence), [])
1954
+ if not evidence:
1955
+ return "<div class='drawer-empty'>The exhibit drawer is closed until the clerk opens the docket.</div>"
1956
+ return (
1957
+ "<div class='drawer-grid drawer-text-stack'>"
1958
+ + "".join(
1959
+ "<section class='drawer-text-block'>"
1960
+ f"<div class='drawer-kicker'>{_escape(item.id)} / {item.reliability:.2f}</div>"
1961
+ f"<h4>{_escape(item.title)}</h4>"
1962
+ f"<p>{_escape(item.excerpt)}</p>"
1963
+ f"<p><strong>Direction:</strong> {_escape(item.supports)}</p>"
1964
+ f"<p>{_escape(item.note)}</p></section>"
1965
+ for item in evidence
1966
+ )
1967
+ + "</div>"
1968
+ )
1969
+
1970
+
1971
+ def render_jurors(events: list[TrialEvent]) -> str:
1972
+ votes = _latest_votes(events)
1973
+ if not votes:
1974
+ sleepers = "".join(_juror(name, False) for name in JUROR_FACES)
1975
+ return (
1976
+ "<section class='drawer-text-block drawer-text-stack'><div class='drawer-kicker'>Jury Box</div>"
1977
+ f"<div class='jury-row'>{sleepers}</div>"
1978
+ "<p>The jurors are seated and silent.</p></section>"
1979
+ )
1980
+ return (
1981
+ "<div class='drawer-grid drawer-text-stack'>"
1982
+ + "".join(
1983
+ "<section class='drawer-text-block'>"
1984
+ f"<div class='drawer-kicker'>{_escape(vote.juror)}</div>"
1985
+ f"<p><strong>Persona:</strong> {_escape(vote.persona)}</p>"
1986
+ f"<p class='vote-{vote.vote}'>{_escape(vote.vote.replace('_', ' '))}</p>"
1987
+ f"<p>{_escape(vote.reason)}</p>"
1988
+ f"<p><strong>Evidence:</strong> {_escape(', '.join(vote.evidence_ids))}</p></section>"
1989
+ for vote in votes
1990
+ )
1991
+ + "</div>"
1992
+ )
1993
+
1994
+
1995
+ def render_mind(events: list[TrialEvent], enabled: bool) -> str:
1996
+ if not enabled:
1997
+ return "<div class='mind-text'>Mind Layer hidden.</div>"
1998
+ if not events:
1999
+ return "<div class='mind-text'>Awaiting trace.</div>"
2000
+ compact = [
2001
+ {
2002
+ "phase": event.phase,
2003
+ "title": event.title,
2004
+ "turns": [turn.model_dump() for turn in event.turns],
2005
+ "trace": event.trace,
2006
+ }
2007
+ for event in events
2008
+ ]
2009
+ return f"<pre class='mind-text'>{_escape(json.dumps(compact, indent=2))}</pre>"
2010
+
2011
+
2012
+ def run_ui(case_label: str, search_query: str, hypothetical: str, speed: str, mind_layer: bool):
2013
+ request = TrialRequest(
2014
+ case_id=CASE_OPTIONS.get(case_label, "socrates"),
2015
+ search_query=search_query or "",
2016
+ hypothetical=hypothetical or "",
2017
+ speed=speed or "swift",
2018
+ mind_layer=bool(mind_layer),
2019
+ )
2020
+ events: list[TrialEvent] = []
2021
+ yield (
2022
+ render_court(events, started=True),
2023
+ render_evidence(events),
2024
+ render_jurors(events),
2025
+ render_mind(events, mind_layer),
2026
+ "The docket closes and the bailiff calls the room to order.",
2027
+ )
2028
+ try:
2029
+ for event in get_events(request):
2030
+ events.append(event)
2031
+ status = f"Step {len(events)}: {event.title}"
2032
+ yield (
2033
+ render_court(events, started=True),
2034
+ render_evidence(events),
2035
+ render_jurors(events),
2036
+ render_mind(events, mind_layer),
2037
+ status,
2038
+ )
2039
+ except Exception as exc:
2040
+ yield (
2041
+ render_court(events, started=True),
2042
+ render_evidence(events),
2043
+ render_jurors(events),
2044
+ render_mind(events, mind_layer),
2045
+ f"Model response required. Trial stopped: {exc}",
2046
+ )
2047
+ return
2048
+ yield (
2049
+ render_court(events, started=True),
2050
+ render_evidence(events),
2051
+ render_jurors(events),
2052
+ render_mind(events, mind_layer),
2053
+ "Verdict sealed.",
2054
+ )
2055
+
2056
+
2057
+ def build_app() -> gr.Blocks:
2058
+ with gr.Blocks(title="Judge-GPT") as demo:
2059
+ with gr.Group(elem_classes=["docket-book-controls"]):
2060
+ gr.HTML("<div class='book-control-heading'>DATA TRIAL:</div>")
2061
+ with gr.Row():
2062
+ case = gr.Dropdown(
2063
+ label="Case entry",
2064
+ choices=list(CASE_OPTIONS.keys()),
2065
+ value="Trial of Socrates",
2066
+ scale=2,
2067
+ )
2068
+ start = gr.Button("Begin Trial", variant="primary", scale=1)
2069
+ status = gr.Markdown("Ready.", elem_classes=["book-status"])
2070
+ courtroom = gr.HTML(render_court([]), label="Live courtroom")
2071
+ search = gr.State("")
2072
+ speed = gr.State("swift")
2073
+ mind = gr.State(True)
2074
+ with gr.Accordion("Advanced trial options", open=False, elem_classes=["trial-options"]):
2075
+ with gr.Row():
2076
+ hypo = gr.Textbox(label="Hypothetical sidebar", lines=1)
2077
+ with gr.Row(elem_classes=["drawer-shell"]):
2078
+ with gr.Column(scale=1):
2079
+ with gr.Tab("Evidence Drawer"):
2080
+ evidence = gr.HTML(render_evidence([]))
2081
+ with gr.Tab("Juror Panel"):
2082
+ jurors = gr.HTML(render_jurors([]))
2083
+ mind_html = gr.HTML(render_mind([], True), visible=False)
2084
+ start.click(
2085
+ run_ui,
2086
+ inputs=[case, search, hypo, speed, mind],
2087
+ outputs=[courtroom, evidence, jurors, mind_html, status],
2088
+ js=START_JS,
2089
+ )
2090
+ return demo
2091
+
2092
+
2093
+ demo = build_app()
2094
+
2095
+ if __name__ == "__main__":
2096
+ demo.queue().launch(
2097
+ show_error=True,
2098
+ allowed_paths=["assets"],
2099
+ css=CSS,
2100
+ head=APP_HEAD,
2101
+ theme=gr.themes.Soft(),
2102
+ )
assets/ATTRIBUTION.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Asset Attribution
2
+
3
+ ## `courtroom-dickinson.jpg`
4
+
5
+ - Source: https://commons.wikimedia.org/wiki/File:Dickinson_Law_Courtroom.jpg
6
+ - Description: Penn State University, Dickinson School of Law courtroom
7
+ - Author: Jeremy Hess Photography
8
+ - License: Creative Commons CC0 1.0 Universal Public Domain Dedication
9
+ - Local use: cinematic courtroom background for Sovereign Bench
10
+
assets/audio/ATTRIBUTION.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Audio Attribution
2
+
3
+ All selected audio is stored locally in `assets/audio/` for the animated courtroom episode.
4
+
5
+ ## Courtroom score and judgement sting
6
+
7
+ - Files: `courtroom.ogg`, `Judgement.ogg`
8
+ - Source: OpenGameArt, "Courtroom and Judgement"
9
+ - Author: Spring Spring
10
+ - License: CC0
11
+ - URL: https://opengameart.org/content/courtroom-and-judgement
12
+
13
+ ## Courtroom chatter and crowd reaction
14
+
15
+ - File: `crowd_shouting.ogg`
16
+ - Source: OpenGameArt, "Crowd Shouting/Speaking Ambience"
17
+ - Author: StarNinjas
18
+ - License: CC0
19
+ - URL: https://opengameart.org/content/crowd-shoutingspeaking-ambience
20
+
21
+ ## Gavel and wood hits
22
+
23
+ - Files: `wood_hammer_01.ogg`, `wood_hit_03.ogg`
24
+ - Source: OpenGameArt, "100 CC0 metal and wood SFX"
25
+ - Author: rubberduck
26
+ - License: CC0
27
+ - URL: https://opengameart.org/content/100-cc0-metal-and-wood-sfx
28
+
29
+ ## Lawyer footsteps
30
+
31
+ - File: `steps_in_wood_floor.wav`
32
+ - Source: OpenGameArt, "Steps in wood floor"
33
+ - Author: mikeask
34
+ - License: CC0
35
+ - URL: https://opengameart.org/content/steps-in-wood-floor
36
+
37
+ ## Book and paper movement
38
+
39
+ - Files: `paper_sound_1.mp3`, `paper_sound_4.mp3`
40
+ - Source: OpenGameArt, "Various Paper Sound Effects"
41
+ - Author: Luckius
42
+ - License: CC0
43
+ - URL: https://opengameart.org/content/various-paper-sound-effects
44
+
45
+ ## Docket selection UI cue
46
+
47
+ - File: `select_001.ogg`
48
+ - Source: OpenGameArt, "Interface Sounds"
49
+ - Author: Kenney
50
+ - License: CC0
51
+ - URL: https://opengameart.org/content/interface-sounds
assets/audio/Judgement.ogg ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a0fb8647947708bcde1771a96cd6272653e90dd1d2823bae9581e28707fad35d
3
+ size 3164976
assets/audio/courtroom.ogg ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5ffd37d0a907c80324a51419544b5cdcb69781cc4642d9f79cff3e2ffee4a556
3
+ size 2666730
assets/audio/crowd_shouting.ogg ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a2c23a64c127c77717cbe4abb21f138bac9d9aa6cb3cc89d62bab5d0d96dd7ca
3
+ size 335526
assets/audio/paper_sound_1.mp3 ADDED
Binary file (12.3 kB). View file
 
assets/audio/paper_sound_4.mp3 ADDED
Binary file (53.5 kB). View file
 
assets/audio/select_001.ogg ADDED
Binary file (5.47 kB). View file
 
assets/audio/steps_in_wood_floor.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aae50329e6b702c23a2e6e21f0eb673a0bca5aa9386fb9eb4ea6e9acdcb05536
3
+ size 613852
assets/audio/wood_hammer_01.ogg ADDED
Binary file (14.2 kB). View file
 
assets/audio/wood_hit_03.ogg ADDED
Binary file (16.6 kB). View file
 
assets/background/CourtRoom.png ADDED

Git LFS Details

  • SHA256: 0ba5b3dc8f5a36d5a3c10b38e86ec7e28938acf577add34246fb47af1b0e31d6
  • Pointer size: 132 Bytes
  • Size of remote file: 2.73 MB
assets/book/README.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docket Book Assets
2
+
3
+ These project-bound UI prop assets were generated with the built-in Codex image generation tool, then processed locally from a chroma-key background to transparent PNGs.
4
+
5
+ - `docket-book-open.png`: open docket book used before the trial starts.
6
+ - `docket-book-closed.png`: closed docket book used after the trial begins.
7
+ - `docket-book-open-keyed.png`: preserved chroma-key source.
8
+ - `docket-book-closed-keyed.png`: preserved chroma-key source.
9
+
10
+ Generation prompt summary:
11
+
12
+ - Antique legal docket book, warm parchment or dark leather, gold corner protectors, polished painterly game UI prop, centered with generous padding.
13
+ - No text, no logos, no watermark, no hands, no pen.
14
+ - Generated on a flat `#00ff00` background for local alpha extraction.
assets/book/docket-book-closed-keyed.png ADDED

Git LFS Details

  • SHA256: fe37e1711a12955aa6a1fce806160a33f225d6eaeeca7c03621ad33ecd3fa0a8
  • Pointer size: 132 Bytes
  • Size of remote file: 2.27 MB
assets/book/docket-book-closed.png ADDED

Git LFS Details

  • SHA256: c8262488326fae3161bf2981cb808d6c894c2ef51852d90cce150475fa3130f7
  • Pointer size: 132 Bytes
  • Size of remote file: 1.68 MB
assets/book/docket-book-open-keyed.png ADDED

Git LFS Details

  • SHA256: 5d5718ab59300553bf95bddc24e381f386b1ee23aea48d1b8d9159291e387aff
  • Pointer size: 132 Bytes
  • Size of remote file: 2.43 MB
assets/book/docket-book-open.png ADDED

Git LFS Details

  • SHA256: 7d90aa3e29db9b58dd9928de7629481bea0a6ab994477f9de72971e1de496547
  • Pointer size: 132 Bytes
  • Size of remote file: 2.11 MB
assets/characters/cleopatra-vii.png ADDED

Git LFS Details

  • SHA256: 1df2fbeb8bcfed5c9c58f8d2514ba914521f1f23d49d3a356a5b9d2013a778b2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.44 MB
assets/characters/confucius.png ADDED

Git LFS Details

  • SHA256: e236ffdc96aca7a2049a8b500aff163496aba73effd5c9b97e8f56ead9e9ebdf
  • Pointer size: 132 Bytes
  • Size of remote file: 1.44 MB
assets/characters/jensen-huang.png ADDED

Git LFS Details

  • SHA256: 295ec10b5eb6ea569283c8ba43259b83490cf0e80dd7011495e797a7dfa19ca3
  • Pointer size: 132 Bytes
  • Size of remote file: 1.35 MB
assets/characters/john-stuart-mill.png ADDED

Git LFS Details

  • SHA256: a56b20ed6931d387e3aa7228fd52daefac2010540d1c55dcabaca45771761f50
  • Pointer size: 132 Bytes
  • Size of remote file: 1.18 MB
assets/characters/karl-marx.png ADDED

Git LFS Details

  • SHA256: ce73bb67347d37b86a730ca7587b0c858803dbacbcdeec7d428d2680db130f28
  • Pointer size: 132 Bytes
  • Size of remote file: 1.31 MB
assets/characters/marcus-aurelius.png ADDED

Git LFS Details

  • SHA256: d87605fff884d942918b9a16c2c79456d132bb115026a080312fae90659a812e
  • Pointer size: 132 Bytes
  • Size of remote file: 1.27 MB
assets/characters/niccolo-machiavelli.png ADDED

Git LFS Details

  • SHA256: 734bb0679951cf04028384b3e1f560bfb6965f8284bd1b4ace5829642a870706
  • Pointer size: 132 Bytes
  • Size of remote file: 1.29 MB
assets/characters/sources/cleopatra-vii-chroma.png ADDED

Git LFS Details

  • SHA256: 5b98d96379e71b0e50b102fe8122523f588e91ef957867a9030b0e213b7dd4ca
  • Pointer size: 132 Bytes
  • Size of remote file: 1.87 MB
assets/characters/sources/confucius-chroma.png ADDED

Git LFS Details

  • SHA256: 2cbdb9b4e6b6f24f9ed18accf7d959e5723aa2cbd4f58448322dcfd27fdfada6
  • Pointer size: 132 Bytes
  • Size of remote file: 1.87 MB
assets/characters/sources/jensen-huang-chroma.png ADDED

Git LFS Details

  • SHA256: 657fe8e6e72ee067e2ae52f340af407b963b823a2a21d8aa9661482ff1eb9664
  • Pointer size: 132 Bytes
  • Size of remote file: 1.81 MB
assets/characters/sources/john-stuart-mill-chroma.png ADDED

Git LFS Details

  • SHA256: cb38a0cdb066776a528400a3bdd4b90302e73680c5708a4a5de2ba0cde14fb5d
  • Pointer size: 132 Bytes
  • Size of remote file: 1.68 MB
assets/characters/sources/karl-marx-chroma.png ADDED

Git LFS Details

  • SHA256: f569950d6edbe2175ca8db4caebf90fdcb399b06c96616d18f003dfb8cbcbbb8
  • Pointer size: 132 Bytes
  • Size of remote file: 1.76 MB
assets/characters/sources/marcus-aurelius-chroma.png ADDED

Git LFS Details

  • SHA256: 8cbbc1bfbf769394bad536e4d57f5c0a24b06f6a2aebc30839bb04b0ed446375
  • Pointer size: 132 Bytes
  • Size of remote file: 1.75 MB
assets/characters/sources/niccolo-machiavelli-chroma.png ADDED

Git LFS Details

  • SHA256: 76ef5b62217064ab9eab67d6729e61c94ac870ee447863f6b6f30612de6e63e8
  • Pointer size: 132 Bytes
  • Size of remote file: 1.71 MB
assets/courtroom-dickinson.jpg ADDED

Git LFS Details

  • SHA256: 9783920c38641d8a7a3c900f258cb22e844c0694a0578b186d411daed4cb109e
  • Pointer size: 132 Bytes
  • Size of remote file: 5.67 MB
assets/foreground/JudgeTable.png ADDED

Git LFS Details

  • SHA256: 699184ad0574c3da0846536964c6ed703f76e78409da1539c2d6bc4a048cee6b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.32 MB
assets/foreground/foregroundFence.png ADDED

Git LFS Details

  • SHA256: f2c7553f88852c89f504973da6094ba09e1e5d322daacbdea283dafe1bffefa1
  • Pointer size: 132 Bytes
  • Size of remote file: 1.41 MB
data/README.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Sovereign Bench Agent Trace Sample
2
+
3
+ This sample contains compact phase-level trace rows from the cached Barnaby Buttons trial. Runtime traces exported by the Gradio app include the full structured `TrialEvent` objects.
4
+
5
+ The trace is synthetic and intended for hackathon demonstration, reproducibility, and UI testing.
data/agent_trace_sample.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "phase": "intake",
4
+ "case_id": "barnaby",
5
+ "agent": "Clerk Meridian",
6
+ "model": "openbmb/AgentCPM-Explore",
7
+ "summary": "Opened The People v. Barnaby Buttons and recorded source provenance for cached demo reliability."
8
+ },
9
+ {
10
+ "phase": "evidence",
11
+ "case_id": "barnaby",
12
+ "agent": "Auditor Prism",
13
+ "model": "nvidia/Nemotron-Orchestrator-8B",
14
+ "summary": "Scored ledger ink, crumb trail, calendar motive, and biscuit alibi as directional evidence with uncertainty."
15
+ },
16
+ {
17
+ "phase": "verdict",
18
+ "case_id": "barnaby",
19
+ "agent": "Marcus Aurelius",
20
+ "model": "openai/gpt-oss-20b",
21
+ "summary": "Issued a narrow claimant finding with cited evidence IDs and an explicit uncertainty warning."
22
+ }
23
+ ]
modal_app.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+
4
+ import modal
5
+
6
+ from sovereign_bench.engine import stream_trial_jsonl
7
+ from sovereign_bench.llm import (
8
+ ModelCall,
9
+ ModelResult,
10
+ build_role_messages,
11
+ messages_hash,
12
+ )
13
+ from sovereign_bench.models import TrialRequest
14
+
15
+ app = modal.App("sovereign-bench")
16
+ GPU_NAME = "H100"
17
+ GPU_TIMEOUT_SECONDS = 20 * 60
18
+ HF_CACHE_DIR = "/root/.cache/huggingface"
19
+
20
+ image = (
21
+ modal.Image.debian_slim(python_version="3.12")
22
+ .pip_install("fastapi", "huggingface_hub", "httpx", "pydantic")
23
+ .add_local_dir("sovereign_bench", remote_path="/root/sovereign_bench")
24
+ )
25
+
26
+ model_cache = modal.Volume.from_name("sovereign-bench-model-cache", create_if_missing=True)
27
+
28
+ vllm_image = (
29
+ modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu22.04", add_python="3.12")
30
+ .entrypoint([])
31
+ .uv_pip_install(
32
+ "vllm==0.18.1",
33
+ "huggingface_hub[hf_transfer]==0.36.0",
34
+ "transformers",
35
+ "httpx",
36
+ "pydantic",
37
+ )
38
+ .env(
39
+ {
40
+ "HF_HUB_ENABLE_HF_TRANSFER": "1",
41
+ "HF_HOME": HF_CACHE_DIR,
42
+ "VLLM_WORKER_MULTIPROC_METHOD": "spawn",
43
+ "VLLM_USE_FLASHINFER_MOE_MXFP4_MXFP8": "1",
44
+ }
45
+ )
46
+ .add_local_dir("sovereign_bench", remote_path="/root/sovereign_bench")
47
+ )
48
+
49
+
50
+ @app.cls(
51
+ image=vllm_image,
52
+ gpu=GPU_NAME,
53
+ secrets=[modal.Secret.from_name("huggingface")],
54
+ volumes={HF_CACHE_DIR: model_cache},
55
+ timeout=GPU_TIMEOUT_SECONDS,
56
+ scaledown_window=10 * 60,
57
+ max_containers=3,
58
+ )
59
+ class VllmModel:
60
+ model_id: str = modal.parameter()
61
+
62
+ @modal.enter()
63
+ def load(self) -> None:
64
+ from vllm import LLM, SamplingParams
65
+
66
+ self.SamplingParams = SamplingParams
67
+ self.llm = LLM(
68
+ model=self.model_id,
69
+ trust_remote_code=True,
70
+ max_model_len=4096,
71
+ gpu_memory_utilization=0.9,
72
+ )
73
+
74
+ @modal.method()
75
+ def generate(self, payload: dict) -> dict:
76
+ from sovereign_bench.llm import ModelCallError, clean_model_text
77
+
78
+ started = time.perf_counter()
79
+ messages = payload["messages"]
80
+ max_tokens = int(payload.get("max_tokens") or 120)
81
+ temperature = float(payload.get("temperature") or 0.45)
82
+ sampling_params = self.SamplingParams(
83
+ max_tokens=max_tokens,
84
+ temperature=temperature,
85
+ top_p=0.9,
86
+ )
87
+ retry_messages = messages + [
88
+ {
89
+ "role": "user",
90
+ "content": (
91
+ "Your previous response did not include visible courtroom dialogue. "
92
+ "Return only the final spoken dialogue now. Do not include <think>, analysis, reasoning, markdown, or notes. /no_think"
93
+ ),
94
+ }
95
+ ]
96
+ last_error: Exception | None = None
97
+ text = ""
98
+ for attempt_messages in (messages, retry_messages):
99
+ outputs = self.llm.chat(
100
+ [attempt_messages],
101
+ sampling_params=sampling_params,
102
+ use_tqdm=False,
103
+ chat_template_kwargs={"enable_thinking": False},
104
+ )
105
+ raw_text = outputs[0].outputs[0].text.strip()
106
+ try:
107
+ text = clean_model_text(raw_text)
108
+ break
109
+ except ModelCallError as exc:
110
+ last_error = exc
111
+ if not text and last_error:
112
+ raise last_error
113
+ return {
114
+ "text": text,
115
+ "latency_ms": int((time.perf_counter() - started) * 1000),
116
+ }
117
+
118
+
119
+ def modal_gpu_enabled() -> bool:
120
+ return os.getenv("SOVEREIGN_DISABLE_MODAL_GPU", "").lower() not in {"1", "true", "yes"}
121
+
122
+
123
+ def modal_gpu_runner(**kwargs) -> ModelResult:
124
+ messages = build_role_messages(
125
+ agent=kwargs["agent"],
126
+ role=kwargs["role"],
127
+ case_summary=kwargs["case_summary"],
128
+ task=kwargs["task"],
129
+ evidence_summary=kwargs["evidence_summary"],
130
+ )
131
+ requested_model = kwargs["model"]
132
+ prompt_hash = messages_hash(messages)
133
+
134
+ if modal_gpu_enabled():
135
+ output = VllmModel(model_id=requested_model).generate.remote(
136
+ {
137
+ "messages": messages,
138
+ "max_tokens": kwargs.get("max_tokens", 120),
139
+ "temperature": 0.45,
140
+ }
141
+ )
142
+ return ModelResult(
143
+ text=output["text"],
144
+ input_text="\n\n".join(f"{item.get('role', 'user').upper()}:\n{item.get('content', '')}" for item in messages)
145
+ + "\n\nASSISTANT:\n",
146
+ call=ModelCall(
147
+ model=requested_model,
148
+ provider="modal-gpu-vllm",
149
+ ok=True,
150
+ latency_ms=output["latency_ms"],
151
+ prompt_hash=prompt_hash,
152
+ requested_model=requested_model,
153
+ runtime="modal-gpu-vllm",
154
+ gpu=GPU_NAME,
155
+ ),
156
+ )
157
+
158
+ raise RuntimeError("Modal GPU is disabled; no provider fallback is allowed.")
159
+
160
+
161
+ @app.function(image=image, secrets=[modal.Secret.from_name("huggingface")])
162
+ def check_huggingface_connection() -> str:
163
+ token = os.getenv("HF_TOKEN")
164
+ if not token:
165
+ return "HF_TOKEN is not available inside Modal."
166
+
167
+ from huggingface_hub import HfApi
168
+
169
+ user = HfApi(token=token).whoami()["name"]
170
+ return f"Connected to Hugging Face as {user}."
171
+
172
+
173
+ @app.function(
174
+ image=image,
175
+ secrets=[modal.Secret.from_name("huggingface")],
176
+ min_containers=1,
177
+ timeout=GPU_TIMEOUT_SECONDS,
178
+ )
179
+ @modal.fastapi_endpoint(method="POST", label="trial-stream")
180
+ def trial_stream(payload: dict):
181
+ from fastapi.responses import StreamingResponse
182
+
183
+ request = TrialRequest.model_validate(payload)
184
+ delay = {"swift": 0.02, "measured": 0.12, "ceremonial": 0.25}[request.speed]
185
+ return StreamingResponse(
186
+ stream_trial_jsonl(request, delay=delay, model_runner=modal_gpu_runner),
187
+ media_type="application/x-ndjson",
188
+ )
189
+
190
+
191
+ @app.local_entrypoint()
192
+ def main():
193
+ print(check_huggingface_connection.remote())
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio
2
+ huggingface_hub
3
+ httpx
4
+ modal
5
+ pydantic
6
+ pytest
7
+ python-dotenv
sovereign_bench/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Sovereign Bench trial engine package."""
2
+
3
+ from .engine import run_trial, stream_trial
4
+ from .models import TrialRequest
5
+
6
+ __all__ = ["TrialRequest", "run_trial", "stream_trial"]
sovereign_bench/cases.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .models import CasePacket, EvidenceItem
4
+
5
+
6
+ SOCRATES = CasePacket(
7
+ id="socrates",
8
+ title="The Polis v. Socrates",
9
+ subtitle="A miniature retrial of impiety, civic anxiety, and troublesome questions.",
10
+ claimant="The Athenian Polis",
11
+ respondent="Socrates",
12
+ charge="Corrupting the youth and refusing the sanctioned gods of the city.",
13
+ setting="Athens, 399 BCE, reassembled inside a pocket tribunal.",
14
+ claimant_claim=(
15
+ "The city argues that Socrates trained young citizens to mock public authority "
16
+ "and placed private daimonion guidance above civic religion."
17
+ ),
18
+ respondent_claim=(
19
+ "Socrates answers that cross-examination was a public service, not corruption, "
20
+ "and that unpopular inquiry should not be confused with civic sabotage."
21
+ ),
22
+ source_note=(
23
+ "Cached public-domain style packet derived from Plato's Apology and Crito, "
24
+ "Xenophon's Apology, and common historical summaries. It is not a live scholarly edition."
25
+ ),
26
+ evidence=[
27
+ EvidenceItem(
28
+ id="SOC-E1",
29
+ title="The Oracle Burden",
30
+ source="Plato, Apology tradition",
31
+ excerpt=(
32
+ "Socrates describes testing reputedly wise citizens after a Delphic oracle "
33
+ "report, creating public embarrassment but framing the act as duty."
34
+ ),
35
+ supports="mixed",
36
+ reliability=0.78,
37
+ note="Shows both civic irritation and a claimed religious motivation.",
38
+ ),
39
+ EvidenceItem(
40
+ id="SOC-E2",
41
+ title="Youthful Imitators",
42
+ source="Plato, Apology tradition",
43
+ excerpt=(
44
+ "Young men with leisure reportedly followed Socrates and copied his questioning, "
45
+ "which angered the questioned citizens."
46
+ ),
47
+ supports="claimant",
48
+ reliability=0.68,
49
+ note="Supports social effect, but does not prove intentional corruption.",
50
+ ),
51
+ EvidenceItem(
52
+ id="SOC-E3",
53
+ title="No Fee, No School",
54
+ source="Ancient defense tradition",
55
+ excerpt=(
56
+ "Socrates distinguishes himself from paid teachers and denies promising technical "
57
+ "instruction or private doctrine."
58
+ ),
59
+ supports="respondent",
60
+ reliability=0.72,
61
+ note="Weakens the claim that he operated a formal corrupting academy.",
62
+ ),
63
+ EvidenceItem(
64
+ id="SOC-E4",
65
+ title="The Daimonion",
66
+ source="Ancient biographical tradition",
67
+ excerpt=(
68
+ "Socrates reports a private divine sign that restrains him from certain actions, "
69
+ "which the court may read as piety or heterodoxy."
70
+ ),
71
+ supports="mixed",
72
+ reliability=0.64,
73
+ note="Central ambiguity: private religious experience versus civic irreverence.",
74
+ ),
75
+ ],
76
+ )
77
+
78
+
79
+ BARNABY = CasePacket(
80
+ id="barnaby",
81
+ title="The People v. Barnaby Buttons",
82
+ subtitle="The last office mooncake, a tampered snack ledger, and crumbs shaped like intent.",
83
+ claimant="The Breakroom Commonwealth",
84
+ respondent="Barnaby Buttons",
85
+ charge="Theft of the final mooncake and alteration of the communal snack ledger.",
86
+ setting="A fluorescent office kitchen at 4:47 p.m., under the humming republic of the fridge.",
87
+ claimant_claim=(
88
+ "Barnaby removed the final mooncake, changed the snack ledger from '1 mooncake' "
89
+ "to '0 mooncakes', and left the team dessertless."
90
+ ),
91
+ respondent_claim=(
92
+ "Barnaby says the mooncake was already abandoned, the ledger pen skipped naturally, "
93
+ "and the crumbs came from an unrelated biscuit."
94
+ ),
95
+ source_note="Cached original whimsical packet made for reliable hackathon demos.",
96
+ evidence=[
97
+ EvidenceItem(
98
+ id="BTN-E1",
99
+ title="Ledger Ink Discontinuity",
100
+ source="Clerk's magnifying loupe",
101
+ excerpt="The zero in '0 mooncakes' uses a darker ink than the previous entries.",
102
+ supports="claimant",
103
+ reliability=0.82,
104
+ note="Strong tampering indicator, though pen swaps happen in offices.",
105
+ ),
106
+ EvidenceItem(
107
+ id="BTN-E2",
108
+ title="Crumb Constellation",
109
+ source="Breakroom floor survey",
110
+ excerpt="Sesame crumbs form a trail from the pantry shelf to Barnaby's keyboard.",
111
+ supports="claimant",
112
+ reliability=0.71,
113
+ note="Suggestive route evidence, vulnerable to shared-desk contamination.",
114
+ ),
115
+ EvidenceItem(
116
+ id="BTN-E3",
117
+ title="Calendar Entry",
118
+ source="Respondent's calendar",
119
+ excerpt="Barnaby had a 4:45 p.m. reminder titled 'Do not forget tea with lunar pastry'.",
120
+ supports="mixed",
121
+ reliability=0.76,
122
+ note="Shows desire and opportunity, but not necessarily theft.",
123
+ ),
124
+ EvidenceItem(
125
+ id="BTN-E4",
126
+ title="Biscuit Alibi",
127
+ source="Vending machine receipt",
128
+ excerpt="A receipt shows Barnaby bought a sesame biscuit at 4:39 p.m.",
129
+ supports="respondent",
130
+ reliability=0.67,
131
+ note="Explains crumbs but not ledger alteration.",
132
+ ),
133
+ ],
134
+ )
135
+
136
+
137
+ CASES = {case.id: case for case in (SOCRATES, BARNABY)}
138
+
139
+
140
+ def get_case(case_id: str) -> CasePacket:
141
+ return CASES.get(case_id, SOCRATES)
sovereign_bench/engine.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from collections import Counter
7
+ from collections.abc import Callable, Iterable
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from .cases import get_case
12
+ from .llm import ModelCall, ModelResult, call_small_model
13
+ from .models import AgentTurn, CasePacket, JurorVote, TrialEvent, TrialRequest, Verdict
14
+ from .retrieval import build_live_case
15
+
16
+ GPT_OSS_MODEL = "openai/gpt-oss-20b"
17
+ OPENBMB_MODEL = "openbmb/AgentCPM-Explore"
18
+ NEMOTRON_MODEL = "nvidia/Nemotron-Orchestrator-8B"
19
+ OPENAI_PROVIDER = "auto"
20
+ OPENBMB_PROVIDER = "featherless-ai"
21
+ NEMOTRON_PROVIDER = "featherless-ai"
22
+
23
+ MODEL_BUDGET = [
24
+ ("Presiding Advocate", GPT_OSS_MODEL, 20.0),
25
+ ("Clerk of Style", OPENBMB_MODEL, 4.0),
26
+ ("Juror/Auditor Ring", NEMOTRON_MODEL, 8.0),
27
+ ]
28
+ TOTAL_PARAMS_B = sum(item[2] for item in MODEL_BUDGET)
29
+
30
+ JUDGE_NAME = "Marcus Aurelius"
31
+ JUDGE_PERSONA = "Stoic duty, restraint, public reason, and disciplined judgment"
32
+
33
+ JUROR_PERSONAS = {
34
+ "Karl Marx": "class power, material conditions, exploitation, institutional incentives",
35
+ "John Stuart Mill": "liberty, harm principle, utility, individual rights",
36
+ "Confucius": "social harmony, role duty, ritual order, moral cultivation",
37
+ "Cleopatra VII": "sovereign pragmatism, diplomacy, survival, legitimacy under pressure",
38
+ "Niccolo Machiavelli": "political realism, stability, power, consequences over ideals",
39
+ "Jensen Huang": "technological optimism, operator mindset, systems thinking, innovation tradeoffs",
40
+ }
41
+ JUROR_NAMES = list(JUROR_PERSONAS)
42
+
43
+
44
+ class RequiredModelError(RuntimeError):
45
+ """Raised when a required courtroom model call cannot produce usable output."""
46
+
47
+
48
+ ModelRunner = Callable[..., ModelResult]
49
+
50
+
51
+ def _turn(agent: str, role: str, result: ModelResult, model: str, confidence: float) -> AgentTurn:
52
+ return AgentTurn(
53
+ agent=agent,
54
+ role=role,
55
+ content=result.text,
56
+ model=model,
57
+ confidence=confidence,
58
+ input=getattr(result, "input_text", ""),
59
+ )
60
+
61
+
62
+ def _case_summary(packet: CasePacket) -> str:
63
+ return (
64
+ f"{packet.title}. Charge: {packet.charge}\n"
65
+ f"Claimant: {packet.claimant_claim}\n"
66
+ f"Respondent: {packet.respondent_claim}"
67
+ )
68
+
69
+
70
+ def _evidence_summary(packet: CasePacket) -> str:
71
+ return "\n".join(
72
+ f"{item.id}: {item.title}; direction={item.supports}; reliability={item.reliability:.2f}; note={item.note}"
73
+ for item in packet.evidence
74
+ )
75
+
76
+
77
+ def _call_trace(calls: list[ModelCall]) -> list[dict]:
78
+ return [call.__dict__ for call in calls]
79
+
80
+
81
+ def resolve_case(request: TrialRequest) -> tuple[CasePacket, dict]:
82
+ if request.case_id == "live":
83
+ packet = build_live_case(request.search_query, request.hypothetical)
84
+ if packet:
85
+ return packet, {"mode": "live"}
86
+ raise RuntimeError("Live retrieval produced too little usable evidence; no fallback case will be substituted.")
87
+ return get_case(request.case_id), {"mode": "cached"}
88
+
89
+
90
+ def _generate_role(model_runner: ModelRunner | None = None, **kwargs) -> ModelResult:
91
+ if model_runner is not None:
92
+ return model_runner(**kwargs)
93
+ return call_small_model(**kwargs)
94
+
95
+
96
+ def _required_role(model_runner: ModelRunner | None, model_calls: list[ModelCall], **kwargs) -> ModelResult:
97
+ try:
98
+ result = _generate_role(model_runner, **kwargs)
99
+ except Exception as exc:
100
+ raise RequiredModelError(f"{kwargs.get('agent', 'Model')} unavailable: {exc}") from exc
101
+ model_calls.append(result.call)
102
+ if not result.call.ok:
103
+ error = result.call.error or "model call did not complete"
104
+ raise RequiredModelError(f"{kwargs.get('agent', 'Model')} unavailable: {error}")
105
+ if not result.text.strip():
106
+ raise RequiredModelError(f"{kwargs.get('agent', 'Model')} returned an empty response.")
107
+ return result
108
+
109
+
110
+ def _trace(packet: CasePacket, source_trace: dict, model_calls: list[ModelCall]) -> dict:
111
+ return {
112
+ "case_id": packet.id,
113
+ "model_budget_b": TOTAL_PARAMS_B,
114
+ "models": [{"role": role, "model": model, "params_b": params} for role, model, params in MODEL_BUDGET],
115
+ "model_calls": _call_trace(model_calls),
116
+ "live_model_call_count": sum(1 for call in model_calls if call.ok),
117
+ "attempted_model_call_count": len(model_calls),
118
+ **source_trace,
119
+ }
120
+
121
+
122
+ def _emit(
123
+ packet: CasePacket,
124
+ source_trace: dict,
125
+ model_calls: list[ModelCall],
126
+ event: TrialEvent,
127
+ delay: float,
128
+ ) -> TrialEvent:
129
+ event.trace = _trace(packet, source_trace, model_calls)
130
+ if delay > 0:
131
+ time.sleep(delay)
132
+ return event
133
+
134
+
135
+ def _extract_json(text: str) -> object:
136
+ stripped = text.strip()
137
+ if stripped.startswith("```"):
138
+ stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.I)
139
+ stripped = re.sub(r"\s*```$", "", stripped)
140
+ try:
141
+ return json.loads(stripped)
142
+ except json.JSONDecodeError:
143
+ match = re.search(r"(\{.*\}|\[.*\])", stripped, flags=re.S)
144
+ if not match:
145
+ raise
146
+ return json.loads(match.group(1))
147
+
148
+
149
+ def _parse_jury_votes(result: ModelResult, packet: CasePacket) -> list[JurorVote]:
150
+ try:
151
+ data = _extract_json(result.text)
152
+ except json.JSONDecodeError as exc:
153
+ raise RequiredModelError(f"Nemotron Jury returned invalid JSON: {exc.msg}") from exc
154
+
155
+ raw_votes = data.get("votes") if isinstance(data, dict) else data
156
+ if not isinstance(raw_votes, list):
157
+ raise RequiredModelError("Nemotron Jury output must contain a votes list.")
158
+ if len(raw_votes) != len(JUROR_NAMES):
159
+ raise RequiredModelError("Nemotron Jury must return exactly six juror votes.")
160
+
161
+ known_evidence = {item.id for item in packet.evidence}
162
+ votes: list[JurorVote] = []
163
+ try:
164
+ for item in raw_votes:
165
+ vote = JurorVote.model_validate(item)
166
+ votes.append(vote)
167
+ except ValidationError as exc:
168
+ raise RequiredModelError(f"Nemotron Jury vote schema is invalid: {exc.errors()[0]['msg']}") from exc
169
+
170
+ if [vote.juror for vote in votes] != JUROR_NAMES:
171
+ raise RequiredModelError("Nemotron Jury must return votes in the fixed juror order.")
172
+ for vote in votes:
173
+ expected_persona = JUROR_PERSONAS[vote.juror]
174
+ if vote.persona.strip().lower() != expected_persona:
175
+ raise RequiredModelError(f"{vote.juror} persona must be '{expected_persona}'.")
176
+ if not vote.reason.strip():
177
+ raise RequiredModelError(f"{vote.juror} must include a rationale.")
178
+ if not vote.evidence_ids or any(evidence_id not in known_evidence for evidence_id in vote.evidence_ids):
179
+ raise RequiredModelError(f"{vote.juror} must cite known evidence IDs.")
180
+ return votes
181
+
182
+
183
+ def _majority_finding(votes: list[JurorVote]) -> str:
184
+ counts = Counter(vote.vote for vote in votes)
185
+ top = counts.most_common()
186
+ if not top:
187
+ return "uncertain"
188
+ if len(top) > 1 and top[0][1] == top[1][1]:
189
+ return "mixed"
190
+ if top[0][0] == "uncertain":
191
+ return "uncertain"
192
+ return top[0][0]
193
+
194
+
195
+ def _verdict_from_votes(votes: list[JurorVote]) -> Verdict:
196
+ finding = _majority_finding(votes)
197
+ evidence_ids = []
198
+ for vote in votes:
199
+ for evidence_id in vote.evidence_ids:
200
+ if evidence_id not in evidence_ids:
201
+ evidence_ids.append(evidence_id)
202
+ cited = evidence_ids[:4]
203
+ counts = Counter(vote.vote for vote in votes)
204
+ vote_line = ", ".join(f"{name}: {counts.get(name, 0)}" for name in ("liable", "not_liable", "uncertain"))
205
+ decree_by_finding = {
206
+ "liable": "The jury majority finds liability on the miniature record.",
207
+ "not_liable": "The jury majority does not find liability on the miniature record.",
208
+ "mixed": "The jury divides too closely for a clean finding.",
209
+ "uncertain": "The jury leaves the court with unresolved uncertainty.",
210
+ }
211
+ remedy_by_finding = {
212
+ "liable": "Enter symbolic censure and proportional repair.",
213
+ "not_liable": "Dismiss without prejudice to stronger proof.",
214
+ "mixed": "Record a divided result and preserve the exhibits for later review.",
215
+ "uncertain": "Withhold sanction and identify the proof gaps before any retrial.",
216
+ }
217
+ return Verdict(
218
+ finding=finding, # type: ignore[arg-type]
219
+ decree=decree_by_finding[finding],
220
+ rationale=f"Jury vote: {vote_line}. Cited evidence IDs: {', '.join(cited)}.",
221
+ evidence_ids=cited,
222
+ uncertainty=(
223
+ "Uncertainty remains visible: this is an AI-native miniature trial. Retrieved facts, cached "
224
+ "packets, and model inferences are separated in the trace and should not be treated as legal advice."
225
+ ),
226
+ remedy=remedy_by_finding[finding],
227
+ )
228
+
229
+
230
+ def _jury_task() -> str:
231
+ personas = "\n".join(f"- {name}: {persona}" for name, persona in JUROR_PERSONAS.items())
232
+ return (
233
+ "Return JSON only with a top-level 'votes' array. Create exactly one vote for each juror, in this order: "
234
+ f"{', '.join(JUROR_NAMES)}. Valid vote values are liable, not_liable, uncertain. Each item must contain "
235
+ "juror, persona, vote, reason, and evidence_ids. The persona value must exactly match the profile below. "
236
+ "Each reason should be one concise sentence and each evidence_ids list must cite evidence IDs from the record. "
237
+ "Vote through the named public-history worldview, not a generic juror role.\n"
238
+ f"{personas}"
239
+ )
240
+
241
+
242
+ def run_trial(request: TrialRequest, model_runner: ModelRunner | None = None) -> list[TrialEvent]:
243
+ return list(stream_trial(request, delay=0.0, model_runner=model_runner))
244
+
245
+
246
+ def stream_trial(
247
+ request: TrialRequest,
248
+ delay: float = 0.0,
249
+ model_runner: ModelRunner | None = None,
250
+ ) -> Iterable[TrialEvent]:
251
+ packet, source_trace = resolve_case(request)
252
+ case_summary = _case_summary(packet)
253
+ evidence_summary = _evidence_summary(packet)
254
+ model_calls: list[ModelCall] = []
255
+ hypo = request.hypothetical.strip()
256
+ hypo_line = f"\n\nUser hypothetical admitted as a blue-ribbon sidebar: {hypo}" if hypo else ""
257
+
258
+ clerk = _required_role(
259
+ model_runner,
260
+ model_calls,
261
+ agent="Clerk Meridian",
262
+ role="clerk",
263
+ model=OPENBMB_MODEL,
264
+ case_summary=case_summary,
265
+ evidence_summary=evidence_summary,
266
+ task="Announce the case by name, identify the parties, and read the charge.",
267
+ provider=OPENBMB_PROVIDER,
268
+ max_tokens=110,
269
+ )
270
+ yield _emit(
271
+ packet,
272
+ source_trace,
273
+ model_calls,
274
+ TrialEvent(
275
+ phase="intake",
276
+ title="The Court Convenes",
277
+ body=f"{packet.title}\n{packet.subtitle}\n\nCharge: {packet.charge}{hypo_line}",
278
+ turns=[_turn("Clerk Meridian", "clerk", clerk, OPENBMB_MODEL, 0.88)],
279
+ evidence=packet.evidence,
280
+ ),
281
+ delay,
282
+ )
283
+
284
+ judge_open = _required_role(
285
+ model_runner,
286
+ model_calls,
287
+ agent=JUDGE_NAME,
288
+ role="judge",
289
+ model=GPT_OSS_MODEL,
290
+ case_summary=case_summary,
291
+ evidence_summary=evidence_summary,
292
+ task=(
293
+ f"As {JUDGE_NAME}, a Stoic courtroom judge guided by {JUDGE_PERSONA}, explain the proceeding "
294
+ "and the burden of proof in one or two disciplined sentences."
295
+ ),
296
+ provider=OPENAI_PROVIDER,
297
+ max_tokens=110,
298
+ )
299
+ yield _emit(
300
+ packet,
301
+ source_trace,
302
+ model_calls,
303
+ TrialEvent(
304
+ phase="intake",
305
+ title="The Burden Is Set",
306
+ body="The bench defines how the miniature court will weigh the record.",
307
+ turns=[_turn(JUDGE_NAME, "judge", judge_open, GPT_OSS_MODEL, 0.88)],
308
+ evidence=packet.evidence,
309
+ ),
310
+ delay,
311
+ )
312
+
313
+ claimant_opening = _required_role(
314
+ model_runner,
315
+ model_calls,
316
+ agent="Advocate Auric",
317
+ role="claimant advocate",
318
+ model=GPT_OSS_MODEL,
319
+ case_summary=case_summary,
320
+ evidence_summary=evidence_summary,
321
+ task="Make the claimant's opening statement alone. Cite the strongest claimant-side exhibit.",
322
+ provider=OPENAI_PROVIDER,
323
+ max_tokens=130,
324
+ )
325
+ yield _emit(
326
+ packet,
327
+ source_trace,
328
+ model_calls,
329
+ TrialEvent(
330
+ phase="claims",
331
+ title="Claimant Opening",
332
+ body=packet.claimant_claim,
333
+ turns=[_turn("Advocate Auric", "claimant advocate", claimant_opening, GPT_OSS_MODEL, 0.88)],
334
+ evidence=packet.evidence,
335
+ ),
336
+ delay,
337
+ )
338
+
339
+ respondent_opening = _required_role(
340
+ model_runner,
341
+ model_calls,
342
+ agent="Counsel Sable",
343
+ role="respondent advocate",
344
+ model=GPT_OSS_MODEL,
345
+ case_summary=case_summary,
346
+ evidence_summary=evidence_summary,
347
+ task="Make the respondent's opening statement alone. Emphasize uncertainty and cite a helpful exhibit.",
348
+ provider=OPENAI_PROVIDER,
349
+ max_tokens=130,
350
+ )
351
+ yield _emit(
352
+ packet,
353
+ source_trace,
354
+ model_calls,
355
+ TrialEvent(
356
+ phase="opening",
357
+ title="Respondent Opening",
358
+ body=packet.respondent_claim,
359
+ turns=[_turn("Counsel Sable", "respondent advocate", respondent_opening, GPT_OSS_MODEL, 0.88)],
360
+ evidence=packet.evidence,
361
+ ),
362
+ delay,
363
+ )
364
+
365
+ auditor = _required_role(
366
+ model_runner,
367
+ model_calls,
368
+ agent="Auditor Prism",
369
+ role="evidence auditor",
370
+ model=NEMOTRON_MODEL,
371
+ case_summary=case_summary,
372
+ evidence_summary=evidence_summary,
373
+ task="Present the evidence record. Identify the strongest exhibit and the weakest inference.",
374
+ provider=NEMOTRON_PROVIDER,
375
+ max_tokens=150,
376
+ )
377
+ yield _emit(
378
+ packet,
379
+ source_trace,
380
+ model_calls,
381
+ TrialEvent(
382
+ phase="evidence",
383
+ title="The Record Is Audited",
384
+ body="\n".join(f"{item.id}: {item.title} | reliability {item.reliability:.2f} | {item.note}" for item in packet.evidence),
385
+ turns=[_turn("Auditor Prism", "evidence auditor", auditor, NEMOTRON_MODEL, 0.86)],
386
+ evidence=packet.evidence,
387
+ ),
388
+ delay,
389
+ )
390
+
391
+ judge_question = _required_role(
392
+ model_runner,
393
+ model_calls,
394
+ agent=JUDGE_NAME,
395
+ role="judge",
396
+ model=GPT_OSS_MODEL,
397
+ case_summary=case_summary,
398
+ evidence_summary=evidence_summary,
399
+ task=(
400
+ f"As {JUDGE_NAME}, ask one sharp hinge question that would change the outcome if answered. "
401
+ "Use Stoic restraint and public reason."
402
+ ),
403
+ provider=OPENAI_PROVIDER,
404
+ max_tokens=100,
405
+ )
406
+ yield _emit(
407
+ packet,
408
+ source_trace,
409
+ model_calls,
410
+ TrialEvent(
411
+ phase="questions",
412
+ title="The Hinge Question",
413
+ body="The bench asks the single question that could turn the record.",
414
+ turns=[_turn(JUDGE_NAME, "judge", judge_question, GPT_OSS_MODEL, 0.88)],
415
+ evidence=packet.evidence,
416
+ ),
417
+ delay,
418
+ )
419
+
420
+ claimant_answer = _required_role(
421
+ model_runner,
422
+ model_calls,
423
+ agent="Advocate Auric",
424
+ role="claimant advocate",
425
+ model=GPT_OSS_MODEL,
426
+ case_summary=case_summary,
427
+ evidence_summary=evidence_summary,
428
+ task=f"Answer {JUDGE_NAME}'s hinge question for the claimant: {judge_question.text}",
429
+ provider=OPENAI_PROVIDER,
430
+ max_tokens=130,
431
+ )
432
+ yield _emit(
433
+ packet,
434
+ source_trace,
435
+ model_calls,
436
+ TrialEvent(
437
+ phase="questions",
438
+ title="Claimant Answers the Bench",
439
+ body="The claimant answers the hinge question.",
440
+ turns=[_turn("Advocate Auric", "claimant advocate", claimant_answer, GPT_OSS_MODEL, 0.88)],
441
+ evidence=packet.evidence,
442
+ ),
443
+ delay,
444
+ )
445
+
446
+ respondent_answer = _required_role(
447
+ model_runner,
448
+ model_calls,
449
+ agent="Counsel Sable",
450
+ role="respondent advocate",
451
+ model=GPT_OSS_MODEL,
452
+ case_summary=case_summary,
453
+ evidence_summary=evidence_summary,
454
+ task=f"Answer {JUDGE_NAME}'s hinge question for the respondent: {judge_question.text}",
455
+ provider=OPENAI_PROVIDER,
456
+ max_tokens=130,
457
+ )
458
+ yield _emit(
459
+ packet,
460
+ source_trace,
461
+ model_calls,
462
+ TrialEvent(
463
+ phase="questions",
464
+ title="Respondent Answers the Bench",
465
+ body="The respondent answers the hinge question.",
466
+ turns=[_turn("Counsel Sable", "respondent advocate", respondent_answer, GPT_OSS_MODEL, 0.88)],
467
+ evidence=packet.evidence,
468
+ ),
469
+ delay,
470
+ )
471
+
472
+ jury_panel = _required_role(
473
+ model_runner,
474
+ model_calls,
475
+ agent="Nemotron Jury",
476
+ role="juror panel",
477
+ model=NEMOTRON_MODEL,
478
+ case_summary=case_summary,
479
+ evidence_summary=evidence_summary,
480
+ task="Announce that the six named jurors retire to vote. Do not reveal the votes yet.",
481
+ provider=NEMOTRON_PROVIDER,
482
+ max_tokens=100,
483
+ )
484
+ yield _emit(
485
+ packet,
486
+ source_trace,
487
+ model_calls,
488
+ TrialEvent(
489
+ phase="deliberation",
490
+ title="The Jury Retires",
491
+ body="Six fixed-perspective jurors leave the public floor to vote from the record.",
492
+ turns=[_turn("Nemotron Jury", "juror panel", jury_panel, NEMOTRON_MODEL, 0.86)],
493
+ evidence=packet.evidence,
494
+ ),
495
+ delay,
496
+ )
497
+
498
+ jury_votes_result = _required_role(
499
+ model_runner,
500
+ model_calls,
501
+ agent="Nemotron Jury",
502
+ role="juror vote generator",
503
+ model=NEMOTRON_MODEL,
504
+ case_summary=case_summary,
505
+ evidence_summary=evidence_summary,
506
+ task=_jury_task(),
507
+ provider=NEMOTRON_PROVIDER,
508
+ max_tokens=650,
509
+ )
510
+ votes = _parse_jury_votes(jury_votes_result, packet)
511
+ for vote in votes:
512
+ juror_result = ModelResult(
513
+ text=f"{vote.vote.replace('_', ' ').title()}. {vote.reason}",
514
+ call=jury_votes_result.call,
515
+ input_text=jury_votes_result.input_text,
516
+ )
517
+ yield _emit(
518
+ packet,
519
+ source_trace,
520
+ model_calls,
521
+ TrialEvent(
522
+ phase="deliberation",
523
+ title=f"Juror {vote.juror} Votes",
524
+ body=f"{vote.persona}. Evidence: {', '.join(vote.evidence_ids)}.",
525
+ turns=[_turn(vote.juror, "juror", juror_result, NEMOTRON_MODEL, 0.86)],
526
+ votes=[vote],
527
+ evidence=packet.evidence,
528
+ ),
529
+ delay,
530
+ )
531
+
532
+ verdict = _verdict_from_votes(votes)
533
+ verdict_voice = _required_role(
534
+ model_runner,
535
+ model_calls,
536
+ agent=JUDGE_NAME,
537
+ role="verdict writer",
538
+ model=GPT_OSS_MODEL,
539
+ case_summary=case_summary,
540
+ evidence_summary=evidence_summary,
541
+ task=(
542
+ f"As {JUDGE_NAME}, announce the final legal finding after the jury vote with Stoic restraint. "
543
+ f"Finding: {verdict.finding}. "
544
+ f"Jury rationale: {verdict.rationale} Remedy: {verdict.remedy}. Include uncertainty without disclaiming the role."
545
+ ),
546
+ provider=OPENAI_PROVIDER,
547
+ max_tokens=160,
548
+ )
549
+ yield _emit(
550
+ packet,
551
+ source_trace,
552
+ model_calls,
553
+ TrialEvent(
554
+ phase="verdict",
555
+ title="The Court Announces Judgment",
556
+ body=f"{verdict_voice.text}\n\n{verdict.rationale}\n\nRemedy: {verdict.remedy}",
557
+ verdict=verdict,
558
+ votes=votes,
559
+ evidence=packet.evidence,
560
+ turns=[_turn(JUDGE_NAME, "verdict writer", verdict_voice, GPT_OSS_MODEL, 0.88)],
561
+ ),
562
+ delay,
563
+ )
564
+
565
+
566
+ def stream_trial_jsonl(
567
+ request: TrialRequest,
568
+ delay: float = 0.0,
569
+ model_runner: ModelRunner | None = None,
570
+ ) -> Iterable[str]:
571
+ for event in stream_trial(request, delay, model_runner=model_runner):
572
+ yield json.dumps(event.model_dump(), ensure_ascii=True) + "\n"
sovereign_bench/export.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from .models import TrialEvent
8
+
9
+
10
+ def write_trace(events: list[TrialEvent]) -> str:
11
+ path = Path(tempfile.gettempdir()) / "sovereign_bench_trace.json"
12
+ path.write_text(
13
+ json.dumps([event.model_dump() for event in events], indent=2, ensure_ascii=True),
14
+ encoding="utf-8",
15
+ )
16
+ return str(path)
17
+
18
+
19
+ def write_decree(events: list[TrialEvent]) -> str:
20
+ verdict_event = next((event for event in events if event.verdict), events[-1])
21
+ verdict = verdict_event.verdict
22
+ path = Path(tempfile.gettempdir()) / "sovereign_bench_decree.md"
23
+ if verdict is None:
24
+ text = "# Sovereign Bench Decree\n\nNo verdict was recorded."
25
+ else:
26
+ text = (
27
+ "# Sovereign Bench Decree\n\n"
28
+ f"## Finding\n{verdict.finding}\n\n"
29
+ f"## Decree\n{verdict.decree}\n\n"
30
+ f"## Rationale\n{verdict.rationale}\n\n"
31
+ f"## Remedy\n{verdict.remedy}\n\n"
32
+ f"## Uncertainty\n{verdict.uncertainty}\n"
33
+ )
34
+ path.write_text(text, encoding="utf-8")
35
+ return str(path)
sovereign_bench/llm.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass
7
+ from hashlib import sha256
8
+
9
+
10
+ @dataclass
11
+ class ModelCall:
12
+ model: str
13
+ provider: str
14
+ ok: bool
15
+ latency_ms: int
16
+ prompt_hash: str
17
+ error: str | None = None
18
+ requested_model: str | None = None
19
+ runtime: str | None = None
20
+ gpu: str | None = None
21
+
22
+
23
+ @dataclass
24
+ class ModelResult:
25
+ text: str
26
+ call: ModelCall
27
+ input_text: str = ""
28
+
29
+
30
+ class ModelCallError(RuntimeError):
31
+ pass
32
+
33
+
34
+ def _short_error(exc: Exception) -> str:
35
+ message = str(exc).replace("\n", " ").strip()
36
+ return f"{exc.__class__.__name__}: {message[:220]}"
37
+
38
+
39
+ def messages_hash(messages: list[dict[str, str]]) -> str:
40
+ joined = "\n".join(f"{item.get('role', '')}:{item.get('content', '')}" for item in messages)
41
+ return sha256(joined.encode("utf-8")).hexdigest()[:16]
42
+
43
+
44
+ def _prompt_from_messages(messages: list[dict[str, str]]) -> str:
45
+ return "\n\n".join(f"{item.get('role', 'user').upper()}:\n{item.get('content', '')}" for item in messages) + "\n\nASSISTANT:\n"
46
+
47
+
48
+ def _response_text(response: object) -> str:
49
+ choice = response.choices[0] # type: ignore[attr-defined]
50
+ message = choice.message
51
+ for attr in ("content", "reasoning_content", "reasoning"):
52
+ value = getattr(message, attr, None)
53
+ if isinstance(value, str) and value.strip():
54
+ return value.strip()
55
+ if isinstance(value, list):
56
+ pieces = []
57
+ for item in value:
58
+ text = getattr(item, "text", None) or (item.get("text") if isinstance(item, dict) else None)
59
+ if text:
60
+ pieces.append(str(text))
61
+ if pieces:
62
+ return " ".join(pieces).strip()
63
+ if hasattr(message, "model_dump"):
64
+ data = message.model_dump()
65
+ for key in ("content", "reasoning_content", "reasoning"):
66
+ value = data.get(key)
67
+ if isinstance(value, str) and value.strip():
68
+ return value.strip()
69
+ return ""
70
+
71
+
72
+ def clean_model_text(text: str) -> str:
73
+ cleaned = re.sub(r"(?is)<think>.*?</think>", "", text).strip()
74
+ if re.search(r"(?i)<think>", cleaned):
75
+ raise ModelCallError("model returned unclosed hidden reasoning")
76
+ cleaned = re.sub(r"(?is)<analysis>.*?</analysis>", "", cleaned).strip()
77
+ cleaned = re.sub(r"(?is)<reasoning>.*?</reasoning>", "", cleaned).strip()
78
+ cleaned = cleaned.replace("</think>", "").strip()
79
+ if not cleaned:
80
+ raise ModelCallError("model returned no visible output")
81
+ return cleaned
82
+
83
+
84
+ def model_enabled() -> bool:
85
+ return os.getenv("SOVEREIGN_DISABLE_LIVE_MODELS", "").lower() not in {"1", "true", "yes"}
86
+
87
+
88
+ def call_hf_chat_model(
89
+ *,
90
+ model: str,
91
+ messages: list[dict[str, str]],
92
+ provider: str = "auto",
93
+ max_tokens: int = 140,
94
+ temperature: float = 0.45,
95
+ ) -> ModelResult:
96
+ prompt_hash = messages_hash(messages)
97
+ started = time.perf_counter()
98
+ token = os.getenv("HF_TOKEN")
99
+ if not token or not model_enabled():
100
+ raise ModelCallError("HF_TOKEN missing or live models disabled")
101
+
102
+ try:
103
+ from huggingface_hub import InferenceClient
104
+
105
+ client = InferenceClient(model=model, provider=provider, token=token, timeout=45.0)
106
+ retry_messages = messages + [
107
+ {
108
+ "role": "user",
109
+ "content": (
110
+ "Your previous response did not include visible courtroom dialogue. "
111
+ "Return only the final spoken dialogue now. Do not include <think>, analysis, reasoning, markdown, or notes. /no_think"
112
+ ),
113
+ }
114
+ ]
115
+ last_error: Exception | None = None
116
+ text = ""
117
+ for attempt_messages in (messages, retry_messages):
118
+ try:
119
+ response = client.chat_completion(
120
+ messages=attempt_messages,
121
+ max_tokens=max_tokens,
122
+ temperature=temperature,
123
+ top_p=0.9,
124
+ )
125
+ raw_text = _response_text(response)
126
+ except Exception as chat_exc:
127
+ prompt = _prompt_from_messages(attempt_messages)
128
+ generated = client.text_generation(
129
+ prompt,
130
+ max_new_tokens=max_tokens,
131
+ temperature=temperature,
132
+ top_p=0.9,
133
+ return_full_text=False,
134
+ )
135
+ raw_text = str(generated).strip()
136
+ if not raw_text:
137
+ raise chat_exc
138
+ try:
139
+ text = clean_model_text(raw_text)
140
+ break
141
+ except ModelCallError as exc:
142
+ last_error = exc
143
+ if not text:
144
+ raise last_error or RuntimeError("empty model response")
145
+ return ModelResult(
146
+ text=text,
147
+ call=ModelCall(
148
+ model=model,
149
+ provider=provider,
150
+ ok=True,
151
+ latency_ms=int((time.perf_counter() - started) * 1000),
152
+ prompt_hash=prompt_hash,
153
+ ),
154
+ )
155
+ except Exception as exc:
156
+ raise ModelCallError(
157
+ f"{model} via {provider} failed after {int((time.perf_counter() - started) * 1000)}ms: {_short_error(exc)}"
158
+ ) from exc
159
+
160
+
161
+ def call_small_model(
162
+ *,
163
+ agent: str,
164
+ role: str,
165
+ model: str,
166
+ case_summary: str,
167
+ task: str,
168
+ evidence_summary: str,
169
+ provider: str = "auto",
170
+ max_tokens: int = 120,
171
+ ) -> ModelResult:
172
+ messages = build_role_messages(
173
+ agent=agent,
174
+ role=role,
175
+ case_summary=case_summary,
176
+ task=task,
177
+ evidence_summary=evidence_summary,
178
+ )
179
+ result = call_hf_chat_model(
180
+ model=model,
181
+ provider=provider,
182
+ messages=messages,
183
+ max_tokens=max_tokens,
184
+ )
185
+ result.input_text = _prompt_from_messages(messages)
186
+ return result
187
+
188
+
189
+ def build_role_messages(
190
+ *,
191
+ agent: str,
192
+ role: str,
193
+ case_summary: str,
194
+ task: str,
195
+ evidence_summary: str,
196
+ ) -> list[dict[str, str]]:
197
+ system = (
198
+ "You are one AI character in Sovereign Bench, a miniature virtual courtroom. "
199
+ "Write concise courtroom dialogue only. Cite evidence IDs when relevant. "
200
+ "Do not claim certainty beyond the record. Do not add markdown. "
201
+ "Return final spoken dialogue only; never reveal hidden reasoning, analysis, or <think> text. "
202
+ "Do not use thinking mode."
203
+ )
204
+ user = (
205
+ f"Agent: {agent}\nRole: {role}\nCase:\n{case_summary}\n\n"
206
+ f"Evidence:\n{evidence_summary}\n\nTask: {task}\n"
207
+ "Answer in 1-3 sentences, theatrical but clear.\n/no_think"
208
+ )
209
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
sovereign_bench/models.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ TrialPhase = Literal[
9
+ "intake",
10
+ "claims",
11
+ "opening",
12
+ "evidence",
13
+ "questions",
14
+ "deliberation",
15
+ "verdict",
16
+ "appeal",
17
+ ]
18
+
19
+
20
+ class EvidenceItem(BaseModel):
21
+ id: str
22
+ title: str
23
+ source: str
24
+ excerpt: str
25
+ supports: Literal["claimant", "respondent", "mixed", "context"]
26
+ reliability: float = Field(ge=0.0, le=1.0)
27
+ note: str
28
+
29
+
30
+ class CasePacket(BaseModel):
31
+ id: str
32
+ title: str
33
+ subtitle: str
34
+ claimant: str
35
+ respondent: str
36
+ charge: str
37
+ setting: str
38
+ claimant_claim: str
39
+ respondent_claim: str
40
+ source_note: str
41
+ evidence: list[EvidenceItem]
42
+
43
+
44
+ class TrialRequest(BaseModel):
45
+ case_id: str = "socrates"
46
+ search_query: str = ""
47
+ hypothetical: str = ""
48
+ speed: Literal["swift", "measured", "ceremonial"] = "swift"
49
+ mind_layer: bool = True
50
+
51
+
52
+ class AgentTurn(BaseModel):
53
+ agent: str
54
+ role: str
55
+ content: str
56
+ model: str
57
+ confidence: float = Field(ge=0.0, le=1.0)
58
+ input: str = ""
59
+
60
+
61
+ class JurorVote(BaseModel):
62
+ juror: str
63
+ persona: str = ""
64
+ vote: Literal["liable", "not_liable", "uncertain"]
65
+ reason: str
66
+ evidence_ids: list[str]
67
+
68
+
69
+ class Verdict(BaseModel):
70
+ finding: Literal["liable", "not_liable", "mixed", "uncertain"]
71
+ decree: str
72
+ rationale: str
73
+ evidence_ids: list[str]
74
+ uncertainty: str
75
+ remedy: str
76
+
77
+
78
+ class TrialEvent(BaseModel):
79
+ phase: TrialPhase
80
+ title: str
81
+ body: str
82
+ turns: list[AgentTurn] = Field(default_factory=list)
83
+ evidence: list[EvidenceItem] = Field(default_factory=list)
84
+ votes: list[JurorVote] = Field(default_factory=list)
85
+ verdict: Verdict | None = None
86
+ trace: dict = Field(default_factory=dict)
sovereign_bench/retrieval.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from urllib.parse import quote_plus
5
+
6
+ import httpx
7
+
8
+ from .models import CasePacket, EvidenceItem
9
+
10
+
11
+ def _plain_text(html: str) -> str:
12
+ html = re.sub(r"(?is)<script.*?</script>|<style.*?</style>", " ", html)
13
+ html = re.sub(r"(?s)<[^>]+>", " ", html)
14
+ html = re.sub(r"\s+", " ", html)
15
+ return html.strip()
16
+
17
+
18
+ def build_live_case(query: str, hypothetical: str = "") -> CasePacket | None:
19
+ clean_query = " ".join(query.split())
20
+ if len(clean_query) < 8:
21
+ return None
22
+
23
+ try:
24
+ url = f"https://r.jina.ai/http://r.jina.ai/http://duckduckgo.com/html/?q={quote_plus(clean_query)}"
25
+ response = httpx.get(url, timeout=8.0, follow_redirects=True)
26
+ text = _plain_text(response.text)
27
+ except Exception:
28
+ return None
29
+
30
+ snippets = [
31
+ segment.strip()
32
+ for segment in re.split(r"(?<=[.!?])\s+", text)
33
+ if 80 <= len(segment.strip()) <= 320 and "http" not in segment[:20].lower()
34
+ ]
35
+ unique: list[str] = []
36
+ for snippet in snippets:
37
+ if snippet.lower() not in {item.lower() for item in unique}:
38
+ unique.append(snippet)
39
+ if len(unique) == 4:
40
+ break
41
+
42
+ if len(unique) < 2:
43
+ return None
44
+
45
+ evidence = [
46
+ EvidenceItem(
47
+ id=f"WEB-E{i}",
48
+ title=f"Retrieved fragment {i}",
49
+ source=f"Web retrieval for: {clean_query}",
50
+ excerpt=snippet,
51
+ supports="context" if i == 1 else "mixed",
52
+ reliability=max(0.45, 0.72 - (i * 0.06)),
53
+ note="Live retrieval fragment; the court treats it as context until corroborated.",
54
+ )
55
+ for i, snippet in enumerate(unique, start=1)
56
+ ]
57
+ framing = hypothetical.strip() or "the parties dispute how the retrieved facts should be interpreted"
58
+ return CasePacket(
59
+ id="live",
60
+ title=f"Live Search Tribunal: {clean_query[:58]}",
61
+ subtitle="A search-fed miniature proceeding with uncertainty kept visible.",
62
+ claimant="The Search Record",
63
+ respondent="The Counter-Interpretation",
64
+ charge=f"Whether {framing}.",
65
+ setting="A temporary court assembled from retrieved public web fragments.",
66
+ claimant_claim="The retrieved record supports a coherent claim that should be credited.",
67
+ respondent_claim="The retrieved record is incomplete, ambiguous, or overread by the claimant.",
68
+ source_note="Live web retrieval via public search snippets. Treat as unverified context, not ground truth.",
69
+ evidence=evidence,
70
+ )
tests/test_cases.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from sovereign_bench.cases import CASES
2
+
3
+
4
+ def test_cached_cases_have_evidence():
5
+ assert {"socrates", "barnaby"} <= set(CASES)
6
+ for case in CASES.values():
7
+ assert len(case.evidence) >= 4
8
+ assert all(item.id and item.excerpt for item in case.evidence)