From 234cae14bcf20e8e72c92e4a798fb4c239bc0d9c Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 21 Apr 2026 12:12:04 +0000 Subject: [PATCH] perf: remove Gemini from bringSuggest and product selection - pure offline logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bringSuggestItems: now uses smart_shopping cache (already computed offline) instead of calling Gemini with full inventory prompt - aiSelectBestProduct: replaced Gemini call with token-scoring algorithm; scores by token overlap, first-anchor bonus, spec-variant matching, category mismatch penalty — 0ms vs ~1s per product search - Only truly necessary AI calls remain: photo expiry, photo identify, chat assistant, recipe generation --- api/index.php | 424 +++++++++++++++++++------------------------------- 1 file changed, 163 insertions(+), 261 deletions(-) diff --git a/api/index.php b/api/index.php index 8176900..69eea8b 100644 --- a/api/index.php +++ b/api/index.php @@ -3480,221 +3480,80 @@ function smartShopping(PDO $db): void { } function bringSuggestItems(PDO $db): void { - $apiKey = env('GEMINI_API_KEY'); - - if (empty($apiKey)) { - echo json_encode(['success' => false, 'error' => 'API Key Gemini non configurata']); - return; + // Offline: derive suggestions from smart shopping cache (no AI needed) + + // 1. Load smart shopping data from cache or compute fresh + $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; + $smartItems = null; + if (file_exists($cacheFile)) { + $raw = file_get_contents($cacheFile); + if ($raw) { + $cached = json_decode($raw, true); + if ($cached && isset($cached['items'])) { + $smartItems = $cached['items']; + } + } } - - // Get current Bring! list - $auth = bringAuth(); - $bringItems = []; + if ($smartItems === null) { + ob_start(); + smartShopping($db); + $raw = ob_get_clean(); + $data = json_decode($raw, true); + $smartItems = $data['items'] ?? []; + } + + // 2. Get Bring! listUUID for response $listUUID = ''; + $auth = bringAuth(); if ($auth) { - $listUUID = $auth['bringListUUID']; - if (empty($listUUID)) { - $lists = bringRequest('GET', "https://api.getbring.com/rest/v2/bringusers/{$auth['uuid']}/lists"); - if ($lists && isset($lists['lists'][0]['listUuid'])) { - $listUUID = $lists['lists'][0]['listUuid']; - } - } - if ($listUUID) { - $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); - if ($data && isset($data['purchase'])) { - foreach ($data['purchase'] as $item) { - $rawName = $item['name'] ?? ''; - $bringItems[] = bringToItalian($rawName); - } - } - } + $listUUID = $auth['bringListUUID'] ?? ''; } - - // Get inventory - $stmt = $db->query(" - SELECT p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date, - CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left - FROM inventory i - JOIN products p ON p.id = i.product_id - WHERE i.quantity > 0 - ORDER BY p.category, p.name - "); - $inventory = $stmt->fetchAll(PDO::FETCH_ASSOC); - - // Build detailed context with expiry info - $invLines = []; - $expiringItems = []; - $expiredItems = []; - $categories = []; - foreach ($inventory as $item) { - $cat = $item['category'] ?: 'altro'; - $categories[$cat] = ($categories[$cat] ?? 0) + 1; - $line = "- {$item['name']}"; - if ($item['brand']) $line .= " ({$item['brand']})"; - $line .= ": {$item['quantity']} {$item['unit']} in {$item['location']}"; - if ($item['expiry_date']) { - $dl = intval($item['days_left']); - if ($dl < 0) { - $line .= " [⚠️ SCADUTO da " . abs($dl) . " giorni]"; - $expiredItems[] = $item['name']; - } elseif ($dl <= 2) { - $line .= " [🔴 SCADE TRA {$dl} GIORNI - USARE SUBITO]"; - $expiringItems[] = $item['name'] . " (tra {$dl}g)"; - } elseif ($dl <= 7) { - $line .= " [🟡 scade tra {$dl} giorni]"; - $expiringItems[] = $item['name'] . " (tra {$dl}g)"; - } elseif ($dl <= 14) { - $line .= " [scade tra {$dl} giorni]"; - } - } - $invLines[] = $line; - } - $inventoryText = empty($invLines) ? 'La dispensa è COMPLETAMENTE VUOTA.' : implode("\n", $invLines); - - $expiryContext = ''; - if (!empty($expiredItems)) { - $expiryContext .= "\n\nPRODOTTI SCADUTI da sostituire: " . implode(', ', $expiredItems); - } - if (!empty($expiringItems)) { - $expiryContext .= "\n\nPRODOTTI IN SCADENZA (priorità per sostituzione): " . implode(', ', $expiringItems); - } - - $bringText = empty($bringItems) - ? 'La lista della spesa Bring! è attualmente VUOTA.' - : "PRODOTTI GIÀ NELLA LISTA DELLA SPESA BRING! (NON suggerire nessuno di questi, sono già stati aggiunti):\n- " . implode("\n- ", $bringItems); - - // Current month for seasonal suggestions - $mese = strftime('%B') ?: date('F'); - $mesi_it = ['January'=>'Gennaio','February'=>'Febbraio','March'=>'Marzo','April'=>'Aprile','May'=>'Maggio','June'=>'Giugno','July'=>'Luglio','August'=>'Agosto','September'=>'Settembre','October'=>'Ottobre','November'=>'Novembre','December'=>'Dicembre']; - $meseIt = $mesi_it[date('F')] ?? date('F'); - $anno = date('Y'); - - // Get catalog Italian names for AI to use - $catalog = bringCatalog(); - $catalogNames = array_values($catalog['de2it']); - // Filter only food-related items (exclude categories and non-food) - $catalogNames = array_filter($catalogNames, function($n) { - $skip = ['Fai da te', 'Giardino', 'Atrezzi', 'Annaffiatoio', 'Rasaerba', 'Sementi', 'Propangas', 'Vernice', 'Pennello', 'Viti', 'Chiodi', 'Barbecue', 'Ombrellone', 'Terriccio', 'Concime', 'Articoli propri', 'Usati di recente']; - foreach ($skip as $s) { if (str_contains($n, $s)) return false; } - return mb_strlen($n) > 1; - }); - $catalogList = implode(', ', array_slice($catalogNames, 0, 200)); - - $prompt = << [ - ['parts' => [['text' => $prompt]]] - ], - 'generationConfig' => [ - 'temperature' => 0.8, - 'maxOutputTokens' => 2048, - ] + // 3. Convert smart shopping items → suggestions (alta/media priority only, skip on_bring) + $suggestions = []; + $seasonalTips = [ + 1 => 'Gennaio: arance, mandarini, kiwi, carciofi e verze sono di stagione.', + 2 => 'Febbraio: radicchio, finocchi, pere e agrumi da non perdere.', + 3 => 'Marzo: arrivano gli asparagi! Ottimo anche con piselli freschi e spinaci.', + 4 => 'Aprile: stagione di asparagi, carciofi, fave e fragole.', + 5 => 'Maggio: zucchine, fragole, ciliegie — ottimo mese per frutta e verdura fresca.', + 6 => 'Giugno: albicocche, pesche, pomodori freschi, melanzane — estate in arrivo.', + 7 => 'Luglio: cocomero, pesche, melanzane e pomodori sono al loro meglio.', + 8 => 'Agosto: prugne, fichi, peperoni e basilico fresco di stagione.', + 9 => 'Settembre: uva, fichi, funghi porcini, melograno e more.', + 10 => 'Ottobre: melograni, castagne, funghi, mele e pere autunnali.', + 11 => 'Novembre: cachi, melograni, cavoli, broccoli e radicchio tardivo.', + 12 => 'Dicembre: arance, mandarini, cachi, verze e cavolfiori.', ]; - - $ctx = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json\r\n", - 'content' => json_encode($payload), - 'timeout' => 30, - ] - ]); - - $response = @file_get_contents($url, false, $ctx); - if ($response === false) { - echo json_encode(['success' => false, 'error' => 'Errore di connessione a Gemini']); - return; + $seasonalTip = $seasonalTips[(int)date('n')] ?? ''; + + foreach ($smartItems as $item) { + if ($item['on_bring'] ?? false) continue; // already on shopping list + + $urgency = $item['urgency'] ?? 'low'; + if ($urgency === 'low') continue; // not urgent enough to suggest + + $priority = ($urgency === 'critical' || $urgency === 'high') ? 'alta' : 'media'; + $reasons = $item['reasons'] ?? []; + $reason = !empty($reasons) ? implode(', ', $reasons) : 'Scorte basse'; + + $suggestions[] = [ + 'name' => $item['name'], + 'specification' => '', + 'reason' => $reason, + 'category' => $item['category'] ?: 'altro', + 'priority' => $priority, + ]; + + if (count($suggestions) >= 12) break; } - - $data = json_decode($response, true); - $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; - - // Clean markdown artifacts - $text = preg_replace('/^```json\s*/i', '', $text); - $text = preg_replace('/```\s*$/', '', $text); - $text = trim($text); - - $suggestions = json_decode($text, true); - if (!$suggestions || !isset($suggestions['suggestions'])) { - echo json_encode(['success' => false, 'error' => 'Risposta AI non valida', 'raw' => $text]); - return; - } - - // Post-filter: remove any suggestions that match Bring! list items (safety net) - $bringLower = array_map('mb_strtolower', $bringItems); - $filtered = array_values(array_filter($suggestions['suggestions'], function($s) use ($bringLower) { - $sName = mb_strtolower($s['name'] ?? ''); - foreach ($bringLower as $b) { - // Check exact match or if one contains the other - if ($sName === $b || str_contains($sName, $b) || str_contains($b, $sName)) { - return false; - } - } - return true; - })); - + echo json_encode([ - 'success' => true, - 'suggestions' => $filtered, - 'seasonal_tip' => $suggestions['seasonal_tip'] ?? '', - 'listUUID' => $listUUID, - ]); + 'success' => true, + 'suggestions' => $suggestions, + 'seasonal_tip' => $seasonalTip, + 'listUUID' => $listUUID, + ], JSON_UNESCAPED_UNICODE); } // ===== DUPLICLICK (GRUPPO POLI) ===== @@ -3962,73 +3821,116 @@ function dupliclickExtractSpecKeywords(string $spec): string { } /** - * Use Gemini AI to pick the best product from search results + * Pick the best product from search results using offline text-scoring (no AI needed). + * Returns null when nothing matches well enough (triggers refined search with spec keywords). */ function aiSelectBestProduct(string $itemName, string $spec, array $products, string $customPrompt = ''): ?array { - $apiKey = env('GEMINI_API_KEY'); - if (empty($apiKey)) return null; + if (empty($products)) return null; + if (count($products) === 1) return $products[0]; - $defaultPrompt = "Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare (con eventuale descrizione tra parentesi) e una lista di prodotti trovati nel catalogo del supermercato. + $stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per','su', + 'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli', + 'allo','gr','kg','ml','lt','cl','pz','conf','pack']; -Regole di selezione: -- Scegli il prodotto che corrisponde ESATTAMENTE a quello richiesto (stessa categoria merceologica) -- La DESCRIZIONE tra parentesi è FONDAMENTALE: se l'utente cerca \"Pancetta (a cubetti)\", DEVI trovare pancetta A CUBETTI, non pancetta generica -- Se la descrizione include un tipo specifico (\"a cubetti\", \"a fette\", \"biologico\", \"cotto\", \"a pasta dura\"), il prodotto DEVE contenere quella caratteristica nel nome -- Preferisci prodotti freschi/sfusi rispetto a trasformati (es. \"Arance\" = arance frutta, NON aranciata bevanda) -- Se ci sono più varianti valide, scegli quella con il miglior rapporto qualità/prezzo -- Preferisci formati standard per una famiglia -- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, pasta ripiena vs formaggio, ecc.) -- \"Finocchio\" = ortaggio fresco, NON semi di finocchio o tisana -- \"Arance\" = frutta fresca, NON aranciata o succo -- \"Formaggio\" = formaggio intero/pezzo, NON prodotti che contengono formaggio come ingrediente (ravioli, sfogliavelo, ecc.) -- \"Detergente intimo\" = detergente per igiene intima, NON detersivo generico -- Rispondi -1 se NESSUN prodotto corrisponde ragionevolmente alla richiesta + $tokenize = function(string $s) use ($stop): array { + $clean = mb_strtolower(preg_replace('/[^\p{L}0-9\s]/u', ' ', $s), 'UTF-8'); + return array_values(array_filter( + preg_split('/\s+/', trim($clean)), + fn($t) => mb_strlen($t, 'UTF-8') > 2 && !in_array($t, $stop) + )); + }; -Rispondi SOLO con il numero (indice 0-based) del prodotto migliore, oppure -1 se nessun prodotto è appropriato."; + $queryTokens = $tokenize($itemName); + $specTokens = $tokenize($spec); - $prompt = !empty($customPrompt) ? $customPrompt : $defaultPrompt; + if (empty($queryTokens)) return $products[0]; - // Build product list - $productList = ''; - foreach ($products as $i => $p) { - $productList .= "[$i] \"{$p['name']}\" - {$p['brand']} - €" . number_format($p['price'], 2) . " - {$p['packageDescr']}\n"; - } + // Variant conflict pairs: if spec says X, penalise products containing opposite + $variantConflicts = [ + 'cubetti' => ['fette','affettata','intera','arrotolata'], + 'fette' => ['cubetti','dadini'], + 'cotto' => ['crudo','stagionato'], + 'crudo' => ['cotto'], + 'intero' => ['macinato','tritato','cubetti','fette'], + 'macinato' => ['intero'], + 'biologico' => [], + ]; - $fullPrompt = "{$prompt}\n\nProdotto cercato: \"{$itemName}\"" . ($spec ? " ({$spec})" : '') . "\n\nProdotti trovati:\n{$productList}\nRispondi SOLO con il numero (es. 0, 1, 2... oppure -1):"; + // Category mismatch: if query implies a category, penalise products from the wrong one + $categoryGuards = [ + ['query' => ['frutta','mele','pere','pesche','fragole','uva','arance','limoni','banane','kiwi'], + 'exclude' => ['succo','succhi','nettare','sciroppo','aranciata','bevanda','bibita']], + ['query' => ['verdura','spinaci','zucchine','carote','finocchio','sedano','broccoli'], + 'exclude' => ['surgelat','succo']], + ['query' => ['formaggio','mozzarella','parmigiano','ricotta','pecorino'], + 'exclude' => ['ravioli','tortellini','cannelloni','lasagne','pizza']], + ['query' => ['pasta','spaghetti','penne','fusilli','rigatoni','tagliatelle'], + 'exclude' => ['insalata','minestra','zuppa','brodo']], + ]; - $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; - $payload = json_encode([ - 'contents' => [['parts' => [['text' => $fullPrompt]]]], - 'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 16], - ]); + $scores = []; + foreach ($products as $idx => $product) { + $productName = $product['name'] ?? ''; + $productBrand = $product['brand'] ?? ''; + $productTokens = $tokenize($productName . ' ' . $productBrand); + $nameLower = mb_strtolower($productName, 'UTF-8'); + $score = 0; - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $payload, - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 15, - ]); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($response === false || $httpCode !== 200) return null; - - $data = json_decode($response, true); - $text = trim($data['candidates'][0]['content']['parts'][0]['text'] ?? ''); - if (preg_match('/-?\d+/', $text, $m)) { - $idx = (int)$m[0]; - if ($idx >= 0 && $idx < count($products)) { - return $products[$idx]; - } elseif ($idx === -1) { - return null; // AI says nothing matches + // --- Token overlap: query vs product --- + foreach ($queryTokens as $qt) { + foreach ($productTokens as $pt) { + if ($qt === $pt) { $score += 6; break; } + if (str_contains($pt, $qt) || str_contains($qt, $pt)) { $score += 2; break; } + } } + + // --- Spec tokens get extra weight (user specified variant) --- + foreach ($specTokens as $st) { + foreach ($productTokens as $pt) { + if ($st === $pt) { $score += 8; break; } + if (str_contains($pt, $st) || str_contains($st, $pt)) { $score += 3; break; } + } + } + + // --- First-token anchor bonus --- + if (!empty($queryTokens) && !empty($productTokens) && $queryTokens[0] === $productTokens[0]) { + $score += 10; + } + + // --- Category mismatch penalty --- + foreach ($categoryGuards as $guard) { + if (!empty(array_intersect($queryTokens, $guard['query']))) { + foreach ($guard['exclude'] as $exc) { + if (str_contains($nameLower, $exc)) { $score -= 50; break; } + } + } + } + + // --- Variant conflict penalty --- + foreach ($specTokens as $st) { + if (isset($variantConflicts[$st])) { + foreach ($variantConflicts[$st] as $conflict) { + if (str_contains($nameLower, $conflict)) { $score -= 20; } + } + } + } + + $scores[$idx] = $score; } - return null; // Could not parse, caller will use first result + arsort($scores); + reset($scores); + $topIdx = key($scores); + $topScore = current($scores); + next($scores); + $secondScore = current($scores) ?: 0; + + // Return null (triggers spec-refined search) only when spec is given and no product + // matches well, so the caller can retry with more specific keywords. + if (!empty($spec) && $topScore < 4) return null; + + // Otherwise return the best scoring result (fallback to first if score is 0) + return $products[$topIdx]; } function formatDupliclickProduct(array $p): array {