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