/** * Prose Validator + Repair Loop — Governance Layer * * Validiert LLM-generierte Prosa-Bloecke gegen das Regelwerk. * Orchestriert den Repair-Loop (max 2 Versuche) mit Fallback. * * 12 Pruefregeln, davon 10 reparierbar und 2 Hard Aborts. */ import type { NarrativeTags } from './narrative-tags' import { getAllAllowedTagValues } from './narrative-tags' import type { AllowedFacts } from './allowed-facts' import { checkForDisallowedContent } from './allowed-facts' import { checkStyleViolations, checkTerminologyUsage } from './terminology' import type { SanitizedFacts } from './sanitizer' import { isSanitized } from './sanitizer' // ============================================================================ // Types // ============================================================================ /** Strukturierter LLM-Output (Pflicht-Format) */ export interface ProseBlockOutput { blockId: string blockType: 'introduction' | 'transition' | 'conclusion' | 'appreciation' language: 'de' text: string assertions: { companyNameUsed: boolean industryReferenced: boolean structureReferenced: boolean itLandscapeReferenced: boolean narrativeTagsUsed: string[] } forbiddenContentDetected: string[] } /** Einzelner Validierungsfehler */ export interface ProseValidationError { rule: string severity: 'error' | 'warning' message: string repairable: boolean } /** Validierungsergebnis */ export interface ProseValidatorResult { valid: boolean errors: ProseValidationError[] repairable: boolean } /** Repair-Loop Audit */ export interface RepairAudit { repairAttempts: number validatorFailures: string[][] repairSuccessful: boolean fallbackUsed: boolean fallbackReason?: string } /** Word count limits per block type */ const WORD_COUNT_LIMITS: Record = { introduction: { min: 30, max: 200 }, transition: { min: 10, max: 80 }, conclusion: { min: 20, max: 150 }, appreciation: { min: 15, max: 100 }, } // ============================================================================ // Prose Validator // ============================================================================ /** * Validiert einen ProseBlockOutput gegen alle 12 Regeln. */ export function validateProseBlock( block: ProseBlockOutput, facts: AllowedFacts | SanitizedFacts, expectedTags: NarrativeTags ): ProseValidatorResult { const errors: ProseValidationError[] = [] // Rule 1: JSON_VALID — wird extern geprueft (Parsing vor Aufruf) // Wenn wir hier sind, ist JSON bereits valide // Rule 2: COMPANY_NAME_PRESENT if (!block.text.includes(facts.companyName) && facts.companyName !== 'Unbekannt') { errors.push({ rule: 'COMPANY_NAME_PRESENT', severity: 'error', message: `Firmenname "${facts.companyName}" nicht im Text gefunden`, repairable: true, }) } // Rule 3: INDUSTRY_REFERENCED if (facts.industry && !block.text.toLowerCase().includes(facts.industry.toLowerCase())) { errors.push({ rule: 'INDUSTRY_REFERENCED', severity: 'warning', message: `Branche "${facts.industry}" nicht im Text referenziert`, repairable: true, }) } // Rule 4: NO_NUMERIC_SCORES if (/\d+\s*%/.test(block.text)) { errors.push({ rule: 'NO_NUMERIC_SCORES', severity: 'error', message: 'Prozentwerte im Text gefunden', repairable: true, }) } if (/score[:\s]*\d+/i.test(block.text)) { errors.push({ rule: 'NO_NUMERIC_SCORES', severity: 'error', message: 'Score-Werte im Text gefunden', repairable: true, }) } if (/\b(L1|L2|L3|L4)\b/.test(block.text)) { errors.push({ rule: 'NO_NUMERIC_SCORES', severity: 'error', message: 'Compliance-Level-Bezeichnungen (L1-L4) im Text gefunden', repairable: true, }) } // Rule 5: NO_DISALLOWED_TOPICS const disallowedViolations = checkForDisallowedContent(block.text) for (const violation of disallowedViolations) { errors.push({ rule: 'NO_DISALLOWED_TOPICS', severity: 'error', message: violation, repairable: true, }) } // Rule 6: WORD_COUNT_IN_RANGE const wordCount = block.text.split(/\s+/).filter(Boolean).length const limits = WORD_COUNT_LIMITS[block.blockType] if (limits) { if (wordCount < limits.min) { errors.push({ rule: 'WORD_COUNT_IN_RANGE', severity: 'warning', message: `Wortanzahl ${wordCount} unter Minimum ${limits.min} fuer ${block.blockType}`, repairable: true, }) } if (wordCount > limits.max) { errors.push({ rule: 'WORD_COUNT_IN_RANGE', severity: 'error', message: `Wortanzahl ${wordCount} ueber Maximum ${limits.max} fuer ${block.blockType}`, repairable: true, }) } } // Rule 7: NO_DIRECT_ADDRESS if (/\b(Sie|Ihr|Ihnen|Ihrem|Ihrer)\b/.test(block.text)) { errors.push({ rule: 'NO_DIRECT_ADDRESS', severity: 'error', message: 'Direkte Ansprache (Sie/Ihr) gefunden', repairable: true, }) } // Rule 8: NARRATIVE_TAGS_CONSISTENT const allowedTags = getAllAllowedTagValues() if (block.assertions.narrativeTagsUsed) { for (const tag of block.assertions.narrativeTagsUsed) { if (!allowedTags.includes(tag)) { errors.push({ rule: 'NARRATIVE_TAGS_CONSISTENT', severity: 'error', message: `Unbekannter Narrative Tag "${tag}" in assertions`, repairable: true, }) } } } // Pruefen ob Text Tags enthaelt die nicht zu den erwarteten gehoeren const expectedTagValues = Object.values(expectedTags) const allTagValues = getAllAllowedTagValues() for (const tagValue of allTagValues) { if (block.text.includes(tagValue) && !expectedTagValues.includes(tagValue)) { errors.push({ rule: 'NARRATIVE_TAGS_CONSISTENT', severity: 'error', message: `Tag "${tagValue}" im Text, aber nicht im erwarteten Tag-Set`, repairable: true, }) } } // Rule 9: TERMINOLOGY_CORRECT const termViolations = checkTerminologyUsage(block.text) for (const warning of termViolations) { errors.push({ rule: 'TERMINOLOGY_CORRECT', severity: 'warning', message: warning, repairable: true, }) } // Rule 10: Style violations const styleViolations = checkStyleViolations(block.text) for (const violation of styleViolations) { errors.push({ rule: 'STYLE_VIOLATION', severity: 'warning', message: violation, repairable: true, }) } // Rule 11: SANITIZATION_PASSED (Hard Abort) if ('__sanitized' in facts && !isSanitized(facts)) { errors.push({ rule: 'SANITIZATION_PASSED', severity: 'error', message: 'Sanitization-Flag gesetzt aber nicht valide', repairable: false, }) } // Rule 12: Self-reported forbidden content if (block.forbiddenContentDetected && block.forbiddenContentDetected.length > 0) { errors.push({ rule: 'SELF_REPORTED_FORBIDDEN', severity: 'error', message: `LLM meldet verbotene Inhalte: ${block.forbiddenContentDetected.join(', ')}`, repairable: true, }) } const hasHardAbort = errors.some(e => !e.repairable) const hasErrors = errors.some(e => e.severity === 'error') return { valid: !hasErrors, errors, repairable: hasErrors && !hasHardAbort, } } // ============================================================================ // JSON Parsing // ============================================================================ /** * Parst und validiert LLM-Output als ProseBlockOutput. * Gibt null zurueck wenn JSON nicht parsebar ist. */ export function parseProseBlockOutput(rawContent: string): ProseBlockOutput | null { try { const parsed = JSON.parse(rawContent) // Pflichtfelder pruefen if ( typeof parsed.blockId !== 'string' || typeof parsed.text !== 'string' || !['introduction', 'transition', 'conclusion', 'appreciation'].includes(parsed.blockType) ) { return null } return { blockId: parsed.blockId, blockType: parsed.blockType, language: parsed.language || 'de', text: parsed.text, assertions: { companyNameUsed: parsed.assertions?.companyNameUsed ?? false, industryReferenced: parsed.assertions?.industryReferenced ?? false, structureReferenced: parsed.assertions?.structureReferenced ?? false, itLandscapeReferenced: parsed.assertions?.itLandscapeReferenced ?? false, narrativeTagsUsed: parsed.assertions?.narrativeTagsUsed ?? [], }, forbiddenContentDetected: parsed.forbiddenContentDetected ?? [], } } catch { return null } } // ============================================================================ // Repair Prompt Builder // ============================================================================ /** * Baut den Repair-Prompt fuer einen fehlgeschlagenen Block. */ export function buildRepairPrompt( originalBlock: ProseBlockOutput, validationErrors: ProseValidationError[] ): string { const errorList = validationErrors .filter(e => e.severity === 'error') .map(e => `- ${e.rule}: ${e.message}`) .join('\n') return `Der vorherige Text enthielt Fehler. Ueberarbeite ihn unter Beibehaltung der Aussage. FEHLER: ${errorList} REGELN: - Entferne alle unerlaubten Inhalte - Behalte den Firmenkontext bei - Erzeuge ausschliesslich JSON im vorgegebenen Format - Aendere KEINE Fakten, ergaenze KEINE neuen Informationen - Verwende KEINE direkte Ansprache (Sie/Ihr) - Verwende KEINE konkreten Prozentwerte oder Scores ORIGINALTEXT: ${JSON.stringify(originalBlock, null, 2)}` } // ============================================================================ // Fallback Templates // ============================================================================ const FALLBACK_TEMPLATES: Record = { introduction: 'Die {{companyName}} dokumentiert im Folgenden die {{documentType}}-relevanten Massnahmen und Bewertungen. Die nachstehenden Ausfuehrungen basieren auf der aktuellen Analyse der organisatorischen und technischen Gegebenheiten.', transition: 'Auf Grundlage der vorstehenden Daten ergeben sich die folgenden Detailbewertungen.', conclusion: 'Die {{companyName}} verfuegt ueber die dokumentierten Massnahmen und Strukturen. Die Einhaltung der regulatorischen Anforderungen wird fortlaufend ueberprueft und angepasst.', appreciation: 'Die bestehende Organisationsstruktur der {{companyName}} bildet eine {{maturity}} Grundlage fuer die nachfolgend dokumentierten Massnahmen.', } /** * Erzeugt einen Fallback-Block wenn der Repair-Loop fehlschlaegt. */ export function buildFallbackBlock( blockId: string, blockType: ProseBlockOutput['blockType'], facts: AllowedFacts, documentType?: string ): ProseBlockOutput { let text = FALLBACK_TEMPLATES[blockType] .replace(/\{\{companyName\}\}/g, facts.companyName) .replace(/\{\{maturity\}\}/g, facts.narrativeTags.maturity) .replace(/\{\{documentType\}\}/g, documentType || 'Compliance') return { blockId, blockType, language: 'de', text, assertions: { companyNameUsed: true, industryReferenced: false, structureReferenced: false, itLandscapeReferenced: false, narrativeTagsUsed: blockType === 'appreciation' ? ['maturity'] : [], }, forbiddenContentDetected: [], } } // ============================================================================ // Repair Loop Orchestrator // ============================================================================ /** Callback fuer LLM-Aufruf (wird von der Route injiziert) */ export type LLMCallFn = (prompt: string) => Promise /** * Orchestriert den Repair-Loop fuer einen einzelnen Prosa-Block. * * 1. Parse + Validate * 2. Bei Fehler: Repair-Prompt → LLM → Parse + Validate (max 2x) * 3. Bei weiterem Fehler: Fallback Template * * @returns Validierter ProseBlockOutput + RepairAudit */ export async function executeRepairLoop( rawLLMOutput: string, facts: AllowedFacts | SanitizedFacts, expectedTags: NarrativeTags, blockId: string, blockType: ProseBlockOutput['blockType'], llmCall: LLMCallFn, documentType?: string, maxRepairAttempts = 2 ): Promise<{ block: ProseBlockOutput; audit: RepairAudit }> { const audit: RepairAudit = { repairAttempts: 0, validatorFailures: [], repairSuccessful: false, fallbackUsed: false, } // Versuch 0: Original-Output parsen + validieren let parsed = parseProseBlockOutput(rawLLMOutput) if (!parsed) { // JSON invalid → Regeneration zaehlt als Repair-Versuch audit.validatorFailures.push(['JSON_VALID: LLM-Output konnte nicht als JSON geparst werden']) audit.repairAttempts++ if (audit.repairAttempts <= maxRepairAttempts) { const repairPrompt = `Der vorherige Output war kein valides JSON. Erzeuge ausschliesslich ein JSON-Objekt mit den Feldern: blockId, blockType, language, text, assertions, forbiddenContentDetected.\n\nOriginal-Output:\n${rawLLMOutput.slice(0, 500)}` try { const repaired = await llmCall(repairPrompt) parsed = parseProseBlockOutput(repaired) } catch { // LLM-Fehler → weiter zum Fallback } } } if (!parsed) { audit.fallbackUsed = true audit.fallbackReason = 'JSON konnte nach Repair nicht geparst werden' return { block: buildFallbackBlock(blockId, blockType, facts, documentType), audit, } } // Validierungs-Schleife for (let attempt = audit.repairAttempts; attempt <= maxRepairAttempts; attempt++) { const result = validateProseBlock(parsed, facts, expectedTags) if (result.valid) { audit.repairSuccessful = attempt === 0 ? true : true return { block: parsed, audit } } // Hard Abort? → Fallback sofort if (!result.repairable) { audit.fallbackUsed = true audit.fallbackReason = `Hard Abort: ${result.errors.filter(e => !e.repairable).map(e => e.rule).join(', ')}` audit.validatorFailures.push(result.errors.map(e => `${e.rule}: ${e.message}`)) return { block: buildFallbackBlock(blockId, blockType, facts, documentType), audit, } } // Fehler protokollieren audit.validatorFailures.push(result.errors.map(e => `${e.rule}: ${e.message}`)) // Noch Repair-Versuche uebrig? if (attempt >= maxRepairAttempts) { break } // Repair-Prompt senden audit.repairAttempts++ try { const repairPrompt = buildRepairPrompt(parsed, result.errors) const repairedOutput = await llmCall(repairPrompt) const repairedParsed = parseProseBlockOutput(repairedOutput) if (!repairedParsed) { // Parsing fehlgeschlagen nach Repair continue } parsed = repairedParsed } catch { // LLM-Fehler → naechster Versuch oder Fallback continue } } // Alle Versuche erschoepft → Fallback audit.fallbackUsed = true audit.fallbackReason = `${maxRepairAttempts} Repair-Versuche erschoepft` return { block: buildFallbackBlock(blockId, blockType, facts, documentType), audit, } }