feat(service): P6.2 RuleEngine + RouteSimulator (deterministic, preview-first)
Keyword moderation flags; deny-by-default for protected (CHILD) destinations, REVIEW for other flags/requiresReview, ALLOW otherwise; versioned rulepack for replay. Simulate renders per-format previews + persists decisions, NEVER sends. BDD (party@9pm → children DENY / adult+seniors REVIEW / 0 sends) + replay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma, IiosRouteBinding } from '@prisma/client';
|
||||||
|
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RuleEngine } from './rule-engine';
|
||||||
|
|
||||||
|
export interface OriginRef {
|
||||||
|
channelType: string;
|
||||||
|
ref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview-first route simulator (P6). Produces a decision + rendered preview per
|
||||||
|
* enabled binding for an origin — and NEVER sends. Deterministic + replayable
|
||||||
|
* (re-simulating yields the same decisions). Persists decisions so an admin can
|
||||||
|
* approve by id.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RouteSimulator {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly rules: RuleEngine,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async simulate(interactionId: string, origin: OriginRef, opts?: { simulationId?: string }) {
|
||||||
|
const interaction = await this.prisma.iiosInteraction.findUnique({
|
||||||
|
where: { id: interactionId },
|
||||||
|
include: { parts: { orderBy: { partIndex: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!interaction) throw new NotFoundException('interaction not found');
|
||||||
|
|
||||||
|
const text = interaction.parts.find((p) => p.kind === 'TEXT')?.bodyText ?? '';
|
||||||
|
const hasAttachment = interaction.parts.some((p) => !!p.contentRef);
|
||||||
|
const flags = this.rules.flag(text);
|
||||||
|
const flagTypes = flags.map((f) => f.flagType);
|
||||||
|
await this.persistFlags(interactionId, flags);
|
||||||
|
|
||||||
|
const simulationId = opts?.simulationId ?? randomUUID();
|
||||||
|
const bindings = await this.prisma.iiosRouteBinding.findMany({
|
||||||
|
where: {
|
||||||
|
scopeId: interaction.scopeId,
|
||||||
|
enabled: true,
|
||||||
|
originChannelType: origin.channelType,
|
||||||
|
...(origin.ref ? { originRef: origin.ref } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const decisions = [];
|
||||||
|
for (const b of bindings) {
|
||||||
|
const { state, reasonCodes } = this.rules.decide(b, flagTypes);
|
||||||
|
const previewPayload = this.render(b, text, hasAttachment);
|
||||||
|
const decision = await this.prisma.iiosRouteDecision.upsert({
|
||||||
|
where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } },
|
||||||
|
create: {
|
||||||
|
interactionId,
|
||||||
|
routeBindingId: b.id,
|
||||||
|
decisionState: state,
|
||||||
|
reasonCodes,
|
||||||
|
previewPayload: previewPayload as Prisma.InputJsonValue,
|
||||||
|
outputFormat: b.outputFormat,
|
||||||
|
rulepackVersion: this.rules.rulepackVersion,
|
||||||
|
simulationId,
|
||||||
|
},
|
||||||
|
update: { decisionState: state, reasonCodes, previewPayload: previewPayload as Prisma.InputJsonValue, simulationId },
|
||||||
|
});
|
||||||
|
decisions.push(decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace event — a preview record, never a send.
|
||||||
|
if (decisions.length > 0) {
|
||||||
|
const event: CloudEvent = {
|
||||||
|
specversion: '1.0',
|
||||||
|
id: `evt_routesim_${simulationId}`,
|
||||||
|
type: IIOS_EVENTS.routeDecisionPreviewed,
|
||||||
|
source: `iios/routing/${interaction.scopeId}`,
|
||||||
|
subject: `interaction/${interactionId}`,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
datacontenttype: 'application/json',
|
||||||
|
insignia: { scopeSnapshotId: interaction.scopeId, correlationId: interaction.traceId ?? undefined, idempotencyKey: `routesim:${simulationId}`, dataClass: 'internal' },
|
||||||
|
data: { simulationId, interactionId, decisions: decisions.map((d) => ({ id: d.id, state: d.decisionState })) },
|
||||||
|
};
|
||||||
|
await this.prisma.iiosOutboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: 'route_simulation',
|
||||||
|
aggregateId: interactionId,
|
||||||
|
eventType: IIOS_EVENTS.routeDecisionPreviewed,
|
||||||
|
cloudEvent: event as unknown as Prisma.InputJsonValue,
|
||||||
|
partitionKey: `${interaction.scopeId}:${interactionId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { simulationId, decisions };
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(binding: IiosRouteBinding, text: string, hasAttachment: boolean) {
|
||||||
|
const attach = binding.includeAttachments && hasAttachment ? ' [+attachment]' : '';
|
||||||
|
const rendered =
|
||||||
|
binding.outputFormat === 'DIGEST'
|
||||||
|
? `[digest] ${text}`
|
||||||
|
: binding.outputFormat === 'SUMMARY'
|
||||||
|
? `[summary] ${text.slice(0, 60)}` // deterministic stub; real summary is P7 (AI)
|
||||||
|
: binding.outputFormat === 'TRANSCRIPT'
|
||||||
|
? `[transcript] ${text}`
|
||||||
|
: text; // FORWARD / THREADED
|
||||||
|
return {
|
||||||
|
format: binding.outputFormat,
|
||||||
|
destinationChannelType: binding.destinationChannelType,
|
||||||
|
destinationRef: binding.destinationRef,
|
||||||
|
text: rendered + attach,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistFlags(interactionId: string, flags: RuleFlagLike[]): Promise<void> {
|
||||||
|
if (flags.length === 0) return;
|
||||||
|
const existing = await this.prisma.iiosModerationFlag.count({ where: { interactionId, proposedBy: 'RULE' } });
|
||||||
|
if (existing > 0) return; // keep replay deterministic — flag once
|
||||||
|
await this.prisma.iiosModerationFlag.createMany({
|
||||||
|
data: flags.map((f) => ({ interactionId, flagType: f.flagType, proposedBy: 'RULE' })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuleFlagLike {
|
||||||
|
flagType: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient, IiosRouteMode } from '@prisma/client';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
|
||||||
|
import { ActorResolver } from '../identity/actor.resolver';
|
||||||
|
import { RuleEngine } from './rule-engine';
|
||||||
|
import { RouteSimulator } from './route-simulator';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
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 sim = () => new RouteSimulator(asService, new RuleEngine());
|
||||||
|
|
||||||
|
const sender: MessagePrincipal = { userId: 'wa-user', orgId: 'org_demo', appId: 'portal-demo', displayName: 'WA User' };
|
||||||
|
const ORIGIN = { channelType: 'WHATSAPP', ref: 'adult-group' };
|
||||||
|
|
||||||
|
async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> {
|
||||||
|
const m = new MessageService(asService, makeFakePorts(), actors);
|
||||||
|
const { threadId } = await m.openThread(null, sender);
|
||||||
|
const msg = await m.send(threadId, sender, { content: text }, `k-${randomUUID()}`);
|
||||||
|
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: msg.id } });
|
||||||
|
return { interactionId: msg.id, scopeId: interaction.scopeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeBinding(
|
||||||
|
scopeId: string,
|
||||||
|
destinationRef: string,
|
||||||
|
restrictionProfile?: string,
|
||||||
|
opts?: { requiresReview?: boolean; mode?: IiosRouteMode },
|
||||||
|
) {
|
||||||
|
return prisma.iiosRouteBinding.create({
|
||||||
|
data: {
|
||||||
|
scopeId,
|
||||||
|
originChannelType: ORIGIN.channelType,
|
||||||
|
originRef: ORIGIN.ref,
|
||||||
|
destinationChannelType: 'PORTAL',
|
||||||
|
destinationRef,
|
||||||
|
restrictionProfile,
|
||||||
|
requiresReview: opts?.requiresReview ?? true,
|
||||||
|
mode: opts?.mode ?? 'MANUAL',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stateFor(interactionId: string, destinationRef: string): Promise<string> {
|
||||||
|
const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef } });
|
||||||
|
const d = await prisma.iiosRouteDecision.findUniqueOrThrow({
|
||||||
|
where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } },
|
||||||
|
});
|
||||||
|
return d.decisionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('Route simulator (P6, preview-first)', () => {
|
||||||
|
it('"party at 9 PM" → children DENY, adult+seniors REVIEW, and NO send', async () => {
|
||||||
|
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
||||||
|
await makeBinding(scopeId, 'adult', 'ADULT');
|
||||||
|
await makeBinding(scopeId, 'seniors', 'SENIORS');
|
||||||
|
await makeBinding(scopeId, 'children', 'CHILD');
|
||||||
|
|
||||||
|
await sim().simulate(interactionId, ORIGIN);
|
||||||
|
|
||||||
|
expect(await stateFor(interactionId, 'children')).toBe('DENY');
|
||||||
|
expect(await stateFor(interactionId, 'adult')).toBe('REVIEW');
|
||||||
|
expect(await stateFor(interactionId, 'seniors')).toBe('REVIEW');
|
||||||
|
expect(await prisma.iiosOutboundCommand.count()).toBe(0); // simulation never sends
|
||||||
|
});
|
||||||
|
|
||||||
|
it('safe message + requiresReview:false → ALLOW', async () => {
|
||||||
|
const { interactionId, scopeId } = await makeInteraction('hello team, meeting notes attached');
|
||||||
|
await makeBinding(scopeId, 'general', undefined, { requiresReview: false });
|
||||||
|
await sim().simulate(interactionId, ORIGIN);
|
||||||
|
expect(await stateFor(interactionId, 'general')).toBe('ALLOW');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replay: simulating twice yields identical decisions (deterministic)', async () => {
|
||||||
|
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
||||||
|
await makeBinding(scopeId, 'children', 'CHILD');
|
||||||
|
|
||||||
|
const first = await sim().simulate(interactionId, ORIGIN);
|
||||||
|
const second = await sim().simulate(interactionId, ORIGIN);
|
||||||
|
|
||||||
|
expect(await prisma.iiosRouteDecision.count()).toBe(1); // upsert, not duplicated
|
||||||
|
expect(first.decisions[0]?.decisionState).toBe(second.decisions[0]?.decisionState);
|
||||||
|
expect(first.decisions[0]?.reasonCodes).toEqual(second.decisions[0]?.reasonCodes);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RuleEngine } from './rule-engine';
|
||||||
|
import { RouteSimulator } from './route-simulator';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [RuleEngine, RouteSimulator],
|
||||||
|
exports: [RuleEngine, RouteSimulator],
|
||||||
|
})
|
||||||
|
export class RoutingModule {}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { IiosRouteDecisionState } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface RuleFlag {
|
||||||
|
flagType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecidableBinding {
|
||||||
|
restrictionProfile: string | null;
|
||||||
|
requiresReview: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic routing rules (P6). Keyword-based moderation flags + a decision
|
||||||
|
* function that is **deny-by-default for restricted destinations**. Versioned so
|
||||||
|
* a past decision is reproducible on replay. AI may only *propose* flags later
|
||||||
|
* (P7) — it can never turn a DENY/REVIEW into an ALLOW here.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RuleEngine {
|
||||||
|
readonly rulepackVersion = 'v1';
|
||||||
|
|
||||||
|
flag(text: string): RuleFlag[] {
|
||||||
|
const t = (text ?? '').toLowerCase();
|
||||||
|
const flags: RuleFlag[] = [];
|
||||||
|
if (t.includes('party') || /\b(9|10|11)\s*pm\b/.test(t)) flags.push({ flagType: 'AFTER_HOURS_EVENT' });
|
||||||
|
if (t.includes('sale') || t.includes('promo') || t.includes('discount')) flags.push({ flagType: 'PROMOTION' });
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destination profiles that are deny-by-default for any flagged content. */
|
||||||
|
private readonly protectedProfiles = new Set(['CHILD']);
|
||||||
|
|
||||||
|
decide(binding: DecidableBinding, flagTypes: string[]): { state: IiosRouteDecisionState; reasonCodes: string[] } {
|
||||||
|
const protectedDest = !!binding.restrictionProfile && this.protectedProfiles.has(binding.restrictionProfile);
|
||||||
|
|
||||||
|
// Deny-by-default: a protected destination (e.g. CHILD) never receives flagged content.
|
||||||
|
if (protectedDest && flagTypes.length > 0) {
|
||||||
|
return { state: 'DENY', reasonCodes: [`${binding.restrictionProfile}_GROUP_RESTRICTION`, ...flagTypes] };
|
||||||
|
}
|
||||||
|
if (flagTypes.length > 0) {
|
||||||
|
return { state: 'REVIEW', reasonCodes: [...flagTypes, 'ADMIN_APPROVAL_REQUIRED'] };
|
||||||
|
}
|
||||||
|
if (binding.requiresReview) {
|
||||||
|
return { state: 'REVIEW', reasonCodes: ['ADMIN_APPROVAL_REQUIRED'] };
|
||||||
|
}
|
||||||
|
return { state: 'ALLOW', reasonCodes: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user