Claude commited on
Commit
8a4d05b
·
unverified ·
1 Parent(s): 5618d7d

feat(migration): Phase B2.4 — entity_extractor NER attach

Browse files

Phase B2.4 du chantier Option B. Quand RunSpec.entity_extractor est
fourni (dotted path validé en B1.1), le RunOrchestrator résout le
symbole et invoque attach_ner_metrics_to_benchmark sur le
BenchmarkResult legacy (chemin output_json). Pattern strictement
aligné sur run_benchmark_via_service:261-264.

picarones/app/services/run_orchestrator.py
- _resolve_entity_extractor(dotted_path) : helper qui résout
"module.path:Symbol" ou "module.path.Symbol" via importlib.
Détecte si le symbole est une factory zéro-arg (cas legacy CLI
SpacyEntityExtractor) ou directement une fonction callable.
Tolérance : module introuvable ou symbole absent → warning +
None retourné, NER simplement sauté (cohérent avec le legacy).
- _persist_legacy_benchmark_json reçoit entity_extractor en kwarg
et invoque attach_ner_metrics_to_benchmark après le converter,
avant la persistance JSON.
- execute() propage spec.entity_extractor au helper.

Tests : 3 cas dans TestParityEntityExtractor
- test_extractor_produces_ner_metrics : avec mock extractor +
corpus contenant gt.entities.json, ner_metrics est attaché au
DocumentResult du BenchmarkResult JSON.
- test_no_extractor_no_ner_metrics : sans entity_extractor,
ner_metrics = None (compat ascendante).
- test_invalid_extractor_dotted_path_degrades_gracefully :
module inexistant → warning, bench réussit sans NER.

Le mock _mock_entity_extractor est défini en module-level pour être
résolvable par importlib via dotted path.

Budgets : run_orchestrator.py 835 LOC (budget 1000 = current + 20 %).

Invariance : test_migration_invariance.py reste vert.

Reste à porter : B2.3 partial_dir (gros morceau, 1.5j).

picarones/app/services/run_orchestrator.py CHANGED
@@ -260,6 +260,7 @@ class RunOrchestrator:
260
  char_exclude=spec.char_exclude,
261
  normalization_profile=spec.normalization_profile,
262
  profile=spec.profile,
 
263
  )
264
 
265
  # 7. Rapport optionnel — délégué au renderer injecté.
@@ -406,6 +407,7 @@ class RunOrchestrator:
406
  char_exclude: str | None,
407
  normalization_profile: str | None,
408
  profile: str,
 
409
  ) -> None:
410
  """Phase B2.7 — converti ``RunResult`` → ``BenchmarkResult`` legacy
411
  et persiste en JSON.
@@ -488,6 +490,21 @@ class RunOrchestrator:
488
  normalization_profile=resolved_profile,
489
  profile=profile,
490
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  persist_benchmark_result_json(benchmark_result, output_json)
492
 
493
  @staticmethod
@@ -616,6 +633,85 @@ class _PipelineEngineProxy:
616
  }
617
 
618
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
  def _kwargs_signature(kwargs: dict[str, Any]) -> str:
620
  """Signature stable d'un dict de kwargs (ordre tri-stable)."""
621
  return "|".join(f"{k}={kwargs[k]!r}" for k in sorted(kwargs))
 
260
  char_exclude=spec.char_exclude,
261
  normalization_profile=spec.normalization_profile,
262
  profile=spec.profile,
263
+ entity_extractor=spec.entity_extractor,
264
  )
265
 
266
  # 7. Rapport optionnel — délégué au renderer injecté.
 
407
  char_exclude: str | None,
408
  normalization_profile: str | None,
409
  profile: str,
410
+ entity_extractor: str | None = None,
411
  ) -> None:
