Blu3Orange commited on
Commit
ff5767d
·
1 Parent(s): 6914efb

Add orchestrator for managing deliberation flow and speaker selection

Browse files
Files changed (3) hide show
  1. app.py +49 -123
  2. core/__init__.py +10 -0
  3. core/orchestrator.py +486 -0
app.py CHANGED
@@ -23,6 +23,7 @@ sys.path.insert(0, str(Path(__file__).parent))
23
  from core.game_state import GameState, GamePhase, DeliberationTurn
24
  from core.models import JurorConfig
25
  from core.conviction import calculate_conviction_change
 
26
  from case_db import CaseLoader, CriminalCase
27
  from agents import load_juror_configs, JurorAgent
28
  from ui.components import render_jury_box, render_vote_tally
@@ -33,15 +34,20 @@ CSS_PATH = Path(__file__).parent / "ui" / "static" / "styles.css"
33
 
34
 
35
  class JuryDeliberation:
36
- """Main game orchestrator."""
37
 
38
  def __init__(self):
39
  self.case_loader = CaseLoader()
40
  self.juror_configs = load_juror_configs()
41
  self.juror_agents: dict[str, JurorAgent] = {}
42
- self.game_state: GameState | None = None
43
  self.current_case: CriminalCase | None = None
44
 
 
 
 
 
 
45
  def initialize_game(self, case_id: str | None = None) -> tuple[str, str, str, list]:
46
  """Initialize a new game.
47
 
@@ -55,7 +61,6 @@ class JuryDeliberation:
55
  self.current_case = self.case_loader.get_random_case()
56
 
57
  if not self.current_case:
58
- # Use a default case if none found
59
  return (
60
  "No cases available. Please add case files to case_db/cases/",
61
  "",
@@ -63,27 +68,25 @@ class JuryDeliberation:
63
  []
64
  )
65
 
66
- # Initialize game state
67
- self.game_state = GameState(
68
- case_id=self.current_case.case_id,
69
- phase=GamePhase.PRESENTATION,
70
- )
71
-
72
  # Initialize juror agents (skip player seat 7)
73
  self.juror_agents = {}
74
  for config in self.juror_configs:
75
  if config.archetype != "player":
76
  try:
77
  agent = JurorAgent(config)
78
- # Set initial conviction
79
- conviction = agent.set_initial_conviction(self.current_case)
80
- # Set initial vote
81
- self.game_state.votes[config.juror_id] = agent.get_vote()
82
- self.game_state.conviction_scores[config.juror_id] = conviction
83
  self.juror_agents[config.juror_id] = agent
84
  except Exception as e:
85
  print(f"Warning: Failed to initialize {config.name}: {e}")
86
 
 
 
 
 
 
 
 
 
87
  # Format case info
88
  case_summary = f"""## {self.current_case.title}
89
 
@@ -96,7 +99,6 @@ class JuryDeliberation:
96
  {self.current_case.summary}
97
  """
98
 
99
- # Format evidence
100
  evidence_html = f"""### Evidence
101
 
102
  {self.current_case.get_evidence_summary()}
@@ -106,32 +108,20 @@ class JuryDeliberation:
106
  {self.current_case.get_witness_summary()}
