Files
webdev-pipeline/convex/auditGenerationAction.ts

1198 lines
38 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 {
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<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[];
};
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<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 && 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;
}
},
});