myke69's picture
Add files using upload-large-folder tool
296a9b2 verified
Raw
History Blame Contribute Delete
5.42 kB
"""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