Blu3Orange commited on
Commit
373ff24
·
1 Parent(s): d90684f

feat: Introduce argument direction handling and enhance conviction mechanics for juror interactions

Browse files
agents/smolagent_juror.py CHANGED
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING
16
  from smolagents import CodeAgent, LiteLLMModel
17
 
18
  from core.models import JurorConfig, JurorMemory, ArgumentMemory
19
- from core.game_state import GameState, DeliberationTurn
20
  from core.conviction import conviction_to_text
21
  from agents.tools import EvidenceLookupTool, CaseQueryTool
22
 
@@ -56,6 +56,61 @@ class SmolagentJuror:
56
  DEFAULT_MODEL_ID = "gemini/gemini-2.5-flash"
57
  MAX_STEPS = 3 # Limit reasoning steps for performance
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def __init__(
60
  self,
61
  config: JurorConfig,
@@ -121,7 +176,8 @@ class SmolagentJuror:
121
  self,
122
  case: "CriminalCase",
123
  game_state: GameState,
124
- task: str = "speak"
 
125
  ) -> str:
126
  """Build the prompt for the agent."""
127
  guilty, not_guilty = game_state.get_vote_tally()
@@ -161,7 +217,22 @@ You are {self.config.name}, Juror #{self.config.seat_number}.
161
  Guilty: {guilty} | Not Guilty: {not_guilty}
162
  """
163
 
164
- if task == "speak":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  task_prompt = f"""
166
  # YOUR TASK
167
  Make an argument in the deliberation. You have access to tools to look up evidence
@@ -196,7 +267,11 @@ Return a brief internal reaction (not spoken aloud).
196
  Returns:
197
  Tuple of (DeliberationTurn, list of ReasoningSteps for UI)
198
  """
199
- prompt = self._build_prompt(case, game_state, task="speak")
 
 
 
 
200
 
201
  try:
202
  # Run the agent
@@ -216,12 +291,13 @@ Return a brief internal reaction (not spoken aloud).
216
  argument_types = self._get_preferred_argument_types()
217
  selected_type = random.choice(argument_types)
218
 
219
- # Create the turn
220
  turn = DeliberationTurn(
221
  round_number=game_state.round_number,
222
  speaker_id=self.config.juror_id,
223
  speaker_name=self.config.name,
224
  argument_type=selected_type,
 
225
  content=content,
226
  )
227
 
@@ -232,12 +308,13 @@ Return a brief internal reaction (not spoken aloud).
232
 
233
  except Exception as e:
234
  print(f"Error in SmolagentJuror.generate_argument for {self.config.name}: {e}")
235
- # Fallback response
236
  turn = DeliberationTurn(
237
  round_number=game_state.round_number,
238
  speaker_id=self.config.juror_id,
239
  speaker_name=self.config.name,
240
  argument_type="observation",
 
241
  content=f"*{self.config.name} pauses thoughtfully* I'm still considering the evidence...",
242
  )
243
  return turn, []
@@ -313,6 +390,20 @@ Return a brief internal reaction (not spoken aloud).
313
  }
314
  return archetype_preferences.get(self.config.archetype, ["observation", "logical"])
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  def receive_argument(self, argument: DeliberationTurn, impact: float = 0.0) -> None:
317
  """Process an argument from another juror."""
318
  arg_memory = ArgumentMemory(
 
16
  from smolagents import CodeAgent, LiteLLMModel
17
 
18
  from core.models import JurorConfig, JurorMemory, ArgumentMemory
19
+ from core.game_state import GameState, DeliberationTurn, ArgumentDirection
20
  from core.conviction import conviction_to_text
21
  from agents.tools import EvidenceLookupTool, CaseQueryTool
22
 
 
56
  DEFAULT_MODEL_ID = "gemini/gemini-2.5-flash"
57
  MAX_STEPS = 3 # Limit reasoning steps for performance
58
 
59
+ # Extremely explicit direction prompts to prevent LLM from contradicting assigned direction
60
+ DIRECTION_PROMPTS = {
61
+ ArgumentDirection.PROSECUTION: """
62
+ ## YOUR TASK: ARGUE FOR GUILTY
63
+
64
+ You are arguing that the defendant IS GUILTY.
65
+
66
+ REQUIREMENTS:
67
+ - Every sentence must support conviction
68
+ - Point to evidence that proves guilt
69
+ - Dismiss defense arguments as weak or irrelevant
70
+ - End with a clear statement: "The defendant is guilty"
71
+
72
+ DO NOT:
73
+ - Express any doubt about guilt
74
+ - Mention reasons for innocence
75
+ - Say "but" or "however" to introduce defense points
76
+ - Play devil's advocate
77
+ """,
78
+
79
+ ArgumentDirection.DEFENSE: """
80
+ ## YOUR TASK: ARGUE FOR NOT GUILTY
81
+
82
+ You are arguing that the defendant is NOT GUILTY.
83
+
84
+ REQUIREMENTS:
85
+ - Every sentence must support acquittal
86
+ - Point to reasonable doubt in the evidence
87
+ - Highlight weaknesses in the prosecution's case
88
+ - End with a clear statement: "The defendant is not guilty" or "There is reasonable doubt"
89
+
90
+ DO NOT:
91
+ - Express any belief in guilt
92
+ - Mention reasons the defendant might be guilty
93
+ - Say "but" or "however" to introduce prosecution points
94
+ - Concede prosecution's strong points
95
+ """,
96
+
97
+ ArgumentDirection.NEUTRAL: """
98
+ ## YOUR TASK: ASK A PROBING QUESTION
99
+
100
+ You are undecided and want to explore the case further.
101
+
102
+ REQUIREMENTS:
103
+ - Ask one specific question about the evidence or testimony
104
+ - Do not take a side
105
+ - Frame it as genuine uncertainty
106
+
107
+ DO NOT:
108
+ - Make any argument for guilty or not guilty
109
+ - Answer your own question
110
+ - Express a leaning toward either side
111
+ """
112
+ }
113
+
114
  def __init__(
115
  self,
116
  config: JurorConfig,
 
176
  self,
177
  case: "CriminalCase",
178
  game_state: GameState,
179
+ task: str = "speak",
180
+ direction: ArgumentDirection | None = None
181
  ) -> str:
182
  """Build the prompt for the agent."""
183
  guilty, not_guilty = game_state.get_vote_tally()
 
217
  Guilty: {guilty} | Not Guilty: {not_guilty}
218
  """
219
 
220
+ if task == "speak" and direction:
221
+ # Use extremely explicit direction prompts
222
+ direction_prompt = self.DIRECTION_PROMPTS.get(direction, "")
223
+ task_prompt = f"""
224
+ {direction_prompt}
225
+
226
+ Stay in character as {self.config.name} ({self.config.archetype}).
227
+ - Be authentic to your personality and background
228
+ - Keep your argument focused (2-4 sentences)
229
+ - You may address other jurors or speak to the room
230
+ - Use tools to look up evidence if needed
231
+
232
+ Provide your argument as a single statement that your character would say out loud
233
+ in the jury room. Do not include internal thoughts - just what you would say.
234
+ """
235
+ elif task == "speak":
236
  task_prompt = f"""
237
  # YOUR TASK
238
  Make an argument in the deliberation. You have access to tools to look up evidence
 
267
  Returns:
268
  Tuple of (DeliberationTurn, list of ReasoningSteps for UI)
269
  """
270
+ # Determine direction BEFORE generating content
271
+ direction = self._determine_argument_direction()
272
+
273
+ # Build prompt with direction guidance
274
+ prompt = self._build_prompt(case, game_state, task="speak", direction=direction)
275
 
276
  try:
277
  # Run the agent
 
291
  argument_types = self._get_preferred_argument_types()
292
  selected_type = random.choice(argument_types)
293
 
294
+ # Create the turn with direction
295
  turn = DeliberationTurn(
296
  round_number=game_state.round_number,
297
  speaker_id=self.config.juror_id,
298
  speaker_name=self.config.name,
299
  argument_type=selected_type,
300
+ direction=direction,
301
  content=content,
302
  )
