@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,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 ""