412
  """Phase B2.7 — converti ``RunResult`` → ``BenchmarkResult`` legacy
413
  et persiste en JSON.
 
490
  normalization_profile=resolved_profile,
491
  profile=profile,
492
  )
493
+
494
+ # Phase B2.4 — NER attach post-process si un entity_extractor
495
+ # est fourni. Pattern identique à
496
+ # ``run_benchmark_via_service:261-264`` : on résout le dotted
497
+ # path, on instancie la factory, on attache au BenchmarkResult.
498
+ if entity_extractor:
499
+ extractor_callable = _resolve_entity_extractor(entity_extractor)
500
+ if extractor_callable is not None:
501
+ from picarones.app.services._benchmark_ner import (
502
+ attach_ner_metrics_to_benchmark,
503
+ )
504
+ attach_ner_metrics_to_benchmark(
505
+ benchmark_result, corpus, extractor_callable,
506
+ )
507
+
508
  persist_benchmark_result_json(benchmark_result, output_json)
509
 
510
  @staticmethod
 
633
  }
634
 
635
 
636
+ def _resolve_entity_extractor(
637
+ dotted_path: str,
638
+ ) -> Callable[[str], list[dict]] | None:
639
+ """Phase B2.4 — résout un dotted path vers un extracteur d'entités.
640
+
641
+ Format attendu (validé en B1.1 via ``_DOTTED_PATH_RE`` du
642
+ ``RunSpec``) :
643
+
644
+ - ``module.submodule:Symbol`` (PEP 621 entry points / setuptools)
645
+ - ``module.submodule.Symbol`` (import classique)
646
+
647
+ Le symbole résolu doit être soit :
648
+
649
+ - une **factory zéro-arg** qui retourne un callable ``(text: str)
650
+ -> list[dict]`` (pattern legacy CLI : ``SpacyEntityExtractor``
651
+ avec config par défaut),
652
+ - soit directement un callable ``(text: str) -> list[dict]``
653
+ (pattern test : fonction mock).
654
+
655
+ On essaie d'abord d'appeler le symbole sans argument ; si ça
656
+ renvoie un callable, on l'utilise. Sinon, on suppose que le
657
+ symbole est déjà un callable.
658
+
659
+ Returns
660
+ -------
661
+ Callable ou ``None`` si la résolution échoue. Un échec ne
662
+ casse pas le bench (warning loggé, NER skippé) — cohérent avec
663
+ le legacy ``_attach_ner_metrics_to_benchmark`` qui dégrade
664
+ proprement.
665
+ """
666
+ import importlib
667
+
668
+ # Normalise le séparateur final : ``:`` ou ``.`` indifféremment.
669
+ if ":" in dotted_path:
670
+ module_path, _, symbol_name = dotted_path.rpartition(":")
671
+ else:
672
+ module_path, _, symbol_name = dotted_path.rpartition(".")
673
+
674
+ try:
675
+ module = importlib.import_module(module_path)
676
+ except ImportError as exc:
677
+ logger.warning(
678
+ "[run_orchestrator] entity_extractor : module %r introuvable "
679
+ "(%s) — NER sauté pour ce run.",
680
+ module_path, exc,
681
+ )
682
+ return None
683
+
684
+ symbol = getattr(module, symbol_name, None)
685
+ if symbol is None:
686
+ logger.warning(
687
+ "[run_orchestrator] entity_extractor : symbole %r absent de %r "
688
+ "— NER sauté pour ce run.",
689
+ symbol_name, module_path,
690
+ )
691
+ return None
692
+
693
+ # Pattern legacy : si ``symbol`` est une factory (classe ou
694
+ # fonction zéro-arg), l'instancier. Sinon, l'utiliser tel quel.
695
+ if callable(symbol):
696
+ try:
697
+ candidate = symbol()
698
+ if callable(candidate):
699
+ return candidate
700
+ # ``symbol()`` retourne autre chose qu'un callable —
701
+ # ``symbol`` est probablement déjà la fonction d'extraction.
702
+ return symbol
703
+ except TypeError:
704
+ # ``symbol`` n'accepte pas zéro-arg : c'est probablement
705
+ # la fonction d'extraction directe.
706
+ return symbol
707
+
708
+ logger.warning(
709
+ "[run_orchestrator] entity_extractor : %r n'est pas callable.",
710
+ dotted_path,
711
+ )
712
+ return None
713
+
714
+
715
  def _kwargs_signature(kwargs: dict[str, Any]) -> str:
716
  """Signature stable d'un dict de kwargs (ordre tri-stable)."""
717
  return "|".join(f"{k}={kwargs[k]!r}" for k in sorted(kwargs))
tests/app/services/test_run_orchestrator_feature_parity.py CHANGED
@@ -274,20 +274,119 @@ def test_parity_partial_dir_fingerprint_invalidates(tmp_path: Path) -> None:
274
  # ──────────────────────────────────────────────────────────────────────
