Files
ttstd-infinite-canvas/web/test/ai-art-critique.test.ts
T
QAyong a6504c0e00 feat(plugin): AI 审美批改 - 增加结构化视觉分析与精准标注 (#387)
* feat(portrait-clearance): 肖像排查 - 增加画布插件与本机检测链路

* fix(web): 插件中心 - 修复应用插件筛选与自动定位

* feat(plugin): AI审美批改 - 完成分析报告与问题标注闭环
2026-09-02 20:32:58 +08:00

661 lines
30 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import "../src/lib/plugins/builtin/ai-art-critique";
import { getNodeAcceptedInputKind, getNodeDefinition } from "../src/lib/canvas/node-registry";
import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "../src/lib/canvas/tool-registry";
import { ART_CRITIQUE_NODE_TYPE, ART_CRITIQUE_RUBRIC_VERSION } from "../src/lib/art-critique/contracts";
import { ART_CRITIQUE_SEVERITY_COLORS, isRenderableArtCritiqueTarget, referenceTargetForIssue, repairIssueTarget, targetBounds, targetPolygon } from "../src/lib/art-critique/annotation";
import { artCritiqueMessages, parseArtCritiqueResponse } from "../src/lib/art-critique/review";
import {
applyGrounding,
applyEditPrompts,
applyReferenceCoordinates,
applyVerification,
artCritiquePipelineTools,
deduplicateAggregateIssues,
filterAggregateAgainstCandidates,
parseAggregatePayload,
parseEditPromptPayload,
parseGroundingPayload,
parseSceneReviewPayload,
parseVerificationPayload,
} from "../src/lib/art-critique/pipeline";
import { ART_CRITIQUE_RUBRIC, buildArtCritiqueRubricPrompt } from "../src/lib/art-critique/rubrics";
import type { ToolResponseResult } from "../src/services/api/image";
function response(value: unknown): ToolResponseResult {
return {
content: "",
toolCalls: [{ id: "call-1", type: "function", function: { name: "submit_art_critique", arguments: JSON.stringify(value) } }],
};
}
const input = { dataUrl: "data:image/png;base64,placeholder", title: "测试图片", sourceFingerprint: "v1-test" };
describe("AI 审美批改插件注册", () => {
test("节点只接受图片并隐藏输出端口", () => {
expect(getNodeAcceptedInputKind(ART_CRITIQUE_NODE_TYPE)).toBe("image");
expect(getNodeDefinition(ART_CRITIQUE_NODE_TYPE)?.showOutputConnection).toBe(false);
});
test("启用后出现在添加节点菜单", () => {
const baseContext = {
workspaceMode: "simple" as const,
isProjectLinked: false,
handlers: {} as AddNodeMenuContext["handlers"],
};
const enabledCommands = resolveAddNodeMenuCommands({ ...baseContext, enabledPluginIds: new Set(["ai-art-critique"]) });
const disabledCommands = resolveAddNodeMenuCommands({ ...baseContext, enabledPluginIds: new Set<string>() });
expect(enabledCommands.some((command) => command.id === ART_CRITIQUE_NODE_TYPE)).toBe(true);
expect(disabledCommands.some((command) => command.id === ART_CRITIQUE_NODE_TYPE)).toBe(false);
});
});
describe("AI 审美批改报告解析", () => {
test("解析 AI 生成的局部编辑提示词并按问题 ID 应用", () => {
const issue = {
id: "issue-1",
category: "lighting",
title: "主体脸部受光不足",
explanation: "脸部暗部过重,表情与互动的可读性下降。",
severity: "high",
confidence: 0.9,
target: {
type: "box",
points: [
{ x: 0.3, y: 0.2 },
{ x: 0.6, y: 0.6 },
],
},
targetDescription: "女性主体的脸部",
suggestion: {
goal: "提升人物表情的可读性",
actions: ["提高脸部局部亮度", "保留原有光线方向"],
preserve: ["保留夜景氛围"],
expectedEffect: "人物重新成为视觉焦点",
},
} satisfies import("../src/lib/art-critique/contracts").ArtCritiqueIssue;
const result = parseEditPromptPayload({
prompts: [{ issueId: "issue-1", editPrompt: "只提高女性主体脸部的局部亮度,保留窗光方向、人物身份、构图和夜景氛围。" }],
});
const warnings: string[] = [];
const applied = applyEditPrompts([issue], result.prompts, warnings);
expect(applied[0].editPrompt).toContain("女性主体脸部");
expect(warnings).toHaveLength(0);
});
test("AI 提示词缺失时不使用本地拼接替代", () => {
const issue = {
id: "issue-1",
category: "lighting" as const,
title: "主体脸部受光不足",
explanation: "脸部暗部过重。",
severity: "high" as const,
confidence: 0.9,
target: { type: "global" as const, points: [] },
suggestion: { goal: "提升可读性", actions: ["提高局部亮度"], preserve: ["保留夜景氛围"], expectedEffect: "人物更突出" },
};
const warnings: string[] = [];
const applied = applyEditPrompts([issue], [], warnings);
expect(applied[0].editPrompt).toBeUndefined();
expect(warnings[0]).toContain("不会使用本地拼接替代");
});
test("使用版本化 Rubric 并把高优先级问题排在前面", () => {
const report = parseArtCritiqueResponse(
response({
summary: "画面有明确主体。",
strengths: ["色彩统一"],
issues: [
{
id: "low-first",
category: "color",
title: "颜色略杂",
explanation: "次要区域饱和度略高。",
severity: "low",
confidence: 0.98,
target: { type: "global", points: [] },
suggestion: { goal: "收拢色彩", actions: ["降低次要区域饱和度"], preserve: ["保留主体强调色"], expectedEffect: "主体更突出" },
},
{
id: "high-second",
category: "composition",
title: "主体方向感不足",
explanation: "主体与留白方向关系不清。",
severity: "high",
confidence: 0.7,
target: {
type: "box",
points: [
{ x: 0.2, y: 0.2 },
{ x: 0.6, y: 0.8 },
],
},
suggestion: { goal: "增强方向感", actions: ["调整主体位置"], preserve: ["保留人物姿态"], expectedEffect: "视线移动更自然" },
},
],
}),
input,
);
expect(report.rubricVersion).toBe(ART_CRITIQUE_RUBRIC_VERSION);
expect(report.issues.map((issue) => issue.id)).toEqual(["high-second", "low-first"]);
});
test("Prompt 包含 Rubric 版本、参考方法和构图意图约束", () => {
const systemText = artCritiqueMessages(input)[0].content;
expect(typeof systemText).toBe("string");
expect(systemText).toContain(ART_CRITIQUE_RUBRIC_VERSION);
expect(systemText).toContain("CADB / SAMP-Net");
expect(systemText).toContain("不能仅因没有使用三分法就报告");
expect(buildArtCritiqueRubricPrompt()).toContain("HumanAesExpert");
expect(ART_CRITIQUE_RUBRIC).toHaveLength(4);
});
test("窄 Reviewer Prompt 只注入自己的规则并允许没有问题", () => {
const prompt = buildArtCritiqueRubricPrompt({ categories: ["color"], includeReferenceMapping: false });
expect(prompt).toContain("【色彩|color】");
expect(prompt).not.toContain("【构图与视觉层级|composition】");
expect(prompt).not.toContain("参考方法映射");
expect(prompt).toContain("允许没有问题");
});
test("保留报告合同并把不完整的局部目标降级为全局问题", () => {
const report = parseArtCritiqueResponse(
response({
summary: "主体明确,但画面层次还可以更集中。",
strengths: ["主体轮廓清楚"],
issues: [
{
id: "composition-1",
category: "composition",
title: "主体偏离视觉重心",
explanation: "主体周围留白不均。",
severity: "high",
confidence: 0.86,
target: { type: "box", points: [{ x: 0.2, y: 0.3 }] },
suggestion: { goal: "集中注意力", actions: ["收紧左侧留白"], preserve: ["保留主体姿态"], expectedEffect: "视觉重心更稳定" },
},
],
}),
input,
);
expect(report.sourceFingerprint).toBe("v1-test");
expect(report.issues).toHaveLength(1);
expect(report.issues[0].target).toEqual({ type: "global", points: [] });
});
test("最多保留五个问题并裁剪越界坐标", () => {
const issues = Array.from({ length: 8 }, (_, index) => ({
id: `issue-${index}`,
category: "color",
title: `问题 ${index}`,
explanation: "需要调整。",
severity: "low",
confidence: 2,
target: { type: "point", points: [{ x: -1, y: 2 }] },
suggestion: { goal: "改善", actions: ["调整"], preserve: [], expectedEffect: "更统一" },
}));
const report = parseArtCritiqueResponse(response({ summary: "总结", strengths: [], issues }), input);
expect(report.issues).toHaveLength(5);
expect(report.issues[0].confidence).toBe(1);
expect(report.issues[0].target.points[0]).toEqual({ x: 0, y: 1 });
});
});
describe("AI 审美批改标注几何", () => {
test("box 目标转换为四边形并计算边界", () => {
const target = {
type: "box" as const,
points: [
{ x: 0.8, y: 0.7 },
{ x: 0.2, y: 0.1 },
],
};
expect(targetPolygon(target)).toEqual([
{ x: 0.2, y: 0.1 },
{ x: 0.8, y: 0.1 },
{ x: 0.8, y: 0.7 },
{ x: 0.2, y: 0.7 },
]);
expect(targetBounds(target)).toEqual({ x: 0.2, y: 0.1, width: 0.6000000000000001, height: 0.6 });
});
test("severity 使用固定颜色角色", () => {
expect(ART_CRITIQUE_SEVERITY_COLORS.high).toBe("#ef4444");
expect(ART_CRITIQUE_SEVERITY_COLORS.medium).toBe("#f59e0b");
});
test("定位失败时用问题描述生成参考区域或多个参考点", () => {
const foreground = referenceTargetForIssue({
category: "composition",
title: "前景人物压过主体",
explanation: "左侧前景的视觉重量过重。",
targetDescription: "左侧前景人物与主体区域",
});
const table = referenceTargetForIssue({
category: "proportion",
title: "桌沿切断人物关系",
explanation: "桌沿和桌腿位于画面下方。",
targetDescription: "桌沿、桌腿和桌面静物",
});
const highlights = referenceTargetForIssue({
category: "color",
title: "多处高饱和暖色亮点分散焦点",
explanation: "画面中存在多个分散的暖色亮点。",
targetDescription: "多处高饱和暖色亮点",
});
expect(foreground.type).toBe("box");
expect(targetBounds(foreground).width).toBeCloseTo(0.36);
expect(table.type).toBe("box");
expect(targetBounds(table).y).toBe(0.5);
expect(highlights.type).toBe("points");
expect(highlights.points.length).toBeGreaterThan(1);
});
test("无效框坐标会被识别并修复为可见参考框", () => {
const issue = {
id: "invalid-box",
category: "composition" as const,
title: "前景暗部压过主体",
explanation: "左侧前景形成连续暗部。",
severity: "high" as const,
confidence: 0.9,
target: {
type: "box" as const,
points: [
{ x: 0.2, y: 0.1 },
{ x: 0.8, y: 0.1 },
],
},
targetDescription: "左侧前景人物",
suggestion: { goal: "减轻干扰", actions: ["降低前景对比"], preserve: [], expectedEffect: "主体更突出" },
};
expect(isRenderableArtCritiqueTarget(issue.target)).toBe(false);
const repaired = repairIssueTarget(issue);
expect(repaired.targetSource).toBe("reference");
expect(repaired.target.type).toBe("box");
expect(targetBounds(repaired.target).height).toBeGreaterThan(0.8);
});
});
describe("AI 审美批改多阶段管线", () => {
test("阶段工具覆盖场景、Reviewer、聚合、定位、提示词生成和复核", () => {
expect(Object.values(artCritiquePipelineTools).map((tool) => tool.function.name)).toEqual([
"analyze_art_scene",
"review_art_composition",
"review_art_color",
"review_art_lighting",
"review_art_structure",
"aggregate_art_critique",
"ground_art_critique_issues",
"generate_art_edit_prompts",
"verify_art_critique",
]);
});
test("解析场景和候选问题", () => {
const result = parseSceneReviewPayload({
scene: {
imageType: "portrait",
style: ["电影感"],
subjects: [{ id: "person", description: "坐在桌边的人物", importance: "primary" }],
intendedFocus: "人物表情",
compositionType: ["对角线"],
lightingType: ["窗光"],
mood: "安静",
estimatedIntent: "表现人物的孤独感",
sceneDepth: "medium",
},
candidates: [
{
id: "c-1",
checkId: "subject-placement",
category: "composition",
title: "主体被左侧前景压住",
observation: "左侧前景占据很大面积",
reason: "人物视觉重量被削弱",
evidence: ["前景轮廓覆盖画面左侧"],
severity: 0.8,
confidence: 0.9,
targetDescription: "左侧前景与人物区域",
},
],
});
expect(result.scene.imageType).toBe("portrait");
expect(result.candidates[0].reviewer).toBe("composition");
expect(result.candidates[0].confidence).toBe(0.9);
});
test("按规则 ID、Reviewer、图片类型、证据和置信度过滤候选", () => {
const result = parseSceneReviewPayload({
scene: {
imageType: "landscape",
style: [],
subjects: [],
intendedFocus: "风景层次",
compositionType: [],
lightingType: [],
mood: "平静",
estimatedIntent: "表现空间感",
sceneDepth: "deep",
},
candidates: [
{ id: "valid", checkId: "visual-balance", category: "composition", title: "有效问题", observation: "有证据", reason: "影响表达", evidence: ["左侧重量明显"], severity: 0.8, confidence: 0.8, targetDescription: "左侧" },
{ id: "no-rule", checkId: "made-up-rule", category: "composition", title: "未知规则", observation: "有证据", reason: "影响表达", evidence: ["证据"], severity: 0.8, confidence: 0.9, targetDescription: "画面" },
{ id: "no-evidence", checkId: "visual-balance", category: "composition", title: "没有证据", observation: "看起来不平衡", reason: "影响表达", evidence: [], severity: 0.8, confidence: 0.9, targetDescription: "画面" },
{ id: "low-confidence", checkId: "visual-balance", category: "composition", title: "置信度低", observation: "证据不足", reason: "影响表达", evidence: ["模糊"], severity: 0.8, confidence: 0.2, targetDescription: "画面" },
{ id: "wrong-reviewer", checkId: "visual-balance", category: "color", title: "类别不匹配", observation: "有证据", reason: "影响表达", evidence: ["证据"], severity: 0.8, confidence: 0.9, targetDescription: "画面" },
],
});
expect(result.candidates.map((candidate) => candidate.id)).toEqual(["valid"]);
expect(result.candidates[0].checkId).toBe("visual-balance");
});
test("聚合结果保留建议并按优先级排序", () => {
const result = parseAggregatePayload({
summary: "主体明确,但层级还可以更集中。",
strengths: ["色彩统一"],
issues: [
{
id: "low",
category: "color",
title: "次要区域略鲜艳",
explanation: "背景有局部高饱和色块。",
severity: "low",
confidence: 0.95,
targetDescription: "背景右上角",
suggestion: { goal: "降低干扰", actions: ["降低饱和度"], preserve: ["保留主体强调色"], expectedEffect: "主体更突出" },
sourceCandidateIds: ["c-2"],
},
{
id: "high",
category: "lighting",
title: "人物和背景分离不足",
explanation: "人物脸部和墙面明度接近。",
severity: "high",
confidence: 0.72,
targetDescription: "人物脸部和后方墙面",
suggestion: { goal: "增强分离", actions: ["提高脸部局部亮度"], preserve: ["保留窗光方向"], expectedEffect: "焦点更清楚" },
sourceCandidateIds: ["c-3"],
},
],
});
expect(result.issues.map((issue) => issue.id)).toEqual(["high", "low"]);
expect(result.issues[0].suggestion.actions).toEqual(["提高脸部局部亮度"]);
});
test("聚合结果只能引用真实候选并过滤无来源问题", () => {
const candidates = [
{
id: "c-valid",
checkId: "palette",
kind: "issue" as const,
category: "color" as const,
title: "背景颜色干扰",
observation: "背景右上角有一块高饱和色块",
reason: "它抢走了主体的注意力",
evidence: ["右上角色块与主体形成强烈饱和度对比"],
severity: 0.8,
confidence: 0.85,
targetDescription: "背景右上角",
reviewer: "color" as const,
},
];
const aggregate = parseAggregatePayload({
summary: "主体明确。",
strengths: ["主体清楚"],
issues: [
{
id: "valid",
category: "color",
title: "背景颜色干扰",
explanation: "背景高饱和色块抢走注意力。",
severity: "medium",
confidence: 0.8,
targetDescription: "背景右上角",
suggestion: { goal: "降低干扰", actions: ["降低饱和度"], preserve: ["保留主体颜色"], expectedEffect: "主体更突出" },
sourceCandidateIds: ["c-valid"],
},
{
id: "invented",
category: "lighting",
title: "凭空新增的问题",
explanation: "输入候选中不存在这个问题。",
severity: "high",
confidence: 0.9,
targetDescription: "画面",
suggestion: { goal: "改善", actions: ["调整"], preserve: [], expectedEffect: "更好" },
sourceCandidateIds: ["missing-candidate"],
},
],
});
const warnings: string[] = [];
const filtered = filterAggregateAgainstCandidates(aggregate, candidates, warnings);
expect(filtered.issues.map((issue) => issue.id)).toEqual(["valid"]);
expect(warnings).toHaveLength(1);
});
test("可选方向不能伪装成问题,且只能引用同类候选", () => {
const candidates = [
{
id: "c-option",
checkId: "temperature",
kind: "option" as const,
category: "color" as const,
title: "尝试更冷的氛围",
observation: "画面当前冷暖平衡,存在向冷色偏移的空间",
reason: "这可能让情绪更克制,但不是确定性缺陷",
evidence: ["主体和背景的冷暖关系清楚"],
severity: 0.6,
confidence: 0.82,
targetDescription: "整体色调",
reviewer: "color" as const,
},
];
const aggregate = parseAggregatePayload({
summary: "画面整体成立。",
strengths: ["色彩关系清楚"],
issues: [
{
id: "wrong-issue",
category: "color",
title: "色彩错误",
explanation: "把可选方向误写成了问题。",
severity: "medium",
confidence: 0.9,
targetDescription: "整体色调",
suggestion: { goal: "改善", actions: ["调整"], preserve: [], expectedEffect: "更好" },
sourceCandidateIds: ["c-option"],
},
],
options: [
{
id: "valid-option",
category: "color",
title: "尝试更冷的氛围",
explanation: "如果想强化克制感,可以考虑向冷色移动。",
confidence: 0.84,
suggestion: { goal: "尝试冷色氛围", actions: ["小幅降低色温"], preserve: ["保留主体肤色"], expectedEffect: "情绪更克制" },
sourceCandidateIds: ["c-option"],
},
],
});
const warnings: string[] = [];
const filtered = filterAggregateAgainstCandidates(aggregate, candidates, warnings);
expect(filtered.issues).toHaveLength(0);
expect(filtered.options.map((option) => option.id)).toEqual(["valid-option"]);
expect(warnings).toHaveLength(1);
});
test("低置信度定位降级为全局,复核拒绝项被过滤", () => {
const issues = [
{
id: "confirmed",
category: "composition" as const,
title: "主体位置",
explanation: "主体偏向一侧。",
severity: "high" as const,
confidence: 0.9,
target: { type: "global" as const, points: [] },
suggestion: { goal: "改善", actions: ["调整位置"], preserve: [], expectedEffect: "更平衡" },
},
{
id: "rejected",
category: "color" as const,
title: "颜色问题",
explanation: "颜色关系不清。",
severity: "medium" as const,
confidence: 0.8,
target: { type: "global" as const, points: [] },
suggestion: { goal: "改善", actions: ["调整颜色"], preserve: [], expectedEffect: "更统一" },
},
];
const grounded = applyGrounding(issues, [
{
issueId: "confirmed",
target: {
type: "box",
points: [
{ x: 0.2, y: 0.2 },
{ x: 0.6, y: 0.8 },
],
},
groundingConfidence: 0.9,
},
{
issueId: "rejected",
target: {
type: "box",
points: [
{ x: 0.1, y: 0.1 },
{ x: 0.4, y: 0.4 },
],
},
groundingConfidence: 0.4,
},
]);
expect(grounded[0].target.type).toBe("box");
expect(grounded[1].target.type).toBe("global");
const verified = applyVerification(grounded, [
{ issueId: "confirmed", verification: { verdict: "confirmed", confidence: 0.92, reason: "图像证据清楚" } },
{ issueId: "rejected", verification: { verdict: "rejected", confidence: 0.9, reason: "与图像不符" } },
]);
expect(verified.issues.map((issue) => issue.id)).toEqual(["confirmed"]);
expect(verified.summary).toEqual({ checked: 2, confirmed: 1, uncertain: 0, rejected: 1 });
});
test("定位服务失败时应用参考坐标并标记来源", () => {
const issues = [
{
id: "reference-1",
category: "composition" as const,
title: "左侧前景人物过重",
explanation: "左侧前景人物形成连续暗色块。",
severity: "high" as const,
confidence: 0.9,
target: { type: "global" as const, points: [] },
targetDescription: "左侧前景人物",
suggestion: { goal: "减轻干扰", actions: ["收窄前景人物"], preserve: [], expectedEffect: "主体更突出" },
},
];
const grounded = applyReferenceCoordinates(issues);
expect(grounded[0].target.type).toBe("box");
expect(grounded[0].targetSource).toBe("reference");
});
test("定位和复核解析保留置信度", () => {
const grounding = parseGroundingPayload({ targets: [{ issueId: "i-1", target: { type: "global", points: [] }, groundingConfidence: 0.61 }] });
const verification = parseVerificationPayload({ decisions: [{ issueId: "i-1", verdict: "uncertain", confidence: 0.77, reason: "局部证据不够明确" }] });
expect(grounding.targets[0].groundingConfidence).toBe(0.61);
expect(verification.decisions[0].verification.verdict).toBe("uncertain");
});
test("定位解析支持分散问题的多个参考点", () => {
const grounding = parseGroundingPayload({
targets: [
{
issueId: "i-2",
target: {
type: "points",
points: [
{ x: 0.2, y: 0.2 },
{ x: 0.8, y: 0.7 },
],
},
groundingConfidence: 0.8,
},
],
});
expect(grounding.targets[0].target.type).toBe("points");
expect(targetPolygon(grounding.targets[0].target)).toHaveLength(2);
});
test("模型返回无效框时改用参考区域而不是退化横线", () => {
const issues = [
{
id: "grounding-invalid-box",
category: "composition" as const,
title: "前景暗部过重",
explanation: "左侧前景压过主体动线。",
severity: "high" as const,
confidence: 0.9,
target: { type: "global" as const, points: [] },
targetDescription: "左侧前景人物",
suggestion: { goal: "减轻前景重量", actions: ["缩小前景人物"], preserve: [], expectedEffect: "主体更突出" },
},
];
const grounded = applyGrounding(issues, [
{
issueId: "grounding-invalid-box",
target: {
type: "box",
points: [
{ x: 0.2, y: 0.1 },
{ x: 0.8, y: 0.1 },
],
},
groundingConfidence: 0.92,
},
]);
expect(grounded[0].targetSource).toBe("reference");
expect(grounded[0].target.type).toBe("box");
expect(targetBounds(grounded[0].target).height).toBeGreaterThan(0.8);
});
test("同类同根因问题会合并并保留来源与建议", () => {
const issues = [
{
id: "face-light",
category: "lighting" as const,
title: "主体脸部受光不足",
explanation: "脸部处于暗部,表情不清晰。",
severity: "high" as const,
confidence: 0.9,
targetDescription: "主体脸部",
suggestion: { goal: "提升表情可读性", actions: ["提高脸部局部亮度"], preserve: ["保留冷色氛围"], expectedEffect: "表情更清楚" },
sourceCandidateIds: ["lighting-exposure"],
},
{
id: "subject-separation",
category: "lighting" as const,
title: "人物暗部融入背景",
explanation: "人物亮度与背景接近,主体分离不足。",
severity: "medium" as const,
confidence: 0.86,
targetDescription: "人物头发和右肩",
suggestion: { goal: "加强主体分离", actions: ["补充克制的轮廓光"], preserve: ["保留人物轮廓"], expectedEffect: "空间层次更清楚" },
sourceCandidateIds: ["lighting-separation"],
},
];
const warnings: string[] = [];
const result = deduplicateAggregateIssues(issues, warnings);
expect(result).toHaveLength(1);
expect(result[0].sourceCandidateIds).toEqual(["lighting-exposure", "lighting-separation"]);
expect(result[0].suggestion.actions).toEqual(["提高脸部局部亮度", "补充克制的轮廓光"]);
expect(warnings).toEqual(["聚合阶段合并了 1 个重复或同根因问题。"]);
});
});