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