283 lines
9.0 KiB
JavaScript
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 };
|