Spaces:
Running
Running
| """ | |
| 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}" | |