File size: 14,555 Bytes
d42aa58
 
 
 
 
 
 
 
 
 
 
a2e9180
d42aa58
 
189df32
 
 
 
 
 
 
d42aa58
 
 
189df32
 
 
d42aa58
 
 
189df32
 
 
 
d42aa58
 
 
189df32
 
d42aa58
 
 
 
 
189df32
 
 
 
496dba9
d42aa58
 
189df32
 
 
d42aa58
 
 
 
189df32
 
d42aa58
189df32
d42aa58
 
 
 
 
189df32
d42aa58
 
 
 
 
 
 
 
189df32
 
 
d42aa58
 
189df32
 
d42aa58
189df32
d42aa58
 
 
 
 
 
 
 
 
 
189df32
 
 
 
d42aa58
 
 
 
 
 
 
189df32
 
d42aa58
 
 
 
 
 
 
189df32
 
 
d42aa58
 
189df32
d42aa58
 
 
 
 
 
189df32
 
 
 
 
d42aa58
189df32
 
 
d42aa58
189df32
d42aa58
189df32
 
 
 
d42aa58
 
 
 
 
 
 
 
189df32
 
 
d42aa58
 
 
 
189df32
 
 
 
 
 
 
 
 
 
 
 
 
 
d42aa58
 
 
 
 
 
 
 
189df32
 
d42aa58
189df32
 
d42aa58
 
189df32
 
 
 
d42aa58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189df32
 
 
 
 
 
 
 
d42aa58
 
 
 
 
 
 
 
 
 
 
189df32
 
d42aa58
189df32
d42aa58
 
 
 
 
189df32
d42aa58
189df32
 
 
 
d42aa58
189df32
 
 
 
 
 
 
 
d42aa58
189df32
d42aa58
189df32
 
 
d42aa58
189df32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d42aa58
189df32
 
 
d42aa58
 
 
 
 
 
 
 
 
 
 
 
 
 
189df32
 
d42aa58
189df32
d42aa58
189df32
d42aa58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189df32
 
 
 
 
 
 
 
d42aa58
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# EpiRAG - agents.py
# Multi-agent swarm debate engine with real-time SSE callbacks (v2).
#
# Architecture:
#   Round 1    - all debate agents respond in parallel (fast, independent views)
#   Rounds 2+  - agents respond sequentially, each sees full transcript before replying
#   Final      - Epsilon synthesizes, then Zeta audits citations only
#
# Agent roster (3 debate + 2 support):
#   Alpha   (llama-3.3-70b-versatile)             - Skeptic
#   Beta    (openai/gpt-oss-120b)                 - Deep Reasoner
#   Gamma   (qwen/qwen3.6-27b)                   - Pattern Connector
#   Epsilon (openai/gpt-oss-20b)                  - Synthesizer
#   Zeta    (llama-3.1-8b-instant)                - Citation Auditor

import concurrent.futures
from groq import Groq

