Improve audit pipeline and outreach review
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
49
lib/ai/customer-tone-guidelines.ts
Normal file
49
lib/ai/customer-tone-guidelines.ts
Normal 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");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user