feat(data-entry): Add unified receipt form with OCR confidence tracking

New unified receipt creation system with:
- UnifiedReceiptForm component with inline OCR preview and confidence indicators
- Compact upload zone with drag-drop and camera support
- TVA and Payment fields with dynamic add/remove
- Supplier dual-field with autocomplete and OCR hint
- Receipt form sections with collapsible auxiliary data

Backend OCR improvements:
- Add confidence_tva and confidence_payment to extraction results
- Update TVA extraction to return confidence scores
- Include TVA (15%) and payment (10%) in overall_confidence calculation

Also includes:
- CSS design system rules documentation
- Port check helper function for service scripts
- Expanded design tokens documentation in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-01-08 21:48:37 +00:00
parent cc98d6f21f
commit b4a226409c
21 changed files with 4876 additions and 55 deletions

View File

@@ -901,12 +901,15 @@ def _merge_extractions(primary, secondary):
# Always use primary (docTR) - higher quality OCR
result.tva_entries = primary.tva_entries
result.tva_total = primary.tva_total
result.confidence_tva = getattr(primary, 'confidence_tva', 0.0)
elif primary.tva_entries:
result.tva_entries = primary.tva_entries
result.tva_total = primary.tva_total
result.confidence_tva = getattr(primary, 'confidence_tva', 0.0)
elif secondary.tva_entries:
result.tva_entries = secondary.tva_entries
result.tva_total = secondary.tva_total
result.confidence_tva = getattr(secondary, 'confidence_tva', 0.0)
# Other fields - prefer primary
result.receipt_number = primary.receipt_number or secondary.receipt_number
@@ -915,7 +918,13 @@ def _merge_extractions(primary, secondary):
result.partner_name = primary.partner_name or secondary.partner_name
result.address = primary.address or secondary.address
result.items_count = primary.items_count or secondary.items_count
result.payment_methods = primary.payment_methods or secondary.payment_methods
# Payment methods with confidence
if primary.payment_methods:
result.payment_methods = primary.payment_methods
result.confidence_payment = getattr(primary, 'confidence_payment', 0.0)
elif secondary.payment_methods:
result.payment_methods = secondary.payment_methods
result.confidence_payment = getattr(secondary, 'confidence_payment', 0.0)
result.suggested_payment_mode = getattr(primary, 'suggested_payment_mode', None) or getattr(secondary, 'suggested_payment_mode', None)
# Client fields
@@ -970,6 +979,11 @@ def _complement_extraction(primary, secondary):
if not primary.tva_entries and secondary.tva_entries:
primary.tva_entries = secondary.tva_entries
primary.tva_total = secondary.tva_total
primary.confidence_tva = getattr(secondary, 'confidence_tva', 0.0)
if not getattr(primary, 'payment_methods', None) and getattr(secondary, 'payment_methods', None):
primary.payment_methods = secondary.payment_methods
primary.confidence_payment = getattr(secondary, 'confidence_payment', 0.0)
if not primary.receipt_number and secondary.receipt_number:
primary.receipt_number = secondary.receipt_number
@@ -1024,6 +1038,8 @@ def _extraction_to_dict(extraction) -> dict:
"confidence_date": extraction.confidence_date,
"confidence_vendor": extraction.confidence_vendor,
"confidence_client": getattr(extraction, 'confidence_client', 0.0),
"confidence_tva": getattr(extraction, 'confidence_tva', 0.0),
"confidence_payment": getattr(extraction, 'confidence_payment', 0.0),
"overall_confidence": extraction.overall_confidence,
"raw_text": extraction.raw_text,
"ocr_engine": extraction.ocr_engine,

View File

