Files
GEOAgentArticleOptimizer/src/app/api/jobs/optimize-stream/route.ts
T
2026-07-16 12:19:16 +08:00

328 lines
11 KiB
TypeScript

import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../lib/api/auth";
import type { ProcessSummaryStep } from "../../../../lib/cases/types";
import { buildArticleCaseSummary, createProcessStep } from "../../../../lib/cases/summaries";
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
import { optimizationFactCardSchema } from "../../../../lib/domain/validation";
import type { LlmAuditSummary } from "../../../../lib/llm/audit";
import { LlmValidationError } from "../../../../lib/llm/client";
import { getLlmTracePayloadStoreFromRuntime } from "../../../../lib/llm/trace-payload-store";
import {
createLlmTraceRecorder,
createNoopLlmTraceRecorder,
safeTraceError,
} from "../../../../lib/llm/trace-recorder";
import { getLlmTraceRepositoryFromRuntime } from "../../../../lib/llm/trace-repository";
import { getExportStoreFromRuntime } from "../../../../lib/workflow/export-store";
import { extractCandidateFactCard } from "../../../../lib/workflow/fact-extractor";
import {
normalizeInput,
type RawArticleInput,
} from "../../../../lib/workflow/input-normalizer";
import {
encodeOptimizationStreamEvent,
type OptimizationStreamEvent,
type OptimizationStreamStage,
} from "../../../../lib/workflow/stream-events";
import { runStreamingOptimizationWorkflow } from "../../../../lib/workflow/streaming-optimizer";
interface OptimizeStreamPayload extends RawArticleInput {
fact_card?: unknown;
}
export async function POST(request: Request) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
let payload: OptimizeStreamPayload;
try {
payload = (await request.json()) as OptimizeStreamPayload;
} catch {
return NextResponse.json({ error: "请求体不是合法 JSON" }, { status: 400 });
}
if (!hasOptimizableBody(payload)) {
return NextResponse.json(
{ error: "请输入需要优化的文章内容" },
{ status: 400 },
);
}
let normalized: ReturnType<typeof normalizeInput>;
try {
normalized = normalizeInput(payload);
} catch (error) {
return jsonError(error, getErrorStatus(error));
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: OptimizationStreamEvent) => {
controller.enqueue(encoder.encode(encodeOptimizationStreamEvent(event)));
};
let jobId: string | undefined;
let caseId: string | undefined;
let stage: OptimizationStreamStage = "job";
let traceRecorder = createNoopLlmTraceRecorder();
const llmAuditSummary: LlmAuditSummary[] = [];
const processSummary: ProcessSummaryStep[] = [];
const requestStartedAt = Date.now();
try {
const repository = getRepositoryFromRuntime();
const caseSummary = buildArticleCaseSummary({
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
publish_platform: normalized.articleInput.platform,
});
const optimizationCase = await repository.createOptimizationCase({
case_type: "article",
...caseSummary,
});
caseId = optimizationCase.id;
const job = await repository.createArticleJob({
case_id: optimizationCase.id,
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
image_inputs: normalized.articleInput.images,
publish_platform: normalized.articleInput.platform,
user_instructions: normalized.articleInput.user_instructions,
});
jobId = job.id;
await repository.saveCaseInput({
case_id: optimizationCase.id,
case_type: "article",
article_job_id: job.id,
payload: {
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
image_inputs: normalized.articleInput.images,
publish_platform: normalized.articleInput.platform,
user_instructions: normalized.articleInput.user_instructions,
},
});
send({
type: "job_created",
job: { id: job.id },
case: { id: optimizationCase.id, case_type: "article" },
});
try {
traceRecorder = await createLlmTraceRecorder({
jobId: job.id,
caseId: optimizationCase.id,
repository: getLlmTraceRepositoryFromRuntime(),
payloadStore: getLlmTracePayloadStoreFromRuntime(),
publish: (event) => send(event),
});
} catch (error) {
send({
type: "trace_warning",
job_id: job.id,
trace_completeness: "incomplete",
error_summary: safeTraceError(error),
});
}
stage = "fact_card";
const factCardStartedAt = Date.now();
const factCard = optimizationFactCardSchema.parse(
payload.fact_card ??
(await extractCandidateFactCard(normalized.articleInput, {
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
onTraceEvent: traceRecorder.onLlmEvent,
})),
);
processSummary.push(
createProcessStep({
stage: "fact_card",
startedAt: factCardStartedAt,
endedAt: Date.now(),
status: "success",
producedResultVersion: false,
}),
);
const savedFactCard = await repository.saveFactCard(job.id, factCard);
const factCardReadyEvent: OptimizationStreamEvent = {
type: "fact_card_ready",
job_id: job.id,
fact_card: savedFactCard,
};
await traceRecorder.onWorkflowEvent(factCardReadyEvent);
send(factCardReadyEvent);
const result = await runStreamingOptimizationWorkflow({
jobId: job.id,
input: normalized.articleInput,
factCard: savedFactCard,
onEvent: async (event) => {
stage = stageForEvent(event, stage);
await traceRecorder.onWorkflowEvent(event);
send(event);
},
onTraceEvent: traceRecorder.onLlmEvent,
onAuditSummary: (summary) => {
llmAuditSummary.push(summary);
},
});
stage = "final";
const optimizedArticle = await repository.saveOptimizedArticle(
job.id,
result.article,
);
const qaReport = await repository.saveQaReport(
job.id,
optimizedArticle.revision ?? 1,
result.qaReport,
);
const exportStore = getExportStoreFromRuntime();
const exportPaths = await exportStore.writeJobExports({
jobId: job.id,
article: optimizedArticle,
qaReport,
});
await repository.updateArticleJob(job.id, {
status: "optimized",
export_paths: exportPaths,
});
const resultVersion = await repository.createOptimizationResultVersion({
case_id: optimizationCase.id,
case_type: "article",
status: "optimized",
article_job_id: job.id,
article_revision: optimizedArticle.revision ?? 1,
result_summary: optimizedArticle.summary,
payload: {
article: optimizedArticle,
qa_report: qaReport,
export_paths: exportPaths,
},
process_summary: [...processSummary, ...result.processSummary],
llm_audit_summary: llmAuditSummary,
error_stage: null,
error_summary: null,
});
const finalReadyEvent: OptimizationStreamEvent = {
type: "final_ready",
job_id: job.id,
case: { id: optimizationCase.id, case_type: "article" },
result_version: { id: resultVersion.id, version: resultVersion.version },
optimized_article: optimizedArticle,
qa_report: qaReport,
export_paths: exportPaths,
};
await traceRecorder.onWorkflowEvent(finalReadyEvent);
await traceRecorder.finish({ status: "completed" });
send(finalReadyEvent);
} catch (error) {
const message = error instanceof Error ? error.message : "优化失败";
let failedVersion:
| { id: string; version: number }
| undefined;
if (caseId) {
try {
const repository = getRepositoryFromRuntime();
const version = await repository.createOptimizationResultVersion({
case_id: caseId,
case_type: "article",
status: "failed",
article_job_id: jobId ?? null,
article_revision: null,
result_summary: "",
payload: null,
process_summary: [
...processSummary,
createProcessStep({
stage,
startedAt: requestStartedAt,
endedAt: Date.now(),
status: "failed",
errorSummary: message,
producedResultVersion: false,
}),
],
llm_audit_summary: llmAuditSummary,
error_stage: stage,
error_summary: message,
});
failedVersion = { id: version.id, version: version.version };
} catch {
failedVersion = undefined;
}
}
const failedEvent: OptimizationStreamEvent = {
type: "failed",
job_id: jobId,
case: caseId ? { id: caseId, case_type: "article" } : undefined,
result_version: failedVersion,
stage,
error: message,
};
await traceRecorder.onWorkflowEvent(failedEvent);
await traceRecorder.finish({
status: "failed",
errorStage: stage,
errorSummary: message,
});
send(failedEvent);
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"content-type": "application/x-ndjson; charset=utf-8",
"cache-control": "no-cache, no-transform",
},
});
}
function hasOptimizableBody(payload: unknown): payload is OptimizeStreamPayload {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return false;
}
const body = (payload as Partial<OptimizeStreamPayload>).body;
return typeof body === "string" && body.trim().length > 0;
}
function stageForEvent(
event: OptimizationStreamEvent,
fallback: OptimizationStreamStage,
): OptimizationStreamStage {
switch (event.type) {
case "draft_started":
case "draft_ready":
return "draft";
case "qa_started":
case "qa_ready":
return "qa";
case "rewrite_started":
case "rewrite_ready":
return "rewrite";
default:
return fallback;
}
}
function jsonError(error: unknown, status: number) {
const message = error instanceof Error ? error.message : "Request failed";
return NextResponse.json({ error: message }, { status });
}
function getErrorStatus(error: unknown) {
if (error instanceof LlmValidationError) return 502;
if (error instanceof Error && /^LLM\b|provider/i.test(error.message)) return 502;
return 400;
}