feat(ai): 3 new AI features — product storage hint, shopping tips, anomaly explain

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)
This commit is contained in:
dadaloop82
2026-05-04 06:01:44 +00:00
parent a85390b498
commit 529c09fda3
3 changed files with 419 additions and 1 deletions
+254
View File
@@ -384,6 +384,18 @@ try {
checkUpdate(); checkUpdate();
break; break;
case 'gemini_product_hint':
geminiProductHint();
break;
case 'gemini_shopping_enrich':
geminiShoppingEnrich($db);
break;
case 'gemini_anomaly_explain':
geminiAnomalyExplain();
break;
default: default:
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]); echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -5924,3 +5936,245 @@ function _phpErrorReport(string $message, string $file, int $line, string $trace
$running = false; $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\": <integer, typical unopened shelf life in 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]);
}
+10
View File
@@ -2093,6 +2093,12 @@ body {
color: var(--text-muted); color: var(--text-muted);
margin-top: 2px; margin-top: 2px;
} }
.suggestion-ai-tip {
font-size: 0.75rem;
color: #6366f1;
margin-top: 3px;
opacity: 0.88;
}
.priority-badge { .priority-badge {
display: inline-block; display: inline-block;
@@ -4899,6 +4905,10 @@ body.cooking-mode-active .app-header {
background: #e0e7ff; background: #e0e7ff;
color: #4338ca; color: #4338ca;
} }
.btn-banner-ai {
background: #ede9fe;
color: #7c3aed;
}
.btn-banner-weigh { .btn-banner-weigh {
background: #f3e8ff; background: #f3e8ff;
color: #7c3aed; color: #7c3aed;
+155 -1
View File
@@ -3283,6 +3283,9 @@ function renderBannerItem() {
} }
let btns = `<button class="btn-banner btn-banner-edit" onclick="editBannerAnomaly()">${t('dashboard.banner_anomaly_action_edit')}</button>`; let btns = `<button class="btn-banner btn-banner-edit" onclick="editBannerAnomaly()">${t('dashboard.banner_anomaly_action_edit')}</button>`;
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerAnomaly()">${t('dashboard.banner_anomaly_action_dismiss')} (${an.inv_qty} ${an.unit})</button>`; btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerAnomaly()">${t('dashboard.banner_anomaly_action_dismiss')} (${an.inv_qty} ${an.unit})</button>`;
if (_geminiAvailable) {
btns += `<button class="btn-banner btn-banner-ai" onclick="explainBannerAnomaly()" title="Chiedi a Gemini una spiegazione">\ud83e\udd16 Spiega</button>`;
}
actionsEl.innerHTML = btns; actionsEl.innerHTML = btns;
} }
@@ -3339,6 +3342,45 @@ function editBannerPrediction() {
editReviewItem(entry.data.inventory_id, entry.data.product_id); 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 = '<em style="opacity:0.7">\ud83e\udd16 Analizzo…</em>';
// 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 = `<span style="font-size:0.85rem">\ud83e\udd16 ${escapeHtml(result.explanation)}</span>`;
} else {
detailEl.innerHTML = originalHtml;
showToast('Impossibile ottenere spiegazione AI', 'error');
}
} catch (e) {
detailEl.innerHTML = originalHtml;
showToast('Errore AI', 'error');
}
}
function editBannerAnomaly() { function editBannerAnomaly() {
const entry = _bannerQueue[_bannerIndex]; const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'anomaly') return; if (!entry || entry.type !== 'anomaly') return;
@@ -5874,6 +5916,10 @@ function showAddForm() {
if (currentProduct && currentProduct.id) { if (currentProduct && currentProduct.id) {
_fetchExpiryHistoryAndUpdate(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() { 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 += ' <span id="ai-hint-loading" style="font-size:0.75rem;opacity:0.7">🤖…</span>';
}
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 = ` <span class="history-badge" style="background:rgba(99,102,241,0.15);color:#6366f1" title="${escapeHtml(data.reason || '')}">🤖 AI</span>`;
if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} <strong>${newLabel}</strong>${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) { function getVacuumExpiryDays(baseDays) {
// Vacuum sealing extends shelf life significantly // Vacuum sealing extends shelf life significantly
if (baseDays <= 7) return Math.round(baseDays * 3); // very fresh: 3x (e.g., 3→9, 7→21) 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 // Scroll to suggestions
suggestionsEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); suggestionsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
// AI enrich suggestions in background (best-effort)
if (_geminiAvailable && suggestionItems.length > 0) {
_enrichSuggestionsWithAI();
}
} catch (err) { } catch (err) {
btn.disabled = false; btn.disabled = false;
@@ -8734,7 +8857,7 @@ function renderSuggestions() {
}[item.priority] || ''; }[item.priority] || '';
return ` return `
<div class="suggestion-item ${item.selected ? 'selected' : ''}" onclick="toggleSuggestion(${idx})"> <div class="suggestion-item ${item.selected ? 'selected' : ''}" onclick="toggleSuggestion(${idx})" data-suggestion-name="${escapeHtml(item.name)}">
<div class="suggestion-check">${item.selected ? '☑️' : '⬜'}</div> <div class="suggestion-check">${item.selected ? '☑️' : '⬜'}</div>
<span class="shopping-item-icon">${catIcon}</span> <span class="shopping-item-icon">${catIcon}</span>
<div class="suggestion-info"> <div class="suggestion-info">
@@ -8747,6 +8870,37 @@ function renderSuggestions() {
updateSuggestionActionBtn(); 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, '&quot;');
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 = `💡 <em>${escapeHtml(enriched.tip)}</em>`;
infoDiv.appendChild(tipEl);
});
} catch (e) {
// best-effort — silently ignore
}
}
function toggleSuggestion(idx) { function toggleSuggestion(idx) {
const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 }; const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 };
const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2)); const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2));