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.
This commit is contained in:
2026-04-17 12:12:26 +03:00
parent 1ea7a48912
commit 26b38ea27c
2 changed files with 86 additions and 17 deletions

View File

@@ -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 - <field name> : 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"
"[<size_or_length>] <type> <vendor> <connector> <part_number>\n"
" <size_or_length>: GB/TB for storage, meters for cables (e.g., '2m', '256GB', '2TB'). Omit diameter/mm measurements.\n"
" <type>: DDR3/DDR4/DDR5/SSD/HDD/NVMe/SAS/SATA/Patchcord/Fiber/Cable/Transceiver etc.\n"
" <vendor>: HP/HPE/Dell/Samsung/Cisco/Lenovo/Hynix etc.\n"
" <connector>: RJ45/LC-LC/MPO/U.3/SATA/SAS/LC/ST etc. Omit if N/A.\n"
" <part_number>: 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

View File

@@ -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