fix: stabilize rules detail editor flow
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import YAML from 'yaml';
|
||||
import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from './rules-yaml-mock.server';
|
||||
|
||||
export type VisualElementSummary = RuleYamlPack['visualElements'][number];
|
||||
|
||||
export type DependencyOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -17,12 +20,13 @@ export type ValidationIssue = {
|
||||
|
||||
export type EditableRuleConfig = {
|
||||
metadata: RuleYamlPack['metadata'];
|
||||
yamlSource: string;
|
||||
documentType: string;
|
||||
mainType: string;
|
||||
subtype: string;
|
||||
fields: ExtractFieldSummary[];
|
||||
subDocuments: SubDocumentSummary[];
|
||||
visualElements: RuleYamlPack['visualElements'];
|
||||
visualElements: VisualElementSummary[];
|
||||
rules: RuleSummary[];
|
||||
};
|
||||
|
||||
@@ -287,6 +291,234 @@ function yamlValue(value: string | number | boolean | undefined): string {
|
||||
return text ? `'${text}'` : "''";
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function groupBy<T>(items: T[], keyGetter: (item: T) => string): Map<string, T[]> {
|
||||
const groups = new Map<string, T[]>();
|
||||
items.forEach((item) => {
|
||||
const key = keyGetter(item);
|
||||
const list = groups.get(key) || [];
|
||||
list.push(item);
|
||||
groups.set(key, list);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function normalizeBooleanText(value: string | boolean | undefined): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return String(value || '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function rewriteExtractNodes(fields: ExtractFieldSummary[]): Array<Record<string, unknown>> {
|
||||
const topLevelFields = fields.filter((field) => !field.name.includes('[*].'));
|
||||
return Array.from(groupBy(topLevelFields, (field) => field.group || '未分组').entries()).map(([group, items]) => ({
|
||||
group,
|
||||
fields: items.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.multipleEntities ? 'multi_entity' : field.type || 'verbatim',
|
||||
required_from: field.requiredFrom || 'draft',
|
||||
desc: field.description || '',
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function rewriteSubDocumentNodes(subDocuments: SubDocumentSummary[]): Array<Record<string, unknown>> {
|
||||
return subDocuments.map((document) => ({
|
||||
id: document.id,
|
||||
name: document.name,
|
||||
required: document.required || 'false',
|
||||
extract: Array.from(groupBy(document.fields || [], (field) => field.group || '未分组').entries()).map(([group, fields]) => ({
|
||||
group,
|
||||
fields: fields.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.multipleEntities ? 'multi_entity' : field.type || 'verbatim',
|
||||
desc: field.description || '',
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function rewriteVisualElementNodes(visualElements: VisualElementSummary[]): Record<string, unknown> {
|
||||
const sections = {
|
||||
seals: [] as Array<Record<string, unknown>>,
|
||||
signatures: [] as Array<Record<string, unknown>>,
|
||||
cross_page_seals: [] as Array<Record<string, unknown>>,
|
||||
};
|
||||
|
||||
visualElements.forEach((item) => {
|
||||
const node: Record<string, unknown> = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
required: normalizeBooleanText(item.required),
|
||||
};
|
||||
if (item.signerRoles && item.signerRoles.length > 0) node.signer_roles = [...item.signerRoles];
|
||||
if (item.signatureTypes && item.signatureTypes.length > 0) node.signature_types = [...item.signatureTypes];
|
||||
if (item.privateSealRestricted) node.private_seal_restricted = true;
|
||||
|
||||
if (item.type === '签名') {
|
||||
sections.signatures.push(node);
|
||||
return;
|
||||
}
|
||||
if (item.type === '骑缝章') {
|
||||
sections.cross_page_seals.push(node);
|
||||
return;
|
||||
}
|
||||
sections.seals.push(node);
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function getRuleLookupKey(rule: Pick<RuleSummary, 'ruleId' | 'name' | 'id'>): string {
|
||||
return rule.ruleId || rule.name || rule.id;
|
||||
}
|
||||
|
||||
function findAiStage(stages: Array<Record<string, unknown>>): Record<string, unknown> | undefined {
|
||||
return stages.find((stage) => String(stage?.check || stage?.type || '').trim() === 'ai');
|
||||
}
|
||||
|
||||
function buildMinimalRuleNode(rule: RuleSummary): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = {
|
||||
rule_id: rule.ruleId,
|
||||
name: rule.name,
|
||||
risk: rule.risk || 'medium',
|
||||
score: rule.score || '1',
|
||||
type: rule.type || 'deterministic',
|
||||
desc: rule.description || '',
|
||||
};
|
||||
|
||||
if (rule.appliesIn.length > 0) {
|
||||
base.applies_in = [...rule.appliesIn];
|
||||
}
|
||||
|
||||
if (rule.type === 'rule_group') {
|
||||
base.logic = rule.logic || '';
|
||||
base.rules = [...rule.subRuleIds];
|
||||
return base;
|
||||
}
|
||||
|
||||
if (rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) {
|
||||
base.stages = [{
|
||||
id: '1',
|
||||
check: 'ai',
|
||||
prompt: rule.prompt || '请根据规则要求检查文档内容并输出结论。',
|
||||
}];
|
||||
return base;
|
||||
}
|
||||
|
||||
if (rule.dependencies.length > 0) {
|
||||
base.stages = [{
|
||||
id: '1',
|
||||
check: rule.checkTypes[0] || 'required',
|
||||
field: rule.dependencies[0],
|
||||
}];
|
||||
return base;
|
||||
}
|
||||
|
||||
throw new Error(`评查点【${rule.name || rule.ruleId || rule.id}】缺少可生成的正式 stages 结构,请先补充依赖字段或改为 AI/规则组合类型。`);
|
||||
}
|
||||
|
||||
function rewriteRuleNode(baseRule: Record<string, unknown> | undefined, rule: RuleSummary): Record<string, unknown> {
|
||||
const nextRule = baseRule ? deepClone(baseRule) : buildMinimalRuleNode(rule);
|
||||
|
||||
nextRule.rule_id = rule.ruleId;
|
||||
nextRule.name = rule.name;
|
||||
nextRule.risk = rule.risk || 'medium';
|
||||
nextRule.score = rule.score || '1';
|
||||
nextRule.type = rule.type || 'deterministic';
|
||||
nextRule.desc = rule.description || '';
|
||||
|
||||
if (rule.appliesIn.length > 0) nextRule.applies_in = [...rule.appliesIn];
|
||||
else delete nextRule.applies_in;
|
||||
|
||||
delete nextRule.dependencies;
|
||||
|
||||
if (rule.type === 'rule_group') {
|
||||
nextRule.logic = rule.logic || '';
|
||||
nextRule.rules = [...rule.subRuleIds];
|
||||
delete nextRule.stages;
|
||||
return nextRule;
|
||||
}
|
||||
|
||||
delete nextRule.rules;
|
||||
delete nextRule.logic;
|
||||
|
||||
const existingStages = Array.isArray(nextRule.stages) ? (nextRule.stages as Array<Record<string, unknown>>) : [];
|
||||
const stages = existingStages.length > 0 ? deepClone(existingStages) : (buildMinimalRuleNode(rule).stages as Array<Record<string, unknown>>);
|
||||
|
||||
if (rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) {
|
||||
const aiStage = findAiStage(stages);
|
||||
if (aiStage) {
|
||||
aiStage.check = 'ai';
|
||||
aiStage.prompt = rule.prompt || aiStage.prompt || '请根据规则要求检查文档内容并输出结论。';
|
||||
} else {
|
||||
stages.unshift({
|
||||
id: '1',
|
||||
check: 'ai',
|
||||
prompt: rule.prompt || '请根据规则要求检查文档内容并输出结论。',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
nextRule.stages = stages;
|
||||
return nextRule;
|
||||
}
|
||||
|
||||
export function serializeEditableRuleConfig(config: EditableRuleConfig): string {
|
||||
const parsed = YAML.parse(config.yamlSource || '') as Record<string, unknown> | null;
|
||||
const root = parsed && typeof parsed === 'object' ? deepClone(parsed) : {};
|
||||
const metadata = (root.metadata && typeof root.metadata === 'object' ? deepClone(root.metadata) : {}) as Record<string, unknown>;
|
||||
|
||||
metadata.type_id = config.metadata.typeId || metadata.type_id || 'pending.internal.document';
|
||||
metadata.name = config.metadata.name || metadata.name || `${config.subtype}规则配置`;
|
||||
metadata.version = config.metadata.version || metadata.version || 'v1';
|
||||
metadata.last_updated = new Date().toISOString().slice(0, 10);
|
||||
if (config.metadata.parent || metadata.parent) metadata.parent = config.metadata.parent || metadata.parent;
|
||||
if (config.metadata.description || metadata.description) metadata.description = config.metadata.description || metadata.description;
|
||||
if (Array.isArray(config.metadata.keywords) && config.metadata.keywords.length > 0) metadata.classification_keywords = [...config.metadata.keywords];
|
||||
if (Array.isArray(config.metadata.inheritsFrom) && config.metadata.inheritsFrom.length > 0) metadata.inherits_from = [...config.metadata.inheritsFrom];
|
||||
root.metadata = metadata;
|
||||
root.extract = rewriteExtractNodes(config.fields);
|
||||
root.sub_documents = rewriteSubDocumentNodes(config.subDocuments);
|
||||
root.visual_elements = rewriteVisualElementNodes(config.visualElements);
|
||||
|
||||
const existingGroups = Array.isArray(root.rules) ? (root.rules as Array<Record<string, unknown>>) : [];
|
||||
const existingRuleMap = new Map<string, Record<string, unknown>>();
|
||||
existingGroups.forEach((groupBlock) => {
|
||||
const groupRules = Array.isArray(groupBlock?.rules) ? (groupBlock.rules as Array<Record<string, unknown>>) : [];
|
||||
groupRules.forEach((ruleNode) => {
|
||||
const key = String(ruleNode?.rule_id || ruleNode?.name || '').trim();
|
||||
if (key) existingRuleMap.set(key, ruleNode);
|
||||
});
|
||||
});
|
||||
|
||||
const nextGroups = new Map<string, Array<Record<string, unknown>>>();
|
||||
config.rules.forEach((rule) => {
|
||||
const groupName = rule.group || '未分组';
|
||||
const list = nextGroups.get(groupName) || [];
|
||||
const existingRule = existingRuleMap.get(getRuleLookupKey(rule));
|
||||
list.push(rewriteRuleNode(existingRule, rule));
|
||||
nextGroups.set(groupName, list);
|
||||
});
|
||||
|
||||
root.rules = Array.from(nextGroups.entries()).map(([group, rules]) => ({ group, rules }));
|
||||
return YAML.stringify(root).replace(
|
||||
/^(\s*last_updated:\s*)(.+)$/m,
|
||||
(_match, prefix, value) => `${prefix}'${String(value).replace(/^['"]|['"]$/g, '')}'`,
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareDraftYamlForSave(yamlText: string): string {
|
||||
return yamlText
|
||||
.replace(/^(\s*version:\s*)(.+)$/m, `${'$1'}''`)
|
||||
.replace(
|
||||
/^(\s*last_updated:\s*)(.+)$/m,
|
||||
(_match, prefix, value) => `${prefix}'${String(value).replace(/^['"]|['"]$/g, '')}'`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSummary): string {
|
||||
const lines: string[] = [
|
||||
`# ${config.documentType} / ${config.mainType} / ${config.subtype}`,
|
||||
@@ -331,6 +563,11 @@ export function buildRuleYamlPreview(config: EditableRuleConfig, rule: RuleSumma
|
||||
}
|
||||
|
||||
export function buildYamlPreview(config: EditableRuleConfig): string {
|
||||
try {
|
||||
return serializeEditableRuleConfig(config);
|
||||
} catch {
|
||||
// 预览失败时仍回退旧实现,避免页面直接白屏;保存时会使用正式序列化并给出错误。
|
||||
}
|
||||
const lines: string[] = [
|
||||
'metadata:',
|
||||
` name: ${yamlValue(config.metadata.name || `${config.subtype}规则配置`)}`,
|
||||
|
||||
Reference in New Issue
Block a user