TRACER-Net / rag_evaluation.py
WeiZhou-CSU's picture
Upload 3 files
57af2af verified
Raw
History Blame Contribute Delete
51.8 kB
"""Deterministic sentence-, citation-, and graph-level RAG evaluation."""
from __future__ import annotations
import re
from typing import Any
from tracernet.services.pathway_evidence import (
build_default_matcher,
canonical_formula,
denies_transformation,
extract_formula_mentions,
normalize_text,
parse_path,
path_components,
)
# Instrument and method names are written as acronyms across this literature
# (XRD, XPS, FTIR, SEM, SIMS, XANES, EDX...). Detecting them by shape rather
# than by an enumerated list keeps the check open-ended: a technique nobody
# thought to list is still verified against the source.
_ACRONYM_RE = re.compile(r"(?<![A-Za-z])[A-Z]{2,6}(?![A-Za-z])")
# A paraphrase may reuse every entity of a snippet while asserting the
# opposite, so a negated or contrastive statement never qualifies as
# entity-grounded. Outright denial is defined once, in pathway_evidence
# (`denies_transformation`), and shared.
#
# The two patterns below extend that shared core for what this module judges: a
# single generated sentence rather than a whole literature chunk. Within one
# sentence a stray "is not" is likely to be the operative claim, so the strict
# reading is the safe one -- the opposite of what suits a long chunk.
_STATEMENT_NEGATION_RE = re.compile(
r"\b(?:is|are|was|were|has|have|had|can|could|would|will)\s+not\b"
r"|\b(?:isn't|aren't|wasn't|weren't|doesn't|didn't|cannot|can't)\b"
r"|\bnever\b",
flags=re.IGNORECASE,
)
# "A rather than B" does not deny the conversion, but it asserts an exclusion
# the source may not make, so grounding declines it as well.
_CONTRAST_RE = re.compile(
r"\b(?:rather\s+than|instead\s+of|contrary\s+to|as\s+opposed\s+to)\b",
flags=re.IGNORECASE,
)
_ENTITY_MATCHER = None
def _entity_resolver():
"""Shared chemical-name resolver (mineral name <-> formula)."""
global _ENTITY_MATCHER
if _ENTITY_MATCHER is None:
import os
_ENTITY_MATCHER = build_default_matcher(
os.path.dirname(os.path.abspath(__file__))
)
return _ENTITY_MATCHER
def _grounding_entities(text: str) -> set[str]:
"""Chemical species and experimental terms that a claim must ground.
Paraphrase is invisible here: only the *substance* of a sentence — which
compounds, conditions and techniques it names — is extracted, so rewritten
wording is fine while an invented species or technique is not.
"""
entities: set[str] = set()
normalized = normalize_text(_repair_formula_markup(text))
folded = normalized.casefold()
for mention in extract_formula_mentions(normalized):
if mention.composition:
entities.add("formula:" + mention.composition)
resolver = _entity_resolver().resolver
for name in resolver.by_name:
if len(name) < 4:
continue
if re.search(rf"(?<!\w){re.escape(name)}(?!\w)", folded):
records = resolver.by_name.get(name) or []
formula = records[0].formula if records else ""
entities.add("formula:" + (canonical_formula(formula) or name))
# Method/instrument acronyms, by shape rather than by enumeration.
for acronym in _ACRONYM_RE.findall(str(text or "")):
entities.add("acronym:" + acronym.casefold())
# Reaction conditions, taken from the project's own condition vocabulary
# (CRN labels plus their injected aliases) rather than a list written here.
# Wordings that the resolver treats as equivalent collapse to one concept,
# so "light" grounds against a source that says "photoinduced".
condition_aliases = _entity_resolver().condition_resolver.aliases
for concept, wordings in condition_aliases.items():
for term in {concept, *wordings}:
term = str(term or "").strip().casefold()
if len(term) < 2:
continue
if re.search(rf"(?<!\w){re.escape(term)}(?!\w)", folded):
entities.add("condition:" + concept)
break
return entities
_STOPWORDS = {
"a",
"an",
"and",
"are",
"as",
"at",
"be",
"by",
"can",
"for",
"from",
"has",
"have",
"in",
"into",
"is",
"it",
"of",
"on",
"or",
"that",
"the",
"their",
"this",
"to",
"under",
"via",
"with",
"within",
}
_SECTION_PREFIXES = (
"provenance analysis report",
"introduction",
"chemical pathway",
"evidence summary",
"literature evidence",
"references",
"reliability assessment",
"validation note:",
"nodes:",
"conditions:",
"citation coverage:",
"path consistency:",
"evidence granularity:",
"vector retrieval:",
"collection size:",
"retrieval basis:",
"retrieved snippets:",
"source count:",
"composite evidence:",
"unsupported claims:",
"overall confidence:",
"the crn records the graph relation",
"no direct pathway-matching literature evidence was retrieved",
"the literature index could not be queried",
"the current evidence therefore does not establish",
"the current evidence does not establish",
"mechanistic interpretation:",
"related literature context was retrieved",
"no pathway-matching literature snippet is available",
"no direct evidence for the complete pathway",
)
def _round_score(value: float | int | None) -> float:
if value is None:
return 0.0
value = max(0.0, min(1.0, float(value)))
return round(value, 3)
def _repair_formula_markup(value: Any) -> str:
"""Remove TeX wrappers, including wrappers damaged by earlier renderers.
Some rendering pipelines flatten upright formula wrappers before
evaluation, leaving command names attached to an otherwise valid formula.
Display artefacts must not turn a supported chemical claim into a graph
mismatch.
"""
text = str(value or "")
for _ in range(3):
repaired = re.sub(
r"\\(?:mathrm|mathbf|mathit|text|operatorname)\s*\{([^{}]*)\}",
r"\1",
text,
flags=re.IGNORECASE,
)
if repaired == text:
break
text = repaired
text = re.sub(
r"(?<![A-Za-z])(?:mathrm|mathbf|mathit|operatorname)(?=[A-Z][a-z]?)",
"",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"_\{?(\d+)\}?", r"\1", text)
return text.replace("{", "").replace("}", "")
def _normalize(text: Any) -> str:
normalized = normalize_text(_repair_formula_markup(text))
normalized = normalized.replace("\\xrightarrow", " arrow ")
normalized = normalized.replace("\\rightarrow", " arrow ")
normalized = normalized.replace("-->", " arrow ").replace("->", " arrow ")
normalized = re.sub(r"[\[\]{}$]", " ", normalized)
return re.sub(r"\s+", " ", normalized).strip().casefold()
def _tokens(text: Any) -> set[str]:
norm = _normalize(text)
toks = re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)?", norm)
return {t for t in toks if len(t) > 1 and t not in _STOPWORDS}
def _split_sentences(text: Any) -> list[str]:
cleaned = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
cleaned = re.sub(r"\n{2,}", "\n", cleaned)
chunks: list[str] = []
for line in cleaned.splitlines():
line = line.strip()
if not line:
continue
chunks.extend(re.split(r"(?<=[.!?。!?])\s+", line))
reattached: list[str] = []
for chunk in chunks:
leading = re.match(r"^((?:\[\d+\]\s*)+)(.*)$", chunk.strip())
if leading and reattached:
reattached[-1] = f"{reattached[-1]} {leading.group(1).strip()}"
if leading.group(2).strip():
reattached.append(leading.group(2).strip())
else:
reattached.append(chunk)
out = []
for chunk in reattached:
sent = chunk.strip()
if not sent:
continue
low = sent.strip("#* ").lower()
if any(low.startswith(prefix) for prefix in _SECTION_PREFIXES):
continue
if (
any(marker in sent for marker in ("--[", "->", "→", "\\xrightarrow"))
and parse_path(sent)
):
continue
if len(_tokens(sent)) < 3 and not re.search(r"\[\d+\]", sent):
continue
out.append(sent)
return out
def _path_terms(path_str: Any) -> list[str]:
species, conditions = path_components(path_str, keep_unparsed=True)
terms: list[str] = []
for item in species + conditions:
item = str(item or "").strip()
if not item:
continue
terms.append(item)
for sub in re.split(r"\s*[+;/]\s*", item):
sub = sub.strip()
if sub and sub != item:
terms.append(sub)
seen = set()
ordered = []
for term in terms:
key = _normalize(term)
if key and key not in seen:
seen.add(key)
ordered.append(term)
return ordered
def _term_is_covered(term: str, text: Any) -> bool:
norm_term = _normalize(term)
norm_text = _normalize(text)
if not norm_term or not norm_text:
return False
if norm_term in norm_text:
return True
term_tokens = _tokens(term)
if not term_tokens:
return False
text_tokens = _tokens(text)
overlap = len(term_tokens & text_tokens)
if len(term_tokens) == 1:
return overlap == 1
return overlap / len(term_tokens) >= 0.5
def _citation_nums(sentence: str) -> list[int]:
nums = []
for raw in re.findall(r"\[(\d+)\]", sentence or ""):
try:
nums.append(int(raw))
except Exception:
pass
return nums
def _overlap_score(sentence: str, snippet: str) -> float:
sent_tokens = _tokens(sentence)
snippet_tokens = _tokens(snippet)
if not sent_tokens or not snippet_tokens:
return 0.0
if _normalize(sentence) in _normalize(snippet):
return 1.0
return min(
1.0,
len(sent_tokens & snippet_tokens) / max(1, min(len(sent_tokens), 18)),
)
def split_into_statements(report: Any) -> list[str]:
"""Public sentence splitter for post-generation RAG evaluation."""
return _split_sentences(report)
def _evidence_text(evidence: Any) -> str:
if isinstance(evidence, dict):
return re.sub(
r"\s+",
" ",
str(evidence.get("snippet") or evidence.get("text") or evidence.get("content") or ""),
).strip()
return re.sub(r"\s+", " ", str(evidence or "")).strip()
def _evidence_source(evidence: Any) -> str:
if not isinstance(evidence, dict):
return ""
return re.sub(
r"\s+",
" ",
str(evidence.get("source") or evidence.get("reference") or evidence.get("doi") or ""),
).strip()
def _is_direct_evidence_block(evidence: Any) -> bool:
if not isinstance(evidence, dict):
return False
edge_matches = evidence.get("edge_matches")
if isinstance(edge_matches, list) and edge_matches:
return any(
isinstance(match, dict) and match.get("verdict") == "direct"
for match in edge_matches
)
status = str(evidence.get("evidence_status") or "").strip().casefold()
return status == "direct"
def _statement_matches_direct_edge(
statement: str,
evidence: dict[str, Any],
) -> bool:
edge_matches = evidence.get("edge_matches")
if not isinstance(edge_matches, list) or not edge_matches:
return _is_direct_evidence_block(evidence)
for match in edge_matches:
if not isinstance(match, dict) or match.get("verdict") != "direct":
continue
reactant = str(match.get("reactant") or "").strip()
product = str(match.get("product") or "").strip()
reactant_terms = [
term
for term in (reactant, str(match.get("reactant_span") or "").strip())
if term
]
product_terms = [
term
for term in (product, str(match.get("product_span") or "").strip())
if term
]
if (
reactant_terms
and product_terms
and any(_term_is_covered(term, statement) for term in reactant_terms)
and any(_term_is_covered(term, statement) for term in product_terms)
):
return True
return False
def _block_supports_statement(
statement: str,
evidence: dict[str, Any],
) -> bool:
return (
_is_direct_evidence_block(evidence)
and _statement_matches_direct_edge(statement, evidence)
)
# A statement that merely *restates* a cited snippet (mechanism, conditions,
# instrumentation, observed phases) is grounded even though it asserts no graph
# edge, so it cannot satisfy the reactant+product test above. Requiring a much
# higher lexical overlap keeps this from becoming a loophole: the sentence has
# to closely track the snippet it cites.
RESTATEMENT_THRESHOLD = 0.55
def match_restated_evidence(
statement: str,
retrieved_snippets: list[Any] | None,
threshold: float = RESTATEMENT_THRESHOLD,
) -> dict[str, Any] | None:
"""Find a direct-evidence snippet that this statement closely restates."""
best_block = None
best_score = 0.0
for block in _normalize_evidence_blocks(retrieved_snippets, None):
if not _is_direct_evidence_block(block):
continue
snippet = _evidence_text(block)
if not snippet:
continue
score = _overlap_score(statement, snippet)
if score > best_score:
best_score = score
best_block = block
if best_block is None or best_score < threshold:
return None
out = dict(best_block)
out["match_score"] = _round_score(best_score)
return out
def match_entity_grounded_evidence(
statement: str,
retrieved_snippets: list[Any] | None,
) -> dict[str, Any] | None:
"""Find a cited snippet that contains every entity the statement asserts.
This is the paraphrase-tolerant tier. Lexical overlap is deliberately not
consulted: a sentence written in genuinely new words is accepted as long as
every chemical species, condition and technique it names is present in the
snippet, and it does not negate that snippet. A sentence that introduces an
entity the source never mentions is rejected, which is what keeps invented
mechanisms out.
"""
claimed = _grounding_entities(statement)
if not claimed:
return None
if (
denies_transformation(statement)
or _STATEMENT_NEGATION_RE.search(statement or "")
or _CONTRAST_RE.search(statement or "")
):
return None
best_block = None
best_ratio = 0.0
for block in _normalize_evidence_blocks(retrieved_snippets, None):
if not _is_direct_evidence_block(block):
continue
snippet = _evidence_text(block)
if not snippet:
continue
available = _grounding_entities(snippet)
if not claimed <= available:
continue
ratio = len(claimed) / max(1, len(available))
if best_block is None or ratio > best_ratio:
best_block = block
best_ratio = ratio
if best_block is None:
return None
out = dict(best_block)
out["match_score"] = _round_score(min(1.0, 0.5 + best_ratio / 2))
out["grounding"] = "entity"
return out
def _normalize_evidence_blocks(retrieved_snippets: list[Any] | None, references: list[str] | None) -> list[dict[str, Any]]:
references = references or []
blocks: list[dict[str, Any]] = []
for idx, item in enumerate(retrieved_snippets or [], 1):
if isinstance(item, dict):
block = dict(item)
block.setdefault("index", idx)
block.setdefault("reference_index", block.get("index", idx))
if "snippet" not in block and "text" in block:
block["snippet"] = block.get("text")
ref_index = block.get("reference_index")
if (
not block.get("source")
and isinstance(ref_index, int)
and 1 <= ref_index <= len(references)
):
block["source"] = references[ref_index - 1]
else:
block = {
"index": idx,
"reference_index": idx,
"snippet": str(item or ""),
"source": references[idx - 1] if idx <= len(references) else "",
"evidence_status": "unclassified",
}
source = _evidence_source(block)
dois = re.findall(r"10\.\d{4,9}/[^\s,;)\]]+", source, flags=re.I)
if dois and not block.get("doi"):
block["doi"] = dois[0].rstrip(".")
blocks.append(block)
return blocks
def match_supporting_evidence(statement: str, retrieved_snippets: list[Any] | None, threshold: float = 0.18) -> dict[str, Any] | None:
"""Find the best retrieved snippet for one generated statement."""
best_block = None
best_score = 0.0
for block in _normalize_evidence_blocks(retrieved_snippets, None):
if not _block_supports_statement(statement, block):
continue
snippet = _evidence_text(block)
if not snippet:
continue
score = _overlap_score(statement, snippet)
if score > best_score:
best_score = score
best_block = block
if best_block is None or best_score < threshold:
return None
out = dict(best_block)
out["match_score"] = _round_score(best_score)
return out
def _pathway_terms(pathway_graph: Any) -> list[str]:
if isinstance(pathway_graph, dict):
terms = list(pathway_graph.get("path_terms") or [])
path_str = pathway_graph.get("path_str") or ""
if path_str:
terms.extend(_path_terms(path_str))
graph_refs = pathway_graph.get("graph_refs") or {}
if isinstance(graph_refs, dict):
terms.extend(graph_refs.get("node_ids") or [])
terms.extend(graph_refs.get("edge_ids") or [])
terms.extend(pathway_graph.get("node_ids") or [])
terms.extend(pathway_graph.get("edge_ids") or [])
elif isinstance(pathway_graph, str):
terms = _path_terms(pathway_graph)
else:
terms = []
seen = set()
ordered = []
for term in terms:
key = _normalize(term)
if key and key not in seen:
seen.add(key)
ordered.append(str(term))
return ordered
def _graph_node_ids(pathway_graph: Any) -> list[str]:
if not isinstance(pathway_graph, dict):
return []
raw = list(pathway_graph.get("node_ids") or [])
refs = pathway_graph.get("graph_refs") or {}
if isinstance(refs, dict):
raw.extend(refs.get("node_ids") or [])
seen: set[str] = set()
out: list[str] = []
for item in raw:
value = str(item or "").strip()
key = _normalize(value)
if key and key not in seen:
seen.add(key)
out.append(value)
return out
def _ordered_path_species(pathway_graph: Any) -> list[str]:
if isinstance(pathway_graph, str):
path_str = pathway_graph
elif isinstance(pathway_graph, dict):
path_str = pathway_graph.get("path_str") or ""
else:
path_str = ""
species, _conditions = path_components(path_str, keep_unparsed=True)
if len(species) >= 2:
return species
if not isinstance(pathway_graph, dict):
return []
for key in ("path_species", "species_path", "path_nodes", "ordered_nodes"):
raw = pathway_graph.get(key)
if not isinstance(raw, (list, tuple)):
continue
labels: list[str] = []
for node in raw:
if isinstance(node, dict):
node = (
node.get("formula")
or node.get("label")
or node.get("name")
or node.get("id")
or node.get("node_id")
)
value = str(node or "").strip()
if value:
labels.append(value)
if len(labels) >= 2:
return labels
return []
def _explicit_graph_edges(pathway_graph: Any) -> list[tuple[str, str]]:
if not isinstance(pathway_graph, dict):
return []
raw_edges = list(pathway_graph.get("path_edges") or [])
raw_edges.extend(pathway_graph.get("edges") or [])
refs = pathway_graph.get("graph_refs") or {}
if isinstance(refs, dict):
raw_edges.extend(refs.get("edges") or [])
pairs: list[tuple[str, str]] = []
for edge in raw_edges:
source = target = ""
if isinstance(edge, dict):
source = edge.get("source") or edge.get("from") or edge.get("start") or ""
target = edge.get("target") or edge.get("to") or edge.get("end") or ""
elif isinstance(edge, (list, tuple)) and len(edge) >= 2:
source, target = edge[0], edge[1]
elif isinstance(edge, str):
parsed, _conditions = path_components(edge, keep_unparsed=True)
if len(parsed) == 2:
source, target = parsed
source = str(source or "").strip()
target = str(target or "").strip()
if source and target:
pairs.append((source, target))
return pairs
def _graph_alignment_details(
statement: str,
pathway_graph: Any,
evidence: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Require an edge or a validated start-to-final pathway relation."""
species = _ordered_path_species(pathway_graph)
if len(species) >= 2:
endpoint_matches = [
match
for match in ((evidence or {}).get("edge_matches") or [])
if (
isinstance(match, dict)
and match.get("verdict") == "direct"
and match.get("evidence_scope") == "pathway_endpoint"
and _normalize(match.get("reactant")) == _normalize(species[0])
and _normalize(match.get("product")) == _normalize(species[-1])
)
]
if endpoint_matches and _statement_matches_direct_edge(
statement,
{"edge_matches": endpoint_matches},
):
return {
"aligned": True,
"method": "validated_pathway_endpoints",
"matched_species_pairs": [{
"source": species[0],
"target": species[-1],
}],
"required_species_pairs": [{
"source": species[0],
"target": species[-1],
}],
"matched_node_ids": [],
}
pairs = list(zip(species, species[1:]))
matched = [
{"source": source, "target": target}
for source, target in pairs
if _term_is_covered(source, statement) and _term_is_covered(target, statement)
]
return {
"aligned": bool(matched),
"method": "adjacent_species_pair",
"matched_species_pairs": matched,
"required_species_pairs": [
{"source": source, "target": target} for source, target in pairs
],
"matched_node_ids": [],
}
edges = _explicit_graph_edges(pathway_graph)
if edges:
matched = [
{"source": source, "target": target}
for source, target in edges
if _term_is_covered(source, statement) and _term_is_covered(target, statement)
]
return {
"aligned": bool(matched),
"method": "explicit_graph_edge",
"matched_species_pairs": matched,
"required_species_pairs": [
{"source": source, "target": target} for source, target in edges
],
"matched_node_ids": [],
}
node_ids = _graph_node_ids(pathway_graph)
if node_ids:
matched_nodes = [node for node in node_ids if _term_is_covered(node, statement)]
return {
"aligned": bool(matched_nodes),
"method": "node_id_fallback",
"matched_species_pairs": [],
"required_species_pairs": [],
"matched_node_ids": matched_nodes,
}
terms = _pathway_terms(pathway_graph)
matched_terms = [term for term in terms if _term_is_covered(term, statement)]
return {
"aligned": bool(matched_terms),
"method": "term_fallback" if terms else "no_graph_terms",
"matched_species_pairs": [],
"required_species_pairs": [],
"matched_node_ids": matched_terms,
}
def check_citation_support(
statement: str,
evidence: dict[str, Any] | None,
references: list[str] | None,
retrieved_snippets: list[Any] | None = None,
) -> bool | None:
cited_nums = _citation_nums(statement)
if not cited_nums:
return None
references = references or []
blocks = _normalize_evidence_blocks(retrieved_snippets, references)
cited_blocks = []
for n in cited_nums:
matching = [
block
for block in blocks
if block.get("reference_index") == n
]
if matching:
cited_blocks.extend(matching)
elif 1 <= n <= len(references):
cited_blocks.append({
"index": n,
"reference_index": n,
"source": references[n - 1],
"snippet": "",
"evidence_status": "metadata_only",
})
if not cited_blocks:
return False
cited_snippet_blocks = [block for block in cited_blocks if _evidence_text(block)]
if not cited_snippet_blocks:
return None
direct_blocks = [
block for block in cited_snippet_blocks
if _block_supports_statement(statement, block)
]
if direct_blocks:
return any(
_overlap_score(statement, _evidence_text(block)) >= 0.18
for block in direct_blocks
)
# The citation may instead back a close restatement of a direct-evidence
# snippet rather than an edge claim; that is still a verified citation.
return any(
_is_direct_evidence_block(block)
and _overlap_score(statement, _evidence_text(block)) >= RESTATEMENT_THRESHOLD
for block in cited_snippet_blocks
) or match_entity_grounded_evidence(statement, cited_snippet_blocks) is not None
def _is_inference_statement(statement: str) -> bool:
low = str(statement or "").lower()
inference_markers = (
"(inferred)",
"inferred",
"likely",
"suggest",
"therefore",
"displayed reaction network",
"traced pathway",
"reaction network",
"pathway condition",
"conservation implication",
"represented by",
)
return any(marker in low for marker in inference_markers)
def _source_traceability_assessment(
evidence_blocks: list[dict[str, Any]],
references: list[str],
) -> dict[str, Any]:
"""Score whether evidence can be traced and independently inspected.
This is deliberately a documentation-quality score, not a journal-prestige
score. It rewards an identifiable publication, a DOI, an inspectable
snippet, and an explicit evidence classification.
"""
grouped: dict[int, list[dict[str, Any]]] = {}
for block in evidence_blocks:
try:
reference_index = int(block.get("reference_index") or 0)
except (TypeError, ValueError):
reference_index = 0
if reference_index <= 0:
try:
reference_index = int(block.get("index") or len(grouped) + 1)
except (TypeError, ValueError):
reference_index = len(grouped) + 1
grouped.setdefault(reference_index, []).append(block)
details: list[dict[str, Any]] = []
for reference_index, blocks in sorted(grouped.items()):
reference = (
str(references[reference_index - 1]).strip()
if 1 <= reference_index <= len(references)
else ""
)
source_text = " ".join(
part
for part in [
reference,
*(_evidence_source(block) for block in blocks),
]
if part
)
has_doi = bool(
re.search(r"10\.\d{4,9}/[^\s,;)\]]+", source_text, flags=re.I)
or any(block.get("doi") for block in blocks)
)
has_year = bool(re.search(r"\b(?:19|20)\d{2}\b", source_text))
has_structured_citation = bool(
re.search(
r"\[(?:J|M|C|R|D)(?:/OL)?\]|\b(?:journal|proceedings|"
r"transactions|review|letters)\b",
source_text,
flags=re.I,
)
)
has_snippet = any(bool(_evidence_text(block)) for block in blocks)
has_direct_classification = any(
_is_direct_evidence_block(block) for block in blocks
)
score = _round_score(
(0.25 if has_doi else 0.0)
+ (0.15 if has_year else 0.0)
+ (0.15 if has_structured_citation else 0.0)
+ (0.25 if has_snippet else 0.0)
+ (0.20 if has_direct_classification else 0.0)
)
level = "high" if score >= 0.8 else "moderate" if score >= 0.55 else "limited"
details.append({
"reference_index": reference_index,
"source": reference or _evidence_source(blocks[0]),
"score": score,
"level": level,
"has_doi": has_doi,
"has_year": has_year,
"has_structured_citation": has_structured_citation,
"has_snippet": has_snippet,
"has_direct_classification": has_direct_classification,
})
overall_score = _round_score(
sum(item["score"] for item in details) / len(details)
if details
else 0.0
)
overall_level = (
"high"
if overall_score >= 0.8
else "moderate"
if overall_score >= 0.55
else "limited"
if details
else "unavailable"
)
return {
"score": overall_score,
"level": overall_level,
"details": details,
"basis": (
"Bibliographic traceability and evidence completeness; "
"not journal prestige or independent replication."
),
}
def _pathway_evidence_coverage(
pathway_graph: Any,
evidence_blocks: list[dict[str, Any]],
) -> tuple[float, bool]:
edges = parse_path(
pathway_graph.get("path_str") if isinstance(pathway_graph, dict) else pathway_graph
)
if not edges:
return 0.0, False
direct_edge_indexes: set[int] = set()
endpoint_supported = False
for block in evidence_blocks:
for match in block.get("edge_matches") or []:
if not isinstance(match, dict) or match.get("verdict") != "direct":
continue
if match.get("evidence_scope") == "pathway_endpoint":
endpoint_supported = True
continue
try:
edge_index = int(match.get("edge_index") or 0)
except (TypeError, ValueError):
edge_index = 0
if 1 <= edge_index <= len(edges):
direct_edge_indexes.add(edge_index)
continue
for edge in edges:
if (
_normalize(match.get("reactant")) == _normalize(edge.reactant)
and _normalize(match.get("product")) == _normalize(edge.product)
):
direct_edge_indexes.add(edge.index)
break
if len(edges) == 1 and direct_edge_indexes:
endpoint_supported = True
return _round_score(len(direct_edge_indexes) / len(edges)), endpoint_supported
def _overall_reliability_assessment(
*,
total: int,
snippet_count: int,
evidence_coverage: float,
citation_accuracy: float | None,
citation_checked: int,
citation_unverifiable_count: int,
graph_alignment: float,
graph_alignment_applicable_count: int,
source_quality_score: float,
edge_evidence_coverage: float,
endpoint_conversion_supported: bool,
unsupported_count: int,
) -> dict[str, Any]:
citation_component = (
float(citation_accuracy)
if citation_accuracy is not None
else 0.0
)
graph_component = (
graph_alignment if graph_alignment_applicable_count else 1.0
)
score = _round_score(
0.35 * evidence_coverage
+ 0.20 * citation_component
+ 0.15 * graph_component
+ 0.15 * source_quality_score
+ 0.15 * edge_evidence_coverage
)
reasons: list[str] = []
if total == 0:
level = "not_evaluable"
reasons.append("No evaluable factual statements were found.")
elif snippet_count == 0:
level = "insufficient_evidence"
reasons.append("No snippet-level literature evidence was available.")
else:
failed_citations = max(0, citation_checked - int(round(
citation_component * citation_checked
)))
reliable = bool(
evidence_coverage >= 0.8
and (
graph_alignment_applicable_count == 0
or graph_alignment >= 0.8
)
and source_quality_score >= 0.55
and citation_checked > 0
and citation_accuracy is not None
and citation_accuracy >= 0.8
and citation_unverifiable_count == 0
and failed_citations == 0
and unsupported_count == 0
and edge_evidence_coverage >= 0.8
)
partially_reliable = bool(
evidence_coverage >= 0.5
and (
graph_alignment_applicable_count == 0
or graph_alignment >= 0.5
)
and source_quality_score >= 0.4
and failed_citations == 0
)
if reliable:
level = "reliable"
elif partially_reliable:
level = "partially_reliable"
else:
level = "insufficient_evidence"
# Each reason names the measurement that fell short and the count behind
# it. "Generated claims are not fully aligned with the selected path"
# told a reader that something was wrong without saying what, how much,
# or where to look -- which is the one thing a reliability conclusion has
# to do.
if unsupported_count:
reasons.append(
f"Statement support: {unsupported_count} of {total} generated "
"statement(s) are not backed by any retrieved snippet."
)
if citation_checked == 0:
reasons.append(
"Citation verification: no cited statement could be checked "
"against a linked snippet, so citation accuracy is unmeasured."
)
elif citation_accuracy is not None and citation_accuracy < 1:
wrong = max(
0, citation_checked - int(round(citation_component * citation_checked))
)
reasons.append(
f"Citation verification: {wrong} of {citation_checked} checked "
"citation(s) do not support the claim they are attached to."
)
if citation_unverifiable_count:
reasons.append(
f"Citation verification: {citation_unverifiable_count} "
"citation(s) have no linked snippet to verify against."
)
if graph_alignment_applicable_count and graph_alignment < 0.8:
aligned = int(round(graph_alignment * graph_alignment_applicable_count))
reasons.append(
f"Graph alignment: {aligned} of {graph_alignment_applicable_count} "
f"path-related statement(s) match the selected path "
f"({round(graph_alignment * 100)}%). The remainder describe "
"species, conditions or steps that the selected path does not "
"encode -- most often a condition reported by the literature "
"that differs from the graph-encoded condition label."
)
if source_quality_score < 0.55:
reasons.append(
"Source traceability: bibliographic completeness scores "
f"{round(source_quality_score * 100)}%; one or more references "
"lack a DOI, year, or inspectable snippet."
)
if edge_evidence_coverage < 0.8:
covered = round(edge_evidence_coverage * 100)
if endpoint_conversion_supported:
reasons.append(
f"Edge evidence coverage: {covered}%. The overall endpoint "
"conversion is supported by retrieved literature, but at "
"least one intermediate graph edge has no direct passage of "
"its own and remains graph-derived."
)
else:
reasons.append(
f"Edge evidence coverage: {covered}%. One or more graph "
"edges have no direct literature passage establishing them."
)
summaries = {
"reliable": (
"Reliable: generated claims are supported by retrieved snippets, "
"their citations are verified, and they align with the selected path."
),
"partially_reliable": (
# The dimensions that fell short are listed underneath, so the
# summary points at them rather than saying "at least one", which
# left the reader to guess which one and why.
"Partially reliable: the retrieved literature supports the report's "
"claims, but the dimensions listed below did not reach the "
"threshold for a fully reliable rating."
),
"insufficient_evidence": (
"Insufficient evidence: the retrieved material does not support a "
"reliable report-level conclusion."
),
"not_evaluable": (
"Not evaluable: no factual statements were available for assessment."
),
}
return {
"score": score,
"level": level,
"summary": summaries[level],
"reasons": reasons,
}
def evaluate_rag_report(
report: Any,
retrieved_snippets: list[Any] | None,
pathway_graph: Any,
references: list[str] | None,
) -> dict[str, Any]:
"""Evaluate a generated RAG report at sentence level after LLM generation."""
statements = split_into_statements(report)
references = references or []
evidence_blocks = _normalize_evidence_blocks(retrieved_snippets, references)
results = []
supported_count = 0
direct_supported_count = 0
restated_supported_count = 0
unsupported_count = 0
unsupported_by_both_count = 0
any_supported_count = 0
graph_aligned_count = 0
graph_applicable_count = 0
graph_derived_count = 0
inference_count = 0
citation_checked = 0
citation_correct_count = 0
citation_unverifiable_count = 0
for idx, statement in enumerate(statements, 1):
evidence = match_supporting_evidence(statement, evidence_blocks)
graph_details = _graph_alignment_details(
statement,
pathway_graph,
evidence=evidence,
)
graph_status = bool(graph_details["aligned"])
explicit_inference = _is_inference_statement(statement)
direct_evidence_status = evidence is not None and not explicit_inference
# Secondary tier: the statement asserts no graph edge but closely
# restates a cited direct-evidence snippet (mechanism, conditions,
# instrumentation, observed phases). Grounded, so it is reported —
# but kept distinct from edge-level direct evidence in the metrics.
restated_evidence = None
if not direct_evidence_status and not explicit_inference:
restated_evidence = match_restated_evidence(statement, evidence_blocks)
if restated_evidence is None:
# Paraphrase-tolerant tier: judge the substance (which species,
# conditions and techniques are asserted) rather than the
# wording, so a genuinely rewritten synthesis is not discarded
# while an invented entity still is.
restated_evidence = match_entity_grounded_evidence(
statement, evidence_blocks
)
restated_status = restated_evidence is not None
grounded_evidence_status = direct_evidence_status or restated_status
graph_applicable_status = bool(
direct_evidence_status or explicit_inference or graph_status
)
if direct_evidence_status:
statement_type = "direct_evidence"
elif restated_status:
statement_type = "snippet_restatement"
elif graph_status:
statement_type = "graph_inference"
else:
statement_type = "unsupported"
# Both an edge claim and a close, entity-grounded restatement are
# supported by retrieved text. They remain separate categories so the
# report never presents mechanism prose as direct evidence for a graph
# edge. Graph alignment is reported separately and cannot inflate
# snippet-evidence coverage.
support_status = grounded_evidence_status
any_support_status = grounded_evidence_status or graph_status
graph_derived_status = graph_status and not grounded_evidence_status
unsupported_status = not grounded_evidence_status
unsupported_by_both_status = not any_support_status
citation_status = check_citation_support(
statement, evidence or restated_evidence, references, evidence_blocks
)
if grounded_evidence_status:
supported_count += 1
if direct_evidence_status:
direct_supported_count += 1
else:
restated_supported_count += 1
else:
unsupported_count += 1
if any_support_status:
any_supported_count += 1
if graph_derived_status:
graph_derived_count += 1
if unsupported_by_both_status:
unsupported_by_both_count += 1
cited_nums = _citation_nums(statement)
if cited_nums and citation_status is None:
citation_unverifiable_count += 1
elif citation_status is not None:
citation_checked += 1
if citation_status:
citation_correct_count += 1
if graph_applicable_status:
graph_applicable_count += 1
if graph_applicable_status and graph_status:
graph_aligned_count += 1
if statement_type in {"graph_inference", "unsupported"}:
inference_count += 1
cited_dois = []
for n in cited_nums:
for block in evidence_blocks:
if block.get("reference_index") != n:
continue
doi = block.get("doi")
if doi:
cited_dois.append(doi)
results.append({
"sentence_id": f"sent_{idx:04d}",
"statement": statement,
"support_status": support_status,
"grounded_evidence_status": grounded_evidence_status,
"any_support_status": any_support_status,
"direct_evidence_status": direct_evidence_status,
"restated_evidence_status": restated_status,
"supporting_evidence": (
evidence if direct_evidence_status else restated_evidence
),
"citation_status": citation_status,
"citation_verification_status": (
"verified_supported" if citation_status is True
else "verified_unsupported" if citation_status is False
else "unverifiable_no_snippet" if cited_nums
else "not_cited"
),
"cited_references": cited_nums,
"cited_dois": cited_dois,
"graph_alignment": graph_status,
"graph_alignment_applicable": graph_applicable_status,
"graph_alignment_status": (
"aligned"
if graph_applicable_status and graph_status
else "not_aligned"
if graph_applicable_status
else "not_applicable"
),
"graph_alignment_details": graph_details,
"graph_derived_status": graph_derived_status,
"graph_only_status": graph_derived_status,
"unsupported_status": unsupported_status,
"unsupported_by_both_status": unsupported_by_both_status,
"unsupported_by_both": unsupported_by_both_status,
"support_basis": (
"direct_snippet"
if direct_evidence_status
else "snippet_restatement"
if restated_status
else ("graph_only" if graph_status else "none")
),
"statement_type": statement_type,
})
total = len(statements)
citation_accuracy = citation_correct_count / citation_checked if citation_checked else None
coverage = supported_count / total if total else 0.0
direct_coverage = direct_supported_count / total if total else 0.0
snippet_count = sum(1 for block in evidence_blocks if _evidence_text(block))
all_inference_report = bool(
total
and all(
_is_inference_statement(item["statement"])
or bool(item["graph_derived_status"])
for item in results
)
)
manual_reasons = []
if snippet_count == 0:
manual_reasons.append("No snippet-level evidence was retrieved.")
if total == 0:
manual_reasons.append("No evaluable factual statements were found.")
elif supported_count == 0:
manual_reasons.append("No evaluated statement has direct snippet-level evidence.")
elif unsupported_count:
manual_reasons.append(
f"{unsupported_count} of {total} evaluated statements lack direct snippet-level evidence."
)
if all_inference_report:
manual_reasons.append("Every evaluated statement is marked or classified as inference.")
failed_citations = citation_checked - citation_correct_count
if failed_citations:
manual_reasons.append(
f"{failed_citations} cited statement(s) failed snippet-level citation support."
)
if citation_unverifiable_count:
manual_reasons.append(
f"{citation_unverifiable_count} cited statement(s) could not be verified "
"because no citation-linked snippet was retrieved."
)
if citation_checked and citation_unverifiable_count:
citation_verification_status = "partially_verifiable"
elif citation_checked and citation_correct_count == citation_checked:
citation_verification_status = "verified"
elif citation_checked and 0 < citation_correct_count < citation_checked:
citation_verification_status = "partially_supported"
elif citation_checked:
citation_verification_status = "unsupported"
elif citation_unverifiable_count:
citation_verification_status = "unverifiable"
else:
citation_verification_status = "not_applicable"
manual_verification = bool(manual_reasons)
manual_reason = " ".join(manual_reasons) if manual_reasons else (
"Direct snippet evidence was found for all evaluated statements; "
"no citation-support failure was detected."
)
graph_alignment = (
round(graph_aligned_count / graph_applicable_count, 3)
if graph_applicable_count
else 0.0
)
edge_evidence_coverage, endpoint_conversion_supported = (
_pathway_evidence_coverage(pathway_graph, evidence_blocks)
)
source_quality = _source_traceability_assessment(evidence_blocks, references)
reliability = _overall_reliability_assessment(
total=total,
snippet_count=snippet_count,
evidence_coverage=coverage,
citation_accuracy=citation_accuracy,
citation_checked=citation_checked,
citation_unverifiable_count=citation_unverifiable_count,
graph_alignment=graph_alignment,
graph_alignment_applicable_count=graph_applicable_count,
source_quality_score=source_quality["score"],
edge_evidence_coverage=edge_evidence_coverage,
endpoint_conversion_supported=endpoint_conversion_supported,
unsupported_count=unsupported_count,
)
return {
"total_statements": total,
"supported_statements": supported_count,
"grounded_statements": supported_count,
"direct_evidence_statements": direct_supported_count,
"restated_evidence_statements": restated_supported_count,
"unsupported_statements": unsupported_count,
"statements_without_direct_evidence": unsupported_count,
"unsupported_by_both_statements": unsupported_by_both_count,
"unsupported_by_both": unsupported_by_both_count,
"any_supported_statements": any_supported_count,
"evidence_coverage": round(coverage, 3),
"statement_evidence_coverage": round(coverage, 3),
"direct_evidence_coverage": round(direct_coverage, 3),
"any_support_coverage": round(any_supported_count / total, 3) if total else 0.0,
"citation_accuracy": round(citation_accuracy, 3) if citation_accuracy is not None else None,
"citation_checked_statements": citation_checked,
"citation_correct_statements": citation_correct_count,
"citation_unverifiable_statements": citation_unverifiable_count,
"citation_unverifiable_count": citation_unverifiable_count,
"citation_verification_status": citation_verification_status,
"graph_alignment": graph_alignment,
"graph_aligned_statements": graph_aligned_count,
"graph_alignment_applicable_statements": graph_applicable_count,
"graph_alignment_status": (
"evaluated" if graph_applicable_count else "not_applicable"
),
"graph_derived_statements": graph_derived_count,
"graph_only_statements": graph_derived_count,
"inference_statements": inference_count,
"inference_only_statements": inference_count,
"all_inference_report": all_inference_report,
"retrieved_snippet_count": snippet_count,
"edge_evidence_coverage": edge_evidence_coverage,
"endpoint_conversion_supported": endpoint_conversion_supported,
"source_quality_score": source_quality["score"],
"source_quality_level": source_quality["level"],
"source_quality_details": source_quality["details"],
"source_quality_basis": source_quality["basis"],
"overall_reliability_score": reliability["score"],
"overall_reliability_level": reliability["level"],
"overall_reliability_summary": reliability["summary"],
"overall_reliability_reasons": reliability["reasons"],
"manual_verification": manual_verification,
"manual_verification_required": manual_verification,
"manual_verification_status": "required" if manual_verification else "not_required",
"manual_verification_reason": manual_reason,
"sentence_level_results": results,
}