Solo448 commited on
Commit
d01dccd
·
verified ·
1 Parent(s): a1da592

Upload 6 files

Browse files
backend/__pycache__/agent.cpython-314.pyc ADDED
Binary file (6.95 kB). View file
 
backend/__pycache__/main.cpython-314.pyc ADDED
Binary file (3.71 kB). View file
 
backend/agent.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, Literal
2
+ from dotenv import load_dotenv
3
+
4
+ from langgraph.graph import StateGraph, START, END
5
+ from langchain_groq import ChatGroq
6
+ from langchain_community.tools.tavily_search import TavilySearchResults
7
+ # Load env early
8
+ load_dotenv()
9
+
10
+ class ResearchState(TypedDict):
11
+ topic: str
12
+ raw_data: str
13
+ draft_summary: str
14
+ final_report: str
15
+ revision_feedback: str
16
+ iteration_count: int
17
+
18
+ async def researcher_node(state: ResearchState) -> ResearchState:
19
+ topic = state.get("topic", "")
20
+ iteration_count = state.get("iteration_count", 0)
21
+ feedback = state.get("revision_feedback", "")
22
+ existing_data = state.get("raw_data", "")
23
+
24
+ query = topic
25
+ if iteration_count > 0 and feedback:
26
+ query = f"{topic} {feedback}"
27
+
28
+ search_tool = TavilySearchResults(max_results=4)
29
+ # Perform sync call since simple tool wrapper isn't natively async sometimes
30
+ results = search_tool.invoke({"query": query})
31
+
32
+ if isinstance(results, list):
33
+ formatted_data = "\n\n".join([f"Source: {res.get('url', 'N/A')}\nContent: {res.get('content', 'N/A')}" for res in results])
34
+ else:
35
+ formatted_data = str(results)
36
+
37
+ new_data = f"--- Search Results Iteration {iteration_count + 1} ---\n{formatted_data}"
38
+ combined_data = existing_data + "\n\n" + new_data if existing_data else new_data
39
+
40
+ return {"raw_data": combined_data}
41
+
42
+ async def analyst_node(state: ResearchState) -> ResearchState:
43
+ topic = state.get("topic", "")
44
+ raw_data = state.get("raw_data", "")
45
+
46
+ # We use versatile model as decided in prior fixes
47
+ llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0.3)
48
+
49
+ prompt = f"""
50
+ You are an expert analyst. Your task is to write a well-formatted, comprehensive
51
+ draft summary based on the provided raw research data.
52
+
53
+ Topic: {topic}
54
+
55
+ Raw Data:
56
+ {raw_data}
57
+
58
+ Please synthesize this information into a structured draft (Markdown format).
59
+ """
60
+ response = await llm.ainvoke(prompt)
61
+ return {"draft_summary": response.content}
62
+
63
+ async def reviewer_node(state: ResearchState) -> ResearchState:
64
+ topic = state.get("topic", "")
65
+ raw_data = state.get("raw_data", "")
66
+ draft_summary = state.get("draft_summary", "")
67
+ iteration_count = state.get("iteration_count", 0)
68
+
69
+ llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0.1)
70
+
71
+ prompt = f"""
72
+ You are a meticulous reviewer and fact-checker. Review the draft summary against the raw data.
73
+
74
+ If the draft is sufficient and has no hallucinations, reply with:
75
+ [APPROVED]
76
+ (followed by the final polished report in markdown)
77
+
78
+ If the raw data is insufficient to fully answer the topic, or important details are missing, reply with:
79
+ [REJECTED]
80
+ (followed by a single sentence describing exactly what additional information is needed)
81
+
82
+ Topic: {topic}
83
+ Raw Data: {raw_data}
84
+ Draft Summary: {draft_summary}
85
+ """
86
+ response = await llm.ainvoke(prompt)
87
+ content = response.content.strip()
88
+
89
+ if content.startswith("[REJECTED]"):
90
+ feedback = content.replace("[REJECTED]", "").strip()
91
+ return {"revision_feedback": feedback, "iteration_count": iteration_count + 1}
92
+ else:
93
+ final_text = content.replace("[APPROVED]", "").strip()
94
+ return {"final_report": final_text, "iteration_count": iteration_count + 1, "revision_feedback": ""}
95
+
96
+ def router(state: ResearchState) -> Literal["researcher", "__end__"]:
97
+ # Loop back to researcher if feedback is provided, max 3 loops to avoid infinite loops
98
+ if state.get("revision_feedback") and state.get("iteration_count", 0) < 3:
99
+ return "researcher"
100
+ return "__end__"
101
+
102
+ def build_async_graph():
103
+ builder = StateGraph(ResearchState)
104
+ builder.add_node("researcher", researcher_node)
105
+ builder.add_node("analyst", analyst_node)
106
+ builder.add_node("reviewer", reviewer_node)
107
+
108
+ builder.add_edge(START, "researcher")
109
+ builder.add_edge("researcher", "analyst")
110
+ builder.add_edge("analyst", "reviewer")
111
+
112
+ # Conditional Edge
113
+ builder.add_conditional_edges("reviewer", router)
114
+
115
+ return builder.compile()
116
+
117
+ # Global compiled graph
118
+ app = build_async_graph()
backend/main.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.responses import FileResponse
5
+ from pydantic import BaseModel
6
+ from sse_starlette.sse import EventSourceResponse
7
+
8
+ # Import the LangGraph app we built
9
+ from backend.agent import app as wf_app
10
+
11
+ app = FastAPI(title="Multi-Agent Research API")
12
+
13
+ # Allow CORS for our Vite frontend
14
+ app.add_middleware(
15
+ CORSMiddleware,
16
+ allow_origins=["*"],
17
+ allow_credentials=True,
18
+ allow_methods=["*"],
19
+ allow_headers=["*"],
20
+ )
21
+
22
+ class ResearchRequest(BaseModel):
23
+ topic: str
24
+
25
+ @app.get("/")
26
+ def serve_frontend():
27
+ return FileResponse("frontend/index.html")
28
+
29
+ @app.post("/api/research")
30
+ async def start_research(req: ResearchRequest):
31
+ # This acts as a streaming endpoint using EventSourceResponse
32
+
33
+ async def event_generator():
34
+ initial_state = {
35
+ "topic": req.topic,
36
+ "raw_data": "",
37
+ "draft_summary": "",
38
+ "final_report": "",
39
+ "revision_feedback": "",
40
+ "iteration_count": 0
41
+ }
42
+
43
+ try:
44
+ # We use astream to stream updates from each node
45
+ async for chunk in wf_app.astream(initial_state, stream_mode="updates"):
46
+ # chunk is a dict like {"node_name": {"key": "value"}}
47
+ for node, state_update in chunk.items():
48
+ # We send a standard SSE payload JSON string
49
+ payload = {
50
+ "node": node,
51
+ "iteration": state_update.get("iteration_count", 0),
52
+ "feedback": state_update.get("revision_feedback", "")
53
+ }
54
+
55
+ if "final_report" in state_update and state_update["final_report"]:
56
+ payload["status"] = "completed"
57
+ payload["report"] = state_update["final_report"]
58
+ elif "draft_summary" in state_update and state_update["draft_summary"]:
59
+ payload["status"] = "drafting"
60
+ payload["draft"] = state_update["draft_summary"]
61
+ elif "raw_data" in state_update and state_update["raw_data"]:
62
+ payload["status"] = "researching"
63
+
64
+ yield json.dumps(payload)
65
+
66
+ except Exception as e:
67
+ yield json.dumps({"error": str(e)})
68
+
69
+ return EventSourceResponse(event_generator())
70
+
71
+ if __name__ == "__main__":
72
+ import uvicorn
73
+ uvicorn.run("backend.main:app", host="0.0.0.0", port=8000, reload=True)
frontend/favicon.svg ADDED
frontend/index.html ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Antigravity Multi-Agent Research</title>
7
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
8
+ <!-- Google Fonts -->
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
10
+ <!-- Marked.js for Markdown parsing -->
11
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
12
+
13
+ <style>
14
+ :root {
15
+ --bg-color: #0b0f19;
16
+ --surface: rgba(255, 255, 255, 0.03);
17
+ --surface-border: rgba(255, 255, 255, 0.08);
18
+ --accent: #5e6ad2;
19
+ --accent-glow: rgba(94, 106, 210, 0.5);
20
+ --text-primary: #f1f5f9;
21
+ --text-secondary: #94a3b8;
22
+ --glow-color: rgba(94, 106, 210, 0.15);
23
+ }
24
+
25
+ * {
26
+ box-sizing: border-box;
27
+ margin: 0;
28
+ padding: 0;
29
+ font-family: 'Inter', sans-serif;
30
+ }
31
+
32
+ body {
33
+ background-color: var(--bg-color);
34
+ color: var(--text-primary);
35
+ min-height: 100vh;
36
+ display: flex;
37
+ flex-direction: column;
38
+ overflow-x: hidden;
39
+ background-image:
40
+ radial-gradient(circle at 15% 50%, rgba(94, 106, 210, 0.12), transparent 40%),
41
+ radial-gradient(circle at 85% 30%, rgba(37, 203, 190, 0.08), transparent 40%);
42
+ }
43
+
44
+ header {
45
+ padding: 2rem;
46
+ text-align: center;
47
+ border-bottom: 1px solid var(--surface-border);
48
+ background: rgba(11, 15, 25, 0.8);
49
+ backdrop-filter: blur(12px);
50
+ position: sticky;
51
+ top: 0;
52
+ z-index: 10;
53
+ }
54
+
55
+ h1 {
56
+ font-weight: 800;
57
+ font-size: 2rem;
58
+ background: linear-gradient(90deg, #fff, #a5b4fc);
59
+ -webkit-background-clip: text;
60
+ background-clip: text;
61
+ -webkit-text-fill-color: transparent;
62
+ color: transparent;
63
+ margin-bottom: 0.5rem;
64
+ }
65
+
66
+ p {
67
+ color: var(--text-secondary);
68
+ }
69
+
70
+ main {
71
+ flex: 1;
72
+ max-width: 900px;
73
+ width: 100%;
74
+ margin: 0 auto;
75
+ padding: 2rem;
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 2rem;
79
+ }
80
+
81
+ .input-panel {
82
+ background: var(--surface);
83
+ border: 1px solid var(--surface-border);
84
+ border-radius: 16px;
85
+ padding: 1.5rem;
86
+ backdrop-filter: blur(20px);
87
+ box-shadow: 0 8px 32px rgba(0,0,0,0.2);
88
+ transition: transform 0.2s, box-shadow 0.2s;
89
+ }
90
+
91
+ .input-panel:focus-within {
92
+ box-shadow: 0 0 0 1px var(--accent), 0 8px 32px var(--glow-color);
93
+ }
94
+
95
+ .input-group {
96
+ display: flex;
97
+ gap: 1rem;
98
+ }
99
+
100
+ input {
101
+ flex: 1;
102
+ background: rgba(0, 0, 0, 0.2);
103
+ border: 1px solid var(--surface-border);
104
+ color: white;
105
+ padding: 1rem 1.2rem;
106
+ border-radius: 12px;
107
+ font-size: 1.1rem;
108
+ outline: none;
109
+ transition: all 0.2s;
110
+ }
111
+
112
+ input:focus {
113
+ border-color: var(--accent);
114
+ }
115
+
116
+ button {
117
+ background: var(--accent);
118
+ color: white;
119
+ border: none;
120
+ padding: 1rem 2rem;
121
+ border-radius: 12px;
122
+ font-size: 1.05rem;
123
+ font-weight: 600;
124
+ cursor: pointer;
125
+ transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
126
+ box-shadow: 0 4px 15px var(--accent-glow);
127
+ }
128
+
129
+ button:hover {
130
+ background: #4f5bc4;
131
+ transform: translateY(-1px);
132
+ }
133
+
134
+ button:active {
135
+ transform: translateY(1px);
136
+ box-shadow: 0 2px 10px var(--accent-glow);
137
+ }
138
+
139
+ button:disabled {
140
+ background: var(--surface-border);
141
+ cursor: not-allowed;
142
+ color: var(--text-secondary);
143
+ box-shadow: none;
144
+ transform: none;
145
+ }
146
+
147
+ #status-container {
148
+ display: none;
149
+ background: var(--surface);
150
+ border: 1px solid var(--surface-border);
151
+ padding: 1.5rem;
152
+ border-radius: 16px;
153
+ animation: fadeIn 0.4s ease forwards;
154
+ }
155
+
156
+ .status-header {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 1rem;
160
+ margin-bottom: 1rem;
161
+ font-weight: 600;
162
+ color: var(--accent);
163
+ }
164
+
165
+ .spinner {
166
+ width: 20px;
167
+ height: 20px;
168
+ border: 3px solid rgba(255,255,255,0.1);
169
+ border-top-color: var(--accent);
170
+ border-radius: 50%;
171
+ animation: spin 1s linear infinite;
172
+ }
173
+
174
+ .log-list {
175
+ list-style: none;
176
+ display: flex;
177
+ flex-direction: column;
178
+ gap: 0.5rem;
179
+ max-height: 200px;
180
+ overflow-y: auto;
181
+ color: var(--text-secondary);
182
+ font-size: 0.95rem;
183
+ }
184
+
185
+ .log-item {
186
+ display: flex;
187
+ align-items: center;
188
+ gap: 0.5rem;
189
+ animation: slideIn 0.3s ease forwards;
190
+ opacity: 0;
191
+ }
192
+
193
+ .log-item::before {
194
+ content: "•";
195
+ color: var(--accent);
196
+ }
197
+
198
+ .log-item.feedback::before {
199
+ color: #f59e0b;
200
+ }
201
+
202
+ #result-container {
203
+ display: none;
204
+ background: var(--surface);
205
+ border: 1px solid var(--surface-border);
206
+ padding: 2.5rem;
207
+ border-radius: 24px;
208
+ backdrop-filter: blur(20px);
209
+ animation: fadeInUp 0.6s ease forwards;
210
+ line-height: 1.7;
211
+ }
212
+
213
+ /* Markdown styling */
214
+ #result-container h1, #result-container h2, #result-container h3 {
215
+ margin-top: 1.5em;
216
+ margin-bottom: 0.5em;
217
+ color: #fff;
218
+ }
219
+ #result-container h1 { font-size: 1.8rem; border-bottom: 1px solid var(--surface-border); padding-bottom: 0.5rem; }
220
+ #result-container h2 { font-size: 1.4rem; color: #e2e8f0; }
221
+ #result-container p { margin-bottom: 1.2rem; color: #cbd5e1; }
222
+ #result-container ul, #result-container ol { margin-bottom: 1.2rem; padding-left: 2rem; color: #cbd5e1;}
223
+ #result-container li { margin-bottom: 0.4rem; }
224
+ #result-container strong { color: #f1f5f9; }
225
+
226
+ @keyframes spin { to { transform: rotate(360deg); } }
227
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
228
+ @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
229
+ @keyframes slideIn { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
230
+
231
+ /* Custom Scrollbar */
232
+ ::-webkit-scrollbar { width: 8px; }
233
+ ::-webkit-scrollbar-track { background: var(--bg-color); }
234
+ ::-webkit-scrollbar-thumb { background: var(--surface-border); border-radius: 4px; }
235
+ ::-webkit-scrollbar-thumb:hover { background: var(--accent); }
236
+ </style>
237
+ </head>
238
+ <body>
239
+
240
+ <header>
241
+ <h1>Agentic Research System</h1>
242
+ <p>A multi-agent LangGraph workflow powered by Groq & Tavily.</p>
243
+ </header>
244
+
245
+ <main>
246
+ <div class="input-panel">
247
+ <form id="research-form">
248
+ <div class="input-group">
249
+ <input type="text" id="topic-input" placeholder="Enter a topic (e.g. Latest advancements in AI)..." required autocomplete="off">
250
+ <button type="submit" id="submit-btn">Research</button>
251
+ </div>
252
+ </form>
253
+ </div>
254
+
255
+ <div id="status-container">
256
+ <div class="status-header">
257
+ <div class="spinner" id="status-spinner"></div>
258
+ <span id="status-text">Initializing agents...</span>
259
+ </div>
260
+ <ul class="log-list" id="log-list"></ul>
261
+ </div>
262
+
263
+ <div id="result-container">
264
+ <div id="result-content"></div>
265
+ </div>
266
+ </main>
267
+
268
+ <script>
269
+ const form = document.getElementById('research-form');
270
+ const input = document.getElementById('topic-input');
271
+ const submitBtn = document.getElementById('submit-btn');
272
+ const statusContainer = document.getElementById('status-container');
273
+ const statusText = document.getElementById('status-text');
274
+ const statusSpinner = document.getElementById('status-spinner');
275
+ const logList = document.getElementById('log-list');
276
+ const resultContainer = document.getElementById('result-container');
277
+ const resultContent = document.getElementById('result-content');
278
+
279
+ // Parse markdown config
280
+ marked.setOptions({
281
+ gfm: true,
282
+ breaks: true
283
+ });
284
+
285
+ form.addEventListener('submit', async (e) => {
286
+ e.preventDefault();
287
+
288
+ const topic = input.value.trim();
289
+ if (!topic) return;
290
+
291
+ // UI Reset
292
+ submitBtn.disabled = true;
293
+ input.disabled = true;
294
+ statusContainer.style.display = 'block';
295
+ resultContainer.style.display = 'none';
296
+ resultContent.innerHTML = '';
297
+ logList.innerHTML = '';
298
+ statusSpinner.style.display = 'block';
299
+ statusText.innerText = 'Initializing agents...';
300
+ statusText.style.color = 'var(--accent)';
301
+
302
+ addLog('Workflow started for: ' + topic);
303
+
304
+ try {
305
+ // Initialize SSE Request via POST
306
+ const response = await fetch('http://localhost:8000/api/research', {
307
+ method: 'POST',
308
+ headers: { 'Content-Type': 'application/json' },
309
+ body: JSON.stringify({ topic })
310
+ });
311
+
312
+ if (!response.body) throw new Error('No body returned');
313
+
314
+ const reader = response.body.getReader();
315
+ const decoder = new TextDecoder('utf-8');
316
+ let buffer = '';
317
+
318
+ while (true) {
319
+ const { done, value } = await reader.read();
320
+ if (done) break;
321
+
322
+ buffer += decoder.decode(value, { stream: true });
323
+ const events = buffer.split(/(?:\r?\n){2}/);
324
+ buffer = events.pop(); // keep remainder in buffer
325
+
326
+ for (const event of events) {
327
+ const evtLines = event.split(/\r?\n/);
328
+ for (const line of evtLines) {
329
+ if (line.startsWith('data: ')) {
330
+ const dataStr = line.slice(6);
331
+ if (dataStr.trim() === '') continue;
332
+
333
+ try {
334
+ const payload = JSON.parse(dataStr);
335
+
336
+ if (payload.error) {
337
+ handleError(payload.error);
338
+ return;
339
+ }
340
+
341
+ updateStatus(payload);
342
+
343
+ } catch (e) {
344
+ console.error('Failed to parse line:', line, e);
345
+ }
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ addLog('Workflow completed successfully.', 'success');
352
+ statusSpinner.style.display = 'none';
353
+ statusText.innerText = 'Research Complete';
354
+ statusText.style.color = '#10b981';
355
+
356
+ } catch (err) {
357
+ handleError(err.message);
358
+ } finally {
359
+ submitBtn.disabled = false;
360
+ input.disabled = false;
361
+ }
362
+ });
363
+
364
+ function updateStatus(payload) {
365
+ const { node, status, iteration, feedback, draft, report } = payload;
366
+
367
+ let iterationPrefix = iteration > 0 ? `[Loop ${iteration}] ` : '';
368
+
369
+ if (status === 'completed') {
370
+ resultContainer.style.display = 'block';
371
+ resultContent.innerHTML = marked.parse(report);
372
+ // Scroll to result smoothly
373
+ resultContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
374
+ }
375
+ else if (status === 'drafting') {
376
+ statusText.innerText = 'Analyst: Synthesizing raw data into draft...';
377
+ addLog(`${iterationPrefix}Analyst composed a draft summary.`);
378
+ }
379
+ else if (status === 'researching') {
380
+ statusText.innerText = 'Researcher: Gathering web data...';
381
+ addLog(`${iterationPrefix}Researcher fetched relevant articles via Tavily.`);
382
+ }
383
+
384
+ // Check feedback loop
385
+ if (feedback) {
386
+ addLog(`Reviewer rejected draft: "${feedback}" - Forcing another iteration...`, 'feedback');
387
+ statusText.innerText = 'Reviewer: Requesting more specific data...';
388
+ statusText.style.color = '#f59e0b';
389
+ } else if (node === 'reviewer' && status !== 'completed') {
390
+ statusText.innerText = 'Reviewer: Fact-checking draft against raw data...';
391
+ }
392
+ }
393
+
394
+ function addLog(message, type = 'normal') {
395
+ const li = document.createElement('li');
396
+ li.className = 'log-item';
397
+ if (type === 'feedback') li.classList.add('feedback');
398
+ li.innerText = message;
399
+
400
+ logList.appendChild(li);
401
+ logList.scrollTop = logList.scrollHeight; // Auto-scroll
402
+ }
403
+
404
+ function handleError(msg) {
405
+ statusSpinner.style.display = 'none';
406
+ statusText.innerText = 'System Error';
407
+ statusText.style.color = '#ef4444';
408
+ addLog(`Error: ${msg}`, 'error');
409
+ console.error(msg);
410
+ }
411
+ </script>
412
+ </body>
413
+ </html>