Refactor pipeline task handling and UI flows

This commit is contained in:
2026-06-13 21:09:49 +02:00
parent 21c7e4c9a4
commit ff4c572157
24 changed files with 1346 additions and 236 deletions

View File

@@ -18,6 +18,8 @@ import {
qualityReviewSchema,
type AuditSpecialistFinding,
type AuditSpecialistResult,
type QualityReview,
type QualityReviewRevisedCopy,
} from "../lib/ai/schemas";
import {
validateCustomerFacingCopy,
@@ -32,6 +34,7 @@ import {
type ScreenshotOneRequest,
} from "../lib/external-audit-services";
import { type AuditUsedSkill } from "../lib/skills-registry";
import { getAuditProgressForStep } from "../lib/audits/progress";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import {
@@ -281,6 +284,44 @@ type GermanCopyOutput = {
};
};
function applyRevisedCopy(
currentCopy: GermanCopyOutput,
revisedCopy: QualityReviewRevisedCopy,
): GermanCopyOutput {
return {
...currentCopy,
publicSummary: revisedCopy.publicSummary,
publicBody: revisedCopy.publicBody,
emailSubject: revisedCopy.emailSubject,
emailBody: revisedCopy.emailBody,
phoneScript: {
openingLine: revisedCopy.phoneScript.openingLine,
callScript: revisedCopy.phoneScript.callScript,
closeLine: revisedCopy.phoneScript.closeLine,
},
followUpDraft: {
message: revisedCopy.followUpDraft.message,
...(revisedCopy.followUpDraft.followInDays !== null
? { followInDays: revisedCopy.followUpDraft.followInDays }
: {}),
...(revisedCopy.followUpDraft.goals !== null
? { goals: revisedCopy.followUpDraft.goals }
: {}),
},
};
}
function germanCopyGuardTelemetry(guardResult: GermanCopyGuardResult) {
return {
passed: guardResult.passed,
issues: guardResult.issues.map((issue) => ({
field: issue.field,
rule: issue.rule,
message: issue.message,
})),
};
}
type MultimodalContentPart =
| {
type: "text";
@@ -554,7 +595,11 @@ function buildQualityReviewPrompt(
`Öffentlicher Text: ${germanCopy.publicBody}`,
`Email-Betreff: ${germanCopy.emailSubject}`,
`Email-Text: ${germanCopy.emailBody}`,
"Antworte als JSON mit isValid, issues, suggestions, notes.",
"Wenn die Copy nur stilistische oder leichte fachliche Hinweise hat, nutze severity warning und rewriteRequired true.",
"Wenn eine Korrektur sinnvoll ist, liefere revisedCopy vollständig mit publicSummary, publicBody, emailSubject, emailBody, phoneScript und followUpDraft.",
"Wenn keine Korrektur nötig ist, setze rewriteRequired false und revisedCopy null.",
"Nutze severity unsafe nur für harte Risiken wie falsche Sprache, erfundene Behauptungen, aggressive Tonalität oder Rohdaten-Leaks.",
"Antworte als JSON mit isValid, severity, issues, suggestions, rewriteRequired, revisedCopy und notes.",
].join("\n");
}
@@ -630,6 +675,27 @@ async function appendRunEvent(
});
}
async function updateRootAuditProgress(
ctx: ActionCtx,
rootRunId: Id<"agentRuns"> | undefined,
currentStep: string,
) {
if (!rootRunId) {
return;
}
const progress = getAuditProgressForStep(currentStep);
await ctx.runMutation(internal.runs.updateProgressInternal, {
id: rootRunId,
status: "running",
currentStep,
progressStep: progress.step,
progressTotal: progress.total,
progressLabel: progress.label,
progressPercent: progress.percent,
});
}
async function loadAuditSkillRegistry(
ctx: ActionCtx,
runId: Id<"agentRuns">,
@@ -1204,6 +1270,7 @@ function getValidMediaType(mimeType: string) {
export const processAuditGeneration = internalAction({
args: {
runId: v.id("agentRuns"),
rootRunId: v.optional(v.id("agentRuns")),
},
handler: async (ctx, args) => {
let started:
@@ -1345,6 +1412,7 @@ export const processAuditGeneration = internalAction({
MAX_PROMPT_BYTES,
);
currentStep = "classification";
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
await persistAuditStage({
ctx,
runId: args.runId,
@@ -1545,6 +1613,7 @@ export const processAuditGeneration = internalAction({
const verifierSystemPrompt =
"Du bist EvidenceQA. Verifiziere Befunde streng gegen belegte Evidence-Refs.";
currentStep = "evidenceVerifier";
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
await persistAuditStage({
ctx,
@@ -1690,6 +1759,7 @@ export const processAuditGeneration = internalAction({
}
currentStep = "multimodalAudit";
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
const validScreenshotParts = screenshotParts.filter(
(part): part is MultimodalFilePart => part !== null,
@@ -1844,6 +1914,7 @@ export const processAuditGeneration = internalAction({
}
currentStep = "germanCopy";
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
// Stage 3: german copy generation
const germanSystemPrompt =
"Du bist fachlicher Texter für lokale Unternehmen im B2B-Kontext.";
@@ -1856,61 +1927,65 @@ export const processAuditGeneration = internalAction({
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 [
publicSummaryResult,
germanBodyResult,
germanSubjectResult,
germanEmailResult,
germanCallScriptResult,
germanFollowUpResult,
] = await Promise.all([
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,
}),
generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: publicAuditTextSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für publicBody`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
}),
generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: emailSubjectSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailSubject`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
}),
generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: emailDraftSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailBody`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
}),
generateObject({
model: provider(germanCopyProfile.modelId),
system: germanSystemPrompt,
schema: callScriptSchema,
prompt: `${safeGermanPrompt ?? ""}\nAusgabe für callScript`,
temperature: germanCopyProfile.temperature,
maxOutputTokens: germanCopyProfile.maxTokens,
}),
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 ?? "";
@@ -2015,42 +2090,89 @@ export const processAuditGeneration = internalAction({
},
followUp: germanCopyOutput.followUpDraft.message,
});
const deterministicGuard = germanCopyGuardTelemetry(guardResult);
// Stage 4: final quality review
const qualityPrompt = buildQualityReviewPrompt(
let qualityPrompt = buildQualityReviewPrompt(
verifiedFindingsText,
germanCopyOutput,
);
const safeQualityPrompt = sanitizeAndCapString(qualityPrompt, MAX_PROMPT_BYTES);
let safeQualityPrompt = sanitizeAndCapString(qualityPrompt, MAX_PROMPT_BYTES);
const qualitySystemPrompt =
"Du prüfst die erzeugten Inhalte als Qualitätssicherung.";
currentStep = "qualityReview";
await updateRootAuditProgress(ctx, args.rootRunId, currentStep);
try {
const qualityResult = await generateObject({
model: provider(qualityReviewProfile.modelId),
system: qualitySystemPrompt,
schema: qualityReviewSchema,
prompt: safeQualityPrompt ?? "",
temperature: qualityReviewProfile.temperature,
maxOutputTokens: qualityReviewProfile.maxTokens,
});
let finalQualityReview: QualityReview | null = null;
let qualityFinishReason: string | undefined;
let rewriteApplied = false;
let copyReviewAttempts = 0;
const qualityReviewUsages: Array<OpenRouterUsage | undefined> = [];
qualityPassed = qualityResult.object.isValid && guardResult.passed;
while (copyReviewAttempts < 2) {
copyReviewAttempts += 1;
const qualityResult = await generateObject({
model: provider(qualityReviewProfile.modelId),
system: qualitySystemPrompt,
schema: qualityReviewSchema,
prompt: safeQualityPrompt ?? "",
temperature: qualityReviewProfile.temperature,
maxOutputTokens: qualityReviewProfile.maxTokens,
});
finalQualityReview = qualityResult.object;
qualityFinishReason = qualityResult.finishReason;
qualityReviewUsages.push(qualityResult.usage);
if (
copyReviewAttempts === 1 &&
qualityResult.object.rewriteRequired &&
qualityResult.object.revisedCopy
) {
germanCopyOutput = applyRevisedCopy(
germanCopyOutput,
qualityResult.object.revisedCopy,
);
rewriteApplied = true;
await appendRunEvent(ctx, {
runId: args.runId,
level: "warning",
message: "Copy-Review hat korrigiert.",
details: qualityResult.object.issues.slice(0, 4).map((issue) => ({
label: "Hinweis",
value: issue,
})),
});
qualityPrompt = buildQualityReviewPrompt(
verifiedFindingsText,
germanCopyOutput,
);
safeQualityPrompt = sanitizeAndCapString(
qualityPrompt,
MAX_PROMPT_BYTES,
);
continue;
}
break;
}
if (!finalQualityReview) {
throw new Error("Copy-Review konnte nicht ausgewertet werden.");
}
qualityPassed =
finalQualityReview.isValid && finalQualityReview.severity === "ok";
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 ?? [],
...finalQualityReview,
rewriteApplied,
reviewAttempts: copyReviewAttempts,
deterministicGuard,
finalDecision: qualityPassed ? "approved" : "stored_with_warnings",
};
const qualityErrorSummary =
"Qualitätsprüfung hat Inhalte als ungenügend markiert.";
await persistAuditStage({
ctx,
@@ -2067,43 +2189,26 @@ export const processAuditGeneration = internalAction({
MAX_RAW_RESPONSE_BYTES,
),
parsedJson: sanitizeAndCapParsedJson(qualityPayload),
...withStageUsage(qualityResult.usage),
status: qualityPassed ? "succeeded" : "failed",
finishReason: qualityResult.finishReason,
...(!qualityPassed ? { errorSummary: qualityErrorSummary } : {}),
...withStageUsage(aggregateOpenRouterUsage(qualityReviewUsages)),
status: "succeeded",
...(qualityFinishReason ? { finishReason: qualityFinishReason } : {}),
});
await recordOpenRouterUsage(ctx, {
runId: args.runId,
leadId: started.lead._id,
...(auditId ? { auditId } : {}),
usage: qualityResult.usage,
usage: aggregateOpenRouterUsage(qualityReviewUsages),
});
if (!qualityPassed) {
const message =
"Qualitätsprüfung und German-Copy-Guard haben nicht bestanden.";
if (!qualityPassed || !guardResult.passed) {
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) => ({
message: "Copy-Review mit Hinweisen abgeschlossen.",
details: [
...finalQualityReview.issues,
...guardResult.issues.map((issue) => issue.message),
].slice(0, 4).map((issue) => ({
label: "Hinweis",
value: issue,
})),
@@ -2288,3 +2393,25 @@ export const processAuditGeneration = internalAction({
}
},
});
export const processAuditGenerationForWorkflow = internalAction({
args: {
runId: v.id("agentRuns"),
rootRunId: v.id("agentRuns"),
},
handler: async (ctx, args): Promise<Id<"agentRuns">> => {
const result = await ctx.runAction(
internal.auditGenerationAction.processAuditGeneration,
{
runId: args.runId,
rootRunId: args.rootRunId,
},
);
if (!result) {
throw new Error("Audit-Generierung konnte nicht abgeschlossen werden.");
}
return args.runId;
},
});