@@ -13,8 +13,9 @@ Usage:
CUI_LIST = ["22891860"]
NAME_PATTERNS = ["LIDL", "LDL"]
def extract_tva_entries(self, text: str) -> List[dict]:
def extract_tva_entries(self, text: str) -> Tuple[List[dict], float]:
# Custom Lidl TVA extraction logic
# Returns (entries_list, confidence_score)
...
"""
@@ -331,7 +332,7 @@ class BaseStoreProfile(ABC):
# Extraction methods - override in subclasses as needed
# -------------------------------------------------------------------------
def extract_tva_entries(self, text: str) -> List[dict]:
def extract_tva_entries(self, text: str) -> Tuple[List[dict], float]:
"""
Extract TVA entries from receipt text - GENERIC implementation.
@@ -346,37 +347,53 @@ class BaseStoreProfile(ABC):
text: Raw OCR text from receipt
Returns:
List of dicts with keys: code, percent, amount
Tuple of (List of dicts with keys: code, percent, amount, confidence float)
"""
entries = []
max_confidence = 0.0
text_upper = text.upper()
# Step 1: Check for known non-VAT payer (by class flag or text detection)
if self.IS_NON_VAT_PAYER or self._is_non_vat_payer(text_upper):
return [] # No TVA entries for non-VAT payers
return ([], 0.0) # No TVA entries for non-VAT payers
# Step 2: Normalize OCR spaces in numbers
normalized = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text_upper)
lines = normalized.split('\n')
# Step 3: Try all formats, collect candidates
# Step 3: Try all formats, collect candidates with confidence
candidates = []
# Try inline multi-rate (Lidl-style)
candidates.extend(self._try_tva_inline(normalized))
inline_entries, inline_conf = self._try_tva_inline(normalized)
candidates.extend(inline_entries)
if inline_conf > max_confidence:
max_confidence = inline_conf
# Try reversed format (Stepout-style)
candidates.extend(self._try_tva_reversed(normalized, lines))
reversed_entries, reversed_conf = self._try_tva_reversed(normalized, lines)
candidates.extend(reversed_entries)
if reversed_conf > max_confidence:
max_confidence = reversed_conf
# Try multiline format (Brick/Electrobering)
candidates.extend(self._try_tva_multiline(normalized, lines))
multiline_entries, multiline_conf = self._try_tva_multiline(normalized, lines)
candidates.extend(multiline_entries)
if multiline_conf > max_confidence:
max_confidence = multiline_conf
# Try table format (OMV-style)
candidates.extend(self._try_tva_table(normalized))
table_entries, table_conf = self._try_tva_table(normalized)
candidates.extend(table_entries)
if table_conf > max_confidence:
max_confidence = table_conf
# Try standard/fallback patterns
if not candidates:
candidates.extend(self._try_tva_standard(normalized))
standard_entries, standard_conf = self._try_tva_standard(normalized)
candidates.extend(standard_entries)
if standard_conf > max_confidence:
max_confidence = standard_conf
# Step 4: Deduplicate and return
seen = set()
@@ -386,7 +403,7 @@ class BaseStoreProfile(ABC):
entries.append(entry)
seen.add(key)
return entries
return (entries, max_confidence if entries else 0.0)
def _is_non_vat_payer(self, text: str) -> bool:
"""Check if receipt is from non-VAT payer."""
@@ -395,9 +412,10 @@ class BaseStoreProfile(ABC):
return True
return False
def _try_tva_inline(self, text: str) -> List[dict]:
def _try_tva_inline(self, text: str) -> Tuple[List[dict], float]:
"""Try Lidl-style inline format: 'TVA A 21,00% 7.71'"""
entries = []
max_confidence = 0.0
# Pattern: "TVA A 21,00% 7.71" or "TVA B 11,00% 2.13"
for pattern, confidence, fmt in self.TVA_PATTERNS:
if fmt != 'inline':
@@ -415,13 +433,16 @@ class BaseStoreProfile(ABC):
'percent': percent,
'amount': amount
})
if confidence > max_confidence:
max_confidence = confidence
except (ValueError, InvalidOperation, IndexError):
continue
return entries
return (entries, max_confidence)
def _try_tva_reversed(self, text: str, lines: List[str]) -> List[dict]:
def _try_tva_reversed(self, text: str, lines: List[str]) -> Tuple[List[dict], float]:
"""Try Stepout-style reversed format: '5.00% TUA*B 2.00' (rate BEFORE TVA marker)"""
entries = []
confidence = 0.97 # Default confidence for reversed format
# Pattern: "5.00% TUA*B 2.00" - procent BEFORE TVA, amount same line or next
for i, line in enumerate(lines):
# Try pattern with amount on SAME line: "5.00% TUA*B 2.00"
@@ -462,11 +483,12 @@ class BaseStoreProfile(ABC):
})
except (ValueError, InvalidOperation, IndexError):
continue
return entries
return (entries, confidence if entries else 0.0)
def _try_tva_multiline(self, text: str, lines: List[str]) -> List[dict]:
def _try_tva_multiline(self, text: str, lines: List[str]) -> Tuple[List[dict], float]:
"""Try multiline format: 'TOTAL TVA A - 19%' + amount on next line"""
entries = []
confidence = 0.95 # Default confidence for multiline format
# Pattern: "TOTAL TVA A - 19%" or "TOTAL TVA A 19%" on one line, amount on next
multiline_patterns = [
r'TOTAL\s+TVA\s*([A-D])\s*[-\s]+(\d{1,2})\s*%',
@@ -491,14 +513,15 @@ class BaseStoreProfile(ABC):
'percent': percent,
'amount': amount
})
return entries
return (entries, confidence)
except (ValueError, InvalidOperation, IndexError):
continue
return entries
return (entries, 0.0)
def _try_tva_table(self, text: str) -> List[dict]:
def _try_tva_table(self, text: str) -> Tuple[List[dict], float]:
"""Try OMV-style table format: 'A-21,00% 285,66 49,58'"""
entries = []
confidence = 0.96 # Default confidence for table format
# Pattern: "A-21,00% 285,66 49,58" (code-percent base_amount tva_amount)
table_pattern = r'([A-D])\s*[-:]\s*(\d{1,2})[.,\s]*\d{0,2}\s*%\s+([\d.,\s]+)\s+([\d.,\s]+)'
for match in re.finditer(table_pattern, text, re.IGNORECASE):
@@ -530,13 +553,15 @@ class BaseStoreProfile(ABC):
'percent': 19, # Default rate
'amount': amount
})
confidence = 0.90 # Lower confidence for fallback
except (ValueError, InvalidOperation):
pass
return entries
return (entries, confidence if entries else 0.0)
def _try_tva_standard(self, text: str) -> List[dict]:
def _try_tva_standard(self, text: str) -> Tuple[List[dict], float]:
"""Try standard TVA patterns as fallback"""
entries = []
matched_confidence = 0.0
standard_fmts = ['standard', 'bon', 'percent', 'coded', 'fallback', 'books']
for pattern, confidence, fmt in self.TVA_PATTERNS:
if fmt not in standard_fmts:
@@ -563,7 +588,7 @@ class BaseStoreProfile(ABC):
'percent': percent,
'amount': amount
})
return entries
return (entries, confidence)
elif len(groups) == 1:
# Just amount
amount = self._parse_decimal(self._clean_ocr_number(groups[0]))
@@ -573,10 +598,10 @@ class BaseStoreProfile(ABC):
'percent': 19,
'amount': amount
})
return entries
return (entries, confidence)
except (ValueError, InvalidOperation, IndexError):
continue
return entries
return (entries, matched_confidence)
def _clean_ocr_number(self, value: str) -> str:
"""Remove OCR spaces from numbers (e.g., '55, 22' -> '55,22')."""

View File

@@ -38,6 +38,8 @@ class ExtractionResult:
confidence_date: float = 0.0
confidence_vendor: float = 0.0
confidence_client: float = 0.0
confidence_tva: float = 0.0
confidence_payment: float = 0.0
raw_text: str = ""
ocr_engine: str = "" # OCR engine used: paddleocr or tesseract
processing_time_ms: int = 0 # Processing time in milliseconds
@@ -51,12 +53,20 @@ class ExtractionResult:
@property
def overall_confidence(self) -> float:
"""Calculate weighted overall confidence score."""
weights = {'amount': 0.4, 'date': 0.3, 'vendor': 0.3}
"""Calculate weighted overall confidence score including TVA and payment."""
weights = {
'amount': 0.35, # Most important - receipt total
'date': 0.20, # Receipt date
'vendor': 0.20, # Vendor identification
'tva': 0.15, # TVA extraction accuracy
'payment': 0.10 # Payment method detection
}
return round(
self.confidence_amount * weights['amount'] +
self.confidence_date * weights['date'] +
self.confidence_vendor * weights['vendor'],
self.confidence_vendor * weights['vendor'] +
self.confidence_tva * weights['tva'] +
self.confidence_payment * weights['payment'],
2
)
@@ -468,9 +478,15 @@ class ReceiptExtractor:
result.amount, result.confidence_amount = store_profile.extract_total(text_upper)
result.receipt_date, result.confidence_date = store_profile.extract_date(text_upper)
result.receipt_number, _ = store_profile.extract_receipt_number(text_upper)
result.tva_entries = store_profile.extract_tva_entries(text_upper)
result.tva_total = sum(e['amount'] for e in result.tva_entries) if result.tva_entries else None
result.tva_entries, result.confidence_tva = store_profile.extract_tva_entries(text_upper)
result.tva_total = sum((e['amount'] for e in result.tva_entries), Decimal(0)) if result.tva_entries else None
result.payment_methods = store_profile.extract_payment_methods(text_upper)
# Calculate payment confidence from individual payment method confidences
if result.payment_methods:
payment_confidences = [pm.get('confidence', 0.0) for pm in result.payment_methods]
result.confidence_payment = max(payment_confidences) if payment_confidences else 0.0
else:
result.confidence_payment = 0.0
# Client data extraction via profile (CUI + name)
profile_client_cui, cui_confidence = store_profile.extract_client_cui(text_upper)
@@ -502,8 +518,8 @@ class ReceiptExtractor:
result.amount, result.confidence_amount = self._extract_amount(text_upper)
result.receipt_date, result.confidence_date = self._extract_date(text_upper)
result.receipt_number, _ = self._extract_number(text_upper)
result.tva_entries, result.tva_total = self._extract_tva_entries(text_upper)
result.payment_methods = self._extract_payment_methods(text_upper)
result.tva_entries, result.tva_total, result.confidence_tva = self._extract_tva_entries(text_upper)
result.payment_methods, result.confidence_payment = self._extract_payment_methods(text_upper)
# Generic client extraction
client_name, client_cui, client_address, confidence = self._extract_client_data(text_upper, text)
@@ -1289,7 +1305,7 @@ class ReceiptExtractor:
self,
candidates: List[tuple],
tva_bon_total: Optional[Decimal]
) -> Tuple[List[dict], Optional[Decimal]]:
) -> Tuple[List[dict], Optional[Decimal], float]:
"""
Select the best TVA candidate from collected candidates.
@@ -1303,10 +1319,10 @@ class ReceiptExtractor:
tva_bon_total: Authoritative TOTAL TVA BON value (if extracted)
Returns:
(best_entries, best_sum)
(best_entries, best_sum, confidence)
"""
if not candidates:
return [], None
return [], None, 0.0
# Score each candidate
scored = []
@@ -1334,9 +1350,9 @@ class ReceiptExtractor:
best = scored[0]
print(f"[TVA Select] Winner: {best[1]} (score={best[0]:.1f})", flush=True)
return best[3], best[4]
return best[3], best[4], best[2] # entries, sum, confidence
def _extract_tva_entries(self, text: str) -> Tuple[List[dict], Optional[Decimal]]:
def _extract_tva_entries(self, text: str) -> Tuple[List[dict], Optional[Decimal], float]:
"""
Extract multiple TVA (VAT) entries from text.
Romanian receipts can have multiple TVA rates (A=19%, B=9%, C=5%, D=0%).
@@ -1345,11 +1361,12 @@ class ReceiptExtractor:
- Try ALL patterns and collect candidates
- Select best candidate based on matching TOTAL TVA BON
Returns (tva_entries, tva_total) where tva_entries is a list of:
Returns (tva_entries, tva_total, confidence) where tva_entries is a list of:
{'code': 'A', 'percent': 19, 'amount': Decimal('15.20')}
"""
tva_entries = []
seen_entries = set() # To avoid duplicates
confidence = 0.0 # Track extraction confidence
# Check for non-VAT payer (NEPLATITOR DE TVA) - TVA = 0
# OCR variants: NEPLATTOR, NEPLATITOR, NEPLATOR, NEPLATTOR, ANEPLATHTOR, MEPLATITOR, etc.
@@ -1366,8 +1383,8 @@ class ReceiptExtractor:
]
for pattern in non_vat_patterns:
if re.search(pattern, text, re.IGNORECASE):
# Non-VAT payer - return TVA = 0
return [{'code': 'D', 'percent': 0, 'amount': Decimal('0.00')}], Decimal('0.00')
# Non-VAT payer - return TVA = 0, high confidence
return [{'code': 'D', 'percent': 0, 'amount': Decimal('0.00')}], Decimal('0.00'), 0.95
# Normalize spaces in numbers first (OCR may produce "32. 31" or "49, 58")
normalized_text = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text)
@@ -1717,7 +1734,7 @@ class ReceiptExtractor:
# === CANDIDATE SELECTION ===
# Select best candidate using TOTAL TVA BON as authoritative reference
if all_candidates:
best_entries, best_sum = self._select_best_tva_candidate(all_candidates, tva_bon_total)
best_entries, best_sum, confidence = self._select_best_tva_candidate(all_candidates, tva_bon_total)
if best_entries:
tva_entries = best_entries
entries_sum = best_sum
@@ -1725,7 +1742,7 @@ class ReceiptExtractor:
# Calculate sum from entries (if not set by candidate selection)
entries_sum = None
if tva_entries:
entries_sum = sum(entry['amount'] for entry in tva_entries)
entries_sum = sum((entry['amount'] for entry in tva_entries), Decimal(0))
# Validate and correct TVA values
tva_entries, tva_total = self._validate_and_correct_tva(
@@ -1735,7 +1752,7 @@ class ReceiptExtractor:
# Sort by code (A, B, C, D)
tva_entries.sort(key=lambda x: x.get('code', 'Z'))
return tva_entries, tva_total
return tva_entries, tva_total, confidence if tva_entries else 0.0
def _get_tva_code_from_percent(self, percent: int) -> str:
"""Map TVA percentage to standard Romanian code.
@@ -1843,7 +1860,7 @@ class ReceiptExtractor:
tva_entries = corrected_entries
# Recalculate sum after corrections
entries_sum = sum(entry['amount'] for entry in tva_entries) if tva_entries else None
entries_sum = sum((entry['amount'] for entry in tva_entries), Decimal(0)) if tva_entries else None
# Validate sum against TOTAL TVA BON
if tva_bon_total and entries_sum:
@@ -1876,7 +1893,7 @@ class ReceiptExtractor:
seen.add(key)
unique_entries.append(entry)
tva_entries = unique_entries
entries_sum = sum(e['amount'] for e in tva_entries)
entries_sum = sum((e['amount'] for e in tva_entries), Decimal(0))
# Final total
tva_total = entries_sum if entries_sum else tva_bon_total
@@ -2032,15 +2049,16 @@ class ReceiptExtractor:
return None
def _extract_payment_methods(self, text: str) -> List[dict]:
def _extract_payment_methods(self, text: str) -> Tuple[List[dict], float]:
"""
Extract payment methods (CARD/NUMERAR) from receipt.
These appear after TOTAL LEI and before TOTAL TVA section.
Returns list of: {'method': 'CARD'/'NUMERAR', 'amount': Decimal}
Returns tuple of: (list of {'method': 'CARD'/'NUMERAR', 'amount': Decimal}, confidence)
"""
payment_methods = []
seen_methods = set()
max_confidence = 0.0
# Normalize spaces in numbers
normalized_text = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text)
@@ -2071,13 +2089,15 @@ class ReceiptExtractor:
'amount': amount
})
seen_methods.add(method)
print(f"[Payment] Found {method}: {amount} (pattern matched)", flush=True)
if confidence > max_confidence:
max_confidence = confidence
print(f"[Payment] Found {method}: {amount} (pattern matched, conf={confidence})", flush=True)
elif amount >= self.MAX_REASONABLE_PAYMENT:
print(f"[Payment] Rejected unreasonable amount {amount} for {method} (likely OCR error)", flush=True)
except (InvalidOperation, ValueError):
continue
return payment_methods
return payment_methods, max_confidence if payment_methods else 0.0
def _validate_payment_methods(
self, payment_methods: List[dict], total: Optional[Decimal]