@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,332 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
COF-ROS-Reasoner: PDF 文本解析和 ROS 相关段落筛选脚本
|
|
4
|
+
|
|
5
|
+
目标:
|
|
6
|
+
- 从 PDF 中提取正文并切分为段落
|
|
7
|
+
- 优先标记包含 ROS 相关关键词的段落
|
|
8
|
+
- 输出为标准化的 JSONL 格式
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import json
|
|
13
|
+
import yaml
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Any, Optional, Tuple
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import pdfplumber
|
|
22
|
+
except ImportError:
|
|
23
|
+
pdfplumber = None
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import fitz # PyMuPDF
|
|
27
|
+
except ImportError:
|
|
28
|
+
fitz = None
|
|
29
|
+
|
|
30
|
+
# 添加 src 路径
|
|
31
|
+
|
|
32
|
+
from data_processing.utils import setup_logging, ensure_directory
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PDFParser:
|
|
36
|
+
"""解析 PDF 文件"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: Dict[str, Any]):
|
|
39
|
+
self.config = config
|
|
40
|
+
self.raw_pdfs_path = Path(config['paths']['raw_pdfs'])
|
|
41
|
+
self.output_path = Path(config['paths']['processed_dir']) / 'pdf_chunks.jsonl'
|
|
42
|
+
|
|
43
|
+
# 设置日志
|
|
44
|
+
setup_logging(config)
|
|
45
|
+
|
|
46
|
+
# chunk 配置
|
|
47
|
+
self.chunk_size = config.get('data_processing', {}).get('chunk_size', 768)
|
|
48
|
+
self.chunk_overlap = config.get('data_processing', {}).get('chunk_overlap', 50)
|
|
49
|
+
|
|
50
|
+
# ROS 关键词
|
|
51
|
+
self.ros_keywords = config.get('data_processing', {}).get('ros_keywords', [])
|
|
52
|
+
|
|
53
|
+
# 证据方法关键词
|
|
54
|
+
self.evidence_methods = config.get('data_processing', {}).get('evidence_methods', [])
|
|
55
|
+
|
|
56
|
+
# 验证 pdfplumber 是否可用
|
|
57
|
+
self.use_pdfplumber = pdfplumber is not None
|
|
58
|
+
|
|
59
|
+
def get_ros_score(self, text: str) -> Tuple[float, List[str]]:
|
|
60
|
+
"""计算文本的 ROS 相关性得分"""
|
|
61
|
+
if not text:
|
|
62
|
+
return 0.0, []
|
|
63
|
+
|
|
64
|
+
text_lower = text.lower()
|
|
65
|
+
matched_keywords = []
|
|
66
|
+
|
|
67
|
+
for keyword in self.ros_keywords:
|
|
68
|
+
if keyword.lower() in text_lower:
|
|
69
|
+
matched_keywords.append(keyword)
|
|
70
|
+
|
|
71
|
+
# 得分 = 匹配的关键词数 / 总关键词数
|
|
72
|
+
score = len(matched_keywords) / len(self.ros_keywords) if self.ros_keywords else 0.0
|
|
73
|
+
return score, matched_keywords
|
|
74
|
+
|
|
75
|
+
def has_evidence_methods(self, text: str) -> bool:
|
|
76
|
+
"""检查是否包含证据方法关键词"""
|
|
77
|
+
text_lower = text.lower()
|
|
78
|
+
for method in self.evidence_methods:
|
|
79
|
+
if method.lower() in text_lower:
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def extract_text_from_pdf_plumber(self, pdf_path: Path) -> List[str]:
|
|
84
|
+
"""使用 pdfplumber 提取文本"""
|
|
85
|
+
if not self.use_pdfplumber:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
pages_text = []
|
|
89
|
+
try:
|
|
90
|
+
with pdfplumber.open(pdf_path) as pdf:
|
|
91
|
+
for page_num, page in enumerate(pdf.pages, 1):
|
|
92
|
+
text = page.extract_text() or ""
|
|
93
|
+
pages_text.append({
|
|
94
|
+
'page': page_num,
|
|
95
|
+
'text': text
|
|
96
|
+
})
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logging.error(f"Error extracting text from {pdf_path}: {e}")
|
|
99
|
+
|
|
100
|
+
return pages_text
|
|
101
|
+
|
|
102
|
+
def extract_text_from_pymupdf(self, pdf_path: Path) -> List[str]:
|
|
103
|
+
"""使用 PyMuPDF 提取文本"""
|
|
104
|
+
pages_text = []
|
|
105
|
+
try:
|
|
106
|
+
doc = fitz.open(pdf_path)
|
|
107
|
+
for page_num in range(len(doc)):
|
|
108
|
+
page = doc[page_num]
|
|
109
|
+
text = page.get_text() or ""
|
|
110
|
+
pages_text.append({
|
|
111
|
+
'page': page_num + 1,
|
|
112
|
+
'text': text
|
|
113
|
+
})
|
|
114
|
+
doc.close()
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logging.error(f"Error extracting text from {pdf_path}: {e}")
|
|
117
|
+
|
|
118
|
+
return pages_text
|
|
119
|
+
|
|
120
|
+
def chunk_text(self, text: str, page: int) -> List[Dict[str, Any]]:
|
|
121
|
+
"""将文本切分为 chunk"""
|
|
122
|
+
chunks = []
|
|
123
|
+
|
|
124
|
+
# 简单按句子切分
|
|
125
|
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
126
|
+
|
|
127
|
+
current_chunk = []
|
|
128
|
+
current_length = 0
|
|
129
|
+
|
|
130
|
+
for sentence in sentences:
|
|
131
|
+
sentence_length = len(sentence)
|
|
132
|
+
|
|
133
|
+
if current_length + sentence_length > self.chunk_size and current_chunk:
|
|
134
|
+
# 保存当前 chunk
|
|
135
|
+
chunk_text = ' '.join(current_chunk)
|
|
136
|
+
ros_score, matched_keywords = self.get_ros_score(chunk_text)
|
|
137
|
+
is_ros_related = ros_score > 0
|
|
138
|
+
|
|
139
|
+
chunks.append({
|
|
140
|
+
'text': chunk_text,
|
|
141
|
+
'page': page,
|
|
142
|
+
'ros_score': ros_score,
|
|
143
|
+
'matched_keywords': matched_keywords,
|
|
144
|
+
'has_evidence_methods': self.has_evidence_methods(chunk_text),
|
|
145
|
+
'is_ros_related': is_ros_related
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
# 开始新 chunk
|
|
149
|
+
current_chunk = [sentence]
|
|
150
|
+
current_length = sentence_length
|
|
151
|
+
else:
|
|
152
|
+
current_chunk.append(sentence)
|
|
153
|
+
current_length += sentence_length
|
|
154
|
+
|
|
155
|
+
# 处理剩余的 chunk
|
|
156
|
+
if current_chunk:
|
|
157
|
+
chunk_text = ' '.join(current_chunk)
|
|
158
|
+
ros_score, matched_keywords = self.get_ros_score(chunk_text)
|
|
159
|
+
is_ros_related = ros_score > 0
|
|
160
|
+
|
|
161
|
+
chunks.append({
|
|
162
|
+
'text': chunk_text,
|
|
163
|
+
'page': page,
|
|
164
|
+
'ros_score': ros_score,
|
|
165
|
+
'matched_keywords': matched_keywords,
|
|
166
|
+
'has_evidence_methods': self.has_evidence_methods(chunk_text),
|
|
167
|
+
'is_ros_related': is_ros_related
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
return chunks
|
|
171
|
+
|
|
172
|
+
def extract_title_from_pdf(self, pdf_path: Path) -> str:
|
|
173
|
+
"""从 PDF 中提取标题(简单实现:取第一页第一行)"""
|
|
174
|
+
if self.use_pdfplumber:
|
|
175
|
+
try:
|
|
176
|
+
with pdfplumber.open(pdf_path) as pdf:
|
|
177
|
+
if pdf.pages:
|
|
178
|
+
first_page = pdf.pages[0]
|
|
179
|
+
text = first_page.extract_text()
|
|
180
|
+
if text:
|
|
181
|
+
lines = text.split('\n')
|
|
182
|
+
for line in lines:
|
|
183
|
+
line = line.strip()
|
|
184
|
+
if line and len(line) > 10:
|
|
185
|
+
return line[:100]
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
return pdf_path.stem
|
|
190
|
+
|
|
191
|
+
def extract_doi_from_pdf(self, pdf_path: Path) -> str:
|
|
192
|
+
"""从 PDF 中提取 DOI"""
|
|
193
|
+
try:
|
|
194
|
+
with open(pdf_path, 'rb') as f:
|
|
195
|
+
content = f.read().decode('utf-8', errors='ignore')
|
|
196
|
+
|
|
197
|
+
# 尝试查找 DOI
|
|
198
|
+
doi_patterns = [
|
|
199
|
+
r'10\.\d{4,}/[^\s]+',
|
|
200
|
+
r'DOI:\s*(10\.\d{4,}/[^\s]+)',
|
|
201
|
+
r'doi:\s*(10\.\d{4,}/[^\s]+)',
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
for pattern in doi_patterns:
|
|
205
|
+
match = re.search(pattern, content, re.IGNORECASE)
|
|
206
|
+
if match:
|
|
207
|
+
return match.group(0) if match.lastindex else match.group(0)
|
|
208
|
+
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
return ""
|
|
213
|
+
|
|
214
|
+
def process_pdf(self, pdf_path: Path) -> List[Dict[str, Any]]:
|
|
215
|
+
"""处理单个 PDF 文件"""
|
|
216
|
+
records = []
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
# 提取标题和 DOI
|
|
220
|
+
title = self.extract_title_from_pdf(pdf_path)
|
|
221
|
+
doi = self.extract_doi_from_pdf(pdf_path)
|
|
222
|
+
|
|
223
|
+
# 提取文本
|
|
224
|
+
if self.use_pdfplumber:
|
|
225
|
+
pages_text = self.extract_text_from_pdf_plumber(pdf_path)
|
|
226
|
+
elif fitz:
|
|
227
|
+
pages_text = self.extract_text_from_pymupdf(pdf_path)
|
|
228
|
+
else:
|
|
229
|
+
logging.error("No PDF library available. Install pdfplumber or PyMuPDF.")
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
# 处理每个页面
|
|
233
|
+
for page_data in pages_text:
|
|
234
|
+
page_num = page_data['page']
|
|
235
|
+
text = page_data['text']
|
|
236
|
+
|
|
237
|
+
# 切分文本
|
|
238
|
+
chunks = self.chunk_text(text, page_num)
|
|
239
|
+
|
|
240
|
+
for chunk_idx, chunk in enumerate(chunks):
|
|
241
|
+
record = {
|
|
242
|
+
'doc_id': f"{pdf_path.stem}_{page_num}_{chunk_idx:03d}",
|
|
243
|
+
'source_type': 'pdf_fulltext',
|
|
244
|
+
'title': title,
|
|
245
|
+
'doi': doi,
|
|
246
|
+
'page': page_num,
|
|
247
|
+
'chunk_id': f"{pdf_path.stem}_{page_num}_{chunk_idx:03d}",
|
|
248
|
+
'is_ros_related': chunk['is_ros_related'],
|
|
249
|
+
'ros_score': chunk['ros_score'],
|
|
250
|
+
'matched_keywords': chunk['matched_keywords'],
|
|
251
|
+
'has_evidence_methods': chunk['has_evidence_methods'],
|
|
252
|
+
'text': chunk['text']
|
|
253
|
+
}
|
|
254
|
+
records.append(record)
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logging.error(f"Error processing PDF {pdf_path}: {e}")
|
|
258
|
+
|
|
259
|
+
return records
|
|
260
|
+
|
|
261
|
+
def run(self):
|
|
262
|
+
"""运行解析流程"""
|
|
263
|
+
logging.info(f"Starting PDF parsing from {self.raw_pdfs_path}")
|
|
264
|
+
logging.info(f"Output will be saved to {self.output_path}")
|
|
265
|
+
|
|
266
|
+
# 检查输入目录
|
|
267
|
+
if not self.raw_pdfs_path.exists():
|
|
268
|
+
logging.error(f"Input directory not found: {self.raw_pdfs_path}")
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
# 收集所有 PDF 文件
|
|
272
|
+
pdf_files = list(self.raw_pdfs_path.glob('*.pdf'))
|
|
273
|
+
logging.info(f"Found {len(pdf_files)} PDF files")
|
|
274
|
+
|
|
275
|
+
if not pdf_files:
|
|
276
|
+
logging.warning("No PDF files found")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
# 处理所有 PDF
|
|
280
|
+
all_records = []
|
|
281
|
+
for pdf_path in pdf_files:
|
|
282
|
+
logging.info(f"Processing {pdf_path.name}")
|
|
283
|
+
records = self.process_pdf(pdf_path)
|
|
284
|
+
all_records.extend(records)
|
|
285
|
+
|
|
286
|
+
# 按 ROS 相关性排序
|
|
287
|
+
all_records.sort(key=lambda x: x.get('ros_score', 0), reverse=True)
|
|
288
|
+
|
|
289
|
+
# 保存输出
|
|
290
|
+
ensure_directory(self.output_path.parent)
|
|
291
|
+
with open(self.output_path, 'w', encoding='utf-8') as f:
|
|
292
|
+
for record in all_records:
|
|
293
|
+
f.write(json.dumps(record, ensure_ascii=False) + '\n')
|
|
294
|
+
|
|
295
|
+
# 统计信息
|
|
296
|
+
ros_related = sum(1 for r in all_records if r.get('is_ros_related', False))
|
|
297
|
+
total_chunks = len(all_records)
|
|
298
|
+
avg_score = sum(r.get('ros_score', 0) for r in all_records) / total_chunks if total_chunks else 0
|
|
299
|
+
|
|
300
|
+
logging.info(f"PDF parsing completed: {total_chunks} chunks from {len(pdf_files)} PDFs")
|
|
301
|
+
logging.info(f"ROS-related chunks: {ros_related}")
|
|
302
|
+
logging.info(f"Average ROS score: {avg_score:.3f}")
|
|
303
|
+
logging.info(f"Output saved to {self.output_path}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def main():
|
|
307
|
+
"""主函数"""
|
|
308
|
+
import argparse
|
|
309
|
+
|
|
310
|
+
parser = argparse.ArgumentParser(description='Parse PDF files and extract ROS-related chunks')
|
|
311
|
+
parser.add_argument('--config', type=str, default='config/config.yaml',
|
|
312
|
+
help='Path to config file')
|
|
313
|
+
parser.add_argument('--pdf-path', type=str, default=None,
|
|
314
|
+
help='Path to PDF file or directory (overrides config)')
|
|
315
|
+
args = parser.parse_args()
|
|
316
|
+
|
|
317
|
+
# 加载配置
|
|
318
|
+
config_path = Path(args.config)
|
|
319
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
320
|
+
config = yaml.safe_load(f)
|
|
321
|
+
|
|
322
|
+
# 如果指定了 PDF 路径,覆盖配置
|
|
323
|
+
if args.pdf_path:
|
|
324
|
+
config['paths']['raw_pdfs'] = args.pdf_path
|
|
325
|
+
|
|
326
|
+
# 运行解析
|
|
327
|
+
parser = PDFParser(config)
|
|
328
|
+
parser.run()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if __name__ == '__main__':
|
|
332
|
+
main()
|
|
@@ -0,0 +1,255 @@
|
|
|
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()
|
|
@@ -0,0 +1,56 @@
|
|
|
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 ""
|