File size: 19,410 Bytes
5db2259
 
 
 
 
a5b507c
5db2259
 
 
 
a5b507c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5db2259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b41cb58
 
 
 
5db2259
 
b41cb58
5db2259
b41cb58
 
5db2259
 
b41cb58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5db2259
 
 
 
 
 
 
b41cb58
 
 
 
 
 
 
 
5db2259
 
 
a5b507c
 
5db2259
 
 
 
 
 
 
 
 
 
 
 
 
a5b507c
5db2259
 
 
 
a5b507c
5db2259
 
 
a5b507c
5db2259
 
 
 
 
 
a5b507c
 
5db2259
 
 
 
 
 
a5b507c
5db2259
 
 
 
 
 
 
 
a5b507c
 
5db2259
 
 
 
 
 
 
a5b507c
 
5db2259
 
 
 
 
 
 
 
 
 
b41cb58
5db2259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b41cb58
5db2259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b41cb58
 
 
 
 
 
 
5db2259
 
 
 
 
 
a5b507c
 
5db2259
b41cb58
5db2259
b41cb58
 
5db2259
b41cb58
5db2259
 
 
 
b41cb58
5db2259
b41cb58
 
 
 
 
a5b507c
b41cb58
 
 
 
a5b507c
 
b41cb58
 
 
 
 
 
 
 
 
 
a5b507c
 
b41cb58
 
 
 
 
 
a5b507c
b41cb58
5db2259
b41cb58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5b507c
 
b41cb58
 
 
 
 
 
 
 
a5b507c
 
b41cb58
 
 
 
 
 
a5b507c
b41cb58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5b507c
5db2259
 
 
 
 
 
 
 
a5b507c
 
5db2259
b41cb58
 
5db2259
 
 
 
 
 
 
 
 
 
a5b507c
5db2259
 
 
 
 
 
 
 
 
 
 
 
 
 
a5b507c
5db2259
 
 
 
b41cb58
 
 
 
5db2259
 
 
 
 
 
 
b41cb58
5db2259
 
a5b507c
5db2259
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
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
"""LangGraph node implementations for the VeriScite audit pipeline."""

import json
import os
from langchain_groq import ChatGroq
from langgraph.config import get_stream_writer

from app.graph.state import GraphState, ClaimCitation
from app.tools import claim_client, llm_verifier, semantic_scholar


def _writer():
    """Safe wrapper around LangGraph's custom stream writer.

    get_stream_writer() raises RuntimeError when called outside an actual
    graph run (e.g. our isolated node tests, which call node functions
    directly rather than through graph.ainvoke/astream). Falls back to a
    no-op so those existing tests keep working unchanged; real graph runs
    get live progress events via stream_mode="custom".
    """
    try:
        return get_stream_writer()
    except RuntimeError:
        return lambda _event: None

EXTRACT_PROMPT = """This text contains a scientific paper's body (with inline citation \
markers like [12] or (Smith et al., 2023)) followed by its bibliography/reference list.

For each claim in the body that is attributed to a citation, extract:
  - claim: the factual statement being made
  - citation_marker: the inline marker exactly as it appears in the body, e.g. "[12]"
  - reference_string: the FULL bibliography entry that marker resolves to (authors,
    year, title) β€” look this up in the reference list, do not guess or invent one.
    If the marker cannot be resolved to a reference list entry, skip that claim.

Respond as a JSON list only, no other text:
[{{"claim": "...", "citation_marker": "...", "reference_string": "..."}}, ...]

Text:
{text}"""

AGREEMENT_THRESHOLD = 0.5  # min confidence gap tolerated before escalation

MAX_AGENT_ITERATIONS = 3  # ReAct-loop safety cap β€” avoids runaway free-tier usage

