@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.
- package/README.md +97 -93
- package/bin/cofos.js +119 -41
- package/package.json +3 -2
- package/requirements.txt +8 -0
- package/runtime/config/config.yaml +313 -0
- package/runtime/config/prompt_templates.yaml +403 -0
- package/runtime/config/qwen3_5_config.md +46 -0
- package/runtime/src/data_processing/__init__.py +31 -0
- package/runtime/src/data_processing/clean_json_records.py +387 -0
- package/runtime/src/data_processing/parse_pdf_text.py +332 -0
- package/runtime/src/data_processing/parse_wos_xlsx.py +255 -0
- package/runtime/src/data_processing/schema_utils.py +56 -0
- package/runtime/src/data_processing/utils.py +74 -0
- package/runtime/src/inference/__init__.py +8 -0
- package/runtime/src/inference/query_router.py +33 -0
- package/runtime/src/inference/run_kg_grounded_inference.py +370 -0
- package/runtime/src/inference/student_adapter_inference.py +520 -0
- package/runtime/src/retrieval/__init__.py +10 -0
- package/runtime/src/retrieval/build_bm25_index.py +382 -0
- package/runtime/src/retrieval/build_faiss_index.py +328 -0
- package/runtime/src/retrieval/retrieve_evidence.py +859 -0
- package/runtime/src/retrieval/text_search.py +60 -0
- package/scripts/chat.py +479 -61
|
@@ -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()
|