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:
@@ -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;
|
||||
|
||||
+155
-1
@@ -3283,6 +3283,9 @@ function renderBannerItem() {
|
||||
}
|
||||
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>`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 = '<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() {
|
||||
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 += ' <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) {
|
||||
// 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 `
|
||||
<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>
|
||||
<span class="shopping-item-icon">${catIcon}</span>
|
||||
<div class="suggestion-info">
|
||||
@@ -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 = `💡 <em>${escapeHtml(enriched.tip)}</em>`;
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user