"""Date parsing and derived-deadline computation. The demo "wow" moment lives here: "notice must be given 90 days before renewal" becomes an absolute notice-by date with a live countdown. """ from __future__ import annotations import datetime as dt import re import dateparser from .schema import Clause, KeyDate, Urgency MONTH = r"(?:January|February|March|April|May|June|July|August|September|October|November|December)" DATE_RE = re.compile( rf"\b({MONTH}\s+\d{{1,2}},\s+\d{{4}}|\d{{1,2}}\s+{MONTH}\s+\d{{4}}|\d{{1,2}}/\d{{1,2}}/\d{{2,4}})\b") # contracts spell numbers both ways: "90 days", "ninety (90) days" _N = r"(?:[a-z\-]+\s+)?\(?(\d{1,3})\)?" NOTICE_RE = re.compile( rf"\b(?:at least|no(?:t)? (?:less|later) than|minimum of)?\s*" rf"{_N}\s+(business\s+)?days?[’']?\s*(?:prior\s+)?" rf"(?:written\s+)?notice\b|\b{_N}\s+(business\s+)?days?\s+(?:prior to|before)\b", re.IGNORECASE) WITHIN_RE = re.compile(rf"\bwithin\s+{_N}\s+(business\s+)?days\b", re.IGNORECASE) def parse_date(text: str) -> dt.date | None: d = dateparser.parse(text, settings={"REQUIRE_PARTS": ["day", "month", "year"], "PREFER_DAY_OF_MONTH": "first"}) return d.date() if d else None def find_dates(text: str) -> list[tuple[str, dt.date, int]]: """All explicit dates in a text: (verbatim, parsed, offset).""" out = [] for m in DATE_RE.finditer(text): d = parse_date(m.group(1)) if d: out.append((m.group(1), d, m.start())) return out def urgency_for(date: dt.date | None, today: dt.date | None = None) -> tuple[Urgency, int | None]: if date is None: return "unknown", None today = today or dt.date.today() days = (date - today).days if days < 0: return "overdue", days if days <= 30: return "critical", days if days <= 90: return "soon", days return "ok", days def notice_days_in(text: str) -> int | None: m = NOTICE_RE.search(text) if m: return int(m.group(1) or m.group(3)) return None _KIND_BY_CATEGORY = { "term": "expiration", "auto_renewal": "renewal", "termination": "expiration", "payment_terms": "payment", "deliverables": "deliverable", } def compute_key_dates(clauses: list[Clause], effective_date: dt.date | None) -> list[KeyDate]: """Explicit dates per clause, plus derived notice deadlines for auto-renewal clauses ('N days prior to renewal/expiration').""" out: list[KeyDate] = [] n = 0 def add(label: str, date: dt.date | None, kind: str, clause_id: str, derivation: str | None = None) -> None: nonlocal n urg, days = urgency_for(date) out.append(KeyDate(id=f"d{n}", label=label, date=date, kind=kind, # type: ignore[arg-type] derivation=derivation, days_until=days, urgency=urg, clause_id=clause_id)) n += 1 # latest explicit future-most date in term/renewal clauses = expiration anchor expiration: dt.date | None = None for c in clauses: if {"term", "auto_renewal", "termination"} & set(c.categories): for _txt, d, _off in find_dates(c.text): if expiration is None or d > expiration: expiration = d if effective_date: add("Effective date", effective_date, "effective", clauses[0].id if clauses else "c0") for c in clauses: kind = next((_KIND_BY_CATEGORY[k] for k in c.categories if k in _KIND_BY_CATEGORY), "other") for verbatim, d, _off in find_dates(c.text): if effective_date and d == effective_date: continue label = c.heading or f"Clause {c.number or c.id}" add(f"{label}: {verbatim}", d, kind, c.id) # derived: notice window before renewal (auto-renewal clauses only — # termination-notice periods are relative to an arbitrary trigger, not term end) if "auto_renewal" in c.categories: nd = notice_days_in(c.text) anchor = expiration if nd and anchor: notify_by = anchor - dt.timedelta(days=nd) add( f"Non-renewal notice deadline ({nd} days before {anchor.isoformat()})", notify_by, "notice_deadline", c.id, derivation=f"{nd} days before term end {anchor.isoformat()} " f"(clause {c.number or c.id})", ) # dedupe identical (label, date) seen: set[tuple[str, str]] = set() uniq = [] for kd in out: key = (kd.label, kd.date.isoformat() if kd.date else "") if key not in seen: seen.add(key) uniq.append(kd) uniq.sort(key=lambda k: (k.date or dt.date.max)) return uniq def find_effective_date(text: str) -> dt.date | None: m = re.search( rf"(?:effective\s+(?:as\s+of\s+|date\s+of\s+)?|dated\s+(?:as\s+of\s+)?|entered\s+into\s+(?:as\s+of\s+|on\s+))" rf"[\"“]?({MONTH}\s+\d{{1,2}},\s+\d{{4}}|\d{{1,2}}\s+{MONTH}\s+\d{{4}})", text[:3000], re.IGNORECASE) if m: return parse_date(m.group(1)) dates = find_dates(text[:3000]) return dates[0][1] if dates else None