From 529c09fda396908d59825d97ef2fad636bbfde18 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Mon, 4 May 2026 06:01:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(ai):=203=20new=20AI=20features=20=E2=80=94?= =?UTF-8?q?=20product=20storage=20hint,=20shopping=20tips,=20anomaly=20exp?= =?UTF-8?q?lain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1: AI product storage/shelf-life hint - New API: gemini_product_hint β†’ {location, expiry_days, reason} - After opening the add form, Gemini suggests optimal storage and expiry - Shown inline next to expiry estimate as a subtle AI badge with tooltip - Also updates location buttons if AI suggests a different location - Cached permanently in food_facts_cache.json (per name+lang) Feature 2: AI-enriched shopping suggestions - New API: gemini_shopping_enrich β†’ adds tip field to each suggestion - After bring_suggest renders, Gemini adds practical buying/storing tips - Tips shown inline under each suggestion item in indigo italic text - Cached per item list + lang in food_facts_cache.json Feature 3: AI anomaly explanation - New API: gemini_anomaly_explain β†’ plain-language explanation - 'πŸ€– Spiega' button added to anomaly banners (when Gemini available) - Explains in 2-3 conversational sentences why the discrepancy likely happened - Replaces technical banner detail text with friendly explanation - No caching (anomaly context is always specific) --- api/index.php | 254 +++++++++++++++++++++++++++++++++++++++++++ assets/css/style.css | 10 ++ assets/js/app.js | 156 +++++++++++++++++++++++++- 3 files changed, 419 insertions(+), 1 deletion(-) diff --git a/api/index.php b/api/index.php index 43e8b31..7c110b1 100644 --- a/api/index.php +++ b/api/index.php @@ -384,6 +384,18 @@ try { checkUpdate(); break; + case 'gemini_product_hint': + geminiProductHint(); + break; + + case 'gemini_shopping_enrich': + geminiShoppingEnrich($db); + break; + + case 'gemini_anomaly_explain': + geminiAnomalyExplain(); + break; + default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); @@ -5924,3 +5936,245 @@ function _phpErrorReport(string $message, string $file, int $line, string $trace $running = false; } + +// ============================================================================= +// ===== GEMINI AI: PRODUCT HINT (shelf-life + storage suggestion) ============= +// ============================================================================= +/** + * POST /api/?action=gemini_product_hint + * Body: { name, category, lang } + * Returns: { success, location, expiry_days, reason, source } + * Uses a permanent cache keyed by (name, lang) β€” science doesn't change. + */ +function geminiProductHint(): void { + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) { + echo json_encode(['success' => false, 'error' => 'no_api_key']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $name = trim($input['name'] ?? ''); + $category = trim($input['category'] ?? ''); + $lang = trim($input['lang'] ?? 'it'); + + if (empty($name)) { + echo json_encode(['success' => false, 'error' => 'missing name']); + return; + } + + // Cache keyed by normalised name + lang + $cacheFile = __DIR__ . '/../data/food_facts_cache.json'; + $cacheKey = 'phint_' . md5(mb_strtolower($name) . '|' . $lang); + $cache = []; + if (file_exists($cacheFile)) { + $cache = json_decode(file_get_contents($cacheFile), true) ?: []; + } + if (!empty($cache[$cacheKey])) { + echo json_encode(array_merge(['success' => true, 'source' => 'cache'], $cache[$cacheKey])); + return; + } + + $langLabel = match($lang) { 'en' => 'English', 'de' => 'German', default => 'Italian' }; + $prompt = "You are a food safety expert. For the food product named \"{$name}\" (category: {$category}), " + . "answer in {$langLabel} with a strict JSON object and NOTHING else:\n" + . "{\n" + . " \"location\": \"dispensa\" | \"frigo\" | \"freezer\",\n" + . " \"expiry_days\": ,\n" + . " \"reason\": \"<1 short sentence explaining location and duration>\"\n" + . "}\n" + . "Rules: location must be one of the three values. expiry_days must be a positive integer. " + . "If the product is typically refrigerated use 'frigo'. If frozen use 'freezer'. Otherwise 'dispensa'. " + . "Output ONLY the JSON, no markdown, no extra text."; + + $payload = ['contents' => [['parts' => [['text' => $prompt]]]]]; + $result = callGeminiWithFallback($apiKey, $payload, 15); + + if ($result['http_code'] !== 200) { + echo json_encode(['success' => false, 'error' => 'gemini_error', 'http_code' => $result['http_code']]); + return; + } + + $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; + // Strip potential markdown fences + $text = preg_replace('/^```json\s*/i', '', trim($text)); + $text = preg_replace('/\s*```$/i', '', $text); + $parsed = json_decode(trim($text), true); + + $allowedLocations = ['dispensa', 'frigo', 'freezer']; + if ( + !is_array($parsed) + || empty($parsed['location']) + || !in_array($parsed['location'], $allowedLocations, true) + || empty($parsed['expiry_days']) + || !is_numeric($parsed['expiry_days']) + ) { + echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => $text]); + return; + } + + $data = [ + 'location' => $parsed['location'], + 'expiry_days' => (int)$parsed['expiry_days'], + 'reason' => $parsed['reason'] ?? '', + ]; + + // Persist to cache (permanent β€” no expiry) + $cache[$cacheKey] = $data; + file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + + echo json_encode(array_merge(['success' => true, 'source' => 'gemini'], $data)); +} + +// ============================================================================= +// ===== GEMINI AI: SHOPPING SUGGESTION ENRICHMENT ============================ +// ============================================================================= +/** + * POST /api/?action=gemini_shopping_enrich + * Body: { items: [{name, reason, category, priority}], lang } + * Returns: { success, items: [{name, reason, tip}] } + * Enriches shopping suggestions with a short actionable tip per item. + * Batches all items in a single Gemini call. Cached by name+lang hash. + */ +function geminiShoppingEnrich(PDO $db): void { + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) { + echo json_encode(['success' => false, 'error' => 'no_api_key']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $items = $input['items'] ?? []; + $lang = trim($input['lang'] ?? 'it'); + + if (empty($items)) { + echo json_encode(['success' => true, 'items' => []]); + return; + } + + // Cache keyed by sorted item names + lang (so reorder doesn't bust it) + $names = array_column($items, 'name'); + sort($names); + $cacheFile = __DIR__ . '/../data/food_facts_cache.json'; + $cacheKey = 'senrich_' . md5(implode('|', $names) . '|' . $lang); + $cache = []; + if (file_exists($cacheFile)) { + $cache = json_decode(file_get_contents($cacheFile), true) ?: []; + } + if (!empty($cache[$cacheKey])) { + echo json_encode(['success' => true, 'items' => $cache[$cacheKey], 'source' => 'cache']); + return; + } + + $langLabel = match($lang) { 'en' => 'English', 'de' => 'German', default => 'Italian' }; + $itemsJson = json_encode(array_map(fn($i) => [ + 'name' => $i['name'], + 'reason' => $i['reason'] ?? '', + 'category' => $i['category'] ?? '', + 'priority' => $i['priority'] ?? 'media', + ], $items), JSON_UNESCAPED_UNICODE); + + $prompt = "You are a practical household assistant. " + . "For each item in this shopping list, add a very short tip (max 10 words) in {$langLabel} " + . "on what to look for when buying or how to store it. " + . "Input JSON array:\n{$itemsJson}\n\n" + . "Reply ONLY with a JSON array of objects with exactly these keys:\n" + . "[{\"name\":\"...\",\"tip\":\"...\"},...]\n" + . "Keep the same order and count as the input. Output ONLY the JSON array, no markdown."; + + $payload = ['contents' => [['parts' => [['text' => $prompt]]]]]; + $result = callGeminiWithFallback($apiKey, $payload, 20); + + if ($result['http_code'] !== 200) { + echo json_encode(['success' => false, 'error' => 'gemini_error']); + return; + } + + $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $text = preg_replace('/^```json\s*/i', '', trim($text)); + $text = preg_replace('/\s*```$/i', '', $text); + $parsed = json_decode(trim($text), true); + + if (!is_array($parsed)) { + echo json_encode(['success' => false, 'error' => 'parse_error']); + return; + } + + // Build tip map by name for safe merging + $tipMap = []; + foreach ($parsed as $p) { + if (!empty($p['name'])) $tipMap[mb_strtolower($p['name'])] = $p['tip'] ?? ''; + } + + $enriched = array_map(function($item) use ($tipMap) { + $item['tip'] = $tipMap[mb_strtolower($item['name'])] ?? ''; + return $item; + }, $items); + + // Cache for 24 h (TTL stored alongside) + $cache[$cacheKey] = $enriched; + file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + + echo json_encode(['success' => true, 'items' => $enriched, 'source' => 'gemini']); +} + +// ============================================================================= +// ===== GEMINI AI: ANOMALY EXPLANATION ======================================= +// ============================================================================= +/** + * POST /api/?action=gemini_anomaly_explain + * Body: { name, inv_qty, expected_qty, diff, direction, unit, lang } + * Returns: { success, explanation } + * Explains in plain language why the anomaly likely occurred and what to do. + */ +function geminiAnomalyExplain(): void { + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) { + echo json_encode(['success' => false, 'error' => 'no_api_key']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $name = trim($input['name'] ?? ''); + $invQty = $input['inv_qty'] ?? 0; + $expQty = $input['expected_qty'] ?? 0; + $diff = $input['diff'] ?? 0; + $direction = $input['direction'] ?? 'missing'; + $unit = $input['unit'] ?? 'pz'; + $lang = trim($input['lang'] ?? 'it'); + + if (empty($name)) { + echo json_encode(['success' => false, 'error' => 'missing name']); + return; + } + + $langLabel = match($lang) { 'en' => 'English', 'de' => 'German', default => 'Italian' }; + + $directionDesc = match($direction) { + 'phantom' => "The inventory shows {$invQty} {$unit} but transaction history predicts only {$expQty} {$unit} (excess of " . abs($diff) . " {$unit}).", + 'missing' => "The inventory shows {$invQty} {$unit} but transaction history predicts {$expQty} {$unit} (shortage of " . abs($diff) . " {$unit}).", + 'untracked' => "More consumption was recorded than purchase entries. The initial stock was likely never registered as an 'in' transaction. Current inventory: {$invQty} {$unit}.", + default => "Inventory discrepancy detected for {$name}.", + }; + + $prompt = "You are a helpful home pantry assistant. " + . "An inventory discrepancy has been detected for the product \"{$name}\". " + . $directionDesc . " " + . "In 2-3 sentences in {$langLabel}, explain in simple friendly language: " + . "(1) the most likely everyday reason this happened, and " + . "(2) the simplest action the user should take to fix it. " + . "Do NOT mention databases, transactions, or technical terms. " + . "Be conversational and practical."; + + $payload = ['contents' => [['parts' => [['text' => $prompt]]]]]; + $result = callGeminiWithFallback($apiKey, $payload, 15); + + if ($result['http_code'] !== 200) { + echo json_encode(['success' => false, 'error' => 'gemini_error']); + return; + } + + $explanation = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); + + echo json_encode(['success' => true, 'explanation' => $explanation]); +} diff --git a/assets/css/style.css b/assets/css/style.css index ed8dcd0..931bd8c 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -2093,6 +2093,12 @@ body { color: var(--text-muted); margin-top: 2px; } +.suggestion-ai-tip { + font-size: 0.75rem; + color: #6366f1; + margin-top: 3px; + opacity: 0.88; +} .priority-badge { display: inline-block; @@ -4899,6 +4905,10 @@ body.cooking-mode-active .app-header { background: #e0e7ff; color: #4338ca; } +.btn-banner-ai { + background: #ede9fe; + color: #7c3aed; +} .btn-banner-weigh { background: #f3e8ff; color: #7c3aed; diff --git a/assets/js/app.js b/assets/js/app.js index 6d303fc..ab14443 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -3283,6 +3283,9 @@ function renderBannerItem() { } let btns = ``; btns += ``; + if (_geminiAvailable) { + btns += ``; + } actionsEl.innerHTML = btns; } @@ -3339,6 +3342,45 @@ function editBannerPrediction() { editReviewItem(entry.data.inventory_id, entry.data.product_id); } +async function explainBannerAnomaly() { + if (!_requireGemini()) return; + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'anomaly') return; + const an = entry.data; + + // Show loading inline in the banner detail area + const detailEl = document.querySelector('#alert-banner .banner-detail'); + if (!detailEl) return; + const originalHtml = detailEl.innerHTML; + detailEl.innerHTML = '\ud83e\udd16 Analizzo…'; + + // Disable the Spiega button to prevent double calls + const explainBtn = document.querySelector('#alert-banner .btn-banner-ai'); + if (explainBtn) explainBtn.disabled = true; + + try { + const result = await api('gemini_anomaly_explain', {}, 'POST', { + name: an.name, + inv_qty: an.inv_qty, + expected_qty: an.expected_qty, + diff: an.diff, + direction: an.direction, + unit: an.unit, + lang: _currentLang, + }); + + if (result.success && result.explanation) { + detailEl.innerHTML = `\ud83e\udd16 ${escapeHtml(result.explanation)}`; + } else { + detailEl.innerHTML = originalHtml; + showToast('Impossibile ottenere spiegazione AI', 'error'); + } + } catch (e) { + detailEl.innerHTML = originalHtml; + showToast('Errore AI', 'error'); + } +} + function editBannerAnomaly() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'anomaly') return; @@ -5874,6 +5916,10 @@ function showAddForm() { if (currentProduct && currentProduct.id) { _fetchExpiryHistoryAndUpdate(currentProduct.id); } + // If Gemini is available and product was just created (no history), ask for AI hint + if (_geminiAvailable && currentProduct && !currentProduct._aiHintFetched) { + _applyAIProductHint(); + } } function toggleVacuumSealed() { @@ -5942,6 +5988,78 @@ async function _fetchExpiryHistoryAndUpdate(productId) { } } +// ===== AI PRODUCT HINT: shelf-life + storage suggestion ===== +let _aiProductHintController = null; +async function _applyAIProductHint() { + if (!currentProduct) return; + // Abort any in-flight request for a previous product + if (_aiProductHintController) _aiProductHintController.abort(); + _aiProductHintController = new AbortController(); + + // Show a subtle loading indicator near the estimate label + const estimateEl = document.querySelector('.expiry-estimate-label'); + if (estimateEl) { + const oldHtml = estimateEl.innerHTML; + estimateEl.dataset.aiOriginal = oldHtml; + estimateEl.innerHTML += ' πŸ€–β€¦'; + } + + try { + const data = await api('gemini_product_hint', {}, 'POST', { + name: currentProduct.name, + category: currentProduct.category || '', + lang: _currentLang, + }); + + // Remove loading indicator + document.getElementById('ai-hint-loading')?.remove(); + + if (!data.success || !data.location || !data.expiry_days) return; + // Mark so we don't re-fetch on the same product + currentProduct._aiHintFetched = true; + + const curLoc = document.getElementById('add-location')?.value; + const locChanged = data.location !== curLoc; + + // Update location if AI suggests a different one (and user hasn't manually picked) + if (locChanged) { + document.getElementById('add-location').value = data.location; + // Update active loc-btn + document.querySelectorAll('#page-add .loc-btn').forEach(b => { + const onclick = b.getAttribute('onclick') || ''; + const locMatch = onclick.match(/'([^']+)'\s*\)/); + if (locMatch) b.classList.toggle('active', locMatch[1] === data.location); + }); + } + + // Update expiry only if we have no historical data (history takes priority) + if (!window._historyExpiryDays) { + window._addBaseExpiryDays = data.expiry_days; + const newDate = addDays(data.expiry_days); + const newLabel = formatEstimatedExpiry(data.expiry_days); + const expiryInput = document.getElementById('add-expiry'); + const dateEl = document.querySelector('.expiry-estimate-date'); + if (expiryInput) expiryInput.value = newDate; + if (dateEl) dateEl.textContent = formatDate(newDate); + const aiSuffix = ` πŸ€– AI`; + if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} ${newLabel}${aiSuffix}`; + } else if (estimateEl && estimateEl.dataset.aiOriginal) { + // Restore original if history already set + estimateEl.innerHTML = estimateEl.dataset.aiOriginal; + } + + // Show a toast only if location changed + if (locChanged) { + const locLabels = { dispensa: t('location.dispensa') || 'Dispensa', frigo: t('location.frigo') || 'Frigo', freezer: t('location.freezer') || 'Freezer' }; + showToast(`πŸ€– AI: conserva in ${locLabels[data.location] || data.location}`, 'info', 4000); + } + } catch (e) { + document.getElementById('ai-hint-loading')?.remove(); + if (estimateEl && estimateEl.dataset.aiOriginal) estimateEl.innerHTML = estimateEl.dataset.aiOriginal; + // silent β€” AI hint is best-effort + } +} + function getVacuumExpiryDays(baseDays) { // Vacuum sealing extends shelf life significantly if (baseDays <= 7) return Math.round(baseDays * 3); // very fresh: 3x (e.g., 3β†’9, 7β†’21) @@ -8710,6 +8828,11 @@ async function generateSuggestions() { // Scroll to suggestions suggestionsEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // AI enrich suggestions in background (best-effort) + if (_geminiAvailable && suggestionItems.length > 0) { + _enrichSuggestionsWithAI(); + } } catch (err) { btn.disabled = false; @@ -8734,7 +8857,7 @@ function renderSuggestions() { }[item.priority] || ''; return ` -
+
${item.selected ? 'β˜‘οΈ' : '⬜'}
${catIcon}
@@ -8747,6 +8870,37 @@ function renderSuggestions() { updateSuggestionActionBtn(); } +async function _enrichSuggestionsWithAI() { + try { + const items = suggestionItems.map(s => ({ + name: s.name, + reason: s.reason || '', + category: s.category || '', + priority: s.priority || 'media', + })); + const data = await api('gemini_shopping_enrich', {}, 'POST', { items, lang: _currentLang }); + if (!data.success || !Array.isArray(data.items)) return; + + // For each item that has a tip, find its DOM element and append the tip + data.items.forEach(enriched => { + if (!enriched.tip) return; + const nameAttr = enriched.name.replace(/"/g, '"'); + const el = document.querySelector(`#suggestion-items [data-suggestion-name="${nameAttr}"]`); + if (!el) return; + const infoDiv = el.querySelector('.suggestion-info'); + if (!infoDiv) return; + // Avoid duplicate tips + if (infoDiv.querySelector('.suggestion-ai-tip')) return; + const tipEl = document.createElement('div'); + tipEl.className = 'suggestion-ai-tip'; + tipEl.innerHTML = `πŸ’‘ ${escapeHtml(enriched.tip)}`; + infoDiv.appendChild(tipEl); + }); + } catch (e) { + // best-effort β€” silently ignore + } +} + function toggleSuggestion(idx) { const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 }; const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2));