AGENT_SYSTEM_PROMPT = """You are resolving a disagreement between two independent \
methods that assessed whether a piece of evidence supports a scientific claim.

Claim: {claim}
Evidence currently available: {evidence}

Assessment A (fine-tuned NLI model): {label_a} (confidence {confidence_a})
Assessment B (zero-shot LLM verifier): {label_b} (confidence {confidence_b})
Assessment B's reasoning: {reasoning_b}

You have tools available to investigate further before concluding. Use them if \
they would genuinely help; call `conclude` as soon as you have a well-supported \
answer. Do not call more tools than necessary. If you cannot resolve the \
disagreement with more evidence, concluding NOT_ENOUGH_INFO is a legitimate, \
complete answer β€” it is not a failure, and is preferable to guessing."""

AGENT_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "retry_with_query",
            "description": ("Search Semantic Scholar again with a different query, "
                             "in case the currently available evidence missed the "
                             "relevant passage. Write your own search query."),
            "parameters": {
                "type": "object",
                "properties": {"query": {"type": "string", "description": "New search query"}},
                "required": ["query"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "request_second_opinion",
            "description": ("Get an independent, blind re-verification of the claim "
                             "against the current evidence (no prior verdicts shown "
                             "to it, to avoid anchoring bias)."),
            "parameters": {"type": "object", "properties": {}},
        },
    },
    {
        "type": "function",
        "function": {
            "name": "conclude",
            "description": "Give your final, complete answer. This ends the investigation.",
            "parameters": {
                "type": "object",
                "properties": {
                    "label": {"type": "string", "enum": ["SUPPORT", "NOT_ENOUGH_INFO", "CONTRADICT"]},
                    "confidence": {"type": "number"},
                    "reasoning": {"type": "string"},
                },
                "required": ["label", "confidence", "reasoning"],
            },
        },
    },
]


def _llm():
    return ChatGroq(model="openai/gpt-oss-20b", temperature=0.0,
                     api_key=os.environ.get("GROQ_API_KEY"))


def _planner_llm():
    # Deliberately a different, actively-supported model from the verifier
    # (gpt-oss-20b) β€” a same-model critic tends to rubber-stamp its own kind
    # of reasoning (Panickssery et al., 2024). llama-3.3-70b-versatile was
    # deprecated by Groq (announced 2026-06-17); gpt-oss-120b is Groq's own
    # recommended replacement and remains actively supported.
    return ChatGroq(model="openai/gpt-oss-120b", temperature=0.0,
                     api_key=os.environ.get("GROQ_API_KEY")).bind_tools(AGENT_TOOLS)


async def extract_claims(state: GraphState) -> GraphState:
    writer = _writer()
    writer({"node": "extract_claims", "status": "start"})
    prompt = EXTRACT_PROMPT.format(text=state["source_text"])
    response = await _llm().ainvoke(prompt)
    try:
        pairs = json.loads(response.content)
    except json.JSONDecodeError:
        pairs = []
    claims: list[ClaimCitation] = [
        {"claim": p["claim"], "citation_marker": p["citation_marker"],
         "reference_string": p.get("reference_string", ""),
         "resolved_paper_id": None, "evidence_text": None}
        for p in pairs
        if p.get("reference_string")  # skip claims whose citation couldn't be resolved
    ]
    writer({"node": "extract_claims", "status": "done", "n_claims": len(claims)})
    return {**state, "claims": claims, "audits": [], "current_index": 0}


async def fetch_citation(state: GraphState) -> GraphState:
    writer = _writer()
    claims = state["claims"]
    idx = state["current_index"]
    cc = claims[idx]
    writer({"node": "fetch_citation", "status": "start", "claim": cc["claim"]})
    s2_key = os.environ.get("S2_API_KEY")
    query = semantic_scholar.extract_query(cc["reference_string"])
    paper = await semantic_scholar.search_paper(query, api_key=s2_key)
    if paper and paper.get("abstract"):
        cc["resolved_paper_id"] = paper.get("paperId")
        cc["evidence_text"] = paper.get("abstract")
        writer({"node": "fetch_citation", "status": "done", "resolved": True,
                "title": paper.get("title")})
    else:
        # Either no match, or matched but no abstract indexed (common for older/
        # non-open-access papers) β€” both are "citation not usable", not a partial
        # success. Leave resolved_paper_id=None so downstream nodes can detect this.
        cc["resolved_paper_id"] = None
        cc["evidence_text"] = None
        writer({"node": "fetch_citation", "status": "done", "resolved": False})
    claims[idx] = cc
    return {**state, "claims": claims}


