@liuhange/dsh-gov-data-inspector 3.1.4 → 3.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +1 -1
- package/lib/__tests__/exceptionInput.test.js +46 -0
- package/lib/__tests__/formatValidator.test.js +29 -0
- package/lib/__tests__/input-boundary-checker.test.js +30 -0
- package/lib/__tests__/local-terms-loader.test.js +38 -0
- package/lib/__tests__/logicRuleEngine.test.js +38 -0
- package/lib/__tests__/semanticRuleEngine.test.js +31 -0
- package/lib/formatValidator.js +12 -4
- package/lib/index.js +16 -1
- package/lib/input-boundary-checker.js +20 -0
- package/lib/inspectionOrchestrator.js +1 -1
- package/lib/local-terms-loader.js +26 -0
- package/lib/logicRuleEngine.js +29 -0
- package/lib/semanticRuleEngine.js +34 -1
- package/package.json +8 -8
- package/LICENSE +0 -21
package/cordis.patch.yml
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# This file declares the plugin's runtime patch for the Cordis framework.
|
|
3
3
|
# Used by DSH plugin manager to apply framework-level patches during installation.
|
|
4
4
|
plugin: @liuhange/dsh-gov-data-inspector
|
|
5
|
-
version: 3.1.
|
|
5
|
+
version: 3.1.5
|
|
6
6
|
patch:
|
|
7
7
|
- target: tools
|
|
8
8
|
action: register
|
|
@@ -177,4 +177,50 @@ describe('UnmatchedWarningBuilder', () => {
|
|
|
177
177
|
expect(warning.message).toContain('未匹配到检测规则');
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
|
+
describe('输入边界友好提示', () => {
|
|
181
|
+
const emptyObjectPath = resolve(tmpDir, 'empty-object.json');
|
|
182
|
+
const tooLargePath = resolve(tmpDir, 'too-large.json');
|
|
183
|
+
const atThresholdPath = resolve(tmpDir, 'at-threshold.json');
|
|
184
|
+
beforeAll(() => {
|
|
185
|
+
writeFileSync(emptyObjectPath, JSON.stringify({}), 'utf-8');
|
|
186
|
+
writeFileSync(tooLargePath, 'a'.repeat(100000), 'utf-8');
|
|
187
|
+
writeFileSync(atThresholdPath, JSON.stringify([{ 事项名称: '测试', 办理时限: '20个工作日' }]).padEnd(50000, ' '), 'utf-8');
|
|
188
|
+
});
|
|
189
|
+
it('IDX-01: 空对象{} → 返回GOV_DATA_NO_DATA', async () => {
|
|
190
|
+
const execute = await getExecute();
|
|
191
|
+
const result = JSON.parse(await execute({ dataSource: emptyObjectPath, inspectionMode: 'guide' }));
|
|
192
|
+
expect(result.error).toBe('GOV_DATA_NO_DATA');
|
|
193
|
+
expect(result.message).toContain('未检测到有效数据');
|
|
194
|
+
});
|
|
195
|
+
it('IDX-02: 10万字符 → 返回GOV_DATA_INPUT_TOO_LARGE', async () => {
|
|
196
|
+
const execute = await getExecute();
|
|
197
|
+
const result = JSON.parse(await execute({ dataSource: tooLargePath }));
|
|
198
|
+
expect(result.error).toBe('GOV_DATA_INPUT_TOO_LARGE');
|
|
199
|
+
expect(result.message).toContain('超过处理上限');
|
|
200
|
+
expect(result.message).toContain('分批提交');
|
|
201
|
+
});
|
|
202
|
+
it('IDX-03: 50000字符 → 正常处理无超长错误', async () => {
|
|
203
|
+
const execute = await getExecute();
|
|
204
|
+
const result = JSON.parse(await execute({ dataSource: atThresholdPath }));
|
|
205
|
+
expect(result.error).not.toBe('GOV_DATA_INPUT_TOO_LARGE');
|
|
206
|
+
});
|
|
207
|
+
it('IDX-04: 空对象不进入漏项检测不报告14个漏项', async () => {
|
|
208
|
+
const execute = await getExecute();
|
|
209
|
+
const result = JSON.parse(await execute({ dataSource: emptyObjectPath, inspectionMode: 'guide' }));
|
|
210
|
+
expect(result.guideInspection).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
it('IDX-05: 空对象不报告missingFields', async () => {
|
|
213
|
+
const execute = await getExecute();
|
|
214
|
+
const result = JSON.parse(await execute({ dataSource: emptyObjectPath, inspectionMode: 'guide' }));
|
|
215
|
+
expect(result.missingFields).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
it('IDX-06: 降级模式+含笔误数据 → 报告含degradedMode和保底校验结果', async () => {
|
|
218
|
+
const typoDataPath = resolve(tmpDir, 'typo-data.json');
|
|
219
|
+
writeFileSync(typoDataPath, JSON.stringify([{ 事项名称: '食品经营许可', 申请材料: '身份证复件', 办理时限: '20个工作日' }]), 'utf-8');
|
|
220
|
+
const execute = await getExecute();
|
|
221
|
+
const result = JSON.parse(await execute({ dataSource: typoDataPath, inspectionMode: 'guide' }));
|
|
222
|
+
expect(result.degradedMode).toBe(true);
|
|
223
|
+
expect(result.degradedReason).toBeDefined();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
180
226
|
//# sourceMappingURL=exceptionInput.test.js.map
|
|
@@ -31,5 +31,34 @@ describe('FormatValidator', () => {
|
|
|
31
31
|
const issues = FormatValidator.validate({ 办理时限: 'abc' }, 'g1', undefined);
|
|
32
32
|
expect(issues.length).toBe(0);
|
|
33
33
|
});
|
|
34
|
+
it('FV-01: 时限值"7个自然日" formatIssues含semanticHint含"自然日而非工作日"', () => {
|
|
35
|
+
const issues = FormatValidator.validate({ 办理时限: '7个自然日' }, 'g1', formatRules);
|
|
36
|
+
const timeLimitIssue = issues.find((i) => i.field === '办理时限');
|
|
37
|
+
expect(timeLimitIssue).toBeDefined();
|
|
38
|
+
expect(timeLimitIssue?.semanticHint).toContain("自然日");
|
|
39
|
+
expect(timeLimitIssue?.semanticHint).toContain("工作日");
|
|
40
|
+
});
|
|
41
|
+
it('FV-02: 时限值"大约两周" semanticHint为undefined', () => {
|
|
42
|
+
const issues = FormatValidator.validate({ 办理时限: '大约两周' }, 'g1', formatRules);
|
|
43
|
+
const timeLimitIssue = issues.find((i) => i.field === '办理时限');
|
|
44
|
+
expect(timeLimitIssue).toBeDefined();
|
|
45
|
+
expect(timeLimitIssue?.semanticHint).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
it('FV-03: 咨询电话"请联系前台" semanticHint为undefined', () => {
|
|
48
|
+
const issues = FormatValidator.validate({ 咨询电话: '请联系前台' }, 'g1', formatRules);
|
|
49
|
+
const phoneIssue = issues.find((i) => i.field === '咨询电话');
|
|
50
|
+
expect(phoneIssue).toBeDefined();
|
|
51
|
+
expect(phoneIssue?.semanticHint).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
it('FV-04: 双标注主归类仍归属formatIssues不计入semanticErrors', () => {
|
|
54
|
+
const issues = FormatValidator.validate({ 办理时限: '7个自然日' }, 'g1', formatRules);
|
|
55
|
+
expect(issues.some((i) => i.field === '办理时限' && i.semanticHint !== undefined)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('FV-05: 双标注policyBasis保留原有格式校验政策依据', () => {
|
|
58
|
+
const issues = FormatValidator.validate({ 办理时限: '7个自然日' }, 'g1', formatRules);
|
|
59
|
+
const timeLimitIssue = issues.find((i) => i.field === '办理时限');
|
|
60
|
+
expect(timeLimitIssue).toBeDefined();
|
|
61
|
+
expect(timeLimitIssue?.issue).toContain('格式要求');
|
|
62
|
+
});
|
|
34
63
|
});
|
|
35
64
|
//# sourceMappingURL=formatValidator.test.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { InputBoundaryChecker } from '../input-boundary-checker.js';
|
|
3
|
+
describe('InputBoundaryChecker', () => {
|
|
4
|
+
it('IBC-01: checkEmpty([{}]) 返回 isEmpty=true', () => {
|
|
5
|
+
const result = InputBoundaryChecker.checkEmpty([{}]);
|
|
6
|
+
expect(result.isEmpty).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
it('IBC-02: checkEmpty([{事项名称:"测试"}]) 返回 isEmpty=false', () => {
|
|
9
|
+
const result = InputBoundaryChecker.checkEmpty([{ 事项名称: '测试' }]);
|
|
10
|
+
expect(result.isEmpty).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
it('IBC-03: checkEmpty([{a:""},{b:null}]) 返回 isEmpty=true', () => {
|
|
13
|
+
const result = InputBoundaryChecker.checkEmpty([{ a: '' }, { b: null }]);
|
|
14
|
+
expect(result.isEmpty).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it('IBC-04: checkTooLarge 50001字符 阈值50000 返回 isTooLarge=true', () => {
|
|
17
|
+
const result = InputBoundaryChecker.checkTooLarge('a'.repeat(50001), 50000);
|
|
18
|
+
expect(result.isTooLarge).toBe(true);
|
|
19
|
+
expect(result.length).toBe(50001);
|
|
20
|
+
});
|
|
21
|
+
it('IBC-05: checkTooLarge 50000字符 阈值50000 返回 isTooLarge=false', () => {
|
|
22
|
+
const result = InputBoundaryChecker.checkTooLarge('a'.repeat(50000), 50000);
|
|
23
|
+
expect(result.isTooLarge).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it('IBC-06: checkTooLarge 100字符 阈值50000 返回 isTooLarge=false', () => {
|
|
26
|
+
const result = InputBoundaryChecker.checkTooLarge('a'.repeat(100), 50000);
|
|
27
|
+
expect(result.isTooLarge).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
//# sourceMappingURL=input-boundary-checker.test.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { LocalTermsLoader } from '../local-terms-loader.js';
|
|
3
|
+
import { writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
describe('LocalTermsLoader', () => {
|
|
6
|
+
const tmpDir = resolve(process.cwd(), 'tmp-test-terms');
|
|
7
|
+
const tmpFile = resolve(tmpDir, 'test-terms.json');
|
|
8
|
+
it('LTL-01: 有效词表路径正常加载', () => {
|
|
9
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
10
|
+
writeFileSync(tmpFile, JSON.stringify({ materials: ['身份证原件', '申请表'], conditions: ['年满18周岁'] }), 'utf-8');
|
|
11
|
+
const result = LocalTermsLoader.load(tmpFile);
|
|
12
|
+
expect(result.materials).toContain('身份证原件');
|
|
13
|
+
expect(result.materials).toContain('申请表');
|
|
14
|
+
expect(result.conditions).toContain('年满18周岁');
|
|
15
|
+
});
|
|
16
|
+
it('LTL-02: 不存在路径返回空词表不抛错', () => {
|
|
17
|
+
const result = LocalTermsLoader.load('/nonexistent/path/terms.json');
|
|
18
|
+
expect(result.materials).toEqual([]);
|
|
19
|
+
expect(result.conditions).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
it('LTL-03: 空词表文件返回空词表不抛错', () => {
|
|
22
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
23
|
+
writeFileSync(tmpFile, JSON.stringify({ materials: [], conditions: [] }), 'utf-8');
|
|
24
|
+
const result = LocalTermsLoader.load(tmpFile);
|
|
25
|
+
expect(result.materials).toEqual([]);
|
|
26
|
+
expect(result.conditions).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
it('LTL-04: 未配置路径使用默认路径加载', () => {
|
|
29
|
+
const result = LocalTermsLoader.load();
|
|
30
|
+
expect(Array.isArray(result.materials)).toBe(true);
|
|
31
|
+
expect(Array.isArray(result.conditions)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('cleanup', () => {
|
|
34
|
+
if (existsSync(tmpDir))
|
|
35
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=local-terms-loader.test.js.map
|
|
@@ -5,6 +5,7 @@ const rules = [
|
|
|
5
5
|
{ ruleId: 'LOG_INSTANT_HANDLE_001', standardClause: 'DB1405/T 085-2025 第5.3条', triggerFields: ['办理流程', '办理时限'], condition: '流程含当场办理且时限>1', threshold: 1, suggestionTemplate: '建议调整', triggerKeywords: ['当场办理'] },
|
|
6
6
|
{ ruleId: 'LOG_CONDITION_PROXY_001', standardClause: 'DB1405/T 085-2025 第5.4条', triggerFields: ['办理条件', '办理流程'], condition: '条件要求本人到场但流程允许代办', suggestionTemplate: '建议统一', threshold: undefined, triggerKeywords: ['本人到场', '本人办理', '代办', '委托办理'] },
|
|
7
7
|
{ ruleId: 'LOG_MATERIAL_CONDITION_001', standardClause: 'DB1405/T 085-2025 第5.5条', triggerFields: ['申请材料', '办理条件'], condition: '条件要求的证明材料未列入材料清单', suggestionTemplate: '建议补充', threshold: undefined, triggerKeywords: ['收入证明', '产权证明', '资质证明', '无犯罪记录证明', '健康证明', '社保证明', '纳税证明'] },
|
|
8
|
+
{ ruleId: 'LOG_CONDITION_AGE_PROXY_001', standardClause: 'DB1405/T 085-2025 第5.6条', triggerFields: ['办理条件', '办理流程'], condition: '办理条件含年龄限制且办理流程含代办', suggestionTemplate: '建议明确:未成年人由监护人代办,或删除年龄限制', threshold: undefined, triggerKeywords: ['年满18周岁', '年满十八周岁', '须为成年人', '监护人代办', '未成年人代办', '法定代理人代办'] },
|
|
8
9
|
];
|
|
9
10
|
describe('LogicRuleEngine', () => {
|
|
10
11
|
it('流程含"现场勘查"且时限为"3个工作日" → 输出logical错误', () => {
|
|
@@ -35,5 +36,42 @@ describe('LogicRuleEngine', () => {
|
|
|
35
36
|
const details = LogicRuleEngine.detect(guide, 'g1', rules);
|
|
36
37
|
expect(details.length).toBe(0);
|
|
37
38
|
});
|
|
39
|
+
it('LRE-01: 条件含"年满18周岁"+流程含"监护人代办" → 检出矛盾', () => {
|
|
40
|
+
const guide = { 办理条件: '申请人须年满18周岁', 办理流程: '可由监护人代办' };
|
|
41
|
+
const details = LogicRuleEngine.detect(guide, 'g1', rules);
|
|
42
|
+
expect(details.some((d) => d.errorType === 'logical' && d.description.includes('年满18周岁'))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it('LRE-02: 条件含"年满十八周岁"+流程含"法定代理人代办" → 检出矛盾', () => {
|
|
45
|
+
const guide = { 办理条件: '申请人须年满十八周岁', 办理流程: '可由法定代理人代办' };
|
|
46
|
+
const details = LogicRuleEngine.detect(guide, 'g1', rules);
|
|
47
|
+
expect(details.some((d) => d.errorType === 'logical' && d.description.includes('年满18周岁'))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it('LRE-03: 仅年龄无代办 → 不报告', () => {
|
|
50
|
+
const guide = { 办理条件: '申请人须年满18周岁', 办理流程: '受理→审批→发证' };
|
|
51
|
+
const details = LogicRuleEngine.detect(guide, 'g1', rules);
|
|
52
|
+
expect(details.some((d) => d.description.includes('年满18周岁') && d.description.includes('代办'))).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it('LRE-04: 仅代办无年龄 → 不报告', () => {
|
|
55
|
+
const guide = { 办理条件: '符合法定条件', 办理流程: '可由监护人代办' };
|
|
56
|
+
const details = LogicRuleEngine.detect(guide, 'g1', rules);
|
|
57
|
+
expect(details.some((d) => d.description.includes('年满18周岁'))).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it('LRE-05: 现有LOG_CONDITION_PROXY_001(本人到场+代办)不回归', () => {
|
|
60
|
+
const guide = { 办理条件: '需本人到场办理', 办理流程: '可代办' };
|
|
61
|
+
const details = LogicRuleEngine.detect(guide, 'g1', rules);
|
|
62
|
+
expect(details.some((d) => d.description.includes('本人到场'))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
it('LRE-06: 新规则standardClause含"DB1405/T 085-2025 第5.6条"', () => {
|
|
65
|
+
const guide = { 办理条件: '申请人须年满18周岁', 办理流程: '可由监护人代办' };
|
|
66
|
+
const details = LogicRuleEngine.detect(guide, 'g1', rules);
|
|
67
|
+
expect(details.some((d) => d.standardClause === 'DB1405/T 085-2025 第5.6条')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it('LRE-07: 新规则suggestion含"未成年人由监护人代办"', () => {
|
|
70
|
+
const guide = { 办理条件: '申请人须年满18周岁', 办理流程: '可由监护人代办' };
|
|
71
|
+
const details = LogicRuleEngine.detect(guide, 'g1', rules);
|
|
72
|
+
const ageProxyDetail = details.find((d) => d.description.includes('年满18周岁'));
|
|
73
|
+
expect(ageProxyDetail).toBeDefined();
|
|
74
|
+
expect(ageProxyDetail?.suggestion).toContain('未成年人由监护人代办');
|
|
75
|
+
});
|
|
38
76
|
});
|
|
39
77
|
//# sourceMappingURL=logicRuleEngine.test.js.map
|
|
@@ -86,5 +86,36 @@ describe('SemanticRuleEngine', () => {
|
|
|
86
86
|
expect(SemanticRuleEngine.extractTimeLimitDays('20个工作日')).toBe(20);
|
|
87
87
|
expect(SemanticRuleEngine.extractTimeLimitDays('大约两周')).toBeNull();
|
|
88
88
|
});
|
|
89
|
+
it('SRE-01: 降级+空KB+本地词表含"身份证复印件"+材料含"身份证复" → 检出笔误', () => {
|
|
90
|
+
const guide = { 事项名称: '食品经营许可', 申请材料: '身份证复、申请表' };
|
|
91
|
+
const result = SemanticRuleEngine.detect(guide, 'g1', { timeLimits: [], materials: [], conditions: [] }, itemTypeMatching, undefined, { degradedMode: true });
|
|
92
|
+
expect(result.details.some((d) => d.errorType === 'semantic' && d.description.includes('笔误'))).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
it('SRE-02: 降级+空KB+本地词表含"营业执照复印件"+材料含"营业执照复" → 检出笔误', () => {
|
|
95
|
+
const guide = { 事项名称: '食品经营许可', 申请材料: '营业执照复' };
|
|
96
|
+
const result = SemanticRuleEngine.detect(guide, 'g1', { timeLimits: [], materials: [], conditions: [] }, itemTypeMatching, undefined, { degradedMode: true });
|
|
97
|
+
expect(result.details.some((d) => d.errorType === 'semantic' && d.description.includes('笔误'))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
it('SRE-03: 降级+空KB+本地词表为空 → 返回空details不抛错', () => {
|
|
100
|
+
const guide = { 事项名称: '食品经营许可', 申请材料: '身份证复件' };
|
|
101
|
+
const result = SemanticRuleEngine.detect(guide, 'g1', { timeLimits: [], materials: [], conditions: [] }, itemTypeMatching, undefined, { degradedMode: true, localTermsPath: '/nonexistent/path/terms.json' });
|
|
102
|
+
expect(result.details).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
it('SRE-04: 降级+空KB+材料与本地词表完全匹配 → 返回空details', () => {
|
|
105
|
+
const guide = { 事项名称: '食品经营许可', 申请材料: '身份证复印件' };
|
|
106
|
+
const result = SemanticRuleEngine.detect(guide, 'g1', { timeLimits: [], materials: [], conditions: [] }, itemTypeMatching, undefined, { degradedMode: true });
|
|
107
|
+
expect(result.details).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
it('SRE-05: 降级+空KB+材料相似度=0.8 → 不报告(严格小于)', () => {
|
|
110
|
+
const guide = { 事项名称: '食品经营许可', 申请材料: '身份证复印件' };
|
|
111
|
+
const result = SemanticRuleEngine.detect(guide, 'g1', { timeLimits: [], materials: [], conditions: [] }, itemTypeMatching, undefined, { degradedMode: true, degradedSimilarityThreshold: 0.8 });
|
|
112
|
+
const hasPenalty = result.details.some((d) => d.description.includes('笔误'));
|
|
113
|
+
expect(hasPenalty).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
it('SRE-06: 降级保底错误明细standardClause含"降级模式·本地词表校验"', () => {
|
|
116
|
+
const guide = { 事项名称: '食品经营许可', 申请材料: '身份证复' };
|
|
117
|
+
const result = SemanticRuleEngine.detect(guide, 'g1', { timeLimits: [], materials: [], conditions: [] }, itemTypeMatching, undefined, { degradedMode: true });
|
|
118
|
+
expect(result.details.some((d) => d.standardClause === '降级模式·本地词表校验')).toBe(true);
|
|
119
|
+
});
|
|
89
120
|
});
|
|
90
121
|
//# sourceMappingURL=semanticRuleEngine.test.js.map
|
package/lib/formatValidator.js
CHANGED
|
@@ -13,23 +13,31 @@ export const FormatValidator = {
|
|
|
13
13
|
if (rule.pattern) {
|
|
14
14
|
const regex = new RegExp(rule.pattern);
|
|
15
15
|
if (!regex.test(strVal)) {
|
|
16
|
-
|
|
16
|
+
const issue = {
|
|
17
17
|
guideId,
|
|
18
18
|
field: rule.field,
|
|
19
19
|
issue: `字段"${rule.field}"值"${strVal}"不符合格式要求(正则: ${rule.pattern})`,
|
|
20
20
|
suggestion: rule.suggestionTemplate,
|
|
21
|
-
}
|
|
21
|
+
};
|
|
22
|
+
if (rule.field === '办理时限' && strVal.includes('自然日')) {
|
|
23
|
+
issue.semanticHint = "时限使用'自然日'而非'工作日',可能存在语义偏差";
|
|
24
|
+
}
|
|
25
|
+
issues.push(issue);
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
if (rule.requiredKeywords && rule.requiredKeywords.length > 0) {
|
|
25
29
|
const hasAnyKeyword = rule.requiredKeywords.some((kw) => strVal.includes(kw));
|
|
26
30
|
if (!hasAnyKeyword) {
|
|
27
|
-
|
|
31
|
+
const issue = {
|
|
28
32
|
guideId,
|
|
29
33
|
field: rule.field,
|
|
30
34
|
issue: `字段"${rule.field}"值"${strVal}"缺少必要关键词(需包含: ${rule.requiredKeywords.join('或')})`,
|
|
31
35
|
suggestion: rule.suggestionTemplate,
|
|
32
|
-
}
|
|
36
|
+
};
|
|
37
|
+
if (rule.field === '办理时限' && strVal.includes('自然日')) {
|
|
38
|
+
issue.semanticHint = "时限使用'自然日'而非'工作日',可能存在语义偏差";
|
|
39
|
+
}
|
|
40
|
+
issues.push(issue);
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
}
|
package/lib/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { StandardRuleSource } from './standardRuleSource.js';
|
|
|
8
8
|
import { EncodingDetector } from './encodingDetector.js';
|
|
9
9
|
import { DataScaleGuard } from './dataScaleGuard.js';
|
|
10
10
|
import { UrlReachabilityChecker } from './urlReachabilityChecker.js';
|
|
11
|
+
import { InputBoundaryChecker } from './input-boundary-checker.js';
|
|
11
12
|
export function apply(ctx) {
|
|
12
13
|
ctx.tools.register({
|
|
13
14
|
name: 'inspect_gov_data',
|
|
@@ -93,6 +94,11 @@ export function apply(ctx) {
|
|
|
93
94
|
if (raw.trim() === '') {
|
|
94
95
|
return JSON.stringify({ error: 'GOV_DATA_INPUT_INVALID', message: '无数据,请确认输入文件内容' });
|
|
95
96
|
}
|
|
97
|
+
const inputLengthThreshold = govConfig?.inputLengthThreshold ?? 50000;
|
|
98
|
+
const tooLargeResult = InputBoundaryChecker.checkTooLarge(raw, inputLengthThreshold);
|
|
99
|
+
if (tooLargeResult.isTooLarge) {
|
|
100
|
+
return JSON.stringify({ error: 'GOV_DATA_INPUT_TOO_LARGE', message: '输入数据超过处理上限,请分批提交' });
|
|
101
|
+
}
|
|
96
102
|
data = JSON.parse(raw);
|
|
97
103
|
if (!Array.isArray(data))
|
|
98
104
|
data = [data];
|
|
@@ -103,6 +109,10 @@ export function apply(ctx) {
|
|
|
103
109
|
if (data.length === 0) {
|
|
104
110
|
return JSON.stringify({ error: 'GOV_DATA_INPUT_INVALID', message: '无数据,请确认输入文件内容' });
|
|
105
111
|
}
|
|
112
|
+
const emptyCheck = InputBoundaryChecker.checkEmpty(data);
|
|
113
|
+
if (emptyCheck.isEmpty) {
|
|
114
|
+
return JSON.stringify({ error: 'GOV_DATA_NO_DATA', message: '未检测到有效数据' });
|
|
115
|
+
}
|
|
106
116
|
const maxFieldCount = rulesConfig.govDataInspection?.maxFieldCount;
|
|
107
117
|
const scaleResult = DataScaleGuard.check(data, maxFieldCount);
|
|
108
118
|
if (scaleResult.exceeded) {
|
|
@@ -166,7 +176,12 @@ export function apply(ctx) {
|
|
|
166
176
|
result.degradedReason = degradedReason;
|
|
167
177
|
}
|
|
168
178
|
if (mode === 'guide' || mode === 'full') {
|
|
169
|
-
const orchestrateResult = InspectionOrchestrator.orchestrate(data, govConfigTyped, kb, standardRules, {
|
|
179
|
+
const orchestrateResult = InspectionOrchestrator.orchestrate(data, govConfigTyped, kb, standardRules, {
|
|
180
|
+
degradedMode,
|
|
181
|
+
itemTypeOverride,
|
|
182
|
+
localTermsPath: govConfig?.localTermsPath,
|
|
183
|
+
degradedSimilarityThreshold: govConfig?.degradedSimilarityThreshold,
|
|
184
|
+
});
|
|
170
185
|
result.guideInspection = orchestrateResult;
|
|
171
186
|
if (orchestrateResult.warnings && orchestrateResult.warnings.length > 0) {
|
|
172
187
|
result.warnings = orchestrateResult.warnings;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const InputBoundaryChecker = {
|
|
2
|
+
checkEmpty(data) {
|
|
3
|
+
if (!Array.isArray(data) || data.length === 0)
|
|
4
|
+
return { isEmpty: true };
|
|
5
|
+
for (const item of data) {
|
|
6
|
+
if (typeof item !== 'object' || item === null)
|
|
7
|
+
return { isEmpty: false };
|
|
8
|
+
const values = Object.values(item);
|
|
9
|
+
const hasValid = values.some((v) => v !== undefined && v !== null && v !== '' && (typeof v !== 'string' || v.trim() !== ''));
|
|
10
|
+
if (hasValid)
|
|
11
|
+
return { isEmpty: false };
|
|
12
|
+
}
|
|
13
|
+
return { isEmpty: true };
|
|
14
|
+
},
|
|
15
|
+
checkTooLarge(raw, threshold) {
|
|
16
|
+
const length = raw.length;
|
|
17
|
+
return { isTooLarge: length > threshold, length };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=input-boundary-checker.js.map
|
|
@@ -22,7 +22,7 @@ export const InspectionOrchestrator = {
|
|
|
22
22
|
const isComplete = missingDetails.length === 0;
|
|
23
23
|
if (isComplete)
|
|
24
24
|
completeCount++;
|
|
25
|
-
const semanticResult = SemanticRuleEngine.detect(guide, guideId, kb, config.itemTypeMatching, config.severityMapping, { degradedMode: options?.degradedMode, itemTypeOverride: options?.itemTypeOverride });
|
|
25
|
+
const semanticResult = SemanticRuleEngine.detect(guide, guideId, kb, config.itemTypeMatching, config.severityMapping, { degradedMode: options?.degradedMode, itemTypeOverride: options?.itemTypeOverride, localTermsPath: options?.localTermsPath, degradedSimilarityThreshold: options?.degradedSimilarityThreshold });
|
|
26
26
|
allErrorDetails.push(...semanticResult.details);
|
|
27
27
|
if (semanticResult.warning) {
|
|
28
28
|
allWarnings.push(semanticResult.warning);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { loadJsonConfig } from '@liuhange/dsh-data-asset-shared';
|
|
3
|
+
const DEFAULT_LOCAL_TERMS_PATH = 'config/local-standard-terms.json';
|
|
4
|
+
const SHARED_LOCAL_TERMS_PATH = '@liuhange/dsh-data-asset-shared/config/local-standard-terms.json';
|
|
5
|
+
export const LocalTermsLoader = {
|
|
6
|
+
load(configPath) {
|
|
7
|
+
try {
|
|
8
|
+
if (configPath && !existsSync(configPath)) {
|
|
9
|
+
return { materials: [], conditions: [] };
|
|
10
|
+
}
|
|
11
|
+
const path = configPath ?? DEFAULT_LOCAL_TERMS_PATH;
|
|
12
|
+
const raw = loadJsonConfig('LOCAL_TERMS_PATH', path, SHARED_LOCAL_TERMS_PATH);
|
|
13
|
+
const parsed = raw;
|
|
14
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
15
|
+
return { materials: [], conditions: [] };
|
|
16
|
+
const obj = parsed;
|
|
17
|
+
const materials = Array.isArray(obj.materials) ? obj.materials.filter((m) => typeof m === 'string') : [];
|
|
18
|
+
const conditions = Array.isArray(obj.conditions) ? obj.conditions.filter((c) => typeof c === 'string') : [];
|
|
19
|
+
return { materials, conditions };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { materials: [], conditions: [] };
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=local-terms-loader.js.map
|
package/lib/logicRuleEngine.js
CHANGED
|
@@ -103,11 +103,40 @@ function ruleMaterialCondition(guide, guideId, rule, severityMapping) {
|
|
|
103
103
|
}
|
|
104
104
|
return [];
|
|
105
105
|
}
|
|
106
|
+
function ruleConditionAgeProxy(guide, guideId, rule, severityMapping) {
|
|
107
|
+
const condition = String(guide['办理条件'] ?? '');
|
|
108
|
+
const process = String(guide['办理流程'] ?? '');
|
|
109
|
+
if (!condition || !process)
|
|
110
|
+
return [];
|
|
111
|
+
if (!rule.triggerKeywords || rule.triggerKeywords.length === 0)
|
|
112
|
+
return [];
|
|
113
|
+
const ageKeywords = rule.triggerKeywords.filter((kw) => !kw.includes('代办'));
|
|
114
|
+
const proxyKeywords = rule.triggerKeywords.filter((kw) => kw.includes('代办'));
|
|
115
|
+
if (ageKeywords.length === 0 || proxyKeywords.length === 0)
|
|
116
|
+
return [];
|
|
117
|
+
const ageHit = ageKeywords.some((kw) => condition.includes(kw));
|
|
118
|
+
const proxyHit = proxyKeywords.some((kw) => process.includes(kw));
|
|
119
|
+
if (ageHit && proxyHit) {
|
|
120
|
+
return [
|
|
121
|
+
ErrorDetailBuilder.build({
|
|
122
|
+
guideId,
|
|
123
|
+
field: '办理流程',
|
|
124
|
+
errorType: 'logical',
|
|
125
|
+
description: `办理条件要求年满18周岁,但办理流程允许监护人代办,两者存在逻辑矛盾。[条款:${rule.standardClause}]`,
|
|
126
|
+
suggestion: `建议明确:未成年人由监护人代办,或删除年龄限制`,
|
|
127
|
+
dataSource: 'standard',
|
|
128
|
+
standardClause: rule.standardClause,
|
|
129
|
+
}, severityMapping),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
106
134
|
const RULE_DISPATCH = {
|
|
107
135
|
LOG_SITE_INSPECTION_001: ruleSiteInspection,
|
|
108
136
|
LOG_INSTANT_HANDLE_001: ruleInstantHandle,
|
|
109
137
|
LOG_CONDITION_PROXY_001: ruleConditionProxy,
|
|
110
138
|
LOG_MATERIAL_CONDITION_001: ruleMaterialCondition,
|
|
139
|
+
LOG_CONDITION_AGE_PROXY_001: ruleConditionAgeProxy,
|
|
111
140
|
};
|
|
112
141
|
export const LogicRuleEngine = {
|
|
113
142
|
detect(guide, guideId, rules, severityMapping) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ErrorDetailBuilder } from './errorDetailBuilder.js';
|
|
2
2
|
import { UnmatchedWarningBuilder } from './unmatchedWarningBuilder.js';
|
|
3
|
+
import { LocalTermsLoader } from './local-terms-loader.js';
|
|
3
4
|
const FLOW_FIELD_KEYS = ['办理流程', '工作流程', '流程描述', '业务流程', '办理环节'];
|
|
4
5
|
function matchItemType(guide, itemTypeMatching, options) {
|
|
5
6
|
if (options?.itemTypeOverride) {
|
|
@@ -175,7 +176,39 @@ export const SemanticRuleEngine = {
|
|
|
175
176
|
}
|
|
176
177
|
const hasKbData = kb.timeLimits.length > 0 || kb.materials.length > 0 || kb.conditions.length > 0;
|
|
177
178
|
if (!hasKbData && options?.degradedMode) {
|
|
178
|
-
|
|
179
|
+
const localTerms = LocalTermsLoader.load(options.localTermsPath);
|
|
180
|
+
if (localTerms.materials.length === 0) {
|
|
181
|
+
return { details: [] };
|
|
182
|
+
}
|
|
183
|
+
const threshold = options.degradedSimilarityThreshold ?? 0.8;
|
|
184
|
+
const materialStr = String(guide['申请材料'] ?? '');
|
|
185
|
+
if (!materialStr)
|
|
186
|
+
return { details: [] };
|
|
187
|
+
const materials = materialStr.split(/[、,,;;\n]/).map((s) => s.trim()).filter(Boolean);
|
|
188
|
+
const details = [];
|
|
189
|
+
for (const mat of materials) {
|
|
190
|
+
let maxSim = 0;
|
|
191
|
+
let closestTerm = '';
|
|
192
|
+
for (const term of localTerms.materials) {
|
|
193
|
+
const sim = levenshteinSimilarity(mat, term);
|
|
194
|
+
if (sim > maxSim) {
|
|
195
|
+
maxSim = sim;
|
|
196
|
+
closestTerm = term;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (maxSim < threshold && maxSim > 0) {
|
|
200
|
+
details.push(ErrorDetailBuilder.build({
|
|
201
|
+
guideId,
|
|
202
|
+
field: '申请材料',
|
|
203
|
+
errorType: 'semantic',
|
|
204
|
+
description: `申请材料"${mat}"与标准词表"${closestTerm}"高度相似(相似度${maxSim.toFixed(2)}),可能存在笔误。[降级模式·本地词表校验]`,
|
|
205
|
+
suggestion: `建议将"${mat}"修改为标准名称"${closestTerm}"`,
|
|
206
|
+
dataSource: 'standard',
|
|
207
|
+
standardClause: '降级模式·本地词表校验',
|
|
208
|
+
}, severityMapping));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { details };
|
|
179
212
|
}
|
|
180
213
|
const details = [];
|
|
181
214
|
details.push(...detectTimeLimit(guide, guideId, itemType, kb, severityMapping));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liuhange/dsh-gov-data-inspector",
|
|
3
3
|
"description": "Government data inspection plugin: service guide quality check and public data asset classification",
|
|
4
|
-
"version": "3.1.
|
|
4
|
+
"version": "3.1.5",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
"cordis.patch.yml"
|
|
28
28
|
],
|
|
29
29
|
"license": "MIT",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"build": "tsc"
|
|
34
|
+
},
|
|
30
35
|
"dependencies": {
|
|
31
36
|
"@liuhange/dsh-data-asset-shared": "^3.1.0"
|
|
32
37
|
},
|
|
@@ -64,10 +69,5 @@
|
|
|
64
69
|
"plugin",
|
|
65
70
|
"open-source"
|
|
66
71
|
],
|
|
67
|
-
"homepage": "https://github.com/liuhange789/data-asset-inspector#readme"
|
|
68
|
-
|
|
69
|
-
"typecheck": "tsc --noEmit",
|
|
70
|
-
"test": "vitest run",
|
|
71
|
-
"build": "tsc"
|
|
72
|
-
}
|
|
73
|
-
}
|
|
72
|
+
"homepage": "https://github.com/liuhange789/data-asset-inspector#readme"
|
|
73
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 liuhange789
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|