Import de recettes via photos #43
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 deservices/tech-step-intent-service: OCR +segmentation de la mise en page → un
ParsedRecipe. Le reste du pipeline d'importexistant est réutilisé tel quel (
translateRecipe: mise en correspondanceingrédients/unités contre le catalogue + détection des techniques via le service NLP
actuel).
Contexte (trouvé en auditant le code, pas supposé)
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 parImportRecipePageque parRecipePickerDialog) →POST /sources/:key/import/:externalIdavec un
createRecipeSchemacomplet.previewSourceItem(sources.service.ts:154) estle point d'entrée qui assemble le draft via
translateRecipeIngredients+matchTechStepSpans. L'import photo doit produire unParsedRecipeet brancherdessus — pas réinventer la traduction ni l'UI de relecture.
services/tech-step-intent-service: spaCyfr_core_news_md, modèle entraîné à sonpropre démarrage, aucun appel réseau sortant.
services/tech-step-llm-worker:Qwen2.5-1.5BGGUF vianode-llama-cpp. Aucune clé d'API IA nulle part. Un OCRcloud (Google Vision, AWS Textract, LLM multimodal) serait une rupture ; la décision
ci-dessous reste dans la même ligne.
services/tech-step-intent-serviceest le patron du nouveau microservice : conteneurPython dans
docker-compose.yml, secret partagé plat (INTENT_SERVICE_SECRETenen-tête
X-Intent-Service-Secret, cf.apps/api/src/lib/recipe-matching/intent-service-client.ts,POST /v1/process),INTENT_SERVICE_BASE_URLdansconfig/env.ts. Différence :INTENT_SERVICE_SECRETest 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 queADMIN_JWT_SECRET/INTERNAL_WORKER_SECRET.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 endpointd'upload, ni service de fichiers statiques hors le SPA.
apps/apin'a aucunegestion
multipart(multer/formidableabsents).@capacitor/cameran'est pas installé (apps/web/package.json:android,app,cli,core,preferencesseulement).createRecipeSchema(packages/shared/src/schemas/recipe.ts) est la cible :name,portions(entier > 0),visibility(défautPERSONAL),dietIds: number[],ingredients(≥ 1, chacuneingredientIdouplaceholderName,quantity+unitIdensemble ou absents),
steps(≥ 1,description1–2000). Une ligne d'ingrédient nonreconnue devient un placeholder (
Ingredient.isPlaceholder) — exactement lecomportement souhaité pour un ingrédient mal OCRisé.
Décisions de conception (validées avec l'utilisateur)
services/recipe-ocr-service/), couplé au NLP actuel. OCR + segmentation mise en page →ParsedRecipe; puistranslateRecipe()inchangé.ingredient-matcheret le classifieur de techniques spaCy tels quels.RecipeImportDraftView), l'utilisateur corrige chaque champ dansRecipeImportFormavantPOST /recipes. Jamais d'enregistrement automatique.tech-step-llm-worker) est noté en repli si la latence/les timeouts posent problème.Architecture
Seul l'encadré
services/recipe-ocr-service+ l'endpointPOST /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 desecret partagé, modèle OCR baké au build de l'image).
POST /v1/extract— corpsmultipart/form-data, champimagesrépété (1–4 fichiersimage/jpeg|png|webp, ≤ 8 Mo chacun). En-têteX-Recipe-Ocr-Secretobligatoire.200:{ recipe: { name: string|null, portions: number|null, ingredients: string[], steps: string[] }, confidence: "high"|"low", pageCount: number }.422si aucun texte de recette exploitable trouvé (photo floue, pas une recette).« Préparation/Étapes » (titres de section, lignes courtes avec quantité vs paragraphes
numérotés). Extraire
name(plus gros titre / première ligne) etportions(« pour 4 personnes », « 4 pers. »). Détail dans Points à trancher (heuristique pure vs
assist LLM local).
GET /healthsans secret (comme l'intent-service, pour la sonde monitoring).fr, comme le reste du NLP).Backend
apps/apiconfig/env.ts—RECIPE_OCR_BASE_URL(.url().optional()),RECIPE_OCR_SECRET(
.string().optional()).docker-compose.ymlles câble vers le service ;.env.exampleles 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+ singletonrecipeOcr(conventionlogger/analytics/forgejo).isConfigured()= les deuxvars présentes.
extract(images): Promise<ParsedRecipe>:POST {BASE}/v1/extractmultipart, en-tête
X-Recipe-Ocr-Secret, mappe la réponse enParsedRecipe. Réseau KO/ non-2xx ⇒
HttpError 502 RECIPE_OCR_REQUEST_FAILED;422du service ⇒HttpError 422 RECIPE_PHOTO_NO_RECIPE_FOUND. Même forme queintent-service-client.ts/forgejo.service.ts.recipe(ousources) —POST /recipes/from-photos(requireAuth) :503 RECIPE_OCR_NOT_CONFIGUREDsi!recipeOcr.isConfigured().multer— ou base64 dans un body JSON, cf.Points à trancher), valide 1–4 images / types / taille.
parsed = await recipeOcr.extract(images).draft = await translateRecipe(parsed)(fonction existante, inchangée) puis miseen forme
RecipeImportDraftView— factoriser letoDraftViewaujourd'hui interne àpreviewSourceItem(sources.service.ts) pour le partager.200avec leRecipeImportDraftView. Rien n'est persisté ; la sauvegarde passeensuite par
POST /recipesnormal.admin-monitoring.service.tsgetMonitoring()(sonde
GET /health, comme l'intent-service),degraded/downsans casser les autres.analytics.recordEvent("recipe.photo_import_previewed", { actorType: "user", actorId, context: { pageCount, confidence } })(fire-and-forget) et un"recipe.photo_import_saved"auPOST /recipesqui suit (via un champ d'origine sur lebody, ou un simple event distinct côté front).
Contrat partagé
packages/sharedschemas/recipe.ts—photoImportImageSchema(type MIME + taille max) si validationpartagée utile ; sinon rien de neuf ici, le body est du multipart.
types/sources.ts— leRecipeImportDraftViewest réutilisé tel quel. Ajouter unchamp optionnel
origin?: "source" | "photo"seulement si l'UI de relecture doitdistinguer 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.*dutranslation.json.Frontend
apps/web/recettes/nouvelle(et/ou le bouton « Nouvelle recette »de
RecipesPage), un choix « Formulaire vierge » / « Depuis une photo ». La branchephoto ouvre un petit écran de capture/sélection (1–4 vignettes, ajout/retrait), puis
appelle
apiClient.importRecipeFromPhotos(files)→POST /recipes/from-photos.<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.
idle → extracting (loader, « Lecture des photos… ») → review | error.Erreur via
ErrorMessageService(jamaiserr.messagebrut).RecipeImportFormtel quel avec leRecipeImportDraftViewreçu ; à la confirmation il fait déjà
POST /recipes. Le seul ajustement possible estcosmétique (titre de l'écran, libellé d'origine — cf.
originci-dessus).recipes.photoImport.*(bouton, états, aide « prenez la page en photobien à plat, bien éclairée », erreurs).
api/client.ts—importRecipeFromPhotos(files: File[]): Promise<RecipeImportDraftView>(construit un
FormData, pas deContent-Typemanuel).Tests
services/recipe-ocr-service(pytest, comme l'intent-service) : sur un jeu d'imagesfixtures (2–3 pages de recettes FR + 1 non-recette),
POST /v1/extractrenvoie lesbons blocs
ingredients/steps;422sur la non-recette ;401sans secret ;GET /healthOK. Jeu d'éval labellisé (commeTECH_STEP_EVAL_DATASET) : quelquesphotos réelles avec le
ParsedRecipeattendu, gated en CI sur un seuil (ex. F1 lignesd'ingrédients / justesse
name).recipe-ocr.service—globalThis.fetchstubbé (motifthe-meal-db.test.ts/forgejo.service.test.ts) : bonne URL, en-tête de secret,mapping réponse →
ParsedRecipe;502sur non-2xx / réseau KO ;503si nonconfiguré.
POST /recipes/from-photos: 401 sans session, 503 non configuré, 400(0 image / 5 images / type non supporté / trop gros), 200 renvoie un
RecipeImportDraftViewcohérent (fetch OCR stubbé), rien n'est écrit en base.recipe-photo-import.feature— choisir « Depuis une photo »,sélectionner 2 fichiers (
cy.selectFile),POST /recipes/from-photosmocké → l'écranRecipeImportForms'affiche pré-rempli ; le service renvoie422→ 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 varsRECIPE_OCR_*, le fait quetranslateRecipeest réutilisé.specs/batch-cooking-architecture.md(ou l'équivalent qui décrit « import depuissource → traduction → sauvegarde ») — ajouter la branche « photo » en amont de la
traduction.
READMEracine +docker-compose.yml— le nouveau service, commetech-step-intent-service.services/recipe-ocr-service/README.md— contratPOST /v1/extract, choix dumoteur OCR, comment le modèle est baké.
Découpage proposé
PRs courtes et stackées (cf. « PRs plutôt petites et scopées ») :
feat(ocr): microservice recipe-ocr-service (OCR + segmentation)— le servicePython seul, Dockerfile,
POST /v1/extract+GET /health, fixtures + pytest + jeud'éval, entrée
docker-compose.yml. Testable en isolation.feat(api): POST /recipes/from-photos + client OCR—RECIPE_OCR_*env,RecipeOcrService, l'endpoint, factorisation detoDraftView, ErrorCodes, monitoring,tests Mocha. Feature-flag naturel (fail-closed).
feat(web): import d'une recette depuis des photos— point d'entrée, écran decapture (web
<input capture>),apiClient.importRecipeFromPhotos, réutilisation deRecipeImportForm, i18n, Cypress.feat(mobile): capture photo native (@capacitor/camera)— si retenu,séparé pour isoler la dépendance native.
Points à trancher
boîtes ; en tirer
{ name, portions, ingredients[], steps[] }de façon fiable est levrai 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
Qwenexistant, ou un modèle unpeu plus gros dédié) si le jeu d'éval montre que l'heuristique plafonne. Ne pas partir
d'emblée sur un LLM.
(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.
multipartviamulter(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.@capacitor/cameraen v1, ous'en tenir à
<input type="file" capture>(qui ouvre déjà l'appareil photo surAndroid) et ajouter le plugin natif dans une PR de suivi. Recommandé :
<input capture>en v1, plugin natif en suivi.
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é.
manuscrites) et quel seuil CI ? Même discipline que
TECH_STEP_EVAL_DATASET.Hors scope
position « local/gratuit » du projet.
recipe.picturealimenté depuisune photo) — nécessiterait le sous-système de fichiers absent aujourd'hui.
retenu, pas d'objectif de qualité chiffré en v1.
dietIdsrestent au choix de l'utilisateur dans l'écran de relecture, cf. #131).🤖 Filed by Claude Code