minoola commited on
Commit
b41cb58
·
verified ·
1 Parent(s): 678e6f5

Upload 2 files

Browse files
Files changed (2) hide show
  1. app/graph/nodes.py +171 -73
  2. app/graph/state.py +1 -2
app/graph/nodes.py CHANGED
@@ -25,20 +25,66 @@ Text:
25
 
26
  AGREEMENT_THRESHOLD = 0.5 # min confidence gap tolerated before escalation
27
 
28
- ADJUDICATOR_PROMPT = """Two independent verification methods disagree on whether \
29
- the evidence supports a scientific claim. Review both assessments and the evidence, \
30
- then make the final call yourself. Respond with strict JSON only.
 
31
 
32
  Claim: {claim}
33
- Evidence: {evidence}
34
 
35
- Assessment A (fine-tuned NLI model): {label_a}, confidence {confidence_a}
36
- Assessment B (zero-shot LLM verifier): {label_b}, confidence {confidence_b}
37
  Assessment B's reasoning: {reasoning_b}
38
 
39
- Classify the relationship as exactly one of: SUPPORT, NOT_ENOUGH_INFO, CONTRADICT.
40
- Respond as JSON: {{"label": "...", "confidence": 0.0-1.0, "reasoning": "one sentence \
41
- explaining why you sided the way you did"}}"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
 
44
  def _llm():
@@ -46,12 +92,14 @@ def _llm():
46
  api_key=os.environ.get("GROQ_API_KEY"))
47
 
48
 
49
- def _adjudicator_llm():
50
- # Deliberately a different model from the verifier (llm_verifier.py uses
51
- # gpt-oss-20b) — the arbiter shouldn't be the same model marking its own
52
- # disagreement.
53
- return ChatGroq(model="llama-3.3-70b-versatile", temperature=0.0,
54
- api_key=os.environ.get("GROQ_API_KEY"))
 
 
55
 
56
 
57
  async def extract_claims(state: GraphState) -> GraphState:
@@ -112,8 +160,7 @@ async def verify_dual(state: GraphState) -> GraphState:
112
  "source": "llm"},
113
  "agreement": agree,
114
  "escalated": False,
115
- "escalation_retry_succeeded": None,
116
- "adjudicator_reasoning": None,
117
  "final_verdict": deberta_verdict if agree else None,
118
  "attribution": None,
119
  "resolution_note": None,
@@ -143,8 +190,7 @@ async def handle_unresolved_citation(state: GraphState) -> GraphState:
143
  "llm_verdict": None,
144
  "agreement": None,
145
  "escalated": False,
146
- "escalation_retry_succeeded": None,
147
- "adjudicator_reasoning": None,
148
  "final_verdict": None,
149
  "attribution": None,
150
  "resolution_note": ("Citation could not be resolved to usable evidence — "
@@ -161,12 +207,13 @@ def route_after_verify(state: GraphState) -> str:
161
 
162
 
163
  async def escalate(state: GraphState) -> GraphState:
164
- """On disagreement: retry with a reformulated query (claim's own keywords
165
- instead of the reference string, in case the first-resolved paper's
166
- abstract missed the relevant passage). If the verifiers now agree, done.
167
- If they still disagree, an adjudicator model (different from the verifier
168
- model) makes the final call the agent always resolves autonomously,
169
- no human-in-the-loop required.
 
