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