feat: Template-Spec v1 Phase B — Rule Engine + Block Removal
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 19s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 19s
- ruleEngine.ts: Minimal JSONLogic evaluator, 6-phase runner (compute_flags, auto_defaults, hard_validations, auto_remove_blocks, module_requirements, warnings), getDocType mapping, applyBlockRemoval - ruleEngine.test.ts: 49 Vitest tests (alle grün) - page.tsx: ruleResult useMemo, enabledModules state, computed flags pills, module toggles, rule engine banners (errors/warnings/legal notice) - migrations/022_template_block_markers.sql: Dokumentation + Verify-Query - scripts/apply_block_markers_022.py: NDA_PENALTY_BLOCK, COOKIE_ANALYTICS_BLOCK, COOKIE_MARKETING_BLOCK in DB-Templates einfügen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,10 @@ import {
|
||||
contextToPlaceholders, getRelevantSections,
|
||||
getUncoveredPlaceholders, getMissingRequired,
|
||||
} from './contextBridge'
|
||||
import {
|
||||
runRuleset, getDocType, applyBlockRemoval,
|
||||
type RuleInput, type RuleEngineResult,
|
||||
} from './ruleEngine'
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY CONFIG
|
||||
@@ -348,6 +352,12 @@ function ContextSectionForm({
|
||||
// GENERATOR SECTION
|
||||
// =============================================================================
|
||||
|
||||
// Available module definitions (id → display label)
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
CLOUD_EXPORT_DELETE_DE: 'Datenexport & Löschrecht',
|
||||
B2C_WITHDRAWAL_DE: 'Widerrufsrecht (B2C)',
|
||||
}
|
||||
|
||||
function GeneratorSection({
|
||||
template,
|
||||
context,
|
||||
@@ -355,6 +365,8 @@ function GeneratorSection({
|
||||
extraPlaceholders,
|
||||
onExtraChange,
|
||||
onClose,
|
||||
enabledModules,
|
||||
onModuleToggle,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
context: TemplateContext
|
||||
@@ -362,6 +374,8 @@ function GeneratorSection({
|
||||
extraPlaceholders: Record<string, string>
|
||||
onExtraChange: (key: string, value: string) => void
|
||||
onClose: () => void
|
||||
enabledModules: string[]
|
||||
onModuleToggle: (mod: string, checked: boolean) => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
||||
@@ -371,20 +385,52 @@ function GeneratorSection({
|
||||
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
||||
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
||||
|
||||
// Rule engine evaluation
|
||||
const ruleResult = useMemo((): RuleEngineResult | null => {
|
||||
if (!template) return null
|
||||
return runRuleset({
|
||||
doc_type: getDocType(template.templateType ?? '', template.language ?? 'de'),
|
||||
render: { lang: template.language ?? 'de', variant: 'standard' },
|
||||
context,
|
||||
modules: { enabled: enabledModules },
|
||||
} satisfies RuleInput)
|
||||
}, [template, context, enabledModules])
|
||||
|
||||
const allPlaceholderValues = useMemo(() => ({
|
||||
...contextToPlaceholders(context),
|
||||
...contextToPlaceholders(ruleResult?.contextAfterDefaults ?? context),
|
||||
...extraPlaceholders,
|
||||
}), [context, extraPlaceholders])
|
||||
}), [context, extraPlaceholders, ruleResult])
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
let content = template.text
|
||||
// Apply block removal BEFORE placeholder substitution
|
||||
let content = applyBlockRemoval(template.text, ruleResult?.removedBlocks ?? [])
|
||||
for (const [key, value] of Object.entries(allPlaceholderValues)) {
|
||||
if (value) {
|
||||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
return content
|
||||
}, [template.text, allPlaceholderValues])
|
||||
}, [template.text, allPlaceholderValues, ruleResult])
|
||||
|
||||
// Compute which modules are relevant (mentioned in violations/warnings)
|
||||
const relevantModules = useMemo(() => {
|
||||
if (!ruleResult) return []
|
||||
const mentioned = new Set<string>()
|
||||
const allIssues = [...ruleResult.violations, ...ruleResult.warnings]
|
||||
for (const issue of allIssues) {
|
||||
if (issue.phase === 'module_requirements') {
|
||||
// Extract module ID from message
|
||||
for (const modId of Object.keys(MODULE_LABELS)) {
|
||||
if (issue.message.includes(modId)) mentioned.add(modId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also show modules that are enabled but not mentioned
|
||||
for (const mod of enabledModules) {
|
||||
if (mod in MODULE_LABELS) mentioned.add(mod)
|
||||
}
|
||||
return [...mentioned]
|
||||
}, [ruleResult, enabledModules])
|
||||
|
||||
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
||||
|
||||
@@ -414,20 +460,40 @@ function GeneratorSection({
|
||||
}
|
||||
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Computed flags pills config
|
||||
const flagPills: { key: keyof typeof ruleResult.computedFlags; label: string; color: string }[] = ruleResult ? [
|
||||
{ key: 'IS_B2C', label: 'B2C', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'SERVICE_IS_SAAS', label: 'SaaS', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'HAS_PENALTY', label: 'Vertragsstrafe', color: 'bg-orange-100 text-orange-700' },
|
||||
{ key: 'HAS_ANALYTICS', label: 'Analytics', color: 'bg-gray-100 text-gray-600' },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-purple-300 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-purple-50 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<svg className="w-5 h-5 text-purple-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-xs text-purple-500 font-medium uppercase tracking-wide">Generator</div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{template.documentTitle}</div>
|
||||
</div>
|
||||
{/* Computed flags pills */}
|
||||
{ruleResult && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{flagPills.map(({ key, label, color }) =>
|
||||
ruleResult.computedFlags[key] ? (
|
||||
<span key={key} className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors" aria-label="Schließen">
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -452,6 +518,11 @@ function GeneratorSection({
|
||||
{missing.length}
|
||||
</span>
|
||||
)}
|
||||
{tab === 'preview' && ruleResult && ruleResult.violations.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-red-100 text-red-600 rounded-full">
|
||||
{ruleResult.violations.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -522,6 +593,29 @@ function GeneratorSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module toggles */}
|
||||
{relevantModules.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Module</p>
|
||||
<div className="space-y-2">
|
||||
{relevantModules.map((modId) => (
|
||||
<label key={modId} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledModules.includes(modId)}
|
||||
onChange={(e) => onModuleToggle(modId, e.target.checked)}
|
||||
className="w-4 h-4 accent-purple-600"
|
||||
/>
|
||||
<span className="text-xs font-mono text-gray-600">{modId}</span>
|
||||
{MODULE_LABELS[modId] && (
|
||||
<span className="text-xs text-gray-500">{MODULE_LABELS[modId]}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation summary + CTA */}
|
||||
<div className="flex items-center justify-between pt-2 flex-wrap gap-3">
|
||||
<div>
|
||||
@@ -550,6 +644,48 @@ function GeneratorSection({
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Rule engine banners */}
|
||||
{ruleResult && ruleResult.violations.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||
🔴 {ruleResult.violations.length} Fehler
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.violations.map((v) => (
|
||||
<li key={v.id} className="text-xs text-red-600">
|
||||
<span className="font-mono font-medium">[{v.id}]</span> {v.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.warnings
|
||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||
.map((w) => (
|
||||
<li key={w.id} className="text-xs text-yellow-700">
|
||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{missing.length > 0 && (
|
||||
@@ -625,6 +761,7 @@ function DocumentGeneratorPageInner() {
|
||||
const [activeTemplate, setActiveTemplate] = useState<LegalTemplateResult | null>(null)
|
||||
const [context, setContext] = useState<TemplateContext>(EMPTY_CONTEXT)
|
||||
const [extraPlaceholders, setExtraPlaceholders] = useState<Record<string, string>>({})
|
||||
const [enabledModules, setEnabledModules] = useState<string[]>([])
|
||||
const generatorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [totalCount, setTotalCount] = useState<number>(0)
|
||||
@@ -690,6 +827,7 @@ function DocumentGeneratorPageInner() {
|
||||
setActiveTemplate(t)
|
||||
setExpandedPreviewId(null)
|
||||
setExtraPlaceholders({})
|
||||
setEnabledModules([])
|
||||
setTimeout(() => {
|
||||
generatorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 100)
|
||||
@@ -868,6 +1006,12 @@ function DocumentGeneratorPageInner() {
|
||||
extraPlaceholders={extraPlaceholders}
|
||||
onExtraChange={(key, value) => setExtraPlaceholders((prev) => ({ ...prev, [key]: value }))}
|
||||
onClose={() => setActiveTemplate(null)}
|
||||
enabledModules={enabledModules}
|
||||
onModuleToggle={(mod, checked) =>
|
||||
setEnabledModules((prev) =>
|
||||
checked ? [...prev, mod] : prev.filter((m) => m !== mod)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedDataPointsData && selectedDataPointsData.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user