fix(backend): stop showing the clinician null, NaN and the wrong failure
Two ways a voice failure described itself wrongly. describe() built the quoted-back text from fields that are all nullable on the wire, and toVoiceIntent casts rather than checks — so a half-classified deadline rendered as “null null” — not a usable date, and an offset with no amount as “+NaN day”. Blank is already handled by the sheet; it now falls back to that. The DTO's constraints resolved to unrelated codes: maxLength fell through to VALIDATION_FIELD_REQUIRED, so an oversized recording said a field was missing, and isIn maps to VALIDATION_LANGUAGE_INVALID, so an unsupported container said the language was invalid. Both now name their own code — the validation factory already returns a message verbatim when it is itself a known ErrorCode, so this needs no change to the shared mapping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -187,6 +187,7 @@ export const ErrorCode = {
|
|||||||
// Voice treatment entry
|
// Voice treatment entry
|
||||||
VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE',
|
VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE',
|
||||||
VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG',
|
VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG',
|
||||||
|
VOICE_UNSUPPORTED_FORMAT: 'VOICE_UNSUPPORTED_FORMAT',
|
||||||
VOICE_ASR_FAILED: 'VOICE_ASR_FAILED',
|
VOICE_ASR_FAILED: 'VOICE_ASR_FAILED',
|
||||||
VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED',
|
VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED',
|
||||||
VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED',
|
VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { ErrorCode } from '../../../common/errors/error-codes';
|
||||||
|
|
||||||
/** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */
|
/** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */
|
||||||
export const VOICE_AUDIO_FORMATS = [
|
export const VOICE_AUDIO_FORMATS = [
|
||||||
@@ -32,10 +33,14 @@ export class ExtractVoiceDto {
|
|||||||
*/
|
*/
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsBase64()
|
@IsBase64()
|
||||||
@MaxLength(8_000_000)
|
// Both constraints name their own code. Left to the default mapping, `maxLength` falls
|
||||||
|
// through to VALIDATION_FIELD_REQUIRED and `isIn` resolves to
|
||||||
|
// VALIDATION_LANGUAGE_INVALID — so an oversized recording told the clinician a field
|
||||||
|
// was missing, and an unsupported container told them their language was invalid.
|
||||||
|
@MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG })
|
||||||
audio: string;
|
audio: string;
|
||||||
|
|
||||||
@IsIn(VOICE_AUDIO_FORMATS)
|
@IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })
|
||||||
format: VoiceAudioFormat;
|
format: VoiceAudioFormat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -346,3 +346,40 @@ describe('resolveDueDate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('what an unresolvable deadline quotes back', () => {
|
||||||
|
// The wire shape allows nulls in every field and toVoiceIntent casts rather than
|
||||||
|
// checks, so these reach the resolver intact. The sheet renders `spoken` verbatim.
|
||||||
|
it('never puts "null" or "NaN" in front of the clinician', () => {
|
||||||
|
const bad = [
|
||||||
|
{ kind: 'weekday', weekday: null, which: null },
|
||||||
|
{ kind: 'offset', unit: null, amount: null },
|
||||||
|
{ kind: 'offset', unit: 'day', amount: Number.NaN },
|
||||||
|
{ kind: 'jalali', jy: null, jm: 7, jd: 25 },
|
||||||
|
{ kind: 'gregorian', y: 2026, m: null, d: null },
|
||||||
|
];
|
||||||
|
for (const intent of bad) {
|
||||||
|
const result = resolveDueDate(
|
||||||
|
intent as unknown as DueIntent,
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
);
|
||||||
|
expect(result.dueDate).toBeNull();
|
||||||
|
expect(result.unresolved?.spoken ?? '').not.toMatch(/null|NaN/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still quotes a deadline it did understand the words of', () => {
|
||||||
|
const result = resolveDueDate(
|
||||||
|
{
|
||||||
|
kind: 'weekday',
|
||||||
|
weekday: 'thursday',
|
||||||
|
which: null,
|
||||||
|
} as unknown as DueIntent,
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
);
|
||||||
|
// A weekday with no "this/next" resolves, so nothing is quoted back at all.
|
||||||
|
expect(result.dueDate).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,16 +77,36 @@ function unresolved(spoken: string): DueResolution {
|
|||||||
return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } };
|
return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to quote back when a deadline could not be resolved.
|
||||||
|
*
|
||||||
|
* Every field here is nullable on the wire and `toVoiceIntent` casts rather than checks,
|
||||||
|
* so a half-classified deadline arrives with nulls in it. The review sheet renders this
|
||||||
|
* verbatim — `"null null" — not a usable date` in front of a clinician is worse than the
|
||||||
|
* reason on its own, which the sheet already handles for a blank string.
|
||||||
|
*/
|
||||||
function describe(intent: DueIntent): string {
|
function describe(intent: DueIntent): string {
|
||||||
|
const usable = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value);
|
||||||
|
|
||||||
switch (intent?.kind) {
|
switch (intent?.kind) {
|
||||||
case 'weekday':
|
case 'weekday':
|
||||||
return `${intent.which} ${intent.weekday}`;
|
// `which` is legitimately null (it means "this"), the weekday is not.
|
||||||
|
return [intent.which, intent.weekday]
|
||||||
|
.filter((part) => typeof part === 'string')
|
||||||
|
.join(' ');
|
||||||
case 'offset':
|
case 'offset':
|
||||||
return `+${intent.amount} ${intent.unit}`;
|
return usable(intent.amount)
|
||||||
|
? `+${intent.amount} ${intent.unit ?? ''}`.trim()
|
||||||
|
: '';
|
||||||
case 'jalali':
|
case 'jalali':
|
||||||
return `${intent.jy}/${intent.jm}/${intent.jd}`;
|
return [intent.jy, intent.jm, intent.jd].every(usable)
|
||||||
|
? `${intent.jy}/${intent.jm}/${intent.jd}`
|
||||||
|
: '';
|
||||||
case 'gregorian':
|
case 'gregorian':
|
||||||
return `${intent.y}-${intent.m}-${intent.d}`;
|
return [intent.y, intent.m, intent.d].every(usable)
|
||||||
|
? `${intent.y}-${intent.m}-${intent.d}`
|
||||||
|
: '';
|
||||||
default: {
|
default: {
|
||||||
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
|
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
|
||||||
// established is a string — echo it so the review row names what was heard.
|
// established is a string — echo it so the review row names what was heard.
|
||||||
|
|||||||
@@ -1267,6 +1267,7 @@
|
|||||||
"VOICE_MIC_DENIED": "Microphone access was blocked. Allow it in your browser settings and try again.",
|
"VOICE_MIC_DENIED": "Microphone access was blocked. Allow it in your browser settings and try again.",
|
||||||
"VOICE_NOT_AVAILABLE": "Voice entry is not available for this language yet.",
|
"VOICE_NOT_AVAILABLE": "Voice entry is not available for this language yet.",
|
||||||
"VOICE_CLIP_TOO_LONG": "That recording is too long. Please keep it under two minutes.",
|
"VOICE_CLIP_TOO_LONG": "That recording is too long. Please keep it under two minutes.",
|
||||||
|
"VOICE_UNSUPPORTED_FORMAT": "That recording format is not supported on this device.",
|
||||||
"VOICE_ASR_FAILED": "Could not turn the recording into text. Please try again.",
|
"VOICE_ASR_FAILED": "Could not turn the recording into text. Please try again.",
|
||||||
"VOICE_EXTRACT_FAILED": "Could not read the treatment details from the recording.",
|
"VOICE_EXTRACT_FAILED": "Could not read the treatment details from the recording.",
|
||||||
"VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.",
|
"VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.",
|
||||||
|
|||||||
@@ -1268,6 +1268,7 @@
|
|||||||
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
|
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
|
||||||
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
|
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
|
||||||
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
|
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
|
||||||
|
"VOICE_UNSUPPORTED_FORMAT": "قالب این ضبط پشتیبانی نمیشود.",
|
||||||
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
|
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
|
||||||
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
|
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
|
||||||
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
|
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
|
||||||
|
|||||||
@@ -1267,6 +1267,7 @@
|
|||||||
"VOICE_MIC_DENIED": "Microfoontoegang is geblokkeerd. Sta dit toe in uw browserinstellingen en probeer opnieuw.",
|
"VOICE_MIC_DENIED": "Microfoontoegang is geblokkeerd. Sta dit toe in uw browserinstellingen en probeer opnieuw.",
|
||||||
"VOICE_NOT_AVAILABLE": "Spraakinvoer is nog niet beschikbaar voor deze taal.",
|
"VOICE_NOT_AVAILABLE": "Spraakinvoer is nog niet beschikbaar voor deze taal.",
|
||||||
"VOICE_CLIP_TOO_LONG": "Die opname is te lang. Houd het onder twee minuten.",
|
"VOICE_CLIP_TOO_LONG": "Die opname is te lang. Houd het onder twee minuten.",
|
||||||
|
"VOICE_UNSUPPORTED_FORMAT": "Dit opnameformaat wordt niet ondersteund.",
|
||||||
"VOICE_ASR_FAILED": "De opname kon niet naar tekst worden omgezet. Probeer het opnieuw.",
|
"VOICE_ASR_FAILED": "De opname kon niet naar tekst worden omgezet. Probeer het opnieuw.",
|
||||||
"VOICE_EXTRACT_FAILED": "De behandelgegevens konden niet uit de opname worden gelezen.",
|
"VOICE_EXTRACT_FAILED": "De behandelgegevens konden niet uit de opname worden gelezen.",
|
||||||
"VOICE_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.",
|
"VOICE_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.",
|
||||||
|
|||||||
Reference in New Issue
Block a user