From 26b38ea27c291045897b9c1dacb2d47e4a9900d8 Mon Sep 17 00:00:00 2001 From: Daniel Bedeleanu Date: Fri, 17 Apr 2026 12:12:26 +0300 Subject: [PATCH] feat: enhance OCR matching with fuzzy logic and refined AI prompt AI Prompt Improvements: - Standardized Item name format: [size] type vendor connector part_number - Clear examples: '5m Patchcord LC-LC', '256GB SSD Samsung SAS', '128GB DDR4 Hynix' - OCR key format: uppercase, space-separated tokens, includes variations/abbreviations - Excludes diameter/mm measurements from Item field - Allows 'HP'/'HPE', 'DDR'/'DDR4', 'NVMe'/'NVME' variations in OCR keys OCR Matching Logic: - Implemented Levenshtein distance fuzzy matching (allows 1-2 char differences) - Priority 0: Exact OCR key match (1000pts) + fuzzy match fallback (800pts) - Priority 2: Part number exact (200pts) + fuzzy match (150pts) - Priority 3: Token-based with fuzzy tolerance (50pts exact, 30pts fuzzy) - Removes whitespace for comparison (e.g., 'LCLC' matches 'LC-LC') Resolves OCR identification failures from minor text variations. --- backend/main.py | 33 ++++++++++++-------- frontend/app/page.tsx | 70 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/backend/main.py b/backend/main.py index 9e6c40eb..c9379b54 100644 --- a/backend/main.py +++ b/backend/main.py @@ -103,18 +103,27 @@ def startup_event(): try: # Default AI Prompt from User Request default_prompt = ( - "identify and summarise the minimal necessary information for a quick description if item. " - "I need the following output - : the result from you.\n" - "For any field, do not add comments in parenthesis. \n\n" - "Item: in three words type of this item\n" - "Type: what type of item is, like \"spare parts\", \"consumables\", \"patch cords\" etc.\n" - "Description: description (max 5 words)\n" - "Category: category, if any\n" - "Connector: connectors\n" - "Size: size or length\n" - "Color: color if useful\n" - "PartNr: part number if any\n" - "OCR: identification string for local OCR matching" + "Extract hardware specifications with PRECISE formatting.\n" + "For any field, do not add comments in parenthesis or measurement units in Item name.\n\n" + "ITEM FIELD FORMAT (Critical):\n" + "[] \n" + " : GB/TB for storage, meters for cables (e.g., '2m', '256GB', '2TB'). Omit diameter/mm measurements.\n" + " : DDR3/DDR4/DDR5/SSD/HDD/NVMe/SAS/SATA/Patchcord/Fiber/Cable/Transceiver etc.\n" + " : HP/HPE/Dell/Samsung/Cisco/Lenovo/Hynix etc.\n" + " : RJ45/LC-LC/MPO/U.3/SATA/SAS/LC/ST etc. Omit if N/A.\n" + " : PN only if visible on label. Omit serial numbers.\n" + "Examples: '5m Patchcord LC-LC' / '256GB SSD Samsung SAS' / '128GB DDR4 Hynix SK-234' / '2TB NVMe HP U.3'\n\n" + "TYPE: Item asset class (DDR3/SSD/NVMe/Patchcord/SFP etc.)\n" + "DESCRIPTION: Technical details max 5 words (e.g., 'High speed fiber optic cable'). Omit size/length here.\n" + "CATEGORY: Broad ecosystem (Memory, Storage, Network, Cabling, etc.)\n" + "CONNECTOR: Physical interface type (e.g., 'LC', 'RJ45', 'U.3')\n" + "SIZE: Capacity or length ONLY (e.g., '256GB', '2m')\n" + "COLOR: Physical color if distinguishing.\n" + "PartNr: Part number only (no serial numbers)\n" + "OCR: Robust matching key. Include: core type + vendor + key identifiers + common variations.\n" + " Example for '5m Patchcord LC-LC': 'PATCHCORD 5M LC LC CAT6 FIBER'\n" + " Include abbreviations: 'DDR4'/'DDR' or 'SSD'/'SSDS' or 'HP'/'HPE' or 'NVMe'/'NVME'\n" + " Format: ALL UPPERCASE, space-separated tokens, NO special chars. Skip serial numbers." ) # Wrap in JSON instructions for reliable parsing diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 24fdc9a6..3eb22bd8 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -41,6 +41,34 @@ function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +// Fuzzy string matching with Levenshtein distance +// Returns true if strings are similar enough (allowing 1-2 character differences) +function fuzzyMatch(str1: string, str2: string, maxDistance: number = 2): boolean { + const s1 = str1.toLowerCase().replace(/\s+/g, ''); + const s2 = str2.toLowerCase().replace(/\s+/g, ''); + + if (s1 === s2) return true; + if (Math.abs(s1.length - s2.length) > maxDistance) return false; + + // Levenshtein distance + const matrix: number[][] = Array(s2.length + 1).fill(null).map(() => Array(s1.length + 1).fill(0)); + for (let i = 0; i <= s1.length; i++) matrix[0][i] = i; + for (let j = 0; j <= s2.length; j++) matrix[j][0] = j; + + for (let j = 1; j <= s2.length; j++) { + for (let i = 1; i <= s1.length; i++) { + const indicator = s1[i - 1] === s2[j - 1] ? 0 : 1; + matrix[j][i] = Math.min( + matrix[j][i - 1] + 1, + matrix[j - 1][i] + 1, + matrix[j - 1][i - 1] + indicator + ); + } + } + + return matrix[s2.length][s1.length] <= maxDistance; +} + export default function Home() { const [mounted, setMounted] = useState(false); const [isOnline, setIsOnline] = useState(true); @@ -266,19 +294,51 @@ export default function Home() { const category = item.category.toLowerCase(); const ocrKey = (item.ocr_text || '').toLowerCase().replace(/[^a-z0-9\s/+-]/g, ' '); - // Priority 0: Exact OCR Key match (Heuristic provided by AI) - if (ocrKey && cleanText.includes(ocrKey)) score += 1000; + // Priority 0: OCR Key match (Heuristic provided by AI) with fuzzy tolerance + if (ocrKey) { + if (cleanText.includes(ocrKey)) { + score += 1000; // Exact match + } else { + // Fuzzy match for OCR keys (allows 2-char differences for robustness) + const ocrTokens = ocrKey.split(/[\s/+-]+/).filter(t => t.length >= 2); + const matchedTokens = ocrTokens.filter(token => { + return tokens.some(t => fuzzyMatch(t, token, 2)); + }); + if (matchedTokens.length >= Math.ceil(ocrTokens.length * 0.7)) { + score += 800; // Fuzzy match (70%+ token coverage) + } + } + } // Priority 1: Serial Number (Absolute match) if (sn && cleanText.includes(sn)) score += 500; - // Priority 2: Part Number (High confidence) - if (pn && cleanText.includes(pn)) score += 200; + // Priority 2: Part Number (High confidence, with fuzzy tolerance) + if (pn) { + if (cleanText.includes(pn)) { + score += 200; // Exact match + } else { + // Fuzzy match for part numbers (allows 1-char differences) + const pnTokens = pn.split(/[\s/+-]/).filter(t => t.length >= 2); + const matchedPnTokens = pnTokens.filter(pnToken => + tokens.some(t => fuzzyMatch(t, pnToken, 1)) + ); + if (matchedPnTokens.length >= Math.max(2, Math.ceil(pnTokens.length * 0.6))) { + score += 150; // Fuzzy match (60%+ token coverage) + } + } + } // Priority 3: Token based matching for PN (LC/UPC etc) if (pn) { const pnTokens = pn.split(/[\s/+-]/).filter(t => t.length >= 3); - pnTokens.forEach(t => { if (cleanText.includes(t)) score += 50; }); + pnTokens.forEach(t => { + if (cleanText.includes(t)) { + score += 50; + } else if (tokens.some(scanToken => fuzzyMatch(scanToken, t, 1))) { + score += 30; // Fuzzy token match + } + }); } // Priority 4: Name & Category