import { jalaliToIsoDate } from '../../common/jalali'; import { civilDateJsWeekday } from '../../common/zoned-civil-time'; import type { DueIntent, UnresolvedItem, Weekday } from './voice.types'; /** * Spoken deadline → ISO civil date. * * The model never does calendar arithmetic; it says what it heard and this decides what * that means. Jalali conversion in particular is arithmetic here, not inference — an LLM * asked to turn "۲۵ مهر" into ISO answers confidently and is often wrong, and * `@IsDateString()` would accept the wrong answer. * * Works entirely in civil dates. The caller derives `todayIso` from the actor's IANA zone * (see `civilDateInZone`) rather than passing a zone in here, so nothing in this file has * to reason about instants. */ /** JS `getUTCDay()` numbering: Sunday = 0. */ const WEEKDAY_TO_JS: Record = { saturday: 6, sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, }; /** Refuse absurd deadlines however they were arrived at. */ const MAX_DAYS_AHEAD = 365 * 5; export type DueResolution = { dueDate: string | null; unresolved: UnresolvedItem | null; }; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; function isRealCivilDate(iso: string): boolean { if (!ISO_DATE.test(iso)) return false; const [y, m, d] = iso.split('-').map(Number); if (m < 1 || m > 12 || d < 1 || d > 31) return false; const probe = new Date(Date.UTC(y, m - 1, d)); return ( probe.getUTCFullYear() === y && probe.getUTCMonth() === m - 1 && probe.getUTCDate() === d ); } function toIso(utcMs: number): string { return new Date(utcMs).toISOString().slice(0, 10); } function utcMsOf(iso: string): number { const [y, m, d] = iso.split('-').map(Number); return Date.UTC(y, m - 1, d); } function addDays(iso: string, days: number): string { return toIso(utcMsOf(iso) + days * 86_400_000); } /** Calendar-month addition with end-of-month clamping (31 Jan + 1 month = 28/29 Feb). */ function addMonths(iso: string, months: number): string { const [y, m, d] = iso.split('-').map(Number); const targetMonthIndex = m - 1 + months; const targetYear = y + Math.floor(targetMonthIndex / 12); const targetMonth = ((targetMonthIndex % 12) + 12) % 12; const lastDay = new Date( Date.UTC(targetYear, targetMonth + 1, 0), ).getUTCDate(); return toIso(Date.UTC(targetYear, targetMonth, Math.min(d, lastDay))); } function unresolved(spoken: string): DueResolution { return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } }; } function describe(intent: DueIntent): string { switch (intent?.kind) { case 'weekday': return `${intent.which} ${intent.weekday}`; case 'offset': return `+${intent.amount} ${intent.unit}`; case 'jalali': return `${intent.jy}/${intent.jm}/${intent.jd}`; case 'gregorian': return `${intent.y}-${intent.m}-${intent.d}`; default: return ''; } } /** The Iranian week starts Saturday. */ const WEEK_START_JS = WEEKDAY_TO_JS.saturday; /** Most recent Saturday, counting today if today is Saturday. */ function startOfWeek(iso: string): string { const back = (civilDateJsWeekday(iso) - WEEK_START_JS + 7) % 7; return addDays(iso, -back); } /** * `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so "by * Thursday" said on a Thursday means the next one — a deadline of today is almost never * what was meant, and this can never resolve into the past. * * `'next'` is *week*-anchored, not "this plus seven". "Thursday next week" means the * Thursday of the Saturday-start week after this one; adding a week to `'this'` would * overshoot by seven days whenever `'this'` had already rolled into next week. The two * can legitimately coincide — said on a Thursday, "the coming Saturday" and "Saturday * next week" are the same day. */ function resolveWeekday( intent: Extract, todayIso: string, ) { const targetJs = WEEKDAY_TO_JS[intent.weekday]; if (targetJs === undefined) return null; if (intent.which === 'this') { const todayJs = civilDateJsWeekday(todayIso); let delta = (targetJs - todayJs + 7) % 7; if (delta === 0) delta = 7; return addDays(todayIso, delta); } if (intent.which === 'next') { const offsetInWeek = (targetJs - WEEK_START_JS + 7) % 7; return addDays(startOfWeek(todayIso), 7 + offsetInWeek); } return null; } function resolveOffset( intent: Extract, todayIso: string, ) { const { amount, unit } = intent; if (!Number.isInteger(amount) || amount < 0) return null; if (unit === 'day') return amount <= MAX_DAYS_AHEAD ? addDays(todayIso, amount) : null; if (unit === 'week') return amount <= 260 ? addDays(todayIso, amount * 7) : null; if (unit === 'month') return amount <= 60 ? addMonths(todayIso, amount) : null; return null; } export function resolveDueDate( intent: DueIntent | null | undefined, todayIso: string, ): DueResolution { // Absent is not an error — most utterances carry no deadline. Anything else that is not // an intent object is a deadline we failed to understand, and must be flagged rather // than silently dropped. if (intent === null || intent === undefined) { return { dueDate: null, unresolved: null }; } if (typeof intent !== 'object') { return unresolved(String(intent).slice(0, 120)); } if (!isRealCivilDate(todayIso)) { return unresolved(describe(intent)); } let resolved: string | null = null; switch (intent.kind) { case 'weekday': resolved = resolveWeekday(intent, todayIso); break; case 'offset': resolved = resolveOffset(intent, todayIso); break; case 'jalali': resolved = jalaliToIsoDate(intent.jy, intent.jm, intent.jd); break; case 'gregorian': { const candidate = `${String(intent.y).padStart(4, '0')}-${String(intent.m).padStart(2, '0')}-${String(intent.d).padStart(2, '0')}`; resolved = isRealCivilDate(candidate) ? candidate : null; break; } default: resolved = null; } if (!resolved) return unresolved(describe(intent)); // An absolute date the model invented can land anywhere; a deadline in the past or // decades away is not a deadline. const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000; if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD) return unresolved(describe(intent)); return { dueDate: resolved, unresolved: null }; }