AGENTS = [
    {
        "name":        "Alpha",
        "model":       "llama-3.3-70b-versatile",
        "provider":    "groq",
        "client_type": "groq",
        "color":       "red",
        "personality": (
            "You are Agent Alpha - a ruthless Skeptic. "
            "Challenge every claim aggressively. Demand evidence from the sources. "
            "Point out what is NOT supported. Be blunt and relentless. "
            "After every factual statement you accept, cite the source: [Source: <name>]."
        )
    },
    {
        "name":        "Beta",
        "model":       "openai/gpt-oss-120b",
        "provider":    "groq",
        "client_type": "groq",
        "color":       "yellow",
        "personality": (
            "You are Agent Beta - a Deep Reasoner. "
            "Think step by step. Show your chain of thought. "
            "Identify hidden assumptions and logical gaps the other agents miss. "
            "Precision and thoroughness over speed. "
            "After every factual statement, cite the source: [Source: <name>]."
        )
    },
    {
        "name":        "Gamma",
        "model":       "qwen/qwen3.6-27b",
        "provider":    "groq",
        "client_type": "groq",
        "color":       "green",
        "personality": (
            "You are Agent Gamma - a Pattern Connector. "
            "Find non-obvious connections between different sources. "
            "Look for relationships and synthesis opportunities others miss. "
            "Think laterally and creatively. "
            "After every factual statement, cite the source: [Source: <name>]."
        )
    },

    {
        "name":        "Epsilon",
        "model":       "openai/gpt-oss-20b",
        "provider":    "groq",
        "client_type": "groq",
        "color":       "blue",
        "personality": (
            "You are Agent Epsilon - the Synthesizer. "
            "Read all agents' arguments and produce a final authoritative answer. "
            "CRITICAL RULE: If the corpus excerpts don't contain the answer but the topic is "
            "within epidemic modeling, network science, or mathematical epidemiology - "
            "synthesize the answer from your own training knowledge. Label such facts as "
            "[General Knowledge] instead of a paper name. Never say 'cannot answer' for "
            "in-domain questions. For corpus-sourced facts use [Source: name]. "
            "Use LaTeX for all mathematical expressions (e.g. $R_0$, $\\beta$)."
        )
    },
    {
        "name":        "Zeta",
        "model":       "llama-3.1-8b-instant",
        "provider":    "groq",
        "client_type": "groq",
        "color":       "orange",
        "personality": (
            "You are Agent Zeta - the Citation Auditor. "
            "You receive a synthesized answer and the original source excerpts. "
            "OUTPUT FORMAT (strict): reproduce the answer text exactly as-is, making ONLY "
            "small inline tweaks where a [Source: name] tag cites a source that is NOT "
            "in the provided excerpts. In that case, change the tag to [Source: not verified]. "
            "NEVER output an 'Audit results:' section, bullet list of audit findings, or "
            "any commentary outside the answer text itself. "
            "Claims with [General Knowledge] tags - leave completely untouched. "
            "Claims with NO source tag - leave completely untouched. "
            "Just output the corrected answer and nothing else."
        )
    },
]

MAX_ROUNDS         = 2
MAX_TOKENS_AGENT   = 280
MAX_TOKENS_SYNTH   = 900
MAX_TOKENS_ZETA    = 800   # raised - Zeta was truncating mid-sentence at 500
TIMEOUT_SECONDS    = 25
CONTEXT_LIMIT      = 2500   # chars fed to synthesizer/zeta
AGENT_CONTEXT_LIMIT = 1200  # chars fed to individual debate agents (keep prompts short)

DOMAIN_GUARD = """
SCOPE: EpiRAG - strictly epidemic modeling, network science, mathematical epidemiology.
If the question is completely off-topic (not related to these fields), say so and stop.
If the question IS in-domain but the provided excerpts lack the answer:
  - Use your training knowledge to answer.
  - Label each such fact as [General Knowledge] instead of inventing source names.
  - Do NOT fabricate paper titles or authors.
  - Never say 'I cannot answer' for in-domain questions - always try.
"""


def _call_agent(agent, messages, groq_key, hf_token=None, max_tokens=MAX_TOKENS_AGENT):
    """Call a Groq-hosted agent. hf_token kept for signature compatibility."""
    try:
        client = Groq(api_key=groq_key)
        resp = client.chat.completions.create(
            model=agent["model"], messages=messages,
            temperature=0.7, max_tokens=max_tokens
        )
        return resp.choices[0].message.content.strip()
    except Exception as e:
        return f"[{agent['name']} error: {str(e)[:100]}]"


def _round1_msgs(agent, question, context):
    ctx = context[:AGENT_CONTEXT_LIMIT] + "..." if len(context) > AGENT_CONTEXT_LIMIT else context
    return [
        {"role": "system", "content": f"{DOMAIN_GUARD}\n\n{agent['personality']}"},
        {"role": "user",   "content": (
            f"Context from research papers/web:\n\n{ctx}\n\n---\n\n"
            f"Question: {question}\n\n"
            f"Answer concisely based on context. Cite sources after claims using [Source: name]. Stay in character."
        )}
    ]


