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>
This commit is contained in:
@@ -92,11 +92,25 @@ function describe(intent: DueIntent): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `'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<DueIntent, { kind: 'weekday' }>,
|
||||
@@ -104,13 +118,20 @@ function resolveWeekday(
|
||||
) {
|
||||
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);
|
||||
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(
|
||||
@@ -132,9 +153,15 @@ export function resolveDueDate(
|
||||
intent: DueIntent | null | undefined,
|
||||
todayIso: string,
|
||||
): DueResolution {
|
||||
if (!intent || typeof intent !== 'object') {
|
||||
// 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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user