170
  """
171
  idx = state["current_index"]
172
  cc = state["claims"][idx]
@@ -174,55 +221,106 @@ async def escalate(state: GraphState) -> GraphState:
174
  last = audits[-1]
175
  last["escalated"] = True
176
 
177
- # ── Retry: reformulated query on the claim itself, not the reference string ──
178
- s2_key = os.environ.get("S2_API_KEY")
179
- retry_query = semantic_scholar.extract_query(cc["claim"])
180
- retry_paper = await semantic_scholar.search_paper(retry_query, api_key=s2_key)
181
-
182
- llm_res = None
183
- if retry_paper and retry_paper.get("abstract"):
184
- retry_evidence = retry_paper["abstract"]
185
- deberta_res = await claim_client.analyze(cc["claim"], retry_evidence)
186
- winner = deberta_res["winner"]
187
- llm_res = await llm_verifier.verify(cc["claim"], retry_evidence)
188
-
189
- if winner["label"] == llm_res.get("label"):
190
- # Retry resolved the disagreement — adopt the new verdict values,
191
- # but do NOT overwrite `agreement` — it must stay False, since it
192
- # records the INITIAL disagreement that triggered escalation.
193
- # build_report's initial_agreement_rate depends on this staying accurate.
194
- last["winner_sentence"] = winner["sentence"]
195
- last["attribution_available"] = deberta_res.get("attribution_available", False)
196
- last["deberta_verdict"] = {"label": winner["label"],
197
- "confidence": winner["confidence"], "source": "deberta"}
198
- last["llm_verdict"] = {"label": llm_res.get("label"),
199
- "confidence": llm_res.get("confidence", 0.0), "source": "llm"}
200
- last["escalation_retry_succeeded"] = True
201
- last["final_verdict"] = last["deberta_verdict"]
202
- return {**state, "audits": audits}
203
 
204
- # ── Retry didn't resolve it (or found nothing usable) — adjudicate ──
205
- last["escalation_retry_succeeded"] = False
206
- prompt = ADJUDICATOR_PROMPT.format(
207
  claim=cc["claim"],
208
- evidence=cc["evidence_text"] or "",
209
  label_a=last["deberta_verdict"]["label"],
210
  confidence_a=last["deberta_verdict"]["confidence"],
211
  label_b=last["llm_verdict"]["label"],
212
  confidence_b=last["llm_verdict"]["confidence"],
213
- reasoning_b=llm_res.get("reasoning", "") if llm_res else "",
214
  )
215
- response = await _adjudicator_llm().ainvoke(prompt)
216
- try:
217
- verdict = json.loads(response.content)
218
- except json.JSONDecodeError:
219
- verdict = {"label": last["deberta_verdict"]["label"], "confidence": 0.0,
220
- "reasoning": "adjudicator_parse_error"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
- last["adjudicator_reasoning"] = verdict.get("reasoning", "")
223
- last["final_verdict"] = {"label": verdict.get("label"),
224
- "confidence": verdict.get("confidence", 0.0),
225
- "source": "adjudicator"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  return {**state, "audits": audits}
227
 
228
 
@@ -232,8 +330,8 @@ async def explain(state: GraphState) -> GraphState:
232
  audits = state["audits"]
233
  last = audits[-1]
234
  if last["final_verdict"] is None:
235
- # Should not normally happen verify_dual sets it on agreement,
236
- # escalate sets it via retry-success or adjudicator. Fallback just in case.
237
  last["final_verdict"] = last["deberta_verdict"]
238
 
239
  if last["attribution_available"]:
@@ -262,9 +360,10 @@ async def build_report(state: GraphState) -> GraphState:
262
  verified = [a for a in audits if a["agreement"] is not None] # excludes unresolved citations
263
  agreement_rate = (sum(a["agreement"] for a in verified) / len(verified)) if verified else 0.0
264
  n_escalated = sum(a["escalated"] for a in audits)
265
- n_retry_resolved = sum(bool(a.get("escalation_retry_succeeded")) for a in audits)
266
- n_adjudicated = sum(
267
- 1 for a in audits if a["escalated"] and not a.get("escalation_retry_succeeded")
 
268
  )
269
  n_unresolved = sum(1 for a in audits if a.get("resolution_note"))
270
  report = {
@@ -272,8 +371,7 @@ async def build_report(state: GraphState) -> GraphState:
272
  "n_unresolved_citations": n_unresolved,
273
  "initial_agreement_rate": agreement_rate, # computed over resolved claims only
274
  "n_escalated": n_escalated,
275
- "n_resolved_by_retry": n_retry_resolved,
276
- "n_resolved_by_adjudicator": n_adjudicated,
277
  "claims": audits,
278
  }
279
  return {**state, "report": report}
 
25
 
26
  AGREEMENT_THRESHOLD = 0.5 # min confidence gap tolerated before escalation
27
 
28
+ MAX_AGENT_ITERATIONS = 3 # ReAct-loop safety cap avoids runaway free-tier usage
29
+
30
+ AGENT_SYSTEM_PROMPT = """You are resolving a disagreement between two independent \
31
+ methods that assessed whether a piece of evidence supports a scientific claim.
32
 
