Files
2026-07-12 10:17:17 -04:00

155 lines
7.5 KiB
Markdown

# OCR Rule-Based Parser for Appointment Extraction
Full code of `parseWithRules()` from `appointments.html` inline script, with test cases. Built to replace a DeepSeek API call with zero-dependency CPU parsing.
## Design
Layered extraction order (5 phases now):
**Phase 0 — Extract header date BEFORE stripping**: Screenshots often have the appointment date in the top-middle header (e.g., "Monday, Jun 09, 2026"). Scan the first ~15 lines for a date pattern BEFORE Phase 1 strips metadata lines. Save as `headerDate`. After all appointments are parsed, apply `headerDate` as the fallback for any appointment missing its own date.
**Phases 1-4** (unchanged):
1. Split blocks by blank lines
2. Strip separator lines (`---`, `===`) and short ALL-CAPS headers (`SCHEDULE`, `NAME`)
3. Table detection: if block has 2+ lines and header line contains `name|phone|date|time|service|vehicle|vin`, parse each line individually
4. Per-block: extract phone → VIN → date → time → duration (replacing matches with single space to preserve column gaps)
5. Split remaining on `\s{2,}` (multi-space column boundaries) — if that fails, fall back to single-space word heuristics
6. Name = first consecutive capitalized words without digits. Vehicle = chunk containing year (`19xx`/`20xx`) or known make. Service = everything else.
## Key Patterns
| Field | Regex | Notes |
|-------|-------|-------|
| Phone | `\(?\d{3}\)?[\s.\-]*\d{3}[\s.\-]*\d{4}` | Captures `(555)123-4567`, `555.123.4567`, `555 123 4567` |
| VIN | `\b[A-HJ-NPR-Z0-9]{17}\b` | Excludes I, O, Q per VIN standard |
| Date (ISO) | `(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})` | `2024-06-15` |
| Date (US) | `(\d{1,2})[\/](\d{1,2})(?:\/(\d{4}))?` | `06/20/2024` or `06/20` |
| Date (named) | `(jan\|feb\|...)[a-z]*\s+(\d{1,2})(?:[,\s]+(\d{4}))?` | `Jan 15, 2024` |
| Time (HH:MM) | `(\d{1,2}):(\d{2})\s*(AM\|PM)?` | `9:00 AM`, `14:00` |
| Time (bare) | `(\d{1,2})\s*(AM\|PM)` | `8am`, `1PM` |
| Duration | `(\d+)\s*(?:min\|minutes?\|hrs?\|hours?)` | Converts `1 hour` → 60, `90 min` → 90 |
| Vehicle (year) | `\b(19\|20)\d{2}\b` | Detects year-in-text |
| Vehicle (make) | `ford\|chevy\|chevrolet\|toyota\|honda\|...` | Known make list |
## Verified Test Cases
### 1. Two appointments with double spacing
```
John Smith (555) 123-4567 2024-06-15 9:00 AM Oil Change 60 min 2018 Toyota Camry
Jane Doe (555) 987-6543 06/20/2024 2:00 PM Brake Inspection 90 min 2020 Honda Civic
```
→ 2 appts: `John Smith / 2024-06-15 09:00 / 2018 Toyota Camry / Oil Change`, `Jane Doe / 2024-06-20 14:00 / 2020 Honda Civic / Brake Inspection`
### 2. Headers and separator lines
```
SCHEDULE
--------
Robert Brown 865-555-1234 Jan 15, 2024 8:00am Tire Rotation 1 hour Toyota Tacoma
Sarah Wilson (423)555-6789 03/01/2024 1:30PM AC Repair 2 hours Jeep Grand Cherokee
```
→ 2 appts (headers stripped): `Robert Brown / 2024-01-15 08:00 / Toyota Tacoma / Tire Rotation`, `Sarah Wilson / 2024-03-01 13:30 / Jeep Grand Cherokee / AC Repair`
### 3. Single appointment
```
Mike Johnson 2024-07-01 10:30 AM Transmission Fluid 2022 Ford F-150
```
→ 1 appt: `Mike Johnson / 2024-07-01 10:30 / 2022 Ford F-150 / Transmission Fluid`
### 4. Table format with header row
```
Name Phone Date Time Service Vehicle
John Smith 555-123-4567 2024-06-15 9:00 AM Oil Change 2018 Toyota Camry
Jane Doe 555-987-6543 06/20/2024 2:00 PM Brake Inspection 2020 Honda Civic
```
→ 2 appts (header stripped, rows parsed): correct fields for both
### 5. Screenshot with header date — no per-appointment dates
```
Monday, Jun 09, 2026
8:00 AM 1.5 hrs John Smith 555-123-4567 Oil Change 2018 Toyota Camry
10:30 AM 1 hr Jane Doe 555-987-6543 Brake Inspection 2020 Honda Civic
```
→ 2 appts, both get `appointmentDate: "2026-06-09"` from Phase 0 header extraction. The "Monday Jun 09" line is extracted before Phase 1 strips it.
## Phase 0 Header Date + Phase 4 Fallback (added 2026-06-09)
**Problem**: Screenshots have the date in the top-middle header (e.g., "Monday, Jun 09, 2026"), but Phase 1 strips it as metadata before any appointments are parsed. Each appointment ends up with no date.
**Solution**:
- **Phase 0** (before Phase 1): Scan first ~15 lines with `headerDateRe` for a date pattern, call `parseDate()`, store as `headerDate`.
- **Phase 4** (applied at BOTH return points — main return and table fallback): If `headerDate` exists, any appointment without `appointmentDate` gets it assigned.
```javascript
// Phase 0
var headerDate = null;
var headerLines = t.split('\n').slice(0, 15);
var headerDateRe = /\b(?:(?:Mon|Tue|...)[,\s]+)?(?:Jan|Feb|...)[a-z]*\s+\d{1,2}(?:[,\s]+\d{4})?\b|.../i;
for (var hi = 0; hi < headerLines.length; hi++) {
var hm = headerLines[hi].match(headerDateRe);
if (hm) { var hd = parseDate(hm[0]); if (hd) { headerDate = hd; break; } }
}
// Phase 4 (before each return)
if (headerDate) {
appointments.forEach(function(a) {
if (!a.appointmentDate) a.appointmentDate = headerDate;
});
}
```
**Critical**: Apply at ALL return points. The table fallback (`lines.forEach(...); return appointments;`) has its own early return that bypasses the main Phase 4 block. Add the headerDate fallback guard before that return too.
## Service Word Blocklist for Name Extraction (added 2026-06-09)
**Problem**: Cross-block name carry (Step 10) picks up trailing all-caps words from `serviceType` and treats them as the next appointment's customer name. Words like "SERVICE", "REPAIR", "CHECK" get carried as names.
**Solution — `isServiceWord()` helper**:
```javascript
var serviceWords = {SERVICE:1,REPAIR:1,CHECK:1,MAINTENANCE:1,INSPECTION:1,DIAGNOSTIC:1,
DIAG:1,REPLACE:1,REPLACEMENT:1,INSTALL:1,REMOVE:1,ADJUST:1,ALIGNMENT:1,ROTATION:1,
BALANCE:1,FLUSH:1,DRAIN:1,FILL:1,TUNE:1,UP:1,ESTIMATE:1,WAITING:1,APPOINTMENT:1,
REQUEST:1,CUSTOMER:1,VEHICLE:1,ADVISOR:1,TECHNICIAN:1,MECHANIC:1};
function isServiceWord(w) {
w = w.toUpperCase().replace(/[^A-Z]/g,'');
return serviceWords[w] || w.length > 12;
}
```
**Applied in three places**:
1. **parseOne() entry** — validate `carryName` before using it:
```javascript
if (carryName) {
if (isServiceWord(carryName)) carryName = null;
else appt.customerName = carryName;
}
```
2. **Step 7 name extraction** — skip parts that are service words, year-prefixed vehicle descriptions, or long all-caps:
```javascript
var nameIdx = 0;
while (nameIdx < parts.length && (
isServiceWord(parts[nameIdx]) ||
/^\d{4}\s/.test(parts[nameIdx]) ||
/^[A-Z\d\s\-]{6,}$/.test(parts[nameIdx])
)) { nameIdx++; }
```
3. **Step 10 cross-block carry** — reject service words before carrying:
```javascript
if (tn && tn[1].length > 3 && tn[1].length < 20 && !isServiceWord(tn[1])) { ... }
```
## Pitfalls Encountered During Development
1. **Whitespace collapse order is critical**: Collapsing `\s+` to single space BEFORE splitting on `\s{2,}` destroys column boundaries. Must split on multi-spaces first, then clean each part.
2. **Bracket mismatch from refactoring**: Extracting inline code into a `parseBlock()` helper left a leftover `});` from the old `blocks.forEach()` closure. Caused `SyntaxError: Unexpected token ')'`.
3. **Dead function declarations**: Two `handleFile` declarations in same scope — second silently overwrites first. Always delete dead code.
4. **Global dependency gaps**: Inline scripts need `window.closeModal`, `window.escapeHtml`, `window.batchCreateAppointments` — module exports aren't accessible.