Files
breakpilot-compliance/admin-compliance/app/sdk/document-generator/ruleEngine.ts
Benjamin Admin e0f7f2134e
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 32s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
feat: Template-Spec v1 Phase C — IF-Renderer + HOSTING/FEATURES + 4 neue DE-Templates
- contextBridge.ts: HostingCtx + FeaturesCtx (35 Felder), ~50 neue Platzhalter-Aliases
- ruleEngine.ts: buildBoolContext() + applyConditionalBlocks() (IF/IF_NOT/IF_ANY)
- ruleEngine.test.ts: 67 Tests (+18 für Phase C), alle grün
- page.tsx: IF-Renderer in Pipeline, HOSTING+FEATURES Formular-Sections, erweiterter SDK-Prefill
- scripts/apply_templates_023.py: 4 neue DE-Templates (Cookie v2, DSE, AGB, Impressum)
- migrations/023_new_templates_de.sql: Dokumentation + Verifikations-Query

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

456 lines
14 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}`
}
// =============================================================================
// buildBoolContext: combines computed flags + derived + FEATURES booleans
// =============================================================================
export function buildBoolContext(ctx: TemplateContext, flags: ComputedFlags): Record<string, boolean> {
const f = ctx.FEATURES
return {
// --- From computed flags ---
IS_B2C: flags.IS_B2C,
IS_B2B: flags.IS_B2B,
SERVICE_IS_SAAS: flags.SERVICE_IS_SAAS,
SERVICE_IS_HYBRID: flags.SERVICE_IS_HYBRID,
HAS_PENALTY: flags.HAS_PENALTY,
HAS_ANALYTICS: flags.HAS_ANALYTICS,
ANALYTICS_ENABLED: flags.HAS_ANALYTICS, // alias used in cookie banner template
HAS_MARKETING: flags.HAS_MARKETING,
MARKETING_ENABLED: flags.HAS_MARKETING, // alias
// --- Derived from PROVIDER ---
HAS_REGISTER: !!(ctx.PROVIDER.REGISTER_COURT),
HAS_VAT_ID: !!(ctx.PROVIDER.VAT_ID),
CONTACT_PHONE: !!(ctx.PROVIDER.PHONE),
// --- Derived from PRIVACY ---
HAS_DPO: !!(ctx.PRIVACY.DPO_NAME),
// --- From FEATURES booleans ---
THIRD_COUNTRY_POSSIBLE: f.HAS_THIRD_COUNTRY,
HAS_THIRD_COUNTRY: f.HAS_THIRD_COUNTRY,
FUNCTIONAL_ENABLED: f.HAS_FUNCTIONAL_COOKIES,
HAS_FUNCTIONAL_COOKIES: f.HAS_FUNCTIONAL_COOKIES,
CMP_LOGS_CONSENTS: f.CMP_LOGS_CONSENTS,
HAS_NEWSLETTER: f.HAS_NEWSLETTER,
HAS_ACCOUNT: f.HAS_ACCOUNT,
HAS_PAYMENTS: f.HAS_PAYMENTS,
HAS_SUPPORT: f.HAS_SUPPORT,
HAS_SOCIAL_MEDIA: f.HAS_SOCIAL_MEDIA,
HAS_PAID_PLANS: f.HAS_PAID_PLANS,
HAS_SLA: f.HAS_SLA,
HAS_EXPORT_POLICY: f.HAS_EXPORT_POLICY,
HAS_WITHDRAWAL: f.HAS_WITHDRAWAL,
HAS_REGULATED_PROFESSION: f.HAS_REGULATED_PROFESSION,
HAS_EDITORIAL_RESPONSIBLE: f.HAS_EDITORIAL_RESPONSIBLE,
HAS_DISPUTE_RESOLUTION: f.HAS_DISPUTE_RESOLUTION,
}
}
// =============================================================================
// applyConditionalBlocks: processes {{#IF}}/{{#IF_NOT}}/{{#IF_ANY}} directives
// Runs BEFORE placeholder substitution.
// =============================================================================
export function applyConditionalBlocks(content: string, boolCtx: Record<string, boolean>): string {
let result = content
// {{#IF_NOT COND}}...{{/IF_NOT}} — process before IF to avoid conflicts
result = result.replace(
/\{\{#IF_NOT ([A-Z_]+)\}\}([\s\S]*?)\{\{\/IF_NOT\}\}/g,
(_, cond: string, body: string) => (boolCtx[cond] ? '' : body)
)
// {{#IF_ANY A B C}}...{{/IF_ANY}}
result = result.replace(
/\{\{#IF_ANY ([A-Z_ ]+)\}\}([\s\S]*?)\{\{\/IF_ANY\}\}/g,
(_, conds: string, body: string) =>
conds.trim().split(/\s+/).some((c) => boolCtx[c]) ? body : ''
)
// {{#IF COND}}...{{/IF}}
result = result.replace(
/\{\{#IF ([A-Z_]+)\}\}([\s\S]*?)\{\{\/IF\}\}/g,
(_, cond: string, body: string) => (boolCtx[cond] ? body : '')
)
return result
}
// =============================================================================
// 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
}