107
  """
108
 
109
- # Render jury box
110
  jury_html = render_jury_box(self.juror_configs, self.game_state)
111
 
112
- # Initial chat with judge introduction
113
  chat_history = [
114
- {"role": "assistant", "content": f"**\u2696\uFE0F 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."}
115
  ]
116
 
117
  return case_summary, evidence_html, jury_html, chat_history
118
 
119
  def select_player_side(self, side: str) -> tuple[str, str, list]:
120
- """Player selects prosecution or defense side.
121
-
122
- Returns:
123
- Tuple of (vote_tally_html, jury_html, chat_history_update)
124
- """
125
- if not self.game_state:
126
  return "", "", []
127
 
128
- self.game_state.player_side = side
129
- self.game_state.phase = GamePhase.DELIBERATION
130
-
131
- # Set player's vote based on side
132
- player_vote = "guilty" if side == "prosecute" else "not_guilty"
133
- self.game_state.votes["juror_7"] = player_vote
134
- self.game_state.conviction_scores["juror_7"] = 0.8 if side == "prosecute" else 0.2
135
 
136
  vote_html = render_vote_tally(self.game_state)
137
  jury_html = render_jury_box(self.juror_configs, self.game_state)
@@ -142,69 +132,33 @@ class JuryDeliberation:
142
  return vote_html, jury_html, chat_update
143
 
144
  async def run_deliberation_round(self) -> tuple[str, str, list[dict]]:
145
- """Run a single round of AI juror deliberation.
146
-
147
- Returns:
148
- Tuple of (vote_html, jury_html, new_chat_messages)
149
- """
150
- if not self.game_state or not self.current_case:
151
  return "", "", []
152
 
153
- self.game_state.round_number += 1
154
  new_messages = []
155
 
156
- # Select 1-3 random speakers (excluding player)
157
- available_jurors = [
158
- jid for jid in self.juror_agents.keys()
159
- if jid != "juror_7"
160
- ]
161
- num_speakers = random.randint(1, min(3, len(available_jurors)))
162
- speakers = random.sample(available_jurors, num_speakers)
163
 
164
- # Each speaker makes an argument
165
- for speaker_id in speakers:
166
- agent = self.juror_agents[speaker_id]
167
- config = next(c for c in self.juror_configs if c.juror_id == speaker_id)
168
 
169
- try:
170
- # Generate argument
171
- turn = await agent.generate_argument(self.current_case, self.game_state)
 
172
 
173
- # Add to chat
 
 
 
174
  new_messages.append(
175
- {"role": "assistant", "content": f"**{config.emoji} {config.name}:** {turn.content}"}
176
  )
177
 
178
- # Other jurors react
179
- for other_id, other_agent in self.juror_agents.items():
180
- if other_id != speaker_id:
181
- # Calculate impact based on archetype compatibility
182
- base_impact = random.uniform(-0.15, 0.15)
183
- impact = calculate_conviction_change(
184
- other_agent.config,
185
- other_agent.memory,
186
- turn,
187
- base_impact=base_impact
188
- )
189
- other_agent.receive_argument(turn, impact)
190
-
191
- # Update vote if conviction changed significantly
192
- new_vote = other_agent.get_vote()
193
- if self.game_state.votes.get(other_id) != new_vote:
194
- self.game_state.votes[other_id] = new_vote
195
- vote_text = "GUILTY" if new_vote == "guilty" else "NOT GUILTY"
196
- other_config = next(c for c in self.juror_configs if c.juror_id == other_id)
197
- new_messages.append(
198
- {"role": "assistant", "content": f"*{other_config.emoji} {other_config.name} changes their vote to {vote_text}*"}
199
- )
200
-
201
- # Log turn
202
- self.game_state.deliberation_log.append(turn)
203
-
204
- except Exception as e:
205
- print(f"Error in deliberation for {speaker_id}: {e}")
206
-
207
- # Update displays
208
  vote_html = render_vote_tally(self.game_state)
209
  jury_html = render_jury_box(self.juror_configs, self.game_state)
210
 
@@ -215,22 +169,16 @@ class JuryDeliberation:
215
  strategy: str,
216
  custom_text: str | None = None
217
  ) -> tuple[str, str, list[tuple]]:
218
- """Process player's argument.
219
-
220
- Returns:
221
- Tuple of (vote_html, jury_html, new_chat_messages)
222
- """
223
- if not self.game_state or not self.current_case:
224
  return "", "", []
225
 
226
- # Get player config
227
  player_config = next(c for c in self.juror_configs if c.archetype == "player")
228
 
229
  # Create argument content
230
  if custom_text:
231
  content = custom_text
232
  else:
233
- # Generate content based on strategy
234
  strategy_templates = {
235
  "Challenge Evidence": "I'd like to point out some issues with the evidence presented...",
236
  "Question Witness Credibility": "Can we really trust the witness testimony here?",
@@ -240,41 +188,19 @@ class JuryDeliberation:
240
  }
241
  content = strategy_templates.get(strategy, "I have some concerns about this case...")
242
 
243
- # Create turn
244
- turn = DeliberationTurn(
245
- round_number=self.game_state.round_number,
246
- speaker_id="juror_7",
247
- speaker_name="You",
248
- argument_type=strategy.lower().replace(" ", "_"),
249
- content=content,
250
- )
251
 
252
  new_messages = [{"role": "user", "content": f"**{player_config.emoji} You:** {content}"}]
253
 
254
- # AI jurors react
255
- for juror_id, agent in self.juror_agents.items():
256
- base_impact = random.uniform(-0.1, 0.1)
257
- # Player arguments have moderate base influence
258
- base_impact *= 1.2
259
- impact = calculate_conviction_change(
260
- agent.config,
261
- agent.memory,
262
- turn,
263
- base_impact=base_impact
264
  )
265
- agent.receive_argument(turn, impact)
266
-
267
- # Check for vote changes
268
- new_vote = agent.get_vote()
269
- if self.game_state.votes.get(juror_id) != new_vote:
270
- self.game_state.votes[juror_id] = new_vote
271
- config = next(c for c in self.juror_configs if c.juror_id == juror_id)
272
- vote_text = "GUILTY" if new_vote == "guilty" else "NOT GUILTY"
273
- new_messages.append(
274
- {"role": "assistant", "content": f"*{config.emoji} {config.name} changes their vote to {vote_text}*"}
275
- )
276
-
277
- self.game_state.deliberation_log.append(turn)
278
 
279
  vote_html = render_vote_tally(self.game_state)
280
  jury_html = render_jury_box(self.juror_configs, self.game_state)
 
23
  from core.game_state import GameState, GamePhase, DeliberationTurn
24
  from core.models import JurorConfig
25
  from core.conviction import calculate_conviction_change
26
+ from core.orchestrator import OrchestratorAgent, TurnManager
27
  from case_db import CaseLoader, CriminalCase
28
  from agents import load_juror_configs, JurorAgent
29
  from ui.components import render_jury_box, render_vote_tally
 
34
 
35
 
36
  class JuryDeliberation:
37
+ """Main game orchestrator - delegates to OrchestratorAgent."""
38
 
39
  def __init__(self):
40
  self.case_loader = CaseLoader()
41
  self.juror_configs = load_juror_configs()
42
  self.juror_agents: dict[str, JurorAgent] = {}
43
+ self.orchestrator: OrchestratorAgent | None = None
44
  self.current_case: CriminalCase | None = None
45
 
46
+ @property
47
+ def game_state(self) -> GameState | None:
48
+ """Get game state from orchestrator."""
49
+ return self.orchestrator.game_state if self.orchestrator else None
50
+
51
  def initialize_game(self, case_id: str | None = None) -> tuple[str, str, str, list]:
52
  """Initialize a new game.
