feat: add workflow timing summaries

This commit is contained in:
Codex
2026-06-21 23:43:03 +08:00
parent 48f266ce32
commit 933ac94edf
4 changed files with 130 additions and 22 deletions
+36 -2
View File
@@ -54,14 +54,26 @@ const validCandidateFactCard = {
is_ready_for_optimization: true, is_ready_for_optimization: true,
}; };
interface TimingStepResponse {
label: string;
duration_ms: number;
}
interface TimingResponse {
total_ms: number;
steps: TimingStepResponse[];
}
interface CreateJobResponse { interface CreateJobResponse {
job: { id: string }; job: { id: string };
candidateFactCard: { company_full_name: string }; candidateFactCard: { company_full_name: string };
timing: TimingResponse;
} }
interface OptimizeJobResponse { interface OptimizeJobResponse {
optimizedArticle: { title: string }; optimizedArticle: { title: string };
qaReport: { checks: unknown[] }; qaReport: { checks: unknown[] };
timing: TimingResponse;
} }
describe("job API routes", () => { describe("job API routes", () => {
@@ -119,6 +131,13 @@ describe("job API routes", () => {
expect(body.candidateFactCard.company_full_name).toBe( expect(body.candidateFactCard.company_full_name).toBe(
"Example Technology Co., Ltd.", "Example Technology Co., Ltd.",
); );
expect(body.timing.total_ms).toBeGreaterThanOrEqual(0);
expect(body.timing.steps).toEqual([
expect.objectContaining({
label: "事实卡提取",
duration_ms: expect.any(Number),
}),
]);
}); });
it("returns a clear error when LLM fact extraction fails", async () => { it("returns a clear error when LLM fact extraction fails", async () => {
@@ -133,12 +152,19 @@ describe("job API routes", () => {
platform: "official_site", platform: "official_site",
}), }),
); );
const body = (await response.json()) as { error: string }; const body = (await response.json()) as { error: string; timing: TimingResponse };
expect(response.status).toBe(502); expect(response.status).toBe(502);
expect(body.error).toBe( expect(body.error).toBe(
"LLM response failed schema validation: target_audience", "LLM response failed schema validation: target_audience",
); );
expect(body.timing.total_ms).toBeGreaterThanOrEqual(0);
expect(body.timing.steps).toEqual([
expect.objectContaining({
label: "事实卡提取",
duration_ms: expect.any(Number),
}),
]);
}); });
it("still returns 400 for invalid article input", async () => { it("still returns 400 for invalid article input", async () => {
@@ -206,6 +232,12 @@ describe("job API routes", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body.optimizedArticle.title).toBe("API LLM Optimized GEO Article"); expect(body.optimizedArticle.title).toBe("API LLM Optimized GEO Article");
expect(body.qaReport.checks).toHaveLength(10); expect(body.qaReport.checks).toHaveLength(10);
expect(body.timing.total_ms).toBeGreaterThanOrEqual(0);
expect(body.timing.steps.map((step) => step.label)).toEqual([
"生成优化稿",
"质量检查",
]);
expect(body.timing.steps.every((step) => step.duration_ms >= 0)).toBe(true);
}); });
it("uses mocked LLM article output during optimize route", async () => { it("uses mocked LLM article output during optimize route", async () => {
@@ -256,10 +288,12 @@ describe("job API routes", () => {
request({}), request({}),
params<{ jobId: string }>({ jobId: job.id }), params<{ jobId: string }>({ jobId: job.id }),
); );
const body = (await response.json()) as { error: string }; const body = (await response.json()) as { error: string; timing: TimingResponse };
expect(response.status).toBe(502); expect(response.status).toBe(502);
expect(body.error).toBe("LLM response failed schema validation: body_markdown"); expect(body.error).toBe("LLM response failed schema validation: body_markdown");
expect(body.timing.total_ms).toBeGreaterThanOrEqual(0);
expect(body.timing.steps).toEqual([]);
}); });
it("rejects unknown export filenames", async () => { it("rejects unknown export filenames", async () => {
+12 -1
View File
@@ -31,6 +31,7 @@ export async function POST(request: Request, context: RouteContext) {
); );
} }
const requestStartedAt = Date.now();
try { try {
const result = await runOptimizationWorkflow({ const result = await runOptimizationWorkflow({
input: { input: {
@@ -71,10 +72,20 @@ export async function POST(request: Request, context: RouteContext) {
exportPaths, exportPaths,
rewriteRounds: result.rewrite_rounds, rewriteRounds: result.rewrite_rounds,
stoppedAfterMaxRewrites: result.stopped_after_max_rewrites, stoppedAfterMaxRewrites: result.stopped_after_max_rewrites,
timing: result.timing,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "LLM optimization failed"; const message = error instanceof Error ? error.message : "LLM optimization failed";
return NextResponse.json({ error: message }, { status: getErrorStatus(error) }); return NextResponse.json(
{
error: message,
timing: {
total_ms: Date.now() - requestStartedAt,
steps: [],
},
},
{ status: getErrorStatus(error) },
);
} }
} }
+26 -4
View File
@@ -23,17 +23,39 @@ export async function POST(request: Request) {
publish_platform: normalized.articleInput.platform, publish_platform: normalized.articleInput.platform,
user_instructions: normalized.articleInput.user_instructions, user_instructions: normalized.articleInput.user_instructions,
}); });
const candidateFactCard = await extractCandidateFactCard(normalized.articleInput); const factStartedAt = Date.now();
const timing = {
total_ms: 0,
steps: [] as Array<{ label: string; duration_ms: number }>,
};
try {
const candidateFactCard = await extractCandidateFactCard(normalized.articleInput);
const duration = Date.now() - factStartedAt;
timing.total_ms = duration;
timing.steps.push({ label: "事实卡提取", duration_ms: duration });
return NextResponse.json({ job, candidateFactCard }, { status: 201 }); return NextResponse.json({ job, candidateFactCard, timing }, { status: 201 });
} catch (error) {
const duration = Date.now() - factStartedAt;
timing.total_ms = duration;
timing.steps.push({ label: "事实卡提取", duration_ms: duration });
return jsonError(error, getErrorStatus(error), timing);
}
} catch (error) { } catch (error) {
return jsonError(error, getErrorStatus(error)); return jsonError(error, getErrorStatus(error));
} }
} }
function jsonError(error: unknown, status: number) { function jsonError(
error: unknown,
status: number,
timing?: { total_ms: number; steps: Array<{ label: string; duration_ms: number }> },
) {
const message = error instanceof Error ? error.message : "Request failed"; const message = error instanceof Error ? error.message : "Request failed";
return NextResponse.json({ error: message }, { status }); return NextResponse.json(
timing ? { error: message, timing } : { error: message },
{ status },
);
} }
function getErrorStatus(error: unknown) { function getErrorStatus(error: unknown) {
+56 -15
View File
@@ -9,29 +9,66 @@ export interface RunOptimizationWorkflowInput {
factCard: ConfirmedFactCard; factCard: ConfirmedFactCard;
} }
export interface WorkflowTimingStep {
label: string;
duration_ms: number;
}
export interface WorkflowTimingSummary {
total_ms: number;
steps: WorkflowTimingStep[];
}
async function timedStep<T>(
label: string,
steps: WorkflowTimingStep[],
action: () => Promise<T>,
): Promise<T> {
const startedAt = Date.now();
try {
return await action();
} finally {
steps.push({
label,
duration_ms: Date.now() - startedAt,
});
}
}
export async function runOptimizationWorkflow({ export async function runOptimizationWorkflow({
input, input,
factCard, factCard,
}: RunOptimizationWorkflowInput) { }: RunOptimizationWorkflowInput) {
let article = await optimizeArticle({ input, factCard }); const startedAt = Date.now();
let qaReport = await inspectQualityWithLlm({ const timingSteps: WorkflowTimingStep[] = [];
article, let article = await timedStep("生成优化稿", timingSteps, () =>
factCard, optimizeArticle({ input, factCard }),
platform: input.platform, );
sourceImages: input.images, let qaReport = await timedStep("质量检查", timingSteps, () =>
}); inspectQualityWithLlm({
let rewriteRounds = 0;
while (qaReport.overall_status === "fail" && rewriteRounds < 2) {
const failedChecks = qaReport.checks.filter((check) => check.status === "fail");
article = await rewriteFailedSections({ article, factCard, failedChecks });
rewriteRounds += 1;
qaReport = await inspectQualityWithLlm({
article, article,
factCard, factCard,
platform: input.platform, platform: input.platform,
sourceImages: input.images, sourceImages: input.images,
}); }),
);
let rewriteRounds = 0;
while (qaReport.overall_status === "fail" && rewriteRounds < 2) {
const nextRound = rewriteRounds + 1;
const failedChecks = qaReport.checks.filter((check) => check.status === "fail");
article = await timedStep(`定向修复第 ${nextRound}`, timingSteps, () =>
rewriteFailedSections({ article, factCard, failedChecks }),
);
rewriteRounds = nextRound;
qaReport = await timedStep(`质量复检第 ${nextRound}`, timingSteps, () =>
inspectQualityWithLlm({
article,
factCard,
platform: input.platform,
sourceImages: input.images,
}),
);
} }
return { return {
@@ -40,5 +77,9 @@ export async function runOptimizationWorkflow({
rewrite_rounds: rewriteRounds, rewrite_rounds: rewriteRounds,
stopped_after_max_rewrites: stopped_after_max_rewrites:
qaReport.overall_status === "fail" && rewriteRounds >= 2, qaReport.overall_status === "fail" && rewriteRounds >= 2,
timing: {
total_ms: Date.now() - startedAt,
steps: timingSteps,
} satisfies WorkflowTimingSummary,
}; };
} }