2026-08-20 17:17:15 +03:30
|
|
|
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 '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.
"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.
The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.
- a non-object `due` (the model emitting a bare string) was treated as "no
deadline spoken" and silently discarded; only null/undefined mean absent now,
anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
table, contradicting the module's own "degrade to null" contract; they now
return false / 0, which also makes isValidJalaliDate's day check naturally
false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
RangeError before any fallback; it now validates and backstops to UTC, so a
bad zone costs at most a day rather than a 500
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30
|
|
|
/** 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 17:17:15 +03:30
|
|
|
/**
|
fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.
"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.
The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.
- a non-object `due` (the model emitting a bare string) was treated as "no
deadline spoken" and silently discarded; only null/undefined mean absent now,
anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
table, contradicting the module's own "degrade to null" contract; they now
return false / 0, which also makes isValidJalaliDate's day check naturally
false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
RangeError before any fallback; it now validates and backstops to UTC, so a
bad zone costs at most a day rather than a 500
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30
|
|
|
* `'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.
|
2026-08-20 17:17:15 +03:30
|
|
|
*/
|
|
|
|
|
function resolveWeekday(
|
|
|
|
|
intent: Extract<DueIntent, { kind: 'weekday' }>,
|
|
|
|
|
todayIso: string,
|
|
|
|
|
) {
|
|
|
|
|
const targetJs = WEEKDAY_TO_JS[intent.weekday];
|
|
|
|
|
if (targetJs === undefined) return null;
|
|
|
|
|
|
fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.
"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.
The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.
- a non-object `due` (the model emitting a bare string) was treated as "no
deadline spoken" and silently discarded; only null/undefined mean absent now,
anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
table, contradicting the module's own "degrade to null" contract; they now
return false / 0, which also makes isValidJalaliDate's day check naturally
false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
RangeError before any fallback; it now validates and backstops to UTC, so a
bad zone costs at most a day rather than a 500
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30
|
|
|
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;
|
2026-08-20 17:17:15 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.
"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.
The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.
- a non-object `due` (the model emitting a bare string) was treated as "no
deadline spoken" and silently discarded; only null/undefined mean absent now,
anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
table, contradicting the module's own "degrade to null" contract; they now
return false / 0, which also makes isValidJalaliDate's day check naturally
false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
RangeError before any fallback; it now validates and backstops to UTC, so a
bad zone costs at most a day rather than a 500
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30
|
|
|
// 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) {
|
2026-08-20 17:17:15 +03:30
|
|
|
return { dueDate: null, unresolved: null };
|
|
|
|
|
}
|
fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.
"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.
The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.
- a non-object `due` (the model emitting a bare string) was treated as "no
deadline spoken" and silently discarded; only null/undefined mean absent now,
anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
table, contradicting the module's own "degrade to null" contract; they now
return false / 0, which also makes isValidJalaliDate's day check naturally
false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
RangeError before any fallback; it now validates and backstops to UTC, so a
bad zone costs at most a day rather than a 500
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30
|
|
|
if (typeof intent !== 'object') {
|
|
|
|
|
return unresolved(String(intent).slice(0, 120));
|
|
|
|
|
}
|
2026-08-20 17:17:15 +03:30
|
|
|
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 };
|
|
|
|
|
}
|