feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
Backend Image / build-and-push (push) Successful in 6m51s
Frontend Image / build-and-push (push) Successful in 18m9s

Task 8: POST /inspections nimmt voiceCaptureId; die Notiz wird zuletzt
in derselben Transaktion bestätigt (jeder offene Status, Ruling 1),
Inhalt, Audio und Claim gelöscht; fremde oder geschlossene Notiz 409
VOICE_CAPTURE_GONE, anderes Volk 409 VOICE_CAPTURE_COLONY_MISMATCH;
Wiederholung mit gleichem Schlüssel idempotent.

Task 9: Ereignisse VoiceCaptureReady/VoiceCaptureFailed (nur IDs und
Endcodes), Meldungen „Sprachnotiz für … ist fertig“ bzw. Fehlerhinweis
mit nächstem Schritt, Betreiberhinweis bei abgeschalteter Auswertung;
Handler auch im Worker registriert (sonst gingen die Ereignisse
ungehört durch); keine Weiterleitung an Webhooks.

Task 11: Job voice-transcribe mit Phasen fetch (Audio nur im Speicher
entschlüsselt in ein RAM-Arbeitsverzeichnis 0700/0600) und ingest
(Ergebnis gegen Claim geprüft, Transkript versiegelt, Audio gelöscht,
Auswertung geweckt; Stille/zu lang enden FAILED); Ereignisse genau
einmal in derselben Transaktion. Aufbewahrung: Audio 24 h, Inhalt bis
contentExpiresAt, deaktivierte Besitzer, Zeilen 90 Tage; im täglichen
Cleanup inkl. leerer Job-Läufe (Ruling 10).

Gates: Backend lint, typecheck, 4471 Unit-Tests, build; e2e 103 Suites
(einziger Fehler: Wetter-Test gegen echte API).

Refs #27

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 10:38:14 +02:00
1 parent e9c6604e3b
commit 1dc39f7658
35 files changed
+2886 -27

No files matched your search

