arka7 commited on
Commit
7b323cf
ยท
verified ยท
1 Parent(s): c6827b7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1219 -1033
app.py CHANGED
@@ -1,1034 +1,1220 @@
1
- import os
2
- import json
3
- import sqlite3
4
- import random
5
- import requests
6
- import asyncio
7
- from typing import TypedDict, Annotated, List, Dict, Any
8
- from datetime import datetime
9
- from pathlib import Path
10
- from enum import Enum
11
-
12
- # Third-party imports
13
- import gradio as gr
14
- from dotenv import load_dotenv
15
- from bs4 import BeautifulSoup
16
- from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage, ToolMessage
17
- from langchain_mistralai.chat_models import ChatMistralAI
18
- from langchain_core.tools import tool
19
- from langchain_community.tools.tavily_search import TavilySearchResults
20
- from langgraph.graph import StateGraph, END
21
-
22
- # --- 1. SETUP & CONFIGURATION ---
23
-
24
- load_dotenv()
25
-
26
- # Database Configuration
27
- DB_DIR = Path(os.getenv("DB_DIR", "./data"))
28
- DB_DIR.mkdir(exist_ok=True, parents=True)
29
- DB_PATH = DB_DIR / "trivia_posts.db"
30
-
31
- print(f"๐Ÿ“ Database location: {DB_PATH.absolute()}")
32
-
33
- # --- 2. API KEY ROTATION ---
34
-
35
- class APIKeyRotator:
36
- """Handles API key rotation for fault tolerance"""
37
-
38
- def __init__(self, service_name: str):
39
- self.service_name = service_name
40
- self.keys = self._load_keys()
41
- self.current_index = 0
42
-
43
- def _load_keys(self) -> List[str]:
44
- keys = []
45
- i = 1
46
- while True:
47
- key = os.getenv(f"{self.service_name}_API_KEY_{i}")
48
- if not key:
49
- if i == 1:
50
- key = os.getenv(f"{self.service_name}_API_KEY")
51
- if key:
52
- keys.append(key)
53
- break
54
- keys.append(key)
55
- i += 1
56
-
57
- if not keys:
58
- print(f"โš ๏ธ No API keys found for {self.service_name}. Ensure secrets are set.")
59
- return []
60
-
61
- random.shuffle(keys)
62
- return keys
63
-
64
- def get_key(self) -> str:
65
- if not self.keys: return ""
66
- return self.keys[self.current_index]
67
-
68
- def rotate(self) -> str:
69
- if not self.keys: return ""
70
- self.current_index = (self.current_index + 1) % len(self.keys)
71
- print(f"๐Ÿ”„ Rotated {self.service_name} API key to index {self.current_index + 1}/{len(self.keys)}")
72
- return self.get_key()
73
-
74
- mistral_rotator = APIKeyRotator("MISTRAL")
75
- tavily_rotator = APIKeyRotator("TAVILY")
76
-
77
- os.environ["MISTRAL_API_KEY"] = mistral_rotator.get_key()
78
- os.environ["TAVILY_API_KEY"] = tavily_rotator.get_key()
79
-
80
- # --- 3. RESILIENT LLM ---
81
-
82
- def create_llm_with_rotation(model: str, temperature: float = 0.2, max_retries: int = 3):
83
- class ResilientLLM:
84
- def __init__(self, model, temperature):
85
- self.model = model
86
- self.temperature = temperature
87
-
88
- def invoke(self, *args, **kwargs):
89
- for attempt in range(max_retries):
90
- try:
91
- llm = ChatMistralAI(
92
- model=self.model,
93
- temperature=self.temperature,
94
- mistral_api_key=mistral_rotator.get_key()
95
- )
96
- return llm.invoke(*args, **kwargs)
97
- except Exception as e:
98
- print(f"โŒ API call failed (attempt {attempt + 1}/{max_retries}): {str(e)}")
99
- if attempt < max_retries - 1:
100
- mistral_rotator.rotate()
101
- else:
102
- raise Exception(f"All API keys exhausted after {max_retries} attempts")
103
-
104
- def bind_tools(self, tools):
105
- class ToolBoundLLM:
106
- def __init__(self, parent, tools):
107
- self.parent = parent
108
- self.tools = tools
109
-
110
- def invoke(self, *args, **kwargs):
111
- for attempt in range(max_retries):
112
- try:
113
- llm = ChatMistralAI(
114
- model=self.parent.model,
115
- temperature=self.parent.temperature,
116
- mistral_api_key=mistral_rotator.get_key()
117
- )
118
- bound_llm = llm.bind_tools(self.tools)
119
- return bound_llm.invoke(*args, **kwargs)
120
- except Exception as e:
121
- print(f"โŒ API call failed (attempt {attempt + 1}/{max_retries}): {str(e)}")
122
- if attempt < max_retries - 1:
123
- mistral_rotator.rotate()
124
- else:
125
- raise Exception(f"All API keys exhausted after {max_retries} attempts")
126
- return ToolBoundLLM(self, tools)
127
-
128
- return ResilientLLM(model, temperature)
129
-
130
- llm_small = create_llm_with_rotation("mistral-small-latest", temperature=0.2)
131
- llm_medium = create_llm_with_rotation("mistral-medium-latest", temperature=0.2)
132
- llm_large = create_llm_with_rotation("mistral-large-latest", temperature=0.2)
133
-
134
- # --- 4. DATABASE FUNCTIONS ---
135
-
136
- def get_db_connection():
137
- return sqlite3.connect(str(DB_PATH))
138
-
139
- def init_database():
140
- conn = get_db_connection()
141
- cursor = conn.cursor()
142
- cursor.execute('''
143
- CREATE TABLE IF NOT EXISTS posts (
144
- id INTEGER PRIMARY KEY AUTOINCREMENT,
145
- date TEXT NOT NULL,
146
- topic TEXT NOT NULL,
147
- summary TEXT NOT NULL,
148
- source_url TEXT,
149
- quality_score REAL CHECK(quality_score >= 0 AND quality_score <= 10),
150
- engagement_score REAL,
151
- hashtags TEXT,
152
- created_at TEXT NOT NULL,
153
- updated_at TEXT
154
- )
155
- ''')
156
- cursor.execute('CREATE INDEX IF NOT EXISTS idx_date ON posts(date)')
157
- cursor.execute('CREATE INDEX IF NOT EXISTS idx_quality_score ON posts(quality_score)')
158
- cursor.execute('CREATE INDEX IF NOT EXISTS idx_created_at ON posts(created_at)')
159
- cursor.execute('''
160
- CREATE TABLE IF NOT EXISTS metadata (
161
- key TEXT PRIMARY KEY, value TEXT, updated_at TEXT NOT NULL
162
- )
163
- ''')
164
- cursor.execute('''
165
- INSERT OR REPLACE INTO metadata (key, value, updated_at)
166
- VALUES ('db_version', '1.0', ?)
167
- ''', (datetime.now().isoformat(),))
168
- conn.commit()
169
- conn.close()
170
-
171
- init_database()
172
-
173
- # --- 5. TOOLS ---
174
-
175
- @tool
176
- def search_science_breakthroughs(query: str) -> str:
177
- """Search for recent scientific breakthroughs."""
178
- try:
179
- search = TavilySearchResults(
180
- max_results=10,
181
- include_domains=["sciencedaily.com", "nature.com", "science.org", "newscientist.com", "scientificamerican.com"],
182
- search_depth="advanced"
183
- )
184
- results = search.invoke(query)
185
- return json.dumps(results, indent=2)
186
- except Exception as e:
187
- return f"Error in search: {str(e)}"
188
-
189
- @tool
190
- def fetch_article_content(url: str) -> str:
191
- """Fetch the full text content of an article."""
192
- try:
193
- response = requests.get(url, timeout=10, headers={'User-Agent': 'Mozilla/5.0'})
194
- response.raise_for_status()
195
- soup = BeautifulSoup(response.content, 'html.parser')
196
- for script in soup(["script", "style", "nav", "footer", "header"]):
197
- script.decompose()
198
- text = soup.get_text()
199
- lines = (line.strip() for line in text.splitlines())
200
- chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
201
- text = ' '.join(chunk for chunk in chunks if chunk)
202
- return text[:5000] if len(text) > 5000 else text
203
- except Exception as e:
204
- return f"Error fetching article: {str(e)}"
205
-
206
- @tool
207
- def get_all_previous_posts() -> str:
208
- """Retrieve all previously published posts."""
209
- conn = None
210
- try:
211
- conn = get_db_connection()
212
- cursor = conn.cursor()
213
- cursor.execute('SELECT id, date, topic, summary, source_url, quality_score FROM posts ORDER BY date DESC')
214
- posts = cursor.fetchall()
215
- if not posts: return "No previous posts found."
216
- formatted = []
217
- for p in posts:
218
- formatted.append({"id": p[0], "date": p[1], "topic": p[2], "summary": p[3][:100], "source_url": p[4], "quality_score": p[5]})
219
- return json.dumps(formatted, indent=2)
220
- except Exception as e:
221
- return f"Error: {str(e)}"
222
- finally:
223
- if conn: conn.close()
224
-
225
- @tool
226
- def get_top_quality_posts(limit: int = 5) -> str:
227
- """Retrieve top quality posts."""
228
- conn = None
229
- try:
230
- conn = get_db_connection()
231
- cursor = conn.cursor()
232
- cursor.execute('SELECT id, date, topic, summary, quality_score, hashtags FROM posts WHERE quality_score >= 7 ORDER BY quality_score DESC LIMIT ?', (limit,))
233
- posts = cursor.fetchall()
234
- if not posts: return "No high-quality example posts found."
235
- formatted = []
236
- for p in posts:
237
- formatted.append({"id": p[0], "date": p[1], "topic": p[2], "summary": p[3], "quality_score": p[4], "hashtags": p[5]})
238
- return json.dumps(formatted, indent=2)
239
- except Exception as e:
240
- return f"Error: {str(e)}"
241
- finally:
242
- if conn: conn.close()
243
-
244
- @tool
245
- def save_approved_post(topic: str, summary: str, source_url: str, quality_score: float, hashtags: str) -> str:
246
- """Save an approved post to the database."""
247
- conn = None
248
- try:
249
- conn = get_db_connection()
250
- cursor = conn.cursor()
251
- now = datetime.now()
252
- cursor.execute('''
253
- INSERT INTO posts (date, topic, summary, source_url, quality_score, hashtags, created_at, updated_at)
254
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
255
- ''', (now.strftime('%Y-%m-%d'), topic, summary, source_url, quality_score, hashtags, now.isoformat(), now.isoformat()))
256
- conn.commit()
257
- return f"Post saved successfully with ID: {cursor.lastrowid}"
258
- except Exception as e:
259
- return f"Error saving post: {str(e)}"
260
- finally:
261
- if conn: conn.close()
262
-
263
- @tool
264
- def check_topic_similarity(new_topic: str) -> str:
265
- """Check topic similarity."""
266
- conn = None
267
- try:
268
- conn = get_db_connection()
269
- cursor = conn.cursor()
270
- cursor.execute('SELECT topic, summary FROM posts')
271
- previous = cursor.fetchall()
272
- if not previous: return json.dumps({"is_duplicate": False, "similar_posts": []})
273
-
274
- new_keywords = set(new_topic.lower().split())
275
- similar = []
276
- for prev_topic, prev_summary in previous:
277
- prev_keywords = set(prev_topic.lower().split())
278
- if not new_keywords: continue
279
- overlap = len(new_keywords & prev_keywords)
280
- similarity = overlap / len(new_keywords)
281
- if similarity > 0.5:
282
- similar.append({"topic": prev_topic, "similarity": similarity})
283
-
284
- return json.dumps({"is_duplicate": len(similar) > 0, "similar_posts": similar}, indent=2)
285
- except Exception as e:
286
- return f"Error: {str(e)}"
287
- finally:
288
- if conn: conn.close()
289
-
290
- @tool
291
- def count_words(text: str) -> str:
292
- return f"Word count: {len(text.split())}"
293
-
294
- @tool
295
- def get_example_posts_for_writer() -> str:
296
- examples = [
297
- {"title": "Researchers Have Developed Next-Generation Plant Immune System",
298
- "content": "Full example content here...",
299
- "structure": "Title -> 2 Paragraphs -> Hashtags"},
300
- ]
301
- return json.dumps(examples, indent=2)
302
-
303
- @tool
304
- def get_example_posts_for_critic() -> str:
305
- examples = [
306
- {"title": "Scientists Found 14 Mysterious Creatures", "quality_score": 9.0},
307
- ]
308
- return json.dumps(examples, indent=2)
309
-
310
- # --- 6. WORKFLOW STAGES ENUM ---
311
-
312
- class WorkflowStage(Enum):
313
- IDLE = "idle"
314
- DISCOVERY = "discovery"
315
- CHECKPOINT_1 = "checkpoint_1"
316
- CURATOR = "curator"
317
- CHECKPOINT_2 = "checkpoint_2"
318
- WRITER = "writer"
319
- CRITIC = "critic"
320
- CHECKPOINT_3 = "checkpoint_3"
321
- FINALIZE = "finalize"
322
- COMPLETE = "complete"
323
- ERROR = "error"
324
-
325
- # --- 7. ENHANCED STATE ---
326
-
327
- class EnhancedAgentState(TypedDict):
328
- stage: str
329
- search_topic: str
330
- candidates: List[Dict]
331
- selected_story: Dict
332
- draft_summary: str
333
- quality_score: float
334
- critic_feedback: str
335
- retry_count: int
336
- error_message: str
337
- progress_log: List[str]
338
-
339
- # --- 8. AGENT FUNCTIONS WITH PROGRESS TRACKING ---
340
-
341
- def run_discovery(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
342
- """Discovery Agent with progress tracking"""
343
- try:
344
- if progress_callback:
345
- progress_callback("๐Ÿ” Searching for scientific breakthroughs...")
346
-
347
- topic = state.get("search_topic", "general science")
348
-
349
- system_msg = SystemMessage(content=f"""You are the Discovery Agent for Tuesday Trivia.
350
- Mission: Find 10-15 recent scientific breakthroughs (2-4 weeks).
351
- Tools: search_science_breakthroughs, get_all_previous_posts, check_topic_similarity.
352
- Output: List 10-15 unique breakthroughs with Title, Desc, URL, Why interesting.""")
353
-
354
- user_msg = HumanMessage(content=f"Search for recent breakthroughs in {topic}.")
355
-
356
- discovery_llm = llm_small.bind_tools([search_science_breakthroughs, get_all_previous_posts, check_topic_similarity])
357
-
358
- if progress_callback:
359
- progress_callback("๐Ÿค– Running discovery agent...")
360
-
361
- response = discovery_llm.invoke([system_msg, user_msg])
362
- conversation = [system_msg, user_msg, response]
363
-
364
- max_steps = 5
365
- steps = 0
366
- while hasattr(response, 'tool_calls') and response.tool_calls and steps < max_steps:
367
- if progress_callback:
368
- progress_callback(f"๐Ÿ”ง Executing tools (step {steps+1}/{max_steps})...")
369
-
370
- tool_messages = []
371
- for tool_call in response.tool_calls:
372
- name = tool_call['name']
373
- args = tool_call['args']
374
-
375
- if name == 'search_science_breakthroughs':
376
- res = search_science_breakthroughs.invoke(args)
377
- elif name == 'get_all_previous_posts':
378
- res = get_all_previous_posts.invoke(args)
379
- elif name == 'check_topic_similarity':
380
- res = check_topic_similarity.invoke(args)
381
- else:
382
- res = f"Unknown tool: {name}"
383
-
384
- tool_messages.append(ToolMessage(content=str(res), tool_call_id=tool_call['id']))
385
-
386
- conversation.extend(tool_messages)
387
- response = discovery_llm.invoke(conversation)
388
- conversation.append(response)
389
- steps += 1
390
-
391
- # Parse candidates from response
392
- candidates_text = response.content
393
-
394
- if progress_callback:
395
- progress_callback("โœ… Discovery complete!")
396
-
397
- state["candidates"] = [{"raw": candidates_text}]
398
- state["stage"] = WorkflowStage.CHECKPOINT_1.value
399
- state["progress_log"].append("Discovery completed successfully")
400
-
401
- return state
402
-
403
- except Exception as e:
404
- state["stage"] = WorkflowStage.ERROR.value
405
- state["error_message"] = f"Discovery failed: {str(e)}"
406
- state["progress_log"].append(f"โŒ Error: {str(e)}")
407
- return state
408
-
409
- def run_curator(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
410
- """Curator Agent with progress tracking"""
411
- try:
412
- if progress_callback:
413
- progress_callback("๐ŸŽฏ Ranking and selecting best story...")
414
-
415
- candidates = state.get("candidates", [])
416
- candidates_text = candidates[0].get("raw", "") if candidates else ""
417
-
418
- system_msg = SystemMessage(content="""You are the Curator Agent.
419
- Rank candidates (1-10) on Recency, Significance, Engagement. Select TOP story.
420
- Output: RANKED CANDIDATES list, then SELECTED STORY with Justification.""")
421
-
422
- user_msg = HumanMessage(content=f"Evaluate these candidates:\n{candidates_text}")
423
-
424
- curator_llm = llm_large.bind_tools([])
425
- response = curator_llm.invoke([system_msg, user_msg])
426
-
427
- if progress_callback:
428
- progress_callback("โœ… Story selected!")
429
-
430
- state["selected_story"] = {"raw": response.content}
431
- state["stage"] = WorkflowStage.CHECKPOINT_2.value
432
- state["progress_log"].append("Curation completed")
433
-
434
- return state
435
-
436
- except Exception as e:
437
- state["stage"] = WorkflowStage.ERROR.value
438
- state["error_message"] = f"Curation failed: {str(e)}"
439
- state["progress_log"].append(f"โŒ Error: {str(e)}")
440
- return state
441
-
442
- def run_writer(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
443
- """Writer Agent with progress tracking"""
444
- try:
445
- if progress_callback:
446
- progress_callback("โœ๏ธ Writing draft post...")
447
-
448
- selected_story = state.get("selected_story", {})
449
- story_text = selected_story.get("raw", "")
450
- retry_count = state.get("retry_count", 0)
451
-
452
- retry_context = ""
453
- if retry_count > 0:
454
- retry_context = f"\nPrevious attempt rejected. Feedback: {state.get('critic_feedback')}"
455
-
456
- system_msg = SystemMessage(content="""You are the Writer Agent.
457
- Structure: Title, Para 1 (Hook), Para 2 (Tech/Impact), Hashtags (#TuesdayTrivia #RnDCell #CCA).
458
- Tone: Conversational, active voice, no questions. 140-180 words.
459
- Use tools to get examples and count words.""")
460
-
461
- user_msg = HumanMessage(content=f"Write post for:\n{story_text}\n{retry_context}")
462
-
463
- writer_llm = llm_medium.bind_tools([get_example_posts_for_writer, fetch_article_content, count_words])
464
-
465
- response = writer_llm.invoke([system_msg, user_msg])
466
- conversation = [system_msg, user_msg, response]
467
-
468
- steps = 0
469
- while hasattr(response, 'tool_calls') and response.tool_calls and steps < 5:
470
- if progress_callback:
471
- progress_callback(f"๐Ÿ”ง Writer using tools (step {steps+1})...")
472
-
473
- tool_messages = []
474
- for tool_call in response.tool_calls:
475
- name = tool_call['name']
476
- if name == 'get_example_posts_for_writer':
477
- res = get_example_posts_for_writer.invoke(tool_call['args'])
478
- elif name == 'fetch_article_content':
479
- res = fetch_article_content.invoke(tool_call['args'])
480
- elif name == 'count_words':
481
- res = count_words.invoke(tool_call['args'])
482
- else:
483
- res = "Unknown"
484
- tool_messages.append(ToolMessage(content=str(res), tool_call_id=tool_call['id']))
485
- conversation.extend(tool_messages)
486
- response = writer_llm.invoke(conversation)
487
- conversation.append(response)
488
- steps += 1
489
-
490
- if progress_callback:
491
- progress_callback("โœ… Draft completed!")
492
-
493
- state["draft_summary"] = response.content
494
- state["retry_count"] = retry_count + 1
495
- state["stage"] = WorkflowStage.CRITIC.value
496
- state["progress_log"].append(f"Draft written (attempt {retry_count + 1})")
497
-
498
- return state
499
-
500
- except Exception as e:
501
- state["stage"] = WorkflowStage.ERROR.value
502
- state["error_message"] = f"Writing failed: {str(e)}"
503
- state["progress_log"].append(f"โŒ Error: {str(e)}")
504
- return state
505
-
506
- def run_critic(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
507
- """Critic Agent with progress tracking"""
508
- try:
509
- if progress_callback:
510
- progress_callback("๐Ÿ” Evaluating draft quality...")
511
-
512
- draft = state.get("draft_summary", "")
513
-
514
- system_msg = SystemMessage(content="""You are the Critic Agent.
515
- Evaluate: Structure, Content, Style, Hashtags, Word Count.
516
- Output: Quality Score (0-10) and Decision (APPROVED/REJECTED). Approved if score >= 8.""")
517
-
518
- user_msg = HumanMessage(content=f"Evaluate:\n{draft}")
519
-
520
- critic_llm = llm_large.bind_tools([get_example_posts_for_critic])
521
- response = critic_llm.invoke([system_msg, user_msg])
522
- conversation = [system_msg, user_msg, response]
523
-
524
- if hasattr(response, 'tool_calls') and response.tool_calls:
525
- for tool_call in response.tool_calls:
526
- res = get_example_posts_for_critic.invoke(tool_call['args'])
527
- conversation.append(ToolMessage(content=str(res), tool_call_id=tool_call['id']))
528
- response = critic_llm.invoke(conversation)
529
-
530
- text = response.content
531
- score = 5.0
532
- try:
533
- if "TOTAL SCORE:" in text:
534
- score = float(text.split("TOTAL SCORE:")[1].split("/")[0].strip())
535
- except:
536
- pass
537
-
538
- if progress_callback:
539
- progress_callback(f"โœ… Evaluation complete! Score: {score}/10")
540
-
541
- state["quality_score"] = score
542
- state["critic_feedback"] = text
543
- state["stage"] = WorkflowStage.CHECKPOINT_3.value
544
- state["progress_log"].append(f"Critic evaluation: {score}/10")
545
-
546
- return state
547
-
548
- except Exception as e:
549
- state["stage"] = WorkflowStage.ERROR.value
550
- state["error_message"] = f"Critic failed: {str(e)}"
551
- state["progress_log"].append(f"โŒ Error: {str(e)}")
552
- return state
553
-
554
- def run_finalize(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
555
- """Finalize and save to database"""
556
- try:
557
- if progress_callback:
558
- progress_callback("๐Ÿ’พ Saving to database...")
559
-
560
- draft = state.get("draft_summary", "")
561
- score = state.get("quality_score", 0.0)
562
-
563
- topic = "Scientific Breakthrough"
564
- hashtags = ""
565
- lines = draft.split('\n')
566
- for line in lines:
567
- if "#" in line:
568
- hashtags = line
569
- break
570
-
571
- res = save_approved_post.invoke({
572
- "topic": topic,
573
- "summary": draft,
574
- "source_url": "N/A",
575
- "quality_score": score,
576
- "hashtags": hashtags
577
- })
578
-
579
- if progress_callback:
580
- progress_callback("โœ… Post saved successfully!")
581
-
582
- state["stage"] = WorkflowStage.COMPLETE.value
583
- state["progress_log"].append(f"Finalized: {res}")
584
-
585
- return state
586
-
587
- except Exception as e:
588
- state["stage"] = WorkflowStage.ERROR.value
589
- state["error_message"] = f"Finalization failed: {str(e)}"
590
- state["progress_log"].append(f"โŒ Error: {str(e)}")
591
- return state
592
-
593
- # --- 9. GRADIO INTERFACE ---
594
-
595
- def create_initial_state(topic: str) -> EnhancedAgentState:
596
- return {
597
- "stage": WorkflowStage.IDLE.value,
598
- "search_topic": topic,
599
- "candidates": [],
600
- "selected_story": {},
601
- "draft_summary": "",
602
- "quality_score": 0.0,
603
- "critic_feedback": "",
604
- "retry_count": 0,
605
- "error_message": "",
606
- "progress_log": []
607
- }
608
-
609
- def start_workflow(topic: str, progress=gr.Progress()):
610
- """Start the discovery process"""
611
- state = create_initial_state(topic)
612
-
613
- def update_progress(msg):
614
- progress(0.3, desc=msg)
615
-
616
- state = run_discovery(state, update_progress)
617
-
618
- if state["stage"] == WorkflowStage.ERROR.value:
619
- return (
620
- state,
621
- f"โŒ **Error:** {state['error_message']}",
622
- gr.update(visible=False),
623
- gr.update(visible=False),
624
- gr.update(visible=False),
625
- gr.update(visible=True), # Show restart
626
- "",
627
- gr.update(visible=False)
628
- )
629
-
630
- candidates_text = state["candidates"][0]["raw"] if state["candidates"] else "No candidates found"
631
-
632
- return (
633
- state,
634
- f"## ๐Ÿ” Discovery Results\n\n{candidates_text}",
635
- gr.update(visible=True), # Approve button
636
- gr.update(visible=True), # Reject button
637
- gr.update(visible=False),
638
- gr.update(visible=False),
639
- "",
640
- gr.update(visible=False)
641
- )
642
-
643
- def handle_checkpoint1_approve(state, progress=gr.Progress()):
644
- """Handle approval at checkpoint 1"""
645
- def update_progress(msg):
646
- progress(0.5, desc=msg)
647
-
648
- state = run_curator(state, update_progress)
649
-
650
- if state["stage"] == WorkflowStage.ERROR.value:
651
- return (
652
- state,
653
- f"โŒ **Error:** {state['error_message']}",
654
- gr.update(visible=False),
655
- gr.update(visible=False),
656
- gr.update(visible=False),
657
- gr.update(visible=True),
658
- "",
659
- gr.update(visible=False)
660
- )
661
-
662
- story_text = state["selected_story"]["raw"] if state["selected_story"] else "No story selected"
663
-
664
- return (
665
- state,
666
- f"## ๐ŸŽฏ Selected Story\n\n{story_text}",
667
- gr.update(visible=True), # Approve button
668
- gr.update(visible=True), # Different story
669
- gr.update(visible=False),
670
- gr.update(visible=False),
671
- "",
672
- gr.update(visible=False)
673
- )
674
-
675
- def handle_checkpoint1_reject(state, progress=gr.Progress()):
676
- """Handle rejection at checkpoint 1 - restart discovery"""
677
- def update_progress(msg):
678
- progress(0.3, desc=msg)
679
-
680
- state = run_discovery(state, update_progress)
681
-
682
- if state["stage"] == WorkflowStage.ERROR.value:
683
- return (
684
- state,
685
- f"โŒ **Error:** {state['error_message']}",
686
- gr.update(visible=False),
687
- gr.update(visible=False),
688
- gr.update(visible=False),
689
- gr.update(visible=True),
690
- "",
691
- gr.update(visible=False)
692
- )
693
-
694
- candidates_text = state["candidates"][0]["raw"] if state["candidates"] else "No candidates found"
695
-
696
- return (
697
- state,
698
- f"## ๐Ÿ” Discovery Results (New Search)\n\n{candidates_text}",
699
- gr.update(visible=True),
700
- gr.update(visible=True),
701
- gr.update(visible=False),
702
- gr.update(visible=False),
703
- "",
704
- gr.update(visible=False)
705
- )
706
-
707
- def handle_checkpoint2_approve(state, progress=gr.Progress()):
708
- """Handle approval at checkpoint 2 - proceed to writing"""
709
- def update_progress(msg):
710
- progress(0.6, desc=msg)
711
-
712
- state = run_writer(state, update_progress)
713
-
714
- if state["stage"] == WorkflowStage.ERROR.value:
715
- return (
716
- state,
717
- f"โŒ **Error:** {state['error_message']}",
718
- gr.update(visible=False),
719
- gr.update(visible=False),
720
- gr.update(visible=False),
721
- gr.update(visible=True),
722
- "",
723
- gr.update(visible=False)
724
- )
725
-
726
- progress(0.8, desc="Evaluating draft...")
727
- state = run_critic(state, lambda x: progress(0.8, desc=x))
728
-
729
- draft = state.get("draft_summary", "")
730
- score = state.get("quality_score", 0)
731
- feedback = state.get("critic_feedback", "")
732
-
733
- return (
734
- state,
735
- f"## โœ๏ธ Draft Post\n\n{draft}\n\n---\n\n**Quality Score:** {score}/10\n\n**Feedback:**\n{feedback}",
736
- gr.update(visible=True), # Finalize
737
- gr.update(visible=True), # Edit/Rewrite
738
- gr.update(visible=False),
739
- gr.update(visible=False),
740
- "",
741
- gr.update(visible=True) # Show edit instructions
742
- )
743
-
744
- def handle_checkpoint2_different(state, progress=gr.Progress()):
745
- """Request different story - re-run curator"""
746
- def update_progress(msg):
747
- progress(0.5, desc=msg)
748
-
749
- state = run_curator(state, update_progress)
750
-
751
- if state["stage"] == WorkflowStage.ERROR.value:
752
- return (
753
- state,
754
- f"โŒ **Error:** {state['error_message']}",
755
- gr.update(visible=False),
756
- gr.update(visible=False),
757
- gr.update(visible=False),
758
- gr.update(visible=True),
759
- "",
760
- gr.update(visible=False)
761
- )
762
-
763
- story_text = state["selected_story"]["raw"] if state["selected_story"] else "No story selected"
764
-
765
- return (
766
- state,
767
- f"## ๐ŸŽฏ Selected Story (Alternative)\n\n{story_text}",
768
- gr.update(visible=True),
769
- gr.update(visible=True),
770
- gr.update(visible=False),
771
- gr.update(visible=False),
772
- "",
773
- gr.update(visible=False)
774
- )
775
-
776
- def handle_checkpoint3_finalize(state, progress=gr.Progress()):
777
- """Finalize and save the post"""
778
- def update_progress(msg):
779
- progress(0.9, desc=msg)
780
-
781
- state = run_finalize(state, update_progress)
782
-
783
- draft = state.get("draft_summary", "")
784
-
785
- return (
786
- state,
787
- f"## โœ… Post Saved Successfully!\n\n{draft}\n\n---\n\n**Status:** Saved to database\n**Quality Score:** {state.get('quality_score', 0)}/10",
788
- gr.update(visible=False),
789
- gr.update(visible=False),
790
- gr.update(visible=False),
791
- gr.update(visible=True), # Show restart
792
- "",
793
- gr.update(visible=False)
794
- )
795
-
796
- def handle_checkpoint3_edit(state, edit_instructions, progress=gr.Progress()):
797
- """Edit the draft based on instructions"""
798
- if not edit_instructions:
799
- return (
800
- state,
801
- "โš ๏ธ Please provide edit instructions",
802
- gr.update(visible=True),
803
- gr.update(visible=True),
804
- gr.update(visible=False),
805
- gr.update(visible=False),
806
- edit_instructions,
807
- gr.update(visible=True)
808
- )
809
-
810
- # Add edit instructions to state for writer to use
811
- if "critic_feedback" in state:
812
- state["critic_feedback"] += f"\n\nUser edit request: {edit_instructions}"
813
-
814
- def update_progress(msg):
815
- progress(0.6, desc=msg)
816
-
817
- state = run_writer(state, update_progress)
818
-
819
- if state["stage"] == WorkflowStage.ERROR.value:
820
- return (
821
- state,
822
- f"โŒ **Error:** {state['error_message']}",
823
- gr.update(visible=False),
824
- gr.update(visible=False),
825
- gr.update(visible=False),
826
- gr.update(visible=True),
827
- "",
828
- gr.update(visible=False)
829
- )
830
-
831
- progress(0.8, desc="Re-evaluating draft...")
832
- state = run_critic(state, lambda x: progress(0.8, desc=x))
833
-
834
- draft = state.get("draft_summary", "")
835
- score = state.get("quality_score", 0)
836
- feedback = state.get("critic_feedback", "")
837
-
838
- return (
839
- state,
840
- f"## โœ๏ธ Revised Draft\n\n{draft}\n\n---\n\n**Quality Score:** {score}/10\n\n**Feedback:**\n{feedback}",
841
- gr.update(visible=True),
842
- gr.update(visible=True),
843
- gr.update(visible=False),
844
- gr.update(visible=False),
845
- "",
846
- gr.update(visible=True)
847
- )
848
-
849
- def restart_workflow():
850
- """Reset everything to start fresh"""
851
- return (
852
- None,
853
- "๐Ÿ‘‹ Ready to start! Enter a topic and click 'Start Discovery'",
854
- gr.update(visible=False),
855
- gr.update(visible=False),
856
- gr.update(visible=True), # Show start button
857
- gr.update(visible=False),
858
- "",
859
- gr.update(visible=False)
860
- )
861
-
862
- # --- 10. BUILD GRADIO UI ---
863
-
864
- css = """
865
- .output-box {
866
- min-height: 400px;
867
- max-height: 600px;
868
- overflow-y: auto;
869
- padding: 20px;
870
- border-radius: 8px;
871
- background: #f8f9fa;
872
- }
873
- .button-row {
874
- display: flex;
875
- gap: 10px;
876
- margin-top: 10px;
877
- }
878
- .status-badge {
879
- display: inline-block;
880
- padding: 5px 10px;
881
- border-radius: 5px;
882
- font-weight: bold;
883
- margin-bottom: 10px;
884
- }
885
- """
886
-
887
- with gr.Blocks(css=css, title="Tuesday Trivia Agent", theme=gr.themes.Soft()) as demo:
888
- gr.Markdown("""
889
- # ๐Ÿงช Tuesday Trivia Multi-Agent System
890
-
891
- An intelligent workflow for discovering, curating, and writing science trivia posts with human oversight at key checkpoints.
892
- """)
893
-
894
- # State
895
- state = gr.State()
896
-
897
- with gr.Row():
898
- with gr.Column(scale=2):
899
- # Main output area
900
- output_display = gr.Markdown("๐Ÿ‘‹ Ready to start! Enter a topic and click 'Start Discovery'", elem_classes="output-box")
901
-
902
- with gr.Column(scale=1):
903
- # Control panel
904
- gr.Markdown("### ๐ŸŽฎ Control Panel")
905
-
906
- topic_input = gr.Textbox(
907
- label="Search Topic",
908
- placeholder="e.g., quantum computing, biotechnology, space exploration",
909
- value="general science"
910
- )
911
-
912
- with gr.Row():
913
- start_btn = gr.Button("๐Ÿš€ Start Discovery", variant="primary", visible=True)
914
- restart_btn = gr.Button("๐Ÿ”„ Start New", variant="secondary", visible=False)
915
-
916
- gr.Markdown("---")
917
- gr.Markdown("### ๐Ÿ“‹ Decision Points")
918
-
919
- # Checkpoint buttons (hidden initially)
920
- approve_btn = gr.Button("โœ… Approve", variant="primary", visible=False)
921
- reject_btn = gr.Button("โŒ Reject / Different", variant="stop", visible=False)
922
-
923
- edit_instructions = gr.Textbox(
924
- label="Edit Instructions (optional)",
925
- placeholder="Provide specific instructions for changes...",
926
- visible=False,
927
- lines=3
928
- )
929
-
930
- gr.Markdown("---")
931
- gr.Markdown("""
932
- ### โ„น๏ธ Instructions
933
-
934
- **Workflow Steps:**
935
- 1. Enter topic โ†’ Start Discovery
936
- 2. Review candidates โ†’ Approve/Reject
937
- 3. Review story selection โ†’ Approve/Different
938
- 4. Review draft โ†’ Finalize/Edit
939
- 5. Post saved to database!
940
-
941
- **Tips:**
942
- - Be specific with topics for better results
943
- - Edit instructions help refine drafts
944
- - All posts are saved with quality scores
945
- """)
946
-
947
- # Event handlers
948
- start_btn.click(
949
- fn=start_workflow,
950
- inputs=[topic_input],
951
- outputs=[state, output_display, approve_btn, reject_btn, start_btn, restart_btn, edit_instructions, edit_instructions]
952
- )
953
-
954
- # Checkpoint 1 handlers
955
- def route_checkpoint1_approve(s):
956
- if s and s.get("stage") == WorkflowStage.CHECKPOINT_1.value:
957
- return handle_checkpoint1_approve(s)
958
- return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
959
-
960
- def route_checkpoint1_reject(s):
961
- if s and s.get("stage") == WorkflowStage.CHECKPOINT_1.value:
962
- return handle_checkpoint1_reject(s)
963
- return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
964
-
965
- # Checkpoint 2 handlers
966
- def route_checkpoint2_approve(s):
967
- if s and s.get("stage") == WorkflowStage.CHECKPOINT_2.value:
968
- return handle_checkpoint2_approve(s)
969
- return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
970
-
971
- def route_checkpoint2_different(s):
972
- if s and s.get("stage") == WorkflowStage.CHECKPOINT_2.value:
973
- return handle_checkpoint2_different(s)
974
- return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
975
-
976
- # Checkpoint 3 handlers
977
- def route_checkpoint3_finalize(s):
978
- if s and s.get("stage") == WorkflowStage.CHECKPOINT_3.value:
979
- return handle_checkpoint3_finalize(s)
980
- return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
981
-
982
- def route_checkpoint3_edit(s, instructions):
983
- if s and s.get("stage") == WorkflowStage.CHECKPOINT_3.value:
984
- return handle_checkpoint3_edit(s, instructions)
985
- return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), instructions, gr.update()
986
-
987
- # Wire up the routing based on current stage
988
- def smart_approve(s):
989
- if not s:
990
- return s, "No active workflow", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
991
-
992
- stage = s.get("stage")
993
- if stage == WorkflowStage.CHECKPOINT_1.value:
994
- return route_checkpoint1_approve(s)
995
- elif stage == WorkflowStage.CHECKPOINT_2.value:
996
- return route_checkpoint2_approve(s)
997
- elif stage == WorkflowStage.CHECKPOINT_3.value:
998
- return route_checkpoint3_finalize(s)
999
-
1000
- return s, "Invalid stage for approval", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1001
-
1002
- def smart_reject(s):
1003
- if not s:
1004
- return s, "No active workflow", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1005
-
1006
- stage = s.get("stage")
1007
- if stage == WorkflowStage.CHECKPOINT_1.value:
1008
- return route_checkpoint1_reject(s)
1009
- elif stage == WorkflowStage.CHECKPOINT_2.value:
1010
- return route_checkpoint2_different(s)
1011
- elif stage == WorkflowStage.CHECKPOINT_3.value:
1012
- return route_checkpoint3_edit(s, "")
1013
-
1014
- return s, "Invalid stage for rejection", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1015
-
1016
- approve_btn.click(
1017
- fn=smart_approve,
1018
- inputs=[state],
1019
- outputs=[state, output_display, approve_btn, reject_btn, start_btn, restart_btn, edit_instructions, edit_instructions]
1020
- )
1021
-
1022
- reject_btn.click(
1023
- fn=smart_reject,
1024
- inputs=[state],
1025
- outputs=[state, output_display, approve_btn, reject_btn, start_btn, restart_btn, edit_instructions, edit_instructions]
1026
- )
1027
-
1028
- restart_btn.click(
1029
- fn=restart_workflow,
1030
- outputs=[state, output_display, approve_btn, reject_btn, start_btn, restart_btn, edit_instructions, edit_instructions]
1031
- )
1032
-
1033
- if __name__ == "__main__":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  demo.launch()
 
1
+ import os
2
+ import json
3
+ import sqlite3
4
+ import random
5
+ import requests
6
+ import asyncio
7
+ from typing import TypedDict, Annotated, List, Dict, Any, Optional
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from enum import Enum
11
+ import tempfile
12
+ import shutil
13
+
14
+ # Third-party imports
15
+ import gradio as gr
16
+ from dotenv import load_dotenv
17
+ from bs4 import BeautifulSoup
18
+ from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage, ToolMessage
19
+ from langchain_mistralai.chat_models import ChatMistralAI
20
+ from langchain_core.tools import tool
21
+ from langchain_community.tools.tavily_search import TavilySearchResults
22
+ from langgraph.graph import StateGraph, END
23
+ from huggingface_hub import HfApi, hf_hub_download, upload_file
24
+ from datasets import Dataset, load_dataset
25
+
26
+ # --- 1. SETUP & CONFIGURATION ---
27
+
28
+ load_dotenv()
29
+
30
+ # Hugging Face Configuration
31
+ HF_TOKEN = os.getenv("HF_TOKEN")
32
+ HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "tuesday-trivia-posts") # e.g., "username/trivia-posts-db"
33
+ HF_USERNAME = os.getenv("HF_USERNAME", None) # Your HF username
34
+
35
+ # Auto-construct repo name if not provided
36
+ if "/" not in HF_DATASET_REPO and HF_USERNAME:
37
+ HF_DATASET_REPO = f"{HF_USERNAME}/{HF_DATASET_REPO}"
38
+
39
+ # Local temporary database
40
+ DB_DIR = Path(tempfile.gettempdir()) / "trivia_data"
41
+ DB_DIR.mkdir(exist_ok=True, parents=True)
42
+ DB_PATH = DB_DIR / "trivia_posts.db"
43
+
44
+ print(f"๐Ÿ“ Local database location: {DB_PATH.absolute()}")
45
+ print(f"โ˜๏ธ HF Dataset repository: {HF_DATASET_REPO}")
46
+
47
+ # Initialize HF API
48
+ hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
49
+
50
+ # --- 2. API KEY ROTATION ---
51
+
52
+ class APIKeyRotator:
53
+ """Handles API key rotation for fault tolerance"""
54
+
55
+ def __init__(self, service_name: str):
56
+ self.service_name = service_name
57
+ self.keys = self._load_keys()
58
+ self.current_index = 0
59
+
60
+ def _load_keys(self) -> List[str]:
61
+ keys = []
62
+ i = 1
63
+ while True:
64
+ key = os.getenv(f"{self.service_name}_API_KEY_{i}")
65
+ if not key:
66
+ if i == 1:
67
+ key = os.getenv(f"{self.service_name}_API_KEY")
68
+ if key:
69
+ keys.append(key)
70
+ break
71
+ keys.append(key)
72
+ i += 1
73
+
74
+ if not keys:
75
+ print(f"โš ๏ธ No API keys found for {self.service_name}. Ensure secrets are set.")
76
+ return []
77
+
78
+ random.shuffle(keys)
79
+ return keys
80
+
81
+ def get_key(self) -> str:
82
+ if not self.keys: return ""
83
+ return self.keys[self.current_index]
84
+
85
+ def rotate(self) -> str:
86
+ if not self.keys: return ""
87
+ self.current_index = (self.current_index + 1) % len(self.keys)
88
+ print(f"๐Ÿ”„ Rotated {self.service_name} API key to index {self.current_index + 1}/{len(self.keys)}")
89
+ return self.get_key()
90
+
91
+ mistral_rotator = APIKeyRotator("MISTRAL")
92
+ tavily_rotator = APIKeyRotator("TAVILY")
93
+
94
+ os.environ["MISTRAL_API_KEY"] = mistral_rotator.get_key()
95
+ os.environ["TAVILY_API_KEY"] = tavily_rotator.get_key()
96
+
97
+ # --- 3. RESILIENT LLM ---
98
+
99
+ def create_llm_with_rotation(model: str, temperature: float = 0.2, max_retries: int = 3):
100
+ class ResilientLLM:
101
+ def __init__(self, model, temperature):
102
+ self.model = model
103
+ self.temperature = temperature
104
+
105
+ def invoke(self, *args, **kwargs):
106
+ for attempt in range(max_retries):
107
+ try:
108
+ llm = ChatMistralAI(
109
+ model=self.model,
110
+ temperature=self.temperature,
111
+ mistral_api_key=mistral_rotator.get_key()
112
+ )
113
+ return llm.invoke(*args, **kwargs)
114
+ except Exception as e:
115
+ print(f"โŒ API call failed (attempt {attempt + 1}/{max_retries}): {str(e)}")
116
+ if attempt < max_retries - 1:
117
+ mistral_rotator.rotate()
118
+ else:
119
+ raise Exception(f"All API keys exhausted after {max_retries} attempts")
120
+
121
+ def bind_tools(self, tools):
122
+ class ToolBoundLLM:
123
+ def __init__(self, parent, tools):
124
+ self.parent = parent
125
+ self.tools = tools
126
+
127
+ def invoke(self, *args, **kwargs):
128
+ for attempt in range(max_retries):
129
+ try:
130
+ llm = ChatMistralAI(
131
+ model=self.parent.model,
132
+ temperature=self.parent.temperature,
133
+ mistral_api_key=mistral_rotator.get_key()
134
+ )
135
+ bound_llm = llm.bind_tools(self.tools)
136
+ return bound_llm.invoke(*args, **kwargs)
137
+ except Exception as e:
138
+ print(f"โŒ API call failed (attempt {attempt + 1}/{max_retries}): {str(e)}")
139
+ if attempt < max_retries - 1:
140
+ mistral_rotator.rotate()
141
+ else:
142
+ raise Exception(f"All API keys exhausted after {max_retries} attempts")
143
+ return ToolBoundLLM(self, tools)
144
+
145
+ return ResilientLLM(model, temperature)
146
+
147
+ llm_small = create_llm_with_rotation("mistral-small-latest", temperature=0.2)
148
+ llm_medium = create_llm_with_rotation("mistral-medium-latest", temperature=0.2)
149
+ llm_large = create_llm_with_rotation("mistral-large-latest", temperature=0.2)
150
+
151
+ # --- 4. DATABASE FUNCTIONS WITH HF SYNC ---
152
+
153
+ def get_db_connection():
154
+ return sqlite3.connect(str(DB_PATH))
155
+
156
+ def sync_from_hf():
157
+ """Download database from HF Dataset if it exists"""
158
+ if not HF_TOKEN or not hf_api:
159
+ print("โš ๏ธ No HF_TOKEN found, skipping sync from HF")
160
+ return False
161
+
162
+ try:
163
+ print("๐Ÿ“ฅ Syncing database from Hugging Face...")
164
+
165
+ # Try to load the dataset
166
+ try:
167
+ dataset = load_dataset(HF_DATASET_REPO, split="train", token=HF_TOKEN)
168
+
169
+ # Convert dataset to SQLite
170
+ conn = get_db_connection()
171
+ cursor = conn.cursor()
172
+
173
+ # Clear existing data
174
+ cursor.execute('DELETE FROM posts')
175
+
176
+ # Insert data from HF
177
+ for row in dataset:
178
+ cursor.execute('''
179
+ INSERT INTO posts (id, date, topic, summary, source_url, quality_score,
180
+ engagement_score, hashtags, created_at, updated_at)
181
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
182
+ ''', (
183
+ row.get('id'),
184
+ row.get('date'),
185
+ row.get('topic'),
186
+ row.get('summary'),
187
+ row.get('source_url'),
188
+ row.get('quality_score'),
189
+ row.get('engagement_score'),
190
+ row.get('hashtags'),
191
+ row.get('created_at'),
192
+ row.get('updated_at')
193
+ ))
194
+
195
+ conn.commit()
196
+ conn.close()
197
+
198
+ print(f"โœ… Synced {len(dataset)} posts from HF Dataset")
199
+ return True
200
+
201
+ except Exception as e:
202
+ if "not found" in str(e).lower() or "doesn't exist" in str(e).lower():
203
+ print(f"โ„น๏ธ Dataset '{HF_DATASET_REPO}' doesn't exist yet, will create on first save")
204
+ return False
205
+ else:
206
+ print(f"โš ๏ธ Error loading dataset: {e}")
207
+ return False
208
+
209
+ except Exception as e:
210
+ print(f"โš ๏ธ Error syncing from HF: {e}")
211
+ return False
212
+
213
+ def sync_to_hf():
214
+ """Upload database to HF Dataset"""
215
+ if not HF_TOKEN or not hf_api:
216
+ print("โš ๏ธ No HF_TOKEN found, skipping sync to HF")
217
+ return False
218
+
219
+ try:
220
+ print("๐Ÿ“ค Syncing database to Hugging Face...")
221
+
222
+ conn = get_db_connection()
223
+ cursor = conn.cursor()
224
+ cursor.execute('SELECT * FROM posts')
225
+
226
+ columns = [description[0] for description in cursor.description]
227
+ rows = cursor.fetchall()
228
+ conn.close()
229
+
230
+ if not rows:
231
+ print("โ„น๏ธ No posts to sync")
232
+ return False
233
+
234
+ # Convert to dictionary format for HF Dataset
235
+ data = {col: [] for col in columns}
236
+ for row in rows:
237
+ for col, value in zip(columns, row):
238
+ data[col].append(value)
239
+
240
+ # Create and push dataset
241
+ dataset = Dataset.from_dict(data)
242
+
243
+ # Try to push, create repo if it doesn't exist
244
+ try:
245
+ dataset.push_to_hub(
246
+ HF_DATASET_REPO,
247
+ token=HF_TOKEN,
248
+ private=True # Make it private by default
249
+ )
250
+ print(f"โœ… Synced {len(rows)} posts to HF Dataset: {HF_DATASET_REPO}")
251
+ return True
252
+
253
+ except Exception as push_error:
254
+ if "not found" in str(push_error).lower():
255
+ # Create the repo first
256
+ print(f"๐Ÿ“ฆ Creating new dataset repository: {HF_DATASET_REPO}")
257
+ hf_api.create_repo(
258
+ repo_id=HF_DATASET_REPO,
259
+ token=HF_TOKEN,
260
+ repo_type="dataset",
261
+ private=True
262
+ )
263
+ # Try pushing again
264
+ dataset.push_to_hub(
265
+ HF_DATASET_REPO,
266
+ token=HF_TOKEN,
267
+ private=True
268
+ )
269
+ print(f"โœ… Created and synced to new dataset: {HF_DATASET_REPO}")
270
+ return True
271
+ else:
272
+ raise push_error
273
+
274
+ except Exception as e:
275
+ print(f"โŒ Error syncing to HF: {e}")
276
+ return False
277
+
278
+ def init_database():
279
+ """Initialize local database and sync from HF if available"""
280
+ conn = get_db_connection()
281
+ cursor = conn.cursor()
282
+ cursor.execute('''
283
+ CREATE TABLE IF NOT EXISTS posts (
284
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
285
+ date TEXT NOT NULL,
286
+ topic TEXT NOT NULL,
287
+ summary TEXT NOT NULL,
288
+ source_url TEXT,
289
+ quality_score REAL CHECK(quality_score >= 0 AND quality_score <= 10),
290
+ engagement_score REAL,
291
+ hashtags TEXT,
292
+ created_at TEXT NOT NULL,
293
+ updated_at TEXT
294
+ )
295
+ ''')
296
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_date ON posts(date)')
297
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_quality_score ON posts(quality_score)')
298
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_created_at ON posts(created_at)')
299
+ cursor.execute('''
300
+ CREATE TABLE IF NOT EXISTS metadata (
301
+ key TEXT PRIMARY KEY, value TEXT, updated_at TEXT NOT NULL
302
+ )
303
+ ''')
304
+ cursor.execute('''
305
+ INSERT OR REPLACE INTO metadata (key, value, updated_at)
306
+ VALUES ('db_version', '1.0', ?)
307
+ ''', (datetime.now().isoformat(),))
308
+ conn.commit()
309
+ conn.close()
310
+
311
+ # Sync from HF after initializing schema
312
+ sync_from_hf()
313
+
314
+ # Initialize immediately
315
+ init_database()
316
+
317
+ # --- 5. TOOLS ---
318
+
319
+ @tool
320
+ def search_science_breakthroughs(query: str) -> str:
321
+ """Search for recent scientific breakthroughs."""
322
+ try:
323
+ search = TavilySearchResults(
324
+ max_results=10,
325
+ include_domains=["sciencedaily.com", "nature.com", "science.org", "newscientist.com", "scientificamerican.com"],
326
+ search_depth="advanced"
327
+ )
328
+ results = search.invoke(query)
329
+ return json.dumps(results, indent=2)
330
+ except Exception as e:
331
+ return f"Error in search: {str(e)}"
332
+
333
+ @tool
334
+ def fetch_article_content(url: str) -> str:
335
+ """Fetch the full text content of an article."""
336
+ try:
337
+ response = requests.get(url, timeout=10, headers={'User-Agent': 'Mozilla/5.0'})
338
+ response.raise_for_status()
339
+ soup = BeautifulSoup(response.content, 'html.parser')
340
+ for script in soup(["script", "style", "nav", "footer", "header"]):
341
+ script.decompose()
342
+ text = soup.get_text()
343
+ lines = (line.strip() for line in text.splitlines())
344
+ chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
345
+ text = ' '.join(chunk for chunk in chunks if chunk)
346
+ return text[:5000] if len(text) > 5000 else text
347
+ except Exception as e:
348
+ return f"Error fetching article: {str(e)}"
349
+
350
+ @tool
351
+ def get_all_previous_posts() -> str:
352
+ """Retrieve all previously published posts."""
353
+ conn = None
354
+ try:
355
+ conn = get_db_connection()
356
+ cursor = conn.cursor()
357
+ cursor.execute('SELECT id, date, topic, summary, source_url, quality_score FROM posts ORDER BY date DESC')
358
+ posts = cursor.fetchall()
359
+ if not posts: return "No previous posts found."
360
+ formatted = []
361
+ for p in posts:
362
+ formatted.append({"id": p[0], "date": p[1], "topic": p[2], "summary": p[3][:100], "source_url": p[4], "quality_score": p[5]})
363
+ return json.dumps(formatted, indent=2)
364
+ except Exception as e:
365
+ return f"Error: {str(e)}"
366
+ finally:
367
+ if conn: conn.close()
368
+
369
+ @tool
370
+ def get_top_quality_posts(limit: int = 5) -> str:
371
+ """Retrieve top quality posts."""
372
+ conn = None
373
+ try:
374
+ conn = get_db_connection()
375
+ cursor = conn.cursor()
376
+ cursor.execute('SELECT id, date, topic, summary, quality_score, hashtags FROM posts WHERE quality_score >= 7 ORDER BY quality_score DESC LIMIT ?', (limit,))
377
+ posts = cursor.fetchall()
378
+ if not posts: return "No high-quality example posts found."
379
+ formatted = []
380
+ for p in posts:
381
+ formatted.append({"id": p[0], "date": p[1], "topic": p[2], "summary": p[3], "quality_score": p[4], "hashtags": p[5]})
382
+ return json.dumps(formatted, indent=2)
383
+ except Exception as e:
384
+ return f"Error: {str(e)}"
385
+ finally:
386
+ if conn: conn.close()
387
+
388
+ @tool
389
+ def save_approved_post(topic: str, summary: str, source_url: str, quality_score: float, hashtags: str) -> str:
390
+ """Save an approved post to the database and sync to HF."""
391
+ conn = None
392
+ try:
393
+ conn = get_db_connection()
394
+ cursor = conn.cursor()
395
+ now = datetime.now()
396
+ cursor.execute('''
397
+ INSERT INTO posts (date, topic, summary, source_url, quality_score, hashtags, created_at, updated_at)
398
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
399
+ ''', (now.strftime('%Y-%m-%d'), topic, summary, source_url, quality_score, hashtags, now.isoformat(), now.isoformat()))
400
+ conn.commit()
401
+ post_id = cursor.lastrowid
402
+ conn.close()
403
+
404
+ # Sync to HF after saving
405
+ sync_result = sync_to_hf()
406
+ sync_msg = "and synced to HF Dataset โ˜๏ธ" if sync_result else "(HF sync skipped)"
407
+
408
+ return f"Post saved successfully with ID: {post_id} {sync_msg}"
409
+ except Exception as e:
410
+ return f"Error saving post: {str(e)}"
411
+ finally:
412
+ if conn:
413
+ try:
414
+ conn.close()
415
+ except:
416
+ pass
417
+
418
+ @tool
419
+ def check_topic_similarity(new_topic: str) -> str:
420
+ """Check topic similarity."""
421
+ conn = None
422
+ try:
423
+ conn = get_db_connection()
424
+ cursor = conn.cursor()
425
+ cursor.execute('SELECT topic, summary FROM posts')
426
+ previous = cursor.fetchall()
427
+ if not previous: return json.dumps({"is_duplicate": False, "similar_posts": []})
428
+
429
+ new_keywords = set(new_topic.lower().split())
430
+ similar = []
431
+ for prev_topic, prev_summary in previous:
432
+ prev_keywords = set(prev_topic.lower().split())
433
+ if not new_keywords: continue
434
+ overlap = len(new_keywords & prev_keywords)
435
+ similarity = overlap / len(new_keywords)
436
+ if similarity > 0.5:
437
+ similar.append({"topic": prev_topic, "similarity": similarity})
438
+
439
+ return json.dumps({"is_duplicate": len(similar) > 0, "similar_posts": similar}, indent=2)
440
+ except Exception as e:
441
+ return f"Error: {str(e)}"
442
+ finally:
443
+ if conn: conn.close()
444
+
445
+ @tool
446
+ def count_words(text: str) -> str:
447
+ return f"Word count: {len(text.split())}"
448
+
449
+ @tool
450
+ def get_example_posts_for_writer() -> str:
451
+ examples = [
452
+ {"title": "Researchers Have Developed Next-Generation Plant Immune System",
453
+ "content": "Full example content here...",
454
+ "structure": "Title -> 2 Paragraphs -> Hashtags"},
455
+ ]
456
+ return json.dumps(examples, indent=2)
457
+
458
+ @tool
459
+ def get_example_posts_for_critic() -> str:
460
+ examples = [
461
+ {"title": "Scientists Found 14 Mysterious Creatures", "quality_score": 9.0},
462
+ ]
463
+ return json.dumps(examples, indent=2)
464
+
465
+ # --- 6. WORKFLOW STAGES ENUM ---
466
+
467
+ class WorkflowStage(Enum):
468
+ IDLE = "idle"
469
+ DISCOVERY = "discovery"
470
+ CHECKPOINT_1 = "checkpoint_1"
471
+ CURATOR = "curator"
472
+ CHECKPOINT_2 = "checkpoint_2"
473
+ WRITER = "writer"
474
+ CRITIC = "critic"
475
+ CHECKPOINT_3 = "checkpoint_3"
476
+ FINALIZE = "finalize"
477
+ COMPLETE = "complete"
478
+ ERROR = "error"
479
+
480
+ # --- 7. ENHANCED STATE ---
481
+
482
+ class EnhancedAgentState(TypedDict):
483
+ stage: str
484
+ search_topic: str
485
+ candidates: List[Dict]
486
+ selected_story: Dict
487
+ draft_summary: str
488
+ quality_score: float
489
+ critic_feedback: str
490
+ retry_count: int
491
+ error_message: str
492
+ progress_log: List[str]
493
+
494
+ # --- 8. AGENT FUNCTIONS WITH PROGRESS TRACKING ---
495
+
496
+ def run_discovery(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
497
+ """Discovery Agent with progress tracking"""
498
+ try:
499
+ if progress_callback:
500
+ progress_callback("๐Ÿ” Searching for scientific breakthroughs...")
501
+
502
+ topic = state.get("search_topic", "general science")
503
+
504
+ system_msg = SystemMessage(content=f"""You are the Discovery Agent for Tuesday Trivia.
505
+ Mission: Find 10-15 recent scientific breakthroughs (2-4 weeks).
506
+ Tools: search_science_breakthroughs, get_all_previous_posts, check_topic_similarity.
507
+ Output: List 10-15 unique breakthroughs with Title, Desc, URL, Why interesting.""")
508
+
509
+ user_msg = HumanMessage(content=f"Search for recent breakthroughs in {topic}.")
510
+
511
+ discovery_llm = llm_small.bind_tools([search_science_breakthroughs, get_all_previous_posts, check_topic_similarity])
512
+
513
+ if progress_callback:
514
+ progress_callback("๐Ÿค– Running discovery agent...")
515
+
516
+ response = discovery_llm.invoke([system_msg, user_msg])
517
+ conversation = [system_msg, user_msg, response]
518
+
519
+ max_steps = 5
520
+ steps = 0
521
+ while hasattr(response, 'tool_calls') and response.tool_calls and steps < max_steps:
522
+ if progress_callback:
523
+ progress_callback(f"๐Ÿ”ง Executing tools (step {steps+1}/{max_steps})...")
524
+
525
+ tool_messages = []
526
+ for tool_call in response.tool_calls:
527
+ name = tool_call['name']
528
+ args = tool_call['args']
529
+
530
+ if name == 'search_science_breakthroughs':
531
+ res = search_science_breakthroughs.invoke(args)
532
+ elif name == 'get_all_previous_posts':
533
+ res = get_all_previous_posts.invoke(args)
534
+ elif name == 'check_topic_similarity':
535
+ res = check_topic_similarity.invoke(args)
536
+ else:
537
+ res = f"Unknown tool: {name}"
538
+
539
+ tool_messages.append(ToolMessage(content=str(res), tool_call_id=tool_call['id']))
540
+
541
+ conversation.extend(tool_messages)
542
+ response = discovery_llm.invoke(conversation)
543
+ conversation.append(response)
544
+ steps += 1
545
+
546
+ # Parse candidates from response
547
+ candidates_text = response.content
548
+
549
+ if progress_callback:
550
+ progress_callback("โœ… Discovery complete!")
551
+
552
+ state["candidates"] = [{"raw": candidates_text}]
553
+ state["stage"] = WorkflowStage.CHECKPOINT_1.value
554
+ state["progress_log"].append("Discovery completed successfully")
555
+
556
+ return state
557
+
558
+ except Exception as e:
559
+ state["stage"] = WorkflowStage.ERROR.value
560
+ state["error_message"] = f"Discovery failed: {str(e)}"
561
+ state["progress_log"].append(f"โŒ Error: {str(e)}")
562
+ return state
563
+
564
+ def run_curator(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
565
+ """Curator Agent with progress tracking"""
566
+ try:
567
+ if progress_callback:
568
+ progress_callback("๐ŸŽฏ Ranking and selecting best story...")
569
+
570
+ candidates = state.get("candidates", [])
571
+ candidates_text = candidates[0].get("raw", "") if candidates else ""
572
+
573
+ system_msg = SystemMessage(content="""You are the Curator Agent.
574
+ Rank candidates (1-10) on Recency, Significance, Engagement. Select TOP story.
575
+ Output: RANKED CANDIDATES list, then SELECTED STORY with Justification.""")
576
+
577
+ user_msg = HumanMessage(content=f"Evaluate these candidates:\n{candidates_text}")
578
+
579
+ curator_llm = llm_large.bind_tools([])
580
+ response = curator_llm.invoke([system_msg, user_msg])
581
+
582
+ if progress_callback:
583
+ progress_callback("โœ… Story selected!")
584
+
585
+ state["selected_story"] = {"raw": response.content}
586
+ state["stage"] = WorkflowStage.CHECKPOINT_2.value
587
+ state["progress_log"].append("Curation completed")
588
+
589
+ return state
590
+
591
+ except Exception as e:
592
+ state["stage"] = WorkflowStage.ERROR.value
593
+ state["error_message"] = f"Curation failed: {str(e)}"
594
+ state["progress_log"].append(f"โŒ Error: {str(e)}")
595
+ return state
596
+
597
+ def run_writer(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
598
+ """Writer Agent with progress tracking"""
599
+ try:
600
+ if progress_callback:
601
+ progress_callback("โœ๏ธ Writing draft post...")
602
+
603
+ selected_story = state.get("selected_story", {})
604
+ story_text = selected_story.get("raw", "")
605
+ retry_count = state.get("retry_count", 0)
606
+
607
+ retry_context = ""
608
+ if retry_count > 0:
609
+ retry_context = f"\nPrevious attempt rejected. Feedback: {state.get('critic_feedback')}"
610
+
611
+ system_msg = SystemMessage(content="""You are the Writer Agent.
612
+ Structure: Title, Para 1 (Hook), Para 2 (Tech/Impact), Hashtags (#TuesdayTrivia #RnDCell #CCA).
613
+ Tone: Conversational, active voice, no questions. 140-180 words.
614
+ Use tools to get examples and count words.""")
615
+
616
+ user_msg = HumanMessage(content=f"Write post for:\n{story_text}\n{retry_context}")
617
+
618
+ writer_llm = llm_medium.bind_tools([get_example_posts_for_writer, fetch_article_content, count_words])
619
+
620
+ response = writer_llm.invoke([system_msg, user_msg])
621
+ conversation = [system_msg, user_msg, response]
622
+
623
+ steps = 0
624
+ while hasattr(response, 'tool_calls') and response.tool_calls and steps < 5:
625
+ if progress_callback:
626
+ progress_callback(f"๐Ÿ”ง Writer using tools (step {steps+1})...")
627
+
628
+ tool_messages = []
629
+ for tool_call in response.tool_calls:
630
+ name = tool_call['name']
631
+ if name == 'get_example_posts_for_writer':
632
+ res = get_example_posts_for_writer.invoke(tool_call['args'])
633
+ elif name == 'fetch_article_content':
634
+ res = fetch_article_content.invoke(tool_call['args'])
635
+ elif name == 'count_words':
636
+ res = count_words.invoke(tool_call['args'])
637
+ else:
638
+ res = "Unknown"
639
+ tool_messages.append(ToolMessage(content=str(res), tool_call_id=tool_call['id']))
640
+ conversation.extend(tool_messages)
641
+ response = writer_llm.invoke(conversation)
642
+ conversation.append(response)
643
+ steps += 1
644
+
645
+ if progress_callback:
646
+ progress_callback("โœ… Draft completed!")
647
+
648
+ state["draft_summary"] = response.content
649
+ state["retry_count"] = retry_count + 1
650
+ state["stage"] = WorkflowStage.CRITIC.value
651
+ state["progress_log"].append(f"Draft written (attempt {retry_count + 1})")
652
+
653
+ return state
654
+
655
+ except Exception as e:
656
+ state["stage"] = WorkflowStage.ERROR.value
657
+ state["error_message"] = f"Writing failed: {str(e)}"
658
+ state["progress_log"].append(f"โŒ Error: {str(e)}")
659
+ return state
660
+
661
+ def run_critic(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
662
+ """Critic Agent with progress tracking"""
663
+ try:
664
+ if progress_callback:
665
+ progress_callback("๐Ÿ” Evaluating draft quality...")
666
+
667
+ draft = state.get("draft_summary", "")
668
+
669
+ system_msg = SystemMessage(content="""You are the Critic Agent.
670
+ Evaluate: Structure, Content, Style, Hashtags, Word Count.
671
+ Output: Quality Score (0-10) and Decision (APPROVED/REJECTED). Approved if score >= 8.""")
672
+
673
+ user_msg = HumanMessage(content=f"Evaluate:\n{draft}")
674
+
675
+ critic_llm = llm_large.bind_tools([get_example_posts_for_critic])
676
+ response = critic_llm.invoke([system_msg, user_msg])
677
+ conversation = [system_msg, user_msg, response]
678
+
679
+ if hasattr(response, 'tool_calls') and response.tool_calls:
680
+ for tool_call in response.tool_calls:
681
+ res = get_example_posts_for_critic.invoke(tool_call['args'])
682
+ conversation.append(ToolMessage(content=str(res), tool_call_id=tool_call['id']))
683
+ response = critic_llm.invoke(conversation)
684
+
685
+ text = response.content
686
+ score = 5.0
687
+ try:
688
+ if "TOTAL SCORE:" in text:
689
+ score = float(text.split("TOTAL SCORE:")[1].split("/")[0].strip())
690
+ except:
691
+ pass
692
+
693
+ if progress_callback:
694
+ progress_callback(f"โœ… Evaluation complete! Score: {score}/10")
695
+
696
+ state["quality_score"] = score
697
+ state["critic_feedback"] = text
698
+ state["stage"] = WorkflowStage.CHECKPOINT_3.value
699
+ state["progress_log"].append(f"Critic evaluation: {score}/10")
700
+
701
+ return state
702
+
703
+ except Exception as e:
704
+ state["stage"] = WorkflowStage.ERROR.value
705
+ state["error_message"] = f"Critic failed: {str(e)}"
706
+ state["progress_log"].append(f"โŒ Error: {str(e)}")
707
+ return state
708
+
709
+ def run_finalize(state: EnhancedAgentState, progress_callback=None) -> EnhancedAgentState:
710
+ """Finalize and save to database"""
711
+ try:
712
+ if progress_callback:
713
+ progress_callback("๐Ÿ’พ Saving to database...")
714
+
715
+ draft = state.get("draft_summary", "")
716
+ score = state.get("quality_score", 0.0)
717
+
718
+ topic = "Scientific Breakthrough"
719
+ hashtags = ""
720
+ lines = draft.split('\n')
721
+ for line in lines:
722
+ if "#" in line:
723
+ hashtags = line
724
+ break
725
+
726
+ res = save_approved_post.invoke({
727
+ "topic": topic,
728
+ "summary": draft,
729
+ "source_url": "N/A",
730
+ "quality_score": score,
731
+ "hashtags": hashtags
732
+ })
733
+
734
+ if progress_callback:
735
+ progress_callback("โœ… Post saved successfully!")
736
+
737
+ state["stage"] = WorkflowStage.COMPLETE.value
738
+ state["progress_log"].append(f"Finalized: {res}")
739
+
740
+ return state
741
+
742
+ except Exception as e:
743
+ state["stage"] = WorkflowStage.ERROR.value
744
+ state["error_message"] = f"Finalization failed: {str(e)}"
745
+ state["progress_log"].append(f"โŒ Error: {str(e)}")
746
+ return state
747
+
748
+ # --- 9. GRADIO INTERFACE ---
749
+
750
+ def create_initial_state(topic: str) -> EnhancedAgentState:
751
+ return {
752
+ "stage": WorkflowStage.IDLE.value,
753
+ "search_topic": topic,
754
+ "candidates": [],
755
+ "selected_story": {},
756
+ "draft_summary": "",
757
+ "quality_score": 0.0,
758
+ "critic_feedback": "",
759
+ "retry_count": 0,
760
+ "error_message": "",
761
+ "progress_log": []
762
+ }
763
+
764
+ def start_workflow(topic: str, progress=gr.Progress()):
765
+ """Start the discovery process"""
766
+ state = create_initial_state(topic)
767
+
768
+ def update_progress(msg):
769
+ progress(0.3, desc=msg)
770
+
771
+ state = run_discovery(state, update_progress)
772
+
773
+ if state["stage"] == WorkflowStage.ERROR.value:
774
+ return (
775
+ state,
776
+ f"โŒ **Error:** {state['error_message']}",
777
+ gr.update(visible=False),
778
+ gr.update(visible=False),
779
+ gr.update(visible=False),
780
+ gr.update(visible=True), # Show restart
781
+ "",
782
+ gr.update(visible=False)
783
+ )
784
+
785
+ candidates_text = state["candidates"][0]["raw"] if state["candidates"] else "No candidates found"
786
+
787
+ return (
788
+ state,
789
+ f"## ๐Ÿ” Discovery Results\n\n{candidates_text}",
790
+ gr.update(visible=True), # Approve button
791
+ gr.update(visible=True), # Reject button
792
+ gr.update(visible=False),
793
+ gr.update(visible=False),
794
+ "",
795
+ gr.update(visible=False)
796
+ )
797
+
798
+ def handle_checkpoint1_approve(state, progress=gr.Progress()):
799
+ """Handle approval at checkpoint 1"""
800
+ def update_progress(msg):
801
+ progress(0.5, desc=msg)
802
+
803
+ state = run_curator(state, update_progress)
804
+
805
+ if state["stage"] == WorkflowStage.ERROR.value:
806
+ return (
807
+ state,
808
+ f"โŒ **Error:** {state['error_message']}",
809
+ gr.update(visible=False),
810
+ gr.update(visible=False),
811
+ gr.update(visible=False),
812
+ gr.update(visible=True),
813
+ "",
814
+ gr.update(visible=False)
815
+ )
816
+
817
+ story_text = state["selected_story"]["raw"] if state["selected_story"] else "No story selected"
818
+
819
+ return (
820
+ state,
821
+ f"## ๐ŸŽฏ Selected Story\n\n{story_text}",
822
+ gr.update(visible=True), # Approve button
823
+ gr.update(visible=True), # Different story
824
+ gr.update(visible=False),
825
+ gr.update(visible=False),
826
+ "",
827
+ gr.update(visible=False)
828
+ )
829
+
830
+ def handle_checkpoint1_reject(state, progress=gr.Progress()):
831
+ """Handle rejection at checkpoint 1 - restart discovery"""
832
+ def update_progress(msg):
833
+ progress(0.3, desc=msg)
834
+
835
+ state = run_discovery(state, update_progress)
836
+
837
+ if state["stage"] == WorkflowStage.ERROR.value:
838
+ return (
839
+ state,
840
+ f"โŒ **Error:** {state['error_message']}",
841
+ gr.update(visible=False),
842
+ gr.update(visible=False),
843
+ gr.update(visible=False),
844
+ gr.update(visible=True),
845
+ "",
846
+ gr.update(visible=False)
847
+ )
848
+
849
+ candidates_text = state["candidates"][0]["raw"] if state["candidates"] else "No candidates found"
850
+
851
+ return (
852
+ state,
853
+ f"## ๐Ÿ” Discovery Results (New Search)\n\n{candidates_text}",
854
+ gr.update(visible=True),
855
+ gr.update(visible=True),
856
+ gr.update(visible=False),
857
+ gr.update(visible=False),
858
+ "",
859
+ gr.update(visible=False)
860
+ )
861
+
862
+ def handle_checkpoint2_approve(state, progress=gr.Progress()):
863
+ """Handle approval at checkpoint 2 - proceed to writing"""
864
+ def update_progress(msg):
865
+ progress(0.6, desc=msg)
866
+
867
+ state = run_writer(state, update_progress)
868
+
869
+ if state["stage"] == WorkflowStage.ERROR.value:
870
+ return (
871
+ state,
872
+ f"โŒ **Error:** {state['error_message']}",
873
+ gr.update(visible=False),
874
+ gr.update(visible=False),
875
+ gr.update(visible=False),
876
+ gr.update(visible=True),
877
+ "",
878
+ gr.update(visible=False)
879
+ )
880
+
881
+ progress(0.8, desc="Evaluating draft...")
882
+ state = run_critic(state, lambda x: progress(0.8, desc=x))
883
+
884
+ draft = state.get("draft_summary", "")
885
+ score = state.get("quality_score", 0)
886
+ feedback = state.get("critic_feedback", "")
887
+
888
+ return (
889
+ state,
890
+ f"## โœ๏ธ Draft Post\n\n{draft}\n\n---\n\n**Quality Score:** {score}/10\n\n**Feedback:**\n{feedback}",
891
+ gr.update(visible=True), # Finalize
892
+ gr.update(visible=True), # Edit/Rewrite
893
+ gr.update(visible=False),
894
+ gr.update(visible=False),
895
+ "",
896
+ gr.update(visible=True) # Show edit instructions
897
+ )
898
+
899
+ def handle_checkpoint2_different(state, progress=gr.Progress()):
900
+ """Request different story - re-run curator"""
901
+ def update_progress(msg):
902
+ progress(0.5, desc=msg)
903
+
904
+ state = run_curator(state, update_progress)
905
+
906
+ if state["stage"] == WorkflowStage.ERROR.value:
907
+ return (
908
+ state,
909
+ f"โŒ **Error:** {state['error_message']}",
910
+ gr.update(visible=False),
911
+ gr.update(visible=False),
912
+ gr.update(visible=False),
913
+ gr.update(visible=True),
914
+ "",
915
+ gr.update(visible=False)
916
+ )
917
+
918
+ story_text = state["selected_story"]["raw"] if state["selected_story"] else "No story selected"
919
+
920
+ return (
921
+ state,
922
+ f"## ๐ŸŽฏ Selected Story (Alternative)\n\n{story_text}",
923
+ gr.update(visible=True),
924
+ gr.update(visible=True),
925
+ gr.update(visible=False),
926
+ gr.update(visible=False),
927
+ "",
928
+ gr.update(visible=False)
929
+ )
930
+
931
+ def handle_checkpoint3_finalize(state, progress=gr.Progress()):
932
+ """Finalize and save the post"""
933
+ def update_progress(msg):
934
+ progress(0.9, desc=msg)
935
+
936
+ state = run_finalize(state, update_progress)
937
+
938
+ draft = state.get("draft_summary", "")
939
+
940
+ return (
941
+ state,
942
+ f"## โœ… Post Saved Successfully!\n\n{draft}\n\n---\n\n**Status:** Saved to database\n**Quality Score:** {state.get('quality_score', 0)}/10",
943
+ gr.update(visible=False),
944
+ gr.update(visible=False),
945
+ gr.update(visible=False),
946
+ gr.update(visible=True), # Show restart
947
+ "",
948
+ gr.update(visible=False)
949
+ )
950
+
951
+ def handle_checkpoint3_edit(state, edit_instructions, progress=gr.Progress()):
952
+ """Edit the draft based on instructions"""
953
+ if not edit_instructions:
954
+ return (
955
+ state,
956
+ "โš ๏ธ Please provide edit instructions",
957
+ gr.update(visible=True),
958
+ gr.update(visible=True),
959
+ gr.update(visible=False),
960
+ gr.update(visible=False),
961
+ edit_instructions,
962
+ gr.update(visible=True)
963
+ )
964
+
965
+ # Add edit instructions to state for writer to use
966
+ if "critic_feedback" in state:
967
+ state["critic_feedback"] += f"\n\nUser edit request: {edit_instructions}"
968
+
969
+ def update_progress(msg):
970
+ progress(0.6, desc=msg)
971
+
972
+ state = run_writer(state, update_progress)
973
+
974
+ if state["stage"] == WorkflowStage.ERROR.value:
975
+ return (
976
+ state,
977
+ f"โŒ **Error:** {state['error_message']}",
978
+ gr.update(visible=False),
979
+ gr.update(visible=False),
980
+ gr.update(visible=False),
981
+ gr.update(visible=True),
982
+ "",
983
+ gr.update(visible=False)
984
+ )
985
+
986
+ progress(0.8, desc="Re-evaluating draft...")
987
+ state = run_critic(state, lambda x: progress(0.8, desc=x))
988
+
989
+ draft = state.get("draft_summary", "")
990
+ score = state.get("quality_score", 0)
991
+ feedback = state.get("critic_feedback", "")
992
+
993
+ return (
994
+ state,
995
+ f"## โœ๏ธ Revised Draft\n\n{draft}\n\n---\n\n**Quality Score:** {score}/10\n\n**Feedback:**\n{feedback}",
996
+ gr.update(visible=True),
997
+ gr.update(visible=True),
998
+ gr.update(visible=False),
999
+ gr.update(visible=False),
1000
+ "",
1001
+ gr.update(visible=True)
1002
+ )
1003
+
1004
+ def restart_workflow():
1005
+ """Reset everything to start fresh"""
1006
+ return (
1007
+ None,
1008
+ "๐Ÿ‘‹ Ready to start! Enter a topic and click 'Start Discovery'",
1009
+ gr.update(visible=False),
1010
+ gr.update(visible=False),
1011
+ gr.update(visible=True), # Show start button
1012
+ gr.update(visible=False),
1013
+ "",
1014
+ gr.update(visible=False)
1015
+ )
1016
+
1017
+ # --- 10. BUILD GRADIO UI ---
1018
+
1019
+ css = """
1020
+ .output-box {
1021
+ min-height: 400px;
1022
+ max-height: 600px;
1023
+ overflow-y: auto;
1024
+ padding: 20px;
1025
+ border-radius: 8px;
1026
+ background: #f8f9fa;
1027
+ }
1028
+ .button-row {
1029
+ display: flex;
1030
+ gap: 10px;
1031
+ margin-top: 10px;
1032
+ }
1033
+ .status-badge {
1034
+ display: inline-block;
1035
+ padding: 5px 10px;
1036
+ border-radius: 5px;
1037
+ font-weight: bold;
1038
+ margin-bottom: 10px;
1039
+ }
1040
+ """
1041
+
1042
+ with gr.Blocks(css=css, title="Tuesday Trivia Agent", theme=gr.themes.Soft()) as demo:
1043
+ gr.Markdown("""
1044
+ # ๐Ÿงช Tuesday Trivia Multi-Agent System
1045
+
1046
+ An intelligent workflow for discovering, curating, and writing science trivia posts with human oversight at key checkpoints.
1047
+
1048
+ **โ˜๏ธ Cloud Storage:** Posts are automatically synced to Hugging Face Datasets for persistence.
1049
+ """)
1050
+
1051
+ # State
1052
+ state = gr.State()
1053
+
1054
+ with gr.Row():
1055
+ with gr.Column(scale=2):
1056
+ # Main output area
1057
+ output_display = gr.Markdown("๐Ÿ‘‹ Ready to start! Enter a topic and click 'Start Discovery'", elem_classes="output-box")
1058
+
1059
+ with gr.Column(scale=1):
1060
+ # Control panel
1061
+ gr.Markdown("### ๐ŸŽฎ Control Panel")
1062
+
1063
+ topic_input = gr.Textbox(
1064
+ label="Search Topic",
1065
+ placeholder="e.g., quantum computing, biotechnology, space exploration",
1066
+ value="general science"
1067
+ )
1068
+
1069
+ with gr.Row():
1070
+ start_btn = gr.Button("๐Ÿš€ Start Discovery", variant="primary", visible=True)
1071
+ restart_btn = gr.Button("๐Ÿ”„ Start New", variant="secondary", visible=False)
1072
+
1073
+ gr.Markdown("---")
1074
+ gr.Markdown("### ๐Ÿ“‹ Decision Points")
1075
+
1076
+ # Checkpoint buttons (hidden initially)
1077
+ approve_btn = gr.Button("โœ… Approve", variant="primary", visible=False)
1078
+ reject_btn = gr.Button("โŒ Reject / Different", variant="stop", visible=False)
1079
+
1080
+ edit_instructions = gr.Textbox(
1081
+ label="Edit Instructions (optional)",
1082
+ placeholder="Provide specific instructions for changes...",
1083
+ visible=False,
1084
+ lines=3
1085
+ )
1086
+
1087
+ # Add sync status
1088
+ with gr.Accordion("โ˜๏ธ Cloud Sync Status", open=False):
1089
+ sync_status = gr.Markdown(f"""
1090
+ **HF Dataset:** `{HF_DATASET_REPO}`
1091
+ **Status:** {'โœ… Connected' if HF_TOKEN else 'โŒ Not configured'}
1092
+
1093
+ Posts are automatically synced to Hugging Face Datasets after saving.
1094
+ """)
1095
+
1096
+ manual_sync_btn = gr.Button("๐Ÿ”„ Manual Sync to HF", size="sm")
1097
+
1098
+ gr.Markdown("---")
1099
+ gr.Markdown("""
1100
+ ### โ„น๏ธ Instructions
1101
+
1102
+ **Workflow Steps:**
1103
+ 1. Enter topic โ†’ Start Discovery
1104
+ 2. Review candidates โ†’ Approve/Reject
1105
+ 3. Review story selection โ†’ Approve/Different
1106
+ 4. Review draft โ†’ Finalize/Edit
1107
+ 5. Post saved to database & HF!
1108
+
1109
+ **Cloud Storage:**
1110
+ - All posts auto-sync to HF Dataset
1111
+ - Survives Space restarts
1112
+ - Access your data anytime
1113
+
1114
+ **Tips:**
1115
+ - Be specific with topics for better results
1116
+ - Edit instructions help refine drafts
1117
+ - All posts are saved with quality scores
1118
+ """)
1119
+
1120
+ # Manual sync function
1121
+ def manual_sync():
1122
+ result = sync_to_hf()
1123
+ if result:
1124
+ return "โœ… Successfully synced to Hugging Face Dataset!"
1125
+ else:
1126
+ return "โš ๏ธ Sync failed or no data to sync. Check logs."
1127
+
1128
+ manual_sync_btn.click(
1129
+ fn=manual_sync,
1130
+ outputs=sync_status
1131
+ )
1132
+
1133
+ # Event handlers
1134
+ start_btn.click(
1135
+ fn=start_workflow,
1136
+ inputs=[topic_input],
1137
+ outputs=[state, output_display, approve_btn, reject_btn, start_btn, restart_btn, edit_instructions, edit_instructions]
1138
+ )
1139
+
1140
+ # Checkpoint 1 handlers
1141
+ def route_checkpoint1_approve(s):
1142
+ if s and s.get("stage") == WorkflowStage.CHECKPOINT_1.value:
1143
+ return handle_checkpoint1_approve(s)
1144
+ return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1145
+
1146
+ def route_checkpoint1_reject(s):
1147
+ if s and s.get("stage") == WorkflowStage.CHECKPOINT_1.value:
1148
+ return handle_checkpoint1_reject(s)
1149
+ return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1150
+
1151
+ # Checkpoint 2 handlers
1152
+ def route_checkpoint2_approve(s):
1153
+ if s and s.get("stage") == WorkflowStage.CHECKPOINT_2.value:
1154
+ return handle_checkpoint2_approve(s)
1155
+ return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1156
+
1157
+ def route_checkpoint2_different(s):
1158
+ if s and s.get("stage") == WorkflowStage.CHECKPOINT_2.value:
1159
+ return handle_checkpoint2_different(s)
1160
+ return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1161
+
1162
+ # Checkpoint 3 handlers
1163
+ def route_checkpoint3_finalize(s):
1164
+ if s and s.get("stage") == WorkflowStage.CHECKPOINT_3.value:
1165
+ return handle_checkpoint3_finalize(s)
1166
+ return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1167
+
1168
+ def route_checkpoint3_edit(s, instructions):
1169
+ if s and s.get("stage") == WorkflowStage.CHECKPOINT_3.value:
1170
+ return handle_checkpoint3_edit(s, instructions)
1171
+ return s, "Invalid state", gr.update(), gr.update(), gr.update(), gr.update(), instructions, gr.update()
1172
+
1173
+ # Wire up the routing based on current stage
1174
+ def smart_approve(s):
1175
+ if not s:
1176
+ return s, "No active workflow", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1177
+
1178
+ stage = s.get("stage")
1179
+ if stage == WorkflowStage.CHECKPOINT_1.value:
1180
+ return route_checkpoint1_approve(s)
1181
+ elif stage == WorkflowStage.CHECKPOINT_2.value:
1182
+ return route_checkpoint2_approve(s)
1183
+ elif stage == WorkflowStage.CHECKPOINT_3.value:
1184
+ return route_checkpoint3_finalize(s)
1185
+
1186
+ return s, "Invalid stage for approval", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1187
+
1188
+ def smart_reject(s):
1189
+ if not s:
1190
+ return s, "No active workflow", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1191
+
1192
+ stage = s.get("stage")
1193
+ if stage == WorkflowStage.CHECKPOINT_1.value:
1194
+ return route_checkpoint1_reject(s)
1195
+ elif stage == WorkflowStage.CHECKPOINT_2.value:
1196
+ return route_checkpoint2_different(s)
1197
+ elif stage == WorkflowStage.CHECKPOINT_3.value:
1198
+ return route_checkpoint3_edit(s, "")
1199
+
1200
+ return s, "Invalid stage for rejection", gr.update(), gr.update(), gr.update(), gr.update(), "", gr.update()
1201
+
1202
+ approve_btn.click(
1203
+ fn=smart_approve,
1204
+ inputs=[state],
1205
+ outputs=[state, output_display, approve_btn, reject_btn, start_btn, restart_btn, edit_instructions, edit_instructions]
1206
+ )
1207
+
1208
+ reject_btn.click(
1209
+ fn=smart_reject,
1210
+ inputs=[state],
1211
+ outputs=[state, output_display, approve_btn, reject_btn, start_btn, restart_btn, edit_instructions, edit_instructions]
1212
+ )
1213
+
1214
+ restart_btn.click(
1215
+ fn=restart_workflow,
1216
+ outputs=[state, output_display, approve_btn, reject_btn, start_btn, restart_btn, edit_instructions, edit_instructions]
1217
+ )
1218
+
1219
+ if __name__ == "__main__":
1220
  demo.launch()