"""Sprint A14-S41 — ``artifacts_index.jsonl`` séparé. Tests de la séparation introduite au S41 : - ``BenchmarkService.persist`` produit un 4ᵉ fichier ``artifacts_index.jsonl`` distinct des ``pipeline_results.jsonl``. - ``pipeline_results.jsonl`` ne contient plus la liste des artefacts. - Round-trip via ``HtmlReportRenderer.load_run_result`` ré-attache les artefacts depuis l'index séparé. - Compatibilité descendante : un run persisté sans ``artifacts_index.jsonl`` (legacy avant S41) reste lisible. """ from __future__ import annotations import json from pathlib import Path from picarones.app.results import RunDocumentResult, RunResult from picarones.domain import ( Artifact, ArtifactType, ProvenanceRecord, RunManifest, utcnow, ) from picarones.pipeline.types import PipelineResult, StepResult from picarones.reports.html.render import HtmlReportRenderer def _make_run_result_with_artifacts() -> RunResult: """Construit un RunResult en mémoire avec quelques artefacts.""" started = utcnow() completed = utcnow() manifest = RunManifest( run_id="run_001", corpus_name="demo", n_documents=2, pipeline_names=("ocr_only",), view_specs=(), code_version="1.0.0-s41-test", started_at=started, completed_at=completed, ) artifact1 = Artifact( id="doc01:image", document_id="doc01", type=ArtifactType.IMAGE, content_hash="a" * 64, ) artifact2 = Artifact( id="doc01:ocr_only:raw_text", document_id="doc01", type=ArtifactType.RAW_TEXT, content_hash="b" * 64, produced_by_step="ocr", provenance=ProvenanceRecord( code_version="1.0.0-s41-test", parameters_hash="c" * 64, ), ) pr1 = PipelineResult( pipeline_name="ocr_only", document_id="doc01", step_results=( StepResult( step_id="ocr", succeeded=True, duration_seconds=0.5, produced_artifacts={"raw_text": "doc01:ocr_only:raw_text"}, ), ), succeeded=True, duration_seconds=0.5, artifacts=(artifact1, artifact2), ) return RunResult( manifest=manifest, document_results=( RunDocumentResult( document_id="doc01", pipeline_results=(pr1,), view_results=(), ), ), ) def _build_benchmark_service(): """Crée un BenchmarkService minimal pour tester persist().""" from picarones.app.services.benchmark_service import BenchmarkService from picarones.evaluation.views.executor import ( DefaultEvaluationViewExecutor, ) from picarones.evaluation.registry import MetricRegistry from picarones.evaluation.projectors.registry import ProjectorRegistry from picarones.pipeline.executor import PipelineExecutor from picarones.pipeline.runner import CorpusRunner runner = CorpusRunner( PipelineExecutor(adapter_resolver=lambda n: None), max_in_flight=1, timeout_seconds_per_doc=1.0, poll_interval_seconds=0.001, ) view_executor = DefaultEvaluationViewExecutor.from_registries( MetricRegistry(), ProjectorRegistry(), lambda art: "", ) return BenchmarkService( corpus_runner=runner, view_executor=view_executor, code_version="1.0.0-s41-test", ) # ────────────────────────────────────────────────────────────────────── # Tests # ────────────────────────────────────────────────────────────────────── class TestArtifactsIndexSeparation: def test_persist_writes_4_files(self, tmp_path: Path) -> None: """``persist`` doit retourner les 4 chemins (manifest + pipeline_results + artifacts_index + view_results).""" bench = _build_benchmark_service() result = _make_run_result_with_artifacts() paths = bench.persist(result, tmp_path) assert "manifest" in paths assert "pipeline_results" in paths assert "artifacts_index" in paths assert "view_results" in paths for kind, path in paths.items(): assert path.exists(), f"{kind} non écrit" def test_artifacts_index_jsonl_format(self, tmp_path: Path) -> None: """Chaque ligne contient un artefact + document_id + pipeline_name.""" bench = _build_benchmark_service() result = _make_run_result_with_artifacts() bench.persist(result, tmp_path) index_path = tmp_path / "artifacts_index.jsonl" lines = [ line for line in index_path.read_text( encoding="utf-8", ).splitlines() if line.strip() ] assert len(lines) == 2 # 2 artefacts dans le RunResult for line in lines: rec = json.loads(line) assert "document_id" in rec assert "pipeline_name" in rec assert rec["document_id"] == "doc01" assert rec["pipeline_name"] == "ocr_only" assert "id" in rec assert "type" in rec def test_pipeline_results_jsonl_no_longer_contains_artifacts( self, tmp_path: Path, ) -> None: """``pipeline_results.jsonl`` ne porte plus la liste des artefacts (extraite vers l'index).""" bench = _build_benchmark_service() result = _make_run_result_with_artifacts() bench.persist(result, tmp_path) pipelines_path = tmp_path / "pipeline_results.jsonl" lines = [ line for line in pipelines_path.read_text( encoding="utf-8", ).splitlines() if line.strip() ] assert len(lines) == 1 rec = json.loads(lines[0]) # Le champ artifacts ne doit pas apparaître (ou être vide). assert ( "artifacts" not in rec or rec.get("artifacts") == [] or rec.get("artifacts") is None ) # Mais les autres champs (step_results, etc.) sont présents. assert rec["pipeline_name"] == "ocr_only" assert "step_results" in rec class TestRoundTripWithIndex: def test_load_run_result_reattaches_artifacts( self, tmp_path: Path, ) -> None: """``load_run_result`` lit l'index séparé et ré-attache les artefacts à chaque PipelineResult.""" bench = _build_benchmark_service() result = _make_run_result_with_artifacts() bench.persist(result, tmp_path) loaded = HtmlReportRenderer.load_run_result(tmp_path) assert len(loaded.document_results) == 1 loaded_pr = loaded.document_results[0].pipeline_results[0] assert len(loaded_pr.artifacts) == 2 # Les content_hash doivent être préservés. loaded_hashes = {a.content_hash for a in loaded_pr.artifacts} assert "a" * 64 in loaded_hashes assert "b" * 64 in loaded_hashes class TestBackwardCompatNoIndex: def test_load_works_without_artifacts_index_file( self, tmp_path: Path, ) -> None: """Un run legacy persisté avant S41 (sans artifacts_index.jsonl) reste chargeable — les pipeline_results portent alors leurs artefacts directement (cas legacy).""" # Simule un run persisté à l'ancienne : pipeline_results # contient artifacts inline, pas de artifacts_index.jsonl. manifest = { "run_id": "legacy", "corpus_name": "demo", "n_documents": 1, "pipeline_names": ["ocr_only"], "view_specs": [], "code_version": "0.9.0-pre-s41", "started_at": "2026-05-06T10:00:00Z", "completed_at": "2026-05-06T10:01:00Z", "dependencies_lock": {}, "metadata": {}, } (tmp_path / "run_manifest.json").write_text( json.dumps(manifest), encoding="utf-8", ) legacy_pipeline_record = { "document_id": "doc01", "pipeline_name": "ocr_only", "step_results": [ { "step_id": "ocr", "succeeded": True, "duration_seconds": 0.5, "produced_artifacts": {"raw_text": "doc01:ocr_only:raw_text"}, "error": None, }, ], "succeeded": True, "duration_seconds": 0.5, "artifacts": [ { "id": "doc01:ocr_only:raw_text", "document_id": "doc01", "type": "raw_text", "content_hash": "b" * 64, "produced_by_step": "ocr", "provenance": None, "uri": None, }, ], } (tmp_path / "pipeline_results.jsonl").write_text( json.dumps(legacy_pipeline_record) + "\n", encoding="utf-8", ) (tmp_path / "view_results.jsonl").write_text( "", encoding="utf-8", ) # Pas de artifacts_index.jsonl — legacy. loaded = HtmlReportRenderer.load_run_result(tmp_path) loaded_pr = loaded.document_results[0].pipeline_results[0] assert len(loaded_pr.artifacts) == 1 assert loaded_pr.artifacts[0].content_hash == "b" * 64