@lzhzzzzwill/cofos 1.0.1 → 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,382 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ COF-ROS-Reasoner: BM25 索引构建脚本
4
+
5
+ 目标:
6
+ - 构建 BM25 关键词索引用于精确匹配检索
7
+ - 支持来自 JSON evidence、PDF chunks、WOS abstracts 的文本
8
+ """
9
+
10
+ import os
11
+ import json
12
+ import yaml
13
+ import logging
14
+ import pickle
15
+ from pathlib import Path
16
+ from typing import Dict, List, Any, Optional
17
+ from datetime import datetime
18
+
19
+ # 添加 src 路径
20
+
21
+ try:
22
+ from rank_bm25 import BM25Okapi
23
+ except ImportError:
24
+ BM25Okapi = None
25
+
26
+ from data_processing.utils import setup_logging, ensure_directory
27
+ from retrieval.text_search import tokenize_search_text
28
+
29
+
30
+ class BM25IndexBuilder:
31
+ """构建 BM25 索引"""
32
+
33
+ def __init__(self, config: Dict[str, Any]):
34
+ self.config = config
35
+ self.output_dir = Path(config['paths']['bm25_store'])
36
+
37
+ # 设置日志
38
+ setup_logging(config)
39
+
40
+ # BM25 索引
41
+ self.bm25 = None
42
+ self.metadata: List[Dict[str, Any]] = []
43
+
44
+ def tokenize(self, text: str) -> List[str]:
45
+ """Use the same tokenizer as query-time retrieval."""
46
+ return tokenize_search_text(text)
47
+
48
+ def build_index(self, documents: List[str], metadata: List[Dict[str, Any]]):
49
+ """构建 BM25 索引"""
50
+ if BM25Okapi is None:
51
+ logging.error("rank-bm25 not installed. Run: pip install rank-bm25")
52
+ raise ImportError("rank-bm25 required")
53
+
54
+ logging.info("Building BM25 index...")
55
+
56
+ # 分词
57
+ tokenized_docs = [self.tokenize(doc) for doc in documents]
58
+
59
+ # 创建 BM25 索引
60
+ self.bm25 = BM25Okapi(tokenized_docs)
61
+ self.metadata = metadata
62
+
63
+ logging.info(f"BM25 index built with {len(documents)} documents")
64
+
65
+ def get_schema_record(self, record: Dict[str, Any]) -> Optional[Dict[str, Any]]:
66
+ """Return Gold schema record or validated Silver extracted payload."""
67
+ extracted = record.get('extracted_json')
68
+ if isinstance(extracted, dict):
69
+ if extracted.get('status') == 'insufficient_evidence' or extracted.get('error'):
70
+ return None
71
+ payload = dict(extracted)
72
+ payload['source_chunk_id'] = record.get('chunk_id', '')
73
+ payload['evidence_level'] = record.get('evidence_level', 'low')
74
+ payload['support_status'] = record.get('support_status', 'unsupported')
75
+ return payload
76
+
77
+ if 'material_context' in record or 'dominant_ros_or_product' in record:
78
+ return record
79
+
80
+ return None
81
+
82
+ def json_record_to_text(self, record: Dict[str, Any]) -> str:
83
+ """构造 JSON 记录的 BM25 文本。"""
84
+ text_parts = []
85
+
86
+ material = record.get('material_context', {})
87
+ for key in ['material_type', 'active_site', 'structural_feature']:
88
+ if material.get(key):
89
+ text_parts.append(material[key])
90
+
91
+ reaction_env = record.get('reaction_environment', {})
92
+ for key in ['phase', 'oxidant_source', 'driving_force', 'notes']:
93
+ if reaction_env.get(key):
94
+ text_parts.append(reaction_env[key])
95
+
96
+ for field in ['dominant_ros_or_product', 'secondary_or_intermediate_ros', 'mechanism_intermediates']:
97
+ for item in record.get(field, []):
98
+ if not isinstance(item, dict):
99
+ continue
100
+ for key in ['species', 'category', 'role', 'basis', 'evidence', 'notation_in_paper']:
101
+ if item.get(key):
102
+ text_parts.append(item[key])
103
+
104
+ for step in record.get('mechanism', []):
105
+ if step:
106
+ text_parts.append(step)
107
+
108
+ return ' '.join(text_parts)
109
+
110
+ def save_index(self):
111
+ """保存 BM25 索引"""
112
+ ensure_directory(self.output_dir)
113
+
114
+ # 保存元数据
115
+ metadata_path = self.output_dir / 'bm25_metadata.jsonl'
116
+ with open(metadata_path, 'w', encoding='utf-8') as f:
117
+ for meta in self.metadata:
118
+ f.write(json.dumps(meta, ensure_ascii=False) + '\n')
119
+ logging.info(f"BM25 metadata saved to {metadata_path}")
120
+
121
+ # 保存文本,用于检索阶段重建 BM25Okapi
122
+ documents_path = self.output_dir / 'bm25_documents.jsonl'
123
+ with open(documents_path, 'w', encoding='utf-8') as f:
124
+ for meta in self.metadata:
125
+ f.write(json.dumps({'text': meta.get('text', '')}, ensure_ascii=False) + '\n')
126
+ logging.info(f"BM25 documents saved to {documents_path}")
127
+
128
+ index_path = self.output_dir / 'bm25_index.pkl'
129
+ with open(index_path, 'wb') as f:
130
+ pickle.dump(self.bm25, f, protocol=pickle.HIGHEST_PROTOCOL)
131
+ logging.info(f"BM25 index saved to {index_path}")
132
+
133
+ # 保存索引信息
134
+ index_info_path = self.output_dir / 'bm25_info.json'
135
+ index_info = {
136
+ 'document_count': len(self.metadata),
137
+ 'created_at': datetime.now().isoformat(),
138
+ 'metadata_path': str(metadata_path),
139
+ 'documents_path': str(documents_path),
140
+ 'index_path': str(index_path),
141
+ }
142
+ with open(index_info_path, 'w', encoding='utf-8') as f:
143
+ json.dump(index_info, f, ensure_ascii=False, indent=2)
144
+ logging.info(f"BM25 info saved to {index_info_path}")
145
+
146
+ def build_from_json_records(self, jsonl_path: Path):
147
+ """从 JSON 记录构建索引"""
148
+ logging.info(f"Building BM25 index from {jsonl_path}")
149
+
150
+ documents = []
151
+ metadata = []
152
+
153
+ if not jsonl_path.exists():
154
+ logging.warning(f"File not found: {jsonl_path}")
155
+ return
156
+
157
+ with open(jsonl_path, 'r', encoding='utf-8') as f:
158
+ for line in f:
159
+ if line.strip():
160
+ record = json.loads(line)
161
+
162
+ # 构造文档文本
163
+ text_parts = []
164
+
165
+ material = record.get('material_context', {})
166
+ material_type = material.get('material_type', '')
167
+ active_site = material.get('active_site', '')
168
+
169
+ if material_type:
170
+ text_parts.append(material_type)
171
+ if active_site:
172
+ text_parts.append(active_site)
173
+
174
+ reaction_env = record.get('reaction_environment', {})
175
+ phase = reaction_env.get('phase', '')
176
+ oxidant = reaction_env.get('oxidant_source', '')
177
+ driving = reaction_env.get('driving_force', '')
178
+
179
+ if phase:
180
+ text_parts.append(phase)
181
+ if oxidant:
182
+ text_parts.append(oxidant)
183
+ if driving:
184
+ text_parts.append(driving)
185
+
186
+ dominant = record.get('dominant_ros_or_product', [])
187
+ for item in dominant:
188
+ species = item.get('species', '')
189
+ evidence = item.get('evidence', '')
190
+ if species:
191
+ text_parts.append(species)
192
+ if evidence:
193
+ text_parts.append(evidence)
194
+
195
+ secondary = record.get('secondary_or_intermediate_ros', [])
196
+ for item in secondary:
197
+ species = item.get('species', '')
198
+ evidence = item.get('evidence', '')
199
+ if species:
200
+ text_parts.append(species)
201
+ if evidence:
202
+ text_parts.append(evidence)
203
+
204
+ mechanism = record.get('mechanism', [])
205
+ for step in mechanism:
206
+ if step:
207
+ text_parts.append(step)
208
+
209
+ text = ' '.join(text_parts)
210
+ if not text.strip():
211
+ continue
212
+
213
+ documents.append(text)
214
+
215
+ meta = {
216
+ 'doc_id': record.get('source_chunk_id', ''),
217
+ 'source_type': 'gold_silver_json',
218
+ 'evidence_level': record.get('evidence_level', 'low'),
219
+ 'text': text
220
+ }
221
+ metadata.append(meta)
222
+
223
+ logging.info(f"Loaded {len(documents)} records from {jsonl_path}")
224
+ self.build_index(documents, metadata)
225
+
226
+ def build_from_chunks(self, chunks_path: Path, source_type: str = 'pdf'):
227
+ """从 chunks 文件构建索引"""
228
+ logging.info(f"Building BM25 index from {chunks_path}")
229
+
230
+ documents = []
231
+ metadata = []
232
+
233
+ if not chunks_path.exists():
234
+ logging.warning(f"File not found: {chunks_path}")
235
+ return
236
+
237
+ with open(chunks_path, 'r', encoding='utf-8') as f:
238
+ for line in f:
239
+ if line.strip():
240
+ record = json.loads(line)
241
+
242
+ text = record.get('text', '')
243
+ if not text.strip():
244
+ continue
245
+
246
+ # 如果只添加 ROS 相关的 chunks
247
+ if source_type == 'pdf' and not record.get('is_ros_related', False):
248
+ continue
249
+
250
+ documents.append(text)
251
+
252
+ meta = {
253
+ 'doc_id': record.get('chunk_id', ''),
254
+ 'source_type': source_type,
255
+ 'source': record.get('source_type', 'pdf_fulltext'),
256
+ 'title': record.get('title', ''),
257
+ 'doi': record.get('doi', ''),
258
+ 'page': record.get('page', 0),
259
+ 'is_ros_related': record.get('is_ros_related', False),
260
+ 'text': text
261
+ }
262
+ metadata.append(meta)
263
+
264
+ logging.info(f"Loaded {len(documents)} {source_type} chunks")
265
+ self.build_index(documents, metadata)
266
+
267
+ def run(self):
268
+ """运行索引构建流程"""
269
+ logging.info("Starting BM25 index construction")
270
+
271
+ documents = []
272
+ metadata = []
273
+
274
+ # 从 Gold/Silver JSON 记录构建
275
+ processed_dir = Path(self.config['paths']['processed_dir'])
276
+ json_paths = [
277
+ processed_dir / 'json_records_cleaned.jsonl',
278
+ processed_dir / 'silver_json_validated.jsonl',
279
+ ]
280
+ for json_path in json_paths:
281
+ if not json_path.exists():
282
+ logging.warning(f"Input file not found, skipping: {json_path}")
283
+ continue
284
+
285
+ json_docs, json_meta = [], []
286
+ with open(json_path, 'r', encoding='utf-8') as f:
287
+ for line in f:
288
+ if line.strip():
289
+ raw_record = json.loads(line)
290
+ record = self.get_schema_record(raw_record)
291
+ if not record:
292
+ continue
293
+
294
+ text = self.json_record_to_text(record)
295
+ if text.strip():
296
+ json_docs.append(text)
297
+ json_meta.append({
298
+ 'doc_id': record.get('source_chunk_id', raw_record.get('chunk_id', '')),
299
+ 'source_type': 'gold_silver_json',
300
+ 'evidence_level': record.get('evidence_level', 'low'),
301
+ 'text': text
302
+ })
303
+ documents.extend(json_docs)
304
+ metadata.extend(json_meta)
305
+ logging.info(f"Added {len(json_docs)} records from {json_path}")
306
+
307
+ # 从 PDF chunks 构建
308
+ pdf_path = Path(self.config['paths']['processed_dir']) / 'pdf_chunks.jsonl'
309
+ if pdf_path.exists():
310
+ pdf_docs, pdf_meta = [], []
311
+ with open(pdf_path, 'r', encoding='utf-8') as f:
312
+ for line in f:
313
+ if line.strip():
314
+ record = json.loads(line)
315
+ text = record.get('text', '')
316
+ if text.strip() and record.get('is_ros_related', False):
317
+ pdf_docs.append(text)
318
+ pdf_meta.append({
319
+ 'doc_id': record.get('chunk_id', ''),
320
+ 'source_type': 'pdf',
321
+ 'title': record.get('title', ''),
322
+ 'doi': record.get('doi', ''),
323
+ 'is_ros_related': record.get('is_ros_related', False),
324
+ 'text': text
325
+ })
326
+ documents.extend(pdf_docs)
327
+ metadata.extend(pdf_meta)
328
+ logging.info(f"Added {len(pdf_docs)} PDF chunks")
329
+
330
+ # 从 WoS abstracts 构建
331
+ wos_path = Path(self.config['paths']['processed_dir']) / 'wos_abstract_chunks.jsonl'
332
+ if wos_path.exists():
333
+ wos_docs, wos_meta = [], []
334
+ with open(wos_path, 'r', encoding='utf-8') as f:
335
+ for line in f:
336
+ if line.strip():
337
+ record = json.loads(line)
338
+ text = record.get('text', '')
339
+ if text.strip():
340
+ wos_docs.append(text)
341
+ wos_meta.append({
342
+ 'doc_id': record.get('doc_id', ''),
343
+ 'source_type': 'wos',
344
+ 'title': record.get('title', ''),
345
+ 'doi': record.get('doi', ''),
346
+ 'year': record.get('year'),
347
+ 'text': text
348
+ })
349
+ documents.extend(wos_docs)
350
+ metadata.extend(wos_meta)
351
+ logging.info(f"Added {len(wos_docs)} WOS abstracts")
352
+
353
+ # 构建索引
354
+ self.build_index(documents, metadata)
355
+
356
+ # 保存索引
357
+ self.save_index()
358
+
359
+ logging.info(f"BM25 index construction completed. Total documents: {len(self.metadata)}")
360
+
361
+
362
+ def main():
363
+ """主函数"""
364
+ import argparse
365
+
366
+ parser = argparse.ArgumentParser(description='Build BM25 index for COF-ROS retrieval')
367
+ parser.add_argument('--config', type=str, default='config/config.yaml',
368
+ help='Path to config file')
369
+ args = parser.parse_args()
370
+
371
+ # 加载配置
372
+ config_path = Path(args.config)
373
+ with open(config_path, 'r', encoding='utf-8') as f:
374
+ config = yaml.safe_load(f)
375
+
376
+ # 运行构建
377
+ builder = BM25IndexBuilder(config)
378
+ builder.run()
379
+
380
+
381
+ if __name__ == '__main__':
382
+ main()