File size: 18,530 Bytes
a9f964a
 
 
af2657b
a9f964a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af2657b
 
 
 
a9f964a
e1fc11d
373ff24
 
a9f964a
 
6914efb
 
a9f964a
 
 
ff5767d
a9f964a
 
 
 
af2657b
ff5767d
a9f964a
e1fc11d
 
a9f964a
ff5767d
 
 
 
 
373ff24
a9f964a
 
 
373ff24
a9f964a
 
 
 
 
 
 
 
 
 
 
 
373ff24
 
 
 
a9f964a
 
af2657b
 
e1fc11d
af2657b
 
a9f964a
 
 
 
af2657b
 
 
 
 
ff5767d
a9f964a
 
 
 
ff5767d
 
 
 
 
 
 
 
e1fc11d
 
 
 
 
 
 
 
 
a9f964a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373ff24
 
 
 
 
 
 
a9f964a
373ff24
a9f964a
 
373ff24
 
 
a9f964a
 
ff5767d
 
a9f964a
 
ff5767d
a9f964a
 
 
 
 
6914efb
a9f964a
 
 
373ff24
 
 
 
 
 
 
 
 
ff5767d
373ff24
a9f964a
 
 
ff5767d
 
a9f964a
ff5767d
 
 
 
a9f964a
ff5767d
 
 
 
a9f964a
af2657b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ff5767d
 
 
 
6914efb
ff5767d
6914efb
a9f964a
 
 
 
373ff24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9f964a
 
 
 
 
 
ff5767d
 
a9f964a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ff5767d
 
 
a9f964a
6914efb
a9f964a
ff5767d
 
 
 
 
 
a9f964a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6914efb
a9f964a
 
 
 
 
6914efb
 
a9f964a
6914efb
a9f964a
6914efb
a9f964a
6914efb
 
 
 
 
a9f964a
373ff24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9f964a
6914efb
 
 
 
 
 
 
 
 
 
 
a9f964a
 
6914efb
a9f964a
 
 
 
 
6914efb
a9f964a
 
 
6914efb
a9f964a
 
 
 
 
 
 
 
 
 
6914efb
 
 
 
 
 
 
a9f964a
 
 
 
 
 
6914efb
a9f964a
 
6914efb
 
 
 
 
 
 
a9f964a
 
6914efb
a9f964a
6914efb
 
 
 
a9f964a
6914efb
 
a9f964a
 
6914efb
 
 
 
 
 
 
a9f964a
373ff24
 
 
a9f964a
373ff24
 
 
 
a9f964a
 
 
 
373ff24
a9f964a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373ff24
 
 
a9f964a
 
 
373ff24
 
a9f964a
 
 
 
 
 
 
 
 
 
 
 
 
 
6914efb
a9f964a
 
 
 
 
 
 
 
e1fc11d
 
 
a9f964a
 
 
 
 
 
 
 
 
 
 
6914efb
 
a9f964a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
"""12 Angry Agents - Main Gradio Application.

AI-powered jury deliberation simulation where 11 AI agents + 1 human player
debate criminal cases. Uses smolagents CodeAgent with LlamaIndex-powered tools.
"""

import asyncio
from pathlib import Path

import gradio as gr
from dotenv import load_dotenv

# Load environment variables
load_dotenv()

# Add project root to path for imports
import sys
sys.path.insert(0, str(Path(__file__).parent))

from core.game_state import GameState, GamePhase
from core.orchestrator import OrchestratorAgent
from case_db import CaseLoader, CriminalCase, CaseIndexFactory
from agents import load_juror_configs, SmolagentJuror
from ui.components import render_jury_box, render_vote_tally
from services.mcp import MCPService, init_mcp_service, register_mcp_tools
from services.tts import get_tts_service
from services.narration import JudgeNarration


# CSS file path for scalable styling
CSS_PATH = Path(__file__).parent / "ui" / "static" / "styles.css"


