|
| 1 | +"""P2-E2 (GenAI lessons 13/14): groundedness spot-check. |
| 2 | +
|
| 3 | +Lesson 13 lists *output validation* among the four security-testing methods; |
| 4 | +lesson 14's Honesty/groundedness metric asks "does the answer follow from the |
| 5 | +supplied evidence?". This module provides a pure-mechanism spot-check: split a |
| 6 | +final answer into sentences, and for each sentence that makes an evidential |
| 7 | +claim, verify it is *supported* by the retrieved/injected evidence text. |
| 8 | +
|
| 9 | +Scoring (no LLM): a sentence is ``supported`` when a substantial fraction of |
| 10 | +its content tokens appear in the evidence; ``unsupported`` when it claims |
| 11 | +specific facts absent from the evidence. Optionally a caller can supply an |
| 12 | +LLM-as-judge callable for paraphrase-tolerant judgement (``judge_fn``) — the |
| 13 | +module stays mechanism-only by default. |
| 14 | +
|
| 15 | +Deliberately a *spot-check*: run on a sample or on critical decisions, never |
| 16 | +on every turn (lesson 14: cost control). |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import re |
| 22 | +from collections.abc import Callable |
| 23 | +from dataclasses import dataclass, field |
| 24 | + |
| 25 | +_STOPWORDS = frozenset( |
| 26 | + { |
| 27 | + "the", "a", "an", "and", "or", "but", "is", "are", "was", "were", |
| 28 | + "to", "of", "in", "on", "for", "with", "at", "by", "from", "as", |
| 29 | + "that", "this", "it", "its", "we", "our", "you", "your", "i", "me", |
| 30 | + "my", "be", "been", "being", "have", "has", "had", "do", "does", |
| 31 | + "did", "will", "would", "can", "could", "should", "not", "no", |
| 32 | + "yes", "so", "if", "then", "than", "there", "here", "which", "who", |
| 33 | + "when", "where", "why", "how", "all", "any", "both", "each", "few", |
| 34 | + "more", "most", "other", "some", "such", "only", "own", "same", |
| 35 | + } |
| 36 | +) |
| 37 | + |
| 38 | +_SENTENCE = re.compile( |
| 39 | + r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s+(?=[A-Z0-9])" |
| 40 | +) |
| 41 | +_WORD = re.compile(r"[a-z0-9']+") |
| 42 | + |
| 43 | +_SUPPORT_THRESHOLD = 0.5 # fraction of content tokens present in evidence |
| 44 | +_MIN_SENTENCE_TOKENS = 2 # ignore fragments like "42" or "Done." |
| 45 | + |
| 46 | + |
| 47 | +def _content_tokens(text: str) -> set[str]: |
| 48 | + return { |
| 49 | + w |
| 50 | + for w in _WORD.findall(str(text).lower()) |
| 51 | + if w not in _STOPWORDS and len(w) > 1 |
| 52 | + } |
| 53 | + |
| 54 | + |
| 55 | +def _split_sentences(text: str) -> list[str]: |
| 56 | + """Split on sentence boundaries, keeping 'src/parser.py.' intact. |
| 57 | +
|
| 58 | + Uses a lookbehind-boundary split (period/question/exclamation followed by |
| 59 | + whitespace + capital) instead of a naive character class, so dotted paths |
| 60 | + and abbreviations do not fragment into fake sentences. |
| 61 | + """ |
| 62 | + parts = re.split(_SENTENCE, str(text)) |
| 63 | + return [p.strip() for p in parts if p.strip()] |
| 64 | + |
| 65 | + |
| 66 | +@dataclass |
| 67 | +class GroundednessVerdict: |
| 68 | + """One sentence's support verdict.""" |
| 69 | + |
| 70 | + sentence: str |
| 71 | + supported: bool |
| 72 | + coverage: float |
| 73 | + reason: str = "" |
| 74 | + |
| 75 | + |
| 76 | +@dataclass |
| 77 | +class GroundednessReport: |
| 78 | + """Aggregate spot-check over an answer against its evidence.""" |
| 79 | + |
| 80 | + answer: str |
| 81 | + evidence: str |
| 82 | + verdicts: list[GroundednessVerdict] = field(default_factory=list) |
| 83 | + |
| 84 | + @property |
| 85 | + def supported_ratio(self) -> float: |
| 86 | + if not self.verdicts: |
| 87 | + return 0.0 |
| 88 | + return sum(1 for v in self.verdicts if v.supported) / len(self.verdicts) |
| 89 | + |
| 90 | + def unsupported_sentences(self) -> list[GroundednessVerdict]: |
| 91 | + return [v for v in self.verdicts if not v.supported] |
| 92 | + |
| 93 | + |
| 94 | +def check_groundedness( |
| 95 | + answer: str, |
| 96 | + evidence: str, |
| 97 | + *, |
| 98 | + threshold: float = _SUPPORT_THRESHOLD, |
| 99 | + judge_fn: Callable[[str, str], bool] | None = None, |
| 100 | +) -> GroundednessReport: |
| 101 | + """Split ``answer`` into sentences and judge each against ``evidence``. |
| 102 | +
|
| 103 | + ``judge_fn(sentence, evidence) -> bool`` lets a caller plug an |
| 104 | + LLM-as-judge for paraphrase-tolerant checks; when absent the default |
| 105 | + token-coverage heuristic runs (pure mechanism, zero cost). |
| 106 | + """ |
| 107 | + answer = str(answer or "") |
| 108 | + evidence = str(evidence or "") |
| 109 | + evidence_tokens = _content_tokens(evidence) |
| 110 | + report = GroundednessReport(answer=answer, evidence=evidence) |
| 111 | + |
| 112 | + for sentence in _split_sentences(answer): |
| 113 | + if judge_fn is not None: |
| 114 | + try: |
| 115 | + supported = bool(judge_fn(sentence, evidence)) |
| 116 | + except Exception: # noqa: BLE001 - judge failure is a soft miss |
| 117 | + supported = False |
| 118 | + report.verdicts.append( |
| 119 | + GroundednessVerdict( |
| 120 | + sentence=sentence, |
| 121 | + supported=supported, |
| 122 | + coverage=1.0 if supported else 0.0, |
| 123 | + reason="judge_fn" if supported else "judge_fn (failed or false)", |
| 124 | + ) |
| 125 | + ) |
| 126 | + continue |
| 127 | + tokens = _content_tokens(sentence) |
| 128 | + if len(tokens) < _MIN_SENTENCE_TOKENS: |
| 129 | + continue # non-evidential fragment (e.g. a bare number) |
| 130 | + present = sum(1 for t in tokens if t in evidence_tokens) |
| 131 | + coverage = present / len(tokens) |
| 132 | + supported = coverage >= threshold |
| 133 | + report.verdicts.append( |
| 134 | + GroundednessVerdict( |
| 135 | + sentence=sentence, |
| 136 | + supported=supported, |
| 137 | + coverage=round(coverage, 3), |
| 138 | + reason=( |
| 139 | + f"{present}/{len(tokens)} content tokens in evidence" |
| 140 | + if supported |
| 141 | + else ( |
| 142 | + f"only {present}/{len(tokens)} content tokens in " |
| 143 | + "evidence; facts may be fabricated" |
| 144 | + ) |
| 145 | + ), |
| 146 | + ) |
| 147 | + ) |
| 148 | + return report |
| 149 | + |
| 150 | + |
| 151 | +__all__ = [ |
| 152 | + "GroundednessReport", |
| 153 | + "GroundednessVerdict", |
| 154 | + "check_groundedness", |
| 155 | +] |
0 commit comments