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}`;
|
feat(backend): assemble resolved extraction from voice intents
Composes the tooth, span, prosthesis, catalog and date resolvers into the
payload the review sheet renders.
Connected spans expand: "a bridge from 14 to 16" selects 15, which was never
spoken. Overlapping spans merge into one bridge, group teeth sort along the
arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to
a single tooth degrades to a single group without losing that tooth — there is
no such thing as a one-tooth bridge. A cross-arch span is impossible and is
reported rather than guessed at.
Prosthesis expands a default across the selection then applies per-tooth
overrides, because "همه زیرکونیا، ۲۶ پیافام" is how clinicians actually speak.
Completeness is computed here so an unshippable map surfaces at review rather
than failing later at dispatch.
Everything the model names is checked against the catalog we supplied it, and
anything rejected is reported rather than dropped — a hallucinated lab id must
not look identical to "no lab was spoken", since silence and a wrong lab lead
to very different corrective actions.
Also fixed, from review of this commit:
- an empty prosthesis object no longer fabricates an "incomplete, cannot ship"
warning on a plain restoration
- an override naming a tooth outside the selection now reports
tooth_not_selected rather than malformed; the clinician was understood, the
tooth just is not on this detail
- a due object with no `kind` is treated as no deadline rather than a blank
"heard but lost" row; an unrecognised kind is still flagged, and named
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:41 +03:30
|
|
|
default: {
|
|
|
|
|
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
|
|
|
|
|
// established is a string — echo it so the review row names what was heard.
|
|
|
|
|
const kind = (intent as { kind?: unknown })?.kind;
|
|
|
|
|
return typeof kind === 'string' ? kind : '';
|
|
|
|
|
}
|
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
|
|
|
/** 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));
|
|
|
|
|
}
|
feat(backend): assemble resolved extraction from voice intents
Composes the tooth, span, prosthesis, catalog and date resolvers into the
payload the review sheet renders.
Connected spans expand: "a bridge from 14 to 16" selects 15, which was never
spoken. Overlapping spans merge into one bridge, group teeth sort along the
arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to
a single tooth degrades to a single group without losing that tooth — there is
no such thing as a one-tooth bridge. A cross-arch span is impossible and is
reported rather than guessed at.
Prosthesis expands a default across the selection then applies per-tooth
overrides, because "همه زیرکونیا، ۲۶ پیافام" is how clinicians actually speak.
Completeness is computed here so an unshippable map surfaces at review rather
than failing later at dispatch.
Everything the model names is checked against the catalog we supplied it, and
anything rejected is reported rather than dropped — a hallucinated lab id must
not look identical to "no lab was spoken", since silence and a wrong lab lead
to very different corrective actions.
Also fixed, from review of this commit:
- an empty prosthesis object no longer fabricates an "incomplete, cannot ship"
warning on a plain restoration
- an override naming a tooth outside the selection now reports
tooth_not_selected rather than malformed; the clinician was understood, the
tooth just is not on this detail
- a due object with no `kind` is treated as no deadline rather than a blank
"heard but lost" row; an unrecognised kind is still flagged, and named
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:41 +03:30
|
|
|
// An object carrying no `kind` at all says nothing about a deadline; flagging it would
|
|
|
|
|
// put a blank "heard but lost" row in front of a clinician who never mentioned one. An
|
|
|
|
|
// object with an *unrecognised* kind did try to say something, and is flagged below.
|
|
|
|
|
if (typeof (intent as { kind?: unknown }).kind !== 'string') {
|
|
|
|
|
return { dueDate: null, unresolved: null };
|
|
|
|
|
}
|
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 };
|
|
|
|
|
}
|