feat: add normalized matching for label photos → asset DB lookup

- normalize_identifier() strips dots/dashes/prefixes, keeps alphanumeric
- find_asset_by_normalized_id() searches serial_number, connect_id,
  equipment_id, barcode with normalized comparison
- /api/ocr now returns matched_assets in addition to legacy machine_id
- New /api/match-text endpoint for client-side text matching
- scripts/match_label_photo.py CLI tool for OCR + DB matching
- Vision model fixed (mimo-v2-omni at opencode.ai, was using
  truncated placeholder key)
This commit is contained in:
2026-05-29 08:37:06 -04:00
parent e10e226743
commit 8022c77b70
3 changed files with 457 additions and 20 deletions
+131 -20
View File
@@ -30,6 +30,9 @@ except ImportError:
import piexif
from PIL import Image as PILImage
# ─── Asset matcher (photo OCR → DB lookup) ─────────────────────────────────
from classify_makes import normalize_identifier, find_asset_by_normalized_id
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
@@ -2212,38 +2215,77 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
if not saved_path:
ocr_path.unlink(missing_ok=True)
# Build response — search for XXXXX-XXXXXX pattern (5 digits - 6 digits or more)
match = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
result: dict = {}
# Build response — search for identifiers in the OCR text
result: dict = {
"raw_text": text.strip()[:1000],
}
# 1. Legacy pattern: XXXXX-XXXXXX (5 digits - 6+ digits = Connect ID)
match = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
if match:
full_match = match.group(0)
digits_only = re.sub(r"\D", "", full_match)
machine_id = digits_only[-5:]
result = {
"machine_id": machine_id,
"raw_text": text.strip()[:500],
"raw_match": full_match,
"confidence": "high",
}
result["machine_id"] = machine_id
result["raw_match"] = full_match
result["confidence"] = "high"
else:
# Try looser: any 5+ digit number, take the last 5 digits
loose = re.search(r"(\d{5,})", text)
if loose:
digits = loose.group(1)
machine_id = digits[-5:] if len(digits) > 5 else digits
result = {
"machine_id": machine_id,
"raw_text": text.strip()[:500],
"confidence": "low",
}
result["machine_id"] = machine_id
result["confidence"] = "low"
else:
result = {
"machine_id": None,
"raw_text": text.strip()[:500],
"confidence": "none",
"detail": "No machine ID pattern found in image. Try again with better lighting.",
}
result["machine_id"] = None
result["confidence"] = "none"
result["detail"] = "No machine ID pattern found in image. Try again with better lighting."
# 2. Cross-reference OCR text against DB — find matched assets by
# serial_number, connect_id, equipment_id, or barcode
db_path = DB_PATH
db_matches = []
seen_ids = set()
for line in text.strip().split('\n'):
line = line.strip()
if not line or len(line) < 4:
continue
# Try full line
norm = normalize_identifier(line)
if norm and len(norm) >= 4:
assets = find_asset_by_normalized_id(db_path, norm)
for a in assets:
if a['id'] not in seen_ids:
seen_ids.add(a['id'])
db_matches.append({
"asset_id": a['id'],
"machine_id": a['machine_id'],
"name": a['name'],
"serial_number": a['serial_number'],
"matched_on": norm,
"source_text": line,
})
# Try individual tokens on the line
tokens = re.findall(r'[A-Za-z0-9]{4,}', line)
for token in tokens:
norm = normalize_identifier(token)
if norm and len(norm) >= 4:
assets = find_asset_by_normalized_id(db_path, norm)
for a in assets:
if a['id'] not in seen_ids:
seen_ids.add(a['id'])
db_matches.append({
"asset_id": a['id'],
"machine_id": a['machine_id'],
"name": a['name'],
"serial_number": a['serial_number'],
"matched_on": norm,
"source_text": token,
})
if db_matches:
result["matched_assets"] = db_matches
if exif_gps:
result["exif_gps"] = exif_gps
@@ -2273,6 +2315,75 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
return result
# ─── Match raw text against DB (for barcode scanner / client-side OCR) ────
@app.post("/api/match-text", status_code=200)
async def match_text(text: str = Form(...)):
"""
Accept raw text (from barcode scanner, QR reader, or client-side vision),
normalize it, and search the DB for matching assets.
Returns matched_assets if any found.
"""
if not text or len(text.strip()) < 4:
return {"matched_assets": [], "detail": "Text too short to match"}
db_path = DB_PATH
db_matches = []
seen_ids = set()
raw = text.strip()
for line in raw.split('\n'):
line = line.strip()
if not line or len(line) < 4:
continue
norm = normalize_identifier(line)
if norm and len(norm) >= 4:
assets = find_asset_by_normalized_id(db_path, norm)
for a in assets:
if a['id'] not in seen_ids:
seen_ids.add(a['id'])
db_matches.append({
"asset_id": a['id'],
"machine_id": a['machine_id'],
"name": a['name'],
"serial_number": a['serial_number'],
"connect_id": a['connect_id'],
"make": a['make'],
"model": a['model'],
"category": a['category'],
"matched_on": norm,
"source_text": line,
})
# Also check individual tokens
tokens = re.findall(r'[A-Za-z0-9]{4,}', line)
for token in tokens:
norm = normalize_identifier(token)
if norm and len(norm) >= 4:
assets = find_asset_by_normalized_id(db_path, norm)
for a in assets:
if a['id'] not in seen_ids:
seen_ids.add(a['id'])
db_matches.append({
"asset_id": a['id'],
"machine_id": a['machine_id'],
"name": a['name'],
"serial_number": a['serial_number'],
"connect_id": a['connect_id'],
"make": a['make'],
"model": a['model'],
"category": a['category'],
"matched_on": norm,
"source_text": token,
})
return {
"raw_text": raw[:1000],
"matched_assets": db_matches,
"match_count": len(db_matches),
}
# ─── T4: Connect Label — unified photo + OCR + GPS endpoint ─────────────────
class ConnectLabelRequest(BaseModel):