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]>
This commit is contained in:
1 parent
89a1546f15
commit
6a915aad9c
38 files changed
+3612
-33
No files matched your search
@@ -0,0 +1,372 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user