@@ -1,6 +1,7 @@
import { Logger } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import * as voiceRetention from '../voice/voice-retention';
import { JobRunService } from '../system/job-run.service';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import {
@@ -9,6 +10,7 @@ import {
FAILED_RETENTION_DAYS,
JOB_RUN_RETENTION_DAYS,
JOB_RUN_STALE_HOURS,
VOICE_IDLE_RUN_RETENTION_HOURS,
PENDING_DELIVERY_MAX_AGE_DAYS,
WEBHOOK_DELIVERY_RETENTION_DAYS,
} from './cleanup.job';
@@ -37,6 +39,13 @@ describe('CleanupJob', () => {
};
beforeEach(() => {
jest.spyOn(voiceRetention, 'expireVoiceContent').mockResolvedValue({
audioExpired: 1,
contentExpired: 2,
inactiveOwnersWiped: 3,
});
jest.spyOn(voiceRetention, 'deleteOldVoiceRows').mockResolvedValue(4);
jest.spyOn(voiceRetention, 'deleteIdleVoiceRuns').mockResolvedValue(21);
let count = 10;
executeRaw = jest.fn<Promise<number>, RawCall>(() =>
Promise.resolve(count++),
@@ -62,6 +71,17 @@ describe('CleanupJob', () => {
expect(JOB_RUN_STALE_HOURS).toBe(6);
});
it('cleanup runs the voice retention and prunes idle voice-transcribe runs only', async () => {
await job.run();
expect(voiceRetention.expireVoiceContent).toHaveBeenCalledTimes(1);
expect(voiceRetention.deleteOldVoiceRows).toHaveBeenCalledTimes(1);
expect(voiceRetention.deleteIdleVoiceRuns).toHaveBeenCalledWith(
expect.anything(),
VOICE_IDLE_RUN_RETENTION_HOURS,
);
});
it('cleanup deletes weather days before January 1st four years back', async () => {
await job.run();
@@ -94,6 +114,11 @@ describe('CleanupJob', () => {
'jobRuns.aborted': 1,
'jobRuns.deleted': 19,
weatherDays: 20,
'voice.audioExpired': 1,
'voice.contentExpired': 2,
'voice.inactiveOwnersWiped': 3,
'voice.rowsDeleted': 4,
'jobRuns.voiceIdle': 21,
});
});
+23
View File
@@ -2,6 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import {
deleteIdleVoiceRuns,
deleteOldVoiceRows,
expireVoiceContent,
} from '../voice/voice-retention';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
@@ -18,6 +23,8 @@ export const JOB_RUN_RETENTION_DAYS = 90;
export const PENDING_DELIVERY_MAX_AGE_DAYS = 7;
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
export const JOB_RUN_STALE_HOURS = 6;
/** A voice-transcribe run (every 2 min) that did nothing is noise after a day. */
export const VOICE_IDLE_RUN_RETENTION_HOURS = 24;
/**
* Daily housekeeping (CronJob 04:00). Every age is compared against now(),
@@ -100,6 +107,17 @@ export class CleanupJob implements Job {
DELETE FROM "ApiaryWeatherDay"
WHERE "day" < make_date(
extract(year FROM now() AT TIME ZONE 'Europe/Berlin')::int - ${WEATHER_RETENTION_YEARS}, 1, 1)`;
// The same retention rules the voice CronJob runs every 2 minutes (audio
// over 24 h, content over 7 days, deactivated users), plus the rows
// after 90 days.
const voiceRetention = await expireVoiceContent(this.prisma);
const voiceRowsDeleted = await deleteOldVoiceRows(this.prisma);
// 1440 runs a day, nearly all without work: keep only runs that did
// something (a counter above 0) or failed, for the usual 90 days.
const voiceIdleRuns = await deleteIdleVoiceRuns(
this.prisma,
VOICE_IDLE_RUN_RETENTION_HOURS,
);
return {
idempotencyRecords,
consumedTokens,
@@ -115,6 +133,11 @@ export class CleanupJob implements Job {
'jobRuns.aborted': jobRunsAborted,
'jobRuns.deleted': jobRunsDeleted,
weatherDays,
'voice.audioExpired': voiceRetention.audioExpired,
'voice.contentExpired': voiceRetention.contentExpired,
'voice.inactiveOwnersWiped': voiceRetention.inactiveOwnersWiped,
'voice.rowsDeleted': voiceRowsDeleted,
'jobRuns.voiceIdle': voiceIdleRuns,
};
}
}
+6 -4
View File
@@ -28,12 +28,14 @@ describe('job names', () => {
).toEqual([]);
});
it('only rekey accepts an argument, and only --dry-run', () => {
it('only rekey and voice-transcribe accept arguments, and only their own', () => {
expect(Object.keys(JOB_ALLOWED_ARGS)).toEqual([...JOB_NAMES]);
for (const name of JOB_NAMES) {
expect(JOB_ALLOWED_ARGS[name]).toEqual(
name === 'rekey' ? ['--dry-run'] : [],
);
const expected: Record<string, string[]> = {
rekey: ['--dry-run'],
'voice-transcribe': ['--phase=fetch', '--phase=ingest'],
};
expect(JOB_ALLOWED_ARGS[name]).toEqual(expected[name] ?? []);
}
});
});
+4
View File
@@ -7,6 +7,7 @@ export const JOB_NAMES = [
'cleanup',
'app-backup',
'rekey',
'voice-transcribe',
] as const;
export type JobName = (typeof JOB_NAMES)[number];
@@ -18,6 +19,7 @@ export const JOB_LABELS_DE: Record<JobName, string> = {
cleanup: 'Aufräumen',
'app-backup': 'App-Backup',
rekey: 'Schlüssel umstellen',
'voice-transcribe': 'Sprachnotizen abschreiben',
};
/**
@@ -32,6 +34,7 @@ export const JOB_FAILURE_PERMISSION: Record<JobName, PermissionKey> = {
cleanup: 'system:manage',
'app-backup': 'backup:configure',
rekey: 'system:manage',
'voice-transcribe': 'system:manage',
};
/**
@@ -45,6 +48,7 @@ export const JOB_ALLOWED_ARGS: Record<JobName, readonly string[]> = {
cleanup: [],
'app-backup': [],
rekey: ['--dry-run'],
'voice-transcribe': ['--phase=fetch', '--phase=ingest'],
};
export function isJobName(value: string): value is JobName {
+2
View File
@@ -21,6 +21,7 @@ import { JobRegistry, JOBS } from './job-registry';
import { RekeyJob } from './rekey.job';
import { TimeEventsStep } from './steps/time-events.step';
import { TaskTemplatesJob } from './task-templates.job';
import { VoiceTranscribeJob } from './voice-transcribe.job';
const JOB_CLASSES = [
DailyEvaluationJob,
@@ -28,6 +29,7 @@ const JOB_CLASSES = [
CleanupJob,
AppBackupJob,
RekeyJob,
VoiceTranscribeJob,
];
/**
@@ -0,0 +1,79 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import * as claims from '../voice/claims/voice-claims';
import * as retention from '../voice/voice-retention';
import { VoiceTranscribeJob } from './voice-transcribe.job';
describe('VoiceTranscribeJob', () => {
let dir: string;
let job: VoiceTranscribeJob;
let retentionSpy: jest.SpyInstance;
let staleSpy: jest.SpyInstance;
let claimSpy: jest.SpyInstance;
const tx = {};
const prisma = {
$transaction: (fn: (t: typeof tx) => Promise<unknown>) => fn(tx),
} as unknown as PrismaService;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'voice-job-'));
process.env.VOICE_WORK_DIR = dir;
retentionSpy = jest
.spyOn(retention, 'expireVoiceContent')
.mockResolvedValue({
audioExpired: 2,
contentExpired: 1,
inactiveOwnersWiped: 0,
});
staleSpy = jest
.spyOn(claims, 'releaseStaleVoiceClaims')
.mockResolvedValue([]);
claimSpy = jest.spyOn(claims, 'claimVoiceCaptures').mockResolvedValue([]);
job = new VoiceTranscribeJob(prisma);
});
afterEach(async () => {
delete process.env.VOICE_WORK_DIR;
jest.restoreAllMocks();
await rm(dir, { recursive: true, force: true });
});
it('is named voice-transcribe', () => {
expect(job.name).toBe('voice-transcribe');
});
it('refuses a missing or unknown phase', async () => {
await expect(job.run([])).rejects.toThrow(/exactly one phase argument/);
await expect(job.run(['--phase=whisper'])).rejects.toThrow(
/exactly one phase argument/,
);
await expect(job.run(['--phase=fetch', '--phase=ingest'])).rejects.toThrow(
/exactly one phase argument/,
);
});
it('fetch ends quickly without work and writes no manifest', async () => {
const result = await job.run(['--phase=fetch']);
expect(result).toMatchObject({
claimed: 0,
audioExpired: 2,
contentExpired: 1,
});
expect(retentionSpy).toHaveBeenCalledTimes(1);
expect(staleSpy).toHaveBeenCalledWith(tx, 'transcribe', {});
expect(claimSpy).toHaveBeenCalledWith(prisma, 'transcribe', {
limit: 5,
maxTotalSec: 600,
});
expect(await readdir(dir)).not.toContain('manifest.json');
await expect(readdir(path.join(dir, 'in'))).resolves.toEqual([]);
});
it('ingest without a manifest does nothing', async () => {
const result = await job.run(['--phase=ingest']);
expect(result).toEqual({ ingested: 0, failed: 0, retried: 0, lost: 0 });
});
});
@@ -0,0 +1,282 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { PrismaService } from '../prisma/prisma.service';
import {
claimVoiceCaptures,
releaseStaleVoiceClaims,
settleVoiceFailure,
settleVoiceSuccess,
VoiceClaim,
} from '../voice/claims/voice-claims';
import { sealTranscript } from '../voice/core/transcript';
import { openVoiceBytes, voiceAad } from '../voice/core/voice-crypto';
import {
clearWorkDir,
readManifest,
readResults,
WhisperResult,
workDirFromEnv,
writeInputs,
} from '../voice/transcribe/work-dir';
import { recordVoiceCaptureFailed } from '../voice/voice-failure-hook';
import { expireVoiceContent } from '../voice/voice-retention';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/** Per run: at most 5 notes and 600 seconds of audio (spec 4.2). */
export const VOICE_FETCH_LIMIT = 5;
export const VOICE_FETCH_MAX_SEC = 600;
/** Whisper text shorter than this is no speech (spec 6.3). */
const MIN_TEXT_CHARS = 5;
const MIN_DURATION_SEC = 1;
const MAX_DURATION_SEC = 240;
type Phase = 'claim' | 'ingest';
type ItemOutcome = 'ingested' | 'failed' | 'retried' | 'lost';
export interface VoiceFetchOptions {
/** Extra condition on "VoiceCapture" (tests scope their runs to own rows). */
filter?: Prisma.Sql;
}
function parsePhase(args: readonly string[]): Phase {
if (args.length === 1 && args[0] === '--phase=fetch') return 'claim';
if (args.length === 1 && args[0] === '--phase=ingest') return 'ingest';
throw new Error('voice-transcribe needs exactly one phase argument');
}
/**
* CronJob `apicore-job-voice-transcribe`, the two Node halves around the
* Whisper container. `fetch` runs the retention rules, claims notes,
* decrypts their audio in memory and hands it over in the RAM work dir;
* `ingest` takes Whisper's results, seals the text, deletes the audio and
* wakes the worker - each note in one transaction, guarded by its claim
* token. Content is never logged: only ids, counts and fixed codes.
*/
@Injectable()
export class VoiceTranscribeJob implements Job {
private readonly logger = new Logger(VoiceTranscribeJob.name);
readonly name: JobName = 'voice-transcribe';
constructor(private readonly prisma: PrismaService) {}
async run(args: readonly string[]): Promise<JobResult> {
return parsePhase(args) === 'claim' ? this.runFetchPhase() : this.ingest();
}
async runFetchPhase(options: VoiceFetchOptions = {}): Promise<JobResult> {
const dir = workDirFromEnv();
await clearWorkDir(dir);
const filter = options.filter ? { filter: options.filter } : {};
const retention = await expireVoiceContent(this.prisma, filter);
const staleReleased = await this.releaseStale(filter);
const claims = await claimVoiceCaptures(this.prisma, 'transcribe', {
limit: VOICE_FETCH_LIMIT,
maxTotalSec: VOICE_FETCH_MAX_SEC,
...filter,
});
const inputs: { claim: VoiceClaim; audio: Buffer }[] = [];
for (const claim of claims) {
const audio = await this.decryptAudio(claim);
if (audio === null) {
await this.failAttempt(claim, 'TRANSCRIBE_FAILED');
} else {
inputs.push({ claim, audio });
}
}
if (inputs.length > 0) {
await writeInputs(dir, inputs);
}
return {
claimed: claims.length,
written: inputs.length,
staleReleased,
...retention,
};
}
async ingest(): Promise<JobResult> {
const dir = workDirFromEnv();
const counts: Record<ItemOutcome, number> = {
ingested: 0,
failed: 0,
retried: 0,
lost: 0,
};
try {
const manifest = await readManifest(dir);
if (manifest === null) {
return counts;
}
const results = await readResults(dir, manifest);
let errors = 0;
for (const item of manifest.items) {
try {
const outcome = await this.ingestOne(
item.captureId,
item.claimToken,
results.get(item.captureId) ?? null,
);
counts[outcome] += 1;
} catch (error: unknown) {
errors += 1;
// The message of a failed seal or query holds no content, but
// keep to the id anyway.
this.logger.error(
`Ingest failed for voice note ${item.captureId}: ${error instanceof Error ? error.name : 'error'}`,
);
}
}
if (errors > 0) {
throw new Error(
`voice ingest failed for ${errors} of ${manifest.items.length} notes (${JSON.stringify(counts)})`,
);
}
return counts;
} finally {
await clearWorkDir(dir);
}
}
private releaseStale(filter: VoiceFetchOptions): Promise<number> {
return this.prisma.$transaction(async (tx) => {
const released = await releaseStaleVoiceClaims(tx, 'transcribe', filter);
for (const { id, code } of released) {
if (code !== null) {
await recordVoiceCaptureFailed(tx, id, 'TRANSCRIBE_FAILED');
}
}
return released.length;
});
}
private async decryptAudio(claim: VoiceClaim): Promise<Buffer | null> {
const row = await this.prisma.voiceCaptureAudio.findUnique({
where: { captureId: claim.id },
});
if (!row) {
return null;
}
try {
return openVoiceBytes(
Buffer.from(row.dataEnc),
voiceAad(claim.id, 'audio'),
);
} catch {
this.logger.warn(`Audio of voice note ${claim.id} cannot be opened`);
return null;
}
}
private failAttempt(
claim: VoiceClaim,
code: 'TRANSCRIBE_FAILED',
): Promise<'retry' | 'failed' | 'lost'> {
return this.prisma.$transaction(async (tx) => {
const outcome = await settleVoiceFailure(tx, 'transcribe', claim, code);
if (outcome === 'failed') {
await recordVoiceCaptureFailed(tx, claim.id, code);
}
return outcome;
});
}
/** The claim as the database holds it now; null if it is gone. */
private async loadClaim(
captureId: string,
claimToken: string,
): Promise<VoiceClaim | null> {
const rows = await this.prisma.$queryRaw<VoiceClaim[]>(Prisma.sql`
SELECT "id", "claimToken", "attempts", "ownerUserId", "colonyId",
"audioMime", "durationSec"
FROM "VoiceCapture"
WHERE "id" = ${captureId} AND "claimToken" = ${claimToken}
AND "status" = 'TRANSCRIBING'`);
return rows[0] ?? null;
}
private async ingestOne(
captureId: string,
claimToken: string,
result: WhisperResult | null,
): Promise<ItemOutcome> {
const claim = await this.loadClaim(captureId, claimToken);
if (claim === null) {
return 'lost';
}
if (result === null || result.error === 'DECODE_FAILED') {
return this.outcomeOf(await this.failAttempt(claim, 'TRANSCRIBE_FAILED'));
}
const noSpeech =
result.error ??
(result.text.trim().length < MIN_TEXT_CHARS ? 'NO_SPEECH' : null);
if (noSpeech === 'NO_SPEECH' || noSpeech === 'TOO_LONG') {
return this.finishFailed(claim, noSpeech);
}
if (result.durationSec > MAX_DURATION_SEC) {
return this.finishFailed(claim, 'TOO_LONG');
}
return this.finishTranscribed(claim, result);
}
private outcomeOf(settled: 'retry' | 'failed' | 'lost'): ItemOutcome {
return settled === 'retry'
? 'retried'
: settled === 'failed'
? 'failed'
: 'lost';
}
/** Terminal failure of the note itself: FAILED with the code, audio gone. */
private finishFailed(
claim: VoiceClaim,
code: 'NO_SPEECH' | 'TOO_LONG',
): Promise<ItemOutcome> {
return this.prisma.$transaction(async (tx) => {
const changed = await tx.$executeRaw`
UPDATE "VoiceCapture"
SET "status" = 'FAILED', "lastErrorCode" = ${code},
"attempts" = "attempts" + 1, "updatedAt" = now(),
"claimedAt" = NULL, "claimToken" = NULL
WHERE "id" = ${claim.id} AND "status" = 'TRANSCRIBING'
AND "claimToken" = ${claim.claimToken}`;
if (changed !== 1) {
return 'lost';
}
await tx.voiceCaptureAudio.deleteMany({ where: { captureId: claim.id } });
await recordVoiceCaptureFailed(tx, claim.id, code);
return 'failed';
});
}
private finishTranscribed(
claim: VoiceClaim,
result: WhisperResult,
): Promise<ItemOutcome> {
const durationSec = Math.max(
MIN_DURATION_SEC,
Math.round(result.durationSec),
);
const sealed = sealTranscript(claim.id, {
v: 1,
text: result.text,
durationSec: result.durationSec,
segments: result.segments,
});
return this.prisma.$transaction(async (tx) => {
const settled = await settleVoiceSuccess(
tx,
'transcribe',
claim,
Prisma.sql`"transcriptEnc" = ${sealed}, "durationSec" = ${durationSec}`,
);
if (!settled) {
return 'lost';
}
await tx.voiceCaptureAudio.deleteMany({ where: { captureId: claim.id } });
await notifyWorker(tx, WAKE_CHANNELS.voiceExtraction);
return 'ingested';
});
}
}