Diomedes Git commited on
Commit
b7d2f41
Β·
1 Parent(s): 23b971a

app.py refix

Browse files
src/gradio/app.py CHANGED
@@ -1,103 +1,619 @@
1
  import gradio as gr
 
 
 
 
 
2
  import tempfile
3
  from pathlib import Path
4
- import asyncio
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- # -------------------------------------------------------------------
7
- # custom Theme (Space Grotesk, modern, clean, not too soft)
8
- # -------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  theme = gr.themes.Soft(
10
  primary_hue=gr.themes.colors.indigo,
11
  secondary_hue=gr.themes.colors.slate,
12
  neutral_hue=gr.themes.colors.gray,
13
- font=[gr.themes.GoogleFont("Space Grotesk"), "sans-serif"],
14
- font_mono=[gr.themes.GoogleFont("Space Grotesk"), "monospace"],
15
  radius_size=gr.themes.sizes.radius_md,
16
  spacing_size=gr.themes.sizes.spacing_md,
17
  text_size=gr.themes.sizes.text_md,
18
  )
19
 
20
- # -------------------------------------------------------------------
21
- # export helper
22
- # -------------------------------------------------------------------
23
- async def run_deliberation_and_export(question, rounds, summariser):
24
- """Run the deliberation AND produce a downloadable .txt file."""
25
-
26
- if not question or question.strip() == "":
27
- return "Please enter a question.", None
28
 
29
- # Run your async deliberation
30
- history = await deliberate(question, rounds=rounds, summariser=summariser)
 
 
 
 
 
 
31
 
32
- # Format text for export
33
- text_content = "\n".join(f"{role}: {msg}" for role, msg in history)
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
- # create a temporary file
36
- tmp = Path(tempfile.mkstemp(suffix=".txt")[1])
37
- tmp.write_text(text_content, encoding="utf-8")
 
 
 
38
 
39
- return history, str(tmp)
 
 
 
 
 
 
 
40
 
41
- # -------------------------------------------------------------------
42
- # gradio Interface
43
- # -------------------------------------------------------------------
44
- with gr.Blocks() as demo:
 
 
 
 
 
45
 
46
- gr.Markdown(
47
- """
48
- # 🧠 Cluas Huginn β€” Council Deliberation
49
- Ask a question. Choose how many rounds the council debates.
50
- The characters then produce: **thesis β†’ antithesis β†’ synthesis**.
51
- """
52
- )
53
 
54
- chatbot = gr.Chatbot(
55
- label="Council Discussion",
56
- height=450,
57
- show_copy_button=True
58
- )
59
 
60
- with gr.Row():
61
- msg = gr.Textbox(
62
- placeholder="Ask the council a question...",
63
- label="Your Question",
64
- scale=4
65
- )
 
66
 
67
- with gr.Row():
68
- rounds_input = gr.Slider(
69
- minimum=1,
70
- maximum=4,
71
- value=1,
72
- step=1,
73
- label="Debate Rounds"
74
- )
75
- summariser_input = gr.Dropdown(
76
- ["Moderator", "Corvus", "Magpie", "Raven", "Crow"],
77
- value="Moderator",
78
- label="Summariser"
79
- )
80
 
81
- with gr.Row():
82
- deliberate_btn = gr.Button("Deliberate", elem_id="deliberate-btn").hover_text(
83
- "Enter a question, choose rounds, and watch the council deliberate:\n"
84
- " thesis β†’ antithesis β†’ synthesis"
85
- )
 
 
 
 
 
 
 
 
 
 
86
 