async def verify_dual(state: GraphState) -> GraphState:
    idx = state["current_index"]
    cc = state["claims"][idx]
    claim, evidence = cc["claim"], cc["evidence_text"] or ""
    writer = _writer()
    writer({"node": "verify_dual", "status": "start"})

    deberta_res = await claim_client.analyze(claim, evidence)
    winner = deberta_res["winner"]  # {"sentence", "label", "confidence", "sentence_index"}
    llm_res = await llm_verifier.verify(claim, evidence)

    agree = winner["label"] == llm_res.get("label")
    deberta_verdict = {"label": winner["label"], "confidence": winner["confidence"], "source": "deberta"}
    writer({"node": "verify_dual", "status": "done", "agree": agree,
            "deberta_label": winner["label"], "llm_label": llm_res.get("label")})
    audit = {
        "claim_citation": cc,
        "winner_sentence": winner["sentence"],
        "attribution_available": deberta_res.get("attribution_available", False),
        "deberta_verdict": deberta_verdict,
        "llm_verdict": {"label": llm_res.get("label"),
                         "confidence": llm_res.get("confidence", 0.0),
                         "source": "llm"},
        "agreement": agree,
        "escalated": False,
        "escalation_trace": None,
        "final_verdict": deberta_verdict if agree else None,
        "attribution": None,
        "resolution_note": None,
    }
    return {**state, "audits": state["audits"] + [audit]}


def route_after_fetch(state: GraphState) -> str:
    """Conditional edge: skip verification entirely if the citation could not
    be resolved to usable evidence text (search miss, or resolved paper had
    no abstract indexed) β€” sending empty evidence to /analyze causes a 422
    from clAIm's backend rather than a meaningful verdict.
    """
    idx = state["current_index"]
    cc = state["claims"][idx]
    return "verify" if cc.get("evidence_text") else "unresolved"


async def handle_unresolved_citation(state: GraphState) -> GraphState:
    idx = state["current_index"]
    cc = state["claims"][idx]
    audit = {
        "claim_citation": cc,
        "winner_sentence": None,
        "attribution_available": False,
        "deberta_verdict": None,
        "llm_verdict": None,
        "agreement": None,
        "escalated": False,
        "escalation_trace": None,
        "final_verdict": None,
        "attribution": None,
        "resolution_note": ("Citation could not be resolved to usable evidence β€” "
                             "either no search match or resolved paper had no "
                             "abstract indexed on Semantic Scholar."),
    }
    return {**state, "audits": state["audits"] + [audit]}


def route_after_verify(state: GraphState) -> str:
    """Conditional edge: escalate on disagreement, else proceed to explain."""
    last_audit = state["audits"][-1]
    return "escalate" if not last_audit["agreement"] else "explain"


