feat: Schlüssel und gemeinsamer Kern für Sprachnotizen (T7 L2 Tasks 5+6)

Task 5: Schlüsselbund-Zweck voice (VOICE_ENCRYPTION_KEY), AES-256-GCM-
Hilfen für Text und Bytes mit AAD capture:<id>:<art>, voice-config
(Modell, Tageslimit, Aufnahme, Whisper-Grösse) und anthropic-config
(nie geloggt, nur worker/all), Startprüfung verlangt den Schlüssel in
Produktion in jeder Rolle, rekey versiegelt offene Sprachnotizen neu
(Audio einzeln gelesen, CAS mit Status), Secret-Vorlagen und
ensure-secrets.sh ergänzt.

Task 6: Text-Normalisierung und Bereinigung (Steuer- und Formatzeichen,
Codepoint-Kürzung), Zitatprüfung mit Dezimalzahlen als ein Wort
(Ruling 7), Felderliste passend zu CreateInspectionDto, versiegeltes
Transkript mit Grenzen, Fachwörter und Skalen mit Paritätstests.

Rollout: Ein Image mit diesem Stand startet in Produktion nur mit
VOICE_ENCRYPTION_KEY in apicore-backend-secret (ARCHITECTURE Annahme 22).

Gates: Backend tsc, eslint, 922 Unit-Tests, e2e 100 Suites / 1004 Tests.

Refs #27 #28

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:41:47 +02:00
1 parent 2a4dc868d9
commit 9795f98006
27 files changed
+2212 -31

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
/**
* Voice settings from the environment. Model ids live here and nowhere else
* (spec 7.1): a change of model is a change of this list and of VOICE_MODEL.
*/
export const VOICE_MODELS = [
'claude-sonnet-5-5',
'claude-haiku-5-5',
'claude-opus-5-5',
] as const;
export type VoiceModel = (typeof VOICE_MODELS)[number];
export const DEFAULT_VOICE_MODEL: VoiceModel = 'claude-sonnet-5-5';
export const WHISPER_MODELS = ['small', 'medium'] as const;
export type WhisperModel = (typeof WHISPER_MODELS)[number];
const DEFAULT_DAILY_EXTRACTIONS = 60;
const MIN_DAILY_EXTRACTIONS = 1;
const MAX_DAILY_EXTRACTIONS = 10000;
export interface VoiceConfig {
model: VoiceModel;
/** Operation-wide limit of Claude calls per UTC day. */
dailyExtractions: number;
/** Switch that shows or hides audio recording in the app (ConfigMap). */
recordingEnabled: boolean;
whisperModel: WhisperModel;
}
function trimmed(value: string | undefined): string | null {
const text = value?.trim();
return text ? text : null;
}
function oneOf<T extends string>(
name: string,
raw: string | undefined,
allowed: readonly T[],
fallback: T,
): T {
const value = trimmed(raw);
if (value === null) {
return fallback;
}
const match = allowed.find((each) => each === value);
if (match === undefined) {
throw new Error(`${name} must be one of ${allowed.join(', ')}`);
}
return match;
}
function dailyExtractions(raw: string | undefined): number {
const value = trimmed(raw);
if (value === null) {
return DEFAULT_DAILY_EXTRACTIONS;
}
const number = /^\d+$/.test(value) ? Number(value) : NaN;
if (
!Number.isInteger(number) ||
number < MIN_DAILY_EXTRACTIONS ||
number > MAX_DAILY_EXTRACTIONS
) {
throw new Error(
`VOICE_DAILY_EXTRACTIONS must be a whole number from ${MIN_DAILY_EXTRACTIONS} to ${MAX_DAILY_EXTRACTIONS}`,
);
}
return number;
}
function recordingEnabled(raw: string | undefined): boolean {
const value = trimmed(raw)?.toLowerCase() ?? null;
if (value === null || value === 'false') {
return false;
}
if (value === 'true') {
return true;
}
throw new Error('VOICE_RECORDING_ENABLED must be "true" or "false"');
}
/** Invalid values fail with the variable name (never a secret): startup refuses them. */
export function voiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig {
return {
model: oneOf(
'VOICE_MODEL',
env.VOICE_MODEL,
VOICE_MODELS,
DEFAULT_VOICE_MODEL,
),
dailyExtractions: dailyExtractions(env.VOICE_DAILY_EXTRACTIONS),
recordingEnabled: recordingEnabled(env.VOICE_RECORDING_ENABLED),
whisperModel: oneOf(
'WHISPER_MODEL',
env.WHISPER_MODEL,
WHISPER_MODELS,
'small',
),
};
}