/** * 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> = { 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 { 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): Promise { 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, 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 }) }