aditya0103's picture
Initial commit: schemas, extraction engine, dataset prep, 54 passing tests
44c2f50
Raw
History Blame Contribute Delete
3.2 kB
"""Robust parsers for the messy strings you find in receipt / invoice ground truth.
Public dataset ground truth is dirty: money values arrive as "$12.99", "12,99",
"12.99 USD", or even "1299" (missing decimal). Dates come in "DD/MM/YYYY",
"MM-DD-YY", "20250615", "Jun 15, 2025", etc.
These parsers do best-effort normalization and return None on failure — the
caller decides whether that's an error.
"""
from __future__ import annotations
import re
from datetime import date, datetime
# --- Money ------------------------------------------------------------------
# Strip anything that isn't a digit, decimal point, or minus sign.
# Handles "$12.99", "12,99 USD", "SGD 45.00", "£10", etc.
_MONEY_STRIP_RE = re.compile(r"[^\d.,\-]")
def parse_money(value: str | float | int | None) -> float | None:
"""Parse a monetary string/number to a float. Returns None on failure.
Handles:
"$12.99" -> 12.99
"12,99" -> 12.99 (European decimal)
"1,299.50" -> 1299.50
"1.299,50" -> 1299.50 (EU format with thousands)
1299 -> 1299.0
"""
if value is None:
return None
if isinstance(value, (int, float)):
return round(float(value), 2)
s = _MONEY_STRIP_RE.sub("", str(value)).strip()
if not s:
return None
# Detect European format: last separator is a comma AND (no dot OR dot is a thousands sep)
last_comma = s.rfind(",")
last_dot = s.rfind(".")
try:
if last_comma > last_dot:
# European: "1.299,50" — dots are thousands, comma is decimal
s = s.replace(".", "").replace(",", ".")
else:
# US / international: commas are thousands, dot is decimal
s = s.replace(",", "")
return round(float(s), 2)
except ValueError:
return None
# --- Dates ------------------------------------------------------------------
# Try formats in this order — first match wins. Order matters: put more
# specific (with delimiters) formats before ambiguous ones.
_DATE_FORMATS = (
"%Y-%m-%d", # 2026-06-15
"%Y/%m/%d",
"%d-%m-%Y", # 15-06-2026
"%d/%m/%Y",
"%m-%d-%Y", # 06-15-2026
"%m/%d/%Y",
"%d-%m-%y",
"%d/%m/%y",
"%b %d, %Y", # Jun 15, 2026
"%B %d, %Y", # June 15, 2026
"%d %b %Y", # 15 Jun 2026
"%d %B %Y", # 15 June 2026
"%Y%m%d", # 20260615
)
def parse_date(value: str | date | None) -> date | None:
"""Parse a date string using a family of common formats. None on failure."""
if value is None:
return None
if isinstance(value, date):
return value
s = str(value).strip()
if not s:
return None
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
# --- Free-text cleanup ------------------------------------------------------
def clean_text(value: str | None) -> str | None:
"""Collapse whitespace, strip. Returns None if the result is empty."""
if not value:
return None
cleaned = " ".join(str(value).split())
return cleaned or None