/** * AI Design Advisor — GPT-4o powered design tips, material recommendations, and DALL-E 3 renders * Helps customers feel confident about their wardrobe design choices. */ import OpenAI from 'openai'; import crypto from 'crypto'; let openaiClient = null; function getOpenAIClient() { if (!openaiClient) { if (!process.env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env'); } openaiClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); } return openaiClient; } /* -------------------------------------------------------------------------- */ /* 1. Design Tips (GPT-4o text) */ /* -------------------------------------------------------------------------- */ const DESIGN_TIP_PROMPT = `Tu es un designer d'intérieur suisse spécialisé dans les armoires sur mesure haut de gamme. À partir de la configuration ci-dessous, donne UN SEUL conseil ou commentaire positif en 1-2 phrases maximum. Le ton doit être chaleureux, professionnel et encourageant — le client doit se sentir confiant dans son choix. Varie tes réponses : parfois un compliment sur la combinaison, parfois un conseil pratique, parfois une info sur les tendances. Réponds UNIQUEMENT en JSON : { "tip": "…votre conseil en français…", "category": "compliment" | "conseil" | "tendance" }`; /** * Generate a design tip for the current configuration * @param {object} config - { width, height, depth, columns (array of {type, shelves, drawers, hasRod}), material, installationType } * @returns {{ tip: string, category: string }} */ export async function getDesignTip(config) { const client = getOpenAIClient(); const configSummary = buildConfigSummary(config); const response = await client.chat.completions.create({ model: 'gpt-4o', temperature: 0.9, max_tokens: 200, messages: [ { role: 'system', content: DESIGN_TIP_PROMPT }, { role: 'user', content: configSummary } ], response_format: { type: 'json_object' } }); const raw = response.choices[0]?.message?.content; try { return JSON.parse(raw); } catch { return { tip: raw, category: 'conseil' }; } } /* -------------------------------------------------------------------------- */ /* 2. Material Recommendations (GPT-4o) */ /* -------------------------------------------------------------------------- */ const MATERIAL_REC_PROMPT = `Tu es un conseiller en matériaux et couleurs pour armoires sur mesure suisses haut de gamme. À partir de la configuration actuelle, des PRIORITÉS UTILISATEUR (échelle 1–5) et de la liste de matériaux disponibles, recommande 3 à 5 finitions. Règles : - Les priorités indiquent ce qui compte le PLUS pour le client (5 = très important, 1 = peu important). Tu dois PONDÉRER tes choix en conséquence (ex. : priorité « prix » élevée → privilégier des décors à bon rapport qualité-prix et l'expliquer ; priorité « qualité » élevée → insister sur la noblesse de la finition, la précision du relief, etc.). - Si un prix indicatif [CHF] est fourni pour un matériau, utilise-le honnêtement pour le critère prix. - Pour CHAQUE recommandation, explique CLAIREMENT POURQUOI tu proposes CE matériau : lien explicite avec les priorités du client (pas seulement « joli »). - Remplis les champs criteria avec une phrase courte CHACUN (en français) : ce que ce décor apporte sur ce critère, au regard des priorités. Réponds UNIQUEMENT en JSON valide : { "recommendations": [ { "finishId": , "summary": "2 phrases max : pourquoi ce choix au regard des priorités", "criteria": { "price": "phrase sur le rapport prix / gamme / accessibilité", "quality": "phrase sur la qualité de finition, rendu, sensation", "durability": "phrase sur tenue dans le temps, entretien, résistance", "aesthetics": "phrase sur l'harmonie couleur/style avec le projet" }, "reason": "une ligne très courte pour infobulle (optionnel, peut résumer summary)" } ] }`; function clampPriority(n, fallback = 3) { const x = Number(n); if (!Number.isFinite(x)) return fallback; return Math.min(5, Math.max(1, Math.round(x))); } /** * @param {object} [p] - { price, quality, durability, aesthetics } each 1–5 */ export function normalizeRecommendationPriorities(p) { const d = { price: 3, quality: 3, durability: 3, aesthetics: 3 }; if (!p || typeof p !== 'object') return { ...d }; return { price: clampPriority(p.price, d.price), quality: clampPriority(p.quality, d.quality), durability: clampPriority(p.durability, d.durability), aesthetics: clampPriority(p.aesthetics, d.aesthetics) }; } function normalizeMaterialRecommendation(rec) { if (!rec || rec.finishId == null) return null; const criteria = rec.criteria && typeof rec.criteria === 'object' ? rec.criteria : {}; const summary = (rec.summary || rec.reason || '').trim(); const reason = (rec.reason || summary).trim(); return { finishId: Number(rec.finishId), summary, reason, criteria: { price: String(criteria.price || '').trim(), quality: String(criteria.quality || '').trim(), durability: String(criteria.durability || '').trim(), aesthetics: String(criteria.aesthetics || '').trim() } }; } function buildPrioritiesBlock(priorities) { const p = normalizeRecommendationPriorities(priorities); return `PRIORITÉS UTILISATEUR (1 = peu important, 5 = très important — ajuste tes recommandations et tes explications en conséquence) : - Prix / rapport qualité-prix : ${p.price} - Qualité perçue & finition : ${p.quality} - Durabilité & entretien : ${p.durability} - Esthétique & harmonie avec le projet : ${p.aesthetics}`; } /** * Recommend materials that complement the current configuration * @param {object} config - Current wardrobe configuration * @param {Array} availableFinishes - [{ id, name, collection, code, price_chf? }] * @param {object} [roomAnalysis] - Optional room photo analysis * @param {object} [priorities] - { price, quality, durability, aesthetics } each 1–5 * @returns {{ recommendations: Array<{ finishId, summary, reason, criteria }> }} */ export async function recommendMaterials(config, availableFinishes, roomAnalysis, priorities) { const client = getOpenAIClient(); const configSummary = buildConfigSummary(config); const priBlock = buildPrioritiesBlock(priorities); // Compact finish list (names + ids + optional price for fair "prix" explanations) const finishList = availableFinishes .slice(0, 80) // limit to avoid token overflow .map((f) => { const name = f.name || f.label || '?'; const col = f.collection || '?'; const price = f.price_chf != null && Number.isFinite(Number(f.price_chf)) ? ` — indicatif ${Number(f.price_chf).toFixed(2)} CHF` : ''; return `${f.id}: ${name} (${col})${price}`; }) .join('\n'); let userMessage = `${priBlock}\n\nCONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`; if (roomAnalysis) { userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`; } const response = await client.chat.completions.create({ model: 'gpt-4o', temperature: 0.65, max_tokens: 1400, messages: [ { role: 'system', content: MATERIAL_REC_PROMPT }, { role: 'user', content: userMessage } ], response_format: { type: 'json_object' } }); const raw = response.choices[0]?.message?.content; try { const parsed = JSON.parse(raw); const rawList = (parsed.recommendations || []).slice(0, 5); const recommendations = rawList .map(normalizeMaterialRecommendation) .filter(Boolean); return { recommendations, priorities: normalizeRecommendationPriorities(priorities) }; } catch { return { recommendations: [], priorities: normalizeRecommendationPriorities(priorities) }; } } /* -------------------------------------------------------------------------- */ /* 3. Photorealistic Render (DALL-E 3) */ /* -------------------------------------------------------------------------- */ // Simple in-memory render cache (config hash → image URL) const renderCache = new Map(); /** * Generate a photorealistic render of the configured wardrobe using DALL-E 3 * @param {object} config - Current wardrobe configuration * @param {object} [roomContext] - Optional room analysis for context * @returns {{ imageUrl: string, cached: boolean }} */ export async function generateRender(config, roomContext) { const client = getOpenAIClient(); // Cache by config hash const hash = crypto.createHash('md5').update(JSON.stringify(config)).digest('hex'); if (renderCache.has(hash)) { return { imageUrl: renderCache.get(hash), cached: true }; } const prompt = buildRenderPrompt(config, roomContext); const response = await client.images.generate({ model: 'dall-e-3', prompt, n: 1, size: '1792x1024', quality: 'hd', style: 'natural' }); const imageUrl = response.data[0]?.url; if (imageUrl) { renderCache.set(hash, imageUrl); // Auto-evict after 30 minutes (DALL-E URLs expire after ~1h) setTimeout(() => renderCache.delete(hash), 30 * 60 * 1000); } return { imageUrl, cached: false }; } /* -------------------------------------------------------------------------- */ /* Helpers */ /* -------------------------------------------------------------------------- */ function buildConfigSummary(config) { const columns = (config.columns || config.modules || []) .map((c, i) => ` Colonne ${i + 1}: ${c.type || c.moduleCode || '?'} — ${c.width || '?'}mm, ${c.shelves || 0} étagères, ${c.drawers || 0} tiroirs${c.hasRod ? ', tringle' : ''}`) .join('\n'); return `Armoire sur mesure SIGNATURE Dimensions: ${config.width || '?'}mm × ${config.height || '?'}mm × ${config.depth || '?'}mm Installation: ${config.installationType || 'libre'} Colonnes:\n${columns} Matériau principal: ${config.materialName || config.finishLabel || 'non défini'} Portes: ${config.doorType || 'battantes'}`; } function buildRenderPrompt(config, roomContext) { const cols = (config.columns || config.modules || []); const colDesc = cols.map((c, i) => { const type = c.type || c.moduleCode || 'standard'; const parts = []; if (c.hasRod) parts.push('a hanging rod'); if (c.shelves > 0) parts.push(`${c.shelves} shelves`); if (c.drawers > 0) parts.push(`${c.drawers} drawers`); return `column ${i + 1}: ${type} with ${parts.join(', ') || 'empty space'}`; }).join('; '); const widthM = ((config.width || 2400) / 1000).toFixed(1); const heightM = ((config.height || 2400) / 1000).toFixed(1); let roomDesc = 'a modern, well-lit bedroom with neutral walls'; if (roomContext) { const walls = roomContext.walls || []; const wallColors = walls.filter(w => w.color).map(w => w.color).join(', '); roomDesc = `a ${roomContext.roomType || 'bedroom'} with ${wallColors || 'neutral'} walls`; } return `Professional interior design photograph of a premium Swiss-made custom wardrobe in ${roomDesc}. The wardrobe is ${widthM}m wide and ${heightM}m tall, built-in style, with ${cols.length} columns: ${colDesc}. Material: ${config.materialName || 'light oak wood finish'}. Doors are ${config.doorType === 'coulissantes' ? 'sliding' : 'hinged'}, partially open to reveal the interior. Style: clean Scandinavian-Swiss aesthetic, warm natural lighting, photorealistic quality. Shot with a wide-angle lens at eye level. The wardrobe should look premium, well-crafted, and perfectly integrated into the room.`; }