Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 92 additions & 9 deletions src/engine/v2/buildProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,17 +288,91 @@ function normalizeBrands(raw: any): string[] {
);
}

const PALETTE_BY_TEMPERATURE: Record<TemperatureKey, string[]> = {
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<ValueKey, string[]> = {
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<string, string[]> = {
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<string, any>): 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;
Expand All @@ -307,7 +381,9 @@ function buildColorPreference(answers: Record<string, any>): 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 ??
Expand Down Expand Up @@ -351,13 +427,20 @@ function buildColorPreference(answers: Record<string, any>): 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 : [],
};
}
Expand Down
25 changes: 24 additions & 1 deletion src/engine/v2/coherence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface CoherenceScores {
colorHarmony: number;
formalitySpread: number;
archetypeCoherence: number;
primaryArchetypeFit: number;
completeness: number;
combined: number;
reasons: string[];
Expand Down Expand Up @@ -69,6 +70,7 @@ export function evaluateCoherence(
colorHarmony: 0,
formalitySpread: 0,
archetypeCoherence: 0,
primaryArchetypeFit: 0,
completeness: 0,
combined: 0,
reasons: ['empty_outfit'],
Expand Down Expand Up @@ -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) =>
Expand All @@ -136,6 +147,7 @@ export function evaluateCoherence(
colorHarmony,
formalitySpread,
archetypeCoherence,
primaryArchetypeFit,
completeness,
combined,
reasons,
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/engine/v2/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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): {
Expand Down
23 changes: 21 additions & 2 deletions src/engine/v2/scoring/brand.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import type { ScoredProduct, UserStyleProfile } from '../types';

const NEGATIVE_BRANDS = new Set<string>([
'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()));
Expand All @@ -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',
};
}