feat: add OpenRouter audit generation pipeline
This commit is contained in:
565
lib/ai/audit-evidence.ts
Normal file
565
lib/ai/audit-evidence.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
import {
|
||||
type SkillRegistryEntry,
|
||||
toAuditUsedSkill,
|
||||
type AuditUsedSkill,
|
||||
} from "../skills-registry";
|
||||
import {
|
||||
buildPageSpeedAuditInputs,
|
||||
type PageSpeedMinimalAuditResult,
|
||||
} from "../pagespeed-audit-input";
|
||||
|
||||
export type SkillRegistryEntryEvidence = SkillRegistryEntry;
|
||||
|
||||
export type AuditLeadEvidence = {
|
||||
companyName?: string | null;
|
||||
niche?: string | null;
|
||||
city?: string | null;
|
||||
websiteDomain?: string | null;
|
||||
websiteUrl?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
contactPerson?: string | null;
|
||||
};
|
||||
|
||||
export type AuditCrawlPageEvidence = {
|
||||
sourceUrl?: string | null;
|
||||
finalUrl?: string | null;
|
||||
title?: string | null;
|
||||
metaDescription?: string | null;
|
||||
pageKind?: string | null;
|
||||
hasContactFormSignal?: boolean;
|
||||
hasContactCtaSignal?: boolean;
|
||||
visibleText?: string | null;
|
||||
visibleTextExcerpt?: string | null;
|
||||
};
|
||||
|
||||
export type AuditTechnicalCheckEvidence = {
|
||||
sourceUrl?: string | null;
|
||||
finalUrl?: string | null;
|
||||
usesHttps?: boolean;
|
||||
missingTitle?: boolean;
|
||||
missingMetaDescription?: boolean;
|
||||
hasVisibleContactPath?: boolean;
|
||||
brokenInternalLinkCount?: number;
|
||||
};
|
||||
|
||||
export type AuditScreenshotEvidence = {
|
||||
storageId: string;
|
||||
viewport: string;
|
||||
sourceUrl: string;
|
||||
capturedAt: number;
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AuditEvidenceInput = {
|
||||
companyContext: string[];
|
||||
checkedPages: string[];
|
||||
observedUxSignals: string[];
|
||||
observedContentSignals: string[];
|
||||
observedTechnicalSignals: string[];
|
||||
screenshotReferences: Array<{
|
||||
storageId: string;
|
||||
sourceUrl: string;
|
||||
viewport: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
capturedAt: number;
|
||||
}>;
|
||||
pageSpeedCustomerImplications: string[];
|
||||
selectedSkills: AuditUsedSkill[];
|
||||
};
|
||||
|
||||
export type AuditEvidenceInputArgs = {
|
||||
lead?: AuditLeadEvidence;
|
||||
crawlPages?: readonly AuditCrawlPageEvidence[];
|
||||
technicalChecks?: readonly AuditTechnicalCheckEvidence[];
|
||||
screenshots?: readonly AuditScreenshotEvidence[];
|
||||
pageSpeedInputs?: readonly PageSpeedMinimalAuditResult[];
|
||||
skillRegistry?: readonly SkillRegistryEntryEvidence[];
|
||||
};
|
||||
|
||||
const COMPANY_CONTEXT_LIMIT = 8;
|
||||
const CHECKED_PAGES_LIMIT = 8;
|
||||
const UX_SIGNAL_LIMIT = 6;
|
||||
const CONTENT_SIGNAL_LIMIT = 6;
|
||||
const TECHNICAL_SIGNAL_LIMIT = 6;
|
||||
const PAGESPEED_SIGNAL_LIMIT = 8;
|
||||
const SCREENSHOT_REFERENCE_LIMIT = 8;
|
||||
const SELECTED_SKILLS_LIMIT = 6;
|
||||
|
||||
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"']+/i;
|
||||
const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/;
|
||||
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 trimAndNormalize(input: unknown): string {
|
||||
if (typeof input !== "string") {
|
||||
return "";
|
||||
}
|
||||
return input.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function sanitizeCustomerText(value: unknown, maxLength = 180): string {
|
||||
let text = trimAndNormalize(value);
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
text = text.replace(/<[^>]*>/g, " ");
|
||||
text = text.replace(/\s{2,}/g, " ").trim();
|
||||
|
||||
if (URL_PATTERN.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (JSON_BRACKET_PATTERN.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (PAGESPEED_NOISE_PATTERN.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (MACHINE_TOKEN_PATTERN.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (text.length > maxLength) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!/[a-zäöüß]/i.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function addUniqueCapped(
|
||||
bucket: string[],
|
||||
input: string,
|
||||
max: number,
|
||||
sanitizer = sanitizeCustomerText,
|
||||
): void {
|
||||
const candidate = sanitizer(input);
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = candidate.toLowerCase();
|
||||
const alreadyThere = bucket.some((line) => line.toLowerCase() === normalized);
|
||||
if (!alreadyThere && bucket.length < max) {
|
||||
bucket.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function compactPath(urlLike: string): string {
|
||||
try {
|
||||
const parsed = new URL(urlLike);
|
||||
const normalizedPath = (parsed.pathname || "/").replace(/\/+/g, "/").trim();
|
||||
if (!normalizedPath || normalizedPath === "/") {
|
||||
return "Startseite";
|
||||
}
|
||||
return normalizedPath.replace(/^\//, "").slice(0, 70);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function compactLabelForPage(pageKind: string, pageLabel: string): string {
|
||||
if (pageLabel.length > 100) {
|
||||
return pageLabel.slice(0, 100);
|
||||
}
|
||||
|
||||
if (pageKind) {
|
||||
return `${pageKind}: ${pageLabel}`;
|
||||
}
|
||||
|
||||
return pageLabel;
|
||||
}
|
||||
|
||||
function toSafePath(url: string | null | undefined): string {
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return compactPath(url);
|
||||
}
|
||||
|
||||
function selectTopSkill(
|
||||
skills: readonly SkillRegistryEntryEvidence[],
|
||||
category: string,
|
||||
evidenceText: string,
|
||||
): AuditUsedSkill | null {
|
||||
const evidenceTokens = evidenceText
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 3);
|
||||
if (evidenceTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = skills.filter((skill) => skill.category === category);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scored = candidates.map((candidate) => {
|
||||
const whenToUseText = candidate.whenToUse.toLowerCase();
|
||||
const matchCount = evidenceTokens.filter((token) =>
|
||||
whenToUseText.includes(token),
|
||||
).length;
|
||||
const score = 1 + Math.min(matchCount, 5) + (candidate.version ? 0.1 : 0);
|
||||
|
||||
return {
|
||||
candidate,
|
||||
score,
|
||||
name: candidate.name.toLowerCase(),
|
||||
};
|
||||
});
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return toAuditUsedSkill(scored[0]!.candidate);
|
||||
}
|
||||
|
||||
function buildObservedSignals(
|
||||
crawlPages: readonly AuditCrawlPageEvidence[],
|
||||
technicalChecks: readonly AuditTechnicalCheckEvidence[],
|
||||
): {
|
||||
ux: string[];
|
||||
content: string[];
|
||||
technical: string[];
|
||||
evidenceText: {
|
||||
design: boolean;
|
||||
ux: boolean;
|
||||
copy: boolean;
|
||||
seo: boolean;
|
||||
};
|
||||
} {
|
||||
const uxSignals: string[] = [];
|
||||
const contentSignals: string[] = [];
|
||||
const technicalSignals: string[] = [];
|
||||
|
||||
let designEvidence = false;
|
||||
let uxEvidence = false;
|
||||
let copyEvidence = false;
|
||||
let seoEvidence = false;
|
||||
|
||||
for (const page of crawlPages) {
|
||||
const title = trimAndNormalize(page.title ?? "");
|
||||
if (title) {
|
||||
if (title.length > 4) {
|
||||
copyEvidence = true;
|
||||
addUniqueCapped(
|
||||
contentSignals,
|
||||
`Seitentitel wurde erfasst: ${title}`,
|
||||
CONTENT_SIGNAL_LIMIT,
|
||||
(value) => sanitizeCustomerText(value, 150),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (page.hasContactFormSignal) {
|
||||
uxEvidence = true;
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Ein Kontaktformular wurde als potenzieller Einstiegspunkt erkannt.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (page.hasContactCtaSignal) {
|
||||
uxEvidence = true;
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Ein klarer Call-to-Action scheint auf der Seite aktiv zu sein.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (page.visibleText || page.visibleTextExcerpt) {
|
||||
copyEvidence = true;
|
||||
addUniqueCapped(
|
||||
contentSignals,
|
||||
"Sichtbarer Text wurde in der Crawl-Auswertung extrahiert.",
|
||||
CONTENT_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const check of technicalChecks) {
|
||||
if (check.usesHttps === false) {
|
||||
uxEvidence = true;
|
||||
addUniqueCapped(
|
||||
technicalSignals,
|
||||
"Ein Teil der Seiten ist nicht per HTTPS erreichbar.",
|
||||
TECHNICAL_SIGNAL_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Die sichere Übertragung der Seite ist nicht durchgängig verifiziert.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (check.missingMetaDescription) {
|
||||
seoEvidence = true;
|
||||
addUniqueCapped(
|
||||
technicalSignals,
|
||||
"Fehlende Meta-Beschreibungen können die Auffindbarkeit schwächen.",
|
||||
TECHNICAL_SIGNAL_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
contentSignals,
|
||||
"Meta-Informationen sind teilweise nicht vollständig vorhanden.",
|
||||
CONTENT_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (check.missingTitle) {
|
||||
seoEvidence = true;
|
||||
addUniqueCapped(
|
||||
technicalSignals,
|
||||
"Einige Seiten besitzen keinen aussagekräftigen Titel.",
|
||||
TECHNICAL_SIGNAL_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
contentSignals,
|
||||
"Seitentitel fehlen auf ausgewählten Seiten.",
|
||||
CONTENT_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (check.hasVisibleContactPath) {
|
||||
uxEvidence = true;
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Ein klarer Kontaktpfad scheint bereits vorhanden zu sein.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
const brokenLinks = check.brokenInternalLinkCount ?? 0;
|
||||
if (brokenLinks > 0) {
|
||||
addUniqueCapped(
|
||||
technicalSignals,
|
||||
`Es wurden ${Math.min(brokenLinks, 10)} interne Verlinkungen mit Fehlerstatus erkannt.`,
|
||||
TECHNICAL_SIGNAL_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Nutzer könnten durch interne Linkfehler im Fluss abbrechen.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (crawlPages.length > 0 || technicalChecks.length > 0) {
|
||||
designEvidence = true;
|
||||
}
|
||||
if (
|
||||
crawlPages.some(
|
||||
(page) =>
|
||||
page.pageKind === "contact" ||
|
||||
page.pageKind === "impressum" ||
|
||||
page.pageKind === "services",
|
||||
)
|
||||
) {
|
||||
seoEvidence = true;
|
||||
uxEvidence = true;
|
||||
}
|
||||
|
||||
return {
|
||||
ux: uxSignals,
|
||||
content: contentSignals,
|
||||
technical: technicalSignals,
|
||||
evidenceText: {
|
||||
design: designEvidence,
|
||||
ux: uxEvidence,
|
||||
copy: copyEvidence,
|
||||
seo: seoEvidence,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractSkills(
|
||||
skillRegistry: readonly SkillRegistryEntryEvidence[],
|
||||
evidence: {
|
||||
design: boolean;
|
||||
ux: boolean;
|
||||
copy: boolean;
|
||||
seo: boolean;
|
||||
marketing: boolean;
|
||||
offer: boolean;
|
||||
},
|
||||
): AuditUsedSkill[] {
|
||||
const selected: AuditUsedSkill[] = [];
|
||||
const categoryOrder = ["design", "ux", "copy", "seo", "marketing", "offer"] as const;
|
||||
const evidenceText = {
|
||||
design:
|
||||
"visuale layout seite struktur design hierarchie conversion",
|
||||
ux:
|
||||
"kontakt formular cta nutzer flow conversion pfad",
|
||||
copy:
|
||||
"text klarheit copy headline ton local",
|
||||
seo: "local auffindbarkeit meta seo impressum kontakt",
|
||||
marketing: "positionierung unterscheidung angebot",
|
||||
offer: "angebot text preis rahmen",
|
||||
};
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
if (!evidence[category]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = selectTopSkill(
|
||||
skillRegistry,
|
||||
category,
|
||||
evidenceText[category]!,
|
||||
);
|
||||
|
||||
if (match) {
|
||||
selected.push(match);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length > SELECTED_SKILLS_LIMIT) {
|
||||
selected.length = SELECTED_SKILLS_LIMIT;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function buildAuditEvidenceInput(
|
||||
args: AuditEvidenceInputArgs,
|
||||
): AuditEvidenceInput {
|
||||
const lead = args.lead ?? {};
|
||||
const crawlPages = args.crawlPages ?? [];
|
||||
const technicalChecks = args.technicalChecks ?? [];
|
||||
const screenshots = args.screenshots ?? [];
|
||||
const pageSpeedInputs = args.pageSpeedInputs ?? [];
|
||||
const skillRegistry = args.skillRegistry ?? [];
|
||||
|
||||
const companyContext: string[] = [];
|
||||
const checkedPages: string[] = [];
|
||||
const screenshotReferences = screenshots
|
||||
.slice(0, SCREENSHOT_REFERENCE_LIMIT)
|
||||
.map((screenshot) => ({
|
||||
storageId: screenshot.storageId,
|
||||
sourceUrl: screenshot.sourceUrl,
|
||||
viewport: screenshot.viewport,
|
||||
width: screenshot.width,
|
||||
height: screenshot.height,
|
||||
mimeType: screenshot.mimeType,
|
||||
capturedAt: screenshot.capturedAt,
|
||||
}));
|
||||
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Firma: ${lead.companyName ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(companyContext, `Sparte: ${lead.niche ?? ""}`, COMPANY_CONTEXT_LIMIT);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Ort: ${lead.city ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Adresse: ${lead.address ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Domain: ${lead.websiteDomain ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Kontaktperson: ${lead.contactPerson ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Telefon: ${lead.phone ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Website: ${lead.websiteUrl ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
|
||||
for (const page of crawlPages) {
|
||||
const safePath = toSafePath(page.finalUrl ?? page.sourceUrl ?? "");
|
||||
const title = sanitizeCustomerText(page.title ?? "", 90);
|
||||
|
||||
const label = compactLabelForPage(
|
||||
page.pageKind ?? "Seite",
|
||||
title || safePath,
|
||||
);
|
||||
if (!label || label === page.pageKind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
addUniqueCapped(checkedPages, label, CHECKED_PAGES_LIMIT);
|
||||
}
|
||||
|
||||
if (checkedPages.length === 0 && lead.companyName) {
|
||||
addUniqueCapped(
|
||||
checkedPages,
|
||||
`Website-Startseite analysiert: ${lead.companyName}`,
|
||||
CHECKED_PAGES_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
const signals = buildObservedSignals(crawlPages, technicalChecks);
|
||||
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
|
||||
const pageSpeedCustomerImplications: string[] = [];
|
||||
|
||||
for (const implication of pageSpeedInputsOutput.customerImplications) {
|
||||
addUniqueCapped(
|
||||
pageSpeedCustomerImplications,
|
||||
implication,
|
||||
PAGESPEED_SIGNAL_LIMIT,
|
||||
sanitizeCustomerText,
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSkills = extractSkills(skillRegistry, {
|
||||
...signals.evidenceText,
|
||||
marketing: false,
|
||||
offer: false,
|
||||
});
|
||||
|
||||
return {
|
||||
companyContext,
|
||||
checkedPages,
|
||||
observedUxSignals: signals.ux,
|
||||
observedContentSignals: signals.content,
|
||||
observedTechnicalSignals: signals.technical,
|
||||
screenshotReferences: screenshotReferences.map((reference) => ({
|
||||
...reference,
|
||||
width: Math.max(reference.width, 0),
|
||||
height: Math.max(reference.height, 0),
|
||||
capturedAt: Number(reference.capturedAt),
|
||||
})),
|
||||
pageSpeedCustomerImplications: pageSpeedCustomerImplications.slice(
|
||||
0,
|
||||
PAGESPEED_SIGNAL_LIMIT,
|
||||
),
|
||||
selectedSkills,
|
||||
};
|
||||
}
|
||||
482
lib/ai/german-copy-guard.ts
Normal file
482
lib/ai/german-copy-guard.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
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",
|
||||
]);
|
||||
|
||||
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|feststell|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(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,
|
||||
];
|
||||
|
||||
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 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 });
|
||||
validateTextField(issues, "emailBody", email.body, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
|
||||
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: true,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.emailBody !== undefined) {
|
||||
validateTextField(issues, "emailBody", input.emailBody, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
81
lib/ai/model-profiles.ts
Normal file
81
lib/ai/model-profiles.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export const MODEL_PROFILE_KEYS = [
|
||||
"classification",
|
||||
"multimodalAudit",
|
||||
"germanCopy",
|
||||
"qualityReview",
|
||||
] as const;
|
||||
|
||||
export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number];
|
||||
|
||||
export type AiModelProfile = {
|
||||
modelId: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
supportsImages: boolean;
|
||||
stage: (typeof MODEL_PROFILE_KEYS)[number];
|
||||
envOverrideKey: string;
|
||||
};
|
||||
|
||||
export const MODEL_PROFILES: Record<ModelProfileKey, AiModelProfile> = {
|
||||
classification: {
|
||||
modelId: "openai/gpt-4.1-mini",
|
||||
temperature: 0.2,
|
||||
maxTokens: 1200,
|
||||
supportsImages: false,
|
||||
stage: "classification",
|
||||
envOverrideKey: "OPENROUTER_MODEL_CLASSIFICATION",
|
||||
},
|
||||
multimodalAudit: {
|
||||
modelId: "openai/gpt-4.1-mini",
|
||||
temperature: 0.3,
|
||||
maxTokens: 2800,
|
||||
supportsImages: true,
|
||||
stage: "multimodalAudit",
|
||||
envOverrideKey: "OPENROUTER_MODEL_MULTIMODAL_AUDIT",
|
||||
},
|
||||
germanCopy: {
|
||||
modelId: "openai/gpt-4.1-mini",
|
||||
temperature: 0.4,
|
||||
maxTokens: 1800,
|
||||
supportsImages: false,
|
||||
stage: "germanCopy",
|
||||
envOverrideKey: "OPENROUTER_MODEL_GERMAN_COPY",
|
||||
},
|
||||
qualityReview: {
|
||||
modelId: "openai/gpt-4.1-mini",
|
||||
temperature: 0.1,
|
||||
maxTokens: 900,
|
||||
supportsImages: false,
|
||||
stage: "qualityReview",
|
||||
envOverrideKey: "OPENROUTER_MODEL_QUALITY_REVIEW",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function normalizeModelOverride(value: string | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
export function resolveModelProfile(
|
||||
profileKey: ModelProfileKey,
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): AiModelProfile {
|
||||
const profile = MODEL_PROFILES[profileKey];
|
||||
const override = normalizeModelOverride(env[profile.envOverrideKey]);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
modelId: override ?? profile.modelId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModelId(
|
||||
profileKey: ModelProfileKey,
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): string {
|
||||
const profile = MODEL_PROFILES[profileKey];
|
||||
const override = normalizeModelOverride(env[profile.envOverrideKey]);
|
||||
|
||||
return override ?? profile.modelId;
|
||||
}
|
||||
35
lib/ai/openrouter-provider.ts
Normal file
35
lib/ai/openrouter-provider.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
|
||||
type OpenRouterEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
function normalizeOptionalEnvValue(value: string | undefined): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
export function createOpenRouterProvider(
|
||||
env: OpenRouterEnv = process.env,
|
||||
): ReturnType<typeof createOpenRouter> {
|
||||
const apiKey = normalizeOptionalEnvValue(env.OPENROUTER_API_KEY);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is required for OpenRouter provider.");
|
||||
}
|
||||
|
||||
const appName = normalizeOptionalEnvValue(env.OPENROUTER_APP_NAME);
|
||||
const appUrl = normalizeOptionalEnvValue(env.OPENROUTER_APP_URL);
|
||||
|
||||
return createOpenRouter({
|
||||
apiKey,
|
||||
appName,
|
||||
appUrl,
|
||||
compatibility: "strict",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
58
lib/ai/schemas.ts
Normal file
58
lib/ai/schemas.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const findingItemSchema = z.object({
|
||||
section: z.string(),
|
||||
finding: z.string(),
|
||||
suggestion: z.string(),
|
||||
});
|
||||
|
||||
export const internalFindingsSchema = z.object({
|
||||
findings: z.array(findingItemSchema),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export const auditSummarySchema = z.object({
|
||||
summary: z.string(),
|
||||
keyFindings: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const publicAuditTextSchema = z.object({
|
||||
publicText: z.string(),
|
||||
});
|
||||
|
||||
export const emailDraftSchema = z.object({
|
||||
body: z.string(),
|
||||
});
|
||||
|
||||
export const emailSubjectSchema = z.object({
|
||||
subject: z.string(),
|
||||
});
|
||||
|
||||
export const callScriptSchema = z.object({
|
||||
openingLine: z.string(),
|
||||
callScript: z.array(z.string()),
|
||||
closeLine: z.string(),
|
||||
});
|
||||
|
||||
export const followUpDraftSchema = z.object({
|
||||
message: z.string(),
|
||||
followInDays: z.number().int().min(0).optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const qualityReviewSchema = z.object({
|
||||
isValid: z.boolean(),
|
||||
issues: z.array(z.string()),
|
||||
suggestions: z.array(z.string()),
|
||||
notes: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type FindingItem = z.infer<typeof findingItemSchema>;
|
||||
export type InternalFindings = z.infer<typeof internalFindingsSchema>;
|
||||
export type AuditSummary = z.infer<typeof auditSummarySchema>;
|
||||
export type PublicAuditText = z.infer<typeof publicAuditTextSchema>;
|
||||
export type EmailDraft = z.infer<typeof emailDraftSchema>;
|
||||
export type EmailSubject = z.infer<typeof emailSubjectSchema>;
|
||||
export type CallScript = z.infer<typeof callScriptSchema>;
|
||||
export type FollowUpDraft = z.infer<typeof followUpDraftSchema>;
|
||||
export type QualityReview = z.infer<typeof qualityReviewSchema>;
|
||||
Reference in New Issue
Block a user