diff --git a/README.md b/README.md index 708428b..c3048f3 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ python scripts/audit-recipe-images.py --check python scripts/lint-public-content.py --check --warn-only --json ``` +Les illustrations générées pour CookiGram sont enregistrées dans +[`assets/provenance/images.yaml`](assets/provenance/images.yaml). L’audit des +illustrations vérifie le lien recette/asset, le prompt versionné et le SHA-256 +du fichier final ; il rejette aussi tout nouveau crédit `Illustration +temporaire`. + Le validateur complet `python -m generator.recipe_check`, le build et les tests Python/JavaScript ne sont pas disponibles dans ce dépôt ; ils nécessitent une installation de `cookigram-core` autorisée. Ne pas documenter de couverture ou de commande `npm`, `pytest`, `ruff` ou `generator` comme prérequis local ici. ## Format d’une recette diff --git a/assets/provenance/images.yaml b/assets/provenance/images.yaml new file mode 100644 index 0000000..6688d2c --- /dev/null +++ b/assets/provenance/images.yaml @@ -0,0 +1,53 @@ +static/images/air-fryer-pommes-terre-romarin.webp: + recipe: air-fryer-pommes-terre-romarin + origin: generated + generator: image_gen + generated_at: 2026-09-15 + prompt: "Photo culinaire manga réaliste, quartiers de pommes de terre croustillants au romarin, panier air fryer, assiette rustique." + sha256: 5677e6d6fa668265f99fe122d1722eea8d7a516d07b1447bddf21eba1989aec2 + attribution: CookiGram + +static/images/air-fryer-poulet-paprika-herbes.webp: + recipe: air-fryer-poulet-paprika-herbes + origin: generated + generator: image_gen + generated_at: 2026-09-15 + prompt: "Photo culinaire manga réaliste, blancs de poulet dorés au paprika et aux herbes dans un panier air fryer, lumière chaleureuse." + sha256: e84a7e5993b1a64e81d0727cdb9377cdf7e68864ac2b91fd2329e4ae89ad0a68 + attribution: CookiGram + +static/images/air-fryer-quesadillas-poulet-fromage.webp: + recipe: air-fryer-quesadillas-poulet-fromage + origin: generated + generator: image_gen + generated_at: 2026-09-15 + prompt: "Photo culinaire manga réaliste, quesadillas triangulaires dorées, poulet épicé et fromage fondant, panier air fryer, guacamole." + sha256: 964301be90ee8615961a88d62d4d1e4940c820ba99d7d337d6c81d1d5a8cb1f8 + attribution: CookiGram + +static/images/air-fryer-saucisses-poivrons-oignons.webp: + recipe: air-fryer-saucisses-poivrons-oignons + origin: generated + generator: image_gen + generated_at: 2026-09-15 + prompt: "Photo culinaire manga réaliste, saucisses dorées avec poivrons et oignons caramélisés, panier air fryer, couleurs appétissantes." + sha256: ee26c1b3e36ea7d55eb28e16d3f205d35ab2188f81c6c695b89f15300812e76f + attribution: CookiGram + +static/images/air-fryer-saumon-citron-aneth.webp: + recipe: air-fryer-saumon-citron-aneth + origin: generated + generator: image_gen + generated_at: 2026-09-15 + prompt: "Photo culinaire manga réaliste, pavé de saumon nacré au citron et à l’aneth, panier air fryer, assiette lumineuse." + sha256: d757a4e99428a416dfe611d5286297f562f84a5ef7653683b64911bcb0536516 + attribution: CookiGram + +static/images/air-fryer-tofu-croustillant.webp: + recipe: air-fryer-tofu-croustillant + origin: generated + generator: image_gen + generated_at: 2026-09-15 + prompt: "Photographie culinaire réaliste, bouchées de tofu croustillantes et dorées, panure à l’ail et au paprika, servies seules sur une assiette simple, lumière naturelle douce, présentation domestique crédible, plat clairement visible, sans sauce, sans texte, sans logo, sans marque, sans personnage, sans emballage, sans décoration inutile." + sha256: ef92fadafdb38daff88f9e0a8caa58a3df9f7b5b973195e29e512c576db2fc56 + attribution: CookiGram diff --git a/image-prompts/air-fryer-tofu-croustillant.md b/image-prompts/air-fryer-tofu-croustillant.md index 816db16..1c13549 100644 --- a/image-prompts/air-fryer-tofu-croustillant.md +++ b/image-prompts/air-fryer-tofu-croustillant.md @@ -1 +1 @@ -Photo culinaire manga réaliste, bouchées de tofu croustillantes et dorées, panure à l’ail et paprika, dip léger. +Photographie culinaire réaliste, bouchées de tofu croustillantes et dorées, panure à l’ail et au paprika, servies seules sur une assiette simple, lumière naturelle douce, présentation domestique crédible, plat clairement visible, sans sauce, sans texte, sans logo, sans marque, sans personnage, sans emballage, sans décoration inutile. diff --git a/recipes/air-fryer-pommes-terre-romarin.gram b/recipes/air-fryer-pommes-terre-romarin.gram index f847d71..47df8d3 100644 --- a/recipes/air-fryer-pommes-terre-romarin.gram +++ b/recipes/air-fryer-pommes-terre-romarin.gram @@ -13,7 +13,7 @@ image: images/air-fryer-pommes-terre-romarin.webp image_credit: author: CookiGram source: https://github.com/PierreCsn/cookigram - license: Illustration temporaire + license: Illustration générée pour CookiGram image_generation: {prompt_file: image-prompts/air-fryer-pommes-terre-romarin.md} scaling: {enabled: true, min: 2, max: 6, step: 1} --- diff --git a/recipes/air-fryer-poulet-paprika-herbes.gram b/recipes/air-fryer-poulet-paprika-herbes.gram index 5cf971e..7382fbd 100644 --- a/recipes/air-fryer-poulet-paprika-herbes.gram +++ b/recipes/air-fryer-poulet-paprika-herbes.gram @@ -13,7 +13,7 @@ image: images/air-fryer-poulet-paprika-herbes.webp image_credit: author: CookiGram source: https://github.com/PierreCsn/cookigram - license: Illustration temporaire + license: Illustration générée pour CookiGram image_generation: {prompt_file: image-prompts/air-fryer-poulet-paprika-herbes.md} scaling: {enabled: true, min: 2, max: 4, step: 1} --- diff --git a/recipes/air-fryer-quesadillas-poulet-fromage.gram b/recipes/air-fryer-quesadillas-poulet-fromage.gram index 5b90eb4..e6d0f4e 100644 --- a/recipes/air-fryer-quesadillas-poulet-fromage.gram +++ b/recipes/air-fryer-quesadillas-poulet-fromage.gram @@ -13,7 +13,7 @@ image: images/air-fryer-quesadillas-poulet-fromage.webp image_credit: author: CookiGram source: https://github.com/PierreCsn/cookigram - license: Illustration temporaire + license: Illustration générée pour CookiGram image_generation: {prompt_file: image-prompts/air-fryer-quesadillas-poulet-fromage.md} scaling: {enabled: true, min: 2, max: 4, step: 1} --- diff --git a/recipes/air-fryer-saucisses-poivrons-oignons.gram b/recipes/air-fryer-saucisses-poivrons-oignons.gram index 2ca2a7f..9f94940 100644 --- a/recipes/air-fryer-saucisses-poivrons-oignons.gram +++ b/recipes/air-fryer-saucisses-poivrons-oignons.gram @@ -13,7 +13,7 @@ image: images/air-fryer-saucisses-poivrons-oignons.webp image_credit: author: CookiGram source: https://github.com/PierreCsn/cookigram - license: Illustration temporaire + license: Illustration générée pour CookiGram image_generation: {prompt_file: image-prompts/air-fryer-saucisses-poivrons-oignons.md} scaling: {enabled: true, min: 2, max: 6, step: 1} --- diff --git a/recipes/air-fryer-saumon-citron-aneth.gram b/recipes/air-fryer-saumon-citron-aneth.gram index 075101b..2ab4975 100644 --- a/recipes/air-fryer-saumon-citron-aneth.gram +++ b/recipes/air-fryer-saumon-citron-aneth.gram @@ -13,7 +13,7 @@ image: images/air-fryer-saumon-citron-aneth.webp image_credit: author: CookiGram source: https://github.com/PierreCsn/cookigram - license: Illustration temporaire + license: Illustration générée pour CookiGram image_generation: {prompt_file: image-prompts/air-fryer-saumon-citron-aneth.md} scaling: {enabled: true, min: 2, max: 6, step: 1} --- diff --git a/recipes/air-fryer-tofu-croustillant.gram b/recipes/air-fryer-tofu-croustillant.gram index b3a3ffc..cd48614 100644 --- a/recipes/air-fryer-tofu-croustillant.gram +++ b/recipes/air-fryer-tofu-croustillant.gram @@ -13,7 +13,7 @@ image: images/air-fryer-tofu-croustillant.webp image_credit: author: CookiGram source: https://github.com/PierreCsn/cookigram - license: Illustration temporaire + license: Illustration générée pour CookiGram image_generation: {prompt_file: image-prompts/air-fryer-tofu-croustillant.md} scaling: {enabled: true, min: 2, max: 4, step: 1} --- diff --git a/scripts/audit-recipe-images.py b/scripts/audit-recipe-images.py index e25db97..b90fb65 100644 --- a/scripts/audit-recipe-images.py +++ b/scripts/audit-recipe-images.py @@ -10,6 +10,8 @@ from __future__ import annotations import argparse +import datetime as dt +import hashlib import json import sys from dataclasses import asdict, dataclass @@ -20,6 +22,9 @@ PLACEHOLDER = "images/placeholder-recipe.jpg" +TEMPORARY_CREDIT = "Illustration temporaire" +GENERATED_CREDIT = "Illustration générée pour CookiGram" +PROVENANCE_MANIFEST = "assets/provenance/images.yaml" @dataclass(frozen=True) @@ -43,8 +48,62 @@ def _frontmatter(path: Path) -> dict[str, Any]: return data if isinstance(data, dict) else {} -def audit(root: Path) -> list[Finding]: +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _manifest(root: Path) -> dict[str, Any]: + path = root / PROVENANCE_MANIFEST + if not path.is_file(): + return {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return data if isinstance(data, dict) else {} + + +def _manifest_findings(root: Path, manifest: dict[str, Any]) -> list[Finding]: findings: list[Finding] = [] + mapped_recipes: dict[str, str] = {} + for asset, record in manifest.items(): + if not isinstance(asset, str) or not isinstance(record, dict): + findings.append(Finding(PROVENANCE_MANIFEST, None, "invalid-manifest", "manifest", "chaque asset doit avoir un mapping YAML")) + continue + asset_path = root / asset + if not asset_path.is_file(): + findings.append(Finding(PROVENANCE_MANIFEST, None, "missing-provenance-asset", "manifest", f"fichier absent: {asset}")) + continue + expected_hash = record.get("sha256") + if not isinstance(expected_hash, str) or expected_hash != _sha256(asset_path): + findings.append(Finding(PROVENANCE_MANIFEST, None, "provenance-hash", "manifest", f"SHA-256 incorrect: {asset}")) + recipe = record.get("recipe") + if not isinstance(recipe, str) or not recipe.strip(): + findings.append(Finding(PROVENANCE_MANIFEST, None, "provenance-recipe", "manifest", f"recipe absente: {asset}")) + elif recipe in mapped_recipes: + findings.append(Finding(PROVENANCE_MANIFEST, None, "duplicate-provenance-recipe", "manifest", f"recipe mappée deux fois: {recipe}")) + else: + mapped_recipes[recipe] = asset + recipe_path = root / "recipes" / f"{recipe}.gram" + if not recipe_path.is_file(): + findings.append(Finding(PROVENANCE_MANIFEST, None, "unknown-provenance-recipe", "manifest", f"recipe introuvable: {recipe}")) + for key in ("origin", "generator", "prompt", "attribution"): + if not isinstance(record.get(key), str) or not record[key].strip(): + findings.append(Finding(PROVENANCE_MANIFEST, None, "incomplete-provenance", "manifest", f"{key} absent pour {asset}")) + generated_at = record.get("generated_at") + if isinstance(generated_at, (dt.date, dt.datetime)): + pass + elif not isinstance(generated_at, str) or not generated_at.strip(): + findings.append(Finding(PROVENANCE_MANIFEST, None, "incomplete-provenance", "manifest", f"generated_at absent pour {asset}")) + if record.get("origin") != "generated": + findings.append(Finding(PROVENANCE_MANIFEST, None, "provenance-origin", "manifest", f"origin non générée pour {asset}")) + return findings + + +def audit(root: Path) -> list[Finding]: + manifest = _manifest(root) + findings: list[Finding] = _manifest_findings(root, manifest) recipes_dir = root / "recipes" for recipe_path in sorted(recipes_dir.glob("*.gram")): @@ -83,6 +142,51 @@ def audit(root: Path) -> list[Finding]: ) ) + credit = metadata.get("image_credit") + license_name = credit.get("license") if isinstance(credit, dict) else None + if license_name == TEMPORARY_CREDIT: + findings.append( + Finding( + recipe=recipe_path.relative_to(root).as_posix(), + image=image, + prompt_file=prompt, + status="temporary-credit", + message="crédit d'illustration temporaire encore présent", + ) + ) + elif license_name == GENERATED_CREDIT: + asset_key = f"static/{image}" if image else "" + record = manifest.get(asset_key) + if not isinstance(record, dict): + findings.append( + Finding( + recipe=recipe_path.relative_to(root).as_posix(), + image=image, + prompt_file=prompt, + status="missing-provenance", + message="illustration générée sans entrée de provenance", + ) + ) + elif record.get("recipe") != recipe_path.stem: + findings.append( + Finding( + recipe=recipe_path.relative_to(root).as_posix(), + image=image, + prompt_file=prompt, + status="provenance-mismatch", + message="la recette du manifest ne correspond pas au fichier", + ) + ) + elif prompt_path.is_file() and record.get("prompt") != prompt_path.read_text(encoding="utf-8").strip(): + findings.append( + Finding( + recipe=recipe_path.relative_to(root).as_posix(), + image=image, + prompt_file=prompt, + status="provenance-prompt-mismatch", + message="le prompt du manifest diffère du prompt versionné", + ) + ) if not prompt_path.is_file(): findings.append( Finding( diff --git a/static/images/air-fryer-pommes-terre-romarin.webp b/static/images/air-fryer-pommes-terre-romarin.webp index 86afbce..7706b30 100644 Binary files a/static/images/air-fryer-pommes-terre-romarin.webp and b/static/images/air-fryer-pommes-terre-romarin.webp differ diff --git a/static/images/air-fryer-poulet-paprika-herbes.webp b/static/images/air-fryer-poulet-paprika-herbes.webp index 201d52b..7389965 100644 Binary files a/static/images/air-fryer-poulet-paprika-herbes.webp and b/static/images/air-fryer-poulet-paprika-herbes.webp differ diff --git a/static/images/air-fryer-quesadillas-poulet-fromage.webp b/static/images/air-fryer-quesadillas-poulet-fromage.webp index e0da567..3fc70cb 100644 Binary files a/static/images/air-fryer-quesadillas-poulet-fromage.webp and b/static/images/air-fryer-quesadillas-poulet-fromage.webp differ diff --git a/static/images/air-fryer-saucisses-poivrons-oignons.webp b/static/images/air-fryer-saucisses-poivrons-oignons.webp index 5509dd0..c4bf064 100644 Binary files a/static/images/air-fryer-saucisses-poivrons-oignons.webp and b/static/images/air-fryer-saucisses-poivrons-oignons.webp differ diff --git a/static/images/air-fryer-saumon-citron-aneth.webp b/static/images/air-fryer-saumon-citron-aneth.webp index 004ea28..5809440 100644 Binary files a/static/images/air-fryer-saumon-citron-aneth.webp and b/static/images/air-fryer-saumon-citron-aneth.webp differ diff --git a/static/images/air-fryer-tofu-croustillant.webp b/static/images/air-fryer-tofu-croustillant.webp index 8a56e75..4cd6cba 100644 Binary files a/static/images/air-fryer-tofu-croustillant.webp and b/static/images/air-fryer-tofu-croustillant.webp differ diff --git a/tests/test_image_provenance.py b/tests/test_image_provenance.py new file mode 100644 index 0000000..a040ea5 --- /dev/null +++ b/tests/test_image_provenance.py @@ -0,0 +1,50 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("audit_recipe_images", ROOT / "scripts/audit-recipe-images.py") +AUDIT = importlib.util.module_from_spec(SPEC) +import sys +sys.modules[SPEC.name] = AUDIT +SPEC.loader.exec_module(AUDIT) + + +class ImageProvenanceTests(unittest.TestCase): + def test_generated_replacements_have_valid_provenance(self): + findings = AUDIT.audit(ROOT) + self.assertEqual(findings, []) + + manifest = yaml.safe_load((ROOT / AUDIT.PROVENANCE_MANIFEST).read_text(encoding="utf-8")) + self.assertEqual(len(manifest), 6) + self.assertEqual({record["recipe"] for record in manifest.values()}, { + "air-fryer-pommes-terre-romarin", + "air-fryer-poulet-paprika-herbes", + "air-fryer-quesadillas-poulet-fromage", + "air-fryer-saucisses-poivrons-oignons", + "air-fryer-saumon-citron-aneth", + "air-fryer-tofu-croustillant", + }) + + def test_temporary_credit_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "recipes").mkdir() + (root / "static/images").mkdir(parents=True) + (root / "image-prompts").mkdir() + (root / "static/images/test.webp").write_bytes(b"image") + (root / "image-prompts/test.md").write_text("prompt\n", encoding="utf-8") + (root / "recipes/test.gram").write_text( + "---\nimage: images/test.webp\nimage_credit:\n license: Illustration temporaire\nimage_generation: {prompt_file: image-prompts/test.md}\n---\n", + encoding="utf-8", + ) + statuses = {finding.status for finding in AUDIT.audit(root)} + self.assertIn("temporary-credit", statuses) + + +if __name__ == "__main__": + unittest.main()