batchCooking/services/tech-step-intent-service/tests/test_locale_pipeline_entities.py
Nicolas 690125bec3 fix(recipes): corrige les matches dupliques et le timeout de warm-up des tests CI
Deux bugs reels trouves par la premiere execution CI de la migration
node-nlp -> tech-step-intent-service :

1. PhraseMatcher retourne tous les matches y compris chevauchants — un
   synonyme comme "fondre" litteralement contenu dans "faire fondre" (tous
   deux synonymes de `melt`) produisait deux candidats separes pour la meme
   technique, dupliquant son techStepId dans le resultat final. Fixe avec
   spacy.util.filter_spans (garde le plus long match par position) dans
   LocalePipeline.process. Test de non-regression ajoute.

2. La suite Mocha construit `app` directement via createApp(), sans jamais
   passer par server.ts — le warm-up (POST /v1/train fr+en sur le corpus
   complet) se declenchait donc paresseusement dans le premier test qui
   appelait le classifieur, depassant le timeout Mocha de 10s par test.
   Fixe par un root hook plugin Mocha (test-support/mocha-root-hooks.ts,
   .mocharc.json) qui reset la DB et warm up le classifieur une seule fois
   avant toute suite, avec son propre timeout de 60s.

Verifie : 27/27 tests pytest du service (dont le nouveau test de
non-regression), lint + build complets du monorepo. La suite Mocha
elle-meme n'a toujours pas pu etre executee dans cet environnement (pas de
Postgres disponible ici) — a confirmer via la CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 20:23:35 +02:00

148 lines
6.3 KiB
Python

