fix(recipes): reequilibre le corpus via substitution de synonyme plutot que du remplissage generique

Trois tentatives precedentes d'egaliser chaque technique a 20 utterances
ont toutes degrade le F1 agrege sous 0.8 (voir le commit revert
precedent). Nouvelle strategie, beaucoup plus conservatrice : egalise
chaque technique vers le maximum DEJA present dans le corpus (7 en fr,
5 en en, portes par cook/preheat), pas vers un nombre choisi dans
l'absolu - +3-4 utterances en moyenne par technique au lieu de +13-17.

augment_utterances.py (nouveau, reutilisable) genere le complement en
priorite par substitution de synonyme (un des synonyms propres a la
technique, en tete d'une utterance existante, remplace par un autre) -
avec un garde-fou supplementaire par rapport aux tentatives precedentes :
le synonyme de remplacement doit lui aussi etre a l'imperatif/infinitif,
pas juste le synonyme d'origine, pour eviter de substituer un groupe
nominal/adjectif ("a petit feu", "gros bouillons") a la place d'un
verbe et produire une phrase grammaticalement cassee. Tournures modales
uniquement en dernier recours pour les techniques dont le vocabulaire
n'apparait qu'en milieu de phrase (julienne, brunoise...).

Resultat : chaque technique a exactement 7 utterances en fr et 5 en en,
sans exception (tests/test_training_data_balance.py fait respecter cet
invariant). _TRAINING_ITERATIONS reste a 25 (inchange). start_period/
timeout d'attente /health releves de 900s a 1200s (temps d'entrainement
mesure ~930s contre ~670s avant, la marge de securite existante etait
devenue trop juste).

Suite complete locale : 35/35 verts (14m41s).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-26 19:08:33 +02:00
parent 74cd14c0a5
commit c7116d4a29
6 changed files with 685 additions and 24 deletions

View file

