2291 lines
74 KiB
TypeScript
2291 lines
74 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 { 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",
|
|
"LOCAL_BUSINESS_DATA_API_KEY",
|
|
"RAPIDAPI_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<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[];
|
|
};
|
|
|
|
type OpenRouterUsage = {
|
|
inputTokens?: number;
|
|
outputTokens?: number;
|
|
totalTokens?: number;
|
|
inputTokenDetails?: { cacheReadTokens?: number };
|
|
};
|
|
|
|
type ExternalCaptureFetch = {
|
|
response: Response;
|
|
abortController: AbortController;
|
|
timeout: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
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<ReturnType<typeof loadLocalAuditSkillRegistry>> {
|
|
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<ExternalCaptureFetch> {
|
|
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<Uint8Array> {
|
|
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<typeof buildJinaReaderAuditInput>
|
|
| 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<AuditSpecialistResult> => {
|
|
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<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 && 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;
|
|
}
|
|
},
|
|
});
|