Files

145 lines
5.9 KiB
Python

"""Find date expressions with code, have Kai check which one answers and what it counts from, resolve in code."""
import re
from datetime import date, timedelta
from hanzo_kai import Kai, Choice, RetryPolicy
MONTHS = "January|February|March|April|May|June|July|August|September|October|November|December"
DAYS = "Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday"
NUMBER = r"\d+|one|two|three|four|five|six|seven|eight|nine|ten"
FIXED = [r"\d{4}-\d{2}-\d{2}", rf"(?:{MONTHS}) \d{{1,2}}(?:, \d{{4}})?"]
RELATIVE = [
rf"(?:in|within) (?:{NUMBER}) (?:days?|weeks?)",
rf"(?:{NUMBER}) (?:days?|weeks?) (?:after|before|from)",
r"today|tomorrow|yesterday",
DAYS,
]
# sent, message, the date asked for, gold
MESSAGES = [
(
"2026-03-02",
"Your invoice is dated February 27. Payment is due 30 days after the invoice date, and we ship on March 9.",
"payment due date",
"2026-03-29",
),
(
"2026-03-05",
"The kickoff moved from March 10 to March 13. Please send your slides two days before the kickoff.",
"deadline for the slides",
"2026-03-11",
),
(
"2026-04-01",
"Your trial ends in 14 days. After that you are billed monthly, starting May 1.",
"last day of the trial",
"2026-04-15",
),
(
"2026-04-10",
"We received your return today. The refund reaches your card within 7 days.",
"latest date for the refund",
"2026-04-17",
),
(
"2026-06-03",
"Your package shipped yesterday and should arrive by June 8.",
"date the package shipped",
"2026-06-02",
),
(
"2026-06-15",
"The report covers May 1 to May 31. Send questions to finance by Friday.",
"deadline for questions",
"2026-06-19",
),
(
"2026-07-01",
"Your appointment is confirmed for 2026-07-14 at 10:00. Please arrive 15 minutes early.",
"appointment date",
"2026-07-14",
),
(
"2026-07-20",
"You bought the kettle on May 2, and the warranty runs 90 days from purchase.",
"last day of the warranty",
"2026-07-31",
),
("2026-08-03", "Thanks for your payment on July 28. Your receipt is attached.", "delivery date", None),
]
CHECK = Choice(
instructions="How does `hypothesis` relate to `premise`?",
criteria={
"entailment": "the premise implies the hypothesis",
"neutral": "the premise neither implies nor contradicts the hypothesis",
"contradiction": "the premise contradicts the hypothesis",
},
)
def spans(text: str, patterns: list[str]) -> list[str]:
"""Every expression in text that one of the patterns matches, in order of appearance."""
return list(dict.fromkeys(m[0] for m in re.finditer(rf"\b(?:{'|'.join(patterns)})\b", text)))
def phrase(text: str, span: str) -> str:
"""The span with the rest of its clause when it counts from something: '30 days after the invoice date'."""
if not span.endswith(("after", "before", "from")):
return span
return span + re.split(r"[.;:!?,](?:\s|$)", text[text.index(span) + len(span) :])[0]
def resolve(span: str, anchor: date) -> date:
"""The calendar date a span names; a relative span counts from anchor, a fixed one takes its year."""
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", span):
return date.fromisoformat(span)
if m := re.fullmatch(rf"({MONTHS}) (\d{{1,2}})(?:, (\d{{4}}))?", span):
return date(int(m[3] or anchor.year), MONTHS.split("|").index(m[1]) + 1, int(m[2]))
if span in ("today", "tomorrow", "yesterday"):
return anchor + timedelta(days={"today": 0, "tomorrow": 1, "yesterday": -1}[span])
if span in DAYS.split("|"):
return anchor + timedelta(days=(DAYS.split("|").index(span) - anchor.weekday() - 1) % 7 + 1)
m = re.fullmatch(rf"(?:in |within )?({NUMBER}) (day|week)s?(?: (after|before|from))?", span)
count = int(m[1]) if m[1].isdigit() else NUMBER.split("|").index(m[1])
step = timedelta(days=count * (7 if m[2] == "week" else 1))
return anchor - step if m[3] == "before" else anchor + step
def best(kai: Kai, text: str, hypotheses: dict[str, str]) -> tuple[str, float]:
"""The key whose hypothesis the text supports most, with its P(entailment): one check per hypothesis."""
support = {
key: kai.decide(state={"premise": text, "hypothesis": hypothesis}, questions={"check": CHECK})
.choices["check"]
.probabilities["entailment"]
for key, hypothesis in hypotheses.items()
}
return max(support.items(), key=lambda item: item[1])
def find(kai: Kai, sent: str, text: str, want: str) -> tuple[str | None, str, str]:
"""The date asked for, auto or review, and what Kai supported on the way."""
fixed, relative = spans(text, FIXED), spans(text, RELATIVE)
span, p = best(kai, text, {s: f"The {want} is {phrase(text, s)}." for s in fixed + relative})
if p < 0.3:
return None, "auto", f"none (best: {span} {p:.2f})"
start, sure, why = date.fromisoformat(sent), p >= 0.7, f"{phrase(text, span)} {p:.2f}"
if (
named := phrase(text, span)[len(span) :].strip()
) and fixed: # it counts from a named event: which date is that?
point, q = best(kai, text, {s: f"{named[0].upper()}{named[1:]} is {s}." for s in fixed})
start, sure, why = resolve(point, start), sure and q >= 0.7, f"{why}; {named} = {point} {q:.2f}"
return resolve(span, start).isoformat(), "auto" if sure else "review", why
with Kai(retry=RetryPolicy(max_retries=10)) as kai:
right = automatic = 0
for sent, text, want, gold in MESSAGES:
found, route, why = find(kai, sent, text, want)
automatic += route == "auto"
right += route == "auto" and found == gold
print(f"{want:27} {why:62} {found or '-':10} {route:6} {'ok' if found == gold else 'wrong'}")
print(f"\n{right} of {automatic} automatic answers right; {len(MESSAGES) - automatic} sent to review")