batchCooking/apps/api/src/modules/sources/sources.service.ts
Nicolas 189df73b4c
Some checks failed
CI / lint (push) Successful in 2m33s
CI / intent-service-test (push) Failing after 31s
CI / build (push) Successful in 3m30s
CI / e2e (push) Successful in 11m14s
CI / test (push) Failing after 24m39s
feat(tech-steps): detecte les temperatures dans les etapes
3e type de metadonnee de clause, a cote des ingredients et des ustensiles :
temperature en °C (« 180°C », « 200 degres », °F converti), numero de
thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » ->
low/medium/high).

services/tech-step-intent-service :
- `temperature_extraction.py` : `extract_temperatures(text, locale)` pur,
  a base de regex (independant du modele spaCy et de l'entrainement).
- `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif
  au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les
  extrait meme pour une locale pas encore entrainee.
- Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` :
  2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas.

apps/api :
- `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere).
- `TechStepMatch.temperatures` : filtrees par appartenance de span a la
  clause, meme regle que les ustensiles.
- `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la
  donnee) + migration manuelle `20260829120000_step_tech_step_temperature`
  (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a
  reset-db.ts). Persistance + lecture dans `recipe.service.ts`
  (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche
  du service de correction ; `sources.service.ts` (apercu d'import) les
  fait transiter.

packages/shared : `StepTechStepTemperatureView` + `temperatures` sur
`StepTechStepView`.

apps/web :
- `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et
  redecoupe les segments non-keyword autour des spans de temperature (la
  logique technique intriquee reste intacte). `?? []` tolere une payload
  d'avant `temperatures`.
- `StepDescription` : surlignage `.step-temperature` + Tooltip via
  `temperatureLabel` ; i18n `recipes.temperature.*`.
- Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les
  ustensiles).

Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le
flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ;
pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres +
intent-service requis) non lance ici.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:29:03 +02:00

338 lines
14 KiB
TypeScript

import { HttpError } from "@batch-cooking/error-tools";
import {
type BrowsableSourceItemView,
type CreateRecipeInput,
type DraftRecipeIngredientView,
type DraftRecipeStepView,
ErrorCode,
type RecipeImportDraftView,
type RecipeView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
import {
loadIngredientCatalog,
loadUnitCatalog,
} from "../../lib/recipe-matching/ingredient-matcher.js";
import {
mergeDuplicateIngredients,
translateRecipeIngredients,
} from "../../lib/recipe-matching/recipe-translation.js";
import { techStepClassifier } from "../../lib/recipe-matching/tech-step-matcher.js";
import {
markAlreadyImported,
type RecipeSourceAdapter,
} from "../../lib/recipe-sources/recipe-source-adapter.js";
import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors.js";
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
import { getHouseSourceIds } from "../house/house.service.js";
import { createImportedRecipe } from "../recipe/recipe.service.js";
import { getIngredients, getUnits, getUtensils } from "../reference/reference.service.js";
/**
* Browsing, previewing, and importing a household's *enabled* external
* recipe sources (`HouseSource`) — the "onglet Sources" feature (see the
* project plan). Browsing lists what a source offers
* (`RecipeSourceAdapter.list()`); previewing fully translates one item
* (`translateRecipeIngredients`, `matchTechStepSpans` — same building
* blocks `recipe.service.ts` uses at real save time) without persisting
* it; importing (`importSourceItem`) is the only function here that
* actually saves — by the time it's called, the caller (the review screen)
* has already resolved every ingredient to a real catalog id, same as a
* manual `POST /recipes`.
*/
/**
* `sourceKey` must both exist as a `Source` (household-enabled, via
* `HouseSource`) *and* still be a registered adapter (`recipe-source-registry.ts`)
* — the two can drift apart (a `Source` row outlives its adapter being
* unregistered, exactly what `jsonLdRecipe` was cleaned up from — see
* `sources/index.ts`), so both are checked. Either failure looks like "this
* source doesn't exist" to the caller (404 `SOURCE_NOT_FOUND`), same
* "don't distinguish not-found from not-visible" posture `recipe.service.ts`
* takes for a recipe the viewer can't see.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
*/
async function assertSourceEnabled(
houseId: number | null,
sourceKey: string,
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
try {
const enabledSourceIds = await getHouseSourceIds(houseId);
const source = await prisma.source.findUnique({
where: { key: sourceKey },
});
if (!source || !enabledSourceIds.includes(source.id)) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Source "${sourceKey}" is not enabled for this household`,
);
}
const adapter = getRecipeSource(sourceKey);
if (!adapter) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Source "${sourceKey}" has no registered adapter`,
);
}
return { adapter, sourceId: source.id };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}
/**
* One page of `sourceKey`'s own catalog, each item flagged with whether
* it's already been imported (and, if so, its real `Recipe` id — see
* `findImportedRecipeIds`).
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
*/
export async function browseSource(
sourceKey: string,
houseId: number | null,
params: { query?: string; cursor?: string },
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
try {
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
const result = await adapter.list({
query: params.query,
cursor: params.cursor,
});
const importedRecipeIds = await findImportedRecipeIds(
prisma,
sourceKey,
result.items.map((item) => item.externalId),
);
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
return {
items: marked.map((item) => ({
externalId: item.externalId,
title: item.title,
picture: item.picture,
url: item.url,
alreadyImported: item.alreadyImported,
recipeId: importedRecipeIds.get(item.externalId) ?? null,
})),
nextCursor: result.nextCursor,
};
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}
/**
* Fully translates one source item into an unsaved {@link RecipeImportDraftView}
* — fetches + parses it (`fetchDetail`/`parse`), then resolves its
* ingredients/units (`translateRecipeIngredients`) and detects each step's
* techniques with their exact matched span (`matchTechStepSpans`, the same
* function `recipe.service.ts` uses at real save time — see its doc
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit
* matching has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
* `loadIngredientCatalog`/`loadUnitCatalog` are always called with
* `adapter.locale` directly, never specially skipped for a particular
* one — a source whose locale has no label table of its own just gets back
* empty catalogs from those two loaders, so every line's `ingredient`/
* `unit` end up `null` the same way, the same graceful "no
* matching-language data" degradation `translateRecipe` already has.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `externalId` couldn't be fetched or parsed into a usable recipe (a `RecipeSourceError` — `recipe-source-errors.ts` — from the adapter).
*/
export async function previewSourceItem(
sourceKey: string,
externalId: string,
houseId: number | null,
): Promise<RecipeImportDraftView> {
try {
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
let parsed: ReturnType<typeof adapter.parse>;
try {
const raw = await adapter.fetchDetail(externalId);
parsed = adapter.parse(raw);
} catch (err) {
if (err instanceof RecipeSourceError) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
}
throw err;
}
const [stepsWithTechStepMatches, ingredientCatalog, unitCatalog, techStepsByKey] =
await Promise.all([
Promise.all(
parsed.steps.map(async (step) => ({
step,
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
})),
),
loadIngredientCatalog(adapter.locale),
loadUnitCatalog(adapter.locale),
prisma.techStep.findMany({ select: { id: true, key: true } }),
]);
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
const translatedIngredients = translateRecipeIngredients(
parsed.ingredients,
ingredientCatalog,
unitCatalog,
adapter.locale,
);
const [ingredientViews, unitViews, utensilViews] = await Promise.all([
getIngredients(),
getUnits(),
getUtensils(),
]);
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
const unitById = new Map(unitViews.map((view) => [view.id, view]));
const utensilById = new Map(utensilViews.map((view) => [view.id, view]));
// A source's raw ingredient lines aren't deduplicated by the matcher —
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
// catalog ingredient. Folded into one line per ingredient (quantities
// summed where that's safe) before the draft ever reaches the review
// screen, rather than surfacing the recipe with two rows for "Œuf" and
// making the person sort it out — see issue #53's follow-up.
const mergedIngredients = mergeDuplicateIngredients(translatedIngredients, unitViews);
const ingredients: DraftRecipeIngredientView[] = mergedIngredients.map((ingredient) => ({
rawText: ingredient.rawText,
quantity: ingredient.quantity,
ingredient:
ingredient.ingredientId !== null
? (ingredientById.get(ingredient.ingredientId) ?? null)
: null,
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
}));
const steps: DraftRecipeStepView[] = stepsWithTechStepMatches.map(({ step, matches }) => ({
description: step.description,
picture: step.picture,
techSteps: matches.flatMap((match) => {
const techStep = techStepById.get(match.techStepId);
return techStep
? [
{
techStep,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
// A draft preview has no persisted `StepTechStep` row to
// read a real `source` from at all (it isn't a saved
// recipe yet — see `DraftRecipeStepView`'s own doc
// comment) — always the classifier's own live match,
// never a correction, so always "auto".
source: "auto",
ingredients: match.ingredients.flatMap((mention) => {
const ingredient = ingredientById.get(mention.ingredientId);
// Same drift guard as `techStep` above — an ingredientId
// the matcher resolved but that's since vanished from the
// catalog is dropped rather than shown with a hole in it.
if (!ingredient) return [];
return [
{
ingredient,
quantity: mention.quantity,
unit: mention.unitId !== null ? (unitById.get(mention.unitId) ?? null) : null,
start: mention.start,
end: mention.end,
// Same reasoning as this match's own `source` above — a draft preview only ever holds live classifier output.
source: "auto" as const,
},
];
}),
utensils: match.utensils.flatMap((mention) => {
const utensil = utensilById.get(mention.utensilId);
return utensil
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
: [];
}),
// Regex-extracted, no id to resolve — carried through as-is
// (same `source: "auto"` reasoning as this match above).
temperatures: match.temperatures.map((mention) => ({
...mention,
source: "auto" as const,
})),
},
]
: [];
}),
}));
return {
sourceKey,
externalId,
name: parsed.name,
description: parsed.description,
picture: parsed.picture,
portions: parsed.portions,
sourceUrl: parsed.sourceUrl,
ingredients,
steps,
};
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}
/**
* Finalizes an import — the review screen (pre-filled from
* {@link previewSourceItem}'s draft, unresolved ingredients fixed up by
* the user via the normal `IngredientPicker`) submits `input` as a
* regular {@link CreateRecipeInput}, exactly like a manually-authored
* recipe. This just adds two things `createRecipe` itself can't:
* confirming `externalId` isn't already imported (the DB's own
* `@@unique([sourceId, externalId])` would reject a second attempt too,
* but as a raw constraint violation — checking first gives a clean,
* expected error instead), and stamping `sourceId`/`externalId` plus
* matching techniques against the source's own locale
* (`createImportedRecipe`, `recipe.service.ts`).
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
* @throws {HttpError} `409 RECIPE_ALREADY_IMPORTED` if `externalId` was already imported from this source.
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function importSourceItem(
sourceKey: string,
externalId: string,
input: CreateRecipeInput,
authorId: number,
authorHouseId: number | null,
): Promise<RecipeView> {
try {
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
if (alreadyImported.has(externalId)) {
throw new HttpError(
409,
ErrorCode.RECIPE_ALREADY_IMPORTED,
`"${externalId}" from source "${sourceKey}" is already imported`,
);
}
return await createImportedRecipe(input, authorId, authorHouseId, {
sourceId,
externalId,
locale: adapter.locale,
});
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}