diff --git a/src/engine/v2/buildProfile.ts b/src/engine/v2/buildProfile.ts index 2350bf6a8..2c8b40ace 100644 --- a/src/engine/v2/buildProfile.ts +++ b/src/engine/v2/buildProfile.ts @@ -288,17 +288,91 @@ function normalizeBrands(raw: any): string[] { ); } +const PALETTE_BY_TEMPERATURE: Record = { + warm: ['camel', 'cognac', 'terracotta', 'olijf', 'bruin', 'crème', 'roest'], + koel: ['navy', 'donkerblauw', 'grijs', 'wit', 'zwart', 'bordeaux', 'kobalt'], + neutraal: ['zwart', 'wit', 'grijs', 'beige', 'taupe', 'navy', 'crème'], +}; + +const PALETTE_BY_VALUE: Record = { + donker: ['zwart', 'antraciet', 'navy', 'donkergroen', 'bordeaux', 'donkergrijs'], + licht: ['wit', 'crème', 'lichtblauw', 'lichtgrijs', 'beige', 'zandkleur'], + medium: ['grijs', 'navy', 'beige', 'camel', 'olijf', 'taupe'], +}; + +const PALETTE_BY_NEUTRAL: Record = { + warm: ['camel', 'crème', 'bruin', 'cognac', 'beige'], + koel: ['grijs', 'wit', 'navy', 'zwart', 'lichtblauw'], + neutraal: ['zwart', 'wit', 'grijs', 'beige', 'taupe'], + zwart: ['zwart', 'antraciet', 'charcoal', 'grijs'], + wit: ['wit', 'crème', 'ivoor', 'lichtgrijs'], + grijs: ['grijs', 'antraciet', 'charcoal', 'zwart', 'wit'], + beige: ['beige', 'camel', 'taupe', 'crème', 'zand'], + navy: ['navy', 'donkerblauw', 'wit', 'grijs'], + bruin: ['bruin', 'camel', 'cognac', 'beige'], +}; + +function normalizeNeutralsList(raw: unknown): string[] { + if (!raw) return []; + const arr = Array.isArray(raw) ? raw : [raw]; + return arr + .map((v) => (typeof v === 'string' ? v.toLowerCase().trim() : '')) + .filter(Boolean); +} + +function derivePreferredColors(params: { + fromAnalysis: string[]; + neutrals: string[]; + temperature: TemperatureKey | null; + value: ValueKey | null; +}): string[] { + const { fromAnalysis, neutrals, temperature, value } = params; + const out: string[] = []; + const push = (colors: string[] | undefined) => { + if (!colors) return; + for (const c of colors) { + const norm = String(c).toLowerCase().trim(); + if (!norm) continue; + if (!out.includes(norm)) out.push(norm); + } + }; + + push(fromAnalysis); + + if (value === 'donker') { + push(PALETTE_BY_VALUE.donker); + } else if (value === 'licht') { + push(PALETTE_BY_VALUE.licht); + } + + if (out.length < 3) { + for (const n of neutrals) { + push(PALETTE_BY_NEUTRAL[n]); + if (out.length >= 6) break; + } + } + + if (out.length < 3 && temperature) { + push(PALETTE_BY_TEMPERATURE[temperature]); + } + + if (out.length < 3 && value === 'medium') { + push(PALETTE_BY_VALUE.medium); + } + + if (out.length < 3) { + push(PALETTE_BY_TEMPERATURE.neutraal); + } + + return out; +} + function buildColorPreference(answers: Record): ColorPreference { const cp = answers.colorProfile ?? {}; const ca = answers.colorAnalysis ?? {}; - const temperature: TemperatureKey | null = (() => { - const rawNeutrals = answers.neutrals; - const neutralsArr: string[] = Array.isArray(rawNeutrals) - ? rawNeutrals - : typeof rawNeutrals === 'string' && rawNeutrals.length > 0 - ? [rawNeutrals] - : []; + const neutralsArr = normalizeNeutralsList(answers.neutrals); + const temperature: TemperatureKey | null = (() => { // 'mix' (explicit combination) or multiple distinct temperatures // → no single preference, don't penalize either temperature if (neutralsArr.includes('mix')) return null; @@ -307,7 +381,9 @@ function buildColorPreference(answers: Record): ColorPreference { ); if (distinctTemps.size > 1) return null; - const singleTemp = neutralsArr[0]; + const singleTemp = neutralsArr.find( + (v) => v === 'warm' || v === 'koel' || v === 'neutraal' + ); const raw = cp.temperature ?? singleTemp ?? @@ -351,13 +427,20 @@ function buildColorPreference(answers: Record): ColorPreference { ? 'neutral' : null); + const preferredColors = derivePreferredColors({ + fromAnalysis: Array.isArray(ca.best_colors) ? ca.best_colors : [], + neutrals: neutralsArr, + temperature, + value, + }); + return { temperature, value, contrast, season, undertone, - preferredColors: Array.isArray(ca.best_colors) ? ca.best_colors : [], + preferredColors, avoidColors: Array.isArray(ca.avoid_colors) ? ca.avoid_colors : [], }; } diff --git a/src/engine/v2/coherence.ts b/src/engine/v2/coherence.ts index d76c5570a..ac8d89f83 100644 --- a/src/engine/v2/coherence.ts +++ b/src/engine/v2/coherence.ts @@ -6,6 +6,7 @@ export interface CoherenceScores { colorHarmony: number; formalitySpread: number; archetypeCoherence: number; + primaryArchetypeFit: number; completeness: number; combined: number; reasons: string[]; @@ -69,6 +70,7 @@ export function evaluateCoherence( colorHarmony: 0, formalitySpread: 0, archetypeCoherence: 0, + primaryArchetypeFit: 0, completeness: 0, combined: 0, reasons: ['empty_outfit'], @@ -113,6 +115,15 @@ export function evaluateCoherence( if (archetypeCoherence > 0.45) reasons.push('archetype_coherent'); } + let primaryArchetypeFit = 1; + if (profile) { + const primaryKey = profile.primaryArchetype; + const fits = products.map((p) => p.archetypeFit[primaryKey] ?? 0); + primaryArchetypeFit = + fits.reduce((a, b) => a + b, 0) / Math.max(1, fits.length); + if (primaryArchetypeFit < 0.4) reasons.push('primary_archetype_weak'); + } + const hasTop = products.some((p) => p.category === 'top' || p.category === 'dress' || p.category === 'jumpsuit'); const hasBottom = products.some( (p) => @@ -136,6 +147,7 @@ export function evaluateCoherence( colorHarmony, formalitySpread, archetypeCoherence, + primaryArchetypeFit, completeness, combined, reasons, @@ -149,7 +161,18 @@ export function coherenceMultiplier(scores: CoherenceScores): number { scores.formalitySpread < 0.5 ? 0.85 : scores.formalitySpread > 0.85 ? 1.05 : 1; const completenessPenalty = scores.completeness < 0.7 ? 0.75 : scores.completeness < 1 ? 0.95 : 1; - return harmonyMultiplier * formalityMultiplier * completenessPenalty; + const archetypeMultiplier = + scores.primaryArchetypeFit < 0.4 + ? 0.6 + : scores.primaryArchetypeFit < 0.55 + ? 0.85 + : 1; + return ( + harmonyMultiplier * + formalityMultiplier * + completenessPenalty * + archetypeMultiplier + ); } export function isHardMismatch( diff --git a/src/engine/v2/engine.ts b/src/engine/v2/engine.ts index a5eee02f4..d7a562c3b 100644 --- a/src/engine/v2/engine.ts +++ b/src/engine/v2/engine.ts @@ -210,8 +210,8 @@ function buildExplanation( function buildMatchPercentage(candidate: OutfitCandidate): number { const raw = candidate.compositionScore; - const scaled = 30 + raw * 65; - return Math.round(Math.max(30, Math.min(98, scaled))); + const scaled = 20 + raw * 75; + return Math.round(Math.max(20, Math.min(95, scaled))); } function categoryRatio(candidate: OutfitCandidate): { diff --git a/src/engine/v2/scoring/brand.ts b/src/engine/v2/scoring/brand.ts index d36db9de5..6374acc85 100644 --- a/src/engine/v2/scoring/brand.ts +++ b/src/engine/v2/scoring/brand.ts @@ -1,15 +1,30 @@ import type { ScoredProduct, UserStyleProfile } from '../types'; +const NEGATIVE_BRANDS = new Set([ + 'shein', + 'temu', + 'wish', + 'romwe', + 'zaful', +]); + +const STRONG_PREFERENCE_THRESHOLD = 3; + export function scoreBrand( product: ScoredProduct, profile: UserStyleProfile ): { score: number; reason: string } { + const brand = String(product.product.brand ?? '').toLowerCase().trim(); + + if (brand && NEGATIVE_BRANDS.has(brand)) { + return { score: 0.15, reason: `brand_negative(${brand})` }; + } + const prefs = profile.preferredBrands; if (!prefs || prefs.length === 0) { return { score: 1.0, reason: 'no_brand_pref' }; } - const brand = String(product.product.brand ?? '').toLowerCase().trim(); if (!brand) return { score: 0.85, reason: 'no_brand_data' }; const prefSet = new Set(prefs.map((b) => b.toLowerCase().trim())); @@ -23,5 +38,9 @@ export function scoreBrand( } } - return { score: 0.7, reason: 'brand_neutral' }; + const strongPref = prefSet.size >= STRONG_PREFERENCE_THRESHOLD; + return { + score: strongPref ? 0.45 : 0.7, + reason: strongPref ? 'brand_non_preferred_strong' : 'brand_neutral', + }; }