Files
hermes-config/skills/mlops/inference/llama-cpp/references/structured-extraction.md
T
2026-07-12 10:17:17 -04:00

121 lines
5.2 KiB
Markdown

# 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);
}
```