@lzhzzzzwill/cofos 1.0.4 → 1.1.1
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 +36 -10
- package/RUNTIME.md +38 -0
- package/bin/cofos.js +82 -20
- package/package.json +13 -5
- package/requirements.txt +0 -3
- package/runtime/config/config.yaml +73 -260
- package/runtime/config/prompt_templates.yaml +73 -369
- package/runtime/src/data_processing/__init__.py +2 -27
- package/runtime/src/data_processing/utils.py +22 -1
- package/runtime/src/inference/__init__.py +2 -6
- package/runtime/src/inference/query_router.py +23 -11
- package/runtime/src/inference/student_adapter_inference.py +312 -61
- package/runtime/src/retrieval/__init__.py +14 -7
- package/runtime/src/retrieval/retrieve_evidence.py +84 -25
- package/scripts/chat.py +112 -29
- package/scripts/sync_runtime.py +45 -0
- package/runtime/config/qwen3_5_config.md +0 -46
- package/runtime/src/data_processing/clean_json_records.py +0 -387
- package/runtime/src/data_processing/parse_wos_xlsx.py +0 -255
- package/runtime/src/data_processing/schema_utils.py +0 -56
- package/runtime/src/inference/run_kg_grounded_inference.py +0 -370
- package/runtime/src/retrieval/build_faiss_index.py +0 -328
|
@@ -1,387 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
COF-ROS-Reasoner: 人工 JSON 清洗脚本
|
|
4
|
-
|
|
5
|
-
目标:
|
|
6
|
-
- 把已有人工 JSON 统一成标准字段
|
|
7
|
-
- 检查 JSON 合法性
|
|
8
|
-
- 统一字段名
|
|
9
|
-
- 标准化 ROS / product 名称
|
|
10
|
-
- 保存坏 JSON 到 error log
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import os
|
|
14
|
-
import json
|
|
15
|
-
import logging
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
from typing import Dict, List, Any, Optional, Tuple
|
|
18
|
-
from datetime import datetime
|
|
19
|
-
|
|
20
|
-
# 添加 src 路径
|
|
21
|
-
|
|
22
|
-
from data_processing.utils import setup_logging
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class JSONCleaner:
|
|
26
|
-
"""清洗和标准化人工标注的 JSON 数据"""
|
|
27
|
-
|
|
28
|
-
def __init__(self, config: Dict[str, Any]):
|
|
29
|
-
self.config = config
|
|
30
|
-
self.ros_normalization = config.get('data_processing', {}).get('ros_normalization', {})
|
|
31
|
-
self.raw_json_path = Path(config['paths']['raw_json'])
|
|
32
|
-
self.output_path = Path(config['paths']['processed_dir']) / 'json_records_cleaned.jsonl'
|
|
33
|
-
self.error_log_path = Path(config['paths']['outputs_dir']) / 'logs' / 'json_cleaning_errors.log'
|
|
34
|
-
|
|
35
|
-
# 设置日志
|
|
36
|
-
setup_logging(config)
|
|
37
|
-
|
|
38
|
-
# 常用字段映射
|
|
39
|
-
self.field_mappings = {
|
|
40
|
-
'dominant_ros': 'dominant_ros_or_product',
|
|
41
|
-
'secondary_ros': 'secondary_or_intermediate_ros',
|
|
42
|
-
'intermediates': 'mechanism_intermediates',
|
|
43
|
-
'reaction_conditions': 'reaction_environment',
|
|
44
|
-
'material': 'material_context',
|
|
45
|
-
'mechanism_path': 'mechanism',
|
|
46
|
-
'reaction_conditions': 'reaction_environment',
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
# 验证字段
|
|
50
|
-
self.required_fields = {
|
|
51
|
-
'material_context',
|
|
52
|
-
'reaction_environment',
|
|
53
|
-
'dominant_ros_or_product',
|
|
54
|
-
'secondary_or_intermediate_ros',
|
|
55
|
-
'mechanism_intermediates',
|
|
56
|
-
'mechanism'
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
self.material_context_fields = {'material_type', 'active_site', 'structural_feature'}
|
|
60
|
-
self.reaction_environment_fields = {'phase', 'oxidant_source', 'driving_force', 'notes'}
|
|
61
|
-
self.ros_fields = {'species', 'category', 'role', 'basis', 'evidence'}
|
|
62
|
-
self.secondary_ros_fields = {'species', 'role', 'basis', 'evidence'}
|
|
63
|
-
self.intermediate_fields = {'species', 'notation_in_paper', 'role', 'evidence'}
|
|
64
|
-
|
|
65
|
-
def safe_str(self, value: Any) -> str:
|
|
66
|
-
"""Convert arbitrary JSON values to reviewable strings."""
|
|
67
|
-
if value is None:
|
|
68
|
-
return ""
|
|
69
|
-
if isinstance(value, str):
|
|
70
|
-
return value.strip()
|
|
71
|
-
if isinstance(value, (int, float, bool)):
|
|
72
|
-
return str(value)
|
|
73
|
-
return json.dumps(value, ensure_ascii=False)
|
|
74
|
-
|
|
75
|
-
def normalize_ros_name(self, name: str) -> str:
|
|
76
|
-
"""标准化 ROS 名称"""
|
|
77
|
-
if not name:
|
|
78
|
-
return ""
|
|
79
|
-
|
|
80
|
-
name = name.strip()
|
|
81
|
-
|
|
82
|
-
# 直接匹配
|
|
83
|
-
if name in self.ros_normalization:
|
|
84
|
-
return self.ros_normalization[name]
|
|
85
|
-
|
|
86
|
-
# 忽略大小写匹配
|
|
87
|
-
name_lower = name.lower()
|
|
88
|
-
for key, value in self.ros_normalization.items():
|
|
89
|
-
if key.lower() == name_lower:
|
|
90
|
-
return value
|
|
91
|
-
|
|
92
|
-
# 返回原名称(如果无法标准化)
|
|
93
|
-
return name
|
|
94
|
-
|
|
95
|
-
def is_valid_ros_name(self, name: str) -> bool:
|
|
96
|
-
"""检查 ROS 名称是否有效(可以被标准化或本身就是有效名称)"""
|
|
97
|
-
if not name:
|
|
98
|
-
return False
|
|
99
|
-
|
|
100
|
-
normalized = self.normalize_ros_name(name)
|
|
101
|
-
return normalized != ""
|
|
102
|
-
|
|
103
|
-
def clean_material_context(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
104
|
-
"""清洗 material_context 字段"""
|
|
105
|
-
material = {}
|
|
106
|
-
|
|
107
|
-
# 处理可能的字段名
|
|
108
|
-
raw_material = data.get('material_context', data.get('material', {}))
|
|
109
|
-
|
|
110
|
-
if isinstance(raw_material, dict):
|
|
111
|
-
for field in self.material_context_fields:
|
|
112
|
-
value = raw_material.get(field, '')
|
|
113
|
-
material[field] = self.safe_str(value)
|
|
114
|
-
|
|
115
|
-
return material
|
|
116
|
-
|
|
117
|
-
def clean_reaction_environment(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
118
|
-
"""清洗 reaction_environment 字段"""
|
|
119
|
-
env = {}
|
|
120
|
-
|
|
121
|
-
# 处理可能的字段名
|
|
122
|
-
raw_env = data.get('reaction_environment', data.get('reaction_conditions', data.get('conditions', {})))
|
|
123
|
-
|
|
124
|
-
if isinstance(raw_env, dict):
|
|
125
|
-
for field in self.reaction_environment_fields:
|
|
126
|
-
value = raw_env.get(field, '')
|
|
127
|
-
env[field] = self.safe_str(value)
|
|
128
|
-
|
|
129
|
-
return env
|
|
130
|
-
|
|
131
|
-
def clean_ros_list(self, ros_list: List[Dict[str, Any]], is_dominant: bool = True) -> List[Dict[str, Any]]:
|
|
132
|
-
"""清洗 ROS 列表"""
|
|
133
|
-
if not isinstance(ros_list, list):
|
|
134
|
-
return []
|
|
135
|
-
|
|
136
|
-
cleaned = []
|
|
137
|
-
fields = self.ros_fields if is_dominant else self.secondary_ros_fields
|
|
138
|
-
|
|
139
|
-
for item in ros_list:
|
|
140
|
-
if not isinstance(item, dict):
|
|
141
|
-
continue
|
|
142
|
-
|
|
143
|
-
cleaned_item = {}
|
|
144
|
-
for field in fields:
|
|
145
|
-
value = item.get(field, '')
|
|
146
|
-
cleaned_item[field] = self.safe_str(value)
|
|
147
|
-
|
|
148
|
-
# 标准化 species
|
|
149
|
-
if 'species' in cleaned_item:
|
|
150
|
-
cleaned_item['species'] = self.normalize_ros_name(cleaned_item['species'])
|
|
151
|
-
|
|
152
|
-
# 如果是 dominant ROS,添加 category 字段(如果没有)
|
|
153
|
-
if is_dominant and 'category' not in cleaned_item:
|
|
154
|
-
species = cleaned_item.get('species', '').lower()
|
|
155
|
-
# H2O2 在 2e- ORR 中应为 oxygen-derived target product
|
|
156
|
-
if 'h2o2' in species or 'hydrogen peroxide' in species:
|
|
157
|
-
cleaned_item['category'] = 'oxygen-derived target product'
|
|
158
|
-
else:
|
|
159
|
-
cleaned_item['category'] = 'radical ROS'
|
|
160
|
-
|
|
161
|
-
cleaned.append(cleaned_item)
|
|
162
|
-
|
|
163
|
-
return cleaned
|
|
164
|
-
|
|
165
|
-
def clean_mechanism_intermediates(self, intermediates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
166
|
-
"""清洗 mechanism_intermediates 字段"""
|
|
167
|
-
if not isinstance(intermediates, list):
|
|
168
|
-
return []
|
|
169
|
-
|
|
170
|
-
cleaned = []
|
|
171
|
-
for item in intermediates:
|
|
172
|
-
if not isinstance(item, dict):
|
|
173
|
-
continue
|
|
174
|
-
|
|
175
|
-
cleaned_item = {}
|
|
176
|
-
for field in self.intermediate_fields:
|
|
177
|
-
value = item.get(field, '')
|
|
178
|
-
cleaned_item[field] = self.safe_str(value)
|
|
179
|
-
|
|
180
|
-
# 标准化 species
|
|
181
|
-
if 'species' in cleaned_item:
|
|
182
|
-
cleaned_item['species'] = self.normalize_ros_name(cleaned_item['species'])
|
|
183
|
-
|
|
184
|
-
cleaned.append(cleaned_item)
|
|
185
|
-
|
|
186
|
-
return cleaned
|
|
187
|
-
|
|
188
|
-
def clean_mechanism(self, mechanism: Any) -> List[str]:
|
|
189
|
-
"""清洗 mechanism 字段"""
|
|
190
|
-
if isinstance(mechanism, list):
|
|
191
|
-
return [self.safe_str(step) for step in mechanism if self.safe_str(step)]
|
|
192
|
-
elif isinstance(mechanism, str):
|
|
193
|
-
if mechanism.strip():
|
|
194
|
-
return [mechanism.strip()]
|
|
195
|
-
elif mechanism:
|
|
196
|
-
return [self.safe_str(mechanism)]
|
|
197
|
-
return []
|
|
198
|
-
|
|
199
|
-
def validate_record(self, record: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
|
200
|
-
"""验证清洗后的记录"""
|
|
201
|
-
errors = []
|
|
202
|
-
|
|
203
|
-
# 检查 required fields
|
|
204
|
-
for field in self.required_fields:
|
|
205
|
-
if field not in record:
|
|
206
|
-
errors.append(f"Missing required field: {field}")
|
|
207
|
-
|
|
208
|
-
# 检查 material_context
|
|
209
|
-
material = record.get('material_context', {})
|
|
210
|
-
if not material.get('material_type'):
|
|
211
|
-
errors.append("material_context.material_type is empty")
|
|
212
|
-
|
|
213
|
-
# 检查 reaction_environment
|
|
214
|
-
env = record.get('reaction_environment', {})
|
|
215
|
-
if not env.get('phase'):
|
|
216
|
-
errors.append("reaction_environment.phase is empty")
|
|
217
|
-
|
|
218
|
-
# 检查至少有一个 dominant ROS
|
|
219
|
-
dominant = record.get('dominant_ros_or_product', [])
|
|
220
|
-
if not dominant:
|
|
221
|
-
errors.append("dominant_ros_or_product is empty")
|
|
222
|
-
|
|
223
|
-
# 检查 species 格式
|
|
224
|
-
for item in dominant:
|
|
225
|
-
species = item.get('species', '')
|
|
226
|
-
if species and not self.is_valid_ros_name(species):
|
|
227
|
-
errors.append(f"Invalid ROS species: {species}")
|
|
228
|
-
|
|
229
|
-
return len(errors) == 0, errors
|
|
230
|
-
|
|
231
|
-
def clean_record(self, record: Dict[str, Any]) -> Dict[str, Any]:
|
|
232
|
-
"""清洗单个 JSON 记录"""
|
|
233
|
-
cleaned = {}
|
|
234
|
-
|
|
235
|
-
# 清洗各个字段
|
|
236
|
-
cleaned['material_context'] = self.clean_material_context(record)
|
|
237
|
-
cleaned['reaction_environment'] = self.clean_reaction_environment(record)
|
|
238
|
-
cleaned['dominant_ros_or_product'] = self.clean_ros_list(
|
|
239
|
-
record.get('dominant_ros_or_product', record.get('dominant_ros', [])),
|
|
240
|
-
is_dominant=True
|
|
241
|
-
)
|
|
242
|
-
cleaned['secondary_or_intermediate_ros'] = self.clean_ros_list(
|
|
243
|
-
record.get('secondary_or_intermediate_ros', record.get('secondary_ros', [])),
|
|
244
|
-
is_dominant=False
|
|
245
|
-
)
|
|
246
|
-
cleaned['mechanism_intermediates'] = self.clean_mechanism_intermediates(
|
|
247
|
-
record.get('mechanism_intermediates', [])
|
|
248
|
-
)
|
|
249
|
-
cleaned['mechanism'] = self.clean_mechanism(record.get('mechanism', []))
|
|
250
|
-
|
|
251
|
-
# 添加元数据
|
|
252
|
-
cleaned['_source'] = self.safe_str(record.get('_source', 'unknown'))
|
|
253
|
-
cleaned['_cleaned_at'] = datetime.now().isoformat()
|
|
254
|
-
|
|
255
|
-
return cleaned
|
|
256
|
-
|
|
257
|
-
def process_file(self, file_path: Path) -> Tuple[int, int]:
|
|
258
|
-
"""处理单个 JSON 文件"""
|
|
259
|
-
success_count = 0
|
|
260
|
-
error_count = 0
|
|
261
|
-
|
|
262
|
-
try:
|
|
263
|
-
with open(file_path, 'r', encoding='utf-8') as f:
|
|
264
|
-
content = f.read()
|
|
265
|
-
|
|
266
|
-
# 尝试解析 JSON
|
|
267
|
-
try:
|
|
268
|
-
data = json.loads(content)
|
|
269
|
-
except json.JSONDecodeError:
|
|
270
|
-
# 可能是 JSONL 格式
|
|
271
|
-
lines = content.strip().split('\n')
|
|
272
|
-
for line in lines:
|
|
273
|
-
if line.strip():
|
|
274
|
-
try:
|
|
275
|
-
data = json.loads(line)
|
|
276
|
-
self.process_single_record(data, file_path)
|
|
277
|
-
success_count += 1
|
|
278
|
-
except json.JSONDecodeError as e:
|
|
279
|
-
self._log_error(file_path, f"Invalid JSON: {e}")
|
|
280
|
-
error_count += 1
|
|
281
|
-
return success_count, error_count
|
|
282
|
-
|
|
283
|
-
# 处理单个记录或记录列表
|
|
284
|
-
if isinstance(data, list):
|
|
285
|
-
for record in data:
|
|
286
|
-
self.process_single_record(record, file_path)
|
|
287
|
-
success_count += 1
|
|
288
|
-
else:
|
|
289
|
-
self.process_single_record(data, file_path)
|
|
290
|
-
success_count += 1
|
|
291
|
-
|
|
292
|
-
except Exception as e:
|
|
293
|
-
self._log_error(file_path, str(e))
|
|
294
|
-
error_count += 1
|
|
295
|
-
|
|
296
|
-
return success_count, error_count
|
|
297
|
-
|
|
298
|
-
def process_single_record(self, record: Dict[str, Any], source_file: str = ""):
|
|
299
|
-
"""处理单个记录"""
|
|
300
|
-
try:
|
|
301
|
-
# 添加源文件信息
|
|
302
|
-
if source_file:
|
|
303
|
-
record['_source'] = str(source_file)
|
|
304
|
-
|
|
305
|
-
# 清洗记录
|
|
306
|
-
cleaned = self.clean_record(record)
|
|
307
|
-
|
|
308
|
-
# 验证
|
|
309
|
-
is_valid, errors = self.validate_record(cleaned)
|
|
310
|
-
|
|
311
|
-
if is_valid:
|
|
312
|
-
# 写入输出文件
|
|
313
|
-
with open(self.output_path, 'a', encoding='utf-8') as f:
|
|
314
|
-
f.write(json.dumps(cleaned, ensure_ascii=False) + '\n')
|
|
315
|
-
else:
|
|
316
|
-
# 记录错误
|
|
317
|
-
error_msg = f"Validation errors in {source_file}: {errors}"
|
|
318
|
-
self._log_error(source_file, error_msg)
|
|
319
|
-
|
|
320
|
-
except Exception as e:
|
|
321
|
-
self._log_error(source_file, str(e))
|
|
322
|
-
|
|
323
|
-
def _log_error(self, source: str, message: str):
|
|
324
|
-
"""记录错误到日志文件"""
|
|
325
|
-
error_entry = {
|
|
326
|
-
'timestamp': datetime.now().isoformat(),
|
|
327
|
-
'source': str(source),
|
|
328
|
-
'error': message
|
|
329
|
-
}
|
|
330
|
-
with open(self.error_log_path, 'a', encoding='utf-8') as f:
|
|
331
|
-
f.write(json.dumps(error_entry, ensure_ascii=False) + '\n')
|
|
332
|
-
logging.error(f"Error processing {source}: {message}")
|
|
333
|
-
|
|
334
|
-
def run(self):
|
|
335
|
-
"""运行清洗流程"""
|
|
336
|
-
logging.info(f"Starting JSON cleaning from {self.raw_json_path}")
|
|
337
|
-
logging.info(f"Output will be saved to {self.output_path}")
|
|
338
|
-
|
|
339
|
-
# 清空输出文件
|
|
340
|
-
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
341
|
-
self.error_log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
342
|
-
self.output_path.write_text('', encoding='utf-8')
|
|
343
|
-
self.error_log_path.write_text('', encoding='utf-8')
|
|
344
|
-
|
|
345
|
-
total_success = 0
|
|
346
|
-
total_error = 0
|
|
347
|
-
|
|
348
|
-
# 处理所有 JSON 文件
|
|
349
|
-
for file_path in self.raw_json_path.glob('*.json'):
|
|
350
|
-
logging.info(f"Processing {file_path.name}")
|
|
351
|
-
success, error = self.process_file(file_path)
|
|
352
|
-
total_success += success
|
|
353
|
-
total_error += error
|
|
354
|
-
|
|
355
|
-
# 处理 JSONL 文件
|
|
356
|
-
for file_path in self.raw_json_path.glob('*.jsonl'):
|
|
357
|
-
logging.info(f"Processing {file_path.name}")
|
|
358
|
-
success, error = self.process_file(file_path)
|
|
359
|
-
total_success += success
|
|
360
|
-
total_error += error
|
|
361
|
-
|
|
362
|
-
logging.info(f"JSON cleaning completed: {total_success} records cleaned, {total_error} errors")
|
|
363
|
-
logging.info(f"Cleaned records saved to {self.output_path}")
|
|
364
|
-
logging.info(f"Errors logged to {self.error_log_path}")
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def main():
|
|
368
|
-
"""主函数"""
|
|
369
|
-
import argparse
|
|
370
|
-
|
|
371
|
-
parser = argparse.ArgumentParser(description='Clean and normalize COF-ROS JSON records')
|
|
372
|
-
parser.add_argument('--config', type=str, default='config/config.yaml',
|
|
373
|
-
help='Path to config file')
|
|
374
|
-
args = parser.parse_args()
|
|
375
|
-
|
|
376
|
-
# 加载配置
|
|
377
|
-
config_path = Path(args.config)
|
|
378
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
379
|
-
config = yaml.safe_load(f)
|
|
380
|
-
|
|
381
|
-
# 运行清洗
|
|
382
|
-
cleaner = JSONCleaner(config)
|
|
383
|
-
cleaner.run()
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if __name__ == '__main__':
|
|
387
|
-
main()
|
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
COF-ROS-Reasoner: WoS 摘要解析脚本
|
|
4
|
-
|
|
5
|
-
目标:
|
|
6
|
-
- 从 WoS xlsx 中提取摘要、题名、DOI、年份、期刊等信息
|
|
7
|
-
- 输出为标准化的 JSONL 格式
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
import json
|
|
12
|
-
import yaml
|
|
13
|
-
import logging
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Dict, List, Any, Optional
|
|
16
|
-
from datetime import datetime
|
|
17
|
-
import math
|
|
18
|
-
|
|
19
|
-
import pandas as pd
|
|
20
|
-
|
|
21
|
-
# 添加 src 路径
|
|
22
|
-
|
|
23
|
-
from data_processing.utils import setup_logging, ensure_directory
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class WoSParser:
|
|
27
|
-
"""解析 WoS 导出的 XLSX 文件"""
|
|
28
|
-
|
|
29
|
-
def __init__(self, config: Dict[str, Any]):
|
|
30
|
-
self.config = config
|
|
31
|
-
self.raw_wos_path = Path(config['paths']['raw_wos'])
|
|
32
|
-
self.output_path = Path(config['paths']['processed_dir']) / 'wos_abstract_chunks.jsonl'
|
|
33
|
-
|
|
34
|
-
# 设置日志
|
|
35
|
-
setup_logging(config)
|
|
36
|
-
|
|
37
|
-
# 保留字段映射
|
|
38
|
-
self.field_mappings = {
|
|
39
|
-
'Article Title': 'title',
|
|
40
|
-
'Abstract': 'abstract',
|
|
41
|
-
'Abstract Note': 'abstract',
|
|
42
|
-
'DOI': 'doi',
|
|
43
|
-
'Publication Year': 'year',
|
|
44
|
-
'Year Published': 'year',
|
|
45
|
-
'PY': 'year',
|
|
46
|
-
'Source Title': 'source_title',
|
|
47
|
-
'Publication Name': 'source_title',
|
|
48
|
-
'Author Keywords': 'author_keywords',
|
|
49
|
-
'Author Keywords (DE)': 'author_keywords',
|
|
50
|
-
'Keywords Plus': 'keywords_plus',
|
|
51
|
-
'Keywords Plus (ID)': 'keywords_plus',
|
|
52
|
-
'UT': 'ut', # WOS Unique ID
|
|
53
|
-
'WOS Unique ID': 'ut',
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
# 需要解析的关键词字段
|
|
57
|
-
self.keyword_fields = ['Author Keywords', 'Keywords Plus']
|
|
58
|
-
|
|
59
|
-
# ROS 关键词(用于筛选)
|
|
60
|
-
self.ros_keywords = config.get('data_processing', {}).get('ros_keywords', [])
|
|
61
|
-
|
|
62
|
-
def normalize_column_key(self, value: Any) -> str:
|
|
63
|
-
"""Normalize a column name for matching."""
|
|
64
|
-
return ' '.join(self.safe_str(value).lower().split())
|
|
65
|
-
|
|
66
|
-
def normalize_dataframe_columns(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
67
|
-
"""Normalize common WoS column names while preserving unknown columns."""
|
|
68
|
-
rename_map = {}
|
|
69
|
-
normalized_targets = set()
|
|
70
|
-
normalized_mapping = {
|
|
71
|
-
self.normalize_column_key(source): target
|
|
72
|
-
for source, target in self.field_mappings.items()
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
for column in df.columns:
|
|
76
|
-
raw = self.safe_str(column)
|
|
77
|
-
target = self.field_mappings.get(raw)
|
|
78
|
-
if not target:
|
|
79
|
-
target = self.field_mappings.get(raw.strip())
|
|
80
|
-
if not target:
|
|
81
|
-
target = normalized_mapping.get(self.normalize_column_key(raw))
|
|
82
|
-
|
|
83
|
-
if target and target not in normalized_targets:
|
|
84
|
-
rename_map[column] = target
|
|
85
|
-
normalized_targets.add(target)
|
|
86
|
-
|
|
87
|
-
normalized = df.rename(columns=rename_map)
|
|
88
|
-
logging.info(f"WoS columns: {list(df.columns)}")
|
|
89
|
-
logging.info(f"Normalized WoS columns: {list(normalized.columns)}")
|
|
90
|
-
return normalized
|
|
91
|
-
|
|
92
|
-
def normalize_field_name(self, field: str) -> str:
|
|
93
|
-
"""标准化字段名"""
|
|
94
|
-
field = self.safe_str(field).strip()
|
|
95
|
-
return self.field_mappings.get(field, field)
|
|
96
|
-
|
|
97
|
-
def safe_str(self, value: Any) -> str:
|
|
98
|
-
"""Convert Excel cell values to clean strings."""
|
|
99
|
-
if value is None:
|
|
100
|
-
return ""
|
|
101
|
-
|
|
102
|
-
try:
|
|
103
|
-
if pd.isna(value):
|
|
104
|
-
return ""
|
|
105
|
-
except TypeError:
|
|
106
|
-
pass
|
|
107
|
-
|
|
108
|
-
if isinstance(value, float) and math.isfinite(value) and value.is_integer():
|
|
109
|
-
return str(int(value))
|
|
110
|
-
|
|
111
|
-
return str(value).strip()
|
|
112
|
-
|
|
113
|
-
def parse_year(self, value: Any) -> Optional[int]:
|
|
114
|
-
"""Safely parse publication year."""
|
|
115
|
-
text = self.safe_str(value)
|
|
116
|
-
if not text:
|
|
117
|
-
return None
|
|
118
|
-
|
|
119
|
-
try:
|
|
120
|
-
return int(float(text))
|
|
121
|
-
except ValueError:
|
|
122
|
-
logging.warning(f"Could not parse publication year: {text}")
|
|
123
|
-
return None
|
|
124
|
-
|
|
125
|
-
def parse_keywords(self, keywords_str: Any) -> List[str]:
|
|
126
|
-
"""解析关键词字符串"""
|
|
127
|
-
keywords_str = self.safe_str(keywords_str)
|
|
128
|
-
if not keywords_str:
|
|
129
|
-
return []
|
|
130
|
-
|
|
131
|
-
# 分隔符可能是 ; 或 |
|
|
132
|
-
if ';' in keywords_str:
|
|
133
|
-
keywords = [k.strip() for k in keywords_str.split(';')]
|
|
134
|
-
elif '|' in keywords_str:
|
|
135
|
-
keywords = [k.strip() for k in keywords_str.split('|')]
|
|
136
|
-
else:
|
|
137
|
-
keywords = [keywords_str.strip()]
|
|
138
|
-
|
|
139
|
-
# 清理空值
|
|
140
|
-
return [k for k in keywords if k]
|
|
141
|
-
|
|
142
|
-
def is_ros_related(self, title: Any, abstract: Any, keywords: List[str] = None) -> bool:
|
|
143
|
-
"""判断是否与 ROS 相关"""
|
|
144
|
-
keyword_text = ' '.join(keywords or [])
|
|
145
|
-
text = f"{self.safe_str(title)} {self.safe_str(abstract)} {keyword_text}"
|
|
146
|
-
text_lower = text.lower()
|
|
147
|
-
|
|
148
|
-
for keyword in self.ros_keywords:
|
|
149
|
-
if keyword.lower() in text_lower:
|
|
150
|
-
return True
|
|
151
|
-
return False
|
|
152
|
-
|
|
153
|
-
def parse_row(self, row: pd.Series, index: int) -> Optional[Dict[str, Any]]:
|
|
154
|
-
"""解析单行数据"""
|
|
155
|
-
try:
|
|
156
|
-
# 提取基本信息
|
|
157
|
-
title = self.safe_str(row.get('title', ''))
|
|
158
|
-
abstract = self.safe_str(row.get('abstract', ''))
|
|
159
|
-
doi = self.safe_str(row.get('doi', ''))
|
|
160
|
-
year = self.parse_year(row.get('year', ''))
|
|
161
|
-
source_title = self.safe_str(row.get('source_title', ''))
|
|
162
|
-
ut = self.safe_str(row.get('ut', ''))
|
|
163
|
-
|
|
164
|
-
# 解析关键词
|
|
165
|
-
author_keywords = self.parse_keywords(row.get('author_keywords', ''))
|
|
166
|
-
keywords_plus = self.parse_keywords(row.get('keywords_plus', ''))
|
|
167
|
-
|
|
168
|
-
# 合并关键词
|
|
169
|
-
keywords = list(set(author_keywords + keywords_plus))
|
|
170
|
-
|
|
171
|
-
# 如果没有摘要,使用标题作为文本
|
|
172
|
-
if not abstract:
|
|
173
|
-
text = title
|
|
174
|
-
else:
|
|
175
|
-
text = abstract
|
|
176
|
-
|
|
177
|
-
# 构建记录
|
|
178
|
-
record = {
|
|
179
|
-
'doc_id': f"WOS:{ut}" if ut else f"WOS:index_{index}",
|
|
180
|
-
'doi': doi,
|
|
181
|
-
'title': title,
|
|
182
|
-
'source_type': 'wos_abstract',
|
|
183
|
-
'year': year,
|
|
184
|
-
'journal': source_title,
|
|
185
|
-
'text': text.strip(),
|
|
186
|
-
'keywords': keywords,
|
|
187
|
-
'is_ros_related': self.is_ros_related(title, abstract, keywords)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return record
|
|
191
|
-
|
|
192
|
-
except Exception as e:
|
|
193
|
-
logging.error(f"Error parsing row {index}: {e}")
|
|
194
|
-
return None
|
|
195
|
-
|
|
196
|
-
def run(self):
|
|
197
|
-
"""运行解析流程"""
|
|
198
|
-
logging.info(f"Starting WoS parsing from {self.raw_wos_path}")
|
|
199
|
-
logging.info(f"Output will be saved to {self.output_path}")
|
|
200
|
-
|
|
201
|
-
# 检查输入文件
|
|
202
|
-
if not self.raw_wos_path.exists():
|
|
203
|
-
logging.error(f"Input file not found: {self.raw_wos_path}")
|
|
204
|
-
return
|
|
205
|
-
|
|
206
|
-
# 读取 XLSX
|
|
207
|
-
try:
|
|
208
|
-
df = pd.read_excel(self.raw_wos_path)
|
|
209
|
-
df = self.normalize_dataframe_columns(df)
|
|
210
|
-
logging.info(f"Loaded {len(df)} rows from {self.raw_wos_path}")
|
|
211
|
-
except Exception as e:
|
|
212
|
-
logging.error(f"Failed to read Excel file: {e}")
|
|
213
|
-
return
|
|
214
|
-
|
|
215
|
-
# 处理记录
|
|
216
|
-
records = []
|
|
217
|
-
for idx, row in df.iterrows():
|
|
218
|
-
record = self.parse_row(row, idx)
|
|
219
|
-
if record:
|
|
220
|
-
records.append(record)
|
|
221
|
-
|
|
222
|
-
# 保存输出
|
|
223
|
-
ensure_directory(self.output_path.parent)
|
|
224
|
-
with open(self.output_path, 'w', encoding='utf-8') as f:
|
|
225
|
-
for record in records:
|
|
226
|
-
f.write(json.dumps(record, ensure_ascii=False) + '\n')
|
|
227
|
-
|
|
228
|
-
# 统计信息
|
|
229
|
-
ros_related = sum(1 for r in records if r.get('is_ros_related', False))
|
|
230
|
-
logging.info(f"WoS parsing completed: {len(records)} records processed")
|
|
231
|
-
logging.info(f"ROS-related records: {ros_related}")
|
|
232
|
-
logging.info(f"Output saved to {self.output_path}")
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def main():
|
|
236
|
-
"""主函数"""
|
|
237
|
-
import argparse
|
|
238
|
-
|
|
239
|
-
parser = argparse.ArgumentParser(description='Parse WoS abstracts from Excel file')
|
|
240
|
-
parser.add_argument('--config', type=str, default='config/config.yaml',
|
|
241
|
-
help='Path to config file')
|
|
242
|
-
args = parser.parse_args()
|
|
243
|
-
|
|
244
|
-
# 加载配置
|
|
245
|
-
config_path = Path(args.config)
|
|
246
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
247
|
-
config = yaml.safe_load(f)
|
|
248
|
-
|
|
249
|
-
# 运行解析
|
|
250
|
-
parser = WoSParser(config)
|
|
251
|
-
parser.run()
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if __name__ == '__main__':
|
|
255
|
-
main()
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""Canonical schema-boundary normalization shared by downstream stages."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import Any, Dict, List
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class SchemaNormalizationMixin:
|
|
8
|
-
"""Normalize accepted legacy shapes once through a shared implementation."""
|
|
9
|
-
|
|
10
|
-
@staticmethod
|
|
11
|
-
def safe_str(value: Any) -> str:
|
|
12
|
-
if value is None:
|
|
13
|
-
return ""
|
|
14
|
-
if isinstance(value, str):
|
|
15
|
-
return value.strip()
|
|
16
|
-
if isinstance(value, (int, float, bool)):
|
|
17
|
-
return str(value)
|
|
18
|
-
return json.dumps(value, ensure_ascii=False)
|
|
19
|
-
|
|
20
|
-
@staticmethod
|
|
21
|
-
def as_list(value: Any) -> List[Any]:
|
|
22
|
-
if value is None:
|
|
23
|
-
return []
|
|
24
|
-
if isinstance(value, list):
|
|
25
|
-
return value
|
|
26
|
-
return [value]
|
|
27
|
-
|
|
28
|
-
def normalize_item_list(
|
|
29
|
-
self, value: Any, default_key: str = "species"
|
|
30
|
-
) -> List[Dict[str, Any]]:
|
|
31
|
-
items = []
|
|
32
|
-
for item in self.as_list(value):
|
|
33
|
-
if isinstance(item, dict):
|
|
34
|
-
items.append(item)
|
|
35
|
-
elif item:
|
|
36
|
-
items.append({default_key: self.safe_str(item)})
|
|
37
|
-
return items
|
|
38
|
-
|
|
39
|
-
def normalize_ros(self, name: Any) -> str:
|
|
40
|
-
normalized_name = self.safe_str(name)
|
|
41
|
-
if not normalized_name:
|
|
42
|
-
return ""
|
|
43
|
-
mapping = self.config.get("data_processing", {}).get(
|
|
44
|
-
"ros_normalization", {}
|
|
45
|
-
)
|
|
46
|
-
return mapping.get(
|
|
47
|
-
normalized_name,
|
|
48
|
-
mapping.get(normalized_name.lower(), normalized_name),
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
def first_species(self, value: Any) -> str:
|
|
52
|
-
for item in self.normalize_item_list(value):
|
|
53
|
-
species = self.normalize_ros(item.get("species", ""))
|
|
54
|
-
if species:
|
|
55
|
-
return species
|
|
56
|
-
return ""
|