The review sheet rendered the full dictation at the top of the modal, unconditionally. A raw transcript can carry the patient's spoken name — the spec said so itself in §9, while §10 said telemetry must never contain it. The transcript no longer reaches the browser by any route: - removed from the success response (VoiceExtractionResponse is now plain ResolvedExtraction, which never had the field) - removed from the error body. VOICE_EXTRACT_FAILED carried details.transcript for a salvage dialog that was never built, and ApiError['details'] is an array, so the shape never even matched — it was serialized onto the wire and dropped - removed from the sheet, and from VoiceExtractionResult. tsc proves that <p> was the only reader in the whole frontend It is logged instead: one info line per recording, written immediately after the emptiness check so a failed extraction still records it, and deliberately outside logTelemetry so that method's patient-free guarantee stays literally true. Accepted consequence, recorded in §10: patient words now persist in production server logs at default level, so whatever retention and access control applies to those logs applies to dictation. The repo's other sensitive-text path (openrouter.provider.ts) uses debug level with truncation; moving this line to debug is a one-word change. Transcript salvage is dropped rather than deferred, which settles §11 open item 16 by taking its second option. When extraction fails the clinician re-dictates; an operator can read the words in the log, the person who spoke them cannot. Spec: §7, §9 and §10 rewritten, item 16 resolved, decisions 51-53 added, and decisions 10 and 25 marked superseded so the log stops contradicting itself. No automated coverage for the response shape or the log line: there is no voice.service.spec.ts — the service is I/O orchestration and has never been unit tested. Removing the type field is what proves no reader survives. Gates: backend 216 tests, nest build, ESLint clean on the voice module; frontend tsc --noEmit clean, 52 Vitest tests, next build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
365 lines
12 KiB
TypeScript
365 lines
12 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 { weekStartForLocale } from './due-date.resolver';
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* The transcript is deliberately absent. A raw dictation can carry the patient's spoken name, so
|
|
* it never leaves the server — it is logged there instead (§10). Nothing the client renders needs
|
|
* it, and what is not sent cannot leak through the network tab or an error reporter.
|
|
*/
|
|
export type VoiceExtractionResponse = ResolvedExtraction;
|
|
|
|
@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. v1 is ungated beyond
|
|
* a configured locale profile; 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;
|
|
let asrSeconds: number | null = null;
|
|
try {
|
|
const result = await asr.transcribe(
|
|
{ data: dto.audio, format: dto.format },
|
|
catalogLocale,
|
|
signal,
|
|
);
|
|
transcript = result.text;
|
|
asrCost = result.usage.costUsd;
|
|
asrSeconds = result.usage.seconds;
|
|
} catch (error) {
|
|
throw this.toAppException(error, 'asr');
|
|
}
|
|
|
|
// durationMs is client-reported, so not enforcement. usage.seconds is the vendor's own
|
|
// measurement — a client under-reporting to slip past the cap is caught here, after the
|
|
// ASR spend but before the more expensive extraction call.
|
|
if (asrSeconds != null) {
|
|
this.assertWithinCap(asrSeconds * 1000);
|
|
}
|
|
|
|
if (!transcript.trim()) {
|
|
throw new AppException(
|
|
ErrorCode.VOICE_NOTHING_RECOGNIZED,
|
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
|
);
|
|
}
|
|
|
|
// The transcript's only destination. Logged before extraction so it survives an extraction
|
|
// failure too, and kept out of logTelemetry so that method's patient-free guarantee stays
|
|
// true. This line DOES carry what the clinician said, which may include a patient's name.
|
|
this.logger.log(
|
|
`voice transcript [${catalogLocale}]: ${transcript.trim()}`,
|
|
);
|
|
|
|
// Stage 2 — structure it. A failure here returns a code only; the transcript stays in the
|
|
// server log above, never in the response.
|
|
let resolved: ResolvedExtraction;
|
|
let llmCost: number | null = null;
|
|
try {
|
|
// Inside the try: a catalog/DB failure here must surface as VOICE_EXTRACT_FAILED, which
|
|
// the clinician can act on, rather than a generic 500.
|
|
const catalog = await this.buildCatalog(organizationId, catalogLocale);
|
|
const result = await extraction.extract(
|
|
transcript,
|
|
catalog,
|
|
catalogLocale,
|
|
signal,
|
|
);
|
|
llmCost = result.costUsd;
|
|
resolved = resolveVoiceIntent(result.intent, {
|
|
todayIso,
|
|
weekStartJs: weekStartForLocale(catalogLocale),
|
|
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
|
|
prosthesisLeaves: catalog.prosthesisTypes,
|
|
prosthesisCategoryCodes: new Set(
|
|
catalog.prosthesisCategories.map((c) => c.code),
|
|
),
|
|
prosthesisSubcategoryCodes: new Set(
|
|
catalog.prosthesisSubcategories.map((c) => c.code),
|
|
),
|
|
linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
|
|
});
|
|
} catch (error) {
|
|
throw this.toAppException(error, 'extraction');
|
|
}
|
|
|
|
this.logTelemetry({
|
|
locale: catalogLocale,
|
|
durationMs: dto.durationMs,
|
|
elapsedMs: Date.now() - startedAt,
|
|
asrCost,
|
|
llmCost,
|
|
resolved,
|
|
});
|
|
|
|
return resolved;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* The client auto-stops at maxMs and only then measures, so a capped recording always
|
|
* reports slightly over. Without this tolerance every auto-stopped recording — the exact
|
|
* case the cap exists for — would be rejected as too long.
|
|
*/
|
|
private static readonly CAP_TOLERANCE_MS = 2_000;
|
|
|
|
private assertWithinCap(durationMs: number) {
|
|
const max = this.voiceConfig.maxRecordingMs;
|
|
if (max != null && durationMs > max + VoiceService.CAP_TOLERANCE_MS) {
|
|
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,
|
|
prosthesisCategories,
|
|
prosthesisSubcategories,
|
|
labs,
|
|
] = await Promise.all([
|
|
this.treatmentCatalog.list(locale, null),
|
|
this.prosthesisCatalog.list(locale),
|
|
this.prosthesisCatalog.listCategories(locale),
|
|
this.prosthesisCatalog.listSubcategories(locale),
|
|
this.listLinkedLabs(organizationId),
|
|
]);
|
|
|
|
return {
|
|
treatmentTypes: treatmentTypes
|
|
.filter((entry) => entry.availableInTreatment)
|
|
.map((entry) => ({ code: entry.code, label: entry.label })),
|
|
// `buildCatalog` used to throw away category/subcategory/chartRegion/stackGroup here —
|
|
// the prompt now presents the catalog as the tree it is (§5).
|
|
prosthesisTypes,
|
|
prosthesisCategories,
|
|
prosthesisSubcategories,
|
|
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',
|
|
): 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;
|
|
// No details: the transcript used to ride along here for a salvage dialog that was never
|
|
// built, so it was serialized onto the wire and dropped. It stays on the server now.
|
|
return new AppException(code, HttpStatus.BAD_GATEWAY);
|
|
}
|
|
|
|
/** Structured and patient-free: never the transcript, never audio, never a patient id. */
|
|
private logTelemetry(input: {
|
|
locale: string;
|
|
durationMs: number;
|
|
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,
|
|
prosthesisAssignments: resolved.prosthesisAssignments.length,
|
|
lab: resolved.labId != null,
|
|
dueDate: resolved.dueDate != null,
|
|
},
|
|
unresolvedCount: resolved.unresolved.length,
|
|
}),
|
|
);
|
|
}
|
|
}
|