328 lines
11 KiB
TypeScript
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;
|
|
}
|