first commit
This commit is contained in:
779
tests/test_dataset_build.py
Normal file
779
tests/test_dataset_build.py
Normal file
@@ -0,0 +1,779 @@
|
||||
import csv
|
||||
import json
|
||||
import shutil
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from rag_eval.dataset_builder.generator.question_generator import OpenAIQuestionGenerator
|
||||
from rag_eval.dataset_builder.generator.validators import dedupe_samples, validate_draft_sample
|
||||
from rag_eval.dataset_builder.models import DraftQuestionSample, ParsedDocument, SourceChunk
|
||||
from rag_eval.dataset_builder.parser.aliyun_document_parser import AliyunDocumentParser
|
||||
from rag_eval.dataset_builder.parser.aliyun_docmind_gateway import AliyunDocmindGateway
|
||||
from rag_eval.dataset_builder.parser.aliyun_layout_normalizer import normalize_layouts
|
||||
from rag_eval.dataset_builder.runner import load_dataset_build_job, run_dataset_build
|
||||
from rag_eval.dataset_builder.schema import DatasetBuildConfigModel
|
||||
from rag_eval.dataset_builder.sources import discover_pdf_files
|
||||
from rag_eval.settings import EvaluationSettings
|
||||
|
||||
|
||||
class FakeParser:
|
||||
def __init__(self, documents_by_name, failures=None):
|
||||
self.documents_by_name = documents_by_name
|
||||
self.failures = failures or set()
|
||||
|
||||
def parse(self, pdf_path: Path):
|
||||
if pdf_path.name in self.failures:
|
||||
raise RuntimeError(f"parse failed for {pdf_path.name}")
|
||||
return self.documents_by_name[pdf_path.name]
|
||||
|
||||
|
||||
class FakeGenerator:
|
||||
def __init__(self, outputs_by_doc_id):
|
||||
self.outputs_by_doc_id = outputs_by_doc_id
|
||||
|
||||
def generate(self, document, *, max_questions, max_chunks_per_question, job_name):
|
||||
return list(self.outputs_by_doc_id.get(document.doc_id, []))
|
||||
|
||||
|
||||
class FakeGateway(AliyunDocmindGateway):
|
||||
def __init__(self, settings, *, statuses=None, layouts=None):
|
||||
super().__init__(settings)
|
||||
self.statuses = list(statuses or [])
|
||||
self.layouts = list(layouts or [])
|
||||
|
||||
def submit_parse_task(self, pdf_path: Path) -> str:
|
||||
return "task-1"
|
||||
|
||||
def get_task_status(self, task_id: str):
|
||||
if self.statuses:
|
||||
return self.statuses.pop(0)
|
||||
return {"status": "succeeded", "doc_id": "doc-1", "doc_name": "doc1.pdf"}
|
||||
|
||||
def fetch_layouts(self, task_id: str):
|
||||
return list(self.layouts)
|
||||
|
||||
|
||||
class DatasetBuildTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
root = Path("tests/.tmp").resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
self.temp_dir = root / self._testMethodName
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.input_dir = self.temp_dir / "pdfs"
|
||||
self.input_dir.mkdir(parents=True, exist_ok=True)
|
||||
(self.input_dir / "doc1.pdf").write_bytes(b"%PDF-1.4 doc1")
|
||||
(self.input_dir / "doc2.pdf").write_bytes(b"%PDF-1.4 doc2")
|
||||
|
||||
self.config_path = self.temp_dir / "dataset-build.yaml"
|
||||
self.config_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"job_name: sample-build",
|
||||
"input:",
|
||||
f" path: {self.input_dir.as_posix()}",
|
||||
" glob: '*.pdf'",
|
||||
"parser:",
|
||||
" provider: aliyun_docmind",
|
||||
" failure_mode: skip",
|
||||
"generation:",
|
||||
" output_type: online_question_bank",
|
||||
" review_mode: draft_with_manual_review",
|
||||
" max_questions_per_document: 3",
|
||||
" max_source_chunks_per_question: 2",
|
||||
"output:",
|
||||
f" dataset_path: {(self.temp_dir / 'generated' / 'draft.csv').as_posix()}",
|
||||
f" artifact_dir: {(self.temp_dir / 'outputs').as_posix()}",
|
||||
"runtime:",
|
||||
" max_documents: 2",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def _make_document(self, doc_id: str, doc_name: str) -> ParsedDocument:
|
||||
chunk = SourceChunk(
|
||||
chunk_id=f"{doc_id}-chunk-1",
|
||||
doc_id=doc_id,
|
||||
doc_name=doc_name,
|
||||
text="Section content for review.",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
section_path="Chapter 1 > Scope",
|
||||
section_title="Scope",
|
||||
source_layout_ids=["layout-1"],
|
||||
)
|
||||
return ParsedDocument(
|
||||
doc_id=doc_id,
|
||||
doc_name=doc_name,
|
||||
raw_text=chunk.text,
|
||||
structure_nodes=[],
|
||||
semantic_blocks=[],
|
||||
source_chunks=[chunk],
|
||||
metadata={},
|
||||
)
|
||||
|
||||
def test_load_dataset_build_job_resolves_paths_and_defaults(self) -> None:
|
||||
settings = EvaluationSettings.model_construct(dataset_generator_model="env-model")
|
||||
job = load_dataset_build_job(self.config_path, settings=settings)
|
||||
self.assertEqual(job.job_name, "sample-build")
|
||||
self.assertEqual(job.generation_model, "env-model")
|
||||
self.assertTrue(job.dataset_path.is_absolute())
|
||||
self.assertEqual(job.failure_mode, "skip")
|
||||
|
||||
def test_load_dataset_build_job_prefers_yaml_generation_model(self) -> None:
|
||||
config_path = self.temp_dir / "dataset-build-with-model.yaml"
|
||||
config_path.write_text(
|
||||
self.config_path.read_text(encoding="utf-8").replace(
|
||||
"generation:\n",
|
||||
"generation:\n model: yaml-model\n",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = EvaluationSettings.model_construct(dataset_generator_model="env-model")
|
||||
job = load_dataset_build_job(config_path, settings=settings)
|
||||
self.assertEqual(job.generation_model, "yaml-model")
|
||||
|
||||
def test_load_dataset_build_job_uses_env_default_failure_mode(self) -> None:
|
||||
config_path = self.temp_dir / "dataset-build-without-failure-mode.yaml"
|
||||
config_path.write_text(
|
||||
self.config_path.read_text(encoding="utf-8").replace(" failure_mode: skip\n", ""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = EvaluationSettings.model_construct(
|
||||
dataset_generator_model="env-model",
|
||||
parser_failure_mode="skip",
|
||||
)
|
||||
job = load_dataset_build_job(config_path, settings=settings)
|
||||
self.assertEqual(job.failure_mode, "skip")
|
||||
|
||||
def test_discover_pdf_files_rejects_missing_or_empty_input(self) -> None:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
discover_pdf_files(self.temp_dir / "missing")
|
||||
|
||||
empty_dir = self.temp_dir / "empty"
|
||||
empty_dir.mkdir()
|
||||
with self.assertRaises(ValueError):
|
||||
discover_pdf_files(empty_dir)
|
||||
|
||||
def test_discover_pdf_files_accepts_single_pdf_file(self) -> None:
|
||||
pdf_path = self.input_dir / "doc1.pdf"
|
||||
files = discover_pdf_files(pdf_path)
|
||||
self.assertEqual(files, [pdf_path])
|
||||
|
||||
def test_dataset_build_schema_rejects_missing_required_fields(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
DatasetBuildConfigModel.model_validate(
|
||||
{
|
||||
"job_name": "sample-build",
|
||||
"parser": {"provider": "aliyun_docmind"},
|
||||
"generation": {
|
||||
"output_type": "online_question_bank",
|
||||
"review_mode": "draft_with_manual_review",
|
||||
},
|
||||
"output": {
|
||||
"dataset_path": "draft.csv",
|
||||
"artifact_dir": "outputs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_dataset_build_schema_rejects_invalid_enums(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
DatasetBuildConfigModel.model_validate(
|
||||
{
|
||||
"job_name": "sample-build",
|
||||
"input": {"path": self.input_dir.as_posix()},
|
||||
"parser": {"provider": "other-provider", "failure_mode": "ignore"},
|
||||
"generation": {
|
||||
"output_type": "other-output",
|
||||
"review_mode": "auto_publish",
|
||||
},
|
||||
"output": {
|
||||
"dataset_path": "draft.csv",
|
||||
"artifact_dir": "outputs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_normalize_layouts_applies_core_rules(self) -> None:
|
||||
layouts = [
|
||||
{"type": "toc", "text": "目录", "page": 1, "layout_id": "toc-1"},
|
||||
{"type": "heading", "text": "第一章 总则", "page": 2, "layout_id": "h1", "level": 1},
|
||||
{"type": "paragraph", "text": "第一段。", "page": 2, "layout_id": "p1"},
|
||||
{"type": "caption", "text": "系统示意图", "page": 2, "layout_id": "c1"},
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [["字段", "说明"], ["名称", "项目名称"]],
|
||||
"page": 3,
|
||||
"layout_id": "t1",
|
||||
},
|
||||
]
|
||||
document = normalize_layouts(doc_id="doc-1", doc_name="sample.pdf", layouts=layouts, max_chunk_chars=80, overlap_chars=10)
|
||||
self.assertEqual(len(document.structure_nodes), 1)
|
||||
self.assertEqual(document.structure_nodes[0].section_path, "第一章 总则")
|
||||
self.assertEqual(len(document.semantic_blocks), 1)
|
||||
self.assertIn("图注:", document.semantic_blocks[0].text)
|
||||
self.assertIn("字段 | 说明", document.semantic_blocks[0].text)
|
||||
self.assertEqual(document.source_chunks[0].page_start, 2)
|
||||
self.assertEqual(document.source_chunks[0].page_end, 3)
|
||||
|
||||
def test_normalize_layouts_splits_long_text_into_multiple_chunks(self) -> None:
|
||||
long_text = "A" * 220
|
||||
layouts = [
|
||||
{"type": "heading", "text": "Chapter 1", "page": 1, "layout_id": "h1", "level": 1},
|
||||
{"type": "paragraph", "text": long_text, "page": 1, "layout_id": "p1"},
|
||||
]
|
||||
document = normalize_layouts(
|
||||
doc_id="doc-1",
|
||||
doc_name="sample.pdf",
|
||||
layouts=layouts,
|
||||
max_chunk_chars=100,
|
||||
overlap_chars=20,
|
||||
)
|
||||
self.assertGreaterEqual(len(document.source_chunks), 3)
|
||||
self.assertTrue(all(chunk.section_title == "Chapter 1" for chunk in document.source_chunks))
|
||||
|
||||
def test_validate_and_dedupe_generated_samples(self) -> None:
|
||||
document = self._make_document("doc-1", "doc1.pdf")
|
||||
valid = DraftQuestionSample(
|
||||
sample_id="doc-1-q1",
|
||||
question="这份文档的范围是什么?",
|
||||
ground_truth="文档说明了适用范围。",
|
||||
scenario="sample-build",
|
||||
language="zh",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
source_chunk_ids=["doc-1-chunk-1"],
|
||||
question_type="summary",
|
||||
difficulty="easy",
|
||||
)
|
||||
invalid = DraftQuestionSample(
|
||||
sample_id="doc-1-q2",
|
||||
question="",
|
||||
ground_truth="",
|
||||
scenario="sample-build",
|
||||
language="zh",
|
||||
doc_id="doc-2",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="",
|
||||
page_start=0,
|
||||
page_end=0,
|
||||
source_chunk_ids=["missing-chunk"],
|
||||
question_type="invalid",
|
||||
difficulty="invalid",
|
||||
)
|
||||
duplicate = DraftQuestionSample(
|
||||
sample_id="doc-1-q3",
|
||||
question=" 这份文档的范围是什么? ",
|
||||
ground_truth="文档说明了适用范围",
|
||||
scenario="sample-build",
|
||||
language="zh",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
source_chunk_ids=["doc-1-chunk-1"],
|
||||
question_type="summary",
|
||||
difficulty="easy",
|
||||
)
|
||||
self.assertEqual(validate_draft_sample(valid, document=document), [])
|
||||
self.assertTrue(validate_draft_sample(invalid, document=document))
|
||||
self.assertEqual(len(dedupe_samples([valid, duplicate])), 1)
|
||||
|
||||
def test_validate_rejects_too_many_source_chunks(self) -> None:
|
||||
document = self._make_document("doc-1", "doc1.pdf")
|
||||
document.source_chunks.append(
|
||||
SourceChunk(
|
||||
chunk_id="doc-1-chunk-2",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
text="More content",
|
||||
page_start=2,
|
||||
page_end=3,
|
||||
section_path="Chapter 1 > Scope",
|
||||
section_title="Scope",
|
||||
source_layout_ids=["layout-2"],
|
||||
)
|
||||
)
|
||||
sample = DraftQuestionSample(
|
||||
sample_id="doc-1-q1",
|
||||
question="What is the scope?",
|
||||
ground_truth="It defines scope.",
|
||||
scenario="sample-build",
|
||||
language="en",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=3,
|
||||
source_chunk_ids=["doc-1-chunk-1", "doc-1-chunk-2"],
|
||||
question_type="fact",
|
||||
difficulty="easy",
|
||||
)
|
||||
errors = validate_draft_sample(
|
||||
sample,
|
||||
document=document,
|
||||
max_source_chunks_per_question=1,
|
||||
)
|
||||
self.assertTrue(any("exceeds limit" in error for error in errors))
|
||||
|
||||
def test_dedupe_keeps_only_one_question_per_chunk_group(self) -> None:
|
||||
sample_a = DraftQuestionSample(
|
||||
sample_id="doc-1-q1",
|
||||
question="What is the scope?",
|
||||
ground_truth="It defines the scope.",
|
||||
scenario="sample-build",
|
||||
language="en",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
source_chunk_ids=["doc-1-chunk-1"],
|
||||
question_type="fact",
|
||||
difficulty="easy",
|
||||
)
|
||||
sample_b = DraftQuestionSample(
|
||||
sample_id="doc-1-q2",
|
||||
question="How is the scope described?",
|
||||
ground_truth="The scope is described in the first section.",
|
||||
scenario="sample-build",
|
||||
language="en",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
source_chunk_ids=["doc-1-chunk-1"],
|
||||
question_type="summary",
|
||||
difficulty="medium",
|
||||
)
|
||||
self.assertEqual(len(dedupe_samples([sample_a, sample_b])), 1)
|
||||
|
||||
def test_aliyun_gateway_parse_success_failure_and_timeout(self) -> None:
|
||||
settings = EvaluationSettings.model_construct(
|
||||
aliyun_parse_poll_interval_seconds=1,
|
||||
aliyun_parse_timeout_seconds=1,
|
||||
)
|
||||
pdf_path = self.input_dir / "doc1.pdf"
|
||||
|
||||
success_gateway = FakeGateway(
|
||||
settings,
|
||||
statuses=[{"status": "running"}, {"status": "succeeded", "doc_id": "doc-1", "doc_name": "doc1.pdf"}],
|
||||
layouts=[{"type": "paragraph", "text": "hello", "page": 1, "layout_id": "p1"}],
|
||||
)
|
||||
with mock.patch("rag_eval.dataset_builder.parser.aliyun_docmind_gateway.time.sleep", return_value=None), mock.patch(
|
||||
"rag_eval.dataset_builder.parser.aliyun_docmind_gateway.time.monotonic",
|
||||
side_effect=[0.0, 0.1, 0.2],
|
||||
):
|
||||
payload = success_gateway.parse_document(pdf_path)
|
||||
self.assertEqual(payload["doc_id"], "doc-1")
|
||||
self.assertEqual(len(payload["layouts"]), 1)
|
||||
|
||||
failure_gateway = FakeGateway(settings, statuses=[{"status": "failed", "message": "bad file"}])
|
||||
with self.assertRaises(RuntimeError):
|
||||
failure_gateway.parse_document(pdf_path)
|
||||
|
||||
timeout_gateway = FakeGateway(settings, statuses=[{"status": "running"}, {"status": "running"}])
|
||||
with mock.patch("rag_eval.dataset_builder.parser.aliyun_docmind_gateway.time.sleep", return_value=None), mock.patch(
|
||||
"rag_eval.dataset_builder.parser.aliyun_docmind_gateway.time.monotonic",
|
||||
side_effect=[0.0, 2.0],
|
||||
):
|
||||
with self.assertRaises(TimeoutError):
|
||||
timeout_gateway.parse_document(pdf_path)
|
||||
|
||||
def test_aliyun_gateway_reports_missing_sdk(self) -> None:
|
||||
settings = EvaluationSettings.model_construct(
|
||||
aliyun_parse_poll_interval_seconds=1,
|
||||
aliyun_parse_timeout_seconds=1,
|
||||
)
|
||||
gateway = AliyunDocmindGateway(settings)
|
||||
|
||||
with mock.patch("rag_eval.dataset_builder.parser.aliyun_docmind_gateway.DocmindClient", None), mock.patch(
|
||||
"rag_eval.dataset_builder.parser.aliyun_docmind_gateway.docmind_models", None
|
||||
), mock.patch("rag_eval.dataset_builder.parser.aliyun_docmind_gateway.openapi_models", None), mock.patch(
|
||||
"rag_eval.dataset_builder.parser.aliyun_docmind_gateway.runtime_models", None
|
||||
):
|
||||
with self.assertRaises(ImportError):
|
||||
gateway._load_sdk()
|
||||
|
||||
def test_document_parser_rejects_empty_layouts(self) -> None:
|
||||
settings = EvaluationSettings.model_construct(
|
||||
aliyun_parse_poll_interval_seconds=1,
|
||||
aliyun_parse_timeout_seconds=1,
|
||||
)
|
||||
gateway = FakeGateway(
|
||||
settings,
|
||||
statuses=[{"status": "succeeded", "doc_id": "doc-1", "doc_name": "doc1.pdf"}],
|
||||
layouts=[],
|
||||
)
|
||||
parser = AliyunDocumentParser(gateway)
|
||||
with self.assertRaises(ValueError):
|
||||
parser.parse(self.input_dir / "doc1.pdf")
|
||||
|
||||
def test_run_dataset_build_skip_mode_writes_all_artifacts(self) -> None:
|
||||
doc1 = self._make_document("doc-1", "doc1.pdf")
|
||||
parser = FakeParser(
|
||||
{"doc1.pdf": doc1},
|
||||
failures={"doc2.pdf"},
|
||||
)
|
||||
generator = FakeGenerator(
|
||||
{
|
||||
"doc-1": [
|
||||
DraftQuestionSample(
|
||||
sample_id="doc-1-q1",
|
||||
question="What is the scope?",
|
||||
ground_truth="It defines the scope.",
|
||||
scenario="sample-build",
|
||||
language="en",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
source_chunk_ids=["doc-1-chunk-1"],
|
||||
question_type="fact",
|
||||
difficulty="easy",
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = run_dataset_build(
|
||||
self.config_path,
|
||||
settings=EvaluationSettings.model_construct(dataset_generator_model="stub-model"),
|
||||
parser=parser,
|
||||
generator=generator,
|
||||
)
|
||||
|
||||
self.assertEqual(len(result.documents), 1)
|
||||
self.assertEqual(len(result.parse_failures), 1)
|
||||
self.assertEqual(len(result.draft_samples), 1)
|
||||
self.assertTrue(result.artifact_paths.documents_jsonl.exists())
|
||||
self.assertTrue(result.artifact_paths.semantic_blocks_jsonl.exists())
|
||||
self.assertTrue(result.artifact_paths.source_chunks_jsonl.exists())
|
||||
self.assertTrue(result.artifact_paths.dataset_draft_csv.exists())
|
||||
self.assertTrue(result.artifact_paths.parse_failures_csv.exists())
|
||||
self.assertTrue(result.artifact_paths.metadata_json.exists())
|
||||
self.assertTrue(result.job.dataset_path.exists())
|
||||
latest_dir = result.job.artifact_dir / "latest"
|
||||
self.assertTrue((latest_dir / "source_chunks.jsonl").exists())
|
||||
self.assertTrue((latest_dir / "dataset_draft.csv").exists())
|
||||
self.assertTrue((latest_dir / "metadata.json").exists())
|
||||
|
||||
with result.artifact_paths.parse_failures_csv.open(encoding="utf-8") as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertIn("doc2.pdf", rows[0]["file_path"])
|
||||
|
||||
metadata = json.loads(result.artifact_paths.metadata_json.read_text(encoding="utf-8"))
|
||||
self.assertEqual(metadata["stats"]["documents_processed"], 1)
|
||||
self.assertEqual(metadata["stats"]["parse_failures"], 1)
|
||||
latest_metadata = json.loads((latest_dir / "metadata.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(latest_metadata["run_id"], result.run_id)
|
||||
|
||||
with result.artifact_paths.source_chunks_jsonl.open(encoding="utf-8") as handle:
|
||||
run_chunks = handle.read()
|
||||
with (latest_dir / "source_chunks.jsonl").open(encoding="utf-8") as handle:
|
||||
latest_chunks = handle.read()
|
||||
self.assertEqual(latest_chunks, run_chunks)
|
||||
|
||||
def test_run_dataset_build_single_pdf_input(self) -> None:
|
||||
single_pdf_config = self.temp_dir / "single-pdf-build.yaml"
|
||||
single_pdf_config.write_text(
|
||||
self.config_path.read_text(encoding="utf-8").replace(
|
||||
f" path: {self.input_dir.as_posix()}",
|
||||
f" path: {(self.input_dir / 'doc1.pdf').as_posix()}",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
parser = FakeParser({"doc1.pdf": self._make_document("doc-1", "doc1.pdf")})
|
||||
generator = FakeGenerator(
|
||||
{
|
||||
"doc-1": [
|
||||
DraftQuestionSample(
|
||||
sample_id="doc-1-q1",
|
||||
question="What is the scope?",
|
||||
ground_truth="It defines the scope.",
|
||||
scenario="sample-build",
|
||||
language="en",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
source_chunk_ids=["doc-1-chunk-1"],
|
||||
question_type="fact",
|
||||
difficulty="easy",
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
result = run_dataset_build(
|
||||
single_pdf_config,
|
||||
settings=EvaluationSettings.model_construct(dataset_generator_model="stub-model"),
|
||||
parser=parser,
|
||||
generator=generator,
|
||||
)
|
||||
self.assertEqual(len(result.documents), 1)
|
||||
self.assertEqual(result.documents[0].doc_name, "doc1.pdf")
|
||||
self.assertEqual(len(result.draft_samples), 1)
|
||||
|
||||
def test_run_dataset_build_caps_questions_per_document(self) -> None:
|
||||
doc1 = self._make_document("doc-1", "doc1.pdf")
|
||||
parser = FakeParser({"doc1.pdf": doc1, "doc2.pdf": self._make_document("doc-2", "doc2.pdf")})
|
||||
generator = FakeGenerator(
|
||||
{
|
||||
"doc-1": [
|
||||
DraftQuestionSample(
|
||||
sample_id=f"doc-1-q{index}",
|
||||
question=f"Question {index}?",
|
||||
ground_truth=f"Answer {index}.",
|
||||
scenario="sample-build",
|
||||
language="en",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
source_chunk_ids=[f"doc-1-chunk-{index}"],
|
||||
question_type="fact",
|
||||
difficulty="easy",
|
||||
)
|
||||
for index in range(1, 5)
|
||||
]
|
||||
}
|
||||
)
|
||||
# Rebuild the doc with enough chunk ids for validation to pass.
|
||||
doc1.source_chunks = [
|
||||
SourceChunk(
|
||||
chunk_id=f"doc-1-chunk-{index}",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
text=f"Chunk {index}",
|
||||
page_start=index,
|
||||
page_end=index,
|
||||
section_path="Chapter 1 > Scope",
|
||||
section_title="Scope",
|
||||
source_layout_ids=[f"layout-{index}"],
|
||||
)
|
||||
for index in range(1, 5)
|
||||
]
|
||||
|
||||
result = run_dataset_build(
|
||||
self.config_path,
|
||||
settings=EvaluationSettings.model_construct(dataset_generator_model="stub-model"),
|
||||
parser=parser,
|
||||
generator=generator,
|
||||
)
|
||||
self.assertLessEqual(len([item for item in result.draft_samples if item.doc_id == "doc-1"]), 3)
|
||||
|
||||
def test_run_dataset_build_filters_questions_exceeding_chunk_limit(self) -> None:
|
||||
doc1 = self._make_document("doc-1", "doc1.pdf")
|
||||
doc1.source_chunks.append(
|
||||
SourceChunk(
|
||||
chunk_id="doc-1-chunk-2",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
text="Chunk 2",
|
||||
page_start=2,
|
||||
page_end=2,
|
||||
section_path="Chapter 1 > Scope",
|
||||
section_title="Scope",
|
||||
source_layout_ids=["layout-2"],
|
||||
)
|
||||
)
|
||||
parser = FakeParser({"doc1.pdf": doc1}, failures={"doc2.pdf"})
|
||||
generator = FakeGenerator(
|
||||
{
|
||||
"doc-1": [
|
||||
DraftQuestionSample(
|
||||
sample_id="doc-1-q1",
|
||||
question="Too many chunks?",
|
||||
ground_truth="This cites two chunks.",
|
||||
scenario="sample-build",
|
||||
language="en",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Chapter 1 > Scope",
|
||||
page_start=1,
|
||||
page_end=2,
|
||||
source_chunk_ids=["doc-1-chunk-1", "doc-1-chunk-2"],
|
||||
question_type="fact",
|
||||
difficulty="easy",
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
strict_config = self.temp_dir / "dataset-build-strict.yaml"
|
||||
strict_config.write_text(
|
||||
self.config_path.read_text(encoding="utf-8").replace(
|
||||
" max_source_chunks_per_question: 2",
|
||||
" max_source_chunks_per_question: 1",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = run_dataset_build(
|
||||
strict_config,
|
||||
settings=EvaluationSettings.model_construct(dataset_generator_model="stub-model"),
|
||||
parser=parser,
|
||||
generator=generator,
|
||||
)
|
||||
self.assertEqual(len(result.draft_samples), 0)
|
||||
|
||||
def test_run_dataset_build_fail_mode_raises(self) -> None:
|
||||
fail_config = self.temp_dir / "dataset-build-fail.yaml"
|
||||
fail_config.write_text(self.config_path.read_text(encoding="utf-8").replace("failure_mode: skip", "failure_mode: fail"), encoding="utf-8")
|
||||
parser = FakeParser({}, failures={"doc1.pdf"})
|
||||
generator = FakeGenerator({})
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
run_dataset_build(
|
||||
fail_config,
|
||||
settings=EvaluationSettings.model_construct(dataset_generator_model="stub-model"),
|
||||
parser=parser,
|
||||
generator=generator,
|
||||
)
|
||||
|
||||
|
||||
class QuestionGeneratorTests(unittest.TestCase):
|
||||
def _make_document(self) -> ParsedDocument:
|
||||
return ParsedDocument(
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
raw_text="source text",
|
||||
structure_nodes=[],
|
||||
semantic_blocks=[],
|
||||
source_chunks=[
|
||||
SourceChunk(
|
||||
chunk_id="doc-1-chunk-1",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
text="Scope content",
|
||||
page_start=1,
|
||||
page_end=1,
|
||||
section_path="Chapter 1 > Scope",
|
||||
section_title="Scope",
|
||||
source_layout_ids=["layout-1"],
|
||||
),
|
||||
SourceChunk(
|
||||
chunk_id="doc-1-chunk-2",
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
text="Procedure content",
|
||||
page_start=2,
|
||||
page_end=2,
|
||||
section_path="Chapter 2 > Process",
|
||||
section_title="Process",
|
||||
source_layout_ids=["layout-2"],
|
||||
),
|
||||
],
|
||||
metadata={},
|
||||
)
|
||||
|
||||
def _make_fake_client(self, content: str):
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: str):
|
||||
self.choices = [type("Choice", (), {"message": type("Message", (), {"content": payload})()})()]
|
||||
|
||||
class FakeCompletions:
|
||||
def __init__(self, payload: str):
|
||||
self.payload = payload
|
||||
|
||||
def create(self, **kwargs):
|
||||
return FakeResponse(self.payload)
|
||||
|
||||
return type(
|
||||
"FakeClient",
|
||||
(),
|
||||
{"chat": type("Chat", (), {"completions": FakeCompletions(content)})()},
|
||||
)()
|
||||
|
||||
def test_question_generator_builds_samples_from_json_response(self) -> None:
|
||||
settings = EvaluationSettings.model_construct(openai_api_key="test-key")
|
||||
content = json.dumps(
|
||||
{
|
||||
"samples": [
|
||||
{
|
||||
"question": "What is the scope?",
|
||||
"ground_truth": "It defines the scope.",
|
||||
"source_chunk_ids": ["doc-1-chunk-1"],
|
||||
"question_type": "fact",
|
||||
"difficulty": "easy",
|
||||
},
|
||||
{
|
||||
"question": "Summarize the process.",
|
||||
"ground_truth": "It explains the process.",
|
||||
"source_chunk_ids": ["doc-1-chunk-2"],
|
||||
"question_type": "summary",
|
||||
"difficulty": "medium",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
generator = OpenAIQuestionGenerator(
|
||||
settings=settings,
|
||||
model="stub-model",
|
||||
client=self._make_fake_client(content),
|
||||
)
|
||||
samples = generator.generate(
|
||||
self._make_document(),
|
||||
max_questions=1,
|
||||
max_chunks_per_question=2,
|
||||
job_name="sample-build",
|
||||
)
|
||||
self.assertEqual(len(samples), 1)
|
||||
self.assertEqual(samples[0].sample_id, "doc-1-q1")
|
||||
self.assertEqual(samples[0].section_path, "Chapter 1 > Scope")
|
||||
|
||||
def test_question_generator_rejects_invalid_json(self) -> None:
|
||||
settings = EvaluationSettings.model_construct(openai_api_key="test-key")
|
||||
generator = OpenAIQuestionGenerator(
|
||||
settings=settings,
|
||||
model="stub-model",
|
||||
client=self._make_fake_client("not-json"),
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
generator.generate(
|
||||
self._make_document(),
|
||||
max_questions=1,
|
||||
max_chunks_per_question=2,
|
||||
job_name="sample-build",
|
||||
)
|
||||
|
||||
def test_question_generator_rejects_non_list_samples(self) -> None:
|
||||
settings = EvaluationSettings.model_construct(openai_api_key="test-key")
|
||||
content = json.dumps({"samples": {"question": "bad-shape"}})
|
||||
generator = OpenAIQuestionGenerator(
|
||||
settings=settings,
|
||||
model="stub-model",
|
||||
client=self._make_fake_client(content),
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
generator.generate(
|
||||
self._make_document(),
|
||||
max_questions=1,
|
||||
max_chunks_per_question=2,
|
||||
job_name="sample-build",
|
||||
)
|
||||
|
||||
|
||||
class MainCliParseTests(unittest.TestCase):
|
||||
def test_cli_options_are_mutually_exclusive(self) -> None:
|
||||
import main
|
||||
|
||||
with mock.patch("sys.argv", ["main.py", "--scenario", "a.yaml", "--dataset-build-config", "b.yaml"]):
|
||||
with self.assertRaises(SystemExit):
|
||||
main.parse_args()
|
||||
310
tests/test_offline_eval.py
Normal file
310
tests/test_offline_eval.py
Normal file
@@ -0,0 +1,310 @@
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pandas as pd
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
|
||||
from rag_eval.config.loader import load_scenario
|
||||
from rag_eval.datasets.normalizers import normalize_records
|
||||
from rag_eval.execution.evaluator import Evaluator
|
||||
from rag_eval.metrics.pipeline import MetricPipeline
|
||||
from rag_eval.reporting.summary import build_summary_markdown
|
||||
from rag_eval.reporting.writers import write_run_artifacts
|
||||
from rag_eval.settings import EvaluationSettings
|
||||
from rag_eval.shared.models import EvaluationResult
|
||||
|
||||
|
||||
class EnvOnlySettings(EvaluationSettings):
|
||||
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
||||
|
||||
|
||||
class FakeMetric:
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
async def ascore(self, **kwargs):
|
||||
class Result:
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
return Result(self.value)
|
||||
|
||||
|
||||
class SlowMetric:
|
||||
async def ascore(self, **kwargs):
|
||||
await __import__("asyncio").sleep(0.05)
|
||||
return type("Result", (), {"value": 1.0})()
|
||||
|
||||
|
||||
class OpenAIConfigTests(unittest.TestCase):
|
||||
def test_openai_client_kwargs_without_base_url(self) -> None:
|
||||
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=True):
|
||||
settings = EnvOnlySettings()
|
||||
self.assertEqual(
|
||||
settings.openai_client_kwargs,
|
||||
{"api_key": "test-key", "base_url": "http://6.86.80.4:30080/v1", "timeout": 30.0},
|
||||
)
|
||||
|
||||
def test_openai_client_kwargs_with_base_url(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPENAI_API_KEY": "test-key",
|
||||
"OPENAI_BASE_URL": "https://proxy.example/v1",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
settings = EnvOnlySettings()
|
||||
self.assertEqual(
|
||||
settings.openai_client_kwargs,
|
||||
{"api_key": "test-key", "base_url": "https://proxy.example/v1", "timeout": 30.0},
|
||||
)
|
||||
|
||||
def test_settings_defaults(self) -> None:
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
settings = EnvOnlySettings()
|
||||
self.assertEqual(settings.openai_base_url, "http://6.86.80.4:30080/v1")
|
||||
self.assertEqual(settings.ragas_judge_model, "deepseek-v4-flash")
|
||||
self.assertEqual(settings.ragas_embedding_model, "text-embedding-v3")
|
||||
self.assertEqual(settings.openai_timeout_seconds, 30.0)
|
||||
self.assertEqual(settings.ragas_metric_timeout_seconds, 45.0)
|
||||
self.assertEqual(settings.batch_size, 8)
|
||||
|
||||
|
||||
class ScenarioAndDatasetTests(unittest.TestCase):
|
||||
def test_load_scenario_resolves_relative_paths(self) -> None:
|
||||
scenario = load_scenario("scenarios/offline/sample-offline.yaml")
|
||||
self.assertEqual(scenario.mode, "offline")
|
||||
self.assertTrue(scenario.dataset.path.name.endswith(".csv"))
|
||||
self.assertTrue(scenario.output_dir.name == "sample-offline-baseline")
|
||||
|
||||
def test_scenario_snapshot_serializes_path_static_kwargs(self) -> None:
|
||||
scenario = load_scenario("scenarios/online/sample-pdf-question-bank-online.yaml")
|
||||
snapshot = scenario.snapshot()
|
||||
self.assertIsInstance(snapshot["app_adapter"]["static_kwargs"]["source_chunks_path"], str)
|
||||
self.assertTrue(
|
||||
snapshot["app_adapter"]["static_kwargs"]["source_chunks_path"].endswith("source_chunks.jsonl")
|
||||
)
|
||||
|
||||
def test_load_sample_pdf_offline_smoke_scenario(self) -> None:
|
||||
scenario = load_scenario("scenarios/offline/sample-pdf-offline-smoke.yaml")
|
||||
self.assertEqual(scenario.mode, "offline")
|
||||
self.assertEqual(scenario.dataset.path.name, "sample_pdf_offline_smoke.csv")
|
||||
self.assertEqual(scenario.output_dir.name, "sample-pdf-offline-smoke")
|
||||
|
||||
def test_normalize_records_splits_valid_and_invalid(self) -> None:
|
||||
records = [
|
||||
{
|
||||
"question": "Q1",
|
||||
"contexts": '["C1"]',
|
||||
"answer": "A1",
|
||||
"ground_truth": "G1",
|
||||
},
|
||||
{
|
||||
"question": "",
|
||||
"contexts": "",
|
||||
"answer": "",
|
||||
"ground_truth": "",
|
||||
},
|
||||
]
|
||||
valid, invalid = normalize_records(records)
|
||||
self.assertEqual(len(valid), 1)
|
||||
self.assertEqual(len(invalid), 1)
|
||||
self.assertEqual(valid[0].contexts, ["C1"])
|
||||
|
||||
def test_normalize_sample_pdf_offline_smoke_row(self) -> None:
|
||||
frame = pd.read_csv("datasets/normalized/sample_pdf_offline_smoke.csv")
|
||||
valid, invalid = normalize_records(frame.to_dict(orient="records"))
|
||||
self.assertEqual(len(invalid), 0)
|
||||
self.assertEqual(len(valid), 3)
|
||||
self.assertTrue(valid[0].answer)
|
||||
self.assertTrue(valid[0].ground_truth)
|
||||
self.assertTrue(valid[0].contexts)
|
||||
|
||||
|
||||
class EvaluatorAndReportingTests(unittest.TestCase):
|
||||
def test_metric_pipeline_scores_sample(self) -> None:
|
||||
pipeline = MetricPipeline(
|
||||
metrics={
|
||||
"faithfulness": FakeMetric(0.1),
|
||||
"answer_relevancy": FakeMetric(0.2),
|
||||
"context_recall": FakeMetric(0.3),
|
||||
"context_precision": FakeMetric(0.4),
|
||||
}
|
||||
)
|
||||
valid, _ = normalize_records(
|
||||
[
|
||||
{
|
||||
"question": "What is RAG?",
|
||||
"contexts": ["RAG combines retrieval and generation."],
|
||||
"answer": "RAG combines retrieval and generation.",
|
||||
"ground_truth": "RAG combines retrieval and generation.",
|
||||
}
|
||||
]
|
||||
)
|
||||
score = __import__("asyncio").run(pipeline.score_sample(valid[0]))
|
||||
self.assertEqual(score.metrics["faithfulness"], 0.1)
|
||||
self.assertEqual(score.metrics["context_precision"], 0.4)
|
||||
|
||||
def test_metric_pipeline_captures_metric_timeout_without_aborting(self) -> None:
|
||||
pipeline = MetricPipeline(
|
||||
metrics={
|
||||
"faithfulness": SlowMetric(),
|
||||
"answer_relevancy": FakeMetric(0.2),
|
||||
},
|
||||
metric_timeout_seconds=0.01,
|
||||
)
|
||||
valid, _ = normalize_records(
|
||||
[
|
||||
{
|
||||
"question": "What is RAG?",
|
||||
"contexts": ["RAG combines retrieval and generation."],
|
||||
"answer": "RAG combines retrieval and generation.",
|
||||
"ground_truth": "RAG combines retrieval and generation.",
|
||||
}
|
||||
]
|
||||
)
|
||||
score = __import__("asyncio").run(pipeline.score_sample(valid[0]))
|
||||
self.assertEqual(score.metrics["faithfulness"], 1.0)
|
||||
self.assertEqual(score.metrics["answer_relevancy"], 0.2)
|
||||
self.assertEqual(score.error, "")
|
||||
|
||||
def test_evaluator_and_reporting_write_run_assets(self) -> None:
|
||||
temp_root = Path("tests/.tmp/run-assets")
|
||||
temp_root.mkdir(parents=True, exist_ok=True)
|
||||
for child in temp_root.iterdir():
|
||||
if child.is_dir():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
output_root = temp_root
|
||||
try:
|
||||
scenario = load_scenario("scenarios/offline/sample-offline.yaml")
|
||||
scenario.output_dir = output_root
|
||||
|
||||
pipeline = MetricPipeline(
|
||||
metrics={
|
||||
"faithfulness": FakeMetric(0.1),
|
||||
"answer_relevancy": FakeMetric(0.2),
|
||||
"context_recall": FakeMetric(0.3),
|
||||
"context_precision": FakeMetric(0.4),
|
||||
}
|
||||
)
|
||||
evaluator = Evaluator(scenario=scenario, metric_pipeline=pipeline)
|
||||
result = evaluator.evaluate()
|
||||
write_run_artifacts(result)
|
||||
|
||||
run_dir = output_root / result.run_id
|
||||
self.assertTrue((run_dir / "scenario.snapshot.yaml").exists())
|
||||
self.assertTrue((run_dir / "scores.csv").exists())
|
||||
self.assertTrue((run_dir / "invalid.csv").exists())
|
||||
self.assertTrue((run_dir / "summary.md").exists())
|
||||
self.assertTrue((run_dir / "metadata.json").exists())
|
||||
|
||||
scores = pd.read_csv(run_dir / "scores.csv")
|
||||
self.assertEqual(len(scores), 3)
|
||||
self.assertIn("faithfulness", scores.columns)
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
|
||||
def test_summary_markdown_lists_all_scored_samples_and_errors(self) -> None:
|
||||
scenario = load_scenario("scenarios/offline/sample-offline.yaml")
|
||||
valid, invalid = normalize_records(
|
||||
[
|
||||
{
|
||||
"sample_id": "sample-1",
|
||||
"question": "Q1",
|
||||
"contexts": ["C1"],
|
||||
"answer": "A1",
|
||||
"ground_truth": "G1",
|
||||
},
|
||||
{
|
||||
"sample_id": "sample-2",
|
||||
"question": "Q2",
|
||||
"contexts": ["C2"],
|
||||
"answer": "A2",
|
||||
"ground_truth": "G2",
|
||||
},
|
||||
{
|
||||
"sample_id": "sample-3",
|
||||
"question": "Q3",
|
||||
"contexts": ["C3"],
|
||||
"answer": "A3",
|
||||
"ground_truth": "G3",
|
||||
},
|
||||
{
|
||||
"sample_id": "sample-4",
|
||||
"question": "Q4",
|
||||
"contexts": ["C4"],
|
||||
"answer": "A4",
|
||||
"ground_truth": "G4",
|
||||
},
|
||||
]
|
||||
)
|
||||
summary = build_summary_markdown(
|
||||
EvaluationResult(
|
||||
scenario=scenario,
|
||||
run_id="test-run",
|
||||
started_at="2026-06-10T00:00:00+00:00",
|
||||
finished_at="2026-06-10T00:01:00+00:00",
|
||||
valid_samples=valid,
|
||||
invalid_samples=invalid,
|
||||
score_rows=[
|
||||
{
|
||||
"sample_id": "sample-1",
|
||||
"faithfulness": 1.0,
|
||||
"answer_relevancy": 0.9,
|
||||
"context_recall": 1.0,
|
||||
"context_precision": 0.8,
|
||||
"error": "",
|
||||
},
|
||||
{
|
||||
"sample_id": "sample-2",
|
||||
"faithfulness": 0.8,
|
||||
"answer_relevancy": 0.7,
|
||||
"context_recall": 0.9,
|
||||
"context_precision": 0.6,
|
||||
"error": "faithfulness: timeout",
|
||||
},
|
||||
{
|
||||
"sample_id": "sample-3",
|
||||
"faithfulness": 0.7,
|
||||
"answer_relevancy": 0.6,
|
||||
"context_recall": 0.8,
|
||||
"context_precision": 0.5,
|
||||
"error": "",
|
||||
},
|
||||
{
|
||||
"sample_id": "sample-4",
|
||||
"faithfulness": 0.6,
|
||||
"answer_relevancy": 0.5,
|
||||
"context_recall": 0.7,
|
||||
"context_precision": 0.4,
|
||||
"error": "context_precision: failed",
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIn("## Per-sample Scores", summary)
|
||||
self.assertIn("sample-1", summary)
|
||||
self.assertIn("sample-2", summary)
|
||||
self.assertIn("sample-3", summary)
|
||||
self.assertIn("sample-4", summary)
|
||||
self.assertIn("faithfulness", summary)
|
||||
self.assertIn("answer_relevancy", summary)
|
||||
self.assertIn("context_recall", summary)
|
||||
self.assertIn("context_precision", summary)
|
||||
self.assertIn("error", summary)
|
||||
self.assertIn("faithfulness: timeout", summary)
|
||||
self.assertIn("context_precision: failed", summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
351
tests/test_online_eval.py
Normal file
351
tests/test_online_eval.py
Normal file
@@ -0,0 +1,351 @@
|
||||
import shutil
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from rag_eval.adapters.base import AppAdapter
|
||||
from rag_eval.config.loader import load_scenario
|
||||
from rag_eval.datasets.normalizers import normalize_records
|
||||
from rag_eval.execution.evaluator import Evaluator
|
||||
from rag_eval.metrics.pipeline import MetricPipeline
|
||||
from rag_eval.shared.models import AppAdapterConfig, DatasetConfig, RuntimeConfig, Scenario
|
||||
from apps.pdf_question_bank import adapter as pdf_question_bank_adapter
|
||||
|
||||
|
||||
class FakeMetric:
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
async def ascore(self, **kwargs):
|
||||
class Result:
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
return Result(self.value)
|
||||
|
||||
|
||||
class FakeOnlineAdapter(AppAdapter):
|
||||
async def run(self, question: str, **kwargs):
|
||||
return {
|
||||
"answer": f"answer for {question}",
|
||||
"contexts": [f"context for {question}"],
|
||||
"raw_response": {"question": question, "metadata": kwargs},
|
||||
}
|
||||
|
||||
|
||||
class ExplodingOnlineAdapter(AppAdapter):
|
||||
async def run(self, question: str, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
class OnlineDatasetTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
root = Path("tests/.tmp").resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
self.temp_dir = root / self._testMethodName
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_online_records_allow_missing_answer_and_contexts_before_adapter(self) -> None:
|
||||
records = [
|
||||
{
|
||||
"sample_id": "sample-1",
|
||||
"question": "What is the policy scope?",
|
||||
"ground_truth": "It covers all employees.",
|
||||
"doc_id": "doc-1",
|
||||
"source_chunk_ids": '["doc-1-chunk-1"]',
|
||||
}
|
||||
]
|
||||
valid, invalid = normalize_records(records, mode="online")
|
||||
self.assertEqual(len(valid), 1)
|
||||
self.assertEqual(len(invalid), 0)
|
||||
self.assertEqual(valid[0].answer, "")
|
||||
self.assertEqual(valid[0].contexts, [])
|
||||
self.assertEqual(valid[0].metadata["source_chunk_ids"], '["doc-1-chunk-1"]')
|
||||
|
||||
def test_online_evaluator_enriches_dataset_and_scores(self) -> None:
|
||||
dataset_path = self.temp_dir / "online.csv"
|
||||
pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"sample_id": "sample-1",
|
||||
"question": "What is the policy scope?",
|
||||
"ground_truth": "It covers all employees.",
|
||||
"doc_id": "doc-1",
|
||||
"section_path": "Policy > Scope",
|
||||
"source_chunk_ids": '["doc-1-chunk-1"]',
|
||||
}
|
||||
]
|
||||
).to_csv(dataset_path, index=False)
|
||||
|
||||
scenario = Scenario(
|
||||
scenario_name="online-test",
|
||||
mode="online",
|
||||
dataset=DatasetConfig(path=dataset_path),
|
||||
judge_model="judge-model",
|
||||
embedding_model="embedding-model",
|
||||
metrics=["faithfulness"],
|
||||
output_dir=self.temp_dir / "outputs",
|
||||
runtime=RuntimeConfig(batch_size=1),
|
||||
app_adapter=AppAdapterConfig(type="python", callable="tests.fake:run"),
|
||||
)
|
||||
pipeline = MetricPipeline(metrics={"faithfulness": FakeMetric(0.8)})
|
||||
evaluator = Evaluator(scenario=scenario, metric_pipeline=pipeline, app_adapter=FakeOnlineAdapter())
|
||||
|
||||
result = evaluator.evaluate()
|
||||
self.assertEqual(len(result.valid_samples), 1)
|
||||
self.assertEqual(len(result.invalid_samples), 0)
|
||||
self.assertEqual(result.valid_samples[0].answer, "answer for What is the policy scope?")
|
||||
self.assertEqual(result.valid_samples[0].contexts, ["context for What is the policy scope?"])
|
||||
self.assertEqual(result.score_rows[0]["faithfulness"], 0.8)
|
||||
|
||||
def test_online_evaluator_captures_adapter_exception_type_in_invalid_rows(self) -> None:
|
||||
dataset_path = self.temp_dir / "online.csv"
|
||||
pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"sample_id": "sample-1",
|
||||
"question": "What is the policy scope?",
|
||||
"ground_truth": "It covers all employees.",
|
||||
"doc_id": "doc-1",
|
||||
"source_chunk_ids": '["doc-1-chunk-1"]',
|
||||
}
|
||||
]
|
||||
).to_csv(dataset_path, index=False)
|
||||
|
||||
scenario = Scenario(
|
||||
scenario_name="online-test",
|
||||
mode="online",
|
||||
dataset=DatasetConfig(path=dataset_path),
|
||||
judge_model="judge-model",
|
||||
embedding_model="embedding-model",
|
||||
metrics=["faithfulness"],
|
||||
output_dir=self.temp_dir / "outputs",
|
||||
runtime=RuntimeConfig(batch_size=1),
|
||||
app_adapter=AppAdapterConfig(type="python", callable="tests.fake:run"),
|
||||
)
|
||||
pipeline = MetricPipeline(metrics={"faithfulness": FakeMetric(0.8)})
|
||||
evaluator = Evaluator(scenario=scenario, metric_pipeline=pipeline, app_adapter=ExplodingOnlineAdapter())
|
||||
|
||||
result = evaluator.evaluate()
|
||||
self.assertEqual(len(result.valid_samples), 0)
|
||||
self.assertEqual(len(result.invalid_samples), 1)
|
||||
self.assertEqual(result.invalid_samples[0].error, "adapter failed [RuntimeError]: boom")
|
||||
|
||||
|
||||
class FakeCompletionResponse:
|
||||
def __init__(self, content: str):
|
||||
self.choices = [type("Choice", (), {"message": type("Message", (), {"content": content})()})()]
|
||||
|
||||
|
||||
class FakeCompletions:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return FakeCompletionResponse(self.content)
|
||||
|
||||
|
||||
class PdfQuestionBankAdapterTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
root = Path("tests/.tmp").resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
self.temp_dir = root / self._testMethodName
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.source_chunks_path = self.temp_dir / "source_chunks.jsonl"
|
||||
self.source_chunks_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'{"chunk_id":"doc-1-chunk-1","doc_id":"doc-1","doc_name":"doc1.pdf","text":"Scope covers all employees.","page_start":1,"page_end":1,"section_path":"Policy > Scope","section_title":"Scope","source_layout_ids":["layout-1"]}',
|
||||
'{"chunk_id":"doc-1-chunk-2","doc_id":"doc-1","doc_name":"doc1.pdf","text":"Managers approve exceptions.","page_start":2,"page_end":2,"section_path":"Policy > Exceptions","section_title":"Exceptions","source_layout_ids":["layout-2"]}',
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
pdf_question_bank_adapter._CHUNK_CACHE.clear()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
pdf_question_bank_adapter._CHUNK_CACHE.clear()
|
||||
|
||||
def test_adapter_loads_chunks_and_returns_resolved_contexts(self) -> None:
|
||||
completions = FakeCompletions("It covers all employees.")
|
||||
client = type(
|
||||
"FakeClient",
|
||||
(),
|
||||
{"chat": type("Chat", (), {"completions": completions})()},
|
||||
)()
|
||||
|
||||
result = pdf_question_bank_adapter.run(
|
||||
question="What is the policy scope?",
|
||||
source_chunks_path=str(self.source_chunks_path),
|
||||
model="stub-model",
|
||||
client=client,
|
||||
source_chunk_ids='["doc-1-chunk-1"]',
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
section_path="Policy > Scope",
|
||||
)
|
||||
|
||||
self.assertEqual(result["answer"], "It covers all employees.")
|
||||
self.assertEqual(result["contexts"], ["Scope covers all employees."])
|
||||
self.assertEqual(result["raw_response"]["resolved_chunk_ids"], ["doc-1-chunk-1"])
|
||||
self.assertEqual(result["raw_response"]["model"], "stub-model")
|
||||
self.assertEqual(len(completions.calls), 1)
|
||||
self.assertEqual(completions.calls[0]["model"], "stub-model")
|
||||
self.assertEqual(completions.calls[0]["temperature"], 0)
|
||||
self.assertIn("Evidence chunks:", completions.calls[0]["messages"][1]["content"])
|
||||
|
||||
def test_adapter_supports_multiple_chunk_ids(self) -> None:
|
||||
completions = FakeCompletions("Combined answer.")
|
||||
client = type(
|
||||
"FakeClient",
|
||||
(),
|
||||
{"chat": type("Chat", (), {"completions": completions})()},
|
||||
)()
|
||||
|
||||
result = pdf_question_bank_adapter.run(
|
||||
question="What does the policy say?",
|
||||
source_chunks_path=str(self.source_chunks_path),
|
||||
model="stub-model",
|
||||
client=client,
|
||||
source_chunk_ids='["doc-1-chunk-1", "doc-1-chunk-2"]',
|
||||
doc_id="doc-1",
|
||||
doc_name="doc1.pdf",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result["contexts"],
|
||||
["Scope covers all employees.", "Managers approve exceptions."],
|
||||
)
|
||||
self.assertEqual(
|
||||
result["raw_response"]["resolved_chunk_ids"],
|
||||
["doc-1-chunk-1", "doc-1-chunk-2"],
|
||||
)
|
||||
|
||||
def test_adapter_rejects_missing_source_chunk_ids(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "source_chunk_ids is required"):
|
||||
pdf_question_bank_adapter.run(
|
||||
question="What is the policy scope?",
|
||||
source_chunks_path=str(self.source_chunks_path),
|
||||
model="stub-model",
|
||||
client=mock.Mock(),
|
||||
)
|
||||
|
||||
def test_adapter_rejects_unknown_chunk_id(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "source_chunk_ids not found"):
|
||||
pdf_question_bank_adapter.run(
|
||||
question="What is the policy scope?",
|
||||
source_chunks_path=str(self.source_chunks_path),
|
||||
model="stub-model",
|
||||
client=mock.Mock(),
|
||||
source_chunk_ids='["missing-chunk"]',
|
||||
)
|
||||
|
||||
def test_adapter_falls_back_to_latest_run_directory_when_latest_alias_is_missing(self) -> None:
|
||||
artifact_root = self.temp_dir / "sample-pdf-question-bank"
|
||||
run_dir = artifact_root / "2026-06-10T02-01-32.508056+00-00"
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
latest_path = artifact_root / "latest" / "source_chunks.jsonl"
|
||||
run_chunks_path = run_dir / "source_chunks.jsonl"
|
||||
run_chunks_path.write_text(self.source_chunks_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
completions = FakeCompletions("It covers all employees.")
|
||||
client = type(
|
||||
"FakeClient",
|
||||
(),
|
||||
{"chat": type("Chat", (), {"completions": completions})()},
|
||||
)()
|
||||
|
||||
result = pdf_question_bank_adapter.run(
|
||||
question="What is the policy scope?",
|
||||
source_chunks_path=str(latest_path),
|
||||
model="stub-model",
|
||||
client=client,
|
||||
source_chunk_ids='["doc-1-chunk-1"]',
|
||||
doc_id="doc-1",
|
||||
)
|
||||
|
||||
self.assertEqual(result["contexts"], ["Scope covers all employees."])
|
||||
self.assertEqual(result["raw_response"]["resolved_chunk_ids"], ["doc-1-chunk-1"])
|
||||
|
||||
def test_online_evaluator_handles_dataset_build_rows_with_python_adapter(self) -> None:
|
||||
dataset_path = self.temp_dir / "question_bank.csv"
|
||||
pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"sample_id": "sample-1",
|
||||
"question": "What is the policy scope?",
|
||||
"ground_truth": "It covers all employees.",
|
||||
"doc_id": "doc-1",
|
||||
"doc_name": "doc1.pdf",
|
||||
"section_path": "Policy > Scope",
|
||||
"source_chunk_ids": '["doc-1-chunk-1"]',
|
||||
}
|
||||
]
|
||||
).to_csv(dataset_path, index=False)
|
||||
|
||||
completions = FakeCompletions("It covers all employees.")
|
||||
client = type(
|
||||
"FakeClient",
|
||||
(),
|
||||
{"chat": type("Chat", (), {"completions": completions})()},
|
||||
)()
|
||||
|
||||
scenario = Scenario(
|
||||
scenario_name="online-question-bank-test",
|
||||
mode="online",
|
||||
dataset=DatasetConfig(path=dataset_path),
|
||||
judge_model="judge-model",
|
||||
embedding_model="embedding-model",
|
||||
metrics=["faithfulness"],
|
||||
output_dir=self.temp_dir / "outputs",
|
||||
runtime=RuntimeConfig(batch_size=1),
|
||||
app_adapter=AppAdapterConfig(
|
||||
type="python",
|
||||
callable="apps.pdf_question_bank.adapter:run",
|
||||
static_kwargs={
|
||||
"source_chunks_path": str(self.source_chunks_path),
|
||||
"model": "stub-model",
|
||||
"client": client,
|
||||
},
|
||||
),
|
||||
)
|
||||
pipeline = MetricPipeline(metrics={"faithfulness": FakeMetric(0.8)})
|
||||
from rag_eval.adapters.python import PythonFunctionAdapter
|
||||
|
||||
evaluator = Evaluator(
|
||||
scenario=scenario,
|
||||
metric_pipeline=pipeline,
|
||||
app_adapter=PythonFunctionAdapter(scenario.app_adapter),
|
||||
)
|
||||
|
||||
result = evaluator.evaluate()
|
||||
self.assertEqual(len(result.valid_samples), 1)
|
||||
self.assertEqual(len(result.invalid_samples), 0)
|
||||
self.assertEqual(result.valid_samples[0].answer, "It covers all employees.")
|
||||
self.assertEqual(result.valid_samples[0].contexts, ["Scope covers all employees."])
|
||||
self.assertEqual(result.score_rows[0]["faithfulness"], 0.8)
|
||||
self.assertEqual(
|
||||
result.valid_samples[0].metadata["raw_response"]["resolved_chunk_ids"],
|
||||
["doc-1-chunk-1"],
|
||||
)
|
||||
|
||||
def test_load_sample_pdf_online_scenario(self) -> None:
|
||||
scenario = load_scenario("scenarios/online/sample-pdf-question-bank-online.yaml")
|
||||
self.assertEqual(scenario.mode, "online")
|
||||
self.assertEqual(scenario.dataset.path.name, "sample-pdf-question-bank.csv")
|
||||
self.assertEqual(scenario.output_dir.name, "sample-pdf-question-bank")
|
||||
self.assertEqual(scenario.runtime.max_samples, 45)
|
||||
self.assertEqual(scenario.app_adapter.callable, "apps.pdf_question_bank.adapter:run")
|
||||
self.assertTrue(
|
||||
str(scenario.app_adapter.static_kwargs["source_chunks_path"]).endswith("source_chunks.jsonl")
|
||||
)
|
||||
Reference in New Issue
Block a user