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:
+254
@@ -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\": <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]);
|
||||
}
|
||||
|
||||
@@ -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