refactor(admin-compliance): split 11 oversized files under 500 LOC hard cap (batch 2)
Barrel-split pattern: each original becomes a thin re-export barrel; logic moved to sibling files so no consumer imports need updating. Files split: - loeschfristen-profiling.ts → profiling-data.ts + profiling-generator.ts - vendor-compliance/catalog/vendor-templates.ts → vendor-country-profiles.ts - vendor-compliance/catalog/legal-basis.ts → legal-basis-retention.ts - dsfa/eu-legal-frameworks.ts → eu-legal-frameworks-national.ts - compliance-scope-types/document-scope-matrix-core.ts → core-part2.ts - compliance-scope-types/document-scope-matrix-extended.ts → extended-part2.ts - app/sdk/document-generator/contextBridge.ts → contextBridge-helpers.ts - app/api/sdk/drafting-engine/draft/route.ts → draft-helpers.ts + draft-helpers-v2.ts All files ≤ 500 LOC. Zero behavior changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Drafting Engine - v2 Pipeline Helpers
|
||||
*
|
||||
* DOCUMENT_PROSE_BLOCKS, buildV2SystemPrompt, buildBlockSpecificPrompt,
|
||||
* callOllama, handleV2Draft — split from draft-helpers.ts for the 500 LOC hard cap.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { DraftContext, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
|
||||
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
|
||||
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
|
||||
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
|
||||
import { computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||
import {
|
||||
constraintEnforcer,
|
||||
proseCache,
|
||||
TEMPLATE_VERSION,
|
||||
TERMINOLOGY_VERSION,
|
||||
VALIDATOR_VERSION,
|
||||
V1_SYSTEM_PROMPT,
|
||||
buildPromptForDocumentType,
|
||||
} from './draft-helpers'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
// ============================================================================
|
||||
// v2 Personalisierte Pipeline
|
||||
// ============================================================================
|
||||
|
||||
export const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
|
||||
tom: [
|
||||
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
|
||||
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
|
||||
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
|
||||
],
|
||||
dsfa: [
|
||||
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
|
||||
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
|
||||
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
|
||||
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
|
||||
],
|
||||
vvt: [
|
||||
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
|
||||
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
|
||||
],
|
||||
dsi: [
|
||||
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
|
||||
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
|
||||
],
|
||||
lf: [
|
||||
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
|
||||
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
|
||||
],
|
||||
av_vertrag: [
|
||||
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
|
||||
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
|
||||
],
|
||||
betroffenenrechte: [
|
||||
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
|
||||
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
|
||||
],
|
||||
risikoanalyse: [
|
||||
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
|
||||
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
|
||||
],
|
||||
notfallplan: [
|
||||
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
|
||||
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
|
||||
],
|
||||
iace_ce_assessment: [
|
||||
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
|
||||
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
|
||||
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
|
||||
],
|
||||
}
|
||||
|
||||
export function buildV2SystemPrompt(
|
||||
sanitizedFactsString: string,
|
||||
narrativeTagsString: string,
|
||||
terminologyString: string,
|
||||
styleString: string,
|
||||
disallowedString: string,
|
||||
companyName: string,
|
||||
blockId: string,
|
||||
blockType: string,
|
||||
sectionName: string,
|
||||
documentType: string,
|
||||
targetWords: number
|
||||
): string {
|
||||
return `Du bist ein Compliance-Dokumenten-Redakteur.
|
||||
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
|
||||
|
||||
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
|
||||
${sanitizedFactsString}
|
||||
|
||||
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
|
||||
${narrativeTagsString}
|
||||
|
||||
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
|
||||
${terminologyString}
|
||||
|
||||
STIL:
|
||||
${styleString}
|
||||
|
||||
VERBOTENE INHALTE:
|
||||
${disallowedString}
|
||||
- Keine konkreten Prozentwerte, Scores oder Zahlen
|
||||
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
|
||||
- Keine direkte Ansprache ("Sie", "Ihr")
|
||||
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
|
||||
|
||||
STRIKTE REGELN:
|
||||
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
|
||||
2. Schreibe in der dritten Person ("Die ${companyName}...")
|
||||
3. Beziehe dich auf die Branche und organisatorische Merkmale
|
||||
4. Verwende NUR Fakten aus dem Kundenprofil oben
|
||||
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
|
||||
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
|
||||
7. Halte dich an die Terminologie-Vorgaben
|
||||
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
|
||||
|
||||
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
|
||||
{
|
||||
"blockId": "${blockId}",
|
||||
"blockType": "${blockType}",
|
||||
"language": "de",
|
||||
"text": "...",
|
||||
"assertions": {
|
||||
"companyNameUsed": true/false,
|
||||
"industryReferenced": true/false,
|
||||
"structureReferenced": true/false,
|
||||
"itLandscapeReferenced": true/false,
|
||||
"narrativeTagsUsed": ["riskSummary", ...]
|
||||
},
|
||||
"forbiddenContentDetected": []
|
||||
}
|
||||
|
||||
DOKUMENTENTYP: ${documentType}
|
||||
SEKTION: ${sectionName}
|
||||
BLOCK-TYP: ${blockType}
|
||||
ZIEL-LAENGE: ${targetWords} Woerter`
|
||||
}
|
||||
|
||||
export function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
|
||||
switch (blockType) {
|
||||
case 'introduction':
|
||||
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
|
||||
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
|
||||
Gehe auf die spezifische Situation des Unternehmens ein.
|
||||
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
|
||||
case 'transition':
|
||||
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
|
||||
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
|
||||
case 'conclusion':
|
||||
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
|
||||
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
|
||||
case 'appreciation':
|
||||
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
|
||||
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
|
||||
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
|
||||
default:
|
||||
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
|
||||
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama error: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.message?.content || ''
|
||||
}
|
||||
|
||||
export async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null, constraintCheck, tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const scores = extractScoresFromDraftContext(draftContext)
|
||||
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
|
||||
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
|
||||
|
||||
let sanitizationResult
|
||||
try {
|
||||
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
|
||||
} catch (error) {
|
||||
if (error instanceof SanitizationError) {
|
||||
return NextResponse.json({
|
||||
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
|
||||
draft: null, constraintCheck, tokensUsed: 0,
|
||||
}, { status: 422 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const sanitizedFacts = sanitizationResult.facts
|
||||
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
|
||||
if (piiWarnings.length > 0) console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
|
||||
|
||||
const factsString = allowedFactsToPromptString(sanitizedFacts)
|
||||
const tagsString = narrativeTagsToPromptString(narrativeTags)
|
||||
const termsString = terminologyToPromptString()
|
||||
const styleString = styleContractToPromptString()
|
||||
const disallowedString = disallowedTopicsToPromptString()
|
||||
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
||||
|
||||
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
|
||||
|
||||
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
||||
const generatedBlocks: ProseBlockOutput[] = []
|
||||
const repairAudits: RepairAudit[] = []
|
||||
let totalTokens = 0
|
||||
|
||||
for (const blockDef of proseBlocks) {
|
||||
const cacheParams: CacheKeyParams = {
|
||||
allowedFacts: sanitizedFacts, templateVersion: TEMPLATE_VERSION,
|
||||
terminologyVersion: TERMINOLOGY_VERSION, narrativeTags,
|
||||
promptHash, blockType: blockDef.blockType, sectionName: blockDef.sectionName,
|
||||
}
|
||||
|
||||
const cached = proseCache.getSync(cacheParams)
|
||||
if (cached) {
|
||||
generatedBlocks.push(cached)
|
||||
repairAudits.push({ repairAttempts: 0, validatorFailures: [], repairSuccessful: true, fallbackUsed: false })
|
||||
continue
|
||||
}
|
||||
|
||||
let systemPrompt = buildV2SystemPrompt(
|
||||
factsString, tagsString, termsString, styleString, disallowedString,
|
||||
sanitizedFacts.companyName, blockDef.blockId, blockDef.blockType,
|
||||
blockDef.sectionName, documentType, blockDef.targetWords
|
||||
)
|
||||
if (v2RagContext) systemPrompt += `\n\nRECHTSKONTEXT (als Referenz, nicht woertlich uebernehmen):\n${v2RagContext}`
|
||||
const userPrompt = buildBlockSpecificPrompt(blockDef.blockType, blockDef.sectionName, documentType)
|
||||
+ (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
|
||||
|
||||
try {
|
||||
const rawOutput = await callOllama(systemPrompt, userPrompt)
|
||||
totalTokens += rawOutput.length / 4
|
||||
const { block, audit } = await executeRepairLoop(
|
||||
rawOutput, sanitizedFacts, narrativeTags, blockDef.blockId, blockDef.blockType,
|
||||
async (repairPrompt) => callOllama(systemPrompt, repairPrompt), documentType
|
||||
)
|
||||
generatedBlocks.push(block)
|
||||
repairAudits.push(audit)
|
||||
if (!audit.fallbackUsed) proseCache.setSync(cacheParams, block)
|
||||
} catch (error) {
|
||||
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
|
||||
generatedBlocks.push(buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType))
|
||||
repairAudits.push({
|
||||
repairAttempts: 0, validatorFailures: [[(error as Error).message]],
|
||||
repairSuccessful: false, fallbackUsed: true,
|
||||
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
|
||||
let dataSections: DraftSection[] = []
|
||||
try {
|
||||
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
|
||||
const parsed = JSON.parse(dataResponse)
|
||||
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`), title: String(s.title || ''),
|
||||
content: String(s.content || ''), schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
totalTokens += dataResponse.length / 4
|
||||
} catch { dataSections = [] }
|
||||
|
||||
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
|
||||
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
|
||||
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
|
||||
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
|
||||
|
||||
const mergedSections: DraftSection[] = []
|
||||
if (introBlock) mergedSections.push({ id: introBlock.blockId, title: 'Einleitung', content: introBlock.text })
|
||||
for (let i = 0; i < dataSections.length; i++) {
|
||||
if (i > 0 && transitionBlocks[i - 1]) mergedSections.push({ id: transitionBlocks[i - 1].blockId, title: '', content: transitionBlocks[i - 1].text })
|
||||
mergedSections.push(dataSections[i])
|
||||
}
|
||||
for (const block of appreciationBlocks) mergedSections.push({ id: block.blockId, title: 'Wuerdigung', content: block.text })
|
||||
if (conclusionBlock) mergedSections.push({ id: conclusionBlock.blockId, title: 'Fazit', content: conclusionBlock.text })
|
||||
|
||||
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
|
||||
id: b.blockId,
|
||||
title: b.blockType === 'introduction' ? 'Einleitung' : b.blockType === 'conclusion' ? 'Fazit' : b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
|
||||
content: b.text,
|
||||
}))
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-v2-${Date.now()}`,
|
||||
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
|
||||
sections: finalSections, createdAt: new Date().toISOString(), instruction: instructions,
|
||||
}
|
||||
|
||||
const auditTrail = {
|
||||
documentType, templateVersion: TEMPLATE_VERSION, terminologyVersion: TERMINOLOGY_VERSION,
|
||||
validatorVersion: VALIDATOR_VERSION, promptHash, llmModel: LLM_MODEL,
|
||||
llmTemperature: 0.15, llmProvider: 'ollama', narrativeTags,
|
||||
sanitization: sanitizationResult.audit, repairAudits,
|
||||
proseBlocks: generatedBlocks.map((b, i) => ({
|
||||
blockId: b.blockId, blockType: b.blockType,
|
||||
wordCount: b.text.split(/\s+/).filter(Boolean).length,
|
||||
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
|
||||
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
|
||||
})),
|
||||
cacheStats: proseCache.getStats(),
|
||||
}
|
||||
|
||||
const truthLabel = { generation_mode: 'draft_assistance', truth_status: 'generated', may_be_used_as_evidence: false, generated_by: 'system' }
|
||||
|
||||
try {
|
||||
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
|
||||
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entity_type: 'document', entity_id: null, generation_mode: 'draft_assistance',
|
||||
truth_status: 'generated', may_be_used_as_evidence: false,
|
||||
llm_model: LLM_MODEL, llm_provider: 'ollama',
|
||||
input_summary: `${documentType} draft generation`,
|
||||
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
|
||||
}),
|
||||
}).catch(() => {/* fire-and-forget */})
|
||||
} catch { /* LLM audit persistence failure should not block the response */ }
|
||||
|
||||
return NextResponse.json({ draft, constraintCheck, tokensUsed: Math.round(totalTokens), pipelineVersion: 'v2', auditTrail, truthLabel })
|
||||
}
|
||||
Reference in New Issue
Block a user