feat: Template-Spec v1 Phase B — Rule Engine + Block Removal
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
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
- 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>
This commit is contained in:
378
admin-compliance/app/sdk/document-generator/ruleEngine.ts
Normal file
378
admin-compliance/app/sdk/document-generator/ruleEngine.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user