AI product identification with Gemini + dashboard layout fix

AI Identification:
- Rewrite analyzeWithAI() to use Gemini API for real image analysis
- Auto-start analysis immediately after taking photo (no manual button)
- Gemini identifies product name, brand, category from photo
- Reverse search on Open Food Facts to find matching barcoded products
- User can select a match to import full product data with barcode
- Or save product directly without barcode
- New API endpoint: gemini_identify with OFF reverse search

Dashboard:
- Move 🚫 Scaduti section to TOP of dashboard
- Show only top 4 soonest expiring items below
- Limit API query to 4 results
This commit is contained in:
dadaloop82
2026-03-10 12:20:57 +00:00
parent b548f2fe66
commit eb46f44eba
4 changed files with 400 additions and 79 deletions
+172 -2
View File
@@ -91,6 +91,10 @@ try {
generateRecipe($db);
break;
case 'gemini_identify':
geminiIdentifyProduct();
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -509,13 +513,13 @@ function getStats(PDO $db): void {
$recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn();
$recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn();
// Expiring soonest (next items to expire, up to 10)
// Expiring soonest (next 4 items to expire)
$expiring = $db->query("
SELECT i.*, p.name, p.brand
FROM inventory i JOIN products p ON i.product_id = p.id
WHERE i.expiry_date IS NOT NULL AND i.expiry_date >= date('now') AND i.quantity > 0
ORDER BY i.expiry_date ASC
LIMIT 10
LIMIT 4
")->fetchAll();
// Expired
@@ -798,3 +802,169 @@ PROMPT;
echo json_encode(['success' => false, 'error' => 'Impossibile generare la ricetta', 'raw' => $text]);
}
}
// ===== GEMINI AI PRODUCT IDENTIFICATION =====
function geminiIdentifyProduct(): void {
// Load API key
$envFile = __DIR__ . '/../.env';
$apiKey = '';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0) continue;
if (strpos($line, '=') !== false) {
list($key, $val) = explode('=', $line, 2);
if (trim($key) === 'GEMINI_API_KEY') {
$apiKey = trim($val);
}
}
}
}
if (empty($apiKey)) {
echo json_encode(['success' => false, 'error' => 'no_api_key']);
return;
}
$input = json_decode(file_get_contents('php://input'), true);
$imageBase64 = $input['image'] ?? '';
if (empty($imageBase64)) {
echo json_encode(['success' => false, 'error' => 'No image provided']);
return;
}
// Step 1: Ask Gemini to identify the product
$prompt = <<<PROMPT
Analizza questa foto di un prodotto alimentare o di uso domestico. Identifica il prodotto nel modo più preciso possibile.
Rispondi SOLO con un JSON valido (senza markdown, senza backtick):
{
"name": "Nome del prodotto (es: Yogurt Greco Bianco)",
"brand": "Marca se visibile (es: Fage, Müller) o stringa vuota",
"category": "Categoria in italiano (es: latticini, pasta, bevande, snack, carne, pesce, frutta, verdura, surgelati, condimenti, conserve, cereali, pane, igiene, pulizia, altro)",
"search_terms": "termini di ricerca per trovare il prodotto su un database (es: greek yogurt fage, pasta barilla spaghetti)",
"confidence": "alta/media/bassa",
"description": "Breve descrizione del prodotto identificato"
}
PROMPT;
$url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt],
[
'inline_data' => [
'mime_type' => 'image/jpeg',
'data' => $imageBase64
]
]
]
]
],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => 512
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode !== 200) {
echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode]);
return;
}
$data = json_decode($response, true);
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
$text = preg_replace('/^```json\\s*/i', '', $text);
$text = preg_replace('/\\s*```$/i', '', $text);
$text = trim($text);
$identified = json_decode($text, true);
if (!$identified || empty($identified['name'])) {
echo json_encode(['success' => false, 'error' => 'Impossibile identificare il prodotto', 'raw' => $text]);
return;
}
// Step 2: Search Open Food Facts by product name to find a matching barcode
$searchTerms = $identified['search_terms'] ?? $identified['name'];
$offProducts = searchOpenFoodFacts($searchTerms, $identified['name'], $identified['brand'] ?? '');
echo json_encode([
'success' => true,
'identified' => $identified,
'off_matches' => $offProducts
]);
}
function searchOpenFoodFacts(string $searchTerms, string $name, string $brand): array {
$results = [];
// Try multiple search strategies
$queries = [];
if (!empty($brand)) {
$queries[] = trim($brand . ' ' . $name);
}
$queries[] = $name;
if ($searchTerms !== $name) {
$queries[] = $searchTerms;
}
$seen = [];
foreach ($queries as $query) {
$encodedQuery = urlencode($query);
$url = "https://world.openfoodfacts.org/cgi/search.pl?search_terms={$encodedQuery}&search_simple=1&action=process&json=1&page_size=5&fields=code,product_name,product_name_it,brands,image_front_small_url,quantity,categories_tags&lc=it";
$ctx = stream_context_create([
'http' => [
'timeout' => 8,
'header' => "User-Agent: DispensaManager/1.0\r\n"
]
]);
$response = @file_get_contents($url, false, $ctx);
if ($response === false) continue;
$data = json_decode($response, true);
if (empty($data['products'])) continue;
foreach ($data['products'] as $p) {
$code = $p['code'] ?? '';
if (empty($code) || isset($seen[$code])) continue;
$seen[$code] = true;
$pName = $p['product_name_it'] ?? $p['product_name'] ?? '';
if (empty($pName)) continue;
$results[] = [
'barcode' => $code,
'name' => $pName,
'brand' => $p['brands'] ?? '',
'image_url' => $p['image_front_small_url'] ?? '',
'quantity_info' => $p['quantity'] ?? '',
'category' => $p['categories_tags'][0] ?? '',
];
if (count($results) >= 6) break 2;
}
}
return $results;
}
+62
View File
@@ -1725,3 +1725,65 @@ body {
margin-bottom: 12px;
line-height: 1.5;
}
/* ===== AI IDENTIFICATION RESULTS ===== */
.ai-identified-card {
background: var(--bg-light);
border-radius: var(--radius-sm);
padding: 12px;
margin-top: 8px;
}
.ai-matches-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.ai-match-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--bg-card);
border: 2px solid var(--bg-light);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.ai-match-item:active {
border-color: var(--primary);
background: var(--bg-light);
}
.ai-match-img {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: var(--radius-sm);
flex-shrink: 0;
background: #fff;
}
.ai-match-info {
flex: 1;
min-width: 0;
font-size: 0.9rem;
line-height: 1.4;
}
.ai-match-info strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-match-barcode {
font-size: 0.7rem;
color: var(--text-muted);
font-family: monospace;
flex-shrink: 0;
}
+160 -69
View File
@@ -1669,14 +1669,12 @@ async function initAICamera() {
const captureDiv = document.getElementById('ai-capture');
const previewDiv = document.getElementById('ai-preview');
const captureBtn = document.getElementById('ai-capture-btn');
const analyzeBtn = document.getElementById('ai-analyze-btn');
const retakeBtn = document.getElementById('ai-retake-btn');
const resultDiv = document.getElementById('ai-result');
captureDiv.style.display = 'block';
previewDiv.style.display = 'none';
captureBtn.style.display = 'block';
analyzeBtn.style.display = 'none';
retakeBtn.style.display = 'none';
resultDiv.style.display = 'none';
@@ -1718,8 +1716,10 @@ function takePhotoForAI() {
document.getElementById('ai-capture').style.display = 'none';
document.getElementById('ai-preview').style.display = 'block';
document.getElementById('ai-capture-btn').style.display = 'none';
document.getElementById('ai-analyze-btn').style.display = 'block';
document.getElementById('ai-retake-btn').style.display = 'block';
// Immediately start analysis
analyzeWithAI();
}
function retakePhotoAI() {
@@ -1730,82 +1730,173 @@ function retakePhotoAI() {
async function analyzeWithAI() {
const resultDiv = document.getElementById('ai-result');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<p>🤖 Analisi in corso...</p><div class="loading-spinner" style="margin:12px auto"></div>';
resultDiv.innerHTML = '<div style="text-align:center;padding:20px"><div class="loading-spinner" style="margin:0 auto 12px"></div><p>🤖 Identifico il prodotto...</p></div>';
const canvas = document.getElementById('ai-canvas');
const imageData = canvas.toDataURL('image/jpeg', 0.7);
// We'll use a free approach: analyze image colors and shapes locally
// and try to identify using image analysis heuristics
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Simple color analysis to guess product type
let r = 0, g = 0, b = 0;
const pixels = imgData.data;
const count = pixels.length / 4;
for (let i = 0; i < pixels.length; i += 16) { // sample every 4th pixel
r += pixels[i];
g += pixels[i + 1];
b += pixels[i + 2];
const base64 = canvas.toDataURL('image/jpeg', 0.7).split(',')[1];
try {
const result = await api('gemini_identify', {}, 'POST', { image: base64 });
if (!result.success) {
if (result.error === 'no_api_key') {
resultDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
} else {
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ ${escapeHtml(result.error || 'Errore nell\'identificazione')}</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">🔄 Riprova</button>`;
}
return;
}
const id = result.identified;
const matches = result.off_matches || [];
let html = `<h4>🤖 Prodotto identificato</h4>`;
html += `<div class="ai-identified-card">`;
html += `<strong>${escapeHtml(id.name)}</strong>`;
if (id.brand) html += ` <span style="color:var(--text-muted)">- ${escapeHtml(id.brand)}</span>`;
if (id.description) html += `<p style="font-size:0.85rem;color:var(--text-light);margin:4px 0 0">${escapeHtml(id.description)}</p>`;
html += `</div>`;
if (matches.length > 0) {
html += `<h4 style="margin-top:16px">📦 Prodotti corrispondenti</h4>`;
html += `<div class="ai-matches-list">`;
matches.forEach((m, idx) => {
html += `<div class="ai-match-item" onclick="selectAIMatch(${idx})">`;
if (m.image_url) {
html += `<img src="${m.image_url}" alt="" class="ai-match-img" onerror="this.style.display='none'">`;
}
html += `<div class="ai-match-info">`;
html += `<strong>${escapeHtml(m.name)}</strong>`;
if (m.brand) html += `<br><small>${escapeHtml(m.brand)}</small>`;
if (m.quantity_info) html += `<br><small style="color:var(--text-muted)">${escapeHtml(m.quantity_info)}</small>`;
html += `</div>`;
html += `<span class="ai-match-barcode">${m.barcode}</span>`;
html += `</div>`;
});
html += `</div>`;
}
// Option to save as-is without barcode
html += `<div style="margin-top:16px; border-top: 1px solid var(--bg-light); padding-top: 12px">`;
html += `<button class="btn btn-secondary full-width" onclick="saveAIProductDirect()">✏️ Salva senza barcode</button>`;
html += `</div>`;
resultDiv.innerHTML = html;
// Store data for later use
window._aiIdentified = id;
window._aiMatches = matches;
} catch (err) {
console.error('AI identify error:', err);
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ Errore di connessione</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">🔄 Riprova</button>`;
}
const samples = count / 4;
r = Math.round(r / samples);
g = Math.round(g / samples);
b = Math.round(b / samples);
// Provide a manual identification form since free AI APIs are limited
resultDiv.innerHTML = `
<h4>🤖 Identificazione Prodotto</h4>
<p style="font-size:0.85rem;color:var(--text-light);margin:8px 0">
L'analisi automatica ha dei limiti senza API a pagamento.
Puoi descrivere il prodotto qui sotto e lo salveremo nel database.
</p>
<form class="form" onsubmit="submitAIProduct(event)" style="margin-top:12px">
<div class="form-group">
<label>🏷️ Che prodotto è? *</label>
<input type="text" id="ai-product-name" class="form-input" required
placeholder="Es: Yogurt greco, Pasta Barilla..." autofocus>
</div>
<div class="form-group">
<label>🏢 Marca (se visibile)</label>
<input type="text" id="ai-product-brand" class="form-input" placeholder="Es: Müller, Barilla...">
</div>
<div class="form-group">
<label>📂 Categoria</label>
<select id="ai-product-category" class="form-input">
<option value="">-- Seleziona --</option>
${Object.entries(CATEGORY_ICONS).map(([k, v]) => `<option value="${k}">${v} ${k.charAt(0).toUpperCase() + k.slice(1)}</option>`).join('')}
</select>
</div>
<button type="submit" class="btn btn-large btn-accent full-width">✅ Salva e Continua</button>
</form>
`;
}
async function submitAIProduct(e) {
e.preventDefault();
async function selectAIMatch(idx) {
const match = window._aiMatches[idx];
if (!match) return;
showLoading(true);
const name = document.getElementById('ai-product-name').value;
const brand = document.getElementById('ai-product-brand').value;
const category = document.getElementById('ai-product-category').value;
// Save the captured image as base64 (we could save to file, but for simplicity use image_url)
const canvas = document.getElementById('ai-canvas');
// For a lightweight approach, don't store the actual image data in DB
try {
const result = await api('product_save', {}, 'POST', {
name, brand, category,
// Use the barcode to do a full lookup (gets all details)
const localResult = await api('search_barcode', { barcode: match.barcode });
if (localResult.found) {
currentProduct = localResult.product;
showLoading(false);
showProductAction();
return;
}
// Full lookup via OpenFoodFacts
const lookupResult = await api('lookup_barcode', { barcode: match.barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
const detected = detectUnitAndQuantity(p.quantity_info);
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: notesParts.join(' · '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
weight_info: p.quantity_info || '',
};
showLoading(false);
showProductAction();
return;
}
}
// Fallback: save with basic info from match
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: match.name,
brand: match.brand || '',
category: match.category || '',
image_url: match.image_url || '',
unit: 'pz',
default_quantity: 1,
});
if (result.success) {
currentProduct = { id: result.id, name, brand, category, unit: 'pz', default_quantity: 1 };
if (saveResult.id) {
currentProduct = { id: saveResult.id, barcode: match.barcode, name: match.name, brand: match.brand || '', category: match.category || '', image_url: match.image_url || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showToast('Prodotto identificato e salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast('Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
console.error('AI match select error:', err);
showToast('Errore di connessione', 'error');
}
}
async function saveAIProductDirect() {
const id = window._aiIdentified;
if (!id) return;
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
name: id.name,
brand: id.brand || '',
category: id.category || '',
unit: 'pz',
default_quantity: 1,
});
if (result.success || result.id) {
currentProduct = { id: result.id, name: id.name, brand: id.brand || '', category: id.category || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);
+6 -8
View File
@@ -53,15 +53,16 @@
</div>
</div>
<!-- Alert for expiring items -->
<div class="alert-section" id="alert-expiring" style="display:none">
<h3>⏰ Prossime Scadenze</h3>
<div id="expiring-list"></div>
</div>
<!-- Alert for expired items (on top) -->
<div class="alert-section alert-danger" id="alert-expired" style="display:none">
<h3>🚫 Scaduti</h3>
<div id="expired-list"></div>
</div>
<!-- Alert for soonest expiring items -->
<div class="alert-section" id="alert-expiring" style="display:none">
<h3>⏰ Prossime Scadenze</h3>
<div id="expiring-list"></div>
</div>
<!-- Full inventory by location -->
<div class="section-card" id="dash-section-dispensa">
@@ -436,9 +437,6 @@
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn">
📸 Scatta Foto
</button>
<button class="btn btn-large btn-primary" onclick="analyzeWithAI()" id="ai-analyze-btn" style="display:none">
🤖 Analizza con AI
</button>
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none">
🔄 Riscatta
</button>