33
  Claim: {claim}
34
+ Evidence currently available: {evidence}
35
 
36
+ Assessment A (fine-tuned NLI model): {label_a} (confidence {confidence_a})
37
+ Assessment B (zero-shot LLM verifier): {label_b} (confidence {confidence_b})
38
  Assessment B's reasoning: {reasoning_b}
39
 
40
+ You have tools available to investigate further before concluding. Use them if \
41
+ they would genuinely help; call `conclude` as soon as you have a well-supported \
42
+ answer. Do not call more tools than necessary. If you cannot resolve the \
43
+ disagreement with more evidence, concluding NOT_ENOUGH_INFO is a legitimate, \
44
+ complete answer — it is not a failure, and is preferable to guessing."""
45
+
46
+ AGENT_TOOLS = [
47
+ {
48
+ "type": "function",
49
+ "function": {
50
+ "name": "retry_with_query",
51
+ "description": ("Search Semantic Scholar again with a different query, "
52
+ "in case the currently available evidence missed the "
53
+ "relevant passage. Write your own search query."),
54
+ "parameters": {
55
+ "type": "object",
56
+ "properties": {"query": {"type": "string", "description": "New search query"}},
57
+ "required": ["query"],
58
+ },
59
+ },
60
+ },
61
+ {
62
+ "type": "function",
63
+ "function": {
64
+ "name": "request_second_opinion",
65
+ "description": ("Get an independent, blind re-verification of the claim "
66
+ "against the current evidence (no prior verdicts shown "
67
+ "to it, to avoid anchoring bias)."),
68
+ "parameters": {"type": "object", "properties": {}},
69
+ },
70
+ },
71
+ {
72
+ "type": "function",
73
+ "function": {
74
+ "name": "conclude",
75
+ "description": "Give your final, complete answer. This ends the investigation.",
76
+ "parameters": {
77
+ "type": "object",
78
+ "properties": {
79
+ "label": {"type": "string", "enum": ["SUPPORT", "NOT_ENOUGH_INFO", "CONTRADICT"]},
80
+ "confidence": {"type": "number"},
81
+ "reasoning": {"type": "string"},
82
+ },
83
+ "required": ["label", "confidence", "reasoning"],
84
+ },
85
+ },
86
+ },
87
+ ]
88
 
89
 
90
  def _llm():
 
92
  api_key=os.environ.get("GROQ_API_KEY"))
93
 
94
 
95
+ def _planner_llm():
96
+ # Deliberately a different, actively-supported model from the verifier
97
+ # (gpt-oss-20b) — a same-model critic tends to rubber-stamp its own kind
98
+ # of reasoning (Panickssery et al., 2024). llama-3.3-70b-versatile was
99
+ # deprecated by Groq (announced 2026-06-17); gpt-oss-120b is Groq's own
100
+ # recommended replacement and remains actively supported.
101
+ return ChatGroq(model="openai/gpt-oss-120b", temperature=0.0,
102
+ api_key=os.environ.get("GROQ_API_KEY")).bind_tools(AGENT_TOOLS)
103
 
104
 
105
  async def extract_claims(state: GraphState) -> GraphState:
 
160
  "source": "llm"},
161
  "agreement": agree,
162
  "escalated": False,
163
+ "escalation_trace": None,
 
164
  "final_verdict": deberta_verdict if agree else None,
165
  "attribution": None,
166
  "resolution_note": None,
 
190
  "llm_verdict": None,
191
  "agreement": None,
192
  "escalated": False,
193
+ "escalation_trace": None,
 
194
  "final_verdict": None,
195
  "attribution": None,
196
  "resolution_note": ("Citation could not be resolved to usable evidence — "
 
207
 
208
 
209
  async def escalate(state: GraphState) -> GraphState:
210
+ """On disagreement: a planner model (gpt-oss-120b, distinct from the
211
+ gpt-oss-20b verifier) autonomously decides how to resolve it -- it can
212
+ retry retrieval with its own reformulated query, request a blind second
213
+ opinion, or conclude directly, in whatever order and however many times
214
+ (up to MAX_AGENT_ITERATIONS) it judges necessary. This is a ReAct-style
215
+ reason-act-observe loop: the control flow is decided by the model at
216
+ each step, not by fixed code. See docs/dev_log.md for design rationale.
217
  """
218
  idx = state["current_index"]
219
  cc = state["claims"][idx]
 
221
  last = audits[-1]
222
  last["escalated"] = True
223
 
224
+ from langchain_core.messages import SystemMessage, ToolMessage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
+ current_evidence = cc["evidence_text"] or ""
227
+ system_prompt = AGENT_SYSTEM_PROMPT.format(
 
228
  claim=cc["claim"],
229
+ evidence=current_evidence,
230
  label_a=last["deberta_verdict"]["label"],
231
  confidence_a=last["deberta_verdict"]["confidence"],
232
  label_b=last["llm_verdict"]["label"],
233
  confidence_b=last["llm_verdict"]["confidence"],
234
+ reasoning_b="", # llm_verifier.verify doesn't currently surface reasoning to verify_dual
235
  )
236
+ messages = [SystemMessage(content=system_prompt)]
237
+ trace = []
238
+ planner = _planner_llm()
239
+ s2_key = os.environ.get("S2_API_KEY")
240
+
241
+ for _ in range(MAX_AGENT_ITERATIONS):
242
+ try:
243
+ response = await planner.ainvoke(messages)
244
+ except Exception as exc:
245
+ # Groq's tool-call parser can hard-fail (400) if the model blends
246
+ # free-text reasoning with a malformed tool call, rather than
247
+ # returning a plain response we could just re-prompt. Observed in
248
+ # practice after repeated tool failures (e.g. two 429s in a row)
249
+ # pushed the model toward verbose reasoning instead of a clean
250
+ # tool call. Treat this the same as "no usable answer yet" rather
251
+ # than crashing the whole graph.
252
+ trace.append({"action": "planner_error", "input": None, "observation": str(exc)})
253
+ break
254
+
255
+ messages.append(response)
256
+
257
+ if not response.tool_calls:
258
+ messages.append(SystemMessage(content="Please call the `conclude` tool with your final answer."))
259
+ continue
260
+
261
+ tool_call = response.tool_calls[0] # one action at a time, per ReAct
262
+ name, args = tool_call["name"], tool_call["args"]
263
+
264
+ if name == "conclude":
265
+ last["final_verdict"] = {"label": args["label"], "confidence": args.get("confidence", 0.0),
266
+ "source": "agent"}
267
+ trace.append({"action": "conclude", "input": args, "observation": None})
268
+ last["escalation_trace"] = trace
269
+ return {**state, "audits": audits}
270
 
271
+ elif name == "retry_with_query":
272
+ paper = await semantic_scholar.search_paper(args["query"], api_key=s2_key)
273
+ if paper and paper.get("abstract"):
274
+ current_evidence = paper["abstract"]
275
+ deberta_res = await claim_client.analyze(cc["claim"], current_evidence)
276
+ winner = deberta_res["winner"]
277
+ new_llm_res = await llm_verifier.verify(cc["claim"], current_evidence)
278
+ observation = (f"New evidence found: \"{current_evidence[:300]}\". "
279
+ f"Re-verified: NLI model says {winner['label']} "
280
+ f"({winner['confidence']:.2f}), LLM verifier says "
281
+ f"{new_llm_res.get('label')} ({new_llm_res.get('confidence', 0):.2f}).")
282
+ last["winner_sentence"] = winner["sentence"]
283
+ last["attribution_available"] = deberta_res.get("attribution_available", False)
284
+ last["deberta_verdict"] = {"label": winner["label"], "confidence": winner["confidence"], "source": "deberta"}
285
+ last["llm_verdict"] = {"label": new_llm_res.get("label"), "confidence": new_llm_res.get("confidence", 0.0), "source": "llm"}
286
+ else:
287
+ observation = "No usable evidence found for that query (no match or no abstract indexed)."
288
+ trace.append({"action": "retry_with_query", "input": args, "observation": observation})
289
+ messages.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))
290
+
291
+ elif name == "request_second_opinion":
292
+ second = await llm_verifier.verify(cc["claim"], current_evidence) # blind -- no prior verdicts in this call
293
+ observation = (f"Second opinion (blind, independent): {second.get('label')} "
294
+ f"(confidence {second.get('confidence', 0):.2f}). "
295
+ f"Reasoning: {second.get('reasoning', '')}")
296
+ trace.append({"action": "request_second_opinion", "input": {}, "observation": observation})
297
+ messages.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))
298
+
299
+ # Iteration cap hit (or planner errored above) without a `conclude` call --
300
+ # force a final answer rather than leaving the claim unresolved. Per
301
+ # ReAct/Reflexion literature, a calibrated NOT_ENOUGH_INFO is a legitimate
302
+ # complete answer, not a failure.
303
+ messages.append(SystemMessage(content="You must conclude now with your best answer, "
304
+ "using only the `conclude` tool."))
305
+ try:
306
+ final_response = await planner.ainvoke(messages)
307
+ except Exception as exc:
308
+ trace.append({"action": "planner_error_on_forced_conclude", "input": None, "observation": str(exc)})
309
+ final_response = None
310
+
311
+ if final_response and final_response.tool_calls and final_response.tool_calls[0]["name"] == "conclude":
312
+ args = final_response.tool_calls[0]["args"]
313
+ last["final_verdict"] = {"label": args["label"], "confidence": args.get("confidence", 0.0), "source": "agent"}
314
+ trace.append({"action": "conclude (forced at iteration cap)", "input": args, "observation": None})
315
+ else:
316
+ # Model never produced a valid conclude call, either it declined to
317
+ # or Groq's parser failed on it. Fall back to NOT_ENOUGH_INFO rather
318
+ # than guess, consistent with the "refusal over fabrication" principle,
319
+ # and rather than crash the request.
320
+ last["final_verdict"] = {"label": "NOT_ENOUGH_INFO", "confidence": 0.0, "source": "agent_fallback"}
321
+ trace.append({"action": "forced_fallback", "input": None, "observation": "Model did not call conclude within iteration cap."})
322
+
323
+ last["escalation_trace"] = trace
324
  return {**state, "audits": audits}
325
 
326
 
 
330
  audits = state["audits"]
331
  last = audits[-1]
332
  if last["final_verdict"] is None:
333
+ # Should not normally happen -- verify_dual sets it on agreement,
334
+ # escalate always sets it via conclude/forced-conclude/fallback.
335
  last["final_verdict"] = last["deberta_verdict"]
336
 
337
  if last["attribution_available"]:
 
360
  verified = [a for a in audits if a["agreement"] is not None] # excludes unresolved citations
361
  agreement_rate = (sum(a["agreement"] for a in verified) / len(verified)) if verified else 0.0
362
  n_escalated = sum(a["escalated"] for a in audits)
363
+ escalated_audits = [a for a in audits if a["escalated"]]
364
+ avg_agent_iterations = (
365
+ sum(len(a.get("escalation_trace") or []) for a in escalated_audits) / len(escalated_audits)
366
+ if escalated_audits else 0.0
367
  )
368
  n_unresolved = sum(1 for a in audits if a.get("resolution_note"))
369
  report = {
 
371
  "n_unresolved_citations": n_unresolved,
372
  "initial_agreement_rate": agreement_rate, # computed over resolved claims only
373
  "n_escalated": n_escalated,
374
+ "avg_agent_iterations_when_escalated": avg_agent_iterations,
 
375
  "claims": audits,
376
  }
377
  return {**state, "report": report}
app/graph/state.py CHANGED
@@ -25,8 +25,7 @@ class ClaimAudit(TypedDict):
25
  llm_verdict: Optional[Verdict]
26
  agreement: Optional[bool]
27
  escalated: bool
28
- escalation_retry_succeeded: Optional[bool] # True if re-retrieval resolved disagreement
29
- adjudicator_reasoning: Optional[str] # set only if adjudicator step ran
30
  final_verdict: Optional[Verdict]
31
  attribution: Optional[list] # [{"token": ..., "score": ...}, ...] or None
32
  resolution_note: Optional[str] # set when citation could not be resolved to evidence
 
25
  llm_verdict: Optional[Verdict]
26
  agreement: Optional[bool]
27
  escalated: bool
28
+ escalation_trace: Optional[list] # [{"action": ..., "input": ..., "observation": ...}, ...]
 
29
  final_verdict: Optional[Verdict]
30
  attribution: Optional[list] # [{"token": ..., "score": ...}, ...] or None
31
  resolution_note: Optional[str] # set when citation could not be resolved to evidence