async def escalate(state: GraphState) -> GraphState:
    """On disagreement: a planner model (gpt-oss-120b, distinct from the
    gpt-oss-20b verifier) autonomously decides how to resolve it -- it can
    retry retrieval with its own reformulated query, request a blind second
    opinion, or conclude directly, in whatever order and however many times
    (up to MAX_AGENT_ITERATIONS) it judges necessary. This is a ReAct-style
    reason-act-observe loop: the control flow is decided by the model at
    each step, not by fixed code. See docs/dev_log.md for design rationale.
    """
    idx = state["current_index"]
    cc = state["claims"][idx]
    audits = state["audits"]
    last = audits[-1]
    last["escalated"] = True
    writer = _writer()
    writer({"node": "escalate", "status": "start", "reason": "verifiers disagreed"})

    from langchain_core.messages import SystemMessage, ToolMessage

    current_evidence = cc["evidence_text"] or ""
    system_prompt = AGENT_SYSTEM_PROMPT.format(
        claim=cc["claim"],
        evidence=current_evidence,
        label_a=last["deberta_verdict"]["label"],
        confidence_a=last["deberta_verdict"]["confidence"],
        label_b=last["llm_verdict"]["label"],
        confidence_b=last["llm_verdict"]["confidence"],
        reasoning_b="",  # llm_verifier.verify doesn't currently surface reasoning to verify_dual
    )
    messages = [SystemMessage(content=system_prompt)]
    trace = []
    planner = _planner_llm()
    s2_key = os.environ.get("S2_API_KEY")

    for iteration in range(MAX_AGENT_ITERATIONS):
        try:
            response = await planner.ainvoke(messages)
        except Exception as exc:
            trace.append({"action": "planner_error", "input": None, "observation": str(exc)})
            writer({"node": "escalate", "status": "action", "action": "planner_error",
                    "iteration": iteration})
            break

        messages.append(response)

        if not response.tool_calls:
            messages.append(SystemMessage(content="Please call the `conclude` tool with your final answer."))
            continue

        tool_call = response.tool_calls[0]  # one action at a time, per ReAct
        name, args = tool_call["name"], tool_call["args"]
        writer({"node": "escalate", "status": "action", "action": name,
                "input": args, "iteration": iteration})

        if name == "conclude":
            last["final_verdict"] = {"label": args["label"], "confidence": args.get("confidence", 0.0),
                                      "source": "agent"}
            trace.append({"action": "conclude", "input": args, "observation": None})
            last["escalation_trace"] = trace
            writer({"node": "escalate", "status": "done", "final_verdict": last["final_verdict"]})
            return {**state, "audits": audits}

        elif name == "retry_with_query":
            paper = await semantic_scholar.search_paper(args["query"], api_key=s2_key)
            if paper and paper.get("abstract"):
                current_evidence = paper["abstract"]
                deberta_res = await claim_client.analyze(cc["claim"], current_evidence)
                winner = deberta_res["winner"]
                new_llm_res = await llm_verifier.verify(cc["claim"], current_evidence)
                observation = (f"New evidence found: \"{current_evidence[:300]}\". "
                                f"Re-verified: NLI model says {winner['label']} "
                                f"({winner['confidence']:.2f}), LLM verifier says "
                                f"{new_llm_res.get('label')} ({new_llm_res.get('confidence', 0):.2f}).")
                last["winner_sentence"] = winner["sentence"]
                last["attribution_available"] = deberta_res.get("attribution_available", False)
                last["deberta_verdict"] = {"label": winner["label"], "confidence": winner["confidence"], "source": "deberta"}
                last["llm_verdict"] = {"label": new_llm_res.get("label"), "confidence": new_llm_res.get("confidence", 0.0), "source": "llm"}
            else:
                observation = "No usable evidence found for that query (no match or no abstract indexed)."
            trace.append({"action": "retry_with_query", "input": args, "observation": observation})
            writer({"node": "escalate", "status": "observation", "action": "retry_with_query",
                    "observation": observation})
            messages.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))

        elif name == "request_second_opinion":
            second = await llm_verifier.verify(cc["claim"], current_evidence)  # blind -- no prior verdicts in this call
            observation = (f"Second opinion (blind, independent): {second.get('label')} "
                            f"(confidence {second.get('confidence', 0):.2f}). "
                            f"Reasoning: {second.get('reasoning', '')}")
            trace.append({"action": "request_second_opinion", "input": {}, "observation": observation})
            writer({"node": "escalate", "status": "observation", "action": "request_second_opinion",
                    "observation": observation})
            messages.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))

    # Iteration cap hit (or planner errored above) without a `conclude` call --
    # force a final answer rather than leaving the claim unresolved. Per
    # ReAct/Reflexion literature, a calibrated NOT_ENOUGH_INFO is a legitimate
    # complete answer, not a failure.
    writer({"node": "escalate", "status": "action", "action": "forced_conclude", "iteration": MAX_AGENT_ITERATIONS})
    messages.append(SystemMessage(content="You must conclude now with your best answer, "
                                           "using only the `conclude` tool."))
    try:
        final_response = await planner.ainvoke(messages)
    except Exception as exc:
        trace.append({"action": "planner_error_on_forced_conclude", "input": None, "observation": str(exc)})
        final_response = None

    if final_response and final_response.tool_calls and final_response.tool_calls[0]["name"] == "conclude":
        args = final_response.tool_calls[0]["args"]
        last["final_verdict"] = {"label": args["label"], "confidence": args.get("confidence", 0.0), "source": "agent"}
        trace.append({"action": "conclude (forced at iteration cap)", "input": args, "observation": None})
    else:
        # Model never produced a valid conclude call, either it declined to
        # or Groq's parser failed on it. Fall back to NOT_ENOUGH_INFO rather
        # than guess, consistent with the "refusal over fabrication" principle,
        # and rather than crash the request.
        last["final_verdict"] = {"label": "NOT_ENOUGH_INFO", "confidence": 0.0, "source": "agent_fallback"}
        trace.append({"action": "forced_fallback", "input": None, "observation": "Model did not call conclude within iteration cap."})

    last["escalation_trace"] = trace
    writer({"node": "escalate", "status": "done", "final_verdict": last["final_verdict"]})
    return {**state, "audits": audits}