@ -96,14 +96,11 @@ jobs:
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~335s per locale (~670s for
# fr+en combined) against the current ~74-technique corpus,
# trained on each technique's own synonyms in addition to its
# example phrases, so this wait is generous rather than the fast
# "base models only" check it used to be before that service
# trained itself at startup (see docker-compose.yml's healthcheck
# for the same reasoning).
timeout 900 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
# see its own README) — measured at ~540s (fr) / ~390s (en),
# ~930s combined, against the current ~74-technique corpus (see
# docker-compose.yml's healthcheck for the same reasoning and why
# this grew slightly from the original ~670s).
timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
- run: pnpm install --frozen-lockfile
- run: pnpm --filter api exec prisma migrate deploy

View file

@ -94,15 +94,17 @@ services:
# This service trains itself from scratch on every start (no model
# ever persisted to disk, see its own README) — `/health` only
# returns 200 once that's done, not just once the base spaCy models
# are loaded. Measured at ~335s per locale (~670s for fr+en combined)
# against the current ~74-technique corpus, trained on each
# technique's own synonyms in addition to its example phrases
# (`intent_service/locale_pipeline.py`'s `_TRAINING_ITERATIONS`) —
# `start_period` generous enough that failing checks during that
# whole window never count against `retries` (which would otherwise
# flip this container to "unhealthy" mid-training, blocking `app`'s
# own `depends_on: condition: service_healthy` indefinitely).
start_period: 900s
# are loaded. Measured at ~540s (fr) / ~390s (en), ~930s combined,
# against the current ~74-technique corpus — each technique now has
# the *same* number of `utterances` per locale as every other
# (equalized to the corpus's own pre-existing max, 7/5 — see
# `training_data.py`'s own doc comment for why a flat, larger target
# like 20 was tried and reverted) — `start_period` generous enough
# that failing checks during that whole window never count against
# `retries` (which would otherwise flip this container to
# "unhealthy" mid-training, blocking `app`'s own `depends_on:
# condition: service_healthy` indefinitely).
start_period: 1200s
# Deliberately its own image, not built into `app`'s (see
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a

View file

@ -43,7 +43,15 @@ Workflow mainteneur pour changer le corpus :
rapport de `apps/api/src/scripts/list-pending-training-suggestions.ts`)
pour une technique, ou `intent_service/utensil_vocabulary.py` pour un
ustensile (pas de rapport équivalent pour ce dernier — pas de mécanisme
de correction utilisateur sur les ustensiles aujourd'hui).
de correction utilisateur sur les ustensiles aujourd'hui). Chaque
technique doit garder le même nombre d'`utterances` que les autres, par
locale (voir `training_data.py`'s own doc comment) — une technique
ajoutée avec moins que le max courant, exécuter `augment_utterances.py`
(racine de ce service) pour rééquilibrer, puis **impérativement**
relancer l'étape 3 ci-dessous avant de committer : chaque tentative
passée d'élargir ce corpus (voir l'historique Git de
`training_data.py`) a dû être ajustée ou annulée après coup faute
d'avoir vérifié le F1 avant de pousser.
2. **Redémarrer ce service** (`docker compose restart tech-step-intent-service`,
ou simplement redéployer) — le nouveau corpus n'a d'effet qu'une fois
réentraîné au démarrage, contrairement à l'ancienne version qui pouvait
@ -85,11 +93,14 @@ côté `apps/api`.
node-nlp (entraînement quasi instantané), entraîner le `textcat` sur le
corpus réel (~74 techniques, chaque technique entraînée sur ses `synonyms`
en plus de ses `utterances` — voir `locale_pipeline.py`) prend de l'ordre
de 335 secondes par locale (mesuré localement, sans GPU), donc environ 670
secondes (~11 minutes) pour `fr`+`en` combinés à chaque démarrage du
process. `docker-compose.yml` et
`.github/workflows/ci.yml` ont un `start_period`/timeout d'attente
généreux pour ça — voir leurs propres commentaires. C'est un compromis
de 540 secondes pour `fr` / 390 secondes pour `en` (mesuré localement,
sans GPU), donc environ 930 secondes (~15-16 minutes) pour `fr`+`en`
combinés à chaque démarrage du process — chaque technique a désormais le
même nombre d'`utterances` par locale (voir `training_data.py`'s own doc
comment), légèrement plus qu'avant ce rééquilibrage. `docker-compose.yml`
et `.github/workflows/ci.yml` ont un `start_period`/timeout d'attente
généreux pour ça (`1200s`) — voir leurs propres commentaires. C'est un
compromis
assumé, pas un défaut de configuration à corriger : moins d'itérations
entraîne plus vite mais laisse des verdicts corrects sous
`CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante,
@ -166,7 +177,7 @@ vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme
## Limitations connues
- **Démarrage lent** (~11 minutes) — voir "Temps de démarrage" ci-dessus.
- **Démarrage lent** (~15-16 minutes) — voir "Temps de démarrage" ci-dessus.
Une optimisation possible non explorée : parallélisation de
l'entraînement `fr`/`en` (actuellement séquentiel,
`PipelineRegistry.initialize`).

View file

@ -0,0 +1,282 @@
"""Maintainer script — equalizes every technique's `utterances` count
(per locale) to the corpus's own current maximum for that locale, never a
fixed number picked in the abstract. Preserves every existing utterance,
synonym, and comment verbatim; only ever *adds*, never rewrites or removes.
**Why "equalize to the current max", not "pad everyone to 20"** this
script's own history: three earlier attempts forced every technique up to
a flat 20 `utterances`/locale (12-17 new ones per technique on average).
All three measurably *failed*
`test/recipe-matching/tech-step-eval.test.ts`'s F1 >= 0.8 regression gate
(0.7999 -> 0.791 -> 0.744, each attempt worse than the last), regardless of
whether the added content was mostly generic modal-frame padding ("il
faut ...") or mostly synonym substitution. The common factor across all
three wasn't *how* the filler was generated, it was *how much*: this
corpus's real per-technique max was only 7 (fr) / 5 (en) before any of
this forcing every technique up to 20 meant most of them tripled or
quadrupled in size on synthetic content alone, which measurably hurt
inter-class separability more than it helped. Equalizing to the corpus's
*own* current max instead means at most a few new utterances per
technique (most need 1-4), which is a small enough addition to plausibly
preserve the F1 gate while still satisfying "same amount of signal per
class" (the actual goal — consistent detection quality across techniques,
not a specific round number).
**Generation strategy** synonym substitution first (see
`_synonym_variants`): for every existing utterance whose leading phrase
exactly matches one of the technique's own `synonyms`, swap in every
*other* synonym from the same list (e.g. `melt`'s "faire fondre le
beurre" -> "liquéfier le beurre") — genuinely technique-distinguishing
vocabulary, not filler shared across every class. A technique whose
`synonyms` only ever appear *mid-sentence* (the "cut style" techniques
`julienne`, `brunoise`, `mirepoix`, `paysanne`... e.g. "couper les
carottes en julienne" doesn't *start* with any of `julienne`'s own
synonyms) has no leading-phrase match to substitute, so a small modal-frame
fallback (`_FR_FRAMES`/`_EN_FRAMES`, 2 per locale much smaller than the
12/10 used in the failed 20-target attempts) closes the remainder. Safe at
this scale specifically *because* the gap being closed is small (equalizing
to the corpus's own current max, 1-4 utterances short per technique, not
13-17) see this module's own doc comment above for why volume, not
generation method, was the real problem in every failed attempt.
Run from `services/tech-step-intent-service/` (this directory):
`./.venv/Scripts/python.exe augment_utterances.py`. Rewrites
`training_data.py` in place by textual splicing (AST only to *locate* each
`utterances=[...]` list's line range — never to regenerate the file). Safe
to re-run: a technique already at the current per-locale max is left
untouched, and the max itself is recomputed from the file's *current*
state each time (so re-running after a manual edit re-equalizes against
whatever the new max is, not a stale one).
"""
import ast
import sys
SRC_PATH = "intent_service/training_data.py"
# Minimal fallback pool — only ever used for the small remainder synonym
# substitution can't reach (see this module's own doc comment for why 2,
# not the 12/10 tried in earlier, failed attempts).
_FR_FRAMES = ["il faut {u}", "veillez à {u}"]
_EN_FRAMES = ["make sure to {u}", "remember to {u}"]
def _is_fr_infinitive_led(u: str) -> bool:
first = u.split(" ", 1)[0].lower()
return first.endswith(("er", "ir", "re")) and len(first) > 2
_EN_VERB_WHITELIST = {
"make", "add", "pour", "mix", "stir", "cut", "place", "cover", "remove", "heat", "let",
"keep", "turn", "cook", "bake", "roast", "grill", "fry", "boil", "simmer", "whisk", "fold",
"chop", "mince", "peel", "drain", "season", "rest", "plate", "coat", "melt", "sauté", "saute",
"braise", "blanch", "marinate", "brown", "glaze", "thicken", "reduce", "dilute", "loosen",
"moisten", "sift", "toast", "zest", "scald", "pod", "shell", "hollow", "shock", "emulsify",
"decant", "dust", "sweat", "rub", "punch", "confit", "caramelize", "score", "line", "clarify",
"stew", "dice", "fillet", "proof", "poach", "pasteurize", "sterilize", "can", "preserve",
"tie", "truss", "baste", "spoon", "brush", "whip", "beat", "work", "sear", "flatten", "press",
"knead", "run", "cool", "warm", "combine", "blend", "arrange", "present", "sprinkle", "strain",
"separate", "bring", "grate", "continue", "deglaze", "scrape", "char", "break", "slice", "set",
"adjust", "switch", "secure", "mark", "butter", "crush", "julienne", "reheat", "smother",
"build", "scoop", "plunge", "increase", "pass", "collect", "have", "salt", "soak",
}
_EN_ADVERB_SKIP = {
"coarsely", "roughly", "finely", "quickly", "lightly", "briefly", "gently", "carefully",
"gradually", "very", "thoroughly", "evenly", "generously", "slowly", "thinly", "deep", "blind",
"dry",
}
def _is_en_imperative_led(u: str) -> bool:
words = u.lower().replace(",", "").split()
if not words:
return False
first = words[0]
if first in _EN_VERB_WHITELIST:
return True
if first in _EN_ADVERB_SKIP and len(words) > 1:
return words[1] in _EN_VERB_WHITELIST
return False
def _frame_variants(existing: list[str], frames: list[str], is_led) -> list[str]:
sources = [u for u in existing if is_led(u)]
if not sources:
return []
seen = set(existing)
out: list[str] = []
for frame in frames:
for u in sources:
candidate = frame.format(u=u)
if candidate in seen:
continue
seen.add(candidate)
out.append(candidate)
return out
def _synonym_variants(existing: list[str], synonyms: list[str], locale: str) -> list[str]:
"""Substitutes every *other* synonym in place of whichever synonym an
existing utterance's leading phrase exactly matches — see this
module's own doc comment for why this is the primary generation
strategy.
Both the matched *and* the replacement synonym must independently pass
`_is_fr_infinitive_led`/`_is_en_imperative_led` a technique's
`synonyms` list mixes genuine verb forms ("mijoter", "frémir") with
noun/adjective phrases used the same way a keyword-matcher needs them
but never as a sentence's own leading verb ("à petit feu", "gros
bouillons", "huile de friture") — without this check, swapping the
verb "frémir" for the noun phrase "à petit feu" inside "laisser
frémir..." produces a syntactically broken sentence ("à petit feu
..."), not just a stylistically different one. Filtering the
replacement pool to the same grammatical shape as the ones this
function already accepts as *sources* keeps every substitution a
like-for-like swap."""
if len(synonyms) < 2:
return []
is_led = _is_fr_infinitive_led if locale == "fr" else _is_en_imperative_led
seen = set(existing)
sorted_synonyms = sorted({syn for syn in synonyms if is_led(syn)}, key=len, reverse=True)
if len(sorted_synonyms) < 2:
return []
out: list[str] = []
for u in existing:
lower_u = u.lower()
matched = next(
(
syn
for syn in sorted_synonyms
if lower_u == syn.lower() or lower_u.startswith(f"{syn.lower()} ")
),
None,
)
if matched is None:
continue
rest = u[len(matched) :]
for syn in sorted_synonyms:
if syn == matched:
continue
candidate = f"{syn}{rest}"
if candidate in seen:
continue
seen.add(candidate)
out.append(candidate)
return out
def top_up(existing: list[str], synonyms: list[str], target: int, locale: str) -> list[str]:
if len(existing) >= target:
return []
needed = target - len(existing)
pool = _synonym_variants(existing, synonyms, locale)
if len(pool) < needed:
frames = _FR_FRAMES if locale == "fr" else _EN_FRAMES
is_led = _is_fr_infinitive_led if locale == "fr" else _is_en_imperative_led
already = set(existing) | set(pool)
for candidate in _frame_variants(existing, frames, is_led):
if candidate in already:
continue
pool.append(candidate)
already.add(candidate)
return pool[:needed]
def main() -> None:
with open(SRC_PATH, encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
lines = source.splitlines(keepends=True)
module_body = tree.body
training_data_list = None
for node in module_body:
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
if node.target.id == "TECH_STEP_TRAINING_DATA":
training_data_list = node.value
break
if training_data_list is None or not isinstance(training_data_list, ast.List):
print("Could not locate TECH_STEP_TRAINING_DATA list", file=sys.stderr)
sys.exit(1)
# First pass: collect every entry's current per-locale utterance/synonym
# lists and find each locale's own current max — the equalization
# target, not a number picked separately from the corpus itself.
parsed: list[tuple[str, str, ast.List, list[str], list[str]]] = []
targets = {"fr": 0, "en": 0}
for entry_call in training_data_list.elts:
assert isinstance(entry_call, ast.Call)
uid = None
for kw in entry_call.keywords:
if kw.arg == "uid":
assert isinstance(kw.value, ast.Constant)
uid = kw.value.value
for kw in entry_call.keywords:
if kw.arg not in ("fr", "en"):
continue
locale = kw.arg
locale_call = kw.value
assert isinstance(locale_call, ast.Call)
utterances_list_node = None
synonyms_list_node = None
for inner_kw in locale_call.keywords:
if inner_kw.arg == "utterances":
utterances_list_node = inner_kw.value
elif inner_kw.arg == "synonyms":
synonyms_list_node = inner_kw.value
if utterances_list_node is None:
continue
assert isinstance(utterances_list_node, ast.List)
existing = [
elt.value for elt in utterances_list_node.elts if isinstance(elt, ast.Constant)
]
synonyms = (
[elt.value for elt in synonyms_list_node.elts if isinstance(elt, ast.Constant)]
if isinstance(synonyms_list_node, ast.List)
else []
)
targets[locale] = max(targets[locale], len(existing))
parsed.append((uid, locale, utterances_list_node, existing, synonyms))
print(f"Equalizing to the corpus's own current max — fr: {targets['fr']}, en: {targets['en']}")
insertions: list[tuple[int, str, list[str]]] = []
total_added = 0
shortfalls: list[tuple[str, str, int]] = []
for uid, locale, utterances_list_node, existing, synonyms in parsed:
target = targets[locale]
new_ones = top_up(existing, synonyms, target, locale)
final_count = len(existing) + len(new_ones)
if final_count < target:
shortfalls.append((uid, locale, final_count))
if not new_ones:
continue
last_elt = utterances_list_node.elts[-1]
insert_after_line = last_elt.end_lineno - 1
indent = lines[insert_after_line][
: len(lines[insert_after_line]) - len(lines[insert_after_line].lstrip())
]
new_lines = [f'{indent}"{s}",\n' for s in new_ones]
insertions.append((insert_after_line, uid, new_lines))
total_added += len(new_ones)
insertions.sort(key=lambda t: t[0], reverse=True)
for line_idx, uid, new_lines in insertions:
lines[line_idx + 1 : line_idx + 1] = new_lines
with open(SRC_PATH, "w", encoding="utf-8", newline="\n") as f:
f.writelines(lines)
print(f"Added {total_added} new utterances across {len(insertions)} (technique, locale) pairs.")
if shortfalls:
print(f"{len(shortfalls)} (uid, locale) pair(s) still below their locale's target — not")
print("enough synonym variety to reach full equalization:")
for uid, locale, count in shortfalls:
print(f" {uid} ({locale}): {count}/{targets[locale]}")
else:
print("Every technique now has exactly the same utterance count as every other, per locale.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,22 @@
"""Garde-fou de non-régression pour l'équilibrage du corpus (voir
`training_data.py`'s propre commentaire de tête) : chaque technique doit
avoir exactement le même nombre d'`utterances` que chaque autre, par
locale un déséquilibre entre classes est une source réelle de
classifications confiantes mais fausses sur une phrase jamais vue (constaté
en pratique voir l'historique Git de ce fichier, trois tentatives
d'équilibrer vers un nombre plus élevé ont toutes dégradé le F1 agrégé de
`test/recipe-matching/tech-step-eval.test.ts` avant que la stratégie
actuelle équilibrer vers le maximum déjà présent dans le corpus, pas un
nombre choisi dans l'absolu — ne passe cette même gate)."""
from intent_service.training_data import TECH_STEP_TRAINING_DATA
def test_every_technique_has_the_same_utterance_count_per_locale():
for locale in ("fr", "en"):
counts = {entry.uid: len(getattr(entry, locale).utterances) for entry in TECH_STEP_TRAINING_DATA}
distinct = set(counts.values())
assert len(distinct) == 1, (
f"utterance counts for locale {locale!r} aren't uniform across techniques "
f"(run augment_utterances.py to re-equalize): {counts}"
)