Files
apex/server/services/layoutOptimizer.js
2026-02-13 00:21:53 +01:00

283 lines
9.0 KiB
JavaScript

/**
* Layout Optimizer using OpenAI GPT-4
* Proposes optimal module arrangement within available width,
* respecting locked modules and optimizing for functionality and aesthetics
*/
import OpenAI from 'openai';
// Lazy initialization to avoid errors when API key is not set
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;
}
/**
* Prompt template for layout optimization
*/
const LAYOUT_OPTIMIZATION_PROMPT = `Tu es un expert en aménagement d'armoires sur mesure avec 20 ans d'expérience.
Propose un layout optimal pour les modules dans l'espace disponible.
CONTRAINTES:
- Largeur totale disponible: {totalWidth}mm
- Hauteur armoire: {height}mm
- Profondeur: {depth}mm
- Type d'installation: {installationType}
MODULES DISPONIBLES DANS LE CATALOGUE:
{availableModules}
MODULES ACTUELLEMENT PLACÉS:
{currentModules}
MODULES FIGÉS (ne pas déplacer, garder leur position exacte):
{lockedModules}
RÈGLES D'OPTIMISATION:
1. La somme des largeurs des modules doit égaler exactement la largeur totale
2. Les modules figés (locked) doivent rester à leur position exacte
3. Favoriser l'équilibre visuel (penderies aux extrémités, tiroirs au centre)
4. Regrouper les éléments de même type si possible
5. Pour une penderie principale, prévoir au moins 600mm de largeur
6. Les tiroirs sont plus pratiques à hauteur accessible (partie basse)
7. Laisser au moins un module rayonnage pour le linge plié
Retourne un JSON valide avec cette structure:
{
"layout": [
{
"moduleCode": "<code du module du catalogue>",
"moduleId": "<id unique du module dans la config, ou null si nouveau>",
"position": <index de position 0, 1, 2...>,
"widthMm": <largeur en mm>,
"locked": <true si figé, false sinon>,
"customizations": {
"shelves": <nombre d'étagères si personnalisable>,
"drawers": <nombre de tiroirs si applicable>,
"hasRod": <true/false pour tringle penderie>
}
}
],
"totalWidth": <somme des largeurs en mm>,
"reasoning": "<Explication en français du choix d'agencement>",
"alternatives": [
{
"description": "<description courte>",
"layout": [<même structure que layout principal>]
}
],
"warnings": ["<avertissements éventuels>"]
}
IMPORTANT: Retourne UNIQUEMENT du JSON valide, sans texte supplémentaire.`;
/**
* Build the prompt with actual data
*/
function buildPrompt(config, availableModules, lockedModuleIds = []) {
const currentModules = config.modules || [];
const lockedModules = currentModules.filter(m => lockedModuleIds.includes(m.id) || m.locked);
const unlockedModules = currentModules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
const modulesCatalog = availableModules.map(m =>
`- ${m.code}: ${m.name} (${m.category}, largeur: ${m.width_mm}mm, ${m.shelves_count} étagères, ${m.drawers_count} tiroirs, penderie: ${m.has_rod ? 'oui' : 'non'}, prix: ${m.base_price_chf} CHF)`
).join('\n');
const currentModulesStr = currentModules.map((m, i) =>
`Position ${i}: ${m.moduleCode || 'module'} - ${m.widthMm}mm${m.locked ? ' [FIGÉ]' : ''}`
).join('\n') || 'Aucun module placé';
const lockedModulesStr = lockedModules.map((m, i) =>
`Position ${currentModules.indexOf(m)}: ${m.moduleCode || 'module'} - ${m.widthMm}mm`
).join('\n') || 'Aucun module figé';
return LAYOUT_OPTIMIZATION_PROMPT
.replace('{totalWidth}', config.widthMm || 2400)
.replace('{height}', config.heightMm || 2400)
.replace('{depth}', config.depthMm || 600)
.replace('{installationType}', config.installationType || 'free')
.replace('{availableModules}', modulesCatalog)
.replace('{currentModules}', currentModulesStr)
.replace('{lockedModules}', lockedModulesStr);
}
/**
* Optimize layout using GPT-4
* @param {Object} config - Current wardrobe configuration
* @param {Array} availableModules - List of available modules from catalog
* @param {Array} lockedModuleIds - IDs of modules that should not be moved
* @returns {Object} Optimized layout proposal
*/
export async function optimizeLayout(config, availableModules, lockedModuleIds = []) {
const openai = getOpenAIClient();
const prompt = buildPrompt(config, availableModules, lockedModuleIds);
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: prompt
}
],
max_tokens: 2000,
response_format: { type: 'json_object' }
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
const result = JSON.parse(content);
// Validate response structure
if (!result.layout || !Array.isArray(result.layout)) {
throw new Error('Invalid layout response structure');
}
// Validate total width matches
const totalLayoutWidth = result.layout.reduce((sum, m) => sum + (m.widthMm || 0), 0);
if (Math.abs(totalLayoutWidth - (config.widthMm || 2400)) > 10) {
result.warnings = result.warnings || [];
result.warnings.push(`La largeur totale (${totalLayoutWidth}mm) ne correspond pas exactement à la largeur demandée (${config.widthMm}mm)`);
}
return {
success: true,
...result,
tokensUsed: response.usage?.total_tokens || 0
};
} catch (error) {
console.error('Layout optimization error:', error);
if (error.code === 'invalid_api_key') {
throw new Error('Invalid OpenAI API key');
}
if (error.code === 'rate_limit_exceeded') {
throw new Error('API rate limit exceeded. Please try again later.');
}
throw error;
}
}
/**
* Quick local optimization without AI (for simple cases)
* Distributes remaining space among unlocked modules
* @param {Object} config - Current configuration
* @param {Array} lockedModuleIds - IDs of locked modules
* @returns {Object} Optimized layout
*/
export function quickOptimize(config, lockedModuleIds = []) {
const modules = [...(config.modules || [])];
const totalWidth = config.widthMm || 2400;
// Calculate locked width
const lockedWidth = modules
.filter(m => lockedModuleIds.includes(m.id) || m.locked)
.reduce((sum, m) => sum + (m.widthMm || 0), 0);
// Get unlocked modules
const unlockedModules = modules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
const remainingWidth = totalWidth - lockedWidth;
if (unlockedModules.length === 0) {
return { success: true, layout: modules, reasoning: 'Tous les modules sont figés' };
}
// Distribute remaining width equally among unlocked modules
const widthPerModule = Math.floor(remainingWidth / unlockedModules.length);
const remainder = remainingWidth - (widthPerModule * unlockedModules.length);
let extraAdded = 0;
const optimizedModules = modules.map(m => {
if (lockedModuleIds.includes(m.id) || m.locked) {
return m;
}
const extra = extraAdded < remainder ? 1 : 0;
extraAdded += extra;
return {
...m,
widthMm: widthPerModule + extra
};
});
return {
success: true,
layout: optimizedModules,
totalWidth: totalWidth,
reasoning: 'Répartition équitable de l\'espace entre les modules non-figés'
};
}
/**
* Suggest initial layout for empty configuration
* @param {number} totalWidth - Total available width in mm
* @param {Array} availableModules - Available module catalog
* @returns {Array} Suggested initial modules
*/
export function suggestInitialLayout(totalWidth, availableModules) {
const suggestions = [];
let remainingWidth = totalWidth;
// Standard layout: penderie - rayonnage - penderie for typical wardrobe
const penderie = availableModules.find(m => m.category === 'penderie' && m.width_mm <= remainingWidth / 3);
const rayonnage = availableModules.find(m => m.category === 'rayonnage');
if (penderie && rayonnage) {
// Two penderies on sides
const penderieWidth = Math.min(penderie.width_mm, Math.floor(totalWidth * 0.3));
const rayonnageWidth = totalWidth - (penderieWidth * 2);
suggestions.push({
moduleCode: penderie.code,
moduleTypeId: penderie.id,
widthMm: penderieWidth,
position: 0,
locked: false,
shelves: penderie.shelves_count,
drawers: 0,
hasRod: true
});
suggestions.push({
moduleCode: rayonnage.code,
moduleTypeId: rayonnage.id,
widthMm: rayonnageWidth,
position: 1,
locked: false,
shelves: rayonnage.shelves_count,
drawers: 0,
hasRod: false
});
suggestions.push({
moduleCode: penderie.code,
moduleTypeId: penderie.id,
widthMm: penderieWidth,
position: 2,
locked: false,
shelves: penderie.shelves_count,
drawers: 0,
hasRod: true
});
}
return suggestions;
}
export default { optimizeLayout, quickOptimize, suggestInitialLayout };