stockforge-ocr / validators /date_validator.py
gagan0716's picture
Upload folder using huggingface_hub
a67c2e8 verified
Raw
History Blame Contribute Delete
7.71 kB
"""
InvoiceForge AI β€” validators/date_validator.py
Multi-format date parser and normaliser.
Supported input formats:
DD/MM/YYYY DD-MM-YYYY DD.MM.YYYY
DD/MM/YY DD-MM-YY DD.MM.YY
DD MMM YYYY DD-MMM-YYYY DD.MMM.YYYY (e.g. 15 Jan 2025)
MMMM DD, YYYY (e.g. January 15, 2025)
YYYY-MM-DD (already normalised)
DD MMM YY (e.g. 15 Jan 25)
All outputs normalised to YYYY-MM-DD.
Sanity checks:
- Year must be between 2000 and 2099
- Month must be 1-12
- Day must be 1-31
- Warns if date is in the future
"""
from __future__ import annotations
import logging
import re
from datetime import date, datetime
from typing import Optional
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# MONTH MAPS
# ─────────────────────────────────────────────────────────────────────────────
MONTH_SHORT: dict[str, str] = {
"jan": "01", "feb": "02", "mar": "03", "apr": "04",
"may": "05", "jun": "06", "jul": "07", "aug": "08",
"sep": "09", "oct": "10", "nov": "11", "dec": "12",
}
MONTH_LONG: dict[str, str] = {
"january": "01", "february": "02", "march": "03",
"april": "04", "may": "05", "june": "06",
"july": "07", "august": "08", "september": "09",
"october": "10", "november": "11", "december": "12",
}
# Combined map
MONTH_MAP: dict[str, str] = {**MONTH_LONG, **MONTH_SHORT}
# ─────────────────────────────────────────────────────────────────────────────
# PATTERNS (evaluated in order β€” most specific first)
# ─────────────────────────────────────────────────────────────────────────────
_PATTERNS: list[tuple[re.Pattern, str]] = [
# YYYY-MM-DD (already ISO)
(re.compile(r"\b(20\d{2})[-/\.](0[1-9]|1[0-2])[-/\.](0[1-9]|[12]\d|3[01])\b"), "YMD"),
# DD-MMM-YYYY or DD.MMM.YYYY or DD MMM YYYY
(re.compile(r"\b(\d{1,2})[-/\.\s]([A-Za-z]{3,9})[-/\.\s](20\d{2}|\d{2})\b"), "DMY_TEXT"),
# MMMM DD, YYYY or MMMM D YYYY
(re.compile(r"\b([A-Za-z]{4,9})\s+(\d{1,2})[,\s]+(20\d{2}|\d{2})\b"), "MY_TEXT"),
# DD/MM/YYYY or DD-MM-YYYY or DD.MM.YYYY (4-digit year)
(re.compile(r"\b(\d{1,2})[-/\.](\d{1,2})[-/\.](20\d{2})\b"), "DMY"),
# DD/MM/YY or DD-MM-YY (2-digit year)
(re.compile(r"\b(\d{1,2})[-/\.](\d{1,2})[-/\.](\d{2})\b"), "DMY_SHORT"),
]
# ─────────────────────────────────────────────────────────────────────────────
# DATE VALIDATOR
# ─────────────────────────────────────────────────────────────────────────────
class DateValidator:
"""
Parses and normalises dates from raw OCR text.
Usage:
dv = DateValidator()
iso_date = dv.parse("Dt: 28-Mar-2026") # "2026-03-28"
result = dv.validate("2026-03-28")
"""
def parse(self, text: str) -> str:
"""
Extract the first plausible date from arbitrary text and return
it in YYYY-MM-DD format. Returns empty string if not found.
"""
for pattern, fmt in _PATTERNS:
m = pattern.search(text)
if not m:
continue
try:
result = self._parse_match(m, fmt)
if result:
return result
except Exception:
continue
return ""
def parse_all(self, text: str) -> list[str]:
"""Return all unique valid dates found in the text."""
dates: list[str] = []
seen: set[str] = set()
for pattern, fmt in _PATTERNS:
for m in pattern.finditer(text):
try:
d = self._parse_match(m, fmt)
if d and d not in seen:
dates.append(d)
seen.add(d)
except Exception:
continue
return dates
def validate(self, date_str: str) -> dict:
"""
Validate a YYYY-MM-DD date string.
Returns:
dict with keys: is_valid, date, warnings, errors
"""
errors: list[str] = []
warnings: list[str] = []
if not date_str:
errors.append("Date is empty.")
return {"is_valid": False, "date": "", "errors": errors, "warnings": warnings}
try:
parsed = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
errors.append(f"Date '{date_str}' is not in YYYY-MM-DD format.")
return {"is_valid": False, "date": date_str, "errors": errors, "warnings": warnings}
today = date.today()
if parsed.year < 2000 or parsed.year > 2099:
errors.append(f"Year {parsed.year} is outside expected range 2000-2099.")
if parsed > today:
warnings.append(f"Invoice date {date_str} is in the future.")
if (today - parsed).days > 365 * 3:
warnings.append(f"Invoice date {date_str} is more than 3 years old.")
return {
"is_valid": len(errors) == 0,
"date": date_str,
"errors": errors,
"warnings": warnings,
}
# ─────────────────────────────────────────────────────────────────────────
# INTERNAL PARSERS
# ─────────────────────────────────────────────────────────────────────────
def _parse_match(self, m: re.Match, fmt: str) -> Optional[str]:
"""Parse a regex match into YYYY-MM-DD string."""
if fmt == "YMD":
y, mo, d = m.group(1), m.group(2), m.group(3)
elif fmt == "DMY_TEXT":
d, month_name, y = m.group(1), m.group(2).lower()[:3], m.group(3)
mo = MONTH_SHORT.get(month_name)
if not mo:
return None
if len(y) == 2:
y = "20" + y
elif fmt == "MY_TEXT":
month_name, d, y = m.group(1).lower(), m.group(2), m.group(3)
mo = MONTH_MAP.get(month_name)
if not mo:
return None
if len(y) == 2:
y = "20" + y
elif fmt == "DMY":
d, mo, y = m.group(1), m.group(2), m.group(3)
elif fmt == "DMY_SHORT":
d, mo, y_short = m.group(1), m.group(2), m.group(3)
y = "20" + y_short
else:
return None
# Pad and validate components
d = d.zfill(2)
mo = mo.zfill(2)
if not (1 <= int(d) <= 31 and 1 <= int(mo) <= 12 and 2000 <= int(y) <= 2099):
return None
return f"{y}-{mo}-{d}"