Files
breakpilot-compliance/admin-compliance/app/sdk/document-generator/ruleEngine.ts
Benjamin Admin 1c5a4c2d96
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 19s
feat: Template-Spec v1 Phase B — Rule Engine + Block Removal
- ruleEngine.ts: Minimal JSONLogic evaluator, 6-phase runner (compute_flags,
  auto_defaults, hard_validations, auto_remove_blocks, module_requirements,
  warnings), getDocType mapping, applyBlockRemoval
- ruleEngine.test.ts: 49 Vitest tests (alle grün)
- page.tsx: ruleResult useMemo, enabledModules state, computed flags pills,
  module toggles, rule engine banners (errors/warnings/legal notice)
- migrations/022_template_block_markers.sql: Dokumentation + Verify-Query
- scripts/apply_block_markers_022.py: NDA_PENALTY_BLOCK, COOKIE_ANALYTICS_BLOCK,
  COOKIE_MARKETING_BLOCK in DB-Templates einfügen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 13:23:03 +01:00

379 lines
11 KiB
TypeScript

/**
* Template-Spec v1 — Rule Engine (Phase B)
*
* Evaluates ruleset.v1.json against a RuleInput and produces flags,
* applied defaults, validation errors/warnings, and removed block IDs.
*
* Design: zero external dependencies — minimal JSONLogic evaluator inline.
*/
import ruleset from './ruleset.v1.json'
import type { TemplateContext, ComputedFlags } from './contextBridge'
// =============================================================================
// Types
// =============================================================================
export interface RuleInput {
doc_type: string
render: { lang: string; variant: string }
context: TemplateContext
modules: { enabled: string[] }
}
export interface RuleViolation {
id: string
severity: 'ERROR' | 'WARN'
message: string
phase: string
}
export interface RuleEngineResult {
computedFlags: ComputedFlags
contextAfterDefaults: TemplateContext
violations: RuleViolation[]
warnings: RuleViolation[]
removedBlocks: string[]
appliedDefaults: string[]
}
// =============================================================================
// Minimal JSONLogic Evaluator
// Supports: var, ==, !=, in, and, or, >=, <=
// =============================================================================
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue }
interface Envelope {
doc_type: string
render: { lang: string; variant: string }
context: TemplateContext
computed_flags: ComputedFlags
modules: { enabled: string[] }
}
function getPath(obj: unknown, path: string): unknown {
if (path === '') return obj
const parts = path.split('.')
let cur: unknown = obj
for (const part of parts) {
if (cur == null || typeof cur !== 'object') return null
cur = (cur as Record<string, unknown>)[part]
}
return cur ?? null
}
function evaluate(expr: JsonValue, env: Envelope): unknown {
if (expr === null || typeof expr !== 'object' || Array.isArray(expr)) {
return expr
}
const ops = Object.keys(expr as Record<string, unknown>)
if (ops.length === 0) return null
const op = ops[0]
const args = (expr as Record<string, JsonValue>)[op]
switch (op) {
case 'var': {
const path = String(args)
return getPath(env, path)
}
case '==': {
const [a, b] = args as JsonValue[]
return evaluate(a, env) == evaluate(b, env) // eslint-disable-line eqeqeq
}
case '!=': {
const [a, b] = args as JsonValue[]
return evaluate(a, env) != evaluate(b, env) // eslint-disable-line eqeqeq
}
case '>=': {
const [a, b] = args as JsonValue[]
const av = evaluate(a, env)
const bv = evaluate(b, env)
if (av == null || bv == null) return false
return Number(av) >= Number(bv)
}
case '<=': {
const [a, b] = args as JsonValue[]
const av = evaluate(a, env)
const bv = evaluate(b, env)
if (av == null || bv == null) return false
return Number(av) <= Number(bv)
}
case 'in': {
const [needle, haystack] = args as JsonValue[]
const n = evaluate(needle, env)
const h = evaluate(haystack, env)
if (!Array.isArray(h)) return false
return h.includes(n as never)
}
case 'and': {
const items = args as JsonValue[]
return items.every((item) => !!evaluate(item, env))
}
case 'or': {
const items = args as JsonValue[]
return items.some((item) => !!evaluate(item, env))
}
default:
return null
}
}
// =============================================================================
// Deep clone helper
// =============================================================================
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
// =============================================================================
// Set value at dot-path (mutates object for defaults application)
// =============================================================================
function setNestedPath(obj: Record<string, unknown>, dotPath: string, value: unknown): void {
const parts = dotPath.split('.')
let cur: Record<string, unknown> = obj
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]
if (cur[part] == null || typeof cur[part] !== 'object') {
cur[part] = {}
}
cur = cur[part] as Record<string, unknown>
}
cur[parts[parts.length - 1]] = value
}
// =============================================================================
// Phase runner helpers
// =============================================================================
type ComputeFlagEntry = {
id: string
set: string
expr: JsonValue
}
type AutoDefaultEntry = {
id: string
when: JsonValue
actions: { type: string; path: string; value: unknown }[]
}
type ValidationEntry = {
id: string
severity: string
when: JsonValue
assert_all: JsonValue[]
message: string
}
type BlockRemoveEntry = {
id: string
when: JsonValue
actions: { type: string; block_id: string }[]
}
type ModuleRequirementEntry = {
id: string
severity: string
when: JsonValue
assert_all: JsonValue[]
message: string
}
type WarningEntry = {
id: string
severity: string
when: JsonValue
assert_all: JsonValue[]
message: string
}
// =============================================================================
// Phase 1: compute_flags
// =============================================================================
function runComputeFlags(env: Envelope): ComputedFlags {
const flags: Record<string, boolean> = {}
for (const rule of (ruleset.compute_flags as unknown) as ComputeFlagEntry[]) {
const result = !!evaluate(rule.expr as JsonValue, env)
// set path like "computed_flags.IS_B2C"
const key = rule.set.replace('computed_flags.', '')
flags[key] = result
}
return flags as unknown as ComputedFlags
}
// =============================================================================
// Phase 2: auto_defaults
// =============================================================================
function runAutoDefaults(env: Envelope): string[] {
const applied: string[] = []
for (const rule of ruleset.auto_defaults as AutoDefaultEntry[]) {
if (!!evaluate(rule.when as JsonValue, env)) {
for (const action of rule.actions) {
if (action.type === 'set') {
// path like "context.SECURITY.LOG_RETENTION_DAYS"
const contextPath = action.path.replace('context.', '')
setNestedPath(env.context as unknown as Record<string, unknown>, contextPath, action.value)
}
}
applied.push(rule.id)
}
}
return applied
}
// =============================================================================
// Phase 4 / 5 / 6: hard_validations, module_requirements, warnings
// =============================================================================
function runValidations(
rules: ValidationEntry[],
env: Envelope,
phase: string
): RuleViolation[] {
const result: RuleViolation[] = []
for (const rule of rules) {
if (!evaluate(rule.when as JsonValue, env)) continue
// assert_all: if empty → always fires (WARN_LEGAL_REVIEW pattern)
const allPass =
rule.assert_all.length === 0
? false
: rule.assert_all.every((assertion) => !!evaluate(assertion as JsonValue, env))
if (!allPass) {
result.push({
id: rule.id,
severity: rule.severity as 'ERROR' | 'WARN',
message: rule.message,
phase,
})
}
}
return result
}
// =============================================================================
// Phase 5: auto_remove_blocks
// =============================================================================
function runAutoRemoveBlocks(env: Envelope): string[] {
const removed: string[] = []
for (const rule of ruleset.auto_remove_blocks as BlockRemoveEntry[]) {
if (!!evaluate(rule.when as JsonValue, env)) {
for (const action of rule.actions) {
if (action.type === 'remove_block') {
removed.push(action.block_id)
}
}
}
}
return removed
}
// =============================================================================
// Main: runRuleset
// =============================================================================
export function runRuleset(input: RuleInput): RuleEngineResult {
// Deep clone context so we don't mutate caller's state
const clonedContext = deepClone(input.context)
const env: Envelope = {
doc_type: input.doc_type,
render: input.render,
context: clonedContext,
computed_flags: {} as ComputedFlags,
modules: input.modules,
}
// Phase 1: compute_flags
env.computed_flags = runComputeFlags(env)
// Phase 2: auto_defaults — mutates clonedContext
const appliedDefaults = runAutoDefaults(env)
// Phase 3: compute_flags again (after defaults)
env.computed_flags = runComputeFlags(env)
// Phase 4: hard_validations
const allHardViolations = runValidations(
(ruleset.hard_validations as unknown) as ValidationEntry[],
env,
'hard_validations'
)
// Phase 5: auto_remove_blocks
const removedBlocks = runAutoRemoveBlocks(env)
// Phase 6a: module_requirements
const moduleViolations = runValidations(
(ruleset.module_requirements as unknown) as ModuleRequirementEntry[],
env,
'module_requirements'
)
// Phase 6b: warnings
const warningViolations = runValidations(
(ruleset.warnings as unknown) as WarningEntry[],
env,
'warnings'
)
const allViolations = [...allHardViolations, ...moduleViolations, ...warningViolations]
return {
computedFlags: env.computed_flags,
contextAfterDefaults: clonedContext,
violations: allViolations.filter((v) => v.severity === 'ERROR'),
warnings: allViolations.filter((v) => v.severity === 'WARN'),
removedBlocks,
appliedDefaults,
}
}
// =============================================================================
// getDocType: maps (templateType, language) → doc_type string
// =============================================================================
const DOC_TYPE_MAP: Record<string, string> = {
'nda+de': 'nda_de',
'nda+en': 'nda_en',
'sla+de': 'sla_de',
'acceptable_use+en': 'acceptable_use_en',
'community_guidelines+de': 'community_de',
'copyright_policy+de': 'copyright_de',
'cloud_service_agreement+de': 'cloud_contract_de',
'data_usage_clause+de': 'data_usage_clause_de',
'cookie_banner+de': 'cookie_banner_de',
'agb+de': 'agb_de',
'agb_clause+de': 'agb_de',
'clause+en': 'liability_clause_en',
}
export function getDocType(templateType: string, language: string): string {
const key = `${templateType}+${language}`
return DOC_TYPE_MAP[key] ?? `${templateType}_${language}`
}
// =============================================================================
// applyBlockRemoval: removes [BLOCK:ID]…[/BLOCK:ID] markers from content
// =============================================================================
export function applyBlockRemoval(content: string, removedBlocks: string[]): string {
let result = content
for (const blockId of removedBlocks) {
// Matches [BLOCK:ID]...content...[/BLOCK:ID] including optional trailing newline
const escaped = blockId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(
`\\[BLOCK:${escaped}\\][\\s\\S]*?\\[\\/BLOCK:${escaped}\\]\\n?`,
'g'
)
result = result.replace(pattern, '')
}
return result
}