172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
|
|
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<Weekday, number> = {
|
||
|
|
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 '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* `which: 'this'` means 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.
|
||
|
|
* `'next'` adds a further week. The Iranian week starts Saturday, which this arithmetic is
|
||
|
|
* agnostic to (it counts forward from today), but the tests pin it explicitly.
|
||
|
|
*/
|
||
|
|
function resolveWeekday(
|
||
|
|
intent: Extract<DueIntent, { kind: 'weekday' }>,
|
||
|
|
todayIso: string,
|
||
|
|
) {
|
||
|
|
const targetJs = WEEKDAY_TO_JS[intent.weekday];
|
||
|
|
if (targetJs === undefined) return null;
|
||
|
|
if (intent.which !== 'this' && intent.which !== 'next') return null;
|
||
|
|
|
||
|
|
const todayJs = civilDateJsWeekday(todayIso);
|
||
|
|
let delta = (targetJs - todayJs + 7) % 7;
|
||
|
|
if (delta === 0) delta = 7;
|
||
|
|
if (intent.which === 'next') delta += 7;
|
||
|
|
return addDays(todayIso, delta);
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveOffset(
|
||
|
|
intent: Extract<DueIntent, { kind: 'offset' }>,
|
||
|
|
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 {
|
||
|
|
if (!intent || typeof intent !== 'object') {
|
||
|
|
return { dueDate: null, unresolved: null };
|
||
|
|
}
|
||
|
|
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 };
|
||
|
|
}
|