File size: 3,269 Bytes
7ff7119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
"""quote_validator_node — anti-hallucination layer #7.

Validates the LLM-provided ``_quotes`` field against the source ``full_text``.
If a quote does not appear in the source (after normalization: whitespace +
diacritics + case folding), the LLM hallucinated → confidence is downgraded
to "low" and a risk is logged.

Original prototype-agentic system did not have this check; we add it here
as an explicit node.
"""

from __future__ import annotations

from graph.states.pipeline_state import ProcessedDocument, Risk
from validation.quote_validator import downgrade_confidence, validate_quotes


async def quote_validator_node(state: dict) -> dict:
    """Walk the documents list and validate each doc's _quotes field.

    Returns ``{"documents": [pd_updated], "risks": [risk_for_invalid]}``.
    The merge_doc_results and merge_risks reducers upsert into the parent state.

    NB: this node runs in the parent pipeline_graph, NOT inside extract_subgraph
    (after the Send fan-in, so we see all docs' extracted data together).
    """
    documents: list[ProcessedDocument] = state.get("documents") or []
    if not documents:
        return {}

    updated_docs: list[ProcessedDocument] = []
    new_risks: list[Risk] = []

    for pd in documents:
        if pd.extracted is None or pd.ingested is None:
            updated_docs.append(pd)
            continue

        full_text = pd.ingested.full_text or ""
        valid, invalid = validate_quotes(pd.extracted.raw, full_text)

        if invalid:
            # Downgrade confidence on invalid quotes
            new_raw = downgrade_confidence(dict(pd.extracted.raw), invalid)
            new_extracted = pd.extracted.model_copy(update={
                "raw": new_raw,
                "confidence": new_raw.get("_confidence", {}),
            })
            updated_docs.append(pd.model_copy(update={"extracted": new_extracted}))

            # Only emit a "low" severity flag if the proportion of invalid quotes
            # is significant (>= 50%). Stochastic LLM paraphrasing alone does
            # not warrant a flag.
            valid, _ = validate_quotes(pd.extracted.raw, full_text)
            total = len(invalid) + len(valid)
            invalid_ratio = len(invalid) / max(1, total)
            if invalid_ratio >= 0.5:
                new_risks.append(Risk(
                    description=(
                        f"{pd.ingested.file_name}: {len(invalid)}/{total} quote(s) not found "
                        "in the source document (suspected LLM hallucination)."
                    ),
                    severity="low",
                    rationale=(
                        "The schema-level ``_quotes`` field contains text that does not appear "
                        "in the normalized full_text. Affected fields' confidence has been "
                        "downgraded to 'low'."
                    ),
                    kind="validation",
                    affected_document=pd.ingested.file_name,
                    source_check_id="quote_validator",
                ))
        else:
            updated_docs.append(pd)

    out: dict = {}
    if updated_docs:
        out["documents"] = updated_docs
    if new_risks:
        out["risks"] = new_risks
    return out