@liuhange/dsh-gov-data-inspector 3.1.0 → 3.1.2
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/README.md +17 -1
- package/lib/__tests__/errorDetailBuilder.test.js +91 -0
- package/lib/__tests__/formatValidator.test.js +35 -0
- package/lib/__tests__/govData.test.js +26 -0
- package/lib/__tests__/inspectionOrchestrator.test.js +125 -0
- package/lib/__tests__/logicRuleEngine.test.js +39 -0
- package/lib/__tests__/missingFieldDetector.test.js +55 -0
- package/lib/__tests__/policyBasis.test.js +23 -0
- package/lib/__tests__/qualityMetricsCalculator.test.js +94 -0
- package/lib/__tests__/semanticRuleEngine.test.js +55 -0
- package/lib/errorDetailBuilder.js +47 -0
- package/lib/formatValidator.js +39 -0
- package/lib/index.js +50 -36
- package/lib/inspectionOrchestrator.js +70 -0
- package/lib/knowledgeBaseLoader.js +119 -0
- package/lib/logicRuleEngine.js +133 -0
- package/lib/missingFieldDetector.js +26 -0
- package/lib/nationalDataSource.js +52 -0
- package/lib/provincialDataSource.js +52 -0
- package/lib/qualityMetricsCalculator.js +33 -0
- package/lib/semanticRuleEngine.js +160 -0
- package/lib/standardRuleSource.js +62 -0
- package/lib/types.js +2 -0
- package/package.json +6 -4
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const FormatValidator = {
|
|
2
|
+
validate(guide, guideId, formatRules) {
|
|
3
|
+
if (!formatRules || formatRules.length === 0)
|
|
4
|
+
return [];
|
|
5
|
+
const issues = [];
|
|
6
|
+
for (const rule of formatRules) {
|
|
7
|
+
const val = guide[rule.field];
|
|
8
|
+
if (val === undefined || val === null)
|
|
9
|
+
continue;
|
|
10
|
+
const strVal = String(val).trim();
|
|
11
|
+
if (!strVal)
|
|
12
|
+
continue;
|
|
13
|
+
if (rule.pattern) {
|
|
14
|
+
const regex = new RegExp(rule.pattern);
|
|
15
|
+
if (!regex.test(strVal)) {
|
|
16
|
+
issues.push({
|
|
17
|
+
guideId,
|
|
18
|
+
field: rule.field,
|
|
19
|
+
issue: `字段"${rule.field}"值"${strVal}"不符合格式要求(正则: ${rule.pattern})`,
|
|
20
|
+
suggestion: rule.suggestionTemplate,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (rule.requiredKeywords && rule.requiredKeywords.length > 0) {
|
|
25
|
+
const hasAnyKeyword = rule.requiredKeywords.some((kw) => strVal.includes(kw));
|
|
26
|
+
if (!hasAnyKeyword) {
|
|
27
|
+
issues.push({
|
|
28
|
+
guideId,
|
|
29
|
+
field: rule.field,
|
|
30
|
+
issue: `字段"${rule.field}"值"${strVal}"缺少必要关键词(需包含: ${rule.requiredKeywords.join('或')})`,
|
|
31
|
+
suggestion: rule.suggestionTemplate,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return issues;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=formatValidator.js.map
|
package/lib/index.js
CHANGED
|
@@ -2,10 +2,13 @@ export const name = '@liuhange/dsh-gov-data-inspector';
|
|
|
2
2
|
export const inject = ['tools'];
|
|
3
3
|
import { loadJsonConfig } from '@liuhange/dsh-data-asset-shared';
|
|
4
4
|
import { resolvePolicyBasis } from './policyBasis.js';
|
|
5
|
+
import { KnowledgeBaseLoader } from './knowledgeBaseLoader.js';
|
|
6
|
+
import { InspectionOrchestrator } from './inspectionOrchestrator.js';
|
|
7
|
+
import { StandardRuleSource } from './standardRuleSource.js';
|
|
5
8
|
export function apply(ctx) {
|
|
6
9
|
ctx.tools.register({
|
|
7
10
|
name: 'inspect_gov_data',
|
|
8
|
-
description: '
|
|
11
|
+
description: '政务数据专项巡检,含办事指南质量检查(漏项/语义/逻辑/格式五维检测)和公共数据资产分类',
|
|
9
12
|
parameters: {
|
|
10
13
|
type: 'object',
|
|
11
14
|
properties: {
|
|
@@ -30,6 +33,50 @@ export function apply(ctx) {
|
|
|
30
33
|
if (!govConfig) {
|
|
31
34
|
return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: 'govDataInspection配置段缺失' });
|
|
32
35
|
}
|
|
36
|
+
const credentials = govConfig.dataSourceCredentials;
|
|
37
|
+
const credConfig = {
|
|
38
|
+
national: {
|
|
39
|
+
apiKey: process.env[credentials.national.apiKey],
|
|
40
|
+
endpoint: process.env[credentials.national.endpoint],
|
|
41
|
+
},
|
|
42
|
+
provincial: {
|
|
43
|
+
apiKey: process.env[credentials.provincial.apiKey],
|
|
44
|
+
endpoint: process.env[credentials.provincial.endpoint],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
let kb;
|
|
48
|
+
try {
|
|
49
|
+
kb = await KnowledgeBaseLoader.load(credConfig, govConfig.dataSourcePriority);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return JSON.stringify({ error: 'GOV_DATA_KB_MISSING', message: '官方数据源全部不可用,语义错误检测已中止' });
|
|
53
|
+
}
|
|
54
|
+
const dataSourceStatus = KnowledgeBaseLoader.getDataSourceStatus();
|
|
55
|
+
let standardRules;
|
|
56
|
+
const stdRuleResult = await StandardRuleSource.fetch({
|
|
57
|
+
docPath: process.env[credentials.standard.docPath],
|
|
58
|
+
endpoint: process.env[credentials.standard.endpoint],
|
|
59
|
+
});
|
|
60
|
+
if (stdRuleResult.rules.length > 0) {
|
|
61
|
+
standardRules = stdRuleResult.rules;
|
|
62
|
+
dataSourceStatus.standard = stdRuleResult.status;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
standardRules = govConfig.logicErrorRules;
|
|
66
|
+
}
|
|
67
|
+
if (!standardRules || standardRules.length === 0) {
|
|
68
|
+
return JSON.stringify({ error: 'GOV_DATA_LOGIC_RULES_MISSING', message: '标准规则集缺失,逻辑错误检测已中止' });
|
|
69
|
+
}
|
|
70
|
+
let policyBasis;
|
|
71
|
+
try {
|
|
72
|
+
policyBasis = resolvePolicyBasis('GOV_DATA_INSPECTION');
|
|
73
|
+
if (!policyBasis || policyBasis.length === 0) {
|
|
74
|
+
return JSON.stringify({ error: 'GOV_DATA_POLICY_MISSING', message: '政策依据配置缺失' });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return JSON.stringify({ error: 'GOV_DATA_POLICY_MISSING', message: '政策依据配置加载失败' });
|
|
79
|
+
}
|
|
33
80
|
let data;
|
|
34
81
|
try {
|
|
35
82
|
const { readFileSync } = await import('node:fs');
|
|
@@ -42,15 +89,15 @@ export function apply(ctx) {
|
|
|
42
89
|
return JSON.stringify({ error: 'GOV_DATA_INPUT_INVALID', message: `无法加载数据: ${args.dataSource}` });
|
|
43
90
|
}
|
|
44
91
|
const mode = args.inspectionMode ?? 'full';
|
|
45
|
-
const policyBasis = resolvePolicyBasis('GOV_DATA_INSPECTION');
|
|
46
92
|
const result = {
|
|
47
93
|
dataSource: args.dataSource,
|
|
48
94
|
inspectionMode: mode,
|
|
49
95
|
timestamp: new Date().toISOString(),
|
|
50
96
|
policyBasis,
|
|
97
|
+
dataSourceStatus,
|
|
51
98
|
};
|
|
52
99
|
if (mode === 'guide' || mode === 'full') {
|
|
53
|
-
result.guideInspection =
|
|
100
|
+
result.guideInspection = InspectionOrchestrator.orchestrate(data, govConfig, kb, standardRules);
|
|
54
101
|
}
|
|
55
102
|
if (mode === 'classification' || mode === 'full') {
|
|
56
103
|
result.classification = classifyGovData(data, govConfig.gbt47949Mapping);
|
|
@@ -63,39 +110,6 @@ export function apply(ctx) {
|
|
|
63
110
|
},
|
|
64
111
|
});
|
|
65
112
|
}
|
|
66
|
-
function inspectGuides(data, requiredElements, weights) {
|
|
67
|
-
let completeCount = 0;
|
|
68
|
-
let semanticErrors = 0;
|
|
69
|
-
let logicalErrors = 0;
|
|
70
|
-
let serviceConvenience = 0;
|
|
71
|
-
for (const row of data) {
|
|
72
|
-
if (typeof row === 'object' && row !== null) {
|
|
73
|
-
const obj = row;
|
|
74
|
-
let hasAllElements = true;
|
|
75
|
-
for (const elem of requiredElements) {
|
|
76
|
-
if (!obj[elem]) {
|
|
77
|
-
hasAllElements = false;
|
|
78
|
-
semanticErrors++;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
if (hasAllElements)
|
|
82
|
-
completeCount++;
|
|
83
|
-
if (obj.timeLimit && typeof obj.timeLimit === 'string' && obj.timeLimit.includes('工作日')) {
|
|
84
|
-
serviceConvenience += weights.timeLimit ?? 0.3;
|
|
85
|
-
}
|
|
86
|
-
if (obj.onlineCapable === true) {
|
|
87
|
-
serviceConvenience += weights.onlineCapable ?? 0.4;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return {
|
|
92
|
-
completeness: data.length > 0 ? Math.round((completeCount / data.length) * 100) : 0,
|
|
93
|
-
semanticErrors,
|
|
94
|
-
logicalErrors,
|
|
95
|
-
serviceConvenience: Math.round(serviceConvenience * 100) / 100,
|
|
96
|
-
totalGuidesChecked: data.length,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
113
|
function classifyGovData(data, gbtMapping) {
|
|
100
114
|
let structured = 0, semiStructured = 0, unstructured = 0;
|
|
101
115
|
for (const row of data) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { MissingFieldDetector } from './missingFieldDetector.js';
|
|
2
|
+
import { SemanticRuleEngine } from './semanticRuleEngine.js';
|
|
3
|
+
import { LogicRuleEngine } from './logicRuleEngine.js';
|
|
4
|
+
import { FormatValidator } from './formatValidator.js';
|
|
5
|
+
import { QualityMetricsCalculator } from './qualityMetricsCalculator.js';
|
|
6
|
+
import { ErrorDetailBuilder } from './errorDetailBuilder.js';
|
|
7
|
+
export const InspectionOrchestrator = {
|
|
8
|
+
orchestrate(data, config, kb, standardRules) {
|
|
9
|
+
const allErrorDetails = [];
|
|
10
|
+
const allFormatIssues = [];
|
|
11
|
+
let completeCount = 0;
|
|
12
|
+
let serviceConvenience = 0;
|
|
13
|
+
for (let i = 0; i < data.length; i++) {
|
|
14
|
+
const row = data[i];
|
|
15
|
+
if (typeof row !== 'object' || row === null)
|
|
16
|
+
continue;
|
|
17
|
+
const guide = row;
|
|
18
|
+
const guideId = String(guide['事项名称'] ?? `指南${i + 1}`);
|
|
19
|
+
const missingDetails = MissingFieldDetector.detect(guide, guideId, config.guideRequiredElements, config.severityMapping, undefined, config.missingFieldStandardClause);
|
|
20
|
+
allErrorDetails.push(...missingDetails);
|
|
21
|
+
const isComplete = missingDetails.length === 0;
|
|
22
|
+
if (isComplete)
|
|
23
|
+
completeCount++;
|
|
24
|
+
const semanticDetails = SemanticRuleEngine.detect(guide, guideId, kb, config.itemTypeMatching, config.severityMapping);
|
|
25
|
+
allErrorDetails.push(...semanticDetails);
|
|
26
|
+
const logicalDetails = LogicRuleEngine.detect(guide, guideId, standardRules, config.severityMapping);
|
|
27
|
+
allErrorDetails.push(...logicalDetails);
|
|
28
|
+
const formatIssues = FormatValidator.validate(guide, guideId, config.formatRules);
|
|
29
|
+
allFormatIssues.push(...formatIssues);
|
|
30
|
+
if (guide['办理时限'] && typeof guide['办理时限'] === 'string' && guide['办理时限'].includes('工作日')) {
|
|
31
|
+
serviceConvenience += config.convenienceWeights.timeLimit ?? 0.3;
|
|
32
|
+
}
|
|
33
|
+
if (guide.onlineCapable === true) {
|
|
34
|
+
serviceConvenience += config.convenienceWeights.onlineCapable ?? 0.4;
|
|
35
|
+
}
|
|
36
|
+
const materialStr = String(guide['申请材料'] ?? '');
|
|
37
|
+
if (materialStr) {
|
|
38
|
+
const materialCount = materialStr.split(/[、,,;;\n]/).filter(Boolean).length;
|
|
39
|
+
if (materialCount <= (config.materialConciseThreshold ?? 5)) {
|
|
40
|
+
serviceConvenience += config.convenienceWeights.materialConcise ?? 0.3;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const counts = ErrorDetailBuilder.countByType(allErrorDetails);
|
|
45
|
+
const totalFieldCount = data.length * config.guideRequiredElements.length;
|
|
46
|
+
const detectionRates = QualityMetricsCalculator.calculate({
|
|
47
|
+
semanticErrorCount: counts.semantic,
|
|
48
|
+
logicalErrorCount: counts.logical,
|
|
49
|
+
missingFieldCount: counts.missing,
|
|
50
|
+
formatIssueCount: allFormatIssues.length,
|
|
51
|
+
totalGuides: data.length,
|
|
52
|
+
requiredFieldCount: config.guideRequiredElements.length,
|
|
53
|
+
totalFieldCount: totalFieldCount > 0 ? totalFieldCount : 1,
|
|
54
|
+
errorDetails: allErrorDetails,
|
|
55
|
+
scoreWeights: config.scoreWeights,
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
completeness: data.length > 0 ? Math.round((completeCount / data.length) * 100) : 0,
|
|
59
|
+
missingFields: counts.missing,
|
|
60
|
+
semanticErrors: counts.semantic,
|
|
61
|
+
logicalErrors: counts.logical,
|
|
62
|
+
errorDetails: allErrorDetails,
|
|
63
|
+
formatIssues: allFormatIssues,
|
|
64
|
+
serviceConvenience: Math.round(serviceConvenience * 100) / 100,
|
|
65
|
+
totalGuidesChecked: data.length,
|
|
66
|
+
detectionRates,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=inspectionOrchestrator.js.map
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { NationalDataSource } from './nationalDataSource.js';
|
|
2
|
+
import { ProvincialDataSource } from './provincialDataSource.js';
|
|
3
|
+
let cachedTimeLimits = null;
|
|
4
|
+
let cachedMaterials = null;
|
|
5
|
+
let cachedConditions = null;
|
|
6
|
+
let cachedDataSourceStatus = null;
|
|
7
|
+
const FAILED_STATUS = { status: 'failed', fetchedAt: '', recordCount: 0 };
|
|
8
|
+
function mergeByPriority(national, provincial, priority) {
|
|
9
|
+
const merged = new Map();
|
|
10
|
+
const sources = { national, provincial };
|
|
11
|
+
for (const src of priority) {
|
|
12
|
+
const data = sources[src];
|
|
13
|
+
if (!data)
|
|
14
|
+
continue;
|
|
15
|
+
for (const item of data) {
|
|
16
|
+
const key = `${item.itemType}:${item.standardName ?? item.elementName ?? 'limit'}`;
|
|
17
|
+
if (!merged.has(key))
|
|
18
|
+
merged.set(key, item);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return Array.from(merged.values());
|
|
22
|
+
}
|
|
23
|
+
function toTimeLimits(data) {
|
|
24
|
+
return data
|
|
25
|
+
.filter((d) => d.legalUpperLimit !== undefined && d.legalLowerLimit !== undefined)
|
|
26
|
+
.map((d) => ({
|
|
27
|
+
itemType: d.itemType,
|
|
28
|
+
legalUpperLimit: d.legalUpperLimit,
|
|
29
|
+
legalLowerLimit: d.legalLowerLimit,
|
|
30
|
+
basisClause: d.standardClause,
|
|
31
|
+
dataSource: d.dataSource,
|
|
32
|
+
standardClause: d.standardClause,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
function toMaterials(data) {
|
|
36
|
+
return data
|
|
37
|
+
.filter((d) => d.standardName !== undefined)
|
|
38
|
+
.map((d) => ({
|
|
39
|
+
itemType: d.itemType,
|
|
40
|
+
standardName: d.standardName,
|
|
41
|
+
isRequired: d.isRequired ?? false,
|
|
42
|
+
basisClause: d.standardClause,
|
|
43
|
+
dataSource: d.dataSource,
|
|
44
|
+
standardClause: d.standardClause,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
function toConditions(data) {
|
|
48
|
+
return data
|
|
49
|
+
.filter((d) => d.elementName !== undefined && d.standardValue !== undefined)
|
|
50
|
+
.map((d) => ({
|
|
51
|
+
itemType: d.itemType,
|
|
52
|
+
elementName: d.elementName,
|
|
53
|
+
standardValue: d.standardValue,
|
|
54
|
+
basisClause: d.standardClause,
|
|
55
|
+
dataSource: d.dataSource,
|
|
56
|
+
standardClause: d.standardClause,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
export const KnowledgeBaseLoader = {
|
|
60
|
+
async load(credentials, dataSourcePriority) {
|
|
61
|
+
const priority = dataSourcePriority ?? ['provincial', 'national'];
|
|
62
|
+
const nationalResult = await NationalDataSource.fetch(credentials?.national);
|
|
63
|
+
const provincialResult = await ProvincialDataSource.fetch(credentials?.provincial);
|
|
64
|
+
const mergedData = mergeByPriority(nationalResult.data, provincialResult.data, priority);
|
|
65
|
+
cachedTimeLimits = toTimeLimits(mergedData);
|
|
66
|
+
cachedMaterials = toMaterials(mergedData);
|
|
67
|
+
cachedConditions = toConditions(mergedData);
|
|
68
|
+
cachedDataSourceStatus = {
|
|
69
|
+
national: nationalResult.status,
|
|
70
|
+
provincial: provincialResult.status,
|
|
71
|
+
standard: FAILED_STATUS,
|
|
72
|
+
};
|
|
73
|
+
const allFailed = nationalResult.status.status === 'failed' && provincialResult.status.status === 'failed';
|
|
74
|
+
if (allFailed) {
|
|
75
|
+
throw new Error('GOV_DATA_KB_MISSING: 官方数据源全部不可用,语义错误检测已中止');
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
timeLimits: cachedTimeLimits,
|
|
79
|
+
materials: cachedMaterials,
|
|
80
|
+
conditions: cachedConditions,
|
|
81
|
+
dataSourceStatus: cachedDataSourceStatus,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
getTimeLimits() {
|
|
85
|
+
if (!cachedTimeLimits)
|
|
86
|
+
throw new Error('GOV_DATA_KB_MISSING: 知识库尚未加载');
|
|
87
|
+
return cachedTimeLimits;
|
|
88
|
+
},
|
|
89
|
+
getMaterials() {
|
|
90
|
+
if (!cachedMaterials)
|
|
91
|
+
throw new Error('GOV_DATA_KB_MISSING: 知识库尚未加载');
|
|
92
|
+
return cachedMaterials;
|
|
93
|
+
},
|
|
94
|
+
getConditions() {
|
|
95
|
+
if (!cachedConditions)
|
|
96
|
+
throw new Error('GOV_DATA_KB_MISSING: 知识库尚未加载');
|
|
97
|
+
return cachedConditions;
|
|
98
|
+
},
|
|
99
|
+
getDataSourceStatus() {
|
|
100
|
+
if (!cachedDataSourceStatus) {
|
|
101
|
+
return {
|
|
102
|
+
national: FAILED_STATUS,
|
|
103
|
+
provincial: FAILED_STATUS,
|
|
104
|
+
standard: FAILED_STATUS,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return cachedDataSourceStatus;
|
|
108
|
+
},
|
|
109
|
+
isLoaded() {
|
|
110
|
+
return cachedTimeLimits !== null && cachedMaterials !== null && cachedConditions !== null;
|
|
111
|
+
},
|
|
112
|
+
_setTestData(kb) {
|
|
113
|
+
cachedTimeLimits = kb.timeLimits;
|
|
114
|
+
cachedMaterials = kb.materials;
|
|
115
|
+
cachedConditions = kb.conditions;
|
|
116
|
+
cachedDataSourceStatus = kb.dataSourceStatus;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
//# sourceMappingURL=knowledgeBaseLoader.js.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ErrorDetailBuilder } from './errorDetailBuilder.js';
|
|
2
|
+
function extractTimeLimitDays(timeLimit) {
|
|
3
|
+
const str = String(timeLimit ?? '');
|
|
4
|
+
const match = str.match(/(\d+)\s*个?\s*工作日/);
|
|
5
|
+
if (!match || !match[1])
|
|
6
|
+
return null;
|
|
7
|
+
return parseInt(match[1], 10);
|
|
8
|
+
}
|
|
9
|
+
function ruleSiteInspection(guide, guideId, rule, severityMapping) {
|
|
10
|
+
const process = String(guide['办理流程'] ?? '');
|
|
11
|
+
const timeLimit = String(guide['办理时限'] ?? '');
|
|
12
|
+
const triggerKw = rule.triggerKeywords[0];
|
|
13
|
+
if (!triggerKw || !process.includes(triggerKw) || !timeLimit)
|
|
14
|
+
return [];
|
|
15
|
+
const days = extractTimeLimitDays(timeLimit);
|
|
16
|
+
if (days === null)
|
|
17
|
+
return [];
|
|
18
|
+
const threshold = rule.threshold ?? 5;
|
|
19
|
+
if (days < threshold) {
|
|
20
|
+
return [
|
|
21
|
+
ErrorDetailBuilder.build({
|
|
22
|
+
guideId,
|
|
23
|
+
field: '办理时限',
|
|
24
|
+
errorType: 'logical',
|
|
25
|
+
description: `办理流程含"${triggerKw}"环节,但办理时限仅为${days}个工作日,少于${threshold}个工作日,存在逻辑矛盾。[条款:${rule.standardClause}]`,
|
|
26
|
+
suggestion: `建议将办理时限调整为不少于${threshold}个工作日,以确保${triggerKw}环节可充分执行`,
|
|
27
|
+
dataSource: 'standard',
|
|
28
|
+
standardClause: rule.standardClause,
|
|
29
|
+
}, severityMapping),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
function ruleInstantHandle(guide, guideId, rule, severityMapping) {
|
|
35
|
+
const process = String(guide['办理流程'] ?? '');
|
|
36
|
+
const timeLimit = String(guide['办理时限'] ?? '');
|
|
37
|
+
const triggerKw = rule.triggerKeywords[0];
|
|
38
|
+
if (!triggerKw || !process.includes(triggerKw) || !timeLimit)
|
|
39
|
+
return [];
|
|
40
|
+
const days = extractTimeLimitDays(timeLimit);
|
|
41
|
+
if (days === null)
|
|
42
|
+
return [];
|
|
43
|
+
const threshold = rule.threshold ?? 1;
|
|
44
|
+
if (days > threshold) {
|
|
45
|
+
return [
|
|
46
|
+
ErrorDetailBuilder.build({
|
|
47
|
+
guideId,
|
|
48
|
+
field: '办理时限',
|
|
49
|
+
errorType: 'logical',
|
|
50
|
+
description: `办理流程含"${triggerKw}"环节,但办理时限为${days}个工作日,与当场办理语义矛盾。[条款:${rule.standardClause}]`,
|
|
51
|
+
suggestion: `建议将办理流程改为非当场办理,或将办理时限调整为${threshold}个工作日以内`,
|
|
52
|
+
dataSource: 'standard',
|
|
53
|
+
standardClause: rule.standardClause,
|
|
54
|
+
}, severityMapping),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
function ruleConditionProxy(guide, guideId, rule, severityMapping) {
|
|
60
|
+
const condition = String(guide['办理条件'] ?? '');
|
|
61
|
+
const process = String(guide['办理流程'] ?? '');
|
|
62
|
+
if (!condition || !process)
|
|
63
|
+
return [];
|
|
64
|
+
const inPersonKws = rule.triggerKeywords.filter((kw) => kw.includes('本人'));
|
|
65
|
+
const proxyKws = rule.triggerKeywords.filter((kw) => !kw.includes('本人'));
|
|
66
|
+
const requiresInPerson = inPersonKws.some((kw) => condition.includes(kw));
|
|
67
|
+
const allowsProxy = proxyKws.some((kw) => process.includes(kw));
|
|
68
|
+
if (requiresInPerson && allowsProxy) {
|
|
69
|
+
return [
|
|
70
|
+
ErrorDetailBuilder.build({
|
|
71
|
+
guideId,
|
|
72
|
+
field: '办理流程',
|
|
73
|
+
errorType: 'logical',
|
|
74
|
+
description: `办理条件要求"本人到场",但办理流程允许代办/委托办理,两者存在逻辑矛盾。[条款:${rule.standardClause}]`,
|
|
75
|
+
suggestion: `建议统一办理条件与流程:若确需本人到场则取消代办选项,若允许代办则调整办理条件`,
|
|
76
|
+
dataSource: 'standard',
|
|
77
|
+
standardClause: rule.standardClause,
|
|
78
|
+
}, severityMapping),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
function ruleMaterialCondition(guide, guideId, rule, severityMapping) {
|
|
84
|
+
const condition = String(guide['办理条件'] ?? '');
|
|
85
|
+
const materialStr = String(guide['申请材料'] ?? '');
|
|
86
|
+
if (!condition || !materialStr)
|
|
87
|
+
return [];
|
|
88
|
+
const proofKeywords = rule.triggerKeywords;
|
|
89
|
+
const requiredProofs = proofKeywords.filter((kw) => condition.includes(kw));
|
|
90
|
+
if (requiredProofs.length === 0)
|
|
91
|
+
return [];
|
|
92
|
+
const missingMaterials = requiredProofs.filter((proof) => !materialStr.includes(proof));
|
|
93
|
+
if (missingMaterials.length > 0) {
|
|
94
|
+
return missingMaterials.map((mat) => ErrorDetailBuilder.build({
|
|
95
|
+
guideId,
|
|
96
|
+
field: '申请材料',
|
|
97
|
+
errorType: 'logical',
|
|
98
|
+
description: `办理条件要求"${mat}",但申请材料清单中未列入该证明材料,条件与材料清单不匹配。[条款:${rule.standardClause}]`,
|
|
99
|
+
suggestion: `建议在申请材料清单中补充"${mat}",或调整办理条件中对该证明的要求`,
|
|
100
|
+
dataSource: 'standard',
|
|
101
|
+
standardClause: rule.standardClause,
|
|
102
|
+
}, severityMapping));
|
|
103
|
+
}
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const RULE_DISPATCH = {
|
|
107
|
+
LOG_SITE_INSPECTION_001: ruleSiteInspection,
|
|
108
|
+
LOG_INSTANT_HANDLE_001: ruleInstantHandle,
|
|
109
|
+
LOG_CONDITION_PROXY_001: ruleConditionProxy,
|
|
110
|
+
LOG_MATERIAL_CONDITION_001: ruleMaterialCondition,
|
|
111
|
+
};
|
|
112
|
+
export const LogicRuleEngine = {
|
|
113
|
+
detect(guide, guideId, rules, severityMapping) {
|
|
114
|
+
if (!rules || rules.length === 0) {
|
|
115
|
+
throw new Error('GOV_DATA_LOGIC_RULES_MISSING: logicErrorRules配置缺失,逻辑检测已中止');
|
|
116
|
+
}
|
|
117
|
+
const details = [];
|
|
118
|
+
for (const rule of rules) {
|
|
119
|
+
const handler = RULE_DISPATCH[rule.ruleId];
|
|
120
|
+
if (!handler)
|
|
121
|
+
continue;
|
|
122
|
+
const hasAllFields = rule.triggerFields.every((f) => {
|
|
123
|
+
const val = guide[f];
|
|
124
|
+
return val !== undefined && val !== null && (typeof val !== 'string' || val.trim() !== '');
|
|
125
|
+
});
|
|
126
|
+
if (!hasAllFields)
|
|
127
|
+
continue;
|
|
128
|
+
details.push(...handler(guide, guideId, rule, severityMapping));
|
|
129
|
+
}
|
|
130
|
+
return details;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=logicRuleEngine.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ErrorDetailBuilder } from './errorDetailBuilder.js';
|
|
2
|
+
export const MissingFieldDetector = {
|
|
3
|
+
detect(guide, guideId, requiredElements, severityMapping, policyBasisText, standardClause) {
|
|
4
|
+
if (!requiredElements || requiredElements.length === 0) {
|
|
5
|
+
throw new Error('GOV_DATA_RULES_MISSING: guideRequiredElements配置缺失,无法执行漏项检测');
|
|
6
|
+
}
|
|
7
|
+
const basisText = policyBasisText ?? '办事指南应包含完整核心要素';
|
|
8
|
+
const details = [];
|
|
9
|
+
for (const elem of requiredElements) {
|
|
10
|
+
const val = guide[elem];
|
|
11
|
+
if (val === undefined || val === null || (typeof val === 'string' && val.trim() === '')) {
|
|
12
|
+
details.push(ErrorDetailBuilder.build({
|
|
13
|
+
guideId,
|
|
14
|
+
field: elem,
|
|
15
|
+
errorType: 'missing',
|
|
16
|
+
description: `办事指南"${guideId}"缺失必填字段"${elem}",该字段为办事指南核心要素,缺失将影响办事指南可用性`,
|
|
17
|
+
suggestion: `请补充"${elem}"字段内容,确保办事指南核心要素完整。${basisText}`,
|
|
18
|
+
dataSource: 'standard',
|
|
19
|
+
standardClause: standardClause ?? '',
|
|
20
|
+
}, severityMapping));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return details;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=missingFieldDetector.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const FAILED_RESULT = {
|
|
2
|
+
data: [],
|
|
3
|
+
status: { status: 'failed', fetchedAt: '', recordCount: 0 },
|
|
4
|
+
};
|
|
5
|
+
export const NationalDataSource = {
|
|
6
|
+
async fetch(credentials) {
|
|
7
|
+
const apiKey = credentials?.apiKey ?? process.env.NATIONAL_API_KEY;
|
|
8
|
+
const endpoint = credentials?.endpoint ?? process.env.NATIONAL_API_ENDPOINT;
|
|
9
|
+
if (!apiKey || !endpoint) {
|
|
10
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
11
|
+
}
|
|
12
|
+
if (!endpoint.startsWith('https://')) {
|
|
13
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(endpoint);
|
|
17
|
+
url.searchParams.set('apiKey', apiKey);
|
|
18
|
+
const response = await fetch(url.toString(), {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
headers: { 'Accept': 'application/json' },
|
|
21
|
+
signal: AbortSignal.timeout(10000),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
25
|
+
const raw = await response.json();
|
|
26
|
+
if (!Array.isArray(raw))
|
|
27
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
28
|
+
const data = raw
|
|
29
|
+
.filter((item) => item && typeof item === 'object')
|
|
30
|
+
.map((item) => ({
|
|
31
|
+
dataSource: 'national',
|
|
32
|
+
itemType: String(item.itemType ?? ''),
|
|
33
|
+
legalUpperLimit: item.legalUpperLimit,
|
|
34
|
+
legalLowerLimit: item.legalLowerLimit,
|
|
35
|
+
standardName: item.standardName,
|
|
36
|
+
isRequired: item.isRequired,
|
|
37
|
+
elementName: item.elementName,
|
|
38
|
+
standardValue: item.standardValue,
|
|
39
|
+
standardClause: String(item.standardClause ?? ''),
|
|
40
|
+
}))
|
|
41
|
+
.filter((d) => d.itemType && d.standardClause);
|
|
42
|
+
return {
|
|
43
|
+
data,
|
|
44
|
+
status: { status: 'success', fetchedAt: new Date().toISOString(), recordCount: data.length },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=nationalDataSource.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const FAILED_RESULT = {
|
|
2
|
+
data: [],
|
|
3
|
+
status: { status: 'failed', fetchedAt: '', recordCount: 0 },
|
|
4
|
+
};
|
|
5
|
+
export const ProvincialDataSource = {
|
|
6
|
+
async fetch(credentials) {
|
|
7
|
+
const apiKey = credentials?.apiKey ?? process.env.PROVINCIAL_API_KEY;
|
|
8
|
+
const endpoint = credentials?.endpoint ?? process.env.PROVINCIAL_API_ENDPOINT;
|
|
9
|
+
if (!apiKey || !endpoint) {
|
|
10
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
11
|
+
}
|
|
12
|
+
if (!endpoint.startsWith('https://')) {
|
|
13
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(endpoint);
|
|
17
|
+
url.searchParams.set('apiKey', apiKey);
|
|
18
|
+
const response = await fetch(url.toString(), {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
headers: { 'Accept': 'application/json' },
|
|
21
|
+
signal: AbortSignal.timeout(10000),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
25
|
+
const raw = await response.json();
|
|
26
|
+
if (!Array.isArray(raw))
|
|
27
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
28
|
+
const data = raw
|
|
29
|
+
.filter((item) => item && typeof item === 'object')
|
|
30
|
+
.map((item) => ({
|
|
31
|
+
dataSource: 'provincial',
|
|
32
|
+
itemType: String(item.itemType ?? ''),
|
|
33
|
+
legalUpperLimit: item.legalUpperLimit,
|
|
34
|
+
legalLowerLimit: item.legalLowerLimit,
|
|
35
|
+
standardName: item.standardName,
|
|
36
|
+
isRequired: item.isRequired,
|
|
37
|
+
elementName: item.elementName,
|
|
38
|
+
standardValue: item.standardValue,
|
|
39
|
+
standardClause: String(item.standardClause ?? ''),
|
|
40
|
+
}))
|
|
41
|
+
.filter((d) => d.itemType && d.standardClause);
|
|
42
|
+
return {
|
|
43
|
+
data,
|
|
44
|
+
status: { status: 'success', fetchedAt: new Date().toISOString(), recordCount: data.length },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=provincialDataSource.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const QualityMetricsCalculator = {
|
|
2
|
+
calculate(input) {
|
|
3
|
+
const { semanticErrorCount, logicalErrorCount, missingFieldCount, formatIssueCount, totalGuides, requiredFieldCount, totalFieldCount, errorDetails, falsePositiveCount = 0, trueSemanticErrorCount, trueLogicalErrorCount, scoreWeights, } = input;
|
|
4
|
+
const semanticDetectionRate = trueSemanticErrorCount !== undefined && trueSemanticErrorCount > 0
|
|
5
|
+
? Math.round((semanticErrorCount / trueSemanticErrorCount) * 100)
|
|
6
|
+
: '未度量';
|
|
7
|
+
const logicalDetectionRate = trueLogicalErrorCount !== undefined && trueLogicalErrorCount > 0
|
|
8
|
+
? Math.round((logicalErrorCount / trueLogicalErrorCount) * 100)
|
|
9
|
+
: '未度量';
|
|
10
|
+
const totalReportedErrors = semanticErrorCount + logicalErrorCount + missingFieldCount;
|
|
11
|
+
const falsePositiveRate = totalReportedErrors > 0 ? Math.round((falsePositiveCount / totalReportedErrors) * 100) : 0;
|
|
12
|
+
const totalRequiredSlots = requiredFieldCount * totalGuides;
|
|
13
|
+
const completenessScore = totalRequiredSlots > 0
|
|
14
|
+
? Math.round((1 - missingFieldCount / totalRequiredSlots) * 100)
|
|
15
|
+
: 0;
|
|
16
|
+
const totalErrors = semanticErrorCount + logicalErrorCount + formatIssueCount;
|
|
17
|
+
const accuracyScore = totalFieldCount > 0 ? Math.round((1 - totalErrors / totalFieldCount) * 100) : 100;
|
|
18
|
+
const traceableCount = errorDetails.filter((d) => d.dataSource !== undefined && d.standardClause !== undefined && d.standardClause !== '').length;
|
|
19
|
+
const traceabilityScore = errorDetails.length > 0 ? Math.round((traceableCount / errorDetails.length) * 100) : 100;
|
|
20
|
+
const { completeness: w1, accuracy: w2, traceability: w3 } = scoreWeights;
|
|
21
|
+
const overallScore = Math.round(w1 * completenessScore + w2 * accuracyScore + w3 * traceabilityScore);
|
|
22
|
+
return {
|
|
23
|
+
semanticDetectionRate,
|
|
24
|
+
logicalDetectionRate,
|
|
25
|
+
falsePositiveRate,
|
|
26
|
+
completenessScore,
|
|
27
|
+
accuracyScore,
|
|
28
|
+
traceabilityScore,
|
|
29
|
+
overallScore,
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=qualityMetricsCalculator.js.map
|