Files
ApiCore/ApiCore-Backend/test/voice-extraction.e2e-spec.ts
T
Nick.RosenloecherandClaude Opus 5.5 6a915aad9c
Backend E2E / e2e (push) Failing after 7m2s
Backend Image / build-and-push (push) Successful in 8m20s
Manifests / validate (push) Successful in 33s
Frontend Image / build-and-push (push) Failing after 21m23s
feat: Auswertung der Sprachnotizen mit Claude (T7 L4 Tasks 14, 15)
Task 14: Port StructuredModel mit Anthropic-Adapter (@anthropic-ai/sdk
0.131.0 exakt, Lieferkette geprüft), fester Endpunkt
https://api.anthropic.com (Umgebungs-Header werden entfernt), feste
Systemregeln ohne Datum/Volk/Person, Transkript als Daten in Tags,
strukturierte Ausgabe per JSON-Schema mit effort low, serverseitiger
Fallback per VOICE_SERVER_FALLBACK abschaltbar. Jeder Vorschlag wird
geprüft: bekanntes Feld, Wert in den Formulargrenzen, Zitat steht
wörtlich im Gesagten (Dezimalzahl als ein Wort), Korrektur gewinnt.
Netzwächter: nur die Worker-Verdrahtung erreicht den Adapter
(transitiv geprüft).

Task 15: Worker-Schleife voice-extractions (LISTEN + 15-s-Abfrage, ein
Lauf zugleich, höchstens 20 Notizen je Lauf), Modellaufruf außerhalb
jeder Transaktion mit 240-s-Obergrenze, Ergebnis versiegelt mit
Ereignis nur bei gehaltenem Claim; ohne Schlüssel EXTRACT_DISABLED ohne
Versuch, Tageslimit zählt jeden bezahlten Aufruf über das neue Journal
VoiceExtractionCall (nur IDs, Modell, Tokens, Ergebnis; 90 Tage, nicht
im Backup). Nur der Worker-Container erhält ANTHROPIC_API_KEY aus dem
Secret apicore-anthropic (optional); API- und Job-Rollen warnen, wenn
der Schlüssel bei ihnen gesetzt ist.

Gates: Backend lint, typecheck, 4589 Unit-Tests, build; e2e 104 Suites
(nur der Wetter-Test gegen die echte API schwankt); Manifest-Policy 55.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:38:15 +02:00

373 lines
12 KiB
TypeScript

