fix: 兼容事实卡LLM字段形状

This commit is contained in:
Codex
2026-06-24 10:08:47 +08:00
parent ce144038f4
commit 9918cd982b
2 changed files with 171 additions and 30 deletions
@@ -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: "",
+135 -30
View File
@@ -52,19 +52,129 @@ export const articleInputSchema = z.object({
user_instructions: z.string().trim().default(""),
}) satisfies z.ZodType<ArticleInput>;
const stringOrStringArraySchema = z.preprocess((value) => {
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function firstStringField(record: Record<string, unknown>, 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<string, unknown>, 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;