diff --git a/src/lib/domain/__tests__/validation.test.ts b/src/lib/domain/__tests__/validation.test.ts index 917bc7a..85fea9f 100644 --- a/src/lib/domain/__tests__/validation.test.ts +++ b/src/lib/domain/__tests__/validation.test.ts @@ -42,6 +42,62 @@ describe("domain validation", () => { expect(parsed.is_ready_for_optimization).toBe(false); }); + it("normalizes near-valid LLM fact card field types", () => { + const emptyExperience = 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", "Brand teams"], + experience_years: "", + core_claims: ["GEO optimization experience"], + forbidden_claims: [], + image_topics: [], + uncertain_items: [], + }); + + const numericExperience = 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: ["GEO optimization experience"], + forbidden_claims: [], + image_topics: [], + uncertain_items: [], + }); + + expect(emptyExperience.target_audience).toBe("Marketing teams、Brand teams"); + expect(emptyExperience.experience_years).toBeNull(); + expect(numericExperience.experience_years).toBe(8); + }); + + it("keeps incomplete candidate fact cards editable and ready", () => { + const parsed = candidateFactCardSchema.parse({ + company_full_name: "", + company_short_names: [], + brand_names: [], + product_names: [], + target_industry: "", + target_audience: "", + experience_years: "", + core_claims: [], + forbidden_claims: [], + image_topics: [], + uncertain_items: [], + }); + + expect(parsed.target_industry).toBe(""); + expect(parsed.target_audience).toBe(""); + expect(parsed.experience_years).toBeNull(); + expect(parsed.uncertain_items).toEqual([]); + expect(parsed.is_ready_for_optimization).toBe(true); + }); + it("rejects a confirmed fact card with an empty company full name", () => { expect(() => confirmedFactCardSchema.parse({ diff --git a/src/lib/domain/validation.ts b/src/lib/domain/validation.ts index cf2bbc5..d969637 100644 --- a/src/lib/domain/validation.ts +++ b/src/lib/domain/validation.ts @@ -52,14 +52,39 @@ export const articleInputSchema = z.object({ user_instructions: z.string().trim().default(""), }) satisfies z.ZodType; +const stringOrStringArraySchema = z.preprocess((value) => { + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean) + .join("、"); + } + return value; +}, z.string().trim()); + +const experienceYearsSchema = z.preprocess((value) => { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if ( + trimmed === "" || + /^(?:unknown|none|null|n\/a|not\s+specified|不详|不明确|未知|无)$/i.test( + trimmed, + ) + ) { + return null; + } + const yearMatch = trimmed.match(/\d{1,3}/); + return yearMatch ? Number(yearMatch[0]) : value; +}, z.number().int().nonnegative().nullable().default(null)); + 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), + target_industry: z.string().trim(), + target_audience: stringOrStringArraySchema, + experience_years: experienceYearsSchema, 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([]),