From 933ac94edfe6c67a7675693343405c2fcd5537a2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 09:48:47 +0800 Subject: [PATCH] feat: add workflow timing summaries --- src/app/api/__tests__/jobs.test.ts | 38 +++++++++++- src/app/api/jobs/[jobId]/optimize/route.ts | 13 +++- src/app/api/jobs/route.ts | 30 +++++++-- src/lib/workflow/orchestrator.ts | 71 +++++++++++++++++----- 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/src/app/api/__tests__/jobs.test.ts b/src/app/api/__tests__/jobs.test.ts index dddccc9..642d23e 100644 --- a/src/app/api/__tests__/jobs.test.ts +++ b/src/app/api/__tests__/jobs.test.ts @@ -54,14 +54,26 @@ const validCandidateFactCard = { is_ready_for_optimization: true, }; +interface TimingStepResponse { + label: string; + duration_ms: number; +} + +interface TimingResponse { + total_ms: number; + steps: TimingStepResponse[]; +} + interface CreateJobResponse { job: { id: string }; candidateFactCard: { company_full_name: string }; + timing: TimingResponse; } interface OptimizeJobResponse { optimizedArticle: { title: string }; qaReport: { checks: unknown[] }; + timing: TimingResponse; } describe("job API routes", () => { @@ -119,6 +131,13 @@ describe("job API routes", () => { expect(body.candidateFactCard.company_full_name).toBe( "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 () => { @@ -133,12 +152,19 @@ describe("job API routes", () => { 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(body.error).toBe( "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 () => { @@ -206,6 +232,12 @@ describe("job API routes", () => { expect(response.status).toBe(200); expect(body.optimizedArticle.title).toBe("API LLM Optimized GEO Article"); 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 () => { @@ -256,10 +288,12 @@ describe("job API routes", () => { request({}), 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(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 () => { diff --git a/src/app/api/jobs/[jobId]/optimize/route.ts b/src/app/api/jobs/[jobId]/optimize/route.ts index ce1df36..81feaa4 100644 --- a/src/app/api/jobs/[jobId]/optimize/route.ts +++ b/src/app/api/jobs/[jobId]/optimize/route.ts @@ -31,6 +31,7 @@ export async function POST(request: Request, context: RouteContext) { ); } + const requestStartedAt = Date.now(); try { const result = await runOptimizationWorkflow({ input: { @@ -71,10 +72,20 @@ export async function POST(request: Request, context: RouteContext) { exportPaths, rewriteRounds: result.rewrite_rounds, stoppedAfterMaxRewrites: result.stopped_after_max_rewrites, + timing: result.timing, }); } catch (error) { 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) }, + ); } } diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts index 200274e..78180ce 100644 --- a/src/app/api/jobs/route.ts +++ b/src/app/api/jobs/route.ts @@ -23,17 +23,39 @@ export async function POST(request: Request) { publish_platform: normalized.articleInput.platform, 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) { 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"; - return NextResponse.json({ error: message }, { status }); + return NextResponse.json( + timing ? { error: message, timing } : { error: message }, + { status }, + ); } function getErrorStatus(error: unknown) { diff --git a/src/lib/workflow/orchestrator.ts b/src/lib/workflow/orchestrator.ts index 4508b29..cef5d0b 100644 --- a/src/lib/workflow/orchestrator.ts +++ b/src/lib/workflow/orchestrator.ts @@ -9,29 +9,66 @@ export interface RunOptimizationWorkflowInput { factCard: ConfirmedFactCard; } +export interface WorkflowTimingStep { + label: string; + duration_ms: number; +} + +export interface WorkflowTimingSummary { + total_ms: number; + steps: WorkflowTimingStep[]; +} + +async function timedStep( + label: string, + steps: WorkflowTimingStep[], + action: () => Promise, +): Promise { + const startedAt = Date.now(); + try { + return await action(); + } finally { + steps.push({ + label, + duration_ms: Date.now() - startedAt, + }); + } +} + export async function runOptimizationWorkflow({ input, factCard, }: RunOptimizationWorkflowInput) { - let article = await optimizeArticle({ input, factCard }); - let qaReport = await inspectQualityWithLlm({ - article, - factCard, - platform: input.platform, - sourceImages: input.images, - }); - 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({ + const startedAt = Date.now(); + const timingSteps: WorkflowTimingStep[] = []; + let article = await timedStep("生成优化稿", timingSteps, () => + optimizeArticle({ input, factCard }), + ); + let qaReport = await timedStep("质量检查", timingSteps, () => + inspectQualityWithLlm({ article, factCard, platform: input.platform, 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 { @@ -40,5 +77,9 @@ export async function runOptimizationWorkflow({ rewrite_rounds: rewriteRounds, stopped_after_max_rewrites: qaReport.overall_status === "fail" && rewriteRounds >= 2, + timing: { + total_ms: Date.now() - startedAt, + steps: timingSteps, + } satisfies WorkflowTimingSummary, }; }