class JuryDeliberation:
    """Main game orchestrator - delegates to OrchestratorAgent."""

    def __init__(self):
        self.case_loader = CaseLoader()
        self.juror_configs = load_juror_configs()
        self.juror_agents: dict[str, SmolagentJuror] = {}
        self.orchestrator: OrchestratorAgent | None = None
        self.current_case: CriminalCase | None = None
        self.mcp_service: MCPService | None = None
        self._case_index = None  # Store reference for MCP service

    @property
    def game_state(self) -> GameState | None:
        """Get game state from orchestrator."""
        return self.orchestrator.game_state if self.orchestrator else None

    async def initialize_game(self, case_id: str | None = None) -> tuple[str, str, str, list, str, bytes | None, str]:
        """Initialize a new game.

        Returns:
            Tuple of (case_summary, evidence_html, jury_box_html, chat_history, vote_html, audio_bytes, transcript)
        """
        # Load case
        if case_id:
            self.current_case = self.case_loader.get_case(case_id)
        else:
            self.current_case = self.case_loader.get_random_case()

        if not self.current_case:
            return (
                "No cases available. Please add case files to case_db/cases/",
                "",
                render_jury_box(self.juror_configs),
                [],
                "",
                None,
                ""
            )

        # Create LlamaIndex for this case
        case_index = CaseIndexFactory.get_index(self.current_case)
        self._case_index = case_index  # Store reference for MCP service

        # Initialize juror agents with case index (skip player seat 7)
        self.juror_agents = {}
        for config in self.juror_configs:
            if config.archetype != "player":
                try:
                    agent = SmolagentJuror(
                        config,
                        case_index=case_index,
                        case=self.current_case
                    )
                    agent.set_initial_conviction(self.current_case)
                    self.juror_agents[config.juror_id] = agent
                except Exception as e:
                    print(f"Warning: Failed to initialize {config.name}: {e}")

        # Create orchestrator with agents
        self.orchestrator = OrchestratorAgent(
            juror_configs=self.juror_configs,
            juror_agents=self.juror_agents,
            case=self.current_case
        )
        self.orchestrator.state.phase = GamePhase.PRESENTATION

        # Initialize MCP service for external agent participation
        self.mcp_service = MCPService(
            orchestrator=self.orchestrator,
            case_index=self._case_index,
            juror_configs=self.juror_configs,
            juror_agents=self.juror_agents
        )
        init_mcp_service(self.mcp_service)

        # Format case info
        case_summary = f"""## {self.current_case.title}

**Year:** {self.current_case.year} | **Jurisdiction:** {self.current_case.jurisdiction}

**Charges:** {self.current_case.get_charges_text()}

---

{self.current_case.summary}
"""

        evidence_html = f"""### Evidence

{self.current_case.get_evidence_summary()}

### Witnesses

{self.current_case.get_witness_summary()}
"""

        jury_html = render_jury_box(self.juror_configs, self.game_state)

        # Generate judge narration
        transcript = JudgeNarration.case_introduction(self.current_case)
        audio_bytes = None
        tts = get_tts_service()
        if tts.is_available:
            audio_bytes = await tts.asynthesize(transcript, style="formal", use_cache=True)

        chat_history = [
            {"role": "assistant", "content": f"**Judge:** {transcript}"}
        ]

        vote_html = render_vote_tally(self.game_state)

        return case_summary, evidence_html, jury_html, chat_history, vote_html, audio_bytes, transcript

    def select_player_side(self, side: str) -> tuple[str, str, list]:
        """Player selects prosecution or defense side."""
        if not self.orchestrator:
            return "", "", []

        self.orchestrator.set_player_side(side)

        vote_html = render_vote_tally(self.game_state)
        jury_html = render_jury_box(self.juror_configs, self.game_state)

        side_text = "PROSECUTION (Guilty)" if side == "prosecute" else "DEFENSE (Not Guilty)"
        chat_update = [{"role": "user", "content": f"*You have chosen to argue for the {side_text}*"}]

        return vote_html, jury_html, chat_update

    async def run_deliberation_round(self, prev_tally: tuple | None = None) -> tuple[str, str, list[dict], bytes | None, str, tuple]:
        """Run a single round of AI juror deliberation using fair speaker queue.

        Args:
            prev_tally: Previous vote tally (guilty, not_guilty) for shift detection.

        Returns:
            Tuple of (vote_html, jury_html, new_messages, audio_bytes, transcript, new_tally)
        """
        if not self.orchestrator or not self.current_case:
            return "", "", [], None, "", (0, 0)

        new_messages = []

        # Use orchestrator's fair speaker selection
        results = await self.orchestrator.run_deliberation_round()

        # Format results for chat
        for result in results:
            turn = result.turn
            config = next(c for c in self.juror_configs if c.juror_id == turn.speaker_id)

            # Speaker's argument
            new_messages.append(
                {"role": "assistant", "content": f"**{config.emoji} {config.name}:** {turn.content}"}
            )

            # Show reasoning steps if available (smolagents feature)
            if result.reasoning_steps:
                reasoning_text = "\n".join([f"  {step}" for step in result.reasoning_steps])
                new_messages.append({
                    "role": "assistant",
                    "content": f"*{config.emoji} {config.name}'s reasoning:*\n```\n{reasoning_text}\n```"
                })

            # Show tool calls if any
            if result.tool_calls:
                tools_text = ", ".join(result.tool_calls)
                new_messages.append({
                    "role": "assistant",
                    "content": f"*Tools used: {tools_text}*"
                })

            # Vote changes
            for juror_id, old_vote, new_vote in result.vote_changes:
                other_config = next(c for c in self.juror_configs if c.juror_id == juror_id)
                vote_text = "GUILTY" if new_vote == "guilty" else "NOT GUILTY"
                new_messages.append(
                    {"role": "assistant", "content": f"*{other_config.emoji} {other_config.name} changes their vote to {vote_text}*"}
                )

        vote_html = render_vote_tally(self.game_state)
        jury_html = render_jury_box(self.juror_configs, self.game_state)

        # Check for significant vote shift and generate narration
        new_guilty, new_not_guilty = self.game_state.get_vote_tally()
        new_tally = (new_guilty, new_not_guilty)
        audio_bytes = None
        transcript = ""

        if prev_tally:
            old_g, old_ng = prev_tally
            shift = abs(new_guilty - old_g)
            if shift >= 2:
                direction = "conviction" if new_guilty > old_g else "acquittal"
                transcript = JudgeNarration.vote_shift(shift, direction, new_guilty, new_not_guilty)
                if transcript:
                    tts = get_tts_service()
                    if tts.is_available:
                        audio_bytes = await tts.asynthesize(transcript, style="dramatic", use_cache=False)
                    new_messages.append({"role": "assistant", "content": f"**Judge:** {transcript}"})

        return vote_html, jury_html, new_messages, audio_bytes, transcript, new_tally

    def make_player_argument(
        self,
        strategy: str,
        custom_text: str | None = None
    ) -> tuple[str, str, list[tuple]]:
        """Process player's argument using orchestrator."""
        if not self.orchestrator or not self.current_case:
            return "", "", []

        player_config = next(c for c in self.juror_configs if c.archetype == "player")

        # Create argument content
        if custom_text:
            content = custom_text
        else:
            strategy_templates = {
                "Challenge Evidence": "I'd like to point out some issues with the evidence presented...",
                "Question Witness Credibility": "Can we really trust the witness testimony here?",
                "Appeal to Reasonable Doubt": "Remember, we need proof beyond reasonable doubt. Do we have that?",
                "Present Alternative Theory": "What if there's another explanation for what happened?",
                "Call for Vote": "I think we should take a vote and see where everyone stands.",
            }
            content = strategy_templates.get(strategy, "I have some concerns about this case...")

        # Use orchestrator to process
        argument_type = strategy.lower().replace(" ", "_")
        result = self.orchestrator.process_player_argument(content, argument_type)

        new_messages = [{"role": "user", "content": f"**{player_config.emoji} You:** {content}"}]

        # Vote changes
        for juror_id, old_vote, new_vote in result.vote_changes:
            config = next(c for c in self.juror_configs if c.juror_id == juror_id)
            vote_text = "GUILTY" if new_vote == "guilty" else "NOT GUILTY"
            new_messages.append(
                {"role": "assistant", "content": f"*{config.emoji} {config.name} changes their vote to {vote_text}*"}
            )

        vote_html = render_vote_tally(self.game_state)
        jury_html = render_jury_box(self.juror_configs, self.game_state)

        return vote_html, jury_html, new_messages


