feat(backend): voice extraction endpoint
POST /voice/extract behind JwtAuthGuard + ClinicOrgGuard, plus GET /voice/availability so the frontend can decide whether to render the microphone — it cannot learn that from NEXT_PUBLIC_*, which are baked in at build time. Audio is held in memory for the request only: never written to disk, never a Prisma row. The transcript goes back to the client and is not persisted. What is logged is structured and patient-free — clip length, which fields resolved, unresolved count, vendor cost, outcome — with log lines as the interim sink until this repo has metrics infrastructure. On extraction failure the transcript still travels back in the error details, so the words the clinician already paid for can be salvaged into a note. v1 ships ungated beyond a configured locale profile; the Plan.features design is deferred, not dropped. From review of this commit, four of which were load-bearing: - Express's 100 kb default body limit rejected any recording past ~20 seconds, making the endpoint unusable at its own 2-minute cap. Body parsers are now registered explicitly with a 10 MB limit scoped to the voice route only. Verified empirically: 600 KB reaches /api/voice/extract, while /api/auth/login still 413s. - ThrottlerGuard keys on req.ip, so behind nginx the whole deployment would share one bucket and an abuser rotating IPs would bypass it. VoiceThrottlerGuard keys on the user id instead — with no plan gate, this is the only control on metered vendor spend. - ThrottlerException had no 429 fallback and surfaced as INTERNAL_ERROR; the guard now throws VOICE_RATE_LIMITED directly. - durationMs was optional, so omitting it bypassed VOICE_MAX_RECORDING_MS entirely. It is required. - VOICE_UNSUPPORTED_FORMAT was dead code — the DTO's @IsIn already rejects unknown containers — so it is gone rather than left unreachable. ThrottlerModule is deliberately not bound as a global APP_GUARD: a global ThrottlerGuard rate-limits every route against every named throttler, which would have capped the whole API at the voice limit. All seven remaining VOICE_* codes have errors.* keys in en, fa and nl. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,3 +47,28 @@ SMTP_PASSWORD=your_app_password
|
|||||||
# SMS_IR_API_KEY=4QKMiSU4Kh7tWPLCdRMV0QpDh8WgF33YkWRS18BcG3vf4QHi
|
# SMS_IR_API_KEY=4QKMiSU4Kh7tWPLCdRMV0QpDh8WgF33YkWRS18BcG3vf4QHi
|
||||||
SMS_IR_API_KEY=lwbK7hxmjimNjFS4g5DWahh75EKCgJUfcUIinUQzfQXwXkSp
|
SMS_IR_API_KEY=lwbK7hxmjimNjFS4g5DWahh75EKCgJUfcUIinUQzfQXwXkSp
|
||||||
SMS_IR_TEMPLATE_ID=123456
|
SMS_IR_TEMPLATE_ID=123456
|
||||||
|
|
||||||
|
# ── Voice treatment entry ──────────────────────────────────────────────────────
|
||||||
|
# Without OPENROUTER_API_KEY the microphone button does not render at all.
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
|
||||||
|
# Locales the microphone is offered in. An unknown locale here fails at boot.
|
||||||
|
# VOICE_ENABLED_LOCALES=fa,en,nl
|
||||||
|
|
||||||
|
# Models, overridable per locale (VOICE_ASR_MODEL_FA, VOICE_LLM_MODEL_NL, ...).
|
||||||
|
# All locales share these today; the per-locale override exists so Persian can be
|
||||||
|
# repointed at a specialist ASR vendor without a code change.
|
||||||
|
# VOICE_ASR_MODEL=openai/whisper-1
|
||||||
|
# VOICE_LLM_MODEL=google/gemini-3.7-flash
|
||||||
|
# VOICE_ASR_PROVIDER_FA=openrouter
|
||||||
|
# VOICE_LLM_PROVIDER_FA=openrouter
|
||||||
|
|
||||||
|
# Recording cap in ms (0 = uncapped). 2 minutes bounds worst-case vendor spend at
|
||||||
|
# about 1.3 cents per recording.
|
||||||
|
# VOICE_MAX_RECORDING_MS=120000
|
||||||
|
|
||||||
|
# Per-user rate limit on the extract endpoint. Unreachable by a human — a recording
|
||||||
|
# plus processing takes ten seconds at minimum — so it is purely an abuse guard.
|
||||||
|
# VOICE_THROTTLE_TTL=60
|
||||||
|
# VOICE_THROTTLE_LIMIT=6
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import configurations from './configs/configurations';
|
import configurations from './configs/configurations';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
@@ -20,6 +21,7 @@ import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comm
|
|||||||
import { TodayModule } from './modules/today/today.module';
|
import { TodayModule } from './modules/today/today.module';
|
||||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
import { RealtimeModule } from './realtime/realtime.module';
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
|
import { VoiceModule } from './modules/voice/voice.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -27,6 +29,22 @@ import { RealtimeModule } from './realtime/realtime.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [configurations],
|
load: [configurations],
|
||||||
}),
|
}),
|
||||||
|
// First use of @nestjs/throttler in this app. Deliberately NOT bound as a global
|
||||||
|
// APP_GUARD: a globally-bound ThrottlerGuard rate-limits every route against every
|
||||||
|
// named throttler, which would cap the whole API at the voice limit. ThrottlerGuard
|
||||||
|
// is applied to the one expensive route instead, so nothing else changes behaviour.
|
||||||
|
ThrottlerModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
throttlers: [
|
||||||
|
{
|
||||||
|
name: 'voice',
|
||||||
|
ttl: (config.get<number>('voice.throttle.ttl') ?? 60) * 1000,
|
||||||
|
limit: config.get<number>('voice.throttle.limit') ?? 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
PrismaModule, // ✅ ADD THIS
|
PrismaModule, // ✅ ADD THIS
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
TreatmentCatalogModule,
|
TreatmentCatalogModule,
|
||||||
@@ -43,9 +61,10 @@ import { RealtimeModule } from './realtime/realtime.module';
|
|||||||
TodayModule,
|
TodayModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
|
VoiceModule,
|
||||||
AdminModule.forRoot(),
|
AdminModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ export const ErrorCode = {
|
|||||||
PERMISSION_LAB_ONLY: 'PERMISSION_LAB_ONLY',
|
PERMISSION_LAB_ONLY: 'PERMISSION_LAB_ONLY',
|
||||||
PERMISSION_NOT_MEMBER: 'PERMISSION_NOT_MEMBER',
|
PERMISSION_NOT_MEMBER: 'PERMISSION_NOT_MEMBER',
|
||||||
PERMISSION_OWNER_ONLY: 'PERMISSION_OWNER_ONLY',
|
PERMISSION_OWNER_ONLY: 'PERMISSION_OWNER_ONLY',
|
||||||
PERMISSION_PARTICIPATION_SUBSCRIPTION: 'PERMISSION_PARTICIPATION_SUBSCRIPTION',
|
PERMISSION_PARTICIPATION_SUBSCRIPTION:
|
||||||
PERMISSION_ENABLE_PARTICIPATION_FIRST: 'PERMISSION_ENABLE_PARTICIPATION_FIRST',
|
'PERMISSION_PARTICIPATION_SUBSCRIPTION',
|
||||||
|
PERMISSION_ENABLE_PARTICIPATION_FIRST:
|
||||||
|
'PERMISSION_ENABLE_PARTICIPATION_FIRST',
|
||||||
PERMISSION_CLINIC_WORKING_HOURS: 'PERMISSION_CLINIC_WORKING_HOURS',
|
PERMISSION_CLINIC_WORKING_HOURS: 'PERMISSION_CLINIC_WORKING_HOURS',
|
||||||
PERMISSION_ACCESS_APPOINTMENTS: 'PERMISSION_ACCESS_APPOINTMENTS',
|
PERMISSION_ACCESS_APPOINTMENTS: 'PERMISSION_ACCESS_APPOINTMENTS',
|
||||||
PERMISSION_EDIT_APPOINTMENTS: 'PERMISSION_EDIT_APPOINTMENTS',
|
PERMISSION_EDIT_APPOINTMENTS: 'PERMISSION_EDIT_APPOINTMENTS',
|
||||||
@@ -51,7 +53,8 @@ export const ErrorCode = {
|
|||||||
VALIDATION_PASSWORD_REQUIRED: 'VALIDATION_PASSWORD_REQUIRED',
|
VALIDATION_PASSWORD_REQUIRED: 'VALIDATION_PASSWORD_REQUIRED',
|
||||||
VALIDATION_MOBILE_INVALID: 'VALIDATION_MOBILE_INVALID',
|
VALIDATION_MOBILE_INVALID: 'VALIDATION_MOBILE_INVALID',
|
||||||
VALIDATION_NAME_TOO_SHORT: 'VALIDATION_NAME_TOO_SHORT',
|
VALIDATION_NAME_TOO_SHORT: 'VALIDATION_NAME_TOO_SHORT',
|
||||||
VALIDATION_ORGANIZATION_NAME_REQUIRED: 'VALIDATION_ORGANIZATION_NAME_REQUIRED',
|
VALIDATION_ORGANIZATION_NAME_REQUIRED:
|
||||||
|
'VALIDATION_ORGANIZATION_NAME_REQUIRED',
|
||||||
VALIDATION_ORGANIZATION_TYPE_INVALID: 'VALIDATION_ORGANIZATION_TYPE_INVALID',
|
VALIDATION_ORGANIZATION_TYPE_INVALID: 'VALIDATION_ORGANIZATION_TYPE_INVALID',
|
||||||
VALIDATION_TOKEN_REQUIRED: 'VALIDATION_TOKEN_REQUIRED',
|
VALIDATION_TOKEN_REQUIRED: 'VALIDATION_TOKEN_REQUIRED',
|
||||||
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
|
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
|
||||||
@@ -69,8 +72,10 @@ export const ErrorCode = {
|
|||||||
APPOINTMENT_INVALID_DATE: 'APPOINTMENT_INVALID_DATE',
|
APPOINTMENT_INVALID_DATE: 'APPOINTMENT_INVALID_DATE',
|
||||||
APPOINTMENT_PROVIDER_NOT_MEMBER: 'APPOINTMENT_PROVIDER_NOT_MEMBER',
|
APPOINTMENT_PROVIDER_NOT_MEMBER: 'APPOINTMENT_PROVIDER_NOT_MEMBER',
|
||||||
APPOINTMENT_PROVIDER_INACTIVE: 'APPOINTMENT_PROVIDER_INACTIVE',
|
APPOINTMENT_PROVIDER_INACTIVE: 'APPOINTMENT_PROVIDER_INACTIVE',
|
||||||
APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT: 'APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT',
|
APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT:
|
||||||
APPOINTMENT_PROVIDER_NO_WORKING_HOURS: 'APPOINTMENT_PROVIDER_NO_WORKING_HOURS',
|
'APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT',
|
||||||
|
APPOINTMENT_PROVIDER_NO_WORKING_HOURS:
|
||||||
|
'APPOINTMENT_PROVIDER_NO_WORKING_HOURS',
|
||||||
APPOINTMENT_PROVIDER_NOT_WORKING_DAY: 'APPOINTMENT_PROVIDER_NOT_WORKING_DAY',
|
APPOINTMENT_PROVIDER_NOT_WORKING_DAY: 'APPOINTMENT_PROVIDER_NOT_WORKING_DAY',
|
||||||
APPOINTMENT_OUTSIDE_WORKING_HOURS: 'APPOINTMENT_OUTSIDE_WORKING_HOURS',
|
APPOINTMENT_OUTSIDE_WORKING_HOURS: 'APPOINTMENT_OUTSIDE_WORKING_HOURS',
|
||||||
APPOINTMENT_NOT_FOUND: 'APPOINTMENT_NOT_FOUND',
|
APPOINTMENT_NOT_FOUND: 'APPOINTMENT_NOT_FOUND',
|
||||||
@@ -82,7 +87,8 @@ export const ErrorCode = {
|
|||||||
|
|
||||||
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
|
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
|
||||||
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
|
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
|
||||||
WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS: 'WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS',
|
WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS:
|
||||||
|
'WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS',
|
||||||
|
|
||||||
STAFF_UNKNOWN_PERMISSIONS: 'STAFF_UNKNOWN_PERMISSIONS',
|
STAFF_UNKNOWN_PERMISSIONS: 'STAFF_UNKNOWN_PERMISSIONS',
|
||||||
STAFF_NO_SUBSCRIPTION: 'STAFF_NO_SUBSCRIPTION',
|
STAFF_NO_SUBSCRIPTION: 'STAFF_NO_SUBSCRIPTION',
|
||||||
@@ -147,7 +153,8 @@ export const ErrorCode = {
|
|||||||
TREATMENT_ATTACHMENT_NOT_FOUND: 'TREATMENT_ATTACHMENT_NOT_FOUND',
|
TREATMENT_ATTACHMENT_NOT_FOUND: 'TREATMENT_ATTACHMENT_NOT_FOUND',
|
||||||
TREATMENT_FILE_UNAVAILABLE: 'TREATMENT_FILE_UNAVAILABLE',
|
TREATMENT_FILE_UNAVAILABLE: 'TREATMENT_FILE_UNAVAILABLE',
|
||||||
TREATMENT_CASE_INVALID_ATTACHMENTS: 'TREATMENT_CASE_INVALID_ATTACHMENTS',
|
TREATMENT_CASE_INVALID_ATTACHMENTS: 'TREATMENT_CASE_INVALID_ATTACHMENTS',
|
||||||
TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE: 'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE',
|
TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE:
|
||||||
|
'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE',
|
||||||
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
|
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
|
||||||
TREATMENT_NOT_FOUND: 'TREATMENT_NOT_FOUND',
|
TREATMENT_NOT_FOUND: 'TREATMENT_NOT_FOUND',
|
||||||
TREATMENT_PATIENT_OR_WALK_IN: 'TREATMENT_PATIENT_OR_WALK_IN',
|
TREATMENT_PATIENT_OR_WALK_IN: 'TREATMENT_PATIENT_OR_WALK_IN',
|
||||||
@@ -177,6 +184,14 @@ export const ErrorCode = {
|
|||||||
TODAY_INVALID_RANGE: 'TODAY_INVALID_RANGE',
|
TODAY_INVALID_RANGE: 'TODAY_INVALID_RANGE',
|
||||||
TODAY_INVALID_RANGE_ORDER: 'TODAY_INVALID_RANGE_ORDER',
|
TODAY_INVALID_RANGE_ORDER: 'TODAY_INVALID_RANGE_ORDER',
|
||||||
|
|
||||||
|
// Voice treatment entry
|
||||||
|
VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE',
|
||||||
|
VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG',
|
||||||
|
VOICE_ASR_FAILED: 'VOICE_ASR_FAILED',
|
||||||
|
VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED',
|
||||||
|
VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED',
|
||||||
|
VOICE_RATE_LIMITED: 'VOICE_RATE_LIMITED',
|
||||||
|
|
||||||
// Generic HTTP
|
// Generic HTTP
|
||||||
NOT_FOUND: 'NOT_FOUND',
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
CONFLICT: 'CONFLICT',
|
CONFLICT: 'CONFLICT',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// backend/src/main.ts
|
// backend/src/main.ts
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { json, urlencoded } from 'express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import cookieParser from 'cookie-parser'; // 👈 Change this line!
|
import cookieParser from 'cookie-parser'; // 👈 Change this line!
|
||||||
@@ -23,7 +24,18 @@ console.log = (...args) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
// bodyParser is disabled here so the JSON parsers can be registered in an explicit
|
||||||
|
// order below; Nest's built-in one is installed during create() and would otherwise
|
||||||
|
// reject a voice recording at its 100 kb default before any later middleware ran.
|
||||||
|
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
||||||
|
|
||||||
|
// Voice recordings are base64 JSON and pass 100 kb at roughly 20 seconds of audio.
|
||||||
|
// Registered first and scoped to the one route: body-parser marks the request handled,
|
||||||
|
// so the default-limit parser below skips it and every other endpoint keeps the
|
||||||
|
// standard limit.
|
||||||
|
app.use('/api/voice/extract', json({ limit: '10mb' }));
|
||||||
|
app.use(json());
|
||||||
|
app.use(urlencoded({ extended: true }));
|
||||||
|
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
|
|||||||
55
backend/src/modules/voice/dto/voice.dto.ts
Normal file
55
backend/src/modules/voice/dto/voice.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
IsBase64,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** 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];
|
||||||
|
|
||||||
|
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()
|
||||||
|
@MaxLength(8_000_000)
|
||||||
|
audio: string;
|
||||||
|
|
||||||
|
@IsIn(VOICE_AUDIO_FORMATS)
|
||||||
|
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;
|
||||||
|
}
|
||||||
35
backend/src/modules/voice/voice-throttler.guard.ts
Normal file
35
backend/src/modules/voice/voice-throttler.guard.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { AppException, ErrorCode } from '../../common/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limits voice extraction per user rather than per IP.
|
||||||
|
*
|
||||||
|
* The default tracker keys on `req.ip`, which behind nginx means the whole deployment
|
||||||
|
* shares one bucket unless `trust proxy` is set — and an abuser rotating IPs would bypass
|
||||||
|
* it entirely. Since v1 ships with no plan gate, this is the only control on metered
|
||||||
|
* vendor spend, so it has to key on something the client cannot change.
|
||||||
|
*
|
||||||
|
* Guard order matters: the controller's JwtAuthGuard runs before this method-level guard,
|
||||||
|
* so `req.user` is populated by the time `getTracker` is called.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class VoiceThrottlerGuard extends ThrottlerGuard {
|
||||||
|
protected getTracker(req: Record<string, unknown>): Promise<string> {
|
||||||
|
const user = req?.user as { id?: unknown } | undefined;
|
||||||
|
if (typeof user?.id === 'string' && user.id) {
|
||||||
|
return Promise.resolve(`voice:user:${user.id}`);
|
||||||
|
}
|
||||||
|
// Unauthenticated requests never reach here, but fall back rather than share a bucket.
|
||||||
|
const ip = typeof req?.ip === 'string' ? req.ip : 'unknown';
|
||||||
|
return Promise.resolve(`voice:ip:${ip}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Without this, ThrottlerException surfaces as INTERNAL_ERROR — there is no 429 fallback. */
|
||||||
|
protected throwThrottlingException(): Promise<void> {
|
||||||
|
throw new AppException(
|
||||||
|
ErrorCode.VOICE_RATE_LIMITED,
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
backend/src/modules/voice/voice.controller.ts
Normal file
71
backend/src/modules/voice/voice.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { ExtractVoiceDto } from './dto/voice.dto';
|
||||||
|
import { VoiceThrottlerGuard } from './voice-throttler.guard';
|
||||||
|
import { VoiceService } from './voice.service';
|
||||||
|
|
||||||
|
type VoiceRequestUser = {
|
||||||
|
id: string;
|
||||||
|
organizationId?: string;
|
||||||
|
language?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('voice')
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
||||||
|
@Controller('voice')
|
||||||
|
export class VoiceController {
|
||||||
|
constructor(private readonly voiceService: VoiceService) {}
|
||||||
|
|
||||||
|
@Get('availability')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Whether voice entry is configured, and for which locales',
|
||||||
|
})
|
||||||
|
getAvailability() {
|
||||||
|
// The frontend cannot learn this from NEXT_PUBLIC_* — those are baked in at build
|
||||||
|
// time, so enabling a locale would otherwise require rebuilding the image.
|
||||||
|
return { success: true, data: this.voiceService.getAvailability() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('extract')
|
||||||
|
// Guarded here rather than globally, and configured by VOICE_THROTTLE_* rather than
|
||||||
|
// hardcoded. Availability is deliberately left unthrottled — it is cheap and the
|
||||||
|
// frontend calls it on load.
|
||||||
|
@UseGuards(VoiceThrottlerGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Transcribe a recording and extract treatment detail intents (TAB_TREATMENT_EDIT)',
|
||||||
|
})
|
||||||
|
async extract(
|
||||||
|
@Req() req: Request & { user: VoiceRequestUser },
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
@Body() dto: ExtractVoiceDto,
|
||||||
|
) {
|
||||||
|
// Cancelling in the browser closes the connection; propagate that as an abort so the
|
||||||
|
// in-flight vendor call stops rather than settling and being discarded. It is metered
|
||||||
|
// per minute, so letting it run costs real money for a result nobody will see.
|
||||||
|
const aborter = new AbortController();
|
||||||
|
res.on('close', () => {
|
||||||
|
if (!res.writableFinished) aborter.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await this.voiceService.extract(
|
||||||
|
req.user,
|
||||||
|
dto,
|
||||||
|
req.user?.language ?? 'en',
|
||||||
|
aborter.signal,
|
||||||
|
);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/modules/voice/voice.module.ts
Normal file
12
backend/src/modules/voice/voice.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
|
||||||
|
import { VoiceController } from './voice.controller';
|
||||||
|
import { VoiceService } from './voice.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ProsthesisCatalogModule],
|
||||||
|
controllers: [VoiceController],
|
||||||
|
providers: [VoiceService, PrismaService],
|
||||||
|
})
|
||||||
|
export class VoiceModule {}
|
||||||
329
backend/src/modules/voice/voice.service.ts
Normal file
329
backend/src/modules/voice/voice.service.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LinkStatus } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { AppException, ErrorCode } from '../../common/errors';
|
||||||
|
import {
|
||||||
|
civilDateInZone,
|
||||||
|
isValidIanaTimeZone,
|
||||||
|
} from '../../common/zoned-civil-time';
|
||||||
|
import type { VoiceConfig, VoiceProfile } from '../../configs/configurations';
|
||||||
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
|
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
|
||||||
|
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||||
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
|
import {
|
||||||
|
resolveVoiceIntent,
|
||||||
|
type ResolvedExtraction,
|
||||||
|
} from './extraction.resolver';
|
||||||
|
import {
|
||||||
|
OpenRouterAsrProvider,
|
||||||
|
OpenRouterExtractionProvider,
|
||||||
|
} from './openrouter.provider';
|
||||||
|
import {
|
||||||
|
VoiceProviderError,
|
||||||
|
type AsrProvider,
|
||||||
|
type ExtractionCatalog,
|
||||||
|
type ExtractionProvider,
|
||||||
|
} from './voice.providers';
|
||||||
|
import type { ExtractVoiceDto } from './dto/voice.dto';
|
||||||
|
|
||||||
|
export type VoiceAvailability = {
|
||||||
|
enabled: boolean;
|
||||||
|
locales: string[];
|
||||||
|
maxRecordingMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VoiceExtractionResponse = ResolvedExtraction & {
|
||||||
|
transcript: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VoiceService {
|
||||||
|
private readonly logger = new Logger(VoiceService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||||
|
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get voiceConfig(): VoiceConfig {
|
||||||
|
return this.config.get<VoiceConfig>('voice')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the frontend needs to decide whether to render the microphone at all.
|
||||||
|
*
|
||||||
|
* v1 ships ungated beyond a configured locale profile — no plan check. The
|
||||||
|
* Plan.features design is deferred, not dropped.
|
||||||
|
*/
|
||||||
|
getAvailability(): VoiceAvailability {
|
||||||
|
const voice = this.voiceConfig;
|
||||||
|
const hasKey = Boolean(voice.openRouter.apiKey);
|
||||||
|
const locales = hasKey ? Object.keys(voice.profiles) : [];
|
||||||
|
return {
|
||||||
|
enabled: locales.length > 0,
|
||||||
|
locales,
|
||||||
|
maxRecordingMs: voice.maxRecordingMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async extract(
|
||||||
|
user: { id: string; organizationId?: string },
|
||||||
|
dto: ExtractVoiceDto,
|
||||||
|
locale: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<VoiceExtractionResponse> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const organizationId = this.assertOrganization(user);
|
||||||
|
await this.assertCanEditTreatment(user.id, organizationId);
|
||||||
|
|
||||||
|
const catalogLocale = normalizeCatalogLocale(locale);
|
||||||
|
const profile = this.resolveProfile(catalogLocale);
|
||||||
|
this.assertWithinCap(dto.durationMs);
|
||||||
|
|
||||||
|
const timeZone = isValidIanaTimeZone(dto.timeZone) ? dto.timeZone : 'UTC';
|
||||||
|
const todayIso = civilDateInZone(new Date(), timeZone);
|
||||||
|
|
||||||
|
const { asr, extraction } = this.buildProviders(profile);
|
||||||
|
|
||||||
|
// Stage 1 — audio never touches disk and is not retained beyond this call.
|
||||||
|
let transcript: string;
|
||||||
|
let asrCost: number | null = null;
|
||||||
|
try {
|
||||||
|
const result = await asr.transcribe(
|
||||||
|
{ data: dto.audio, format: dto.format },
|
||||||
|
catalogLocale,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
transcript = result.text;
|
||||||
|
asrCost = result.usage.costUsd;
|
||||||
|
} catch (error) {
|
||||||
|
throw this.toAppException(error, 'asr');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcript.trim()) {
|
||||||
|
throw new AppException(
|
||||||
|
ErrorCode.VOICE_NOTHING_RECOGNIZED,
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2 — structure it. On failure the transcript still goes back to the client so
|
||||||
|
// the words the clinician already paid for are not lost (transcript salvage).
|
||||||
|
const catalog = await this.buildCatalog(organizationId, catalogLocale);
|
||||||
|
let resolved: ResolvedExtraction;
|
||||||
|
let llmCost: number | null = null;
|
||||||
|
try {
|
||||||
|
const result = await extraction.extract(
|
||||||
|
transcript,
|
||||||
|
catalog,
|
||||||
|
catalogLocale,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
llmCost = result.costUsd;
|
||||||
|
resolved = resolveVoiceIntent(result.intent, {
|
||||||
|
todayIso,
|
||||||
|
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
|
||||||
|
prosthesisTypeCodes: new Set(
|
||||||
|
catalog.prosthesisTypes.map((t) => t.code),
|
||||||
|
),
|
||||||
|
linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw this.toAppException(error, 'extraction', transcript);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logTelemetry({
|
||||||
|
locale: catalogLocale,
|
||||||
|
durationMs: dto.durationMs ?? null,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
asrCost,
|
||||||
|
llmCost,
|
||||||
|
resolved,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...resolved, transcript };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertOrganization(user: { organizationId?: string }): string {
|
||||||
|
if (!user?.organizationId) {
|
||||||
|
throw new AppException(
|
||||||
|
ErrorCode.AUTH_ORG_NOT_SELECTED,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return user.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanEditTreatment(userId: string, organizationId: string) {
|
||||||
|
const membership = await this.prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
OR: [{ isOwner: true }, { isActive: true }],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
permissions: { include: { permission: true } },
|
||||||
|
organization: { include: { type: true, plan: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!membership) {
|
||||||
|
throw new AppException(
|
||||||
|
ErrorCode.PERMISSION_NOT_MEMBER,
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
|
||||||
|
throw new AppException(
|
||||||
|
ErrorCode.PERMISSION_EDIT_TREATMENTS,
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProfile(locale: string): VoiceProfile {
|
||||||
|
const voice = this.voiceConfig;
|
||||||
|
const profile = voice.profiles[locale];
|
||||||
|
if (!profile || !voice.openRouter.apiKey) {
|
||||||
|
throw new AppException(
|
||||||
|
ErrorCode.VOICE_NOT_AVAILABLE,
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertWithinCap(durationMs?: number) {
|
||||||
|
const max = this.voiceConfig.maxRecordingMs;
|
||||||
|
if (max != null && durationMs != null && durationMs > max) {
|
||||||
|
throw new AppException(
|
||||||
|
ErrorCode.VOICE_CLIP_TOO_LONG,
|
||||||
|
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProviders(profile: VoiceProfile): {
|
||||||
|
asr: AsrProvider;
|
||||||
|
extraction: ExtractionProvider;
|
||||||
|
} {
|
||||||
|
const { apiKey, baseUrl } = this.voiceConfig.openRouter;
|
||||||
|
const base = { apiKey: apiKey!, baseUrl };
|
||||||
|
return {
|
||||||
|
asr: new OpenRouterAsrProvider({ ...base, model: profile.asr.model }),
|
||||||
|
extraction: new OpenRouterExtractionProvider({
|
||||||
|
...base,
|
||||||
|
model: profile.llm.model,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codes with labels in the actor's locale, plus the clinic's linked labs. */
|
||||||
|
private async buildCatalog(
|
||||||
|
organizationId: string,
|
||||||
|
locale: string,
|
||||||
|
): Promise<ExtractionCatalog> {
|
||||||
|
const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([
|
||||||
|
this.treatmentCatalog.list(locale, null),
|
||||||
|
this.prosthesisCatalog.list(locale),
|
||||||
|
this.listLinkedLabs(organizationId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
treatmentTypes: treatmentTypes
|
||||||
|
.filter((entry) => entry.availableInTreatment)
|
||||||
|
.map((entry) => ({ code: entry.code, label: entry.label })),
|
||||||
|
prosthesisTypes: prosthesisTypes.map((entry) => ({
|
||||||
|
code: entry.code,
|
||||||
|
label: entry.label,
|
||||||
|
})),
|
||||||
|
labs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listLinkedLabs(
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<{ id: string; name: string }[]> {
|
||||||
|
const [linksA, linksB] = await Promise.all([
|
||||||
|
this.prisma.organizationLink.findMany({
|
||||||
|
where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
|
||||||
|
include: { organizationB: { select: { id: true, name: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.organizationLink.findMany({
|
||||||
|
where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
|
||||||
|
include: { organizationA: { select: { id: true, name: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
...linksA.map((l) => ({
|
||||||
|
id: l.organizationB.id,
|
||||||
|
name: l.organizationB.name,
|
||||||
|
})),
|
||||||
|
...linksB.map((l) => ({
|
||||||
|
id: l.organizationA.id,
|
||||||
|
name: l.organizationA.name,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAppException(
|
||||||
|
error: unknown,
|
||||||
|
stage: 'asr' | 'extraction',
|
||||||
|
transcript?: string,
|
||||||
|
): AppException {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
// The clinician cancelled; not a failure worth a translated message.
|
||||||
|
return new AppException(ErrorCode.BAD_REQUEST, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (error instanceof VoiceProviderError) {
|
||||||
|
this.logger.warn(`voice ${stage} failed: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`voice ${stage} failed unexpectedly`, error as Error);
|
||||||
|
}
|
||||||
|
const code =
|
||||||
|
stage === 'asr'
|
||||||
|
? ErrorCode.VOICE_ASR_FAILED
|
||||||
|
: ErrorCode.VOICE_EXTRACT_FAILED;
|
||||||
|
return new AppException(
|
||||||
|
code,
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
transcript ? { transcript } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured, patient-free. Never the transcript, never audio, never a patient id.
|
||||||
|
* Log lines are the interim sink until this repo has metrics infrastructure.
|
||||||
|
*/
|
||||||
|
private logTelemetry(input: {
|
||||||
|
locale: string;
|
||||||
|
durationMs: number | null;
|
||||||
|
elapsedMs: number;
|
||||||
|
asrCost: number | null;
|
||||||
|
llmCost: number | null;
|
||||||
|
resolved: ResolvedExtraction;
|
||||||
|
}) {
|
||||||
|
const { resolved } = input;
|
||||||
|
this.logger.log(
|
||||||
|
JSON.stringify({
|
||||||
|
event: 'voice.extract',
|
||||||
|
locale: input.locale,
|
||||||
|
clipMs: input.durationMs,
|
||||||
|
elapsedMs: input.elapsedMs,
|
||||||
|
costUsd: (input.asrCost ?? 0) + (input.llmCost ?? 0),
|
||||||
|
resolvedFields: {
|
||||||
|
treatmentType: resolved.treatmentType != null,
|
||||||
|
teeth: resolved.teeth.length,
|
||||||
|
comment: resolved.comment != null,
|
||||||
|
prosthesisComplete: resolved.prosthesis?.complete ?? null,
|
||||||
|
lab: resolved.labId != null,
|
||||||
|
dueDate: resolved.dueDate != null,
|
||||||
|
},
|
||||||
|
unresolvedCount: resolved.unresolved.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1240,6 +1240,13 @@
|
|||||||
"LAB_CASE_START_INCOMPLETE": "Complete teeth and prosthesis types before starting this case.",
|
"LAB_CASE_START_INCOMPLETE": "Complete teeth and prosthesis types before starting this case.",
|
||||||
"LAB_CASE_CLIENT_REQUIRED": "Enter a clinic name or a patient name for this case.",
|
"LAB_CASE_CLIENT_REQUIRED": "Enter a clinic name or a patient name for this case.",
|
||||||
"BAD_REQUEST": "The request could not be processed.",
|
"BAD_REQUEST": "The request could not be processed.",
|
||||||
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
|
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later.",
|
||||||
|
"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_CLIP_TOO_LONG": "That recording is too long. Please keep it under two minutes.",
|
||||||
|
"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_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.",
|
||||||
|
"VOICE_RATE_LIMITED": "Too many recordings in a short time. Please wait a moment."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1241,6 +1241,13 @@
|
|||||||
"LAB_CASE_START_INCOMPLETE": "قبل از شروع پرونده، دندانها و نوع پروتز را کامل کنید.",
|
"LAB_CASE_START_INCOMPLETE": "قبل از شروع پرونده، دندانها و نوع پروتز را کامل کنید.",
|
||||||
"LAB_CASE_CLIENT_REQUIRED": "نام کلینیک یا نام بیمار را وارد کنید.",
|
"LAB_CASE_CLIENT_REQUIRED": "نام کلینیک یا نام بیمار را وارد کنید.",
|
||||||
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
|
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
|
||||||
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
|
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید.",
|
||||||
|
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
|
||||||
|
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
|
||||||
|
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
|
||||||
|
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
|
||||||
|
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
|
||||||
|
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
|
||||||
|
"VOICE_RATE_LIMITED": "تعداد ضبطها در بازه کوتاه زیاد بود. کمی صبر کنید."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1240,6 +1240,13 @@
|
|||||||
"LAB_CASE_START_INCOMPLETE": "Vul tanden en prothesetypes in voordat u deze case start.",
|
"LAB_CASE_START_INCOMPLETE": "Vul tanden en prothesetypes in voordat u deze case start.",
|
||||||
"LAB_CASE_CLIENT_REQUIRED": "Voer een klinieknaam of een patiëntnaam in voor deze case.",
|
"LAB_CASE_CLIENT_REQUIRED": "Voer een klinieknaam of een patiëntnaam in voor deze case.",
|
||||||
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
|
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
|
||||||
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
|
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later 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_CLIP_TOO_LONG": "Die opname is te lang. Houd het onder twee minuten.",
|
||||||
|
"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_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.",
|
||||||
|
"VOICE_RATE_LIMITED": "Te veel opnames in korte tijd. Wacht even."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user