diff --git a/src/app/api/__tests__/jobs.test.ts b/src/app/api/__tests__/jobs.test.ts index 642d23e..cb84cf6 100644 --- a/src/app/api/__tests__/jobs.test.ts +++ b/src/app/api/__tests__/jobs.test.ts @@ -21,6 +21,7 @@ vi.mock("../../../lib/llm/client", async () => { import { POST as confirmFactCard } from "../jobs/[jobId]/confirm-fact-card/route"; import { GET as downloadExport } from "../jobs/[jobId]/exports/[fileName]/route"; import { POST as optimizeJob } from "../jobs/[jobId]/optimize/route"; +import { GET as getJobProgress } from "../jobs/[jobId]/progress/route"; import { POST as createJob } from "../jobs/route"; const validFactCard = { @@ -236,10 +237,50 @@ describe("job API routes", () => { expect(body.timing.steps.map((step) => step.label)).toEqual([ "生成优化稿", "质量检查", + "整理结果", ]); expect(body.timing.steps.every((step) => step.duration_ms >= 0)).toBe(true); }); + it("returns backend-visible optimization progress after a job runs", async () => { + const { job } = await createJobFixture(); + await confirmFactCard( + request(validFactCard), + params<{ jobId: string }>({ jobId: job.id }), + ); + + llmMocks.generateValidatedJson + .mockResolvedValueOnce({ + title: "API LLM Optimized GEO Article", + summary: + "A official site article for Marketing teams about GEO optimization.", + body_markdown: + "Example Technology Co., Ltd. has 8 years of GEO optimization experience.", + image_suggestions: [], + changed_sections: ["title", "body"], + requires_user_confirmation: [], + }) + .mockResolvedValueOnce({ checks: [] }); + + await optimizeJob(request({}), params<{ jobId: string }>({ jobId: job.id })); + + const response = await getJobProgress( + request({}), + params<{ jobId: string }>({ jobId: job.id }), + ); + const body = (await response.json()) as { + progress: { status: string; steps: Array<{ label: string; status: string }> }; + }; + + expect(response.status).toBe(200); + expect(body.progress.status).toBe("completed"); + expect(body.progress.steps.map((step) => step.label)).toEqual([ + "生成优化稿", + "质量检查", + "整理结果", + ]); + }); + it("uses mocked LLM article output during optimize route", async () => { const { job } = await createJobFixture(); await confirmFactCard( diff --git a/src/app/api/jobs/[jobId]/optimize/route.ts b/src/app/api/jobs/[jobId]/optimize/route.ts index 81feaa4..79ff32f 100644 --- a/src/app/api/jobs/[jobId]/optimize/route.ts +++ b/src/app/api/jobs/[jobId]/optimize/route.ts @@ -5,6 +5,10 @@ import { getRepositoryFromRuntime } from "../../../../../lib/db/repository"; import { LlmValidationError } from "../../../../../lib/llm/client"; import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store"; import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator"; +import { + recordWorkflowProgress, + startWorkflowProgress, +} from "../../../../../lib/workflow/progress-store"; interface RouteContext { params: Promise<{ jobId: string }>; @@ -32,6 +36,7 @@ export async function POST(request: Request, context: RouteContext) { } const requestStartedAt = Date.now(); + startWorkflowProgress(jobId); try { const result = await runOptimizationWorkflow({ input: { @@ -42,6 +47,9 @@ export async function POST(request: Request, context: RouteContext) { user_instructions: job.user_instructions, }, factCard: factCardRecord, + onProgress: (event) => { + recordWorkflowProgress(jobId, event); + }, }); const optimizedArticle = await repository.saveOptimizedArticle( jobId, diff --git a/src/app/api/jobs/[jobId]/progress/route.ts b/src/app/api/jobs/[jobId]/progress/route.ts new file mode 100644 index 0000000..cdaa9d2 --- /dev/null +++ b/src/app/api/jobs/[jobId]/progress/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "../../../../../lib/api/auth"; +import { getWorkflowProgress } from "../../../../../lib/workflow/progress-store"; + +interface RouteContext { + params: Promise<{ jobId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const { jobId } = await context.params; + return NextResponse.json({ progress: getWorkflowProgress(jobId) }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 05732cc..db15e17 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -43,6 +43,22 @@ interface TimingSummary { steps: TimingStep[]; } +interface ProgressStep { + label: string; + status: "running" | "completed" | "failed"; + duration_ms?: number; +} + +interface WorkflowProgress { + current_step: string | null; + status: "idle" | "running" | "completed" | "failed"; + steps: ProgressStep[]; +} + +interface ProgressResponse extends ApiErrorResponse { + progress: WorkflowProgress; +} + interface CreateJobResponse extends ApiErrorResponse { job: { id: string }; candidateFactCard: CandidateFactCard; @@ -71,6 +87,7 @@ export default function Home() { const [apiAccessKey, setApiAccessKey] = useState(""); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [lastTiming, setLastTiming] = useState(null); + const [liveProgress, setLiveProgress] = useState(null); const exportBlocked = useMemo( () => qaReport?.overall_status === "fail", @@ -89,10 +106,37 @@ export default function Home() { return () => window.clearInterval(timer); }, [busyAction]); + useEffect(() => { + if (busyAction !== "optimize" || !jobId) return; + let cancelled = false; + + async function pollProgress() { + try { + const response = await fetch(`/api/jobs/${jobId}/progress`, { + headers: apiHeaders(apiAccessKey), + }); + const body = (await response.json()) as ProgressResponse; + if (!cancelled && response.ok) { + setLiveProgress(body.progress); + } + } catch { + // The main optimization request still owns user-facing errors. + } + } + + void pollProgress(); + const timer = window.setInterval(pollProgress, 2000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [apiAccessKey, busyAction, jobId]); + async function analyze() { setBusyAction("analyze"); setMessage(""); setLastTiming(null); + setLiveProgress(null); setOptimizedArticle(null); setQaReport(null); try { @@ -148,6 +192,7 @@ export default function Home() { setBusyAction("optimize"); setMessage(""); setLastTiming(null); + setLiveProgress(null); try { const response = await fetch(`/api/jobs/${jobId}/optimize`, { method: "POST", @@ -158,6 +203,7 @@ export default function Home() { setOptimizedArticle(body.optimizedArticle); setQaReport(body.qaReport); setLastTiming(body.timing ?? null); + setLiveProgress(null); const timingText = body.timing ? `用时 ${formatTiming(body.timing.total_ms)}。` : ""; @@ -204,6 +250,7 @@ export default function Home() { action={busyAction as ProgressAction | null} elapsedSeconds={elapsedSeconds} lastTiming={lastTiming} + liveProgress={liveProgress} />
@@ -41,9 +59,18 @@ export function ProgressPanel({

{getElapsedNotice(elapsedSeconds)}

    - {getProgressStages(action).map((stage) => ( -
  1. {stage.label}
  2. - ))} + {liveSteps + ? liveSteps.map((step) => ( +
  3. + {formatStepStatus(step.status)} {step.label} + {typeof step.duration_ms === "number" + ? `: ${formatMilliseconds(step.duration_ms)}` + : ""} +
  4. + )) + : getProgressStages(action).map((stage) => ( +
  5. {stage.label}
  6. + ))}
) : completedTiming ? ( @@ -73,6 +100,12 @@ function getActionLabel(action: ProgressAction) { return "正在优化文章"; } +function formatStepStatus(status: ProgressStep["status"]) { + if (status === "running") return "进行中"; + if (status === "failed") return "失败"; + return "完成"; +} + function formatMilliseconds(milliseconds: number) { const seconds = Math.round(milliseconds / 1000); if (seconds < 60) return `${seconds} 秒`; diff --git a/src/lib/llm/__tests__/client.test.ts b/src/lib/llm/__tests__/client.test.ts index 96c3a9e..276fbf5 100644 --- a/src/lib/llm/__tests__/client.test.ts +++ b/src/lib/llm/__tests__/client.test.ts @@ -142,6 +142,31 @@ describe("generateValidatedJson", () => { expect(warnSpy).not.toHaveBeenCalled(); }); + it("does not duplicate start and response logs when using the default JSON generator", async () => { + process.env.LLM_PROVIDER = "deepseek"; + process.env.DEEPSEEK_API_KEY = "test-key"; + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + client.setChatCompletionForTesting(async () => ({ + choices: [{ message: { content: '{"value":"from-llm"}' } }], + })); + + const result = await client.generateValidatedJson({ + schema: z.object({ value: z.string() }), + prompt: "Return JSON.", + task: "article_optimizer", + }); + + expect(result).toEqual({ value: "from-llm" }); + const startLogs = infoSpy.mock.calls + .map((call) => call[0]) + .filter((line) => line.startsWith("[llm:start]")); + const responseLogs = infoSpy.mock.calls + .map((call) => call[0]) + .filter((line) => line.startsWith("[llm:response]")); + expect(startLogs).toHaveLength(1); + expect(responseLogs).toHaveLength(1); + }); + it("logs validation failure without leaking provider secrets", async () => { process.env.LLM_PROVIDER = "deepseek"; process.env.DEEPSEEK_API_KEY = "super-secret-key"; diff --git a/src/lib/llm/client.ts b/src/lib/llm/client.ts index e1e1168..60c133f 100644 --- a/src/lib/llm/client.ts +++ b/src/lib/llm/client.ts @@ -224,17 +224,22 @@ export async function generateValidatedJson({ ); } + const usesDefaultGenerator = generateJsonForValidation === generateJson; const status = getLlmProviderStatus(); const startedAt = Date.now(); - console.info( - `[llm:start] provider=${status.provider} model=${input.model ?? status.model} task=${task}`, - ); + if (!usesDefaultGenerator) { + console.info( + `[llm:start] provider=${status.provider} model=${input.model ?? status.model} task=${task}`, + ); + } try { const generated = await generateJsonForValidation(input); - console.info( - `[llm:response] task=${task} duration_ms=${Date.now() - startedAt} raw=${truncateRaw(stringifyForLog(generated))}`, - ); + if (!usesDefaultGenerator) { + console.info( + `[llm:response] task=${task} duration_ms=${Date.now() - startedAt} raw=${truncateRaw(stringifyForLog(generated))}`, + ); + } const parsed = schema.safeParse(generated); if (parsed.success) { console.info(`[llm:validated] task=${task} ok=true`); @@ -254,7 +259,9 @@ export async function generateValidatedJson({ console.info(`[llm:validated] task=${task} ok=false reason=provider_error`); const message = error instanceof Error ? error.message : String(error); console.warn( - `[llm:error] task=${task} duration_ms=${Date.now() - startedAt} message=${quoteLogValue(message)}`, + usesDefaultGenerator + ? `[llm:error] task=${task} message=${quoteLogValue(message)}` + : `[llm:error] task=${task} duration_ms=${Date.now() - startedAt} message=${quoteLogValue(message)}`, ); throw error instanceof Error ? error : new Error(message); } diff --git a/src/lib/progress/__tests__/progress.test.ts b/src/lib/progress/__tests__/progress.test.ts index 65c6207..8e594a6 100644 --- a/src/lib/progress/__tests__/progress.test.ts +++ b/src/lib/progress/__tests__/progress.test.ts @@ -29,7 +29,7 @@ describe("progress helpers", () => { expect(getProgressStages("optimize").map((stage) => stage.label)).toEqual([ "生成优化稿", "质量检查", - "必要时定向修复", + "等待后端步骤更新", "整理结果", ]); }); diff --git a/src/lib/progress/progress.ts b/src/lib/progress/progress.ts index ac10351..e14509b 100644 --- a/src/lib/progress/progress.ts +++ b/src/lib/progress/progress.ts @@ -17,7 +17,7 @@ const progressStages: Record = { optimize: [ { label: "生成优化稿" }, { label: "质量检查" }, - { label: "必要时定向修复" }, + { label: "等待后端步骤更新" }, { label: "整理结果" }, ], }; diff --git a/src/lib/workflow/__tests__/llm-integration.test.ts b/src/lib/workflow/__tests__/llm-integration.test.ts index 767df3c..a6bcc1a 100644 --- a/src/lib/workflow/__tests__/llm-integration.test.ts +++ b/src/lib/workflow/__tests__/llm-integration.test.ts @@ -282,4 +282,41 @@ describe("LLM workflow integration", () => { expect(companyCheck?.status).toBe("fail"); expect(companyCheck?.reason).toContain("公司"); }); + + it("does not let LLM escalate soft quality checks to hard failures", async () => { + llmMocks.generateValidatedJson.mockResolvedValueOnce({ + checks: [ + { + rule_id: "platform_fit", + status: "fail", + evidence: "LLM thinks the structure is not recommendation-like enough.", + reason: "This is a soft platform-fit concern.", + suggested_fix: "Adjust structure if needed.", + target_agent: "body", + }, + ], + }); + + const report = await inspectQualityWithLlm({ + article: { + title: "Example GEO Optimization Guide", + summary: + "A official site article for Marketing teams about GEO optimization.", + body_markdown: + "Example Technology Co., Ltd. has 8 years of GEO optimization experience.", + image_suggestions: [], + changed_sections: [], + requires_user_confirmation: [], + }, + factCard: confirmedFactCard, + platform: "official_site", + sourceImages: [], + }); + + const platformCheck = report.checks.find( + (check) => check.rule_id === "platform_fit", + ); + expect(platformCheck?.status).toBe("warn"); + expect(report.overall_status).not.toBe("fail"); + }); }); diff --git a/src/lib/workflow/__tests__/orchestrator.test.ts b/src/lib/workflow/__tests__/orchestrator.test.ts new file mode 100644 index 0000000..0fa11e6 --- /dev/null +++ b/src/lib/workflow/__tests__/orchestrator.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ConfirmedFactCard } from "../../domain/types"; +import { runOptimizationWorkflow } from "../orchestrator"; + +const workflowMocks = vi.hoisted(() => ({ + optimizeArticle: vi.fn(), + inspectQualityWithLlm: vi.fn(), + rewriteFailedSections: vi.fn(), +})); + +vi.mock("../article-optimizer", () => ({ + optimizeArticle: workflowMocks.optimizeArticle, +})); + +vi.mock("../quality-inspector", () => ({ + inspectQualityWithLlm: workflowMocks.inspectQualityWithLlm, +})); + +vi.mock("../targeted-rewriter", () => ({ + rewriteFailedSections: workflowMocks.rewriteFailedSections, +})); + +const factCard: ConfirmedFactCard = { + company_full_name: "Example Technology Co., Ltd.", + company_short_names: ["Example Tech"], + brand_names: ["Example"], + product_names: ["Example GEO"], + target_industry: "GEO optimization", + target_audience: "Marketing teams", + experience_years: 8, + core_claims: ["Eight years of GEO optimization experience"], + forbidden_claims: [], + image_topics: [], + uncertain_items: [], + is_ready_for_optimization: true, + confirmed_by_user: true, +}; + +const article = { + title: "Optimized", + summary: "Summary", + body_markdown: "Body", + image_suggestions: [], + changed_sections: [], + requires_user_confirmation: [], +}; + +const failCheck = { + rule_id: "body_quality" as const, + status: "fail" as const, + evidence: "Bad body.", + reason: "Needs rewrite.", + suggested_fix: "Rewrite body.", + target_agent: "body", +}; + +describe("runOptimizationWorkflow", () => { + it("reports live progress for initial generation, QA, rewrite rounds, and finalization", async () => { + workflowMocks.optimizeArticle.mockResolvedValueOnce(article); + workflowMocks.inspectQualityWithLlm + .mockResolvedValueOnce({ + overall_status: "fail", + checks: [failCheck], + }) + .mockResolvedValueOnce({ + overall_status: "pass", + checks: [], + }); + workflowMocks.rewriteFailedSections.mockResolvedValueOnce({ + ...article, + body_markdown: "Rewritten body", + }); + const events: Array<{ label: string; status: string }> = []; + + await runOptimizationWorkflow({ + input: { + title: "Original", + body: "Original body", + images: [], + platform: "official_site", + user_instructions: "", + }, + factCard, + onProgress: (event) => events.push({ label: event.label, status: event.status }), + }); + + expect(events).toEqual([ + { label: "生成优化稿", status: "running" }, + { label: "生成优化稿", status: "completed" }, + { label: "质量检查", status: "running" }, + { label: "质量检查", status: "completed" }, + { label: "定向修复第 1 轮", status: "running" }, + { label: "定向修复第 1 轮", status: "completed" }, + { label: "质量复检第 1 轮", status: "running" }, + { label: "质量复检第 1 轮", status: "completed" }, + { label: "整理结果", status: "running" }, + { label: "整理结果", status: "completed" }, + ]); + }); +}); diff --git a/src/lib/workflow/__tests__/workflow.test.ts b/src/lib/workflow/__tests__/workflow.test.ts index 5e16dbf..0dbdcc5 100644 --- a/src/lib/workflow/__tests__/workflow.test.ts +++ b/src/lib/workflow/__tests__/workflow.test.ts @@ -92,7 +92,7 @@ describe("workflow nodes", () => { expect(report.checks[0]?.evidence).not.toContain("Article"); }); - it("hard-fails incomplete company names, hallucinated numbers, industry drift, and conflicting years", () => { + it("hard-fails incomplete company names, hallucinated numbers, and conflicting years while warning on industry drift", () => { const report = inspectQuality({ article: { title: "Example Wins Finance Automation Market!!!", @@ -113,11 +113,36 @@ describe("workflow nodes", () => { expect.arrayContaining([ "company_name_integrity", "hallucination_risk", - "industry_alignment", "claim_consistency", ]), ); + const industryCheck = report.checks.find( + (check) => check.rule_id === "industry_alignment", + ); + expect(industryCheck?.status).toBe("warn"); expect(report.overall_status).toBe("fail"); }); + it("does not flag numbers already present in confirmed fact card claims", () => { + const report = inspectQuality({ + article: { + title: "Example GEO Optimization Guide", + summary: "Example Technology Co., Ltd. serves teams with 8 years of experience.", + body_markdown: + "Example Technology Co., Ltd. has 8 years of GEO optimization experience.", + image_suggestions: [], + changed_sections: [], + requires_user_confirmation: [], + }, + factCard: confirmedFactCard, + platform: "official_site", + sourceImages: [], + }); + + const hallucinationCheck = report.checks.find( + (check) => check.rule_id === "hallucination_risk", + ); + expect(hallucinationCheck?.status).toBe("pass"); + }); + }); diff --git a/src/lib/workflow/orchestrator.ts b/src/lib/workflow/orchestrator.ts index cef5d0b..b79410c 100644 --- a/src/lib/workflow/orchestrator.ts +++ b/src/lib/workflow/orchestrator.ts @@ -7,6 +7,7 @@ import { rewriteFailedSections } from "./targeted-rewriter"; export interface RunOptimizationWorkflowInput { input: ArticleInput; factCard: ConfirmedFactCard; + onProgress?: (event: WorkflowProgressEvent) => void | Promise; } export interface WorkflowTimingStep { @@ -14,6 +15,14 @@ export interface WorkflowTimingStep { duration_ms: number; } +export type WorkflowProgressStatus = "running" | "completed" | "failed"; + +export interface WorkflowProgressEvent { + label: string; + status: WorkflowProgressStatus; + duration_ms?: number; +} + export interface WorkflowTimingSummary { total_ms: number; steps: WorkflowTimingStep[]; @@ -23,54 +32,91 @@ async function timedStep( label: string, steps: WorkflowTimingStep[], action: () => Promise, + onProgress?: (event: WorkflowProgressEvent) => void | Promise, ): Promise { const startedAt = Date.now(); + await onProgress?.({ label, status: "running" }); try { - return await action(); - } finally { + const result = await action(); + const duration = Date.now() - startedAt; steps.push({ label, - duration_ms: Date.now() - startedAt, + duration_ms: duration, }); + await onProgress?.({ label, status: "completed", duration_ms: duration }); + return result; + } catch (error) { + const duration = Date.now() - startedAt; + steps.push({ + label, + duration_ms: duration, + }); + await onProgress?.({ label, status: "failed", duration_ms: duration }); + throw error; } } +async function completeStep( + label: string, + steps: WorkflowTimingStep[], + onProgress?: (event: WorkflowProgressEvent) => void | Promise, +) { + await timedStep(label, steps, async () => undefined, onProgress); +} + export async function runOptimizationWorkflow({ input, factCard, + onProgress, }: RunOptimizationWorkflowInput) { const startedAt = Date.now(); const timingSteps: WorkflowTimingStep[] = []; - let article = await timedStep("生成优化稿", timingSteps, () => - optimizeArticle({ input, factCard }), + let article = await timedStep( + "生成优化稿", + timingSteps, + () => optimizeArticle({ input, factCard }), + onProgress, ); - let qaReport = await timedStep("质量检查", timingSteps, () => - inspectQualityWithLlm({ - article, - factCard, - platform: input.platform, - 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, () => + let qaReport = await timedStep( + "质量检查", + timingSteps, + () => inspectQualityWithLlm({ article, factCard, platform: input.platform, sourceImages: input.images, }), + onProgress, + ); + 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 }), + onProgress, + ); + rewriteRounds = nextRound; + qaReport = await timedStep( + `质量复检第 ${nextRound} 轮`, + timingSteps, + () => + inspectQualityWithLlm({ + article, + factCard, + platform: input.platform, + sourceImages: input.images, + }), + onProgress, ); } + await completeStep("整理结果", timingSteps, onProgress); + return { article, qaReport, diff --git a/src/lib/workflow/progress-store.ts b/src/lib/workflow/progress-store.ts new file mode 100644 index 0000000..1d0f917 --- /dev/null +++ b/src/lib/workflow/progress-store.ts @@ -0,0 +1,88 @@ +import type { + WorkflowProgressEvent, + WorkflowProgressStatus, +} from "./orchestrator"; + +export interface WorkflowProgressStep { + label: string; + status: WorkflowProgressStatus; + started_at: string; + completed_at?: string; + duration_ms?: number; +} + +export interface WorkflowProgressSnapshot { + job_id: string; + current_step: string | null; + status: "idle" | "running" | "completed" | "failed"; + started_at: string | null; + updated_at: string | null; + steps: WorkflowProgressStep[]; +} + +const progressByJob = new Map(); + +export function startWorkflowProgress(jobId: string) { + const now = new Date().toISOString(); + const snapshot: WorkflowProgressSnapshot = { + job_id: jobId, + current_step: null, + status: "running", + started_at: now, + updated_at: now, + steps: [], + }; + progressByJob.set(jobId, snapshot); + return snapshot; +} + +export function recordWorkflowProgress( + jobId: string, + event: WorkflowProgressEvent, +) { + const now = new Date().toISOString(); + const snapshot = progressByJob.get(jobId) ?? startWorkflowProgress(jobId); + const existing = snapshot.steps.find((step) => step.label === event.label); + + if (existing) { + existing.status = event.status; + existing.duration_ms = event.duration_ms ?? existing.duration_ms; + if (event.status !== "running") { + existing.completed_at = now; + } + } else { + snapshot.steps.push({ + label: event.label, + status: event.status, + started_at: now, + completed_at: event.status === "running" ? undefined : now, + duration_ms: event.duration_ms, + }); + } + + snapshot.current_step = + event.status === "running" ? event.label : snapshot.current_step; + if (event.status === "failed") { + snapshot.status = "failed"; + } else if (event.label === "整理结果" && event.status === "completed") { + snapshot.status = "completed"; + snapshot.current_step = null; + } else { + snapshot.status = "running"; + } + snapshot.updated_at = now; + return snapshot; +} + +export function getWorkflowProgress(jobId: string) { + return ( + progressByJob.get(jobId) ?? { + job_id: jobId, + current_step: null, + status: "idle", + started_at: null, + updated_at: null, + steps: [], + } + ); +} diff --git a/src/lib/workflow/quality-inspector.ts b/src/lib/workflow/quality-inspector.ts index 4ac98e8..c2a858c 100644 --- a/src/lib/workflow/quality-inspector.ts +++ b/src/lib/workflow/quality-inspector.ts @@ -29,6 +29,12 @@ const REQUIRED_RULES: QualityRuleId[] = [ "context_sensitive_terms", ]; +const HARD_FAILURE_RULES = new Set([ + "company_name_integrity", + "hallucination_risk", + "claim_consistency", +]); + const llmQaPatchSchema = z.object({ checks: z.array(qaCheckSchema).default([]), }); @@ -78,9 +84,13 @@ export async function inspectQualityWithLlm( if (deterministicCheck.status === "fail") { return deterministicCheck; } + const status = + llmCheck.status === "fail" && !HARD_FAILURE_RULES.has(deterministicCheck.rule_id) + ? "warn" + : llmCheck.status; return { ...deterministicCheck, - status: llmCheck.status, + status, evidence: llmCheck.evidence, reason: llmCheck.reason, suggested_fix: llmCheck.suggested_fix, @@ -107,14 +117,15 @@ function inspectRule( const lower = combined.toLowerCase(); if (ruleId === "industry_alignment") { - const aligned = lower.includes(factCard.target_industry.toLowerCase()); + const targetIndustry = factCard.target_industry.trim().toLowerCase(); + const aligned = targetIndustry.length === 0 || lower.includes(targetIndustry); return check( ruleId, - aligned ? "pass" : "fail", + aligned ? "pass" : "warn", aligned ? factCard.target_industry : article.summary, aligned ? "文章内容与事实卡确认的目标行业一致。" - : "文章内容偏离事实卡确认的目标行业。", + : "文章可能没有充分体现事实卡确认的目标行业。", "围绕事实卡确认的目标行业重写相关段落。", aligned ? null : "body", ); @@ -254,11 +265,30 @@ function check( function findUnsupportedNumbers(text: string, factCard: ConfirmedFactCard) { const allowed = new Set( - [factCard.experience_years] - .filter((value): value is number => typeof value === "number") + [ + factCard.experience_years, + ...extractNumbersFromFactCard(factCard), + ] + .filter((value): value is number | string => + typeof value === "number" || typeof value === "string", + ) .map(String), ); return [...text.matchAll(/\b\d{1,4}\b/g)] .map((match) => match[0]) .filter((number) => !allowed.has(number)); } + +function extractNumbersFromFactCard(factCard: ConfirmedFactCard) { + return [ + factCard.company_full_name, + ...factCard.company_short_names, + ...factCard.brand_names, + ...factCard.product_names, + factCard.target_industry, + factCard.target_audience, + ...factCard.core_claims, + ...factCard.forbidden_claims, + ...factCard.image_topics, + ].flatMap((value) => [...value.matchAll(/\b\d{1,4}\b/g)].map((match) => match[0])); +}