"""Rejoue les cas d'offsets caractère exacts et d'insensibilité accents/casse
de `tech-step-matcher.test.ts` (`apps/api/test/recipe-matching/tech-step-matcher.test.ts`)
contre le `PhraseMatcher`/`diacritics_normalizer` de `LocalePipeline` — le
point de fidélité le plus critique de cette migration (voir le plan). Doit
être vert *avant* de brancher `apps/api` dessus.
Ces tests entraînent un pipeline minimal (pas le corpus complet
`TECH_STEP_TRAINING_DATA`, propriété de `apps/api`) avec juste assez de
`synonyms`/`utterances` pour reproduire chaque cas — le textcat n'est pas ce
qui est vérifié ici (voir `test_locale_pipeline_intent.py`).
"""
import pytest
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
# Un jeu d'entrées minimal mais réaliste, reprenant les synonymes réels de
# `tech-step-training-data.ts` pour "preheat"/"melt" qui rendent les cas
# `tech-step-matcher.test.ts` exacts (voir ce fichier, lignes 155/787).
_FR_ENTRIES = [
TrainEntry(
uid="preheat",
synonyms=["préchauffer", "poêle chaude"],
utterances=["préchauffer le four à 180 degrés", "mettre la poêle sur feu vif"],
),
TrainEntry(
# `synonyms` deliberately includes both "fondre" (standalone) and
# "faire fondre" (containing it) — mirrors the real corpus
# (`tech-step-training-data.ts`) exactly, and is what
# `test_does_not_double_match_a_synonym_nested_in_a_longer_one`
# below exists to guard: the `PhraseMatcher` reports both as
# separate overlapping matches, `LocalePipeline.process` must
# collapse them into one.
uid="melt",
synonyms=["fondre", "fondu", "faire fondre", "faire chauffer"],
utterances=["faire fondre le beurre", "faire chauffer une noix de beurre"],
),
TrainEntry(
uid="simmer",
synonyms=["mijoter"],
utterances=["faire mijoter à feu doux", "laisser mijoter à couvert"],
),
]
@pytest.fixture(scope="module")
def fr_pipeline() -> LocalePipeline:
pipeline = LocalePipeline("fr")
pipeline.train(_FR_ENTRIES)
return pipeline
def test_matches_an_exact_expression(fr_pipeline: LocalePipeline):
result = fr_pipeline.process("Faire mijoter à feu doux")
assert [entity.uid for entity in result.entities] == ["simmer"]
entity = result.entities[0]
text = "Faire mijoter à feu doux"
assert text[entity.start : entity.end].lower() == "mijoter"
def test_is_case_and_accent_insensitive(fr_pipeline: LocalePipeline):
result = fr_pipeline.process("FAIRE MIJOTER")
assert [entity.uid for entity in result.entities] == ["simmer"]
def test_returns_no_entities_when_nothing_matches(fr_pipeline: LocalePipeline):
result = fr_pipeline.process("Ranger les couverts dans le tiroir")
assert result.entities == []
def test_returns_empty_for_an_empty_text(fr_pipeline: LocalePipeline):
result = fr_pipeline.process("")
assert result.entities == []
assert result.intent is None
assert result.score == 0.0
def test_untrained_locale_returns_empty_without_error():
pipeline = LocalePipeline("en")
result = pipeline.process("melt the butter")
assert result.entities == []
assert result.intent is None
assert result.score == 0.0
def test_detects_two_techniques_with_exact_tight_spans_reading_order(fr_pipeline: LocalePipeline):
text = "Préchauffer la poêle, puis faire fondre le beurre"
result = fr_pipeline.process(text)
uids_by_start = sorted(((entity.start, entity.uid) for entity in result.entities))
assert [uid for _, uid in uids_by_start] == ["preheat", "melt"]
preheat_entity = next(e for e in result.entities if e.uid == "preheat")
melt_entity = next(e for e in result.entities if e.uid == "melt")
assert text[preheat_entity.start : preheat_entity.end].lower() == "préchauffer"
assert text[melt_entity.start : melt_entity.end].lower() == "faire fondre"
def test_does_not_double_match_a_synonym_nested_in_a_longer_one(fr_pipeline: LocalePipeline):
# Regression: "fondre" is itself a substring of "faire fondre" — both
# are registered as `melt` synonyms (like the real corpus). Without
# `filter_spans` in `LocalePipeline.process`, the `PhraseMatcher`
# reports *both* overlapping matches, producing `melt` twice in
# apps/api's final `matchTechSteps` output instead of once (caught by a
# real CI failure in `tech-step-matcher.test.ts` once this service
# replaced node-nlp).
text = "faire fondre le beurre"
result = fr_pipeline.process(text)
assert [entity.uid for entity in result.entities] == ["melt"]
entity = result.entities[0]
assert text[entity.start : entity.end] == "faire fondre"
def test_matches_the_classic_poele_chaude_example_with_exact_offsets(fr_pipeline: LocalePipeline):
# Le cas motivant les context spans côté apps/api (tech-step-matcher.test.ts) :
# le mot-clé de `preheat` est un groupe nominal ("poêle chaude"), pas un
# verbe. Offsets attendus IDENTIQUES à ceux du test TS d'origine :
# preheat -> [9, 21) ("poêle chaude"), melt -> [23, 37) ("faire chauffer").
text = "Dans une poêle chaude, faire chauffer une noix de beurre"
result = fr_pipeline.process(text)
preheat_entity = next(e for e in result.entities if e.uid == "preheat")
melt_entity = next(e for e in result.entities if e.uid == "melt")
assert (preheat_entity.start, preheat_entity.end) == (9, 21)
assert text[preheat_entity.start : preheat_entity.end] == "poêle chaude"
assert (melt_entity.start, melt_entity.end) == (23, 37)
assert text[melt_entity.start : melt_entity.end] == "faire chauffer"
def test_chop_matches_english_text_tight_span():
pipeline = LocalePipeline("en")
pipeline.train(
[
TrainEntry(
uid="chop",
synonyms=["chop"],
utterances=["chop the onions finely", "finely chop the garlic"],
),
TrainEntry(uid="boil", synonyms=["boil"], utterances=["bring to the boil", "boil the water"]),
]
)
text = "Chop the onions finely"
result = pipeline.process(text)
chop_entity = next(e for e in result.entities if e.uid == "chop")
assert (chop_entity.start, chop_entity.end) == (0, 4)
assert text[chop_entity.start : chop_entity.end] == "Chop"