420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
import type { ExtractFieldSummary, RuleSummary, RuleYamlPack, SubDocumentSummary } from './rules-yaml-mock.server';
|
||
|
||
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'];
|
||
documentType: string;
|
||
mainType: string;
|
||
subtype: string;
|
||
fields: ExtractFieldSummary[];
|
||
subDocuments: SubDocumentSummary[];
|
||
visualElements: RuleYamlPack['visualElements'];
|
||
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 = field.group ? `字段抽取 / ${field.group}` : '字段抽取';
|
||
const options = [{
|
||
value: field.name,
|
||
label: field.name,
|
||
source,
|
||
group: source
|
||
}];
|
||
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: source
|
||
});
|
||
}
|
||
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 = `视觉要素 / ${item.type}`;
|
||
return [
|
||
{
|
||
value: item.id,
|
||
label,
|
||
source,
|
||
group: source
|
||
},
|
||
{
|
||
value: item.name || item.id,
|
||
label,
|
||
source,
|
||
group: source
|
||
},
|
||
{
|
||
value: `visual.${item.id}`,
|
||
label,
|
||
source,
|
||
group: source
|
||
},
|
||
{
|
||
value: `visual.${item.name || item.id}`,
|
||
label,
|
||
source,
|
||
group: source
|
||
},
|
||
{
|
||
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: '字段类型不能为空。'
|
||
});
|
||
}
|
||
});
|
||
|
||
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: '文书字段类型不能为空。'
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
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}'` : "''";
|
||
}
|
||
|
||
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 {
|
||
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)}`,
|
||
` 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)}`,
|
||
` 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`;
|
||
}
|