import { customerToneGuidelines } from "./customer-tone-guidelines"; const GERMAN_MARKERS = new Set([ "ich", "mich", "mir", "mein", "meine", "wir", "du", "sie", "er", "sie", "der", "die", "das", "und", "ist", "sind", "sind", "waren", "hat", "habe", "haben", "eine", "einer", "einem", "dieser", "diese", "dieses", "nicht", "mit", "wenn", "für", "bei", "kurz", "kurzer", "hinweis", "zur", "kontaktseite", "webauftritt", ]); const ENGLISH_MARKERS = new Set([ "the", "and", "you", "your", "we", "our", "is", "are", "was", "were", "to", "of", "in", "for", "on", "with", "this", "that", "it", "from", "have", "has", "will", "can", "if", "quick", "audit", "bad", "website", "report", ]); const OBSERVATION_TOKENS = [ /\b(mir|ich)\b[^\n]{0,80}\b(aufgefallen|festgestellt|bemerkt|beobachtet|gesehen|sichtbar)\b/i, /\b(erkennt|zeigt|sichtbar|festgestellt|feststellen|feststellbar|finde|fällt)\b/i, /\b(ich sehe|ich habe gesehen|bei der Prüfung)\b/i, ]; const SUGGESTION_TOKENS = [ /\b(empfehle|empfiehlt|vorschlage|vorschlagen|schlage vor|könnte helfen|kannst|können wir|sollte|sollten|ich könnte|ich würde|ich empfehle)\b/i, /\b(schlage vor|schlage)\b/i, /\b(?:mein(?:e[rmns]?)?\s+)?(?:konkreter\s+)?vorschlag(?:\s+ist)?\b/i, /\b(ergänzt|ergänzen|anpassen|optimieren|verbessern|prüfen|einbauen|einzusetzen|setzten)\b/i, ]; const AI_SLOP_TOKENS = [ /\bmaßgeschneid(?:ert|ert|er)\b/i, /\bnahtlos\b/i, /\bstate[- ]of[- ]the[- ]art\b/i, /\bgame[- ]?changer\b/i, /\bsynerg(?:ie|istisch)\b/i, /\brevolutionär\b/i, /\bnext level\b/i, /\bzukunftsweisend\b/i, /\bdigital transformieren\b/i, /\boutstanding\b/i, /\bhebt.{0,20}Sichtbarkeit\b/i, ]; const HOSTILE_TOKENS = [ /\b(Ihr|Ihre|Sie|eure|euer)\b[^\n.!?]{0,80}\b(katastroph|schlecht|veraltet|unprofessionell|unbrauchbar|mangelhaft|chaotisch|desastr|desaster|skrupellos)\b/i, /\b(ist|sind)\s+(?:total|absolut)\s+(?:schlecht|kaputt|katastroph)\b/i, /\babsolut unprofessionell\b/i, ]; const SCORE_CONTEXT_TOKENS = [ /\b(?:pagespeed|lighthouse|score)\b[^\n]{0,120}\b\d{1,2}(?:[.,]\d+)?%?/i, /\b\d{1,2}(?:[.,]\d+)?%?[^\n]{0,120}\b(?:pagespeed|lighthouse|score)\b/i, ]; const PRICE_PATTERNS = [ /\b\d{1,4}\s*(?:€|EUR|Euro|euro)/, /(?:€|EUR|Euro|euro)\s*\d{1,4}(?:[.,]\d{1,2})?/, /\b(?:preis|preise|kosten)\b[^a-z]{0,5}\d{1,4}\s*(?:€|EUR|Euro|euro)?/i, ]; const RAW_TECH_PATTERNS = [ /\braw\s*storage\s*id\b/i, /\bstorage[_-]?id\b/i, /\bmodel[_-]?id\b/i, /\b(?:gpt|claude|gemini|llama|mistral|qwen|mixtral|deepseek|phi|sonar|gemma)\b[-\w]*/i, /\{[^\n]{0,240}:[^\n]{0,240}\}/, /\[[^\n]{0,240}\]/, /\b[0-9a-f]{24}\b/i, ]; const EMAIL_TEMPLATE_PATTERNS = [ /\bich habe beobachtet\b/i, /\bmir ist aufgefallen\b/i, /\bich schlage vor\b/i, /\bich empfehle\b/i, ]; const EMAIL_BROCHURE_PATTERNS = [ /\bmaßnahmen umsetzen\b/i, /\bconversion[- ]rate steigern\b/i, /\branking positiv beeinflussen\b/i, /\babsprungraten senken\b/i, /\bnachhaltig verbessern\b/i, /\bsignifikant\b/i, /\boptimierungspotenzial(?:e)?\b/i, /\bnutzerzufriedenheit\b/i, /\bsuchmaschinenplatzierung\b/i, ]; const EMAIL_AUDIT_TOPIC_PATTERNS = [ /\bmeta[- ]beschreibung\b/i, /\bpage[- ]?speed\b/i, /\bladezeit(?:en)?\b/i, /\bkontaktformular\b/i, /\bcall[- ]to[- ]action\b/i, /\bmobile(?:n|r|s)? gerät/i, /\bdesktop\b/i, /\bh1[- ]?überschrift(?:en)?\b/i, /\bbewertung(?:en)?\b/i, /\bvertrauenssignal(?:e)?\b/i, /\bstrukturierte daten\b/i, ]; const EMAIL_MINI_AUDIT_TRANSITIONS = [ /\baußerdem\b/i, /\bzudem\b/i, /\bein weiterer punkt\b/i, /\bschließlich\b/i, /\bdurch die umsetzung\b/i, ]; const EMAIL_LOW_FRICTION_ASK_PATTERNS = [ /\bsoll ich ihnen\b/i, /\bwäre (?:das|ein kurzer hinweis)\b/i, /\bdarf ich ihnen\b/i, /\bkann ich ihnen\b/i, /\boffen für\b/i, ]; const INFORMAL_EMAIL_ADDRESS_PATTERNS = [ /\bdu\b/i, /\bdir\b/i, /\bdein(?:e[rmns]?)?\b/i, /\beuch\b/i, /\beuer(?:e[rmns]?)?\b/i, ]; export type GermanCopyGuardIssue = { field: string; rule: string; message: string; }; export type GermanCopyGuardResult = { passed: boolean; issues: GermanCopyGuardIssue[]; }; export type AuditCopy = { summary: string; body: string; }; export type EmailCopy = { subject: string; body: string; }; export type CallScriptCopy = { openingLine: string; callScript: string[]; closeLine: string; }; export type FollowUpCopy = { message: string; }; export type GermanCustomerCopy = { auditSummary?: string; auditBody?: string; emailSubject?: string; emailBody?: string; callScript?: CallScriptCopy; followUp?: string; }; type ValidationOptions = { requireIchForm?: boolean; requireObservationAndSuggestion?: boolean; skipIfTooShort?: boolean; }; function addIssue( issues: GermanCopyGuardIssue[], field: string, rule: string, message: string, ) { issues.push({ field, rule, message }); } function tokenizeWords(value: string): string[] { return value .toLowerCase() .match(/[a-zäöüß]{3,}/giu) ?.map((token) => token.toLowerCase()) ?? []; } function hasGermanAnchor(value: string): boolean { const words = tokenizeWords(value); if (!words.length) { return true; } if (/[äöüß]/i.test(value)) { return true; } const germanCount = words.reduce( (count, word) => count + (GERMAN_MARKERS.has(word) ? 1 : 0), 0, ); const englishCount = words.reduce( (count, word) => count + (ENGLISH_MARKERS.has(word) ? 1 : 0), 0, ); if (words.length <= 4) { if (germanCount >= 1) { return true; } return englishCount === 0; } if (germanCount >= 1) { return true; } if (englishCount === 0) { return true; } if (englishCount / words.length >= 0.2) { return false; } return true; } function hasIchForm(value: string): boolean { return /\b(ich|mich|mir|mein|meine|meinem|meiner)\b/i.test(value); } function hasObservation(value: string): boolean { return OBSERVATION_TOKENS.some((pattern) => pattern.test(value)); } function hasSuggestion(value: string): boolean { return SUGGESTION_TOKENS.some((pattern) => pattern.test(value)); } function hasAiSlop(value: string): boolean { return AI_SLOP_TOKENS.some((pattern) => pattern.test(value)); } function hasHostileTone(value: string): boolean { return HOSTILE_TOKENS.some((pattern) => pattern.test(value)); } function hasScoreArtifact(value: string): boolean { return SCORE_CONTEXT_TOKENS.some((pattern) => pattern.test(value)); } function hasPrice(value: string): boolean { return PRICE_PATTERNS.some((pattern) => pattern.test(value)); } function hasRawArtifact(value: string): boolean { return RAW_TECH_PATTERNS.some((pattern) => pattern.test(value)); } function countMatches(value: string, patterns: readonly RegExp[]) { return patterns.reduce( (count, pattern) => count + (pattern.test(value) ? 1 : 0), 0, ); } function countRegexMatches(value: string, pattern: RegExp) { return value.match(pattern)?.length ?? 0; } function countSentences(value: string) { return value .split(/[.!?]+/) .map((sentence) => sentence.trim()) .filter(Boolean).length; } function countParagraphs(value: string) { return value .trim() .split(/\n\s*\n/) .map((paragraph) => paragraph.trim()) .filter(Boolean).length; } function startsWithTemplateEmailPhrase(value: string) { return new RegExp( String.raw`^\s*(?:(?:guten tag|hallo|sehr geehrte[^,.!?]*|moin)[,.!?\s]+)?(?:${EMAIL_TEMPLATE_PATTERNS.map( (pattern) => pattern.source, ).join("|")})`, "i", ).test(value); } function hasLowFrictionAsk(value: string) { return ( value.includes("?") && EMAIL_LOW_FRICTION_ASK_PATTERNS.some((pattern) => pattern.test(value)) ); } function validateEmailSubjectTone( issues: GermanCopyGuardIssue[], subject: string, ) { const trimmed = subject.trim(); if (!trimmed) { return; } const words = tokenizeWords(trimmed); const { subject: subjectRules } = customerToneGuidelines.email; const hasInflatedSubject = /optimierungspotenzial/i.test(trimmed) || /mehr sichtbarkeit/i.test(trimmed) || /bessere nutzererfahrung/i.test(trimmed) || /kundengewinnung/i.test(trimmed) || /conversion/i.test(trimmed) || /ranking/i.test(trimmed); if ( trimmed.length > subjectRules.maxCharacters || words.length < subjectRules.minWords || words.length > subjectRules.maxWords || /:/.test(trimmed) || hasInflatedSubject ) { addIssue( issues, "emailSubject", "unnatural_email_subject", "Betreff wirkt zu pitchig, zu lang oder nicht wie eine kurze Erstmail.", ); } } function validateEmailBodyTone( issues: GermanCopyGuardIssue[], body: string, ) { const trimmed = body.trim(); if (!trimmed) { return; } const { email } = customerToneGuidelines; const wordCount = tokenizeWords(trimmed).length; if (wordCount < email.wordCount.min || wordCount > email.wordCount.max) { addIssue( issues, "emailBody", "unnatural_email_length", "E-Mail sollte als Erstkontakt kurz bleiben: 60-130 Wörter.", ); } if (countSentences(trimmed) > email.maxSentences) { addIssue( issues, "emailBody", "too_many_email_sentences", "E-Mail enthält zu viele Sätze für eine erste Kontaktaufnahme.", ); } if (countParagraphs(trimmed) > email.maxParagraphs) { addIssue( issues, "emailBody", "too_many_email_paragraphs", "E-Mail sollte höchstens zwei kurze Absätze enthalten.", ); } const templatePhraseCount = EMAIL_TEMPLATE_PATTERNS.reduce( (count, pattern) => count + countRegexMatches(trimmed, new RegExp(pattern.source, "gi")), 0, ); const firstPersonCount = countRegexMatches(trimmed, /\bich\b/gi); if ( startsWithTemplateEmailPhrase(trimmed) || templatePhraseCount >= 2 || firstPersonCount > 2 ) { addIssue( issues, "emailBody", "formulaic_email_tone", "E-Mail wirkt formelhaft; vermeide wiederholte Ich-habe-/Ich-schlage-vor-Muster.", ); } if (EMAIL_BROCHURE_PATTERNS.some((pattern) => pattern.test(trimmed))) { addIssue( issues, "emailBody", "brochure_email_language", "E-Mail klingt nach Broschüre statt nach natürlicher Erstansprache.", ); } const topicCount = countMatches(trimmed, EMAIL_AUDIT_TOPIC_PATTERNS); const transitionCount = countMatches(trimmed, EMAIL_MINI_AUDIT_TRANSITIONS); if (topicCount >= 4 || transitionCount >= 2) { addIssue( issues, "emailBody", "email_reads_like_mini_audit", "E-Mail bündelt zu viele Audit-Punkte und sollte höchstens zwei Befunde anreißen.", ); } if (INFORMAL_EMAIL_ADDRESS_PATTERNS.some((pattern) => pattern.test(trimmed))) { addIssue( issues, "emailBody", "informal_email_address", "E-Mail sollte unbekannte lokale Betriebe formal mit Sie/Ihnen ansprechen.", ); } if (!hasLowFrictionAsk(trimmed)) { addIssue( issues, "emailBody", "missing_low_friction_ask", "E-Mail sollte mit einer kurzen, leicht beantwortbaren Frage enden.", ); } } function validateTextField( issues: GermanCopyGuardIssue[], field: string, value: string, options: ValidationOptions = {}, ) { if (options.skipIfTooShort && value.trim().length < 6) { return; } if (!hasGermanAnchor(value)) { addIssue( issues, field, "not_german", "Text wirkt nicht ausreichend deutsch.", ); } if (options.requireIchForm && !hasIchForm(value)) { addIssue( issues, field, "missing_ich_form", "Text sollte in Ich-Form geschrieben sein.", ); } if (hasScoreArtifact(value)) { addIssue( issues, field, "pagespeed_score_artifact", "Technische Score-/PageSpeed-Werte sollten nicht im Kunden-Text erscheinen.", ); } if (hasPrice(value)) { addIssue( issues, field, "price_mention", "Preis- oder Währungsangaben sollten im Kunden-Text vermieden werden.", ); } if (hasAiSlop(value)) { addIssue( issues, field, "generic_ai_slop", "Generische KI-Slop-Formulierungen erkannt.", ); } if (hasHostileTone(value)) { addIssue( issues, field, "hostile_tone", "Anklagende oder negativ wertende Sprache wurde erkannt.", ); } if (hasRawArtifact(value)) { addIssue( issues, field, "raw_technical_artifact", "Technische Artefakte im Text erkannt.", ); } if (options.requireObservationAndSuggestion && (!hasObservation(value) || !hasSuggestion(value))) { addIssue( issues, field, "missing_observation_or_suggestion", "Beobachtung und Vorschlag sollten im gleichen Text erkennbar sein.", ); } } function validateCallScriptText( issues: GermanCopyGuardIssue[], linePrefix: string, scriptLine: string, options: ValidationOptions, ) { const lineValue = scriptLine?.trim(); if (!lineValue) { return; } validateTextField(issues, linePrefix, lineValue, options); } export function validateAuditCopy(audit: AuditCopy): GermanCopyGuardResult { const issues: GermanCopyGuardIssue[] = []; validateTextField(issues, "auditSummary", audit.summary, { requireIchForm: true, requireObservationAndSuggestion: true, }); validateTextField(issues, "auditBody", audit.body, { requireIchForm: true, requireObservationAndSuggestion: true, }); return { passed: issues.length === 0, issues }; } export function validateEmailCopy(email: EmailCopy): GermanCopyGuardResult { const issues: GermanCopyGuardIssue[] = []; validateTextField(issues, "emailSubject", email.subject, { skipIfTooShort: true }); validateEmailSubjectTone(issues, email.subject); validateTextField(issues, "emailBody", email.body, { requireIchForm: false, requireObservationAndSuggestion: false, }); validateEmailBodyTone(issues, email.body); return { passed: issues.length === 0, issues }; } export function validateCallScriptCopy(script: CallScriptCopy): GermanCopyGuardResult { const issues: GermanCopyGuardIssue[] = []; validateCallScriptText(issues, "callScript.openingLine", script.openingLine, { requireIchForm: true, }); validateCallScriptText(issues, "callScript.closeLine", script.closeLine, { requireIchForm: false, }); script.callScript.forEach((line, index) => { validateCallScriptText( issues, `callScript.callScript[${index}]`, line, { requireIchForm: false, }, ); }); const scriptConcatenated = [ script.openingLine, ...script.callScript, script.closeLine, ] .filter((line) => line.trim().length > 0) .join(" "); if (!hasObservation(scriptConcatenated) || !hasSuggestion(scriptConcatenated)) { addIssue( issues, "callScript", "missing_observation_or_suggestion", "Beobachtung und Vorschlag sollten im Call-Script erkennbar sein.", ); } return { passed: issues.length === 0, issues }; } export function validateFollowUpCopy(followUp: FollowUpCopy): GermanCopyGuardResult { const issues: GermanCopyGuardIssue[] = []; validateTextField(issues, "followUp", followUp.message, { requireIchForm: true, requireObservationAndSuggestion: true, }); return { passed: issues.length === 0, issues }; } export function validateCustomerFacingCopy(input: GermanCustomerCopy): GermanCopyGuardResult { const issues: GermanCopyGuardIssue[] = []; if (input.auditSummary !== undefined) { validateTextField(issues, "auditSummary", input.auditSummary, { requireIchForm: true, requireObservationAndSuggestion: true, }); } if (input.auditBody !== undefined) { validateTextField(issues, "auditBody", input.auditBody, { requireIchForm: true, requireObservationAndSuggestion: true, }); } if (input.emailSubject !== undefined) { validateTextField(issues, "emailSubject", input.emailSubject, { skipIfTooShort: true, }); validateEmailSubjectTone(issues, input.emailSubject); } if (input.emailBody !== undefined) { validateTextField(issues, "emailBody", input.emailBody, { requireIchForm: false, requireObservationAndSuggestion: false, }); validateEmailBodyTone(issues, input.emailBody); } if (input.callScript) { issues.push( ...validateCallScriptCopy({ openingLine: input.callScript.openingLine, callScript: [...input.callScript.callScript], closeLine: input.callScript.closeLine, }).issues, ); } if (input.followUp !== undefined) { validateTextField(issues, "followUp", input.followUp, { requireIchForm: true, requireObservationAndSuggestion: true, }); } return { passed: issues.length === 0, issues }; }