feat(p7): AiJobService + AiBudgetGuard + local inference (gate→budget→propose)
Task 7.3: AiJobService runs scoped/purposed AI jobs that only propose — fails closed via OPA (KG-03), degrades on budget (AiBudgetGuard sums costUnits, KG-12), then persists job+modelRun+artifact(PROPOSED)+claims+evidence with full provenance. CLASSIFY also proposes IiosModerationFlag(proposedBy:'AI'). accept/ reject record human feedback + emit events. Idempotent per job key. INFERENCE_PORT binds LocalDeterministicInference for nest start; specs inject testkit FakeInference. 8 tests: classify/summarize/extract, fail-closed, budget-degrade, idempotent, accept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { resetDb } from '../test-utils/reset-db';
|
||||
import { makeFakePorts, makeFakeInference } from '@insignia/iios-testkit';
|
||||
import { PolicyDeniedError } from '@insignia/iios-contracts';
|
||||
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
|
||||
import { ActorResolver } from '../identity/actor.resolver';
|
||||
import { AiJobService } from './ai.service';
|
||||
import { AiBudgetGuard } from './ai-budget.guard';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import type { FakeInference } from '@insignia/iios-testkit';
|
||||
import type { FakePorts } from '@insignia/iios-testkit';
|
||||
|
||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const asService = prisma as unknown as PrismaService;
|
||||
const actors = new ActorResolver(asService);
|
||||
|
||||
const user: MessagePrincipal = { userId: 'admin-1', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Admin' };
|
||||
|
||||
function svc(opts?: { ports?: FakePorts; inference?: FakeInference; quota?: number }) {
|
||||
const ports = opts?.ports ?? makeFakePorts();
|
||||
const inference = opts?.inference ?? makeFakeInference();
|
||||
const budget = new AiBudgetGuard(asService);
|
||||
if (opts?.quota !== undefined) budget.setQuota(opts.quota);
|
||||
return new AiJobService(asService, ports, inference, budget, actors);
|
||||
}
|
||||
|
||||
async function makeInteraction(text: string): Promise<string> {
|
||||
const m = new MessageService(asService, makeFakePorts(), actors);
|
||||
const { threadId } = await m.openThread(null, user);
|
||||
const msg = await m.send(threadId, user, { content: text }, `k-${randomUUID()}`);
|
||||
return msg.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => { await prisma.$connect(); });
|
||||
afterAll(async () => { await prisma.$disconnect(); });
|
||||
beforeEach(async () => { await resetDb(prisma); });
|
||||
|
||||
describe('AiJobService (P7 proposal layer)', () => {
|
||||
it('CLASSIFY "party at 9 PM" → CLASSIFICATION artifact + MODERATION_FLAG claim + AI moderation flag', async () => {
|
||||
const interactionId = await makeInteraction('There is a party at 9 PM tonight!');
|
||||
const { job, artifact } = await svc().runJob({ interactionId, jobType: 'CLASSIFY', principal: user });
|
||||
|
||||
expect(job.status).toBe('DONE');
|
||||
expect(artifact?.artifactType).toBe('CLASSIFICATION');
|
||||
expect(artifact?.claims.some((c) => c.claimType === 'MODERATION_FLAG')).toBe(true);
|
||||
const flag = await prisma.iiosModerationFlag.findFirst({ where: { interactionId, proposedBy: 'AI' } });
|
||||
expect(flag?.flagType).toBe('AFTER_HOURS_EVENT');
|
||||
expect(flag?.confidence).toBeGreaterThan(0);
|
||||
expect(flag?.explanation).toBeTruthy();
|
||||
});
|
||||
|
||||
it('SUMMARIZE → SUMMARY artifact with a cited evidence link', async () => {
|
||||
const interactionId = await makeInteraction('The committee met today. Budget discussed.');
|
||||
const { artifact } = await svc().runJob({ interactionId, jobType: 'SUMMARIZE', principal: user });
|
||||
expect(artifact?.artifactType).toBe('SUMMARY');
|
||||
expect((artifact?.contentRef as { text?: string }).text).toContain('[ai]');
|
||||
expect(artifact?.evidence.some((e) => e.sourceId === interactionId)).toBe(true);
|
||||
});
|
||||
|
||||
it('EXTRACT → EVENT claim stays certainty=DISCUSSION (Scenario 7)', async () => {
|
||||
const interactionId = await makeInteraction('Maybe a party at 9 PM?');
|
||||
const { artifact } = await svc().runJob({ interactionId, jobType: 'EXTRACT', principal: user });
|
||||
const event = artifact?.claims.find((c) => c.claimType === 'EVENT');
|
||||
expect((event?.claimJson as { certainty?: string }).certainty).toBe('DISCUSSION');
|
||||
});
|
||||
|
||||
it('fails closed: OPA deny → FAILED job, PolicyDeniedError, 0 artifacts (KG-03)', async () => {
|
||||
const interactionId = await makeInteraction('hello');
|
||||
const ports = makeFakePorts();
|
||||
ports.opa.deny('no ai purpose');
|
||||
await expect(svc({ ports }).runJob({ interactionId, jobType: 'CLASSIFY', principal: user })).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||
expect(await prisma.iiosAiArtifact.count()).toBe(0);
|
||||
expect((await prisma.iiosAiJob.findFirstOrThrow({ where: { interactionId } })).status).toBe('FAILED');
|
||||
});
|
||||
|
||||
it('budget governor: quota 0 → ABSTAINED job, 0 artifacts (KG-12 degrade)', async () => {
|
||||
const interactionId = await makeInteraction('The committee met today.');
|
||||
const { job, artifact } = await svc({ quota: 0 }).runJob({ interactionId, jobType: 'SUMMARIZE', principal: user });
|
||||
expect(job.status).toBe('ABSTAINED');
|
||||
expect(job.abstentionReason).toBe('BUDGET_EXCEEDED');
|
||||
expect(artifact).toBeNull();
|
||||
expect(await prisma.iiosAiArtifact.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('is idempotent: replaying the same job key twice → exactly 1 artifact', async () => {
|
||||
const interactionId = await makeInteraction('There is a party at 9 PM tonight!');
|
||||
const s = svc();
|
||||
const key = `ai:CLASSIFY:${interactionId}`;
|
||||
await s.runJob({ interactionId, jobType: 'CLASSIFY', principal: user, idempotencyKey: key });
|
||||
await s.runJob({ interactionId, jobType: 'CLASSIFY', principal: user, idempotencyKey: key });
|
||||
expect(await prisma.iiosAiJob.count({ where: { interactionId } })).toBe(1);
|
||||
expect(await prisma.iiosAiArtifact.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('accept records human feedback (ACCEPTED + claims validated) and emits an event', async () => {
|
||||
const interactionId = await makeInteraction('There is a party at 9 PM tonight!');
|
||||
const { artifact } = await svc().runJob({ interactionId, jobType: 'CLASSIFY', principal: user });
|
||||
const updated = await svc().accept(artifact!.id, user);
|
||||
expect(updated.status).toBe('ACCEPTED');
|
||||
expect(updated.acceptedByActorId).toBeTruthy();
|
||||
const claim = await prisma.iiosAiClaim.findFirst({ where: { artifactId: artifact!.id } });
|
||||
expect(claim?.validationStatus).toBe('ACCEPTED');
|
||||
const evt = await prisma.iiosOutboxEvent.findFirst({ where: { eventType: 'com.insignia.iios.ai.artifact.accepted.v1', aggregateId: artifact!.id } });
|
||||
expect(evt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user