"use node"; import { type DataContent, generateObject } from "ai"; import { createOpenRouterProvider } from "../lib/ai/openrouter-provider"; import { resolveModelProfile } from "../lib/ai/model-profiles"; import { loadLocalAuditSkillRegistry } from "../lib/ai/local-audit-skill-registry"; import { buildCustomerTonePromptSection } from "../lib/ai/customer-tone-guidelines"; import { auditClassificationSchema, auditEvidenceVerificationSchema, auditSummarySchema, auditSpecialistResultSchema, callScriptSchema, emailDraftSchema, emailSubjectSchema, followUpDraftSchema, publicAuditTextSchema, qualityReviewSchema, type AuditSpecialistFinding, type AuditSpecialistResult, } from "../lib/ai/schemas"; import { validateCustomerFacingCopy, type GermanCopyGuardResult, } from "../lib/ai/german-copy-guard"; import { buildAuditEvidenceInput } from "../lib/ai/audit-evidence"; import { buildJinaReaderAuditInput, buildScreenshotOneRequests, estimateExternalAuditCostUsd, type JinaReaderPageInput, type ScreenshotOneRequest, } from "../lib/external-audit-services"; import { type AuditUsedSkill } from "../lib/skills-registry"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { internalAction, type ActionCtx, } from "./_generated/server"; import { v } from "convex/values"; const MAX_PROMPT_BYTES = 12_000; const MAX_RAW_RESPONSE_BYTES = 12_000; const MAX_PARSED_JSON_BYTES = 12_000; const EXTERNAL_CAPTURE_TIMEOUT_MS = 12_000; const MAX_SCREENSHOT_BYTES = 6_000_000; const MAX_JINA_MARKDOWN_BYTES = 256_000; const MAX_JINA_MARKDOWN_CHARS = 4_000; const TRUNCATION_MARKER = "\n\n[... abgeschnitten ...]"; function byteLength(value: string) { return new TextEncoder().encode(value).byteLength; } function truncateToByteLimit(value: string, maxBytes: number) { if (maxBytes <= 0) { return ""; } let usedBytes = 0; let endIndex = 0; for (const char of value) { const charBytes = byteLength(char); if (usedBytes + charBytes > maxBytes) { break; } usedBytes += charBytes; endIndex += char.length; } return value.slice(0, endIndex); } function truncateWithMarker(value: string, maxBytes: number) { if (byteLength(value) <= maxBytes) { return value; } const markerBytes = byteLength(TRUNCATION_MARKER); if (markerBytes >= maxBytes) { const markerBytesBuffer = new TextEncoder().encode(TRUNCATION_MARKER); return new TextDecoder().decode(markerBytesBuffer.slice(0, maxBytes)); } const trimmed = truncateToByteLimit(value, Math.max(0, maxBytes - markerBytes)); return `${trimmed}${TRUNCATION_MARKER}`; } function sanitizeAndCapString(value: string | undefined, maxBytes: number) { if (!value) { return undefined; } const safe = sanitizeSecretCandidates(value); return byteLength(safe) > maxBytes ? truncateWithMarker(safe, maxBytes) : safe; } const secretHints = [ "OPENROUTER_API_KEY", "GOOGLE_PLACES_API_KEY", "GOOGLE_GEOCODING_API_KEY", "PAGESPEED_API_KEY", "SMTP_PASSWORD", "SMTP_HOST", "SMTP_USER", "BETTER_AUTH_SECRET", "RYBBIT_API_KEY", "SCREENSHOTONE_API_KEY", "JINA_API_KEY", ]; function sanitizeSecretCandidates(value: string) { let safe = value; for (const key of secretHints) { const secret = process.env[key]; if (!secret) { continue; } safe = safe.replace(new RegExp(escapeRegExp(secret), "g"), "[REDACTED]"); } return safe .replace(/\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*[^\s\"']+/gi, "[REDACTED]") .trim(); } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function messageFromError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return sanitizeSecretCandidates(message); } function sanitizeAndCapParsedJson(parsedJson: unknown): string | undefined { if (parsedJson === undefined) { return undefined; } if (typeof parsedJson === "string") { return sanitizeAndCapString(parsedJson, MAX_PARSED_JSON_BYTES); } const serialized = safeStringify(parsedJson); const safeSerialized = sanitizeAndCapString(serialized, MAX_PARSED_JSON_BYTES); return safeSerialized; } function safeStringify(value: unknown) { try { return JSON.stringify(value); } catch { return "[unserializable payload]"; } } function toPersistedUsage(usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number; cacheReadTokens?: number; }) { return { ...(usage.inputTokens !== undefined ? { promptTokens: usage.inputTokens } : {}), ...(usage.outputTokens !== undefined ? { completionTokens: usage.outputTokens } : {}), ...(usage.totalTokens !== undefined ? { totalTokens: usage.totalTokens } : {}), ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}), }; } type StageUsageInput = { inputTokens?: number; outputTokens?: number; totalTokens?: number; inputTokenDetails?: { cacheReadTokens?: number }; }; type StageUsage = { inputTokens?: number; outputTokens?: number; totalTokens?: number; cacheReadTokens?: number; }; function toStageUsage(usage: StageUsageInput | undefined): StageUsage | undefined { if (!usage) { return undefined; } const stageUsage = { ...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}), ...(usage.outputTokens !== undefined ? { outputTokens: usage.outputTokens } : {}), ...(usage.totalTokens !== undefined ? { totalTokens: usage.totalTokens } : {}), ...(usage.inputTokenDetails?.cacheReadTokens !== undefined ? { cacheReadTokens: usage.inputTokenDetails.cacheReadTokens } : {}), }; return Object.keys(stageUsage).length > 0 ? stageUsage : undefined; } function withStageUsage( usage: StageUsageInput | undefined, ): { usage?: StageUsage } { const stageUsage = toStageUsage(usage); return stageUsage ? { usage: stageUsage } : {}; } function toOpenRouterUsageTokens(usage: OpenRouterUsage) { return { ...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens, promptTokens: usage.inputTokens } : {}), ...(usage.outputTokens !== undefined ? { outputTokens: usage.outputTokens, completionTokens: usage.outputTokens, } : {}), ...(usage.totalTokens !== undefined ? { totalTokens: usage.totalTokens } : {}), ...(usage.inputTokenDetails?.cacheReadTokens !== undefined ? { cacheReadTokens: usage.inputTokenDetails.cacheReadTokens } : {}), }; } function toDefinedUsageTokens(tokens: { inputTokens?: number; outputTokens?: number; promptTokens?: number; completionTokens?: number; totalTokens?: number; cacheReadTokens?: number; }) { return { ...(tokens.inputTokens !== undefined ? { inputTokens: tokens.inputTokens } : {}), ...(tokens.outputTokens !== undefined ? { outputTokens: tokens.outputTokens } : {}), ...(tokens.promptTokens !== undefined ? { promptTokens: tokens.promptTokens } : {}), ...(tokens.completionTokens !== undefined ? { completionTokens: tokens.completionTokens } : {}), ...(tokens.totalTokens !== undefined ? { totalTokens: tokens.totalTokens } : {}), ...(tokens.cacheReadTokens !== undefined ? { cacheReadTokens: tokens.cacheReadTokens } : {}), }; } type AuditEvidence = Awaited< ReturnType >; type GermanCopyOutput = { internalSummary: string; publicSummary: string; publicBody: string; emailSubject: string; emailBody: string; phoneScript: { openingLine: string; callScript: string[]; closeLine: string; }; followUpDraft: { message: string; followInDays?: number; goals?: string[]; }; }; type MultimodalContentPart = | { type: "text"; text: string; } | MultimodalFilePart; type MultimodalFilePart = { type: "file"; data: DataContent | URL; mediaType: string; filename?: string; }; type MultimodalUserMessage = { role: "user"; content: MultimodalContentPart[]; }; type OpenRouterUsage = { inputTokens?: number; outputTokens?: number; totalTokens?: number; inputTokenDetails?: { cacheReadTokens?: number }; }; type ExternalCaptureFetch = { response: Response; abortController: AbortController; timeout: ReturnType; }; const evidenceRunOptions = { maxCheckedPages: 8, screenshotLimit: 2, maxExternalMarkdownChars: MAX_JINA_MARKDOWN_CHARS, } as const; const terminalLeadContactStatuses = [ "do_not_contact", "contacted", "replied", ] as const; const specialistStageConfigs = [ { stage: "localSeoSpecialist", title: "Local SEO Specialist", focus: "NAP, Ort-Leistung-Relevanz, Title/Meta/H1, lokale Vertrauenssignale und Impressum-/Kontaktklarheit.", }, { stage: "conversionUxSpecialist", title: "Conversion UX Specialist", focus: "Kontaktpfad, CTA-Sichtbarkeit, Click-to-call, Formularreibung und mobile Handlungsfähigkeit.", }, { stage: "visualTrustSpecialist", title: "Visual Trust Specialist", focus: "Erster visueller Eindruck, Hierarchie, Lesbarkeit, Bild-/Team-/Vertrauenssignale aus Screenshots.", }, { stage: "critiqueSpecialist", title: "Impeccable Critique Specialist", focus: "Designkritik nach critique/impeccable: visuelle Hierarchie, Informationsarchitektur, kognitive Last, Nielsen-Heuristiken, AI-Slop-/Template-Indizien und persona-nahe Reibung.", guidance: "Nutze fuer passende Befunde skillId impeccable-critique. Liefere keine Heuristik-Score-Tabelle, sondern konkrete, evidence-gebundene Findings. Markenfit, Emotion oder AI-Slop nur behaupten, wenn Screenshot/Text/DOM es stuetzen.", }, { stage: "performanceAccessibilitySpecialist", title: "Performance Accessibility Specialist", focus: "Mobile Ladeerfahrung, PageSpeed-Auswirkungen, Tap-Ziele, Kontrast, Labels und einfache Barrieren.", }, ] as const; type SpecialistStage = (typeof specialistStageConfigs)[number]["stage"]; type VerifierCandidate = { findingId: string; finding: AuditSpecialistFinding; }; function toAuditGenerationProfileMessage(stage: string, runId: Id<"agentRuns">) { return { level: "info" as const, runId, message: `Audit-KI-Stufe ${stage} gestartet.`, details: [{ label: "Run-ID", value: String(runId) }], }; } function isTerminalLeadContactStatus(status?: string) { return status ? terminalLeadContactStatuses.includes( status as (typeof terminalLeadContactStatuses)[number], ) : false; } function buildClassificationPrompt(evidence: AuditEvidence) { return [ "Du bist Senior-Analyst und erzeugst intern verwertbare Befunde in Deutsch.", `Unternehmenskontext: ${evidence.companyContext.join(" | ")}`, `Prüfseiten: ${evidence.checkedPages.join(" ; ")}`, `UX-Signale: ${evidence.observedUxSignals.join(" ; ")}`, `Content-Signale: ${evidence.observedContentSignals.join(" ; ")}`, `Technische Signale: ${evidence.observedTechnicalSignals.join(" ; ")}`, `Seitenperformance: ${evidence.pageSpeedCustomerImplications.join(" ; ")}`, evidence.externalMarkdown ? `Externe Jina-Reader-Auszüge: ${evidence.externalMarkdown}` : "", "Antworte ausschließlich als JSON-Objekt mit den Schlüsseln:", "'findings' als Liste v3-validierter Befunde, 'summary' als kurzer Gesamttext und 'usedSkills' als Liste der verwendeten Skill-IDs oder null.", "Jeder Befund braucht skill_id, observation, customer_benefit, public_phrasing, severity (1-3), evidence und applies.", ].join("\n"); } function buildMultimodalPrompt(evidence: AuditEvidence, withScreenshots = false) { return [ "Du bist Senior-Digitalberater für lokale Unternehmen und analysierst visuelle und textuelle Hinweise.", `Unternehmenskontext: ${evidence.companyContext.join(" | ")}`, `Untersuchte Seiten: ${evidence.checkedPages.join(" ; ")}`, `UX-Signale: ${evidence.observedUxSignals.join(" ; ")}`, `Content-Signale: ${evidence.observedContentSignals.join(" ; ")}`, `Technische Signale: ${evidence.observedTechnicalSignals.join(" ; ")}`, `PageSpeed-Folgen: ${evidence.pageSpeedCustomerImplications.join(" ; ")}`, evidence.externalMarkdown ? `Externe Jina-Reader-Auszüge: ${evidence.externalMarkdown}` : "", withScreenshots ? "Bewerte, wo sinnvoll, sichtbare Seitelemente aus den Screenshots." : "Nutze die textuellen Befunde, um eine kurze visuelle Bewertung abzuleiten.", "Antworte als kurzes, intern nutzbares JSON-Objekt mit 'summary' und 3-6 'keyFindings'.", ].join("\n"); } function formatEvidenceLedger(evidence: AuditEvidence) { return evidence.evidenceLedger .slice(0, 24) .map((entry) => [ `id=${entry.id}`, `type=${entry.type}`, `label=${entry.label}`, entry.sourceUrl ? `url=${entry.sourceUrl}` : "", `summary=${entry.summary}`, ] .filter(Boolean) .join(" | "), ) .join("\n"); } function buildSpecialistPrompt( evidence: AuditEvidence, config: (typeof specialistStageConfigs)[number], ) { return [ `Du bist ${config.title} für lokale Website-Audits.`, `Fokus: ${config.focus}`, "guidance" in config ? config.guidance : "", "Erzeuge nur Befunde, die mit evidenceRefs aus dem Evidence-Ledger belegt sind.", "Nutze keine Unknown-/Unbekannt-Werte als Kundenbefund. Wenn Belege fehlen, liefere keinen Befund.", "Jeder Befund braucht skillId, claim, recommendation, customerBenefit, severity, confidence, evidenceRefs, applies und unknowns.", "Jede evidenceRef braucht id, type, label und sourceUrl; nutze die Ledger-URL oder einen leeren String.", "Antworte mit status, findings und notes; wenn nichts belegt ist, nutze findings: [] und erklaerende notes.", `Unternehmenskontext: ${evidence.companyContext.join(" | ")}`, `Prüfseiten: ${evidence.checkedPages.join(" ; ")}`, `UX-Signale: ${evidence.observedUxSignals.join(" ; ")}`, `Content-Signale: ${evidence.observedContentSignals.join(" ; ")}`, `Technische Signale: ${evidence.observedTechnicalSignals.join(" ; ")}`, `PageSpeed-Folgen: ${evidence.pageSpeedCustomerImplications.join(" ; ")}`, `Evidence-Ledger:\n${formatEvidenceLedger(evidence)}`, ].join("\n"); } function toVerifierCandidates( findings: readonly AuditSpecialistFinding[], ): VerifierCandidate[] { return findings.slice(0, 12).map((finding, index) => ({ findingId: `finding-${index + 1}`, finding, })); } function formatVerifierCandidate(candidate: VerifierCandidate) { const { finding, findingId } = candidate; return [ `id=${findingId}`, `skillId=${finding.skillId}`, `claim=${finding.claim}`, `recommendation=${finding.recommendation}`, `customerBenefit=${finding.customerBenefit}`, `severity=${finding.severity}`, `confidence=${Math.round(finding.confidence * 100)}%`, `evidenceRefs=${finding.evidenceRefs .map((ref) => `${ref.id} (${ref.type}, ${ref.label})`) .join("; ")}`, finding.unknowns.length > 0 ? `unknowns=${finding.unknowns.join("; ")}` : "", ] .filter(Boolean) .join("\n"); } function buildEvidenceVerifierPrompt( candidates: readonly VerifierCandidate[], evidence: AuditEvidence, ) { return [ "Du bist EvidenceQA und verifizierst Audit-Befunde.", "Behalte nur Befunde, die konkrete evidenceRefs besitzen, nicht generisch sind und keine Unknown-Werte als Claim nutzen.", "Lege widersprüchliche CTA/Kontakt/Meta-Aussagen in contradictions offen.", "Antworte mit verifiedFindingIds, rejectedFindings, contradictions und notes.", "verifiedFindingIds enthaelt nur IDs aus den unten gelisteten Befunden.", "Gib keine vollstaendigen verified Findings zurueck; die Anwendung uebernimmt die Originalbefunde anhand der IDs.", "Ein rejectedFinding braucht findingId, skillId, claim und rejectionReason.", `Evidence-Ledger:\n${formatEvidenceLedger(evidence)}`, `Befunde zur Prüfung:\n${candidates.map(formatVerifierCandidate).join("\n\n")}`, ].join("\n"); } function formatVerifiedFindings(findings: readonly AuditSpecialistFinding[]) { return findings .map((finding, index) => [ `${index + 1}. [${finding.skillId}] ${finding.claim}`, `Empfehlung: ${finding.recommendation}`, `Nutzen: ${finding.customerBenefit}`, `Priorität: ${finding.severity}; Sicherheit: ${Math.round(finding.confidence * 100)}%`, `Belege: ${finding.evidenceRefs.map((ref) => `${ref.type}:${ref.label}`).join(", ")}`, ].join("\n"), ) .join("\n\n"); } function buildGermanCopyPrompt( internalFindings: string, multimodalSummary: string, evidence: AuditEvidence, ) { return [ "Du bist Senior-Redakteur für lokale Kundengewinnung.", "Erstelle kundenrelevante Texte in deutscher Sprache und nutze ausschließlich verifizierte Befunde als fachliche Grundlage.", "Vermeide mechanische Wiederholungen wie 'Ich habe beobachtet' oder 'Ich schlage vor'.", "PublicSummary und PublicBody dürfen auditartig bleiben, sollen aber natürlich und konkret klingen.", buildCustomerTonePromptSection(), `Lead-/Unternehmenskontext: ${evidence.companyContext.join(" | ")}`, `Geprüfte Seiten: ${evidence.checkedPages.join(" ; ")}`, `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.", "Prüfe besonders die E-Mail: Klingt sie wie eine echte Erstmail von Matthias?", "Würde ein lokaler Betrieb sie als hilfreichen Hinweis lesen, nicht als KI-Verkaufstext?", "Ist jede konkrete Behauptung in der E-Mail durch verified findings / verifizierte Befunde gedeckt?", buildCustomerTonePromptSection(), `Interne Befunde: ${internalFindings}`, `Öffentliche Zusammenfassung: ${germanCopy.publicSummary}`, `Öffentlicher Text: ${germanCopy.publicBody}`, `Email-Betreff: ${germanCopy.emailSubject}`, `Email-Text: ${germanCopy.emailBody}`, "Antworte als JSON mit isValid, issues, suggestions, notes.", ].join("\n"); } function toSkillSummaries( skills: Array<{ id?: string; name: string; category?: string; version?: string; source?: string; }>, registry: Array<{ id?: string; name: string; purpose?: string; instructions?: string; requiredInput?: string; expectedOutput?: string; category?: string; version?: string; source?: string; }> = [], ) { return skills.slice(0, 6).map((skill) => ({ name: skill.name, purpose: registry.find( (candidate) => (skill.id && candidate.id === skill.id) || candidate.name === skill.name, )?.purpose ?? registry.find( (candidate) => (skill.id && candidate.id === skill.id) || candidate.name === skill.name, )?.instructions ?? "Zweckbeschreibung nicht verfügbar.", summary: [ skill.name, skill.version ? `Version ${skill.version}` : "", skill.category ? `Kategorie ${skill.category}` : "", skill.source ? `Quelle ${skill.source}` : "", ] .filter(Boolean) .join(" · "), })); } function toPersistedUsedSkill(skill: AuditUsedSkill) { return { ...(skill.id ? { id: skill.id } : {}), name: skill.name, ...(skill.category ? { category: skill.category } : {}), ...(skill.version ? { version: skill.version } : {}), ...(skill.source ? { source: skill.source } : {}), }; } async function appendRunEvent( ctx: ActionCtx, args: { runId: Id<"agentRuns">; level: "info" | "warning" | "error"; message: string; details?: { label: string; value: string; source?: string }[]; }, ) { await ctx.runMutation(internal.runs.appendEventInternal, { runId: args.runId, level: args.level, message: args.message, ...(args.details !== undefined ? { details: args.details } : {}), }); } async function loadAuditSkillRegistry( ctx: ActionCtx, runId: Id<"agentRuns">, ): Promise> { try { return loadLocalAuditSkillRegistry(); } catch (error) { const safeErrorSummary = messageFromError(error); try { await appendRunEvent(ctx, { runId, level: "warning", message: "Skill-Registry konnte nicht geladen werden; Audit läuft ohne Skill-Auswahl weiter.", details: [{ label: "Fehler", value: safeErrorSummary }], }); } catch { // Registry loading is best-effort; warning persistence must not fail the run. } return []; } } function toTargetUrl(lead: { websiteUrl?: string; websiteDomain?: string }) { if (lead.websiteUrl) { return lead.websiteUrl; } if (lead.websiteDomain) { return `https://${lead.websiteDomain}`; } return null; } async function recordAuditUsageEvent( ctx: ActionCtx, args: { runId: Id<"agentRuns">; leadId: Id<"leads">; auditId?: Id<"audits">; provider: "openrouter" | "screenshotone" | "jina"; operation: "audit_capture" | "audit_generation"; estimatedCostUsd: number; tokens?: { inputTokens?: number; outputTokens?: number; promptTokens?: number; completionTokens?: number; totalTokens?: number; cacheReadTokens?: number; }; callCounts?: { requests?: number; pages?: number; screenshots?: number; }; }, ) { const tokens = args.tokens ? toDefinedUsageTokens(args.tokens) : undefined; const callCounts = args.callCounts ? { ...(args.callCounts.requests !== undefined ? { requests: args.callCounts.requests } : {}), ...(args.callCounts.pages !== undefined ? { pages: args.callCounts.pages } : {}), ...(args.callCounts.screenshots !== undefined ? { screenshots: args.callCounts.screenshots } : {}), } : undefined; try { await ctx.runMutation(internal.usageEvents.recordUsageEvent, { provider: args.provider, operation: args.operation, runId: args.runId, leadId: args.leadId, ...(args.auditId ? { auditId: args.auditId } : {}), estimatedCostUsd: args.estimatedCostUsd, ...(tokens && Object.keys(tokens).length > 0 ? { tokens } : {}), ...(callCounts && Object.keys(callCounts).length > 0 ? { callCounts } : {}), }); } catch (error) { const safeErrorSummary = messageFromError(error); try { await appendRunEvent(ctx, { runId: args.runId, level: "warning", message: "Usage-Logging konnte nicht gespeichert werden.", details: [ { label: "Provider", value: args.provider }, { label: "Fehler", value: safeErrorSummary }, ], }); } catch { return; } } } async function recordOpenRouterUsage( ctx: ActionCtx, args: { runId: Id<"agentRuns">; leadId: Id<"leads">; auditId?: Id<"audits">; usage?: OpenRouterUsage; }, ) { if (!args.usage) { return; } const estimate = estimateExternalAuditCostUsd({ openRouter: { inputTokens: args.usage.inputTokens, outputTokens: args.usage.outputTokens, }, }); await recordAuditUsageEvent(ctx, { runId: args.runId, leadId: args.leadId, ...(args.auditId ? { auditId: args.auditId } : {}), provider: "openrouter", operation: "audit_generation", estimatedCostUsd: estimate.byProvider.openRouter, tokens: toOpenRouterUsageTokens(args.usage), }); } function sumUsageField( usages: readonly (OpenRouterUsage | undefined)[], field: "inputTokens" | "outputTokens" | "totalTokens", ) { return usages.reduce((sum, usage) => sum + (usage?.[field] ?? 0), 0); } function aggregateOpenRouterUsage( usages: readonly (OpenRouterUsage | undefined)[], ): OpenRouterUsage | undefined { const inputTokens = sumUsageField(usages, "inputTokens"); const outputTokens = sumUsageField(usages, "outputTokens"); const totalTokens = sumUsageField(usages, "totalTokens"); const cacheReadTokens = usages.reduce( (sum, usage) => sum + (usage?.inputTokenDetails?.cacheReadTokens ?? 0), 0, ); if ( inputTokens === 0 && outputTokens === 0 && totalTokens === 0 && cacheReadTokens === 0 ) { return undefined; } return { inputTokens, outputTokens, totalTokens, ...(cacheReadTokens > 0 ? { inputTokenDetails: { cacheReadTokens } } : {}), }; } function dimensionsForViewport(viewport: ScreenshotOneRequest["viewport"]) { return viewport === "mobile" ? { width: 390, height: 844 } : { width: 1280, height: 900 }; } async function fetchExternalCapture( input: string, init: RequestInit = {}, ): Promise { const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(), EXTERNAL_CAPTURE_TIMEOUT_MS, ); try { const response = await fetch(input, { ...init, signal: controller.signal, }); return { response, abortController: controller, timeout }; } catch (error) { clearTimeout(timeout); throw error; } } function clearExternalCaptureTimeout(capture: ExternalCaptureFetch | undefined) { if (capture) { clearTimeout(capture.timeout); } } async function readLimitedResponseBytes( response: Response, maxBytes: number, signal?: AbortSignal, ): Promise { const reader = response.body?.getReader(); if (!reader) { throw new Error("External capture response body is unavailable."); } const chunks: Uint8Array[] = []; let totalBytes = 0; const cancelReader = () => { void reader.cancel().catch(() => null); }; if (signal?.aborted) { await reader.cancel().catch(() => null); throw new Error("External capture response timed out."); } signal?.addEventListener("abort", cancelReader, { once: true }); try { while (true) { if (signal?.aborted) { throw new Error("External capture response timed out."); } const { done, value } = await reader.read(); if (signal?.aborted) { throw new Error("External capture response timed out."); } if (done) { break; } if (!value) { continue; } totalBytes += value.byteLength; if (totalBytes > maxBytes) { await reader.cancel().catch(() => null); throw new Error("External capture response exceeded the configured body limit."); } chunks.push(value); } } finally { signal?.removeEventListener("abort", cancelReader); reader.releaseLock(); } const bytes = new Uint8Array(totalBytes); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes; } async function readLimitedMarkdown(response: Response, signal?: AbortSignal) { const bytes = await readLimitedResponseBytes( response, MAX_JINA_MARKDOWN_BYTES, signal, ); return new TextDecoder().decode(bytes).slice(0, MAX_JINA_MARKDOWN_CHARS); } async function cancelExternalResponseBody(response: Response) { if (!response.body) { return; } try { await response.body.cancel(); } catch { return; } } async function captureExternalAuditArtifacts( ctx: ActionCtx, args: { runId: Id<"agentRuns">; leadId: Id<"leads">; auditId?: Id<"audits">; targetUrl: string | null; needsScreenshots: boolean; needsMarkdown: boolean; }, ) { let jinaReaderAuditInput: | ReturnType | undefined; const screenshots: Array<{ storageId: Id<"_storage">; viewport: "desktop" | "mobile"; sourceUrl: string; capturedAt: number; width: number; height: number; mimeType: string; }> = []; if (!args.targetUrl) { return { screenshots, jinaReaderAuditInput }; } if (args.needsScreenshots) { const screenshotOneApiKey = process.env.SCREENSHOTONE_API_KEY; if (!screenshotOneApiKey) { try { await appendRunEvent(ctx, { runId: args.runId, level: "warning", message: "ScreenshotOne ist nicht konfiguriert; Screenshot-Erfassung wurde übersprungen.", }); } catch { // Capture warnings are best-effort; classification can continue. } } else { try { const screenshotRequests = buildScreenshotOneRequests({ accessKey: screenshotOneApiKey, targetUrl: args.targetUrl, }); const estimate = estimateExternalAuditCostUsd({ screenshotOne: { screenshots: screenshotRequests.length }, }); await recordAuditUsageEvent(ctx, { runId: args.runId, leadId: args.leadId, ...(args.auditId ? { auditId: args.auditId } : {}), provider: "screenshotone", operation: "audit_capture", estimatedCostUsd: estimate.byProvider.screenshotOne, callCounts: { requests: screenshotRequests.length, screenshots: screenshotRequests.length, }, }); for (const request of screenshotRequests) { let capture: ExternalCaptureFetch | undefined; try { capture = await fetchExternalCapture(request.url); const response = capture.response; if (!response.ok) { await cancelExternalResponseBody(response); continue; } const mimeType = response.headers.get("content-type") ?? "image/png"; const screenshotBytes = await readLimitedResponseBytes( response, MAX_SCREENSHOT_BYTES, capture.abortController.signal, ); const screenshotBlobBytes = new Uint8Array(screenshotBytes); const storageId = await ctx.storage.store( new Blob([screenshotBlobBytes], { type: mimeType }), ); const dimensions = dimensionsForViewport(request.viewport); const capturedAt = Date.now(); const sourceUrl = new URL(request.url).searchParams.get("url") ?? args.targetUrl; await ctx.runMutation( internal.auditGeneration.persistExternalCaptureScreenshot, { leadId: args.leadId, runId: args.runId, storageId, viewport: request.viewport, sourceUrl, capturedAt, width: dimensions.width, height: dimensions.height, mimeType, }, ); screenshots.push({ storageId, viewport: request.viewport, sourceUrl, capturedAt, width: dimensions.width, height: dimensions.height, mimeType, }); } catch { continue; } finally { clearExternalCaptureTimeout(capture); } } } catch (error) { const safeErrorSummary = messageFromError(error); try { await appendRunEvent(ctx, { runId: args.runId, level: "warning", message: "ScreenshotOne-Capture konnte nicht vorbereitet werden.", details: [{ label: "Fehler", value: safeErrorSummary }], }); } catch { // Capture warnings are best-effort; classification can continue. } } } } if (args.needsMarkdown) { const jinaApiKey = process.env.JINA_API_KEY; try { jinaReaderAuditInput = buildJinaReaderAuditInput({ baseUrl: args.targetUrl, maxMarkdownChars: evidenceRunOptions.maxExternalMarkdownChars, }); const jinaPages: JinaReaderPageInput[] = []; for (const page of jinaReaderAuditInput.pages) { let capture: ExternalCaptureFetch | undefined; try { capture = await fetchExternalCapture(page.readerUrl, { headers: jinaApiKey ? { Authorization: `Bearer ${jinaApiKey}` } : undefined, }); const response = capture.response; if (!response.ok) { await cancelExternalResponseBody(response); continue; } jinaPages.push({ url: page.sourceUrl, markdown: await readLimitedMarkdown( response, capture.abortController.signal, ), }); } catch { continue; } finally { clearExternalCaptureTimeout(capture); } } jinaReaderAuditInput = buildJinaReaderAuditInput({ baseUrl: args.targetUrl, pages: jinaPages, maxMarkdownChars: evidenceRunOptions.maxExternalMarkdownChars, }); const estimate = estimateExternalAuditCostUsd({ jina: { requests: jinaReaderAuditInput.readerUrls.length, pages: jinaReaderAuditInput.pages.length, }, }); await recordAuditUsageEvent(ctx, { runId: args.runId, leadId: args.leadId, ...(args.auditId ? { auditId: args.auditId } : {}), provider: "jina", operation: "audit_capture", estimatedCostUsd: estimate.byProvider.jina, callCounts: { requests: jinaReaderAuditInput.readerUrls.length, pages: jinaReaderAuditInput.pages.length, }, }); } catch (error) { const safeErrorSummary = messageFromError(error); jinaReaderAuditInput = undefined; try { await appendRunEvent(ctx, { runId: args.runId, level: "warning", message: "Jina-Capture konnte nicht vorbereitet werden.", details: [{ label: "Fehler", value: safeErrorSummary }], }); } catch { // Capture warnings are best-effort; classification can continue. } } } return { screenshots, jinaReaderAuditInput }; } async function persistAuditStage({ ctx, runId, leadId, auditId, stage, modelProfile, modelId, prompt, systemPrompt, rawResponse, parsedJson, usage, status, finishReason, errorSummary, }: { ctx: ActionCtx; runId: Id<"agentRuns">; leadId: Id<"leads">; auditId?: Id<"audits">; stage: | "classification" | SpecialistStage | "evidenceVerifier" | "multimodalAudit" | "germanCopy" | "qualityReview"; modelProfile: string; modelId: string; prompt: string; systemPrompt?: string; rawResponse?: string; parsedJson?: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number; cacheReadTokens?: number; }; status: "pending" | "running" | "succeeded" | "failed" | "canceled"; finishReason?: string; errorSummary?: string; }) { const persistedUsage = usage ? toPersistedUsage(usage) : undefined; await ctx.runMutation(internal.auditGeneration.persistAuditGenerationResult, { leadId, runId, ...(auditId ? { auditId } : {}), stage, modelProfile, modelId, prompt, ...(systemPrompt !== undefined ? { systemPrompt } : {}), ...(rawResponse !== undefined ? { rawResponse } : {}), ...(parsedJson !== undefined ? { parsedJson } : {}), ...(persistedUsage && Object.keys(persistedUsage).length > 0 ? { usage: persistedUsage } : {}), status, ...(finishReason !== undefined ? { finishReason } : {}), ...(errorSummary !== undefined ? { errorSummary } : {}), }); } function getValidMediaType(mimeType: string) { if (!mimeType) { return "image/png"; } if (mimeType.startsWith("image/")) { return mimeType; } return "image/png"; } export const processAuditGeneration = internalAction({ args: { runId: v.id("agentRuns"), }, handler: async (ctx, args) => { let started: | { lead: { _id: Id<"leads">; websiteUrl?: string; websiteDomain?: string; contactStatus?: string; }; auditId?: Id<"audits">; } | null = null; let auditId: Id<"audits"> | undefined; let classificationSummary = ""; let multimodalSummary = ""; let germanCopyOutput: GermanCopyOutput = { internalSummary: "", publicSummary: "", publicBody: "", emailSubject: "", emailBody: "", phoneScript: { openingLine: "", callScript: [], closeLine: "", }, followUpDraft: { message: "", }, }; let qualityPassed = false; let errors = 0; let currentStep: | "audit_generation" | "classification" | SpecialistStage | "evidenceVerifier" | "multimodalAudit" | "germanCopy" | "qualityReview" = "audit_generation"; let verifiedFindings: AuditSpecialistFinding[] = []; try { started = await ctx.runMutation(internal.auditGeneration.startAuditGenerationRun, { runId: args.runId, }); } catch (error) { const safeErrorSummary = messageFromError(error); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Audit-Generierung konnte nicht gestartet werden.", details: [{ label: "Fehler", value: safeErrorSummary }], }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors: 1, errorSummary: "Start der Audit-Generierung fehlgeschlagen.", currentStep: "audit_generation", }); return null; } if (!started) { return null; } try { const evidence = await ctx.runQuery(internal.auditGeneration.getAuditGenerationEvidence, { runId: args.runId, }); if (!evidence) { await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Audit-Generierung kann keine Datenbasis laden.", }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors: 1, currentStep, errorSummary: "Evidence-Daten für den Lead konnten nicht geladen werden.", }); return null; } if (started.auditId) { auditId = started.auditId; } const targetUrl = toTargetUrl(started.lead); const hasLegacyMarkdown = evidence.crawlPages.some( (page) => Boolean(page.visibleTextExcerpt), ); const externalCapture = await captureExternalAuditArtifacts(ctx, { runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), targetUrl, needsScreenshots: evidence.screenshots.length === 0, needsMarkdown: !hasLegacyMarkdown, }); const evidenceScreenshots = evidence.screenshots.length > 0 ? evidence.screenshots : externalCapture.screenshots; const externalMarkdown = externalCapture.jinaReaderAuditInput?.markdown; const skillRegistry = await loadAuditSkillRegistry(ctx, args.runId); const evidenceInput = buildAuditEvidenceInput({ lead: evidence.lead, crawlPages: evidence.crawlPages, technicalChecks: evidence.technicalChecks, screenshots: evidenceScreenshots.slice(0, evidenceRunOptions.screenshotLimit), pageSpeedInputs: evidence.pageSpeedInputs, skillRegistry, externalMarkdown, }); await appendRunEvent(ctx, toAuditGenerationProfileMessage("Start", args.runId)); const provider = createOpenRouterProvider(); const classificationProfile = resolveModelProfile("classification"); const multimodalProfile = resolveModelProfile("multimodalAudit"); const germanCopyProfile = resolveModelProfile("germanCopy"); const qualityReviewProfile = resolveModelProfile("qualityReview"); // Stage 1: classification const classificationPrompt = buildClassificationPrompt(evidenceInput); const classificationSystemPrompt = "Du bist interner KI-Berater für Website-Audits. Gib nur strukturierte JSON-Ausgaben zurück."; const safeClassificationPrompt = sanitizeAndCapString( classificationPrompt, MAX_PROMPT_BYTES, ); currentStep = "classification"; await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "classification", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeClassificationPrompt ?? "", systemPrompt: classificationSystemPrompt, status: "running", }); try { const classificationResult = await generateObject({ model: provider(classificationProfile.modelId), system: classificationSystemPrompt, schema: auditClassificationSchema, prompt: safeClassificationPrompt ?? "", temperature: classificationProfile.temperature, maxOutputTokens: classificationProfile.maxTokens, }); classificationSummary = typeof classificationResult.object.summary === "string" ? classificationResult.object.summary : ""; const rawClassification = safeStringify(classificationResult.object); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "classification", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeClassificationPrompt ?? "", systemPrompt: classificationSystemPrompt, rawResponse: sanitizeAndCapString( rawClassification, MAX_RAW_RESPONSE_BYTES, ), parsedJson: sanitizeAndCapParsedJson(classificationResult.object), ...withStageUsage(classificationResult.usage), status: "succeeded", finishReason: classificationResult.finishReason, }); await recordOpenRouterUsage(ctx, { runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), usage: classificationResult.usage, }); await appendRunEvent(ctx, { runId: args.runId, level: "info", message: "Interne Klassifikation abgeschlossen.", details: [ { label: "Befunde", value: String(classificationResult.object.findings.length) }, ], }); } catch (error) { errors += 1; const safeErrorSummary = messageFromError(error); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "classification", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeClassificationPrompt ?? "", systemPrompt: classificationSystemPrompt, status: "failed", errorSummary: safeErrorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Interne Klassifikation fehlgeschlagen.", details: [{ label: "Fehler", value: safeErrorSummary }], }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors, errorSummary: "Interne Klassifikation konnte nicht erstellt werden.", currentStep: "classification", }); return null; } // Stage 2: specialist fan-out and evidence verification const specialistSystemPrompt = "Du bist ein spezialisierter Website-Audit-Agent. Antworte ausschließlich als JSON gemäß Schema."; const specialistResults = await Promise.all( specialistStageConfigs.map(async (config): Promise => { const specialistPrompt = buildSpecialistPrompt(evidenceInput, config); const safeSpecialistPrompt = sanitizeAndCapString( specialistPrompt, MAX_PROMPT_BYTES, ); currentStep = config.stage; await persistAuditStage({ ctx, runId: args.runId, leadId: started!.lead._id, ...(auditId ? { auditId } : {}), stage: config.stage, modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeSpecialistPrompt ?? "", systemPrompt: specialistSystemPrompt, status: "running", }); try { const specialistResult = await generateObject({ model: provider(classificationProfile.modelId), system: specialistSystemPrompt, schema: auditSpecialistResultSchema, prompt: safeSpecialistPrompt ?? "", temperature: classificationProfile.temperature, maxOutputTokens: classificationProfile.maxTokens, }); await persistAuditStage({ ctx, runId: args.runId, leadId: started!.lead._id, ...(auditId ? { auditId } : {}), stage: config.stage, modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeSpecialistPrompt ?? "", systemPrompt: specialistSystemPrompt, rawResponse: sanitizeAndCapString( safeStringify(specialistResult.object), MAX_RAW_RESPONSE_BYTES, ), parsedJson: sanitizeAndCapParsedJson(specialistResult.object), ...withStageUsage(specialistResult.usage), status: "succeeded", finishReason: specialistResult.finishReason, }); await recordOpenRouterUsage(ctx, { runId: args.runId, leadId: started!.lead._id, ...(auditId ? { auditId } : {}), usage: specialistResult.usage, }); return specialistResult.object; } catch (error) { const safeErrorSummary = messageFromError(error); await persistAuditStage({ ctx, runId: args.runId, leadId: started!.lead._id, ...(auditId ? { auditId } : {}), stage: config.stage, modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeSpecialistPrompt ?? "", systemPrompt: specialistSystemPrompt, status: "failed", errorSummary: safeErrorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "warning", message: `${config.title} konnte keine Befunde liefern.`, details: [{ label: "Fehler", value: safeErrorSummary }], }); return { status: "failed", findings: [], notes: [safeErrorSummary], }; } }), ); const specialistFindings = specialistResults.flatMap((result) => result.findings.filter((finding) => finding.applies), ); const verifierCandidates = toVerifierCandidates(specialistFindings); const verifierPrompt = buildEvidenceVerifierPrompt( verifierCandidates, evidenceInput, ); const safeVerifierPrompt = sanitizeAndCapString( verifierPrompt, MAX_PROMPT_BYTES, ); const verifierSystemPrompt = "Du bist EvidenceQA. Verifiziere Befunde streng gegen belegte Evidence-Refs."; currentStep = "evidenceVerifier"; await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "evidenceVerifier", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeVerifierPrompt ?? "", systemPrompt: verifierSystemPrompt, status: "running", }); try { const verifierResult = await generateObject({ model: provider(classificationProfile.modelId), system: verifierSystemPrompt, schema: auditEvidenceVerificationSchema, prompt: safeVerifierPrompt ?? "", temperature: classificationProfile.temperature, maxOutputTokens: classificationProfile.maxTokens, }); const verifiedFindingIds = new Set( verifierResult.object.verifiedFindingIds, ); verifiedFindings = verifierCandidates .filter((candidate) => verifiedFindingIds.has(candidate.findingId)) .map((candidate) => candidate.finding); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "evidenceVerifier", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeVerifierPrompt ?? "", systemPrompt: verifierSystemPrompt, rawResponse: sanitizeAndCapString( safeStringify(verifierResult.object), MAX_RAW_RESPONSE_BYTES, ), parsedJson: sanitizeAndCapParsedJson(verifierResult.object), ...withStageUsage(verifierResult.usage), status: "succeeded", finishReason: verifierResult.finishReason, }); await recordOpenRouterUsage(ctx, { runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), usage: verifierResult.usage, }); } catch (error) { errors += 1; const safeErrorSummary = messageFromError(error); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "evidenceVerifier", modelProfile: "classification", modelId: classificationProfile.modelId, prompt: safeVerifierPrompt ?? "", systemPrompt: verifierSystemPrompt, status: "failed", errorSummary: safeErrorSummary, }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors, errorSummary: "Evidence-Verifikation konnte nicht abgeschlossen werden.", currentStep: "evidenceVerifier", }); return null; } if (verifiedFindings.length === 0) { await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors: errors + 1, errorSummary: "Keine belegten Audit-Befunde nach Evidence-Verifikation.", currentStep: "evidenceVerifier", }); return null; } // Stage 2: multimodal audit summary const multimodalSystemPrompt = "Du bist Prüfanalyst für Conversion-Optimierung mit Fokus auf lokale Unternehmen."; const multimodalPrompt = buildMultimodalPrompt(evidenceInput, true); const safeMultimodalPrompt = sanitizeAndCapString( multimodalPrompt, MAX_PROMPT_BYTES, ); const screenshotSources = multimodalProfile.supportsImages ? evidenceScreenshots.slice(0, evidenceRunOptions.screenshotLimit) : []; const screenshotParts = screenshotSources.length > 0 ? await Promise.all( screenshotSources.map( async (screenshot): Promise => { try { const storageId = screenshot.storageId as Id<"_storage">; const maybeBlob = await ctx.storage.get(storageId); if (maybeBlob) { const fileData = await maybeBlob.arrayBuffer(); return { type: "file" as const, data: fileData, mediaType: getValidMediaType(screenshot.mimeType), }; } const storageUrl = await ctx.storage.getUrl(storageId); if (storageUrl) { return { type: "file" as const, data: storageUrl, mediaType: getValidMediaType(screenshot.mimeType), }; } } catch { return null; } return null; }, ), ) : []; if (!multimodalProfile.supportsImages && evidenceScreenshots.length > 0) { await appendRunEvent(ctx, { runId: args.runId, level: "info", message: "Multimodales Modell unterstützt keine Bilder; Analyse läuft textbasiert.", }); } currentStep = "multimodalAudit"; const validScreenshotParts = screenshotParts.filter( (part): part is MultimodalFilePart => part !== null, ); if (validScreenshotParts.length === 0) { await appendRunEvent(ctx, { runId: args.runId, level: "warning", message: "Keine multimodalen Belege verfügbar; Analyse läuft textbasiert.", details: [ { label: "Hinweis", value: "Screenshots konnten nicht geladen werden.", }, ], }); } try { await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "multimodalAudit", modelProfile: "multimodalAudit", modelId: multimodalProfile.modelId, prompt: safeMultimodalPrompt ?? "", systemPrompt: multimodalSystemPrompt, status: "running", }); let multimodalResult: | { object: { summary?: string; keyFindings?: string[] }; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number; inputTokenDetails?: { cacheReadTokens?: number }; }; finishReason?: string; } = { object: { summary: "" }, usage: undefined, }; if (validScreenshotParts.length > 0) { const multimodalUserMessage: MultimodalUserMessage = { role: "user", content: [ { type: "text", text: safeMultimodalPrompt ?? "" }, ...validScreenshotParts, ], }; multimodalResult = await generateObject({ model: provider(multimodalProfile.modelId), system: multimodalSystemPrompt, schema: auditSummarySchema, temperature: multimodalProfile.temperature, maxOutputTokens: multimodalProfile.maxTokens, messages: [multimodalUserMessage], }); } else { const multimodalTextMessage: MultimodalUserMessage = { role: "user", content: [{ type: "text", text: safeMultimodalPrompt ?? "" }], }; multimodalResult = await generateObject({ model: provider(multimodalProfile.modelId), system: multimodalSystemPrompt, schema: auditSummarySchema, temperature: multimodalProfile.temperature, maxOutputTokens: multimodalProfile.maxTokens, messages: [multimodalTextMessage], }); } if (!multimodalResult?.object) { throw new Error( "Multimodale Audit-Analyse konnte nicht ausgeführt werden.", ); } multimodalSummary = typeof multimodalResult.object.summary === "string" ? multimodalResult.object.summary : ""; const multimodalRaw = safeStringify(multimodalResult.object); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "multimodalAudit", modelProfile: "multimodalAudit", modelId: multimodalProfile.modelId, prompt: safeMultimodalPrompt ?? "", systemPrompt: multimodalSystemPrompt, rawResponse: sanitizeAndCapString( multimodalRaw, MAX_RAW_RESPONSE_BYTES, ), parsedJson: sanitizeAndCapParsedJson(multimodalResult.object), ...withStageUsage(multimodalResult.usage), status: "succeeded", finishReason: multimodalResult.finishReason, }); await recordOpenRouterUsage(ctx, { runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), usage: multimodalResult.usage, }); await appendRunEvent(ctx, { runId: args.runId, level: "info", message: "Multimodale Audit-Analyse abgeschlossen.", }); } catch (error) { errors += 1; const safeErrorSummary = messageFromError(error); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "multimodalAudit", modelProfile: "multimodalAudit", modelId: multimodalProfile.modelId, prompt: safeMultimodalPrompt ?? "", systemPrompt: multimodalSystemPrompt, status: "failed", errorSummary: safeErrorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Multimodale Audit-Analyse fehlgeschlagen.", details: [{ label: "Fehler", value: safeErrorSummary }], }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors, errorSummary: "Multimodale Audit-Analyse konnte nicht abgeschlossen werden.", currentStep: "multimodalAudit", }); return null; } currentStep = "germanCopy"; // Stage 3: german copy generation const germanSystemPrompt = "Du bist fachlicher Texter für lokale Unternehmen im B2B-Kontext."; const verifiedFindingsText = formatVerifiedFindings(verifiedFindings); const germanPrompt = buildGermanCopyPrompt( verifiedFindingsText, multimodalSummary, evidenceInput, ); const safeGermanPrompt = sanitizeAndCapString(germanPrompt, MAX_PROMPT_BYTES); try { const publicSummaryResult = await generateObject({ model: provider(germanCopyProfile.modelId), system: germanSystemPrompt, schema: publicAuditTextSchema, prompt: safeGermanPrompt ? `${safeGermanPrompt}\nAusgabe für publicSummary` : "Ausgabe für publicSummary", temperature: germanCopyProfile.temperature, maxOutputTokens: germanCopyProfile.maxTokens, }); const germanBodyResult = await generateObject({ model: provider(germanCopyProfile.modelId), system: germanSystemPrompt, schema: publicAuditTextSchema, prompt: `${safeGermanPrompt ?? ""}\nAusgabe für publicBody`, temperature: germanCopyProfile.temperature, maxOutputTokens: germanCopyProfile.maxTokens, }); const germanSubjectResult = await generateObject({ model: provider(germanCopyProfile.modelId), system: germanSystemPrompt, schema: emailSubjectSchema, prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailSubject`, temperature: germanCopyProfile.temperature, maxOutputTokens: germanCopyProfile.maxTokens, }); const germanEmailResult = await generateObject({ model: provider(germanCopyProfile.modelId), system: germanSystemPrompt, schema: emailDraftSchema, prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailBody`, temperature: germanCopyProfile.temperature, maxOutputTokens: germanCopyProfile.maxTokens, }); const germanCallScriptResult = await generateObject({ model: provider(germanCopyProfile.modelId), system: germanSystemPrompt, schema: callScriptSchema, prompt: `${safeGermanPrompt ?? ""}\nAusgabe für callScript`, temperature: germanCopyProfile.temperature, maxOutputTokens: germanCopyProfile.maxTokens, }); const germanFollowUpResult = await generateObject({ model: provider(germanCopyProfile.modelId), system: germanSystemPrompt, schema: followUpDraftSchema, prompt: `${safeGermanPrompt ?? ""}\nAusgabe für followUpDraft`, temperature: germanCopyProfile.temperature, maxOutputTokens: germanCopyProfile.maxTokens, }); const publicSummary = publicSummaryResult.object.publicText ?? ""; const publicBody = germanBodyResult.object.publicText ?? ""; germanCopyOutput = { internalSummary: classificationSummary, publicSummary, publicBody, emailSubject: germanSubjectResult.object.subject ?? "", emailBody: germanEmailResult.object.body ?? "", phoneScript: { openingLine: germanCallScriptResult.object.openingLine ?? "", callScript: germanCallScriptResult.object.callScript ?? [], closeLine: germanCallScriptResult.object.closeLine ?? "", }, followUpDraft: { message: germanFollowUpResult.object.message ?? "", ...(germanFollowUpResult.object.followInDays !== null ? { followInDays: germanFollowUpResult.object.followInDays } : {}), goals: germanFollowUpResult.object.goals ?? [], }, }; const germanRaw = safeStringify(germanCopyOutput); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "germanCopy", modelProfile: "germanCopy", modelId: germanCopyProfile.modelId, prompt: safeGermanPrompt ?? "", systemPrompt: germanSystemPrompt, rawResponse: sanitizeAndCapString(germanRaw, MAX_RAW_RESPONSE_BYTES), parsedJson: sanitizeAndCapParsedJson(germanCopyOutput), ...withStageUsage(germanEmailResult.usage), status: "succeeded", finishReason: germanEmailResult.finishReason, }); await recordOpenRouterUsage(ctx, { runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), usage: aggregateOpenRouterUsage([ publicSummaryResult.usage, germanBodyResult.usage, germanSubjectResult.usage, germanEmailResult.usage, germanCallScriptResult.usage, germanFollowUpResult.usage, ]), }); await appendRunEvent(ctx, { runId: args.runId, level: "info", message: "Deutsche Kundenkommunikation generiert.", }); } catch (error) { errors += 1; const safeErrorSummary = messageFromError(error); await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "germanCopy", modelProfile: "germanCopy", modelId: germanCopyProfile.modelId, prompt: safeGermanPrompt ?? "", systemPrompt: germanSystemPrompt, status: "failed", errorSummary: safeErrorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Deutsche Texte konnten nicht generiert werden.", details: [{ label: "Fehler", value: safeErrorSummary }], }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors, currentStep: "germanCopy", errorSummary: "Deutsche Texte konnten nicht generiert werden.", }); return null; } const guardResult: GermanCopyGuardResult = validateCustomerFacingCopy({ auditSummary: germanCopyOutput.publicSummary, auditBody: germanCopyOutput.publicBody, emailSubject: germanCopyOutput.emailSubject, emailBody: germanCopyOutput.emailBody, callScript: { openingLine: germanCopyOutput.phoneScript.openingLine, callScript: germanCopyOutput.phoneScript.callScript, closeLine: germanCopyOutput.phoneScript.closeLine, }, followUp: germanCopyOutput.followUpDraft.message, }); // Stage 4: final quality review const qualityPrompt = buildQualityReviewPrompt( verifiedFindingsText, 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: qualityResult.object.isValid && guardResult.passed, issues: [ ...qualityResult.object.issues, ...guardResult.issues.map( (issue) => `${issue.field}: ${issue.message}`, ), ], suggestions: qualityResult.object.suggestions, notes: qualityResult.object.notes ?? [], }; const qualityErrorSummary = "Qualitätsprüfung hat Inhalte als ungenügend markiert."; await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "qualityReview", modelProfile: "qualityReview", modelId: qualityReviewProfile.modelId, prompt: safeQualityPrompt ?? "", systemPrompt: qualitySystemPrompt, rawResponse: sanitizeAndCapString( safeStringify(qualityPayload), MAX_RAW_RESPONSE_BYTES, ), parsedJson: sanitizeAndCapParsedJson(qualityPayload), ...withStageUsage(qualityResult.usage), status: qualityPassed ? "succeeded" : "failed", finishReason: qualityResult.finishReason, ...(!qualityPassed ? { errorSummary: qualityErrorSummary } : {}), }); await recordOpenRouterUsage(ctx, { runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), usage: qualityResult.usage, }); if (!qualityPassed) { const message = "Qualitätsprüfung und German-Copy-Guard haben nicht bestanden."; await appendRunEvent(ctx, { runId: args.runId, level: "warning", message, }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", currentStep: "qualityReview", errors: errors + 1, errorSummary: message, }); return null; } if (!qualityResult.object.isValid) { await appendRunEvent(ctx, { runId: args.runId, level: "warning", message: "Qualitätsprüfung hat Review-Hinweise gemeldet; German-Copy-Guard bestanden.", details: qualityResult.object.issues.slice(0, 4).map((issue) => ({ label: "Hinweis", value: issue, })), }); } else { await appendRunEvent(ctx, { runId: args.runId, level: "info", message: "Qualitätsprüfung bestanden.", }); } } catch (error) { const safeErrorSummary = messageFromError(error); errors += 1; await persistAuditStage({ ctx, runId: args.runId, leadId: started.lead._id, ...(auditId ? { auditId } : {}), stage: "qualityReview", modelProfile: "qualityReview", modelId: qualityReviewProfile.modelId, prompt: safeQualityPrompt ?? "", systemPrompt: qualitySystemPrompt, status: "failed", errorSummary: safeErrorSummary, }); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Qualitätsprüfung fehlgeschlagen.", details: [{ label: "Fehler", value: safeErrorSummary }], }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors, errorSummary: safeErrorSummary, currentStep: "qualityReview", }); return null; } const checkedDomain = started.lead.websiteDomain ?? evidence.lead.websiteDomain ?? "unbekannte-domain"; const checkedPages = evidenceInput.checkedPages.slice( 0, evidenceRunOptions.maxCheckedPages, ); const persistedAuditId = await ctx.runMutation( internal.audits.upsertFromAuditGeneration, { leadId: started.lead._id, runId: args.runId, ...(auditId ? { auditId } : {}), checkedDomain, checkedPages, internalSummary: classificationSummary, multimodalSummary, publicSummary: germanCopyOutput.publicSummary, publicBody: germanCopyOutput.publicBody, usedSkills: evidenceInput.selectedSkills .slice(0, 6) .map(toPersistedUsedSkill), skillSummaries: toSkillSummaries(evidenceInput.selectedSkills, skillRegistry), }, ); if (persistedAuditId) { auditId = persistedAuditId; } if (auditId) { await ctx.runMutation(internal.auditGeneration.replaceAuditFindings, { auditId, runId: args.runId, findings: verifiedFindings.slice(0, 12).map((finding) => ({ skillId: finding.skillId, claim: finding.claim, recommendation: finding.recommendation, customerBenefit: finding.customerBenefit, severity: finding.severity, confidence: finding.confidence, evidenceRefs: finding.evidenceRefs.slice(0, 6).map((ref) => ({ id: ref.id, type: ref.type, label: ref.label, ...(ref.sourceUrl ? { sourceUrl: ref.sourceUrl } : {}), })), reviewStatus: "pending" as const, })), }); } await ctx.runMutation(internal.outreach.upsertFromAuditGeneration, { leadId: started.lead._id, ...(auditId ? { auditId } : {}), strategy: "email_first", phoneScript: [ germanCopyOutput.phoneScript.openingLine, ...germanCopyOutput.phoneScript.callScript, germanCopyOutput.phoneScript.closeLine, ] .filter(Boolean) .join(" "), emailSubject: germanCopyOutput.emailSubject, emailBody: germanCopyOutput.emailBody, followUpDraft: `${germanCopyOutput.followUpDraft.message}\n${( germanCopyOutput.followUpDraft.goals ?? [] ) .slice(0, 4) .join(" | ")}`.trim(), }); const lead = await ctx.runQuery(internal.leads.getInternal, { id: started.lead._id, }); const leadContactStatus = lead?.contactStatus ?? started.lead.contactStatus; if (isTerminalLeadContactStatus(leadContactStatus)) { await appendRunEvent(ctx, { runId: args.runId, level: "warning", message: "Lead-Status wurde nicht auf outreach_ready gesetzt.", details: [ { label: "Grund", value: "Lead ist bereits als terminal kontaktiert oder blockiert markiert.", }, ], }); } else { await ctx.runMutation(internal.leads.reviewUpdateInternal, { id: started.lead._id, contactStatus: "outreach_ready", }); } await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "succeeded", currentStep: "qualityReview", errors, }); await appendRunEvent(ctx, { runId: args.runId, level: "info", message: qualityPassed ? "Audit-Generierung erfolgreich abgeschlossen." : "Audit-Generierung abgeschlossen mit Qualitätsmängeln.", details: [ { label: "Ausgabe", value: "Audit und Outreach gespeichert." }, ...(qualityPassed ? [{ label: "Status", value: "succeeded" }] : []), ], }); return args.runId; } catch (error) { errors += 1; const safeErrorSummary = messageFromError(error); await appendRunEvent(ctx, { runId: args.runId, level: "error", message: "Audit-Generierung wurde unerwartet beendet.", details: [{ label: "Fehler", value: safeErrorSummary }], }); await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { runId: args.runId, status: "failed", errors, currentStep, errorSummary: safeErrorSummary, }); return null; } }, });