# Create global game instance
game = JuryDeliberation()


def create_ui():
    """Create the Gradio UI."""

    with gr.Blocks() as demo:

        # State
        chat_history = gr.State([])

        # Header
        gr.HTML("<h1 class='jury-title'>12 ANGRY AGENTS</h1>", elem_id="app-title")
        gr.HTML("<p class='jury-subtitle'>AI-Powered Jury Deliberation</p>", elem_id="app-subtitle")

        with gr.Row(elem_id="main-row"):
            # Left Column: Jury Box
            with gr.Column(scale=1, elem_id="jury-column"):
                gr.Markdown("### The Jury")
                jury_box = gr.HTML(
                    render_jury_box(game.juror_configs),
                    elem_id="jury-box-container"
                )
                vote_tally = gr.HTML("", elem_id="vote-tally-container")

                # Judge narration section
                gr.Markdown("### Judge")
                with gr.Group(elem_id="judge-narration"):
                    judge_audio = gr.Audio(
                        label="",
                        autoplay=True,
                        elem_id="judge-audio",
                        visible=True
                    )
                    judge_transcript = gr.Textbox(
                        label="",
                        lines=2,
                        interactive=False,
                        elem_id="judge-transcript",
                        show_label=False
                    )

                gr.Markdown("### Your Side")
                with gr.Row(elem_id="side-selection-row"):
                    defend_btn = gr.Button(
                        "DEFEND\n(Not Guilty)",
                        variant="secondary",
                        elem_id="defend-btn"
                    )
                    prosecute_btn = gr.Button(
                        "PROSECUTE\n(Guilty)",
                        variant="primary",
                        elem_id="prosecute-btn"
                    )

            # Center Column: Deliberation
            with gr.Column(scale=2, elem_id="deliberation-column"):
                gr.Markdown("### Deliberation Room")
                deliberation_chat = gr.Chatbot(
                    label="Deliberation",
                    height=400,
                    show_label=False,
                    elem_id="deliberation-chat",
                )

                # Player input
                with gr.Row(elem_id="player-input-row"):
                    strategy_select = gr.Dropdown(
                        choices=[
                            "Challenge Evidence",
                            "Question Witness Credibility",
                            "Appeal to Reasonable Doubt",
                            "Present Alternative Theory",
                            "Call for Vote",
                        ],
                        label="Your Strategy",
                        value="Challenge Evidence",
                        elem_id="strategy-select",
                    )
                    speak_btn = gr.Button(
                        "Speak",
                        variant="primary",
                        elem_id="speak-btn",
                        elem_classes=["primary-btn"]
                    )

                custom_text = gr.Textbox(
                    label="Custom argument (optional)",
                    placeholder="Type your own argument here...",
                    max_lines=2,
                    elem_id="custom-argument",
                )

                with gr.Row(elem_id="action-buttons-row"):
                    pass_btn = gr.Button("Pass Turn", elem_id="pass-btn")
                    next_round_btn = gr.Button(
                        "Next Round (AI Speaks)",
                        variant="secondary",
                        elem_id="next-round-btn"
                    )

            # Right Column: Case File
            with gr.Column(scale=1, elem_id="case-column", elem_classes=["case-file"]):
                gr.Markdown("### Case File")
                case_summary = gr.Markdown(
                    "*Click 'Start New Case' to begin*",
                    elem_id="case-summary"
                )

                with gr.Accordion("Evidence & Witnesses", open=False, elem_id="evidence-accordion"):
                    evidence_display = gr.Markdown("", elem_id="evidence-display")

        # Start Game Button
        start_btn = gr.Button(
            "Start New Case",
            variant="primary",
            size="lg",
            elem_id="start-btn",
            elem_classes=["primary-btn"]
        )

        # State for tracking vote tally for shift detection
        vote_tally_state = gr.State(None)

        # Event handlers
        async def start_game():
            case_md, evidence_md, jury_html, chat, vote_html, audio_bytes, transcript = await game.initialize_game()
            initial_tally = game.game_state.get_vote_tally() if game.game_state else (0, 0)
            return case_md, evidence_md, jury_html, chat, vote_html, audio_bytes, transcript, initial_tally

        start_btn.click(
            fn=start_game,
            inputs=[],
            outputs=[case_summary, evidence_display, jury_box, deliberation_chat, vote_tally, judge_audio, judge_transcript, vote_tally_state]
        )

        def select_defend(chat):
            vote_html, jury_html, new_msgs = game.select_player_side("defend")
            return vote_html, jury_html, chat + new_msgs

        def select_prosecute(chat):
            vote_html, jury_html, new_msgs = game.select_player_side("prosecute")
            return vote_html, jury_html, chat + new_msgs

        defend_btn.click(
            fn=select_defend,
            inputs=[deliberation_chat],
            outputs=[vote_tally, jury_box, deliberation_chat]
        )

        prosecute_btn.click(
            fn=select_prosecute,
            inputs=[deliberation_chat],
            outputs=[vote_tally, jury_box, deliberation_chat]
        )

        async def run_ai_round(chat, prev_tally):
            vote_html, jury_html, new_msgs, audio_bytes, transcript, new_tally = await game.run_deliberation_round(prev_tally)
            return vote_html, jury_html, chat + new_msgs, audio_bytes, transcript, new_tally

        next_round_btn.click(
            fn=run_ai_round,
            inputs=[deliberation_chat, vote_tally_state],
            outputs=[vote_tally, jury_box, deliberation_chat, judge_audio, judge_transcript, vote_tally_state]
        )

        def player_speak(strategy, custom, chat):
            text = custom if custom else None
            vote_html, jury_html, new_msgs = game.make_player_argument(strategy, text)
            return vote_html, jury_html, chat + new_msgs, ""

        speak_btn.click(
            fn=player_speak,
            inputs=[strategy_select, custom_text, deliberation_chat],
            outputs=[vote_tally, jury_box, deliberation_chat, custom_text]
        )

        def pass_turn(chat):
            chat.append({"role": "user", "content": "*You pass your turn*"})
            return chat

        pass_btn.click(
            fn=pass_turn,
            inputs=[deliberation_chat],
            outputs=[deliberation_chat]
        )

        # Register MCP tools for external AI agent participation
        register_mcp_tools(demo)

        return demo


# Main entry point
if __name__ == "__main__":
    demo = create_ui()
    demo.launch(
        mcp_server=True,
        share=False,
        server_name="0.0.0.0",
        server_port=7860,
        css_paths=[CSS_PATH],
        theme=gr.themes.Base(),
    )