mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
145 lines
5.9 KiB
Python
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")
|