diff --git a/src/lib/db/d1-repository.ts b/src/lib/db/d1-repository.ts index a0596ad..928565d 100644 --- a/src/lib/db/d1-repository.ts +++ b/src/lib/db/d1-repository.ts @@ -6,7 +6,7 @@ import type { ScoringRun, } from "../calibration/types"; import type { - ConfirmedFactCard, + OptimizationFactCard, ImageInput, OptimizedArticle, PublishPlatform, @@ -285,7 +285,9 @@ export function createD1Repository(db: D1Database): AppRepository { ) .bind( jobId, - "auto_extract_then_user_confirmed", + factCard.confirmed_by_user + ? "auto_extract_then_user_confirmed" + : "auto_extract_for_optimization", serialize(factCard), factCard.confirmed_by_user ? 1 : 0, timestamp, @@ -300,7 +302,7 @@ export function createD1Repository(db: D1Database): AppRepository { .bind(jobId) .first(); return row - ? { job_id: row.job_id, ...parseJson(row.fact_card) } + ? { job_id: row.job_id, ...parseJson(row.fact_card) } : null; }, async saveOptimizedArticle(jobId, article) { diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index d7b09ee..6a1c769 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -8,7 +8,7 @@ import type { ScoringRun, } from "../calibration/types"; import type { - ConfirmedFactCard, + OptimizationFactCard, ImageInput, OptimizedArticle, PublishPlatform, @@ -335,7 +335,7 @@ export function updateArticleJob( export function saveFactCard( dbPath: string | undefined, jobId: string, - factCard: ConfirmedFactCard, + factCard: OptimizationFactCard, ) { return withDb(dbPath, (db) => { const timestamp = nowIso(); @@ -351,7 +351,9 @@ export function saveFactCard( updated_at = excluded.updated_at`, ).run({ job_id: jobId, - source: "auto_extract_then_user_confirmed", + source: factCard.confirmed_by_user + ? "auto_extract_then_user_confirmed" + : "auto_extract_for_optimization", fact_card: serialize(factCard), confirmed_by_user: factCard.confirmed_by_user ? 1 : 0, created_at: timestamp, @@ -368,7 +370,7 @@ export function getFactCard(dbPath: string | undefined, jobId: string) { .prepare("select job_id, fact_card from fact_cards where job_id = ?") .get(jobId) as FactCardRow | undefined; return row - ? { job_id: row.job_id, ...parseJson(row.fact_card) } + ? { job_id: row.job_id, ...parseJson(row.fact_card) } : null; }); } diff --git a/src/lib/db/repository.ts b/src/lib/db/repository.ts index 0cf11e2..e87222e 100644 --- a/src/lib/db/repository.ts +++ b/src/lib/db/repository.ts @@ -5,7 +5,7 @@ import type { RubricVersion, ScoringRun, } from "../calibration/types"; -import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types"; +import type { OptimizationFactCard, OptimizedArticle, QaReport } from "../domain/types"; import type { ArticleJob, BrandTemplate, @@ -28,9 +28,9 @@ export interface AppRepository { ): Promise; saveFactCard( jobId: string, - factCard: ConfirmedFactCard, - ): Promise<{ job_id: string } & ConfirmedFactCard>; - getFactCard(jobId: string): Promise<({ job_id: string } & ConfirmedFactCard) | null>; + factCard: OptimizationFactCard, + ): Promise<{ job_id: string } & OptimizationFactCard>; + getFactCard(jobId: string): Promise<({ job_id: string } & OptimizationFactCard) | null>; saveOptimizedArticle(jobId: string, article: OptimizedArticle): Promise; getLatestOptimizedArticle(jobId: string): Promise; saveQaReport(jobId: string, revision: number, report: QaReport): Promise; diff --git a/src/lib/db/sqlite-repository.ts b/src/lib/db/sqlite-repository.ts index 4f9ff69..7b45cbe 100644 --- a/src/lib/db/sqlite-repository.ts +++ b/src/lib/db/sqlite-repository.ts @@ -1,4 +1,4 @@ -import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types"; +import type { OptimizationFactCard, OptimizedArticle, QaReport } from "../domain/types"; import type { AppRepository } from "./repository"; import { createArticleJob, @@ -50,7 +50,7 @@ export function createSqliteRepository(dbPath?: string): AppRepository { ) { return Promise.resolve(updateArticleJob(dbPath, id, changes)); }, - saveFactCard(jobId: string, factCard: ConfirmedFactCard) { + saveFactCard(jobId: string, factCard: OptimizationFactCard) { return Promise.resolve(saveFactCard(dbPath, jobId, factCard)); }, getFactCard(jobId: string) { diff --git a/src/lib/domain/__tests__/validation.test.ts b/src/lib/domain/__tests__/validation.test.ts index 8f2359c..bd796cf 100644 --- a/src/lib/domain/__tests__/validation.test.ts +++ b/src/lib/domain/__tests__/validation.test.ts @@ -4,6 +4,7 @@ import { articleInputSchema, confirmedFactCardSchema, candidateFactCardSchema, + optimizationFactCardSchema, optimizedArticleSchema, qaReportSchema, } from "../validation"; @@ -25,6 +26,54 @@ describe("domain validation", () => { expect(parsed.platform).toBe("official_site"); }); + it("accepts article input with an empty optional title", () => { + const parsed = articleInputSchema.parse({ + title: " ", + body: "完整文章正文可以直接粘贴在这里。", + images: [], + platform: "official_site", + user_instructions: "", + }); + + expect(parsed.title).toBe(""); + expect(parsed.body).toBe("完整文章正文可以直接粘贴在这里。"); + }); + + it("still rejects article input with an empty body", () => { + expect(() => + articleInputSchema.parse({ + title: "", + body: " ", + images: [], + platform: "official_site", + user_instructions: "", + }), + ).toThrow(); + }); + + it("accepts an unconfirmed optimization fact card with unresolved items", () => { + const parsed = optimizationFactCardSchema.parse({ + company_full_name: "", + company_short_names: ["示例科技"], + brand_names: [], + product_names: ["GEO内容优化平台"], + target_industry: "", + target_audience: "市场团队", + experience_years: "", + core_claims: ["提供GEO内容优化服务"], + forbidden_claims: [], + image_topics: [], + uncertain_items: ["客户案例需要确认"], + confirmed_by_user: false, + }); + + expect(parsed.company_full_name).toBe(""); + expect(parsed.experience_years).toBeNull(); + expect(parsed.confirmed_by_user).toBe(false); + expect(parsed.is_ready_for_optimization).toBe(false); + expect(parsed.uncertain_items).toEqual(["客户案例需要确认"]); + }); + it("marks a fact card with unresolved uncertain items as not ready for optimization", () => { const parsed = candidateFactCardSchema.parse({ company_full_name: "Example Technology Co., Ltd.", diff --git a/src/lib/domain/types.ts b/src/lib/domain/types.ts index 8c429e9..52778da 100644 --- a/src/lib/domain/types.ts +++ b/src/lib/domain/types.ts @@ -46,7 +46,11 @@ export interface CandidateFactCard { is_ready_for_optimization: boolean; } -export interface ConfirmedFactCard extends CandidateFactCard { +export interface OptimizationFactCard extends CandidateFactCard { + confirmed_by_user: boolean; +} + +export interface ConfirmedFactCard extends OptimizationFactCard { confirmed_by_user: true; is_ready_for_optimization: true; } diff --git a/src/lib/domain/validation.ts b/src/lib/domain/validation.ts index 5c7c9b8..0b57420 100644 --- a/src/lib/domain/validation.ts +++ b/src/lib/domain/validation.ts @@ -6,6 +6,7 @@ import type { CheckStatus, ConfirmedFactCard, ImageInput, + OptimizationFactCard, OptimizedArticle, PublishPlatform, QaCheck, @@ -110,7 +111,7 @@ export const imageInputSchema = z.object({ }) satisfies z.ZodType; export const articleInputSchema = z.object({ - title: z.string().trim().min(1), + title: z.string().trim().default(""), body: z.string().trim().min(1), images: z.array(imageInputSchema).default([]), platform: publishPlatformSchema, @@ -280,6 +281,17 @@ export const candidateFactCardSchema = factCardBaseSchema is_ready_for_optimization: card.uncertain_items.length === 0, })) satisfies z.ZodType; +export const optimizationFactCardSchema = factCardBaseSchema + .extend({ + confirmed_by_user: z.boolean().optional().default(false), + is_ready_for_optimization: z.boolean().optional(), + }) + .transform((card) => ({ + ...card, + confirmed_by_user: card.confirmed_by_user, + is_ready_for_optimization: card.uncertain_items.length === 0, + })) satisfies z.ZodType; + export const confirmedFactCardSchema = factCardBaseSchema .extend({ company_full_name: z.string().trim().min(1), diff --git a/src/lib/llm/prompts.ts b/src/lib/llm/prompts.ts index 50c3bb8..9612aa9 100644 --- a/src/lib/llm/prompts.ts +++ b/src/lib/llm/prompts.ts @@ -1,6 +1,6 @@ import type { ArticleInput, - ConfirmedFactCard, + OptimizationFactCard, OptimizedArticle, PublishPlatform, QaCheck, @@ -157,7 +157,7 @@ export function buildFactExtractorPrompt(input: ArticleInput) { export function buildArticleOptimizerPrompt( input: ArticleInput, - factCard: ConfirmedFactCard, + factCard: OptimizationFactCard, ) { return [ "Return an OptimizedArticle JSON object with these exact keys:", @@ -187,7 +187,7 @@ export function buildArticleOptimizerPrompt( export function buildQualityInspectorPrompt(input: { article: OptimizedArticle; - factCard: ConfirmedFactCard; + factCard: OptimizationFactCard; platform: PublishPlatform; deterministicChecks: QaCheck[]; }) { @@ -218,7 +218,7 @@ export function buildQualityInspectorPrompt(input: { export function buildTargetedRewritePrompt(input: { article: OptimizedArticle; - factCard: ConfirmedFactCard; + factCard: OptimizationFactCard; failedChecks: QaCheck[]; }) { return [ diff --git a/src/lib/workflow/__tests__/workflow.test.ts b/src/lib/workflow/__tests__/workflow.test.ts index 53a0a49..3b2061e 100644 --- a/src/lib/workflow/__tests__/workflow.test.ts +++ b/src/lib/workflow/__tests__/workflow.test.ts @@ -41,6 +41,19 @@ describe("workflow nodes", () => { ]); }); + it("normalizes missing article titles to an empty string", () => { + const normalized = normalizeInput({ + body: "只有正文也可以开始优化。", + image_lines: "", + platform: "official_site", + user_instructions: "", + }); + + expect(normalized.article_draft.title).toBe(""); + expect(normalized.articleInput.title).toBe(""); + expect(normalized.articleInput.body).toBe("只有正文也可以开始优化。"); + }); + it("returns the 10 required quality checks", () => { const report = inspectQuality({ article: { diff --git a/src/lib/workflow/article-optimizer.ts b/src/lib/workflow/article-optimizer.ts index 9be8d29..3e9d042 100644 --- a/src/lib/workflow/article-optimizer.ts +++ b/src/lib/workflow/article-optimizer.ts @@ -1,6 +1,6 @@ import type { ArticleInput, - ConfirmedFactCard, + OptimizationFactCard, OptimizedArticle, } from "../domain/types"; import { optimizedArticleSchema } from "../domain/validation"; @@ -12,7 +12,7 @@ import { export interface OptimizeArticleInput { input: ArticleInput; - factCard: ConfirmedFactCard; + factCard: OptimizationFactCard; } export async function optimizeArticle({ diff --git a/src/lib/workflow/input-normalizer.ts b/src/lib/workflow/input-normalizer.ts index 328334f..057a83c 100644 --- a/src/lib/workflow/input-normalizer.ts +++ b/src/lib/workflow/input-normalizer.ts @@ -2,7 +2,7 @@ import type { ArticleInput, ImageInput, PublishPlatform } from "../domain/types" import { articleInputSchema } from "../domain/validation"; export interface RawArticleInput { - title: string; + title?: string; body: string; image_lines?: string; images?: ImageInput[]; @@ -23,7 +23,7 @@ export function normalizeInput(input: RawArticleInput) { })); const articleInput = articleInputSchema.parse({ - title: input.title, + title: input.title ?? "", body: input.body, images, platform: input.platform, diff --git a/src/lib/workflow/orchestrator.ts b/src/lib/workflow/orchestrator.ts index b79410c..54f2dd8 100644 --- a/src/lib/workflow/orchestrator.ts +++ b/src/lib/workflow/orchestrator.ts @@ -1,4 +1,4 @@ -import type { ArticleInput, ConfirmedFactCard } from "../domain/types"; +import type { ArticleInput, OptimizationFactCard } from "../domain/types"; import { optimizeArticle } from "./article-optimizer"; import { inspectQualityWithLlm } from "./quality-inspector"; @@ -6,7 +6,7 @@ import { rewriteFailedSections } from "./targeted-rewriter"; export interface RunOptimizationWorkflowInput { input: ArticleInput; - factCard: ConfirmedFactCard; + factCard: OptimizationFactCard; onProgress?: (event: WorkflowProgressEvent) => void | Promise; } diff --git a/src/lib/workflow/quality-inspector.ts b/src/lib/workflow/quality-inspector.ts index 869cfc0..eb6b23d 100644 --- a/src/lib/workflow/quality-inspector.ts +++ b/src/lib/workflow/quality-inspector.ts @@ -1,6 +1,6 @@ import type { CheckStatus, - ConfirmedFactCard, + OptimizationFactCard, ImageInput, OptimizedArticle, PublishPlatform, @@ -40,7 +40,7 @@ const llmQaPatchSchema = z.object({ export interface InspectQualityInput { article: OptimizedArticle; - factCard: ConfirmedFactCard; + factCard: OptimizationFactCard; platform: PublishPlatform; sourceImages: ImageInput[]; } @@ -262,7 +262,7 @@ function check( return { rule_id, status, evidence, reason, suggested_fix, target_agent }; } -function findUnsupportedNumbers(text: string, factCard: ConfirmedFactCard) { +function findUnsupportedNumbers(text: string, factCard: OptimizationFactCard) { const allowed = new Set( [ factCard.experience_years, @@ -278,7 +278,7 @@ function findUnsupportedNumbers(text: string, factCard: ConfirmedFactCard) { .filter((number) => !allowed.has(number)); } -function extractNumbersFromFactCard(factCard: ConfirmedFactCard) { +function extractNumbersFromFactCard(factCard: OptimizationFactCard) { return [ factCard.company_full_name, ...factCard.company_short_names, diff --git a/src/lib/workflow/targeted-rewriter.ts b/src/lib/workflow/targeted-rewriter.ts index 92dcf19..6580cfa 100644 --- a/src/lib/workflow/targeted-rewriter.ts +++ b/src/lib/workflow/targeted-rewriter.ts @@ -1,4 +1,4 @@ -import type { ConfirmedFactCard, OptimizedArticle, QaCheck } from "../domain/types"; +import type { OptimizationFactCard, OptimizedArticle, QaCheck } from "../domain/types"; import { optimizedArticleSchema } from "../domain/validation"; import { generateValidatedJson } from "../llm/client"; import { @@ -8,7 +8,7 @@ import { export interface RewriteFailedSectionsInput { article: OptimizedArticle; - factCard: ConfirmedFactCard; + factCard: OptimizationFactCard; failedChecks: QaCheck[]; }