diff --git a/database/local_backend/scanPipeline.js b/database/local_backend/scanPipeline.js index 0a25b67..8958da7 100644 --- a/database/local_backend/scanPipeline.js +++ b/database/local_backend/scanPipeline.js @@ -19,6 +19,27 @@ const SEVERITY_WEIGHTS = { high: 3 }; + feature/be006-performance-clean +const PIPELINE_VERSION = "1.4.1"; +const CACHE_LIMIT = 50; + +const NON_VEGAN_INGREDIENTS = [ + "milk", + "egg", + "honey", + "gelatin", + "cheese", + "butter", + "cream", + "whey", + "casein" +]; + +const GLUTEN_SOURCES = ["wheat", "barley", "rye", "malt"]; + +const recommendationCache = new Map(); + + function cleanData(raw) { const normalizeList = (text) => text @@ -38,13 +59,85 @@ function cleanData(raw) { }; } +feature/be006-performance-clean +function buildProcessedUserProfile(userProfile) { + const safeUserProfile = userProfile || {}; + + return { + id: safeUserProfile.id || null, + allergies: (safeUserProfile.allergies || []).map((item) => + item.toLowerCase() + ), + avoidAdditives: (safeUserProfile.avoidAdditives || []).map((item) => + item.toLowerCase() + ), + dietPreferences: safeUserProfile.dietPreferences || [], + dietPreferencesSet: new Set(safeUserProfile.dietPreferences || []) + }; +} + +function buildProductLookupSets(cleaned) { + return { + ingredientSet: new Set(cleaned.ingredients || []), + additiveSet: new Set(cleaned.additives || []) + }; +} + +function createCacheKey(cleaned, processedUserProfile) { + const barcode = cleaned.barcode || "no-barcode"; + const userId = processedUserProfile.id || "anonymous"; + return `${barcode}_${userId}`; +} + +function hasIngredientMatch(ingredients, targets) { + return ingredients.some((ingredient) => + targets.some((target) => ingredient.includes(target)) + ); +} + +function hasAdditiveMatch(additives, targets) { + return additives.some((additive) => + targets.some((target) => additive.includes(target)) + ); +} + +function getWarnings(cleaned, processedUserProfile) { + function getWarnings(cleaned, user) { const safeUser = user || {}; + const warnings = []; const ingredients = cleaned.ingredients || []; const additives = cleaned.additives || []; const nutrition = cleaned.nutrition || {}; + + processedUserProfile.allergies.forEach((allergen) => { + if (ingredients.some((ingredient) => ingredient.includes(allergen))) { + warnings.push({ + type: "allergen", + code: `ALLERGEN_${allergen.toUpperCase()}`, + message: `Contains ${allergen}`, + severity: "high" + }); + } + }); + + processedUserProfile.avoidAdditives.forEach((additive) => { + if (additives.some((item) => item.includes(additive))) { + warnings.push({ + type: "additive", + code: `ADDITIVE_${additive}`, + message: `Contains additive ${additive}, which you prefer to avoid`, + severity: "medium" + }); + } + }); + + if (processedUserProfile.dietPreferencesSet.has("vegan")) { + if (hasIngredientMatch(ingredients, NON_VEGAN_INGREDIENTS)) { + + if (Array.isArray(safeUser.allergies)) { safeUser.allergies.forEach((allergen) => { const normalizedAllergen = allergen.toLowerCase(); @@ -93,6 +186,7 @@ function getWarnings(cleaned, user) { ); if (hasNonVeganIngredient) { + warnings.push({ type: "diet", code: "DIET_VEGAN_UNSUITABLE", @@ -102,6 +196,10 @@ function getWarnings(cleaned, user) { } } + + if (processedUserProfile.dietPreferencesSet.has("glutenFree")) { + if (hasIngredientMatch(ingredients, GLUTEN_SOURCES)) { + if (safeUser.dietPreferences?.includes("glutenFree")) { const glutenSources = ["wheat", "barley", "rye", "malt"]; @@ -110,6 +208,7 @@ function getWarnings(cleaned, user) { ); if (hasGluten) { + warnings.push({ type: "diet", code: "DIET_GLUTEN_UNSUITABLE", @@ -135,9 +234,13 @@ function getWarnings(cleaned, user) { } function classifyProduct(warnings) { + const hasHigh = warnings.some((warning) => warning.severity === "high"); + const hasMedium = warnings.some((warning) => warning.severity === "medium"); + const hasHigh = warnings.some((w) => w.severity === "high"); const hasMedium = warnings.some((w) => w.severity === "medium"); + if (hasHigh) return "red"; if (hasMedium) return "grey"; return "green"; @@ -146,17 +249,22 @@ function classifyProduct(warnings) { function calculateRiskScore(warnings) { if (!warnings.length) return 0; - const total = warnings.reduce((sum, w) => { - return sum + (SEVERITY_WEIGHTS[w.severity] || 1); + const total = warnings.reduce((sum, warning) => { + return sum + (SEVERITY_WEIGHTS[warning.severity] || 1); }, 0); return Math.min(100, total * 20); } + +function calculateRecommendationScore(cleaned, processedUserProfile, warnings) { + let score = 100; + function calculateRecommendationScore(cleaned, userProfile, warnings) { const safeUserProfile = userProfile || {}; let score = 100; + const ingredients = cleaned.ingredients || []; const nutrition = cleaned.nutrition || {}; @@ -164,6 +272,20 @@ function calculateRecommendationScore(cleaned, userProfile, warnings) { score -= (SEVERITY_WEIGHTS[warning.severity] || 1) * 15; }); + + if ( + processedUserProfile.dietPreferencesSet.has("vegan") && + !hasIngredientMatch(ingredients, NON_VEGAN_INGREDIENTS) + ) { + score += 10; + } + + if ( + processedUserProfile.dietPreferencesSet.has("glutenFree") && + !hasIngredientMatch(ingredients, GLUTEN_SOURCES) + ) { + score += 10; + if (safeUserProfile.dietPreferences?.includes("vegan")) { const nonVeganList = [ "milk", @@ -196,6 +318,7 @@ function calculateRecommendationScore(cleaned, userProfile, warnings) { if (!hasGluten) { score += 10; } + } if (typeof nutrition.sugarG === "number" && nutrition.sugarG <= 5) { @@ -205,9 +328,13 @@ function calculateRecommendationScore(cleaned, userProfile, warnings) { return Math.max(0, Math.min(100, score)); } + +function getAlternatives(classification, processedUserProfile) { + function getAlternatives(cleaned, classification, userProfile) { const safeUserProfile = userProfile || {}; + const base = [ { name: "Dark Chocolate 85%", @@ -234,11 +361,19 @@ function getAlternatives(cleaned, classification, userProfile) { let filtered = base; + + if (processedUserProfile.dietPreferencesSet.has("vegan")) { + filtered = filtered.filter((item) => item.tags.includes("vegan")); + } + + if (processedUserProfile.dietPreferencesSet.has("glutenFree")) { + if (safeUserProfile.dietPreferences?.includes("vegan")) { filtered = filtered.filter((item) => item.tags.includes("vegan")); } if (safeUserProfile.dietPreferences?.includes("glutenFree")) { + filtered = filtered.filter((item) => item.tags.includes("glutenFree")); } @@ -246,6 +381,12 @@ function getAlternatives(cleaned, classification, userProfile) { filtered = base; } + + return classification === "green" ? filtered.slice(0, 2) : filtered; +} + +function generateRecommendationReason(item, processedUserProfile) { + if (classification === "green") { return filtered.slice(0, 2); } @@ -255,18 +396,27 @@ function getAlternatives(cleaned, classification, userProfile) { function generateRecommendationReason(item, userProfile) { const safeUserProfile = userProfile || {}; + const reasons = []; const tags = item.tags || []; if ( + + processedUserProfile.dietPreferencesSet.has("vegan") && + safeUserProfile.dietPreferences?.includes("vegan") && + tags.includes("vegan") ) { reasons.push("Matches vegan preference"); } if ( + + processedUserProfile.dietPreferencesSet.has("glutenFree") && + safeUserProfile.dietPreferences?.includes("glutenFree") && + tags.includes("glutenFree") ) { reasons.push("Matches gluten-free preference"); @@ -279,6 +429,33 @@ function generateRecommendationReason(item, userProfile) { return reasons.length ? reasons : ["General healthier alternative"]; } +function enforceCacheLimit() { + if (recommendationCache.size >= CACHE_LIMIT) { + recommendationCache.clear(); + } +} + +function buildScanResult(rawData, userProfile) { + const cleaned = cleanData(rawData || {}); + const processedUserProfile = buildProcessedUserProfile(userProfile); + const cacheKey = createCacheKey(cleaned, processedUserProfile); + + if (recommendationCache.has(cacheKey)) { + const cachedResult = recommendationCache.get(cacheKey); + + return { + ...cachedResult, + metadata: { + ...cachedResult.metadata, + servedFromCache: true + } + }; + } + + const warnings = getWarnings(cleaned, processedUserProfile); + +} + function buildScanResult(rawData, userProfile) { const safeUserProfile = userProfile || {}; const cleaned = cleanData(rawData || {}); @@ -305,10 +482,16 @@ function buildScanResult(rawData, userProfile) { } const warnings = getWarnings(cleaned, safeUserProfile); + const classification = classifyProduct(warnings); const riskScore = calculateRiskScore(warnings); const recommendationScore = calculateRecommendationScore( cleaned, + + processedUserProfile, + warnings + ); + safeUserProfile, warnings ); @@ -322,7 +505,16 @@ function buildScanResult(rawData, userProfile) { reason: generateRecommendationReason(item, safeUserProfile) })); - return { + + const alternatives = getAlternatives( + classification, + processedUserProfile + ).map((item) => ({ + ...item, + reason: generateRecommendationReason(item, processedUserProfile) + })); + + const result = { product: cleaned, // DB036 ADDITION @@ -339,6 +531,19 @@ function buildScanResult(rawData, userProfile) { suitability: { isSafe: classification !== "red", + + reasons: warnings.map((warning) => warning.message), + riskScore, + recommendationScore, + matchedPreferences: processedUserProfile.dietPreferences + }, + alternatives, + metadata: { + processedAt: new Date().toISOString(), + pipelineVersion: PIPELINE_VERSION, + userId: processedUserProfile.id, + servedFromCache: false + reasons: warnings.map((w) => w.message), riskScore, recommendationScore, @@ -351,8 +556,59 @@ function buildScanResult(rawData, userProfile) { processedAt: new Date().toISOString(), pipelineVersion: PIPELINE_VERSION, userId: safeUserProfile.id || null + } }; + + enforceCacheLimit(); + recommendationCache.set(cacheKey, result); + + return result; +} + + +module.exports = { + cleanData, + buildProcessedUserProfile, + buildProductLookupSets, + createCacheKey, + getWarnings, + classifyProduct, + calculateRiskScore, + calculateRecommendationScore, + getAlternatives, + generateRecommendationReason, + buildScanResult +}; + +if (require.main === module) { + const testRaw = { + barcode: "12345", + productName: "Milk Chocolate", + ingredientsText: "Milk, Cocoa, Sugar, Wheat flour", + additivesText: "621", + nutrition: { sugarG: 25 } + }; + + const testUser = { + id: "user-123", + allergies: ["milk"], + avoidAdditives: ["621"], + dietPreferences: ["vegan", "glutenFree"] + }; + + const firstRun = buildScanResult(testRaw, testUser); + const secondRun = buildScanResult(testRaw, testUser); + + console.log("Structured Scan Result:"); + console.log(JSON.stringify(firstRun, null, 2)); + + console.log("\nRunning same input again to test cache:"); + console.log(JSON.stringify(secondRun, null, 2)); + + console.log("\nBasic cache check:"); + console.log("First run servedFromCache:", firstRun.metadata.servedFromCache); + console.log("Second run servedFromCache:", secondRun.metadata.servedFromCache); } function mergeScanResultWithRemote(localResult, remoteResult) { @@ -469,3 +725,4 @@ if (require.main === module) { console.error("Reconnect sync failed:", error.message); }); } +