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(); 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(); 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): 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(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function groupBy(items: T[], keyGetter: (item: T) => string): Map { const groups = new Map(); 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> { 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> { const derivedFields = fields.filter((field) => field.group === '派生字段'); if (derivedFields.length === 0) { return []; } const existingMap = new Map>(); if (Array.isArray(existingNodes)) { existingNodes.forEach((node) => { if (!node || typeof node !== 'object') return; const record = deepClone(node as Record); 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 = { ...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> { 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 { const sections = { seals: [] as Array>, signatures: [] as Array>, cross_page_seals: [] as Array>, }; visualElements.forEach((item) => { const node: Record = { 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): string { return rule.ruleId || rule.name || rule.id; } function findAiStage(stages: Array>): Record | undefined { return stages.find((stage) => String(stage?.check || stage?.type || '').trim() === 'ai'); } function buildMinimalRuleNode(rule: RuleSummary): Record { const base: Record = { 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 | undefined, rule: RuleSummary): Record { 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>) : []; const stages = existingStages.length > 0 ? deepClone(existingStages) : (buildMinimalRuleNode(rule).stages as Array>); 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 | null; const root = parsed && typeof parsed === 'object' ? deepClone(parsed) : {}; const metadata = (root.metadata && typeof root.metadata === 'object' ? deepClone(root.metadata) : {}) as Record; 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>) : []; const existingRuleMap = new Map>(); existingGroups.forEach((groupBlock) => { const groupRules = Array.isArray(groupBlock?.rules) ? (groupBlock.rules as Array>) : []; groupRules.forEach((ruleNode) => { const key = String(ruleNode?.rule_id || ruleNode?.name || '').trim(); if (key) existingRuleMap.set(key, ruleNode); }); }); const nextGroups = new Map>>(); 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`; }