diff --git a/server.py b/server.py index 837064f..945fd5a 100644 --- a/server.py +++ b/server.py @@ -21,15 +21,31 @@ try: except ImportError: HAS_TESSERACT = False +# --- Load .env file automatically --- +_dotenv = Path(__file__).parent.parent.parent / ".hermes" / ".env" +if not _dotenv.exists(): + _dotenv = Path.home() / ".hermes" / ".env" +if _dotenv.exists(): + for _line in _dotenv.read_text().splitlines(): + _line = _line.strip() + if not _line or _line.startswith("#") or "=" not in _line: + continue + _key, _val = _line.split("=", 1) + _key = _key.strip() + _val = _val.strip() + # Only set if not already in environment + if _key not in os.environ: + os.environ[_key] = _val + # === LLM OCR via OpenCode Go === -OPENCODE_GO_KEY = os.environ.get("OPENCODE_GO_API_KEY", "") -OPENCODE_GO_BASE = os.environ.get("OPENCODE_GO_BASE_URL", "https://opencode.ai/zen/go/v1") -LLM_OCR_MODEL = os.environ.get("LLM_OCR_MODEL", "mimo-v2-omni") +OPENCODE_GO_KEY = (os.environ.get("OPENCODE_GO_API_KEY") or "").strip() +OPENCODE_GO_BASE = (os.environ.get("OPENCODE_GO_BASE_URL") or "https://opencode.ai/zen/go/v1").strip() +LLM_OCR_MODEL = (os.environ.get("LLM_OCR_MODEL") or "mimo-v2-omni").strip() # === Google Gemini OCR (free tier) === -GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "") -GOOGLE_API_BASE = os.environ.get("GOOGLE_API_BASE_URL", "https://generativelanguage.googleapis.com/v1beta") -GOOGLE_OCR_MODEL = os.environ.get("GOOGLE_OCR_MODEL", "gemini-2.5-flash") +GOOGLE_API_KEY = (os.environ.get("GOOGLE_API_KEY") or "").strip() +GOOGLE_API_BASE = (os.environ.get("GOOGLE_API_BASE_URL") or "https://generativelanguage.googleapis.com/v1beta").strip() +GOOGLE_OCR_MODEL = (os.environ.get("GOOGLE_OCR_MODEL") or "gemini-2.5-flash").strip() UPLOADS = Path(__file__).parent / "uploads" UPLOADS.mkdir(exist_ok=True) @@ -133,8 +149,8 @@ def _save_photo_to_db( """INSERT OR IGNORE INTO photos (orig_filename, file_hash, saved_as, file_size, exif_json, gps_lat, gps_lng, ocr_engine, ocr_model, ocr_raw_text, - ocr_match_5dash6, ocr_match_5plus, machine_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ocr_match_5dash6, ocr_match_5plus, machine_id, sticker_color, has_barcode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( orig_filename, file_hash, @@ -149,6 +165,8 @@ def _save_photo_to_db( ocr_result.get("match_5dash6") if ocr_result else None, ocr_result.get("match_5plus") if ocr_result else None, machine_id, + ocr_result.get("sticker_color") if ocr_result else None, + 1 if ocr_result and ocr_result.get("has_barcode") else 0, ), ) conn.commit() @@ -552,6 +570,28 @@ def run_ocr_google(image_bytes: bytes, model: str | None = None, sticker_mode: b # --------------------------------------------------------------------------- # Process one photo (shared between analyze + bulk) # --------------------------------------------------------------------------- +def _extract_machine_id(ocr_result: dict) -> str | None: + """Extract machine ID from OCR result with priority logic. + + 1. match_5dash6 (5dash6 format: 12345-678901) → first 5 digits + 2. match_5plus (any 5+ digit run) → first 5 digits + 3. Otherwise → None + """ + match_5dash6 = ocr_result.get("match_5dash6") + match_5plus = ocr_result.get("match_5plus") + + if match_5dash6: + # "12345-678901" → strip non-digits → "12345678901" → first 5 + clean = re.sub(r"\D", "", match_5dash6) + if len(clean) >= 5: + return clean[:5] + if match_5plus: + clean = re.sub(r"\D", "", match_5plus) + if len(clean) >= 5: + return clean[:5] + return None + + def _process_one(orig_filename: str, contents: bytes, ocr_engine: str, ocr_model: str | None, sticker_mode: bool) -> dict: """Run EXIF + OCR on a single photo. Returns result dict.""" @@ -596,8 +636,9 @@ def _process_one(orig_filename: str, contents: bytes, ocr_engine: str, machine_id = None asset = None - if ocr_result.get("match_5dash6"): - machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:] + mid = _extract_machine_id(ocr_result) + if mid: + machine_id = mid asset = lookup_machine_id(machine_id) # Save to DB if not a duplicate @@ -737,8 +778,9 @@ async def bulk_process( asset = None needs_gps = False - if ocr_result.get("match_5dash6"): - machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:] + mid = _extract_machine_id(ocr_result) + if mid: + machine_id = mid asset = lookup_machine_id(machine_id) if asset: summary["matched"] += 1 @@ -857,8 +899,9 @@ async def reprocess_photo( machine_id = None asset = None - if ocr_result.get("match_5dash6"): - machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:] + mid = _extract_machine_id(ocr_result) + if mid: + machine_id = mid asset = lookup_machine_id(machine_id) # Update DB record with new OCR results @@ -866,7 +909,8 @@ async def reprocess_photo( try: db2.execute( """UPDATE photos SET ocr_engine=?, ocr_model=?, ocr_raw_text=?, - ocr_match_5dash6=?, ocr_match_5plus=?, machine_id=? + ocr_match_5dash6=?, ocr_match_5plus=?, machine_id=?, + sticker_color=? WHERE id=?""", ( ocr_result.get("engine"), @@ -875,6 +919,7 @@ async def reprocess_photo( ocr_result.get("match_5dash6"), ocr_result.get("match_5plus"), machine_id, + ocr_result.get("sticker_color"), photo_id, ), ) @@ -892,6 +937,54 @@ async def reprocess_photo( } +@app.post("/api/photos/{photo_id}/reset") +async def reset_photo(photo_id: int): + """Delete a photo record and its file, allowing re-upload as new.""" + conn = _get_photos_db() + try: + row = conn.execute( + "SELECT saved_as FROM photos WHERE id = ?", (photo_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Photo not found") + + # Delete the file from disk + filepath = UPLOADS / row["saved_as"] + if filepath.exists(): + filepath.unlink() + + conn.execute("DELETE FROM photos WHERE id = ?", (photo_id,)) + conn.commit() + finally: + conn.close() + + return {"ok": True, "reset": photo_id, "message": "Photo entry deleted. Re-upload to process as new."} + + +@app.delete("/api/photos/{photo_id}") +async def delete_photo(photo_id: int): + """Delete a photo record and its file from disk.""" + conn = _get_photos_db() + try: + row = conn.execute( + "SELECT saved_as FROM photos WHERE id = ?", (photo_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Photo not found") + + # Delete the file from disk + filepath = UPLOADS / row["saved_as"] + if filepath.exists(): + filepath.unlink() + + conn.execute("DELETE FROM photos WHERE id = ?", (photo_id,)) + conn.commit() + finally: + conn.close() + + return {"ok": True, "deleted": photo_id} + + @app.get("/api/lookup") async def lookup_asset(machine_id: str = ""): """Look up an asset by machine_id in the canteen assets database.""" diff --git a/static/index.html b/static/index.html index c0ee528..33ad673 100644 --- a/static/index.html +++ b/static/index.html @@ -374,20 +374,78 @@ function loadPreviousPhotos() { section.style.display = 'block'; document.getElementById('prevCount').textContent = data.photos.length; div.innerHTML = data.photos.map(p => renderPrevCard(p)).join(''); + // Init maps and asset lookups after rendering + data.photos.forEach(p => { + if (p.gps_lat && p.gps_lng && p.machine_id) { + initPrevMap(p); + lookupPrevAsset(p); + } + }); }) .catch(() => {}); } +function initPrevMap(p) { + const el = document.getElementById('prevMap' + p.id); + if (!el || typeof L === 'undefined') return; + // Clear any existing map + el.innerHTML = ''; + el.style.background = 'var(--card2)'; + try { + const map = L.map(el, { zoomControl: true }).setView([p.gps_lat, p.gps_lng], 16); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OSM', maxZoom: 19 + }).addTo(map); + const label = p.machine_id ? '' + esc(p.machine_id) + '' : '📸 Photo location'; + L.marker([p.gps_lat, p.gps_lng]) + .addTo(map) + .bindPopup(label + '
📍 ' + Number(p.gps_lat).toFixed(5) + ', ' + Number(p.gps_lng).toFixed(5)); + setTimeout(() => { try { map.invalidateSize(); } catch(e) {} }, 400); + // Store map ref for later cleanup + if (!window._prevMaps) window._prevMaps = {}; + window._prevMaps[p.id] = map; + } catch (e) { /* ignore map errors */ } +} + +function lookupPrevAsset(p) { + const el = document.getElementById('prevAsset' + p.id); + const infoEl = document.getElementById('prevMachineInfo' + p.id); + if (!el) return; + fetch('/api/lookup?machine_id=' + encodeURIComponent(p.machine_id)) + .then(r => r.json()) + .then(data => { + if (data.found) { + const a = data.asset; + el.innerHTML = '🆔 ' + esc(a.machine_id) + ' · ' + esc(a.name) + '' + + (a.category ? ' · 📦 ' + esc(a.category) : '') + + (a.status === 'active' ? ' 🟢 Active' : ''); + if (infoEl) { + let info = ''; + if (a.building_name) info += '🏢 ' + esc(a.building_name) + ' '; + if (a.floor) info += '📶 Floor ' + esc(a.floor) + ' '; + if (a.room) info += '🚪 ' + esc(a.room) + ' '; + if (a.address) info += '🏠 ' + esc(a.address); + if (a.make || a.model) info += '🔧 ' + esc(a.make || '') + ' ' + esc(a.model || ''); + infoEl.innerHTML = info ? info : '(No location details)'; + } + } else { + el.innerHTML = '⚠️ No asset match for ' + esc(p.machine_id) + ''; + } + }) + .catch(() => { el.innerHTML = '⚠️ Lookup failed'; }); +} + function renderPrevCard(p) { - const hasMatch = p.ocr_match_5dash6 ? 'matched' : 'no-match'; - const matchText = p.ocr_match_5dash6 || (p.ocr_match_5plus ? 'digits found' : 'no match'); + const hasMatch = p.machine_id ? 'matched' : 'no-match'; + const matchText = p.machine_id || (p.ocr_match_5plus ? 'digits found' : 'no match'); const engine = p.ocr_engine || 'tesseract'; - const engCls = engine === 'llm' || engine === 'llm_batch' ? 'llm' : 'tesseract'; + const engCls = engine === 'llm' || engine === 'llm_batch' ? 'llm' : engine === 'google' ? 'llm' : 'tesseract'; const time = p.created_at ? new Date(p.created_at + 'Z').toLocaleString() : ''; const colorHtml = p.sticker_color ? `` : ''; - return `
+ const hasCoords = p.gps_lat && p.gps_lng; + return `
${esc(p.orig_filename)} ${time} @@ -397,6 +455,7 @@ function renderPrevCard(p) { ${esc(matchText)} ${esc(engine)}${p.ocr_model ? ' ' + esc(p.ocr_model) : ''} ${colorHtml} + ${hasCoords ? ` 📍 ${Number(p.gps_lat).toFixed(4)}, ${Number(p.gps_lng).toFixed(4)}` : ''}
+ +
+ ${hasCoords && p.machine_id ? `
+
+
+
+
` : hasCoords ? `
+
+
` : ''}
`; } +async function resetPhoto(photoId) { + if (!confirm('Reset this entry? The photo will be deleted from the database and can be re-uploaded as new.')) return; + try { + const resp = await fetch(`/api/photos/${photoId}/reset`, { method: 'POST' }); + if (!resp.ok) throw new Error('Reset failed'); + loadPreviousPhotos(); + } catch (e) { + alert('Reset failed: ' + e.message); + } +} + +async function resetAndReupload(photoId) { + if (!photoId) { alert('No photo ID available'); return; } + if (!confirm('Reset this entry and re-process as fresh?')) return; + try { + await fetch(`/api/photos/${photoId}/reset`, { method: 'POST' }); + // Re-trigger the upload + uploadSelected(); + } catch (e) { + alert('Reset failed: ' + e.message); + } +} + async function reprocessPhoto(photoId) { const eng = document.getElementById('reEng' + photoId).value; const model = document.getElementById('reModel' + photoId).value; @@ -442,6 +533,17 @@ async function reprocessPhoto(photoId) { } } +async function deletePhoto(photoId) { + if (!confirm('Delete this entry and its file?')) return; + try { + const resp = await fetch(`/api/photos/${photoId}`, { method: 'DELETE' }); + if (!resp.ok) throw new Error('Delete failed'); + loadPreviousPhotos(); + } catch (e) { + alert('Delete failed: ' + e.message); + } +} + // --- File handling --- document.getElementById('fileInput').addEventListener('change', async function(e) { @@ -592,9 +694,10 @@ async function uploadSelected() { const resp = await fetch('/api/analyze' + getOcrParams(), { method: 'POST', body: fd }); const data = await resp.json(); - let html = ''; + let html = ''; if (data.duplicate) { - html += '
♻️ Already processed — skipped
'; + html += '
♻️ Already processed — skipped' + + '
'; } html += '
Saved as' + esc(data.saved_as) + '
'; html += '
File size' + data.file_size_kb + ' KB
';