import * as bcrypt from 'bcryptjs';
import { Prisma } from '../generated/prisma';
import { PrismaService } from '../src/prisma/prisma.service';
import { sealTranscript } from '../src/voice/core/transcript';
import { openVoiceText, voiceAad } from '../src/voice/core/voice-crypto';
import { FakeStructuredModel } from '../src/voice/extraction/fake-structured-model';
import { VoiceExtractionService } from '../src/voice/extraction/voice-extraction.service';
const SECRET_TEXT = 'Geheimer Befund Königin gesehen vier Brutwaben';
const ANSWER = {
findings: [
{
field: 'queenSeen',
value: true,
quote: 'Königin gesehen',
corrected: false,
},
],
unclear: [],
};
/**
* The extraction loop against the real database with a FakeStructuredModel
* (no network, no key). Runs are scoped to this file's own users; every row
* is created and removed here because other suites share the database.
*/
describe('voice extraction loop (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const originalLimit = process.env.VOICE_DAILY_EXTRACTIONS;
let prisma: PrismaService;
let roleId: string;
let userId: string;
let apiaryId: string;
let colonyId: string;
const mine = Prisma.sql`"ownerUserId" IN (SELECT "id" FROM "User" WHERE "email" LIKE ${`%${suffix}%`})`;
const run = (model: FakeStructuredModel, signal?: AbortSignal) =>
new VoiceExtractionService(prisma, model).runOnce(signal, {
filter: mine,
});
const row = (id: string) =>
prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
const events = (id: string, type: string) =>
prisma.domainEvent.findMany({
where: { aggregateType: 'VoiceCapture', aggregateId: id, type },
});
async function seed(
data: Partial<Prisma.VoiceCaptureUncheckedCreateInput> = {},
): Promise<string> {
const created = await prisma.voiceCapture.create({
data: {
ownerUserId: userId,
colonyId,
inspectionDate: new Date('2026-10-16'),
source: 'TEXT',
status: 'TRANSCRIBED',
contentExpiresAt: new Date(Date.now() + 7 * 24 * 3_600_000),
...data,
},
});
await prisma.voiceCapture.update({
where: { id: created.id },
data: {
transcriptEnc: sealTranscript(created.id, {
v: 1,
text: SECRET_TEXT,
}),
},
});
return created.id;
}
async function ledgerCleanup(): Promise<void> {
const own = await prisma.voiceCapture.findMany({
where: { ownerUserId: userId },
select: { id: true },
});
const ledgerIds = own.map((each) => each.id);
await prisma.voiceExtractionCall.deleteMany({
where: {
OR: [
{ captureId: { in: ledgerIds } },
{ captureId: { endsWith: suffix } },
{ id: { endsWith: suffix } },
],
},
});
}
beforeAll(async () => {
prisma = new PrismaService();
await prisma.$connect();
roleId = (
await prisma.role.create({
data: { key: `VOICE_EX_${suffix}`, name: `Voice ex ${suffix}` },
})
).id;
userId = (
await prisma.user.create({
data: {
email: `voice-ex-${suffix}@example.com`,
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
name: 'Voice extraction',
roleId,
},
})
).id;
apiaryId = (
await prisma.apiary.create({ data: { name: `Voice ex ${suffix}` } })
).id;
colonyId = (
await prisma.colony.create({
data: {
code: `VEX-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
});
beforeEach(async () => {
process.env.VOICE_DAILY_EXTRACTIONS = '10000';
const own = await prisma.voiceCapture.findMany({
where: { ownerUserId: userId },
select: { id: true },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'VoiceCapture',
aggregateId: { in: own.map((c) => c.id) },
},
});
await ledgerCleanup();
await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } });
});
afterAll(async () => {
if (originalLimit === undefined) {
delete process.env.VOICE_DAILY_EXTRACTIONS;
} else {
process.env.VOICE_DAILY_EXTRACTIONS = originalLimit;
}
const own = await prisma.voiceCapture.findMany({
where: { ownerUserId: userId },
select: { id: true },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'VoiceCapture',
aggregateId: { in: own.map((c) => c.id) },
},
});
await ledgerCleanup();
await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } });
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.role.deleteMany({ where: { id: roleId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.$disconnect();
});
it('turns a transcribed note into READY with a sealed suggestion, tokens and one event', async () => {
const id = await seed();
const model = new FakeStructuredModel([FakeStructuredModel.ok(ANSWER)]);
await run(model);
const after = await row(id);
expect(after).toMatchObject({
status: 'READY',
claimToken: null,
claimedAt: null,
model: 'claude-sonnet-5-5',
inputTokens: 1500,
outputTokens: 400,
});
expect(after.readyAt).not.toBeNull();
expect(after.suggestionEnc).not.toContain('queenSeen');
const stored = JSON.parse(
openVoiceText(after.suggestionEnc ?? '', voiceAad(id, 'suggestion')),
) as { suggestions: { field: string }[] };
expect(stored.suggestions.map((each) => each.field)).toEqual(['queenSeen']);
const ready = await events(id, 'VoiceCaptureReady');
expect(ready).toHaveLength(1);
expect(JSON.stringify(ready[0].payload)).not.toContain('Königin');
});
it('parks the note with EXTRACT_DISABLED, burns no attempt and writes one operator event', async () => {
const first = await seed();
const second = await seed();
await run(new FakeStructuredModel([], false));
for (const id of [first, second]) {
const after = await row(id);
expect(after).toMatchObject({
status: 'TRANSCRIBED',
attempts: 0,
lastErrorCode: 'EXTRACT_DISABLED',
});
expect(after.availableAt.getTime()).toBeGreaterThan(
Date.now() + 3_000_000,
);
}
const disabled = [
...(await events(first, 'VoiceCaptureFailed')),
...(await events(second, 'VoiceCaptureFailed')),
];
expect(disabled).toHaveLength(1);
});
it('counts paid calls, not notes, against the daily limit', async () => {
// Two paid calls today that never produced a READY note, plus calls that
// never reached the model (they do not count).
await prisma.$executeRaw`
INSERT INTO "VoiceExtractionCall" ("id", "model", "outcome", "captureId")
VALUES (${`ex-1-${suffix}`}, 'claude-sonnet-5-5', 'RETRY', ${`gone-${suffix}`}),
(${`ex-2-${suffix}`}, 'claude-sonnet-5-5', 'REFUSED', ${`gone-${suffix}`}),
(${`ex-3-${suffix}`}, 'claude-sonnet-5-5', 'AUTH', ${`gone-${suffix}`})`;
const waiting = await seed();
const own = await prisma.voiceExtractionCall.count({
where: {
captureId: `gone-${suffix}`,
outcome: { notIn: ['AUTH', 'ABORTED'] },
},
});
expect(own).toBe(2);
const todayCounted = await prisma.$queryRaw<{ n: number }[]>`
SELECT count(*)::int AS "n" FROM "VoiceExtractionCall"
WHERE "outcome" NOT IN ('AUTH', 'ABORTED')
AND "at" >= date_trunc('day', now() AT TIME ZONE 'UTC')`;
process.env.VOICE_DAILY_EXTRACTIONS = String(todayCounted[0].n);
const model = new FakeStructuredModel([FakeStructuredModel.ok(ANSWER)]);
await run(model);
expect(model.requests).toHaveLength(0);
const after = await row(waiting);
expect(after).toMatchObject({
status: 'TRANSCRIBED',
attempts: 0,
lastErrorCode: 'DAILY_LIMIT',
});
expect(after.availableAt.getTime()).toBeGreaterThan(Date.now());
// One call below the limit the note goes through and is booked.
process.env.VOICE_DAILY_EXTRACTIONS = String(todayCounted[0].n + 1);
await prisma.voiceCapture.update({
where: { id: waiting },
data: { availableAt: new Date(Date.now() - 1000) },
});
await run(model);
expect(model.requests).toHaveLength(1);
const booked = await prisma.voiceExtractionCall.findMany({
where: { captureId: waiting },
});
expect(booked).toEqual([
expect.objectContaining({
outcome: 'OK',
model: 'claude-sonnet-5-5',
inputTokens: 1500,
outputTokens: 400,
}),
]);
});
it('retries three times with the backoff and then ends FAILED with one event', async () => {
const id = await seed();
const model = new FakeStructuredModel([
{ kind: 'error', code: 'RETRYABLE' },
]);
await run(model);
expect(await row(id)).toMatchObject({
status: 'TRANSCRIBED',
attempts: 1,
lastErrorCode: 'EXTRACT_RETRY',
});
for (const attempts of [1, 2]) {
await prisma.voiceCapture.update({
where: { id },
data: { availableAt: new Date(Date.now() - 1000) },
});
await run(model);
expect((await row(id)).attempts).toBe(attempts + 1);
}
expect(await row(id)).toMatchObject({
status: 'FAILED',
attempts: 3,
lastErrorCode: 'EXTRACT_FAILED',
});
expect(await events(id, 'VoiceCaptureFailed')).toHaveLength(1);
});
it('ends FAILED/REFUSED at once when the model declines', async () => {
const id = await seed();
await run(
new FakeStructuredModel([
{
kind: 'refused',
usage: { input: 10, output: 1, cacheRead: 0, cacheWrite: 0 },
model: 'claude-sonnet-5-5',
},
]),
);
expect(await row(id)).toMatchObject({
status: 'FAILED',
lastErrorCode: 'REFUSED',
});
expect(await events(id, 'VoiceCaptureFailed')).toHaveLength(1);
});
it('does not burn an attempt when shutdown cuts the call off', async () => {
const id = await seed();
const stop = new AbortController();
const model = new FakeStructuredModel([]);
jest.spyOn(model, 'extract').mockImplementation(() => {
stop.abort();
return Promise.resolve({ kind: 'error', code: 'RETRYABLE' });
});
await run(model, stop.signal);
expect(await row(id)).toMatchObject({
status: 'TRANSCRIBED',
attempts: 0,
});
});
it('a confirmed capture is not overwritten by a late extraction', async () => {
const id = await seed();
const model = new FakeStructuredModel([]);
jest.spyOn(model, 'extract').mockImplementation(async () => {
// The user saves the inspection while the model is still working.
await prisma.voiceCapture.update({
where: { id },
data: { status: 'DISCARDED', transcriptEnc: null },
});
return FakeStructuredModel.ok(ANSWER);
});
await run(model);
const after = await row(id);
expect(after.status).toBe('DISCARDED');
expect(after.suggestionEnc).toBeNull();
expect(await events(id, 'VoiceCaptureReady')).toHaveLength(0);
});
it('puts a note whose worker died back, and fails it after the third stale release', async () => {
const id = await seed({
status: 'EXTRACTING',
attempts: 2,
claimToken: 'dead-worker',
});
await prisma.$executeRaw`
UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '10 minutes'
WHERE "id" = ${id}`;
await run(new FakeStructuredModel([], false));
expect(await row(id)).toMatchObject({
status: 'FAILED',
lastErrorCode: 'EXTRACT_FAILED',
});
expect(await events(id, 'VoiceCaptureFailed')).toHaveLength(1);
});
});