Files
kamidso-back/kamidso/native_connectors/semantic/dates.py
T
javadandCursor edbaa2acec Add native Didar connectors so chats can query CRM data reliably.
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>
2026-10-12 19:26:47 +03:30

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)