feat: wire vision/OCR results back into asset records

- Auto-save photo_path to matched assets when photo uploaded via /api/ocr
- Auto-extract and save serial_number from OCR text to blank-serial matched assets
- Add _extract_serial_from_text helper for serial number pattern matching
- Create scripts/backfill_vision_photos.py for batch-processing unlinked photos
- Add docs/OCR_PIPELINE.md documenting the full OCR/vision pipeline
- Fix test DB schema: add connect_id, equipment_id, barcode, customer_name
- Fix OCR test assertions to match actual endpoint behavior
This commit is contained in:
2026-05-29 10:03:51 -04:00
parent 99cef94153
commit 17b870e4cc
4 changed files with 619 additions and 21 deletions
+82 -1
View File
@@ -207,6 +207,9 @@ def _create_v2_tables(conn: sqlite3.Connection):
status TEXT NOT NULL DEFAULT 'active',
make TEXT DEFAULT '',
model TEXT DEFAULT '',
connect_id TEXT DEFAULT '',
equipment_id TEXT DEFAULT '',
barcode TEXT DEFAULT '',
address TEXT DEFAULT '',
building_name TEXT DEFAULT '',
building_number TEXT DEFAULT '',
@@ -226,7 +229,11 @@ def _create_v2_tables(conn: sqlite3.Connection):
disney_park TEXT DEFAULT NULL,
is_disney INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
company TEXT DEFAULT '',
customer_name TEXT DEFAULT '',
place TEXT DEFAULT '',
location_area TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS service_entrances (
@@ -1996,6 +2003,35 @@ def _extract_gps_from_bytes(image_bytes: bytes) -> dict | None:
return {"lat": lat, "lng": lng}
def _extract_serial_from_text(text: str) -> str | None:
"""Try to extract a plausible serial number from OCR/vision text.
Looks for explicit label prefixes (S/N, Serial#, ID#, Machine ID)
and returns the value portion. Avoids false-positive matches on
short numbers (< 6 chars) that are likely Connect-ID fragments.
Returns the raw serial value (with punctuation preserved) or None.
"""
if not text:
return None
patterns = [
r'(?:S/N|SN|SERIAL\s*NO|SERIAL|SERIAL\s*#)\s*[:=#]?\s*([A-Za-z0-9.\-/]{6,})',
r'(?:EQUIPMENT\s*ID|EQ\s*ID|ASSET\s*ID)\s*[:=]?\s*([A-Za-z0-9.\-/]{6,})',
r'(?:MODEL\s*NO|MODEL\s*#|PART\s*NO|P/N)\s*[:=]?\s*([A-Za-z0-9.\-/]{6,})',
]
for pat in patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
val = m.group(1).strip().rstrip('.')
# Filter out pure-number strings that look like Connect IDs (XXXXX-XXXXXX)
if re.match(r'^\d{4,}$', val.replace('-', '').replace('.', '')):
continue # Probably a Connect ID, not a serial
clean = sum(1 for c in val if c.isalnum())
if clean >= 6:
return val
return None
def _save_upload_bytes(contents: bytes, filename: str | None, subdir: str, allowed_exts: set, max_size: int) -> str:
"""Save raw bytes to uploads/{subdir}/ with a UUID filename.
@@ -2347,6 +2383,51 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
if db_matches:
result["matched_assets"] = db_matches
# Auto-save photo_path to matched assets when photo was saved permanently
if saved_path and db_matches:
try:
conn = get_db()
updated_count = 0
for m in db_matches:
aid = m["asset_id"]
existing = conn.execute(
"SELECT photo_path FROM assets WHERE id = ?",
(aid,),
).fetchone()
if existing and not existing["photo_path"]:
conn.execute(
"UPDATE assets SET photo_path = ?, updated_at = datetime('now') WHERE id = ?",
(saved_path, aid),
)
updated_count += 1
conn.commit()
conn.close()
if updated_count > 0:
result["photo_saved"] = updated_count
except Exception:
pass # Non-critical — don't fail OCR if photo save fails
# Auto-update serial_number on matched assets from OCR text
if db_matches and any(m.get("serial_number") == "" for m in db_matches):
serial = _extract_serial_from_text(text)
if serial:
try:
conn = get_db()
updated_count = 0
for m in db_matches:
if m.get("serial_number") == "":
conn.execute(
"UPDATE assets SET serial_number = ?, updated_at = datetime('now') WHERE id = ?",
(serial, m["asset_id"]),
)
updated_count += 1
conn.commit()
conn.close()
if updated_count > 0:
result["serial_saved"] = serial
except Exception:
pass # Non-critical
if exif_gps:
result["exif_gps"] = exif_gps