Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling. Co-authored-by: Cursor <cursoragent@cursor.com>
237 lines
8.2 KiB
Python
237 lines
8.2 KiB
Python
"""Resolve Jalali and relative business periods to a half-open UTC range."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import jdatetime
|
|
|
|
from kamidso.native_connectors.semantic.catalog import load_bundle
|
|
from kamidso.native_connectors.semantic.text import normalize_text
|
|
|
|
_TEHRAN = ZoneInfo("Asia/Tehran")
|
|
|
|
|
|
class PeriodError(ValueError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResolvedPeriod:
|
|
input_text: str
|
|
calendar: str
|
|
timezone: str
|
|
start_local: str
|
|
end_local_exclusive: str
|
|
start_utc: str
|
|
end_utc_exclusive: str
|
|
start: datetime
|
|
end: datetime
|
|
|
|
def contains(self, moment: datetime) -> bool:
|
|
return self.start <= moment < self.end
|
|
|
|
|
|
def parse_utc(value: object) -> Optional[datetime]:
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None
|
|
text = value.strip().replace("Z", "+00:00")
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _months() -> dict:
|
|
raw = load_bundle()["dates"].get("jalali_months") or {}
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
|
|
def _relatives() -> list:
|
|
parser = load_bundle()["dates"].get("parser_requirements") or {}
|
|
raw = parser.get("accept_relative_periods") if isinstance(parser, dict) else []
|
|
if not isinstance(raw, list):
|
|
return []
|
|
return [normalize_text(str(item)) for item in raw]
|
|
|
|
|
|
def _as_tehran(now: Optional[datetime]) -> datetime:
|
|
if now is None:
|
|
return datetime.now(_TEHRAN)
|
|
if now.tzinfo is None:
|
|
return now.replace(tzinfo=_TEHRAN)
|
|
return now.astimezone(_TEHRAN)
|
|
|
|
|
|
def _jalali_today(now: datetime) -> jdatetime.date:
|
|
return jdatetime.date.fromgregorian(date=now.date())
|
|
|
|
|
|
def _add_months(year: int, month: int, delta: int) -> tuple:
|
|
index = year * 12 + (month - 1) + delta
|
|
return index // 12, index % 12 + 1
|
|
|
|
|
|
def _month_bounds(year: int, month: int) -> tuple:
|
|
start = jdatetime.date(year, month, 1)
|
|
if month == 12:
|
|
end = jdatetime.date(year + 1, 1, 1)
|
|
else:
|
|
end = jdatetime.date(year, month + 1, 1)
|
|
return start, end
|
|
|
|
|
|
def _format_offset(moment: datetime) -> str:
|
|
text = moment.strftime("%z")
|
|
if len(text) == 5:
|
|
return text[:3] + ":" + text[3:]
|
|
return text
|
|
|
|
|
|
def _from_jalali(start: jdatetime.date, end: jdatetime.date, text: str) -> ResolvedPeriod:
|
|
start_greg = start.togregorian()
|
|
end_greg = end.togregorian()
|
|
start_local = datetime(start_greg.year, start_greg.month, start_greg.day, tzinfo=_TEHRAN)
|
|
end_local = datetime(end_greg.year, end_greg.month, end_greg.day, tzinfo=_TEHRAN)
|
|
start_utc = start_local.astimezone(timezone.utc)
|
|
end_utc = end_local.astimezone(timezone.utc)
|
|
return ResolvedPeriod(
|
|
input_text=text,
|
|
calendar="jalali",
|
|
timezone="Asia/Tehran",
|
|
start_local=(
|
|
f"{start.year:04d}-{start.month:02d}-{start.day:02d}T00:00:00"
|
|
f"{_format_offset(start_local)}"
|
|
),
|
|
end_local_exclusive=(
|
|
f"{end.year:04d}-{end.month:02d}-{end.day:02d}T00:00:00"
|
|
f"{_format_offset(end_local)}"
|
|
),
|
|
start_utc=start_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
end_utc_exclusive=end_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
start=start_utc,
|
|
end=end_utc,
|
|
)
|
|
|
|
|
|
def _from_tehran_datetimes(start_local: datetime, end_local: datetime, text: str) -> ResolvedPeriod:
|
|
start_j = jdatetime.date.fromgregorian(date=start_local.date())
|
|
end_j = jdatetime.date.fromgregorian(date=end_local.date())
|
|
start_utc = start_local.astimezone(timezone.utc)
|
|
end_utc = end_local.astimezone(timezone.utc)
|
|
return ResolvedPeriod(
|
|
input_text=text,
|
|
calendar="jalali",
|
|
timezone="Asia/Tehran",
|
|
start_local=(
|
|
f"{start_j.year:04d}-{start_j.month:02d}-{start_j.day:02d}"
|
|
f"T{start_local.strftime('%H:%M:%S')}{_format_offset(start_local)}"
|
|
),
|
|
end_local_exclusive=(
|
|
f"{end_j.year:04d}-{end_j.month:02d}-{end_j.day:02d}"
|
|
f"T{end_local.strftime('%H:%M:%S')}{_format_offset(end_local)}"
|
|
),
|
|
start_utc=start_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
end_utc_exclusive=end_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
start=start_utc,
|
|
end=end_utc,
|
|
)
|
|
|
|
|
|
def _relative(normalized: str, now: datetime, original: str) -> Optional[ResolvedPeriod]:
|
|
today = datetime(now.year, now.month, now.day, tzinfo=_TEHRAN)
|
|
jalali_today = _jalali_today(now)
|
|
if normalized == "امروز":
|
|
return _from_tehran_datetimes(today, today + timedelta(days=1), original)
|
|
if normalized == "دیروز":
|
|
return _from_tehran_datetimes(today - timedelta(days=1), today, original)
|
|
if normalized in {"این هفته", "هفته قبل"}:
|
|
delta = (today.weekday() - 5) % 7
|
|
this_saturday = today - timedelta(days=delta)
|
|
if normalized == "هفته قبل":
|
|
start = this_saturday - timedelta(days=7)
|
|
end = this_saturday
|
|
else:
|
|
start = this_saturday
|
|
end = this_saturday + timedelta(days=7)
|
|
return _from_tehran_datetimes(start, end, original)
|
|
if normalized == "این ماه":
|
|
start, end = _month_bounds(jalali_today.year, jalali_today.month)
|
|
return _from_jalali(start, end, original)
|
|
if normalized == "ماه قبل":
|
|
year, month = _add_months(jalali_today.year, jalali_today.month, -1)
|
|
start, end = _month_bounds(year, month)
|
|
return _from_jalali(start, end, original)
|
|
if normalized == "امسال":
|
|
return _from_jalali(
|
|
jdatetime.date(jalali_today.year, 1, 1),
|
|
jdatetime.date(jalali_today.year + 1, 1, 1),
|
|
original,
|
|
)
|
|
if normalized == "سال قبل":
|
|
return _from_jalali(
|
|
jdatetime.date(jalali_today.year - 1, 1, 1),
|
|
jdatetime.date(jalali_today.year, 1, 1),
|
|
original,
|
|
)
|
|
if normalized == "سه ماه اخیر":
|
|
start_year, start_month = _add_months(jalali_today.year, jalali_today.month, -2)
|
|
end_year, end_month = _add_months(jalali_today.year, jalali_today.month, 1)
|
|
return _from_jalali(
|
|
jdatetime.date(start_year, start_month, 1),
|
|
jdatetime.date(end_year, end_month, 1),
|
|
original,
|
|
)
|
|
if normalized == "شش ماه اخیر":
|
|
start_year, start_month = _add_months(jalali_today.year, jalali_today.month, -5)
|
|
end_year, end_month = _add_months(jalali_today.year, jalali_today.month, 1)
|
|
return _from_jalali(
|
|
jdatetime.date(start_year, start_month, 1),
|
|
jdatetime.date(end_year, end_month, 1),
|
|
original,
|
|
)
|
|
return None
|
|
|
|
|
|
def resolve_period(text: str, *, now: Optional[datetime] = None) -> ResolvedPeriod:
|
|
original = str(text or "").strip()
|
|
normalized = normalize_text(original)
|
|
if not normalized:
|
|
raise PeriodError("A period is required.")
|
|
local_now = _as_tehran(now)
|
|
known = set(_relatives())
|
|
if normalized in known:
|
|
resolved = _relative(normalized, local_now, original)
|
|
if resolved is not None:
|
|
return resolved
|
|
months = _months()
|
|
month_pattern = "|".join(sorted((str(name) for name in months), key=len, reverse=True))
|
|
if not month_pattern:
|
|
raise PeriodError(f"Could not resolve period '{original}'.")
|
|
match = re.fullmatch(
|
|
rf"(?:ماه\s+)?(?P<month>{month_pattern})(?:\s+ماه)?(?:\s+(?P<year>\d{{4}}))?",
|
|
normalized,
|
|
)
|
|
if match is None:
|
|
raise PeriodError(
|
|
f"Could not resolve period '{original}'. "
|
|
"Use a Jalali month such as مهر ۱۴۰۵ or a relative period such as این ماه."
|
|
)
|
|
month = int(months[match.group("month")])
|
|
year_text = match.group("year")
|
|
if year_text:
|
|
year = int(year_text)
|
|
if year < 1200 or year > 1600:
|
|
raise PeriodError(f"'{year_text}' is not a Jalali year.")
|
|
else:
|
|
year = _jalali_today(local_now).year
|
|
start, end = _month_bounds(year, month)
|
|
return _from_jalali(start, end, original)
|