@liuhange/dsh-gov-data-inspector 3.1.3 → 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/lib/index.js CHANGED
@@ -5,6 +5,10 @@ import { resolvePolicyBasis } from './policyBasis.js';
5
5
  import { KnowledgeBaseLoader } from './knowledgeBaseLoader.js';
6
6
  import { InspectionOrchestrator } from './inspectionOrchestrator.js';
7
7
  import { StandardRuleSource } from './standardRuleSource.js';
8
+ import { EncodingDetector } from './encodingDetector.js';
9
+ import { DataScaleGuard } from './dataScaleGuard.js';
10
+ import { UrlReachabilityChecker } from './urlReachabilityChecker.js';
11
+ import { InputBoundaryChecker } from './input-boundary-checker.js';
8
12
  export function apply(ctx) {
9
13
  ctx.tools.register({
10
14
  name: 'inspect_gov_data',
@@ -14,6 +18,7 @@ export function apply(ctx) {
14
18
  properties: {
15
19
  dataSource: { type: 'string', description: '政务数据文件路径' },
16
20
  inspectionMode: { type: 'string', enum: ['guide', 'classification', 'full'] },
21
+ itemTypeOverride: { type: 'string', description: '手动指定事项类型,覆盖自动匹配结果' },
17
22
  },
18
23
  required: ['dataSource'],
19
24
  },
@@ -29,11 +34,91 @@ export function apply(ctx) {
29
34
  catch {
30
35
  return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: '无法加载business-rules.json' });
31
36
  }
32
- const govConfig = rulesConfig.govDataInspection;
33
- if (!govConfig) {
37
+ const govConfigRaw = rulesConfig.govDataInspection;
38
+ if (!govConfigRaw || typeof govConfigRaw !== 'object') {
34
39
  return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: 'govDataInspection配置段缺失' });
35
40
  }
36
- const credentials = govConfig.dataSourceCredentials;
41
+ const govConfig = govConfigRaw;
42
+ const requiredFields = {
43
+ guideRequiredElements: 'string[]',
44
+ logicErrorRules: 'array',
45
+ formatRules: 'array',
46
+ severityMapping: 'object',
47
+ scoreWeights: 'object',
48
+ convenienceWeights: 'object',
49
+ gbt47949Mapping: 'object',
50
+ itemTypeMatching: 'object',
51
+ materialConciseThreshold: 'number',
52
+ missingFieldStandardClause: 'string',
53
+ dataSourcePriority: 'array',
54
+ dataSourceCredentials: 'object',
55
+ };
56
+ for (const [field, expectedType] of Object.entries(requiredFields)) {
57
+ const val = govConfig[field];
58
+ if (val === undefined || val === null) {
59
+ return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: `govDataInspection.${field}配置缺失` });
60
+ }
61
+ if (expectedType === 'array' && !Array.isArray(val)) {
62
+ return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: `govDataInspection.${field}应为数组` });
63
+ }
64
+ if (expectedType === 'object' && (typeof val !== 'object' || Array.isArray(val))) {
65
+ return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: `govDataInspection.${field}应为对象` });
66
+ }
67
+ if (expectedType === 'string' && typeof val !== 'string') {
68
+ return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: `govDataInspection.${field}应为字符串` });
69
+ }
70
+ if (expectedType === 'number' && typeof val !== 'number') {
71
+ return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: `govDataInspection.${field}应为数字` });
72
+ }
73
+ if (expectedType === 'string[]' && (!Array.isArray(val) || val.some((v) => typeof v !== 'string'))) {
74
+ return JSON.stringify({ error: 'GOV_DATA_RULES_MISSING', message: `govDataInspection.${field}应为字符串数组` });
75
+ }
76
+ }
77
+ const govConfigTyped = govConfig;
78
+ const encodingResult = EncodingDetector.detect(args.dataSource);
79
+ if (encodingResult.content === null) {
80
+ if (encodingResult.encodingError === '文件为空') {
81
+ return JSON.stringify({ error: 'GOV_DATA_INPUT_INVALID', message: '无数据,请确认输入文件内容' });
82
+ }
83
+ if (encodingResult.encodingError?.includes('无法读取文件')) {
84
+ const reachability = await UrlReachabilityChecker.check(args.dataSource);
85
+ if (!reachability.reachable) {
86
+ return JSON.stringify({ error: 'GOV_DATA_URL_UNREACHABLE', message: reachability.error ?? '数据源不可达,请检查路径或 URL' });
87
+ }
88
+ }
89
+ return JSON.stringify({ error: 'GOV_DATA_ENCODING_ERROR', message: encodingResult.encodingError ?? '文件编码错误' });
90
+ }
91
+ let data;
92
+ try {
93
+ const raw = encodingResult.content;
94
+ if (raw.trim() === '') {
95
+ return JSON.stringify({ error: 'GOV_DATA_INPUT_INVALID', message: '无数据,请确认输入文件内容' });
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
+ }
102
+ data = JSON.parse(raw);
103
+ if (!Array.isArray(data))
104
+ data = [data];
105
+ }
106
+ catch (e) {
107
+ return JSON.stringify({ error: 'GOV_DATA_INPUT_INVALID', message: `JSON 格式错误,请检查文件内容: ${e.message}` });
108
+ }
109
+ if (data.length === 0) {
110
+ return JSON.stringify({ error: 'GOV_DATA_INPUT_INVALID', message: '无数据,请确认输入文件内容' });
111
+ }
112
+ const emptyCheck = InputBoundaryChecker.checkEmpty(data);
113
+ if (emptyCheck.isEmpty) {
114
+ return JSON.stringify({ error: 'GOV_DATA_NO_DATA', message: '未检测到有效数据' });
115
+ }
116
+ const maxFieldCount = rulesConfig.govDataInspection?.maxFieldCount;
117
+ const scaleResult = DataScaleGuard.check(data, maxFieldCount);
118
+ if (scaleResult.exceeded) {
119
+ return JSON.stringify({ error: 'GOV_DATA_SCALE_EXCEEDED', message: `数据量超出处理上限(字段数${scaleResult.fieldCount}),请分批处理或增加maxFieldCount配置` });
120
+ }
121
+ const credentials = govConfigTyped.dataSourceCredentials;
37
122
  const credConfig = {
38
123
  national: {
39
124
  apiKey: process.env[credentials.national.apiKey],
@@ -44,13 +129,10 @@ export function apply(ctx) {
44
129
  endpoint: process.env[credentials.provincial.endpoint],
45
130
  },
46
131
  };
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
- }
132
+ const kbLoadResult = await KnowledgeBaseLoader.load(credConfig, govConfigTyped.dataSourcePriority);
133
+ const kb = kbLoadResult.kb;
134
+ const degradedMode = kbLoadResult.degraded;
135
+ const degradedReason = kbLoadResult.degradedReason;
54
136
  const dataSourceStatus = KnowledgeBaseLoader.getDataSourceStatus();
