ragcheck-eval 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.
Files changed (54) hide show
  1. ragcheck/__init__.py +18 -0
  2. ragcheck/adapters/__init__.py +7 -0
  3. ragcheck/adapters/base.py +39 -0
  4. ragcheck/adapters/function.py +19 -0
  5. ragcheck/adapters/langchain.py +59 -0
  6. ragcheck/cache.py +67 -0
  7. ragcheck/cli.py +210 -0
  8. ragcheck/config.py +54 -0
  9. ragcheck/datasets/__init__.py +6 -0
  10. ragcheck/datasets/loaders.py +35 -0
  11. ragcheck/datasets/models.py +32 -0
  12. ragcheck/datasets/synthetic.py +255 -0
  13. ragcheck/demo.py +168 -0
  14. ragcheck/judge/__init__.py +5 -0
  15. ragcheck/judge/judge.py +93 -0
  16. ragcheck/judge/prompts/answer_relevance.md +21 -0
  17. ragcheck/judge/prompts/answers_equivalent.md +16 -0
  18. ragcheck/judge/prompts/chunk_relevance.md +13 -0
  19. ragcheck/judge/prompts/citation_support.md +13 -0
  20. ragcheck/judge/prompts/dataset_paraphrase.md +10 -0
  21. ragcheck/judge/prompts/dataset_qa_cross.md +18 -0
  22. ragcheck/judge/prompts/dataset_qa_multi.md +15 -0
  23. ragcheck/judge/prompts/dataset_qa_single.md +15 -0
  24. ragcheck/judge/prompts/dataset_unanswerable.md +15 -0
  25. ragcheck/judge/prompts/faithfulness_decompose.md +18 -0
  26. ragcheck/judge/prompts/faithfulness_verify.md +13 -0
  27. ragcheck/judge/prompts/refusal_detection.md +13 -0
  28. ragcheck/judge/validation.py +119 -0
  29. ragcheck/llm.py +186 -0
  30. ragcheck/metrics/__init__.py +59 -0
  31. ragcheck/metrics/base.py +56 -0
  32. ragcheck/metrics/generation/__init__.py +7 -0
  33. ragcheck/metrics/generation/citation_accuracy.py +87 -0
  34. ragcheck/metrics/generation/faithfulness.py +99 -0
  35. ragcheck/metrics/generation/relevance.py +55 -0
  36. ragcheck/metrics/retrieval/__init__.py +8 -0
  37. ragcheck/metrics/retrieval/context_precision.py +70 -0
  38. ragcheck/metrics/retrieval/context_recall.py +81 -0
  39. ragcheck/metrics/retrieval/hit_rate.py +42 -0
  40. ragcheck/metrics/retrieval/mrr.py +41 -0
  41. ragcheck/metrics/robustness/__init__.py +6 -0
  42. ragcheck/metrics/robustness/paraphrase_consistency.py +96 -0
  43. ragcheck/metrics/robustness/refusal_calibration.py +81 -0
  44. ragcheck/report/__init__.py +5 -0
  45. ragcheck/report/cli_summary.py +45 -0
  46. ragcheck/report/html.py +145 -0
  47. ragcheck/report/models.py +55 -0
  48. ragcheck/report/regression.py +94 -0
  49. ragcheck/runner.py +166 -0
  50. ragcheck_eval-0.1.0.dist-info/METADATA +190 -0
  51. ragcheck_eval-0.1.0.dist-info/RECORD +54 -0
  52. ragcheck_eval-0.1.0.dist-info/WHEEL +4 -0
  53. ragcheck_eval-0.1.0.dist-info/entry_points.txt +2 -0
  54. ragcheck_eval-0.1.0.dist-info/licenses/LICENSE +21 -0
