Externalize audit pipeline services

This commit is contained in:
2026-06-07 23:06:31 +02:00
parent 470fb0f348
commit a45b92ea0a
42 changed files with 3141 additions and 247 deletions

View File

@@ -60,6 +60,7 @@ export type AuditEvidenceInput = {
observedUxSignals: string[];
observedContentSignals: string[];
observedTechnicalSignals: string[];
externalMarkdown?: string;
screenshotReferences: Array<{
storageId: string;
sourceUrl: string;
@@ -80,6 +81,7 @@ export type AuditEvidenceInputArgs = {
screenshots?: readonly AuditScreenshotEvidence[];
pageSpeedInputs?: readonly PageSpeedMinimalAuditResult[];
skillRegistry?: readonly SkillRegistryEntryEvidence[];
externalMarkdown?: string;
};
const COMPANY_CONTEXT_LIMIT = 8;
@@ -90,6 +92,20 @@ const TECHNICAL_SIGNAL_LIMIT = 6;
const PAGESPEED_SIGNAL_LIMIT = 8;
const SCREENSHOT_REFERENCE_LIMIT = 8;
const SELECTED_SKILLS_LIMIT = 6;
const EXTERNAL_MARKDOWN_LIMIT = 4_000;
const V3_LOCAL_AUDIT_PRIORITY = new Map(
[
"visual-design",
"contact-conversion",
"local-seo-basics",
"performance-experience",
"mobile-usability",
"conversion-copy",
"first-impression-clarity",
"trust-signals",
"accessibility-basics",
].map((id, index) => [id, index] as const),
);
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"']+/i;
const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/;
@@ -140,6 +156,19 @@ function sanitizeCustomerText(value: unknown, maxLength = 180): string {
return text;
}
function sanitizeExternalMarkdown(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const markdown = value.replace(/\s+/g, " ").trim();
if (!markdown) {
return undefined;
}
return markdown.slice(0, EXTERNAL_MARKDOWN_LIMIT);
}
function addUniqueCapped(
bucket: string[],
input: string,
@@ -233,6 +262,77 @@ function selectTopSkill(
return toAuditUsedSkill(scored[0]!.candidate);
}
type SkillInputAvailability = {
websiteExists: boolean;
hasDesktopScreenshot: boolean;
hasMobileScreenshot: boolean;
hasMarkdown: boolean;
hasPageSpeed: boolean;
hasDom: boolean;
};
function hasRequiredV3Input(input: string, availability: SkillInputAvailability) {
switch (input) {
case "desktop_screenshot":
return availability.hasDesktopScreenshot;
case "mobile_screenshot":
return availability.hasMobileScreenshot;
case "markdown":
return availability.hasMarkdown;
case "pagespeed":
return availability.hasPageSpeed;
case "dom":
return availability.hasDom;
default:
return false;
}
}
function v3SkillApplies(
skill: SkillRegistryEntryEvidence,
availability: SkillInputAvailability,
) {
const appliesWhen = skill.appliesWhen ?? "website_exists";
const applies =
appliesWhen === "always" ||
(appliesWhen === "website_exists" && availability.websiteExists) ||
(appliesWhen === "has_mobile_screenshot" &&
availability.hasMobileScreenshot) ||
(appliesWhen === "has_pagespeed" && availability.hasPageSpeed);
if (!applies) {
return false;
}
return (skill.inputs ?? []).every((input) =>
hasRequiredV3Input(input, availability),
);
}
function selectV3Skills(
skillRegistry: readonly SkillRegistryEntryEvidence[],
availability: SkillInputAvailability,
) {
return skillRegistry
.map((skill, registryIndex) => ({ skill, registryIndex }))
.filter(({ skill }) => skill.id && !skill.category)
.filter(({ skill }) => v3SkillApplies(skill, availability))
.sort((a, b) => {
// Keep core local-audit coverage inside the cap; otherwise preserve registry order.
const aPriority = V3_LOCAL_AUDIT_PRIORITY.get(a.skill.id ?? "");
const bPriority = V3_LOCAL_AUDIT_PRIORITY.get(b.skill.id ?? "");
if (aPriority !== undefined || bPriority !== undefined) {
return (
(aPriority ?? Number.POSITIVE_INFINITY) -
(bPriority ?? Number.POSITIVE_INFINITY)
);
}
return a.registryIndex - b.registryIndex;
})
.slice(0, SELECTED_SKILLS_LIMIT)
.map(({ skill }) => toAuditUsedSkill(skill));
}
function buildObservedSignals(
crawlPages: readonly AuditCrawlPageEvidence[],
technicalChecks: readonly AuditTechnicalCheckEvidence[],
@@ -403,8 +503,12 @@ function extractSkills(
marketing: boolean;
offer: boolean;
},
availability: SkillInputAvailability,
): AuditUsedSkill[] {
const selected: AuditUsedSkill[] = [];
const selected: AuditUsedSkill[] = selectV3Skills(
skillRegistry,
availability,
);
const categoryOrder = ["design", "ux", "copy", "seo", "marketing", "offer"] as const;
const evidenceText = {
design:
@@ -450,6 +554,7 @@ export function buildAuditEvidenceInput(
const screenshots = args.screenshots ?? [];
const pageSpeedInputs = args.pageSpeedInputs ?? [];
const skillRegistry = args.skillRegistry ?? [];
const externalMarkdown = sanitizeExternalMarkdown(args.externalMarkdown);
const companyContext: string[] = [];
const checkedPages: string[] = [];
@@ -542,6 +647,26 @@ export function buildAuditEvidenceInput(
...signals.evidenceText,
marketing: false,
offer: false,
}, {
websiteExists:
Boolean(lead.websiteDomain || lead.websiteUrl) ||
crawlPages.length > 0 ||
screenshots.length > 0,
hasDesktopScreenshot: screenshots.some(
(screenshot) => screenshot.viewport === "desktop",
),
hasMobileScreenshot: screenshots.some(
(screenshot) => screenshot.viewport === "mobile",
),
hasMarkdown:
Boolean(externalMarkdown) ||
crawlPages.some((page) =>
Boolean(page.visibleText || page.visibleTextExcerpt),
),
hasPageSpeed:
pageSpeedInputsOutput.customerImplications.length > 0 ||
pageSpeedInputs.some((input) => input.status === "succeeded"),
hasDom: crawlPages.length > 0 || technicalChecks.length > 0,
});
return {
@@ -550,6 +675,7 @@ export function buildAuditEvidenceInput(
observedUxSignals: signals.ux,
observedContentSignals: signals.content,
observedTechnicalSignals: signals.technical,
...(externalMarkdown ? { externalMarkdown } : {}),
screenshotReferences: screenshotReferences.map((reference) => ({
...reference,
width: Math.max(reference.width, 0),