55
137
  let standardRules;
56
138
  const stdRuleResult = await StandardRuleSource.fetch({
@@ -62,10 +144,13 @@ export function apply(ctx) {
62
144
  dataSourceStatus.standard = stdRuleResult.status;
63
145
  }
64
146
  else {
65
- standardRules = govConfig.logicErrorRules;
147
+ standardRules = govConfigTyped.logicErrorRules;
66
148
  }
67
149
  if (!standardRules || standardRules.length === 0) {
68
- return JSON.stringify({ error: 'GOV_DATA_LOGIC_RULES_MISSING', message: '标准规则集缺失,逻辑错误检测已中止' });
150
+ if (!degradedMode) {
151
+ return JSON.stringify({ error: 'GOV_DATA_LOGIC_RULES_MISSING', message: '标准规则集缺失,逻辑错误检测已中止' });
152
+ }
153
+ standardRules = [];
69
154
  }
70
155
  let policyBasis;
71
156
  try {
@@ -77,18 +162,8 @@ export function apply(ctx) {
77
162
  catch {
78
163
  return JSON.stringify({ error: 'GOV_DATA_POLICY_MISSING', message: '政策依据配置加载失败' });
79
164
  }
80
- let data;
81
- try {
82
- const { readFileSync } = await import('node:fs');
83
- const raw = readFileSync(args.dataSource, 'utf-8');
84
- data = JSON.parse(raw);
85
- if (!Array.isArray(data))
86
- data = [data];
87
- }
88
- catch {
89
- return JSON.stringify({ error: 'GOV_DATA_INPUT_INVALID', message: `无法加载数据: ${args.dataSource}` });
90
- }
91
165
  const mode = args.inspectionMode ?? 'full';
166
+ const itemTypeOverride = args.itemTypeOverride;
92
167
  const result = {
93
168
  dataSource: args.dataSource,
94
169
  inspectionMode: mode,
@@ -96,11 +171,24 @@ export function apply(ctx) {
96
171
  policyBasis,
97
172
  dataSourceStatus,
98
173
  };
174
+ if (degradedMode) {
175
+ result.degradedMode = true;
176
+ result.degradedReason = degradedReason;
177
+ }
99
178
  if (mode === 'guide' || mode === 'full') {
100
- result.guideInspection = InspectionOrchestrator.orchestrate(data, govConfig, kb, standardRules);
179
+ const orchestrateResult = InspectionOrchestrator.orchestrate(data, govConfigTyped, kb, standardRules, {
180
+ degradedMode,
181
+ itemTypeOverride,
182
+ localTermsPath: govConfig?.localTermsPath,
183
+ degradedSimilarityThreshold: govConfig?.degradedSimilarityThreshold,
184
+ });
185
+ result.guideInspection = orchestrateResult;
186
+ if (orchestrateResult.warnings && orchestrateResult.warnings.length > 0) {
187
+ result.warnings = orchestrateResult.warnings;
188
+ }
101
189
  }
102
190
  if (mode === 'classification' || mode === 'full') {
103
- result.classification = classifyGovData(data, govConfig.gbt47949Mapping);
191
+ result.classification = classifyGovData(data, govConfigTyped.gbt47949Mapping);
104
192
  }
105
193
  return JSON.stringify(result, null, 2);
106
194
  }
@@ -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
@@ -5,9 +5,10 @@ import { FormatValidator } from './formatValidator.js';
5
5
  import { QualityMetricsCalculator } from './qualityMetricsCalculator.js';
6
6
  import { ErrorDetailBuilder } from './errorDetailBuilder.js';
7
7
  export const InspectionOrchestrator = {
8
- orchestrate(data, config, kb, standardRules) {
8
+ orchestrate(data, config, kb, standardRules, options) {
9
9
  const allErrorDetails = [];
10
10
  const allFormatIssues = [];
11
+ const allWarnings = [];
11
12
  let completeCount = 0;
12
13
  let serviceConvenience = 0;
13
14
  for (let i = 0; i < data.length; i++) {
@@ -21,8 +22,11 @@ export const InspectionOrchestrator = {
21
22
  const isComplete = missingDetails.length === 0;
22
23
  if (isComplete)
23
24
  completeCount++;
24
- const semanticDetails = SemanticRuleEngine.detect(guide, guideId, kb, config.itemTypeMatching, config.severityMapping);
25
- allErrorDetails.push(...semanticDetails);
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
+ allErrorDetails.push(...semanticResult.details);
27
+ if (semanticResult.warning) {
28
+ allWarnings.push(semanticResult.warning);
29
+ }
26
30
  const logicalDetails = LogicRuleEngine.detect(guide, guideId, standardRules, config.severityMapping);
27
31
  allErrorDetails.push(...logicalDetails);
28
32
  const formatIssues = FormatValidator.validate(guide, guideId, config.formatRules);
@@ -64,6 +68,7 @@ export const InspectionOrchestrator = {
64
68
  serviceConvenience: Math.round(serviceConvenience * 100) / 100,
65
69
  totalGuidesChecked: data.length,
66
70
  detectionRates,
71
+ warnings: allWarnings,
67
72
  };
68
73
  },
69
74
  };
@@ -71,15 +71,20 @@ export const KnowledgeBaseLoader = {
71
71
  standard: FAILED_STATUS,
72
72
  };
73
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 {
74
+ const kb = {
78
75
  timeLimits: cachedTimeLimits,
79
76
  materials: cachedMaterials,
80
77
  conditions: cachedConditions,
81
78
  dataSourceStatus: cachedDataSourceStatus,
82
79
  };
80
+ if (allFailed) {
81
+ return {
82
+ kb,
83
+ degraded: true,
84
+ degradedReason: '官方数据源全部不可用,已降级为本地规则检测模式',
85
+ };
86
+ }
87
+ return { kb, degraded: false };
83
88
  },
84
89
  getTimeLimits() {
85
90
  if (!cachedTimeLimits)
@@ -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
@@ -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,7 +1,26 @@
1
+ import { EndpointResolver } from './endpointResolver.js';
1
2
  const FAILED_RESULT = {
2
3
  data: [],
3
4
  status: { status: 'failed', fetchedAt: '', recordCount: 0 },
4
5
  };
6
+ function parseOfficialData(raw, source) {
7
+ if (!Array.isArray(raw))
8
+ return [];
9
+ return raw
10
+ .filter((item) => item && typeof item === 'object')
11
+ .map((item) => ({
12
+ dataSource: source,
13
+ itemType: String(item.itemType ?? ''),
14
+ legalUpperLimit: item.legalUpperLimit,
15
+ legalLowerLimit: item.legalLowerLimit,
16
+ standardName: item.standardName,
17
+ isRequired: item.isRequired,
18
+ elementName: item.elementName,
19
+ standardValue: item.standardValue,
20
+ standardClause: String(item.standardClause ?? ''),
21
+ }))
22
+ .filter((d) => d.itemType && d.standardClause);
23
+ }
5
24
  export const NationalDataSource = {
6
25
  async fetch(credentials) {
7
26
  const apiKey = credentials?.apiKey ?? process.env.NATIONAL_API_KEY;
@@ -9,12 +28,23 @@ export const NationalDataSource = {
9
28
  if (!apiKey || !endpoint) {
10
29
  return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
11
30
  }
12
- if (!endpoint.startsWith('https://')) {
31
+ const resolved = EndpointResolver.resolve(endpoint);
32
+ if (!resolved) {
13
33
  return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
14
34
  }
15
35
  try {
16
- const url = new URL(endpoint);
17
- url.searchParams.set('apiKey', apiKey);
36
+ if (resolved.kind === 'file') {
37
+ const { readFileSync } = require('node:fs');
38
+ const raw = JSON.parse(readFileSync(resolved.target, 'utf-8'));
39
+ const data = parseOfficialData(raw, 'national');
40
+ return {
41
+ data,
42
+ status: { status: 'success', fetchedAt: new Date().toISOString(), recordCount: data.length },
43
+ };
44
+ }
45
+ const url = new URL(resolved.target);
46
+ if (apiKey)
47
+ url.searchParams.set('apiKey', apiKey);
18
48
  const response = await fetch(url.toString(), {
19
49
  method: 'GET',
20
50
  headers: { 'Accept': 'application/json' },
@@ -23,22 +53,7 @@ export const NationalDataSource = {
23
53
  if (!response.ok)
24
54
  return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
25
55
  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);
56
+ const data = parseOfficialData(raw, 'national');
42
57
  return {
43
58
  data,
44
59
  status: { status: 'success', fetchedAt: new Date().toISOString(), recordCount: data.length },
@@ -1,7 +1,26 @@
1
+ import { EndpointResolver } from './endpointResolver.js';
1
2
  const FAILED_RESULT = {
2
3
  data: [],
3
4
  status: { status: 'failed', fetchedAt: '', recordCount: 0 },
4
5
  };
6
+ function parseOfficialData(raw, source) {
7
+ if (!Array.isArray(raw))
8
+ return [];
9
+ return raw
10
+ .filter((item) => item && typeof item === 'object')
11
+ .map((item) => ({
12
+ dataSource: source,
13
+ itemType: String(item.itemType ?? ''),
14
+ legalUpperLimit: item.legalUpperLimit,
15
+ legalLowerLimit: item.legalLowerLimit,
16
+ standardName: item.standardName,
17
+ isRequired: item.isRequired,
18
+ elementName: item.elementName,
19
+ standardValue: item.standardValue,
20
+ standardClause: String(item.standardClause ?? ''),
21
+ }))
22
+ .filter((d) => d.itemType && d.standardClause);
23
+ }
5
24
  export const ProvincialDataSource = {
6
25
  async fetch(credentials) {
7
26
  const apiKey = credentials?.apiKey ?? process.env.PROVINCIAL_API_KEY;
@@ -9,12 +28,23 @@ export const ProvincialDataSource = {
9
28
  if (!apiKey || !endpoint) {
10
29
  return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
11
30
  }
12
- if (!endpoint.startsWith('https://')) {
31
+ const resolved = EndpointResolver.resolve(endpoint);
32
+ if (!resolved) {
13
33
  return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
14
34
  }
15
35
  try {
16
- const url = new URL(endpoint);
17
- url.searchParams.set('apiKey', apiKey);
36
+ if (resolved.kind === 'file') {
37
+ const { readFileSync } = require('node:fs');
38
+ const raw = JSON.parse(readFileSync(resolved.target, 'utf-8'));
39
+ const data = parseOfficialData(raw, 'provincial');
40
+ return {
41
+ data,
42
+ status: { status: 'success', fetchedAt: new Date().toISOString(), recordCount: data.length },
43
+ };
44
+ }
45
+ const url = new URL(resolved.target);
46
+ if (apiKey)
47
+ url.searchParams.set('apiKey', apiKey);
18
48
  const response = await fetch(url.toString(), {
19
49
  method: 'GET',
20
50
  headers: { 'Accept': 'application/json' },
@@ -23,22 +53,7 @@ export const ProvincialDataSource = {
23
53
  if (!response.ok)
24
54
  return { ...FAILED_RESULT, status: { ...FAILED_RESULT.status } };
25
55
  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);
56
+ const data = parseOfficialData(raw, 'provincial');
42
57
  return {
43
58
  data,
44
59
  status: { status: 'success', fetchedAt: new Date().toISOString(), recordCount: data.length },
@@ -1,16 +1,36 @@
1
1
  import { ErrorDetailBuilder } from './errorDetailBuilder.js';
2
- function matchItemType(guide, itemTypeMatching) {
2
+ import { UnmatchedWarningBuilder } from './unmatchedWarningBuilder.js';
3
+ import { LocalTermsLoader } from './local-terms-loader.js';
4
+ const FLOW_FIELD_KEYS = ['办理流程', '工作流程', '流程描述', '业务流程', '办理环节'];
5
+ function matchItemType(guide, itemTypeMatching, options) {
6
+ if (options?.itemTypeOverride) {
7
+ return { itemType: options.itemTypeOverride, matchSource: 'manual' };
8
+ }
3
9
  if (!itemTypeMatching)
4
- return null;
10
+ return { itemType: null, matchSource: null };
5
11
  const itemName = String(guide['事项名称'] ?? '');
6
12
  const itemCode = String(guide['事项编码'] ?? '');
7
13
  for (const [itemType, rule] of Object.entries(itemTypeMatching)) {
8
- if (rule.keywords.some((kw) => itemName.includes(kw)))
9
- return itemType;
10
- if (itemCode && itemCode.startsWith(rule.codePrefix))
11
- return itemType;
14
+ if (rule.keywords.some((kw) => itemName.includes(kw))) {
15
+ return { itemType, matchSource: 'name' };
16
+ }
17
+ }
18
+ for (const [itemType, rule] of Object.entries(itemTypeMatching)) {
19
+ if (itemCode && itemCode.startsWith(rule.codePrefix)) {
20
+ return { itemType, matchSource: 'code' };
21
+ }
22
+ }
23
+ for (const flowKey of FLOW_FIELD_KEYS) {
24
+ const flowText = String(guide[flowKey] ?? '');
25
+ if (!flowText)
26
+ continue;
27
+ for (const [itemType, rule] of Object.entries(itemTypeMatching)) {
28
+ if (rule.keywords.some((kw) => flowText.includes(kw))) {
29
+ return { itemType, matchSource: 'flow' };
30
+ }
31
+ }
12
32
  }
13
- return null;
33
+ return { itemType: null, matchSource: null };
14
34
  }
15
35
  function extractTimeLimitDays(timeLimit) {
16
36
  const str = String(timeLimit ?? '');
@@ -141,18 +161,60 @@ function detectConditions(guide, guideId, itemType, kb, severityMapping) {
141
161
  return details;
142
162
  }
143
163
  export const SemanticRuleEngine = {
144
- detect(guide, guideId, kb, itemTypeMatching, severityMapping) {
164
+ detect(guide, guideId, kb, itemTypeMatching, severityMapping, options) {
165
+ const matchResult = matchItemType(guide, itemTypeMatching, options);
166
+ const itemType = matchResult.itemType;
167
+ if (!itemType) {
168
+ const warning = UnmatchedWarningBuilder.build(guideId);
169
+ return { details: [], unmatched: true, warning };
170
+ }
145
171
  if (!kb || !kb.timeLimits || !kb.materials || !kb.conditions) {
172
+ if (options?.degradedMode) {
173
+ return { details: [] };
174
+ }
146
175
  throw new Error('GOV_DATA_KB_MISSING: 标准知识库不可用,语义错误检测已中止');
147
176
  }
148
- const itemType = matchItemType(guide, itemTypeMatching);
149
- if (!itemType)
150
- return [];
177
+ const hasKbData = kb.timeLimits.length > 0 || kb.materials.length > 0 || kb.conditions.length > 0;
178
+ if (!hasKbData && options?.degradedMode) {
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 };
212
+ }
151
213
  const details = [];
152
214
  details.push(...detectTimeLimit(guide, guideId, itemType, kb, severityMapping));
153
215
  details.push(...detectMaterials(guide, guideId, itemType, kb, severityMapping));
154
216
  details.push(...detectConditions(guide, guideId, itemType, kb, severityMapping));
155
- return details;
217
+ return { details };
156
218
  },
157
219
  matchItemType,
158
220
  extractTimeLimitDays,
@@ -0,0 +1,10 @@
1
+ export const UnmatchedWarningBuilder = {
2
+ build(guideId) {
3
+ return {
4
+ type: 'UNMATCHED_ITEM_TYPE',
5
+ guideId,
6
+ message: '该事项未匹配到检测规则,语义检测被跳过',
7
+ };
8
+ },
9
+ };
10
+ //# sourceMappingURL=unmatchedWarningBuilder.js.map
@@ -0,0 +1,28 @@
1
+ export const UrlReachabilityChecker = {
2
+ async check(dataSource) {
3
+ if (!dataSource || typeof dataSource !== 'string') {
4
+ return { reachable: false, error: '数据源路径为空' };
5
+ }
6
+ if (dataSource.startsWith('http://') || dataSource.startsWith('https://')) {
7
+ try {
8
+ const response = await fetch(dataSource, {
9
+ method: 'HEAD',
10
+ signal: AbortSignal.timeout(5000),
11
+ });
12
+ if (response.ok) {
13
+ return { reachable: true };
14
+ }
15
+ return { reachable: false, error: `数据源不可达,HTTP状态码: ${response.status}` };
16
+ }
17
+ catch (e) {
18
+ return { reachable: false, error: `数据源不可达: ${e.message}` };
19
+ }
20
+ }
21
+ const { existsSync } = require('node:fs');
22
+ if (existsSync(dataSource)) {
23
+ return { reachable: true };
24
+ }
25
+ return { reachable: false, error: '数据源不可达,请检查路径或 URL' };
26
+ },
27
+ };
28
+ //# sourceMappingURL=urlReachabilityChecker.js.map