53
 
 
61
  self.current_case = self.case_loader.get_random_case()
62
 
63
  if not self.current_case:
 
64
  return (
65
  "No cases available. Please add case files to case_db/cases/",
66
  "",
 
68
  []
69
  )
70
 
 
 
 
 
 
 
71
  # Initialize juror agents (skip player seat 7)
72
  self.juror_agents = {}
73
  for config in self.juror_configs:
74
  if config.archetype != "player":
75
  try:
76
  agent = JurorAgent(config)
77
+ agent.set_initial_conviction(self.current_case)
 
 
 
 
78
  self.juror_agents[config.juror_id] = agent
79
  except Exception as e:
80
  print(f"Warning: Failed to initialize {config.name}: {e}")
81
 
82
+ # Create orchestrator with agents
83
+ self.orchestrator = OrchestratorAgent(
84
+ juror_configs=self.juror_configs,
85
+ juror_agents=self.juror_agents,
86
+ case=self.current_case
87
+ )
88
+ self.orchestrator.state.phase = GamePhase.PRESENTATION
89
+
90
  # Format case info
91
  case_summary = f"""## {self.current_case.title}
92
 
 
99
  {self.current_case.summary}
100
  """
101
 
 
102
  evidence_html = f"""### Evidence
103
 
104
  {self.current_case.get_evidence_summary()}
 
108
  {self.current_case.get_witness_summary()}
109
  """
110
 
 
111
  jury_html = render_jury_box(self.juror_configs, self.game_state)
112
 
 
113
  chat_history = [
114
+ {"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."}
115
  ]
116
 
117
  return case_summary, evidence_html, jury_html, chat_history
118
 
119
  def select_player_side(self, side: str) -> tuple[str, str, list]:
120
+ """Player selects prosecution or defense side."""
121
+ if not self.orchestrator:
 
 
 
 
122
  return "", "", []
123
 