ragcheck/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """RAGCheck: pytest for RAG systems."""
2
+
3
+ from ragcheck.adapters.base import RAGAdapter, RAGResponse, RetrievedChunk
4
+ from ragcheck.datasets.models import EvalDataset, EvalSample, QAPair
5
+ from ragcheck.metrics.base import Metric, MetricResult
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "EvalDataset",
11
+ "EvalSample",
12
+ "Metric",
13
+ "MetricResult",
14
+ "QAPair",
15
+ "RAGAdapter",
16
+ "RAGResponse",
17
+ "RetrievedChunk",
18
+ ]
@@ -0,0 +1,7 @@
1
+ """Adapters that wrap arbitrary RAG pipelines for evaluation."""
2
+
3
+ from ragcheck.adapters.base import RAGAdapter, RAGResponse, RetrievedChunk
4
+ from ragcheck.adapters.function import FunctionAdapter
5
+ from ragcheck.adapters.langchain import LangChainAdapter
6
+
7
+ __all__ = ["FunctionAdapter", "LangChainAdapter", "RAGAdapter", "RAGResponse", "RetrievedChunk"]
@@ -0,0 +1,39 @@
1
+ """Core adapter interface: wrap any RAG pipeline so RAGCheck can evaluate it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class RetrievedChunk(BaseModel):
11
+ """One chunk returned by the retrieval stage."""
12
+
13
+ content: str
14
+ source_id: str
15
+ score: float | None = None
16
+ metadata: dict = Field(default_factory=dict)
17
+
18
+
19
+ class RAGResponse(BaseModel):
20
+ """Everything a pipeline produced for one question."""
21
+
22
+ answer: str
23
+ retrieved_chunks: list[RetrievedChunk]
24
+ latencies_ms: dict[str, float] = Field(default_factory=dict)
25
+ token_usage: dict[str, int] = Field(default_factory=dict)
26
+ refused: bool = False
27
+
28
+
29
+ class RAGAdapter(ABC):
30
+ """Wrap any RAG pipeline so RAGCheck can evaluate it."""
31
+
32
+ @abstractmethod
33
+ def query(self, question: str) -> RAGResponse:
34
+ """Answer a single question, returning the answer plus retrieval trace."""
35
+ ...
36
+
37
+ def batch_query(self, questions: list[str]) -> list[RAGResponse]:
38
+ """Override for pipelines with native batching. Default: sequential."""
39
+ return [self.query(q) for q in questions]
@@ -0,0 +1,19 @@
1
+ """Adapter that wraps a plain Python callable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from ragcheck.adapters.base import RAGAdapter, RAGResponse
8
+
9
+
10
+ class FunctionAdapter(RAGAdapter):
11
+ """Wrap any ``(question: str) -> RAGResponse`` callable as a RAGAdapter."""
12
+
13
+ def __init__(self, fn: Callable[[str], RAGResponse]) -> None:
14
+ """Store the pipeline function to delegate queries to."""
15
+ self._fn = fn
16
+
17
+ def query(self, question: str) -> RAGResponse:
18
+ """Delegate the question to the wrapped function."""
19
+ return self._fn(question)
@@ -0,0 +1,59 @@
1
+ """Adapter for LangChain retriever + chain pipelines.
2
+
3
+ LangChain is not a dependency of ragcheck; objects are duck-typed. The
4
+ retriever must expose ``invoke(question) -> list[Document]`` (LangChain's
5
+ standard retriever interface) and the chain ``invoke(inputs) -> str | Message``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Protocol
12
+
13
+ from ragcheck.adapters.base import RAGAdapter, RAGResponse, RetrievedChunk
14
+
15
+
16
+ class _Invokable(Protocol):
17
+ def invoke(self, __input: Any) -> Any: ...
18
+
19
+
20
+ class LangChainAdapter(RAGAdapter):
21
+ """Wrap a LangChain retriever + generation chain as a RAGAdapter.
22
+
23
+ The chain is invoked with ``{"question": ..., "context": ...}`` where
24
+ context is the concatenated retrieved documents. Document source ids come
25
+ from ``doc.metadata[source_key]`` (falling back to a positional id).
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ retriever: _Invokable,
31
+ chain: _Invokable,
32
+ source_key: str = "source",
33
+ ) -> None:
34
+ """Bind a retriever and a chain; ``source_key`` names the id metadata field."""
35
+ self.retriever = retriever
36
+ self.chain = chain
37
+ self.source_key = source_key
38
+
39
+ def query(self, question: str) -> RAGResponse:
40
+ """Retrieve documents, run the chain, and normalize into a RAGResponse."""
41
+ t0 = time.perf_counter()
42
+ documents = self.retriever.invoke(question)
43
+ t1 = time.perf_counter()
44
+ chunks = [
45
+ RetrievedChunk(
46
+ content=getattr(doc, "page_content", str(doc)),
47
+ source_id=str(getattr(doc, "metadata", {}).get(self.source_key, f"doc_{i}")),
48
+ )
49
+ for i, doc in enumerate(documents)
50
+ ]
51
+ context = "\n\n".join(f"[{c.source_id}] {c.content}" for c in chunks)
52
+ raw = self.chain.invoke({"question": question, "context": context})
53
+ t2 = time.perf_counter()
54
+ answer = str(getattr(raw, "content", raw)).strip()
55
+ return RAGResponse(
56
+ answer=answer,
57
+ retrieved_chunks=chunks,
58
+ latencies_ms={"retrieval": (t1 - t0) * 1000, "generation": (t2 - t1) * 1000},
59
+ )
ragcheck/cache.py ADDED
@@ -0,0 +1,67 @@
1
+ """SQLite-backed cache for LLM judgments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import sqlite3
7
+ import threading
8
+ from pathlib import Path
9
+
10
+
11
+ def make_key(*parts: str) -> str:
12
+ """Build a stable SHA256 cache key from ordered string components.
13
+
14
+ Callers pass (metric_name, prompt_version, question, context, answer, ...);
15
+ components are length-prefixed so no two part sequences collide.
16
+ """
17
+ h = hashlib.sha256()
18
+ for part in parts:
19
+ encoded = part.encode("utf-8")
20
+ h.update(str(len(encoded)).encode("ascii"))
21
+ h.update(b":")
22
+ h.update(encoded)
23
+ return h.hexdigest()
24
+
25
+
26
+ class JudgmentCache:
27
+ """Persistent key-value store for judge outputs.
28
+
29
+ Thread-safe (judged metrics run samples in parallel). Hits are counted
30
+ on ``hits`` / ``misses`` so the runner can report cache effectiveness
31
+ on re-runs.
32
+ """
33
+
34
+ def __init__(self, path: str | Path) -> None:
35
+ """Open (or create) the cache database at ``path``."""
36
+ self._conn = sqlite3.connect(str(path), check_same_thread=False)
37
+ self._lock = threading.Lock()
38
+ self._conn.execute(
39
+ "CREATE TABLE IF NOT EXISTS judgments (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
40
+ )
41
+ self._conn.commit()
42
+ self.hits = 0
43
+ self.misses = 0
44
+
45
+ def get(self, key: str) -> str | None:
46
+ """Return the cached value for ``key``, or None on a miss."""
47
+ with self._lock:
48
+ row = self._conn.execute(
49
+ "SELECT value FROM judgments WHERE key = ?", (key,)
50
+ ).fetchone()
51
+ if row is None:
52
+ self.misses += 1
53
+ return None
54
+ self.hits += 1
55
+ return str(row[0])
56
+
57
+ def set(self, key: str, value: str) -> None:
58
+ """Store ``value`` under ``key``, overwriting any previous entry."""
59
+ with self._lock:
60
+ self._conn.execute(
61
+ "INSERT OR REPLACE INTO judgments (key, value) VALUES (?, ?)", (key, value)
62
+ )
63
+ self._conn.commit()
64
+
65
+ def close(self) -> None:
66
+ """Close the underlying SQLite connection."""
67
+ self._conn.close()
ragcheck/cli.py ADDED
@@ -0,0 +1,210 @@
1
+ """RAGCheck command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from ragcheck.config import load_config
11
+ from ragcheck.report.cli_summary import print_summary
12
+ from ragcheck.runner import run_eval
13
+
14
+ app = typer.Typer(help="pytest for RAG systems.", no_args_is_help=True)
15
+ console = Console()
16
+
17
+
18
+ @app.callback()
19
+ def main() -> None:
20
+ """RAGCheck: evaluate RAG pipelines."""
21
+ # Explicit callback keeps subcommand names (`ragcheck run ...`) even while
22
+ # only one command exists; typer otherwise collapses single-command apps.
23
+
24
+
25
+ @app.command()
26
+ def run(
27
+ config: Path = typer.Argument(..., help="Path to an eval config YAML."),
28
+ yes: bool = typer.Option(
29
+ False, "--yes", help="Proceed without confirmation on large LLM-judged runs."
30
+ ),
31
+ ) -> None:
32
+ """Run an evaluation and print the scorecard."""
33
+ eval_config = load_config(config)
34
+ if yes:
35
+ eval_config.assume_yes = True
36
+ report, out_path = run_eval(eval_config)
37
+ print_summary(report, console)
38
+ console.print(f"[green]Report written to[/green] {out_path}")
39
+ if eval_config.html:
40
+ console.print(f"[green]HTML report:[/green] {out_path.with_suffix('.html')}")
41
+
42
+
43
+ @app.command()
44
+ def compare(
45
+ old: Path = typer.Argument(..., help="Baseline report JSON."),
46
+ new: Path = typer.Argument(..., help="New report JSON."),
47
+ fail_if: list[str] = typer.Option(
48
+ [],
49
+ "--fail-if",
50
+ help="Exit 1 when a metric's delta drops below a threshold, e.g. 'faithfulness<-0.05'. "
51
+ "Repeatable.",
52
+ ),
53
+ ) -> None:
54
+ """Diff two eval reports; non-zero exit on threshold breach (CI-friendly)."""
55
+ from ragcheck.report.regression import (
56
+ compare_reports,
57
+ load_report,
58
+ markdown_diff,
59
+ parse_fail_if,
60
+ )
61
+
62
+ diffs = compare_reports(load_report(old), load_report(new), parse_fail_if(fail_if))
63
+ console.print(markdown_diff(diffs, old.name, new.name))
64
+ breached = [d for d in diffs if d.breached]
65
+ if breached:
66
+ names = ", ".join(d.metric_name for d in breached)
67
+ console.print(f"\n[red]Regression threshold breached:[/red] {names}")
68
+ raise typer.Exit(code=1)
69
+
70
+
71
+ @app.command()
72
+ def demo(
73
+ output: Path = typer.Option(Path("ragcheck_output"), help="Where to write the reports."),
74
+ ) -> None:
75
+ """Run the zero-key demo: canned pipeline, offline judge, full reports in ~5 seconds."""
76
+ from ragcheck.config import EvalConfig, MetricSpec
77
+ from ragcheck.demo import DemoPipeline, demo_dataset
78
+ from ragcheck.runner import evaluate
79
+
80
+ console.print(
81
+ "[bold]RAGCheck demo[/bold] - no API key needed.\n"
82
+ "[dim]Canned pipeline answers (two deliberately wrong) + a deterministic offline "
83
+ "judge, so you can see the workflow. Real runs use Claude/Groq judges.[/dim]\n"
84
+ )
85
+ config = EvalConfig(
86
+ dataset=Path("demo"),
87
+ adapter="ragcheck.demo:DemoPipeline",
88
+ metrics=[
89
+ MetricSpec(name="hit_rate", params={"k": 3}),
90
+ MetricSpec(name="mrr"),
91
+ MetricSpec(name="faithfulness"),
92
+ MetricSpec(name="answer_relevance"),
93
+ MetricSpec(name="refusal_calibration"),
94
+ ],
95
+ judge_provider="offline-demo",
96
+ output_dir=output,
97
+ cache_path=output / "demo_cache.sqlite",
98
+ run_name="demo",
99
+ )
100
+ report, out_path = evaluate(
101
+ DemoPipeline(), demo_dataset(), config, adapter_name="demo_pipeline"
102
+ )
103
+ print_summary(report, console)
104
+ faith = next(m for m in report.metrics if m.metric_name == "faithfulness")
105
+ refusal = next(m for m in report.metrics if m.metric_name == "refusal_calibration")
106
+ console.print(
107
+ f"\n[bold]What just happened:[/bold] faithfulness caught the planted hallucination "
108
+ f"({[c['claim'] for c in faith.details['failed_claims']][:1]}), and refusal "
109
+ f"calibration flagged the invented answer "
110
+ f"(false-answer rate: {refusal.details['false_answer_rate']:.2f}).\n"
111
+ f"[green]Open the HTML report to see the failing samples with their contexts:[/green] "
112
+ f"{out_path.with_suffix('.html')}\n"
113
+ "[dim]Next: wrap your own pipeline (docs/quickstart.md) and set "
114
+ "ANTHROPIC_API_KEY or GROQ_API_KEY for real judging.[/dim]"
115
+ )
116
+
117
+
118
+ @app.command("generate-dataset")
119
+ def generate_dataset_cmd(
120
+ corpus_dir: Path = typer.Argument(..., help="Directory of .txt/.md corpus files."),
121
+ n: int = typer.Option(200, help="Base QA pairs to generate (before paraphrase expansion)."),
122
+ unanswerable_frac: float = typer.Option(0.15, help="Fraction of n that is unanswerable."),
123
+ paraphrase_groups: int = typer.Option(
124
+ 20, help="Answerable questions to expand with 4 paraphrases each."
125
+ ),
126
+ out: Path = typer.Option(Path("dataset.jsonl"), help="Output JSONL path."),
127
+ provider: str = typer.Option("anthropic", help="Generator provider: anthropic or groq."),
128
+ model: str | None = typer.Option(None, help="Generator model (provider default if omitted)."),
129
+ seed: int = typer.Option(13, help="Sampling seed (keep fixed for resumability)."),
130
+ cache_path: Path = typer.Option(
131
+ Path(".ragcheck_cache.sqlite"), help="Generation cache (makes re-runs resumable)."
132
+ ),
133
+ ) -> None:
134
+ """Generate an eval dataset (difficulty tiers, unanswerables, paraphrases) from a corpus."""
135
+ from collections import Counter
136
+
137
+ from ragcheck.cache import JudgmentCache
138
+ from ragcheck.datasets.synthetic import SyntheticGenerator
139
+ from ragcheck.judge.judge import Judge
140
+ from ragcheck.llm import build_client
141
+
142
+ cache = JudgmentCache(cache_path)
143
+ judge = Judge(build_client(provider, model), cache)
144
+ generator = SyntheticGenerator(judge, seed=seed)
145
+ dataset = generator.generate(
146
+ corpus_dir, n=n, unanswerable_frac=unanswerable_frac, paraphrase_groups=paraphrase_groups
147
+ )
148
+
149
+ out.parent.mkdir(parents=True, exist_ok=True)
150
+ out.write_text("\n".join(p.model_dump_json(exclude_none=True) for p in dataset.pairs) + "\n")
151
+
152
+ tiers = Counter(p.difficulty for p in dataset.pairs if p.answerable)
153
+ n_groups = len({p.paraphrase_group for p in dataset.pairs if p.paraphrase_group})
154
+ console.print(
155
+ f"[green]Wrote {len(dataset.pairs)} pairs to[/green] {out}\n"
156
+ f"tiers: {dict(tiers)} | unanswerable: "
157
+ f"{sum(1 for p in dataset.pairs if not p.answerable)} | "
158
+ f"paraphrase groups: {n_groups} | "
159
+ f"cache: {cache.hits} hits / {cache.misses} misses"
160
+ )
161
+
162
+
163
+ @app.command("validate-judge")
164
+ def validate_judge_cmd(
165
+ labels: Path = typer.Argument(..., help="JSONL of {question, answer, context, human_label}."),
166
+ metric: str = typer.Option("faithfulness", help="LLM-judged metric to validate."),
167
+ provider: str = typer.Option("anthropic", help="Judge provider: anthropic or groq."),
168
+ model: str | None = typer.Option(None, help="Judge model (provider default if omitted)."),
169
+ threshold: float = typer.Option(
170
+ 0.5, help="Per-sample metric score at or above which the judge label is 'pass'."
171
+ ),
172
+ concurrency: int = typer.Option(4, help="Parallel judge calls."),
173
+ cache_path: Path = typer.Option(Path(".ragcheck_cache.sqlite"), help="Judgment cache."),
174
+ output: Path = typer.Option(
175
+ Path("ragcheck_output"), help="Directory for the validation report JSON."
176
+ ),
177
+ ) -> None:
178
+ """Measure LLM-judge vs. human agreement (Cohen's kappa) on labeled samples."""
179
+ from rich.table import Table
180
+
181
+ from ragcheck.cache import JudgmentCache
182
+ from ragcheck.judge.judge import Judge
183
+ from ragcheck.judge.validation import load_labels, validate_judge
184
+ from ragcheck.llm import build_client
185
+
186
+ judge = Judge(build_client(provider, model), JudgmentCache(cache_path))
187
+ report = validate_judge(
188
+ load_labels(labels), metric, judge, threshold=threshold, concurrency=concurrency
189
+ )
190
+
191
+ table = Table(title=f"Judge validation - {report.metric_name}")
192
+ table.add_column("Stat")
193
+ table.add_column("Value", justify="right")
194
+ table.add_row("judge model", f"{report.judge_model} ({report.prompt_version})")
195
+ table.add_row("samples", str(report.n_samples))
196
+ table.add_row("agreement", f"{report.agreement:.3f}")
197
+ table.add_row("Cohen's kappa", f"{report.kappa:.3f}")
198
+ c = report.confusion
199
+ confusion = f"tp={c['tp']} fp={c['fp']} fn={c['fn']} tn={c['tn']}"
200
+ table.add_row("confusion (judge vs human)", confusion)
201
+ console.print(table)
202
+
203
+ output.mkdir(parents=True, exist_ok=True)
204
+ out_path = output / f"judge_validation_{report.metric_name}.json"
205
+ out_path.write_text(report.model_dump_json(indent=2))
206
+ console.print(f"[green]Validation report written to[/green] {out_path}")
207
+
208
+
209
+ if __name__ == "__main__":
210
+ app()
ragcheck/config.py ADDED
@@ -0,0 +1,54 @@
1
+ """Eval run configuration: Pydantic models + YAML loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+
11
+ class MetricSpec(BaseModel):
12
+ """A metric selection with optional constructor params."""
13
+
14
+ name: str
15
+ params: dict = Field(default_factory=dict)
16
+
17
+
18
+ class EvalConfig(BaseModel):
19
+ """Everything needed to run one evaluation."""
20
+
21
+ dataset: Path
22
+ adapter: str
23
+ metrics: list[MetricSpec]
24
+ judge_provider: str = "anthropic"
25
+ judge_model: str | None = None
26
+ concurrency: int = 4
27
+ html: bool = True
28
+ judge_validation: Path | None = None # embed this validation report, if present
29
+ confirm_above: int = 200 # LLM-judged runs larger than this need assume_yes
30
+ assume_yes: bool = False
31
+ output_dir: Path = Path("ragcheck_output")
32
+ cache_path: Path = Path(".ragcheck_cache.sqlite")
33
+ run_name: str | None = None
34
+
35
+ @field_validator("metrics", mode="before")
36
+ @classmethod
37
+ def _coerce_metric_specs(cls, value: object) -> object:
38
+ """Allow bare metric names in YAML alongside {name, params} mappings."""
39
+ if isinstance(value, list):
40
+ return [{"name": v} if isinstance(v, str) else v for v in value]
41
+ return value
42
+
43
+
44
+ def load_config(path: str | Path) -> EvalConfig:
45
+ """Load an EvalConfig from YAML, resolving paths relative to the file."""
46
+ path = Path(path)
47
+ data = yaml.safe_load(path.read_text())
48
+ config = EvalConfig.model_validate(data)
49
+ base = path.parent
50
+ for field in ("dataset", "output_dir", "cache_path"):
51
+ value: Path = getattr(config, field)
52
+ if not value.is_absolute():
53
+ setattr(config, field, base / value)
54
+ return config
@@ -0,0 +1,6 @@
1
+ """Evaluation dataset models and loaders."""
2
+
3
+ from ragcheck.datasets.loaders import load_dataset
4
+ from ragcheck.datasets.models import EvalDataset, EvalSample, QAPair
5
+
6
+ __all__ = ["EvalDataset", "EvalSample", "QAPair", "load_dataset"]
@@ -0,0 +1,35 @@
1
+ """Load evaluation datasets from JSONL or CSV files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from ragcheck.datasets.models import EvalDataset, QAPair
10
+
11
+
12
+ def load_dataset(path: str | Path) -> EvalDataset:
13
+ """Load a dataset from a ``.jsonl`` or ``.csv`` file.
14
+
15
+ JSONL: one QAPair object per line. CSV: columns matching QAPair fields;
16
+ ``relevant_source_ids`` is a semicolon-separated string.
17
+ """
18
+ path = Path(path)
19
+ if path.suffix == ".jsonl":
20
+ pairs = [
21
+ QAPair.model_validate(json.loads(line))
22
+ for line in path.read_text().splitlines()
23
+ if line.strip()
24
+ ]
25
+ elif path.suffix == ".csv":
26
+ pairs = []
27
+ with path.open(newline="") as f:
28
+ for row in csv.DictReader(f):
29
+ raw_ids = row.pop("relevant_source_ids", "") or ""
30
+ data: dict[str, object] = {k: v for k, v in row.items() if v not in (None, "")}
31
+ data["relevant_source_ids"] = [s for s in raw_ids.split(";") if s]
32
+ pairs.append(QAPair.model_validate(data))
33
+ else:
34
+ raise ValueError(f"Unsupported dataset format: {path.suffix} (use .jsonl or .csv)")
35
+ return EvalDataset(name=path.stem, pairs=pairs)
@@ -0,0 +1,32 @@
1
+ """Dataset data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from ragcheck.adapters.base import RAGResponse
8
+
9
+
10
+ class QAPair(BaseModel):
11
+ """A single evaluation question with optional ground truth."""
12
+
13
+ question: str
14
+ ground_truth_answer: str | None = None
15
+ relevant_source_ids: list[str] = Field(default_factory=list)
16
+ answerable: bool = True
17
+ difficulty: str = "medium"
18
+ paraphrase_group: str | None = None
19
+
20
+
21
+ class EvalDataset(BaseModel):
22
+ """A named collection of QA pairs."""
23
+
24
+ name: str = "dataset"
25
+ pairs: list[QAPair]
26
+
27
+
28
+ class EvalSample(BaseModel):
29
+ """A QA pair joined with the pipeline's response - the unit metrics consume."""
30
+
31
+ qa: QAPair
32
+ response: RAGResponse