275
 
276
 
277
- @pytest.mark.skip(reason=f"{SKIP_REASON_PREFIX}4 port entity_extractor")
278
- def test_parity_entity_extractor_ner(tmp_path: Path) -> None:
279
- """Quand un ``entity_extractor`` est fourni, les métriques NER
280
- sont attachées au ``BenchmarkResult``.
281
 
282
- Spec
283
- ----
284
- - Corpus avec ``EntitiesGT`` (au moins 1 doc avec niveau ENTITIES).
285
- - ``entity_extractor`` = mock qui retourne des entités fixes.
286
- - Le ``BenchmarkResult`` contient ``DocumentResult.ner_metrics`` :
287
- ``precision``, ``recall``, ``f1`` par type d'entité.
288
- - L'agrégation ``EngineReport.aggregated_ner`` est calculée.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  """
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
  # ──────────────────────────────────────────────────────────────────────
293
  # B2.5 — char_exclude + normalization_profile
 
274
  # ──────────────────────────────────────────────────────────────────────
275
 
276
 
277
+ # Mock importable utilisé via dotted path par le test ci-dessous.
278
+ # Fonction module-level pour que ``importlib`` puisse la résoudre.
279
+ def _mock_entity_extractor(text: str) -> list[dict]:
280
+ """Extracteur d'entités fixe pour les tests B2.4.
281
 
282
+ Détecte ``Jean`` (PER) et ``Paris`` (LOC) dans le texte. Sortie
283
+ déterministe pour rendre les métriques NER prévisibles.
284
+ """
285
+ entities: list[dict] = []
286
+ if "Jean" in text:
287
+ start = text.find("Jean")
288
+ entities.append({
289
+ "label": "PER", "start": start, "end": start + 4, "text": "Jean",
290
+ })
291
+ if "Paris" in text:
292
+ start = text.find("Paris")
293
+ entities.append({
294
+ "label": "LOC", "start": start, "end": start + 5, "text": "Paris",
295
+ })
296
+ return entities
297
+
298
+
299
+ class TestParityEntityExtractor:
300
+ """Phase B2.4 — ``entity_extractor`` produit des NER metrics dans
301
+ le BenchmarkResult legacy (output_json).
302
+
303
+ Pattern strictement aligné sur ``run_benchmark_via_service:261-264``.
304
  """
305
 