124
+ self.orchestrator.set_player_side(side)
 
 
 
 
 
 
125
 
126
  vote_html = render_vote_tally(self.game_state)
127
  jury_html = render_jury_box(self.juror_configs, self.game_state)
 
132
  return vote_html, jury_html, chat_update
133
 
134
  async def run_deliberation_round(self) -> tuple[str, str, list[dict]]:
135
+ """Run a single round of AI juror deliberation using fair speaker queue."""
136
+ if not self.orchestrator or not self.current_case:
 
 
 
 
137
  return "", "", []
138
 
 
139
  new_messages = []
140
 
141
+ # Use orchestrator's fair speaker selection
142
+ results = await self.orchestrator.run_deliberation_round()
 
 
 
 
 
143
 
144
+ # Format results for chat
145
+ for result in results:
146
+ turn = result.turn
147
+ config = next(c for c in self.juror_configs if c.juror_id == turn.speaker_id)
148
 
149
+ # Speaker's argument
150
+ new_messages.append(
151
+ {"role": "assistant", "content": f"**{config.emoji} {config.name}:** {turn.content}"}
152
+ )
153
 
154
+ # Vote changes
155
+ for juror_id, old_vote, new_vote in result.vote_changes:
156
+ other_config = next(c for c in self.juror_configs if c.juror_id == juror_id)
157
+ vote_text = "GUILTY" if new_vote == "guilty" else "NOT GUILTY"
158
  new_messages.append(
159
+ {"role": "assistant", "content": f"*{other_config.emoji} {other_config.name} changes their vote to {vote_text}*"}
160
  )
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  vote_html = render_vote_tally(self.game_state)
163
  jury_html = render_jury_box(self.juror_configs, self.game_state)
164
 
 
169
  strategy: str,
170
  custom_text: str | None = None
171
  ) -> tuple[str, str, list[tuple]]:
172
+ """Process player's argument using orchestrator."""
173
+ if not self.orchestrator or not self.current_case:
 
 
 
 
174
  return "", "", []
175
 
 
176
  player_config = next(c for c in self.juror_configs if c.archetype == "player")
177
 
178
  # Create argument content
179
  if custom_text:
180
  content = custom_text
181
  else:
 
182
  strategy_templates = {
183
  "Challenge Evidence": "I'd like to point out some issues with the evidence presented...",
184
  "Question Witness Credibility": "Can we really trust the witness testimony here?",
 
188
  }
189
  content = strategy_templates.get(strategy, "I have some concerns about this case...")
190
 
191
+ # Use orchestrator to process
192
+ argument_type = strategy.lower().replace(" ", "_")
193
+ result = self.orchestrator.process_player_argument(content, argument_type)
 
 
 
 
 
194
 
195
  new_messages = [{"role": "user", "content": f"**{player_config.emoji} You:** {content}"}]
196
 
