| """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 |
|
|
| MAX_AGENT_ITERATIONS = 3 |
|
|
| 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(): |
| |
| |
| |
| |
| |
| 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") |
| ] |
| 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: |
| |
| |
| |
| 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"] |
| 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="", |
| ) |
| 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] |
| 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) |
| 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"])) |
|
|
| |
| |
| |
| |
| 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: |
| |
| |
| |
| |
| 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: |
| |
| |
| 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 |
|
|
| 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] |
| 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, |
| "n_escalated": n_escalated, |
| "avg_agent_iterations_when_escalated": avg_agent_iterations, |
| "claims": audits, |
| } |
| writer({"node": "build_report", "status": "done"}) |
| return {**state, "report": report} |
|
|