Spaces:
Running
Running
| """Tests Sprint 40 — backend extracteur NER + câblage runner. | |
| Couvre : | |
| 1. ``SpacyEntityExtractor`` lazy-importe spaCy ; sans spaCy installé, | |
| l'extracteur retourne ``[]`` avec un warning explicite. | |
| 2. ``is_spacy_available`` reflète l'état réel. | |
| 3. ``get_extractor(profile)`` accepte une clé de profil ou un nom de | |
| modèle direct. | |
| 4. ``DocumentResult.ner_metrics`` est sérialisé via ``as_dict`` | |
| uniquement quand renseigné, et libéré par ``compact()``. | |
| 5. ``EngineReport.aggregated_ner`` apparaît dans ``as_dict`` quand | |
| renseigné (rétrocompat sinon). | |
| 6. Câblage runner avec un extracteur **mock** (callable injecté) : | |
| - ``ner_metrics`` est attaché aux DR dont le doc a une GT entités ; | |
| - ``aggregated_ner`` est calculé sur l'EngineReport ; | |
| - les docs sans GT entités sont ignorés. | |
| 7. Sans extracteur fourni au runner, rien n'est calculé (rétrocompat). | |
| 8. Un extracteur qui lève sur un doc spécifique → warning, autres docs | |
| inchangés. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| import pytest | |
| from picarones.core.corpus import Corpus, Document, EntitiesGT, GTLevel, TextGT | |
| from picarones.measurements.ner_backends import ( | |
| SPACY_PROFILES, | |
| SpacyEntityExtractor, | |
| get_extractor, | |
| is_spacy_available, | |
| ) | |
| from picarones.core.results import DocumentResult, EngineReport | |
| from picarones.measurements.runner import _aggregate_ner, _attach_ner_metrics | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 1-3. Backend SpacyEntityExtractor | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestSpacyExtractor: | |
| def test_falls_back_silently_without_spacy( | |
| self, caplog: pytest.LogCaptureFixture | |
| ) -> None: | |
| """Sans spaCy installé, l'extracteur retourne [] avec un warning | |
| explicite et ne lève pas.""" | |
| ext = SpacyEntityExtractor("fr_core_news_sm") | |
| with caplog.at_level("WARNING", logger="picarones.measurements.ner_backends"): | |
| result = ext("Marie de Bourgogne en 1477.") | |
| # Sans spaCy, on a toujours [] et un warning | |
| if not is_spacy_available(): | |
| assert result == [] | |
| assert any( | |
| "spaCy" in rec.message or "spacy" in rec.message | |
| for rec in caplog.records | |
| ) | |
| assert ext.available is False | |
| def test_empty_text_returns_empty(self) -> None: | |
| ext = SpacyEntityExtractor() | |
| assert ext("") == [] | |
| def test_idempotent_load(self) -> None: | |
| """L'appel répété ne re-tente pas le chargement.""" | |
| ext = SpacyEntityExtractor("inexistant_model_xyz") | |
| ext("test") # premier appel : tentative de chargement | |
| ext("test") # deuxième : pas de re-tentative | |
| assert ext._loaded is True | |
| class TestProfilesAndFactory: | |
| def test_known_profiles_listed(self) -> None: | |
| for key in ("fr", "en", "multilingual"): | |
| assert key in SPACY_PROFILES | |
| def test_get_extractor_with_known_profile(self) -> None: | |
| ext = get_extractor("fr") | |
| assert isinstance(ext, SpacyEntityExtractor) | |
| assert ext.model_name == SPACY_PROFILES["fr"] | |
| def test_get_extractor_with_direct_model_name(self) -> None: | |
| ext = get_extractor("custom_model_name") | |
| assert ext.model_name == "custom_model_name" | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 4-5. DocumentResult / EngineReport sérialisation | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def _make_document_result( | |
| doc_id: str = "d1", | |
| hypothesis: str = "Marie de Bourgogne en 1477.", | |
| ner_metrics: dict | None = None, | |
| ) -> DocumentResult: | |
| from picarones.measurements.metrics import MetricsResult | |
| return DocumentResult( | |
| doc_id=doc_id, | |
| image_path="/tmp/x.png", | |
| ground_truth="Marie de Bourgogne en 1477.", | |
| hypothesis=hypothesis, | |
| metrics=MetricsResult( | |
| cer=0.0, cer_nfc=0.0, cer_caseless=0.0, | |
| wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0, | |
| reference_length=27, hypothesis_length=27, | |
| ), | |
| duration_seconds=0.1, | |
| ner_metrics=ner_metrics, | |
| ) | |
| class TestModelSerialization: | |
| def test_ner_metrics_omitted_when_none(self) -> None: | |
| dr = _make_document_result(ner_metrics=None) | |
| d = dr.as_dict() | |
| assert "ner_metrics" not in d | |
| def test_ner_metrics_present_when_set(self) -> None: | |
| dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}}) | |
| d = dr.as_dict() | |
| assert d["ner_metrics"] == {"global": {"f1": 0.8}} | |
| def test_compact_clears_ner_metrics(self) -> None: | |
| # Sprint A14-S1 — A.I.0 P0 : ``compact()`` est désormais no-op | |
| # par défaut (cf. core/results.py). Le comportement | |
| # "efface les analyses" est explicitement opt-in via | |
| # ``drop_analyses=True``. | |
| dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}}) | |
| dr.compact(drop_analyses=True) | |
| assert dr.ner_metrics is None | |
| def test_compact_default_is_noop(self) -> None: | |
| """Sprint A14-S1 — défaut sans argument ne touche à rien.""" | |
| dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}}) | |
| dr.compact() | |
| assert dr.ner_metrics == {"global": {"f1": 0.8}} | |
| def test_engine_report_aggregated_ner_omitted_when_none(self) -> None: | |
| rep = EngineReport( | |
| engine_name="t", engine_version="1", engine_config={}, | |
| document_results=[_make_document_result()], | |
| ) | |
| d = rep.as_dict() | |
| assert "aggregated_ner" not in d | |
| def test_engine_report_aggregated_ner_included_when_set(self) -> None: | |
| rep = EngineReport( | |
| engine_name="t", engine_version="1", engine_config={}, | |
| document_results=[_make_document_result()], | |
| aggregated_ner={"global": {"f1": 0.75}, "doc_count": 1}, | |
| ) | |
| d = rep.as_dict() | |
| assert d["aggregated_ner"] == {"global": {"f1": 0.75}, "doc_count": 1} | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 6. Câblage runner avec extracteur mock | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def _mock_extractor_factory(per_text: dict[str, list[dict]]) -> callable: | |
| """Construit un extracteur qui renvoie une réponse prédéfinie par | |
| texte d'entrée — utile pour tester le câblage runner sans dépendance | |
| NLP réelle.""" | |
| def _extract(text: str) -> list[dict]: | |
| return per_text.get(text, []) | |
| return _extract | |
| def _corpus_with_entities(tmp_path: Path) -> Corpus: | |
| """Crée un corpus minimal avec deux documents, dont un seul porte | |
| une GT entités.""" | |
| image1 = tmp_path / "doc1.png" | |
| image2 = tmp_path / "doc2.png" | |
| image1.write_bytes(b"fake") | |
| image2.write_bytes(b"fake") | |
| doc1 = Document( | |
| image_path=image1, | |
| ground_truth="Marie de Bourgogne en 1477.", | |
| ground_truths={ | |
| GTLevel.TEXT: TextGT(text="Marie de Bourgogne en 1477."), | |
| GTLevel.ENTITIES: EntitiesGT(entities=[ | |
| {"label": "PER", "start": 0, "end": 17, "text": "Marie de Bourgogne"}, | |
| {"label": "DATE", "start": 21, "end": 25, "text": "1477"}, | |
| ]), | |
| }, | |
| ) | |
| doc2 = Document( | |
| image_path=image2, | |
| ground_truth="Texte sans GT entités.", | |
| ) | |
| return Corpus(name="test", documents=[doc1, doc2]) | |
| class TestRunnerWiring: | |
| def test_attach_ner_only_for_docs_with_entities(self, tmp_path: Path) -> None: | |
| corpus = _corpus_with_entities(tmp_path) | |
| # Mock extractor : renvoie la même chose que la GT pour doc1 (parfait) | |
| extractor = _mock_extractor_factory({ | |
| "Marie de Bourgogne en 1477.": [ | |
| {"label": "PER", "start": 0, "end": 17, "text": "Marie de Bourgogne"}, | |
| {"label": "DATE", "start": 21, "end": 25, "text": "1477"}, | |
| ], | |
| "Texte sans GT entités.": [], # pas appelé en réalité | |
| }) | |
| dr1 = _make_document_result( | |
| doc_id="doc1", hypothesis="Marie de Bourgogne en 1477.", | |
| ) | |
| dr2 = _make_document_result( | |
| doc_id="doc2", hypothesis="Texte sans GT entités.", | |
| ) | |
| _attach_ner_metrics(corpus, [dr1, dr2], extractor) | |
| # doc1 : a une GT entités → ner_metrics calculé | |
| assert dr1.ner_metrics is not None | |
| assert dr1.ner_metrics["global"]["f1"] == pytest.approx(1.0) | |
| # doc2 : pas de GT entités → rien | |
| assert dr2.ner_metrics is None | |
| def test_aggregate_ner_combines_doc_metrics(self, tmp_path: Path) -> None: | |
| # Deux documents avec ner_metrics fournis | |
| dr1 = _make_document_result() | |
| dr1.ner_metrics = { | |
| "global": {"precision": 1.0, "recall": 0.5, "f1": 2/3, "support": 2}, | |
| "per_category": { | |
| "PER": {"precision": 1.0, "recall": 0.5, "f1": 2/3, "support": 2}, | |
| }, | |
| "true_positives": 1, "false_positives": 0, "false_negatives": 1, | |
| "hallucinated_entities": [], "missed_entities": [{"label": "PER"}], | |
| "iou_threshold": 0.5, | |
| } | |
| dr2 = _make_document_result() | |
| dr2.ner_metrics = { | |
| "global": {"precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 1}, | |
| "per_category": { | |
| "LOC": {"precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 1}, | |
| }, | |
| "true_positives": 1, "false_positives": 0, "false_negatives": 0, | |
| "hallucinated_entities": [], "missed_entities": [], | |
| "iou_threshold": 0.5, | |
| } | |
| agg = _aggregate_ner([dr1, dr2]) | |
| assert agg is not None | |
| assert agg["doc_count"] == 2 | |
| assert agg["true_positives"] == 2 | |
| assert agg["false_negatives"] == 1 | |
| assert agg["missed_total"] == 1 | |
| # Micro F1 global : TP=2, FP=0, FN=1 → P=1, R=2/3, F1=0.8 | |
| assert agg["global"]["f1"] == pytest.approx(0.8) | |
| def test_aggregate_returns_none_when_no_ner_metrics(self) -> None: | |
| dr = _make_document_result(ner_metrics=None) | |
| assert _aggregate_ner([dr]) is None | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 7. Rétrocompat : sans extractor, rien ne change | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestBackwardCompat: | |
| def test_no_extractor_no_calculation(self, tmp_path: Path) -> None: | |
| """Si entity_extractor=None, le runner ne touche pas aux | |
| ner_metrics. On valide que le DocumentResult par défaut a bien | |
| ner_metrics=None — le runner ne l'attribue pas spontanément.""" | |
| # Les deux DRs ne reçoivent jamais d'extracteur ; ils restent | |
| # tels quels. Le corpus n'est pas nécessaire ici (valide la | |
| # rétrocompat du modèle). | |
| dr1 = _make_document_result(doc_id="doc1") | |
| dr2 = _make_document_result(doc_id="doc2") | |
| assert dr1.ner_metrics is None | |
| assert dr2.ner_metrics is None | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 8. Robustesse : extracteur qui lève | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestRobustness: | |
| def test_extractor_raising_does_not_break_others( | |
| self, tmp_path: Path, caplog: pytest.LogCaptureFixture | |
| ) -> None: | |
| """Si l'extracteur lève sur le doc1, le doc2 doit tout de même | |
| être traité (et inversement, ici doc1 est le seul avec GT | |
| entités, donc on vérifie qu'aucun crash ne casse le runner).""" | |
| corpus = _corpus_with_entities(tmp_path) | |
| def _broken_extractor(text: str) -> list[dict]: | |
| raise RuntimeError("boom") | |
| dr1 = _make_document_result( | |
| doc_id="doc1", hypothesis="Marie de Bourgogne en 1477.", | |
| ) | |
| with caplog.at_level("WARNING", logger="picarones.measurements.runner"): | |
| _attach_ner_metrics(corpus, [dr1], _broken_extractor) | |
| # Pas de propagation, ner_metrics reste None | |
| assert dr1.ner_metrics is None | |
| # Et un warning explicite a été émis | |
| assert any("ner.attach" in rec.message for rec in caplog.records) | |