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

- 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:
Benjamin Admin
2026-03-04 13:23:03 +01:00
parent 3dbbebb827
commit 1c5a4c2d96
5 changed files with 1222 additions and 7 deletions

View File

@@ -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 && (

View 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)
})
})

View 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
}

View 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;

View 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()