File size: 3,941 Bytes
102a3c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c847ed
 
102a3c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"""Surface string -> typed value, for predictors that can only point at text.

A span extractor finds ``March 3rd`` or ``$1,204.50``; the field wants
``2026-03-03`` or ``1204.5``. These parsers are what lets a span-only system be
scored on typed fields at all -- and what it cannot do (arithmetic) stays visible
as misses on ``computed`` gold.
"""

from __future__ import annotations

import datetime as dt
import re
from typing import Any, Optional

from .schema import DATE, DATETIME, DURATION, NUMBER, TEXT, TIME, Field

# A minus sign only when it is not joined to a word: "INV-2207" is 2207, not -2207.
_NUM = re.compile(r"(?:(?<![A-Za-z0-9])-)?\d[\d,]*(?:\.\d+)?")
_WORDS = {"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7,
          "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "fifteen": 15, "twenty": 20,
          "thirty": 30, "forty": 40, "fifty": 50, "hundred": 100}
_TIME = re.compile(r"\b(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([ap]\.?m\.?)?", re.I)
_UNIT_DAYS = {"day": 1, "days": 1, "week": 7, "weeks": 7}
_UNIT_MIN = {"minute": 1, "minutes": 1, "min": 1, "mins": 1, "hour": 60, "hours": 60, "hr": 60, "hrs": 60, "h": 60}
_UNIT_MON = {"month": 1, "months": 1, "year": 12, "years": 12}


def number(s: str, integer: bool = False) -> Optional[float]:
    m = _NUM.search(s)
    if m:
        try:
            v = float(m.group().replace(",", ""))
        except ValueError:
            return None
    else:
        w = s.strip().lower()
        if w not in _WORDS:
            return None
        v = float(_WORDS[w])
    return int(round(v)) if integer else v


def date(s: str, ref: Optional[str]) -> Optional[str]:
    d = _datetime(s, ref)
    return d.date().isoformat() if d else None


def datetime_(s: str, ref: Optional[str]) -> Optional[str]:
    d = _datetime(s, ref)
    if d is None or (d.hour, d.minute) == (0, 0) and not _TIME.search(s):
        return None
    return d.strftime("%Y-%m-%dT%H:%M")


def time(s: str) -> Optional[str]:
    m = _TIME.search(s)
    if not m:
        return None
    h, mi = int(m.group(1)), int(m.group(2))
    ampm = (m.group(4) or "").lower().replace(".", "")
    if ampm == "pm" and h < 12:
        h += 12
    if ampm == "am" and h == 12:
        h = 0
    if h > 23 or mi > 59:
        return None
    return f"{h:02d}:{mi:02d}"


def duration(s: str, unit: str) -> Optional[float]:
    v = number(s)
    if v is None:
        return None
    words = re.findall(r"[a-z]+", s.lower())
    table = (_UNIT_DAYS if unit == "days" else _UNIT_MIN if unit == "minutes" else
             _UNIT_MON if unit == "months" else {unit: 1})
    for w in words:
        if w in table:
            return v * table[w]
    return v


def _datetime(s: str, ref: Optional[str]) -> Optional[dt.datetime]:
    import dateparser
    base = dt.datetime.fromisoformat(ref) if ref else dt.datetime(2026, 1, 1)
    settings = {"RELATIVE_BASE": base, "PREFER_DATES_FROM": "past", "RETURN_AS_TIMEZONE_AWARE": False,
                "STRICT_PARSING": False, "PREFER_DAY_OF_MONTH": "first"}
    text = re.sub(r"^\s*(on|last)\s+", lambda m: "last " if m.group(1) == "last" else "", s.strip(), flags=re.I)
    try:
        return dateparser.parse(text, languages=["en"], settings=settings)
    except Exception:
        return None


def to_type(f: Field, s: str, ref: Optional[str]) -> Any:
    """Parse a candidate string as the field's (item) type; None if it is not one."""
    t = f.item_type
    if t == TEXT:
        return s.strip() or None
    if t == NUMBER:
        return number(s, bool(f.options.get("integer")))
    if t == DATE:
        return date(s, ref)
    if f.type == DATETIME:
        return datetime_(s, ref)
    if f.type == TIME:
        return time(s)
    if f.type == DURATION:
        v = duration(s, f.options.get("unit", "days"))
        return int(round(v)) if v is not None and f.options.get("integer") else v
    return None