def _round_n_msgs(agent, question, context, full_transcript: str):
    """
    Rounds 2+: each agent receives the transcript of all previous rounds
    (sequential mode) for richer context before responding.
    """
    ctx = context[:AGENT_CONTEXT_LIMIT] + "..." if len(context) > AGENT_CONTEXT_LIMIT else context
    # Cap transcript too - agents only need the gist, not the full wall of text
    transcript_cap = full_transcript[-2000:] if len(full_transcript) > 2000 else full_transcript
    return [
        {"role": "system", "content": f"{DOMAIN_GUARD}\n\n{agent['personality']}"},
        {"role": "user",   "content": (
            f"Context:\n\n{ctx}\n\nQuestion: {question}\n\n"
            f"=== DEBATE TRANSCRIPT SO FAR ===\n\n{transcript_cap}\n\n"
            f"=== YOUR TURN ===\n\n"
            f"Refine your position. Where do you agree or disagree? Be concise and cite sources."
        )}
    ]


def _synth_msgs(question, context, all_rounds):
    transcript = ""
    for i, rnd in enumerate(all_rounds, 1):
        transcript += f"\n\n{'='*40}\nROUND {i}\n{'='*40}\n"
        for name, ans in rnd.items():
            transcript += f"\n-- {name} --\n{ans}\n"
    ctx = context[:CONTEXT_LIMIT] + "..." if len(context) > CONTEXT_LIMIT else context
    return [
        {"role": "system", "content": (
            f"{DOMAIN_GUARD}\n\nYou are the Synthesizer. "
            "Produce the single best final answer following these rules STRICTLY:\n"
            "1. For facts taken directly from the provided context excerpts, cite with [Source: name].\n"
            "2. For established domain knowledge (textbook definitions, well-known models, "
            "   standard equations) that is NOT in the excerpts - write it WITHOUT any [Source:] tag. "
            "   Do NOT invent source names. Do NOT write [Source: ...] for things you know from training.\n"
            "3. Resolve agent disagreements using the strongest evidence from excerpts.\n"
            "4. Use LaTeX for all math (e.g. $R_0$, $\\beta$, $$\\frac{dI}{dt} = \\beta SI$$).\n"
            "5. Be concise and structured. End with: CONFIDENCE: HIGH / MEDIUM / LOW"
        )},
        {"role": "user", "content": (
            f"Context excerpts from corpus:\n\n{ctx}\n\n---\n\n"
            f"Question: {question}\n\n---\n\n"
            f"Debate transcript:{transcript}\n\n---\n\n"
            f"Produce the final synthesized answer. Only use [Source: name] for things "
            f"that appear in the context excerpts above."
        )}
    ]


def _factcheck_msgs(question, context, synthesis):
    ctx = context[:CONTEXT_LIMIT] + "..." if len(context) > CONTEXT_LIMIT else context
    # List the excerpt source names so Zeta knows what's available
    import re as _re
    source_names = list(dict.fromkeys(_re.findall(r'\[(?:LOCAL|WEB)\]\s*-\s*([^\[\]\(]+?)(?:\s*\(relevance)', ctx)))
    sources_list = ", ".join(source_names) if source_names else "(see excerpts above)"
    return [
        {"role": "system", "content": f"{DOMAIN_GUARD}\n\n{_get_zeta()['personality']}"},
        {"role": "user",   "content": (
            f"Available source excerpts (source names: {sources_list}):\n\n{ctx}\n\n---\n\n"
            f"Question: {question}\n\n---\n\n"
            f"Synthesized answer to audit:\n\n{synthesis}\n\n---\n\n"
            f"Check only explicit [Source: name] citations. "
            f"Uncited claims (no [Source:] tag) are established domain knowledge - leave them alone. "
            f"Output the answer with only small inline corrections where a cited source name "
            f"is not in the list above."
        )}
    ]


def _get_zeta():
    return next(a for a in AGENTS if a["name"] == "Zeta")


def _converged(answers):
    agree = ["i agree", "correct", "you're right", "i concur",
             "well said", "exactly", "this is accurate", "that's right"]
    hits  = sum(1 for a in answers.values()
                if any(p in a.lower() for p in agree))
    return hits >= len(answers) * 0.5


def _build_transcript(all_rounds: list[dict]) -> str:
    """Build a full text transcript of all rounds completed so far."""
    lines = []
    for i, rnd in enumerate(all_rounds, 1):
        lines.append(f"=== Round {i} ===")
        for name, ans in rnd.items():
            lines.append(f"-- {name} --\n{ans}")
        lines.append("")
    return "\n".join(lines)


