Linux SRV-16 6.18.44-paas #1 SMP PREEMPT_DYNAMIC Thu Aug 13 10:08:12 UTC 2026 x86_64
/
proc
/
212
/
root
/
proc
/
self
/
root
/
proc
/
212
/
root
/
proc
/
70528
/
cwd
/
src
/
Service
/
//proc/212/root/proc/self/root/proc/212/root/proc/70528/cwd/src/Service/MockupGeneratorService.php
<?php namespace App\Service; use App\Entity\Product; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\File; use Symfony\Contracts\HttpClient\HttpClientInterface; use App\Service\ImageGenerator; class MockupGeneratorService { /** Délai d'inactivité de l'appel décrivant la maquette, en secondes. */ public const LAYOUT_REQUEST_TIMEOUT = 180; public $httpClient; public $logger; public $pdfRenderer; public $imageGenerator; public $stateStorage; public $openaiApiKey; public $mockupStorageDir; public $mockupInstructionsFile; /** @var array<string, float[]>|null table des formats nommés, lue à la demande */ private $namedFormats = null; public function __construct( HttpClientInterface $httpClient, LoggerInterface $logger, MockupPdfRendererService $pdfRenderer, ImageGenerator $imageGenerator, MockupStateStorageService $stateStorage, string $openaiApiKey, string $mockupStorageDir, string $mockupInstructionsFile ) { $this->openaiApiKey = $openaiApiKey; $this->mockupStorageDir = $mockupStorageDir; $this->mockupInstructionsFile = new File($mockupInstructionsFile); $this->httpClient = $httpClient; $this->logger = $logger; $this->pdfRenderer = $pdfRenderer; $this->imageGenerator = $imageGenerator; $this->stateStorage = $stateStorage; } public function generateMockup( Product $product, array $configurations = [], ?File $visualFile = null, ?string $personnalPrompt = null ): string { $content = [ [ 'type' => 'input_text', 'text' => $this->buildLayoutPrompt($product, $configurations, $personnalPrompt) ] ]; if ($visualFile !== null) { $content[] = $this->buildVisualContent($visualFile); } $layout = $this->requestLayout($this->buildText($visualFile), $content, $product); // le format commandé prime sur les dimensions renvoyées par l'IA $layout = $this->applyDocumentFormat($layout, $configurations, $product); // génère une image distincte pour chaque élément "generated_image" du layout $layout = $this->generateLayoutImages($layout, $product); return $this->renderAndStore($product, $layout, $visualFile, [ "product_id" => $product->getId(), "configurations" => $configurations, "prompt" => $personnalPrompt, "refinements" => [] ]); } /** * Corrige une maquette déjà générée au lieu d'en produire une nouvelle : * la description JSON précédente et ses images servent de point de départ, * seules les corrections demandées par le client sont appliquées. * * @throws \InvalidArgumentException si la maquette est inconnue ou les corrections vides */ public function refineMockup(Product $product, string $mockupFilename, ?string $instructions, array $configurations = []): string { $instructions = trim((string) $instructions); if ($instructions === '') { throw new \InvalidArgumentException("Décrivez les corrections à apporter à la maquette."); } $state = $this->stateStorage->load($mockupFilename); // la configuration courante du client prime : le format a pu changer depuis la génération $state['configurations'] = $configurations ?: ($state['configurations'] ?? []); $previousLayout = $state['layout'] ?? []; if (empty($previousLayout['document'])) { throw new \InvalidArgumentException("La maquette précédente n'est plus disponible : générez-en une nouvelle."); } $visualFile = !empty($state['visual_path']) ? new File($state['visual_path']) : null; $content = [ [ 'type' => 'input_text', 'text' => $this->buildRefinementPrompt($product, $state, $instructions) ] ]; if ($visualFile !== null) { $content[] = $this->buildVisualContent($visualFile); } $layout = $this->requestLayout($this->buildRefinementText($visualFile), $content, $product); // le format commandé prime, y compris s'il a changé depuis la génération $layout = $this->applyDocumentFormat($layout, $state['configurations'], $product); // ne régénère que les images visées par les corrections, les autres sont reprises telles quelles $layout = $this->refineLayoutImages($layout, $previousLayout, $product); $refinements = $state['refinements'] ?? []; $refinements[] = $instructions; return $this->renderAndStore($product, $layout, $visualFile, [ "product_id" => $product->getId(), "configurations" => $state['configurations'] ?? [], "prompt" => $state['prompt'] ?? null, "refinements" => $refinements, "refined_from" => $mockupFilename ]); } /** * Demande à l'IA la description JSON d'une maquette (génération ou affinage). */ private function requestLayout(string $systemText, array $content, Product $product): array { $jsonData = [ 'model' => 'gpt-4.1', 'input' => [ [ 'role' => 'system', 'content' => [ [ 'type' => 'input_text', 'text' => $systemText ] ] ], [ 'role' => 'user', 'content' => $content ] ], 'text' => [ 'format' => [ 'type' => 'json_object' ] ] ]; $response = $this->httpClient->request( 'POST', 'https://api.openai.com/v1/responses', [ 'headers' => [ 'Authorization' => 'Bearer '.$this->openaiApiKey, 'Content-Type' => 'application/json', ], 'json' => $jsonData, // la description complète d'une maquette dépasse le délai d'inactivité par défaut (60 s) 'timeout' => self::LAYOUT_REQUEST_TIMEOUT ] ); $data = $response->toArray(); $layout = json_decode($data['output'][0]['content'][0]['text'] ?? '', true); // version loguable de la requête : sans le visuel en base64 (plusieurs Mo par génération) $loggableRequest = $this->loggableRequest($jsonData); if (!is_array($layout) || empty($layout['document'])) { $this->logger->error("Génération de maquette : réponse OpenAI sans description exploitable", [ "product_id" => $product->getId(), "data" => $loggableRequest, "response" => $data ]); throw new \RuntimeException("Réponse OpenAI sans description de maquette exploitable."); } $this->logger->info("Génération de maquette : description reçue", [ "product_id" => $product->getId(), "request" => $loggableRequest, "layout" => $layout ]); return $layout; } /** * Impose au document les dimensions du format commandé. L'IA confond les * formats nommés (un A5 revient en 105 x 148, soit un A6 ISO) : les * dimensions ne peuvent donc pas dépendre d'elle. Les éléments sont mis à * l'échelle pour rester cohérents avec la page corrigée. */ private function applyDocumentFormat(array $layout, array $configuration, Product $product): array { $format = $this->documentDimensionsFor($configuration['taille'] ?? null); if ($format === null) { return $layout; } [$width, $height] = $format; $currentWidth = (float) ($layout['document']['width_mm'] ?? 0); $currentHeight = (float) ($layout['document']['height_mm'] ?? 0); // orientation paysage voulue par l'IA : on la conserve, avec les bonnes dimensions if ($currentWidth > $currentHeight && $width < $height) { [$width, $height] = [$height, $width]; } if (abs($currentWidth - $width) < 0.5 && abs($currentHeight - $height) < 0.5) { return $layout; } $this->logger->warning("Génération de maquette : dimensions corrigées d'après le format commandé", [ "product_id" => $product->getId(), "format" => $configuration['taille'] ?? null, "recu" => $currentWidth."x".$currentHeight, "impose" => $width."x".$height ]); $layout['document']['width_mm'] = $width; $layout['document']['height_mm'] = $height; if ($currentWidth <= 0 || $currentHeight <= 0) { return $layout; } return $this->scaleElements($layout, $width / $currentWidth, $height / $currentHeight); } private function scaleElements(array $layout, float $scaleX, float $scaleY): array { foreach ($layout['pages'] ?? [] as $pageIndex => $page) { foreach ($page['elements'] ?? [] as $elementIndex => $element) { foreach (['x' => $scaleX, 'width' => $scaleX, 'y' => $scaleY, 'height' => $scaleY] as $key => $scale) { if (isset($element[$key]) && is_numeric($element[$key])) { $layout['pages'][$pageIndex]['elements'][$elementIndex][$key] = round($element[$key] * $scale, 2); } } } } return $layout; } /** * Libellé du format pour les prompts, dimensions comprises : "A5" seul se * confond avec les autres formats nommés, "A5 (150 x 210 mm)" non. */ private function formatLabel(?string $taille): string { $taille = trim((string) $taille); if ($taille === '') { return 'non spécifié'; } $dimensions = $this->documentDimensionsFor($taille); if ($dimensions === null) { return $taille; } return sprintf('%s — document de %s x %s mm exactement', $taille, $dimensions[0], $dimensions[1]); } /** * Dimensions en millimètres d'un format commandé : soit le nom porte * directement ses dimensions ("100 x 150 mm"), soit c'est un format nommé * défini dans les règles techniques ("A5"). * * @return float[]|null [largeur, hauteur] */ private function documentDimensionsFor(?string $taille): ?array { $taille = trim((string) $taille); if ($taille === '') { return null; } if (preg_match('/(\d+(?:[.,]\d+)?)\s*[x×]\s*(\d+(?:[.,]\d+)?)/u', $taille, $matches)) { return [(float) str_replace(',', '.', $matches[1]), (float) str_replace(',', '.', $matches[2])]; } $formats = $this->namedFormats(); $key = mb_strtolower($taille); return $formats[$key] ?? null; } /** * Table des formats nommés, lue dans la section "Dimensions des formats * nommés" des règles techniques, seule référence de l'imprimeur. * * @return array<string, float[]> */ private function namedFormats(): array { if ($this->namedFormats !== null) { return $this->namedFormats; } $this->namedFormats = []; $content = $this->mockupInstructionsFile->getContent(); if (preg_match('/Dimensions des formats nomm[eé]s(.*?)(?:\R\s*\d+\.\s|\z)/su', $content, $section)) { preg_match_all( '/^\s*([^\s:][^:\r\n]*?)\s*:\s*(\d+(?:[.,]\d+)?)\s*[x×]\s*(\d+(?:[.,]\d+)?)\s*mm/mu', $section[1], $matches, PREG_SET_ORDER ); foreach ($matches as $match) { $this->namedFormats[mb_strtolower(trim($match[1]))] = [ (float) str_replace(',', '.', $match[2]), (float) str_replace(',', '.', $match[3]) ]; } } return $this->namedFormats; } /** * Rend la maquette en PDF et conserve son état pour un affinage ultérieur. */ private function renderAndStore(Product $product, array $layout, ?File $visualFile, array $state): string { $filename = sprintf('mockup_%d_%s.pdf', $product->getId(), bin2hex(random_bytes(6))); $this->pdfRenderer->render($layout, $visualFile, $this->mockupStorageDir.'/'.$filename); $state['layout'] = $layout; try { $this->stateStorage->save($filename, $state, $visualFile); } catch (\Throwable $e) { // la maquette elle-même est valide : seul l'affinage ultérieur devient impossible $this->logger->error("Génération de maquette : état non enregistré, cette maquette ne pourra pas être affinée", [ "product_id" => $product->getId(), "mockup" => $filename, "exception" => $e->getMessage() ]); } return $filename; } private function buildVisualContent(File $visualFile): array { $image = base64_encode(file_get_contents($visualFile->getPathname())); return [ 'type' => 'input_image', 'image_url' => "data:{$visualFile->getMimeType()};base64,$image" ]; } /** * Remplace les visuels en base64 par leur taille : une requête loguée telle * quelle pèse plusieurs Mo par génération. */ private function loggableRequest(array $jsonData): array { foreach ($jsonData['input'] as $messageIndex => $message) { foreach ($message['content'] ?? [] as $partIndex => $part) { if (($part['type'] ?? null) === 'input_image') { $jsonData['input'][$messageIndex]['content'][$partIndex]['image_url'] = sprintf( '[visuel, %d Ko]', (int) (strlen($part['image_url'] ?? '') / 1024) ); } } } return $jsonData; } /** * Génère une image par élément "generated_image" du layout et stocke le * chemin du fichier produit dans la clé "generated_path" de chaque élément. */ private function generateLayoutImages(array $layout, Product $product): array { foreach ($layout['pages'] ?? [] as $pageIndex => $page) { foreach ($page['elements'] ?? [] as $elementIndex => $element) { if (($element['type'] ?? null) !== 'generated_image') { continue; } // identifiant stable : permet de retrouver l'image de cet élément lors d'un affinage $element['id'] = $this->elementId($element, $pageIndex, $elementIndex); // ce prompt reste la référence : les affinages s'y ajoutent au lieu de le réécrire $element['corrections'] = []; $prompt = $this->buildImagePrompt($element); if ($prompt === '') { $layout['pages'][$pageIndex]['elements'][$elementIndex] = $element; continue; } $size = $this->imageSizeFor($element, $layout); try { $element['generated_path'] = $this->imageGenerator->generate($prompt, $size); } catch (\Throwable $e) { $this->logger->error("Génération de maquette : échec de génération d'une image IA", [ "product_id" => $product->getId(), "prompt" => $prompt, "size" => $size, "exception" => $e->getMessage() ]); throw $e; } $layout['pages'][$pageIndex]['elements'][$elementIndex] = $element; } } return $layout; } /** * Prompt réellement envoyé au générateur d'images : la description d'origine, * les textes à reproduire mot pour mot, puis les corrections accumulées. * La description initiale n'est jamais réécrite, ce qui évite que la maquette * s'éloigne de la demande au fil des affinages. */ private function buildImagePrompt(array $element): string { $prompt = trim((string) ($element['prompt'] ?? '')); if ($prompt === '') { return ''; } $texts = $this->cleanList($element['texts'] ?? []); if ($texts) { $prompt .= "\n\nTextes à faire figurer sur le visuel, recopiés EXACTEMENT tels quels, sans faute et avec tous les accents français. N'ajouter aucun autre texte, ne rien traduire, ne rien abréger, ne rien inventer :\n- ".implode("\n- ", $texts); } $corrections = $this->cleanList($element['corrections'] ?? []); if ($corrections) { $prompt .= "\n\nCorrections demandées par le client, à appliquer sans rien changer d'autre à la composition :\n- ".implode("\n- ", $corrections); } return $prompt."\n\nOrthographe et typographie : tout texte visible doit être en français, parfaitement orthographié et accentué, sans mot inventé, sans faux texte ni lorem ipsum. Un mot qui ne peut pas être écrit correctement doit être supprimé plutôt que déformé." ."\n\nComposition : aucun texte ni élément important à moins de 8 % du bord de l'image ; les bords restent occupés par le fond, la couleur ou le dégradé, afin que le visuel puisse être ajusté au format d'impression."; } /** * Format d'image demandé au générateur, aligné sur les proportions de * l'emplacement : une image carrée posée sur un support portrait serait * rognée sur les côtés, emportant une partie de la composition. */ private function imageSizeFor(array $element, array $layout): string { $width = (float) ($element['width'] ?? $layout['document']['width_mm'] ?? 0); $height = (float) ($element['height'] ?? $layout['document']['height_mm'] ?? 0); if ($width <= 0 || $height <= 0) { return ImageGenerator::SIZE_SQUARE; } $ratio = $width / $height; if ($ratio >= 1.15) { return ImageGenerator::SIZE_LANDSCAPE; } if ($ratio <= 0.87) { return ImageGenerator::SIZE_PORTRAIT; } return ImageGenerator::SIZE_SQUARE; } /** * @return string[] valeurs textuelles non vides d'une liste renvoyée par l'IA */ private function cleanList($values): array { if (!is_array($values)) { return []; } $cleaned = []; foreach ($values as $value) { if (is_scalar($value) && trim((string) $value) !== '') { $cleaned[] = trim((string) $value); } } return $cleaned; } /** * Applique les corrections aux images du layout affiné : un élément marqué * "keep" réutilise l'image de la maquette précédente, un élément à corriger * est retouché à partir de celle-ci. */ private function refineLayoutImages(array $layout, array $previousLayout, Product $product): array { $previousElements = $this->generatedElementsById($previousLayout); foreach ($layout['pages'] ?? [] as $pageIndex => $page) { foreach ($page['elements'] ?? [] as $elementIndex => $element) { if (($element['type'] ?? null) !== 'generated_image') { continue; } $id = $this->elementId($element, $pageIndex, $elementIndex); $previous = $previousElements[$id] ?? []; $regenerate = ($element['image_action'] ?? 'keep') === 'regenerate'; $correction = trim((string) ($element['correction'] ?? '')); $element['id'] = $id; // la description d'origine fait foi : l'affinage ne peut qu'y ajouter des // corrections, sinon la maquette dérive un peu plus à chaque itération $element['prompt'] = $previous['prompt'] ?? ($element['prompt'] ?? ''); $element['texts'] = $this->cleanList($element['texts'] ?? []) ?: ($previous['texts'] ?? []); $element['corrections'] = $previous['corrections'] ?? []; unset($element['image_action'], $element['correction']); $previousPath = $previous['generated_path'] ?? null; if (!$regenerate && $previousPath !== null) { $element['generated_path'] = $previousPath; $layout['pages'][$pageIndex]['elements'][$elementIndex] = $element; continue; } if ($correction !== '') { $element['corrections'][] = $correction; } $prompt = $this->buildImagePrompt($element); if ($prompt === '') { if ($previousPath !== null) { $element['generated_path'] = $previousPath; } $layout['pages'][$pageIndex]['elements'][$elementIndex] = $element; continue; } $element['generated_path'] = $this->refineImage( $previousPath, $prompt, $this->imageSizeFor($element, $layout), $product ); $layout['pages'][$pageIndex]['elements'][$elementIndex] = $element; } } return $layout; } /** * Retouche l'image précédente quand elle existe, pour ne corriger que ce qui * est demandé ; à défaut, régénère un visuel complet à partir du prompt. */ private function refineImage(?string $previousPath, string $prompt, string $size, Product $product): string { if ($previousPath !== null) { try { return $this->imageGenerator->edit($previousPath, $prompt, $size); } catch (\Throwable $e) { $this->logger->warning("Affinage de maquette : retouche impossible, génération complète de l'image", [ "product_id" => $product->getId(), "prompt" => $prompt, "exception" => $e->getMessage() ]); } } try { return $this->imageGenerator->generate($prompt, $size); } catch (\Throwable $e) { $this->logger->error("Affinage de maquette : échec de génération d'une image IA", [ "product_id" => $product->getId(), "prompt" => $prompt, "exception" => $e->getMessage() ]); throw $e; } } /** * Éléments générés de la maquette précédente, par identifiant : description * d'origine, textes, corrections déjà appliquées et image produite. * * @return array<string, array> */ private function generatedElementsById(array $layout): array { $elements = []; foreach ($layout['pages'] ?? [] as $pageIndex => $page) { foreach ($page['elements'] ?? [] as $elementIndex => $element) { if (($element['type'] ?? null) !== 'generated_image') { continue; } $path = $element['generated_path'] ?? null; $element['generated_path'] = ($path && is_file($path)) ? $path : null; $element['texts'] = $this->cleanList($element['texts'] ?? []); $element['corrections'] = $this->cleanList($element['corrections'] ?? []); $elements[$this->elementId($element, $pageIndex, $elementIndex)] = $element; } } return $elements; } /** * Identifiant d'un élément généré. Il peut provenir d'une réponse de l'IA : * il est assaini avant d'être utilisé comme clé ou comme nom de fichier. */ private function elementId(array $element, int $pageIndex, int $elementIndex): string { $id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($element['id'] ?? '')); return $id !== '' ? $id : sprintf('img_%d_%d', $pageIndex, $elementIndex); } /** * Description de la maquette envoyée à l'IA : sans les chemins de fichiers, * qui sont propres au serveur. */ private function layoutForPrompt(array $layout): array { foreach ($layout['pages'] ?? [] as $pageIndex => $page) { foreach (array_keys($page['elements'] ?? []) as $elementIndex) { unset( $layout['pages'][$pageIndex]['elements'][$elementIndex]['generated_path'], $layout['pages'][$pageIndex]['elements'][$elementIndex]['generated_file'] ); } } return $layout; } private function buildLayoutPrompt(Product $product, array $configuration, ?string $personnalPrompt): string { $taille = $this->formatLabel($configuration['taille'] ?? null); $pages = $configuration['pages'] ?? 'non spécifié'; $couleur = $configuration['couleur'] ?? 'non spécifié'; $grammage = $configuration['grammage'] ?? 'non spécifié'; $finition = $configuration['finition'] ?? 'non spécifié'; $reglesTechniques = $this->mockupInstructionsFile->getContent(); $recommandation = $personnalPrompt ? "Recommandations du client :\n\"{$personnalPrompt}\"\n\nCes recommandations doivent être utilisées uniquement pour construire les champs \"prompt\" et \"texts\" de l'élément \"generated_image\".\nNe jamais ajouter d'informations absentes de ces recommandations." : "Aucune recommandation du client : ne rien inventer comme contenu."; return <<<PROMPT Créer la description JSON structurée d'une maquette d'impression professionnelle. Produit à imprimer : - Produit : {$product->getName()} - Format : {$taille} - Pages : {$pages} - Couleurs : {$couleur} - Support : {$grammage} - Finition : {$finition} Le produit d'impression fourni par le système est prioritaire. Les recommandations utilisateur servent uniquement au contenu graphique. 1. Si un visuel final est fourni en pièce jointe : - Considérer que ce visuel est l'œuvre finale du client. - La maquette consiste uniquement à positionner ce visuel sur le support demandé. - Ne jamais modifier le visuel. - Ne jamais ajouter de texte, logo, coordonnées, slogan ou élément graphique. Règles techniques d'impression à respecter impérativement : {$reglesTechniques} Structure JSON attendue, sans aucune autre clé : { "document": { "width_mm": largeur du format fini, "height_mm": hauteur du format fini, "bleed_mm": 2 }, "pages": [ { "elements": [ ... ] } ] } Les éléments du tableau "elements" peuvent être : - { "type": "rect", "x": nombre, "y": nombre, "width": nombre, "height": nombre, "color": { "c": 0-100, "m": 0-100, "j": 0-100, "n": 0-100 } } - { "type": "image", "x": nombre, "y": nombre, "width": nombre, "height": nombre } → À utiliser uniquement lorsqu'un visuel est fourni en entrée. - { "type": "generated_image", "prompt": "description précise et complète du visuel à générer", "texts": ["texte exact devant figurer sur le visuel", "..."], "x": nombre, "y": nombre, "width": nombre, "height": nombre } → À utiliser uniquement lorsqu'aucun visuel n'est fourni. - "prompt" : description précise du visuel à générer, reprenant fidèlement et intégralement les recommandations du client (style, ambiance, couleurs, composition, blocs, illustrations). Cette description ne sera plus jamais réécrite : elle doit se suffire à elle-même. - "texts" : TOUS les textes devant apparaître sur le visuel, un par entrée, recopiés mot pour mot depuis les recommandations du client, en français correct et accentué (titres, sous-titres, listes, coordonnées, boutons). Ne jamais traduire, abréger, reformuler ni inventer un texte. Si le client n'indique aucun texte, laisser un tableau vide. - Les proportions de l'élément doivent suivre celles du support : un visuel pleine page sur un format portrait est portrait. Types d'éléments disponibles, dessinés dans l'ordre du tableau (les coordonnées sont en millimètres depuis le coin haut-gauche du format fini ; "color" est toujours en CMJN : {"c": 0-100, "m": 0-100, "j": 0-100, "n": 0-100}) : - {"type": "rect", "x", "y", "width", "height", "color"} : fond uni, uniquement si le visuel ne couvre pas toute la page. - {"type": "image", "x", "y", "width", "height"} : le visuel fourni (exactement un par page, proportions respectées). Contraintes : - Les dimensions du document proviennent EXCLUSIVEMENT des sections "Produits et formats disponibles" et "Dimensions des formats nommés" ci-dessus. Ne jamais utiliser les dimensions ISO standard : ici un A6 fait 100 x 150 mm, pas 105 x 148. - Si les proportions du visuel correspondent au format : le visuel couvre toute la page, fond perdu compris (x = -2, y = -2, width = largeur + 4, height = hauteur + 4). - Sinon : centrer le visuel le plus grand possible sans le rogner ni le déformer, et couvrir toute la page (fond perdu compris) avec un "rect" placé avant lui, d'une couleur unie harmonisée avec le visuel (couleur dominante ou blanc). - Si un aplat noir est utilisé en fond : noir renforcé {"c": 30, "m": 30, "j": 30, "n": 100}. - Le tableau "pages" contient une entrée par face à imprimer ; en recto/verso, répéter le même visuel sur chaque face. {$recommandation} Répondre uniquement en JSON valide. PROMPT; } /** * Prompt d'affinage : la maquette précédente est fournie telle quelle et * seules les corrections demandées par le client doivent la modifier. */ private function buildRefinementPrompt(Product $product, array $state, string $instructions): string { $configuration = $state['configurations'] ?? []; $taille = $this->formatLabel($configuration['taille'] ?? null); $pages = $configuration['pages'] ?? 'non spécifié'; $couleur = $configuration['couleur'] ?? 'non spécifié'; $grammage = $configuration['grammage'] ?? 'non spécifié'; $finition = $configuration['finition'] ?? 'non spécifié'; $reglesTechniques = $this->mockupInstructionsFile->getContent(); $maquette = json_encode( $this->layoutForPrompt($state['layout'] ?? []), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); $demandeInitiale = !empty($state['prompt']) ? "Description initiale du client : \"{$state['prompt']}\"" : "Aucune description initiale du client."; $corrections = $state['refinements'] ?? []; $historique = $corrections ? "Corrections déjà appliquées lors des affinages précédents, à conserver :\n- ".implode("\n- ", $corrections) : "Aucune correction n'a encore été appliquée."; return <<<PROMPT Corriger la maquette d'impression existante décrite ci-dessous, à partir des corrections demandées par le client. Produit à imprimer, tel que configuré par le client (fait foi) : - Produit : {$product->getName()} - Format : {$taille} - Pages : {$pages} - Couleurs : {$couleur} - Support : {$grammage} - Finition : {$finition} {$demandeInitiale} {$historique} Description JSON de la maquette actuelle : {$maquette} Corrections demandées par le client : "{$instructions}" Règles d'affinage : - Repartir de la maquette actuelle. Ne modifier que ce que les corrections demandent : tout le reste est reproduit à l'identique. - Conserver la structure, l'ordre et les identifiants ("id") des éléments existants. Ne jamais renommer ni renuméroter un "id". - Le champ "prompt" des éléments "generated_image" est la description d'origine : la recopier telle quelle, sans jamais la réécrire, la résumer ni la compléter. - Les dimensions du document proviennent du format configuré ci-dessus. Si celles de la maquette actuelle ne correspondent plus au format, les corriger ; sinon, ne pas y toucher. - Corrections de mise en page (centrage, cadrage, rognage, marges, taille, position, couleur de fond) : ajuster "x", "y", "width", "height" ou "color" des éléments concernés, sans jamais déformer un visuel. - Corrections portant sur le contenu d'un élément "generated_image" (texte, orthographe, grammaire, typographie, style, couleurs du visuel) : conserver son "id" et son "prompt", ajouter "image_action": "regenerate" et décrire dans un champ "correction" UNIQUEMENT ce qui doit changer par rapport au visuel actuel, en une consigne courte et précise. - Le champ "correction" ne parle que du contenu visuel. Il ne doit jamais mentionner de dimensions, de millimètres, de format nommé, de fond perdu ni de zone tranquille : ces aspects se règlent par les coordonnées des éléments. Une adaptation au format se traduit par exemple par "recomposer le visuel en orientation portrait". - Correction d'un texte du visuel : reprendre le tableau "texts" en y corrigeant la ou les entrées concernées, mot pour mot, en français correct et accentué. Les autres entrées restent identiques. Un texte ne figurant pas dans "texts" ne doit pas apparaître sur le visuel. - Tout élément "generated_image" que les corrections ne concernent pas : ajouter "image_action": "keep", sans champ "correction". Son image actuelle est réutilisée telle quelle, sans nouvelle génération. - L'élément "image" est le visuel final fourni par le client : son contenu n'est jamais modifié, seules ses coordonnées peuvent l'être. - Si une correction demande de supprimer ou d'ajouter du texte dans le visuel, cela passe obligatoirement par les champs "correction" et "texts" d'un élément "generated_image" : ne jamais créer d'élément de type "text". Règles techniques d'impression à respecter impérativement : {$reglesTechniques} Répondre uniquement avec le JSON complet de la maquette corrigée, avec la même structure que la maquette actuelle : { "document": { "width_mm": nombre, "height_mm": nombre, "bleed_mm": 2 }, "pages": [ { "elements": [ ... ] } ] } Chaque élément "generated_image" conserve ses clés "id", "prompt" et "texts", et comporte en plus "image_action" ("keep" ou "regenerate") ainsi que "correction" lorsqu'il doit être regénéré. Répondre uniquement en JSON valide. PROMPT; } private function buildText(?File $file) { if($file === null) { return <<<TEXT Tu es un opérateur prépresse spécialisé dans les supports imprimés. Tu réponds uniquement avec un JSON valide. Aucun visuel n'est fourni. Tu dois décrire un visuel d'exemple correspondant aux recommandations de l'utilisateur. Le JSON utilisera un élément : { "type":"generated_image", "prompt":"description détaillée du visuel", "texts":["texte exact à faire figurer sur le visuel"], "x":..., "y":..., "width":..., "height":... } N'invente aucune information qui ne figure pas dans les recommandations de l'utilisateur. Tu recopies les textes du client mot pour mot dans "texts", en français correct et accentué, sans rien traduire ni reformuler. Toutes les dimensions sont exprimées en millimètres. TEXT; } return <<<TEXT Tu es un opérateur prépresse spécialisé dans les supports imprimés. Tu réponds uniquement avec un JSON valide. L'image jointe est le visuel final du client. Tu ne modifies jamais cette image. Tu ne génères jamais de texte, logo, illustration ou décoration. Le JSON décrit uniquement le positionnement de cette image sur le support. Toutes les dimensions sont exprimées en millimètres. TEXT; } private function buildRefinementText(?File $file) { $visuel = $file === null ? "Aucun visuel n'est fourni : le contenu graphique provient des éléments \"generated_image\"." : "L'image jointe est le visuel final du client : tu le modifies selon la demande du client."; return <<<TEXT Tu es un opérateur prépresse spécialisé dans les supports imprimés. Tu corriges une maquette existante : tu ne la recrées jamais depuis zéro. Tu réponds uniquement avec un JSON valide, de la même structure que la maquette fournie. {$visuel} Tu ne modifies que les éléments visés par les corrections du client et tu reproduis tous les autres à l'identique. Tu conserves les identifiants ("id") et les descriptions ("prompt") des éléments existants : tu décris ce qui doit changer dans un champ "correction", tu ne réécris jamais une description. Toutes les dimensions sont exprimées en millimètres. TEXT; } }