Le matching par regex ne généralisait jamais au-delà de son propre vocabulaire — une étape décrivant la fonte du beurre comme "jusqu'à ce que le beurre ait disparu dans la poêle" ne contient aucun verbe sur lequel une regex pourrait s'ancrer, alors que le sens est sans ambiguïté. Nouveau pipeline en 3 étapes (TechStepClassifierService, node-nlp 4.27.0 — la 5.x est encore alpha, non retenue) : 1. NER (entités enum) trouve les mentions candidates + leur position exacte, à partir de listes de synonymes (tech-step-training-data.ts) plutôt que de regex écrites à la main. ner.threshold: 1 (exact, après normalisation) — le défaut à 0.8 faisait matcher "faire" (verbe auxiliaire omniprésent) contre "frire" par pure proximité de chaîne. 2. La description est découpée en clauses autour de ces candidats (splitIntoClauses, pure/testable sans modèle). 3. Le NlpManager classe chaque clause individuellement, entraîné sur des phrases qui n'emploient jamais le verbe de la technique — c'est ce qui apporte la compréhension du sens. En dessous de CONFIDENCE_THRESHOLD (0.65, ajusté empiriquement), retombe sur la technique impliquée par l'ancre NER plutôt que d'abandonner un match clairement ancré sur un mot-clé. TechStepMapping (table de regex par technique/locale) supprimée — migration 20260821130000_drop_tech_step_mapping — plus aucune table n'est interrogée à l'exécution, les données de matching vivent en code. TECH_STEPS (reference-seed-data.ts) simplifié en simple liste de uid, les mappings ayant disparu. Deux pièges trouvés en construisant ce pipeline, corrigés à la source : - db/prisma.ts construisait PrismaClient sans importer config/env.ts — un run de test isolé pouvait faire gagner la course au .env interne de Prisma (dev) contre .env.test. Fixé en important config/env.js en tout premier, pour effet de bord. - NlpManager a autoSave/autoLoad: true par défaut — persiste le modèle entraîné dans model.nlp et le recharge au lieu de ré-entraîner au prochain démarrage. Les deux désactivés explicitement (sinon un modèle obsolète masquerait silencieusement toute mise à jour du corpus/seuil) ; model.nlp ajouté au .gitignore en garde-fou. apps/api/src/db/prisma.ts, recipe.service.ts, sources.service.ts et recipe-translation.ts adaptés à la matching async (le classifieur entraîné remplace le couple loadTechStepMappingRules+matchTechStepSpans synchrone) ; server.ts appelle techStepClassifier.warmUp() avant d'accepter du trafic (le tout premier appel réel à NlpManager.process() charge les ressources par langue de node-nlp, plusieurs secondes). Vérifié : tsc --noEmit, biome check (0 erreur), build complet des 6 packages, 308 tests API (dont un test-support/reset-db.ts corrigé — référençait encore tech_step_mapping dans son TRUNCATE). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
8.7 KiB
TypeScript
188 lines
8.7 KiB
TypeScript
/**
|
|
* The generic contract every recipe source (a specific website, an API, …)
|
|
* implements — groundwork for the "Import d'une recette" pipeline described
|
|
* in specs/batch-cooking-architecture.md (import depuis source → traduction
|
|
* en étapes → sauvegarde). This file only defines the shapes; no concrete
|
|
* source exists yet (see `recipe-source-registry.ts` for where one would be
|
|
* registered) and nothing here talks to the database or an HTTP route —
|
|
* that wiring (persisting an imported recipe, resolving `sourceId`) is
|
|
* deliberately out of scope until a real source needs it.
|
|
*
|
|
* The flow a caller drives against one adapter:
|
|
* 1. `list()` — browse what's available from the source (paginated,
|
|
* optionally filtered by `query`), like flipping through a catalog.
|
|
* 2. {@link markAlreadyImported} — flag which of those items we've
|
|
* already imported, so browsing a source doesn't dangle recipes the
|
|
* user has already brought in as if they were new. A separate, pure
|
|
* step rather than something `list()` itself does: an adapter only
|
|
* knows its source, never our database — same reasoning as
|
|
* `tech-step-matcher.ts`'s split between pure `splitIntoClauses` and
|
|
* its DB/model-touching `TechStepClassifierService`. Whichever future
|
|
* layer queries "which externalIds from this source do we already have"
|
|
* (not yet decided — it needs a place to persist that link,
|
|
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
|
|
* the page before returning it.
|
|
* 3. `fetchDetail(externalId)` — once the user picks one item from that
|
|
* list, fetch its full raw content.
|
|
* 4. `parse(raw)` — turn that raw content into a {@link ParsedRecipe},
|
|
* pure and synchronous so it's unit-testable without any network
|
|
* access (same split as step 2 above).
|
|
*/
|
|
|
|
/** Search/pagination input for {@link RecipeSourceAdapter.list}. */
|
|
export interface RecipeSourceListParams {
|
|
/** Free-text search, if the source supports it. Omitted means "browse everything". */
|
|
query?: string;
|
|
/**
|
|
* Opaque continuation token from a previous {@link RecipeSourceListResult.nextCursor}
|
|
* — omitted (or `null`) means "start from the first page". Deliberately
|
|
* opaque (not a page number) so an adapter can back it with whatever its
|
|
* source actually supports (page number, offset, an API-provided token).
|
|
*/
|
|
cursor?: string | null;
|
|
}
|
|
|
|
/** One entry in a {@link RecipeSourceAdapter.list} result — enough to show in a browsing UI and to fetch the full recipe once selected. */
|
|
export interface RecipeSourceListItem {
|
|
/**
|
|
* Source-specific identifier, opaque to callers — passed back verbatim
|
|
* to {@link RecipeSourceAdapter.fetchDetail}, and the key
|
|
* {@link markAlreadyImported} matches against to tell an already-imported
|
|
* item apart from a new one.
|
|
*/
|
|
externalId: string;
|
|
title: string;
|
|
picture: string | null;
|
|
/** Canonical URL of the recipe on the source, kept for attribution even before it's imported. */
|
|
url: string;
|
|
}
|
|
|
|
export interface RecipeSourceListResult {
|
|
items: RecipeSourceListItem[];
|
|
/** Pass back as `cursor` to fetch the next page — `null` means this was the last page. */
|
|
nextCursor: string | null;
|
|
}
|
|
|
|
/** A browsed {@link RecipeSourceListItem}, after {@link markAlreadyImported} has flagged whether we already imported it. What a browsing UI actually renders — e.g. to grey it out or offer "already added" instead of "import". */
|
|
export interface BrowsableRecipeItem extends RecipeSourceListItem {
|
|
alreadyImported: boolean;
|
|
}
|
|
|
|
/**
|
|
* Splits a page of {@link RecipeSourceListItem}s into already-imported vs.
|
|
* new, purely by checking each item's `externalId` against
|
|
* `importedExternalIds` — no I/O here, the caller is responsible for
|
|
* gathering that set (from wherever we end up persisting the link between
|
|
* an imported `Recipe` and the source item it came from) before calling
|
|
* this. Kept as a tiny, dedicated, easily-testable step rather than folded
|
|
* into `list()` itself, so an adapter never needs to know our database
|
|
* exists.
|
|
*/
|
|
export function markAlreadyImported(
|
|
items: RecipeSourceListItem[],
|
|
importedExternalIds: ReadonlySet<string>,
|
|
): BrowsableRecipeItem[] {
|
|
return items.map((item) => ({
|
|
...item,
|
|
alreadyImported: importedExternalIds.has(item.externalId),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* One ingredient line as lifted from a source, before it's resolved against
|
|
* our own `Ingredient`/`Unit` reference catalogs (that resolution —
|
|
* matching free text to a `key`, the way `tech-step-matcher.ts` matches
|
|
* step text to a `TechStep` — is a separate, not-yet-built concern; this
|
|
* type only carries what a source's raw text actually says). `rawText` is
|
|
* kept alongside the (best-effort) parsed fields so a failed/partial parse
|
|
* is still traceable back to what the source originally wrote.
|
|
*/
|
|
export interface ParsedRecipeIngredient {
|
|
rawText: string;
|
|
quantity: number | null;
|
|
/** Free-text unit exactly as written by the source (e.g. `"cuillère à soupe"`, `"g"`) — not yet resolved to a `Unit.key`. */
|
|
unit: string | null;
|
|
/** Free-text ingredient name exactly as written by the source — not yet resolved to an `Ingredient.key`. */
|
|
name: string;
|
|
}
|
|
|
|
export interface ParsedRecipeStep {
|
|
description: string;
|
|
picture: string | null;
|
|
}
|
|
|
|
/**
|
|
* The normalized shape every adapter's {@link RecipeSourceAdapter.parse}
|
|
* produces, regardless of the source. Intentionally *not*
|
|
* `CreateRecipeInput` (packages/shared/src/schemas/recipe.ts): ingredients
|
|
* are still free text (no `ingredientId`/`unitId` — that catalog-matching
|
|
* step doesn't exist yet), and there's no `dietIds`/`visibility` since a
|
|
* source can't know those. Turning a `ParsedRecipe` into a saved `Recipe`
|
|
* is future work for whichever module ends up driving this pipeline.
|
|
*/
|
|
export interface ParsedRecipe {
|
|
name: string;
|
|
description: string | null;
|
|
picture: string | null;
|
|
/** `null` when the source doesn't state a serving size. */
|
|
portions: number | null;
|
|
/** Canonical URL of the recipe on the source — the eventual `Source`/`Recipe.sourceId` link (schema.prisma) is populated from this once the import pipeline saves the recipe. */
|
|
sourceUrl: string;
|
|
ingredients: ParsedRecipeIngredient[];
|
|
steps: ParsedRecipeStep[];
|
|
}
|
|
|
|
/**
|
|
* A single recipe source — a specific website or API, plus the two pieces
|
|
* of source-specific logic needed to pull a recipe out of it. `TRawDetail`
|
|
* is whatever shape `fetchDetail` naturally returns for this source (an
|
|
* HTML string, a parsed JSON body, …); `parse` is the only thing that needs
|
|
* to understand it.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const myAdapter: RecipeSourceAdapter<{ html: string }> = {
|
|
* key: "someRecipeSite",
|
|
* name: "Some Recipe Site",
|
|
* official: false,
|
|
* iconUrl: "https://somerecipesite.example/favicon.svg",
|
|
* locale: "fr",
|
|
* async list(params) { ... },
|
|
* async fetchDetail(externalId) { ... },
|
|
* parse(raw) { ... },
|
|
* };
|
|
* registerRecipeSource(myAdapter);
|
|
* ```
|
|
*/
|
|
export interface RecipeSourceAdapter<TRawDetail = unknown> {
|
|
/** Stable identifier used to look this adapter up in the registry — same "English camelCase uid" convention as `Diet.key`/`Unit.key`/`TechStep.key`. */
|
|
key: string;
|
|
/** Human-readable name, for display in a source picker. */
|
|
name: string;
|
|
/**
|
|
* Whether this source is an official API (the site/publisher itself
|
|
* provides structured recipe data) versus unofficial web scraping (we
|
|
* parse HTML the site never committed to a stable shape for) — surfaced
|
|
* to households (`Source.official`, synced via `syncRecipeSources`) so
|
|
* they can tell the two apart when deciding which sources to enable
|
|
* (see `HouseSource`, schema.prisma). No default on purpose: every
|
|
* adapter author has to consciously pick one rather than silently
|
|
* inheriting a guess.
|
|
*/
|
|
official: boolean;
|
|
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
|
|
iconUrl: string | null;
|
|
/**
|
|
* Language of the text this source produces (`ParsedRecipe.description`/
|
|
* `steps[].description`/`ingredients[].name`) — e.g. `"en"` for
|
|
* TheMealDB. Not a user preference: the language the source's own
|
|
* content is actually written in, regardless of who's browsing it.
|
|
* Determines which trained-classifier/ingredient-label locale
|
|
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
|
|
* recipes against when previewing/importing one.
|
|
*/
|
|
locale: string;
|
|
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
|
fetchDetail(externalId: string): Promise<TRawDetail>;
|
|
parse(raw: TRawDetail): ParsedRecipe;
|
|
}
|