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>
75 lines
2.3 KiB
TypeScript
75 lines
2.3 KiB
TypeScript
import {
|
|
IsBase64,
|
|
IsIn,
|
|
IsInt,
|
|
IsString,
|
|
MaxLength,
|
|
Min,
|
|
} from 'class-validator';
|
|
import { ErrorCode } from '../../../common/errors/error-codes';
|
|
|
|
/** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */
|
|
export const VOICE_AUDIO_FORMATS = [
|
|
'webm',
|
|
'mp4',
|
|
'm4a',
|
|
'aac',
|
|
'ogg',
|
|
'wav',
|
|
'mp3',
|
|
'flac',
|
|
] as const;
|
|
|
|
export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number];
|
|
|
|
/** Locales the app ships; a profile still has to be configured for one to be usable. */
|
|
export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const;
|
|
|
|
export class ExtractVoiceDto {
|
|
/**
|
|
* Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but
|
|
* far below OpenRouter's 25 MB ceiling, so an oversized upload is rejected before it
|
|
* costs a vendor call.
|
|
*/
|
|
@IsString()
|
|
@IsBase64()
|
|
// 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;
|
|
|
|
@IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })
|
|
format: VoiceAudioFormat;
|
|
|
|
/**
|
|
* The clinician's IANA zone. The server derives "today" from it rather than trusting a
|
|
* client-supplied date, which is what relative deadlines resolve against.
|
|
*/
|
|
@IsString()
|
|
@MaxLength(64)
|
|
timeZone: string;
|
|
|
|
/**
|
|
* Recording length as measured by the client.
|
|
*
|
|
* Required, not optional: an optional value means omitting it bypasses
|
|
* VOICE_MAX_RECORDING_MS entirely, which would make the cap advisory.
|
|
*/
|
|
@IsInt()
|
|
@Min(0)
|
|
durationMs: number;
|
|
|
|
/**
|
|
* The locale the clinician is actually speaking, as the UI offered the microphone.
|
|
*
|
|
* Sent explicitly rather than read from `user.language`: the two can diverge (a
|
|
* bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would
|
|
* transcribe Persian with an English hint and anchor "next Thursday" to the wrong
|
|
* week start. Gating the button and resolving the request must agree by construction.
|
|
*/
|
|
@IsIn(VOICE_LOCALES)
|
|
locale: string;
|
|
}
|