Refine sufficient check using LLM draft (#18028)

This commit is contained in:
Yingfeng
2026-08-10 11:44:47 +08:00
committed by GitHub
parent 42373a8229
commit b5bffa0fa3
5 changed files with 224 additions and 1 deletions

View File

@@ -206,6 +206,32 @@ async def agentic_research(state: dict, tools) -> dict:
if boost:
_LOG.info("[Agentic research] Round %d: AutoRater is_sufficient=%s confidence=%.2f", cycle + 1, boost.get("is_sufficient"), boost.get("confidence", 1.0))
# LLM groundedness review (Google "draft review" thought): check whether each
# claim's report is semantically supported by the cited evidence. Ungrounded
# claims (hallucinated / over-claimed drafts) are merged into hard_violations
# so the decision ladder forces a caveated answer — this catches relation/over-
# claim errors that the lexical code-level grounded check (cross_check_claim)
# cannot see.
from rag.advanced_rag.harness.orchestrator.grounded_llm import llm_grounded_verify
grounded = await llm_grounded_verify(
tools,
ctx.question,
[(r.claim_id, r.report or "") for r in agent_results_list if r.report],
cited_ids,
)
# Treat a claim as violating when it is explicitly grounded=False OR has
# non-empty ungrounded assertions (covers the degenerate grounded=False /
# empty-ungrounded case too). Only accept IDs present in the original
# claims collection — the LLM may echo a bogus claim_id that must not leak
# into hard_violations.
valid_claim_ids = {r.claim_id for r in agent_results_list}
ungrounded_ids = [cid for cid, g in grounded.items() if cid in valid_claim_ids and (g.get("grounded") is False or g.get("ungrounded"))]
if ungrounded_ids:
existing = set(verdict.hard_violations or [])
verdict.hard_violations = list(existing | set(ungrounded_ids))
_LOG.info("[Agentic research] Round %d: %d claim(s) have ungrounded (draft-review) assertions: %s", cycle + 1, len(ungrounded_ids), ungrounded_ids)
action, should_continue, caveat = route_sufficiency_verdict(
verdict,
mode_label,

View File

@@ -214,6 +214,37 @@ async def decompose_and_search(state: dict, tools) -> dict:
if boost:
_LOG.info("[Decompose] AutoRater is_sufficient=%s confidence=%.2f", boost.get("is_sufficient"), boost.get("confidence", 1.0))
# LLM groundedness review (Google "draft review"): runs unconditionally so every
# decomposed result — including a non-critical-band SUFFICIENT — is groundedness-
# validated before the status gate. (The lexical NER grounded check is disabled
# in favour of this LLM review, so it must not be skipped on any path.) Ungrounded
# claim drafts are merged into hard_violations → decision ladder caveat.
from rag.advanced_rag.harness.orchestrator.grounded_llm import llm_grounded_verify
# Union of cited evidence IDs across all claim results (matches the
# agentic orchestrator's cited-evidence behavior) so the reviewer sees
# the exact evidence each claim referenced, not a global prefix.
cited_evidence_ids: list[str] = []
for r in agent_results:
cited_evidence_ids.extend(r.evidence_ids or [])
grounded = await llm_grounded_verify(
tools,
ctx.question,
[(r.claim_id, r.report or "") for r in agent_results if r.report],
cited_evidence_ids or None,
)
# Treat a claim as violating when it is explicitly grounded=False OR has
# non-empty ungrounded assertions (covers the degenerate grounded=False /
# empty-ungrounded case too). Only accept IDs that exist in the original
# claims collection — the LLM may echo a bogus claim_id, which must not
# leak into hard_violations.
valid_claim_ids = {r.claim_id for r in agent_results}
ungrounded_ids = [cid for cid, g in grounded.items() if cid in valid_claim_ids and (g.get("grounded") is False or g.get("ungrounded"))]
if ungrounded_ids:
existing = set(verdict.hard_violations or [])
verdict.hard_violations = list(existing | set(ungrounded_ids))
_LOG.info("[Decompose] %d claim(s) have ungrounded (draft-review) assertions: %s", len(ungrounded_ids), ungrounded_ids)
action, should_continue, caveat = route_sufficiency_verdict(
verdict,
mode_label,

View File

@@ -0,0 +1,115 @@
"""LLM groundedness review (draft review) for the orchestrator.
Inspired by Google's Sufficient Context Agent — it reviews the *intermediate
draft* (each claim's report) against the retrieved snippets to decide whether
the draft is actually grounded in the evidence.
This complements the code-level grounded check (``cross_check_claim`` /
``_grounded_hit``, which is lexical: it only catches missing entities / digits).
The LLM review is *semantic*: it catches assertions whose relation or claim is
not supported by the evidence even when the entity is present (e.g. evidence
says "Ithaca is a birthplace" but the report claims "hometown is Ithaca", or
the evidence only mentions a medication was prescribed while the report
over-claims "the patient is well").
Ungrounded assertions are fed back into the decision ladder as hard violations,
so a hallucinated / over-claimed draft forces a caveated answer or a re-search.
"""
from __future__ import annotations
import logging
from rag.prompts.generator import PROMPT_JINJA_ENV, gen_json
from rag.prompts.template import load_prompt
_LOG = logging.getLogger(__name__)
GROUNDED_REVIEW = load_prompt("grounded_select")
async def _llm_chat_json(chat_mdl, prompt_text: str):
try:
return await gen_json(prompt_text, "Output:\n", chat_mdl)
except Exception as exc: # noqa: BLE001
_LOG.info("[Grounded-draft] gen_json failed: %s", exc)
return {}
def _render_reports(reports: list[tuple[str, str]]) -> str:
"""Render claim_id → report lines for the reviewer prompt."""
if not reports:
return "(no claims)"
return "\n".join(f"Claim {cid}: {rpt}" for cid, rpt in reports if rpt)
async def llm_grounded_verify(
tools,
question: str,
reports: list[tuple[str, str]],
evidence_ids=None,
) -> dict:
"""Draft review: is each claim's report grounded in the cited evidence?
Parameters
----------
tools : RAGTools
Must expose ``chat_mdl`` and ``kbinfos``.
reports : list[(claim_id, report)]
Each claim's draft text (``AgentResult.report``).
evidence_ids : list[str] | None
Union of cited evidence chunk IDs (falls back to a bounded prefix).
Returns
-------
dict : ``{claim_id: {"grounded": bool, "ungrounded": [str, ...]}}``
Empty dict when the LLM review is unavailable (no chat model, no
evidence, or a failure) — callers treat that as "no new signal".
"""
if not reports:
return {}
chat_mdl = getattr(tools, "chat_mdl", None)
if chat_mdl is None:
return {}
from rag.advanced_rag.harness.orchestrator.sufficiency_llm import (
_evidence_md,
_narrow_keywords,
)
evidence_md = _evidence_md(tools, evidence_ids, keywords=_narrow_keywords(question))
if not evidence_md:
return {}
prompt_text = PROMPT_JINJA_ENV.from_string(GROUNDED_REVIEW).render(
question=question,
reports=_render_reports(reports),
evidence=evidence_md,
)
_LOG.info("[Grounded-draft] reviewing %d claim report(s) (evidence %d chars)", len(reports), len(evidence_md))
result = await _llm_chat_json(chat_mdl, prompt_text)
# The LLM may return a malformed payload (array / scalar / null) instead of a
# dict. Log and fall back to the documented empty result so we never crash
# with AttributeError on .get("claims").
if not isinstance(result, dict):
_LOG.info("[Grounded-draft] unexpected LLM response type=%s (expected dict); treating as no groundedness signal", type(result).__name__)
return {}
out: dict = {}
for item in result.get("claims") or []:
cid = str(item.get("claim_id") or "")
if not cid:
continue
ung = item.get("ungrounded_assertions") or []
ungrounded = []
for u in ung:
if isinstance(u, dict):
ungrounded.append(str(u.get("assertion") or u.get("reason") or ""))
elif u:
ungrounded.append(str(u))
out[cid] = {
"grounded": bool(item.get("grounded")),
"ungrounded": [a for a in ungrounded if a],
}
return out

View File

@@ -17,6 +17,13 @@ from rag.advanced_rag.harness.sufficiency_ladder import (
_LOG = logging.getLogger(__name__)
# Experimental switch: when True, the lexical NER grounded-fact check
# (``_grounded_hit`` below) runs in ``cross_check_claim``; when False it is
# disabled and groundedness is delegated entirely to the LLM draft review
# (``llm_grounded_verify`` in orchestrator/grounded_llm.py). Set to False to
# trial "LLM draft instead of NER".
_ENABLE_NER_GROUNDED = False
# ═══════════════════════════════════════════════════════════════
# Cross-check: code-only
@@ -456,7 +463,7 @@ def cross_check_claim(agent_result: AgentResult, all_chunks: dict) -> ClaimCross
return entity_hits / len(key_tokens) >= 0.5
grounded_facts = [str(g) for g in (agent_result.grounded or []) if str(g).strip()]
if grounded_facts:
if _ENABLE_NER_GROUNDED and grounded_facts:
ungounded = [g for g in grounded_facts if not _grounded_hit(g)]
if ungounded:
_LOG.warning(

View File

@@ -0,0 +1,44 @@
You are an answer-groundedness reviewer. For each claim's report (the draft answer), determine whether every assertion is supported by the provided evidence.
A claim's report is GROUNDED only if each of its assertions can be inferred from the evidence. The assertion does NOT need to use the same words as the evidence (semantic paraphrase is fine), but it must NOT:
- assert a fact that is absent from the evidence (likely model prior-injection / hallucination);
- assert a relation or value that contradicts the evidence;
- over-claim beyond what the evidence supports (e.g. the evidence only says a medication was prescribed, but the report claims "the patient is well").
Question: {{ question }}
Claim reports to verify:
{{ reports }}
Evidence (each chunk labeled with an integer ID):
{{ evidence }}
For each claim, classify its assertions:
- SUPPORTED: the evidence explicitly supports it, including a semantic paraphrase.
- UNGROUNDED: the evidence lacks the content, or the claimed relation/value contradicts the evidence, or the assertion over-claims beyond the evidence.
Output format (JSON):
```json
{
"claims": [
{
"claim_id": "c1",
"grounded": true,
"ungrounded_assertions": []
},
{
"claim_id": "c2",
"grounded": false,
"ungrounded_assertions": [
{"assertion": "the patient had no adverse reactions", "reason": "the evidence only mentions the medication was prescribed, not the patient's reaction"}
]
}
]
}
```
Requirements:
1. Include EVERY claim in the output (do not skip any claim_id).
2. `grounded` is true only if ALL of the claim's assertions are supported.
3. `ungrounded_assertions` is empty when `grounded` is true; otherwise list each ungrounded assertion with a one-line `reason`.
4. Prefer identifying genuine over-claims or contradictions over surface-level wording differences — a semantic paraphrase IS supported.