diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b657f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +uploads/ +__pycache__/ +*.py[cod] diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc index 0616400..f761353 100644 Binary files a/__pycache__/server.cpython-311.pyc and b/__pycache__/server.cpython-311.pyc differ diff --git a/server.py b/server.py index 148acc3..6486dd1 100644 --- a/server.py +++ b/server.py @@ -1,8 +1,8 @@ """EXIF + OCR test backend — validate that GPS survives upload pipeline.""" -import io, json, re, uuid, sqlite3 +import io, json, os, re, uuid, sqlite3, urllib.request from pathlib import Path -from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile from fastapi.staticfiles import StaticFiles from PIL import Image as PILImage @@ -20,6 +20,11 @@ try: except ImportError: HAS_TESSERACT = False +# === 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") + UPLOADS = Path(__file__).parent / "uploads" UPLOADS.mkdir(exist_ok=True) CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db" @@ -114,6 +119,70 @@ def run_ocr(image_bytes: bytes) -> dict: tmp_path.unlink(missing_ok=True) +def run_ocr_llm(image_bytes: bytes, model: str | None = None) -> dict: + """Run OCR via an LLM vision model on OpenCode Go. + + Falls back to Tesseract if the API key is missing or the call fails. + Returns the same shape as run_ocr() with an additional ''engine'' field. + """ + if not OPENCODE_GO_KEY: + result = run_ocr(image_bytes) + result["engine"] = "tesseract" + result["llm_fallback_reason"] = "no_api_key" + return result + + import base64 + + model = model or LLM_OCR_MODEL + b64 = base64.b64encode(image_bytes).decode() + + body = json.dumps({ + "model": model, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "Read ALL text and numbers visible in this photo. " + "Return the exact text shown, nothing else."}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}} + ] + }], + "max_tokens": 200, + }).encode() + + req = urllib.request.Request( + f"{OPENCODE_GO_BASE}/chat/completions", + data=body, + headers={ + "Authorization": f"Bearer {OPENCODE_GO_KEY}", + "Content-Type": "application/json", + "User-Agent": "Hermes-Agent/1.0", + }, + ) + + try: + resp = urllib.request.urlopen(req, timeout=60) + result = json.loads(resp.read()) + text = result["choices"][0]["message"]["content"].strip() + except Exception as exc: + # Fallback to Tesseract + result = run_ocr(image_bytes) + result["engine"] = "tesseract" + result["llm_fallback_reason"] = str(exc)[:200] + return result + + match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text) + match_5plus = re.search(r"(\d{5,})", text) + + return { + "available": True, + "engine": "llm", + "llm_model": model, + "raw_text": text[:500], + "match_5dash6": match_5dash.group(0) if match_5dash else None, + "match_5plus": match_5plus.group(0) if match_5plus else None, + } + + def lookup_machine_id(machine_id: str) -> dict | None: """Look up an asset by machine_id. Returns asset dict or None.""" conn = _get_canteen_db() @@ -150,8 +219,17 @@ def lookup_machine_id(machine_id: str) -> dict | None: @app.post("/api/analyze") -async def analyze_photo(file: UploadFile = File(...)): - """Upload a photo, get back EXIF + OCR results.""" +async def analyze_photo( + file: UploadFile = File(...), + ocr_engine: str = Query(default="tesseract"), + ocr_model: str = Query(default=""), +): + """Upload a photo, get back EXIF + OCR results. + + Query params: + - ocr_engine: ''tesseract'' (default) or ''llm'' + - ocr_model: model name override (e.g. ''mimo-v2-omni'', ''glm-5.1'') + """ contents = await file.read() file_size = len(contents) @@ -162,7 +240,14 @@ async def analyze_photo(file: UploadFile = File(...)): (UPLOADS / fname).write_bytes(contents) exif_result = extract_exif(contents) - ocr_result = run_ocr(contents) if HAS_TESSERACT else {"available": False, "text": ""} + + if ocr_engine == "llm": + ocr_result = run_ocr_llm(contents, ocr_model or None) + elif HAS_TESSERACT: + ocr_result = run_ocr(contents) + ocr_result["engine"] = "tesseract" + else: + ocr_result = {"available": False, "text": "", "engine": "none"} machine_id = None asset = None @@ -183,9 +268,17 @@ async def analyze_photo(file: UploadFile = File(...)): @app.post("/api/bulk-process") -async def bulk_process(files: list[UploadFile] = File(...)): +async def bulk_process( + files: list[UploadFile] = File(...), + ocr_engine: str = Query(default="tesseract"), + ocr_model: str = Query(default=""), +): """Process multiple photos: OCR each, extract EXIF GPS, look up matching assets. + Query params: + - ocr_engine: ''tesseract'' (default) or ''llm'' + - ocr_model: model name override + Returns a list of results, each with: - filename, exif (gps), ocr match, matched asset (if found) - needs_gps: true if asset exists AND has no coordinates AND photo has GPS @@ -208,7 +301,14 @@ async def bulk_process(files: list[UploadFile] = File(...)): (UPLOADS / fname).write_bytes(contents) exif_result = extract_exif(contents) - ocr_result = run_ocr(contents) if HAS_TESSERACT else {"available": False, "text": ""} + + if ocr_engine == "llm": + ocr_result = run_ocr_llm(contents, ocr_model or None) + elif HAS_TESSERACT: + ocr_result = run_ocr(contents) + ocr_result["engine"] = "tesseract" + else: + ocr_result = {"available": False, "text": "", "engine": "none"} has_gps = exif_result.get("gps") is not None if has_gps: diff --git a/static/index.html b/static/index.html index f3b925c..56a6b71 100644 --- a/static/index.html +++ b/static/index.html @@ -187,6 +187,20 @@ margin-top: 6px; } + /* OCR engine toggle */ + .ocr-toggle { + display: flex; align-items: center; gap: 8px; margin-bottom: 8px; + font-size: 12px; color: var(--text2); + } + .ocr-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; } + .ocr-toggle input[type="checkbox"] { accent-color: var(--accent); } + .engine-badge { + font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600; + background: var(--card2); color: var(--text2); display: inline-block; margin-left: 4px; + } + .engine-badge.llm { background: var(--accent); color: #fff; } + .engine-badge.tesseract { background: var(--card2); color: var(--text2); } + /* Bulk results */ #mapContainer { height: 250px; border-radius: var(--radius-sm); margin-bottom: 10px; display: none; } .bulk-card { @@ -263,6 +277,14 @@
+ +
+ +
+