diff --git a/src/lib/domain/__tests__/validation.test.ts b/src/lib/domain/__tests__/validation.test.ts index 93fa23e..910a36b 100644 --- a/src/lib/domain/__tests__/validation.test.ts +++ b/src/lib/domain/__tests__/validation.test.ts @@ -77,6 +77,42 @@ describe("domain validation", () => { expect(numericExperience.experience_years).toBe(8); }); + it("normalizes object-shaped and single-string LLM fact card fields", () => { + const parsed = candidateFactCardSchema.parse({ + company_full_name: { name: "示例科技有限公司" }, + company_short_names: "示例科技", + brand_names: [{ name: "示例品牌" }], + product_names: [{ product: "GEO内容优化平台" }], + target_industry: { industry: "GEO内容优化" }, + target_audience: { audience: "市场团队" }, + experience_years: { years: "8年" }, + core_claims: [ + { claim: "提供GEO内容优化服务", source: "原文明确出现" }, + ], + forbidden_claims: [ + { claim: "行业第一", reason: "缺少第三方依据" }, + ], + image_topics: [{ topic: "产品后台截图" }], + uncertain_items: [ + { item: "客户案例", reason: "原文没有给出客户名称" }, + { claim: "出海能力", evidence: "只出现营销表述" }, + ], + }); + + expect(parsed.company_full_name).toBe("示例科技有限公司"); + expect(parsed.company_short_names).toEqual(["示例科技"]); + expect(parsed.brand_names).toEqual(["示例品牌"]); + expect(parsed.product_names).toEqual(["GEO内容优化平台"]); + expect(parsed.target_industry).toBe("GEO内容优化"); + expect(parsed.target_audience).toBe("市场团队"); + expect(parsed.experience_years).toBe(8); + expect(parsed.core_claims).toEqual(["提供GEO内容优化服务"]); + expect(parsed.forbidden_claims).toEqual(["行业第一"]); + expect(parsed.image_topics).toEqual(["产品后台截图"]); + expect(parsed.uncertain_items).toEqual(["客户案例", "出海能力"]); + expect(parsed.is_ready_for_optimization).toBe(false); + }); + it("keeps incomplete candidate fact cards editable and ready", () => { const parsed = candidateFactCardSchema.parse({ company_full_name: "", diff --git a/src/lib/domain/validation.ts b/src/lib/domain/validation.ts index 64cd6a6..c57927e 100644 --- a/src/lib/domain/validation.ts +++ b/src/lib/domain/validation.ts @@ -52,19 +52,129 @@ export const articleInputSchema = z.object({ user_instructions: z.string().trim().default(""), }) satisfies z.ZodType; -const stringOrStringArraySchema = z.preprocess((value) => { +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function firstStringField(record: Record, keys: string[]) { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + } + return null; +} + +const llmStringKeys = [ + "value", + "text", + "name", + "title", + "summary", + "claim", + "item", + "topic", + "audience", + "industry", + "company", + "company_name", + "brand", + "product", + "product_name", + "content", + "body", + "markdown", + "body_markdown", + "reason", + "description", + "evidence", + "source", + "suggestion", + "fix", + "change", + "changed", + "after", +]; + +function normalizedStringOrNull(value: unknown) { + const normalized = normalizeStringValue(value); + return typeof normalized === "string" && normalized.trim().length > 0 + ? normalized.trim() + : null; +} + +function normalizeStringValue(value: unknown): unknown { + if (typeof value === "string") return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value); if (Array.isArray(value)) { return value - .map((item) => (typeof item === "string" ? item.trim() : "")) - .filter(Boolean) + .map(normalizedStringOrNull) + .filter((item): item is string => Boolean(item)) .join("、"); } - return value; -}, z.string().trim()); + if (!isPlainRecord(value)) return value; -const experienceYearsSchema = z.preprocess((value) => { - if (typeof value !== "string") return value; - const trimmed = value.trim(); + const direct = firstStringField(value, llmStringKeys); + if (direct) return direct; + + const stringValues = Object.values(value) + .map(normalizedStringOrNull) + .filter((item): item is string => Boolean(item)); + return stringValues.join(";"); +} + +function normalizeStringList(value: unknown): unknown { + if (value == null) return []; + const items = Array.isArray(value) ? value : [value]; + return items + .map(normalizedStringOrNull) + .filter((item): item is string => Boolean(item)); +} + +const llmStringSchema = z.preprocess(normalizeStringValue, z.string().trim()); + +const requiredLlmStringSchema = z.preprocess( + normalizeStringValue, + z.string().trim().min(1), +); + +const optionalLlmStringSchema = z.preprocess((value) => { + if (value == null) return ""; + return normalizeStringValue(value); +}, z.string().trim().default("")); + +const stringListSchema = z.preprocess( + normalizeStringList, + z.array(z.string().trim().min(1)).default([]), +); + +function normalizeExperienceYears(value: unknown): unknown { + let candidate = value; + if (Array.isArray(candidate)) { + candidate = candidate[0] ?? null; + } + if (isPlainRecord(candidate)) { + for (const key of ["years", "year", "experience_years", "value"]) { + const entry = candidate[key]; + if (typeof entry === "number" && Number.isFinite(entry)) return entry; + } + candidate = + firstStringField(candidate, [ + "years", + "year", + "experience_years", + "value", + "text", + "description", + ]) ?? candidate; + } + if (typeof candidate !== "string") return candidate; + + const trimmed = candidate.trim(); if ( trimmed === "" || /^(?:unknown|none|null|n\/a|not\s+specified|不详|不明确|未知|无)$/i.test( @@ -74,21 +184,26 @@ const experienceYearsSchema = z.preprocess((value) => { return null; } const yearMatch = trimmed.match(/\d{1,3}/); - return yearMatch ? Number(yearMatch[0]) : value; -}, z.number().int().nonnegative().nullable().default(null)); + return yearMatch ? Number(yearMatch[0]) : candidate; +} + +const experienceYearsSchema = z.preprocess( + normalizeExperienceYears, + 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(), - target_audience: stringOrStringArraySchema, + company_full_name: llmStringSchema, + company_short_names: stringListSchema, + brand_names: stringListSchema, + product_names: stringListSchema, + target_industry: llmStringSchema, + target_audience: llmStringSchema, 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([]), - uncertain_items: z.array(z.string().trim().min(1)).default([]), + core_claims: stringListSchema, + forbidden_claims: stringListSchema, + image_topics: stringListSchema, + uncertain_items: stringListSchema, }); export const candidateFactCardSchema = factCardBaseSchema @@ -117,16 +232,6 @@ export const imageSuggestionSchema = z.object({ suggestion: z.string().trim().min(1), }); -function firstStringField(record: Record, keys: string[]) { - for (const key of keys) { - const value = record[key]; - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); - } - } - return null; -} - function normalizeChangedSection(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) { return value;