接入文章优化LLM追踪

This commit is contained in:
czj
2026-07-16 12:19:16 +08:00
parent 90de3a698c
commit 8e50ecef77
9 changed files with 150 additions and 22 deletions
+57 -5
View File
@@ -205,9 +205,8 @@ describe("job API routes", () => {
}); });
it("streams a one-click optimization from body-only input", async () => { it("streams a one-click optimization from body-only input", async () => {
llmMocks.generateValidatedJson mockTrackedLlmResult(validCandidateFactCard, "llmcall_fact");
.mockResolvedValueOnce(validCandidateFactCard) mockTrackedLlmResult({
.mockResolvedValueOnce({
title: "流式优化标题", title: "流式优化标题",
summary: "流式优化摘要。", summary: "流式优化摘要。",
body_markdown: body_markdown:
@@ -215,8 +214,8 @@ describe("job API routes", () => {
image_suggestions: [], image_suggestions: [],
changed_sections: ["title", "body"], changed_sections: ["title", "body"],
requires_user_confirmation: [], requires_user_confirmation: [],
}) }, "llmcall_draft");
.mockResolvedValueOnce({ checks: [] }); mockTrackedLlmResult({ checks: [] }, "llmcall_qa");
const response = await optimizeStream( const response = await optimizeStream(
request({ request({
@@ -231,13 +230,25 @@ describe("job API routes", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(events.map((event) => event.type)).toEqual([ expect(events.map((event) => event.type)).toEqual([
"job_created", "job_created",
"llm_call_started",
"llm_call_responded",
"llm_call_validated",
"fact_card_ready", "fact_card_ready",
"draft_started", "draft_started",
"llm_call_started",
"llm_call_responded",
"llm_call_validated",
"draft_ready", "draft_ready",
"qa_started", "qa_started",
"llm_call_started",
"llm_call_responded",
"llm_call_validated",
"qa_ready", "qa_ready",
"final_ready", "final_ready",
]); ]);
expect(JSON.stringify(events)).not.toContain("messages");
expect(JSON.stringify(events)).not.toContain("choices");
expect(JSON.stringify(events)).not.toContain("test-key");
expect( expect(
events.find((event) => event.type === "fact_card_ready")?.fact_card, events.find((event) => event.type === "fact_card_ready")?.fact_card,
).toEqual(expect.objectContaining({ confirmed_by_user: false })); ).toEqual(expect.objectContaining({ confirmed_by_user: false }));
@@ -784,6 +795,47 @@ async function createJobFixture() {
return response.json() as Promise<{ job: { id: string } }>; return response.json() as Promise<{ job: { id: string } }>;
} }
function mockTrackedLlmResult(value: unknown, callId: string) {
llmMocks.generateValidatedJson.mockImplementationOnce(async (input) => {
const onTraceEvent = input.onTraceEvent as
| ((event: Record<string, unknown>) => void | Promise<void>)
| undefined;
await onTraceEvent?.({
type: "started",
call_id: callId,
task: input.task ?? "unknown",
context: {
workflow_stage: input.traceStage ?? "unknown",
rewrite_round: input.rewriteRound,
schema_name: input.schemaName,
},
provider: "deepseek",
model: "deepseek-v4-pro",
request: { model: "deepseek-v4-pro", messages: [] },
started_at: "2026-07-16T00:00:00.000Z",
});
await onTraceEvent?.({
type: "responded",
call_id: callId,
response: {
choices: [{ message: { content: JSON.stringify(value) } }],
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
},
duration_ms: 1200,
responded_at: "2026-07-16T00:00:01.200Z",
});
await onTraceEvent?.({
type: "validated",
call_id: callId,
schema_name: input.schemaName ?? "anonymousSchema",
schema_valid: true,
validation_issues: [],
validated_at: "2026-07-16T00:00:01.300Z",
});
return value;
});
}
function request(body: unknown, options: { apiKey?: string | null } = {}) { function request(body: unknown, options: { apiKey?: string | null } = {}) {
const headers: Record<string, string> = { "content-type": "application/json" }; const headers: Record<string, string> = { "content-type": "application/json" };
const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey; const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey;
+46 -6
View File
@@ -7,6 +7,13 @@ import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
import { optimizationFactCardSchema } from "../../../../lib/domain/validation"; import { optimizationFactCardSchema } from "../../../../lib/domain/validation";
import type { LlmAuditSummary } from "../../../../lib/llm/audit"; import type { LlmAuditSummary } from "../../../../lib/llm/audit";
import { LlmValidationError } from "../../../../lib/llm/client"; 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 { getExportStoreFromRuntime } from "../../../../lib/workflow/export-store";
import { extractCandidateFactCard } from "../../../../lib/workflow/fact-extractor"; import { extractCandidateFactCard } from "../../../../lib/workflow/fact-extractor";
import { import {
@@ -61,6 +68,7 @@ export async function POST(request: Request) {
let jobId: string | undefined; let jobId: string | undefined;
let caseId: string | undefined; let caseId: string | undefined;
let stage: OptimizationStreamStage = "job"; let stage: OptimizationStreamStage = "job";
let traceRecorder = createNoopLlmTraceRecorder();
const llmAuditSummary: LlmAuditSummary[] = []; const llmAuditSummary: LlmAuditSummary[] = [];
const processSummary: ProcessSummaryStep[] = []; const processSummary: ProcessSummaryStep[] = [];
const requestStartedAt = Date.now(); const requestStartedAt = Date.now();
@@ -104,6 +112,23 @@ export async function POST(request: Request) {
case: { id: optimizationCase.id, case_type: "article" }, 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"; stage = "fact_card";
const factCardStartedAt = Date.now(); const factCardStartedAt = Date.now();
const factCard = optimizationFactCardSchema.parse( const factCard = optimizationFactCardSchema.parse(
@@ -112,6 +137,7 @@ export async function POST(request: Request) {
onAuditSummary: (summary) => { onAuditSummary: (summary) => {
llmAuditSummary.push(summary); llmAuditSummary.push(summary);
}, },
onTraceEvent: traceRecorder.onLlmEvent,
})), })),
); );
processSummary.push( processSummary.push(
@@ -124,20 +150,24 @@ export async function POST(request: Request) {
}), }),
); );
const savedFactCard = await repository.saveFactCard(job.id, factCard); const savedFactCard = await repository.saveFactCard(job.id, factCard);
send({ const factCardReadyEvent: OptimizationStreamEvent = {
type: "fact_card_ready", type: "fact_card_ready",
job_id: job.id, job_id: job.id,
fact_card: savedFactCard, fact_card: savedFactCard,
}); };
await traceRecorder.onWorkflowEvent(factCardReadyEvent);
send(factCardReadyEvent);
const result = await runStreamingOptimizationWorkflow({ const result = await runStreamingOptimizationWorkflow({
jobId: job.id, jobId: job.id,
input: normalized.articleInput, input: normalized.articleInput,
factCard: savedFactCard, factCard: savedFactCard,
onEvent: (event) => { onEvent: async (event) => {
stage = stageForEvent(event, stage); stage = stageForEvent(event, stage);
await traceRecorder.onWorkflowEvent(event);
send(event); send(event);
}, },
onTraceEvent: traceRecorder.onLlmEvent,
onAuditSummary: (summary) => { onAuditSummary: (summary) => {
llmAuditSummary.push(summary); llmAuditSummary.push(summary);
}, },
@@ -180,7 +210,7 @@ export async function POST(request: Request) {
error_stage: null, error_stage: null,
error_summary: null, error_summary: null,
}); });
send({ const finalReadyEvent: OptimizationStreamEvent = {
type: "final_ready", type: "final_ready",
job_id: job.id, job_id: job.id,
case: { id: optimizationCase.id, case_type: "article" }, case: { id: optimizationCase.id, case_type: "article" },
@@ -188,7 +218,10 @@ export async function POST(request: Request) {
optimized_article: optimizedArticle, optimized_article: optimizedArticle,
qa_report: qaReport, qa_report: qaReport,
export_paths: exportPaths, export_paths: exportPaths,
}); };
await traceRecorder.onWorkflowEvent(finalReadyEvent);
await traceRecorder.finish({ status: "completed" });
send(finalReadyEvent);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "优化失败"; const message = error instanceof Error ? error.message : "优化失败";
let failedVersion: let failedVersion:
@@ -225,14 +258,21 @@ export async function POST(request: Request) {
failedVersion = undefined; failedVersion = undefined;
} }
} }
send({ const failedEvent: OptimizationStreamEvent = {
type: "failed", type: "failed",
job_id: jobId, job_id: jobId,
case: caseId ? { id: caseId, case_type: "article" } : undefined, case: caseId ? { id: caseId, case_type: "article" } : undefined,
result_version: failedVersion, result_version: failedVersion,
stage, stage,
error: message, error: message,
};
await traceRecorder.onWorkflowEvent(failedEvent);
await traceRecorder.finish({
status: "failed",
errorStage: stage,
errorSummary: message,
}); });
send(failedEvent);
} finally { } finally {
controller.close(); controller.close();
} }
-1
View File
@@ -7,7 +7,6 @@ import type {
LlmTraceCallStatus, LlmTraceCallStatus,
LlmTraceCompleteness, LlmTraceCompleteness,
LlmTraceErrorType, LlmTraceErrorType,
LlmTraceRun,
LlmTraceRunStatus, LlmTraceRunStatus,
LlmTraceWorkflowStage, LlmTraceWorkflowStage,
} from "./trace-types"; } from "./trace-types";
-1
View File
@@ -9,7 +9,6 @@ import type {
LlmTraceCallStatus, LlmTraceCallStatus,
LlmTraceCompleteness, LlmTraceCompleteness,
LlmTraceErrorType, LlmTraceErrorType,
LlmTraceRun,
LlmTraceRunStatus, LlmTraceRunStatus,
LlmTraceWorkflowStage, LlmTraceWorkflowStage,
} from "./trace-types"; } from "./trace-types";
+5
View File
@@ -13,12 +13,14 @@ import {
export interface OptimizeArticleInput { export interface OptimizeArticleInput {
input: ArticleInput; input: ArticleInput;
factCard: OptimizationFactCard; factCard: OptimizationFactCard;
onTraceEvent?: GenerateInput["onTraceEvent"];
onAuditSummary?: GenerateInput["onAuditSummary"]; onAuditSummary?: GenerateInput["onAuditSummary"];
} }
export async function optimizeArticle({ export async function optimizeArticle({
input, input,
factCard, factCard,
onTraceEvent,
onAuditSummary, onAuditSummary,
}: OptimizeArticleInput): Promise<OptimizedArticle> { }: OptimizeArticleInput): Promise<OptimizedArticle> {
const llmArticle = await generateValidatedJson({ const llmArticle = await generateValidatedJson({
@@ -27,6 +29,9 @@ export async function optimizeArticle({
prompt: buildArticleOptimizerPrompt(input, factCard), prompt: buildArticleOptimizerPrompt(input, factCard),
temperature: 0.2, temperature: 0.2,
task: "article_optimizer", task: "article_optimizer",
schemaName: "optimizedArticleSchema",
traceStage: "draft",
onTraceEvent,
onAuditSummary, onAuditSummary,
}); });
+7 -1
View File
@@ -8,7 +8,10 @@ import {
export async function extractCandidateFactCard( export async function extractCandidateFactCard(
input: ArticleInput, input: ArticleInput,
options: { onAuditSummary?: GenerateInput["onAuditSummary"] } = {}, options: {
onAuditSummary?: GenerateInput["onAuditSummary"];
onTraceEvent?: GenerateInput["onTraceEvent"];
} = {},
): Promise<CandidateFactCard> { ): Promise<CandidateFactCard> {
return generateValidatedJson({ return generateValidatedJson({
schema: candidateFactCardSchema, schema: candidateFactCardSchema,
@@ -16,6 +19,9 @@ export async function extractCandidateFactCard(
prompt: buildFactExtractorPrompt(input), prompt: buildFactExtractorPrompt(input),
temperature: 0.1, temperature: 0.1,
task: "fact_extractor", task: "fact_extractor",
schemaName: "candidateFactCardSchema",
traceStage: "fact_card",
onTraceEvent: options.onTraceEvent,
onAuditSummary: options.onAuditSummary, onAuditSummary: options.onAuditSummary,
}); });
} }
+6
View File
@@ -43,6 +43,8 @@ export interface InspectQualityInput {
factCard: OptimizationFactCard; factCard: OptimizationFactCard;
platform: PublishPlatform; platform: PublishPlatform;
sourceImages: ImageInput[]; sourceImages: ImageInput[];
rewriteRound?: number;
onTraceEvent?: GenerateInput["onTraceEvent"];
onAuditSummary?: GenerateInput["onAuditSummary"]; onAuditSummary?: GenerateInput["onAuditSummary"];
} }
@@ -72,6 +74,10 @@ export async function inspectQualityWithLlm(
}), }),
temperature: 0.1, temperature: 0.1,
task: "quality_inspector", task: "quality_inspector",
schemaName: "llmQaPatchSchema",
traceStage: "qa",
rewriteRound: input.rewriteRound,
onTraceEvent: input.onTraceEvent,
onAuditSummary: input.onAuditSummary, onAuditSummary: input.onAuditSummary,
}); });
+14 -1
View File
@@ -13,6 +13,7 @@ export interface RunStreamingOptimizationWorkflowInput {
input: ArticleInput; input: ArticleInput;
factCard: OptimizationFactCard; factCard: OptimizationFactCard;
onEvent: (event: OptimizationStreamEvent) => void | Promise<void>; onEvent: (event: OptimizationStreamEvent) => void | Promise<void>;
onTraceEvent?: GenerateInput["onTraceEvent"];
onAuditSummary?: GenerateInput["onAuditSummary"]; onAuditSummary?: GenerateInput["onAuditSummary"];
} }
@@ -21,6 +22,7 @@ export async function runStreamingOptimizationWorkflow({
input, input,
factCard, factCard,
onEvent, onEvent,
onTraceEvent,
onAuditSummary, onAuditSummary,
}: RunStreamingOptimizationWorkflowInput) { }: RunStreamingOptimizationWorkflowInput) {
const processSummary: ProcessSummaryStep[] = []; const processSummary: ProcessSummaryStep[] = [];
@@ -31,7 +33,12 @@ export async function runStreamingOptimizationWorkflow({
message: "正在生成优化草稿", message: "正在生成优化草稿",
}); });
let stageStartedAt = Date.now(); let stageStartedAt = Date.now();
let article = await optimizeArticle({ input, factCard, onAuditSummary }); let article = await optimizeArticle({
input,
factCard,
onTraceEvent,
onAuditSummary,
});
processSummary.push( processSummary.push(
createProcessStep({ createProcessStep({
stage: "draft", stage: "draft",
@@ -54,6 +61,8 @@ export async function runStreamingOptimizationWorkflow({
factCard, factCard,
platform: input.platform, platform: input.platform,
sourceImages: input.images, sourceImages: input.images,
rewriteRound: 0,
onTraceEvent,
onAuditSummary, onAuditSummary,
}); });
processSummary.push( processSummary.push(
@@ -81,6 +90,8 @@ export async function runStreamingOptimizationWorkflow({
article, article,
factCard, factCard,
failedChecks, failedChecks,
rewriteRound: nextRound,
onTraceEvent,
onAuditSummary, onAuditSummary,
}); });
rewriteRounds = nextRound; rewriteRounds = nextRound;
@@ -112,6 +123,8 @@ export async function runStreamingOptimizationWorkflow({
factCard, factCard,
platform: input.platform, platform: input.platform,
sourceImages: input.images, sourceImages: input.images,
rewriteRound: nextRound,
onTraceEvent,
onAuditSummary, onAuditSummary,
}); });
processSummary.push( processSummary.push(
+8
View File
@@ -10,6 +10,8 @@ export interface RewriteFailedSectionsInput {
article: OptimizedArticle; article: OptimizedArticle;
factCard: OptimizationFactCard; factCard: OptimizationFactCard;
failedChecks: QaCheck[]; failedChecks: QaCheck[];
rewriteRound?: number;
onTraceEvent?: GenerateInput["onTraceEvent"];
onAuditSummary?: GenerateInput["onAuditSummary"]; onAuditSummary?: GenerateInput["onAuditSummary"];
} }
@@ -17,6 +19,8 @@ export async function rewriteFailedSections({
article, article,
factCard, factCard,
failedChecks, failedChecks,
rewriteRound,
onTraceEvent,
onAuditSummary, onAuditSummary,
}: RewriteFailedSectionsInput): Promise<OptimizedArticle> { }: RewriteFailedSectionsInput): Promise<OptimizedArticle> {
const llmArticle = await generateValidatedJson({ const llmArticle = await generateValidatedJson({
@@ -25,6 +29,10 @@ export async function rewriteFailedSections({
prompt: buildTargetedRewritePrompt({ article, factCard, failedChecks }), prompt: buildTargetedRewritePrompt({ article, factCard, failedChecks }),
temperature: 0.15, temperature: 0.15,
task: "targeted_rewriter", task: "targeted_rewriter",
schemaName: "optimizedArticleSchema",
traceStage: "rewrite",
rewriteRound,
onTraceEvent,
onAuditSummary, onAuditSummary,
}); });