feat(backend): resolve spoken deadlines to ISO dates
Jalali conversion is arithmetic here, not inference. A model asked to turn "۲۵ مهر" into ISO answers confidently and is often wrong, and @IsDateString() accepts the wrong answer — so the model emits a date intent and this decides what it means. Deviation from the spec, deliberately: the resolver takes todayIso rather than an IANA zone. Working in civil dates means nothing here reasons about instants. The zone is used one level up, where civilDateInZone() derives "today" from the actor's zone server-side — better than the spec's client-supplied date, which the client could set arbitrarily. Conventions pinned by tests: - "this <weekday>" is 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. - month offsets clamp to the end of shorter months (31 Jan + 1 = 28/29 Feb) - a resolved date in the past, or more than five years out, is treated as unresolved however it was arrived at — an absolute date the model invented can land anywhere - no due date at all is not an error; an unparseable one is, and echoes what was heard so the review sheet can show it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { appointmentWithinWorkingHours } from './working-hours';
|
||||
import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from './zoned-civil-time';
|
||||
import {
|
||||
civilDateInZone,
|
||||
isValidIanaTimeZone,
|
||||
zonedWeekdayAndMinutes,
|
||||
} from './zoned-civil-time';
|
||||
|
||||
describe('zoned civil time', () => {
|
||||
it('accepts IANA zones and rejects garbage', () => {
|
||||
@@ -20,10 +24,40 @@ describe('zoned civil time', () => {
|
||||
const start = new Date('2026-08-20T06:15:00.000Z');
|
||||
const end = new Date('2026-08-20T06:45:00.000Z');
|
||||
expect(
|
||||
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'Asia/Tehran'),
|
||||
appointmentWithinWorkingHours(
|
||||
start,
|
||||
end,
|
||||
[{ startMinute: 8 * 60, endMinute: 17 * 60 }],
|
||||
'Asia/Tehran',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'UTC'),
|
||||
appointmentWithinWorkingHours(
|
||||
start,
|
||||
end,
|
||||
[{ startMinute: 8 * 60, endMinute: 17 * 60 }],
|
||||
'UTC',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
describe('civilDateInZone', () => {
|
||||
it('gives the local civil date, which can differ from the UTC date', () => {
|
||||
// 21:30 UTC is already the next day in Tehran (+03:30).
|
||||
const instant = new Date('2025-10-11T21:30:00.000Z');
|
||||
expect(civilDateInZone(instant, 'UTC')).toBe('2025-10-11');
|
||||
expect(civilDateInZone(instant, 'Asia/Tehran')).toBe('2025-10-12');
|
||||
});
|
||||
|
||||
it('gives the previous day for zones behind UTC just after midnight', () => {
|
||||
const instant = new Date('2025-10-11T02:00:00.000Z');
|
||||
expect(civilDateInZone(instant, 'America/New_York')).toBe('2025-10-10');
|
||||
expect(civilDateInZone(instant, 'Europe/Amsterdam')).toBe('2025-10-11');
|
||||
});
|
||||
|
||||
it('zero-pads single-digit months and days', () => {
|
||||
expect(civilDateInZone(new Date('2025-01-05T12:00:00.000Z'), 'UTC')).toBe(
|
||||
'2025-01-05',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,3 +53,23 @@ export function civilDateJsWeekday(isoDate: string): number {
|
||||
const utcNoon = new Date(Date.UTC(y, m - 1, d, 12, 0, 0, 0));
|
||||
return utcNoon.getUTCDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's civil date (`YYYY-MM-DD`) in an IANA zone.
|
||||
*
|
||||
* Lets the server derive "today" from a client-supplied time zone instead of trusting a
|
||||
* client-supplied date, which matters for relative deadlines like "by Thursday".
|
||||
*/
|
||||
export function civilDateInZone(date: Date, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(date);
|
||||
|
||||
const year = parts.find((p) => p.type === 'year')?.value ?? '1970';
|
||||
const month = parts.find((p) => p.type === 'month')?.value ?? '01';
|
||||
const day = parts.find((p) => p.type === 'day')?.value ?? '01';
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
221
backend/src/modules/voice/due-date.resolver.spec.ts
Normal file
221
backend/src/modules/voice/due-date.resolver.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { resolveDueDate } from './due-date.resolver';
|
||||
import type { DueIntent } from './voice.types';
|
||||
|
||||
// 2025-10-11 is a Saturday — the first day of the Iranian week.
|
||||
const SATURDAY = '2025-10-11';
|
||||
const THURSDAY = '2025-10-16';
|
||||
|
||||
describe('resolveDueDate', () => {
|
||||
describe('weekday intents', () => {
|
||||
it('resolves "this <weekday>" to the coming occurrence in the same week', () => {
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
||||
SATURDAY,
|
||||
);
|
||||
expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days in a Saturday-start week
|
||||
});
|
||||
|
||||
it('resolves "next <weekday>" to the following week', () => {
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
|
||||
SATURDAY,
|
||||
);
|
||||
expect(result.dueDate).toBe('2025-10-23');
|
||||
});
|
||||
|
||||
it('reads "by Thursday" said on a Thursday as the next one, not today', () => {
|
||||
// A deadline of today is almost never what was meant.
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
||||
THURSDAY,
|
||||
);
|
||||
expect(result.dueDate).toBe('2025-10-23');
|
||||
});
|
||||
|
||||
it('resolves the Saturday that starts the next week', () => {
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'saturday', which: 'this' },
|
||||
SATURDAY,
|
||||
);
|
||||
expect(result.dueDate).toBe('2025-10-18');
|
||||
});
|
||||
|
||||
it('rejects an unknown weekday or qualifier', () => {
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{
|
||||
kind: 'weekday',
|
||||
weekday: 'caturday',
|
||||
which: 'this',
|
||||
} as unknown as DueIntent,
|
||||
SATURDAY,
|
||||
).dueDate,
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{
|
||||
kind: 'weekday',
|
||||
weekday: 'thursday',
|
||||
which: 'soon',
|
||||
} as unknown as DueIntent,
|
||||
SATURDAY,
|
||||
).dueDate,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('offset intents', () => {
|
||||
it('adds days, weeks and months', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBe('2025-10-12');
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'week', amount: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBe('2025-10-18');
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'month', amount: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBe('2025-11-11');
|
||||
});
|
||||
|
||||
it('clamps to the end of a shorter month', () => {
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'month', amount: 1 },
|
||||
'2025-01-31',
|
||||
).dueDate,
|
||||
).toBe('2025-02-28');
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'month', amount: 1 },
|
||||
'2024-01-31',
|
||||
).dueDate,
|
||||
).toBe('2024-02-29');
|
||||
});
|
||||
|
||||
it('crosses a year boundary', () => {
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'month', amount: 3 },
|
||||
'2025-11-15',
|
||||
).dueDate,
|
||||
).toBe('2026-02-15');
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'day', amount: 30 },
|
||||
'2025-12-20',
|
||||
).dueDate,
|
||||
).toBe('2026-01-19');
|
||||
});
|
||||
|
||||
it('rejects negative, fractional and absurd amounts', () => {
|
||||
for (const amount of [-1, 1.5, 10_000, Number.NaN]) {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'day', amount }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('jalali intents', () => {
|
||||
it('converts by arithmetic, not inference', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBe('2025-10-17');
|
||||
});
|
||||
|
||||
it('handles the leap-year Esfand 30', () => {
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{ kind: 'jalali', jy: 1403, jm: 12, jd: 30 },
|
||||
'2025-03-01',
|
||||
).dueDate,
|
||||
).toBe('2025-03-20');
|
||||
});
|
||||
|
||||
it('rejects Esfand 30 in a non-leap year', () => {
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
|
||||
SATURDAY,
|
||||
);
|
||||
expect(result.dueDate).toBeNull();
|
||||
expect(result.unresolved?.reason).toBe('invalid_date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gregorian intents', () => {
|
||||
it('accepts a real date and rejects an impossible one', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 10, d: 17 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBe('2025-10-17');
|
||||
expect(
|
||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 2, d: 30 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 13, d: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('guard rails', () => {
|
||||
it('treats a past date as unresolved', () => {
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'gregorian', y: 2020, m: 1, d: 1 },
|
||||
SATURDAY,
|
||||
);
|
||||
expect(result.dueDate).toBeNull();
|
||||
expect(result.unresolved?.reason).toBe('invalid_date');
|
||||
});
|
||||
|
||||
it('treats a date decades away as unresolved', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'gregorian', y: 2099, m: 1, d: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts today itself via a zero-day offset', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'day', amount: 0 }, SATURDAY)
|
||||
.dueDate,
|
||||
).toBe(SATURDAY);
|
||||
});
|
||||
|
||||
it('reports no due date at all when the model said nothing, without flagging it', () => {
|
||||
expect(resolveDueDate(null, SATURDAY)).toEqual({
|
||||
dueDate: null,
|
||||
unresolved: null,
|
||||
});
|
||||
expect(resolveDueDate(undefined, SATURDAY)).toEqual({
|
||||
dueDate: null,
|
||||
unresolved: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades rather than throwing on a malformed today or intent', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, 'not-a-date')
|
||||
.dueDate,
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDueDate({ kind: 'nope' } as unknown as DueIntent, SATURDAY)
|
||||
.dueDate,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('echoes what was heard so the review sheet can show it', () => {
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
|
||||
SATURDAY,
|
||||
);
|
||||
expect(result.unresolved?.spoken).toBe('1404/12/30');
|
||||
});
|
||||
});
|
||||
});
|
||||
171
backend/src/modules/voice/due-date.resolver.ts
Normal file
171
backend/src/modules/voice/due-date.resolver.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user