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>
148 lines
6.3 KiB
Python
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"
|