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

@@ -72,6 +72,20 @@ export type AuditEvidenceInput = {
}>;
pageSpeedCustomerImplications: string[];
selectedSkills: AuditUsedSkill[];
evidenceLedger: AuditEvidenceLedgerEntry[];
};
export type AuditEvidenceLedgerEntry = {
id: string;
type:
| "crawl_page"
| "technical_check"
| "screenshot"
| "pagespeed"
| "jina_excerpt";
label: string;
sourceUrl?: string;
summary: string;
};
export type AuditEvidenceInputArgs = {
@@ -96,6 +110,7 @@ const EXTERNAL_MARKDOWN_LIMIT = 4_000;
const V3_LOCAL_AUDIT_PRIORITY = new Map(
[
"visual-design",
"impeccable-critique",
"contact-conversion",
"local-seo-basics",
"performance-experience",
@@ -113,6 +128,32 @@ const PAGESPEED_NOISE_PATTERN =
/\b(?:raw\s*storage\s*id|rawstorageid|lighthouse|pagespeed|score)\b/i;
const MACHINE_TOKEN_PATTERN = /\b[a-z\d_-]{24,}\b/i;
function stableEvidencePart(value: unknown) {
const normalized = trimAndNormalize(String(value ?? "").toLowerCase())
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return normalized || "source";
}
function evidenceId(type: AuditEvidenceLedgerEntry["type"], ...parts: unknown[]) {
return [type, ...parts.map(stableEvidencePart)].join(":");
}
function addEvidenceLedgerEntry(
ledger: AuditEvidenceLedgerEntry[],
entry: AuditEvidenceLedgerEntry,
) {
if (!entry.summary || ledger.some((current) => current.id === entry.id)) {
return;
}
ledger.push(entry);
}
function trimAndNormalize(input: unknown): string {
if (typeof input !== "string") {
return "";
@@ -555,6 +596,7 @@ export function buildAuditEvidenceInput(
const pageSpeedInputs = args.pageSpeedInputs ?? [];
const skillRegistry = args.skillRegistry ?? [];
const externalMarkdown = sanitizeExternalMarkdown(args.externalMarkdown);
const evidenceLedger: AuditEvidenceLedgerEntry[] = [];
const companyContext: string[] = [];
const checkedPages: string[] = [];
@@ -620,6 +662,22 @@ export function buildAuditEvidenceInput(
}
addUniqueCapped(checkedPages, label, CHECKED_PAGES_LIMIT);
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId("crawl_page", page.finalUrl ?? page.sourceUrl, page.pageKind),
type: "crawl_page",
label,
...(page.finalUrl ?? page.sourceUrl ? { sourceUrl: page.finalUrl ?? page.sourceUrl ?? undefined } : {}),
summary: sanitizeCustomerText(
[
title ? `Titel: ${title}` : "",
page.metaDescription ? `Meta: ${page.metaDescription}` : "",
page.visibleTextExcerpt ?? page.visibleText ?? "",
]
.filter(Boolean)
.join(" | "),
260,
),
});
}
if (checkedPages.length === 0 && lead.companyName) {
@@ -634,6 +692,44 @@ export function buildAuditEvidenceInput(
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
const pageSpeedCustomerImplications: string[] = [];
for (const check of technicalChecks) {
const summary = [
check.usesHttps === true ? "HTTPS vorhanden" : "",
check.usesHttps === false ? "HTTPS fehlt" : "",
check.missingTitle === true ? "Title fehlt" : "",
check.missingMetaDescription === true ? "Meta-Description fehlt" : "",
check.hasVisibleContactPath === true ? "Kontaktpfad sichtbar" : "",
check.brokenInternalLinkCount !== undefined
? `Interne Linkfehler: ${check.brokenInternalLinkCount}`
: "",
]
.filter(Boolean)
.join(" | ");
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId("technical_check", check.finalUrl ?? check.sourceUrl),
type: "technical_check",
label: `Technik: ${toSafePath(check.finalUrl ?? check.sourceUrl ?? "") || "Seite"}`,
...(check.finalUrl ?? check.sourceUrl ? { sourceUrl: check.finalUrl ?? check.sourceUrl ?? undefined } : {}),
summary: sanitizeCustomerText(summary, 260),
});
}
for (const screenshot of screenshots) {
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId(
"screenshot",
screenshot.storageId,
screenshot.viewport,
screenshot.sourceUrl,
),
type: "screenshot",
label: `${screenshot.viewport === "desktop" ? "Desktop" : "Mobil"} Screenshot`,
sourceUrl: screenshot.sourceUrl,
summary: `${screenshot.viewport} Screenshot ${screenshot.width}x${screenshot.height}`,
});
}
for (const implication of pageSpeedInputsOutput.customerImplications) {
addUniqueCapped(
pageSpeedCustomerImplications,
@@ -643,6 +739,32 @@ export function buildAuditEvidenceInput(
);
}
for (const input of pageSpeedInputs) {
const implication = pageSpeedInputsOutput.customerImplications.find(Boolean);
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId("pagespeed", input.strategy, input.sourceUrl, input.status),
type: "pagespeed",
label: `PageSpeed ${input.strategy}`,
sourceUrl: input.sourceUrl,
summary: sanitizeCustomerText(
implication ??
(input.status === "succeeded"
? "PageSpeed-Messung erfolgreich"
: "PageSpeed-Messung nicht verfügbar"),
260,
),
});
}
if (externalMarkdown) {
addEvidenceLedgerEntry(evidenceLedger, {
id: evidenceId("jina_excerpt", externalMarkdown.slice(0, 80)),
type: "jina_excerpt",
label: "Jina Reader Auszug",
summary: sanitizeCustomerText(externalMarkdown, 260),
});
}
const selectedSkills = extractSkills(skillRegistry, {
...signals.evidenceText,
marketing: false,
@@ -687,5 +809,6 @@ export function buildAuditEvidenceInput(
PAGESPEED_SIGNAL_LIMIT,
),
selectedSkills,
evidenceLedger,
};
}