303
 
 
308
 
309
  except Exception as e:
310
  print(f"Error in SmolagentJuror.generate_argument for {self.config.name}: {e}")
311
+ # Fallback response - use neutral direction
312
  turn = DeliberationTurn(
313
  round_number=game_state.round_number,
314
  speaker_id=self.config.juror_id,
315
  speaker_name=self.config.name,
316
  argument_type="observation",
317
+ direction=ArgumentDirection.NEUTRAL,
318
  content=f"*{self.config.name} pauses thoughtfully* I'm still considering the evidence...",
319
  )
320
  return turn, []
 
390
  }
391
  return archetype_preferences.get(self.config.archetype, ["observation", "logical"])
392
 
393
+ def _determine_argument_direction(self) -> ArgumentDirection:
394
+ """Determine argument direction based on current conviction."""
395
+ conviction = self.memory.current_conviction
396
+
397
+ if conviction > 0.6:
398
+ return ArgumentDirection.PROSECUTION
399
+ elif conviction < 0.4:
400
+ return ArgumentDirection.DEFENSE
401
+ else:
402
+ # Undecided - 30% chance to ask neutral questions
403
+ if random.random() < 0.3:
404
+ return ArgumentDirection.NEUTRAL
405
+ return ArgumentDirection.PROSECUTION if conviction > 0.5 else ArgumentDirection.DEFENSE
406
+
407
  def receive_argument(self, argument: DeliberationTurn, impact: float = 0.0) -> None:
408
  """Process an argument from another juror."""
409
  arg_memory = ArgumentMemory(
app.py CHANGED
@@ -23,6 +23,8 @@ from case_db import CaseLoader, CriminalCase, CaseIndexFactory
23
  from agents import load_juror_configs, SmolagentJuror
24
  from ui.components import render_jury_box, render_vote_tally
25
  from services.mcp import MCPService, init_mcp_service, register_mcp_tools
 
 
26
 
27
 
28
  # CSS file path for scalable styling
@@ -46,11 +48,11 @@ class JuryDeliberation:
46
  """Get game state from orchestrator."""
47
  return self.orchestrator.game_state if self.orchestrator else None
48
 
49
- def initialize_game(self, case_id: str | None = None) -> tuple[str, str, str, list]:
50
  """Initialize a new game.
51
 
52
  Returns:
53
- Tuple of (case_summary, evidence_html, jury_box_html, chat_history)
54
  """
55
  # Load case
56
  if case_id:
@@ -63,7 +65,10 @@ class JuryDeliberation:
63
  "No cases available. Please add case files to case_db/cases/",
64
  "",
65
  render_jury_box(self.juror_configs),
66
- []
 
 
 
67
  )
68
 
69
  # Create LlamaIndex for this case
@@ -125,11 +130,20 @@ class JuryDeliberation:
125
 
126
  jury_html = render_jury_box(self.juror_configs, self.game_state)
127
 
 
 
 
 
 
 
 
128
  chat_history = [
129
- {"role": "assistant", "content": f"**Judge:** Members of the jury, you are here today to determine the fate of the defendant in the case of {self.current_case.title}. Please review the evidence carefully and deliberate with your fellow jurors. The burden of proof lies with the prosecution."}
130
  ]
131
 
132
- return case_summary, evidence_html, jury_html, chat_history
 
 
133
 
134
  def select_player_side(self, side: str) -> tuple[str, str, list]:
135
  """Player selects prosecution or defense side."""
@@ -146,10 +160,17 @@ class JuryDeliberation:
146
 
147
  return vote_html, jury_html, chat_update
148
 
149
- async def run_deliberation_round(self) -> tuple[str, str, list[dict]]:
150
- """Run a single round of AI juror deliberation using fair speaker queue."""
 
 
 
 
 
 
 
151
  if not self.orchestrator or not self.current_case:
152
- return "", "", []
153
 
154
  new_messages = []
155
 
@@ -193,7 +214,25 @@ class JuryDeliberation:
193
  vote_html = render_vote_tally(self.game_state)
194
  jury_html = render_jury_box(self.juror_configs, self.game_state)
195
 
196
- return vote_html, jury_html, new_messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
  def make_player_argument(
199
  self,
@@ -265,6 +304,23 @@ def create_ui():
265
  )
266
  vote_tally = gr.HTML("", elem_id="vote-tally-container")
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  gr.Markdown("### Your Side")
269
  with gr.Row(elem_id="side-selection-row"):
270
  defend_btn = gr.Button(
@@ -344,15 +400,19 @@ def create_ui():
344
  elem_classes=["primary-btn"]
345
  )
346
 
 
 
 
347
  # Event handlers
348
- def start_game():
349
- case_md, evidence_md, jury_html, chat = game.initialize_game()
350
- return case_md, evidence_md, jury_html, chat, render_vote_tally(game.game_state)
 
351
 
352
  start_btn.click(
353
  fn=start_game,
354
  inputs=[],
355
- outputs=[case_summary, evidence_display, jury_box, deliberation_chat, vote_tally]
356
  )
357
 
358
  def select_defend(chat):
@@ -375,14 +435,14 @@ def create_ui():
375
  outputs=[vote_tally, jury_box, deliberation_chat]
376
  )
377
 
378
- async def run_ai_round(chat):
379
- vote_html, jury_html, new_msgs = await game.run_deliberation_round()
380
- return vote_html, jury_html, chat + new_msgs
381
 
382
  next_round_btn.click(
383
  fn=run_ai_round,
384
- inputs=[deliberation_chat],
385
- outputs=[vote_tally, jury_box, deliberation_chat]
386
  )
387
 
388
  def player_speak(strategy, custom, chat):
 
23
  from agents import load_juror_configs, SmolagentJuror
24
  from ui.components import render_jury_box, render_vote_tally
25
  from services.mcp import MCPService, init_mcp_service, register_mcp_tools
26
+ from services.tts import get_tts_service
27
+ from services.narration import JudgeNarration
28
 
29
 
30
  # CSS file path for scalable styling
 
48
  """Get game state from orchestrator."""
49
  return self.orchestrator.game_state if self.orchestrator else None
50
 
51
+ async def initialize_game(self, case_id: str | None = None) -> tuple[str, str, str, list, str, bytes | None, str]:
52
  """Initialize a new game.
53
 
54
  Returns:
55
+ Tuple of (case_summary, evidence_html, jury_box_html, chat_history, vote_html, audio_bytes, transcript)
56
  """
57
  # Load case
58
  if case_id:
 
65
  "No cases available. Please add case files to case_db/cases/",
66
  "",
67
  render_jury_box(self.juror_configs),
68
+ [],
69
+ "",
70
+ None,
71
+ ""
72
  )
73
 
74
  # Create LlamaIndex for this case
 
130
 
131
  jury_html = render_jury_box(self.juror_configs, self.game_state)
132
 
133
+ # Generate judge narration
134
+ transcript = JudgeNarration.case_introduction(self.current_case)
135
+ audio_bytes = None
136
+ tts = get_tts_service()
137
+ if tts.is_available:
138
+ audio_bytes = await tts.asynthesize(transcript, style="formal", use_cache=True)
139
+
140
  chat_history = [
141
+ {"role": "assistant", "content": f"**Judge:** {transcript}"}
142
  ]
143
 
144
+ vote_html = render_vote_tally(self.game_state)
145
+
146
+ return case_summary, evidence_html, jury_html, chat_history, vote_html, audio_bytes, transcript
147
 
148
  def select_player_side(self, side: str) -> tuple[str, str, list]:
149
  """Player selects prosecution or defense side."""
 
160
 
161
  return vote_html, jury_html, chat_update
162
 
163
+ async def run_deliberation_round(self, prev_tally: tuple | None = None) -> tuple[str, str, list[dict], bytes | None, str, tuple]:
164
+ """Run a single round of AI juror deliberation using fair speaker queue.
165
+
166
+ Args:
167
+ prev_tally: Previous vote tally (guilty, not_guilty) for shift detection.
168
+
169
+ Returns:
170
+ Tuple of (vote_html, jury_html, new_messages, audio_bytes, transcript, new_tally)
171
+ """
172
  if not self.orchestrator or not self.current_case:
