mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-13 04:13:35 +08:00
852 lines
37 KiB
Python
852 lines
37 KiB
Python
"""Sufficiency check — cross-check + fusion score + 5-way verdict."""
|
|
|
|
import logging
|
|
|
|
from rag.advanced_rag.harness.types import (
|
|
AgentResult,
|
|
ClaimCrossCheckResult,
|
|
SufficiencyVerdict,
|
|
ExecutionStrategy,
|
|
)
|
|
from rag.advanced_rag.harness.config import get_mode
|
|
from rag.advanced_rag.harness.sufficiency_ladder import (
|
|
ANSWER_WITH_CAVEAT,
|
|
RECONCILE,
|
|
UNANSWERABLE,
|
|
)
|
|
|
|
_LOG = logging.getLogger(__name__)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Cross-check: code-only
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
import re
|
|
|
|
|
|
def extract_numbers(text: str) -> list[float]:
|
|
"""Extract numeric values from text."""
|
|
return [float(m) for m in re.findall(r"\d+\.?\d*", text)]
|
|
|
|
|
|
def _filter_relevant_numbers(numbers: list[float]) -> list[float]:
|
|
"""Drop numbers that carry no factual-claim signal.
|
|
|
|
- Values in ``[0, 1]`` are overwhelmingly ratios / probabilities /
|
|
confidence scores the agent sprinkled into its prose, not facts to
|
|
verify against the evidence.
|
|
- Drop duplicates: "48 m" appearing three times should be checked once.
|
|
"""
|
|
kept: list[float] = []
|
|
for n in numbers:
|
|
if 0.0 < n < 1.0:
|
|
continue
|
|
if n in kept:
|
|
continue
|
|
kept.append(n)
|
|
return kept
|
|
|
|
|
|
# ── Multilingual named-entity extraction ──────────────────────────────
|
|
# Cross-check previously only recognized English capitalized sequences, so
|
|
# Chinese/Japanese/Korean reports yielded zero entities and could never be
|
|
# verified. We now detect the report's language and route:
|
|
# - en/zh/de/fr/es/pt/ja: spaCy NER (models pre-loaded at build time; see
|
|
# pyproject.toml).
|
|
# - Any other language: langdetect returns the code; if no spaCy model is
|
|
# mapped the report still degrades gracefully to a no-op (never crashes).
|
|
# Language is detected with ``langdetect`` (Bayesian N-gram profiles, pure
|
|
# Python, 55+ languages — a product-grade alternative to hand-rolled
|
|
# heuristics), with a small Unicode-range heuristic as offline fallback.
|
|
# The spaCy pipeline is lazy-loaded once per process (singleton cache, like
|
|
# lightgraph) and degrades gracefully if a model is unavailable, so
|
|
# sufficiency never crashes on a missing model.
|
|
|
|
# RAGFlow language label / ISO code → spaCy model (mirrors lightgraph).
|
|
_LANG_TO_SPACY_MODEL = {
|
|
"en": "en_core_web_sm",
|
|
"english": "en_core_web_sm",
|
|
"zh": "zh_core_web_sm",
|
|
"chinese": "zh_core_web_sm",
|
|
"zh-cn": "zh_core_web_sm",
|
|
"de": "de_core_news_sm",
|
|
"german": "de_core_news_sm",
|
|
"fr": "fr_core_news_sm",
|
|
"french": "fr_core_news_sm",
|
|
"es": "es_core_news_sm",
|
|
"spanish": "es_core_news_sm",
|
|
"pt": "pt_core_news_sm",
|
|
"portuguese": "pt_core_news_sm",
|
|
"ja": "ja_core_news_sm",
|
|
"japanese": "ja_core_news_sm",
|
|
}
|
|
|
|
# spaCy NER labels that are not "evidence" (numbers, time, percentages).
|
|
_SPACY_SKIP_LABELS = {"ORDINAL", "CARDINAL", "DATE", "TIME", "PERCENT", "MONEY", "QUANTITY"}
|
|
|
|
_spacy_nlp_cache: dict = {}
|
|
|
|
|
|
def _is_cjk(text: str) -> bool:
|
|
"""True if ``text`` contains Chinese/Japanese characters."""
|
|
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
|
|
|
|
|
|
def _detect_language(text: str) -> str:
|
|
"""Detect the report's language and return an ISO 639-1 code.
|
|
|
|
Primary path uses ``langdetect`` (Bayesian N-gram profile classifier,
|
|
pure Python, ~55 languages). If langdetect is unavailable (or returns
|
|
nothing reliable for a very short / punctuation-only string), we fall
|
|
back to a small Unicode-range heuristic so the pipeline still works
|
|
offline without the extra dependency.
|
|
"""
|
|
if not text:
|
|
return "en"
|
|
try:
|
|
from langdetect import detect, DetectorFactory
|
|
|
|
# langdetect's global DetectorFactory is randomly seeded by default, so
|
|
# ambiguous/short inputs can flip between runs. Pin the seed once so
|
|
# detection (and therefore the spaCy NER model selected) is deterministic.
|
|
DetectorFactory.seed = 0
|
|
lang = detect(text[:500]) # long reports don't need the full body
|
|
if lang and lang != "unknown":
|
|
return lang
|
|
except Exception as exc: # langdetect not installed / detection error
|
|
_LOG.info(
|
|
"[multilingual-ner] langdetect unavailable/failed, falling back to Unicode heuristic: %s",
|
|
exc,
|
|
)
|
|
# Offline heuristic fallback.
|
|
if any("\u4e00" <= ch <= "\u9fff" for ch in text):
|
|
# Distinguish zh vs ja by presence of hiragana/katakana.
|
|
if any("\u3040" <= ch <= "\u30ff" for ch in text):
|
|
return "ja"
|
|
return "zh"
|
|
if any("\uac00" <= ch <= "\ud7af" for ch in text):
|
|
return "ko"
|
|
return "en"
|
|
|
|
|
|
def _resolve_spacy_model(language: str) -> str:
|
|
key = (language or "en").strip().lower()
|
|
return _LANG_TO_SPACY_MODEL.get(key, "en_core_web_sm")
|
|
|
|
|
|
def _spacy_ner_entities(text: str, language: str) -> list[str]:
|
|
"""Extract named entities via spaCy NER, with per-process model caching.
|
|
|
|
Returns [] on any failure (missing model, spacy import error) so the caller
|
|
can fall back to the regex path without crashing.
|
|
"""
|
|
model_name = _resolve_spacy_model(language)
|
|
if model_name in _spacy_nlp_cache:
|
|
nlp = _spacy_nlp_cache[model_name]
|
|
else:
|
|
try:
|
|
import spacy
|
|
|
|
nlp = spacy.load(model_name)
|
|
_spacy_nlp_cache[model_name] = nlp
|
|
except Exception as exc: # model not installed / spacy unavailable
|
|
_LOG.info("[multilingual-ner] spaCy model %s unavailable, falling back to regex: %s", model_name, exc)
|
|
return []
|
|
if nlp is None:
|
|
return []
|
|
try:
|
|
doc = nlp(text)
|
|
except Exception as exc:
|
|
_LOG.info("[multilingual-ner] spaCy inference failed for %s: %s", model_name, exc)
|
|
return []
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for ent in doc.ents:
|
|
label = ent.label_ or ""
|
|
if label in _SPACY_SKIP_LABELS:
|
|
continue
|
|
name = ent.text.strip()
|
|
if not name or name in seen:
|
|
continue
|
|
seen.add(name)
|
|
out.append(name)
|
|
return out
|
|
|
|
|
|
def extract_named_entities(text: str) -> list[str]:
|
|
"""Extract named entities via spaCy NER for any configured language.
|
|
|
|
All languages (en/zh/de/fr/es/pt/ja) go through the spaCy NER pipeline —
|
|
no hand-rolled regexes or stopword lists. spaCy only returns recognized
|
|
named entities (PER/LOC/ORG/NORP/MISC/...), so connective tissue never
|
|
appears. Number/time/percentage labels are filtered (they are verified
|
|
separately via ``extract_numbers``). Returns [] if spaCy is unavailable.
|
|
"""
|
|
if not text:
|
|
return []
|
|
lang = _detect_language(text)
|
|
return _spacy_ner_entities(text, lang)
|
|
|
|
|
|
def _detect_numeric_conflict(disclosed: list[str]) -> list[str]:
|
|
"""Detect close-but-different figures among the agent's disclosed numbers.
|
|
|
|
Extracts the leading number from each disclosure entry (e.g. "2,161,000 from
|
|
Wikipedia ..." → 2161000) and flags pairs that are numerically *close but not
|
|
equal* — the signature of a multi-source口径 conflict (same quantity, different
|
|
figure), which Q754 hit (population 2,161,000 vs 2,145,906 → 228 vs 227).
|
|
Numbers that are far apart are likely different quantities and not flagged.
|
|
"""
|
|
import re as _re
|
|
|
|
figures: list[tuple[float, str]] = []
|
|
for entry in disclosed:
|
|
m = _re.search(r"([\d][\d,]*(?:\.\d+)?)", entry)
|
|
if not m:
|
|
continue
|
|
try:
|
|
fig = float(m.group(1).replace(",", ""))
|
|
except ValueError:
|
|
continue
|
|
figures.append((fig, entry[:80]))
|
|
|
|
conflicts: list[str] = []
|
|
for i in range(len(figures)):
|
|
for j in range(i + 1, len(figures)):
|
|
a, b = figures[i][0], figures[j][0]
|
|
if a <= 0 or b <= 0:
|
|
continue
|
|
ratio = max(a, b) / min(a, b)
|
|
# Close (within 30%) but not equal → same quantity, conflicting value.
|
|
if 1 < ratio <= 1.3:
|
|
conflicts.append(f"{figures[i][1]} vs {figures[j][1]}")
|
|
return conflicts
|
|
|
|
|
|
# Attribute/relationship descriptors: content words in a grounded fact that are
|
|
# neither named entities nor numbers, e.g. "hometown", "captain", "born", "age".
|
|
# Their presence in the evidence is what makes a grounded assertion authoritative
|
|
# (the fact's *relationship*, not just its entities, must be supported).
|
|
_PREDICATE_STOP = {
|
|
"the",
|
|
"a",
|
|
"an",
|
|
"of",
|
|
"in",
|
|
"on",
|
|
"at",
|
|
"to",
|
|
"for",
|
|
"and",
|
|
"or",
|
|
"is",
|
|
"was",
|
|
"are",
|
|
"were",
|
|
"by",
|
|
"with",
|
|
"from",
|
|
"his",
|
|
"her",
|
|
"their",
|
|
"its",
|
|
"that",
|
|
"this",
|
|
"he",
|
|
"she",
|
|
"they",
|
|
"it",
|
|
"also",
|
|
"as",
|
|
"when",
|
|
"who",
|
|
"what",
|
|
"which",
|
|
"there",
|
|
"have",
|
|
"has",
|
|
"had",
|
|
"be",
|
|
"been",
|
|
"being",
|
|
}
|
|
|
|
|
|
def _predicate_terms(fact: str, excluded: list[str]) -> list[str]:
|
|
"""Return the predicate/attribute descriptors of ``fact`` (lowercased, ≤4 tokens).
|
|
|
|
These are the content words that carry the relationship being asserted,
|
|
excluding proper entities (in ``excluded``) and common stop words. For
|
|
"hometown is Ithaca" → ["hometown"]; for "Dustin Brown captain of Los Angeles
|
|
Kings" → ["captain"]. Used to verify the fact's relationship is evidenced.
|
|
"""
|
|
excluded_lower = {e.lower() for e in excluded}
|
|
tokens = re.findall(r"[A-Za-z][A-Za-z'-]{1,19}", fact.lower())
|
|
out: list[str] = []
|
|
for tok in tokens:
|
|
if tok in excluded_lower or tok in _PREDICATE_STOP or tok in out:
|
|
continue
|
|
out.append(tok)
|
|
return out[:4]
|
|
|
|
|
|
def _entity_present(ent: str, chunk_texts: list[str]) -> bool:
|
|
"""Whether ``ent`` (lowercased) appears in any of the evidence chunk texts.
|
|
|
|
CJK entities have no word boundaries (every Han char is ``\\w``) and are
|
|
commonly followed by function words ("的/是"), so they use a substring
|
|
match. Non-CJK entities use a bounded word/phrase match (Ann must not match
|
|
Annual). Mirrors the matching inside ``cross_check_claim``.
|
|
"""
|
|
if _is_cjk(ent):
|
|
return any(ent.lower() in t for t in chunk_texts)
|
|
return any(re.search(rf"(?<![\w]){re.escape(ent.lower())}(?![\w])", t) for t in chunk_texts)
|
|
|
|
|
|
def required_entity_gaps(
|
|
question: str,
|
|
claims: list,
|
|
all_chunks: dict,
|
|
) -> dict[str, list[str]]:
|
|
"""Find per-claim "required entities" missing from the evidence.
|
|
|
|
Aligns with the Sufficient Context paper (arXiv 2411.06037): sufficiency is
|
|
anchored on *what the question needs*, not on what the agent *claims* in its
|
|
report. Extracting entities from the report is vulnerable to the agent
|
|
back-filling facts from prior knowledge (check1.log Q2: the agent injected
|
|
Tyson Fury's "age 35" from memory while the corpus had no Fury data, so the
|
|
report-based cross-check passed on Mike Tyson's numbers that padded the
|
|
score). Instead we extract entities from the *claim description + question*
|
|
(what the answer actually requires) and check each against the evidence.
|
|
|
|
Returns {claim_id: [missing entities]}. A claim whose required entity is
|
|
absent from every cited/evidence chunk is flagged — under AND semantics one
|
|
missing required entity means that part of the question is unsupported.
|
|
"""
|
|
# Union of all evidence chunk texts (independent of any claim's citation).
|
|
chunk_texts: list[str] = []
|
|
for chunk in (all_chunks or {}).values():
|
|
text = chunk.get("content_with_weight") or chunk.get("text") or ""
|
|
if text:
|
|
chunk_texts.append(text.lower())
|
|
|
|
# Pre-extract question entities once (shared across claims) + seed per-claim.
|
|
q_entities = extract_named_entities(question or "")
|
|
gaps: dict[str, list[str]] = {}
|
|
for claim in claims or []:
|
|
cid = getattr(claim, "claim_id", None)
|
|
desc = getattr(claim, "description", None) or ""
|
|
if not cid or not desc:
|
|
continue
|
|
# Required entities = those in the claim description, plus any
|
|
# question-level entities also mentioned by the claim (so a composite
|
|
# question like "Mike Tyson AND Tyson Fury" keeps each named entity
|
|
# individually accountable).
|
|
desc_entities = extract_named_entities(desc)
|
|
desc_lower = {e.lower() for e in desc_entities}
|
|
required = [e for e in desc_entities] + [e for e in q_entities if e.lower() in desc_lower]
|
|
missing = [e for e in required if not _entity_present(e, chunk_texts)]
|
|
if missing:
|
|
gaps[cid] = missing
|
|
return gaps
|
|
|
|
|
|
def cross_check_claim(agent_result: AgentResult, all_chunks: dict) -> ClaimCrossCheckResult:
|
|
"""Code-level cross-check: number matching + entity presence."""
|
|
report = agent_result.report
|
|
claimed = agent_result.is_verified
|
|
_LOG.info(
|
|
"[Cross-check] claim=%s entering — self_verified=%s, report_len=%d, evidence_ids=%s",
|
|
agent_result.claim_id,
|
|
claimed,
|
|
len(report or ""),
|
|
agent_result.evidence_ids,
|
|
)
|
|
|
|
if not claimed:
|
|
_LOG.info("[Cross-check] claim=%s → FAILED (agent self-reported as unverified, score=0.0)", agent_result.claim_id)
|
|
return ClaimCrossCheckResult(
|
|
claim_id=agent_result.claim_id,
|
|
cross_check_passed=False,
|
|
cross_check_score=0.0,
|
|
mismatches=["agent self-reported as unverified"],
|
|
)
|
|
|
|
raw_numbers = extract_numbers(report)
|
|
numbers = _filter_relevant_numbers(raw_numbers)
|
|
entities = extract_named_entities(report)
|
|
_LOG.info(
|
|
"[Cross-check] claim=%s extracted %d raw number(s) → %d relevant (noise filtered/deduped): %s, %d entity(ies)=%s from report",
|
|
agent_result.claim_id,
|
|
len(raw_numbers),
|
|
len(numbers),
|
|
numbers[:8],
|
|
len(entities),
|
|
entities[:8],
|
|
)
|
|
|
|
# Existence check across the *union* of evidence chunks, not per-chunk.
|
|
# Verifying "does this fact appear anywhere in the cited evidence" is the
|
|
# right semantic — a number/entity supported by one chunk is verified.
|
|
# The old per-chunk loop demanded a fact appear in EVERY evidence chunk,
|
|
# so a fact confirmed in chunk A was recorded as a mismatch in chunks B..N
|
|
# and the score was diluted to near-zero even when the answer was correct
|
|
# (see benchmark/3.log: c4's 48/157/27/89 all matched chunk 0 yet scored
|
|
# 0.286; c5's 21/27/48/89 matched chunk 3 yet scored 0.168).
|
|
chunk_texts: list[str] = []
|
|
missing_ids: list[str] = []
|
|
for eid in agent_result.evidence_ids or []:
|
|
chunk = all_chunks.get(eid)
|
|
if not chunk:
|
|
missing_ids.append(str(eid))
|
|
continue
|
|
chunk_texts.append((chunk.get("content_with_weight") or chunk.get("text") or "").lower())
|
|
if missing_ids:
|
|
_LOG.info("[Cross-check] claim=%s %d evidence_id(s) MISSING from pool (index drift?): %s", agent_result.claim_id, len(missing_ids), missing_ids[:5])
|
|
|
|
_LOG.info(
|
|
"[Cross-check] claim=%s %d evidence chunk(s) gathered (%d expected); verifying %d fact(s) against their union",
|
|
agent_result.claim_id,
|
|
len(chunk_texts),
|
|
len(agent_result.evidence_ids or []),
|
|
len(numbers) + len(entities),
|
|
)
|
|
|
|
# ── Grounded-fact verification (answer↔evidence consistency) ──
|
|
# The agent explicitly lists the KEY assertions it claims are evidence-
|
|
# backed (schema field `grounded`). This is the ground truth to verify:
|
|
# the ratio-based number/entity check below can be gamed by padding facts
|
|
# (check1.log Q203: "hometown=Ithaca" scored 9/12=0.75 because Dustin Brown
|
|
# / Kings / Stanley Cup matched, while the answer-critical hometown fact
|
|
# was prior-injected and absent from evidence; Q665 likewise: "first solo
|
|
# album=1970" passed on Beatles padding). If any *grounded* fact is absent
|
|
# from the evidence, the claim is a prior-knowledge injection → hard-fail.
|
|
# A grounded fact is a natural-language assertion (e.g. "Dustin Brown
|
|
# captain of Los Angeles Kings"), which will never match evidence verbatim.
|
|
# Match on its *key content tokens* (entities + numbers) instead: the fact
|
|
# is considered grounded when at least half its key tokens appear in the
|
|
# evidence. "hometown is Ithaca" → key token [Ithaca] → absent → violation.
|
|
# "Dustin Brown captain of Los Angeles Kings" → [Dustin Brown, Los Angeles
|
|
# Kings] → both present → grounded. This is lenient against wording noise
|
|
# yet strict on the answer-critical named facts that prior-injection adds.
|
|
def _grounded_hit(fact: str) -> bool:
|
|
f = fact.lower()
|
|
if _is_cjk(f):
|
|
return any(f in t for t in chunk_texts)
|
|
# Key tokens = named entities + numbers mentioned in the fact.
|
|
key_tokens = [tok for tok in extract_named_entities(fact)]
|
|
key_tokens += [f"{int(n)}" for n in _filter_relevant_numbers(extract_numbers(fact))]
|
|
if not key_tokens:
|
|
# No extractable key content — fall back to whole-phrase match.
|
|
return any(re.search(rf"(?<![\w]){re.escape(f)}(?![\w])", t) for t in chunk_texts)
|
|
# Predicate/attribute co-occurrence: partial token overlap alone is not
|
|
# authoritative — the fact's *relationship* must also be evidenced. For
|
|
# "hometown is Ithaca", matching only "Ithaca" (present as a BIRTHPLACE)
|
|
# must NOT ground the claim. Extract the attribute descriptors (non-entity
|
|
# content words that carry the relation: "hometown", "captain of", "born")
|
|
# and require one of them to appear in the evidence. This downgrades a
|
|
# pure entity-overlap hit to non-authoritative when the predicate is absent.
|
|
predicate_tokens = _predicate_terms(fact, key_tokens)
|
|
entity_hits = sum(1 for tok in key_tokens if _entity_present(tok, chunk_texts))
|
|
if predicate_tokens:
|
|
pred_ok = any(re.search(rf"(?<![\w]){re.escape(pt)}(?![\w])", t) for pt in predicate_tokens for t in chunk_texts)
|
|
if not pred_ok:
|
|
return False # relationship not evidenced → prior-injection risk
|
|
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:
|
|
ungounded = [g for g in grounded_facts if not _grounded_hit(g)]
|
|
if ungounded:
|
|
_LOG.warning(
|
|
"[Cross-check] claim=%s → GROUNDED-FACT VIOLATION: %d key fact(s) agent marked as evidence-backed are ABSENT from evidence: %s — hard-failing claim",
|
|
agent_result.claim_id,
|
|
len(ungounded),
|
|
ungounded[:6],
|
|
)
|
|
return ClaimCrossCheckResult(
|
|
claim_id=agent_result.claim_id,
|
|
cross_check_passed=False,
|
|
cross_check_score=0.0,
|
|
mismatches=[f"grounded fact not in evidence: {g}" for g in ungounded],
|
|
)
|
|
|
|
# ── Numeric multi-source conflict detection (Q754: 225 vs 228口径) ──
|
|
# The agent may have used one figure while the evidence holds another value
|
|
# for the same quantity (e.g. Paris population from INSEE vs Wikipedia vs a
|
|
# news estimate). A ratio/mean cross-check passes as long as the used number
|
|
# matches somewhere, hiding the fact that a DIFFERENT authoritative figure
|
|
# exists. When the report discloses several distinct figures that are close
|
|
# but not equal (the classic "multiple sources, pick one" trap), surface them
|
|
# as conflicts and cap the claim below the pass floor so the caller does not
|
|
# blindly accept one口径.
|
|
disclosed = [str(n) for n in (agent_result.numbers or []) if str(n).strip()]
|
|
if disclosed:
|
|
conflict = _detect_numeric_conflict(disclosed)
|
|
if conflict:
|
|
_LOG.warning(
|
|
"[Cross-check] claim=%s → NUMERIC CONFLICT: multiple close-but-different figures for the same quantity: %s — capping below pass",
|
|
agent_result.claim_id,
|
|
conflict,
|
|
)
|
|
return ClaimCrossCheckResult(
|
|
claim_id=agent_result.claim_id,
|
|
cross_check_passed=False,
|
|
cross_check_score=0.0,
|
|
mismatches=[f"numeric source conflict: {c}" for c in conflict],
|
|
)
|
|
|
|
def _anywhere(needle: str) -> bool:
|
|
return any(re.search(needle, t) for t in chunk_texts)
|
|
|
|
matches: list[str] = []
|
|
mismatches: list[str] = []
|
|
for num in numbers:
|
|
# Numbers are extracted as floats ("1976" -> 1976.0) while chunk text
|
|
# spells them "1976" — match both raw and integral forms. Bounded so a
|
|
# number does not match inside a longer digit run (1976 vs 19760).
|
|
forms = {str(num), str(int(num))} if float(num).is_integer() else {str(num)}
|
|
found = any(_anywhere(rf"(?<![\w]){re.escape(f)}(?![\w])") for f in forms)
|
|
if found:
|
|
matches.append(f"number {num} found in evidence")
|
|
else:
|
|
mismatches.append(f"number {num} not found in any evidence chunk")
|
|
|
|
for ent in entities:
|
|
if _is_cjk(ent):
|
|
# CJK entities have no word boundaries (every Han char is \w), and
|
|
# they are commonly followed by function words ("的/是"). Use a
|
|
# substring match on the lowercased text instead of a \b-like regex,
|
|
# which would fail on "清真寺的..." because "的" is \w.
|
|
found = any(ent.lower() in t for t in chunk_texts)
|
|
else:
|
|
# Bounded word/phrase match: Ann must not match Annual.
|
|
found = _anywhere(rf"(?<![\w]){re.escape(ent.lower())}(?![\w])")
|
|
if found:
|
|
matches.append(f"entity '{ent}' found in evidence")
|
|
else:
|
|
mismatches.append(f"entity '{ent}' not found in any evidence chunk")
|
|
|
|
total = len(matches) + len(mismatches)
|
|
if total == 0:
|
|
# No evidence was actually examined — fail rather than pass neutrally:
|
|
# a claim with zero evidence IDs cannot be cross-checked at all.
|
|
if not agent_result.evidence_ids:
|
|
_LOG.info("[Cross-check] claim=%s → FAILED (no evidence ids, score=0.0)", agent_result.claim_id)
|
|
return ClaimCrossCheckResult(
|
|
claim_id=agent_result.claim_id,
|
|
cross_check_passed=False,
|
|
cross_check_score=0.0,
|
|
mismatches=["no evidence"],
|
|
)
|
|
# Evidence IDs exist but nothing extractable to verify against (e.g.
|
|
# Chinese reports yield no capitalized entities and no digits). We
|
|
# cannot confirm OR falsify — score it neutral (0.5) and do NOT mark it
|
|
# passed. The old "pass neutrally with score=1.0" treated unverifiable
|
|
# claims as fully verified, which let any entity-free, digit-free report
|
|
# sail through as SUFFICIENT.
|
|
_LOG.info(
|
|
"[Cross-check] claim=%s → NEUTRAL (evidence ids exist but nothing extractable to verify, score=0.5, not passed)",
|
|
agent_result.claim_id,
|
|
)
|
|
return ClaimCrossCheckResult(
|
|
claim_id=agent_result.claim_id,
|
|
cross_check_passed=False,
|
|
cross_check_score=0.5,
|
|
mismatches=["nothing extractable to cross-check"],
|
|
)
|
|
cross_score = len(matches) / total
|
|
# Pass when at least half the checked facts (numbers + entities) are
|
|
# confirmed in the evidence. The old ``mismatch < match*0.5`` required a
|
|
# 2/3 match rate and treated a single spurious number as fatal, which
|
|
# systematically failed otherwise-correct claims (see benchmark/2.log).
|
|
cross_passed = cross_score >= 0.5
|
|
_LOG.info(
|
|
"[Cross-check] claim=%s → %s (%d/%d matched, score=%.3f, pass>=0.50). matches=%s mismatches=%s",
|
|
agent_result.claim_id,
|
|
"PASSED" if cross_passed else "FAILED",
|
|
len(matches),
|
|
total,
|
|
cross_score,
|
|
matches[:5],
|
|
mismatches[:5],
|
|
)
|
|
|
|
return ClaimCrossCheckResult(
|
|
claim_id=agent_result.claim_id,
|
|
cross_check_passed=cross_passed,
|
|
cross_check_score=cross_score,
|
|
evidence_matches=matches,
|
|
mismatches=mismatches,
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Fusion score
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
def compute_fusion_score(
|
|
agent_results: list[AgentResult],
|
|
cross_check_results: list[ClaimCrossCheckResult],
|
|
mode: ExecutionStrategy,
|
|
question: str = "",
|
|
claims: list | None = None,
|
|
all_chunks: dict | None = None,
|
|
) -> SufficiencyVerdict:
|
|
"""Extract sufficiency *signals* — hard vetoes, agent confidence, conflicts.
|
|
|
|
This is no longer a weighted fusion. The LLM AutoRater (invoked by the
|
|
orchestrator via ``llm_sufficiency_boost``) is the primary sufficiency
|
|
judge; this function only produces the *code-level* inputs the decision
|
|
ladder consumes:
|
|
- hard_violations: claims with a proven evidence gap (required entity
|
|
missing / grounded absent / numeric conflict) that must veto "good
|
|
enough" even if the AutoRater says sufficient;
|
|
- agent_confidence: mean self-confidence over the trusted subset;
|
|
- has_conflicts / missing_claims: surfaced for the ladder / caveat.
|
|
|
|
``question`` / ``claims`` / ``all_chunks`` are optional; when provided they
|
|
drive the *required-entity* AND-semantics veto from the Sufficient Context
|
|
paper (anchored on what the question needs, not what the agent claims), plus
|
|
suppression of self-confidence for claims whose required entities are
|
|
missing from the evidence.
|
|
"""
|
|
# ── Required-entity gaps (Sufficient Context paper, AND semantics) ──
|
|
# Anchored on *what the question needs* rather than what the agent claims
|
|
# (see required_entity_gaps). Any claim whose required entity is absent from
|
|
# the evidence flags a localized gap: under AND semantics the whole verdict
|
|
# must not be SUFFICIENT, and the agent's self-confidence for that claim is
|
|
# suppressed (selective generation: confidence must not override missing
|
|
# evidence). Computed once here so it drives both Signal A and the veto.
|
|
required_gaps: dict[str, list[str]] = {}
|
|
if question or claims:
|
|
required_gaps = required_entity_gaps(question, claims, all_chunks)
|
|
if required_gaps:
|
|
_LOG.info(
|
|
"[Sufficiency] Required-entity gaps (AND semantics): %s",
|
|
{k: v for k, v in required_gaps.items()},
|
|
)
|
|
gapped_ids = set(required_gaps.keys())
|
|
|
|
# Signal A: agent self-assessed confidence (continuous, per design doc).
|
|
# Only self-verified claims count toward "agent is confident" — an
|
|
# unverified claim's confidence is not trustworthy. This replaces the old
|
|
# boolean pass-rate (verified_count / n) which inflated the score to 1.0
|
|
# whenever the agent merely said "verified" (benchmark/2.log showed
|
|
# confidence 0.5-0.7 being reported as agent_score=1.0).
|
|
#
|
|
# A claim whose REQUIRED entity is missing from the evidence must not lend
|
|
# its self-confidence to Signal A — the agent is confident about a fact the
|
|
# corpus cannot support (prior-knowledge back-fill, see check1.log Q2). Its
|
|
# confidence is zeroed for the mean, so self-assessed confidence can never
|
|
# mask an evidence gap.
|
|
verified = [r for r in agent_results if r.is_verified and r.claim_id not in gapped_ids]
|
|
suppressed = [r.claim_id for r in agent_results if r.is_verified and r.claim_id in gapped_ids]
|
|
verified_count = len(verified)
|
|
agent_score = sum(r.confidence for r in verified) / verified_count if verified_count else 0.0
|
|
if suppressed:
|
|
_LOG.info(
|
|
"[Sufficiency] Signal A: suppressed %d self-verified claim(s) with missing required entities (confidence zeroed): %s",
|
|
len(suppressed),
|
|
suppressed,
|
|
)
|
|
_LOG.info(
|
|
"[Sufficiency] Signal A (self): %d/%d claims self-verified (and evidence-backed), mean confidence → agent_score=%.3f (raw confidence values=%s)",
|
|
verified_count,
|
|
len(agent_results),
|
|
agent_score,
|
|
[round(r.confidence, 3) for r in agent_results],
|
|
)
|
|
|
|
# Signal B: cross-check score (continuous match rate), per design doc.
|
|
# Uses each claim's actual cross_check_score rather than a boolean
|
|
# pass/fail count, so partial-but-real evidence (e.g. 0.6) contributes
|
|
# proportionally instead of being zeroed.
|
|
#
|
|
# Unrelated-claim pollution (see 6.log/7.log): the planner occasionally
|
|
# invents a claim with no bearing on the question (e.g. "Suharto was born
|
|
# in Kemusuk" while the question asks about a mosque's heights). The agent
|
|
# finds no evidence for it (cross_check_score≈0), fails the cross-check,
|
|
# AND self-reports it as unverified. Such a claim drags Signal B down and
|
|
# pushes an otherwise sufficient fusion into the critical band that
|
|
# needlessly triggers the LLM fallback. We exclude "unanswerable + agent
|
|
# self-unverified" claims from the mean — they neither help nor should
|
|
# punish the verdict. We key on the agent's is_verified flag (not the
|
|
# confidence threshold) because a claim the agent itself calls unverified
|
|
# is the strongest unrelated/ungrounded signal (7.log's c2 reported
|
|
# confidence 0.35 but self-flagged unverified, which a confidence<0.2
|
|
# threshold would have missed).
|
|
cross_results = list(cross_check_results)
|
|
noise_threshold = 0.2
|
|
agent_verified = {r.claim_id: r.is_verified for r in agent_results}
|
|
noise_ids = [r.claim_id for r in cross_results if r.cross_check_score < noise_threshold and not r.cross_check_passed and not agent_verified.get(r.claim_id, False)]
|
|
kept = [r for r in cross_results if r.claim_id not in noise_ids]
|
|
if noise_ids and kept:
|
|
_LOG.info(
|
|
"[Sufficiency] Excluding %d unrelated/unverifiable claim(s) from Signal B: %s (cross<%.2f AND agent self-unverified)",
|
|
len(noise_ids),
|
|
noise_ids,
|
|
noise_threshold,
|
|
)
|
|
cross_results = kept
|
|
|
|
# ── Hard-veto floor: a *localized* evidence gap must veto "good enough" ──
|
|
# Multi-claim questions (e.g. "Mike Tyson AND Tyson Fury") can average a
|
|
# genuinely weak claim up to the sufficient band. Q2 (check.log): c4/c5's
|
|
# "Tyson Fury" / "Usyk" entities matched 0 chunks, yet the 5-claim mean
|
|
# cross_check_score=0.822 crossed the threshold and produced an answer that
|
|
# could not back the Fury half. These claims become ``hard_violations`` that
|
|
# force the decision ladder to a caveated answer even if the LLM AutoRater
|
|
# says sufficient (code-proven evidence gap beats "roughly good enough").
|
|
min_cross_floor = getattr(mode, "fusion_min_cross", 0.5) or 0.5
|
|
self_verified_ids = {r.claim_id for r in agent_results if r.is_verified}
|
|
weak = [r.claim_id for r in cross_results if r.claim_id in self_verified_ids and r.cross_check_score < min_cross_floor]
|
|
# AND-semantics required-entity veto: a claim missing a required entity
|
|
# (even if its report-based cross-check passed — the numbers matched on
|
|
# unrelated padding) must veto. This catches the Q2 case that min-cross
|
|
# alone missed: c2/c3 self-verified with score 0.83/0.80 (padded by Mike
|
|
# Tyson's digits) yet Tyson Fury's entities were absent from every chunk.
|
|
weak += [cid for cid in gapped_ids if cid not in weak]
|
|
if weak:
|
|
_LOG.info(
|
|
"[Sufficiency] Hard-veto: %d self-verified claim(s) below floor %.2f OR missing a required entity: %s",
|
|
len(weak),
|
|
min_cross_floor,
|
|
weak,
|
|
)
|
|
|
|
# Conflict detection — based on the kept (non-noisy) claims so an
|
|
# unrelated claim's mismatches don't manufacture a conflict.
|
|
has_conflicts = any(len(r.mismatches) > 0 for r in cross_results)
|
|
_LOG.info("[Sufficiency] Conflict detection: has_conflicts=%s", has_conflicts)
|
|
|
|
# Cross-check status (code-only view, no AutoRater). This is a *preliminary*
|
|
# label used by the orchestrator to decide whether to call the LLM AutoRater
|
|
# (medium triggers it only in the borderline band); the final decision comes
|
|
# from the decision ladder with the AutoRater's verdict.
|
|
if not any(r.cross_check_passed for r in cross_results):
|
|
status = "UNANSWERABLE"
|
|
elif has_conflicts:
|
|
status = "CONFLICTING"
|
|
elif weak:
|
|
status = "INSUFFICIENT"
|
|
elif agent_score >= mode.sufficiency_threshold:
|
|
status = "SUFFICIENT"
|
|
else:
|
|
status = "USEFUL_BUT_INCOMPLETE"
|
|
_LOG.info(
|
|
"[Sufficiency] Code-level status=%s (agent_conf=%.3f, conflicts=%s, hard_veto=%s)",
|
|
status,
|
|
agent_score,
|
|
has_conflicts,
|
|
bool(weak),
|
|
)
|
|
|
|
missing = [r.claim_id for r in cross_results if not r.cross_check_passed]
|
|
# Excluded unrelated claims are surfaced (not silently dropped) so the
|
|
# caller knows the planner invented unanswerable claims.
|
|
missing += noise_ids
|
|
# Thin-evidence claims that vetoed are surfaced too, so the caller sees
|
|
# exactly which part of the question still lacks support.
|
|
missing += [c for c in weak if c not in missing]
|
|
|
|
return SufficiencyVerdict(
|
|
status=status,
|
|
# Reference score for logging/monitoring only — the final decision comes
|
|
# from the decision ladder, not from this scalar.
|
|
score=agent_score,
|
|
claim_assessments=[{"claim_id": r.claim_id, "is_verified": r.cross_check_passed, "score": r.cross_check_score, "mismatches": r.mismatches} for r in cross_results],
|
|
has_conflicts=has_conflicts,
|
|
missing_claims=missing,
|
|
feedback=_build_feedback(missing, cross_results),
|
|
hard_violations=weak,
|
|
agent_confidence=agent_score,
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Helpers
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _build_feedback(missing: list[str], results: list[ClaimCrossCheckResult]) -> str:
|
|
if not missing:
|
|
return "all claims verified"
|
|
hints = []
|
|
for r in results:
|
|
if not r.cross_check_passed:
|
|
hints.append(f"claim {r.claim_id}: {len(r.mismatches)} mismatch(es)")
|
|
return "missing: " + "; ".join(hints)
|
|
|
|
|
|
def route_sufficiency_verdict(
|
|
verdict: SufficiencyVerdict,
|
|
mode_label: str,
|
|
cycle: int,
|
|
max_cycles: int,
|
|
auto: dict | None = None,
|
|
) -> tuple:
|
|
"""Decision-ladder routing → (action, should_continue, caveat).
|
|
|
|
The LLM AutoRater (``auto``) is the primary sufficiency judge; the agent
|
|
confidence (``verdict.agent_confidence``) is the risk gate. ``auto`` is the
|
|
dict returned by ``llm_sufficiency_boost`` (``is_sufficient`` /
|
|
``confidence`` / ``missing`` / ``contradictions``). When absent (e.g. the
|
|
medium mode did not trigger the AutoRater, or the tools lack an LLM judge),
|
|
we fall back to a code-only decision so the loop still terminates sensibly.
|
|
"""
|
|
mode = get_mode(mode_label)
|
|
hard_violations = getattr(verdict, "hard_violations", []) or []
|
|
agent_confidence = getattr(verdict, "agent_confidence", getattr(verdict, "score", 0.0))
|
|
|
|
# AutoRater signals, with sane defaults when it was not invoked.
|
|
auto_sufficient = bool(auto.get("is_sufficient")) if auto else (verdict.status == "SUFFICIENT")
|
|
auto_confidence = float(auto.get("confidence") or 1.0) if auto else 1.0
|
|
missing = list(auto.get("missing") or []) if auto else verdict.missing_claims
|
|
contradictions = list(auto.get("contradictions") or []) if auto else ([verdict.feedback] if verdict.has_conflicts else [])
|
|
|
|
from rag.advanced_rag.harness.sufficiency_ladder import sufficiency_ladder
|
|
|
|
out = sufficiency_ladder(
|
|
auto_sufficient=auto_sufficient,
|
|
auto_confidence=auto_confidence,
|
|
missing=missing,
|
|
contradictions=contradictions,
|
|
agent_confidence=agent_confidence,
|
|
c_high=mode.c_high,
|
|
c_low=mode.c_low,
|
|
llm_floor=mode.llm_floor,
|
|
allows_reconcile=mode.allows_reconcile,
|
|
cycle=cycle,
|
|
max_cycles=max_cycles,
|
|
hard_violations=hard_violations,
|
|
)
|
|
_LOG.info(
|
|
"[Sufficiency ladder] auto_sufficient=%s auto_conf=%.2f agent_conf=%.2f hard_violations=%s → %s",
|
|
auto_sufficient,
|
|
auto_confidence,
|
|
agent_confidence,
|
|
hard_violations,
|
|
out.action,
|
|
)
|
|
|
|
# Map ladder action onto orchestrator actions.
|
|
action = out.action
|
|
if action == ANSWER_WITH_CAVEAT:
|
|
action = "ANSWER_PARTIAL"
|
|
elif action == RECONCILE:
|
|
# medium has no reconcile loop → degrade to CONTINUE (keep searching).
|
|
if mode.allows_reconcile:
|
|
return ("CONTINUE", True, out.caveat)
|
|
return ("CONTINUE", True, out.caveat)
|
|
elif action == UNANSWERABLE:
|
|
if mode.fallback_to_direct_llm:
|
|
action = "FALLBACK_LLM"
|
|
else:
|
|
action = "ABSTAIN"
|
|
return (action, False, out.caveat)
|
|
# ANSWER / GAP
|
|
return (action, out.should_continue, out.caveat)
|