"use node"; import { type DataContent, generateObject } from "ai"; import { createOpenRouterProvider } from "../lib/ai/openrouter-provider"; import { resolveModelProfile } from "../lib/ai/model-profiles"; import { auditSummarySchema, callScriptSchema, emailDraftSchema, emailSubjectSchema, followUpDraftSchema, internalFindingsSchema, publicAuditTextSchema, qualityReviewSchema, } from "../lib/ai/schemas"; import { validateCustomerFacingCopy, type GermanCopyGuardResult, } from "../lib/ai/german-copy-guard"; import { buildAuditEvidenceInput } from "../lib/ai/audit-evidence"; import { api, 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 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", ]; 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) { return error instanceof Error ? error.message : String(error); } 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 { promptTokens: usage.inputTokens, completionTokens: usage.outputTokens, totalTokens: usage.totalTokens, cacheReadTokens: usage.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[]; }; const evidenceRunOptions = { maxCheckedPages: 8, screenshotLimit: 2, } 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(" ; ")}`, "Antworte ausschließlich als JSON-Objekt mit den Schlüsseln:", "'findings' als Liste und 'summary' als kurzer Gesamttext.", ].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(" ; ")}`, 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<{ 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})` : ""} aus ${skill.category}.`, })); } 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(api.runs.appendEvent, { runId: args.runId, level: args.level, message: args.message, details: args.details, }); } 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; }) { await ctx.runMutation(internal.auditGeneration.persistAuditGenerationResult, { leadId, runId, ...(auditId ? { auditId } : {}), stage, modelProfile, modelId, prompt, systemPrompt, rawResponse, parsedJson, usage: usage ? toPersistedUsage(usage) : undefined, status, finishReason, 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) { await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Audit-Generierung konnte nicht gestartet werden.", details: [{ label: "Fehler", value: messageFromError(error) }], }); 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 evidenceInput = buildAuditEvidenceInput({ lead: evidence.lead, crawlPages: evidence.crawlPages, technicalChecks: evidence.technicalChecks, screenshots: evidence.screenshots.slice(0, evidenceRunOptions.screenshotLimit), pageSpeedInputs: evidence.pageSpeedInputs, skillRegistry: [], }); 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, stage: "classification", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeClassificationPrompt ?? "", systemPrompt: classificationSystemPrompt, status: "running", parsedJson: undefined, rawResponse: undefined, }); try { const classificationResult = await generateObject({ model: provider(classificationProfile.modelId), system: classificationSystemPrompt, schema: internalFindingsSchema, 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, stage: "classification", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeClassificationPrompt ?? "", systemPrompt: classificationSystemPrompt, rawResponse: sanitizeAndCapString( rawClassification, MAX_RAW_RESPONSE_BYTES, ), parsedJson: sanitizeAndCapParsedJson(classificationResult.object), usage: { inputTokens: classificationResult.usage.inputTokens, outputTokens: classificationResult.usage.outputTokens, totalTokens: classificationResult.usage.totalTokens, cacheReadTokens: classificationResult.usage.inputTokenDetails?.cacheReadTokens, }, status: "succeeded", finishReason: classificationResult.finishReason, }); 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 errorSummary = messageFromError(error); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, auditId, stage: "classification", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeClassificationPrompt ?? "", systemPrompt: classificationSystemPrompt, status: "failed", errorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Interne Klassifikation fehlgeschlagen.", details: [{ label: "Fehler", value: errorSummary }], }); 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 ? evidence.screenshots.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 && evidence.screenshots.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, 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, stage: "multimodalAudit", modelProfile: "multimodalAudit", modelId: multimodalProfile.modelId, prompt: safeMultimodalPrompt ?? "", systemPrompt: multimodalSystemPrompt, rawResponse: sanitizeAndCapString( multimodalRaw, MAX_RAW_RESPONSE_BYTES, ), parsedJson: sanitizeAndCapParsedJson(multimodalResult.object), usage: { inputTokens: multimodalResult.usage?.inputTokens, outputTokens: multimodalResult.usage?.outputTokens, totalTokens: multimodalResult.usage?.totalTokens, cacheReadTokens: multimodalResult.usage?.inputTokenDetails?.cacheReadTokens, }, status: "succeeded", finishReason: multimodalResult.finishReason, }); await appendRunEvent(ctx, { runId: args.runId, level: "info", message: "Multimodale Audit-Analyse abgeschlossen.", }); } catch (error) { errors += 1; const errorSummary = messageFromError(error); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, auditId, stage: "multimodalAudit", modelProfile: "multimodalAudit", modelId: multimodalProfile.modelId, prompt: safeMultimodalPrompt ?? "", systemPrompt: multimodalSystemPrompt, status: "failed", errorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Multimodale Audit-Analyse fehlgeschlagen.", details: [{ label: "Fehler", value: errorSummary }], }); 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 ?? "", followInDays: germanFollowUpResult.object.followInDays, goals: germanFollowUpResult.object.goals ?? [], }, }; const germanRaw = safeStringify(germanCopyOutput); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, auditId, stage: "germanCopy", modelProfile: "germanCopy", modelId: germanCopyProfile.modelId, prompt: safeGermanPrompt ?? "", systemPrompt: germanSystemPrompt, rawResponse: sanitizeAndCapString(germanRaw, MAX_RAW_RESPONSE_BYTES), parsedJson: sanitizeAndCapParsedJson(germanCopyOutput), usage: { inputTokens: germanEmailResult.usage.inputTokens, outputTokens: germanEmailResult.usage.outputTokens, totalTokens: germanEmailResult.usage.totalTokens, cacheReadTokens: germanEmailResult.usage.inputTokenDetails?.cacheReadTokens, }, status: "succeeded", finishReason: germanEmailResult.finishReason, }); await appendRunEvent(ctx, { runId: args.runId, level: "info", message: "Deutsche Kundenkommunikation generiert.", }); } catch (error) { errors += 1; const errorSummary = messageFromError(error); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, auditId, stage: "germanCopy", modelProfile: "germanCopy", modelId: germanCopyProfile.modelId, prompt: safeGermanPrompt ?? "", systemPrompt: germanSystemPrompt, status: "failed", errorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Deutsche Texte konnten nicht generiert werden.", details: [{ label: "Fehler", value: errorSummary }], }); 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 = qualityResult.object.isValid && guardResult.passed; const qualityPayload = { isValid: qualityPassed, issues: [ ...qualityResult.object.issues, ...guardResult.issues.map((issue) => issue.message), ], suggestions: qualityResult.object.suggestions, notes: qualityResult.object.notes, }; await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, auditId, stage: "qualityReview", modelProfile: "qualityReview", modelId: qualityReviewProfile.modelId, prompt: safeQualityPrompt ?? "", systemPrompt: qualitySystemPrompt, rawResponse: sanitizeAndCapString( safeStringify(qualityPayload), MAX_RAW_RESPONSE_BYTES, ), parsedJson: sanitizeAndCapParsedJson(qualityPayload), usage: { inputTokens: qualityResult.usage.inputTokens, outputTokens: qualityResult.usage.outputTokens, totalTokens: qualityResult.usage.totalTokens, cacheReadTokens: qualityResult.usage.inputTokenDetails?.cacheReadTokens, }, status: qualityPassed ? "succeeded" : "failed", finishReason: qualityResult.finishReason, errorSummary: qualityPassed ? undefined : "Qualitätsprüfung hat Inhalte als ungenügend markiert.", }); 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; } await appendRunEvent(ctx, { runId: args.runId, level: "info", message: "Qualitätsprüfung bestanden.", }); } catch (error) { const errorSummary = messageFromError(error); errors += 1; await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, auditId, stage: "qualityReview", modelProfile: "qualityReview", modelId: qualityReviewProfile.modelId, prompt: safeQualityPrompt ?? "", systemPrompt: qualitySystemPrompt, status: "failed", errorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Qualitätsprüfung fehlgeschlagen.", details: [{ label: "Fehler", value: errorSummary }], }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors, errorSummary, 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((skill) => ({ name: skill.name, category: skill.category, version: skill.version, source: skill.source, })), 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(api.leads.get, { 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(api.leads.reviewUpdate, { id: started.lead._id, contactStatus: "outreach_ready", }); } await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "succeeded", currentStep: "qualityReview", errors, errorSummary: qualityPassed ? undefined : "Qualitätsprüfung nicht bestanden.", }); 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 errorSummary = messageFromError(error); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Audit-Generierung wurde unerwartet beendet.", details: [{ label: "Fehler", value: errorSummary }], }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors, currentStep, errorSummary, }); return null; } }, });