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 `