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:
@@ -23,6 +23,113 @@ from typing import Optional, Tuple
|
||||
DB_PATH = str(Path(__file__).parent / "assets.db")
|
||||
|
||||
|
||||
# ─── Universal identifier normalization (for photo→DB matching) ───────────
|
||||
|
||||
def normalize_identifier(raw: str) -> str:
|
||||
"""
|
||||
Normalize any asset identifier (serial number, barcode, equipment ID,
|
||||
connect ID, machine ID) for comparison.
|
||||
|
||||
- Strips leading label prefixes (S/N:, ID#, Machine ID:, Monyx ID, etc.)
|
||||
- Removes dots, dashes, spaces, slashes, colons
|
||||
- Uppercases
|
||||
- Returns just the alphanumeric core for matching
|
||||
|
||||
Examples:
|
||||
'2500.0100.0025534' → '2500010000255534'
|
||||
'201037BA00039' → '201037BA00039'
|
||||
'S/N: 2500.0100.0025534' → '2500010000255534'
|
||||
'ID# 4434331624226353' → '4434331624226353'
|
||||
'Monyx ID 48602143' → '48602143'
|
||||
'RY10006338' → 'RY10006338'
|
||||
'201037BA00039' → '201037BA00039'
|
||||
"""
|
||||
if not raw:
|
||||
return ''
|
||||
s = raw.strip().upper()
|
||||
# Strip common label prefixes
|
||||
s = re.sub(
|
||||
r'^(S/N|SN|SERIAL|SERIAL\s*NO|ID|UID|MACHINE\s*ID|MACHINE|'
|
||||
r'EQUIPMENT\s*ID|EQ\s*ID|ASSET\s*ID|ITEM|MODEL|PART\s*NO|'
|
||||
r'MONYX\s*ID|PROPERTY\s*OF|BARCODE)\s*[:=#]\s*',
|
||||
'', s, flags=re.IGNORECASE
|
||||
)
|
||||
# Strip leading non-alphanumeric (leftover label debris)
|
||||
s = re.sub(r'^[^A-Z0-9]+', '', s)
|
||||
# Remove all non-alphanumeric (dots, dashes, spaces, etc.)
|
||||
s = re.sub(r'[^A-Z0-9]', '', s)
|
||||
return s
|
||||
|
||||
|
||||
def find_asset_by_normalized_id(db_path: str, normalized: str) -> list:
|
||||
"""
|
||||
Search assets.db for any asset whose serial_number, machine_id, connect_id,
|
||||
equipment_id, or barcode matches the given normalized identifier.
|
||||
|
||||
Returns a list of matching rows (dicts).
|
||||
"""
|
||||
if not normalized or len(normalized) < 3:
|
||||
return []
|
||||
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("""
|
||||
SELECT id, machine_id, name, serial_number, connect_id, equipment_id,
|
||||
barcode, make, model, category
|
||||
FROM assets
|
||||
WHERE replace(replace(replace(replace(upper(serial_number), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
||||
OR replace(replace(replace(replace(upper(connect_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
||||
OR replace(replace(replace(replace(upper(equipment_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
||||
OR replace(replace(replace(replace(upper(machine_id), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
||||
OR replace(replace(replace(replace(upper(barcode), '-', ''), '.', ''), ' ', ''), '/', '') = ?
|
||||
-- Connect-ID suffix match (last 7+ digits → equipment_id suffix)
|
||||
""", (normalized, normalized, normalized, normalized, normalized)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def find_assets_by_scanned_text(db_path: str, raw_text: str) -> list:
|
||||
"""
|
||||
Given raw OCR text from a label photo, extract all plausible identifiers
|
||||
and search the DB for matches. Returns list of (normalized, field, asset) tuples.
|
||||
"""
|
||||
if not raw_text:
|
||||
return []
|
||||
|
||||
results = []
|
||||
|
||||
# 1. Try each line as a potential identifier
|
||||
lines = raw_text.strip().split('\n')
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or len(line) < 4:
|
||||
continue
|
||||
norm = normalize_identifier(line)
|
||||
if len(norm) >= 4:
|
||||
matches = find_asset_by_normalized_id(db_path, norm)
|
||||
for m in matches:
|
||||
results.append((norm, line.strip(), m))
|
||||
|
||||
# 2. Also try individual number-like tokens on each line (space-separated values on a line)
|
||||
for line in lines:
|
||||
tokens = re.findall(r'[A-Z0-9]{4,}', line.upper())
|
||||
for token in tokens:
|
||||
if len(token) >= 4:
|
||||
matches = find_asset_by_normalized_id(db_path, token)
|
||||
for m in matches:
|
||||
results.append((token, line.strip(), m))
|
||||
|
||||
# Deduplicate by asset id
|
||||
seen = set()
|
||||
unique = []
|
||||
for norm, src, asset in results:
|
||||
if asset['id'] not in seen:
|
||||
seen.add(asset['id'])
|
||||
unique.append({'normalized': norm, 'source_text': src, 'asset': asset})
|
||||
return unique
|
||||
|
||||
|
||||
# ─── Character substitution (human entry errors) ──────────────────────────
|
||||
|
||||
def normalise_serial(sn: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user