# Plan: Two-Stage Detection (YOLO + PaddleOCR Value Selection)

## Context

Current inference flow: YOLO detects field region -> crop region -> PaddleOCR reads ALL text -> concatenate -> normalizer extracts value via regex.

Problem: When training labels include label+value (e.g., "Fakturadatum 2024-01-15"), the detected region contains both label text and value text. Currently all OCR tokens are concatenated, and normalizers must regex out the value. This works for most fields but is fragile.

Solution: After PaddleOCR returns individual text lines from the detected region, add a **value selection** step that picks the most likely value token(s) BEFORE sending to normalizer. This gives normalizers cleaner input and provides a more precise value bbox.

## Key Insight

PaddleOCR already returns individual `OCRToken` objects (text + bbox + confidence). The current code just concatenates them all (line 227 of `field_extractor.py`):

```python
raw_text = ' '.join(t.text for t in ocr_tokens)
```

The change: replace blind concatenation with field-aware token selection.

## Architecture

```
Current:
  YOLO bbox -> crop -> PaddleOCR -> [all tokens] -> concat -> normalizer

New:
  YOLO bbox -> crop -> PaddleOCR -> [all tokens] -> value_selector -> normalizer
                                         |                |
                                    individual         selected
                                    text lines         value token(s)
```

## Scope: Inference Only

| Pipeline Stage | Changed? | Reason |
|---|---|---|
| **Labeling** (autolabel, expansion) | NO | Expanded bbox gives YOLO strong visual patterns for generalization |
| **Training** (YOLO26s) | NO | Model learns field regions correctly with current labels |
| **Inference - Detection** (YOLO) | NO | Detection output is correct |
| **Inference - Extraction** (OCR -> text) | **YES** | Add ValueSelector between OCR tokens and normalizer |

This design works with ANY model -- tight-label model (15K docs) or expanded-label model (58 images). ValueSelector is model-agnostic.

## Files to Modify

| File | Change |
|------|--------|
| `packages/backend/backend/pipeline/value_selector.py` | **NEW** - Value selection logic per field type |
| `packages/backend/backend/pipeline/field_extractor.py` | Use ValueSelector in both extraction paths |
| `tests/pipeline/test_value_selector.py` | **NEW** - Tests for value selection |

## Implementation

### 1. New File: `value_selector.py`

Core class `ValueSelector` with method:

```python
def select_value_tokens(
    self,
    tokens: list[OCRToken],
    field_name: str
) -> list[OCRToken]:
```

Field-specific selection rules:

| Field | Strategy | Pattern |
|-------|----------|---------|
| InvoiceDate, InvoiceDueDate | Date pattern match | `\d{4}[-./]\d{2}[-./]\d{2}` or `\d{2}[-./]\d{2}[-./]\d{4}` or `\d{8}` |
| Amount | Number pattern match | `\d[\d\s]*[,.]\d{2}` (prefer tokens with comma/decimal) |
| Bankgiro | Giro pattern match | `\d{3,4}-\d{4}` or 7-8 consecutive digits |
| Plusgiro | Giro pattern match | `\d+-\d` or 2-8 digits |
| OCR | Longest digit sequence | Token with most consecutive digits (min 5) |
| InvoiceNumber | Exclude known labels | Remove tokens matching Swedish label keywords, keep rest |
| supplier_org_number | Org number pattern | `\d{6}-?\d{4}` |
| customer_number | Exclude labels | Remove label keywords, keep alphanumeric tokens |
| payment_line | Full concatenation | Keep all tokens (payment line needs full text) |

Label keyword exclusion list (Swedish):
```python
LABEL_KEYWORDS = {
    "fakturanummer", "fakturadatum", "forfallodag", "forfalldatum",
    "bankgiro", "plusgiro", "bg", "pg", "ocr", "belopp", "summa",
    "total", "att", "betala", "kundnummer", "organisationsnummer",
    "org", "nr", "datum", "nummer", "ref", "referens",
    "momsreg", "vat", "moms", "sek", "kr",
}
```

Selection algorithm:
1. Try field-specific pattern matching on individual tokens
2. If match found -> return matched token(s) only
3. If no match -> **fallback to ALL tokens** (current behavior, so we never lose data)

### 2. Modify: `field_extractor.py`

**OCR path** (`extract_from_detection`, line 224-228):

```python
# Before (current):
ocr_tokens = self.ocr_engine.extract_from_image(region)
raw_text = ' '.join(t.text for t in ocr_tokens)

# After:
ocr_tokens = self.ocr_engine.extract_from_image(region)
value_tokens = self._value_selector.select_value_tokens(ocr_tokens, field_name)
raw_text = ' '.join(t.text for t in value_tokens)
```

**PDF text path** (`extract_from_detection_with_pdf`, line 172-174):

```python
# Before (current):
matching_tokens.sort(key=lambda x: -x[1])
raw_text = ' '.join(t[0].text for t in matching_tokens)

# After:
matching_tokens.sort(key=lambda x: -x[1])
all_text_tokens = [OCRToken(text=t[0].text, bbox=t[0].bbox, confidence=1.0) for t in matching_tokens]
value_tokens = self._value_selector.select_value_tokens(all_text_tokens, field_name)
raw_text = ' '.join(t.text for t in value_tokens)
```

**Constructor**: Add `ValueSelector` instance.

### 3. Tests: `test_value_selector.py`

Test cases per field type:
- Date: "Fakturadatum 2024-01-15" -> selects "2024-01-15"
- Amount: "Belopp 1 234,56 kr" -> selects "1 234,56"
- Bankgiro: "BG: 123-4567" -> selects "123-4567"
- OCR: "OCR 1234567890" -> selects "1234567890"
- InvoiceNumber: "Fakturanr INV-2024-001" -> selects "INV-2024-001"
- Fallback: Unknown pattern -> returns all tokens (no data loss)

## Key Design Decisions

1. **Fallback to full text**: If value selection can't identify the value, return ALL tokens. This means the change can never make things worse than current behavior.

2. **ValueSelector is stateless**: Pure function, no side effects. Easy to test.

3. **No training changes**: Training labels stay as-is (expanded bboxes). Only inference pipeline changes.

4. **No normalizer changes**: Normalizers still work the same. They just get cleaner input.

## Verification

1. Run existing tests: `pytest tests/pipeline/ -v`
2. Run new tests: `pytest tests/pipeline/test_value_selector.py -v`
3. Manual validation: Run inference on a few invoices and compare raw_text before/after
4. Regression check: Ensure no field extraction accuracy drops on existing test documents
