From 46d1ca337551d410706a435cc0c8a4fb305b5b9a Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Sat, 13 Jun 2026 13:05:17 +1000 Subject: [PATCH] Fix size-aware product suggestions in resolver Previously a different-size variant was boosted to 0.95 confidence ('same product, different size'), so a Small order would be confidently suggested the Large product. Now suggestions require the same size; a known different size is suppressed entirely (you create the right one instead). Also handle 'Size: X for ...' phrasing, medium sizes, and American 'Color:' spelling. Co-Authored-By: Claude Fable 5 --- client/src/utils/productMatcher.ts | 112 +++++++++++++++-------------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/client/src/utils/productMatcher.ts b/client/src/utils/productMatcher.ts index 533d3fb..05f0281 100644 --- a/client/src/utils/productMatcher.ts +++ b/client/src/utils/productMatcher.ts @@ -260,6 +260,31 @@ const calculateTitleSimilarity = (title1: string, title2: string): number => { return matchingWords / totalUniqueWords; }; +/** + * Extract a size token (large/med/small) from a title or variant string. + * Prefers an explicit "Size: X" so trailing words like "Large for med plants" + * don't get misread. + */ +export const extractSize = (text: string): string => { + const lower = text.toLowerCase(); + const explicit = lower.match(/size:\s*(large|medium|med|small)/); + let token = explicit ? explicit[1] : ''; + if (!token) { + if (lower.includes('large') && !lower.includes('small')) token = 'large'; + else if (lower.includes('small') && !lower.includes('large')) token = 'small'; + } + return token === 'medium' ? 'med' : token; +}; + +const cleanForCompare = (title: string): string => + title + .replace(/Colou?r:\s*[^,\s-]+(?:\s+[^,\s-]+)*/i, '') // strip Color/Colour + .replace(/Size:\s*[^,\s-]+(?:\s+(?:for|plants?|succulents?)\b[^,]*)?/i, '') // strip "Size: X for ..." + .replace(/3D-Printed/i, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + /** * Find potential matches for a missing product * This helps suggest existing products that might be similar @@ -270,77 +295,54 @@ export const findPotentialMatches = ( maxSuggestions: number = 5 ): Array<{product: any; confidence: number; reason: string}> => { const suggestions: Array<{product: any; confidence: number; reason: string}> = []; - - const itemTitle = missingProductTitle.toLowerCase(); - let itemSize = ''; - - // Extract size from missing product - if (itemTitle.includes('large') && !itemTitle.includes('small')) { - itemSize = 'large'; - } else if (itemTitle.includes('small') && !itemTitle.includes('large')) { - itemSize = 'small'; - } - - const cleanItemTitle = missingProductTitle - .replace(/Colour:\s*[^,\s-]+(?:\s+[^,\s-]+)*/i, '') - .replace(/Size:\s*[^,\s-]+/i, '') - .replace(/3D-Printed/i, '') - .replace(/\s+/g, ' ') - .trim() - .toLowerCase(); - + + const itemSize = extractSize(missingProductTitle); + const cleanItemTitle = cleanForCompare(missingProductTitle); + for (const product of existingProducts) { - const productTitle = product.title.toLowerCase(); - let productSize = ''; - - if (productTitle.includes('large') && !productTitle.includes('small')) { - productSize = 'large'; - } else if (productTitle.includes('small') && !productTitle.includes('large')) { - productSize = 'small'; - } - - const cleanProductTitle = product.title - .replace(/Colour:\s*[^,\s-]+(?:\s+[^,\s-]+)*/i, '') - .replace(/Size:\s*[^,\s-]+/i, '') - .replace(/3D-Printed/i, '') - .replace(/\s+/g, ' ') - .trim() - .toLowerCase(); - + const productSize = extractSize(`${product.title} ${(product.aliases || []).join(' ')}`); + const cleanProductTitle = cleanForCompare(product.title); + const titleSimilarity = calculateTitleSimilarity(cleanItemTitle, cleanProductTitle); - + let reason = ''; - let confidence = titleSimilarity; - - // Check if it's the same product family but different size + let confidence = 0; + if (titleSimilarity > 0.8) { - if (itemSize && productSize && itemSize !== productSize) { - reason = `Same product, different size (${productSize} vs ${itemSize})`; - confidence = 0.95; // Very high confidence - likely meant to be this size variant + if (itemSize && productSize) { + if (itemSize === productSize) { + reason = `Same product, same size (${itemSize})`; + confidence = Math.max(titleSimilarity, 0.95); + } else { + // Both sizes known and they differ — this is NOT the right product. + // Suppress it so a wrong-size variant is never suggested. + confidence = 0; + } } else if (itemSize && !productSize) { - reason = `Same product, missing size specification`; - confidence = 0.85; + reason = `Same product (existing has no size set)`; + confidence = 0.75; } else if (!itemSize && productSize) { reason = `Same product, has size: ${productSize}`; - confidence = 0.8; + confidence = 0.7; } else { reason = `Very similar product`; confidence = titleSimilarity; } } else if (titleSimilarity > 0.6) { - reason = `Similar product`; - confidence = titleSimilarity; + // Don't suggest a different-size variant even on a looser title match + if (itemSize && productSize && itemSize !== productSize) { + confidence = 0; + } else { + reason = `Similar product`; + confidence = titleSimilarity; + } } - + if (confidence > 0.6) { - suggestions.push({ - product, - confidence, - reason - }); + suggestions.push({ product, confidence, reason }); } } - + // Sort by confidence and return top suggestions return suggestions .sort((a, b) => b.confidence - a.confidence)