Improve audit pipeline and outreach review

This commit is contained in:
2026-06-08 22:16:32 +02:00
parent ff18fc202e
commit 1695110e0a
34 changed files with 2792 additions and 238 deletions

View File

@@ -1,3 +1,5 @@
import { customerToneGuidelines } from "./customer-tone-guidelines";
const GERMAN_MARKERS = new Set([
"ich",
"mich",
@@ -31,6 +33,12 @@ const GERMAN_MARKERS = new Set([
"wenn",
"für",
"bei",
"kurz",
"kurzer",
"hinweis",
"zur",
"kontaktseite",
"webauftritt",
]);
const ENGLISH_MARKERS = new Set([
@@ -120,6 +128,63 @@ const RAW_TECH_PATTERNS = [
/\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;
@@ -256,6 +321,178 @@ 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,
@@ -372,10 +609,12 @@ 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: true,
requireObservationAndSuggestion: true,
requireIchForm: false,
requireObservationAndSuggestion: false,
});
validateEmailBodyTone(issues, email.body);
return { passed: issues.length === 0, issues };
}
@@ -453,13 +692,15 @@ export function validateCustomerFacingCopy(input: GermanCustomerCopy): GermanCop
validateTextField(issues, "emailSubject", input.emailSubject, {
skipIfTooShort: true,
});
validateEmailSubjectTone(issues, input.emailSubject);
}
if (input.emailBody !== undefined) {
validateTextField(issues, "emailBody", input.emailBody, {
requireIchForm: true,
requireObservationAndSuggestion: true,
requireIchForm: false,
requireObservationAndSuggestion: false,
});
validateEmailBodyTone(issues, input.emailBody);
}
if (input.callScript) {