697 lines
27 KiB
TypeScript
697 lines
27 KiB
TypeScript
import { readFile } from 'node:fs/promises';
|
||
import YAML from 'yaml';
|
||
|
||
const LEAUDIT_RULES_ROOT = `${process.cwd()}/mock-data/leaudit-rules/packs/yc`;
|
||
|
||
export type RulePackScope = {
|
||
documentType: string;
|
||
moduleType: string;
|
||
mainType: string;
|
||
subtype: string;
|
||
businessType?: string;
|
||
ruleTypeCode?: string;
|
||
};
|
||
|
||
export type RuleSummary = {
|
||
id: string;
|
||
ruleId: string;
|
||
name: string;
|
||
group: string;
|
||
risk: string;
|
||
score: string;
|
||
type: string;
|
||
checkTypes: string[];
|
||
logic: string;
|
||
subRules: Array<{
|
||
id: string;
|
||
check: string;
|
||
content: string;
|
||
}>;
|
||
subRuleIds: string[];
|
||
scope: string[];
|
||
dependencies: string[];
|
||
stageCount: number;
|
||
appliesIn: string[];
|
||
prompt: string;
|
||
description: string;
|
||
};
|
||
|
||
export type ExtractFieldSummary = {
|
||
id: string;
|
||
group: string;
|
||
name: string;
|
||
type: string;
|
||
multipleEntities: boolean;
|
||
allowed?: string[];
|
||
requiredFrom: string;
|
||
description: string;
|
||
};
|
||
|
||
export type SubDocumentSummary = {
|
||
id: string;
|
||
name: string;
|
||
required: string;
|
||
fieldCount: number;
|
||
groups: string[];
|
||
description: string;
|
||
fields: ExtractFieldSummary[];
|
||
};
|
||
|
||
export type RuleYamlPack = RulePackScope & {
|
||
id: string;
|
||
yamlPath: string | null;
|
||
yamlSource: string;
|
||
sourceStatus: 'ready' | 'empty' | 'missing';
|
||
currentVersionId?: number | null;
|
||
fallbackVersionId?: number | null;
|
||
resolvedVersionId?: number | null;
|
||
metadata: {
|
||
typeId: string;
|
||
name: string;
|
||
version: string;
|
||
lastUpdated: string;
|
||
parent: string;
|
||
description: string;
|
||
tags: string[];
|
||
keywords: string[];
|
||
inheritsFrom: string[];
|
||
};
|
||
stats: {
|
||
ruleCount: number;
|
||
fieldCount: number;
|
||
subDocumentCount: number;
|
||
visualElementCount: number;
|
||
};
|
||
rules: RuleSummary[];
|
||
fields: ExtractFieldSummary[];
|
||
subDocuments: SubDocumentSummary[];
|
||
visualElements: Array<{
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
required: string;
|
||
requiredFrom?: string;
|
||
signerRoles?: string[];
|
||
signatureTypes?: string[];
|
||
privateSealRestricted?: boolean;
|
||
expectedMatchField?: string;
|
||
expectedMatchAlternatives?: string[];
|
||
prompt?: string;
|
||
}>;
|
||
};
|
||
|
||
export const EMPTY_RULE_YAML = `metadata:
|
||
type_id: pending.internal.document
|
||
name: 内部公文规则配置
|
||
version: '0.1'
|
||
last_updated: '待配置'
|
||
description: '当前暂无内部公文规则 YAML。此测试页面保留规则列表与配置页流程。'
|
||
extract: []
|
||
rules: []
|
||
`;
|
||
|
||
const MOCK_RULE_PACKS: Array<RulePackScope & { id: string; yamlPath: string | null }> = [
|
||
{ id: 'contract-purchase', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '买卖合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_purchase/rules.yaml` },
|
||
{ id: 'contract-sale', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '通用买卖合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_sale/rules.yaml` },
|
||
{ id: 'contract-tech', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '技术合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_tech/rules.yaml` },
|
||
{ id: 'contract-lease', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '租赁合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_lease/rules.yaml` },
|
||
{ id: 'contract-entrust', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '委托合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_entrust/rules.yaml` },
|
||
{ id: 'contract-construction', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '建设工程合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_construction/rules.yaml` },
|
||
{ id: 'contract-evaluation', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '委托评估合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_evaluation/rules.yaml` },
|
||
{ id: 'contract-gift-general', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '赠与合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_gift_general/rules.yaml` },
|
||
{ id: 'contract-gift-charity', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '公益捐赠合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_gift_charity/rules.yaml` },
|
||
{ id: 'contract-loan', documentType: '合同', moduleType: '合同评查', mainType: '合同', subtype: '借款合同', yamlPath: `${LEAUDIT_RULES_ROOT}/contract_loan/rules.yaml` },
|
||
{ id: 'case-penalty', documentType: '案卷', moduleType: '案卷评查', mainType: '行政处罚', subtype: '通用', yamlPath: `${LEAUDIT_RULES_ROOT}/行政处罚/rules.yaml` },
|
||
{ id: 'case-license-new', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '新办', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_新办/rules.yaml` },
|
||
{ id: 'case-license-extend', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '延续', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_延续/rules.yaml` },
|
||
{ id: 'case-license-change', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '变更', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_变更/rules.yaml` },
|
||
{ id: 'case-license-cancel', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '注销', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_注销/rules.yaml` },
|
||
{ id: 'case-license-suspend', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '停业', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_停业/rules.yaml` },
|
||
{ id: 'case-license-close', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '歇业', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_歇业/rules.yaml` },
|
||
{ id: 'case-license-reissue', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '补办', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_补办/rules.yaml` },
|
||
{ id: 'case-license-retrieve', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '收回', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_收回/rules.yaml` },
|
||
{ id: 'case-license-restore', documentType: '案卷', moduleType: '案卷评查', mainType: '行政许可', subtype: '恢复营业', yamlPath: `${LEAUDIT_RULES_ROOT}/行政许可_恢复营业/rules.yaml` },
|
||
{ id: 'internal-document', documentType: '内部公文', moduleType: '内部公文评查', mainType: '内部公文', subtype: '通用', yamlPath: null }
|
||
];
|
||
|
||
function getTopLevelSection(source: string, key: string): string {
|
||
const lines = source.split('\n');
|
||
const start = lines.findIndex(line => line === `${key}:`);
|
||
if (start === -1) {
|
||
return '';
|
||
}
|
||
|
||
const end = lines.findIndex((line, index) => index > start && /^[a-zA-Z_][\w-]*:/.test(line));
|
||
return lines.slice(start + 1, end === -1 ? undefined : end).join('\n');
|
||
}
|
||
|
||
function stripYamlValue(value = ''): string {
|
||
return value.trim().replace(/^['"]|['"]$/g, '').replace(/\u0000/g, '');
|
||
}
|
||
|
||
function toStringList(value: unknown): string[] {
|
||
if (!Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
||
}
|
||
|
||
function parseScalar(section: string, key: string): string {
|
||
const match = section.match(new RegExp(`^\\s{2}${key}:\\s*(.*)$`, 'm'));
|
||
return stripYamlValue(match?.[1] || '');
|
||
}
|
||
|
||
function parseListAfterKey(section: string, key: string): string[] {
|
||
const lines = section.split('\n');
|
||
const start = lines.findIndex(line => new RegExp(`^\\s{2}${key}:\\s*$`).test(line));
|
||
if (start === -1) {
|
||
return [];
|
||
}
|
||
|
||
const values: string[] = [];
|
||
for (let index = start + 1; index < lines.length; index += 1) {
|
||
const line = lines[index];
|
||
if (/^\s{2}\w/.test(line)) {
|
||
break;
|
||
}
|
||
const match = line.match(/^\s{2,}-\s*(.+)$/);
|
||
if (match) {
|
||
values.push(stripYamlValue(match[1]));
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function parseMetadata(source: string): RuleYamlPack['metadata'] {
|
||
const section = getTopLevelSection(source, 'metadata');
|
||
return {
|
||
typeId: parseScalar(section, 'type_id'),
|
||
name: parseScalar(section, 'name'),
|
||
version: parseScalar(section, 'version'),
|
||
lastUpdated: parseScalar(section, 'last_updated'),
|
||
parent: parseScalar(section, 'parent'),
|
||
description: stripYamlValue((section.match(/^\s{2}description:\s*'?([\s\S]*?)(?:\n\s{2}\w|\n[a-zA-Z_]|\n?$)/m)?.[1] || '').replace(/\n\s+/g, ' ').trim()),
|
||
tags: parseListAfterKey(section, 'tags'),
|
||
keywords: parseListAfterKey(section, 'classification_keywords'),
|
||
inheritsFrom: parseListAfterKey(section, 'inherits_from')
|
||
};
|
||
}
|
||
|
||
function splitBlocks(section: string, marker: RegExp): string[] {
|
||
const lines = section.split('\n');
|
||
const starts = lines.reduce<number[]>((indexes, line, index) => {
|
||
if (marker.test(line)) {
|
||
indexes.push(index);
|
||
}
|
||
return indexes;
|
||
}, []);
|
||
|
||
return starts.map((start, index) => lines.slice(start, starts[index + 1]).join('\n'));
|
||
}
|
||
|
||
function parseRules(source: string): RuleSummary[] {
|
||
const section = getTopLevelSection(source, 'rules');
|
||
const groups = splitBlocks(section, /^\s*-\s+group:\s*/);
|
||
const readExplicitDependencies = (block: string): string[] => {
|
||
const lines = block.split('\n');
|
||
const start = lines.findIndex(line => /^\s+dependencies:\s*$/.test(line));
|
||
|
||
if (start === -1) {
|
||
return [];
|
||
}
|
||
|
||
const dependencies: string[] = [];
|
||
for (let index = start + 1; index < lines.length; index += 1) {
|
||
const line = lines[index];
|
||
if (/^\s+[a-zA-Z_][^:]*:\s*/.test(line) && !/^\s*-\s+/.test(line)) {
|
||
break;
|
||
}
|
||
const match = line.match(/^\s*-\s+(.+)$/);
|
||
if (match) {
|
||
dependencies.push(stripYamlValue(match[1]));
|
||
}
|
||
}
|
||
return dependencies;
|
||
};
|
||
const normalizeDependency = (value: string) => {
|
||
const normalized = stripYamlValue(value);
|
||
if (normalized === 'cross_page_seal') return '骑缝章';
|
||
if (normalized === 'seal') return '印章';
|
||
if (normalized === 'signature') return '签名';
|
||
return normalized;
|
||
};
|
||
const readPrompts = (block: string): string[] => {
|
||
const lines = block.split('\n');
|
||
const prompts: string[] = [];
|
||
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const match = lines[index].match(/^(\s*)prompt:\s*(.*)$/);
|
||
if (!match) continue;
|
||
|
||
const indent = match[1].length;
|
||
const parts = [match[2]];
|
||
for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
|
||
const line = lines[nextIndex];
|
||
const nextIndent = line.match(/^\s*/)?.[0].length || 0;
|
||
const trimmed = line.trim();
|
||
if (trimmed && nextIndent <= indent) break;
|
||
if (trimmed && nextIndent === indent + 2 && /^[a-zA-Z_][\w-]*:\s*/.test(trimmed)) break;
|
||
parts.push(line);
|
||
}
|
||
|
||
prompts.push(parts.join('\n')
|
||
.replace(/^['"]/, '')
|
||
.replace(/['"]\s*$/, '')
|
||
.split('\n')
|
||
.map(line => line.replace(/^\s{8}/, ''))
|
||
.join('\n')
|
||
.trim());
|
||
}
|
||
|
||
return prompts.filter(Boolean);
|
||
};
|
||
const readPromptDependencies = (prompts: string[]): string[] => {
|
||
return Array.from(new Set(
|
||
prompts.flatMap((prompt) => Array.from(prompt.matchAll(/\{\{\s*([^{}]+?)\s*\}\}/g)).map((match) => stripYamlValue(match[1])))
|
||
));
|
||
};
|
||
const readList = (block: string, key: string, indent = 4): string[] => {
|
||
const lines = block.split('\n');
|
||
const start = lines.findIndex(line => new RegExp(`^\\s+${key}:\\s*$`).test(line));
|
||
if (start === -1) {
|
||
return [];
|
||
}
|
||
|
||
const baseIndent = lines[start].match(/^\s*/)?.[0].length || indent;
|
||
|
||
const values: string[] = [];
|
||
for (let index = start + 1; index < lines.length; index += 1) {
|
||
const line = lines[index];
|
||
const lineIndent = line.match(/^\s*/)?.[0].length || 0;
|
||
if (line.trim() && lineIndent <= baseIndent && !/^\s*-\s+/.test(line)) {
|
||
break;
|
||
}
|
||
const match = line.match(/^\s*-\s+(.+)$/);
|
||
if (match) {
|
||
values.push(stripYamlValue(match[1]));
|
||
}
|
||
}
|
||
return values;
|
||
};
|
||
const readFlexibleList = (block: string, key: string): string[] => {
|
||
const lines = block.split('\n');
|
||
const start = lines.findIndex(line => new RegExp(`^(\\s*)${key}:\\s*$`).test(line));
|
||
if (start === -1) return [];
|
||
const indent = lines[start].match(/^\s*/)?.[0].length || 0;
|
||
const values: string[] = [];
|
||
|
||
for (let index = start + 1; index < lines.length; index += 1) {
|
||
const line = lines[index];
|
||
const lineIndent = line.match(/^\s*/)?.[0].length || 0;
|
||
const match = line.match(/^\s*-\s+(.+)$/);
|
||
if (match) {
|
||
values.push(stripYamlValue(match[1]));
|
||
continue;
|
||
}
|
||
if (line.trim() && lineIndent <= indent && !/^\s*-\s+/.test(line)) break;
|
||
}
|
||
|
||
return values;
|
||
};
|
||
const readStageList = (block: string, key: string): string[] => {
|
||
const lines = block.split('\n');
|
||
const start = lines.findIndex(line => new RegExp(`^\\s+${key}:\\s*$`).test(line));
|
||
if (start === -1) {
|
||
return [];
|
||
}
|
||
|
||
const baseIndent = lines[start].match(/^\s*/)?.[0].length || 6;
|
||
|
||
const values: string[] = [];
|
||
for (let index = start + 1; index < lines.length; index += 1) {
|
||
const line = lines[index];
|
||
const lineIndent = line.match(/^\s*/)?.[0].length || 0;
|
||
if (line.trim() && lineIndent <= baseIndent && !/^\s*-\s+/.test(line)) {
|
||
break;
|
||
}
|
||
const match = line.match(/^\s*-\s+(.+)$/);
|
||
if (match) {
|
||
values.push(stripYamlValue(match[1]));
|
||
}
|
||
}
|
||
return values;
|
||
};
|
||
const readStageScalar = (block: string, key: string): string => stripYamlValue(block.match(new RegExp(`^\\s+${key}:\\s*(.+)$`, 'm'))?.[1] || '');
|
||
const readStagePairValues = (block: string): string[] => {
|
||
const lines = block.split('\n');
|
||
const values: string[] = [];
|
||
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const match = lines[index].match(/^\s+(?:source|target|ref_field):\s*(.+)$/);
|
||
if (match) {
|
||
values.push(stripYamlValue(match[1]));
|
||
}
|
||
}
|
||
|
||
return values;
|
||
};
|
||
const summarizeStage = (stageBlock: string): string => {
|
||
const fields = readStageList(stageBlock, 'fields');
|
||
const field = readStageScalar(stageBlock, 'field');
|
||
const left = readStageScalar(stageBlock, 'left') || readStageScalar(stageBlock, 'left_field');
|
||
const op = readStageScalar(stageBlock, 'op');
|
||
const right = readStageScalar(stageBlock, 'right') || readStageScalar(stageBlock, 'right_field');
|
||
const value = readStageScalar(stageBlock, 'value');
|
||
const prompt = readStageScalar(stageBlock, 'prompt');
|
||
const element = readStageScalar(stageBlock, 'element') || readStageScalar(stageBlock, 'seal_id') || readStageScalar(stageBlock, 'signature_id');
|
||
|
||
if (fields.length > 0) return fields.join('、');
|
||
if (left || right) return [left, op, right].filter(Boolean).join(' ');
|
||
if (field && value) return `${field} = ${value}`;
|
||
if (field) return field;
|
||
if (element) return element;
|
||
if (prompt) return prompt.slice(0, 80);
|
||
return stageBlock.split('\n').map(line => line.trim()).filter(Boolean).slice(1, 4).join(';') || '未配置内容';
|
||
};
|
||
const readSubRules = (block: string) => splitBlocks(block, /^\s{4}-\s+id:\s*/).map(stageBlock => {
|
||
const id = stripYamlValue(stageBlock.match(/^\s*-\s+id:\s*(.+)$/m)?.[1] || '');
|
||
const check = readStageScalar(stageBlock, 'check') || readStageScalar(stageBlock, 'type') || '-';
|
||
return {
|
||
id,
|
||
check,
|
||
content: summarizeStage(stageBlock)
|
||
};
|
||
}).filter(stage => stage.id);
|
||
|
||
return groups.flatMap(groupBlock => {
|
||
const group = stripYamlValue(groupBlock.match(/^\s*-\s+group:\s*(.+)$/m)?.[1] || '未分组');
|
||
return splitBlocks(groupBlock, /^\s*-\s+rule_id:\s*/).map(ruleBlock => {
|
||
const ruleId = stripYamlValue(ruleBlock.match(/^\s*-\s+rule_id:\s*(.+)$/m)?.[1] || '');
|
||
const name = stripYamlValue(ruleBlock.match(/^\s+name:\s*(.+)$/m)?.[1] || '未命名规则');
|
||
const checkTypes = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s+(?:check|type):\s*(.+)$/gm)).map(match => stripYamlValue(match[1]))));
|
||
const stageDependencies = Array.from(ruleBlock.matchAll(/^\s+(?:field|number|chinese|left|right|left_field|right_field|target|element|seal_id|signature_id|ref_field):\s*(.+)$/gm))
|
||
.map(match => normalizeDependency(match[1]));
|
||
const stageFieldDependencies = splitBlocks(ruleBlock, /^\s{4,}-\s+id:\s*/)
|
||
.flatMap(stageBlock => [
|
||
...readStageList(stageBlock, 'fields'),
|
||
...readStagePairValues(stageBlock),
|
||
])
|
||
.map(normalizeDependency);
|
||
const prompts = readPrompts(ruleBlock);
|
||
const promptDependencies = readPromptDependencies(prompts).map(normalizeDependency);
|
||
const dependencies = Array.from(new Set([
|
||
...readExplicitDependencies(ruleBlock),
|
||
...stageDependencies,
|
||
...stageFieldDependencies,
|
||
...promptDependencies,
|
||
]));
|
||
const scope = Array.from(new Set(Array.from(ruleBlock.matchAll(/^\s{4,}-\s*([^:\n]+)$/gm)).map(match => stripYamlValue(match[1])).filter(value => !/^\d+$/.test(value))));
|
||
const subRules = readSubRules(ruleBlock);
|
||
|
||
return {
|
||
id: ruleId || `${group}-${name}`,
|
||
ruleId,
|
||
name,
|
||
group,
|
||
risk: stripYamlValue(ruleBlock.match(/^\s+risk:\s*(.+)$/m)?.[1] || 'medium'),
|
||
score: stripYamlValue(ruleBlock.match(/^\s+score:\s*(.+)$/m)?.[1] || '-'),
|
||
type: stripYamlValue(ruleBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'deterministic'),
|
||
checkTypes,
|
||
logic: stripYamlValue(ruleBlock.match(/^\s+logic:\s*(.+)$/m)?.[1] || ''),
|
||
subRules,
|
||
subRuleIds: readList(ruleBlock, 'rules'),
|
||
scope: scope.slice(0, 8),
|
||
dependencies,
|
||
stageCount: subRules.length,
|
||
appliesIn: readFlexibleList(ruleBlock, 'applies_in'),
|
||
prompt: prompts.join('\n\n'),
|
||
description: stripYamlValue(ruleBlock.match(/^\s+desc:\s*(.+)$/m)?.[1] || '')
|
||
};
|
||
});
|
||
});
|
||
}
|
||
|
||
export function parseRuleSummariesFromYaml(source: string): RuleSummary[] {
|
||
return parseRules(source);
|
||
}
|
||
|
||
function parseTopLevelFields(source: string): ExtractFieldSummary[] {
|
||
const parsed = YAML.parse(source || '') as Record<string, unknown> | null;
|
||
const extractGroups = Array.isArray(parsed?.extract) ? parsed.extract : [];
|
||
const extractedFields = extractGroups.flatMap((groupNode) => {
|
||
if (!groupNode || typeof groupNode !== 'object') {
|
||
return [];
|
||
}
|
||
const groupObject = groupNode as Record<string, unknown>;
|
||
const group = String(groupObject.group || '未分组').trim() || '未分组';
|
||
const fields = Array.isArray(groupObject.fields) ? groupObject.fields : [];
|
||
return fields.flatMap((fieldNode) => {
|
||
if (!fieldNode || typeof fieldNode !== 'object') {
|
||
return [];
|
||
}
|
||
const field = fieldNode as Record<string, unknown>;
|
||
const name = String(field.name || '').trim();
|
||
if (!name) {
|
||
return [];
|
||
}
|
||
const rawType = String(field.type || '-').trim();
|
||
const requiredFrom = String(field.required_from || '-').trim() || '-';
|
||
const parentField: ExtractFieldSummary = {
|
||
id: `${group}-${name}`,
|
||
group,
|
||
name,
|
||
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
|
||
multipleEntities: rawType === 'multi_entity',
|
||
allowed: toStringList(field.allowed),
|
||
requiredFrom,
|
||
description: String(field.desc || '').trim(),
|
||
};
|
||
const childFields = Array.isArray(field.fields) ? field.fields : [];
|
||
const normalizedChildFields = childFields.flatMap((childNode) => {
|
||
if (!childNode || typeof childNode !== 'object') {
|
||
return [];
|
||
}
|
||
const childField = childNode as Record<string, unknown>;
|
||
const childName = String(childField.name || '').trim();
|
||
if (!childName) {
|
||
return [];
|
||
}
|
||
return [{
|
||
id: `${group}-${name}-${childName}`,
|
||
group,
|
||
name: `${name}[*].${childName}`,
|
||
type: String(childField.type || 'verbatim').trim() || 'verbatim',
|
||
multipleEntities: false,
|
||
allowed: toStringList(childField.allowed),
|
||
requiredFrom: String(childField.required_from || requiredFrom).trim() || requiredFrom,
|
||
description: String(childField.desc || `${name}的子字段`).trim(),
|
||
}];
|
||
});
|
||
return [parentField, ...normalizedChildFields];
|
||
});
|
||
}).filter(field => field.name);
|
||
|
||
const derivedSection = getTopLevelSection(source, 'derived_fields');
|
||
const derivedFields = splitBlocks(derivedSection, /^\s*-\s+name:\s*/).map(fieldBlock => {
|
||
const name = stripYamlValue(fieldBlock.match(/^\s*-\s+name:\s*(.+)$/m)?.[1] || '');
|
||
return {
|
||
id: `derived-${name}`,
|
||
group: '派生字段',
|
||
name,
|
||
type: stripYamlValue(fieldBlock.match(/^\s+type:\s*(.+)$/m)?.[1] || 'computed'),
|
||
multipleEntities: false,
|
||
requiredFrom: '-',
|
||
description: stripYamlValue(fieldBlock.match(/^\s+compute:\s*(.+)$/m)?.[1] || '由其他字段计算得出')
|
||
};
|
||
}).filter(field => field.name);
|
||
|
||
return [...extractedFields, ...derivedFields];
|
||
}
|
||
|
||
function parseSubDocuments(source: string): SubDocumentSummary[] {
|
||
const parsed = YAML.parse(source || '') as Record<string, unknown> | null;
|
||
const subDocuments = parsed?.sub_documents;
|
||
if (!Array.isArray(subDocuments)) {
|
||
return [];
|
||
}
|
||
|
||
return subDocuments.flatMap((documentNode) => {
|
||
if (!documentNode || typeof documentNode !== 'object') {
|
||
return [];
|
||
}
|
||
const document = documentNode as Record<string, unknown>;
|
||
const id = String(document.id || '').trim();
|
||
if (!id) {
|
||
return [];
|
||
}
|
||
|
||
const extractGroups = Array.isArray(document.extract) ? document.extract : [];
|
||
const fields = extractGroups.flatMap((groupNode) => {
|
||
if (!groupNode || typeof groupNode !== 'object') {
|
||
return [];
|
||
}
|
||
const groupObject = groupNode as Record<string, unknown>;
|
||
const group = String(groupObject.group || '未分组').trim() || '未分组';
|
||
const groupFields = Array.isArray(groupObject.fields) ? groupObject.fields : [];
|
||
return groupFields.flatMap((fieldNode) => {
|
||
if (!fieldNode || typeof fieldNode !== 'object') {
|
||
return [];
|
||
}
|
||
const field = fieldNode as Record<string, unknown>;
|
||
const name = String(field.name || '').trim();
|
||
if (!name) {
|
||
return [];
|
||
}
|
||
const rawType = String(field.type || '-').trim();
|
||
return [{
|
||
id: `${id}-${group}-${name}`,
|
||
group,
|
||
name,
|
||
type: rawType === 'multi_entity' ? 'verbatim' : rawType,
|
||
multipleEntities: rawType === 'multi_entity',
|
||
allowed: toStringList(field.allowed),
|
||
requiredFrom: '-',
|
||
description: String(field.desc || '').trim(),
|
||
}];
|
||
});
|
||
});
|
||
|
||
const groups = Array.from(new Set(fields.map((field) => field.group).filter(Boolean)));
|
||
const classifier = document.classifier && typeof document.classifier === 'object'
|
||
? (document.classifier as Record<string, unknown>)
|
||
: null;
|
||
const description = Array.isArray(classifier?.keywords)
|
||
? classifier!.keywords.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 3).join('、')
|
||
: '';
|
||
|
||
return [{
|
||
id,
|
||
name: String(document.name || id).trim(),
|
||
required: String(document.required ?? '-').trim(),
|
||
fieldCount: fields.length,
|
||
groups,
|
||
description,
|
||
fields,
|
||
}];
|
||
});
|
||
}
|
||
|
||
function parseVisualElements(source: string): RuleYamlPack['visualElements'] {
|
||
const parsed = YAML.parse(source || '') as Record<string, unknown> | null;
|
||
const visualRoot = parsed?.visual_elements;
|
||
if (!visualRoot || typeof visualRoot !== 'object') {
|
||
return [];
|
||
}
|
||
|
||
const typedSections = [
|
||
{ key: 'seals', label: '签章' },
|
||
{ key: 'signatures', label: '签名' },
|
||
{ key: 'cross_page_seals', label: '骑缝章' },
|
||
] as const;
|
||
|
||
return typedSections.flatMap(({ key, label }) => {
|
||
const bucket = (visualRoot as Record<string, unknown>)[key];
|
||
if (!Array.isArray(bucket)) {
|
||
return [];
|
||
}
|
||
|
||
return bucket.flatMap((item) => {
|
||
if (!item || typeof item !== 'object') {
|
||
return [];
|
||
}
|
||
const node = item as Record<string, unknown>;
|
||
const id = String(node.id || '').trim();
|
||
if (!id) {
|
||
return [];
|
||
}
|
||
const toStringList = (value: unknown): string[] => (
|
||
Array.isArray(value)
|
||
? value.map((entry) => String(entry || '').trim()).filter(Boolean)
|
||
: []
|
||
);
|
||
|
||
const expectedMatch = node.expected_text_match && typeof node.expected_text_match === 'object'
|
||
? (node.expected_text_match as Record<string, unknown>)
|
||
: null;
|
||
const signatureTypes = key === 'seals'
|
||
? toStringList(node.allowed_types)
|
||
: toStringList(node.signature_types);
|
||
|
||
return [{
|
||
id,
|
||
name: String(node.name || id).trim(),
|
||
type: label,
|
||
required: String(node.required ?? '-').trim(),
|
||
requiredFrom: String(node.required_from ?? '').trim(),
|
||
signerRoles: key === 'signatures' ? toStringList(node.signer_roles) : [],
|
||
signatureTypes,
|
||
privateSealRestricted: key === 'signatures' ? Boolean(node.private_seal_restricted) : false,
|
||
expectedMatchField: String(expectedMatch?.field || '').trim(),
|
||
expectedMatchAlternatives: toStringList(expectedMatch?.alternatives),
|
||
prompt: String(node.prompt || '').trim(),
|
||
}];
|
||
});
|
||
});
|
||
}
|
||
|
||
export function buildRuleYamlPack(
|
||
config: RulePackScope & {
|
||
id: string;
|
||
yamlPath: string | null;
|
||
currentVersionId?: number | null;
|
||
fallbackVersionId?: number | null;
|
||
resolvedVersionId?: number | null;
|
||
},
|
||
yamlSource: string,
|
||
sourceStatus: RuleYamlPack['sourceStatus']
|
||
): RuleYamlPack {
|
||
const metadata = parseMetadata(yamlSource);
|
||
const fields = parseTopLevelFields(yamlSource);
|
||
const subDocuments = parseSubDocuments(yamlSource);
|
||
const rules = parseRules(yamlSource);
|
||
const visualElements = parseVisualElements(yamlSource);
|
||
|
||
return {
|
||
...config,
|
||
yamlSource,
|
||
sourceStatus,
|
||
metadata,
|
||
rules,
|
||
fields,
|
||
subDocuments,
|
||
visualElements,
|
||
stats: {
|
||
ruleCount: rules.length,
|
||
fieldCount: fields.length + subDocuments.reduce((sum, doc) => sum + doc.fieldCount, 0),
|
||
subDocumentCount: subDocuments.length,
|
||
visualElementCount: visualElements.length
|
||
}
|
||
};
|
||
}
|
||
|
||
export async function loadRuleYamlPacks(): Promise<RuleYamlPack[]> {
|
||
return Promise.all(MOCK_RULE_PACKS.map(async config => {
|
||
// TODO(production-data-source):
|
||
// 当前测试页直接读取 leaudit 本地 YAML 作为 mock。
|
||
// 生产切换时,这里应改为调用后端接口:
|
||
// 1. 后端根据文档类型/主类型/子类型查询数据库中的 OSS YAML 路径;
|
||
// 2. 后端读取 OSS YAML 正文并返回元数据和内容;
|
||
// 3. 前端仍消费 buildPack 之后的结构化数据,页面不直接关心 OSS 实现。
|
||
if (!config.yamlPath) {
|
||
return buildRuleYamlPack(config, EMPTY_RULE_YAML, 'empty');
|
||
}
|
||
|
||
try {
|
||
const yamlSource = await readFile(config.yamlPath, 'utf8');
|
||
return buildRuleYamlPack(config, yamlSource, 'ready');
|
||
} catch {
|
||
return buildRuleYamlPack(config, EMPTY_RULE_YAML, 'missing');
|
||
}
|
||
}));
|
||
}
|
||
|
||
export async function loadRuleYamlPack(id: string): Promise<RuleYamlPack | undefined> {
|
||
const packs = await loadRuleYamlPacks();
|
||
return packs.find(pack => pack.id === id);
|
||
}
|