feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
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:
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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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] ?? []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user