"use node"; import { join } from "node:path"; import { type DataContent, generateObject } from "ai"; import { createOpenRouterProvider } from "../lib/ai/openrouter-provider"; import { resolveModelProfile } from "../lib/ai/model-profiles"; 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 { loadSkillsRegistry, 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 >; 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; }; 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>> { try { return await loadSkillsRegistry( join(process.cwd(), "v2_elemente", "skills.md"), ); } 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 { 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 { 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 | 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 => { 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; } }, });