Import de recettes via photos #43

Open
opened 2026-09-05 19:36:06 +02:00 by kyuno · 0 comments
Owner

Résumé

Permettre de créer une recette à partir de photos (page de livre de cuisine, fiche
manuscrite, découpe de magazine). L'utilisateur prend 1 à 4 photos, l'app en extrait une
recette structurée (nom, portions, ingrédients, étapes), puis la présente pour
relecture/correction dans le même écran que l'import depuis une source externe
avant
sauvegarde. Rien n'est jamais enregistré sans validation humaine.

L'extraction passe par un nouveau microservice OCR local en Python
(services/recipe-ocr-service/), frère de services/tech-step-intent-service : OCR +
segmentation de la mise en page → un ParsedRecipe. Le reste du pipeline d'import
existant est réutilisé tel quel (translateRecipe : mise en correspondance
ingrédients/unités contre le catalogue + détection des techniques via le service NLP
actuel).

Contexte (trouvé en auditant le code, pas supposé)

  • Le pipeline d'import est déjà là et réutilisable. Une source externe suit
    parse() → ParsedRecipe (apps/api/src/lib/recipe-sources/recipe-source-adapter.ts :
    { name, portions, ingredients: ParsedRecipeIngredient[], steps: ParsedRecipeStep[] },
    texte brut) → translateRecipe()
    (apps/api/src/lib/recipe-matching/recipe-translation.ts) → RecipeImportDraftView
    (packages/shared/src/types/sources.ts:53) → écran de relecture
    (apps/web/src/features/recipes/sources/RecipeImportForm.tsx, réutilisé aussi bien par
    ImportRecipePage que par RecipePickerDialog) → POST /sources/:key/import/:externalId
    avec un createRecipeSchema complet. previewSourceItem (sources.service.ts:154) est
    le point d'entrée qui assemble le draft via translateRecipeIngredients +
    matchTechStepSpans. L'import photo doit produire un ParsedRecipe et brancher
    dessus
    — pas réinventer la traduction ni l'UI de relecture.
  • La position ML du projet est délibérément « local + gratuit + bakée au build ».
    services/tech-step-intent-service : spaCy fr_core_news_md, modèle entraîné à son
    propre démarrage, aucun appel réseau sortant. services/tech-step-llm-worker :
    Qwen2.5-1.5B GGUF via node-llama-cpp. Aucune clé d'API IA nulle part. Un OCR
    cloud (Google Vision, AWS Textract, LLM multimodal) serait une rupture ; la décision
    ci-dessous reste dans la même ligne.
  • services/tech-step-intent-service est le patron du nouveau microservice : conteneur
    Python dans docker-compose.yml, secret partagé plat (INTENT_SERVICE_SECRET en
    en-tête X-Intent-Service-Secret, cf. apps/api/src/lib/recipe-matching/intent-service-client.ts,
    POST /v1/process), INTENT_SERVICE_BASE_URL dans config/env.ts. Différence :
    INTENT_SERVICE_SECRET est requis (dépendance de tout enregistrement de recette) ;
    l'OCR n'est nécessaire que pour l'import photo → il doit être .optional() +
    fail-closed
    (bouton masqué, endpoint 503), même posture que ADMIN_JWT_SECRET /
    INTERNAL_WORKER_SECRET.
  • Aucun sous-système d'upload de fichier n'existe. L'issue #80 a explicitement écarté
    les pièces jointes pour cette raison ; toutes les images de l'app (Recipe.picture,
    Step.picture) sont des URLs externes, il n'y a ni stockage objet, ni endpoint
    d'upload, ni service de fichiers statiques hors le SPA. apps/api n'a aucune
    gestion multipart (multer/formidable absents).
  • @capacitor/camera n'est pas installé (apps/web/package.json : android, app,
    cli, core, preferences seulement).
  • createRecipeSchema (packages/shared/src/schemas/recipe.ts) est la cible : name,
    portions (entier > 0), visibility (défaut PERSONAL), dietIds: number[],
    ingredients (≥ 1, chacune ingredientId ou placeholderName, quantity+unitId
    ensemble ou absents), steps (≥ 1, description 1–2000). Une ligne d'ingrédient non
    reconnue devient un placeholder (Ingredient.isPlaceholder) — exactement le
    comportement souhaité pour un ingrédient mal OCRisé.

