Files
leaudit-platform-frontend/app/utils/rules-config-editor.ts
T
2026-05-07 18:58:55 +08:00

738 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
source: string;
group: string;
};
export type ValidationIssue = {
id: string;
severity: 'error' | 'warning';
area: '抽取配置' | '案卷文书' | '评查规则';
target: string;
message: string;
};
export type EditableRuleConfig = {
metadata: RuleYamlPack['metadata'];
yamlSource: string;
documentType: string;
mainType: string;
subtype: string;
fields: ExtractFieldSummary[];
subDocuments: SubDocumentSummary[];
visualElements: VisualElementSummary[];
rules: RuleSummary[];
};
function uniqueByValue(options: DependencyOption[]): DependencyOption[] {
const seen = new Set<string>();
return options.filter(option => {
if (!option.value || seen.has(option.value)) {
return false;
}
seen.add(option.value);
return true;
});
}
export function getDefaultExpandedDependencyGroups(options: DependencyOption[], selectedValues: string[]): string[] {
const selected = new Set(selectedValues);
const seen = new Set<string>();
return options
.filter(option => selected.has(option.value))
.map(option => option.group)
.filter(group => {
if (!group || seen.has(group)) {
return false;
}
seen.add(group);
return true;
});
}
export function collectDependencyOptions(config: Pick<EditableRuleConfig, 'fields' | 'subDocuments' | 'visualElements'>): DependencyOption[] {
const topLevelFields = config.fields.flatMap(field => {
const source = '字段抽取';
const group = field.group || '未分组';
const options = [{
value: field.name,
label: field.name,
source,
group
}];
if (field.group === '派生字段') {
options.push({
value: `derived.${field.name}`,
label: field.name,
source: '派生字段',
group: '派生字段'
});
}
if (field.name.includes('[*].')) {
options.push({
value: field.name.replace('[*].', '.'),
label: field.name.replace('[*].', ' / '),
source,
group
});
}
return options;
});
const documentFields = config.subDocuments.flatMap(document => [
{
value: document.name,
label: document.name,
source: '案卷文书',
group: '案卷文书'
},
...(document.fields || []).flatMap(field => [
{
value: `${document.name}.${field.name}`,
label: `${document.name} / ${field.name}`,
source: field.group ? `案卷文书 / ${field.group}` : '案卷文书',
group: field.group ? `${document.name} / ${field.group}` : document.name
},
{
value: field.name,
label: `${field.name}${document.name}`,
source: field.group ? `案卷文书 / ${field.group}` : '案卷文书',
group: field.group ? `${document.name} / ${field.group}` : document.name
}
])
]);
const visualElements = config.visualElements.flatMap(item => {
const label = item.name || item.id;
const source = '视觉要素';
const group = item.type || '未分组';
return [
{
value: item.id,
label,
source,
group
},
{
value: item.name || item.id,
label,
source,
group
},
{
value: `visual.${item.id}`,
label,
source,
group
},
{
value: `visual.${item.name || item.id}`,
label,
source,
group
},
{
value: item.type,
label: item.type,
source: '视觉要素',
group: '视觉要素'
}
];
});
return uniqueByValue([...topLevelFields, ...documentFields, ...visualElements]);
}
export function validateEditableRuleConfig(config: EditableRuleConfig): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const dependencyOptions = collectDependencyOptions(config);
const dependencyValues = new Set(dependencyOptions.map(option => option.value));
const hasKnownDependency = (dependency: string) => {
if (/^-?\d+(\.\d+)?$/.test(dependency)) return true;
if (dependencyValues.has(dependency)) return true;
const prefix = dependency.split('.')[0];
return dependency.includes('.') && dependencyValues.has(prefix);
};
config.fields.forEach(field => {
if (!field.name.trim()) {
issues.push({
id: `field-name-${field.id}`,
severity: 'error',
area: '抽取配置',
target: field.group || '未分组字段',
message: '字段名称不能为空。'
});
}
if (!field.type.trim() || field.type === '-') {
issues.push({
id: `field-type-${field.id}`,
severity: 'error',
area: '抽取配置',
target: field.name || '未命名字段',
message: '字段类型不能为空。'
});
}
if (field.type === 'enum' && (!Array.isArray(field.allowed) || field.allowed.length === 0)) {
issues.push({
id: `field-allowed-${field.id}`,
severity: 'error',
area: '抽取配置',
target: field.name || '未命名字段',
message: '枚举字段必须配置可选值。'
});
}
});
config.subDocuments.forEach(document => {
if (!document.name.trim()) {
issues.push({
id: `document-name-${document.id}`,
severity: 'error',
area: '案卷文书',
target: document.id,
message: '文书名称不能为空。'
});
}
if ((document.fields || []).length === 0) {
issues.push({
id: `document-fields-${document.id}`,
severity: 'warning',
area: '案卷文书',
target: document.name || document.id,
message: '当前文书还没有配置文书字段。'
});
}
(document.fields || []).forEach(field => {
if (!field.name.trim()) {
issues.push({
id: `document-field-name-${document.id}-${field.id}`,
severity: 'error',
area: '案卷文书',
target: document.name || document.id,
message: '文书字段名称不能为空。'
});
}
if (!field.type.trim() || field.type === '-') {
issues.push({
id: `document-field-type-${document.id}-${field.id}`,
severity: 'error',
area: '案卷文书',
target: field.name || '未命名字段',
message: '文书字段类型不能为空。'
});
}
if (field.type === 'enum' && (!Array.isArray(field.allowed) || field.allowed.length === 0)) {
issues.push({
id: `document-field-allowed-${document.id}-${field.id}`,
severity: 'error',
area: '案卷文书',
target: field.name || '未命名字段',
message: '文书枚举字段必须配置可选值。'
});
}
});
});
config.rules.forEach(rule => {
if (!rule.name.trim()) {
issues.push({
id: `rule-name-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.ruleId || rule.id,
message: '评查点名称不能为空。'
});
}
if (!rule.group.trim()) {
issues.push({
id: `rule-group-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '评查点必须选择规则组。'
});
}
if (!rule.score.trim() || rule.score === '-') {
issues.push({
id: `rule-score-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '评查点必须设置分值。'
});
}
if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && !rule.prompt.trim()) {
issues.push({
id: `rule-prompt-${rule.id}`,
severity: 'warning',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '智能语义检查建议维护提示词,便于后续重组 YAML。'
});
}
if (rule.type === 'rule_group' && !rule.logic.trim()) {
issues.push({
id: `rule-group-logic-${rule.id}`,
severity: 'error',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: '规则组合必须维护逻辑运算式。'
});
}
rule.dependencies.forEach(dependency => {
if (!hasKnownDependency(dependency)) {
issues.push({
id: `rule-dependency-${rule.id}-${dependency}`,
severity: 'warning',
area: '评查规则',
target: rule.name || rule.ruleId || rule.id,
message: `依赖字段【${dependency}】未在当前 YAML 的字段配置或视觉要素中找到。`
});
}
});
});
return issues;
}
function yamlValue(value: string | number | boolean | undefined): string {
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') return String(value);
const text = String(value || '').replace(/'/g, "''");
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.group !== '派生字段' && !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',
...(field.type === 'enum' && Array.isArray(field.allowed) && field.allowed.length > 0 ? { allowed: [...field.allowed] } : {}),
required_from: field.requiredFrom || 'draft',
desc: field.description || '',
})),
}));
}
function rewriteDerivedFieldNodes(
fields: ExtractFieldSummary[],
existingNodes: unknown,
): Array<Record<string, unknown>> {
const derivedFields = fields.filter((field) => field.group === '派生字段');
if (derivedFields.length === 0) {
return [];
}
const existingMap = new Map<string, Record<string, unknown>>();
if (Array.isArray(existingNodes)) {
existingNodes.forEach((node) => {
if (!node || typeof node !== 'object') return;
const record = deepClone(node as Record<string, unknown>);
const name = String(record.name || '').trim();
if (name) {
existingMap.set(name, record);
}
});
}
return derivedFields.map((field) => {
const existing = existingMap.get(field.name) || {};
const nextNode: Record<string, unknown> = {
...existing,
name: field.name,
type: field.type || String(existing.type || 'computed'),
};
const nextCompute = field.description && field.description !== '由其他字段计算得出'
? field.description
: String(existing.compute || '').trim();
if (nextCompute) {
nextNode.compute = nextCompute;
} else {
delete nextNode.compute;
}
return nextNode;
});
}
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',
...(field.type === 'enum' && Array.isArray(field.allowed) && field.allowed.length > 0 ? { allowed: [...field.allowed] } : {}),
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.requiredFrom) node.required_from = item.requiredFrom;
if (item.expectedMatchField) {
node.expected_text_match = {
field: item.expectedMatchField,
...(item.expectedMatchAlternatives && item.expectedMatchAlternatives.length > 0
? { alternatives: [...item.expectedMatchAlternatives] }
: {}),
};
}
if (item.prompt) node.prompt = item.prompt;
if (item.type === '签名') {
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;
sections.signatures.push(node);
return;
}
if (item.type === '骑缝章') {
sections.cross_page_seals.push(node);
return;
}
if (item.signatureTypes && item.signatureTypes.length > 0) node.allowed_types = [...item.signatureTypes];
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;
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;
if (rule.logic?.trim()) nextRule.logic = rule.logic.trim();
else if (typeof nextRule.logic === 'string' && !String(nextRule.logic).trim()) delete nextRule.logic;
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.derived_fields = rewriteDerivedFieldNodes(config.fields, root.derived_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}`,
`- group: ${yamlValue(rule.group || '未分组')}`,
' rules:',
` - rule_id: ${yamlValue(rule.ruleId)}`,
` name: ${yamlValue(rule.name)}`,
` risk: ${yamlValue(rule.risk)}`,
` score: ${yamlValue(rule.score)}`,
` type: ${yamlValue(rule.type)}`,
` desc: ${yamlValue(rule.description)}`
];
if (rule.appliesIn.length > 0) {
lines.push(' applies_in:');
rule.appliesIn.forEach(phase => lines.push(` - ${yamlValue(phase)}`));
}
if (rule.type === 'rule_group' && rule.logic.trim()) {
lines.push(` logic: ${yamlValue(rule.logic)}`);
if (rule.subRuleIds.length > 0) {
lines.push(' rules:');
rule.subRuleIds.forEach(ruleId => lines.push(` - ${yamlValue(ruleId)}`));
}
}
if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && rule.prompt.trim()) {
lines.push(
' stages:',
` - id: '1'`,
` check: ai`,
` prompt: ${yamlValue(rule.prompt)}`
);
}
if (rule.dependencies.length > 0) {
lines.push(' dependencies:');
rule.dependencies.forEach(dependency => lines.push(` - ${yamlValue(dependency)}`));
}
return `${lines.join('\n')}\n`;
}
export function buildYamlPreview(config: EditableRuleConfig): string {
try {
return serializeEditableRuleConfig(config);
} catch {
// 预览失败时仍回退旧实现,避免页面直接白屏;保存时会使用正式序列化并给出错误。
}
const lines: string[] = [
'metadata:',
` name: ${yamlValue(config.metadata.name || `${config.subtype}规则配置`)}`,
` version: ${yamlValue(config.metadata.version || 'mock')}`,
` description: ${yamlValue(config.metadata.description || '前端交互验证草稿')}`
];
if (config.fields.length > 0) {
lines.push('extract:');
const groups = Array.from(new Set(config.fields.map(field => field.group || '未分组')));
groups.forEach(group => {
lines.push(`- group: ${yamlValue(group)}`, ' fields:');
config.fields.filter(field => (field.group || '未分组') === group).forEach(field => {
lines.push(
` - name: ${yamlValue(field.name)}`,
` type: ${yamlValue(field.multipleEntities ? 'multi_entity' : field.type)}`,
...(field.type === 'enum' && Array.isArray(field.allowed) && field.allowed.length > 0
? [` allowed: [${field.allowed.map((item) => yamlValue(item)).join(', ')}]`]
: []),
` desc: ${yamlValue(field.description)}`
);
});
});
}
if (config.subDocuments.length > 0) {
lines.push('sub_documents:');
config.subDocuments.forEach(document => {
lines.push(
`- id: ${yamlValue(document.id)}`,
` name: ${yamlValue(document.name)}`,
` required: ${yamlValue(document.required)}`
);
if ((document.fields || []).length > 0) {
lines.push(' extract:');
const groups = Array.from(new Set(document.fields.map(field => field.group || '未分组')));
groups.forEach(group => {
lines.push(` - group: ${yamlValue(group)}`, ' fields:');
document.fields.filter(field => (field.group || '未分组') === group).forEach(field => {
lines.push(
` - name: ${yamlValue(field.name)}`,
` type: ${yamlValue(field.multipleEntities ? 'multi_entity' : field.type)}`,
...(field.type === 'enum' && Array.isArray(field.allowed) && field.allowed.length > 0
? [` allowed: [${field.allowed.map((item) => yamlValue(item)).join(', ')}]`]
: []),
` desc: ${yamlValue(field.description)}`
);
});
});
}
});
}
lines.push('rules:');
const ruleGroups = Array.from(new Set(config.rules.map(rule => rule.group || '未分组')));
ruleGroups.forEach(group => {
lines.push(`- group: ${yamlValue(group)}`, ' rules:');
config.rules.filter(rule => (rule.group || '未分组') === group).forEach(rule => {
lines.push(
` - rule_id: ${yamlValue(rule.ruleId)}`,
` name: ${yamlValue(rule.name)}`,
` risk: ${yamlValue(rule.risk)}`,
` score: ${yamlValue(rule.score)}`,
` type: ${yamlValue(rule.type)}`,
` desc: ${yamlValue(rule.description)}`
);
if (rule.appliesIn.length > 0) {
lines.push(' applies_in:');
rule.appliesIn.forEach(phase => lines.push(` - ${yamlValue(phase)}`));
}
if (rule.type === 'rule_group' && rule.logic.trim()) {
lines.push(` logic: ${yamlValue(rule.logic)}`);
if (rule.subRuleIds.length > 0) {
lines.push(' rules:');
rule.subRuleIds.forEach(ruleId => lines.push(` - ${yamlValue(ruleId)}`));
}
}
if ((rule.type === 'ai_rule' || rule.checkTypes.includes('ai')) && rule.prompt.trim()) {
lines.push(
' stages:',
` - id: '1'`,
` check: ai`,
` prompt: ${yamlValue(rule.prompt)}`
);
}
if (rule.dependencies.length > 0) {
lines.push(' dependencies:');
rule.dependencies.forEach(dependency => lines.push(` - ${yamlValue(dependency)}`));
}
});
});
return `${lines.join('\n')}\n`;
}