batchCooking/services/tech-step-intent-service/tests/test_utensil_matching.py
Nicolas 4e0a9ce8d2 feat(recipes): associe ingredients, quantites et ustensiles aux techniques detectees
Etend le pipeline de detection de techniques (tech-step-matcher.ts) pour
resoudre, par clause, les metadonnees qui accompagnent une technique
detectee :

- Ingredients : nouvelle fonction findIngredientMentions (ingredient-matcher.ts)
  qui scanne le texte d'une clause contre le catalogue Ingredient existant
  (reutilise INGREDIENT_LABELS_FR/EN deja utilise par matchIngredientName),
  avec extraction best-effort de la quantite+unite immediatement avant la
  mention.
- Ustensiles : nouveau catalogue Utensil (Prisma) + second PhraseMatcher
  cote service Python (intent_service/utensil_vocabulary.py), independant
  du textcat des techniques (pas d'interpretation necessaire pour un
  ustensile). POST /v1/process distingue desormais chaque entite via un
  champ kind (technique|utensil).
- Persistance : deux nouvelles tables StepTechStepIngredient/
  StepTechStepUtensil, liees a StepTechStep par sa cle composite
  (stepId, order), peuplees au moment du matching (recipe.service.ts) et
  exposees via StepTechStepView (packages/shared).

Aucune analyse syntaxique ajoutee (le parser spaCy reste exclu du
pipeline) : l'association se fait par appartenance a la clause deja
calculee par splitIntoClauses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:23:17 +02:00

78 lines
3.1 KiB
Python

"""Couvre `LocalePipeline`'s second `PhraseMatcher` (ustensiles,
`utensil_vocabulary.py`) — même style que `test_locale_pipeline_entities.py`
(offsets exacts, insensibilité accents/casse), mais contre le vocabulaire
*réel* (`UTENSIL_VOCABULARY`, statique, construit par `preload()` — pas
besoin d'un jeu de test dédié comme pour les techniques, voir
`LocalePipeline.preload`'s own comment)."""
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
# Un `train()` minimal suffit — le `PhraseMatcher` d'ustensiles est
# construit par `preload()` (appelé par `train()`), indépendamment du
# `TrainEntry` de techniques passé ici (voir `preload()`'s own comment sur
# pourquoi les deux ne sont pas couplés).
_MINIMAL_ENTRIES = [
TrainEntry(uid="melt", synonyms=["fondre"], utterances=["faire fondre le beurre"]),
TrainEntry(uid="simmer", synonyms=["mijoter"], utterances=["faire mijoter à feu doux"]),
]
def _fr_pipeline() -> LocalePipeline:
pipeline = LocalePipeline("fr")
pipeline.train(_MINIMAL_ENTRIES)
return pipeline
def test_matches_a_real_utensil_with_exact_span():
pipeline = _fr_pipeline()
text = "Dans une poêle chaude, faire fondre le beurre"
result = pipeline.process(text)
pan_entities = [e for e in result.entities if e.uid == "pan"]
assert len(pan_entities) == 1
entity = pan_entities[0]
assert entity.kind == "utensil"
assert text[entity.start : entity.end] == "poêle"
def test_is_case_and_accent_insensitive():
pipeline = _fr_pipeline()
result = pipeline.process("Verser dans la POÊLE")
utensil_uids = [e.uid for e in result.entities if e.kind == "utensil"]
assert utensil_uids == ["pan"]
def test_matches_a_multi_word_synonym():
pipeline = _fr_pipeline()
text = "Découper les légumes sur la planche à découper"
result = pipeline.process(text)
board_entities = [e for e in result.entities if e.uid == "cuttingBoard"]
assert len(board_entities) == 1
assert text[board_entities[0].start : board_entities[0].end] == "planche à découper"
def test_technique_and_utensil_are_both_returned_without_interfering():
pipeline = _fr_pipeline()
text = "Dans une casserole, faire mijoter à feu doux"
result = pipeline.process(text)
kinds_by_uid = {e.uid: e.kind for e in result.entities}
assert kinds_by_uid.get("simmer") == "technique"
assert kinds_by_uid.get("saucepan") == "utensil"
def test_returns_no_utensil_entities_when_none_are_mentioned():
pipeline = _fr_pipeline()
result = pipeline.process("Laisser reposer la pâte une heure")
assert [e for e in result.entities if e.kind == "utensil"] == []
def test_matches_english_utensils_too():
pipeline = LocalePipeline("en")
pipeline.train([TrainEntry(uid="chop", synonyms=["chop"], utterances=["chop the onions finely"])])
text = "Heat the pan before adding the onions"
result = pipeline.process(text)
pan_entities = [e for e in result.entities if e.uid == "pan"]
assert len(pan_entities) == 1
assert pan_entities[0].kind == "utensil"
assert text[pan_entities[0].start : pan_entities[0].end] == "pan"