173
+ return "", "", [], None, "", (0, 0)
174
 
175
  new_messages = []
176
 
 
214
  vote_html = render_vote_tally(self.game_state)
215
  jury_html = render_jury_box(self.juror_configs, self.game_state)
216
 
217
+ # Check for significant vote shift and generate narration
218
+ new_guilty, new_not_guilty = self.game_state.get_vote_tally()
219
+ new_tally = (new_guilty, new_not_guilty)
220
+ audio_bytes = None
221
+ transcript = ""
222
+
223
+ if prev_tally:
224
+ old_g, old_ng = prev_tally
225
+ shift = abs(new_guilty - old_g)
226
+ if shift >= 2:
227
+ direction = "conviction" if new_guilty > old_g else "acquittal"
228
+ transcript = JudgeNarration.vote_shift(shift, direction, new_guilty, new_not_guilty)
229
+ if transcript:
230
+ tts = get_tts_service()
231
+ if tts.is_available:
232
+ audio_bytes = await tts.asynthesize(transcript, style="dramatic", use_cache=False)
233
+ new_messages.append({"role": "assistant", "content": f"**Judge:** {transcript}"})
234
+
235
+ return vote_html, jury_html, new_messages, audio_bytes, transcript, new_tally
236
 
237
  def make_player_argument(
238
  self,
 
304
  )
305
  vote_tally = gr.HTML("", elem_id="vote-tally-container")
306
 
307
+ # Judge narration section
308
+ gr.Markdown("### Judge")
309
+ with gr.Group(elem_id="judge-narration"):
310
+ judge_audio = gr.Audio(
311
+ label="",
312
+ autoplay=True,
313
+ elem_id="judge-audio",
314
+ visible=True
315
+ )
316
+ judge_transcript = gr.Textbox(
317
+ label="",
318
+ lines=2,
319
+ interactive=False,
320
+ elem_id="judge-transcript",
321
+ show_label=False
322
+ )
323
+
324
  gr.Markdown("### Your Side")
325
  with gr.Row(elem_id="side-selection-row"):
326
  defend_btn = gr.Button(
 
400
  elem_classes=["primary-btn"]
401
  )
402
 
403
+ # State for tracking vote tally for shift detection
404
+ vote_tally_state = gr.State(None)
405
+
406
  # Event handlers
407
+ async def start_game():
408
+ case_md, evidence_md, jury_html, chat, vote_html, audio_bytes, transcript = await game.initialize_game()
409
+ initial_tally = game.game_state.get_vote_tally() if game.game_state else (0, 0)
410
+ return case_md, evidence_md, jury_html, chat, vote_html, audio_bytes, transcript, initial_tally
411
 
412
  start_btn.click(
413
  fn=start_game,
414
  inputs=[],
415
+ outputs=[case_summary, evidence_display, jury_box, deliberation_chat, vote_tally, judge_audio, judge_transcript, vote_tally_state]
416
  )
417
 
418
  def select_defend(chat):
 
435
  outputs=[vote_tally, jury_box, deliberation_chat]
436
  )
437
 
438
+ async def run_ai_round(chat, prev_tally):
439
+ vote_html, jury_html, new_msgs, audio_bytes, transcript, new_tally = await game.run_deliberation_round(prev_tally)
440
+ return vote_html, jury_html, chat + new_msgs, audio_bytes, transcript, new_tally
441
 
442
  next_round_btn.click(
443
  fn=run_ai_round,
444
+ inputs=[deliberation_chat, vote_tally_state],
445
+ outputs=[vote_tally, jury_box, deliberation_chat, judge_audio, judge_transcript, vote_tally_state]
446
  )
447
 
448
  def player_speak(strategy, custom, chat):
case_db/index.py CHANGED
@@ -36,7 +36,9 @@ class CaseIndex:
36
 
37
  # Build the index
38
  self.index = self._build_index()
39
- self.query_engine = self.index.as_query_engine()
 
 
40
 
41
  def _build_index(self) -> VectorStoreIndex:
42
  """Build vector index from case documents."""
@@ -152,10 +154,13 @@ class CaseIndex:
152
  question: Natural language question about the case
153
 
154
  Returns:
155
- Synthesized answer from relevant case documents
156
  """
157
- response = self.query_engine.query(question)
158
- return str(response)
 
 
 
159
 
160
  def query_evidence(self, query: str) -> str:
161
  """Query specifically for evidence-related information.
 
36
 
37
  # Build the index
38
  self.index = self._build_index()
39
+ # Use retriever instead of query_engine to avoid redundant LLM calls
40
+ # The CodeAgent will reason about raw retrieved docs directly
41
+ self.retriever = self.index.as_retriever(similarity_top_k=3)
42
 
43
  def _build_index(self) -> VectorStoreIndex:
44
  """Build vector index from case documents."""
 
154
  question: Natural language question about the case
155
 
156
  Returns:
157
+ Relevant case documents (raw text, no LLM synthesis)
158
  """
159
+ nodes = self.retriever.retrieve(question)
160
+ if not nodes:
161
+ return "No relevant information found."
162
+ # Return raw document text for CodeAgent to reason about
163
+ return "\n\n".join([node.text for node in nodes])
164
 
165
  def query_evidence(self, query: str) -> str:
166
  """Query specifically for evidence-related information.
core/__init__.py CHANGED
@@ -4,13 +4,19 @@ from .game_state import (
4
  GameState,
5
  DeliberationTurn,
6
  GamePhase,
 
7
  )
8
  from .models import (
9
  JurorConfig,
10
  JurorMemory,
11
  ArgumentMemory,
12
  )
13
- from .conviction import calculate_conviction_change, check_vote_flip
 
 
 
 
 
