68d922c248
Task 7.4: route-simulator now unions RULE flags with persisted AI moderation flags before decide() — so AI flags influence routing exactly like rule flags, only ever REVIEW/DENY, never ALLOW. SUMMARY/TRANSCRIPT/DIGEST previews render a proposed AI summary (aiSourced flag) instead of the P6 stub, still preview-only. AiProjector auto-runs CLASSIFY on interaction.normalized (channel-bearing only), idempotent via claim(). 4 tests prove KG-05: AI flag forces REVIEW on an auto-ALLOW binding, DENY on CHILD, AI summary in preview with 0 sends, projector idempotency. Full suite 79 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
47 lines
1.8 KiB
TypeScript
47 lines
1.8 KiB
TypeScript
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { OutboxBus } from '../outbox/outbox.bus';
|
|
import { AiJobService } from './ai.service';
|
|
|
|
/**
|
|
* Auto-proposal (P7). Reacts to `interaction.normalized` — channel-bearing messages
|
|
* that arrived via an adapter — and runs a CLASSIFY job so inbound content gets an AI
|
|
* moderation proposal automatically. Those flags only ever push routing toward
|
|
* REVIEW/DENY (KG-05); nothing is sent or approved. Idempotent via `claim()` and the
|
|
* job's own idempotency key.
|
|
*/
|
|
@Injectable()
|
|
export class AiProjector implements OnModuleInit {
|
|
private readonly consumer = 'ai-projector';
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly bus: OutboxBus,
|
|
private readonly ai: AiJobService,
|
|
) {}
|
|
|
|
onModuleInit(): void {
|
|
this.bus.on(IIOS_EVENTS.interactionNormalized, (p) => void this.onNormalized(p as CloudEvent).catch(() => {}));
|
|
}
|
|
|
|
async onNormalized(event: CloudEvent): Promise<void> {
|
|
if (!(await this.claim(event.id))) return;
|
|
const { interactionId } = event.data as { interactionId: string };
|
|
const interaction = await this.prisma.iiosInteraction.findUnique({
|
|
where: { id: interactionId },
|
|
include: { channel: true },
|
|
});
|
|
if (!interaction?.channel) return; // native DMs have no origin channel → not auto-classified
|
|
await this.ai.runJob({ interactionId, jobType: 'CLASSIFY' });
|
|
}
|
|
|
|
private async claim(eventId: string): Promise<boolean> {
|
|
const res = await this.prisma.iiosProcessedEvent.createMany({
|
|
data: [{ consumerName: this.consumer, eventId }],
|
|
skipDuplicates: true,
|
|
});
|
|
return res.count > 0;
|
|
}
|
|
}
|