async def explain(state: GraphState) -> GraphState:
    idx = state["current_index"]
    cc = state["claims"][idx]
    audits = state["audits"]
    last = audits[-1]
    writer = _writer()
    writer({"node": "explain", "status": "start"})
    if last["final_verdict"] is None:
        # Should not normally happen -- verify_dual sets it on agreement,
        # escalate always sets it via conclude/forced-conclude/fallback.
        last["final_verdict"] = last["deberta_verdict"]

    if last["attribution_available"]:
        label_id = claim_client.LABEL2ID[last["final_verdict"]["label"]]
        last["attribution"] = await claim_client.attribute(
            cc["claim"], last["winner_sentence"], label_id
        )
    else:
        last["attribution"] = None  # winner was NOT_ENOUGH_INFO β€” no attribution to show

    writer({"node": "explain", "status": "done", "final_verdict": last["final_verdict"]})
    return {**state, "audits": audits}


def advance_or_report(state: GraphState) -> str:
    """Conditional edge: loop to next claim, or finish and build report."""
    next_index = state["current_index"] + 1
    return "next_claim" if next_index < len(state["claims"]) else "report"


async def next_claim(state: GraphState) -> GraphState:
    return {**state, "current_index": state["current_index"] + 1}


async def build_report(state: GraphState) -> GraphState:
    writer = _writer()
    audits = state["audits"]
    verified = [a for a in audits if a["agreement"] is not None]  # excludes unresolved citations
    agreement_rate = (sum(a["agreement"] for a in verified) / len(verified)) if verified else 0.0
    n_escalated = sum(a["escalated"] for a in audits)
    escalated_audits = [a for a in audits if a["escalated"]]
    avg_agent_iterations = (
        sum(len(a.get("escalation_trace") or []) for a in escalated_audits) / len(escalated_audits)
        if escalated_audits else 0.0
    )
    n_unresolved = sum(1 for a in audits if a.get("resolution_note"))
    report = {
        "n_claims": len(audits),
        "n_unresolved_citations": n_unresolved,
        "initial_agreement_rate": agreement_rate,  # computed over resolved claims only
        "n_escalated": n_escalated,
        "avg_agent_iterations_when_escalated": avg_agent_iterations,
        "claims": audits,
    }
    writer({"node": "build_report", "status": "done"})
    return {**state, "report": report}