14
  from .orchestrator import (
15
  OrchestratorAgent,
16
  TurnManager,
@@ -22,11 +28,14 @@ __all__ = [
22
  "GameState",
23
  "DeliberationTurn",
24
  "GamePhase",
 
25
  "JurorConfig",
26
  "JurorMemory",
27
  "ArgumentMemory",
28
  "calculate_conviction_change",
29
  "check_vote_flip",
 
 
30
  "OrchestratorAgent",
31
  "TurnManager",
32
  "TurnResult",
 
4
  GameState,
5
  DeliberationTurn,
6
  GamePhase,
7
+ ArgumentDirection,
8
  )
9
  from .models import (
10
  JurorConfig,
11
  JurorMemory,
12
  ArgumentMemory,
13
  )
14
+ from .conviction import (
15
+ calculate_conviction_change,
16
+ check_vote_flip,
17
+ calculate_direction_impact,
18
+ apply_conviction_update,
19
+ )
20
  from .orchestrator import (
21
  OrchestratorAgent,
22
  TurnManager,
 
28
  "GameState",
29
  "DeliberationTurn",
30
  "GamePhase",
31
+ "ArgumentDirection",
32
  "JurorConfig",
33
  "JurorMemory",
34
  "ArgumentMemory",
35
  "calculate_conviction_change",
36
  "check_vote_flip",
37
+ "calculate_direction_impact",
38
+ "apply_conviction_update",
39
  "OrchestratorAgent",
40
  "TurnManager",
41
  "TurnResult",
core/conviction.py CHANGED
@@ -1,8 +1,9 @@
1
  """Conviction score mechanics for juror persuasion."""
2
 
3
  import random
 
4
  from .models import JurorConfig, JurorMemory
5
- from .game_state import DeliberationTurn
6
 
7
 
8
  # Archetype modifiers for different argument types
@@ -104,11 +105,32 @@ def get_archetype_modifier(archetype: str, argument_type: str) -> float:
104
  return archetype_mods.get(argument_type, 1.0)
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def calculate_conviction_change(
108
  juror: JurorConfig,
109
  juror_memory: JurorMemory,
110
  argument: DeliberationTurn,
111
- base_impact: float = 0.0,
112
  ) -> float:
113
  """
114
  Calculate how much an argument shifts a juror's conviction.
@@ -116,12 +138,22 @@ def calculate_conviction_change(
116
  Args:
117
  juror: The juror configuration
118
  juror_memory: The juror's current memory state
119
- argument: The argument being made
120
- base_impact: Base impact from argument strength (-1.0 to 1.0)
121
 
122
  Returns:
123
  delta to add to conviction score (-0.3 to +0.3 typically)
124
  """
 
 
 
 
 
 
 
 
 
 
125
  # Personality modifiers
126
  archetype_modifier = get_archetype_modifier(
127
  juror.archetype,
@@ -131,10 +163,6 @@ def calculate_conviction_change(
131
  # Stubbornness reduces all changes
132
  stubbornness_modifier = 1.0 - (juror.stubbornness * 0.7)
133
 
134
- # Volatility adds randomness - but stubbornness dampens volatility effect
135
- effective_volatility = juror.volatility * (1.0 - juror.stubbornness * 0.5)
136
- volatility_noise = random.gauss(0, effective_volatility * 0.1)
137
-
138
  # Relationship modifier - trust the speaker?
139
  trust = juror_memory.opinions_of_others.get(argument.speaker_id, 0.0)
140
  trust_modifier = 1.0 + (trust * 0.3) # -30% to +30%
@@ -144,16 +172,21 @@ def calculate_conviction_change(
144
  distance = abs(current - 0.5) # 0 at center, 0.5 at extremes
145
  extreme_resistance = 1.0 - (distance ** 2) * 1.5 # Quadratic: 1.0 center, 0.625 extremes
146
 
147
- # Calculate final delta
148
  delta = (
149
  base_impact
150
  * archetype_modifier
151
  * stubbornness_modifier
152
  * trust_modifier
153
  * extreme_resistance
154
- + volatility_noise
155
  )
156
 
 
 
 
 
 
 
157
  # Clamp to reasonable range
158
  return max(-0.3, min(0.3, delta))
159
 
@@ -181,6 +214,33 @@ def check_vote_flip(juror_memory: JurorMemory) -> bool:
181
  return False
182
 
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  def conviction_to_text(conviction: float) -> str:
185
  """Convert conviction score to human-readable text."""
186
  if conviction < 0.2:
 
1
  """Conviction score mechanics for juror persuasion."""
2
 
3
  import random
4
+ from typing import Literal
5
  from .models import JurorConfig, JurorMemory
6
+ from .game_state import DeliberationTurn, ArgumentDirection
7
 
8
 
9
  # Archetype modifiers for different argument types
 
105
  return archetype_mods.get(argument_type, 1.0)
106
 
107
 
108
+ def calculate_direction_impact(
109
+ direction: ArgumentDirection,
110
+ base_strength: float = 0.1
111
+ ) -> float:
112
+ """
113
+ Convert argument direction to signed impact.
114
+
115
+ Args:
116
+ direction: Which side the argument supports
117
+ base_strength: Base argument strength (0.0 to 1.0)
118
+
119
+ Returns:
120
+ Signed impact: positive pushes toward guilty, negative toward not guilty
121
+ """
122
+ if direction == ArgumentDirection.PROSECUTION:
123
+ return base_strength # Positive: push toward guilty
124
+ elif direction == ArgumentDirection.DEFENSE:
125
+ return -base_strength # Negative: push toward not guilty
126
+ return 0.0 # Neutral: no directional push
127
+
128
+
129
  def calculate_conviction_change(
130
  juror: JurorConfig,
131
  juror_memory: JurorMemory,
132
  argument: DeliberationTurn,
133
+ base_strength: float = 0.1,
134
  ) -> float:
135
  """
136
  Calculate how much an argument shifts a juror's conviction.
 
138
  Args:
139
  juror: The juror configuration
140
  juror_memory: The juror's current memory state
141
+ argument: The argument being made (includes direction)
142
+ base_strength: Base argument strength (0.0 to 1.0)
143
 
144
  Returns:
145
  delta to add to conviction score (-0.3 to +0.3 typically)
146
  """
147
+ # Calculate directional base impact from argument direction
148
+ base_impact = calculate_direction_impact(
149
+ argument.direction,
150
+ base_strength
151
+ )
152
+
153
+ # If no meaningful impact (neutral direction), skip noise and return 0
154
+ if abs(base_impact) < 0.001:
155
+ return 0.0
156
+
157
  # Personality modifiers
158
  archetype_modifier = get_archetype_modifier(
159
  juror.archetype,
 
163
  # Stubbornness reduces all changes
164
  stubbornness_modifier = 1.0 - (juror.stubbornness * 0.7)
165
 
 
 
 
 
166
  # Relationship modifier - trust the speaker?
167
  trust = juror_memory.opinions_of_others.get(argument.speaker_id, 0.0)
168
  trust_modifier = 1.0 + (trust * 0.3) # -30% to +30%
 
172
  distance = abs(current - 0.5) # 0 at center, 0.5 at extremes
173
  extreme_resistance = 1.0 - (distance ** 2) * 1.5 # Quadratic: 1.0 center, 0.625 extremes
174
 
175
+ # Calculate base delta without noise
176
  delta = (
177
  base_impact
178
  * archetype_modifier
179
  * stubbornness_modifier
180
  * trust_modifier
181
  * extreme_resistance
 
182
  )
183
 
184
+ # Only add volatility noise when there's meaningful impact
185
+ if abs(delta) > 0.01:
186
+ effective_volatility = juror.volatility * (1.0 - juror.stubbornness * 0.5)
187
+ volatility_noise = random.gauss(0, effective_volatility * 0.1)
188
+ delta += volatility_noise
189
+
190
  # Clamp to reasonable range
191
  return max(-0.3, min(0.3, delta))
192
 
 
214
  return False
215
 
216
 
217
+ def apply_conviction_update(
218
+ juror_memory: JurorMemory,
219
+ delta: float
220
+ ) -> tuple[bool, Literal["guilty", "not_guilty"] | None]:
221
+ """
222
+ Apply conviction update and check for vote flip using hysteresis.
223
+
224
+ Args:
225
+ juror_memory: Juror's memory state
226
+ delta: Conviction change to apply
227
+
228
+ Returns:
229
+ Tuple of (vote_flipped, new_vote or None)
230
+ """
231
+ old_vote = juror_memory.get_current_vote()
232
+
233
+ # Apply the delta
234
+ juror_memory.update_conviction(delta)
235
+
236
+ # Check for vote flip using hysteresis
237
+ if check_vote_flip(juror_memory):
238
+ new_vote = juror_memory.get_current_vote()
239
+ return True, new_vote
240
+
241
+ return False, None
242
+
243
+
244
  def conviction_to_text(conviction: float) -> str:
245
  """Convert conviction score to human-readable text."""
246
  if conviction < 0.2:
core/game_state.py CHANGED
@@ -18,6 +18,13 @@ class GamePhase(str, Enum):
18
  VERDICT = "verdict"
19
 
20
 
 
 
 
 
 
 
 
21
  @dataclass
22
  class DeliberationTurn:
23
  """A single turn in deliberation."""
@@ -26,6 +33,7 @@ class DeliberationTurn:
26
  speaker_id: str
27
  speaker_name: str
28
  argument_type: str # "evidence", "emotional", "logical", "question", etc.
 
29
  content: str
30
  target_id: str | None = None # Who they're addressing
31
  impact: dict[str, float] = field(default_factory=dict) # conviction changes
@@ -38,6 +46,7 @@ class DeliberationTurn:
38
  "speaker_id": self.speaker_id,
39
  "speaker_name": self.speaker_name,
40
  "argument_type": self.argument_type,
 
41
  "content": self.content,
42
  "target_id": self.target_id,
43
  "impact": self.impact,
 
18
  VERDICT = "verdict"
19
 
20
 
21
+ class ArgumentDirection(str, Enum):
22
+ """Direction of an argument's stance."""
23
+ PROSECUTION = "prosecution" # Argues for guilty verdict
24
+ DEFENSE = "defense" # Argues for not guilty verdict
25
+ NEUTRAL = "neutral" # Procedural, questions, undecided
26
+
27
+
28
  @dataclass
29
  class DeliberationTurn:
30
  """A single turn in deliberation."""
 
33
  speaker_id: str
34
  speaker_name: str
35
  argument_type: str # "evidence", "emotional", "logical", "question", etc.
36
+ direction: ArgumentDirection # prosecution, defense, or neutral
37
  content: str
38
  target_id: str | None = None # Who they're addressing
39
  impact: dict[str, float] = field(default_factory=dict) # conviction changes
 
46
  "speaker_id": self.speaker_id,
47
  "speaker_name": self.speaker_name,
48
  "argument_type": self.argument_type,
49
+ "direction": self.direction.value,
50
  "content": self.content,
51
  "target_id": self.target_id,
52
  "impact": self.impact,
core/models.py CHANGED
@@ -83,8 +83,22 @@ class JurorMemory:
83
  doubts: list[str] = field(default_factory=list)
84
 
85
  def get_current_vote(self) -> Literal["guilty", "not_guilty"]:
86
- """Get vote based on current conviction."""
87
- return "guilty" if self.current_conviction > 0.5 else "not_guilty"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  def add_argument(self, argument: ArgumentMemory) -> None:
90
  """Add a heard argument to memory."""
 
83
  doubts: list[str] = field(default_factory=list)
84
 
85
  def get_current_vote(self) -> Literal["guilty", "not_guilty"]:
86
+ """Get vote based on current conviction with hysteresis."""
87
+ # If no history, use simple threshold
88
+ if len(self.conviction_history) < 2:
89
+ return "guilty" if self.current_conviction > 0.5 else "not_guilty"
90
+
91
+ # Determine previous vote from previous conviction
92
+ prev_conviction = self.conviction_history[-2]
93
+ prev_vote_guilty = prev_conviction > 0.5
94
+
95
+ # Apply hysteresis thresholds to prevent flip-flopping
96
+ if prev_vote_guilty:
97
+ # Currently guilty - need to drop below 0.4 to flip
98
+ return "not_guilty" if self.current_conviction < 0.4 else "guilty"
99
+ else:
100
+ # Currently not guilty - need to rise above 0.6 to flip
101
+ return "guilty" if self.current_conviction > 0.6 else "not_guilty"
102
 
103
  def add_argument(self, argument: ArgumentMemory) -> None:
104
  """Add a heard argument to memory."""
core/orchestrator.py CHANGED
@@ -7,9 +7,9 @@ import random
7
  from dataclasses import dataclass, field
8
  from typing import TYPE_CHECKING
9
 
10
- from core.game_state import GameState, DeliberationTurn, GamePhase
11
  from core.models import JurorConfig, JurorMemory
12
- from core.conviction import calculate_conviction_change
13
 
14
  if TYPE_CHECKING:
15
  from agents.smolagent_juror import SmolagentJuror
@@ -276,24 +276,28 @@ class OrchestratorAgent:
276
  continue
277
 
278
  old_vote = self.state.votes.get(other_id)
279
- base_impact = random.uniform(-0.15, 0.15)
280
 
 
 
281
  if other_id in active_listeners:
282
- base_impact *= 1.2
283
 
 
284
  delta = calculate_conviction_change(
285
  other_agent.config,
286
  other_agent.memory,
287
- turn,
288
- base_impact=base_impact
289
  )
290
 
291
- other_agent.receive_argument(turn, delta)
 
292
  conviction_changes[other_id] = delta
293
  turn.impact[other_id] = delta
294
 
295
- new_vote = other_agent.get_vote()
296
- if old_vote != new_vote:
 
297
  self.state.votes[other_id] = new_vote
298
  vote_changes.append((other_id, old_vote, new_vote))
299
 
@@ -351,11 +355,19 @@ class OrchestratorAgent:
351
  target_id: str | None = None
352
  ) -> TurnResult:
353
  """Process an argument from the human player."""
 
 
 
 
 
 
 
354
  turn = DeliberationTurn(
355
  round_number=self.state.round_number,
356
  speaker_id="juror_7",
357
  speaker_name="You",
358
  argument_type=argument_type,
 
359
  content=content,
360
  target_id=target_id
361
  )
@@ -365,24 +377,27 @@ class OrchestratorAgent:
365
 
366
  for juror_id, agent in self.juror_agents.items():
367
  old_vote = self.state.votes.get(juror_id)
368
- base_impact = random.uniform(-0.1, 0.1) * 1.2
369
 
 
 
370
  if target_id == juror_id:
371
- base_impact *= 1.5
372
 
373
  delta = calculate_conviction_change(
374
  agent.config,
375
  agent.memory,
376
  turn,
377
- base_impact=base_impact
378
  )
379
 
380
- agent.receive_argument(turn, delta)
 
381
  conviction_changes[juror_id] = delta
382
  turn.impact[juror_id] = delta
383
 
384
- new_vote = agent.get_vote()
385
- if old_vote != new_vote:
 
386
  self.state.votes[juror_id] = new_vote
387
  vote_changes.append((juror_id, old_vote, new_vote))
388
 
@@ -401,7 +416,8 @@ class OrchestratorAgent:
401
  speaker_id: str,
402
  speaker_name: str,
403
  content: str,
404
- argument_type: str,
 
405
  target_id: str | None = None
406
  ) -> TurnResult:
407
  """Process an argument from an external MCP agent.
@@ -413,7 +429,8 @@ class OrchestratorAgent:
413
  speaker_id: Juror seat ID (e.g., "juror_3")
414
  speaker_name: Display name for the speaker
415
  content: Argument text
416
- argument_type: Type of argument
 
417
  target_id: Optional juror_id to address directly
418
 
419
  Returns:
@@ -424,6 +441,7 @@ class OrchestratorAgent:
424
  speaker_id=speaker_id,
425
  speaker_name=speaker_name,
426
  argument_type=argument_type,
 
427
  content=content,
428
  target_id=target_id
429
  )
@@ -436,24 +454,27 @@ class OrchestratorAgent:
436
  continue
437
 
438
  old_vote = self.state.votes.get(juror_id)
439
- base_impact = random.uniform(-0.1, 0.1) * 1.2
440
 
 
 
441
  if target_id == juror_id:
442
- base_impact *= 1.5
443
 
444
  delta = calculate_conviction_change(
445
  agent.config,
446
  agent.memory,
447
  turn,
448
- base_impact=base_impact
449
  )
450
 
451
- agent.receive_argument(turn, delta)
 
452
  conviction_changes[juror_id] = delta
453
  turn.impact[juror_id] = delta
454
 
455
- new_vote = agent.get_vote()
456
- if old_vote != new_vote:
 
457
  self.state.votes[juror_id] = new_vote
458
  vote_changes.append((juror_id, old_vote, new_vote))
459
 
 
7
  from dataclasses import dataclass, field
8
  from typing import TYPE_CHECKING
9
 
10
+ from core.game_state import GameState, DeliberationTurn, GamePhase, ArgumentDirection
11
  from core.models import JurorConfig, JurorMemory
12
+ from core.conviction import calculate_conviction_change, apply_conviction_update
13
 
14
  if TYPE_CHECKING:
15
  from agents.smolagent_juror import SmolagentJuror
 
276
  continue
277
 
278
  old_vote = self.state.votes.get(other_id)
 
279
 
280
+ # Calculate base strength based on speaker influence
281
+ base_strength = 0.08 + (agent.config.influence * 0.07)
282
  if other_id in active_listeners:
283
+ base_strength *= 1.2
284
 
285
+ # Use direction-aware conviction calculation
286
  delta = calculate_conviction_change(
287
  other_agent.config,
288
  other_agent.memory,
289
+ turn, # turn now includes direction
290
+ base_strength=base_strength
291
  )
292
 
293
+ # Store argument in memory (don't apply delta here - do it via apply_conviction_update)
294
+ other_agent.receive_argument(turn, 0.0)
295
  conviction_changes[other_id] = delta
296
  turn.impact[other_id] = delta
297
 
298
+ # Apply conviction update with hysteresis
299
+ vote_flipped, new_vote = apply_conviction_update(other_agent.memory, delta)
300
+ if vote_flipped and new_vote:
301
  self.state.votes[other_id] = new_vote
302
  vote_changes.append((other_id, old_vote, new_vote))
303
 
 
355
  target_id: str | None = None
356
  ) -> TurnResult:
357
  """Process an argument from the human player."""
358
+ # Determine direction from player's chosen side
359
+ direction = (
360
+ ArgumentDirection.PROSECUTION
361
+ if self.state.player_side == "prosecute"
362
+ else ArgumentDirection.DEFENSE
363
+ )
364
+
365
  turn = DeliberationTurn(
366
  round_number=self.state.round_number,
367
  speaker_id="juror_7",
368
  speaker_name="You",
369
  argument_type=argument_type,
370
+ direction=direction,
371
  content=content,
372
  target_id=target_id
373
  )
 
377
 
378
  for juror_id, agent in self.juror_agents.items():
379
  old_vote = self.state.votes.get(juror_id)
 
380
 
381
+ # Calculate base strength (player has moderate influence)
382
+ base_strength = 0.10
383
  if target_id == juror_id:
384
+ base_strength *= 1.5
385
 
386
  delta = calculate_conviction_change(
387
  agent.config,
388
  agent.memory,
389
  turn,
390
+ base_strength=base_strength
391
  )
392
 
393
+ # Store argument in memory (don't apply delta here)
394
+ agent.receive_argument(turn, 0.0)
395
  conviction_changes[juror_id] = delta
396
  turn.impact[juror_id] = delta
397
 
398
+ # Apply conviction update with hysteresis
399
+ vote_flipped, new_vote = apply_conviction_update(agent.memory, delta)
400
+ if vote_flipped and new_vote:
401
  self.state.votes[juror_id] = new_vote
402
  vote_changes.append((juror_id, old_vote, new_vote))
403
 
 
416
  speaker_id: str,
417
  speaker_name: str,
418
  content: str,
419
+ direction: ArgumentDirection, # REQUIRED - no fallback
420
+ argument_type: str = "logical",
421
  target_id: str | None = None
422
  ) -> TurnResult:
423
  """Process an argument from an external MCP agent.
 
429
  speaker_id: Juror seat ID (e.g., "juror_3")
430
  speaker_name: Display name for the speaker
431
  content: Argument text
432
+ direction: REQUIRED - "prosecution", "defense", or "neutral"
433
+ argument_type: Type of argument (default: "logical")
434
  target_id: Optional juror_id to address directly
435
 
436
  Returns:
 
441
  speaker_id=speaker_id,
442
  speaker_name=speaker_name,
443
  argument_type=argument_type,
444
+ direction=direction,
445
  content=content,
446
  target_id=target_id
447
  )
 
454
  continue
455
 
456
  old_vote = self.state.votes.get(juror_id)
 
457
 
458
+ # Calculate base strength (external agents have moderate influence)
459
+ base_strength = 0.10
460
  if target_id == juror_id:
461
+ base_strength *= 1.5
462
 
463
  delta = calculate_conviction_change(
464
  agent.config,
465
  agent.memory,
466
  turn,
467
+ base_strength=base_strength
468
  )
469
 
470
+ # Store argument in memory (don't apply delta here)
471
+ agent.receive_argument(turn, 0.0)
472
  conviction_changes[juror_id] = delta
473
  turn.impact[juror_id] = delta
474
 
475
+ # Apply conviction update with hysteresis
476
+ vote_flipped, new_vote = apply_conviction_update(agent.memory, delta)
477
+ if vote_flipped and new_vote:
478
  self.state.votes[juror_id] = new_vote
479
  vote_changes.append((juror_id, old_vote, new_vote))
480
 
services/__init__.py CHANGED
@@ -1,6 +1,8 @@
1
  """Services for 12 Angry Agents."""
2
 
3
  from .embeddings import EmbeddingService, get_embedding_service
 
 
4
  from .mcp import (
5
  MCPService,
6
  MCPSessionManager,
@@ -16,6 +18,11 @@ __all__ = [
16
  # Embedding service
17
  "EmbeddingService",
18
  "get_embedding_service",
 
 
 
 
 
19
  # MCP service
20
  "MCPService",
21
  "MCPSessionManager",
 
1
  """Services for 12 Angry Agents."""
2
 
3
  from .embeddings import EmbeddingService, get_embedding_service
4
+ from .tts import TTSService, get_tts_service, reset_tts_service
5
+ from .narration import JudgeNarration
6
  from .mcp import (
7
  MCPService,
8
  MCPSessionManager,
 
18
  # Embedding service
19
  "EmbeddingService",
20
  "get_embedding_service",
21
+ # TTS service
22
+ "TTSService",
23
+ "get_tts_service",
24
+ "reset_tts_service",
25
+ "JudgeNarration",
26
  # MCP service
27
  "MCPService",
28
  "MCPSessionManager",
services/mcp/service.py CHANGED
@@ -7,6 +7,7 @@ from enum import Enum
7
  from typing import TYPE_CHECKING, Optional
8
 
9
  from .session import MCPSessionManager, MCPSession
 
10
 
11
  if TYPE_CHECKING:
12
  from core.orchestrator import OrchestratorAgent, TurnResult
@@ -300,6 +301,7 @@ class MCPService:
300
  self,
301
  session_id: str,
302
  content: str,
 
303
  argument_type: str = "logical",
304
  target_juror: Optional[str] = None
305
  ) -> dict:
@@ -308,7 +310,8 @@ class MCPService:
308
  Args:
309
  session_id: Session token
310
  content: Argument text
311
- argument_type: Type of argument
 
312
  target_juror: Optional juror_id to address
313
 
314
  Returns:
@@ -330,6 +333,18 @@ class MCPService:
330
  "error_code": "EMPTY_CONTENT"
331
  }
332
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  # Validate argument type
334
  valid_types = ["logical", "emotional", "evidence", "moral", "narrative", "question"]
335
  if argument_type not in valid_types:
@@ -358,6 +373,7 @@ class MCPService:
358
  speaker_id=session.seat_id,
359
  speaker_name=speaker_name,
360
  content=content,
 
361
  argument_type=argument_type,
362
  target_id=target_juror
363
  )
@@ -372,6 +388,7 @@ class MCPService:
372
  "success": True,
373
  "data": {
374
  "message": "Argument submitted.",
 
375
  "vote_changes": [
376
  {"juror": vc[0], "from": vc[1], "to": vc[2]}
377
  for vc in result.vote_changes
 
7
  from typing import TYPE_CHECKING, Optional
8
 
9
  from .session import MCPSessionManager, MCPSession
10
+ from core.game_state import ArgumentDirection
11
 
12
  if TYPE_CHECKING:
13
  from core.orchestrator import OrchestratorAgent, TurnResult
 
301
  self,
302
  session_id: str,
303
  content: str,
304
+ direction: str, # REQUIRED: "prosecution", "defense", "neutral"
305
  argument_type: str = "logical",
306
  target_juror: Optional[str] = None
307
  ) -> dict:
 
310
  Args:
311
  session_id: Session token
312
  content: Argument text
313
+ direction: REQUIRED - "prosecution", "defense", or "neutral"
314
+ argument_type: Type of argument (default: "logical")
315
  target_juror: Optional juror_id to address
316
 
317
  Returns:
 
333
  "error_code": "EMPTY_CONTENT"
334
  }
335
 
336
+ # Validate direction (REQUIRED - no fallback)
337
+ valid_directions = ["prosecution", "defense", "neutral"]
338
+ if not direction or direction not in valid_directions:
339
+ return {
340
+ "success": False,
341
+ "error": f"direction is required and must be one of: {', '.join(valid_directions)}",
342
+ "error_code": "MISSING_DIRECTION"
343
+ }
344
+
345
+ # Convert direction string to ArgumentDirection enum
346
+ arg_direction = ArgumentDirection(direction)
347
+
348
  # Validate argument type
349
  valid_types = ["logical", "emotional", "evidence", "moral", "narrative", "question"]
350
  if argument_type not in valid_types:
 
373
  speaker_id=session.seat_id,
374
  speaker_name=speaker_name,
375
  content=content,
376
+ direction=arg_direction,
377
  argument_type=argument_type,
378
  target_id=target_juror
379
  )
 
388
  "success": True,
389
  "data": {
390
  "message": "Argument submitted.",
391
+ "direction": direction,
392
  "vote_changes": [
393
  {"juror": vc[0], "from": vc[1], "to": vc[2]}
394
  for vc in result.vote_changes
services/narration.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Judge narration text templates for different game events."""
2
+
3
+ from __future__ import annotations
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from case_db.models import CriminalCase
8
+ from core.game_state import GameState
9
+
10
+
11
+ class JudgeNarration:
12
+ """Generate judge narration text for game events.
13
+
14
+ Provides templated narration for:
15
+ - Case introduction
16
+ - Round transitions
17
+ - Vote shifts
18
+ - Final verdict
19
+ """
20
+
21
+ @staticmethod
22
+ def case_introduction(case: "CriminalCase") -> str:
23
+ """Generate case introduction narration.
24
+
25
+ Args:
26
+ case: The criminal case being tried.
27
+
28
+ Returns:
29
+ Narration text for the judge to speak.
30
+ """
31
+ charges = ", ".join(case.charges) if case.charges else "the charges presented"
32
+ defendant_name = case.defendant.name if case.defendant else "the defendant"
33
+
34
+ return (
35
+ f"Members of the jury. You are now empaneled to hear the case of "
36
+ f"{case.title}. The defendant, {defendant_name}, stands charged with "
37
+ f"{charges}. You will hear testimony from witnesses, examine the evidence, "
38
+ f"and determine whether the prosecution has proven its case beyond a "
39
+ f"reasonable doubt. Remember: the defendant is presumed innocent until "
40
+ f"proven guilty."
41
+ )
42
+
43
+ @staticmethod
44
+ def round_transition(round_number: int, guilty: int, not_guilty: int) -> str:
45
+ """Generate round transition narration.
46
+
47
+ Args:
48
+ round_number: Current deliberation round.
49
+ guilty: Current guilty votes.
50
+ not_guilty: Current not guilty votes.
51
+
52
+ Returns:
53
+ Narration text.
54
+ """
55
+ if round_number == 1:
56
+ return (
57
+ f"The initial vote has been taken. The count stands at "
58
+ f"{guilty} for guilty, {not_guilty} for not guilty. "
59
+ f"Let the deliberation begin."
60
+ )
61
+
62
+ return (
63
+ f"Round {round_number} of deliberations continues. "
64
+ f"The current vote stands at {guilty} for guilty, "
65
+ f"{not_guilty} for not guilty. Please proceed."
66
+ )
67
+
68
+ @staticmethod
69
+ def vote_shift(
70
+ shift_count: int,
71
+ direction: str,
72
+ guilty: int,
73
+ not_guilty: int,
74
+ ) -> str | None:
75
+ """Generate vote shift narration.
76
+
77
+ Args:
78
+ shift_count: Number of votes that shifted.
79
+ direction: Direction of shift ("conviction" or "acquittal").
80
+ guilty: New guilty vote count.
81
+ not_guilty: New not guilty vote count.
82
+
83
+ Returns:
84
+ Narration text, or None if shift not significant enough.
85
+ """
86
+ if shift_count < 2:
87
+ return None
88
+
89
+ return (
90
+ f"We have movement in the jury. {shift_count} jurors have shifted "
91
+ f"toward {direction}. The vote now stands at {guilty} guilty, "
92
+ f"{not_guilty} not guilty."
93
+ )
94
+
95
+ @staticmethod
96
+ def verdict(
97
+ verdict: str,
98
+ unanimous: bool,
99
+ guilty: int,
100
+ not_guilty: int,
101
+ rounds: int,
102
+ ) -> str:
103
+ """Generate verdict announcement narration.
104
+
105
+ Args:
106
+ verdict: Final verdict ("GUILTY", "NOT GUILTY", or "HUNG JURY").
107
+ unanimous: Whether verdict was unanimous.
108
+ guilty: Final guilty vote count.
109
+ not_guilty: Final not guilty vote count.
110
+ rounds: Number of deliberation rounds.
111
+
112
+ Returns:
113
+ Narration text.
114
+ """
115
+ if verdict == "HUNG JURY":
116
+ return (
117
+ f"After {rounds} rounds of deliberation, the jury remains "
118
+ f"hopelessly deadlocked at {guilty} to {not_guilty}. "
119
+ f"This court has no choice but to declare a mistrial. "
120
+ f"The defendant may be retried at the prosecution's discretion."
121
+ )
122
+
123
+ unanimity = "unanimously " if unanimous else ""
124
+ if verdict == "GUILTY":
125
+ return (
126
+ f"Members of the jury, have you reached a verdict? "
127
+ f"We have, your honor. "
128
+ f"After {rounds} rounds of deliberation, the jury has {unanimity}"
129
+ f"found the defendant... guilty. "
130
+ f"The defendant will be remanded for sentencing. "
131
+ f"This court thanks you for your service."
132
+ )
133
+ else: # NOT GUILTY
134
+ return (
135
+ f"Members of the jury, have you reached a verdict? "
136
+ f"We have, your honor. "
137
+ f"After {rounds} rounds of deliberation, the jury has {unanimity}"
138
+ f"found the defendant... not guilty. "
139
+ f"The defendant is free to go. "
140
+ f"This court thanks you for your service."
141
+ )
142
+
143
+ @staticmethod
144
+ def side_selected(side: str) -> str:
145
+ """Generate narration when player selects their side.
146
+
147
+ Args:
148
+ side: The side chosen ("prosecution" or "defense").
149
+
150
+ Returns:
151
+ Narration text.
152
+ """
153
+ if side == "prosecution":
154
+ return (
155
+ "You have chosen to argue for conviction. "
156
+ "Remember, the burden of proof lies with the prosecution. "
157
+ "The deliberation will now begin."
158
+ )
159
+ return (
160
+ "You have chosen to argue for acquittal. "
161
+ "The defense need only establish reasonable doubt. "
162
+ "The deliberation will now begin."
163
+ )
services/tts.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ElevenLabs TTS service for judge narration."""
2
+
3
+ import os
4
+ import asyncio
5
+ import hashlib
6
+ from pathlib import Path
7
+ from concurrent.futures import ThreadPoolExecutor
8
+
9
+ from elevenlabs.client import ElevenLabs
10
+ from elevenlabs import VoiceSettings
11
+
12
+
13
+ _tts_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="tts")
14
+
15
+
16
+ class TTSService:
17
+ """Non-blocking ElevenLabs TTS service for judge narration.
18
+
19
+ Features:
20
+ - Low-latency Flash v2.5 model (~75ms inference)
21
+ - Sync and async synthesis methods
22
+ - File-based caching for repeated narrations
23
+ - Graceful degradation when API unavailable
24
+ """
25
+
26
+ DEFAULT_MODEL = "eleven_flash_v2_5"
27
+ DEFAULT_OUTPUT_FORMAT = "mp3_44100_128"
28
+ CACHE_DIR = Path(".cache/tts")
29
+
30
+ def __init__(
31
+ self,
32
+ api_key: str | None = None,
33
+ voice_id: str | None = None,
34
+ model_id: str = DEFAULT_MODEL,
35
+ cache_enabled: bool = True,
36
+ ):
37
+ """Initialize the TTS service.
38
+
39
+ Args:
40
+ api_key: ElevenLabs API key. Falls back to ELEVENLABS_API_KEY env var.
41
+ voice_id: Voice ID to use. Falls back to VALOR_VOICE_ID env var.
42
+ model_id: Model ID (default: eleven_flash_v2_5 for low latency).
43
+ cache_enabled: Whether to cache synthesized audio.
44
+ """
45
+ self.api_key = api_key or os.getenv("ELEVENLABS_API_KEY")
46
+ self.voice_id = voice_id or os.getenv("VALOR_VOICE_ID", "JBFqnCBsd6RMkjVDRZzb")
47
+ self.model_id = model_id
48
+ self.cache_enabled = cache_enabled
49
+
50
+ self._client: ElevenLabs | None = None
51
+ self._available = False
52
+
53
+ if self.api_key:
54
+ try:
55
+ self._client = ElevenLabs(api_key=self.api_key)
56
+ self._available = True
57
+ except Exception as e:
58
+ print(f"TTS init failed: {e}")
59
+
60
+ if cache_enabled:
61
+ self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
62
+
63
+ @property
64
+ def is_available(self) -> bool:
65
+ """Check if TTS service is available."""
66
+ return self._available
67
+
68
+ def _get_voice_settings(self, style: str = "formal") -> VoiceSettings:
69
+ """Get voice settings for different narration styles.
70
+
71
+ Args:
72
+ style: Narration style - "formal" for case intro,
73
+ "dramatic" for vote shifts and verdict.
74
+ """
75
+ if style == "dramatic":
76
+ return VoiceSettings(
77
+ stability=0.6,
78
+ similarity_boost=0.8,
79
+ style=0.4,
80
+ use_speaker_boost=True,
81
+ )
82
+ # formal (default)
83
+ return VoiceSettings(
84
+ stability=0.75,
85
+ similarity_boost=0.75,
86
+ style=0.2,
87
+ use_speaker_boost=True,
88
+ )
89
+
90
+ def _get_cache_key(self, text: str, style: str) -> str:
91
+ """Generate cache key from text and style."""
92
+ content = f"{text}|{style}|{self.voice_id}|{self.model_id}"
93
+ return hashlib.sha256(content.encode()).hexdigest()[:16]
94
+
95
+ def _get_from_cache(self, text: str, style: str) -> bytes | None:
96
+ """Retrieve audio from cache if available."""
97
+ if not self.cache_enabled:
98
+ return None
99
+ cache_path = self.CACHE_DIR / f"{self._get_cache_key(text, style)}.mp3"
100
+ if cache_path.exists():
101
+ try:
102
+ return cache_path.read_bytes()
103
+ except Exception:
104
+ return None
105
+ return None
106
+
107
+ def _save_to_cache(self, text: str, style: str, audio: bytes) -> None:
108
+ """Save audio to cache."""
109
+ if not self.cache_enabled:
110
+ return
111
+ cache_path = self.CACHE_DIR / f"{self._get_cache_key(text, style)}.mp3"
112
+ try:
113
+ cache_path.write_bytes(audio)
114
+ except Exception as e:
115
+ print(f"Warning: Failed to cache TTS audio: {e}")
116
+
117
+ def synthesize(
118
+ self,
119
+ text: str,
120
+ style: str = "formal",
121
+ use_cache: bool = True,
122
+ ) -> bytes | None:
123
+ """Synthesize text to speech (synchronous).
124
+
125
+ Args:
126
+ text: Text to synthesize.
127
+ style: Narration style ("formal" or "dramatic").
128
+ use_cache: Whether to use/store cached audio.
129
+
130
+ Returns:
131
+ Audio bytes (MP3) or None if synthesis fails.
132
+ """
133
+ if not self.is_available or not text.strip():
134
+ return None
135
+
136
+ if use_cache:
137
+ cached = self._get_from_cache(text, style)
138
+ if cached:
139
+ return cached
140
+
141
+ try:
142
+ audio_gen = self._client.text_to_speech.convert(
143
+ text=text,
144
+ voice_id=self.voice_id,
145
+ model_id=self.model_id,
146
+ output_format=self.DEFAULT_OUTPUT_FORMAT,
147
+ voice_settings=self._get_voice_settings(style),
148
+ )
149
+ audio_bytes = b"".join(audio_gen)
150
+
151
+ if use_cache:
152
+ self._save_to_cache(text, style, audio_bytes)
153
+
154
+ return audio_bytes
155
+ except Exception as e:
156
+ print(f"TTS synthesis failed: {e}")
157
+ return None
158
+
159
+ async def asynthesize(
160
+ self,
161
+ text: str,
162
+ style: str = "formal",
163
+ use_cache: bool = True,
164
+ ) -> bytes | None:
165
+ """Synthesize text to speech (asynchronous, non-blocking).
166
+
167
+ Args:
168
+ text: Text to synthesize.
169
+ style: Narration style ("formal" or "dramatic").
170
+ use_cache: Whether to use/store cached audio.
171
+
172
+ Returns:
173
+ Audio bytes (MP3) or None if synthesis fails.
174
+ """
175
+ loop = asyncio.get_event_loop()
176
+ try:
177
+ return await asyncio.wait_for(
178
+ loop.run_in_executor(
179
+ _tts_executor,
180
+ lambda: self.synthesize(text, style, use_cache)
181
+ ),
182
+ timeout=10.0 # 10 second timeout
183
+ )
184
+ except asyncio.TimeoutError:
185
+ print("TTS synthesis timed out")
186
+ return None
187
+ except Exception as e:
188
+ print(f"Async TTS synthesis failed: {e}")
189
+ return None
190
+
191
+
192
+ # Singleton instance
193
+ _tts_service: TTSService | None = None
194
+
195
+
196
+ def get_tts_service(force_new: bool = False) -> TTSService:
197
+ """Get or create the singleton TTS service.
198
+
199
+ Args:
200
+ force_new: Force creation of a new instance.
201
+
202
+ Returns:
203
+ TTSService instance.
204
+ """
205
+ global _tts_service
206
+ if _tts_service is None or force_new:
207
+ _tts_service = TTSService()
208
+ return _tts_service
209
+
210
+
211
+ def reset_tts_service() -> None:
212
+ """Reset the singleton instance. Useful for testing."""
213
+ global _tts_service
214
+ _tts_service = None
ui/static/styles.css CHANGED
@@ -252,3 +252,58 @@ h1, h2, h3 {
252
  p, span {
253
  color: var(--text-light);
254
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  p, span {
253
  color: var(--text-light);
254
  }
255
+
256
+ /* ============================================
257
+ Judge Narration Component
258
+ ============================================ */
259
+ #judge-narration {
260
+ background: var(--secondary-dark);
261
+ border-radius: var(--border-radius-md);
262
+ padding: 12px;
263
+ margin-top: 15px;
264
+ border-left: 3px solid var(--accent-red);
265
+ }
266
+
267
+ #judge-narration .gr-group {
268
+ background: transparent !important;
269
+ border: none !important;
270
+ }
271
+
272
+ #judge-audio {
273
+ margin-bottom: 8px;
274
+ }
275
+
276
+ #judge-audio audio {
277
+ width: 100%;
278
+ height: 40px;
279
+ border-radius: var(--border-radius-sm);
280
+ }
281
+
282
+ #judge-transcript {
283
+ margin-top: 5px;
284
+ }
285
+
286
+ #judge-transcript textarea {
287
+ background: transparent !important;
288
+ border: none !important;
289
+ color: var(--text-muted) !important;
290
+ font-style: italic;
291
+ font-size: 12px;
292
+ padding: 5px 0 !important;
293
+ min-height: 40px !important;
294
+ }
295
+
296
+ #judge-transcript label {
297
+ display: none !important;
298
+ }
299
+
300
+ /* Pulsing animation when judge is speaking */
301
+ #judge-narration.speaking {
302
+ animation: judge-pulse 1.5s infinite;
303
+ }
304
+
305
+ @keyframes judge-pulse {
306
+ 0% { border-left-color: var(--accent-red); }
307
+ 50% { border-left-color: var(--guilty-red); }
308
+ 100% { border-left-color: var(--accent-red); }
309
+ }