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:
@@ -20,6 +20,10 @@ import {
|
||||
contextToPlaceholders, getRelevantSections,
|
||||
getUncoveredPlaceholders, getMissingRequired,
|
||||
} from './contextBridge'
|
||||
import {
|
||||
runRuleset, getDocType, applyBlockRemoval,
|
||||
type RuleInput, type RuleEngineResult,
|
||||
} from './ruleEngine'
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY CONFIG
|
||||
@@ -348,6 +352,12 @@ function ContextSectionForm({
|
||||
// GENERATOR SECTION
|
||||
// =============================================================================
|
||||
|
||||
// Available module definitions (id → display label)
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
CLOUD_EXPORT_DELETE_DE: 'Datenexport & Löschrecht',
|
||||
B2C_WITHDRAWAL_DE: 'Widerrufsrecht (B2C)',
|
||||
}
|
||||
|
||||
function GeneratorSection({
|
||||
template,
|
||||
context,
|
||||
@@ -355,6 +365,8 @@ function GeneratorSection({
|
||||
extraPlaceholders,
|
||||
onExtraChange,
|
||||
onClose,
|
||||
enabledModules,
|
||||
onModuleToggle,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
context: TemplateContext
|
||||
@@ -362,6 +374,8 @@ function GeneratorSection({
|
||||
extraPlaceholders: Record<string, string>
|
||||
onExtraChange: (key: string, value: string) => void
|
||||
onClose: () => void
|
||||
enabledModules: string[]
|
||||
onModuleToggle: (mod: string, checked: boolean) => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
||||
@@ -371,20 +385,52 @@ function GeneratorSection({
|
||||
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
||||
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
||||
|
||||
// Rule engine evaluation
|
||||
const ruleResult = useMemo((): RuleEngineResult | null => {
|
||||
if (!template) return null
|
||||
return runRuleset({
|
||||
doc_type: getDocType(template.templateType ?? '', template.language ?? 'de'),
|
||||
render: { lang: template.language ?? 'de', variant: 'standard' },
|
||||
context,
|
||||
modules: { enabled: enabledModules },
|
||||
} satisfies RuleInput)
|
||||
}, [template, context, enabledModules])
|
||||
|
||||
const allPlaceholderValues = useMemo(() => ({
|
||||
...contextToPlaceholders(context),
|
||||
...contextToPlaceholders(ruleResult?.contextAfterDefaults ?? context),
|
||||
...extraPlaceholders,
|
||||
}), [context, extraPlaceholders])
|
||||
}), [context, extraPlaceholders, ruleResult])
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
let content = template.text
|
||||
// Apply block removal BEFORE placeholder substitution
|
||||
let content = applyBlockRemoval(template.text, ruleResult?.removedBlocks ?? [])
|
||||
for (const [key, value] of Object.entries(allPlaceholderValues)) {
|
||||
if (value) {
|
||||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
return content
|
||||
}, [template.text, allPlaceholderValues])
|
||||
}, [template.text, allPlaceholderValues, ruleResult])
|
||||
|
||||
// Compute which modules are relevant (mentioned in violations/warnings)
|
||||
const relevantModules = useMemo(() => {
|
||||
if (!ruleResult) return []
|
||||
const mentioned = new Set<string>()
|
||||
const allIssues = [...ruleResult.violations, ...ruleResult.warnings]
|
||||
for (const issue of allIssues) {
|
||||
if (issue.phase === 'module_requirements') {
|
||||
// Extract module ID from message
|
||||
for (const modId of Object.keys(MODULE_LABELS)) {
|
||||
if (issue.message.includes(modId)) mentioned.add(modId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also show modules that are enabled but not mentioned
|
||||
for (const mod of enabledModules) {
|
||||
if (mod in MODULE_LABELS) mentioned.add(mod)
|
||||
}
|
||||
return [...mentioned]
|
||||
}, [ruleResult, enabledModules])
|
||||
|
||||
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
||||
|
||||
@@ -414,20 +460,40 @@ function GeneratorSection({
|
||||
}
|
||||
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Computed flags pills config
|
||||
const flagPills: { key: keyof typeof ruleResult.computedFlags; label: string; color: string }[] = ruleResult ? [
|
||||
{ key: 'IS_B2C', label: 'B2C', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'SERVICE_IS_SAAS', label: 'SaaS', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'HAS_PENALTY', label: 'Vertragsstrafe', color: 'bg-orange-100 text-orange-700' },
|
||||
{ key: 'HAS_ANALYTICS', label: 'Analytics', color: 'bg-gray-100 text-gray-600' },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-purple-300 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-purple-50 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<svg className="w-5 h-5 text-purple-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-xs text-purple-500 font-medium uppercase tracking-wide">Generator</div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{template.documentTitle}</div>
|
||||
</div>
|
||||
{/* Computed flags pills */}
|
||||
{ruleResult && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{flagPills.map(({ key, label, color }) =>
|
||||
ruleResult.computedFlags[key] ? (
|
||||
<span key={key} className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors" aria-label="Schließen">
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -452,6 +518,11 @@ function GeneratorSection({
|
||||
{missing.length}
|
||||
</span>
|
||||
)}
|
||||
{tab === 'preview' && ruleResult && ruleResult.violations.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-red-100 text-red-600 rounded-full">
|
||||
{ruleResult.violations.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -522,6 +593,29 @@ function GeneratorSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module toggles */}
|
||||
{relevantModules.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Module</p>
|
||||
<div className="space-y-2">
|
||||
{relevantModules.map((modId) => (
|
||||
<label key={modId} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledModules.includes(modId)}
|
||||
onChange={(e) => onModuleToggle(modId, e.target.checked)}
|
||||
className="w-4 h-4 accent-purple-600"
|
||||
/>
|
||||
<span className="text-xs font-mono text-gray-600">{modId}</span>
|
||||
{MODULE_LABELS[modId] && (
|
||||
<span className="text-xs text-gray-500">{MODULE_LABELS[modId]}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation summary + CTA */}
|
||||
<div className="flex items-center justify-between pt-2 flex-wrap gap-3">
|
||||
<div>
|
||||
@@ -550,6 +644,48 @@ function GeneratorSection({
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Rule engine banners */}
|
||||
{ruleResult && ruleResult.violations.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||
🔴 {ruleResult.violations.length} Fehler
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.violations.map((v) => (
|
||||
<li key={v.id} className="text-xs text-red-600">
|
||||
<span className="font-mono font-medium">[{v.id}]</span> {v.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.warnings
|
||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||
.map((w) => (
|
||||
<li key={w.id} className="text-xs text-yellow-700">
|
||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{missing.length > 0 && (
|
||||
@@ -625,6 +761,7 @@ function DocumentGeneratorPageInner() {
|
||||
const [activeTemplate, setActiveTemplate] = useState<LegalTemplateResult | null>(null)
|
||||
const [context, setContext] = useState<TemplateContext>(EMPTY_CONTEXT)
|
||||
const [extraPlaceholders, setExtraPlaceholders] = useState<Record<string, string>>({})
|
||||
const [enabledModules, setEnabledModules] = useState<string[]>([])
|
||||
const generatorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [totalCount, setTotalCount] = useState<number>(0)
|
||||
@@ -690,6 +827,7 @@ function DocumentGeneratorPageInner() {
|
||||
setActiveTemplate(t)
|
||||
setExpandedPreviewId(null)
|
||||
setExtraPlaceholders({})
|
||||
setEnabledModules([])
|
||||
setTimeout(() => {
|
||||
generatorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 100)
|
||||
@@ -868,6 +1006,12 @@ function DocumentGeneratorPageInner() {
|
||||
extraPlaceholders={extraPlaceholders}
|
||||
onExtraChange={(key, value) => setExtraPlaceholders((prev) => ({ ...prev, [key]: value }))}
|
||||
onClose={() => setActiveTemplate(null)}
|
||||
enabledModules={enabledModules}
|
||||
onModuleToggle={(mod, checked) =>
|
||||
setEnabledModules((prev) =>
|
||||
checked ? [...prev, mod] : prev.filter((m) => m !== mod)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedDataPointsData && selectedDataPointsData.length > 0 && (
|
||||
|
||||
499
admin-compliance/app/sdk/document-generator/ruleEngine.test.ts
Normal file
499
admin-compliance/app/sdk/document-generator/ruleEngine.test.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
runRuleset,
|
||||
getDocType,
|
||||
applyBlockRemoval,
|
||||
type RuleInput,
|
||||
} from './ruleEngine'
|
||||
import { EMPTY_CONTEXT } from './contextBridge'
|
||||
import type { TemplateContext } from './contextBridge'
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function makeInput(overrides: Partial<RuleInput> = {}): RuleInput {
|
||||
return {
|
||||
doc_type: 'nda_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: JSON.parse(JSON.stringify(EMPTY_CONTEXT)) as TemplateContext,
|
||||
modules: { enabled: [] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function withCtx(partial: Partial<TemplateContext>): TemplateContext {
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(EMPTY_CONTEXT)),
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getDocType
|
||||
// =============================================================================
|
||||
|
||||
describe('getDocType', () => {
|
||||
it('nda+de → nda_de', () => expect(getDocType('nda', 'de')).toBe('nda_de'))
|
||||
it('nda+en → nda_en', () => expect(getDocType('nda', 'en')).toBe('nda_en'))
|
||||
it('sla+de → sla_de', () => expect(getDocType('sla', 'de')).toBe('sla_de'))
|
||||
it('acceptable_use+en → acceptable_use_en', () =>
|
||||
expect(getDocType('acceptable_use', 'en')).toBe('acceptable_use_en'))
|
||||
it('cloud_service_agreement+de → cloud_contract_de', () =>
|
||||
expect(getDocType('cloud_service_agreement', 'de')).toBe('cloud_contract_de'))
|
||||
it('agb+de → agb_de', () => expect(getDocType('agb', 'de')).toBe('agb_de'))
|
||||
it('unknown type → fallback composite', () =>
|
||||
expect(getDocType('some_custom', 'fr')).toBe('some_custom_fr'))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// compute_flags
|
||||
// =============================================================================
|
||||
|
||||
describe('compute_flags', () => {
|
||||
it('IS_B2C=true when CUSTOMER.IS_CONSUMER=true', () => {
|
||||
const ctx = withCtx({
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: false },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.computedFlags.IS_B2C).toBe(true)
|
||||
expect(r.computedFlags.IS_B2B).toBe(false)
|
||||
})
|
||||
|
||||
it('IS_B2B=true when CUSTOMER.IS_BUSINESS=true', () => {
|
||||
const ctx = withCtx({
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: false, IS_BUSINESS: true },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.computedFlags.IS_B2B).toBe(true)
|
||||
expect(r.computedFlags.IS_B2C).toBe(false)
|
||||
})
|
||||
|
||||
it('SERVICE_IS_SAAS=true when SERVICE.MODEL=SaaS', () => {
|
||||
const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.computedFlags.SERVICE_IS_SAAS).toBe(true)
|
||||
expect(r.computedFlags.SERVICE_IS_HYBRID).toBe(false)
|
||||
})
|
||||
|
||||
it('HAS_PENALTY=true when NDA.PENALTY_AMOUNT_EUR is set', () => {
|
||||
const ctx = withCtx({ NDA: { ...EMPTY_CONTEXT.NDA, PENALTY_AMOUNT_EUR: 5000 } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.computedFlags.HAS_PENALTY).toBe(true)
|
||||
})
|
||||
|
||||
it('HAS_PENALTY=false when NDA.PENALTY_AMOUNT_EUR is null', () => {
|
||||
const ctx = withCtx({ NDA: { ...EMPTY_CONTEXT.NDA, PENALTY_AMOUNT_EUR: null } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.computedFlags.HAS_PENALTY).toBe(false)
|
||||
})
|
||||
|
||||
it('HAS_ANALYTICS=true when CONSENT.ANALYTICS_TOOLS is set', () => {
|
||||
const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, ANALYTICS_TOOLS: 'GA4' } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.computedFlags.HAS_ANALYTICS).toBe(true)
|
||||
})
|
||||
|
||||
it('HAS_ANALYTICS=false when CONSENT.ANALYTICS_TOOLS is null', () => {
|
||||
const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, ANALYTICS_TOOLS: null } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.computedFlags.HAS_ANALYTICS).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// auto_defaults
|
||||
// =============================================================================
|
||||
|
||||
describe('auto_defaults', () => {
|
||||
it('sets LOG_RETENTION_DAYS=7 when value is 0', () => {
|
||||
const ctx = withCtx({
|
||||
SECURITY: { ...EMPTY_CONTEXT.SECURITY, LOG_RETENTION_DAYS: 0 },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.contextAfterDefaults.SECURITY.LOG_RETENTION_DAYS).toBe(7)
|
||||
expect(r.appliedDefaults).toContain('DEFAULT_LOG_RETENTION')
|
||||
})
|
||||
|
||||
it('sets SECURITY_LOG_RETENTION_DAYS=30 when value is 0', () => {
|
||||
const ctx = withCtx({
|
||||
SECURITY: { ...EMPTY_CONTEXT.SECURITY, SECURITY_LOG_RETENTION_DAYS: 0 },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.contextAfterDefaults.SECURITY.SECURITY_LOG_RETENTION_DAYS).toBe(30)
|
||||
expect(r.appliedDefaults).toContain('DEFAULT_SECURITY_LOG_RETENTION')
|
||||
})
|
||||
|
||||
it('does not apply default when LOG_RETENTION_DAYS is non-zero', () => {
|
||||
const ctx = withCtx({
|
||||
SECURITY: { ...EMPTY_CONTEXT.SECURITY, LOG_RETENTION_DAYS: 14 },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.contextAfterDefaults.SECURITY.LOG_RETENTION_DAYS).toBe(14)
|
||||
expect(r.appliedDefaults).not.toContain('DEFAULT_LOG_RETENTION')
|
||||
})
|
||||
|
||||
it('does not mutate original context object', () => {
|
||||
const ctx = withCtx({
|
||||
SECURITY: { ...EMPTY_CONTEXT.SECURITY, LOG_RETENTION_DAYS: 0 },
|
||||
})
|
||||
const originalValue = ctx.SECURITY.LOG_RETENTION_DAYS
|
||||
runRuleset(makeInput({ context: ctx }))
|
||||
expect(ctx.SECURITY.LOG_RETENTION_DAYS).toBe(originalValue)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// hard_validations
|
||||
// =============================================================================
|
||||
|
||||
describe('hard_validations', () => {
|
||||
it('DOC_LANG_MATCH_DE: error when lang=en for nda_de', () => {
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'nda_de',
|
||||
render: { lang: 'en', variant: 'standard' },
|
||||
}))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).toContain('DOC_LANG_MATCH_DE')
|
||||
})
|
||||
|
||||
it('DOC_LANG_MATCH_DE: no error when lang=de for nda_de', () => {
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'nda_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
}))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).not.toContain('DOC_LANG_MATCH_DE')
|
||||
})
|
||||
|
||||
it('DOC_LANG_MATCH_EN: error when lang=de for nda_en', () => {
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'nda_en',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
}))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).toContain('DOC_LANG_MATCH_EN')
|
||||
})
|
||||
|
||||
it('CUSTOMER_ROLE_XOR: error when both IS_CONSUMER and IS_BUSINESS are true', () => {
|
||||
const ctx = withCtx({
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: true },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).toContain('CUSTOMER_ROLE_XOR')
|
||||
})
|
||||
|
||||
it('CUSTOMER_ROLE_XOR: no error when only IS_BUSINESS=true', () => {
|
||||
const ctx = withCtx({
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: false, IS_BUSINESS: true },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).not.toContain('CUSTOMER_ROLE_XOR')
|
||||
})
|
||||
|
||||
it('PROVIDER_IDENTITY: error when LEGAL_NAME is empty', () => {
|
||||
const ctx = withCtx({
|
||||
PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: '', EMAIL: 'test@example.com' },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).toContain('PROVIDER_IDENTITY')
|
||||
})
|
||||
|
||||
it('PROVIDER_IDENTITY: no error when LEGAL_NAME and EMAIL are set', () => {
|
||||
const ctx = withCtx({
|
||||
PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: 'ACME GmbH', EMAIL: 'info@acme.de' },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).not.toContain('PROVIDER_IDENTITY')
|
||||
})
|
||||
|
||||
it('SLA_AVAILABILITY_RANGE: error for sla_de with 85% availability', () => {
|
||||
const ctx = withCtx({ SLA: { ...EMPTY_CONTEXT.SLA, AVAILABILITY_PERCENT: 85 } })
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'sla_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
}))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).toContain('SLA_AVAILABILITY_RANGE')
|
||||
})
|
||||
|
||||
it('SLA_AVAILABILITY_RANGE: no error for sla_de with 99.5%', () => {
|
||||
const ctx = withCtx({ SLA: { ...EMPTY_CONTEXT.SLA, AVAILABILITY_PERCENT: 99.5 } })
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'sla_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
}))
|
||||
const ids = r.violations.map((v) => v.id)
|
||||
expect(ids).not.toContain('SLA_AVAILABILITY_RANGE')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// auto_remove_blocks
|
||||
// =============================================================================
|
||||
|
||||
describe('auto_remove_blocks', () => {
|
||||
it('removes NDA_PENALTY_BLOCK when PENALTY_AMOUNT_EUR is null', () => {
|
||||
const ctx = withCtx({ NDA: { ...EMPTY_CONTEXT.NDA, PENALTY_AMOUNT_EUR: null } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.removedBlocks).toContain('NDA_PENALTY_BLOCK')
|
||||
})
|
||||
|
||||
it('keeps NDA_PENALTY_BLOCK when PENALTY_AMOUNT_EUR is set', () => {
|
||||
const ctx = withCtx({ NDA: { ...EMPTY_CONTEXT.NDA, PENALTY_AMOUNT_EUR: 5000 } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.removedBlocks).not.toContain('NDA_PENALTY_BLOCK')
|
||||
})
|
||||
|
||||
it('removes COOKIE_ANALYTICS_BLOCK when HAS_ANALYTICS=false', () => {
|
||||
const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, ANALYTICS_TOOLS: null } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.removedBlocks).toContain('COOKIE_ANALYTICS_BLOCK')
|
||||
})
|
||||
|
||||
it('keeps COOKIE_ANALYTICS_BLOCK when ANALYTICS_TOOLS is set', () => {
|
||||
const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, ANALYTICS_TOOLS: 'GA4' } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.removedBlocks).not.toContain('COOKIE_ANALYTICS_BLOCK')
|
||||
})
|
||||
|
||||
it('removes COOKIE_MARKETING_BLOCK when HAS_MARKETING=false', () => {
|
||||
const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, MARKETING_PARTNERS: null } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.removedBlocks).toContain('COOKIE_MARKETING_BLOCK')
|
||||
})
|
||||
|
||||
it('keeps COOKIE_MARKETING_BLOCK when MARKETING_PARTNERS is set', () => {
|
||||
const ctx = withCtx({
|
||||
CONSENT: { ...EMPTY_CONTEXT.CONSENT, MARKETING_PARTNERS: 'Facebook Ads' },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
expect(r.removedBlocks).not.toContain('COOKIE_MARKETING_BLOCK')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// module_requirements
|
||||
// =============================================================================
|
||||
|
||||
describe('module_requirements', () => {
|
||||
it('REQ_CLOUD_EXPORT_MODULE: ERROR for cloud_contract_de + SaaS without module', () => {
|
||||
const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' } })
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'cloud_contract_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
modules: { enabled: [] },
|
||||
}))
|
||||
expect(r.violations.some((v) => v.id === 'REQ_CLOUD_EXPORT_MODULE')).toBe(true)
|
||||
})
|
||||
|
||||
it('REQ_CLOUD_EXPORT_MODULE: no error when module is enabled', () => {
|
||||
const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' } })
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'cloud_contract_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
modules: { enabled: ['CLOUD_EXPORT_DELETE_DE'] },
|
||||
}))
|
||||
expect(r.violations.some((v) => v.id === 'REQ_CLOUD_EXPORT_MODULE')).toBe(false)
|
||||
})
|
||||
|
||||
it('REQ_B2C_WITHDRAWAL_MODULE: WARN for agb_de + B2C + SaaS without module', () => {
|
||||
const ctx = withCtx({
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: false },
|
||||
SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' },
|
||||
})
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'agb_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
modules: { enabled: [] },
|
||||
}))
|
||||
expect(r.warnings.some((w) => w.id === 'REQ_B2C_WITHDRAWAL_MODULE')).toBe(true)
|
||||
})
|
||||
|
||||
it('REQ_B2C_WITHDRAWAL_MODULE: no warn when module is enabled', () => {
|
||||
const ctx = withCtx({
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: false },
|
||||
SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' },
|
||||
})
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'agb_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
modules: { enabled: ['B2C_WITHDRAWAL_DE'] },
|
||||
}))
|
||||
expect(r.warnings.some((w) => w.id === 'REQ_B2C_WITHDRAWAL_MODULE')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// warnings
|
||||
// =============================================================================
|
||||
|
||||
describe('warnings', () => {
|
||||
it('WARN_LEGAL_REVIEW: always present', () => {
|
||||
const r = runRuleset(makeInput())
|
||||
expect(r.warnings.some((w) => w.id === 'WARN_LEGAL_REVIEW')).toBe(true)
|
||||
})
|
||||
|
||||
it('WARN_EXPORT_FORMATS_MISSING: fires for cloud_contract_de + SaaS with EXPORT_WINDOW_DAYS=0', () => {
|
||||
const ctx = withCtx({
|
||||
SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS', EXPORT_WINDOW_DAYS: 0 },
|
||||
})
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'cloud_contract_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
}))
|
||||
expect(r.warnings.some((w) => w.id === 'WARN_EXPORT_FORMATS_MISSING')).toBe(true)
|
||||
})
|
||||
|
||||
it('WARN_EXPORT_FORMATS_MISSING: does not fire for non-SaaS cloud doc', () => {
|
||||
const ctx = withCtx({
|
||||
SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'OnPrem', EXPORT_WINDOW_DAYS: 0 },
|
||||
})
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'cloud_contract_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
}))
|
||||
expect(r.warnings.some((w) => w.id === 'WARN_EXPORT_FORMATS_MISSING')).toBe(false)
|
||||
})
|
||||
|
||||
it('WARN_EXPORT_FORMATS_MISSING: does not fire when EXPORT_WINDOW_DAYS >= 1', () => {
|
||||
const ctx = withCtx({
|
||||
SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS', EXPORT_WINDOW_DAYS: 30 },
|
||||
})
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'cloud_contract_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
}))
|
||||
expect(r.warnings.some((w) => w.id === 'WARN_EXPORT_FORMATS_MISSING')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// applyBlockRemoval
|
||||
// =============================================================================
|
||||
|
||||
describe('applyBlockRemoval', () => {
|
||||
const NDA_WITH_PENALTY = `# NDA
|
||||
|
||||
## §1 Zweck
|
||||
|
||||
Inhalt.
|
||||
|
||||
[BLOCK:NDA_PENALTY_BLOCK]
|
||||
## §7 Vertragsstrafe
|
||||
|
||||
Der Verletzer zahlt {{PENALTY_AMOUNT}} EUR.
|
||||
|
||||
[/BLOCK:NDA_PENALTY_BLOCK]
|
||||
|
||||
## §8 Schlussbestimmungen
|
||||
`
|
||||
|
||||
it('removes the block when its ID is in removedBlocks', () => {
|
||||
const result = applyBlockRemoval(NDA_WITH_PENALTY, ['NDA_PENALTY_BLOCK'])
|
||||
expect(result).not.toContain('[BLOCK:NDA_PENALTY_BLOCK]')
|
||||
expect(result).not.toContain('Vertragsstrafe')
|
||||
expect(result).toContain('§8 Schlussbestimmungen')
|
||||
})
|
||||
|
||||
it('keeps the block when its ID is NOT in removedBlocks', () => {
|
||||
const result = applyBlockRemoval(NDA_WITH_PENALTY, [])
|
||||
expect(result).toContain('[BLOCK:NDA_PENALTY_BLOCK]')
|
||||
expect(result).toContain('Vertragsstrafe')
|
||||
})
|
||||
|
||||
it('removes multiple blocks independently', () => {
|
||||
const content = `Header\n[BLOCK:A]\nBlock A\n[/BLOCK:A]\nMiddle\n[BLOCK:B]\nBlock B\n[/BLOCK:B]\nFooter`
|
||||
const result = applyBlockRemoval(content, ['A', 'B'])
|
||||
expect(result).toContain('Header')
|
||||
expect(result).toContain('Middle')
|
||||
expect(result).toContain('Footer')
|
||||
expect(result).not.toContain('Block A')
|
||||
expect(result).not.toContain('Block B')
|
||||
})
|
||||
|
||||
it('handles multiline content within a block', () => {
|
||||
const content = `[BLOCK:X]\nLine 1\nLine 2\nLine 3\n[/BLOCK:X]\nAfter`
|
||||
const result = applyBlockRemoval(content, ['X'])
|
||||
expect(result).not.toContain('Line 1')
|
||||
expect(result).toContain('After')
|
||||
})
|
||||
|
||||
it('is a no-op when content has no block markers', () => {
|
||||
const plain = '# Simple document\n\nJust text.'
|
||||
expect(applyBlockRemoval(plain, ['NDA_PENALTY_BLOCK'])).toBe(plain)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Integration: valid NDA with penalty
|
||||
// =============================================================================
|
||||
|
||||
describe('integration', () => {
|
||||
it('valid NDA DE with penalty: 0 errors, correct flags', () => {
|
||||
const ctx = withCtx({
|
||||
PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: 'ACME GmbH', EMAIL: 'info@acme.de' },
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: false, IS_BUSINESS: true },
|
||||
NDA: { PURPOSE: 'Software-Entwicklung', DURATION_YEARS: 5, PENALTY_AMOUNT_EUR: 10000 },
|
||||
})
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'nda_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
}))
|
||||
|
||||
expect(r.violations).toHaveLength(0)
|
||||
expect(r.computedFlags.HAS_PENALTY).toBe(true)
|
||||
expect(r.computedFlags.IS_B2B).toBe(true)
|
||||
expect(r.removedBlocks).not.toContain('NDA_PENALTY_BLOCK')
|
||||
// WARN_LEGAL_REVIEW always present
|
||||
expect(r.warnings.some((w) => w.id === 'WARN_LEGAL_REVIEW')).toBe(true)
|
||||
})
|
||||
|
||||
it('valid NDA DE without penalty: penalty block removed', () => {
|
||||
const ctx = withCtx({
|
||||
PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: 'ACME GmbH', EMAIL: 'info@acme.de' },
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: false, IS_BUSINESS: true },
|
||||
NDA: { PURPOSE: 'Vertrieb', DURATION_YEARS: 3, PENALTY_AMOUNT_EUR: null },
|
||||
})
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'nda_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
}))
|
||||
|
||||
expect(r.violations).toHaveLength(0)
|
||||
expect(r.computedFlags.HAS_PENALTY).toBe(false)
|
||||
expect(r.removedBlocks).toContain('NDA_PENALTY_BLOCK')
|
||||
})
|
||||
|
||||
it('cookie banner DE with analytics: keeps analytics block', () => {
|
||||
const ctx = withCtx({
|
||||
PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: 'Shop GmbH', EMAIL: 'info@shop.de' },
|
||||
CONSENT: { WEBSITE_NAME: 'MyShop', ANALYTICS_TOOLS: 'GA4', MARKETING_PARTNERS: null },
|
||||
})
|
||||
const r = runRuleset(makeInput({
|
||||
doc_type: 'cookie_banner_de',
|
||||
render: { lang: 'de', variant: 'standard' },
|
||||
context: ctx,
|
||||
}))
|
||||
|
||||
expect(r.removedBlocks).not.toContain('COOKIE_ANALYTICS_BLOCK')
|
||||
expect(r.removedBlocks).toContain('COOKIE_MARKETING_BLOCK')
|
||||
expect(r.computedFlags.HAS_ANALYTICS).toBe(true)
|
||||
expect(r.computedFlags.HAS_MARKETING).toBe(false)
|
||||
})
|
||||
})
|
||||
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
|
||||
}
|
||||
42
backend-compliance/migrations/022_template_block_markers.sql
Normal file
42
backend-compliance/migrations/022_template_block_markers.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Migration 022: Template Block Markers
|
||||
-- ============================================================
|
||||
-- This migration documents the block marker system introduced
|
||||
-- in Template-Spec v1 Phase B.
|
||||
--
|
||||
-- Block markers are inserted into template content via the
|
||||
-- Python script: scripts/apply_block_markers_022.py
|
||||
--
|
||||
-- Block IDs:
|
||||
-- NDA_PENALTY_BLOCK — Vertragsstrafe section (NDA DE + EN)
|
||||
-- COOKIE_ANALYTICS_BLOCK — Analyse-Tools section (Cookie Banner DE)
|
||||
-- COOKIE_MARKETING_BLOCK — Marketing-Partner section (Cookie Banner DE)
|
||||
--
|
||||
-- Marker syntax in template content:
|
||||
-- [BLOCK:NDA_PENALTY_BLOCK]
|
||||
-- ## §N Vertragsstrafe
|
||||
-- ...content...
|
||||
-- [/BLOCK:NDA_PENALTY_BLOCK]
|
||||
--
|
||||
-- The Rule Engine (ruleEngine.ts) evaluates ruleset.v1.json and
|
||||
-- produces a list of block IDs to remove before placeholder substitution.
|
||||
-- The applyBlockRemoval() function strips matching [BLOCK:ID]...[/BLOCK:ID]
|
||||
-- sections from template content.
|
||||
--
|
||||
-- Run the Python migration script to apply markers to DB content:
|
||||
-- docker exec bp-compliance-backend python3 /tmp/apply_block_markers_022.py
|
||||
--
|
||||
-- No schema changes are required — block markers are stored inline
|
||||
-- in the existing `content` TEXT column of compliance_legal_templates.
|
||||
-- ============================================================
|
||||
|
||||
-- Verify markers were applied (run after script):
|
||||
SELECT
|
||||
document_type,
|
||||
language,
|
||||
title,
|
||||
CASE WHEN content LIKE '%[BLOCK:NDA_PENALTY_BLOCK]%' THEN 'YES' ELSE 'no' END AS has_nda_penalty,
|
||||
CASE WHEN content LIKE '%[BLOCK:COOKIE_ANALYTICS_BLOCK]%' THEN 'YES' ELSE 'no' END AS has_cookie_analytics,
|
||||
CASE WHEN content LIKE '%[BLOCK:COOKIE_MARKETING_BLOCK]%' THEN 'YES' ELSE 'no' END AS has_cookie_marketing
|
||||
FROM compliance.compliance_legal_templates
|
||||
WHERE document_type IN ('nda', 'cookie_banner')
|
||||
ORDER BY document_type, language;
|
||||
152
scripts/apply_block_markers_022.py
Normal file
152
scripts/apply_block_markers_022.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration 022: Insert [BLOCK:ID]...[/BLOCK:ID] markers into DB templates.
|
||||
|
||||
Targets:
|
||||
- NDA DE + NDA EN: wraps Vertragsstrafe section → NDA_PENALTY_BLOCK
|
||||
- Cookie Banner DE: wraps Analyse section → COOKIE_ANALYTICS_BLOCK
|
||||
wraps Marketing section → COOKIE_MARKETING_BLOCK
|
||||
|
||||
Usage:
|
||||
python3 apply_block_markers_022.py
|
||||
|
||||
Or via Docker:
|
||||
docker cp apply_block_markers_022.py bp-compliance-backend:/tmp/
|
||||
docker exec bp-compliance-backend python3 /tmp/apply_block_markers_022.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Allow running inside container where the app is at /app
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# DB connection
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DATABASE_URL = os.environ.get(
|
||||
'DATABASE_URL',
|
||||
'postgresql://compliance_user:compliance_pass@bp-core-postgres:5432/breakpilot_db'
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Marker helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def wrap_block(content: str, block_id: str, pattern: str, flags: int = re.MULTILINE | re.DOTALL) -> tuple[str, int]:
|
||||
"""
|
||||
Finds the first match of `pattern` in `content` and wraps it with
|
||||
[BLOCK:block_id]...[/BLOCK:block_id].
|
||||
|
||||
Returns (new_content, match_count).
|
||||
"""
|
||||
match_count = 0
|
||||
|
||||
def replacer(m: re.Match) -> str:
|
||||
nonlocal match_count
|
||||
match_count += 1
|
||||
matched = m.group(0)
|
||||
# Avoid double-wrapping
|
||||
if f'[BLOCK:{block_id}]' in matched:
|
||||
return matched
|
||||
return f'[BLOCK:{block_id}]\n{matched}[/BLOCK:{block_id}]\n'
|
||||
|
||||
new_content = re.sub(pattern, replacer, content, flags=flags)
|
||||
return new_content, match_count
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Template-specific transformations
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def apply_nda_penalty_block(content: str) -> tuple[str, int]:
|
||||
"""Wraps the Vertragsstrafe section in NDA templates."""
|
||||
# Match: section header containing "Vertragsstrafe" up to the next ## section or end
|
||||
pattern = r'(^## \d+[\.:]?\s+[^\n]*[Vv]ertragsstrafe[^\n]*\n)(.*?)(?=^## \d+|\Z)'
|
||||
return wrap_block(content, 'NDA_PENALTY_BLOCK', pattern)
|
||||
|
||||
|
||||
def apply_cookie_analytics_block(content: str) -> tuple[str, int]:
|
||||
"""Wraps the ### Analyse section in Cookie Banner templates."""
|
||||
pattern = r'(^### Analyse\b[^\n]*\n)(.*?)(?=^###|\Z)'
|
||||
return wrap_block(content, 'COOKIE_ANALYTICS_BLOCK', pattern)
|
||||
|
||||
|
||||
def apply_cookie_marketing_block(content: str) -> tuple[str, int]:
|
||||
"""Wraps the ### Marketing section in Cookie Banner templates."""
|
||||
pattern = r'(^### Marketing\b[^\n]*\n)(.*?)(?=^###|\Z)'
|
||||
return wrap_block(content, 'COOKIE_MARKETING_BLOCK', pattern)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TARGETS = [
|
||||
# (document_type_filter, language_filter, list of transform functions)
|
||||
('nda', 'de', [apply_nda_penalty_block]),
|
||||
('nda', 'en', [apply_nda_penalty_block]),
|
||||
('cookie_banner', 'de', [apply_cookie_analytics_block, apply_cookie_marketing_block]),
|
||||
]
|
||||
|
||||
def main() -> None:
|
||||
print('=== Migration 022: Block Markers ===\n')
|
||||
|
||||
with engine.begin() as conn:
|
||||
for doc_type, lang, transforms in TARGETS:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
'SELECT id, title, content FROM compliance.compliance_legal_templates '
|
||||
'WHERE document_type = :doc_type AND language = :lang'
|
||||
),
|
||||
{'doc_type': doc_type, 'lang': lang}
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
print(f'[SKIP] No templates found for {doc_type}/{lang}')
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
tid, title, content = row.id, row.title, row.content
|
||||
if content is None:
|
||||
print(f'[SKIP] {title} (id={tid}) — content is NULL')
|
||||
continue
|
||||
|
||||
original_len = len(content)
|
||||
new_content = content
|
||||
total_matches = 0
|
||||
|
||||
for transform in transforms:
|
||||
new_content, match_count = transform(new_content)
|
||||
total_matches += match_count
|
||||
|
||||
if new_content == content:
|
||||
print(f'[NOOP] {title} ({doc_type}/{lang}) — no changes')
|
||||
continue
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
'UPDATE compliance.compliance_legal_templates '
|
||||
'SET content = :content, updated_at = NOW() '
|
||||
'WHERE id = :id'
|
||||
),
|
||||
{'content': new_content, 'id': tid}
|
||||
)
|
||||
print(
|
||||
f'[OK] {title} ({doc_type}/{lang})'
|
||||
f' | {original_len} → {len(new_content)} chars'
|
||||
f' | {total_matches} block(s) wrapped'
|
||||
)
|
||||
|
||||
print('\n=== Done ===')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user