330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
|
|
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,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|