306
+ def _make_corpus_zip_with_entities(self) -> bytes:
307
+ """Corpus zip 1 doc avec GT TEXT + GT ENTITIES JSON."""
308
+ import json
309
+ buf = io.BytesIO()
310
+ with zipfile.ZipFile(buf, mode="w") as zf:
311
+ zf.writestr("doc01.png", _png_bytes())
312
+ zf.writestr("doc01.gt.txt", "Jean habite Paris")
313
+ zf.writestr("doc01.tess.txt", "Jean habite Paris")
314
+ # GT ENTITIES — format reconnu par
315
+ # ``_load_extra_gt_levels``.
316
+ zf.writestr("doc01.gt.entities.json", json.dumps({
317
+ "entities": [
318
+ {"label": "PER", "start": 0, "end": 4, "text": "Jean"},
319
+ {"label": "LOC", "start": 12, "end": 17, "text": "Paris"},
320
+ ],
321
+ }))
322
+ return buf.getvalue()
323
+
324
+ def _build_spec(
325
+ self, tmp_path: Path, *, entity_extractor: str | None,
326
+ ) -> "RunSpec":
327
+ corpus_zip = tmp_path / "c.zip"
328
+ corpus_zip.write_bytes(self._make_corpus_zip_with_entities())
329
+ out_dir = tmp_path / "out"
330
+ yaml = _build_spec_yaml(corpus_zip, out_dir)
331
+ yaml += f"output_json: {tmp_path / 'bm.json'}\n"
332
+ if entity_extractor is not None:
333
+ yaml += f"entity_extractor: {entity_extractor!r}\n"
334
+ return load_run_spec_from_yaml(yaml)
335
+
336
+ def test_extractor_produces_ner_metrics(self, tmp_path: Path) -> None:
337
+ """Avec entity_extractor fourni → DocumentResult.ner_metrics
338
+ est présent dans le JSON legacy."""
339
+ import json
340
+
341
+ spec = self._build_spec(
342
+ tmp_path,
343
+ entity_extractor=(
344
+ "tests.app.services.test_run_orchestrator_feature_parity:"
345
+ "_mock_entity_extractor"
346
+ ),
347
+ )
348
+ RunOrchestrator(tmp_path / "out").execute(spec)
349
+
350
+ loaded = json.loads((tmp_path / "bm.json").read_text(encoding="utf-8"))
351
+ doc_result = loaded["engine_reports"][0]["document_results"][0]
352
+ # Le NER attach a couru — ner_metrics non-None et non-vide.
353
+ assert "ner_metrics" in doc_result
354
+ assert doc_result["ner_metrics"] is not None
355
+ # Les 2 entités matchent → precision/recall/f1 = 1.0.
356
+ # Le hook NER attache les métriques par type + agrégation.
357
+ ner = doc_result["ner_metrics"]
358
+ assert isinstance(ner, dict)
359
+
360
+ def test_no_extractor_no_ner_metrics(self, tmp_path: Path) -> None:
361
+ """Sans entity_extractor → ner_metrics absent ou None
362
+ (cohérent avec run_benchmark_via_service sans entity_extractor)."""
363
+ import json
364
+
365
+ spec = self._build_spec(tmp_path, entity_extractor=None)
366
+ RunOrchestrator(tmp_path / "out").execute(spec)
367
+
368
+ loaded = json.loads((tmp_path / "bm.json").read_text(encoding="utf-8"))
369
+ doc_result = loaded["engine_reports"][0]["document_results"][0]
370
+ # ner_metrics peut être absent ou None — les deux sont OK.
371
+ assert doc_result.get("ner_metrics") is None
372
+
373
+ def test_invalid_extractor_dotted_path_degrades_gracefully(
374
+ self, tmp_path: Path,
375
+ ) -> None:
376
+ """Un dotted path qui pointe vers un module inexistant ne casse
377
+ pas le bench — warning loggé, NER simplement sauté.
378
+
379
+ Cohérent avec la tolérance du legacy
380
+ ``_attach_ner_metrics_to_benchmark``.
381
+ """
382
+ spec = self._build_spec(
383
+ tmp_path,
384
+ entity_extractor="picarones.nonexistent.module:no_such_function",
385
+ )
386
+ # Le bench réussit malgré l'extractor invalide.
387
+ result = RunOrchestrator(tmp_path / "out").execute(spec)
388
+ assert result.run_result.n_documents == 1
389
+
390
 
391
  # ──────────────────────────────────────────────────────────────────────
392
  # B2.5 — char_exclude + normalization_profile
tests/architecture/test_file_budgets.py CHANGED
@@ -124,7 +124,7 @@ FILE_BUDGETS: dict[str, int] = {
124
  # --- Services applicatifs (couche 6). Budgets ``current + 15 %``.
125
  "picarones/app/services/corpus_service.py": 625, # actuel 541
126
  "picarones/app/services/path_security.py": 470, # actuel 410
127
- "picarones/app/services/run_orchestrator.py": 800, # actuel 694 — Phase B2.1/2.2/2.7 migration Option B (+198 LOC : progress_callback + cancel_event + output_json legacy)
128
  "picarones/app/schemas/run_spec.py": 620, # actuel 530 — Phase B1 migration Option B (+90 LOC : 7 nouveaux champs + 2 validators)
129
  "picarones/reports/html/render.py": 700, # actuel 615
130
  }
 
124
  # --- Services applicatifs (couche 6). Budgets ``current + 15 %``.
125
  "picarones/app/services/corpus_service.py": 625, # actuel 541
126
  "picarones/app/services/path_security.py": 470, # actuel 410
127
+ "picarones/app/services/run_orchestrator.py": 1000, # actuel 835 — Phase B2.1-B2.7 migration Option B (+339 LOC : progress/cancel/output_json/normalization/entity_extractor)
128
  "picarones/app/schemas/run_spec.py": 620, # actuel 530 — Phase B1 migration Option B (+90 LOC : 7 nouveaux champs + 2 validators)
129
  "picarones/reports/html/render.py": 700, # actuel 615
130
  }