fix: 兼容事实卡LLM字段形状
This commit is contained in:
@@ -77,6 +77,42 @@ describe("domain validation", () => {
|
|||||||
expect(numericExperience.experience_years).toBe(8);
|
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", () => {
|
it("keeps incomplete candidate fact cards editable and ready", () => {
|
||||||
const parsed = candidateFactCardSchema.parse({
|
const parsed = candidateFactCardSchema.parse({
|
||||||
company_full_name: "",
|
company_full_name: "",
|
||||||
|
|||||||
+135
-30
@@ -52,19 +52,129 @@ export const articleInputSchema = z.object({
|
|||||||
user_instructions: z.string().trim().default(""),
|
user_instructions: z.string().trim().default(""),
|
||||||
}) satisfies z.ZodType<ArticleInput>;
|
}) 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)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
.map(normalizedStringOrNull)
|
||||||
.filter(Boolean)
|
.filter((item): item is string => Boolean(item))
|
||||||
.join("、");
|
.join("、");
|
||||||
}
|
}
|
||||||
return value;
|
if (!isPlainRecord(value)) return value;
|
||||||
}, z.string().trim());
|
|
||||||
|
|
||||||
const experienceYearsSchema = z.preprocess((value) => {
|
const direct = firstStringField(value, llmStringKeys);
|
||||||
if (typeof value !== "string") return value;
|
if (direct) return direct;
|
||||||
const trimmed = value.trim();
|
|
||||||
|
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 (
|
if (
|
||||||
trimmed === "" ||
|
trimmed === "" ||
|
||||||
/^(?:unknown|none|null|n\/a|not\s+specified|不详|不明确|未知|无)$/i.test(
|
/^(?:unknown|none|null|n\/a|not\s+specified|不详|不明确|未知|无)$/i.test(
|
||||||
@@ -74,21 +184,26 @@ const experienceYearsSchema = z.preprocess((value) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const yearMatch = trimmed.match(/\d{1,3}/);
|
const yearMatch = trimmed.match(/\d{1,3}/);
|
||||||
return yearMatch ? Number(yearMatch[0]) : value;
|
return yearMatch ? Number(yearMatch[0]) : candidate;
|
||||||
}, z.number().int().nonnegative().nullable().default(null));
|
}
|
||||||
|
|
||||||
|
const experienceYearsSchema = z.preprocess(
|
||||||
|
normalizeExperienceYears,
|
||||||
|
z.number().int().nonnegative().nullable().default(null),
|
||||||
|
);
|
||||||
|
|
||||||
const factCardBaseSchema = z.object({
|
const factCardBaseSchema = z.object({
|
||||||
company_full_name: z.string().trim(),
|
company_full_name: llmStringSchema,
|
||||||
company_short_names: z.array(z.string().trim().min(1)).default([]),
|
company_short_names: stringListSchema,
|
||||||
brand_names: z.array(z.string().trim().min(1)).default([]),
|
brand_names: stringListSchema,
|
||||||
product_names: z.array(z.string().trim().min(1)).default([]),
|
product_names: stringListSchema,
|
||||||
target_industry: z.string().trim(),
|
target_industry: llmStringSchema,
|
||||||
target_audience: stringOrStringArraySchema,
|
target_audience: llmStringSchema,
|
||||||
experience_years: experienceYearsSchema,
|
experience_years: experienceYearsSchema,
|
||||||
core_claims: z.array(z.string().trim().min(1)).default([]),
|
core_claims: stringListSchema,
|
||||||
forbidden_claims: z.array(z.string().trim().min(1)).default([]),
|
forbidden_claims: stringListSchema,
|
||||||
image_topics: z.array(z.string().trim().min(1)).default([]),
|
image_topics: stringListSchema,
|
||||||
uncertain_items: z.array(z.string().trim().min(1)).default([]),
|
uncertain_items: stringListSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const candidateFactCardSchema = factCardBaseSchema
|
export const candidateFactCardSchema = factCardBaseSchema
|
||||||
@@ -117,16 +232,6 @@ export const imageSuggestionSchema = z.object({
|
|||||||
suggestion: z.string().trim().min(1),
|
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) {
|
function normalizeChangedSection(value: unknown) {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
Reference in New Issue
Block a user