Improve audit pipeline and outreach review
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user