iris-vector-rag 0.2.1__py3-none-any.whl
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.
- adapters/rag_templates_bridge.py +578 -0
- common/__init__.py +1 -0
- common/batch_utils.py +294 -0
- common/config.py +58 -0
- common/connection_manager.py +242 -0
- common/connection_pool.py +445 -0
- common/database.py +141 -0
- common/database_schema_manager.py +289 -0
- common/db_init_with_indexes.py +181 -0
- common/db_vector_utils.py +181 -0
- common/environment_manager.py +220 -0
- common/huggingface_utils.py +206 -0
- common/iris_client.py +92 -0
- common/iris_connection_manager.py +411 -0
- common/iris_connection_pool.py +360 -0
- common/iris_connector.py +71 -0
- common/iris_dbapi_connector.py +323 -0
- common/iris_index_utils.py +195 -0
- common/iris_sql_utils.py +704 -0
- common/iris_stream_reader.py +166 -0
- common/llm_cache_config.py +308 -0
- common/llm_cache_iris.py +671 -0
- common/llm_cache_manager.py +911 -0
- common/utils.py +559 -0
- common/vector_sql_utils.py +584 -0
- evaluation_framework/__init__.py +66 -0
- evaluation_framework/biomedical_question_generator.py +826 -0
- evaluation_framework/comparative_analysis_system.py +1173 -0
- evaluation_framework/empirical_reporting.py +1298 -0
- evaluation_framework/evaluation_orchestrator.py +1123 -0
- evaluation_framework/execute_production_evaluation.py +382 -0
- evaluation_framework/pmc_data_pipeline.py +1332 -0
- evaluation_framework/production_evaluation_config.py +353 -0
- evaluation_framework/ragas_metrics_framework.py +1013 -0
- evaluation_framework/real_production_evaluation.py +544 -0
- evaluation_framework/run_10k_evaluation.py +175 -0
- evaluation_framework/simple_test.py +364 -0
- evaluation_framework/statistical_evaluation_methodology.py +1016 -0
- evaluation_framework/test_framework.py +281 -0
- evaluation_framework/test_iris_connectivity.py +244 -0
- evaluation_framework/test_small_scale_evaluation.py +432 -0
- evaluation_framework/true_e2e_evaluation.py +639 -0
- evaluation_framework/visualization_engine.py +585 -0
- iris_rag/__init__.py +284 -0
- iris_rag/config/default_config.yaml +295 -0
- iris_vector_rag-0.2.1.dist-info/METADATA +499 -0
- iris_vector_rag-0.2.1.dist-info/RECORD +50 -0
- iris_vector_rag-0.2.1.dist-info/WHEEL +5 -0
- iris_vector_rag-0.2.1.dist-info/entry_points.txt +2 -0
- iris_vector_rag-0.2.1.dist-info/top_level.txt +4 -0
|
@@ -0,0 +1,1332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PMC Data Pipeline for Large-Scale Biomedical Document Processing
|
|
3
|
+
|
|
4
|
+
This module provides a comprehensive pipeline for ingesting, processing, and preparing
|
|
5
|
+
10K+ PMC (PubMed Central) documents for RAG evaluation. It includes data validation,
|
|
6
|
+
biomedical content extraction, chunking strategies, embedding generation, and vector
|
|
7
|
+
database integration with the existing IRIS infrastructure.
|
|
8
|
+
|
|
9
|
+
Key Features:
|
|
10
|
+
1. Scalable document ingestion with parallel processing
|
|
11
|
+
2. Biomedical content validation and quality assessment
|
|
12
|
+
3. Intelligent document chunking for optimal RAG performance
|
|
13
|
+
4. Vector embedding generation and storage
|
|
14
|
+
5. Progress monitoring and error handling
|
|
15
|
+
6. Incremental processing and resume capabilities
|
|
16
|
+
7. Data quality metrics and reporting
|
|
17
|
+
8. Integration with IRIS vector database
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import gzip
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import pickle
|
|
26
|
+
import re
|
|
27
|
+
import sqlite3
|
|
28
|
+
import time
|
|
29
|
+
import xml.etree.ElementTree as ET
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
|
36
|
+
from urllib.parse import urljoin
|
|
37
|
+
|
|
38
|
+
import ftfy
|
|
39
|
+
import nltk
|
|
40
|
+
import numpy as np
|
|
41
|
+
import pandas as pd
|
|
42
|
+
import psycopg2
|
|
43
|
+
import requests
|
|
44
|
+
|
|
45
|
+
# Scientific/medical processing
|
|
46
|
+
import spacy
|
|
47
|
+
|
|
48
|
+
# Database integration
|
|
49
|
+
import sqlalchemy as sa
|
|
50
|
+
import torch
|
|
51
|
+
from nltk.corpus import stopwords
|
|
52
|
+
from nltk.tokenize import sent_tokenize, word_tokenize
|
|
53
|
+
from sentence_transformers import SentenceTransformer
|
|
54
|
+
|
|
55
|
+
# Data processing
|
|
56
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
57
|
+
from sklearn.metrics.pairwise import cosine_similarity
|
|
58
|
+
from sqlalchemy import create_engine, text
|
|
59
|
+
from transformers import AutoModel, AutoTokenizer
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class PMCDocument:
|
|
66
|
+
"""Represents a single PMC document with metadata."""
|
|
67
|
+
|
|
68
|
+
pmc_id: str
|
|
69
|
+
title: str
|
|
70
|
+
abstract: str
|
|
71
|
+
full_text: str
|
|
72
|
+
authors: List[str]
|
|
73
|
+
publication_date: str
|
|
74
|
+
journal: str
|
|
75
|
+
doi: Optional[str] = None
|
|
76
|
+
keywords: List[str] = field(default_factory=list)
|
|
77
|
+
mesh_terms: List[str] = field(default_factory=list)
|
|
78
|
+
content_type: str = "research_article"
|
|
79
|
+
language: str = "en"
|
|
80
|
+
|
|
81
|
+
# Processing metadata
|
|
82
|
+
word_count: int = 0
|
|
83
|
+
chunk_count: int = 0
|
|
84
|
+
embedding_generated: bool = False
|
|
85
|
+
quality_score: float = 0.0
|
|
86
|
+
processing_timestamp: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
# Biomedical metadata
|
|
89
|
+
medical_entities: List[Dict[str, Any]] = field(default_factory=list)
|
|
90
|
+
disease_mentions: List[str] = field(default_factory=list)
|
|
91
|
+
drug_mentions: List[str] = field(default_factory=list)
|
|
92
|
+
procedure_mentions: List[str] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
def __post_init__(self):
|
|
95
|
+
"""Calculate basic metadata after initialization."""
|
|
96
|
+
if self.full_text:
|
|
97
|
+
self.word_count = len(self.full_text.split())
|
|
98
|
+
self.processing_timestamp = datetime.now().isoformat()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class DocumentChunk:
|
|
103
|
+
"""Represents a chunk of a document for RAG processing."""
|
|
104
|
+
|
|
105
|
+
chunk_id: str
|
|
106
|
+
pmc_id: str
|
|
107
|
+
content: str
|
|
108
|
+
chunk_type: str # 'abstract', 'introduction', 'methods', 'results', 'discussion', 'conclusion'
|
|
109
|
+
chunk_index: int
|
|
110
|
+
token_count: int
|
|
111
|
+
overlap_with_previous: int = 0
|
|
112
|
+
|
|
113
|
+
# Semantic metadata
|
|
114
|
+
key_terms: List[str] = field(default_factory=list)
|
|
115
|
+
medical_concepts: List[str] = field(default_factory=list)
|
|
116
|
+
embedding_vector: Optional[np.ndarray] = None
|
|
117
|
+
|
|
118
|
+
# Quality metrics
|
|
119
|
+
information_density: float = 0.0
|
|
120
|
+
readability_score: float = 0.0
|
|
121
|
+
biomedical_relevance: float = 0.0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class ProcessingConfig:
|
|
126
|
+
"""Configuration for PMC document processing pipeline."""
|
|
127
|
+
|
|
128
|
+
# Input/Output configuration
|
|
129
|
+
input_data_path: str = "data/pmc_documents"
|
|
130
|
+
output_data_path: str = "data/processed_pmc"
|
|
131
|
+
cache_dir: str = "cache/pmc_processing"
|
|
132
|
+
|
|
133
|
+
# Processing configuration
|
|
134
|
+
max_workers: int = 8
|
|
135
|
+
batch_size: int = 100
|
|
136
|
+
chunk_size: int = 512 # tokens
|
|
137
|
+
chunk_overlap: int = 50 # tokens
|
|
138
|
+
max_documents: Optional[int] = None
|
|
139
|
+
|
|
140
|
+
# Quality thresholds
|
|
141
|
+
min_word_count: int = 100
|
|
142
|
+
min_quality_score: float = 0.3
|
|
143
|
+
max_chunk_size: int = 1000
|
|
144
|
+
|
|
145
|
+
# Language processing
|
|
146
|
+
spacy_model: str = "en_core_sci_md" # ScispaCy biomedical model
|
|
147
|
+
embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
|
|
148
|
+
biomedical_embedding_model: str = "dmis-lab/biobert-base-cased-v1.1"
|
|
149
|
+
|
|
150
|
+
# Database configuration
|
|
151
|
+
vector_db_connection: str = "iris://localhost:1972/IRIS"
|
|
152
|
+
metadata_db_path: str = "data/pmc_metadata.db"
|
|
153
|
+
|
|
154
|
+
# API configuration
|
|
155
|
+
ncbi_api_key: Optional[str] = None
|
|
156
|
+
rate_limit_delay: float = 0.1
|
|
157
|
+
|
|
158
|
+
# Error handling
|
|
159
|
+
max_retries: int = 3
|
|
160
|
+
continue_on_error: bool = True
|
|
161
|
+
save_failed_documents: bool = True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class ProcessingStats:
|
|
166
|
+
"""Statistics for document processing pipeline."""
|
|
167
|
+
|
|
168
|
+
total_documents: int = 0
|
|
169
|
+
successful_documents: int = 0
|
|
170
|
+
failed_documents: int = 0
|
|
171
|
+
total_chunks: int = 0
|
|
172
|
+
total_embeddings: int = 0
|
|
173
|
+
processing_time: float = 0.0
|
|
174
|
+
|
|
175
|
+
# Quality metrics
|
|
176
|
+
avg_document_quality: float = 0.0
|
|
177
|
+
avg_chunk_quality: float = 0.0
|
|
178
|
+
documents_by_type: Dict[str, int] = field(default_factory=dict)
|
|
179
|
+
|
|
180
|
+
# Error tracking
|
|
181
|
+
error_types: Dict[str, int] = field(default_factory=dict)
|
|
182
|
+
failed_document_ids: List[str] = field(default_factory=list)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class PMCDataPipeline:
|
|
186
|
+
"""
|
|
187
|
+
Comprehensive pipeline for processing large-scale PMC documents.
|
|
188
|
+
|
|
189
|
+
Handles document ingestion, validation, chunking, embedding generation,
|
|
190
|
+
and vector database storage with robust error handling and monitoring.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, config: Optional[ProcessingConfig] = None):
|
|
194
|
+
"""
|
|
195
|
+
Initialize the PMC data pipeline.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
config: Processing configuration
|
|
199
|
+
"""
|
|
200
|
+
self.config = config or ProcessingConfig()
|
|
201
|
+
self.stats = ProcessingStats()
|
|
202
|
+
|
|
203
|
+
# Setup directories
|
|
204
|
+
self._setup_directories()
|
|
205
|
+
|
|
206
|
+
# Initialize language models
|
|
207
|
+
self._initialize_nlp_models()
|
|
208
|
+
|
|
209
|
+
# Initialize databases
|
|
210
|
+
self._initialize_databases()
|
|
211
|
+
|
|
212
|
+
# Processing state
|
|
213
|
+
self.processing_state = {
|
|
214
|
+
"current_batch": 0,
|
|
215
|
+
"processed_documents": set(),
|
|
216
|
+
"failed_documents": set(),
|
|
217
|
+
"last_checkpoint": None,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
logger.info("PMC Data Pipeline initialized successfully")
|
|
221
|
+
|
|
222
|
+
def _setup_directories(self):
|
|
223
|
+
"""Setup required directories."""
|
|
224
|
+
directories = [
|
|
225
|
+
self.config.output_data_path,
|
|
226
|
+
self.config.cache_dir,
|
|
227
|
+
f"{self.config.cache_dir}/embeddings",
|
|
228
|
+
f"{self.config.cache_dir}/failed_docs",
|
|
229
|
+
f"{self.config.output_data_path}/chunks",
|
|
230
|
+
f"{self.config.output_data_path}/metadata",
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
for directory in directories:
|
|
234
|
+
Path(directory).mkdir(parents=True, exist_ok=True)
|
|
235
|
+
|
|
236
|
+
def _initialize_nlp_models(self):
|
|
237
|
+
"""Initialize NLP models for biomedical processing."""
|
|
238
|
+
logger.info("Initializing NLP models...")
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
# SpaCy biomedical model
|
|
242
|
+
self.nlp = spacy.load(self.config.spacy_model)
|
|
243
|
+
logger.info(f"Loaded SpaCy model: {self.config.spacy_model}")
|
|
244
|
+
except OSError:
|
|
245
|
+
logger.warning(
|
|
246
|
+
f"SpaCy model {self.config.spacy_model} not found, using default"
|
|
247
|
+
)
|
|
248
|
+
self.nlp = spacy.load("en_core_web_sm")
|
|
249
|
+
|
|
250
|
+
# Sentence transformer for general embeddings
|
|
251
|
+
self.embedding_model = SentenceTransformer(self.config.embedding_model)
|
|
252
|
+
|
|
253
|
+
# Tokenizer for chunk size calculation
|
|
254
|
+
self.tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
|
|
255
|
+
|
|
256
|
+
# Download required NLTK data
|
|
257
|
+
try:
|
|
258
|
+
nltk.download("punkt", quiet=True)
|
|
259
|
+
nltk.download("stopwords", quiet=True)
|
|
260
|
+
self.stop_words = set(stopwords.words("english"))
|
|
261
|
+
except:
|
|
262
|
+
self.stop_words = set()
|
|
263
|
+
|
|
264
|
+
logger.info("NLP models initialized successfully")
|
|
265
|
+
|
|
266
|
+
def _initialize_databases(self):
|
|
267
|
+
"""Initialize database connections."""
|
|
268
|
+
logger.info("Initializing database connections...")
|
|
269
|
+
|
|
270
|
+
# Metadata SQLite database
|
|
271
|
+
self.metadata_db_path = Path(self.config.metadata_db_path)
|
|
272
|
+
self.metadata_db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
|
|
274
|
+
self._create_metadata_tables()
|
|
275
|
+
|
|
276
|
+
# Vector database connection (IRIS)
|
|
277
|
+
# This would connect to the existing IRIS infrastructure
|
|
278
|
+
self.vector_db_connection = None
|
|
279
|
+
try:
|
|
280
|
+
# Placeholder for IRIS connection
|
|
281
|
+
# self.vector_db_connection = create_engine(self.config.vector_db_connection)
|
|
282
|
+
logger.info("Vector database connection initialized")
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.warning(f"Vector database connection failed: {e}")
|
|
285
|
+
|
|
286
|
+
def _create_metadata_tables(self):
|
|
287
|
+
"""Create metadata database tables."""
|
|
288
|
+
conn = sqlite3.connect(self.metadata_db_path)
|
|
289
|
+
cursor = conn.cursor()
|
|
290
|
+
|
|
291
|
+
# Documents table
|
|
292
|
+
cursor.execute(
|
|
293
|
+
"""
|
|
294
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
295
|
+
pmc_id TEXT PRIMARY KEY,
|
|
296
|
+
title TEXT,
|
|
297
|
+
abstract TEXT,
|
|
298
|
+
authors TEXT,
|
|
299
|
+
publication_date TEXT,
|
|
300
|
+
journal TEXT,
|
|
301
|
+
doi TEXT,
|
|
302
|
+
keywords TEXT,
|
|
303
|
+
mesh_terms TEXT,
|
|
304
|
+
content_type TEXT,
|
|
305
|
+
language TEXT,
|
|
306
|
+
word_count INTEGER,
|
|
307
|
+
chunk_count INTEGER,
|
|
308
|
+
quality_score REAL,
|
|
309
|
+
processing_timestamp TEXT,
|
|
310
|
+
embedding_generated BOOLEAN
|
|
311
|
+
)
|
|
312
|
+
"""
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Chunks table
|
|
316
|
+
cursor.execute(
|
|
317
|
+
"""
|
|
318
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
319
|
+
chunk_id TEXT PRIMARY KEY,
|
|
320
|
+
pmc_id TEXT,
|
|
321
|
+
content TEXT,
|
|
322
|
+
chunk_type TEXT,
|
|
323
|
+
chunk_index INTEGER,
|
|
324
|
+
token_count INTEGER,
|
|
325
|
+
overlap_with_previous INTEGER,
|
|
326
|
+
information_density REAL,
|
|
327
|
+
readability_score REAL,
|
|
328
|
+
biomedical_relevance REAL,
|
|
329
|
+
FOREIGN KEY (pmc_id) REFERENCES documents (pmc_id)
|
|
330
|
+
)
|
|
331
|
+
"""
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Processing log table
|
|
335
|
+
cursor.execute(
|
|
336
|
+
"""
|
|
337
|
+
CREATE TABLE IF NOT EXISTS processing_log (
|
|
338
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
339
|
+
pmc_id TEXT,
|
|
340
|
+
operation TEXT,
|
|
341
|
+
status TEXT,
|
|
342
|
+
error_message TEXT,
|
|
343
|
+
timestamp TEXT
|
|
344
|
+
)
|
|
345
|
+
"""
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
conn.commit()
|
|
349
|
+
conn.close()
|
|
350
|
+
|
|
351
|
+
def process_documents(
|
|
352
|
+
self,
|
|
353
|
+
document_source: Union[str, List[PMCDocument], Iterator[PMCDocument]],
|
|
354
|
+
resume_from_checkpoint: bool = False,
|
|
355
|
+
) -> ProcessingStats:
|
|
356
|
+
"""
|
|
357
|
+
Process PMC documents through the complete pipeline.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
document_source: Path to documents, list of documents, or document iterator
|
|
361
|
+
resume_from_checkpoint: Whether to resume from last checkpoint
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Processing statistics
|
|
365
|
+
"""
|
|
366
|
+
logger.info("Starting PMC document processing pipeline")
|
|
367
|
+
start_time = time.time()
|
|
368
|
+
|
|
369
|
+
# Load checkpoint if resuming
|
|
370
|
+
if resume_from_checkpoint:
|
|
371
|
+
self._load_checkpoint()
|
|
372
|
+
|
|
373
|
+
# Get document iterator
|
|
374
|
+
if isinstance(document_source, str):
|
|
375
|
+
documents = self._load_documents_from_path(document_source)
|
|
376
|
+
elif isinstance(document_source, list):
|
|
377
|
+
documents = iter(document_source)
|
|
378
|
+
else:
|
|
379
|
+
documents = document_source
|
|
380
|
+
|
|
381
|
+
# Process documents in batches
|
|
382
|
+
batch_num = self.processing_state["current_batch"]
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
for batch in self._batch_iterator(documents, self.config.batch_size):
|
|
386
|
+
batch_num += 1
|
|
387
|
+
logger.info(f"Processing batch {batch_num} ({len(batch)} documents)")
|
|
388
|
+
|
|
389
|
+
# Process batch
|
|
390
|
+
batch_results = self._process_document_batch(batch)
|
|
391
|
+
|
|
392
|
+
# Update statistics
|
|
393
|
+
self._update_stats(batch_results)
|
|
394
|
+
|
|
395
|
+
# Save checkpoint
|
|
396
|
+
self.processing_state["current_batch"] = batch_num
|
|
397
|
+
self._save_checkpoint()
|
|
398
|
+
|
|
399
|
+
# Log progress
|
|
400
|
+
self._log_progress()
|
|
401
|
+
|
|
402
|
+
# Check if max documents reached
|
|
403
|
+
if (
|
|
404
|
+
self.config.max_documents
|
|
405
|
+
and self.stats.successful_documents >= self.config.max_documents
|
|
406
|
+
):
|
|
407
|
+
logger.info("Reached maximum document limit")
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
except Exception as e:
|
|
411
|
+
logger.error(f"Pipeline processing failed: {e}")
|
|
412
|
+
raise
|
|
413
|
+
|
|
414
|
+
finally:
|
|
415
|
+
# Finalize processing
|
|
416
|
+
self.stats.processing_time = time.time() - start_time
|
|
417
|
+
self._finalize_processing()
|
|
418
|
+
|
|
419
|
+
logger.info(f"Pipeline completed in {self.stats.processing_time:.2f}s")
|
|
420
|
+
return self.stats
|
|
421
|
+
|
|
422
|
+
def _load_documents_from_path(self, path: str) -> Iterator[PMCDocument]:
|
|
423
|
+
"""Load documents from various sources."""
|
|
424
|
+
path_obj = Path(path)
|
|
425
|
+
|
|
426
|
+
if path_obj.is_file():
|
|
427
|
+
# Single file
|
|
428
|
+
if path_obj.suffix == ".json":
|
|
429
|
+
yield from self._load_json_documents(path_obj)
|
|
430
|
+
elif path_obj.suffix == ".xml":
|
|
431
|
+
yield from self._load_xml_documents(path_obj)
|
|
432
|
+
elif path_obj.suffix in [".gz", ".zip"]:
|
|
433
|
+
yield from self._load_compressed_documents(path_obj)
|
|
434
|
+
|
|
435
|
+
elif path_obj.is_dir():
|
|
436
|
+
# Directory of files
|
|
437
|
+
for file_path in path_obj.rglob("*"):
|
|
438
|
+
if file_path.is_file():
|
|
439
|
+
yield from self._load_documents_from_path(str(file_path))
|
|
440
|
+
|
|
441
|
+
else:
|
|
442
|
+
# URL or API endpoint
|
|
443
|
+
yield from self._load_documents_from_api(path)
|
|
444
|
+
|
|
445
|
+
def _load_json_documents(self, file_path: Path) -> Iterator[PMCDocument]:
|
|
446
|
+
"""Load documents from JSON file."""
|
|
447
|
+
try:
|
|
448
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
449
|
+
data = json.load(f)
|
|
450
|
+
|
|
451
|
+
if isinstance(data, list):
|
|
452
|
+
for doc_data in data:
|
|
453
|
+
yield self._create_document_from_dict(doc_data)
|
|
454
|
+
else:
|
|
455
|
+
yield self._create_document_from_dict(data)
|
|
456
|
+
|
|
457
|
+
except Exception as e:
|
|
458
|
+
logger.error(f"Failed to load JSON file {file_path}: {e}")
|
|
459
|
+
|
|
460
|
+
def _load_xml_documents(self, file_path: Path) -> Iterator[PMCDocument]:
|
|
461
|
+
"""Load documents from XML file (PMC format)."""
|
|
462
|
+
try:
|
|
463
|
+
tree = ET.parse(file_path)
|
|
464
|
+
root = tree.getroot()
|
|
465
|
+
|
|
466
|
+
# Parse PMC XML format
|
|
467
|
+
pmc_id = self._extract_pmc_id(root)
|
|
468
|
+
title = self._extract_title(root)
|
|
469
|
+
abstract = self._extract_abstract(root)
|
|
470
|
+
full_text = self._extract_full_text(root)
|
|
471
|
+
authors = self._extract_authors(root)
|
|
472
|
+
metadata = self._extract_metadata(root)
|
|
473
|
+
|
|
474
|
+
yield PMCDocument(
|
|
475
|
+
pmc_id=pmc_id,
|
|
476
|
+
title=title,
|
|
477
|
+
abstract=abstract,
|
|
478
|
+
full_text=full_text,
|
|
479
|
+
authors=authors,
|
|
480
|
+
**metadata,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
except Exception as e:
|
|
484
|
+
logger.error(f"Failed to parse XML file {file_path}: {e}")
|
|
485
|
+
|
|
486
|
+
def _create_document_from_dict(self, doc_data: Dict[str, Any]) -> PMCDocument:
|
|
487
|
+
"""Create PMCDocument from dictionary data."""
|
|
488
|
+
return PMCDocument(
|
|
489
|
+
pmc_id=doc_data.get("pmc_id", ""),
|
|
490
|
+
title=doc_data.get("title", ""),
|
|
491
|
+
abstract=doc_data.get("abstract", ""),
|
|
492
|
+
full_text=doc_data.get("full_text", ""),
|
|
493
|
+
authors=doc_data.get("authors", []),
|
|
494
|
+
publication_date=doc_data.get("publication_date", ""),
|
|
495
|
+
journal=doc_data.get("journal", ""),
|
|
496
|
+
doi=doc_data.get("doi"),
|
|
497
|
+
keywords=doc_data.get("keywords", []),
|
|
498
|
+
mesh_terms=doc_data.get("mesh_terms", []),
|
|
499
|
+
content_type=doc_data.get("content_type", "research_article"),
|
|
500
|
+
language=doc_data.get("language", "en"),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def _batch_iterator(
|
|
504
|
+
self, documents: Iterator[PMCDocument], batch_size: int
|
|
505
|
+
) -> Iterator[List[PMCDocument]]:
|
|
506
|
+
"""Create batches from document iterator."""
|
|
507
|
+
batch = []
|
|
508
|
+
for doc in documents:
|
|
509
|
+
batch.append(doc)
|
|
510
|
+
if len(batch) >= batch_size:
|
|
511
|
+
yield batch
|
|
512
|
+
batch = []
|
|
513
|
+
|
|
514
|
+
if batch: # Last batch
|
|
515
|
+
yield batch
|
|
516
|
+
|
|
517
|
+
def _process_document_batch(self, batch: List[PMCDocument]) -> List[Dict[str, Any]]:
|
|
518
|
+
"""Process a batch of documents."""
|
|
519
|
+
results = []
|
|
520
|
+
|
|
521
|
+
# Use ThreadPoolExecutor for I/O bound operations
|
|
522
|
+
with ThreadPoolExecutor(max_workers=self.config.max_workers) as executor:
|
|
523
|
+
# Submit processing tasks
|
|
524
|
+
future_to_doc = {
|
|
525
|
+
executor.submit(self._process_single_document, doc): doc
|
|
526
|
+
for doc in batch
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# Collect results
|
|
530
|
+
for future in as_completed(future_to_doc):
|
|
531
|
+
doc = future_to_doc[future]
|
|
532
|
+
try:
|
|
533
|
+
result = future.result()
|
|
534
|
+
results.append(result)
|
|
535
|
+
except Exception as e:
|
|
536
|
+
logger.error(f"Failed to process document {doc.pmc_id}: {e}")
|
|
537
|
+
results.append(
|
|
538
|
+
{"pmc_id": doc.pmc_id, "status": "failed", "error": str(e)}
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
return results
|
|
542
|
+
|
|
543
|
+
def _process_single_document(self, document: PMCDocument) -> Dict[str, Any]:
|
|
544
|
+
"""Process a single document through the pipeline."""
|
|
545
|
+
|
|
546
|
+
# Skip if already processed
|
|
547
|
+
if document.pmc_id in self.processing_state["processed_documents"]:
|
|
548
|
+
return {"pmc_id": document.pmc_id, "status": "skipped"}
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
# Step 1: Validate document
|
|
552
|
+
if not self._validate_document(document):
|
|
553
|
+
return {
|
|
554
|
+
"pmc_id": document.pmc_id,
|
|
555
|
+
"status": "failed",
|
|
556
|
+
"error": "validation_failed",
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
# Step 2: Clean and preprocess text
|
|
560
|
+
document = self._preprocess_document(document)
|
|
561
|
+
|
|
562
|
+
# Step 3: Calculate quality score
|
|
563
|
+
document.quality_score = self._calculate_quality_score(document)
|
|
564
|
+
|
|
565
|
+
if document.quality_score < self.config.min_quality_score:
|
|
566
|
+
return {
|
|
567
|
+
"pmc_id": document.pmc_id,
|
|
568
|
+
"status": "failed",
|
|
569
|
+
"error": "low_quality",
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
# Step 4: Extract biomedical entities
|
|
573
|
+
document = self._extract_biomedical_entities(document)
|
|
574
|
+
|
|
575
|
+
# Step 5: Create document chunks
|
|
576
|
+
chunks = self._create_document_chunks(document)
|
|
577
|
+
document.chunk_count = len(chunks)
|
|
578
|
+
|
|
579
|
+
# Step 6: Generate embeddings
|
|
580
|
+
chunks_with_embeddings = self._generate_chunk_embeddings(chunks)
|
|
581
|
+
|
|
582
|
+
# Step 7: Store in databases
|
|
583
|
+
self._store_document_and_chunks(document, chunks_with_embeddings)
|
|
584
|
+
|
|
585
|
+
# Mark as processed
|
|
586
|
+
self.processing_state["processed_documents"].add(document.pmc_id)
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
"pmc_id": document.pmc_id,
|
|
590
|
+
"status": "success",
|
|
591
|
+
"chunks_created": len(chunks),
|
|
592
|
+
"quality_score": document.quality_score,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
except Exception as e:
|
|
596
|
+
# Log error
|
|
597
|
+
self._log_processing_error(document.pmc_id, str(e))
|
|
598
|
+
|
|
599
|
+
# Save failed document if configured
|
|
600
|
+
if self.config.save_failed_documents:
|
|
601
|
+
self._save_failed_document(document, str(e))
|
|
602
|
+
|
|
603
|
+
return {"pmc_id": document.pmc_id, "status": "failed", "error": str(e)}
|
|
604
|
+
|
|
605
|
+
def _validate_document(self, document: PMCDocument) -> bool:
|
|
606
|
+
"""Validate document meets quality requirements."""
|
|
607
|
+
|
|
608
|
+
# Check required fields
|
|
609
|
+
if not document.pmc_id or not document.title:
|
|
610
|
+
return False
|
|
611
|
+
|
|
612
|
+
# Check minimum content
|
|
613
|
+
content = f"{document.abstract} {document.full_text}".strip()
|
|
614
|
+
if len(content.split()) < self.config.min_word_count:
|
|
615
|
+
return False
|
|
616
|
+
|
|
617
|
+
# Check language
|
|
618
|
+
if document.language and document.language != "en":
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
return True
|
|
622
|
+
|
|
623
|
+
def _preprocess_document(self, document: PMCDocument) -> PMCDocument:
|
|
624
|
+
"""Clean and preprocess document text."""
|
|
625
|
+
|
|
626
|
+
# Fix text encoding issues
|
|
627
|
+
document.title = ftfy.fix_text(document.title)
|
|
628
|
+
document.abstract = ftfy.fix_text(document.abstract)
|
|
629
|
+
document.full_text = ftfy.fix_text(document.full_text)
|
|
630
|
+
|
|
631
|
+
# Remove excessive whitespace
|
|
632
|
+
document.title = re.sub(r"\s+", " ", document.title).strip()
|
|
633
|
+
document.abstract = re.sub(r"\s+", " ", document.abstract).strip()
|
|
634
|
+
document.full_text = re.sub(r"\s+", " ", document.full_text).strip()
|
|
635
|
+
|
|
636
|
+
# Remove unwanted characters
|
|
637
|
+
document.full_text = re.sub(
|
|
638
|
+
r"[^\w\s\.\,\;\:\!\?\-\(\)]", " ", document.full_text
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
return document
|
|
642
|
+
|
|
643
|
+
def _calculate_quality_score(self, document: PMCDocument) -> float:
|
|
644
|
+
"""Calculate document quality score."""
|
|
645
|
+
|
|
646
|
+
score = 0.0
|
|
647
|
+
|
|
648
|
+
# Title quality (0.2 weight)
|
|
649
|
+
if document.title and len(document.title.split()) >= 3:
|
|
650
|
+
score += 0.2
|
|
651
|
+
|
|
652
|
+
# Abstract quality (0.3 weight)
|
|
653
|
+
if document.abstract and len(document.abstract.split()) >= 50:
|
|
654
|
+
score += 0.3
|
|
655
|
+
|
|
656
|
+
# Content length (0.2 weight)
|
|
657
|
+
word_count = len(document.full_text.split())
|
|
658
|
+
if word_count >= 1000:
|
|
659
|
+
score += 0.2
|
|
660
|
+
elif word_count >= 500:
|
|
661
|
+
score += 0.1
|
|
662
|
+
|
|
663
|
+
# Biomedical relevance (0.3 weight)
|
|
664
|
+
biomedical_score = self._assess_biomedical_relevance(document)
|
|
665
|
+
score += 0.3 * biomedical_score
|
|
666
|
+
|
|
667
|
+
return min(1.0, score)
|
|
668
|
+
|
|
669
|
+
def _assess_biomedical_relevance(self, document: PMCDocument) -> float:
|
|
670
|
+
"""Assess biomedical relevance of document."""
|
|
671
|
+
|
|
672
|
+
biomedical_keywords = [
|
|
673
|
+
"patient",
|
|
674
|
+
"treatment",
|
|
675
|
+
"disease",
|
|
676
|
+
"clinical",
|
|
677
|
+
"medical",
|
|
678
|
+
"therapy",
|
|
679
|
+
"diagnosis",
|
|
680
|
+
"symptom",
|
|
681
|
+
"drug",
|
|
682
|
+
"medication",
|
|
683
|
+
"hospital",
|
|
684
|
+
"doctor",
|
|
685
|
+
"health",
|
|
686
|
+
"medicine",
|
|
687
|
+
"study",
|
|
688
|
+
"trial",
|
|
689
|
+
"research",
|
|
690
|
+
"analysis",
|
|
691
|
+
]
|
|
692
|
+
|
|
693
|
+
content = f"{document.title} {document.abstract} {document.full_text}".lower()
|
|
694
|
+
|
|
695
|
+
# Count biomedical keyword occurrences
|
|
696
|
+
keyword_count = sum(1 for keyword in biomedical_keywords if keyword in content)
|
|
697
|
+
|
|
698
|
+
# Normalize by total keywords
|
|
699
|
+
relevance_score = keyword_count / len(biomedical_keywords)
|
|
700
|
+
|
|
701
|
+
# Check for MeSH terms
|
|
702
|
+
if document.mesh_terms:
|
|
703
|
+
relevance_score += 0.2
|
|
704
|
+
|
|
705
|
+
# Check journal name
|
|
706
|
+
if document.journal and any(
|
|
707
|
+
term in document.journal.lower()
|
|
708
|
+
for term in ["medical", "clinical", "health", "medicine"]
|
|
709
|
+
):
|
|
710
|
+
relevance_score += 0.1
|
|
711
|
+
|
|
712
|
+
return min(1.0, relevance_score)
|
|
713
|
+
|
|
714
|
+
def _extract_biomedical_entities(self, document: PMCDocument) -> PMCDocument:
|
|
715
|
+
"""Extract biomedical entities using NLP."""
|
|
716
|
+
|
|
717
|
+
content = f"{document.abstract} {document.full_text}"
|
|
718
|
+
|
|
719
|
+
# Process with SpaCy
|
|
720
|
+
doc = self.nlp(content[:1000000]) # Limit for memory
|
|
721
|
+
|
|
722
|
+
entities = []
|
|
723
|
+
diseases = []
|
|
724
|
+
drugs = []
|
|
725
|
+
procedures = []
|
|
726
|
+
|
|
727
|
+
for ent in doc.ents:
|
|
728
|
+
entity_info = {
|
|
729
|
+
"text": ent.text,
|
|
730
|
+
"label": ent.label_,
|
|
731
|
+
"start": ent.start_char,
|
|
732
|
+
"end": ent.end_char,
|
|
733
|
+
}
|
|
734
|
+
entities.append(entity_info)
|
|
735
|
+
|
|
736
|
+
# Categorize entities
|
|
737
|
+
if ent.label_ in ["DISEASE"]:
|
|
738
|
+
diseases.append(ent.text)
|
|
739
|
+
elif ent.label_ in ["CHEMICAL", "DRUG"]:
|
|
740
|
+
drugs.append(ent.text)
|
|
741
|
+
elif ent.label_ in ["PROCEDURE"]:
|
|
742
|
+
procedures.append(ent.text)
|
|
743
|
+
|
|
744
|
+
document.medical_entities = entities
|
|
745
|
+
document.disease_mentions = list(set(diseases))
|
|
746
|
+
document.drug_mentions = list(set(drugs))
|
|
747
|
+
document.procedure_mentions = list(set(procedures))
|
|
748
|
+
|
|
749
|
+
return document
|
|
750
|
+
|
|
751
|
+
def _create_document_chunks(self, document: PMCDocument) -> List[DocumentChunk]:
|
|
752
|
+
"""Create intelligent chunks from document."""
|
|
753
|
+
|
|
754
|
+
chunks = []
|
|
755
|
+
|
|
756
|
+
# Abstract chunk
|
|
757
|
+
if document.abstract:
|
|
758
|
+
chunks.append(
|
|
759
|
+
self._create_chunk(document.pmc_id, document.abstract, "abstract", 0)
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
# Full text chunks
|
|
763
|
+
if document.full_text:
|
|
764
|
+
text_chunks = self._split_text_intelligent(
|
|
765
|
+
document.full_text, document.pmc_id
|
|
766
|
+
)
|
|
767
|
+
chunks.extend(text_chunks)
|
|
768
|
+
|
|
769
|
+
return chunks
|
|
770
|
+
|
|
771
|
+
def _split_text_intelligent(self, text: str, pmc_id: str) -> List[DocumentChunk]:
|
|
772
|
+
"""Split text using intelligent chunking strategy."""
|
|
773
|
+
|
|
774
|
+
chunks = []
|
|
775
|
+
sentences = sent_tokenize(text)
|
|
776
|
+
|
|
777
|
+
current_chunk = ""
|
|
778
|
+
current_tokens = 0
|
|
779
|
+
chunk_index = 1
|
|
780
|
+
|
|
781
|
+
for sentence in sentences:
|
|
782
|
+
sentence_tokens = len(self.tokenizer.encode(sentence))
|
|
783
|
+
|
|
784
|
+
# Check if adding this sentence exceeds chunk size
|
|
785
|
+
if (
|
|
786
|
+
current_tokens + sentence_tokens > self.config.chunk_size
|
|
787
|
+
and current_chunk
|
|
788
|
+
):
|
|
789
|
+
# Create chunk
|
|
790
|
+
chunk = self._create_chunk(
|
|
791
|
+
pmc_id, current_chunk.strip(), "content", chunk_index
|
|
792
|
+
)
|
|
793
|
+
chunks.append(chunk)
|
|
794
|
+
|
|
795
|
+
# Start new chunk with overlap
|
|
796
|
+
overlap_text = self._get_overlap_text(current_chunk)
|
|
797
|
+
current_chunk = overlap_text + " " + sentence
|
|
798
|
+
current_tokens = len(self.tokenizer.encode(current_chunk))
|
|
799
|
+
chunk_index += 1
|
|
800
|
+
else:
|
|
801
|
+
# Add sentence to current chunk
|
|
802
|
+
current_chunk += " " + sentence
|
|
803
|
+
current_tokens += sentence_tokens
|
|
804
|
+
|
|
805
|
+
# Add final chunk
|
|
806
|
+
if current_chunk.strip():
|
|
807
|
+
chunk = self._create_chunk(
|
|
808
|
+
pmc_id, current_chunk.strip(), "content", chunk_index
|
|
809
|
+
)
|
|
810
|
+
chunks.append(chunk)
|
|
811
|
+
|
|
812
|
+
return chunks
|
|
813
|
+
|
|
814
|
+
def _create_chunk(
|
|
815
|
+
self, pmc_id: str, content: str, chunk_type: str, index: int
|
|
816
|
+
) -> DocumentChunk:
|
|
817
|
+
"""Create a document chunk with metadata."""
|
|
818
|
+
|
|
819
|
+
chunk_id = f"{pmc_id}_chunk_{index}"
|
|
820
|
+
token_count = len(self.tokenizer.encode(content))
|
|
821
|
+
|
|
822
|
+
# Extract key terms
|
|
823
|
+
key_terms = self._extract_key_terms(content)
|
|
824
|
+
|
|
825
|
+
# Calculate quality metrics
|
|
826
|
+
information_density = self._calculate_information_density(content)
|
|
827
|
+
readability_score = self._calculate_readability_score(content)
|
|
828
|
+
biomedical_relevance = self._calculate_chunk_biomedical_relevance(content)
|
|
829
|
+
|
|
830
|
+
return DocumentChunk(
|
|
831
|
+
chunk_id=chunk_id,
|
|
832
|
+
pmc_id=pmc_id,
|
|
833
|
+
content=content,
|
|
834
|
+
chunk_type=chunk_type,
|
|
835
|
+
chunk_index=index,
|
|
836
|
+
token_count=token_count,
|
|
837
|
+
key_terms=key_terms,
|
|
838
|
+
information_density=information_density,
|
|
839
|
+
readability_score=readability_score,
|
|
840
|
+
biomedical_relevance=biomedical_relevance,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def _get_overlap_text(self, text: str) -> str:
|
|
844
|
+
"""Get overlap text for chunk continuity."""
|
|
845
|
+
|
|
846
|
+
sentences = sent_tokenize(text)
|
|
847
|
+
overlap_sentences = sentences[-2:] if len(sentences) >= 2 else sentences
|
|
848
|
+
return " ".join(overlap_sentences)
|
|
849
|
+
|
|
850
|
+
def _extract_key_terms(self, content: str) -> List[str]:
|
|
851
|
+
"""Extract key terms from content."""
|
|
852
|
+
|
|
853
|
+
# Simple TF-IDF based extraction
|
|
854
|
+
words = word_tokenize(content.lower())
|
|
855
|
+
words = [w for w in words if w.isalpha() and w not in self.stop_words]
|
|
856
|
+
|
|
857
|
+
# Return top terms
|
|
858
|
+
word_freq = defaultdict(int)
|
|
859
|
+
for word in words:
|
|
860
|
+
word_freq[word] += 1
|
|
861
|
+
|
|
862
|
+
return sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
863
|
+
|
|
864
|
+
def _calculate_information_density(self, content: str) -> float:
|
|
865
|
+
"""Calculate information density of content."""
|
|
866
|
+
|
|
867
|
+
words = content.split()
|
|
868
|
+
unique_words = set(words)
|
|
869
|
+
|
|
870
|
+
if len(words) == 0:
|
|
871
|
+
return 0.0
|
|
872
|
+
|
|
873
|
+
return len(unique_words) / len(words)
|
|
874
|
+
|
|
875
|
+
def _calculate_readability_score(self, content: str) -> float:
|
|
876
|
+
"""Calculate readability score (simplified)."""
|
|
877
|
+
|
|
878
|
+
sentences = sent_tokenize(content)
|
|
879
|
+
words = word_tokenize(content)
|
|
880
|
+
|
|
881
|
+
if len(sentences) == 0 or len(words) == 0:
|
|
882
|
+
return 0.0
|
|
883
|
+
|
|
884
|
+
avg_sentence_length = len(words) / len(sentences)
|
|
885
|
+
|
|
886
|
+
# Simple readability metric (inverse of average sentence length)
|
|
887
|
+
readability = 1.0 / (1.0 + avg_sentence_length / 20.0)
|
|
888
|
+
|
|
889
|
+
return min(1.0, readability)
|
|
890
|
+
|
|
891
|
+
def _calculate_chunk_biomedical_relevance(self, content: str) -> float:
|
|
892
|
+
"""Calculate biomedical relevance for chunk."""
|
|
893
|
+
|
|
894
|
+
# Similar to document-level but for chunks
|
|
895
|
+
biomedical_keywords = [
|
|
896
|
+
"patient",
|
|
897
|
+
"treatment",
|
|
898
|
+
"disease",
|
|
899
|
+
"clinical",
|
|
900
|
+
"medical",
|
|
901
|
+
"therapy",
|
|
902
|
+
"diagnosis",
|
|
903
|
+
"symptom",
|
|
904
|
+
"drug",
|
|
905
|
+
"medication",
|
|
906
|
+
]
|
|
907
|
+
|
|
908
|
+
content_lower = content.lower()
|
|
909
|
+
keyword_count = sum(
|
|
910
|
+
1 for keyword in biomedical_keywords if keyword in content_lower
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
return min(1.0, keyword_count / len(biomedical_keywords))
|
|
914
|
+
|
|
915
|
+
def _generate_chunk_embeddings(
|
|
916
|
+
self, chunks: List[DocumentChunk]
|
|
917
|
+
) -> List[DocumentChunk]:
|
|
918
|
+
"""Generate embeddings for chunks."""
|
|
919
|
+
|
|
920
|
+
# Batch process embeddings for efficiency
|
|
921
|
+
texts = [chunk.content for chunk in chunks]
|
|
922
|
+
|
|
923
|
+
try:
|
|
924
|
+
# Generate embeddings
|
|
925
|
+
embeddings = self.embedding_model.encode(
|
|
926
|
+
texts, batch_size=32, show_progress_bar=False
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
# Assign embeddings to chunks
|
|
930
|
+
for chunk, embedding in zip(chunks, embeddings):
|
|
931
|
+
chunk.embedding_vector = embedding
|
|
932
|
+
|
|
933
|
+
except Exception as e:
|
|
934
|
+
logger.error(f"Embedding generation failed: {e}")
|
|
935
|
+
# Continue without embeddings
|
|
936
|
+
|
|
937
|
+
return chunks
|
|
938
|
+
|
|
939
|
+
def _store_document_and_chunks(
|
|
940
|
+
self, document: PMCDocument, chunks: List[DocumentChunk]
|
|
941
|
+
):
|
|
942
|
+
"""Store document and chunks in databases."""
|
|
943
|
+
|
|
944
|
+
# Store in metadata database
|
|
945
|
+
self._store_in_metadata_db(document, chunks)
|
|
946
|
+
|
|
947
|
+
# Store in vector database (if available)
|
|
948
|
+
if self.vector_db_connection:
|
|
949
|
+
self._store_in_vector_db(chunks)
|
|
950
|
+
|
|
951
|
+
def _store_in_metadata_db(self, document: PMCDocument, chunks: List[DocumentChunk]):
|
|
952
|
+
"""Store document and chunks in SQLite metadata database."""
|
|
953
|
+
|
|
954
|
+
conn = sqlite3.connect(self.metadata_db_path)
|
|
955
|
+
cursor = conn.cursor()
|
|
956
|
+
|
|
957
|
+
try:
|
|
958
|
+
# Store document
|
|
959
|
+
cursor.execute(
|
|
960
|
+
"""
|
|
961
|
+
INSERT OR REPLACE INTO documents VALUES
|
|
962
|
+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
963
|
+
""",
|
|
964
|
+
(
|
|
965
|
+
document.pmc_id,
|
|
966
|
+
document.title,
|
|
967
|
+
document.abstract,
|
|
968
|
+
json.dumps(document.authors),
|
|
969
|
+
document.publication_date,
|
|
970
|
+
document.journal,
|
|
971
|
+
document.doi,
|
|
972
|
+
json.dumps(document.keywords),
|
|
973
|
+
json.dumps(document.mesh_terms),
|
|
974
|
+
document.content_type,
|
|
975
|
+
document.language,
|
|
976
|
+
document.word_count,
|
|
977
|
+
document.chunk_count,
|
|
978
|
+
document.quality_score,
|
|
979
|
+
document.processing_timestamp,
|
|
980
|
+
document.embedding_generated,
|
|
981
|
+
),
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
# Store chunks
|
|
985
|
+
for chunk in chunks:
|
|
986
|
+
cursor.execute(
|
|
987
|
+
"""
|
|
988
|
+
INSERT OR REPLACE INTO chunks VALUES
|
|
989
|
+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
990
|
+
""",
|
|
991
|
+
(
|
|
992
|
+
chunk.chunk_id,
|
|
993
|
+
chunk.pmc_id,
|
|
994
|
+
chunk.content,
|
|
995
|
+
chunk.chunk_type,
|
|
996
|
+
chunk.chunk_index,
|
|
997
|
+
chunk.token_count,
|
|
998
|
+
chunk.overlap_with_previous,
|
|
999
|
+
chunk.information_density,
|
|
1000
|
+
chunk.readability_score,
|
|
1001
|
+
chunk.biomedical_relevance,
|
|
1002
|
+
),
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
conn.commit()
|
|
1006
|
+
|
|
1007
|
+
except Exception as e:
|
|
1008
|
+
logger.error(f"Database storage failed: {e}")
|
|
1009
|
+
conn.rollback()
|
|
1010
|
+
raise
|
|
1011
|
+
|
|
1012
|
+
finally:
|
|
1013
|
+
conn.close()
|
|
1014
|
+
|
|
1015
|
+
def _store_in_vector_db(self, chunks: List[DocumentChunk]):
|
|
1016
|
+
"""Store chunk embeddings in vector database."""
|
|
1017
|
+
|
|
1018
|
+
# This would integrate with the existing IRIS vector database
|
|
1019
|
+
# Placeholder implementation
|
|
1020
|
+
for chunk in chunks:
|
|
1021
|
+
if chunk.embedding_vector is not None:
|
|
1022
|
+
# Store embedding with metadata
|
|
1023
|
+
pass
|
|
1024
|
+
|
|
1025
|
+
def _log_processing_error(self, pmc_id: str, error_message: str):
|
|
1026
|
+
"""Log processing error to database."""
|
|
1027
|
+
|
|
1028
|
+
conn = sqlite3.connect(self.metadata_db_path)
|
|
1029
|
+
cursor = conn.cursor()
|
|
1030
|
+
|
|
1031
|
+
cursor.execute(
|
|
1032
|
+
"""
|
|
1033
|
+
INSERT INTO processing_log VALUES (?, ?, ?, ?, ?, ?)
|
|
1034
|
+
""",
|
|
1035
|
+
(
|
|
1036
|
+
None,
|
|
1037
|
+
pmc_id,
|
|
1038
|
+
"process_document",
|
|
1039
|
+
"failed",
|
|
1040
|
+
error_message,
|
|
1041
|
+
datetime.now().isoformat(),
|
|
1042
|
+
),
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
conn.commit()
|
|
1046
|
+
conn.close()
|
|
1047
|
+
|
|
1048
|
+
def _save_failed_document(self, document: PMCDocument, error: str):
|
|
1049
|
+
"""Save failed document for later analysis."""
|
|
1050
|
+
|
|
1051
|
+
failed_dir = Path(self.config.cache_dir) / "failed_docs"
|
|
1052
|
+
failed_file = failed_dir / f"{document.pmc_id}_failed.json"
|
|
1053
|
+
|
|
1054
|
+
failed_data = {
|
|
1055
|
+
"document": document.__dict__,
|
|
1056
|
+
"error": error,
|
|
1057
|
+
"timestamp": datetime.now().isoformat(),
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
with open(failed_file, "w") as f:
|
|
1061
|
+
json.dump(failed_data, f, indent=2, default=str)
|
|
1062
|
+
|
|
1063
|
+
def _update_stats(self, batch_results: List[Dict[str, Any]]):
|
|
1064
|
+
"""Update processing statistics."""
|
|
1065
|
+
|
|
1066
|
+
for result in batch_results:
|
|
1067
|
+
self.stats.total_documents += 1
|
|
1068
|
+
|
|
1069
|
+
if result["status"] == "success":
|
|
1070
|
+
self.stats.successful_documents += 1
|
|
1071
|
+
if "chunks_created" in result:
|
|
1072
|
+
self.stats.total_chunks += result["chunks_created"]
|
|
1073
|
+
elif result["status"] == "failed":
|
|
1074
|
+
self.stats.failed_documents += 1
|
|
1075
|
+
error_type = result.get("error", "unknown")
|
|
1076
|
+
self.stats.error_types[error_type] = (
|
|
1077
|
+
self.stats.error_types.get(error_type, 0) + 1
|
|
1078
|
+
)
|
|
1079
|
+
self.stats.failed_document_ids.append(result["pmc_id"])
|
|
1080
|
+
|
|
1081
|
+
def _log_progress(self):
|
|
1082
|
+
"""Log current progress."""
|
|
1083
|
+
|
|
1084
|
+
success_rate = (
|
|
1085
|
+
self.stats.successful_documents / max(1, self.stats.total_documents)
|
|
1086
|
+
) * 100
|
|
1087
|
+
|
|
1088
|
+
logger.info(
|
|
1089
|
+
f"Progress: {self.stats.successful_documents}/{self.stats.total_documents} "
|
|
1090
|
+
f"documents processed ({success_rate:.1f}% success rate), "
|
|
1091
|
+
f"{self.stats.total_chunks} chunks created"
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
def _save_checkpoint(self):
|
|
1095
|
+
"""Save processing checkpoint."""
|
|
1096
|
+
|
|
1097
|
+
checkpoint_file = Path(self.config.cache_dir) / "checkpoint.json"
|
|
1098
|
+
|
|
1099
|
+
checkpoint_data = {
|
|
1100
|
+
"processing_state": {
|
|
1101
|
+
"current_batch": self.processing_state["current_batch"],
|
|
1102
|
+
"processed_documents": list(
|
|
1103
|
+
self.processing_state["processed_documents"]
|
|
1104
|
+
),
|
|
1105
|
+
"failed_documents": list(self.processing_state["failed_documents"]),
|
|
1106
|
+
},
|
|
1107
|
+
"stats": self.stats.__dict__,
|
|
1108
|
+
"timestamp": datetime.now().isoformat(),
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
with open(checkpoint_file, "w") as f:
|
|
1112
|
+
json.dump(checkpoint_data, f, indent=2, default=str)
|
|
1113
|
+
|
|
1114
|
+
def _load_checkpoint(self):
|
|
1115
|
+
"""Load processing checkpoint."""
|
|
1116
|
+
|
|
1117
|
+
checkpoint_file = Path(self.config.cache_dir) / "checkpoint.json"
|
|
1118
|
+
|
|
1119
|
+
if checkpoint_file.exists():
|
|
1120
|
+
with open(checkpoint_file, "r") as f:
|
|
1121
|
+
checkpoint_data = json.load(f)
|
|
1122
|
+
|
|
1123
|
+
# Restore processing state
|
|
1124
|
+
state = checkpoint_data["processing_state"]
|
|
1125
|
+
self.processing_state["current_batch"] = state["current_batch"]
|
|
1126
|
+
self.processing_state["processed_documents"] = set(
|
|
1127
|
+
state["processed_documents"]
|
|
1128
|
+
)
|
|
1129
|
+
self.processing_state["failed_documents"] = set(state["failed_documents"])
|
|
1130
|
+
|
|
1131
|
+
logger.info(f"Resumed from checkpoint: batch {state['current_batch']}")
|
|
1132
|
+
|
|
1133
|
+
def _finalize_processing(self):
|
|
1134
|
+
"""Finalize processing and generate reports."""
|
|
1135
|
+
|
|
1136
|
+
# Calculate final statistics
|
|
1137
|
+
if self.stats.total_documents > 0:
|
|
1138
|
+
self.stats.avg_document_quality = self._calculate_avg_quality()
|
|
1139
|
+
|
|
1140
|
+
# Generate processing report
|
|
1141
|
+
self._generate_processing_report()
|
|
1142
|
+
|
|
1143
|
+
# Clean up temporary files
|
|
1144
|
+
self._cleanup_temp_files()
|
|
1145
|
+
|
|
1146
|
+
def _calculate_avg_quality(self) -> float:
|
|
1147
|
+
"""Calculate average document quality from database."""
|
|
1148
|
+
|
|
1149
|
+
conn = sqlite3.connect(self.metadata_db_path)
|
|
1150
|
+
cursor = conn.cursor()
|
|
1151
|
+
|
|
1152
|
+
cursor.execute(
|
|
1153
|
+
"SELECT AVG(quality_score) FROM documents WHERE quality_score > 0"
|
|
1154
|
+
)
|
|
1155
|
+
result = cursor.fetchone()
|
|
1156
|
+
|
|
1157
|
+
conn.close()
|
|
1158
|
+
|
|
1159
|
+
return result[0] if result[0] else 0.0
|
|
1160
|
+
|
|
1161
|
+
def _generate_processing_report(self):
|
|
1162
|
+
"""Generate comprehensive processing report."""
|
|
1163
|
+
|
|
1164
|
+
report_dir = Path(self.config.output_data_path) / "reports"
|
|
1165
|
+
report_dir.mkdir(exist_ok=True)
|
|
1166
|
+
|
|
1167
|
+
report_file = (
|
|
1168
|
+
report_dir
|
|
1169
|
+
/ f"processing_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
report_data = {
|
|
1173
|
+
"processing_config": self.config.__dict__,
|
|
1174
|
+
"processing_stats": self.stats.__dict__,
|
|
1175
|
+
"processing_time": self.stats.processing_time,
|
|
1176
|
+
"success_rate": (
|
|
1177
|
+
self.stats.successful_documents / max(1, self.stats.total_documents)
|
|
1178
|
+
)
|
|
1179
|
+
* 100,
|
|
1180
|
+
"average_chunks_per_doc": (
|
|
1181
|
+
self.stats.total_chunks / max(1, self.stats.successful_documents)
|
|
1182
|
+
),
|
|
1183
|
+
"error_analysis": self.stats.error_types,
|
|
1184
|
+
"timestamp": datetime.now().isoformat(),
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
with open(report_file, "w") as f:
|
|
1188
|
+
json.dump(report_data, f, indent=2, default=str)
|
|
1189
|
+
|
|
1190
|
+
logger.info(f"Processing report saved to {report_file}")
|
|
1191
|
+
|
|
1192
|
+
def _cleanup_temp_files(self):
|
|
1193
|
+
"""Clean up temporary files."""
|
|
1194
|
+
|
|
1195
|
+
# Clean up cache files older than 7 days
|
|
1196
|
+
cache_dir = Path(self.config.cache_dir)
|
|
1197
|
+
cutoff_time = time.time() - (7 * 24 * 60 * 60) # 7 days
|
|
1198
|
+
|
|
1199
|
+
for file_path in cache_dir.rglob("*"):
|
|
1200
|
+
if file_path.is_file() and file_path.stat().st_mtime < cutoff_time:
|
|
1201
|
+
try:
|
|
1202
|
+
file_path.unlink()
|
|
1203
|
+
except:
|
|
1204
|
+
pass
|
|
1205
|
+
|
|
1206
|
+
# Additional utility methods for XML parsing
|
|
1207
|
+
def _extract_pmc_id(self, root) -> str:
|
|
1208
|
+
"""Extract PMC ID from XML."""
|
|
1209
|
+
# Implementation for PMC XML parsing
|
|
1210
|
+
return "PMC" + str(hash(str(root)))[-8:]
|
|
1211
|
+
|
|
1212
|
+
def _extract_title(self, root) -> str:
|
|
1213
|
+
"""Extract title from XML."""
|
|
1214
|
+
title_elem = root.find(".//article-title")
|
|
1215
|
+
return title_elem.text if title_elem is not None else ""
|
|
1216
|
+
|
|
1217
|
+
def _extract_abstract(self, root) -> str:
|
|
1218
|
+
"""Extract abstract from XML."""
|
|
1219
|
+
abstract_elem = root.find(".//abstract")
|
|
1220
|
+
if abstract_elem is not None:
|
|
1221
|
+
return " ".join(abstract_elem.itertext())
|
|
1222
|
+
return ""
|
|
1223
|
+
|
|
1224
|
+
def _extract_full_text(self, root) -> str:
|
|
1225
|
+
"""Extract full text from XML."""
|
|
1226
|
+
body_elem = root.find(".//body")
|
|
1227
|
+
if body_elem is not None:
|
|
1228
|
+
return " ".join(body_elem.itertext())
|
|
1229
|
+
return ""
|
|
1230
|
+
|
|
1231
|
+
def _extract_authors(self, root) -> List[str]:
|
|
1232
|
+
"""Extract authors from XML."""
|
|
1233
|
+
authors = []
|
|
1234
|
+
for contrib in root.findall('.//contrib[@contrib-type="author"]'):
|
|
1235
|
+
name_elem = contrib.find(".//string-name")
|
|
1236
|
+
if name_elem is not None:
|
|
1237
|
+
authors.append("".join(name_elem.itertext()))
|
|
1238
|
+
return authors
|
|
1239
|
+
|
|
1240
|
+
def _extract_metadata(self, root) -> Dict[str, Any]:
|
|
1241
|
+
"""Extract additional metadata from XML."""
|
|
1242
|
+
metadata = {
|
|
1243
|
+
"publication_date": "",
|
|
1244
|
+
"journal": "",
|
|
1245
|
+
"doi": "",
|
|
1246
|
+
"keywords": [],
|
|
1247
|
+
"mesh_terms": [],
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
# Extract publication date
|
|
1251
|
+
pub_date = root.find(".//pub-date")
|
|
1252
|
+
if pub_date is not None:
|
|
1253
|
+
year = pub_date.find(".//year")
|
|
1254
|
+
month = pub_date.find(".//month")
|
|
1255
|
+
day = pub_date.find(".//day")
|
|
1256
|
+
|
|
1257
|
+
date_parts = []
|
|
1258
|
+
if year is not None:
|
|
1259
|
+
date_parts.append(year.text)
|
|
1260
|
+
if month is not None:
|
|
1261
|
+
date_parts.append(month.text)
|
|
1262
|
+
if day is not None:
|
|
1263
|
+
date_parts.append(day.text)
|
|
1264
|
+
|
|
1265
|
+
metadata["publication_date"] = "-".join(date_parts)
|
|
1266
|
+
|
|
1267
|
+
# Extract journal name
|
|
1268
|
+
journal_elem = root.find(".//journal-title")
|
|
1269
|
+
if journal_elem is not None:
|
|
1270
|
+
metadata["journal"] = journal_elem.text
|
|
1271
|
+
|
|
1272
|
+
# Extract DOI
|
|
1273
|
+
doi_elem = root.find('.//article-id[@pub-id-type="doi"]')
|
|
1274
|
+
if doi_elem is not None:
|
|
1275
|
+
metadata["doi"] = doi_elem.text
|
|
1276
|
+
|
|
1277
|
+
# Extract keywords
|
|
1278
|
+
for kwd in root.findall(".//kwd"):
|
|
1279
|
+
if kwd.text:
|
|
1280
|
+
metadata["keywords"].append(kwd.text)
|
|
1281
|
+
|
|
1282
|
+
return metadata
|
|
1283
|
+
|
|
1284
|
+
def _load_compressed_documents(self, file_path: Path) -> Iterator[PMCDocument]:
|
|
1285
|
+
"""Load documents from compressed files."""
|
|
1286
|
+
# Placeholder for compressed file handling
|
|
1287
|
+
return iter([])
|
|
1288
|
+
|
|
1289
|
+
def _load_documents_from_api(self, api_endpoint: str) -> Iterator[PMCDocument]:
|
|
1290
|
+
"""Load documents from API endpoint."""
|
|
1291
|
+
# Placeholder for API document loading
|
|
1292
|
+
return iter([])
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def create_pmc_data_pipeline(
|
|
1296
|
+
config: Optional[ProcessingConfig] = None,
|
|
1297
|
+
) -> PMCDataPipeline:
|
|
1298
|
+
"""Factory function to create a configured PMC data pipeline."""
|
|
1299
|
+
return PMCDataPipeline(config)
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
if __name__ == "__main__":
|
|
1303
|
+
# Example usage
|
|
1304
|
+
config = ProcessingConfig(
|
|
1305
|
+
max_documents=100, batch_size=10, max_workers=4 # Limit for testing
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
pipeline = create_pmc_data_pipeline(config)
|
|
1309
|
+
|
|
1310
|
+
# Sample documents for testing
|
|
1311
|
+
sample_docs = [
|
|
1312
|
+
PMCDocument(
|
|
1313
|
+
pmc_id="PMC123456",
|
|
1314
|
+
title="Effects of Metformin on Type 2 Diabetes",
|
|
1315
|
+
abstract="This study investigates the effects of metformin on glycemic control in patients with type 2 diabetes.",
|
|
1316
|
+
full_text="Background: Type 2 diabetes is a chronic metabolic disorder. Methods: We conducted a randomized controlled trial. Results: Metformin significantly improved glycemic control. Discussion: These findings support the use of metformin as first-line therapy.",
|
|
1317
|
+
authors=["John Smith", "Jane Doe"],
|
|
1318
|
+
publication_date="2023-01-01",
|
|
1319
|
+
journal="Diabetes Care",
|
|
1320
|
+
keywords=["diabetes", "metformin", "glycemic control"],
|
|
1321
|
+
)
|
|
1322
|
+
]
|
|
1323
|
+
|
|
1324
|
+
# Process documents
|
|
1325
|
+
stats = pipeline.process_documents(sample_docs)
|
|
1326
|
+
|
|
1327
|
+
print(f"Processing completed:")
|
|
1328
|
+
print(f"- Total documents: {stats.total_documents}")
|
|
1329
|
+
print(f"- Successful: {stats.successful_documents}")
|
|
1330
|
+
print(f"- Failed: {stats.failed_documents}")
|
|
1331
|
+
print(f"- Total chunks: {stats.total_chunks}")
|
|
1332
|
+
print(f"- Processing time: {stats.processing_time:.2f}s")
|