@lzhzzzzwill/cofos 1.0.0 → 1.0.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.
@@ -0,0 +1,387 @@
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()