prabalGaur commited on
Commit
2ed47a5
·
verified ·
1 Parent(s): 659bce5

Upload community_contributions/emmy/llm_battle.py with huggingface_hub

Browse files
community_contributions/emmy/llm_battle.py ADDED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import Dict, Generator, List, Optional, Tuple
7
+
8
+ import gradio as gr
9
+ from dotenv import load_dotenv
10
+ from openai import OpenAI
11
+
12
+ load_dotenv()
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Configuration helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ @dataclass
21
+ class AgentConfig:
22
+ """Holds configuration required to talk to an LLM provider."""
23
+
24
+ name: str
25
+ model: str
26
+ api_key_env: str
27
+ base_url_env: Optional[str] = None
28
+ temperature: float = 0.7
29
+ supports_json: bool = True
30
+
31
+
32
+ def load_client(config: AgentConfig) -> OpenAI:
33
+ """Create an OpenAI-compatible client for the given agent."""
34
+ api_key = os.getenv(config.api_key_env) or os.getenv("OPENAI_API_KEY")
35
+ if not api_key:
36
+ raise RuntimeError(
37
+ f"Missing API key for {config.name}. "
38
+ f"Set {config.api_key_env} or OPENAI_API_KEY."
39
+ )
40
+
41
+ base_url = (
42
+ os.getenv(config.base_url_env)
43
+ if config.base_url_env
44
+ else os.getenv("OPENAI_BASE_URL")
45
+ )
46
+
47
+ return OpenAI(api_key=api_key, base_url=base_url)
48
+
49
+
50
+ def extract_text(response) -> str:
51
+ """Extract text content from an OpenAI-style response object or dict."""
52
+
53
+ choices = getattr(response, "choices", None)
54
+ if choices is None and isinstance(response, dict):
55
+ choices = response.get("choices")
56
+ if not choices:
57
+ raise RuntimeError(f"LLM response missing choices field: {response!r}")
58
+
59
+ choice = choices[0]
60
+ message = getattr(choice, "message", None)
61
+ if message is None and isinstance(choice, dict):
62
+ message = choice.get("message")
63
+
64
+ content = None
65
+ if message is not None:
66
+ content = getattr(message, "content", None)
67
+ if content is None and isinstance(message, dict):
68
+ content = message.get("content")
69
+
70
+ if isinstance(content, list):
71
+ parts: List[str] = []
72
+ for part in content:
73
+ if isinstance(part, dict):
74
+ if "text" in part:
75
+ parts.append(str(part["text"]))
76
+ elif "output_text" in part:
77
+ parts.append(str(part["output_text"]))
78
+ elif "type" in part and "content" in part:
79
+ parts.append(str(part["content"]))
80
+ else:
81
+ parts.append(str(part))
82
+ content = "".join(parts)
83
+
84
+ if content is None:
85
+ text = getattr(choice, "text", None)
86
+ if text is None and isinstance(choice, dict):
87
+ text = choice.get("text")
88
+ if text:
89
+ content = text
90
+
91
+ if content is None:
92
+ raise RuntimeError(f"LLM response missing content/text: {response!r}")
93
+
94
+ return str(content).strip()
95
+
96
+
97
+ # Default configuration leverages OpenAI unless overrides are provided.
98
+ DEBATER_A_CONFIG = AgentConfig(
99
+ name="Debater A",
100
+ model=os.getenv("DEBATER_A_MODEL", "gpt-4o"),
101
+ api_key_env="OPENAI_API_KEY",
102
+ base_url_env="OPENAI_BASE_URL",
103
+ temperature=float(os.getenv("DEBATER_A_TEMPERATURE", 0.7)),
104
+ )
105
+
106
+ DEBATER_B_CONFIG = AgentConfig(
107
+ name="Debater B",
108
+ model=os.getenv("DEBATER_B_MODEL", "gemini-2.0-flash"),
109
+ api_key_env="GOOGLE_API_KEY",
110
+ base_url_env="GEMINI_BASE_URL",
111
+ temperature=float(os.getenv("DEBATER_B_TEMPERATURE", 0.7)),
112
+ )
113
+
114
+ JUDGE_CONFIG = AgentConfig(
115
+ name="Judge",
116
+ model=os.getenv("JUDGE_MODEL", "gpt-oss:20b-cloud"),
117
+ api_key_env="OLLAMA_API_KEY",
118
+ base_url_env="OLLAMA_BASE_URL",
119
+ temperature=float(os.getenv("JUDGE_TEMPERATURE", 0.2)),
120
+ supports_json=False,
121
+ )
122
+
123
+ REPORTER_CONFIG = AgentConfig(
124
+ name="Reporter",
125
+ model=os.getenv("REPORTER_MODEL", "MiniMax-M2"),
126
+ api_key_env="MINIMAX_API_KEY",
127
+ base_url_env="MINIMAX_BASE_URL",
128
+ temperature=float(os.getenv("REPORTER_TEMPERATURE", 0.4)),
129
+ supports_json=False,
130
+ )
131
+
132
+ THEME = gr.themes.Default(
133
+ primary_hue="blue",
134
+ secondary_hue="sky",
135
+ neutral_hue="gray",
136
+ )
137
+
138
+ CUSTOM_CSS = """
139
+ body, .gradio-container {
140
+ background: radial-gradient(circle at top, #0f172a 0%, #020617 60%, #020617 100%);
141
+ color: #e2e8f0;
142
+ }
143
+ #live-debate-panel {
144
+ background: linear-gradient(135deg, rgba(30,64,175,0.95), rgba(29,78,216,0.85));
145
+ color: #f8fafc;
146
+ border-radius: 16px;
147
+ padding: 24px;
148
+ box-shadow: 0 20px 45px rgba(15,23,42,0.35);
149
+ }
150
+ #live-debate-panel h3 {
151
+ color: #bfdbfe;
152
+ }
153
+ .gr-button-primary {
154
+ background: linear-gradient(135deg, #1d4ed8, #2563eb) !important;
155
+ border: none !important;
156
+ }
157
+ .gr-button-primary:hover {
158
+ background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
159
+ }
160
+ """
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Debate runtime classes
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ @dataclass
168
+ class DebateState:
169
+ topic: str
170
+ stance_a: str
171
+ stance_b: str
172
+ transcript: List[Tuple[str, str]] = field(default_factory=list)
173
+
174
+
175
+ class LLMAdapter:
176
+ """Thin wrapper around the OpenAI SDK to simplify prompting."""
177
+
178
+ def __init__(self, config: AgentConfig):
179
+ self.config = config
180
+ self.client = load_client(config)
181
+
182
+ def complete(
183
+ self,
184
+ prompt: str,
185
+ *,
186
+ system: Optional[str] = None,
187
+ max_tokens: int = 512,
188
+ json_mode: bool = False,
189
+ ) -> str:
190
+ messages = []
191
+ if system:
192
+ messages.append({"role": "system", "content": system})
193
+ messages.append({"role": "user", "content": prompt})
194
+
195
+ params = dict(
196
+ model=self.config.model,
197
+ messages=messages,
198
+ temperature=self.config.temperature,
199
+ max_tokens=max_tokens,
200
+ )
201
+ if json_mode and self.config.supports_json:
202
+ params["response_format"] = {"type": "json_object"}
203
+
204
+ response = self.client.chat.completions.create(**params)
205
+ return extract_text(response)
206
+
207
+
208
+ class Debater:
209
+ def __init__(self, adapter: LLMAdapter, stance_label: str):
210
+ self.adapter = adapter
211
+ self.stance_label = stance_label
212
+
213
+ def argue(self, topic: str) -> str:
214
+ prompt = (
215
+ f"You are {self.adapter.config.name}, debating the topic:\n"
216
+ f"'{topic}'.\n\n"
217
+ f"Present a concise argument that {self.stance_label.lower()} "
218
+ f"the statement. Use at most 150 words. Provide clear reasoning "
219
+ f"and, if applicable, cite plausible evidence or examples."
220
+ )
221
+ return self.adapter.complete(prompt, max_tokens=300)
222
+
223
+
224
+ class Judge:
225
+ RUBRIC = [
226
+ "Clarity of the argument",
227
+ "Use of evidence or examples",
228
+ "Logical coherence",
229
+ "Persuasiveness and impact",
230
+ ]
231
+
232
+ def __init__(self, adapter: LLMAdapter):
233
+ self.adapter = adapter
234
+
235
+ def evaluate(self, topic: str, argument_a: str, argument_b: str) -> Dict[str, object]:
236
+ rubric_text = "\n".join(f"- {item}" for item in self.RUBRIC)
237
+ prompt = (
238
+ "You are serving as an impartial debate judge.\n"
239
+ f"Topic: {topic}\n\n"
240
+ f"Argument from Debater A:\n{argument_a}\n\n"
241
+ f"Argument from Debater B:\n{argument_b}\n\n"
242
+ "Score each debater from 0-10 on the following criteria:\n"
243
+ f"{rubric_text}\n\n"
244
+ "Return a JSON object with this exact structure:\n"
245
+ '{\n'
246
+ ' "winner": "A" or "B" or "Tie",\n'
247
+ ' "reason": "brief justification",\n'
248
+ ' "scores": [\n'
249
+ ' {"criterion": "...", "debater_a": 0-10, "debater_b": 0-10, "notes": "optional"}\n'
250
+ " ]\n"
251
+ "}\n"
252
+ "Ensure the JSON is valid."
253
+ )
254
+ raw = self.adapter.complete(prompt, max_tokens=400, json_mode=True)
255
+ try:
256
+ data = json.loads(raw)
257
+ if "scores" not in data:
258
+ raise ValueError("scores missing")
259
+ return data
260
+ except Exception:
261
+ # Fallback: wrap raw text if parsing fails.
262
+ return {"winner": "Unknown", "reason": raw, "scores": []}
263
+
264
+
265
+ class Reporter:
266
+ def __init__(self, adapter: LLMAdapter):
267
+ self.adapter = adapter
268
+
269
+ def summarize(
270
+ self,
271
+ topic: str,
272
+ argument_a: str,
273
+ argument_b: str,
274
+ judge_result: Dict[str, object],
275
+ ) -> str:
276
+ prompt = (
277
+ f"Summarize a single-round debate on '{topic}'.\n\n"
278
+ f"Debater A argued:\n{argument_a}\n\n"
279
+ f"Debater B argued:\n{argument_b}\n\n"
280
+ f"Judge verdict: {json.dumps(judge_result, ensure_ascii=False)}\n\n"
281
+ "Provide a short journalistic summary (max 200 words) highlighting "
282
+ "each side's key points and the judge's decision. Use neutral tone."
283
+ )
284
+ response = self.adapter.client.chat.completions.create(
285
+ model=self.adapter.config.model,
286
+ messages=[
287
+ {"role": "system", "content": "You are an impartial debate reporter."},
288
+ {"role": "user", "content": prompt},
289
+ ],
290
+ temperature=self.adapter.config.temperature,
291
+ max_tokens=300,
292
+ **(
293
+ {"extra_body": {"reasoning_split": True}}
294
+ if getattr(self.adapter.client, "base_url", None)
295
+ and "minimax" in str(self.adapter.client.base_url).lower()
296
+ else {}
297
+ ),
298
+ )
299
+ return extract_text(response)
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Debate pipeline + UI
304
+ # ---------------------------------------------------------------------------
305
+
306
+
307
+ debater_a = Debater(LLMAdapter(DEBATER_A_CONFIG), stance_label="supports")
308
+ debater_b = Debater(LLMAdapter(DEBATER_B_CONFIG), stance_label="opposes")
309
+ judge = Judge(LLMAdapter(JUDGE_CONFIG))
310
+ reporter = Reporter(LLMAdapter(REPORTER_CONFIG))
311
+
312
+
313
+ def format_transcript(transcript: List[Tuple[str, str]]) -> str:
314
+ """Return markdown-formatted transcript."""
315
+ lines = []
316
+ for speaker, message in transcript:
317
+ lines.append(f"### {speaker}\n\n{message}\n")
318
+ return "\n".join(lines)
319
+
320
+
321
+ def run_debate(
322
+ topic: str, stance_a: str, stance_b: str
323
+ ) -> Generator[Tuple[str, str, List[List[object]], str, str], None, None]:
324
+ """Generator for Gradio to stream debate progress."""
325
+ if not topic.strip():
326
+ warning = "⚠️ Please enter a debate topic to get started."
327
+ yield warning, "", [], "", ""
328
+ return
329
+
330
+ state = DebateState(topic=topic.strip(), stance_a=stance_a, stance_b=stance_b)
331
+
332
+ state.transcript.append(
333
+ ("Moderator", f"Welcome to the debate on **{state.topic}**!")
334
+ )
335
+ yield format_transcript(state.transcript), "Waiting for judge...", [], "", ""
336
+
337
+ argument_a = debater_a.argue(state.topic)
338
+ state.transcript.append((f"Debater A ({state.stance_a})", argument_a))
339
+ yield format_transcript(state.transcript), "Collecting arguments...", [], "", ""
340
+
341
+ argument_b = debater_b.argue(state.topic)
342
+ state.transcript.append((f"Debater B ({state.stance_b})", argument_b))
343
+ yield format_transcript(state.transcript), "Judge deliberating...", [], "", ""
344
+
345
+ judge_result = judge.evaluate(state.topic, argument_a, argument_b)
346
+ verdict_text = (
347
+ f"Winner: {judge_result.get('winner', 'Unknown')}\nReason: "
348
+ f"{judge_result.get('reason', 'No explanation provided.')}"
349
+ )
350
+ score_rows = [
351
+ [
352
+ entry.get("criterion", ""),
353
+ entry.get("debater_a", ""),
354
+ entry.get("debater_b", ""),
355
+ entry.get("notes", ""),
356
+ ]
357
+ for entry in judge_result.get("scores", [])
358
+ ]
359
+ judge_report_md = (
360
+ f"**Judge Verdict:** {judge_result.get('winner', 'Unknown')}\n\n"
361
+ f"{judge_result.get('reason', '')}"
362
+ )
363
+ yield (
364
+ format_transcript(state.transcript),
365
+ judge_report_md,
366
+ score_rows,
367
+ verdict_text,
368
+ format_transcript(state.transcript),
369
+ )
370
+
371
+ reporter_summary = reporter.summarize(
372
+ state.topic, argument_a, argument_b, judge_result
373
+ )
374
+
375
+ final_markdown = (
376
+ f"{judge_report_md}\n\n---\n\n"
377
+ f"**Reporter Summary**\n\n{reporter_summary}"
378
+ )
379
+ yield (
380
+ format_transcript(state.transcript),
381
+ final_markdown,
382
+ score_rows,
383
+ verdict_text,
384
+ format_transcript(state.transcript),
385
+ )
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Gradio Interface
390
+ # ---------------------------------------------------------------------------
391
+
392
+
393
+ with gr.Blocks(
394
+ title="LLM Debate Arena",
395
+ fill_width=True,
396
+ theme=THEME,
397
+ css=CUSTOM_CSS,
398
+ ) as demo:
399
+ gr.Markdown(
400
+ "# 🔁 LLM Debate Arena\n"
401
+ "Configure two debating agents, watch their arguments in real time, and "
402
+ "review the judge's verdict plus a reporter summary."
403
+ )
404
+
405
+ with gr.Row():
406
+ topic_input = gr.Textbox(
407
+ label="Debate Topic",
408
+ placeholder="e.g., Should autonomous delivery robots be allowed in city centers?",
409
+ )
410
+ with gr.Row():
411
+ stance_a_input = gr.Textbox(
412
+ label="Debater A Stance",
413
+ value="Supports the statement",
414
+ )
415
+ stance_b_input = gr.Textbox(
416
+ label="Debater B Stance",
417
+ value="Opposes the statement",
418
+ )
419
+
420
+ run_button = gr.Button("Start Debate", variant="primary")
421
+
422
+ with gr.Tab("Live Debate"):
423
+ transcript_md = gr.Markdown(
424
+ "### Waiting for the debate to start...",
425
+ elem_id="live-debate-panel",
426
+ )
427
+
428
+ with gr.Tab("Judge's Report"):
429
+ judge_md = gr.Markdown("Judge verdict will appear here.")
430
+ score_table = gr.Dataframe(
431
+ headers=["Criterion", "Debater A", "Debater B", "Notes"],
432
+ datatype=["str", "number", "number", "str"],
433
+ interactive=False,
434
+ )
435
+ verdict_box = gr.Textbox(
436
+ label="Verdict Detail",
437
+ interactive=False,
438
+ )
439
+ transcript_box = gr.Textbox(
440
+ label="Full Transcript (for copying)",
441
+ interactive=False,
442
+ lines=10,
443
+ )
444
+
445
+ run_button.click(
446
+ fn=run_debate,
447
+ inputs=[topic_input, stance_a_input, stance_b_input],
448
+ outputs=[transcript_md, judge_md, score_table, verdict_box, transcript_box],
449
+ queue=True,
450
+ )
451
+
452
+ if __name__ == "__main__":
453
+ demo.queue(default_concurrency_limit=4).launch()