feat: improve computeShoppingName — expanded keyword map + Gemini AI fallback
- Extended keyword map: 100+ entries covering bread (bauletto->Pane), cheese (bel->Formaggio, casatella->Formaggio), wine (vesoletto/trebbiano->Vino), foreign brand names (kaffee->Caffe, risofrolle->Cracker, zuppalatte->Biscotti, inchusa->Birra, apfelsaft->Succo, kartoffelpüree->Purè, ciobar->Cioccolata calda, ovomaltine->Bevande), desserts (gelato->Gelato), herbs (camomilla->Camomilla), liquors (sambuca->Liquore), sugar variants (zuccheri->Zucchero), foreign words (jus/zumo/arome->Italian equivalents) - Add _geminiClassifyProduct(): calls Gemini 2.0 Flash for ambiguous product names, with persistent cache in data/shopping_name_cache.json (never re-queries same product) - computeShoppingName() now calls Gemini when keyword map and Bring! catalog both fail and the product name is multi-token or has a brand/category hint - saveProduct() auto-computes shopping_name on every create/update (already in prev commit) - DB migration: all 210 products re-classified with new rules - shopping list: 38->33 groups (Formaggio +4v, Affettato +5v, Biscotti +1v, Pane +1v...) - Final SQL fixes for edge cases: Gelato, Camomilla, brand name single tokens
This commit is contained in:
+245
-8
@@ -3589,6 +3589,72 @@ function italianToBring(string $italianName): string {
|
|||||||
* The returned string is always a valid Bring! catalog name where possible,
|
* The returned string is always a valid Bring! catalog name where possible,
|
||||||
* so that italianToBring(computeShoppingName($n)) resolves to a catalog key.
|
* 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 = <<<PROMPT
|
||||||
|
Sei un assistente per la spesa italiana. Data la descrizione di un prodotto alimentare,
|
||||||
|
rispondi con UNA SOLA parola (o al massimo due) in italiano che rappresenta la categoria
|
||||||
|
generica più appropriata per la lista della spesa.
|
||||||
|
|
||||||
|
Il nome deve essere:
|
||||||
|
- Breve (1-2 parole al massimo)
|
||||||
|
- In italiano
|
||||||
|
- Riconoscibile da un supermercato italiano (es: "Pane", "Latte", "Formaggio", "Yogurt",
|
||||||
|
"Pasta", "Riso", "Olio", "Biscotti", "Succo", "Marmellata", "Salsa", "Farina", ...)
|
||||||
|
- Se esiste nel catalogo Bring! scegli quella voce: {$catalogList}
|
||||||
|
|
||||||
|
Prodotto: "{$name}"
|
||||||
|
Marca: "{$brand}"
|
||||||
|
Categoria OpenFoodFacts: "{$category}"
|
||||||
|
|
||||||
|
Rispondi SOLO con la parola/coppia di parole, senza punteggiatura, senza spiegazioni.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'contents' => [['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 {
|
function computeShoppingName(string $name, string $category = '', string $brand = ''): string {
|
||||||
$lower = mb_strtolower(trim($name));
|
$lower = mb_strtolower(trim($name));
|
||||||
$stop = ['di','del','della','dei','degli','delle','da','in','con','per','su',
|
$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.
|
// 1. Curated keyword → canonical group name.
|
||||||
// These handle products that map to distinct Bring! entries but belong together
|
// Extended list covers the most common Italian pantry items and avoids Gemini calls.
|
||||||
// (all cured/cold-cut meats → "Affettato", which is in the Bring! catalog).
|
|
||||||
$keywordMap = [
|
$keywordMap = [
|
||||||
// Cold cuts / affettati — group them all under "Affettato" (catalog: Aufschnitt)
|
// Cold cuts / affettati
|
||||||
'mortadella' => 'Affettato',
|
'mortadella' => 'Affettato',
|
||||||
'nduja' => 'Affettato',
|
'nduja' => 'Affettato',
|
||||||
'salame' => 'Affettato',
|
'salame' => 'Affettato',
|
||||||
@@ -3614,11 +3679,165 @@ function computeShoppingName(string $name, string $category = '', string $brand
|
|||||||
'schinkenspeck' => 'Affettato',
|
'schinkenspeck' => 'Affettato',
|
||||||
'schinken' => 'Affettato',
|
'schinken' => 'Affettato',
|
||||||
'prosciutto' => 'Affettato',
|
'prosciutto' => 'Affettato',
|
||||||
// Items that have their own Bring! entry — keep specific
|
// Items with their own Bring! entry
|
||||||
'bresaola' => 'Bresaola',
|
'bresaola' => 'Bresaola',
|
||||||
'pancetta' => 'Pancetta',
|
'pancetta' => 'Pancetta',
|
||||||
'salsiccia' => 'Salsiccia',
|
'salsiccia' => 'Salsiccia',
|
||||||
'wurstel' => 'Wurstel',
|
'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) {
|
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,
|
// 2. Bring! catalog back-translation: "Latte di Montagna" → "Milch" → "Latte"
|
||||||
// back-translate to Italian to get the canonical catalog name (e.g. "Latte").
|
|
||||||
$bringKey = italianToBring($name);
|
$bringKey = italianToBring($name);
|
||||||
if ($bringKey !== $name) {
|
if ($bringKey !== $name) {
|
||||||
$italian = bringToItalian($bringKey);
|
$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)) {
|
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);
|
return ucfirst($name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user