From 8c24949bfaa1004cb29c0ef3a5781e6f69d9da6e Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 13:26:15 +0800 Subject: [PATCH] feat: define optimizer domain model --- src/lib/domain/__tests__/validation.test.ts | 91 ++++++++++++++ src/lib/domain/types.ts | 84 +++++++++++++ src/lib/domain/validation.ts | 127 ++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 src/lib/domain/__tests__/validation.test.ts create mode 100644 src/lib/domain/types.ts create mode 100644 src/lib/domain/validation.ts diff --git a/src/lib/domain/__tests__/validation.test.ts b/src/lib/domain/__tests__/validation.test.ts new file mode 100644 index 0000000..917bc7a --- /dev/null +++ b/src/lib/domain/__tests__/validation.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + articleInputSchema, + confirmedFactCardSchema, + candidateFactCardSchema, + qaReportSchema, +} from "../validation"; + +describe("domain validation", () => { + it("accepts article input with title, body, images, platform, and instructions", () => { + const parsed = articleInputSchema.parse({ + title: "How GEO Optimization Improves Brand Visibility", + body: "A practical overview of GEO optimization for marketing teams.", + images: [ + { type: "description", content: "Dashboard screenshot" }, + { type: "link", content: "https://example.com/image.png" }, + ], + platform: "official_site", + user_instructions: "Keep the article factual and concise.", + }); + + expect(parsed.images).toHaveLength(2); + expect(parsed.platform).toBe("official_site"); + }); + + 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.", + 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: ["Product dashboard"], + uncertain_items: ["Conflicting product names found"], + }); + + expect(parsed.is_ready_for_optimization).toBe(false); + }); + + it("rejects a confirmed fact card with an empty company full name", () => { + expect(() => + confirmedFactCardSchema.parse({ + company_full_name: "", + company_short_names: ["Example"], + 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: [], + confirmed_by_user: true, + }), + ).toThrow(); + }); + + it("accepts QA reports only with pass, warn, or fail statuses", () => { + const valid = qaReportSchema.parse({ + job_id: "job_123", + revision: 1, + overall_status: "warn", + checks: [ + { + rule_id: "title_quality", + status: "pass", + evidence: "Title reads naturally.", + reason: "No grammar issue detected.", + suggested_fix: "", + target_agent: null, + }, + ], + }); + + expect(valid.checks[0]?.status).toBe("pass"); + expect(() => + qaReportSchema.parse({ + job_id: "job_123", + revision: 1, + overall_status: "blocked", + checks: [], + }), + ).toThrow(); + }); +}); diff --git a/src/lib/domain/types.ts b/src/lib/domain/types.ts new file mode 100644 index 0000000..8c429e9 --- /dev/null +++ b/src/lib/domain/types.ts @@ -0,0 +1,84 @@ +export type PublishPlatform = + | "official_site" + | "media_article" + | "comparison_review" + | "recommendation_list"; + +export type CheckStatus = "pass" | "warn" | "fail"; + +export type QualityRuleId = + | "industry_alignment" + | "image_text_match" + | "voice_consistency" + | "platform_fit" + | "company_name_integrity" + | "title_quality" + | "body_quality" + | "hallucination_risk" + | "claim_consistency" + | "context_sensitive_terms"; + +export interface ImageInput { + type: "description" | "link"; + content: string; +} + +export interface ArticleInput { + title: string; + body: string; + images: ImageInput[]; + platform: PublishPlatform; + user_instructions: string; +} + +export interface CandidateFactCard { + company_full_name: string; + company_short_names: string[]; + brand_names: string[]; + product_names: string[]; + target_industry: string; + target_audience: string; + experience_years: number | null; + core_claims: string[]; + forbidden_claims: string[]; + image_topics: string[]; + uncertain_items: string[]; + is_ready_for_optimization: boolean; +} + +export interface ConfirmedFactCard extends CandidateFactCard { + confirmed_by_user: true; + is_ready_for_optimization: true; +} + +export interface ImageSuggestion { + source: string; + suggestion: string; +} + +export interface OptimizedArticle { + job_id?: string; + revision?: number; + title: string; + summary: string; + body_markdown: string; + image_suggestions: ImageSuggestion[]; + changed_sections: string[]; + requires_user_confirmation: string[]; +} + +export interface QaCheck { + rule_id: QualityRuleId; + status: CheckStatus; + evidence: string; + reason: string; + suggested_fix: string; + target_agent: string | null; +} + +export interface QaReport { + job_id?: string; + revision?: number; + overall_status: CheckStatus; + checks: QaCheck[]; +} diff --git a/src/lib/domain/validation.ts b/src/lib/domain/validation.ts new file mode 100644 index 0000000..545a99e --- /dev/null +++ b/src/lib/domain/validation.ts @@ -0,0 +1,127 @@ +import { z } from "zod"; + +import type { + ArticleInput, + CandidateFactCard, + CheckStatus, + ConfirmedFactCard, + ImageInput, + OptimizedArticle, + PublishPlatform, + QaCheck, + QaReport, + QualityRuleId, +} from "./types"; + +export const publishPlatformSchema = z.enum([ + "official_site", + "media_article", + "comparison_review", + "recommendation_list", +]) satisfies z.ZodType; + +export const checkStatusSchema = z.enum([ + "pass", + "warn", + "fail", +]) satisfies z.ZodType; + +export const qualityRuleIdSchema = z.enum([ + "industry_alignment", + "image_text_match", + "voice_consistency", + "platform_fit", + "company_name_integrity", + "title_quality", + "body_quality", + "hallucination_risk", + "claim_consistency", + "context_sensitive_terms", +]) satisfies z.ZodType; + +export const imageInputSchema = z.object({ + type: z.enum(["description", "link"]), + content: z.string().trim().min(1), +}) satisfies z.ZodType; + +export const articleInputSchema = z.object({ + title: z.string().trim().min(1), + body: z.string().trim().min(1), + images: z.array(imageInputSchema).default([]), + platform: publishPlatformSchema, + user_instructions: z.string().trim().default(""), +}) satisfies z.ZodType; + +const factCardBaseSchema = z.object({ + company_full_name: z.string().trim(), + company_short_names: z.array(z.string().trim().min(1)).default([]), + brand_names: z.array(z.string().trim().min(1)).default([]), + product_names: z.array(z.string().trim().min(1)).default([]), + target_industry: z.string().trim().min(1), + target_audience: z.string().trim().min(1), + experience_years: z.number().int().nonnegative().nullable().default(null), + core_claims: z.array(z.string().trim().min(1)).default([]), + forbidden_claims: z.array(z.string().trim().min(1)).default([]), + image_topics: z.array(z.string().trim().min(1)).default([]), + uncertain_items: z.array(z.string().trim().min(1)).default([]), +}); + +export const candidateFactCardSchema = factCardBaseSchema + .extend({ + is_ready_for_optimization: z.boolean().optional(), + }) + .transform((card) => ({ + ...card, + is_ready_for_optimization: card.uncertain_items.length === 0, + })) satisfies z.ZodType; + +export const confirmedFactCardSchema = candidateFactCardSchema + .pipe( + z.object({ + company_full_name: z.string().trim().min(1), + company_short_names: z.array(z.string().trim().min(1)).default([]), + brand_names: z.array(z.string().trim().min(1)).default([]), + product_names: z.array(z.string().trim().min(1)).default([]), + target_industry: z.string().trim().min(1), + target_audience: z.string().trim().min(1), + experience_years: z.number().int().nonnegative().nullable().default(null), + core_claims: z.array(z.string().trim().min(1)).default([]), + forbidden_claims: z.array(z.string().trim().min(1)).default([]), + image_topics: z.array(z.string().trim().min(1)).default([]), + uncertain_items: z.array(z.never()).length(0), + is_ready_for_optimization: z.literal(true), + }), + ) + .and(z.object({ confirmed_by_user: z.literal(true) })) satisfies z.ZodType; + +export const imageSuggestionSchema = z.object({ + source: z.string().trim().min(1), + suggestion: z.string().trim().min(1), +}); + +export const optimizedArticleSchema = z.object({ + job_id: z.string().trim().min(1).optional(), + revision: z.number().int().positive().optional(), + title: z.string().trim().min(1), + summary: z.string().trim().min(1), + body_markdown: z.string().trim().min(1), + image_suggestions: z.array(imageSuggestionSchema).default([]), + changed_sections: z.array(z.string().trim().min(1)).default([]), + requires_user_confirmation: z.array(z.string().trim().min(1)).default([]), +}) satisfies z.ZodType; + +export const qaCheckSchema = z.object({ + rule_id: qualityRuleIdSchema, + status: checkStatusSchema, + evidence: z.string().trim().min(1), + reason: z.string().trim().min(1), + suggested_fix: z.string().trim().default(""), + target_agent: z.string().trim().min(1).nullable().default(null), +}) satisfies z.ZodType; + +export const qaReportSchema = z.object({ + job_id: z.string().trim().min(1).optional(), + revision: z.number().int().positive().optional(), + overall_status: checkStatusSchema, + checks: z.array(qaCheckSchema).default([]), +}) satisfies z.ZodType;