Spaces:
Runtime error
Runtime error
| """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 | |