Files
apex/server/services/designAdvisor.js
lejayandCursor 6c0d7e47b8 v2.0
Co-authored-by: Cursor <[email protected]>
2026-05-28 07:20:38 +02:00

285 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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": <number>,
"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.`;
}