Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions assets/provenance/images.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion image-prompts/air-fryer-tofu-croustillant.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion recipes/air-fryer-pommes-terre-romarin.gram
Original file line number Diff line number Diff line change
Expand Up @@ -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}
---
Expand Down
2 changes: 1 addition & 1 deletion recipes/air-fryer-poulet-paprika-herbes.gram
Original file line number Diff line number Diff line change
Expand Up @@ -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}
---
Expand Down
2 changes: 1 addition & 1 deletion recipes/air-fryer-quesadillas-poulet-fromage.gram
Original file line number Diff line number Diff line change
Expand Up @@ -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}
---
Expand Down
2 changes: 1 addition & 1 deletion recipes/air-fryer-saucisses-poivrons-oignons.gram
Original file line number Diff line number Diff line change
Expand Up @@ -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}
---
Expand Down
2 changes: 1 addition & 1 deletion recipes/air-fryer-saumon-citron-aneth.gram
Original file line number Diff line number Diff line change
Expand Up @@ -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}
---
Expand Down
2 changes: 1 addition & 1 deletion recipes/air-fryer-tofu-croustillant.gram
Original file line number Diff line number Diff line change
Expand Up @@ -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}
---
Expand Down
106 changes: 105 additions & 1 deletion scripts/audit-recipe-images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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")):
Expand Down Expand Up @@ -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(
Expand Down
Binary file modified static/images/air-fryer-pommes-terre-romarin.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/images/air-fryer-poulet-paprika-herbes.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/images/air-fryer-quesadillas-poulet-fromage.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/images/air-fryer-saucisses-poivrons-oignons.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/images/air-fryer-saumon-citron-aneth.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/images/air-fryer-tofu-croustillant.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions tests/test_image_provenance.py
Original file line number Diff line number Diff line change
@@ -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()
Loading