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:
2026-07-01 16:34:32 +05:30
parent e03f2f72e4
commit c6dc1cf112
6 changed files with 491 additions and 0 deletions
+109
View File
@@ -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();
});
});