feat: add workflow timing summaries
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user