feat(treatment): add voice detail entry #65

Merged
admin merged 32 commits from feat/voice-treatment-entry into master 2026-08-24 11:13:48 +03:30
4 changed files with 449 additions and 3 deletions
Showing only changes of commit 70092e932d - Show all commits

View File

@@ -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',
);
});
});
});

View File

@@ -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}`;
}

View 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');
});
});
});

View 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 };
}