197
+ # Vote changes
198
+ for juror_id, old_vote, new_vote in result.vote_changes:
199
+ config = next(c for c in self.juror_configs if c.juror_id == juror_id)
200
+ vote_text = "GUILTY" if new_vote == "guilty" else "NOT GUILTY"
201
+ new_messages.append(
202
+ {"role": "assistant", "content": f"*{config.emoji} {config.name} changes their vote to {vote_text}*"}
 
 
 
 
203
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
  vote_html = render_vote_tally(self.game_state)
206
  jury_html = render_jury_box(self.juror_configs, self.game_state)
core/__init__.py CHANGED
@@ -11,6 +11,12 @@ from .models import (
11
  ArgumentMemory,
12
  )
13
  from .conviction import calculate_conviction_change, check_vote_flip
 
 
 
 
 
 
14
 
15
  __all__ = [
16
  "GameState",
@@ -21,4 +27,8 @@ __all__ = [
21
  "ArgumentMemory",
22
  "calculate_conviction_change",
23
  "check_vote_flip",
 
 
 
 
24
  ]
 
11
  ArgumentMemory,
12
  )
13
  from .conviction import calculate_conviction_change, check_vote_flip
14
+ from .orchestrator import (
15
+ OrchestratorAgent,
16
+ TurnManager,
17
+ TurnResult,
18
+ SpeakerWeight,
19
+ )
20
 
21
  __all__ = [
22
  "GameState",
 
27
  "ArgumentMemory",
28
  "calculate_conviction_change",
29
  "check_vote_flip",
30
+ "OrchestratorAgent",
31
+ "TurnManager",
32
+ "TurnResult",
33
+ "SpeakerWeight",
34
  ]
core/orchestrator.py ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestrator for 12 Angry Agents deliberation.
2
+
3
+ Handles turn management, speaker selection, and deliberation flow.
4
+ """
5
+
6
+ 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.base_juror import JurorAgent
16
+ from case_db.models import CriminalCase
17
+
18
+
19
+ @dataclass
20
+ class SpeakerWeight:
21
+ """Weight information for speaker selection."""
22
+ juror_id: str
23
+ weight: float
24
+ reason: str # Why this weight was assigned
25
+
26
+
27
+ @dataclass
28
+ class TurnResult:
29
+ """Result of a single turn in deliberation."""
30
+ turn: DeliberationTurn
31
+ conviction_changes: dict[str, float] # juror_id -> delta
32
+ vote_changes: list[tuple[str, str, str]] # (juror_id, old_vote, new_vote)
33
+
34
+
35
+ class TurnManager:
36
+ """Manages fair speaker selection with weighted queue.
37
+
38
+ Selection priorities:
39
+ 1. Jurors "on the fence" (conviction 0.35-0.65) - most interesting
40
+ 2. Jurors who haven't spoken recently - fairness
41
+ 3. Jurors with high influence - they drive conversation
42
+ 4. Some randomness to keep things unpredictable
43
+ """
44
+
45
+ # Weights for different factors
46
+ ON_FENCE_BONUS = 2.0 # Bonus for jurors near 0.5 conviction
47
+ RECENCY_PENALTY = 0.3 # Multiplier for recent speakers
48
+ INFLUENCE_WEIGHT = 1.5 # How much influence affects selection
49
+ RANDOM_FACTOR = 0.3 # Random noise to add variety
50
+
51
+ # Track how many rounds before a juror can be "prioritized" again
52
+ RECENCY_WINDOW = 2
53
+
54
+ def __init__(self):
55
+ self.speaker_history: list[list[str]] = [] # Per-round speaker lists
56
+
57
+ def select_speakers(
58
+ self,
59
+ game_state: GameState,
60
+ juror_configs: list[JurorConfig],
61
+ juror_memories: dict[str, JurorMemory],
62
+ num_speakers: int = None,
63
+ exclude_player: bool = True
64
+ ) -> list[str]:
65
+ """Select speakers for the next round using weighted selection.
66
+
67
+ Args:
68
+ game_state: Current game state
69
+ juror_configs: All juror configurations
70
+ juror_memories: Memory state for each juror
71
+ num_speakers: Number of speakers (1-4, random if None)
72
+ exclude_player: Whether to exclude player from selection
73
+
74
+ Returns:
75
+ List of juror_ids selected to speak
76
+ """
77
+ # Determine number of speakers
78
+ if num_speakers is None:
79
+ num_speakers = random.randint(1, 3)
80
+
81
+ # Get eligible jurors
82
+ eligible = [
83
+ c for c in juror_configs
84
+ if not (exclude_player and c.is_player())
85
+ ]
86
+
87
+ if not eligible:
88
+ return []
89
+
90
+ # Calculate weights for each juror
91
+ weights = self._calculate_weights(
92
+ eligible,
93
+ juror_memories,
94
+ game_state.round_number
95
+ )
96
+
97
+ # Select speakers using weighted random selection
98
+ selected = self._weighted_select(weights, min(num_speakers, len(eligible)))
99
+
100
+ # Record this round's speakers
101
+ self.speaker_history.append(selected)
102
+
103
+ # Update game state speaking queue
104
+ game_state.speaking_queue = selected
105
+
106
+ return selected
107
+
108
+ def _calculate_weights(
109
+ self,
110
+ configs: list[JurorConfig],
111
+ memories: dict[str, JurorMemory],
112
+ current_round: int
113
+ ) -> list[SpeakerWeight]:
114
+ """Calculate selection weight for each juror."""
115
+ weights = []
116
+
117
+ for config in configs:
118
+ jid = config.juror_id
119
+ memory = memories.get(jid)
120
+
121
+ # Base weight from influence
122
+ base_weight = 0.5 + (config.influence * self.INFLUENCE_WEIGHT)
123
+
124
+ # On-the-fence bonus (conviction between 0.35-0.65)
125
+ if memory:
126
+ conviction = memory.current_conviction
127
+ fence_distance = abs(conviction - 0.5)
128
+ if fence_distance < 0.15: # Very on the fence
129
+ fence_bonus = self.ON_FENCE_BONUS * (1 - fence_distance / 0.15)
130
+ else:
131
+ fence_bonus = 0.0
132
+ else:
133
+ fence_bonus = 0.0
134
+
135
+ # Recency penalty - spoke recently?
136
+ recency_multiplier = 1.0
137
+ reason_parts = []
138
+
139
+ for rounds_ago, speakers in enumerate(reversed(self.speaker_history[-self.RECENCY_WINDOW:])):
140
+ if jid in speakers:
141
+ # More recent = bigger penalty
142
+ penalty = self.RECENCY_PENALTY ** (rounds_ago + 1)
143
+ recency_multiplier *= penalty
144
+ reason_parts.append(f"spoke {rounds_ago + 1} rounds ago")
145
+ break
146
+
147
+ # Volatility bonus - volatile jurors speak more
148
+ volatility_bonus = config.volatility * 0.5
149
+
150
+ # Calculate final weight
151
+ weight = (base_weight + fence_bonus + volatility_bonus) * recency_multiplier
152
+
153
+ # Add some randomness
154
+ weight += random.uniform(0, self.RANDOM_FACTOR)
155
+
156
+ # Build reason string
157
+ reasons = []
158
+ if fence_bonus > 0:
159
+ reasons.append(f"on fence (+{fence_bonus:.2f})")
160
+ if recency_multiplier < 1.0:
161
+ reasons.append(f"recent speaker (x{recency_multiplier:.2f})")
162
+ if config.influence > 0.6:
163
+ reasons.append(f"high influence")
164
+ if config.volatility > 0.6:
165
+ reasons.append(f"volatile")
166
+
167
+ weights.append(SpeakerWeight(
168
+ juror_id=jid,
169
+ weight=max(0.1, weight), # Minimum weight to ensure everyone has a chance
170
+ reason=", ".join(reasons) if reasons else "baseline"
171
+ ))
172
+
173
+ return weights
174
+
175
+ def _weighted_select(
176
+ self,
177
+ weights: list[SpeakerWeight],
178
+ count: int
179
+ ) -> list[str]:
180
+ """Select jurors using weighted random selection without replacement."""
181
+ selected = []
182
+ remaining = list(weights)
183
+
184
+ for _ in range(count):
185
+ if not remaining:
186
+ break
187
+
188
+ # Calculate total weight
189
+ total = sum(w.weight for w in remaining)
190
+ if total <= 0:
191
+ break
192
+
193
+ # Random selection
194
+ r = random.uniform(0, total)
195
+ cumulative = 0
196
+
197
+ for i, w in enumerate(remaining):
198
+ cumulative += w.weight
199
+ if r <= cumulative:
200
+ selected.append(w.juror_id)
201
+ remaining.pop(i)
202
+ break
203
+
204
+ return selected
205
+
206
+ def get_speaker_weights_debug(
207
+ self,
208
+ configs: list[JurorConfig],
209
+ memories: dict[str, JurorMemory],
210
+ current_round: int
211
+ ) -> list[SpeakerWeight]:
212
+ """Get weights for debugging/display purposes."""
213
+ return self._calculate_weights(configs, memories, current_round)
214
+
215
+ def reset(self):
216
+ """Reset speaker history for new game."""
217
+ self.speaker_history = []
218
+
219
+
220
+ class OrchestratorAgent:
221
+ """Master agent that coordinates the deliberation.
222
+
223
+ Handles:
224
+ - Game phase transitions
225
+ - Turn management and speaker selection
226
+ - Processing arguments and reactions
227
+ - Vote tracking and stability detection
228
+ """
229
+
230
+ def __init__(
231
+ self,
232
+ juror_configs: list[JurorConfig],
233
+ juror_agents: dict[str, "JurorAgent"],
234
+ case: "CriminalCase"
235
+ ):
236
+ self.juror_configs = juror_configs
237
+ self.juror_agents = juror_agents
238
+ self.case = case
239
+ self.turn_manager = TurnManager()
240
+
241
+ # Initialize game state
242
+ self.state = GameState(case_id=case.case_id)
243
+
244
+ # Initialize votes and convictions from agents
245
+ for jid, agent in juror_agents.items():
246
+ self.state.votes[jid] = agent.get_vote()
247
+ self.state.conviction_scores[jid] = agent.memory.current_conviction
248
+
249
+ @property
250
+ def game_state(self) -> GameState:
251
+ """Get current game state."""
252
+ return self.state
253
+
254
+ def get_juror_memories(self) -> dict[str, JurorMemory]:
255
+ """Get memory state for all jurors."""
256
+ return {jid: agent.memory for jid, agent in self.juror_agents.items()}
257
+
258
+ async def run_deliberation_round(
259
+ self,
260
+ num_speakers: int = None
261
+ ) -> list[TurnResult]:
262
+ """Run a single round of deliberation.
263
+
264
+ Args:
265
+ num_speakers: Number of AI speakers this round (random 1-3 if None)
266
+
267
+ Returns:
268
+ List of TurnResult for each speaker
269
+ """
270
+ self.state.round_number += 1
271
+ results = []
272
+
273
+ # Record vote snapshot at start of round
274
+ votes_at_start = dict(self.state.votes)
275
+
276
+ # Select speakers using fair queue
277
+ speakers = self.turn_manager.select_speakers(
278
+ self.state,
279
+ self.juror_configs,
280
+ self.get_juror_memories(),
281
+ num_speakers=num_speakers,
282
+ exclude_player=True
283
+ )
284
+
285
+ # Process each speaker
286
+ for speaker_id in speakers:
287
+ result = await self._process_speaker_turn(speaker_id)
288
+ if result:
289
+ results.append(result)
290
+
291
+ # Check for vote stability
292
+ if self.state.votes == votes_at_start:
293
+ self.state.rounds_without_change += 1
294
+ else:
295
+ self.state.rounds_without_change = 0
296
+
297
+ return results
298
+
299
+ async def _process_speaker_turn(self, speaker_id: str) -> TurnResult | None:
300
+ """Process a single speaker's turn.
301
+
302
+ Args:
303
+ speaker_id: ID of the speaking juror
304
+
305
+ Returns:
306
+ TurnResult with argument and impacts, or None on error
307
+ """
308
+ agent = self.juror_agents.get(speaker_id)
309
+ if not agent:
310
+ return None
311
+
312
+ try:
313
+ # Generate argument
314
+ turn = await agent.generate_argument(self.case, self.state)
315
+
316
+ # Process reactions from other jurors
317
+ conviction_changes = {}
318
+ vote_changes = []
319
+
320
+ for other_id, other_agent in self.juror_agents.items():
321
+ if other_id == speaker_id:
322
+ continue
323
+
324
+ # Calculate conviction change
325
+ old_vote = self.state.votes.get(other_id)
326
+ old_conviction = other_agent.memory.current_conviction
327
+
328
+ # Base impact with some randomness
329
+ base_impact = random.uniform(-0.15, 0.15)
330
+
331
+ # Calculate actual impact
332
+ delta = calculate_conviction_change(
333
+ other_agent.config,
334
+ other_agent.memory,
335
+ turn,
336
+ base_impact=base_impact
337
+ )
338
+
339
+ # Apply to agent
340
+ other_agent.receive_argument(turn, delta)
341
+ conviction_changes[other_id] = delta
342
+
343
+ # Record in turn impact
344
+ turn.impact[other_id] = delta
345
+
346
+ # Check for vote change
347
+ new_vote = other_agent.get_vote()
348
+ if old_vote != new_vote:
349
+ self.state.votes[other_id] = new_vote
350
+ vote_changes.append((other_id, old_vote, new_vote))
351
+
352
+ # Update conviction in game state
353
+ self.state.conviction_scores[other_id] = other_agent.memory.current_conviction
354
+
355
+ # Log the turn
356
+ self.state.deliberation_log.append(turn)
357
+
358
+ return TurnResult(
359
+ turn=turn,
360
+ conviction_changes=conviction_changes,
361
+ vote_changes=vote_changes
362
+ )
363
+
364
+ except Exception as e:
365
+ print(f"Error processing turn for {speaker_id}: {e}")
366
+ return None
367
+
368
+ def process_player_argument(
369
+ self,
370
+ content: str,
371
+ argument_type: str,
372
+ target_id: str | None = None
373
+ ) -> TurnResult:
374
+ """Process an argument from the human player.
375
+
376
+ Args:
377
+ content: The argument text
378
+ argument_type: Type of argument (e.g., "challenge_evidence")
379
+ target_id: Optional specific juror being addressed
380
+
381
+ Returns:
382
+ TurnResult with impacts
383
+ """
384
+ # Create turn
385
+ turn = DeliberationTurn(
386
+ round_number=self.state.round_number,
387
+ speaker_id="juror_7",
388
+ speaker_name="You",
389
+ argument_type=argument_type,
390
+ content=content,
391
+ target_id=target_id
392
+ )
393
+
394
+ # Process reactions
395
+ conviction_changes = {}
396
+ vote_changes = []
397
+
398
+ for juror_id, agent in self.juror_agents.items():
399
+ old_vote = self.state.votes.get(juror_id)
400
+
401
+ # Player arguments have slightly higher base impact
402
+ base_impact = random.uniform(-0.1, 0.1) * 1.2
403
+
404
+ # Bonus if targeting this specific juror
405
+ if target_id == juror_id:
406
+ base_impact *= 1.5
407
+
408
+ delta = calculate_conviction_change(
409
+ agent.config,
410
+ agent.memory,
411
+ turn,
412
+ base_impact=base_impact
413
+ )
414
+
415
+ agent.receive_argument(turn, delta)
416
+ conviction_changes[juror_id] = delta
417
+ turn.impact[juror_id] = delta
418
+
419
+ # Check vote change
420
+ new_vote = agent.get_vote()
421
+ if old_vote != new_vote:
422
+ self.state.votes[juror_id] = new_vote
423
+ vote_changes.append((juror_id, old_vote, new_vote))
424
+
425
+ self.state.conviction_scores[juror_id] = agent.memory.current_conviction
426
+
427
+ self.state.deliberation_log.append(turn)
428
+
429
+ return TurnResult(
430
+ turn=turn,
431
+ conviction_changes=conviction_changes,
432
+ vote_changes=vote_changes
433
+ )
434
+
435
+ def set_player_side(self, side: str) -> None:
436
+ """Set the player's chosen side.
437
+
438
+ Args:
439
+ side: "prosecute" or "defend"
440
+ """
441
+ self.state.player_side = side
442
+ self.state.phase = GamePhase.DELIBERATION
443
+
444
+ # Set player vote
445
+ player_vote = "guilty" if side == "prosecute" else "not_guilty"
446
+ self.state.votes["juror_7"] = player_vote
447
+ self.state.conviction_scores["juror_7"] = 0.8 if side == "prosecute" else 0.2
448
+
449
+ def check_should_end(self) -> bool:
450
+ """Check if deliberation should end."""
451
+ return self.state.should_end_deliberation()
452
+
453
+ def get_verdict(self) -> dict:
454
+ """Get the final verdict information."""
455
+ guilty, not_guilty = self.state.get_vote_tally()
456
+
457
+ if self.state.is_unanimous():
458
+ verdict = "GUILTY" if guilty == 12 else "NOT GUILTY"
459
+ unanimous = True
460
+ else:
461
+ verdict = "HUNG JURY"
462
+ unanimous = False
463
+
464
+ return {
465
+ "verdict": verdict,
466
+ "unanimous": unanimous,
467
+ "guilty_count": guilty,
468
+ "not_guilty_count": not_guilty,
469
+ "rounds": self.state.round_number,
470
+ "ended_by": self._get_end_reason()
471
+ }
472
+
473
+ def _get_end_reason(self) -> str:
474
+ """Get the reason deliberation ended."""
475
+ if self.state.is_unanimous():
476
+ return "unanimous_verdict"
477
+ elif self.state.rounds_without_change >= self.state.stability_threshold:
478
+ return "votes_stabilized"
479
+ elif self.state.round_number >= self.state.max_rounds:
480
+ return "max_rounds_reached"
481
+ return "unknown"
482
+
483
+ def reset(self) -> None:
484
+ """Reset for a new game."""
485
+ self.turn_manager.reset()
486
+ self.state = GameState(case_id=self.case.case_id if self.case else "")