87
- download_btn = gr.File(
88
- label="Download Chat",
89
- file_types=[".txt"],
90
- interactive=False,
91
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
- # Wire it up
94
- deliberate_btn.click(
95
- fn=run_deliberation_and_export,
96
- inputs=[msg, rounds_input, summariser_input],
97
- outputs=[chatbot, download_btn]
98
  )
99
 
100
- # -------------------------------------------------------------------
101
- # launch (Gradio 6 theme pattern)
102
- # -------------------------------------------------------------------
103
- demo.launch(theme=theme, favicon_path=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
+ import logging
3
+ import asyncio
4
+ import html
5
+ import random
6
+ import re
7
  import tempfile
8
  from pathlib import Path
9
+ from typing import Any, Dict, List, Literal, Optional, Tuple
10
+ from src.characters.corvus import Corvus
11
+ from src.characters.magpie import Magpie
12
+ from src.characters.raven import Raven
13
+ from src.characters.crow import Crow
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # init all characters
18
+ corvus = Corvus(location="Glasgow, Scotland")
19
+ magpie = Magpie(location="Brooklyn, NY")
20
+ raven = Raven(location="Seattle, WA")
21
+ crow = Crow(location="Tokyo, Japan")
22
+
23
+
24
+ CUSTOM_CSS = """
25
+ #deliberate-btn {
26
+ position: relative;
27
+ }
28
+
29
+ #deliberate-btn:hover::after {
30
+ content: "Enter a question, choose rounds, and watch the council deliberate: \\A " thesis β†’ antithesis β†’ synthesis";
31
+ position: absolute;
32
+ bottom: 100%;
33
+ left: 50%;
34
+ transform: translateX(-50%);
35
+ background: #1a1a2e;
36
+ color: #e0e0e0;
37
+ padding: 8px 12px;
38
+ border-radius: 6px;
39
+ font-size: 0.85em;
40
+ white-space: nowrap;
41
+ z-index: 1000;
42
+ margin-bottom: 8px;
43
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
44
+ }
45
+
46
+ #deliberate-btn:hover::before {
47
+ content: "";
48
+ position: absolute;
49
+ bottom: 100%;
50
+ left: 50%;
51
+ transform: translateX(-50%);
52
+ border: 6px solid transparent;
53
+ border-top-color: #1a1a2e;
54
+ margin-bottom: -4px;
55
+ z-index: 1000;
56
+ }
57
+
58
+ @import url('https://fonts.googleapis.com/css2?family=Karla:wght@400;500;700&display=swap');
59
+
60
+
61
+ .message {
62
+ font-family: 'Karla', sans-serif !important;
63
+ }
64
+ """
65
+
66
+
67
+ CHARACTERS = [
68
+ # (name, emoji, instance, delay, location)
69
+ ("Corvus", "πŸ¦β€β¬›", corvus, 1.5, "Glasgow, Scotland"),
70
+ ("Magpie", "πŸͺΆ", magpie, 1.2, "Brooklyn, NY"),
71
+ ("Raven", "πŸ¦…", raven, 1.0, "Seattle, WA"),
72
+ ("Crow", "πŸ•ŠοΈ", crow, 1.0, "Tokyo, Japan")
73
+ ]
74
+
75
+ CHARACTER_PERSONAS: Dict[str, Dict[str, str]] = {
76
+ "Corvus": {
77
+ "role": "Melancholic scholar focused on academic rigor",
78
+ "tone": "Precise, evidence-driven, cites papers when relevant.",
79
+ },
80
+ "Magpie": {
81
+ "role": "Sanguine trendspotter tracking cultural signals",
82
+ "tone": "Upbeat, curious, highlights emerging stories and trivia.",
83
+ },
84
+ "Raven": {
85
+ "role": "Choleric activist monitoring news and accountability",
86
+ "tone": "Direct, justice-oriented, challenges misinformation.",
87
+ },
88
+ "Crow": {
89
+ "role": "Phlegmatic observer studying patterns in nature",
90
+ "tone": "Calm, methodical, references environmental signals.",
91
+ },
92
+ }
93
+
94
+ CHARACTER_EMOJIS = {name: emoji for name, emoji, _, _, _ in CHARACTERS}
95
+
96
+
97
+ def parse_mentions(message: str) -> List[str] | None:
98
+ """Extract @CharacterName mentions. Returns None if no mentions (all respond)."""
99
+ pattern = r'@(Corvus|Magpie|Raven|Crow)'
100
+ mentions = re.findall(pattern, message, re.IGNORECASE)
101
+ return [m.capitalize() for m in mentions] if mentions else None
102
+
103
+
104
+ def format_message(character_name: str, message: str) -> Tuple[str, str]:
105
+ """Format message with character name and emoji"""
106
+ emoji = CHARACTER_EMOJIS.get(character_name, "πŸ’¬")
107
+
108
+ COLORS = {
109
+ "Corvus": "#2596be", # blue
110
+ "Magpie": "#c91010", # red
111
+ "Raven": "#2E8B57", # green
112
+ "Crow": "#1C1C1C", # dark gray
113
+ "User": "#FFD700", # gold/yellow
114
+ }
115
+
116
+ color = COLORS.get(character_name, "#FFFFFF")
117
+ formatted = f'{emoji} <span style="color:{color}; font-weight:bold;">{character_name}</span>: {message}'
118
+
119
+ return formatted, character_name
120
+
121
+
122
+ async def get_character_response(character, message: str, history: List) -> str:
123
+ """Get response from a character with graceful error handling"""
124
+ conversation_history = []
125
+ for msg in history:
126
+ role = msg.get("role")
127
+ content_blocks = msg.get("content", [])
128
+
129
+ if content_blocks and isinstance(content_blocks, list):
130
+ text = content_blocks[0].get("text", "") if content_blocks else ""
131
+ else:
132
+ text = ""
133
+
134
+ conversation_history.append({"role": role, "content": text})
135
+
136
+ try:
137
+ response = await character.respond(message, conversation_history)
138
+ return response
139
+ except Exception as e:
140
+ logger.error(f"{character.name} error: {str(e)}")
141
+
142
+ # Character-specific error messages
143
+ error_messages = {
144
+ "Corvus": "*pauses mid-thought, adjusting spectacles* Hmm, I seem to have lost my train of thought...",
145
+ "Magpie": "*distracted by something shiny* Oh! Sorry, what were we talking about?",
146
+ "Raven": "*scowls* The systems are down. Typical.",
147
+ "Crow": "*silent, gazing into the distance*"
148
+ }
149
+ return error_messages.get(character.name, f"*{character.name} seems distracted*")
150
+
151
+
152
+ async def chat_fn(message: str, history: list):
153
+ """Async chat handler with sequential responses"""
154
+ if not message.strip():
155
+ yield history
156
+ return
157
+
158
+ sanitized_message = html.escape(message)
159
+
160
+ history.append({
161
+ "role": "user",
162
+ "content": [{"type": "text", "text": sanitized_message}]
163
+ })
164
+ yield history
165
+
166
+ mentioned_chars = parse_mentions(message)
167
+
168
+ for name, emoji, char_obj, delay, location in CHARACTERS:
169
+ if mentioned_chars and name not in mentioned_chars:
170
+ continue
171
+
172
+ # animated typing indicator
173
+ for i in range(4):
174
+ dots = "." * i
175
+ typing_msg = {
176
+ "role": "assistant",
177
+ "content": [{"type": "text", "text": f"{emoji}{dots}"}]
178
+ }
179
+ if i == 0:
180
+ history.append(typing_msg)
181
+ else:
182
+ history[-1]["content"][0]["text"] = f"{emoji}{dots}"
183
+ yield history
184
+ await asyncio.sleep(0.2)
185
+
186
+ try:
187
+ response = await get_character_response(
188
+ char_obj,
189
+ sanitized_message,
190
+ history[:-1])
191
+ history.pop()
192
+ sanitized_response = html.escape(response)
193
+ formatted, _ = format_message(name, sanitized_response)
194
+ history.append({
195
+ "role": "assistant",
196
+ "content": [{"type": "text", "text": formatted}]})
197
+ yield history
198
+ await asyncio.sleep(delay)
199
+ except Exception as e:
200
+ logger.error(f"{name} error: {e}")
201
+ history.pop()
202
+ yield history
203
+
204
+
205
+ def _phase_instruction(phase: str) -> str:
206
+ instructions = {
207
+ "thesis": "Present your initial perspective. Offer concrete signals, data, or references that support your stance.",
208
+ "antithesis": "Critique or challenge earlier answers. Highlight blind spots, weak evidence, or alternative interpretations.",
209
+ "synthesis": "Integrate the best ideas so far. Resolve tensions and propose a balanced, actionable view.",
210
+ }
211
+ return instructions.get(phase, "")
212
+
213
+
214
+ def _build_phase_prompt(
215
+ *,
216
+ phase: str,
217
+ character_name: str,
218
+ location: str,
219
+ question: str,
220
+ history_snippet: str,
221
+ ) -> str:
222
+ persona = CHARACTER_PERSONAS.get(character_name, {})
223
+ role = persona.get("role", "council member")
224
+ tone = persona.get("tone", "")
225
+ phase_text = _phase_instruction(phase)
226
+ history_block = history_snippet or "No prior discussion yet."
227
+
228
+ return (
229
+ f"You are {character_name}, {role} based in {location}. {tone}\n"
230
+ f"PHASE: {phase.upper()}.\n"
231
+ f"INSTRUCTION: {phase_text}\n\n"
232
+ f"QUESTION / CONTEXT:\n{question}\n\n"
233
+ f"RECENT COUNCIL NOTES:\n{history_block}\n\n"
234
+ "Respond as a short chat message (2-4 sentences)."
235
+ )
236
+
237
+
238
+ def _history_text(history: List[str], limit: int = 12) -> str:
239
+ if not history:
240
+ return ""
241
+ return "\n".join(history[-limit:])
242
+
243
+
244
+ async def _neutral_summary(history_text: str) -> str:
245
+ if not history_text.strip():
246
+ return "No discussion available to summarize."
247
+ prompt = (
248
+ "You are the neutral moderator of the Corvid Council. "
249
+ "Summarize the key points, agreements, and disagreements succinctly.\n\n"
250
+ f"TRANSCRIPT:\n{history_text}"
251
+ )
252
+ return await get_character_response(corvus, prompt, [])
253
+
254
+
255
+ async def _summarize_cycle(history_text: str) -> str:
256
+ prompt = (
257
+ "Provide a concise recap (3 sentences max) capturing the thesis, antithesis, "
258
+ "and synthesis highlights from the transcript below.\n\n"
259
+ f"{history_text}"
260
+ )
261
+ return await get_character_response(corvus, prompt, [])
262
+
263
+
264
+ async def deliberate(
265
+ question: str,
266
+ rounds: int = 1,
267
+ summariser: str = "moderator",
268
+ format: Literal["llm", "chat"] = "llm",
269
+ structure: Literal["nested", "flat"] = "nested",
270
+ seed: Optional[int] = None,
271
+ ) -> Dict[str, Any]:
272
+ """
273
+ Run a dialectic deliberation (thesis β†’ antithesis β†’ synthesis) with the Corvid Council.
274
+
275
+ Args:
276
+ question: Topic to deliberate on.
277
+ rounds: Number of full cycles (thesis/antithesis/synthesis). Each cycle deepens the analysis.
278
+ summariser: "moderator" for neutral summary or one of the characters ("Corvus", ...).
279
+ format: "llm" returns plain text history, "chat" returns display-ready HTML snippets.
280
+ structure: "nested" groups responses by phase, "flat" returns a chronological list.
281
+ seed: Optional seed to reproduce character ordering.
282
+
283
+ Returns:
284
+ Structured dict containing per-phase responses, cycle summaries, and final outcome.
285
+ """
286
+ question = question.strip()
287
+ if not question:
288
+ raise ValueError("Question is required for deliberation.")
289
 
290
+ rounds = max(1, min(rounds, 3))
291
+ rng = random.Random(seed)
292
+ if seed is None:
293
+ seed = rng.randint(0, 1_000_000)
294
+ rng.seed(seed)
295
+
296
+ char_order = CHARACTERS.copy()
297
+ rng.shuffle(char_order)
298
+ order_names = [name for name, *_ in char_order]
299
+
300
+ conversation_llm: List[str] = []
301
+ chat_history: List[Dict[str, Any]] = []
302
+ phase_records: Dict[str, List[Dict[str, Any]]] = {
303
+ "thesis": [],
304
+ "antithesis": [],
305
+ "synthesis": [],
306
+ }
307
+ flattened_records: List[Dict[str, Any]] = []
308
+ cycle_summaries: List[Dict[str, Any]] = []
309
+
310
+ async def run_phase(phase: str, base_context: str, cycle_idx: int) -> List[Dict[str, Any]]:
311
+ history_excerpt = _history_text(conversation_llm)
312
+ prompts = []
313
+ tasks = []
314
+ for name, _, character, _, location in char_order:
315
+ prompt = _build_phase_prompt(
316
+ phase=phase,
317
+ character_name=name,
318
+ location=location,
319
+ question=base_context,
320
+ history_snippet=history_excerpt,
321
+ )
322
+ prompts.append((name, prompt))
323
+ tasks.append(get_character_response(character, prompt, []))
324
+
325
+ responses = await asyncio.gather(*tasks, return_exceptions=True)
326
+ entries: List[Dict[str, Any]] = []
327
+
328
+ for (name, prompt), response in zip(prompts, responses):
329
+ if isinstance(response, Exception):
330
+ logger.error("Phase %s: %s failed (%s)", phase, name, response)
331
+ text = f"*{name} could not respond.*"
332
+ else:
333
+ text = response.strip()
334
+
335
+ conversation_llm.append(f"[{phase.upper()} | Cycle {cycle_idx + 1}] {name}: {text}")
336
+ display_text = html.escape(text)
337
+ formatted, _ = format_message(name, display_text)
338
+ chat_entry = {"role": "assistant", "content": [{"type": "text", "text": formatted}]}
339
+ chat_history.append(chat_entry)
340
+
341
+ entry = {
342
+ "cycle": cycle_idx + 1,
343
+ "phase": phase,
344
+ "name": name,
345
+ "content": text,
346
+ "prompt": prompt,
347
+ }
348
+ phase_records[phase].append(entry)
349
+ flattened_records.append(entry)
350
+ entries.append(entry)
351
+
352
+ return entries
353
+
354
+ cycle_context = question
355
+
356
+ for cycle_idx in range(rounds):
357
+ thesis_entries = await run_phase("thesis", cycle_context, cycle_idx)
358
+ antithesis_entries = await run_phase("antithesis", cycle_context, cycle_idx)
359
+ synthesis_entries = await run_phase("synthesis", cycle_context, cycle_idx)
360
+
361
+ cycle_text = _history_text(conversation_llm, limit=36)
362
+ summary_text = await _summarize_cycle(cycle_text)
363
+ cycle_summaries.append({
364
+ "cycle": cycle_idx + 1,
365
+ "summary": summary_text,
366
+ })
367
+ cycle_context = f"{question}\n\nPrevious cycle summary:\n{summary_text}"
368
+
369
+ full_history_text = "\n".join(conversation_llm)
370
+ summariser_normalized = summariser.strip().lower()
371
+
372
+ if summariser_normalized == "moderator":
373
+ final_summary = await _neutral_summary(full_history_text)
374
+ summary_author = "Moderator"
375
+ else:
376
+ name_map = {name.lower(): char for name, _, char, _, _ in CHARACTERS}
377
+ selected = name_map.get(summariser_normalized)
378
+ if not selected:
379
+ raise ValueError(f"Unknown summariser '{summariser}'. Choose moderator or one of: {', '.join(order_names)}.")
380
+ summary_prompt = (
381
+ "Provide a concise synthesis (3 sentences max) from your perspective, referencing the discussion below.\n\n"
382
+ f"{full_history_text}"
383
+ )
384
+ final_summary = await get_character_response(selected, summary_prompt, [])
385
+ summary_author = summariser.title()
386
+
387
+ history_output: List[Any]
388
+ if format == "chat":
389
+ history_output = chat_history
390
+ else:
391
+ history_output = conversation_llm
392
+
393
+ if structure == "nested":
394
+ phase_output: Dict[str, Any] = phase_records
395
+ else:
396
+ phase_output = flattened_records
397
+
398
+ return {
399
+ "question": question,
400
+ "rounds": rounds,
401
+ "seed": seed,
402
+ "character_order": order_names,
403
+ "structure": structure,
404
+ "format": format,
405
+ "phases": phase_output,
406
+ "cycle_summaries": cycle_summaries,
407
+ "final_summary": {
408
+ "by": summary_author,
409
+ "content": final_summary,
410
+ },
411
+ "history": history_output,
412
+ }
413
+
414
+
415
+ async def run_deliberation_and_export(question, rounds, summariser):
416
+ """Run the deliberation AND produce a downloadable .txt file."""
417
+
418
+ if not question or question.strip() == "":
419
+ return [], None
420
+
421
+ try:
422
+ # Run deliberation
423
+ result = await deliberate(question, rounds=rounds, summariser=summariser)
424
+
425
+ # Format text for export from conversation history
426
+ history_items = result["history"]
427
+ if isinstance(history_items, list) and history_items:
428
+ if isinstance(history_items[0], str):
429
+ # LLM format
430
+ text_content = "\n".join(history_items)
431
+ else:
432
+ # Chat format
433
+ text_content = "\n".join([
434
+ item.get("content", [{}])[0].get("text", "")
435
+ for item in history_items
436
+ ])
437
+ else:
438
+ text_content = "No deliberation results."
439
+
440
+ # Create temporary file
441
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".txt")
442
+ with open(tmp_fd, "w", encoding="utf-8") as tmp_file:
443
+ tmp_file.write(f"Question: {question}\n")
444
+ tmp_file.write(f"Rounds: {rounds}\n")
445
+ tmp_file.write(f"Summariser: {summariser}\n")
446
+ tmp_file.write(f"Character Order: {', '.join(result['character_order'])}\n")
447
+ tmp_file.write("=" * 80 + "\n\n")
448
+ tmp_file.write(text_content)
449
+ tmp_file.write("\n\n" + "=" * 80 + "\n")
450
+ tmp_file.write(f"Final Summary ({result['final_summary']['by']}):\n")
451
+ tmp_file.write(result['final_summary']['content'])
452
+
453
+ return result["history"], tmp_path
454
+ except Exception as e:
455
+ logger.error(f"Deliberation error: {e}")
456
+ return [], None
457
+
458
+
459
+ # Theme configuration
460
  theme = gr.themes.Soft(
461
  primary_hue=gr.themes.colors.indigo,
462
  secondary_hue=gr.themes.colors.slate,
463
  neutral_hue=gr.themes.colors.gray,
464
+ font=[gr.themes.GoogleFont("Labrada"), "serif"],
465
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"],
466
  radius_size=gr.themes.sizes.radius_md,
467
  spacing_size=gr.themes.sizes.spacing_md,
468
  text_size=gr.themes.sizes.text_md,
469
  )
470
 
471
+ # Create Gradio interface
472
+ with gr.Blocks(title="Cluas Huginn", theme=theme, css=CUSTOM_CSS) as demo:
 
 
 
 
 
 
473
 
474
+ # Branding / tagline
475
+ gr.Markdown("""
476
+ <div style="text-align:center; color:#ccc;">
477
+ <h1>πŸ¦β€β¬› Cluas Huginn</h1>
478
+ <p><i>A gathering of guides, a council of counsels</i></p>
479
+ <p>Chat with the council of four corvid experts</p>
480
+ </div>
481
+ """)
482
 
483
+ # Tabs for Chat and Deliberation modes
484
+ with gr.Tabs():
485
+
486
+ # TAB 1: Chat mode
487
+ with gr.Tab("Chat"):
488
+ gr.Markdown("**Chat Mode:** Talk directly with the council. Use @CharacterName to address specific members.")
489
+
490
+ # Optional accordion for full character bios
491
+ with gr.Accordion("Character Bios", open=False):
492
+ bio_lines = "\n".join([
493
+ f"- **{name}** {emoji}: {location}"
494
+ for name, emoji, _, _, location in CHARACTERS
495
+ ])
496
+ gr.Markdown(bio_lines)
497
 
498
+ # Load avatars dynamically from folder
499
+ avatar_folder = Path("avatars")
500
+ avatar_images = [
501
+ str(avatar_folder / f"{name.lower()}.png")
502
+ for name, *_ in CHARACTERS
503
+ ]
504
 
505
+ # Chatbot with avatars
506
+ chatbot = gr.Chatbot(
507
+ label="Council Discussion",
508
+ height=600,
509
+ show_label=True,
510
+ avatar_images=tuple(avatar_images),
511
+ user_avatar="avatars/user.png"
512
+ )
513
 
514
+ # User input row
515
+ with gr.Row():
516
+ msg = gr.Textbox(
517
+ label="Your Message",
518
+ placeholder="Ask the council a question...",
519
+ scale=4,
520
+ container=False,
521
+ )
522
+ submit_btn = gr.Button("Send", variant="primary", scale=1)
523
 
524
+ # Handle submit
525
+ msg.submit(chat_fn, [msg, chatbot], [chatbot], queue=True, show_progress=True)\
526
+ .then(lambda: "", None, [msg])
 
 
 
 
527
 
528
+ submit_btn.click(chat_fn, [msg, chatbot], [chatbot], queue=True, show_progress=True)\
529
+ .then(lambda: "", None, [msg])
 
 
 
530
 
531
+ # TAB 2: Deliberation mode
532
+ with gr.Tab("Deliberation"):
533
+ gr.Markdown("""
534
+ ### 🧠 Council Deliberation
535
+ Ask a question and let the council engage in structured debate:
536
+ **thesis** (initial perspectives) β†’ **antithesis** (critiques) β†’ **synthesis** (integration).
537
+ """)
538
 
539
+ with gr.Row():
540
+ question_input = gr.Textbox(
541
+ label="Question for the Council",
542
+ placeholder="What would you like the council to deliberate on?",
543
+ lines=3,
544
+ scale=1,
545
+ )
 
 
 
 
 
 
546
 
547
+ with gr.Row():
548
+ rounds_input = gr.Slider(
549
+ minimum=1,
550
+ maximum=3,
551
+ value=1,
552
+ step=1,
553
+ label="Debate Rounds",
554
+ info="More rounds = deeper analysis"
555
+ )
556
+ summariser_input = gr.Dropdown(
557
+ ["Moderator", "Corvus", "Magpie", "Raven", "Crow"],
558
+ value="Moderator",
559
+ label="Final Summariser",
560
+ info="Who provides the final synthesis?"
561
+ )
562
 
563
+ deliberate_btn = gr.Button("🎯 Deliberate", variant="primary", scale=1, elem_id="deliberate-btn")
564
+ deliberation_output = gr.Textbox(label="Deliberation Output", lines=15)
565
+
566
+ download_btn = gr.File(
567
+ label="πŸ“₯ Download Chat",
568
+ file_types=[".txt"],
569
+ interactive=False,
570
+ )
571
+
572
+ # Wire up deliberation
573
+ deliberate_btn.click(
574
+ fn=run_deliberation_and_export,
575
+ inputs=[question_input, rounds_input, summariser_input],
576
+ outputs=[deliberation_output, download_btn],
577
+ queue=True,
578
+ show_progress=True
579
+ )
580
+
581
+ gr.Markdown("""
582
+ ### About
583
+ The Corvid Council is a multi-agent system where four specialized AI characters collaborate to answer questions.
584
+ Each character brings unique perspective and expertise to enrich the discussion.
585
+
586
+ **Chat Mode:** Direct conversation with the council.
587
+ **Deliberation Mode:** Structured debate using thesis-antithesis-synthesis framework.
588
+ """)
589
+
590
+ # Attribution footnote
591
+ gr.Markdown("""
592
+ <p style="font-size: 0.7em; color: #999; text-align: center; margin-top: 2em;">
593
+ Data sources: <a href="https://ebird.org" style="color: #999;">eBird.org</a>, PubMed, ArXiv
594
+ </p>
595
+ """)
596
 
597
+ gr.api(
598
+ deliberate,
599
+ api_name="deliberate",
 
 
600
  )
601
 
602
+
603
+ # Export for app.py
604
+ my_gradio_app = demo
605
+
606
+ if __name__ == "__main__":
607
+ import sys
608
+
609
+ if "--clear-memory" in sys.argv:
610
+ print("Clearing all character memories...")
611
+ corvus.clear_memory()
612
+ magpie.clear_memory()
613
+ raven.clear_memory()
614
+ crow.clear_memory()
615
+ print("Memory cleared!")
616
+ sys.exit(0)
617
+
618
+ demo.queue()
619
+ demo.launch(theme=theme)
src/gradio/avatars/corvus.svg DELETED
src/gradio/avatars/crow-alt.svg DELETED
src/gradio/avatars/crow.svg DELETED
src/gradio/avatars/magpie.svg DELETED
src/gradio/avatars/raven.svg DELETED