Décisions de conception (validées avec l'utilisateur)

Question Décision Pourquoi
Moteur d'extraction Microservice OCR Python local (services/recipe-ocr-service/), couplé au NLP actuel. OCR + segmentation mise en page → ParsedRecipe ; puis translateRecipe() inchangé. Reste dans la ligne « local/gratuit/bakée au build » du projet. Réutilise l'ingredient-matcher et le classifieur de techniques spaCy tels quels.
Nombre de photos / stockage 1 à 4 photos par import, non conservées. Envoyées au service d'extraction puis jetées. Pas de sous-système de stockage à créer, respectueux de la vie privée, cohérent avec #80. Ré-extraction = reprendre les photos.
Confiance dans l'extraction Toujours relue par un humain — l'extraction produit un brouillon (RecipeImportDraftView), l'utilisateur corrige chaque champ dans RecipeImportForm avant POST /recipes. Jamais d'enregistrement automatique. Identique à l'import depuis source. L'OCR d'une photo est intrinsèquement bruité.
Synchronicité (v1) Requête longue synchrone + loader (OCR ~2–10 s). Un job asynchrone avec polling (comme tech-step-llm-worker) est noté en repli si la latence/les timeouts posent problème. Pas de table de jobs à introduire pour un volume faible et une action ponctuelle.
Priorité Medium Fonctionnalité de confort, pas un parcours cœur.

Architecture

Photos (1..4)  ──► apps/web : écran « Nouvelle recette » → « Depuis une photo »
                    · web  : <input type="file" accept="image/*" capture="environment">
                    · natif: @capacitor/camera (dépendance à ajouter — cf. Points à trancher)
                             ▼  multipart
apps/api : POST /recipes/from-photos   (requireAuth ; 503 si OCR non configuré)
                    ▼  relaie les images
services/recipe-ocr-service (Python, nouveau)
   1. OCR FR (PaddleOCR / docTR / Tesseract — cf. Points à trancher)
   2. Segmentation mise en page → { name?, portions?, ingredients: string[], steps: string[] }
                    ▼  ParsedRecipe (même shape que recipe-source-adapter.ts)
apps/api : translateRecipe(parsed)          ◄── RÉUTILISÉ
   · translateRecipeIngredients  (ingredient-matcher : catalogue Ingredient/Unit)
   · tech-step detection          (tech-step-matcher → services/tech-step-intent-service)
                    ▼
RecipeImportDraftView                        ◄── shape RÉUTILISÉE (types/sources.ts)
                    ▼
apps/web : RecipeImportForm (relecture/correction)   ◄── composant RÉUTILISÉ
                    ▼  createRecipeSchema complet
apps/api : POST /recipes   (recipe.service.ts createRecipeInternal)   ◄── RÉUTILISÉ

Seul l'encadré services/recipe-ocr-service + l'endpoint POST /recipes/from-photos +
son client sont nouveaux. Tout le reste existe.

Nouveau microservice — services/recipe-ocr-service/

Frère de services/tech-step-intent-service (même Dockerfile-pattern, même en-tête de
secret partagé, modèle OCR baké au build de l'image).

  • POST /v1/extract — corps multipart/form-data, champ images répété (1–4 fichiers
    image/jpeg|png|webp, ≤ 8 Mo chacun). En-tête X-Recipe-Ocr-Secret obligatoire.
  • Réponse 200 : { recipe: { name: string|null, portions: number|null, ingredients: string[], steps: string[] }, confidence: "high"|"low", pageCount: number }.
    422 si aucun texte de recette exploitable trouvé (photo floue, pas une recette).
  • OCR : moteur FR (voir Points à trancher). Sortie = texte + boîtes/lignes.
  • Segmentation : à partir des boîtes, séparer bloc « Ingrédients » vs bloc
    « Préparation/Étapes » (titres de section, lignes courtes avec quantité vs paragraphes
    numérotés). Extraire name (plus gros titre / première ligne) et portions
    (« pour 4 personnes », « 4 pers. »). Détail dans Points à trancher (heuristique pure vs
    assist LLM local).
  • GET /health sans secret (comme l'intent-service, pour la sonde monitoring).
  • Multi-langue hors v1 : FR uniquement (locale fr, comme le reste du NLP).

Backend apps/api

  • config/env.ts — RECIPE_OCR_BASE_URL (.url().optional()), RECIPE_OCR_SECRET
    (.string().optional()). docker-compose.yml les câble vers le service ; .env.example
    les documente « seulement pour l'import de recette par photo ; le bouton est masqué tant
    que non configuré ».
  • lib/recipe-ocr/recipe-ocr.service.ts — class RecipeOcrService + singleton
    recipeOcr (convention logger/analytics/forgejo). isConfigured() = les deux
    vars présentes. extract(images): Promise<ParsedRecipe> : POST {BASE}/v1/extract
    multipart, en-tête X-Recipe-Ocr-Secret, mappe la réponse en ParsedRecipe. Réseau KO
    / non-2xx ⇒ HttpError 502 RECIPE_OCR_REQUEST_FAILED ; 422 du service ⇒
    HttpError 422 RECIPE_PHOTO_NO_RECIPE_FOUND. Même forme que
    intent-service-client.ts / forgejo.service.ts.
  • Module recipe (ou sources) — POST /recipes/from-photos (requireAuth) :
    1. 503 RECIPE_OCR_NOT_CONFIGURED si !recipeOcr.isConfigured().
    2. Parse le multipart (nouvelle dépendance multer — ou base64 dans un body JSON, cf.
      Points à trancher), valide 1–4 images / types / taille.
    3. parsed = await recipeOcr.extract(images).
    4. draft = await translateRecipe(parsed) (fonction existante, inchangée) puis mise
      en forme RecipeImportDraftView — factoriser le toDraftView aujourd'hui interne à
      previewSourceItem (sources.service.ts) pour le partager.
    5. 200 avec le RecipeImportDraftView. Rien n'est persisté ; la sauvegarde passe
      ensuite par POST /recipes normal.
  • Monitoring — ajouter le service à admin-monitoring.service.ts getMonitoring()
    (sonde GET /health, comme l'intent-service), degraded/down sans casser les autres.
  • Analytics — analytics.recordEvent("recipe.photo_import_previewed", { actorType: "user", actorId, context: { pageCount, confidence } }) (fire-and-forget) et un
    "recipe.photo_import_saved" au POST /recipes qui suit (via un champ d'origine sur le
    body, ou un simple event distinct côté front).

Contrat partagé packages/shared

  • schemas/recipe.ts — photoImportImageSchema (type MIME + taille max) si validation
    partagée utile ; sinon rien de neuf ici, le body est du multipart.
  • types/sources.ts — le RecipeImportDraftView est réutilisé tel quel. Ajouter un
    champ optionnel origin?: "source" | "photo" seulement si l'UI de relecture doit
    distinguer les deux (ex. libellé « Extrait de vos photos » au lieu de « Importé de … »).
  • errors/error-codes.ts — RECIPE_OCR_NOT_CONFIGURED (5003),
    RECIPE_OCR_REQUEST_FAILED (5004), RECIPE_PHOTO_NO_RECIPE_FOUND (4004,
    « votre photo ne contenait pas de recette lisible ») + libellés dans errors.* du
    translation.json.

Frontend apps/web

  • Point d'entrée : sur /recettes/nouvelle (et/ou le bouton « Nouvelle recette »
    de RecipesPage), un choix « Formulaire vierge » / « Depuis une photo ». La branche
    photo ouvre un petit écran de capture/sélection (1–4 vignettes, ajout/retrait), puis
    appelle apiClient.importRecipeFromPhotos(files) → POST /recipes/from-photos.
  • Capture : web <input type="file" accept="image/*" multiple capture="environment"> ;
    natif @capacitor/camera (dépendance à ajouter — cf. Points à trancher). Capacitor.isNativePlatform()
    décide, motif déjà utilisé partout.
  • État : idle → extracting (loader, « Lecture des photos… ») → review | error.
    Erreur via ErrorMessageService (jamais err.message brut).
  • Relecture : rendre RecipeImportForm tel quel avec le RecipeImportDraftView
    reçu ; à la confirmation il fait déjà POST /recipes. Le seul ajustement possible est
    cosmétique (titre de l'écran, libellé d'origine — cf. origin ci-dessus).
  • i18n : bloc recipes.photoImport.* (bouton, états, aide « prenez la page en photo
    bien à plat, bien éclairée », erreurs).
  • api/client.ts — importRecipeFromPhotos(files: File[]): Promise<RecipeImportDraftView>
    (construit un FormData, pas de Content-Type manuel).

Tests

  • services/recipe-ocr-service (pytest, comme l'intent-service) : sur un jeu d'images
    fixtures (2–3 pages de recettes FR + 1 non-recette), POST /v1/extract renvoie les
    bons blocs ingredients/steps ; 422 sur la non-recette ; 401 sans secret ;
    GET /health OK. Jeu d'éval labellisé (comme TECH_STEP_EVAL_DATASET) : quelques
    photos réelles avec le ParsedRecipe attendu, gated en CI sur un seuil (ex. F1 lignes
    d'ingrédients / justesse name).
  • API (Mocha) : recipe-ocr.service — globalThis.fetch stubbé (motif
    the-meal-db.test.ts / forgejo.service.test.ts) : bonne URL, en-tête de secret,
    mapping réponse → ParsedRecipe ; 502 sur non-2xx / réseau KO ; 503 si non
    configuré. POST /recipes/from-photos : 401 sans session, 503 non configuré, 400
    (0 image / 5 images / type non supporté / trop gros), 200 renvoie un
    RecipeImportDraftView cohérent (fetch OCR stubbé), rien n'est écrit en base.
  • Web (Cypress) : recipe-photo-import.feature — choisir « Depuis une photo »,
    sélectionner 2 fichiers (cy.selectFile), POST /recipes/from-photos mocké → l'écran
    RecipeImportForm s'affiche pré-rempli ; le service renvoie 422 → message d'erreur ;
    bouton masqué quand le statut indique « non configuré ». Spec layout pour l'écran de
    capture.

Documentation à mettre à jour

  • specs/backend-architecture.md — nouvelle section « Import de recette par photo » :
    le microservice, POST /recipes/from-photos, les 2 vars RECIPE_OCR_*, le fait que
    translateRecipe est réutilisé.
  • specs/batch-cooking-architecture.md (ou l'équivalent qui décrit « import depuis
    source → traduction → sauvegarde ») — ajouter la branche « photo » en amont de la
    traduction.
  • README racine + docker-compose.yml — le nouveau service, comme
    tech-step-intent-service.
  • services/recipe-ocr-service/README.md — contrat POST /v1/extract, choix du
    moteur OCR, comment le modèle est baké.

Découpage proposé

PRs courtes et stackées (cf. « PRs plutôt petites et scopées ») :

  1. feat(ocr): microservice recipe-ocr-service (OCR + segmentation) — le service
    Python seul, Dockerfile, POST /v1/extract + GET /health, fixtures + pytest + jeu
    d'éval, entrée docker-compose.yml. Testable en isolation.
  2. feat(api): POST /recipes/from-photos + client OCR — RECIPE_OCR_* env,
    RecipeOcrService, l'endpoint, factorisation de toDraftView, ErrorCodes, monitoring,
    tests Mocha. Feature-flag naturel (fail-closed).
  3. feat(web): import d'une recette depuis des photos — point d'entrée, écran de
    capture (web <input capture>), apiClient.importRecipeFromPhotos, réutilisation de
    RecipeImportForm, i18n, Cypress.
  4. (optionnel) feat(mobile): capture photo native (@capacitor/camera) — si retenu,
    séparé pour isoler la dépendance native.

Points à trancher

  • Segmentation : heuristique pure vs assist LLM local. L'OCR donne du texte + des
    boîtes ; en tirer { name, portions, ingredients[], steps[] } de façon fiable est le
    vrai risque. Recommandé : heuristique d'abord (titres de section « Ingrédients » /
    « Préparation », lignes avec quantité, étapes numérotées, position/taille des boîtes),
    avec un seam propre pour brancher un LLM local (le Qwen existant, ou un modèle un
    peu plus gros dédié) si le jeu d'éval montre que l'heuristique plafonne. Ne pas partir
    d'emblée sur un LLM.
  • Moteur OCR : PaddleOCR (meilleure précision FR, plus lourd) vs docTR
    (bon compromis, boîtes de mise en page natives) vs Tesseract (le plus léger, moins
    bon sur photo). Recommandé : PaddleOCR ou docTR — les boîtes de layout sont utiles pour
    la segmentation. À valider selon les ressources de la machine de déploiement.
  • Transport des images : multipart via multer (nouvelle dépendance,
    endpoint propre) vs base64 dans un body JSON (aucune dépendance, mais +33 % de
    charge utile et il faut relever la limite du body-parser JSON). Recommandé : multer.
  • Capture native maintenant ou plus tard : ajouter @capacitor/camera en v1, ou
    s'en tenir à <input type="file" capture> (qui ouvre déjà l'appareil photo sur
    Android) et ajouter le plugin natif dans une PR de suivi. Recommandé : <input capture>
    en v1, plugin natif en suivi.
  • Cadence / ressources : l'OCR est CPU-intensif ; une seule extraction à la fois par
    conteneur ? file d'attente ? seuil de taille d'image (redimensionnement côté API avant
    d'envoyer) ? À caler comme le TECH_STEP_WORKER_CRON : valeur plancher provisoire,
    affinée sur volume observé.
  • Jeu d'éval : sur quel corpus de photos réelles (livres FR variés, fiches
    manuscrites) et quel seuil CI ? Même discipline que TECH_STEP_EVAL_DATASET.

Hors scope

  • OCR/LLM cloud (Vision API, LLM multimodal, clé d'API payante) — rupture avec la
    position « local/gratuit » du projet.
  • Stockage des photos (ré-extraction, historique, recipe.picture alimenté depuis
    une photo) — nécessiterait le sous-système de fichiers absent aujourd'hui.
  • Multi-langue de l'extraction (autre que FR).
  • Reconnaissance d'écriture manuscrite poussée — best-effort via le moteur OCR
    retenu, pas d'objectif de qualité chiffré en v1.
  • Import par lot (plusieurs recettes en une fois) et import depuis un PDF.
  • Détection automatique du régime / des allergènes à partir du texte extrait (les
    dietIds restent au choix de l'utilisateur dans l'écran de relecture, cf. #131).
  • Job asynchrone + polling — repli documenté si la requête synchrone ne tient pas.

🤖 Filed by Claude Code

## Résumé Permettre de **créer une recette à partir de photos** (page de livre de cuisine, fiche manuscrite, découpe de magazine). L'utilisateur prend 1 à 4 photos, l'app en extrait une recette structurée (nom, portions, ingrédients, étapes), puis **la présente pour relecture/correction dans le même écran que l'import depuis une source externe** avant sauvegarde. Rien n'est jamais enregistré sans validation humaine. L'extraction passe par un **nouveau microservice OCR local en Python** (`services/recipe-ocr-service/`), frère de `services/tech-step-intent-service` : OCR + segmentation de la mise en page → un `ParsedRecipe`. Le reste du pipeline d'import existant est **réutilisé tel quel** (`translateRecipe` : mise en correspondance ingrédients/unités contre le catalogue + détection des techniques via le service NLP actuel). ## Contexte (trouvé en auditant le code, pas supposé) - **Le pipeline d'import est déjà là et réutilisable.** Une source externe suit `parse()` → `ParsedRecipe` (`apps/api/src/lib/recipe-sources/recipe-source-adapter.ts` : `{ name, portions, ingredients: ParsedRecipeIngredient[], steps: ParsedRecipeStep[] }`, texte brut) → `translateRecipe()` (`apps/api/src/lib/recipe-matching/recipe-translation.ts`) → `RecipeImportDraftView` (`packages/shared/src/types/sources.ts:53`) → écran de relecture (`apps/web/src/features/recipes/sources/RecipeImportForm.tsx`, réutilisé aussi bien par `ImportRecipePage` que par `RecipePickerDialog`) → `POST /sources/:key/import/:externalId` avec un `createRecipeSchema` complet. `previewSourceItem` (`sources.service.ts:154`) est le point d'entrée qui assemble le draft via `translateRecipeIngredients` + `matchTechStepSpans`. **L'import photo doit produire un `ParsedRecipe` et brancher dessus** — pas réinventer la traduction ni l'UI de relecture. - **La position ML du projet est délibérément « local + gratuit + bakée au build ».** `services/tech-step-intent-service` : spaCy `fr_core_news_md`, modèle entraîné à son propre démarrage, aucun appel réseau sortant. `services/tech-step-llm-worker` : `Qwen2.5-1.5B` GGUF via `node-llama-cpp`. **Aucune clé d'API IA nulle part.** Un OCR cloud (Google Vision, AWS Textract, LLM multimodal) serait une rupture ; la décision ci-dessous reste dans la même ligne. - **`services/tech-step-intent-service` est le patron du nouveau microservice** : conteneur Python dans `docker-compose.yml`, secret partagé plat (`INTENT_SERVICE_SECRET` en en-tête `X-Intent-Service-Secret`, cf. `apps/api/src/lib/recipe-matching/intent-service-client.ts`, `POST /v1/process`), `INTENT_SERVICE_BASE_URL` dans `config/env.ts`. **Différence :** `INTENT_SERVICE_SECRET` est **requis** (dépendance de tout enregistrement de recette) ; l'OCR n'est nécessaire que pour l'import photo → il doit être **`.optional()` + fail-closed** (bouton masqué, endpoint `503`), même posture que `ADMIN_JWT_SECRET` / `INTERNAL_WORKER_SECRET`. - **Aucun sous-système d'upload de fichier n'existe.** L'issue #80 a explicitement écarté les pièces jointes pour cette raison ; toutes les images de l'app (`Recipe.picture`, `Step.picture`) sont des URLs externes, il n'y a ni stockage objet, ni endpoint d'upload, ni service de fichiers statiques hors le SPA. `apps/api` n'a **aucune** gestion `multipart` (`multer`/`formidable` absents). - **`@capacitor/camera` n'est pas installé** (`apps/web/package.json` : `android`, `app`, `cli`, `core`, `preferences` seulement). - **`createRecipeSchema`** (`packages/shared/src/schemas/recipe.ts`) est la cible : `name`, `portions` (entier > 0), `visibility` (défaut `PERSONAL`), `dietIds: number[]`, `ingredients` (≥ 1, chacune `ingredientId` **ou** `placeholderName`, `quantity`+`unitId` ensemble ou absents), `steps` (≥ 1, `description` 1–2000). Une ligne d'ingrédient non reconnue devient un **placeholder** (`Ingredient.isPlaceholder`) — exactement le comportement souhaité pour un ingrédient mal OCRisé. ## Décisions de conception (validées avec l'utilisateur) | Question | Décision | Pourquoi | |---|---|---| | Moteur d'extraction | **Microservice OCR Python local** (`services/recipe-ocr-service/`), couplé au NLP actuel. OCR + segmentation mise en page → `ParsedRecipe` ; puis `translateRecipe()` inchangé. | Reste dans la ligne « local/gratuit/bakée au build » du projet. Réutilise l'`ingredient-matcher` et le classifieur de techniques spaCy tels quels. | | Nombre de photos / stockage | **1 à 4 photos par import, non conservées.** Envoyées au service d'extraction puis jetées. | Pas de sous-système de stockage à créer, respectueux de la vie privée, cohérent avec #80. Ré-extraction = reprendre les photos. | | Confiance dans l'extraction | **Toujours relue par un humain** — l'extraction produit un *brouillon* (`RecipeImportDraftView`), l'utilisateur corrige chaque champ dans `RecipeImportForm` avant `POST /recipes`. Jamais d'enregistrement automatique. | Identique à l'import depuis source. L'OCR d'une photo est intrinsèquement bruité. | | Synchronicité (v1) | **Requête longue synchrone + loader** (OCR ~2–10 s). Un job asynchrone avec polling (comme `tech-step-llm-worker`) est noté en repli si la latence/les timeouts posent problème. | Pas de table de jobs à introduire pour un volume faible et une action ponctuelle. | | Priorité | **Medium** | Fonctionnalité de confort, pas un parcours cœur. | ## Architecture ``` Photos (1..4) ──► apps/web : écran « Nouvelle recette » → « Depuis une photo » · web : <input type="file" accept="image/*" capture="environment"> · natif: @capacitor/camera (dépendance à ajouter — cf. Points à trancher) ▼ multipart apps/api : POST /recipes/from-photos (requireAuth ; 503 si OCR non configuré) ▼ relaie les images services/recipe-ocr-service (Python, nouveau) 1. OCR FR (PaddleOCR / docTR / Tesseract — cf. Points à trancher) 2. Segmentation mise en page → { name?, portions?, ingredients: string[], steps: string[] } ▼ ParsedRecipe (même shape que recipe-source-adapter.ts) apps/api : translateRecipe(parsed) ◄── RÉUTILISÉ · translateRecipeIngredients (ingredient-matcher : catalogue Ingredient/Unit) · tech-step detection (tech-step-matcher → services/tech-step-intent-service) ▼ RecipeImportDraftView ◄── shape RÉUTILISÉE (types/sources.ts) ▼ apps/web : RecipeImportForm (relecture/correction) ◄── composant RÉUTILISÉ ▼ createRecipeSchema complet apps/api : POST /recipes (recipe.service.ts createRecipeInternal) ◄── RÉUTILISÉ ``` Seul l'encadré `services/recipe-ocr-service` + l'endpoint `POST /recipes/from-photos` + son client sont nouveaux. Tout le reste existe. ## Nouveau microservice — `services/recipe-ocr-service/` Frère de `services/tech-step-intent-service` (même Dockerfile-pattern, même en-tête de secret partagé, modèle OCR baké au build de l'image). - **`POST /v1/extract`** — corps `multipart/form-data`, champ `images` répété (1–4 fichiers `image/jpeg|png|webp`, ≤ 8 Mo chacun). En-tête `X-Recipe-Ocr-Secret` obligatoire. - **Réponse** `200` : `{ recipe: { name: string|null, portions: number|null, ingredients: string[], steps: string[] }, confidence: "high"|"low", pageCount: number }`. `422` si aucun texte de recette exploitable trouvé (photo floue, pas une recette). - **OCR** : moteur FR (voir Points à trancher). Sortie = texte + boîtes/lignes. - **Segmentation** : à partir des boîtes, séparer bloc « Ingrédients » vs bloc « Préparation/Étapes » (titres de section, lignes courtes avec quantité vs paragraphes numérotés). Extraire `name` (plus gros titre / première ligne) et `portions` (« pour 4 personnes », « 4 pers. »). Détail dans Points à trancher (heuristique pure vs assist LLM local). - **`GET /health`** sans secret (comme l'intent-service, pour la sonde monitoring). - Multi-langue hors v1 : FR uniquement (locale `fr`, comme le reste du NLP). ## Backend `apps/api` - **`config/env.ts`** — `RECIPE_OCR_BASE_URL` (`.url().optional()`), `RECIPE_OCR_SECRET` (`.string().optional()`). `docker-compose.yml` les câble vers le service ; `.env.example` les documente « seulement pour l'import de recette par photo ; le bouton est masqué tant que non configuré ». - **`lib/recipe-ocr/recipe-ocr.service.ts`** — `class RecipeOcrService` + singleton `recipeOcr` (convention `logger`/`analytics`/`forgejo`). `isConfigured()` = les deux vars présentes. `extract(images): Promise<ParsedRecipe>` : `POST {BASE}/v1/extract` multipart, en-tête `X-Recipe-Ocr-Secret`, mappe la réponse en `ParsedRecipe`. Réseau KO / non-2xx ⇒ `HttpError 502 RECIPE_OCR_REQUEST_FAILED` ; `422` du service ⇒ `HttpError 422 RECIPE_PHOTO_NO_RECIPE_FOUND`. Même forme que `intent-service-client.ts` / `forgejo.service.ts`. - **Module `recipe` (ou `sources`)** — `POST /recipes/from-photos` (`requireAuth`) : 1. `503 RECIPE_OCR_NOT_CONFIGURED` si `!recipeOcr.isConfigured()`. 2. Parse le multipart (nouvelle dépendance `multer` — ou base64 dans un body JSON, cf. Points à trancher), valide 1–4 images / types / taille. 3. `parsed = await recipeOcr.extract(images)`. 4. `draft = await translateRecipe(parsed)` **(fonction existante, inchangée)** puis mise en forme `RecipeImportDraftView` — factoriser le `toDraftView` aujourd'hui interne à `previewSourceItem` (`sources.service.ts`) pour le partager. 5. `200` avec le `RecipeImportDraftView`. **Rien n'est persisté** ; la sauvegarde passe ensuite par `POST /recipes` normal. - **Monitoring** — ajouter le service à `admin-monitoring.service.ts` `getMonitoring()` (sonde `GET /health`, comme l'intent-service), `degraded`/`down` sans casser les autres. - **Analytics** — `analytics.recordEvent("recipe.photo_import_previewed", { actorType: "user", actorId, context: { pageCount, confidence } })` (fire-and-forget) et un `"recipe.photo_import_saved"` au `POST /recipes` qui suit (via un champ d'origine sur le body, ou un simple event distinct côté front). ## Contrat partagé `packages/shared` - **`schemas/recipe.ts`** — `photoImportImageSchema` (type MIME + taille max) si validation partagée utile ; sinon rien de neuf ici, le body est du multipart. - **`types/sources.ts`** — le `RecipeImportDraftView` est réutilisé tel quel. Ajouter un champ optionnel `origin?: "source" | "photo"` **seulement** si l'UI de relecture doit distinguer les deux (ex. libellé « Extrait de vos photos » au lieu de « Importé de … »). - **`errors/error-codes.ts`** — `RECIPE_OCR_NOT_CONFIGURED` (5003), `RECIPE_OCR_REQUEST_FAILED` (5004), `RECIPE_PHOTO_NO_RECIPE_FOUND` (4004, « votre photo ne contenait pas de recette lisible ») + libellés dans `errors.*` du `translation.json`. ## Frontend `apps/web` - **Point d'entrée** : sur `/recettes/nouvelle` (et/ou le bouton « Nouvelle recette » de `RecipesPage`), un choix « Formulaire vierge » / « Depuis une photo ». La branche photo ouvre un petit écran de capture/sélection (1–4 vignettes, ajout/retrait), puis appelle `apiClient.importRecipeFromPhotos(files)` → `POST /recipes/from-photos`. - **Capture** : web `<input type="file" accept="image/*" multiple capture="environment">` ; natif `@capacitor/camera` (dépendance à ajouter — cf. Points à trancher). `Capacitor.isNativePlatform()` décide, motif déjà utilisé partout. - **État** : `idle → extracting (loader, « Lecture des photos… ») → review | error`. Erreur via `ErrorMessageService` (jamais `err.message` brut). - **Relecture** : rendre `RecipeImportForm` **tel quel** avec le `RecipeImportDraftView` reçu ; à la confirmation il fait déjà `POST /recipes`. Le seul ajustement possible est cosmétique (titre de l'écran, libellé d'origine — cf. `origin` ci-dessus). - **i18n** : bloc `recipes.photoImport.*` (bouton, états, aide « prenez la page en photo bien à plat, bien éclairée », erreurs). - **`api/client.ts`** — `importRecipeFromPhotos(files: File[]): Promise<RecipeImportDraftView>` (construit un `FormData`, pas de `Content-Type` manuel). ## Tests - **`services/recipe-ocr-service`** (pytest, comme l'intent-service) : sur un jeu d'images fixtures (2–3 pages de recettes FR + 1 non-recette), `POST /v1/extract` renvoie les bons blocs `ingredients`/`steps` ; `422` sur la non-recette ; `401` sans secret ; `GET /health` OK. **Jeu d'éval labellisé** (comme `TECH_STEP_EVAL_DATASET`) : quelques photos réelles avec le `ParsedRecipe` attendu, gated en CI sur un seuil (ex. F1 lignes d'ingrédients / justesse `name`). - **API (Mocha)** : `recipe-ocr.service` — `globalThis.fetch` stubbé (motif `the-meal-db.test.ts` / `forgejo.service.test.ts`) : bonne URL, en-tête de secret, mapping réponse → `ParsedRecipe` ; `502` sur non-2xx / réseau KO ; `503` si non configuré. `POST /recipes/from-photos` : 401 sans session, 503 non configuré, 400 (0 image / 5 images / type non supporté / trop gros), 200 renvoie un `RecipeImportDraftView` cohérent (fetch OCR stubbé), rien n'est écrit en base. - **Web (Cypress)** : `recipe-photo-import.feature` — choisir « Depuis une photo », sélectionner 2 fichiers (`cy.selectFile`), `POST /recipes/from-photos` mocké → l'écran `RecipeImportForm` s'affiche pré-rempli ; le service renvoie `422` → message d'erreur ; bouton masqué quand le statut indique « non configuré ». Spec layout pour l'écran de capture. ## Documentation à mettre à jour - **`specs/backend-architecture.md`** — nouvelle section « Import de recette par photo » : le microservice, `POST /recipes/from-photos`, les 2 vars `RECIPE_OCR_*`, le fait que `translateRecipe` est réutilisé. - **`specs/batch-cooking-architecture.md`** (ou l'équivalent qui décrit « import depuis source → traduction → sauvegarde ») — ajouter la branche « photo » en amont de la traduction. - **`README` racine** + `docker-compose.yml` — le nouveau service, comme `tech-step-intent-service`. - **`services/recipe-ocr-service/README.md`** — contrat `POST /v1/extract`, choix du moteur OCR, comment le modèle est baké. ## Découpage proposé PRs courtes et stackées (cf. « PRs plutôt petites et scopées ») : 1. **`feat(ocr): microservice recipe-ocr-service (OCR + segmentation)`** — le service Python seul, Dockerfile, `POST /v1/extract` + `GET /health`, fixtures + pytest + jeu d'éval, entrée `docker-compose.yml`. Testable en isolation. 2. **`feat(api): POST /recipes/from-photos + client OCR`** — `RECIPE_OCR_*` env, `RecipeOcrService`, l'endpoint, factorisation de `toDraftView`, ErrorCodes, monitoring, tests Mocha. Feature-flag naturel (fail-closed). 3. **`feat(web): import d'une recette depuis des photos`** — point d'entrée, écran de capture (web `<input capture>`), `apiClient.importRecipeFromPhotos`, réutilisation de `RecipeImportForm`, i18n, Cypress. 4. *(optionnel)* **`feat(mobile): capture photo native (@capacitor/camera)`** — si retenu, séparé pour isoler la dépendance native. ## Points à trancher - **Segmentation : heuristique pure vs assist LLM local.** L'OCR donne du texte + des boîtes ; en tirer `{ name, portions, ingredients[], steps[] }` de façon fiable est le vrai risque. Recommandé : **heuristique d'abord** (titres de section « Ingrédients » / « Préparation », lignes avec quantité, étapes numérotées, position/taille des boîtes), avec un **seam propre** pour brancher un LLM local (le `Qwen` existant, ou un modèle un peu plus gros dédié) si le jeu d'éval montre que l'heuristique plafonne. Ne pas partir d'emblée sur un LLM. - **Moteur OCR** : **PaddleOCR** (meilleure précision FR, plus lourd) vs **docTR** (bon compromis, boîtes de mise en page natives) vs **Tesseract** (le plus léger, moins bon sur photo). Recommandé : PaddleOCR ou docTR — les boîtes de layout sont utiles pour la segmentation. À valider selon les ressources de la machine de déploiement. - **Transport des images** : `multipart` via **`multer`** (nouvelle dépendance, endpoint propre) vs **base64 dans un body JSON** (aucune dépendance, mais +33 % de charge utile et il faut relever la limite du body-parser JSON). Recommandé : `multer`. - **Capture native maintenant ou plus tard** : ajouter `@capacitor/camera` en v1, ou s'en tenir à `<input type="file" capture>` (qui ouvre déjà l'appareil photo sur Android) et ajouter le plugin natif dans une PR de suivi. Recommandé : `<input capture>` en v1, plugin natif en suivi. - **Cadence / ressources** : l'OCR est CPU-intensif ; une seule extraction à la fois par conteneur ? file d'attente ? seuil de taille d'image (redimensionnement côté API avant d'envoyer) ? À caler comme le `TECH_STEP_WORKER_CRON` : valeur plancher provisoire, affinée sur volume observé. - **Jeu d'éval** : sur quel corpus de photos réelles (livres FR variés, fiches manuscrites) et quel seuil CI ? Même discipline que `TECH_STEP_EVAL_DATASET`. ## Hors scope - OCR/LLM **cloud** (Vision API, LLM multimodal, clé d'API payante) — rupture avec la position « local/gratuit » du projet. - **Stockage** des photos (ré-extraction, historique, `recipe.picture` alimenté depuis une photo) — nécessiterait le sous-système de fichiers absent aujourd'hui. - **Multi-langue** de l'extraction (autre que FR). - **Reconnaissance d'écriture manuscrite** poussée — best-effort via le moteur OCR retenu, pas d'objectif de qualité chiffré en v1. - **Import par lot** (plusieurs recettes en une fois) et import depuis un **PDF**. - **Détection automatique** du régime / des allergènes à partir du texte extrait (les `dietIds` restent au choix de l'utilisateur dans l'écran de relecture, cf. #131). - **Job asynchrone + polling** — repli documenté si la requête synchrone ne tient pas. --- 🤖 Filed by [Claude Code](https://claude.com/claude-code)
kyuno added the
enhancement
to plan
labels 2026-09-05 19:36:06 +02:00
kyuno added
Medium
planned
and removed
to plan
labels 2026-09-09 16:18:50 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: kyuno/batchCooking#43
No description provided.