diff --git a/api/index.php b/api/index.php index a77dfad..b15410d 100644 --- a/api/index.php +++ b/api/index.php @@ -1957,52 +1957,66 @@ function generateRecipe(PDO $db): void { }); // Build ingredient list grouped by priority - $priorityHeaders = [ - 1 => '⚠️ PRODOTTI SCADUTI (PRIORITÀ MASSIMA - USA SUBITO SE ANCORA COMMESTIBILI)', - 2 => '🔴 SCADENZA IMMINENTE (entro 3 giorni - USA PER PRIMI)', - 3 => '🟠 SCADENZA RAVVICINATA (entro 7 giorni)', - 4 => '🟡 ALTRI PRODOTTI CON SCADENZA', - 5 => '📦 PRODOTTI APERTI (già aperti/tagliati — da consumare prima delle confezioni chiuse)', - 6 => '🟢 ALTRI PRODOTTI', - ]; + // ---- Build compact ingredient list for AI prompt ---- + // Skip common staples that are always assumed available (rule says: acqua, sale, pepe, olio) + $staplePatterns = '/\b(sale|pepe|olio d.oliva|olio di semi|olio extra|acqua|aceto balsamico|aceto di|sel marin)\b/i'; + $priorityGroups = []; foreach ($items as $item) { - $line = "- {$item['name']}"; - if ($item['brand']) $line .= " ({$item['brand']})"; - $line .= ": {$item['quantity']} {$item['unit']}"; - if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) { - $line .= " (da {$item['default_quantity']} {$item['package_unit']} ciascuna, totale: " . ($item['quantity'] * $item['default_quantity']) . " {$item['package_unit']})"; - } - if ($item['expiry_date']) { - $daysLeft = intval($item['days_left']); - if ($daysLeft < 0) { - $line .= " [SCADUTO da " . abs($daysLeft) . " giorni]"; - } else { - $line .= " [scade tra $daysLeft giorni]"; - } - } - if (strtolower($item['location']) === 'frigo') { - $line .= " [FRIGO]"; - } + $group = $getItemPriority($item); + // Skip always-available staples from category 6 (closed, no expiry concern) + if ($group >= 5 && preg_match($staplePatterns, $item['name'])) continue; + $qty = floatval($item['quantity']); $isOpen = !empty($item['opened_at']) || ($qty > 0 && $qty < 1 && $item['unit'] === 'conf'); - if ($isOpen) { - $line .= ' [APERTO]'; + $daysLeft = intval($item['days_left']); + + // Compact line: name + qty (with conf expansion) + flags only when relevant + $line = "- {$item['name']}: {$item['quantity']} {$item['unit']}"; + if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) { + $line .= " ({$item['default_quantity']}{$item['package_unit']}/conf)"; } - $line .= " (in {$item['location']})"; - - $group = $getItemPriority($item); + // Add expiry info only for priority groups 1-4 + if ($group <= 4 && $item['expiry_date']) { + if ($daysLeft < 0) { + $line .= " ⚠️SCADUTO"; + } elseif ($daysLeft <= 3) { + $line .= " 🔴{$daysLeft}gg"; + } elseif ($daysLeft <= 7) { + $line .= " 🟠{$daysLeft}gg"; + } else { + $line .= " {$daysLeft}gg"; + } + } + if ($isOpen) $line .= ' [APERTO]'; + $priorityGroups[$group][] = $line; } + // Build sections: detailed headers for urgent groups, brief for rest $ingredientSections = []; + $priorityHeaders = [ + 1 => 'SCADUTI — usa subito', + 2 => 'SCADENZA ≤3gg — priorità alta', + 3 => 'SCADENZA ≤7gg', + 4 => 'ALTRI CON SCADENZA', + 5 => 'APERTI', + 6 => 'DISPENSA', + ]; + // Limit groups to keep prompt compact: + // 1-3 (urgent): all items; 4 (has expiry): max 40; 5 (opened): all; 6 (pantry): max 20 foreach ($priorityHeaders as $g => $header) { - if (!empty($priorityGroups[$g])) { - $ingredientSections[] = "=== {$header} ===\n" . implode("\n", $priorityGroups[$g]); + if (empty($priorityGroups[$g])) continue; + $groupItems = $priorityGroups[$g]; + if ($g === 4 && count($groupItems) > 40) { + $groupItems = array_slice($groupItems, 0, 40); + } elseif ($g === 6 && count($groupItems) > 20) { + $groupItems = array_slice($groupItems, 0, 20); } + $ingredientSections[] = "[$header]\n" . implode("\n", $groupItems); } - $ingredientsText = implode("\n\n", $ingredientSections); + $ingredientsText = implode("\n", $ingredientSections); // Build mandatory/recommended lists ONLY when user explicitly selected // 'scadenze' (expiry priority) or 'zerowaste' (zero waste) options. @@ -2043,10 +2057,10 @@ function generateRecipe(PDO $db): void { $mustUseText = ''; if (!empty($mandatoryItems)) { - $mustUseText .= "\n\n⚠️⚠️⚠️ INGREDIENTI OBBLIGATORI (SCADUTI O IN SCADENZA OGGI/DOMANI) ⚠️⚠️⚠️\nLa ricetta DEVE usare ALMENO uno (meglio se tutti) di questi ingredienti come ingrediente PRINCIPALE. Non sono opzionali!\n" . implode("\n", array_map(fn($n) => "→ $n", $mandatoryItems)); + $mustUseText .= "\n\n⚠️ OBBLIGATORI (scaduti/imminenti — DEVE usarne almeno 1):\n" . implode("\n", array_map(fn($n) => "→ $n", $mandatoryItems)); } if (!empty($recommendedItems)) { - $mustUseText .= "\n\n🔶 INGREDIENTI FORTEMENTE CONSIGLIATI (aperti e/o in scadenza a breve)\nSono già aperti e/o scadono presto — includi più di questi possibile nella ricetta:\n" . implode("\n", array_map(fn($n) => "· $n", $recommendedItems)); + $mustUseText .= "\n\n🔶 CONSIGLIATI (aperti/in scadenza):\n" . implode("\n", array_map(fn($n) => "· $n", $recommendedItems)); } $mealLabels = [ @@ -2079,18 +2093,18 @@ function generateRecipe(PDO $db): void { if (!empty($subType) && isset($subTypeLabels[$mealType][$subType])) { $subHint = $subTypeLabels[$mealType][$subType]; $mealLabel .= " — tipo: $subHint"; - $subTypeText = "\n\n🎨 SOTTO-TIPO RICHIESTO:\nL'utente ha scelto specificamente: {$subHint}\nLa ricetta DEVE essere di questo tipo preciso. Non proporre un tipo diverso di {$mealType}."; + $subTypeText = "\n\n🎨 SOTTO-TIPO: {$subHint}. La ricetta DEVE essere di questo tipo."; } // Build extra rules from options $extraRules = []; $optionLabels = [ - 'veloce' => 'La ricetta deve essere VELOCE: massimo 15-20 minuti totali di preparazione e cottura.', - 'pocafame' => 'L\'utente ha POCA FAME: proponi una porzione leggera, magari uno snack, un\'insalata o qualcosa di semplice e poco abbondante.', - 'scadenze' => 'PRIORITÀ SCADENZE: usa ASSOLUTAMENTE per primi gli ingredienti più vicini alla scadenza o già scaduti (se ancora commestibili).', - 'salutare' => 'Ricetta EXTRA SALUTARE: prediligi ingredienti integrali, tante verdure, pochi grassi, cotture leggere.', - 'opened' => 'PRIORITÀ COSE APERTE: dai la MASSIMA PRIORITÀ ai prodotti con confezione aperta (contrassegnati [APERTO]) e a quelli in FRIGO (contrassegnati [FRIGO]). Questi prodotti si deteriorano più in fretta e DEVONO essere usati per primi. Costruisci la ricetta attorno a questi ingredienti.', - 'zerowaste' => 'ZERO SPRECHI: cerca di usare quanti più ingredienti in scadenza possibile, combina anche ingredienti insoliti pur di non sprecare nulla.' + 'veloce' => 'VELOCE: max 15-20 min totali.', + 'pocafame' => 'POCA FAME: porzione leggera, snack o insalata.', + 'scadenze' => 'PRIORITÀ SCADENZE: usa per primi i prodotti in scadenza.', + 'salutare' => 'SALUTARE: ingredienti integrali, verdure, pochi grassi.', + 'opened' => 'PRIORITÀ APERTI: usa per primi i prodotti [APERTO].', + 'zerowaste' => 'ZERO SPRECHI: usa il più possibile ingredienti in scadenza.' ]; foreach ($options as $opt) { if (isset($optionLabels[$opt])) { @@ -2106,7 +2120,7 @@ function generateRecipe(PDO $db): void { // Appliances $appliancesText = ''; if (!empty($appliances)) { - $appliancesText = "\n\nELETTRODOMESTICI DISPONIBILI:\nL'utente dispone di: " . implode(', ', $appliances) . ".\nPuoi usare SOLO questi elettrodomestici (più fornelli e forno che si presumono sempre disponibili). Non suggerire ricette che richiedano elettrodomestici non elencati."; + $appliancesText = "\n\nELETTRODOMESTICI: " . implode(', ', $appliances) . " (+ fornelli e forno). Usa SOLO questi."; } // Dietary restrictions @@ -2184,8 +2198,8 @@ function generateRecipe(PDO $db): void { $matchingBlock = "Nessun ingrediente perfettamente corrispondente trovato — usa la cosa più affine disponibile e segnalalo in nutrition_note."; } - $mealPlanText = "\n\n🎯 TIPOLOGIA PASTO PIANIFICATA — OBBLIGATORIA:\nOggi questo pasto DEVE essere: {$hint}\nQuesta è una regola del piano alimentare personale dell'utente, NON un suggerimento.\n{$matchingBlock}"; - $mealPlanRule = "0. TIPOLOGIA PASTO OBBLIGATORIA: la ricetta DEVE rispettare il tipo pianificato ({$hint}). Usa gli ingredienti compatibili evidenziati sopra come base principale del piatto. Non ignorare questa regola.\n "; + $mealPlanText = "\n\n🎯 TIPO OBBLIGATORIO: {$hint}\n{$matchingBlock}"; + $mealPlanRule = "0. La ricetta DEVE essere: {$hint}. Usa gli ingredienti compatibili come base.\n "; } // Today's previous recipes from DB - avoid repetition @@ -2216,77 +2230,41 @@ function generateRecipe(PDO $db): void { $varietyText = ''; if (!empty($todayTitles)) { $todayList = implode(', ', array_map(function($t) { return '"' . $t . '"'; }, $todayTitles)); - $varietyText .= "\n\nRICETTE GIÀ PREPARATE OGGI:\n{$todayList}\nNON proporre una ricetta simile o con lo stesso concetto di quelle già fatte oggi. Varia il tipo di piatto, gli ingredienti principali e lo stile di cucina. Ad esempio se a pranzo c'era una piadina, a cena proponi pasta, riso, zuppa o altro — MAI un'altra piadina o wrap o piatto concettualmente simile."; + $varietyText .= "\n\nGIÀ FATTO OGGI: {$todayList} — proponi qualcosa di DIVERSO."; } // Weekly variety: list all recent recipes so AI avoids repetition $weekOnly = array_diff($weekTitles, $todayTitles); if (!empty($weekOnly)) { $weekList = implode(', ', array_map(function($t) { return '"' . $t . '"'; }, array_values($weekOnly))); - $varietyText .= "\n\nRICETTE DEGLI ULTIMI 7 GIORNI:\n{$weekList}\nCerca di variare rispetto a queste ricette recenti: evita piatti troppo simili o con gli stessi ingredienti principali. Alterna pasta, riso, zuppe, carne, pesce, verdure, piatti freddi, ecc."; + $varietyText .= "\n\nULTIMI 7GG: {$weekList} — varia."; } // If this is a re-generation, stress the need for a truly different recipe $regenText = ''; if ($variation > 0) { - $regenText = "\n\n🔁 RIGENERAZIONE #{$variation}: L'utente ha già visto e scartato le ricette precedenti. " . - "Devi proporre qualcosa di COMPLETAMENTE DIVERSO: stile di cucina diverso, ingrediente principale diverso, " . - "tecnica di cottura diversa, piatto di un'altra tradizione culinaria o di un'altra categoria. " . - "Non basta cambiare il nome della stessa idea. Sorprendi! Sii creativo!"; + $regenText = "\n\n🔁 RIGENERA #{$variation}: proponi qualcosa di COMPLETAMENTE DIVERSO (altro stile, altro ingrediente principale, altra tecnica)."; if (!empty($rejectedIngredients)) { $rejList = implode(', ', array_map(fn($n) => '"' . $n . '"', $rejectedIngredients)); - $regenText .= "\n\n🚫 INGREDIENTI PRINCIPALI GIÀ RIFIUTATI DALL'UTENTE: {$rejList}\n" . - "NON usare NESSUNO di questi come ingrediente PRINCIPALE della nuova ricetta. " . - "Puoi usarli come ingrediente secondario solo se indispensabile. " . - "Scegli ingredienti principali completamente diversi dalla lista della dispensa!"; + $regenText .= " Evita come ingrediente principale: {$rejList}."; } } $prompt = << false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode]); + $errDetail = ''; + if ($response) { + $errData = json_decode($response, true); + $errDetail = $errData['error']['message'] ?? substr($response, 0, 300); + } + echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode, 'detail' => $errDetail]); return; } diff --git a/assets/js/app.js b/assets/js/app.js index b62ce78..2bb5f8d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -76,6 +76,16 @@ let _scaleStabilityVal = null; // value we are currently timing for stabilit let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value let _scaleRecipeAutoFillPaused = false; // pause flag for recipe-use modal only let _scaleLastConfirmedGrams = null; // grams of last auto-confirmed weight (to detect product change) +let _scaleLastStableGrams = null; // last accepted stable reading in grams (for jitter filtering) + +function _scaleToGrams(value, unit) { + if (!isFinite(value)) return null; + const u = (unit || 'g').toLowerCase(); + if (u === 'kg') return value * 1000; + if (u === 'lbs' || u === 'lb') return value * 453.592; + if (u === 'oz') return value * 28.3495; + return value; // g / ml treated as grams-equivalent for stability filtering +} function scaleInit() { const s = getSettings(); @@ -121,34 +131,53 @@ function _scaleOnMessage(msg) { updateScaleReadButtons(); } else if (msg.type === 'weight') { // Ignore negative weight values (tare artifacts, sensor noise) - if (parseFloat(msg.value) < 0) return; - _scaleLatestWeight = msg; + const rawValue = parseFloat(msg.value); + if (rawValue < 0) return; + + // Ignore sub-gram jitter for stability decisions: only integer-gram changes matter. + let effectiveStable = !!msg.stable; + const grams = _scaleToGrams(rawValue, msg.unit); + if (grams !== null) { + if (effectiveStable) { + _scaleLastStableGrams = grams; + } else if (_scaleLastStableGrams !== null) { + if (Math.round(grams) === Math.round(_scaleLastStableGrams)) { + effectiveStable = true; + } + } + if (effectiveStable) { + _scaleLastStableGrams = grams; + } + } + + const liveMsg = effectiveStable === msg.stable ? msg : { ...msg, stable: effectiveStable }; + _scaleLatestWeight = liveMsg; // Update live reading modal overlay if visible (scale-read modal) const live = document.getElementById('scale-reading-live'); - if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`; + if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${liveMsg.stable ? ' ✓' : ' …'}`; // Also update edit-form inline scale reading if visible const editLive = document.getElementById('edit-scale-reading'); - if (editLive) editLive.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`; + if (editLive) editLive.textContent = `${msg.value} ${msg.unit || 'kg'}${liveMsg.stable ? ' ✓' : ' …'}`; // Always update the persistent live box on the use page (every message, stable or not) - _scaleUpdateLiveBox(msg); + _scaleUpdateLiveBox(liveMsg); // If weight is NOT stable: stop any running timer/bar but keep the sentinel value. // The sentinel is reset only when a genuinely different stable value arrives. - if (!msg.stable) { + if (!liveMsg.stable) { _cancelScaleTimersOnly(); } // Fulfil pending callback on stable reading - if (msg.stable && _scaleWeightCallback) { + if (liveMsg.stable && _scaleWeightCallback) { const cb = _scaleWeightCallback; _scaleWeightCallback = null; - cb(msg); + cb(liveMsg); } // Drive stability logic on use page - if (msg.stable && _currentPageId === 'use') { - _scaleAutoFillUse(msg); + if (liveMsg.stable && _currentPageId === 'use') { + _scaleAutoFillUse(liveMsg); } // Same for recipe-use modal - if (msg.stable && document.getElementById('ruse-quantity') && !_scaleRecipeAutoFillPaused) { - _scaleAutoFillRecipeUse(msg); + if (liveMsg.stable && document.getElementById('ruse-quantity') && !_scaleRecipeAutoFillPaused) { + _scaleAutoFillRecipeUse(liveMsg); } } } @@ -9652,7 +9681,8 @@ async function generateRecipe() { if (result.error === 'no_api_key') { showToast('⚠️ Chiave API Gemini non configurata', 'warning'); } else { - showToast(result.error || 'Errore nella generazione', 'error'); + const detail = result.detail ? ` (${result.detail})` : ''; + showToast((result.error || 'Errore nella generazione') + detail, 'error'); } return; }