Files
webdev-pipeline/convex/auditGenerationAction.ts

1874 lines
58 KiB
TypeScript

"use node";
import { type DataContent, generateObject } from "ai";
import { createOpenRouterProvider } from "../lib/ai/openrouter-provider";
import { resolveModelProfile } from "../lib/ai/model-profiles";
import { loadLocalAuditSkillRegistry } from "../lib/ai/local-audit-skill-registry";
import {
auditClassificationSchema,
auditSummarySchema,
callScriptSchema,
emailDraftSchema,
emailSubjectSchema,
followUpDraftSchema,
publicAuditTextSchema,
qualityReviewSchema,
} from "../lib/ai/schemas";
import {
validateCustomerFacingCopy,
type GermanCopyGuardResult,
} from "../lib/ai/german-copy-guard";
import { buildAuditEvidenceInput } from "../lib/ai/audit-evidence";
import {
buildJinaReaderAuditInput,
buildScreenshotOneRequests,
estimateExternalAuditCostUsd,
type JinaReaderPageInput,
type ScreenshotOneRequest,
} from "../lib/external-audit-services";
import { type AuditUsedSkill } from "../lib/skills-registry";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import {
internalAction,
type ActionCtx,
} from "./_generated/server";
import { v } from "convex/values";
const MAX_PROMPT_BYTES = 12_000;
const MAX_RAW_RESPONSE_BYTES = 12_000;
const MAX_PARSED_JSON_BYTES = 12_000;
const EXTERNAL_CAPTURE_TIMEOUT_MS = 12_000;
const MAX_SCREENSHOT_BYTES = 6_000_000;
const MAX_JINA_MARKDOWN_BYTES = 256_000;
const MAX_JINA_MARKDOWN_CHARS = 4_000;
const TRUNCATION_MARKER = "\n\n[... abgeschnitten ...]";
function byteLength(value: string) {
return new TextEncoder().encode(value).byteLength;
}
function truncateToByteLimit(value: string, maxBytes: number) {
if (maxBytes <= 0) {
return "";
}
let usedBytes = 0;
let endIndex = 0;
for (const char of value) {
const charBytes = byteLength(char);
if (usedBytes + charBytes > maxBytes) {
break;
}
usedBytes += charBytes;
endIndex += char.length;
}
return value.slice(0, endIndex);
}
function truncateWithMarker(value: string, maxBytes: number) {
if (byteLength(value) <= maxBytes) {
return value;
}
const markerBytes = byteLength(TRUNCATION_MARKER);
if (markerBytes >= maxBytes) {
const markerBytesBuffer = new TextEncoder().encode(TRUNCATION_MARKER);
return new TextDecoder().decode(markerBytesBuffer.slice(0, maxBytes));
}
const trimmed = truncateToByteLimit(value, Math.max(0, maxBytes - markerBytes));
return `${trimmed}${TRUNCATION_MARKER}`;
}
function sanitizeAndCapString(value: string | undefined, maxBytes: number) {
if (!value) {
return undefined;
}
const safe = sanitizeSecretCandidates(value);
return byteLength(safe) > maxBytes ? truncateWithMarker(safe, maxBytes) : safe;
}
const secretHints = [
"OPENROUTER_API_KEY",
"GOOGLE_PLACES_API_KEY",
"GOOGLE_GEOCODING_API_KEY",
"PAGESPEED_API_KEY",
"SMTP_PASSWORD",
"SMTP_HOST",
"SMTP_USER",
"BETTER_AUTH_SECRET",
"RYBBIT_API_KEY",
"SCREENSHOTONE_API_KEY",
"JINA_API_KEY",
];
function sanitizeSecretCandidates(value: string) {
let safe = value;
for (const key of secretHints) {
const secret = process.env[key];
if (!secret) {
continue;
}
safe = safe.replace(new RegExp(escapeRegExp(secret), "g"), "[REDACTED]");
}
return safe
.replace(/\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*[^\s\"']+/gi, "[REDACTED]")
.trim();
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function messageFromError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return sanitizeSecretCandidates(message);
}
function sanitizeAndCapParsedJson(parsedJson: unknown): string | undefined {
if (parsedJson === undefined) {
return undefined;
}
if (typeof parsedJson === "string") {
return sanitizeAndCapString(parsedJson, MAX_PARSED_JSON_BYTES);
}
const serialized = safeStringify(parsedJson);
const safeSerialized = sanitizeAndCapString(serialized, MAX_PARSED_JSON_BYTES);
return safeSerialized;
}
function safeStringify(value: unknown) {
try {
return JSON.stringify(value);
} catch {
return "[unserializable payload]";
}
}
function toPersistedUsage(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
cacheReadTokens?: number;
}) {
return {
...(usage.inputTokens !== undefined
? { promptTokens: usage.inputTokens }
: {}),
...(usage.outputTokens !== undefined
? { completionTokens: usage.outputTokens }
: {}),
...(usage.totalTokens !== undefined ? { totalTokens: usage.totalTokens } : {}),
...(usage.cacheReadTokens !== undefined
? { cacheReadTokens: usage.cacheReadTokens }
: {}),
};
}
type StageUsageInput = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
inputTokenDetails?: { cacheReadTokens?: number };
};
type StageUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
cacheReadTokens?: number;
};
function toStageUsage(usage: StageUsageInput | undefined): StageUsage | undefined {
if (!usage) {
return undefined;
}
const stageUsage = {
...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}),
...(usage.outputTokens !== undefined
? { outputTokens: usage.outputTokens }
: {}),
...(usage.totalTokens !== undefined ? { totalTokens: usage.totalTokens } : {}),
...(usage.inputTokenDetails?.cacheReadTokens !== undefined
? { cacheReadTokens: usage.inputTokenDetails.cacheReadTokens }
: {}),
};
return Object.keys(stageUsage).length > 0 ? stageUsage : undefined;
}
function withStageUsage(
usage: StageUsageInput | undefined,
): { usage?: StageUsage } {
const stageUsage = toStageUsage(usage);
return stageUsage ? { usage: stageUsage } : {};
}
function toOpenRouterUsageTokens(usage: OpenRouterUsage) {
return {
...(usage.inputTokens !== undefined
? { inputTokens: usage.inputTokens, promptTokens: usage.inputTokens }
: {}),
...(usage.outputTokens !== undefined
? {
outputTokens: usage.outputTokens,
completionTokens: usage.outputTokens,
}
: {}),
...(usage.totalTokens !== undefined ? { totalTokens: usage.totalTokens } : {}),
...(usage.inputTokenDetails?.cacheReadTokens !== undefined
? { cacheReadTokens: usage.inputTokenDetails.cacheReadTokens }
: {}),
};
}
function toDefinedUsageTokens(tokens: {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cacheReadTokens?: number;
}) {
return {
...(tokens.inputTokens !== undefined ? { inputTokens: tokens.inputTokens } : {}),
...(tokens.outputTokens !== undefined
? { outputTokens: tokens.outputTokens }
: {}),
...(tokens.promptTokens !== undefined
? { promptTokens: tokens.promptTokens }
: {}),
...(tokens.completionTokens !== undefined
? { completionTokens: tokens.completionTokens }
: {}),
...(tokens.totalTokens !== undefined ? { totalTokens: tokens.totalTokens } : {}),
...(tokens.cacheReadTokens !== undefined
? { cacheReadTokens: tokens.cacheReadTokens }
: {}),
};
}
type AuditEvidence = Awaited<
ReturnType<typeof buildAuditEvidenceInput>
>;
type GermanCopyOutput = {
internalSummary: string;
publicSummary: string;
publicBody: string;
emailSubject: string;
emailBody: string;
phoneScript: {
openingLine: string;
callScript: string[];
closeLine: string;
};
followUpDraft: {
message: string;
followInDays?: number;
goals?: string[];
};
};
type MultimodalContentPart =
| {
type: "text";
text: string;
}
| MultimodalFilePart;
type MultimodalFilePart = {
type: "file";
data: DataContent | URL;
mediaType: string;
filename?: string;
};
type MultimodalUserMessage = {
role: "user";
content: MultimodalContentPart[];
};
type OpenRouterUsage = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
inputTokenDetails?: { cacheReadTokens?: number };
};
type ExternalCaptureFetch = {
response: Response;
abortController: AbortController;
timeout: ReturnType<typeof setTimeout>;
};
const evidenceRunOptions = {
maxCheckedPages: 8,
screenshotLimit: 2,
maxExternalMarkdownChars: MAX_JINA_MARKDOWN_CHARS,
} as const;
const terminalLeadContactStatuses = [
"do_not_contact",
"contacted",
"replied",
] as const;
function toAuditGenerationProfileMessage(stage: string, runId: Id<"agentRuns">) {
return {
level: "info" as const,
runId,
message: `Audit-KI-Stufe ${stage} gestartet.`,
details: [{ label: "Run-ID", value: String(runId) }],
};
}
function isTerminalLeadContactStatus(status?: string) {
return status
? terminalLeadContactStatuses.includes(
status as (typeof terminalLeadContactStatuses)[number],
)
: false;
}
function buildClassificationPrompt(evidence: AuditEvidence) {
return [
"Du bist Senior-Analyst und erzeugst intern verwertbare Befunde in Deutsch.",
`Unternehmenskontext: ${evidence.companyContext.join(" | ")}`,
`Prüfseiten: ${evidence.checkedPages.join(" ; ")}`,
`UX-Signale: ${evidence.observedUxSignals.join(" ; ")}`,
`Content-Signale: ${evidence.observedContentSignals.join(" ; ")}`,
`Technische Signale: ${evidence.observedTechnicalSignals.join(" ; ")}`,
`Seitenperformance: ${evidence.pageSpeedCustomerImplications.join(" ; ")}`,
evidence.externalMarkdown
? `Externe Jina-Reader-Auszüge: ${evidence.externalMarkdown}`
: "",
"Antworte ausschließlich als JSON-Objekt mit den Schlüsseln:",
"'findings' als Liste v3-validierter Befunde, 'summary' als kurzer Gesamttext und 'usedSkills' als Liste der verwendeten Skill-IDs oder null.",
"Jeder Befund braucht skill_id, observation, customer_benefit, public_phrasing, severity (1-3), evidence und applies.",
].join("\n");
}
function buildMultimodalPrompt(evidence: AuditEvidence, withScreenshots = false) {
return [
"Du bist Senior-Digitalberater für lokale Unternehmen und analysierst visuelle und textuelle Hinweise.",
`Unternehmenskontext: ${evidence.companyContext.join(" | ")}`,
`Untersuchte Seiten: ${evidence.checkedPages.join(" ; ")}`,
`UX-Signale: ${evidence.observedUxSignals.join(" ; ")}`,
`Content-Signale: ${evidence.observedContentSignals.join(" ; ")}`,
`Technische Signale: ${evidence.observedTechnicalSignals.join(" ; ")}`,
`PageSpeed-Folgen: ${evidence.pageSpeedCustomerImplications.join(" ; ")}`,
evidence.externalMarkdown
? `Externe Jina-Reader-Auszüge: ${evidence.externalMarkdown}`
: "",
withScreenshots
? "Bewerte, wo sinnvoll, sichtbare Seitelemente aus den Screenshots."
: "Nutze die textuellen Befunde, um eine kurze visuelle Bewertung abzuleiten.",
"Antworte als kurzes, intern nutzbares JSON-Objekt mit 'summary' und 3-6 'keyFindings'.",
].join("\n");
}
function buildGermanCopyPrompt(
internalFindings: string,
multimodalSummary: string,
) {
return [
"Du bist Senior-Redakteur für lokale Kundengewinnung.",
"Erstelle kundenrelevante Texte in deutscher Sprache, im Ich-Ich Kontext,",
"mit Beobachtung und konkretem Vorschlag in jedem Stück.",
`Interne Befunde: ${internalFindings}`,
`Multimodale Zusammenfassung: ${multimodalSummary}`,
"Liefer bitte alle Felder als validiertes JSON gemäß Schema.",
].join("\n");
}
function buildQualityReviewPrompt(
internalFindings: string,
germanCopy: GermanCopyOutput,
) {
return [
"Du bist Qualitätssicherungs-Engine für Kundenkommunikation.",
"Prüfe Inhalte auf deutsche Sprache, Tonalität, Beobachtung/Suggestion und klare, faktennahe Inhalte.",
`Interne Befunde: ${internalFindings}`,
`Öffentliche Zusammenfassung: ${germanCopy.publicSummary}`,
`Öffentlicher Text: ${germanCopy.publicBody}`,
`Email-Betreff: ${germanCopy.emailSubject}`,
`Email-Text: ${germanCopy.emailBody}`,
"Antworte als JSON mit isValid, issues, suggestions, notes.",
].join("\n");
}
function toSkillSummaries(
skills: Array<{
id?: string;
name: string;
category?: string;
version?: string;
source?: string;
}>,
) {
return skills.slice(0, 6).map((skill) => ({
name: skill.name,
purpose: "Erkenntnisbasiertes Hilfsmodul für die Audit-Bearbeitung.",
summary: `${skill.name}${skill.version ? ` (${skill.version})` : ""}${
skill.category ? ` aus ${skill.category}` : ""
}.`,
}));
}
function toPersistedUsedSkill(skill: AuditUsedSkill) {
return {
...(skill.id ? { id: skill.id } : {}),
name: skill.name,
...(skill.category ? { category: skill.category } : {}),
...(skill.version ? { version: skill.version } : {}),
...(skill.source ? { source: skill.source } : {}),
};
}
async function appendRunEvent(
ctx: ActionCtx,
args: {
runId: Id<"agentRuns">;
level: "info" | "warning" | "error";
message: string;
details?: { label: string; value: string; source?: string }[];
},
) {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId: args.runId,
level: args.level,
message: args.message,
...(args.details !== undefined ? { details: args.details } : {}),
});
}
async function loadAuditSkillRegistry(
ctx: ActionCtx,
runId: Id<"agentRuns">,
): Promise<ReturnType<typeof loadLocalAuditSkillRegistry>> {
try {
return loadLocalAuditSkillRegistry();
} catch (error) {
const safeErrorSummary = messageFromError(error);
try {
await appendRunEvent(ctx, {
runId,
level: "warning",
message: "Skill-Registry konnte nicht geladen werden; Audit läuft ohne Skill-Auswahl weiter.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
} catch {
// Registry loading is best-effort; warning persistence must not fail the run.
}
return [];
}
}
function toTargetUrl(lead: { websiteUrl?: string; websiteDomain?: string }) {
if (lead.websiteUrl) {
return lead.websiteUrl;
}
if (lead.websiteDomain) {
return `https://${lead.websiteDomain}`;
}
return null;
}
async function recordAuditUsageEvent(
ctx: ActionCtx,
args: {
runId: Id<"agentRuns">;
leadId: Id<"leads">;
auditId?: Id<"audits">;
provider: "openrouter" | "screenshotone" | "jina";
operation: "audit_capture" | "audit_generation";
estimatedCostUsd: number;
tokens?: {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
cacheReadTokens?: number;
};
callCounts?: {
requests?: number;
pages?: number;
screenshots?: number;
};
},
) {
const tokens = args.tokens ? toDefinedUsageTokens(args.tokens) : undefined;
const callCounts = args.callCounts
? {
...(args.callCounts.requests !== undefined
? { requests: args.callCounts.requests }
: {}),
...(args.callCounts.pages !== undefined ? { pages: args.callCounts.pages } : {}),
...(args.callCounts.screenshots !== undefined
? { screenshots: args.callCounts.screenshots }
: {}),
}
: undefined;
try {
await ctx.runMutation(internal.usageEvents.recordUsageEvent, {
provider: args.provider,
operation: args.operation,
runId: args.runId,
leadId: args.leadId,
...(args.auditId ? { auditId: args.auditId } : {}),
estimatedCostUsd: args.estimatedCostUsd,
...(tokens && Object.keys(tokens).length > 0 ? { tokens } : {}),
...(callCounts && Object.keys(callCounts).length > 0 ? { callCounts } : {}),
});
} catch (error) {
const safeErrorSummary = messageFromError(error);
try {
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message: "Usage-Logging konnte nicht gespeichert werden.",
details: [
{ label: "Provider", value: args.provider },
{ label: "Fehler", value: safeErrorSummary },
],
});
} catch {
return;
}
}
}
async function recordOpenRouterUsage(
ctx: ActionCtx,
args: {
runId: Id<"agentRuns">;
leadId: Id<"leads">;
auditId?: Id<"audits">;
usage?: OpenRouterUsage;
},
) {
if (!args.usage) {
return;
}
const estimate = estimateExternalAuditCostUsd({
openRouter: {
inputTokens: args.usage.inputTokens,
outputTokens: args.usage.outputTokens,
},
});
await recordAuditUsageEvent(ctx, {
runId: args.runId,
leadId: args.leadId,
...(args.auditId ? { auditId: args.auditId } : {}),
provider: "openrouter",
operation: "audit_generation",
estimatedCostUsd: estimate.byProvider.openRouter,
tokens: toOpenRouterUsageTokens(args.usage),
});
}
function sumUsageField(
usages: readonly (OpenRouterUsage | undefined)[],
field: "inputTokens" | "outputTokens" | "totalTokens",
) {
return usages.reduce((sum, usage) => sum + (usage?.[field] ?? 0), 0);
}
function aggregateOpenRouterUsage(
usages: readonly (OpenRouterUsage | undefined)[],
): OpenRouterUsage | undefined {
const inputTokens = sumUsageField(usages, "inputTokens");
const outputTokens = sumUsageField(usages, "outputTokens");
const totalTokens = sumUsageField(usages, "totalTokens");
const cacheReadTokens = usages.reduce(
(sum, usage) => sum + (usage?.inputTokenDetails?.cacheReadTokens ?? 0),
0,
);
if (
inputTokens === 0 &&
outputTokens === 0 &&
totalTokens === 0 &&
cacheReadTokens === 0
) {
return undefined;
}
return {
inputTokens,
outputTokens,
totalTokens,
...(cacheReadTokens > 0
? { inputTokenDetails: { cacheReadTokens } }
: {}),
};
}
function dimensionsForViewport(viewport: ScreenshotOneRequest["viewport"]) {
return viewport === "mobile"
? { width: 390, height: 844 }
: { width: 1280, height: 900 };
}
async function fetchExternalCapture(
input: string,
init: RequestInit = {},
): Promise<ExternalCaptureFetch> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
EXTERNAL_CAPTURE_TIMEOUT_MS,
);
try {
const response = await fetch(input, {
...init,
signal: controller.signal,
});
return { response, abortController: controller, timeout };
} catch (error) {
clearTimeout(timeout);
throw error;
}
}
function clearExternalCaptureTimeout(capture: ExternalCaptureFetch | undefined) {
if (capture) {
clearTimeout(capture.timeout);
}
}
async function readLimitedResponseBytes(
response: Response,
maxBytes: number,
signal?: AbortSignal,
): Promise<Uint8Array> {
const reader = response.body?.getReader();
if (!reader) {
throw new Error("External capture response body is unavailable.");
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
const cancelReader = () => {
void reader.cancel().catch(() => null);
};
if (signal?.aborted) {
await reader.cancel().catch(() => null);
throw new Error("External capture response timed out.");
}
signal?.addEventListener("abort", cancelReader, { once: true });
try {
while (true) {
if (signal?.aborted) {
throw new Error("External capture response timed out.");
}
const { done, value } = await reader.read();
if (signal?.aborted) {
throw new Error("External capture response timed out.");
}
if (done) {
break;
}
if (!value) {
continue;
}
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => null);
throw new Error("External capture response exceeded the configured body limit.");
}
chunks.push(value);
}
} finally {
signal?.removeEventListener("abort", cancelReader);
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
async function readLimitedMarkdown(response: Response, signal?: AbortSignal) {
const bytes = await readLimitedResponseBytes(
response,
MAX_JINA_MARKDOWN_BYTES,
signal,
);
return new TextDecoder().decode(bytes).slice(0, MAX_JINA_MARKDOWN_CHARS);
}
async function cancelExternalResponseBody(response: Response) {
if (!response.body) {
return;
}
try {
await response.body.cancel();
} catch {
return;
}
}
async function captureExternalAuditArtifacts(
ctx: ActionCtx,
args: {
runId: Id<"agentRuns">;
leadId: Id<"leads">;
auditId?: Id<"audits">;
targetUrl: string | null;
needsScreenshots: boolean;
needsMarkdown: boolean;
},
) {
let jinaReaderAuditInput:
| ReturnType<typeof buildJinaReaderAuditInput>
| undefined;
const screenshots: Array<{
storageId: Id<"_storage">;
viewport: "desktop" | "mobile";
sourceUrl: string;
capturedAt: number;
width: number;
height: number;
mimeType: string;
}> = [];
if (!args.targetUrl) {
return { screenshots, jinaReaderAuditInput };
}
if (args.needsScreenshots) {
const screenshotOneApiKey = process.env.SCREENSHOTONE_API_KEY;
if (!screenshotOneApiKey) {
try {
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message: "ScreenshotOne ist nicht konfiguriert; Screenshot-Erfassung wurde übersprungen.",
});
} catch {
// Capture warnings are best-effort; classification can continue.
}
} else {
try {
const screenshotRequests = buildScreenshotOneRequests({
accessKey: screenshotOneApiKey,
targetUrl: args.targetUrl,
});
const estimate = estimateExternalAuditCostUsd({
screenshotOne: { screenshots: screenshotRequests.length },
});
await recordAuditUsageEvent(ctx, {
runId: args.runId,
leadId: args.leadId,
...(args.auditId ? { auditId: args.auditId } : {}),
provider: "screenshotone",
operation: "audit_capture",
estimatedCostUsd: estimate.byProvider.screenshotOne,
callCounts: {
requests: screenshotRequests.length,
screenshots: screenshotRequests.length,
},
});
for (const request of screenshotRequests) {
let capture: ExternalCaptureFetch | undefined;
try {
capture = await fetchExternalCapture(request.url);
const response = capture.response;
if (!response.ok) {
await cancelExternalResponseBody(response);
continue;
}
const mimeType = response.headers.get("content-type") ?? "image/png";
const screenshotBytes = await readLimitedResponseBytes(
response,
MAX_SCREENSHOT_BYTES,
capture.abortController.signal,
);
const screenshotBlobBytes = new Uint8Array(screenshotBytes);
const storageId = await ctx.storage.store(
new Blob([screenshotBlobBytes], { type: mimeType }),
);
const dimensions = dimensionsForViewport(request.viewport);
const capturedAt = Date.now();
const sourceUrl =
new URL(request.url).searchParams.get("url") ?? args.targetUrl;
await ctx.runMutation(
internal.auditGeneration.persistExternalCaptureScreenshot,
{
leadId: args.leadId,
runId: args.runId,
storageId,
viewport: request.viewport,
sourceUrl,
capturedAt,
width: dimensions.width,
height: dimensions.height,
mimeType,
},
);
screenshots.push({
storageId,
viewport: request.viewport,
sourceUrl,
capturedAt,
width: dimensions.width,
height: dimensions.height,
mimeType,
});
} catch {
continue;
} finally {
clearExternalCaptureTimeout(capture);
}
}
} catch (error) {
const safeErrorSummary = messageFromError(error);
try {
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message: "ScreenshotOne-Capture konnte nicht vorbereitet werden.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
} catch {
// Capture warnings are best-effort; classification can continue.
}
}
}
}
if (args.needsMarkdown) {
const jinaApiKey = process.env.JINA_API_KEY;
try {
jinaReaderAuditInput = buildJinaReaderAuditInput({
baseUrl: args.targetUrl,
maxMarkdownChars: evidenceRunOptions.maxExternalMarkdownChars,
});
const jinaPages: JinaReaderPageInput[] = [];
for (const page of jinaReaderAuditInput.pages) {
let capture: ExternalCaptureFetch | undefined;
try {
capture = await fetchExternalCapture(page.readerUrl, {
headers: jinaApiKey
? { Authorization: `Bearer ${jinaApiKey}` }
: undefined,
});
const response = capture.response;
if (!response.ok) {
await cancelExternalResponseBody(response);
continue;
}
jinaPages.push({
url: page.sourceUrl,
markdown: await readLimitedMarkdown(
response,
capture.abortController.signal,
),
});
} catch {
continue;
} finally {
clearExternalCaptureTimeout(capture);
}
}
jinaReaderAuditInput = buildJinaReaderAuditInput({
baseUrl: args.targetUrl,
pages: jinaPages,
maxMarkdownChars: evidenceRunOptions.maxExternalMarkdownChars,
});
const estimate = estimateExternalAuditCostUsd({
jina: {
requests: jinaReaderAuditInput.readerUrls.length,
pages: jinaReaderAuditInput.pages.length,
},
});
await recordAuditUsageEvent(ctx, {
runId: args.runId,
leadId: args.leadId,
...(args.auditId ? { auditId: args.auditId } : {}),
provider: "jina",
operation: "audit_capture",
estimatedCostUsd: estimate.byProvider.jina,
callCounts: {
requests: jinaReaderAuditInput.readerUrls.length,
pages: jinaReaderAuditInput.pages.length,
},
});
} catch (error) {
const safeErrorSummary = messageFromError(error);
jinaReaderAuditInput = undefined;
try {
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message: "Jina-Capture konnte nicht vorbereitet werden.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
} catch {
// Capture warnings are best-effort; classification can continue.
}
}
}
return { screenshots, jinaReaderAuditInput };
}
async function persistAuditStage({
ctx,
runId,
leadId,
auditId,
stage,
modelProfile,
modelId,
prompt,
systemPrompt,
rawResponse,
parsedJson,
usage,
status,
finishReason,
errorSummary,
}: {
ctx: ActionCtx;
runId: Id<"agentRuns">;
leadId: Id<"leads">;
auditId?: Id<"audits">;
stage: "classification" | "multimodalAudit" | "germanCopy" | "qualityReview";
modelProfile: string;
modelId: string;
prompt: string;
systemPrompt?: string;
rawResponse?: string;
parsedJson?: string;
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
cacheReadTokens?: number;
};
status: "pending" | "running" | "succeeded" | "failed" | "canceled";
finishReason?: string;
errorSummary?: string;
}) {
const persistedUsage = usage ? toPersistedUsage(usage) : undefined;
await ctx.runMutation(internal.auditGeneration.persistAuditGenerationResult, {
leadId,
runId,
...(auditId ? { auditId } : {}),
stage,
modelProfile,
modelId,
prompt,
...(systemPrompt !== undefined ? { systemPrompt } : {}),
...(rawResponse !== undefined ? { rawResponse } : {}),
...(parsedJson !== undefined ? { parsedJson } : {}),
...(persistedUsage && Object.keys(persistedUsage).length > 0
? { usage: persistedUsage }
: {}),
status,
...(finishReason !== undefined ? { finishReason } : {}),
...(errorSummary !== undefined ? { errorSummary } : {}),
});
}
function getValidMediaType(mimeType: string) {
if (!mimeType) {
return "image/png";
}
if (mimeType.startsWith("image/")) {
return mimeType;
}
return "image/png";
}
export const processAuditGeneration = internalAction({
args: {
runId: v.id("agentRuns"),
},
handler: async (ctx, args) => {
let started:
| {
lead: {
_id: Id<"leads">;
websiteUrl?: string;
websiteDomain?: string;
contactStatus?: string;
};
auditId?: Id<"audits">;
}
| null = null;
let auditId: Id<"audits"> | undefined;
let classificationSummary = "";
let multimodalSummary = "";
let germanCopyOutput: GermanCopyOutput = {
internalSummary: "",
publicSummary: "",
publicBody: "",
emailSubject: "",
emailBody: "",
phoneScript: {
openingLine: "",
callScript: [],
closeLine: "",
},
followUpDraft: {
message: "",
},
};
let qualityPassed = false;
let errors = 0;
let currentStep: "audit_generation" | "classification" | "multimodalAudit" | "germanCopy" | "qualityReview" =
"audit_generation";
try {
started = await ctx.runMutation(internal.auditGeneration.startAuditGenerationRun, {
runId: args.runId,
});
} catch (error) {
const safeErrorSummary = messageFromError(error);
await appendRunEvent(ctx, {
runId: args.runId,
level: "error",
message: "Audit-Generierung konnte nicht gestartet werden.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "failed",
errors: 1,
errorSummary: "Start der Audit-Generierung fehlgeschlagen.",
currentStep: "audit_generation",
});
return null;
}
if (!started) {
return null;
}
try {
const evidence = await ctx.runQuery(internal.auditGeneration.getAuditGenerationEvidence, {
runId: args.runId,
});
if (!evidence) {
await appendRunEvent(ctx, {
runId: args.runId,
level: "error",
message: "Audit-Generierung kann keine Datenbasis laden.",
});
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "failed",
errors: 1,
currentStep,
errorSummary: "Evidence-Daten für den Lead konnten nicht geladen werden.",
});
return null;
}
if (started.auditId) {
auditId = started.auditId;
}
const targetUrl = toTargetUrl(started.lead);
const hasLegacyMarkdown = evidence.crawlPages.some(
(page) => Boolean(page.visibleTextExcerpt),
);
const externalCapture = await captureExternalAuditArtifacts(ctx, {
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
targetUrl,
needsScreenshots: evidence.screenshots.length === 0,
needsMarkdown: !hasLegacyMarkdown,
});
const evidenceScreenshots =
evidence.screenshots.length > 0
? evidence.screenshots
: externalCapture.screenshots;
const externalMarkdown = externalCapture.jinaReaderAuditInput?.markdown;
const skillRegistry = await loadAuditSkillRegistry(ctx, args.runId);
const evidenceInput = buildAuditEvidenceInput({
lead: evidence.lead,
crawlPages: evidence.crawlPages,
technicalChecks: evidence.technicalChecks,
screenshots: evidenceScreenshots.slice(0, evidenceRunOptions.screenshotLimit),
pageSpeedInputs: evidence.pageSpeedInputs,
skillRegistry,
externalMarkdown,
});
await appendRunEvent(ctx, toAuditGenerationProfileMessage("Start", args.runId));
const provider = createOpenRouterProvider();
const classificationProfile = resolveModelProfile("classification");
const multimodalProfile = resolveModelProfile("multimodalAudit");
const germanCopyProfile = resolveModelProfile("germanCopy");
const qualityReviewProfile = resolveModelProfile("qualityReview");
// Stage 1: classification
const classificationPrompt = buildClassificationPrompt(evidenceInput);
const classificationSystemPrompt =
"Du bist interner KI-Berater für Website-Audits. Gib nur strukturierte JSON-Ausgaben zurück.";
const safeClassificationPrompt = sanitizeAndCapString(
classificationPrompt,
MAX_PROMPT_BYTES,
);
currentStep = "classification";
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "classification",
modelProfile: "classification",
modelId: classificationProfile.modelId,
prompt: safeClassificationPrompt ?? "",
systemPrompt: classificationSystemPrompt,
status: "running",
});
try {
const classificationResult = await generateObject({
model: provider(classificationProfile.modelId),
system: classificationSystemPrompt,
schema: auditClassificationSchema,
prompt: safeClassificationPrompt ?? "",
temperature: classificationProfile.temperature,
maxOutputTokens: classificationProfile.maxTokens,
});
classificationSummary =
typeof classificationResult.object.summary === "string"
? classificationResult.object.summary
: "";
const rawClassification = safeStringify(classificationResult.object);
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "classification",
modelProfile: "classification",
modelId: classificationProfile.modelId,
prompt: safeClassificationPrompt ?? "",
systemPrompt: classificationSystemPrompt,
rawResponse: sanitizeAndCapString(
rawClassification,
MAX_RAW_RESPONSE_BYTES,
),
parsedJson: sanitizeAndCapParsedJson(classificationResult.object),
...withStageUsage(classificationResult.usage),
status: "succeeded",
finishReason: classificationResult.finishReason,
});
await recordOpenRouterUsage(ctx, {
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
usage: classificationResult.usage,
});
await appendRunEvent(ctx, {
runId: args.runId,
level: "info",
message: "Interne Klassifikation abgeschlossen.",
details: [
{ label: "Befunde", value: String(classificationResult.object.findings.length) },
],
});
} catch (error) {
errors += 1;
const safeErrorSummary = messageFromError(error);
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "classification",
modelProfile: "classification",
modelId: classificationProfile.modelId,
prompt: safeClassificationPrompt ?? "",
systemPrompt: classificationSystemPrompt,
status: "failed",
errorSummary: safeErrorSummary,
});
await appendRunEvent(ctx, {
runId: args.runId,
level: "error",
message: "Interne Klassifikation fehlgeschlagen.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "failed",
errors,
errorSummary: "Interne Klassifikation konnte nicht erstellt werden.",
currentStep: "classification",
});
return null;
}
// Stage 2: multimodal audit summary
const multimodalSystemPrompt =
"Du bist Prüfanalyst für Conversion-Optimierung mit Fokus auf lokale Unternehmen.";
const multimodalPrompt = buildMultimodalPrompt(evidenceInput, true);
const safeMultimodalPrompt = sanitizeAndCapString(
multimodalPrompt,
MAX_PROMPT_BYTES,
);
const screenshotSources = multimodalProfile.supportsImages
? evidenceScreenshots.slice(0, evidenceRunOptions.screenshotLimit)
: [];
const screenshotParts = screenshotSources.length > 0
? await Promise.all(
screenshotSources.map(
async (screenshot): Promise<MultimodalFilePart | null> => {
try {
const storageId = screenshot.storageId as Id<"_storage">;
const maybeBlob = await ctx.storage.get(storageId);
if (maybeBlob) {
const fileData = await maybeBlob.arrayBuffer();
return {
type: "file" as const,
data: fileData,
mediaType: getValidMediaType(screenshot.mimeType),
};
}
const storageUrl = await ctx.storage.getUrl(storageId);
if (storageUrl) {
return {
type: "file" as const,
data: storageUrl,
mediaType: getValidMediaType(screenshot.mimeType),
};
}
} catch {
return null;
}
return null;
},
),
)
: [];
if (!multimodalProfile.supportsImages && evidenceScreenshots.length > 0) {
await appendRunEvent(ctx, {
runId: args.runId,
level: "info",
message:
"Multimodales Modell unterstützt keine Bilder; Analyse läuft textbasiert.",
});
}
currentStep = "multimodalAudit";
const validScreenshotParts = screenshotParts.filter(
(part): part is MultimodalFilePart => part !== null,
);
if (validScreenshotParts.length === 0) {
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message: "Keine multimodalen Belege verfügbar; Analyse läuft textbasiert.",
details: [
{
label: "Hinweis",
value: "Screenshots konnten nicht geladen werden.",
},
],
});
}
try {
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "multimodalAudit",
modelProfile: "multimodalAudit",
modelId: multimodalProfile.modelId,
prompt: safeMultimodalPrompt ?? "",
systemPrompt: multimodalSystemPrompt,
status: "running",
});
let multimodalResult:
| {
object: { summary?: string; keyFindings?: string[] };
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
inputTokenDetails?: { cacheReadTokens?: number };
};
finishReason?: string;
} = {
object: { summary: "" },
usage: undefined,
};
if (validScreenshotParts.length > 0) {
const multimodalUserMessage: MultimodalUserMessage = {
role: "user",
content: [
{ type: "text", text: safeMultimodalPrompt ?? "" },
...validScreenshotParts,
],
};
multimodalResult = await generateObject({
model: provider(multimodalProfile.modelId),
system: multimodalSystemPrompt,
schema: auditSummarySchema,
temperature: multimodalProfile.temperature,
maxOutputTokens: multimodalProfile.maxTokens,
messages: [multimodalUserMessage],
});
} else {
const multimodalTextMessage: MultimodalUserMessage = {
role: "user",
content: [{ type: "text", text: safeMultimodalPrompt ?? "" }],
};
multimodalResult = await generateObject({
model: provider(multimodalProfile.modelId),
system: multimodalSystemPrompt,
schema: auditSummarySchema,
temperature: multimodalProfile.temperature,
maxOutputTokens: multimodalProfile.maxTokens,
messages: [multimodalTextMessage],
});
}
if (!multimodalResult?.object) {
throw new Error(
"Multimodale Audit-Analyse konnte nicht ausgeführt werden.",
);
}
multimodalSummary =
typeof multimodalResult.object.summary === "string"
? multimodalResult.object.summary
: "";
const multimodalRaw = safeStringify(multimodalResult.object);
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "multimodalAudit",
modelProfile: "multimodalAudit",
modelId: multimodalProfile.modelId,
prompt: safeMultimodalPrompt ?? "",
systemPrompt: multimodalSystemPrompt,
rawResponse: sanitizeAndCapString(
multimodalRaw,
MAX_RAW_RESPONSE_BYTES,
),
parsedJson: sanitizeAndCapParsedJson(multimodalResult.object),
...withStageUsage(multimodalResult.usage),
status: "succeeded",
finishReason: multimodalResult.finishReason,
});
await recordOpenRouterUsage(ctx, {
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
usage: multimodalResult.usage,
});
await appendRunEvent(ctx, {
runId: args.runId,
level: "info",
message: "Multimodale Audit-Analyse abgeschlossen.",
});
} catch (error) {
errors += 1;
const safeErrorSummary = messageFromError(error);
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "multimodalAudit",
modelProfile: "multimodalAudit",
modelId: multimodalProfile.modelId,
prompt: safeMultimodalPrompt ?? "",
systemPrompt: multimodalSystemPrompt,
status: "failed",
errorSummary: safeErrorSummary,
});
await appendRunEvent(ctx, {
runId: args.runId,
level: "error",
message: "Multimodale Audit-Analyse fehlgeschlagen.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "failed",
errors,
errorSummary:
"Multimodale Audit-Analyse konnte nicht abgeschlossen werden.",
currentStep: "multimodalAudit",
});
return null;
}
currentStep = "germanCopy";
// Stage 3: german copy generation
const germanSystemPrompt =
"Du bist fachlicher Texter für lokale Unternehmen im B2B-Kontext.";
const germanPrompt = buildGermanCopyPrompt(
classificationSummary,
multimodalSummary,
);
const safeGermanPrompt = sanitizeAndCapString(germanPrompt, MAX_PROMPT_BYTES);
try {
const publicSummaryResult = await generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: publicAuditTextSchema,
prompt: safeGermanPrompt
? `${safeGermanPrompt}\nAusgabe für publicSummary`
: "Ausgabe für publicSummary",
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
});
const germanBodyResult = await generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: publicAuditTextSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für publicBody`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
});
const germanSubjectResult = await generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: emailSubjectSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailSubject`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
});
const germanEmailResult = await generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: emailDraftSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailBody`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
});
const germanCallScriptResult = await generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: callScriptSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für callScript`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
});
const germanFollowUpResult = await generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: followUpDraftSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für followUpDraft`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
});
const publicSummary = publicSummaryResult.object.publicText ?? "";
const publicBody = germanBodyResult.object.publicText ?? "";
germanCopyOutput = {
internalSummary: classificationSummary,
publicSummary,
publicBody,
emailSubject: germanSubjectResult.object.subject ?? "",
emailBody: germanEmailResult.object.body ?? "",
phoneScript: {
openingLine: germanCallScriptResult.object.openingLine ?? "",
callScript: germanCallScriptResult.object.callScript ?? [],
closeLine: germanCallScriptResult.object.closeLine ?? "",
},
followUpDraft: {
message: germanFollowUpResult.object.message ?? "",
...(germanFollowUpResult.object.followInDays !== null
? { followInDays: germanFollowUpResult.object.followInDays }
: {}),
goals: germanFollowUpResult.object.goals ?? [],
},
};
const germanRaw = safeStringify(germanCopyOutput);
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "germanCopy",
modelProfile: "germanCopy",
modelId: germanCopyProfile.modelId,
prompt: safeGermanPrompt ?? "",
systemPrompt: germanSystemPrompt,
rawResponse: sanitizeAndCapString(germanRaw, MAX_RAW_RESPONSE_BYTES),
parsedJson: sanitizeAndCapParsedJson(germanCopyOutput),
...withStageUsage(germanEmailResult.usage),
status: "succeeded",
finishReason: germanEmailResult.finishReason,
});
await recordOpenRouterUsage(ctx, {
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
usage: aggregateOpenRouterUsage([
publicSummaryResult.usage,
germanBodyResult.usage,
germanSubjectResult.usage,
germanEmailResult.usage,
germanCallScriptResult.usage,
germanFollowUpResult.usage,
]),
});
await appendRunEvent(ctx, {
runId: args.runId,
level: "info",
message: "Deutsche Kundenkommunikation generiert.",
});
} catch (error) {
errors += 1;
const safeErrorSummary = messageFromError(error);
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "germanCopy",
modelProfile: "germanCopy",
modelId: germanCopyProfile.modelId,
prompt: safeGermanPrompt ?? "",
systemPrompt: germanSystemPrompt,
status: "failed",
errorSummary: safeErrorSummary,
});
await appendRunEvent(ctx, {
runId: args.runId,
level: "error",
message: "Deutsche Texte konnten nicht generiert werden.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "failed",
errors,
currentStep: "germanCopy",
errorSummary: "Deutsche Texte konnten nicht generiert werden.",
});
return null;
}
const guardResult: GermanCopyGuardResult = validateCustomerFacingCopy({
auditSummary: germanCopyOutput.publicSummary,
auditBody: germanCopyOutput.publicBody,
emailSubject: germanCopyOutput.emailSubject,
emailBody: germanCopyOutput.emailBody,
callScript: {
openingLine: germanCopyOutput.phoneScript.openingLine,
callScript: germanCopyOutput.phoneScript.callScript,
closeLine: germanCopyOutput.phoneScript.closeLine,
},
followUp: germanCopyOutput.followUpDraft.message,
});
// Stage 4: final quality review
const qualityPrompt = buildQualityReviewPrompt(
classificationSummary,
germanCopyOutput,
);
const safeQualityPrompt = sanitizeAndCapString(qualityPrompt, MAX_PROMPT_BYTES);
const qualitySystemPrompt =
"Du prüfst die erzeugten Inhalte als Qualitätssicherung.";
currentStep = "qualityReview";
try {
const qualityResult = await generateObject({
model: provider(qualityReviewProfile.modelId),
system: qualitySystemPrompt,
schema: qualityReviewSchema,
prompt: safeQualityPrompt ?? "",
temperature: qualityReviewProfile.temperature,
maxOutputTokens: qualityReviewProfile.maxTokens,
});
qualityPassed = guardResult.passed;
const qualityPayload = {
isValid: qualityResult.object.isValid && guardResult.passed,
issues: [
...qualityResult.object.issues,
...guardResult.issues.map(
(issue) => `${issue.field}: ${issue.message}`,
),
],
suggestions: qualityResult.object.suggestions,
notes: qualityResult.object.notes ?? [],
};
const qualityErrorSummary =
"Qualitätsprüfung hat Inhalte als ungenügend markiert.";
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "qualityReview",
modelProfile: "qualityReview",
modelId: qualityReviewProfile.modelId,
prompt: safeQualityPrompt ?? "",
systemPrompt: qualitySystemPrompt,
rawResponse: sanitizeAndCapString(
safeStringify(qualityPayload),
MAX_RAW_RESPONSE_BYTES,
),
parsedJson: sanitizeAndCapParsedJson(qualityPayload),
...withStageUsage(qualityResult.usage),
status: qualityPassed ? "succeeded" : "failed",
finishReason: qualityResult.finishReason,
...(!qualityPassed ? { errorSummary: qualityErrorSummary } : {}),
});
await recordOpenRouterUsage(ctx, {
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
usage: qualityResult.usage,
});
if (!qualityPassed) {
const message =
"Qualitätsprüfung und German-Copy-Guard haben nicht bestanden.";
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message,
});
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "failed",
currentStep: "qualityReview",
errors: errors + 1,
errorSummary: message,
});
return null;
}
if (!qualityResult.object.isValid) {
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message:
"Qualitätsprüfung hat Review-Hinweise gemeldet; German-Copy-Guard bestanden.",
details: qualityResult.object.issues.slice(0, 4).map((issue) => ({
label: "Hinweis",
value: issue,
})),
});
} else {
await appendRunEvent(ctx, {
runId: args.runId,
level: "info",
message: "Qualitätsprüfung bestanden.",
});
}
} catch (error) {
const safeErrorSummary = messageFromError(error);
errors += 1;
await persistAuditStage({
ctx,
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
stage: "qualityReview",
modelProfile: "qualityReview",
modelId: qualityReviewProfile.modelId,
prompt: safeQualityPrompt ?? "",
systemPrompt: qualitySystemPrompt,
status: "failed",
errorSummary: safeErrorSummary,
});
await appendRunEvent(ctx, {
runId: args.runId,
level: "error",
message: "Qualitätsprüfung fehlgeschlagen.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "failed",
errors,
errorSummary: safeErrorSummary,
currentStep: "qualityReview",
});
return null;
}
const checkedDomain =
started.lead.websiteDomain ??
evidence.lead.websiteDomain ??
"unbekannte-domain";
const checkedPages = evidenceInput.checkedPages.slice(
0,
evidenceRunOptions.maxCheckedPages,
);
const persistedAuditId = await ctx.runMutation(
internal.audits.upsertFromAuditGeneration,
{
leadId: started.lead._id,
runId: args.runId,
...(auditId ? { auditId } : {}),
checkedDomain,
checkedPages,
internalSummary: classificationSummary,
multimodalSummary,
publicSummary: germanCopyOutput.publicSummary,
publicBody: germanCopyOutput.publicBody,
usedSkills: evidenceInput.selectedSkills
.slice(0, 6)
.map(toPersistedUsedSkill),
skillSummaries: toSkillSummaries(evidenceInput.selectedSkills),
},
);
if (persistedAuditId) {
auditId = persistedAuditId;
}
await ctx.runMutation(internal.outreach.upsertFromAuditGeneration, {
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
strategy: "email_first",
phoneScript: [
germanCopyOutput.phoneScript.openingLine,
...germanCopyOutput.phoneScript.callScript,
germanCopyOutput.phoneScript.closeLine,
]
.filter(Boolean)
.join(" "),
emailSubject: germanCopyOutput.emailSubject,
emailBody: germanCopyOutput.emailBody,
followUpDraft: `${germanCopyOutput.followUpDraft.message}\n${(
germanCopyOutput.followUpDraft.goals ?? []
)
.slice(0, 4)
.join(" | ")}`.trim(),
});
const lead = await ctx.runQuery(internal.leads.getInternal, {
id: started.lead._id,
});
const leadContactStatus =
lead?.contactStatus ?? started.lead.contactStatus;
if (isTerminalLeadContactStatus(leadContactStatus)) {
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message: "Lead-Status wurde nicht auf outreach_ready gesetzt.",
details: [
{
label: "Grund",
value:
"Lead ist bereits als terminal kontaktiert oder blockiert markiert.",
},
],
});
} else {
await ctx.runMutation(internal.leads.reviewUpdateInternal, {
id: started.lead._id,
contactStatus: "outreach_ready",
});
}
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "succeeded",
currentStep: "qualityReview",
errors,
});
await appendRunEvent(ctx, {
runId: args.runId,
level: "info",
message: qualityPassed
? "Audit-Generierung erfolgreich abgeschlossen."
: "Audit-Generierung abgeschlossen mit Qualitätsmängeln.",
details: [
{ label: "Ausgabe", value: "Audit und Outreach gespeichert." },
...(qualityPassed ? [{ label: "Status", value: "succeeded" }] : []),
],
});
return args.runId;
} catch (error) {
errors += 1;
const safeErrorSummary = messageFromError(error);
await appendRunEvent(ctx, {
runId: args.runId,
level: "error",
message: "Audit-Generierung wurde unerwartet beendet.",
details: [{ label: "Fehler", value: safeErrorSummary }],
});
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
runId: args.runId,
status: "failed",
errors,
currentStep,
errorSummary: safeErrorSummary,
});
return null;
}
},
});