1198 lines
38 KiB
TypeScript
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;
|
|
}
|
|
},
|
|
});
|