View File

@@ -0,0 +1,49 @@
export const customerToneGuidelines = {
senderPosture: "kollegial_direkt",
voiceLabel: "kollegial direkt",
email: {
wordCount: {
min: 60,
max: 130,
},
maxSentences: 7,
maxParagraphs: 2,
subject: {
minWords: 2,
maxWords: 6,
maxCharacters: 55,
},
bannedPhrases: [
"Optimierungspotenziale",
"Mehr Sichtbarkeit und bessere Nutzererfahrung",
"Ich habe beobachtet",
"Ich schlage vor",
"Maßnahmen umsetzen",
"Conversion-Rate steigern",
"Ranking positiv beeinflussen",
"Absprungraten senken",
"nachhaltig verbessern",
"signifikant",
],
preferredAskExamples: [
"Soll ich Ihnen die zwei Punkte kurz schicken?",
"Soll ich Ihnen die Stelle kurz als Screenshot schicken?",
"Wäre ein kurzer Hinweis dazu hilfreich?",
],
},
} as const;
export function buildCustomerTonePromptSection() {
return [
"Tonalität für Kunden-E-Mail: kollegial direkt, konkret, ruhig und nicht verkäuferisch.",
"Schreibe wie Matthias als lokaler Web-Profi, nicht wie eine Agentur-Broschüre.",
"Die E-Mail ist eine erste Kontaktaufnahme: maximal zwei verifizierte Befunde, kein Mini-Audit.",
"Betreff: 2-6 Wörter, maximal 55 Zeichen, kein Doppelpunkt, keine Benefit-Kette.",
"E-Mail-Text: 60-130 Wörter, maximal 7 Sätze, 1-2 kurze Absätze.",
"Starte mit einer konkreten Beobachtung zur Website, nicht mit 'Ich habe beobachtet, dass'.",
"Nenne eine praktische Auswirkung in Alltagssprache und ende mit einer weichen Frage.",
"Nutze für unbekannte lokale Betriebe formal Sie/Ihnen.",
"Ich-Form ist erlaubt, aber nicht als Wiederholungsmuster: kein mehrfaches 'Ich habe...' oder 'Ich schlage vor...'.",
`Beispiel für den Abschluss: ${customerToneGuidelines.email.preferredAskExamples[0]}`,
].join("\n");
}

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) {

View File

@@ -18,6 +18,25 @@ export const LOCAL_AUDIT_SKILL_REGISTRY_SOURCE = [
"Lesen auf dem Smartphone\", nicht „sieht altbacken aus\". Kundennutzen: ein moderner,",
"ruhiger Auftritt schafft Vertrauen, bevor der erste Satz gelesen wird.",
"",
"## impeccable-critique",
"",
"```yaml",
"id: impeccable-critique",
"title: Impeccable Critique Review",
"applies_when: website_exists",
"inputs: [desktop_screenshot, mobile_screenshot, markdown, dom]",
"outputs: findings",
"```",
"",
"Bewerte die Seite wie ein strenger Design Director: visuelle Hierarchie,",
"Informationsarchitektur, kognitive Last, Orientierung, Lesbarkeit, Progressive",
"Disclosure und erkennbare AI-Slop-/Template-Muster. Nutze Nielsen-Heuristiken",
"als Denkrahmen, aber gib keine Score-Tabelle aus. Befunde müssen beobachtbar und",
"belegt sein: z. B. „mehrere gleich laute CTAs konkurrieren im sichtbaren Bereich\"",
"statt „Design wirkt beliebig\". Marken- oder Emotionsfit nur nennen, wenn Evidence",
"aus Screenshot, Text oder DOM vorliegt. Kundennutzen: eine klarere, weniger",
"generische Oberfläche senkt Zweifel und führt Besucher schneller zur Anfrage.",
"",
"## first-impression-clarity",
"",
"```yaml",

View File

@@ -20,6 +20,67 @@ export const v3FindingItemSchema = z.object({
export const findingItemSchema = legacyFindingItemSchema;
export const auditFindingEvidenceRefSchema = z.object({
id: nonEmptyTextSchema,
type: z.enum([
"crawl_page",
"technical_check",
"screenshot",
"pagespeed",
"jina_excerpt",
"generation_stage",
]),
label: nonEmptyTextSchema,
sourceUrl: z.string().trim(),
});
export const auditSpecialistFindingSchema = z
.object({
skillId: nonEmptyTextSchema,
claim: nonEmptyTextSchema,
recommendation: nonEmptyTextSchema,
customerBenefit: nonEmptyTextSchema,
severity: z.union([z.literal(1), z.literal(2), z.literal(3)]),
confidence: z.number().min(0).max(1),
evidenceRefs: z.array(auditFindingEvidenceRefSchema).min(1),
applies: z.boolean(),
unknowns: z.array(z.string()),
})
.superRefine((finding, ctx) => {
const combined = [
finding.claim,
finding.recommendation,
finding.customerBenefit,
].join(" ");
if (/\bunbekannt\b|\bunknown\b/i.test(combined)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "unknown-only findings are not valid audit claims",
path: ["claim"],
});
}
});
export const auditSpecialistResultSchema = z.object({
status: z.enum(["success", "partial", "skipped", "failed"]),
findings: z.array(auditSpecialistFindingSchema),
notes: z.array(z.string()),
});
export const auditRejectedFindingSchema = z.object({
findingId: nonEmptyTextSchema,
skillId: nonEmptyTextSchema,
claim: nonEmptyTextSchema,
rejectionReason: nonEmptyTextSchema,
});
export const auditEvidenceVerificationSchema = z.object({
verifiedFindingIds: z.array(nonEmptyTextSchema),
rejectedFindings: z.array(auditRejectedFindingSchema),
contradictions: z.array(z.string()),
notes: z.array(z.string()),
});
export const internalFindingsSchema = z.object({
findings: z.array(findingItemSchema),
summary: z.string(),
@@ -80,6 +141,10 @@ export const qualityReviewSchema = z.object({
export type FindingItem = z.infer<typeof findingItemSchema>;
export type V3FindingItem = z.infer<typeof v3FindingItemSchema>;
export type AuditFindingEvidenceRef = z.infer<typeof auditFindingEvidenceRefSchema>;
export type AuditSpecialistFinding = z.infer<typeof auditSpecialistFindingSchema>;
export type AuditSpecialistResult = z.infer<typeof auditSpecialistResultSchema>;
export type AuditEvidenceVerification = z.infer<typeof auditEvidenceVerificationSchema>;
export type InternalFindings = z.infer<typeof internalFindingsSchema>;
export type AuditClassification = z.infer<typeof auditClassificationSchema>;
export type AuditGenerationResult = z.infer<typeof auditGenerationResultSchema>;