initial commit
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# Structured Extraction from OCR/Messy Text
|
||||
|
||||
Pattern: use a small local LLM (1-3B params) to extract structured JSON from messy, semi-structured text — OCR output, logs, emails, forms.
|
||||
|
||||
## Model recommendation
|
||||
|
||||
**Qwen2.5-1.5B-Instruct Q4_K_M** (~1 GB) is excellent for this task class. It follows structured output instructions well at this size, runs at ~30 tok/s on CPU-only (4 threads), and loads in under 500ms. The 32K context window is overkill for extraction tasks; `-c 4096` is sufficient.
|
||||
|
||||
Other viable options at similar size: Llama-3.2-3B, Gemma-2-2B.
|
||||
|
||||
## Prompt template
|
||||
|
||||
The key insight: small models need explicit rules about what NOT to include, not just what TO include. The system prompt must call out specific contamination patterns.
|
||||
|
||||
```
|
||||
Extract appointment details from OCR text. Return ONLY valid JSON, no markdown.
|
||||
|
||||
RULES:
|
||||
- customerName: person name before phone number. Strip "RO" prefix. NEVER include advisor names.
|
||||
- customerPhone: 10 digits only, no dashes
|
||||
- customerEmail: actual email with @. If text after phone is advisor+opcode, leave empty.
|
||||
- vin: 17-char uppercase VIN
|
||||
- vehicleInfo: year + make + model
|
||||
- serviceType: work description after opcode
|
||||
- opCode: only bracketed code like [REP] — no advisor names
|
||||
- appointmentTime: 24h format (e.g. "09:00")
|
||||
- duration: integer minutes
|
||||
|
||||
JSON: {"appointments":[{"customerName":"","customerPhone":"","customerEmail":"","vin":"","vehicleInfo":"","serviceType":"","opCode":"","appointmentTime":"","duration":0}]}
|
||||
```
|
||||
|
||||
**Critical rules that fixed real failures**:
|
||||
- `NEVER include advisor names` — without this, the model puts "Rrahman Grajqevci [REP]" in the opCode field
|
||||
- `Strip "RO" prefix` — OCR text often has "RO Gary Bowers"; the model needs explicit instruction to drop RO
|
||||
- `If text after phone is advisor+opcode, leave empty` — prevents email field from catching advisor name + bracket pattern
|
||||
|
||||
## Temperature
|
||||
|
||||
Always use `temperature: 0` for extraction tasks. Any non-zero temperature introduces field hallucination risk.
|
||||
|
||||
## Markdown fence stripping
|
||||
|
||||
Small models reliably wrap JSON output in ``` fences even when told not to. Always strip client-side:
|
||||
|
||||
```javascript
|
||||
raw = raw.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '');
|
||||
```
|
||||
|
||||
## Field normalization
|
||||
|
||||
Post-extraction normalization avoids subtle bugs:
|
||||
|
||||
```javascript
|
||||
appointments = parsed.appointments.map(a => ({
|
||||
customerName: a.customerName || '',
|
||||
customerPhone: (a.customerPhone || '').replace(/\D/g, ''), // digits only
|
||||
customerEmail: a.customerEmail || '',
|
||||
vin: (a.vin || '').toUpperCase(),
|
||||
vehicleInfo: a.vehicleInfo || '',
|
||||
serviceType: a.serviceType || '',
|
||||
appointmentTime: a.appointmentTime || '',
|
||||
duration: parseInt(a.duration) || 60,
|
||||
notes: a.opCode ? 'RO: ' + a.opCode : ''
|
||||
}));
|
||||
```
|
||||
|
||||
## OCR Preprocessing (Tesseract time-digit failures)
|
||||
|
||||
Small digits in narrow table columns (e.g., appointment times like 9:00, 12:00, 3:00) are frequently misread by Tesseract as identical values. Upscaling + sharpening before OCR significantly improves digit recognition.
|
||||
|
||||
### Browser-side Canvas preprocessing
|
||||
|
||||
Before passing the image to Tesseract.js, preprocess on a canvas:
|
||||
|
||||
```javascript
|
||||
const preprocessed = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Upscale 2x — critical for small text
|
||||
canvas.width = img.width * 2;
|
||||
canvas.height = img.height * 2;
|
||||
// Boost contrast and brightness
|
||||
ctx.filter = 'contrast(1.2) brightness(1.1)';
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
// Unsharp mask: overlay semi-transparent shifted copies
|
||||
ctx.filter = 'none';
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.drawImage(canvas, -1, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(canvas, 1, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(canvas, 0, -1, canvas.width, canvas.height);
|
||||
ctx.drawImage(canvas, 0, 1, canvas.width, canvas.height);
|
||||
ctx.globalAlpha = 1.0;
|
||||
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('toBlob failed')), 'image/png');
|
||||
};
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
// Then pass to Tesseract
|
||||
const { data: { text } } = await Tesseract.recognize(preprocessed, 'eng', { ... });
|
||||
```
|
||||
|
||||
**Why this works**: Tesseract's default DPI assumption is 70. Doubling the pixel dimensions effectively doubles the perceived DPI. The unsharp mask enhances digit edges that Tesseract's LSTM models rely on for character discrimination.
|
||||
|
||||
**Limitation**: If preprocessing alone doesn't fix time-digit recognition (narrow columns with very small fonts), the LLM cannot compensate — it only sees what Tesseract outputs. In that case, consider cropping the time column and OCR-ing it separately at 4x upscale.
|
||||
|
||||
## Fallback
|
||||
|
||||
Always have a deterministic fallback (regex parser, manual input) for when the LLM server is unreachable. The fetch should be wrapped in try/catch:
|
||||
|
||||
```javascript
|
||||
try {
|
||||
appointments = await llmParse(text);
|
||||
} catch (err) {
|
||||
console.warn('LLM unavailable, falling back:', err.message);
|
||||
appointments = ruleBasedParse(text);
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user