diff --git a/admin-compliance/app/sdk/document-generator/page.tsx b/admin-compliance/app/sdk/document-generator/page.tsx index 8f6cfd7..94799e4 100644 --- a/admin-compliance/app/sdk/document-generator/page.tsx +++ b/admin-compliance/app/sdk/document-generator/page.tsx @@ -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 = { + 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 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>(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() + 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 (
{/* Header */}
-
- +
+
Generator
{template.documentTitle}
+ {/* Computed flags pills */} + {ruleResult && ( +
+ {flagPills.map(({ key, label, color }) => + ruleResult.computedFlags[key] ? ( + + {label} + + ) : null + )} +
+ )}
- ))}
@@ -522,6 +593,29 @@ function GeneratorSection({
)} + {/* Module toggles */} + {relevantModules.length > 0 && ( +
+

Module

+
+ {relevantModules.map((modId) => ( + + ))} +
+
+ )} + {/* Validation summary + CTA */}
@@ -550,6 +644,48 @@ function GeneratorSection({ {activeTab === 'preview' && (
+ {/* Rule engine banners */} + {ruleResult && ruleResult.violations.length > 0 && ( +
+

+ πŸ”΄ {ruleResult.violations.length} Fehler +

+
    + {ruleResult.violations.map((v) => ( +
  • + [{v.id}] {v.message} +
  • + ))} +
+
+ )} + {ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && ( +
+
    + {ruleResult.warnings + .filter((w) => w.id !== 'WARN_LEGAL_REVIEW') + .map((w) => ( +
  • + 🟑 [{w.id}] {w.message} +
  • + ))} +
+
+ )} + {ruleResult && ( +
+

+ ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz + wird eine rechtliche ÜberprΓΌfung dringend empfohlen. +

+
+ )} + {ruleResult && ruleResult.appliedDefaults.length > 0 && ( +

+ Defaults angewendet: {ruleResult.appliedDefaults.join(', ')} +

+ )} +
{missing.length > 0 && ( @@ -625,6 +761,7 @@ function DocumentGeneratorPageInner() { const [activeTemplate, setActiveTemplate] = useState(null) const [context, setContext] = useState(EMPTY_CONTEXT) const [extraPlaceholders, setExtraPlaceholders] = useState>({}) + const [enabledModules, setEnabledModules] = useState([]) const generatorRef = useRef(null) const [totalCount, setTotalCount] = useState(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 && ( diff --git a/admin-compliance/app/sdk/document-generator/ruleEngine.test.ts b/admin-compliance/app/sdk/document-generator/ruleEngine.test.ts new file mode 100644 index 0000000..0080d20 --- /dev/null +++ b/admin-compliance/app/sdk/document-generator/ruleEngine.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/admin-compliance/app/sdk/document-generator/ruleEngine.ts b/admin-compliance/app/sdk/document-generator/ruleEngine.ts new file mode 100644 index 0000000..9646da3 --- /dev/null +++ b/admin-compliance/app/sdk/document-generator/ruleEngine.ts @@ -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)[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) + if (ops.length === 0) return null + const op = ops[0] + const args = (expr as Record)[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(obj: T): T { + return JSON.parse(JSON.stringify(obj)) +} + +// ============================================================================= +// Set value at dot-path (mutates object for defaults application) +// ============================================================================= + +function setNestedPath(obj: Record, dotPath: string, value: unknown): void { + const parts = dotPath.split('.') + let cur: Record = 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 + } + 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 = {} + 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, 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 = { + '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 +} diff --git a/backend-compliance/migrations/022_template_block_markers.sql b/backend-compliance/migrations/022_template_block_markers.sql new file mode 100644 index 0000000..0902723 --- /dev/null +++ b/backend-compliance/migrations/022_template_block_markers.sql @@ -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; diff --git a/scripts/apply_block_markers_022.py b/scripts/apply_block_markers_022.py new file mode 100644 index 0000000..b9c0181 --- /dev/null +++ b/scripts/apply_block_markers_022.py @@ -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()