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