upheld 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
upheld/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ from upheld.auditor import Auditor, audit
2
+ from upheld.models import AuditReport, AuditRequest, CitedSpan, Source, Verdict
3
+ from upheld.pipeline import run_audit
4
+ from upheld.providers import Provider, from_anthropic, from_gemini, from_openai, from_perplexity, from_response
5
+ from upheld.verify import EnsembleVerifier, GraniteJudgeVerifier, LettuceVerifier, LexicalVerifier, MiniCheckVerifier, make_verifier
6
+
7
+ __all__ = [
8
+ "Auditor",
9
+ "EnsembleVerifier",
10
+ "GraniteJudgeVerifier",
11
+ "MiniCheckVerifier",
12
+ "AuditReport",
13
+ "AuditRequest",
14
+ "CitedSpan",
15
+ "LettuceVerifier",
16
+ "LexicalVerifier",
17
+ "Provider",
18
+ "Source",
19
+ "Verdict",
20
+ "audit",
21
+ "from_anthropic",
22
+ "from_gemini",
23
+ "from_openai",
24
+ "from_perplexity",
25
+ "from_response",
26
+ "make_verifier",
27
+ "run_audit",
28
+ ]
upheld/api.py ADDED
@@ -0,0 +1,69 @@
1
+ import asyncio
2
+ import os
3
+ from contextlib import asynccontextmanager, nullcontext
4
+ from importlib.metadata import version
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ from fastapi import Body, FastAPI, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+ from upheld.auditor import to_request
13
+ from upheld.evidence import make_embedder, retrieval_name
14
+ from upheld.models import AuditReport, AuditRequest
15
+ from upheld.pipeline import run_audit
16
+ from upheld.providers import Provider
17
+ from upheld.verify import make_verifier
18
+
19
+ _state: dict = {}
20
+
21
+ # The built website (web/dist). Served at / when present, so `upheld serve` hosts the
22
+ # landing page and the playground next to the API.
23
+ SITE_DIR = Path(os.environ.get("UPHELD_SITE_DIR", Path(__file__).resolve().parents[2] / "web" / "dist"))
24
+
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(app: FastAPI):
28
+ # Load the models once at startup, not per request.
29
+ _state["verifier"] = make_verifier(os.environ.get("UPHELD_VERIFIER", "ensemble"))
30
+ _state["embedder"] = make_embedder(os.environ.get("UPHELD_RETRIEVAL", "auto"))
31
+ # A public deployment can cap concurrent audits so requests queue instead of
32
+ # competing for one model on a small machine.
33
+ limit = int(os.environ.get("UPHELD_MAX_CONCURRENT", "0"))
34
+ _state["slots"] = asyncio.Semaphore(limit) if limit > 0 else None
35
+ yield
36
+ _state.clear()
37
+
38
+
39
+ app = FastAPI(title="upheld", version=version("upheld"), lifespan=lifespan)
40
+
41
+ # Origins allowed to call the API from a browser, comma-separated, e.g. a static
42
+ # deploy of the website on another domain.
43
+ if origins := [o.strip() for o in os.environ.get("UPHELD_CORS_ORIGINS", "").split(",") if o.strip()]:
44
+ app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["GET", "POST"], allow_headers=["Content-Type"])
45
+
46
+
47
+ @app.get("/health")
48
+ async def health() -> dict[str, str]:
49
+ return {"status": "ok", "verifier": _state["verifier"].name, "retrieval": retrieval_name(_state["embedder"])}
50
+
51
+
52
+ @app.post("/audit")
53
+ async def audit(req: AuditRequest) -> AuditReport:
54
+ async with _state["slots"] or nullcontext():
55
+ return await run_audit(req, _state["verifier"], embedder=_state["embedder"])
56
+
57
+
58
+ @app.post("/audit/{provider}")
59
+ async def audit_response(provider: Provider, response: Annotated[dict[str, Any], Body()], question: str | None = None) -> AuditReport:
60
+ """Audit a provider's API response as it came back. auto tells the provider from the response's shape."""
61
+ try:
62
+ req = to_request(response, question=question, provider=provider)
63
+ except ValueError as e:
64
+ raise HTTPException(422, str(e)) from e
65
+ return await audit(req)
66
+
67
+
68
+ if SITE_DIR.is_dir():
69
+ app.mount("/", StaticFiles(directory=SITE_DIR, html=True), name="site")
upheld/auditor.py ADDED
@@ -0,0 +1,124 @@
1
+ """Audit from Python in one call: an answer with its sources, or a provider's whole response."""
2
+
3
+ import asyncio
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from functools import lru_cache
6
+ from typing import Any, Coroutine, TypeVar
7
+
8
+ from upheld.evidence import make_embedder
9
+ from upheld.models import AuditReport, AuditRequest, Source
10
+ from upheld.pipeline import run_audit
11
+ from upheld.providers import Provider, from_response
12
+ from upheld.verify import Verifier, make_verifier
13
+
14
+ T = TypeVar("T")
15
+
16
+ SourcesArg = list[Source | dict | str] | None
17
+
18
+
19
+ class Auditor:
20
+ """A verifier and retrieval model, loaded once and reused for every audit.
21
+
22
+ The default ensemble takes a few seconds and about 4 GB of memory to load,
23
+ so make one Auditor and keep it. `audit` blocks; `audit_async` is for code
24
+ that already runs an event loop, such as a web server.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ verifier: str | Verifier = "ensemble",
30
+ *,
31
+ threshold: float | None = None,
32
+ retrieval: str = "auto",
33
+ device: str | None = None,
34
+ ):
35
+ self.verifier = make_verifier(verifier, threshold, device) if isinstance(verifier, str) else verifier
36
+ self.embedder = make_embedder(retrieval)
37
+
38
+ def audit(
39
+ self,
40
+ answer: Any,
41
+ sources: SourcesArg = None,
42
+ *,
43
+ question: str | None = None,
44
+ provider: Provider | str = Provider.AUTO,
45
+ fetch: bool = True,
46
+ allow_private: bool = False,
47
+ ) -> AuditReport:
48
+ return _run(self.audit_async(answer, sources, question=question, provider=provider, fetch=fetch, allow_private=allow_private))
49
+
50
+ async def audit_async(
51
+ self,
52
+ answer: Any,
53
+ sources: SourcesArg = None,
54
+ *,
55
+ question: str | None = None,
56
+ provider: Provider | str = Provider.AUTO,
57
+ fetch: bool = True,
58
+ allow_private: bool = False,
59
+ ) -> AuditReport:
60
+ req = to_request(answer, sources, question=question, provider=provider)
61
+ return await run_audit(req, self.verifier, embedder=self.embedder, fetch=fetch, allow_private=allow_private)
62
+
63
+
64
+ def audit(
65
+ answer: Any,
66
+ sources: SourcesArg = None,
67
+ *,
68
+ question: str | None = None,
69
+ verifier: str = "ensemble",
70
+ threshold: float | None = None,
71
+ retrieval: str = "auto",
72
+ provider: Provider | str = Provider.AUTO,
73
+ fetch: bool = True,
74
+ allow_private: bool = False,
75
+ ) -> AuditReport:
76
+ """Audit one answer and return its report.
77
+
78
+ `answer` is the answer's text with [1]-style markers or inline links, an
79
+ AuditRequest, or a provider's response (see upheld.providers). `sources`
80
+ go with text: each a Source, a dict, or a string, which is a URL if it
81
+ starts with http:// or https:// and the source's text otherwise, numbered
82
+ from 1. The models load on the first call and stay loaded for the next
83
+ call with the same verifier, threshold and retrieval.
84
+ """
85
+ auditor = _auditor(verifier, threshold, retrieval)
86
+ return auditor.audit(answer, sources, question=question, provider=provider, fetch=fetch, allow_private=allow_private)
87
+
88
+
89
+ @lru_cache(maxsize=1)
90
+ def _auditor(verifier: str, threshold: float | None, retrieval: str) -> Auditor:
91
+ return Auditor(verifier, threshold=threshold, retrieval=retrieval)
92
+
93
+
94
+ def to_request(answer: Any, sources: SourcesArg = None, *, question: str | None = None, provider: Provider | str = Provider.AUTO) -> AuditRequest:
95
+ """Turn what `audit` accepts into an AuditRequest."""
96
+ if isinstance(answer, str):
97
+ return AuditRequest(answer=answer, question=question, sources=[_source(s, n) for n, s in enumerate(sources or [], 1)])
98
+ if sources:
99
+ raise ValueError("sources go with an answer's text; a request or a provider response carries its own")
100
+ if isinstance(answer, AuditRequest):
101
+ answer = answer.model_dump()
102
+ if isinstance(answer, dict) and "answer" in answer and provider == Provider.AUTO:
103
+ return AuditRequest.model_validate({**answer, "question": question} if question else answer)
104
+ return from_response(answer, provider, question=question)
105
+
106
+
107
+ def _source(source: Source | dict | str, n: int) -> Source:
108
+ if isinstance(source, Source):
109
+ return source
110
+ if isinstance(source, dict):
111
+ return Source.model_validate({"id": str(n), **source})
112
+ if source.startswith(("http://", "https://")):
113
+ return Source(id=str(n), url=source)
114
+ return Source(id=str(n), text=source)
115
+
116
+
117
+ def _run(coro: Coroutine[Any, Any, T]) -> T:
118
+ """Run a coroutine to completion, also from inside a running event loop (a Jupyter notebook, say)."""
119
+ try:
120
+ asyncio.get_running_loop()
121
+ except RuntimeError:
122
+ return asyncio.run(coro)
123
+ with ThreadPoolExecutor(max_workers=1) as pool:
124
+ return pool.submit(asyncio.run, coro).result()
upheld/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ import asyncio
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from upheld.auditor import to_request
12
+ from upheld.evals import load_examples, metrics_at, score_examples
13
+ from upheld.evidence import make_embedder
14
+ from upheld.models import AuditReport, AuditRequest, Verdict
15
+ from upheld.pipeline import run_audit
16
+ from upheld.providers import Provider
17
+ from upheld.verify import make_verifier
18
+
19
+ app = typer.Typer(no_args_is_help=True, help="Check whether an AI answer's citations actually support its claims.")
20
+ console = Console()
21
+
22
+ VERDICT_STYLE = {
23
+ Verdict.SUPPORTED: "green",
24
+ Verdict.UNSUPPORTED: "red",
25
+ Verdict.WRONG_ENTITY: "magenta",
26
+ Verdict.SOURCE_MISSING: "red bold",
27
+ Verdict.UNVERIFIABLE: "yellow",
28
+ Verdict.UNCITED: "dim",
29
+ }
30
+
31
+ VerifierOpt = Annotated[
32
+ str,
33
+ typer.Option(
34
+ help="ensemble (MiniCheck, word overlap and LettuceDetect combined), ensemble+judge (adds the Granite Guardian 3.3 8B judge: most accurate, slower, needs the judge extra), minicheck (MiniCheck-Flan-T5), lettuce (LettuceDetect), lexical (word overlap, no model), or judge (the Granite judge alone)."
35
+ ),
36
+ ]
37
+ ThresholdOpt = Annotated[float | None, typer.Option(help="Support score below this counts as unsupported.")]
38
+ RetrievalOpt = Annotated[
39
+ str, typer.Option(help="How to find evidence in each source: hybrid (BM25 + embeddings), bm25, or auto (hybrid if installed).")
40
+ ]
41
+
42
+
43
+ @app.command()
44
+ def audit(
45
+ path: Annotated[
46
+ Path,
47
+ typer.Argument(help="Answer as .json (an AuditRequest, or a provider's response), or markdown/text with a reference list. Use - for stdin."),
48
+ ],
49
+ question: Annotated[str | None, typer.Option(help="The question the answer responds to.")] = None,
50
+ provider: Annotated[
51
+ Provider,
52
+ typer.Option(
53
+ "--from",
54
+ help="Whose API response the JSON is: openai, anthropic, perplexity or gemini. auto tells them apart by shape, and reads JSON with an `answer` as an AuditRequest.",
55
+ ),
56
+ ] = Provider.AUTO,
57
+ verifier: VerifierOpt = "ensemble",
58
+ threshold: ThresholdOpt = None,
59
+ retrieval: RetrievalOpt = "auto",
60
+ fetch: Annotated[bool, typer.Option(help="Fetch cited URLs. Off means only inline source text is used.")] = True,
61
+ allow_private: Annotated[bool, typer.Option(help="Allow fetching localhost and private-network URLs.")] = False,
62
+ as_json: Annotated[bool, typer.Option("--json", help="Print the full report as JSON.")] = False,
63
+ ) -> None:
64
+ """Audit one answer."""
65
+ text = sys.stdin.read() if str(path) == "-" else path.read_text()
66
+ try:
67
+ if path.suffix == ".json" or provider != Provider.AUTO or (str(path) == "-" and text.lstrip().startswith("{")):
68
+ req = to_request(json.loads(text), question=question, provider=provider)
69
+ else:
70
+ req = AuditRequest(answer=text, question=question)
71
+ except ValueError as e: # bad JSON, or not a response upheld can read
72
+ console.print(f"[red]error:[/] {e}")
73
+ raise typer.Exit(1) from e
74
+ report = asyncio.run(
75
+ run_audit(
76
+ req, make_verifier(verifier, threshold), embedder=make_embedder(retrieval), fetch=fetch, allow_private=allow_private
77
+ )
78
+ )
79
+ if as_json:
80
+ print(report.model_dump_json(indent=2))
81
+ else:
82
+ render(report)
83
+
84
+
85
+ @app.command("eval")
86
+ def evaluate(
87
+ path: Annotated[Path, typer.Argument(help="Labeled JSONL: claim, evidence, label (supported|unsupported).")],
88
+ verifier: VerifierOpt = "ensemble",
89
+ threshold: ThresholdOpt = None,
90
+ ) -> None:
91
+ """Measure a verifier against human labels, with a threshold sweep."""
92
+ examples = load_examples(path)
93
+ v = make_verifier(verifier, threshold)
94
+ scores = score_examples(examples, v)
95
+
96
+ table = Table(title=f"{v.name} on {path.name} (n={len(examples)}, positive class = unsupported)")
97
+ for col in ("threshold", "precision", "recall", "F1", "macro-F1", "accuracy", "flagged"):
98
+ table.add_column(col, justify="right")
99
+ for t in sorted({0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, v.threshold}):
100
+ m = metrics_at(t, scores, examples)
101
+ style = "bold" if t == v.threshold else None
102
+ table.add_row(
103
+ f"{t:.2f}",
104
+ f"{m.precision:.3f}",
105
+ f"{m.recall:.3f}",
106
+ f"{m.f1:.3f}",
107
+ f"{m.macro_f1:.3f}",
108
+ f"{m.accuracy:.3f}",
109
+ f"{m.flagged_rate:.1%}",
110
+ style=style,
111
+ )
112
+ console.print(table)
113
+
114
+
115
+ @app.command()
116
+ def serve(
117
+ host: str = "127.0.0.1",
118
+ port: int = 8000,
119
+ verifier: VerifierOpt = "ensemble",
120
+ retrieval: RetrievalOpt = "auto",
121
+ ) -> None:
122
+ """Run the HTTP API, plus the website and playground when web/dist is built."""
123
+ import os
124
+
125
+ try:
126
+ import fastapi # noqa: F401
127
+ import uvicorn
128
+ except ImportError as e:
129
+ console.print(f'[red]error:[/] upheld serve needs the api extra ({e.name} is missing): pip install "upheld\\[api]"')
130
+ raise typer.Exit(1) from e
131
+
132
+ os.environ["UPHELD_VERIFIER"] = verifier
133
+ os.environ["UPHELD_RETRIEVAL"] = retrieval
134
+ uvicorn.run("upheld.api:app", host=host, port=port)
135
+
136
+
137
+ def render(report: AuditReport) -> None:
138
+ s = report.summary
139
+ console.print(f"[bold]verifier[/] {report.verifier} · [bold]retrieval[/] {report.retrieval}")
140
+ console.print(
141
+ f"[bold]{s.supported_claims}/{s.cited_claims}[/] cited claims supported · "
142
+ f"{s.citations_checked} citations checked · {s.claim_verdicts.get('uncited', 0)} uncited claims\n"
143
+ )
144
+
145
+ claims = Table(show_lines=True, expand=True)
146
+ claims.add_column("#", justify="right", width=3)
147
+ claims.add_column("verdict", width=14)
148
+ claims.add_column("claim", ratio=3)
149
+ claims.add_column("details", ratio=2)
150
+ for c in report.claims:
151
+ details = []
152
+ for check in c.checks:
153
+ score = f" {check.support_score:.2f}" if check.support_score is not None else ""
154
+ line = f"[{check.source_id}] [{VERDICT_STYLE[check.verdict]}]{check.verdict.value}[/]{score}"
155
+ if check.missing_entities:
156
+ line += f" · not in source: {', '.join(check.missing_entities)}"
157
+ if check.unsupported_spans:
158
+ line += " · flagged: " + "; ".join(f'"{sp.text.strip()}"' for sp in check.unsupported_spans)
159
+ if check.note:
160
+ line += f" · {check.note}"
161
+ details.append(line)
162
+ if c.note:
163
+ details.append(f"[dim]{c.note}[/]")
164
+ claims.add_row(str(c.index), f"[{VERDICT_STYLE[c.verdict]}]{c.verdict.value}[/]", c.text, "\n".join(details))
165
+ console.print(claims)
166
+
167
+ sources = Table(title="sources", expand=True)
168
+ for col in ("id", "status", "url / note"):
169
+ sources.add_column(col)
170
+ for src in report.sources:
171
+ resolved = f"→ {src.resolved_url}" if src.resolved_url else None
172
+ sources.add_row(src.id, src.status.value, " · ".join(x for x in (src.url, resolved, src.note) if x))
173
+ console.print(sources)
upheld/entities.py ADDED
@@ -0,0 +1,68 @@
1
+ """Catch "deceptive grounding": evidence that supports the claim's shape but is about a different entity.
2
+
3
+ Support verifiers judge whether a passage backs a statement; they can pass a
4
+ claim about drug X when the passage describes drug Y. This check is cheap and
5
+ high precision: it flags named entities from the claim that appear nowhere in
6
+ the cited source.
7
+ """
8
+
9
+ import re
10
+
11
+ from wordfreq import zipf_frequency
12
+
13
+ COMMON_ZIPF = 4.0 # roughly: appears at least 10 times per million words
14
+
15
+ _NAME = r"[A-Z][\w'’-]*(?:\s+\d+\b)?" # "Ozempic", "GPT-4o", "STEP 1", "Windows 11"
16
+ _CAPITALIZED_RUN = re.compile(rf"\b{_NAME}(?:\s+(?:(?:of|de|the|and|&)\s+)?{_NAME})*")
17
+ _CONNECTORS = frozenset("of de the and &".split())
18
+ _NOT_ENTITIES = frozenset(
19
+ "The A An In On At By For From To Of And But Or If As It Its This That These Those There Their They He She We You I "
20
+ "However Also According Although While When Where Which Who What Why How After Before During Since Despite "
21
+ "Without With Under Over Between Among Through Unlike Like Following Because Unless Until Though Thus Therefore "
22
+ "Both Each Every Many Most Some Several Other Another All Any Such Not No Only Even Then Meanwhile Instead Here Today "
23
+ "January February March April May June July August September October November December "
24
+ "Monday Tuesday Wednesday Thursday Friday Saturday Sunday".split()
25
+ )
26
+
27
+
28
+ _EDGE_PUNCT = re.compile(r"^['‘’\"“”]+|(?:['’]s)?['‘’\"“”]*$") # quotes and possessive 's: "Charles II's" -> "Charles II"
29
+
30
+
31
+ def named_entities(claim: str) -> list[str]:
32
+ found: list[str] = []
33
+ # Where the sentence's first word starts, after any bullet, quote or markdown.
34
+ first_word = next((i for i, ch in enumerate(claim) if ch.isalnum()), 0)
35
+ for m in _CAPITALIZED_RUN.finditer(claim):
36
+ words = [_EDGE_PUNCT.sub("", w) for w in m.group().split()]
37
+ words = [w for w in words if w]
38
+ lead = 0
39
+ while words and (words[0] in _NOT_ENTITIES or words[0] in _CONNECTORS):
40
+ words.pop(0)
41
+ lead += 1
42
+ while words and (words[-1] in _NOT_ENTITIES or words[-1] in _CONNECTORS):
43
+ words.pop()
44
+ if not words:
45
+ continue
46
+ # A lone capitalized word at sentence start may just be grammar
47
+ # ("Researchers found..."). Keep it only if it's an uncommon word
48
+ # (Ozempic, Nvidia) or looks like a name (GPT-4o, NASA).
49
+ name = " ".join(words)
50
+ at_start = m.start() <= first_word and lead == 0
51
+ if at_start and len(words) == 1 and not re.search(r"\d|[A-Z].*[A-Z]|-", name):
52
+ if zipf_frequency(name.lower(), "en") >= COMMON_ZIPF:
53
+ continue
54
+ found.append(name)
55
+ return list(dict.fromkeys(found))
56
+
57
+
58
+ def _present(entity: str, source_text: str) -> bool:
59
+ haystack = source_text.lower()
60
+ if entity.lower() in haystack:
61
+ return True
62
+ # Accept the head word for multi-word names ("Marie Curie" -> "Curie").
63
+ last = entity.split()[-1].lower()
64
+ return len(last) > 3 and re.search(rf"\b{re.escape(last)}\b", haystack) is not None
65
+
66
+
67
+ def missing_entities(claim: str, source_text: str) -> list[str]:
68
+ return [e for e in named_entities(claim) if not _present(e, source_text)]
upheld/evals.py ADDED
@@ -0,0 +1,91 @@
1
+ """Measure a verifier against human labels.
2
+
3
+ Input is JSONL, one labeled citation per line:
4
+ {"claim": "...", "evidence": "..." | ["...", ...], "label": "supported" | "unsupported",
5
+ "answer": "..."?, "question": "..."?}
6
+
7
+ `answer` is the full response the claim came from (the claim must appear in
8
+ it verbatim). Give it when you have it: verifiers trained on full responses
9
+ judge claims better in context.
10
+
11
+ "unsupported" is the positive class, since catching bad citations is the job.
12
+ """
13
+
14
+ import json
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from upheld.verify import Verifier
19
+
20
+
21
+ @dataclass
22
+ class Example:
23
+ claim: str
24
+ evidence: list[str]
25
+ unsupported: bool
26
+ question: str | None = None
27
+ answer: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class Metrics:
32
+ threshold: float
33
+ n: int
34
+ precision: float # precision, recall and f1 are for the unsupported class
35
+ recall: float
36
+ f1: float
37
+ macro_f1: float # mean F1 over both classes, as AttributionBench reports
38
+ accuracy: float
39
+ flagged_rate: float
40
+
41
+
42
+ def load_examples(path: Path) -> list[Example]:
43
+ examples = []
44
+ for line_no, line in enumerate(path.read_text().splitlines(), 1):
45
+ if not line.strip():
46
+ continue
47
+ row = json.loads(line)
48
+ if row["label"] not in ("supported", "unsupported"):
49
+ raise ValueError(f"line {line_no}: label must be 'supported' or 'unsupported'")
50
+ evidence = row["evidence"] if isinstance(row["evidence"], list) else [row["evidence"]]
51
+ answer = row.get("answer")
52
+ if answer is not None and row["claim"] not in answer:
53
+ raise ValueError(f"line {line_no}: claim does not appear verbatim in answer")
54
+ examples.append(Example(row["claim"], evidence, row["label"] == "unsupported", row.get("question"), answer))
55
+ return examples
56
+
57
+
58
+ def score_examples(examples: list[Example], verifier: Verifier) -> list[float]:
59
+ scores = []
60
+ for e in examples:
61
+ answer = e.answer or e.claim
62
+ start = answer.index(e.claim)
63
+ [result] = verifier.check(answer, [(start, start + len(e.claim))], e.evidence, e.question)
64
+ scores.append(result.score)
65
+ return scores
66
+
67
+
68
+ def metrics_at(threshold: float, scores: list[float], examples: list[Example]) -> Metrics:
69
+ return metrics_for([s < threshold for s in scores], examples, threshold)
70
+
71
+
72
+ def metrics_for(flagged: list[bool], examples: list[Example], threshold: float) -> Metrics:
73
+ """Metrics for a list of unsupported/supported decisions, one per example."""
74
+ actual = [e.unsupported for e in examples]
75
+ n = len(examples)
76
+ f1, precision, recall = _f1([f and a for f, a in zip(flagged, actual)], flagged, actual)
77
+ f1_supported, _, _ = _f1(
78
+ [not f and not a for f, a in zip(flagged, actual)], [not f for f in flagged], [not a for a in actual]
79
+ )
80
+ correct = sum(f == a for f, a in zip(flagged, actual))
81
+ return Metrics(
82
+ threshold, n, precision, recall, f1, (f1 + f1_supported) / 2, correct / n if n else 0.0, sum(flagged) / n if n else 0.0
83
+ )
84
+
85
+
86
+ def _f1(true_positive: list[bool], predicted: list[bool], actual: list[bool]) -> tuple[float, float, float]:
87
+ tp = sum(true_positive)
88
+ precision = tp / sum(predicted) if sum(predicted) else 0.0
89
+ recall = tp / sum(actual) if sum(actual) else 0.0
90
+ f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
91
+ return f1, precision, recall
upheld/evidence.py ADDED
@@ -0,0 +1,123 @@
1
+ """Find the passages in a source that are most relevant to a claim.
2
+
3
+ Keyword search (BM25) misses paraphrases: an answer says "intended to stand
4
+ for only 20 years" where the source says "slated for dismantling in 1909".
5
+ Hybrid mode adds a small embedding model and merges the two rankings with
6
+ reciprocal rank fusion, so a passage either method ranks highly makes the cut.
7
+ """
8
+
9
+ import re
10
+ from typing import Protocol
11
+
12
+ import numpy as np
13
+ from rank_bm25 import BM25Okapi
14
+
15
+ STOPWORDS = frozenset(
16
+ "a an and are as at be been but by for from has have in is it its of on or that the this to was were will with "
17
+ "which who what when where how than then there their they them these those not no can could would should".split()
18
+ )
19
+ _WORD = re.compile(r"[a-z0-9]+(?:[.,'][a-z0-9]+)*")
20
+
21
+
22
+ def tokenize(text: str) -> list[str]:
23
+ return [w for w in _WORD.findall(text.lower()) if w not in STOPWORDS]
24
+
25
+
26
+ def chunk(text: str, size: int = 120, overlap: int = 30) -> list[str]:
27
+ """Split text into overlapping word windows, starting each window at a sentence boundary where possible."""
28
+ sentences = re.split(r"(?<=[.!?])\s+", text.strip())
29
+ chunks: list[str] = []
30
+ current: list[str] = []
31
+ for sentence in sentences:
32
+ words = sentence.split()
33
+ if current and len(current) + len(words) > size:
34
+ chunks.append(" ".join(current))
35
+ current = current[-overlap:] if overlap else []
36
+ current.extend(words)
37
+ if current:
38
+ chunks.append(" ".join(current))
39
+ return chunks
40
+
41
+
42
+ class Embedder(Protocol):
43
+ name: str
44
+
45
+ def embed(self, texts: list[str], *, query: bool = False) -> np.ndarray:
46
+ """Return one L2-normalized vector per text."""
47
+ ...
48
+
49
+
50
+ class SentenceEmbedder:
51
+ DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
52
+ # bge v1.5 recommends this prefix on short retrieval queries.
53
+ BGE_QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
54
+
55
+ def __init__(self, model_name: str = DEFAULT_MODEL):
56
+ try:
57
+ from sentence_transformers import SentenceTransformer
58
+ except ImportError as e:
59
+ raise ImportError('hybrid retrieval needs the optional extra: pip install "upheld[dense]"') from e
60
+ self.model = SentenceTransformer(model_name)
61
+ self.name = model_name.rsplit("/", 1)[-1]
62
+ self.query_prefix = self.BGE_QUERY_PREFIX if "bge" in model_name.lower() else ""
63
+
64
+ def embed(self, texts: list[str], *, query: bool = False) -> np.ndarray:
65
+ if query:
66
+ texts = [self.query_prefix + t for t in texts]
67
+ return self.model.encode(texts, normalize_embeddings=True, batch_size=32, show_progress_bar=False)
68
+
69
+
70
+ def make_embedder(retrieval: str) -> Embedder | None:
71
+ """`bm25` -> None, `hybrid` -> embedding model, `auto` -> hybrid if the dense extra is installed."""
72
+ if retrieval == "bm25":
73
+ return None
74
+ if retrieval == "hybrid":
75
+ return SentenceEmbedder()
76
+ if retrieval == "auto":
77
+ try:
78
+ return SentenceEmbedder()
79
+ except ImportError:
80
+ return None
81
+ raise ValueError(f"unknown retrieval: {retrieval} (choose auto, bm25 or hybrid)")
82
+
83
+
84
+ def retrieval_name(embedder: Embedder | None) -> str:
85
+ return "bm25" if embedder is None else f"hybrid:bm25+{embedder.name}"
86
+
87
+
88
+ def rrf(rankings: list[list[int]], k: int = 60) -> list[tuple[int, float]]:
89
+ """Reciprocal rank fusion: score each item by the sum of 1 / (k + rank) across rankings."""
90
+ scores: dict[int, float] = {}
91
+ for ranking in rankings:
92
+ for rank, item in enumerate(ranking, 1):
93
+ scores[item] = scores.get(item, 0.0) + 1 / (k + rank)
94
+ return sorted(scores.items(), key=lambda pair: pair[1], reverse=True)
95
+
96
+
97
+ class SourceIndex:
98
+ """One source's passages, indexed once and searched once per claim."""
99
+
100
+ def __init__(self, text: str, embedder: Embedder | None = None):
101
+ self.passages = chunk(text)
102
+ self.embedder = embedder
103
+ self._bm25 = BM25Okapi([tokenize(p) for p in self.passages]) if self.passages else None
104
+ self._vectors: np.ndarray | None = None
105
+
106
+ def search(self, query: str, k: int = 3) -> list[tuple[str, float]]:
107
+ """Top-k passages with scores scaled so the best is 1.0."""
108
+ if len(self.passages) <= k:
109
+ return [(p, 1.0) for p in self.passages]
110
+ scores = self._bm25.get_scores(tokenize(query))
111
+ order = [int(i) for i in np.argsort(-scores)]
112
+ if self.embedder is None:
113
+ ranked = [(i, float(scores[i])) for i in order[:k]]
114
+ else:
115
+ if self._vectors is None:
116
+ self._vectors = self.embedder.embed(self.passages)
117
+ query_vector = self.embedder.embed([query], query=True)[0]
118
+ dense = [int(i) for i in np.argsort(-(self._vectors @ query_vector))]
119
+ # Passages with no keyword overlap don't get a BM25 rank.
120
+ keyword = [i for i in order if scores[i] > 0]
121
+ ranked = rrf([keyword, dense])[:k]
122
+ best = ranked[0][1] or 1.0
123
+ return [(self.passages[i], round(s / best, 3)) for i, s in ranked]