fix: 兼容事实卡LLM字段形状
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user