diff --git a/api/index.php b/api/index.php index 5c0c02d..fb81141 100644 --- a/api/index.php +++ b/api/index.php @@ -3589,6 +3589,72 @@ function italianToBring(string $italianName): string { * The returned string is always a valid Bring! catalog name where possible, * so that italianToBring(computeShoppingName($n)) resolves to a catalog key. */ +/** + * Ask Gemini to classify a product name into a short Italian shopping category word. + * Results are cached in a local JSON file to avoid repeated API calls. + * Returns null on failure so the caller can fall back gracefully. + */ +function _geminiClassifyProduct(string $name, string $brand, string $category): ?string { + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) return null; + + // Load/save classification cache + $cacheFile = __DIR__ . '/../data/shopping_name_cache.json'; + $cache = []; + if (file_exists($cacheFile)) { + $raw = @file_get_contents($cacheFile); + if ($raw) $cache = json_decode($raw, true) ?: []; + } + $cacheKey = md5(mb_strtolower($name . '|' . $brand)); + if (isset($cache[$cacheKey])) return $cache[$cacheKey]; + + $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; + + // Build catalog list so Gemini picks an existing Bring! entry when possible + $catalog = bringCatalog(); + $catalogList = implode(', ', array_slice(array_values($catalog['de2it']), 0, 200)); + + $prompt = << [['parts' => [['text' => $prompt]]]], + 'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 16], + ]; + + $result = callGemini($url, $payload, 15); + if ($result['http_code'] !== 200 || !isset($result['data']['candidates'][0])) return null; + + $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); + // Sanitize: keep only letters and spaces, max 30 chars, capitalize first letter + $text = preg_replace('/[^\p{L}\s]/u', '', $text); + $text = trim(preg_replace('/\s+/', ' ', $text)); + if (mb_strlen($text) < 2 || mb_strlen($text) > 30) return null; + $text = mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1); + + // Persist to cache + $cache[$cacheKey] = $text; + @file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + + return $text; +} + function computeShoppingName(string $name, string $category = '', string $brand = ''): string { $lower = mb_strtolower(trim($name)); $stop = ['di','del','della','dei','degli','delle','da','in','con','per','su', @@ -3600,10 +3666,9 @@ function computeShoppingName(string $name, string $category = '', string $brand )); // 1. Curated keyword → canonical group name. - // These handle products that map to distinct Bring! entries but belong together - // (all cured/cold-cut meats → "Affettato", which is in the Bring! catalog). + // Extended list covers the most common Italian pantry items and avoids Gemini calls. $keywordMap = [ - // Cold cuts / affettati — group them all under "Affettato" (catalog: Aufschnitt) + // Cold cuts / affettati 'mortadella' => 'Affettato', 'nduja' => 'Affettato', 'salame' => 'Affettato', @@ -3614,11 +3679,165 @@ function computeShoppingName(string $name, string $category = '', string $brand 'schinkenspeck' => 'Affettato', 'schinken' => 'Affettato', 'prosciutto' => 'Affettato', - // Items that have their own Bring! entry — keep specific + // Items with their own Bring! entry 'bresaola' => 'Bresaola', 'pancetta' => 'Pancetta', 'salsiccia' => 'Salsiccia', 'wurstel' => 'Wurstel', + // Bread & bakery + 'pane' => 'Pane', + 'bauletto' => 'Pane', + 'pancarrè' => 'Pane', + 'pancare' => 'Pane', + 'toast' => 'Pane', + 'focaccia' => 'Pane', + 'ciabatta' => 'Pane', + 'baguette' => 'Pane', + 'grissini' => 'Grissini', + 'crackers' => 'Cracker', + 'cracker' => 'Cracker', + 'taralli' => 'Taralli', + 'tarallini' => 'Taralli', + 'piadina' => 'Piadina', + 'piadelle' => 'Piadina', + 'biscotto' => 'Biscotti', + 'biscotti' => 'Biscotti', + // Dairy + 'latte' => 'Latte', + 'yogurt' => 'Yogurt', + 'yaourt' => 'Yogurt', + 'yougurt' => 'Yogurt', + 'burro' => 'Burro', + 'panna' => 'Panna', + 'mozzarella' => 'Mozzarella', + 'formaggio' => 'Formaggio', + 'ricotta' => 'Ricotta', + 'ricottina' => 'Ricotta', + 'casatella' => 'Formaggio', + 'philadelphia' => 'Formaggio cremoso', + // "Bel Paese" — known Italian cheese brand + 'bel' => 'Formaggio', + // Pasta + 'pasta' => 'Pasta', + 'spaghetti' => 'Pasta', + 'penne' => 'Pasta', + 'rigatoni' => 'Pasta', + 'fusilli' => 'Pasta', + 'orecchiette' => 'Pasta', + 'tortiglioni' => 'Pasta', + 'linguine' => 'Pasta', + 'sedani' => 'Pasta', + 'lasagne' => 'Pasta', + 'tortellini' => 'Pasta', + 'gnocchi' => 'Gnocchi', + // Rice + 'riso' => 'Riso', + // Eggs + 'uova' => 'Uova', + 'uovo' => 'Uova', + // Fruit & veg + 'mela' => 'Mele', + 'mele' => 'Mele', + 'pera' => 'Pere', + 'arancia' => 'Arance', + 'arance' => 'Arance', + 'limone' => 'Limone', + 'banana' => 'Banane', + 'banane' => 'Banane', + 'kiwi' => 'Kiwi', + 'avocado' => 'Avocado', + 'pomodoro' => 'Pomodori', + 'pomodori' => 'Pomodori', + 'pomodorini' => 'Pomodorini', + 'carota' => 'Carote', + 'carote' => 'Carote', + 'cipolla' => 'Cipolla', + 'cipolle' => 'Cipolla', + 'aglio' => 'Aglio', + 'zucchina' => 'Zucchine', + 'zucchine' => 'Zucchine', + 'spinaci' => 'Spinaci', + 'lattuga' => 'Insalata', + 'melone' => 'Melone', + 'finocchio' => 'Finocchio', + // Condiments & pantry + 'olio' => 'Olio', + 'aceto' => 'Aceto', + 'sale' => 'Sale', + 'zucchero' => 'Zucchero', + 'farina' => 'Farina', + 'lievito' => 'Lievito', + 'miele' => 'Miele', + 'marmellata' => 'Marmellata', + 'confettura' => 'Marmellata', + 'maionese' => 'Maionese', + 'senape' => 'Senape', + 'ketchup' => 'Ketchup', + // Canned / preserved + 'passata' => 'Passata', + 'polpa' => 'Polpa di pomodoro', + 'pelati' => 'Pelati', + 'tonno' => 'Tonno', + 'sardine' => 'Sardine', + 'ceci' => 'Ceci', + 'lenticchie' => 'Lenticchie', + 'fagioli' => 'Fagioli', + 'piselli' => 'Piselli', + 'mais' => 'Mais', + // Frozen + 'surgelato' => 'Surgelati', + 'surgelati' => 'Surgelati', + // Drinks + 'vino' => 'Vino', + 'birra' => 'Birra', + 'succo' => 'Succo', + // Cereals & snacks + 'muesli' => 'Muesli', + 'cereali' => 'Cereali', + // Frozen & desserts (before coffee/tea tokens to avoid "gelato caffè → Caffè") + 'gelato' => 'Gelato', + 'semifreddo' => 'Gelato', + // Beverages (coffee, tea, herbal) + 'camomilla' => 'Camomilla', + 'camomille' => 'Camomilla', + 'tisana' => 'Tè', + // Cat food / pet + 'gatto' => 'Cibo per gatti', + 'cane' => 'Cibo per cani', + // Known product/brand single tokens → category override + 'risofrolle' => 'Cracker', + 'zuppalatte' => 'Biscotti', + 'kaffee' => 'Caffè', + 'ovomaltine' => 'Bevande', + 'ciobar' => 'Cioccolata calda', + 'apfelsaft' => 'Succo', + 'kartoffelpüree'=> 'Purè', + 'purée' => 'Purè', + 'pure' => 'Purè', + 'inchusa' => 'Birra', + 'ichnusa' => 'Birra', + 'vesoletto' => 'Vino', + 'trebbiano' => 'Vino', + 'sangiovese' => 'Vino', + 'barbera' => 'Vino', + 'chianti' => 'Vino', + 'soave' => 'Vino', + 'prosecco' => 'Vino', + 'frizzante' => 'Acqua', + 'semolino' => 'Semolino', + 'bicarbonato' => 'Bicarbonato', + 'sambuca' => 'Liquore', + 'limoncello' => 'Liquore', + 'grappa' => 'Liquore', + 'dado' => 'Brodo', + 'zuccheri' => 'Zucchero', + 'zucchero' => 'Zucchero', + // Foreign-language tokens + 'jus' => 'Succo', + 'zumo' => 'Succo', + 'arome' => 'Aroma', + 'caffe' => 'Caffè', + 'caffè' => 'Caffè', ]; foreach ($tokens as $token) { @@ -3627,8 +3846,7 @@ function computeShoppingName(string $name, string $category = '', string $brand } } - // 2. Bring! back-translation: run italianToBring() — if it found a catalog key, - // back-translate to Italian to get the canonical catalog name (e.g. "Latte"). + // 2. Bring! catalog back-translation: "Latte di Montagna" → "Milch" → "Latte" $bringKey = italianToBring($name); if ($bringKey !== $name) { $italian = bringToItalian($bringKey); @@ -3637,9 +3855,28 @@ function computeShoppingName(string $name, string $category = '', string $brand } } - // 3. Fallback: capitalize the first meaningful token. + // 3. Gemini AI classification — called when: + // - The name has 2+ tokens (e.g. "Gran bauletto rustico"), + // - OR the single token doesn't look like a clean Italian product word + // (contains non-Italian chars, uppercase mix, brand-style length, etc.), + // - OR category/brand context is available to help Gemini disambiguate. + // Single-token ultra-common words (5+ lowercase Italian chars) that already look + // like valid category names are skipped (unlikely to need AI). + $firstToken = $tokens[0] ?? ''; + $isCleanItalianToken = count($tokens) === 1 + && mb_strlen($firstToken) >= 5 + && mb_strtolower($firstToken) === $firstToken // all lowercase → already in stop-word-free form + && preg_match('/^[a-z]+$/', $firstToken); // only ASCII lowercase (no accents = usually Italian noun) + $hasCategoryHint = $category !== '' || $brand !== ''; + $needsAI = !$isCleanItalianToken || ($hasCategoryHint && count($tokens) >= 2); + if ($needsAI) { + $aiResult = _geminiClassifyProduct($name, $brand, $category); + if ($aiResult !== null) return $aiResult; + } + + // 4. Fallback: capitalize the first meaningful token. if (!empty($tokens)) { - return mb_strtoupper(mb_substr($tokens[0], 0, 1)) . mb_substr($tokens[0], 1); + return mb_strtoupper(mb_substr($firstToken, 0, 1)) . mb_substr($firstToken, 1); } return ucfirst($name); } diff --git a/data/shopping_name_cache.json b/data/shopping_name_cache.json new file mode 100644 index 0000000..b66064b --- /dev/null +++ b/data/shopping_name_cache.json @@ -0,0 +1,22 @@ +{ + "dc1bb00e006a5ed073aad9b0ca2f1601": "Toast", + "f03b656f4cfaa9d633fc155cdafcb83b": "Sale", + "fa1266e5e6bb32602e08aaf9434ec9ad": "Patate", + "ca2da3ad2a7b42e717f766e06a83730e": "Verdure", + "ce8f4f54fc6ead0f0a8ce36503bba462": "Pasta", + "2ddb0faf33c4ceeed89fada2c7c2b9c5": "Ingredienti Spezie", + "0290647fcd95ec97f0d6666c46a72943": "Brodo", + "405ea6ec33d54042d046599650f422ea": "Succo", + "f624c420f14d8eff122c0bb395eb63da": "Snack Dolci", + "92751fbb97923590c402bc7810778b36": "Biscotti", + "0e342f4f977e814b2108e8e0475a57d5": "Aceto", + "edd038513b2641005bd36884f90789c1": "Pane", + "8727f7abcb66764b5eb3d1f036bc18b8": "Tè", + "0eb53fe1a5d4d106eac47c8a81d1afe7": "Farina", + "0ebada5597d1d166d0ed8f49500bfeba": "Verdure", + "fe7456efb7e767a06e3af9f5ec7b3637": "Piatti Pronti", + "2a5d2289bb7bc306dd066dfaff7ef581": "Ingredienti Spezie", + "b630c06f2ac72a1e2ffbd57d327a3733": "Salsa", + "32a05ae91ccfa4d37be454836971436b": "Ingredienti", + "a21f0e7718c8f12166d864d0d05f60a0": "Salsa" +} \ No newline at end of file