Remplace TechStepClassifierService's node-nlp (NlpManager) par services/tech-step-intent-service, un microservice FastAPI/spaCy dedie (PhraseMatcher pour le NER par synonymes, textcat pour la classification d'intention). Corpus (TECH_STEP_TRAINING_DATA) toujours possede par apps/api, pousse au service via POST /v1/train a chaque warm-up ; le service ne touche jamais Postgres (meme posture que services/tech-step-llm-worker). Cote apps/api : - intent-service-client.ts : client HTTP vers le nouveau service - tech-step-matcher.ts : delegue NER + intent classification au client, logique pure (splitIntoClauses, seuil/fallback) inchangee - env.ts : INTENT_SERVICE_BASE_URL/INTENT_SERVICE_SECRET (secret requis, service coeur non optionnel) - server.ts : warm-up avec retry/backoff (service Python demarre a part) - scripts/calibrate-tech-step-threshold.ts : recalibration empirique de CONFIDENCE_THRESHOLD contre le jeu d'eval existant - node-nlp retire (package.json, node-nlp.d.ts, model.nlp du .gitignore) docker-compose.yml : nouveau service tech-step-intent-service (pas de port expose, healthcheck, app en depend). CI : job intent-service-test (pytest) + le job test demarre le service en arriere-plan avant la suite Mocha (jamais de mock d'un service interne, cf specs/dev-conventions.md). Verifie : 26/26 tests pytest du service (dont les offsets caracteres exacts de tech-step-matcher.test.ts), lint + build complets du monorepo, smoke test HTTP reel bout en bout. La suite Mocha et docker compose build/up n'ont pas pu etre executes dans cet environnement (pas de Postgres/Docker disponibles ici) — a confirmer via la CI et en local. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
76 lines
2.9 KiB
Python
76 lines
2.9 KiB
Python
"""Vérifie le round-trip entraînement -> prédiction du `textcat` (la partie
|
|
"comprendre le sens, pas juste les mots clés" du pipeline — voir le
|
|
commentaire de `tech-step-matcher.ts` côté apps/api pour la motivation
|
|
d'origine)."""
|
|
|
|
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
|
|
|
|
_ENTRIES = [
|
|
TrainEntry(
|
|
uid="melt",
|
|
synonyms=["faire fondre"],
|
|
utterances=[
|
|
"faire fondre le beurre à feu doux",
|
|
"laisser fondre le beurre dans la poêle",
|
|
"jusqu'à ce que le beurre ait disparu",
|
|
"jusqu'à ce que le beurre ait complètement disparu dans la poêle",
|
|
],
|
|
),
|
|
TrainEntry(
|
|
uid="boil",
|
|
synonyms=["bouillir"],
|
|
utterances=[
|
|
"porter l'eau à ébullition",
|
|
"faire bouillir l'eau salée",
|
|
"laisser bouillir quelques minutes",
|
|
"porter à ébullition puis baisser le feu",
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
def test_train_returns_label_utterance_and_synonym_counts():
|
|
pipeline = LocalePipeline("fr")
|
|
label_count, utterance_count, synonym_count = pipeline.train(_ENTRIES)
|
|
assert label_count == 2
|
|
assert utterance_count == sum(len(entry.utterances) for entry in _ENTRIES)
|
|
assert synonym_count == sum(len(entry.synonyms) for entry in _ENTRIES)
|
|
assert pipeline.is_trained is True
|
|
|
|
|
|
def test_classifies_a_paraphrase_never_using_the_techniques_own_verb():
|
|
# Le cas motivant tout le pipeline (voir tech-step-matcher.ts) : aucune
|
|
# forme de "fondre" dans cette phrase, mais elle ne peut raisonnablement
|
|
# signifier que `melt` une fois le textcat entraîné sur les paraphrases
|
|
# ci-dessus.
|
|
pipeline = LocalePipeline("fr")
|
|
pipeline.train(_ENTRIES)
|
|
|
|
result = pipeline.process("jusqu'à ce que le beurre ait disparu dans la poêle")
|
|
assert result.intent == "melt"
|
|
assert result.score > 0.5
|
|
|
|
|
|
def test_empty_entries_leaves_the_pipeline_untrained():
|
|
pipeline = LocalePipeline("fr")
|
|
pipeline.train([])
|
|
assert pipeline.is_trained is False
|
|
result = pipeline.process("faire fondre le beurre")
|
|
assert result.intent is None
|
|
assert result.entities == []
|
|
|
|
|
|
def test_retraining_replaces_the_previous_textcat_rather_than_accumulating():
|
|
# `textcat` (exclusive_classes) exige >= 2 labels (voir la note dans
|
|
# LocalePipeline.train) — le second entraînement garde donc 2 entrées,
|
|
# mais remplace "boil" par une technique différente ("chop"), pour
|
|
# vérifier que "boil" ne peut plus jamais ressortir après coup (pas de
|
|
# fusion incrémentale — voir la doc de `LocalePipeline.train`).
|
|
pipeline = LocalePipeline("fr")
|
|
pipeline.train(_ENTRIES)
|
|
|
|
chop_entry = TrainEntry(uid="chop", synonyms=["couper"], utterances=["couper les légumes en dés"])
|
|
pipeline.train([_ENTRIES[0], chop_entry])
|
|
|
|
result = pipeline.process("porter l'eau à ébullition")
|
|
assert result.intent != "boil"
|