def run_debate(question, context, groq_key, hf_token, callback=None):
    """
    Run the full multi-agent swarm debate (v2).

    Flow:
      Round 1    - parallel (independent first thoughts)
      Rounds 2-N - sequential (each agent sees full transcript before responding)
      Synthesis  - Epsilon synthesizes
      Fact-check - Zeta verifies citations

    callback(event: dict) is called after each agent responds, enabling SSE streaming.
    event shapes:
      {"type": "agent_done",   "round": int, "name": str, "color": str, "text": str}
      {"type": "round_start",  "round": int}
      {"type": "synthesizing"}
      {"type": "factchecking"}
      {"type": "done",         "consensus": bool, "rounds": int}
    Returns:
      {"final_answer", "debate_rounds", "consensus", "rounds_run", "agent_count"}
    """
    def emit(event):
        if callback:
            callback(event)

    debate_agents  = [a for a in AGENTS if a["name"] not in ("Epsilon", "Zeta")]
    synthesizer    = next(a for a in AGENTS if a["name"] == "Epsilon")
    fact_checker   = _get_zeta()
    agent_colors   = {a["name"]: a["color"] for a in AGENTS}
    debate_rounds  = []

    # -- Round 1: Parallel
    emit({"type": "round_start", "round": 1})
    round1 = {}

    with concurrent.futures.ThreadPoolExecutor(max_workers=len(debate_agents)) as ex:
        futures = {
            ex.submit(_call_agent, agent,
                      _round1_msgs(agent, question, context),
                      groq_key, hf_token): agent
            for agent in debate_agents
        }
        for future in concurrent.futures.as_completed(futures, timeout=TIMEOUT_SECONDS * 2):
            agent = futures[future]
            try:
                answer = future.result(timeout=TIMEOUT_SECONDS)
            except Exception as e:
                answer = f"[{agent['name']} timed out: {e}]"
            round1[agent["name"]] = answer
            emit({"type": "agent_done", "round": 1,
                  "name": agent["name"], "color": agent_colors[agent["name"]],
                  "text": answer})

    debate_rounds.append(round1)
    consensus    = _converged(round1)
    rounds_run   = 1

    # -- Rounds 2+: Sequential (each agent sees full transcript)
    while not consensus and rounds_run < MAX_ROUNDS:
        rounds_run += 1
        emit({"type": "round_start", "round": rounds_run})

        full_transcript = _build_transcript(debate_rounds)
        next_round      = {}

        for agent in debate_agents:
            msgs   = _round_n_msgs(agent, question, context, full_transcript)
            answer = _call_agent(agent, msgs, groq_key, hf_token)
            next_round[agent["name"]] = answer
            emit({"type": "agent_done", "round": rounds_run,
                  "name": agent["name"], "color": agent_colors[agent["name"]],
                  "text": answer})
            # Update transcript between agents so each sees the others' answers
            debate_rounds_temp = debate_rounds + [next_round]
            full_transcript    = _build_transcript(debate_rounds_temp)

        debate_rounds.append(next_round)
        consensus = _converged(next_round)

    # -- Synthesis
    emit({"type": "synthesizing"})
    ctx_trunc = context[:4000] if len(context) > 4000 else context
    synthesis = _call_agent(
        synthesizer,
        _synth_msgs(question, ctx_trunc, debate_rounds),
        groq_key, hf_token,
        max_tokens=MAX_TOKENS_SYNTH
    )

    # -- Fact-check
    emit({"type": "factchecking"})
    final = _call_agent(
        fact_checker,
        _factcheck_msgs(question, ctx_trunc, synthesis),
        groq_key, hf_token,
        max_tokens=MAX_TOKENS_ZETA
    )

    emit({"type": "done", "consensus": consensus, "rounds": rounds_run})

    return {
        "final_answer":  final,
        "debate_rounds": debate_rounds,
        "consensus":     consensus,
        "rounds_run":    rounds_run,
        "agent_count":   len(debate_agents)
    }