structx 0.4.8__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.
- structx/__init__.py +24 -0
- structx/core/__init__.py +0 -0
- structx/core/config.py +127 -0
- structx/core/exceptions.py +34 -0
- structx/core/models.py +147 -0
- structx/extraction/__init__.py +38 -0
- structx/extraction/core/__init__.py +6 -0
- structx/extraction/core/llm_core.py +206 -0
- structx/extraction/core/model_utils.py +143 -0
- structx/extraction/engines/__init__.py +5 -0
- structx/extraction/engines/extraction_engine.py +221 -0
- structx/extraction/extractor.py +540 -0
- structx/extraction/generator.py +232 -0
- structx/extraction/processors/__init__.py +6 -0
- structx/extraction/processors/data_content.py +367 -0
- structx/extraction/processors/model_operations.py +233 -0
- structx/extraction/result_manager.py +125 -0
- structx/utils/__init__.py +0 -0
- structx/utils/constants.py +36 -0
- structx/utils/file_reader.py +298 -0
- structx/utils/helpers.py +233 -0
- structx/utils/prompts.py +183 -0
- structx/utils/types.py +12 -0
- structx/utils/usage.py +327 -0
- structx-0.4.8.dist-info/METADATA +313 -0
- structx-0.4.8.dist-info/RECORD +29 -0
- structx-0.4.8.dist-info/WHEEL +5 -0
- structx-0.4.8.dist-info/licenses/LICENSE +21 -0
- structx-0.4.8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from typing import Dict, List, Optional, Type
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
from pydantic import BaseModel, Field, create_model
|
|
5
|
+
from pydantic._internal._model_construction import ModelMetaclass
|
|
6
|
+
|
|
7
|
+
from structx.core.exceptions import ModelGenerationError
|
|
8
|
+
from structx.core.models import ExtractionRequest, ModelField
|
|
9
|
+
from structx.utils.constants import SAFE_TYPES, TYPE_ALIASES
|
|
10
|
+
from structx.utils.helpers import handle_errors
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModelGenerator(ModelMetaclass):
|
|
14
|
+
"""Metaclass for generating dynamic Pydantic models"""
|
|
15
|
+
|
|
16
|
+
_model_registry = {} # Class variable to store created models
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def _register_model(cls, model_name: str, model: Type[BaseModel]):
|
|
20
|
+
"""Register a created model for reference"""
|
|
21
|
+
cls._model_registry[model_name] = model
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def _get_registered_model(cls, model_name: str) -> Optional[Type[BaseModel]]:
|
|
25
|
+
"""Get a registered model by name"""
|
|
26
|
+
return cls._model_registry.get(model_name)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
@handle_errors(error_message="Error parsing generic type", error_type=ValueError)
|
|
30
|
+
def _parse_generic_type(cls, type_str: str) -> Type:
|
|
31
|
+
"""Parse and validate generic type strings"""
|
|
32
|
+
base_type = type_str[: type_str.index("[")] if "[" in type_str else type_str
|
|
33
|
+
|
|
34
|
+
# Check if it's a generic type constructor
|
|
35
|
+
if base_type not in SAFE_TYPES:
|
|
36
|
+
raise ValueError(f"Unsupported base type: {base_type}")
|
|
37
|
+
|
|
38
|
+
if "[" not in type_str:
|
|
39
|
+
return SAFE_TYPES[base_type]
|
|
40
|
+
|
|
41
|
+
params_str = type_str[type_str.index("[") + 1 : type_str.rindex("]")]
|
|
42
|
+
|
|
43
|
+
if base_type == "Dict":
|
|
44
|
+
key_type_str, value_type_str = params_str.split(",", 1)
|
|
45
|
+
key_type = cls._evaluate_type(key_type_str.strip())
|
|
46
|
+
value_type = cls._evaluate_type(value_type_str.strip())
|
|
47
|
+
return Dict[key_type, value_type]
|
|
48
|
+
|
|
49
|
+
elif base_type in ("List", "Optional"):
|
|
50
|
+
inner_type = cls._evaluate_type(params_str.strip())
|
|
51
|
+
return SAFE_TYPES[base_type][inner_type]
|
|
52
|
+
|
|
53
|
+
raise ValueError(f"Unsupported generic type: {type_str}")
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def _normalize_type(cls, type_str: str) -> str:
|
|
57
|
+
"""Normalize type strings to Python types"""
|
|
58
|
+
# Remove any whitespace
|
|
59
|
+
type_str = type_str.strip()
|
|
60
|
+
|
|
61
|
+
# Check if it's a basic type alias
|
|
62
|
+
if type_str.lower() in TYPE_ALIASES:
|
|
63
|
+
return TYPE_ALIASES[type_str.lower()]
|
|
64
|
+
|
|
65
|
+
# Handle generic types (List[], Dict[], etc.)
|
|
66
|
+
if "[" in type_str and "]" in type_str:
|
|
67
|
+
base_type = type_str[: type_str.index("[")]
|
|
68
|
+
params = type_str[type_str.index("[") + 1 : type_str.rindex("]")]
|
|
69
|
+
|
|
70
|
+
# Normalize base type
|
|
71
|
+
if base_type.lower() in TYPE_ALIASES:
|
|
72
|
+
base_type = TYPE_ALIASES[base_type.lower()]
|
|
73
|
+
|
|
74
|
+
# Recursively normalize parameter types
|
|
75
|
+
param_types = [cls._normalize_type(p.strip()) for p in params.split(",")]
|
|
76
|
+
return f"{base_type}[{', '.join(param_types)}]"
|
|
77
|
+
|
|
78
|
+
return type_str
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
@handle_errors(
|
|
82
|
+
error_message="Error evaluating type",
|
|
83
|
+
error_type=ValueError,
|
|
84
|
+
default_return=str,
|
|
85
|
+
)
|
|
86
|
+
def _evaluate_type(cls, type_str: str) -> Type:
|
|
87
|
+
"""Safely evaluate type string"""
|
|
88
|
+
# Normalize the type string first
|
|
89
|
+
normalized_type = cls._normalize_type(type_str)
|
|
90
|
+
logger.debug(f"Normalized type '{type_str}' to '{normalized_type}'")
|
|
91
|
+
|
|
92
|
+
# Check if it's a base type
|
|
93
|
+
if normalized_type in SAFE_TYPES:
|
|
94
|
+
return SAFE_TYPES[normalized_type]
|
|
95
|
+
|
|
96
|
+
# Check if it's a registered model
|
|
97
|
+
if normalized_type in cls._model_registry:
|
|
98
|
+
return cls._get_registered_model(normalized_type)
|
|
99
|
+
|
|
100
|
+
# Check if it's a generic type
|
|
101
|
+
if any(
|
|
102
|
+
normalized_type.startswith(f"{t}[") for t in ("List", "Dict", "Optional")
|
|
103
|
+
):
|
|
104
|
+
return cls._parse_generic_type(normalized_type)
|
|
105
|
+
|
|
106
|
+
# If it's not a generic type and not registered, it might be a model
|
|
107
|
+
# that hasn't been created yet
|
|
108
|
+
logger.info(
|
|
109
|
+
f"Type '{normalized_type}' not found in registry, will be created later"
|
|
110
|
+
)
|
|
111
|
+
return normalized_type
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
@handle_errors(
|
|
115
|
+
error_message="Error creating nested model", error_type=ModelGenerationError
|
|
116
|
+
)
|
|
117
|
+
def _create_nested_model(
|
|
118
|
+
cls,
|
|
119
|
+
field_name: str,
|
|
120
|
+
field_description: str,
|
|
121
|
+
nested_fields: List[ModelField],
|
|
122
|
+
parent_name: str = "",
|
|
123
|
+
) -> Type[BaseModel]:
|
|
124
|
+
"""Create a nested Pydantic model"""
|
|
125
|
+
logger.debug(f"\nCreating nested model: {field_name}")
|
|
126
|
+
logger.debug(f"Parent name: {parent_name}")
|
|
127
|
+
|
|
128
|
+
# First create any nested models needed
|
|
129
|
+
nested_models = {}
|
|
130
|
+
for field in nested_fields:
|
|
131
|
+
if field.nested_fields:
|
|
132
|
+
nested_model = cls._create_nested_model(
|
|
133
|
+
field_name=field.name,
|
|
134
|
+
field_description=field.description,
|
|
135
|
+
nested_fields=field.nested_fields,
|
|
136
|
+
parent_name=field_name,
|
|
137
|
+
)
|
|
138
|
+
nested_models[field.name] = nested_model
|
|
139
|
+
cls._register_model(field.name, nested_model)
|
|
140
|
+
|
|
141
|
+
field_definitions: Dict[str, tuple[type, Field]] = {}
|
|
142
|
+
|
|
143
|
+
for field in nested_fields:
|
|
144
|
+
# Get base type
|
|
145
|
+
if field.name in nested_models:
|
|
146
|
+
field_type = nested_models[field.name]
|
|
147
|
+
else:
|
|
148
|
+
normalized_type = cls._normalize_type(field.type)
|
|
149
|
+
field_type = cls._evaluate_type(normalized_type)
|
|
150
|
+
|
|
151
|
+
# Handle List types
|
|
152
|
+
if isinstance(field.type, str) and (
|
|
153
|
+
field.type.startswith("List[")
|
|
154
|
+
or field.type.startswith("array[")
|
|
155
|
+
or field.type == "array"
|
|
156
|
+
):
|
|
157
|
+
if field.nested_fields:
|
|
158
|
+
# Create model for list items
|
|
159
|
+
item_model = cls._create_nested_model(
|
|
160
|
+
field_name=f"{field.name}Item",
|
|
161
|
+
field_description=field.description,
|
|
162
|
+
nested_fields=field.nested_fields,
|
|
163
|
+
parent_name=field_name,
|
|
164
|
+
)
|
|
165
|
+
field_type = List[item_model]
|
|
166
|
+
else:
|
|
167
|
+
field_type = List[field_type]
|
|
168
|
+
|
|
169
|
+
# Prepare validation dict without description to avoid conflicts
|
|
170
|
+
validation_dict = (field.validation or {}).copy()
|
|
171
|
+
validation_dict.pop("description", None)
|
|
172
|
+
|
|
173
|
+
field_definitions[field.name] = (
|
|
174
|
+
Optional[field_type],
|
|
175
|
+
Field(
|
|
176
|
+
default=None,
|
|
177
|
+
description=field.description,
|
|
178
|
+
**validation_dict,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
model_name = f"{parent_name}{field_name}" if parent_name else field_name
|
|
183
|
+
|
|
184
|
+
# Create the model using Pydantic v2 style
|
|
185
|
+
model = create_model(model_name, __base__=BaseModel, **field_definitions)
|
|
186
|
+
|
|
187
|
+
# Add description as model docstring
|
|
188
|
+
model.__doc__ = field_description
|
|
189
|
+
|
|
190
|
+
# Register the model
|
|
191
|
+
cls._register_model(model_name, model)
|
|
192
|
+
|
|
193
|
+
return model
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
@handle_errors(
|
|
197
|
+
error_message="Error generating model from extraction request",
|
|
198
|
+
error_type=ModelGenerationError,
|
|
199
|
+
)
|
|
200
|
+
def from_extraction_request(cls, request: ExtractionRequest) -> Type[BaseModel]:
|
|
201
|
+
"""Create a new model from extraction request"""
|
|
202
|
+
logger.info("\nStarting model generation from extraction request")
|
|
203
|
+
logger.info(f"Model name: {request.model_name}")
|
|
204
|
+
logger.info(f"Description: {request.model_description}")
|
|
205
|
+
logger.info("Fields:")
|
|
206
|
+
for field in request.fields:
|
|
207
|
+
logger.info(f"- {field.name}: {field.type}")
|
|
208
|
+
if field.nested_fields:
|
|
209
|
+
logger.info(
|
|
210
|
+
f" With nested fields: {[f.name for f in field.nested_fields]}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Reset model registry
|
|
214
|
+
cls._model_registry = {}
|
|
215
|
+
logger.debug("Reset model registry")
|
|
216
|
+
|
|
217
|
+
# Create the model
|
|
218
|
+
model = cls._create_nested_model(
|
|
219
|
+
field_name=request.model_name,
|
|
220
|
+
field_description=request.model_description,
|
|
221
|
+
nested_fields=request.fields,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
logger.info("\nModel generation completed")
|
|
225
|
+
|
|
226
|
+
return model
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class DynamicModel(BaseModel, metaclass=ModelGenerator):
|
|
230
|
+
"""Base class for dynamically generated models"""
|
|
231
|
+
|
|
232
|
+
pass
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data and content processing for extractions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from functools import partial
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Dict, List, Union
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from loguru import logger
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
|
|
15
|
+
from structx.core.exceptions import ExtractionError
|
|
16
|
+
from structx.utils.file_reader import FileReader
|
|
17
|
+
from structx.utils.helpers import handle_errors
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContentAnalyzer:
|
|
21
|
+
"""
|
|
22
|
+
Analyzes content types and extracts samples for schema generation.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def detect_content_type_and_context(df: pd.DataFrame) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Detect content type and provide context for better model generation.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
df: DataFrame to analyze
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
String describing the content type and context
|
|
35
|
+
"""
|
|
36
|
+
# Check if this is file-based extraction
|
|
37
|
+
is_file_based = "pdf_path" in df.columns or "source" in df.columns
|
|
38
|
+
|
|
39
|
+
if not is_file_based:
|
|
40
|
+
return "tabular data with structured columns"
|
|
41
|
+
|
|
42
|
+
# Analyze file types and provide context
|
|
43
|
+
file_types = set()
|
|
44
|
+
file_examples = []
|
|
45
|
+
|
|
46
|
+
for idx, row in df.iterrows():
|
|
47
|
+
if idx >= 5: # Limit analysis to first 5 files
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
if "pdf_path" in row and pd.notna(row["pdf_path"]):
|
|
51
|
+
file_types.add("PDF")
|
|
52
|
+
file_examples.append(Path(row["pdf_path"]).name)
|
|
53
|
+
elif "source" in row and pd.notna(row["source"]):
|
|
54
|
+
source_path = Path(row["source"])
|
|
55
|
+
ext = source_path.suffix.lower()
|
|
56
|
+
if ext in [".pdf"]:
|
|
57
|
+
file_types.add("PDF")
|
|
58
|
+
elif ext in [".docx", ".doc"]:
|
|
59
|
+
file_types.add("Word document")
|
|
60
|
+
elif ext in [".txt", ".md"]:
|
|
61
|
+
file_types.add("Text document")
|
|
62
|
+
else:
|
|
63
|
+
file_types.add(f"{ext} file")
|
|
64
|
+
file_examples.append(source_path.name)
|
|
65
|
+
|
|
66
|
+
context_info = f"document(s) of type: {', '.join(file_types)}"
|
|
67
|
+
if file_examples:
|
|
68
|
+
context_info += f". Examples: {', '.join(file_examples[:3])}"
|
|
69
|
+
if len(file_examples) > 3:
|
|
70
|
+
context_info += f" and {len(file_examples) - 3} more"
|
|
71
|
+
|
|
72
|
+
return context_info
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def extract_content_sample_for_schema(
|
|
76
|
+
df: pd.DataFrame, max_chars: int = 2000
|
|
77
|
+
) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Extract content samples from files to inform schema generation.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
df: DataFrame containing file information
|
|
83
|
+
max_chars: Maximum characters per sample
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
String containing sample content for schema generation
|
|
87
|
+
"""
|
|
88
|
+
samples = []
|
|
89
|
+
|
|
90
|
+
for idx, row in df.iterrows():
|
|
91
|
+
if idx >= 3: # Limit to first 3 files for sampling
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Check if this is a file-based row
|
|
96
|
+
if "pdf_path" in row and pd.notna(row["pdf_path"]):
|
|
97
|
+
# For PDF files, extract text sample using pypdf
|
|
98
|
+
try:
|
|
99
|
+
import pypdf
|
|
100
|
+
|
|
101
|
+
with open(row["pdf_path"], "rb") as file:
|
|
102
|
+
reader = pypdf.PdfReader(file)
|
|
103
|
+
text = ""
|
|
104
|
+
# Extract from first few pages
|
|
105
|
+
for page_num in range(min(3, len(reader.pages))):
|
|
106
|
+
text += reader.pages[page_num].extract_text()
|
|
107
|
+
if len(text) > max_chars:
|
|
108
|
+
break
|
|
109
|
+
samples.append(text[:max_chars])
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.warning(
|
|
112
|
+
f"Could not extract PDF sample from {row['pdf_path']}: {e}"
|
|
113
|
+
)
|
|
114
|
+
# Fallback: use filename and basic info
|
|
115
|
+
samples.append(f"PDF file: {Path(row['pdf_path']).name}")
|
|
116
|
+
|
|
117
|
+
elif "source" in row and pd.notna(row["source"]):
|
|
118
|
+
# For other file types, try to read content
|
|
119
|
+
source_path = Path(row["source"])
|
|
120
|
+
if source_path.exists():
|
|
121
|
+
try:
|
|
122
|
+
if source_path.suffix.lower() in [".txt", ".md"]:
|
|
123
|
+
with open(source_path, "r", encoding="utf-8") as f:
|
|
124
|
+
content = f.read()[:max_chars]
|
|
125
|
+
samples.append(content)
|
|
126
|
+
else:
|
|
127
|
+
# For other file types, use filename
|
|
128
|
+
samples.append(f"File: {source_path.name}")
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.warning(
|
|
131
|
+
f"Could not read sample from {source_path}: {e}"
|
|
132
|
+
)
|
|
133
|
+
samples.append(f"File: {source_path.name}")
|
|
134
|
+
else:
|
|
135
|
+
# This is likely traditional tabular data
|
|
136
|
+
# Convert row data to string representation
|
|
137
|
+
row_text = " | ".join(
|
|
138
|
+
[f"{col}: {val}" for col, val in row.items() if pd.notna(val)]
|
|
139
|
+
)
|
|
140
|
+
samples.append(row_text[:max_chars])
|
|
141
|
+
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.warning(f"Error extracting sample from row {idx}: {e}")
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if not samples:
|
|
147
|
+
# Fallback: return a generic sample based on available columns
|
|
148
|
+
return f"Data with columns: {', '.join(df.columns.tolist())}"
|
|
149
|
+
|
|
150
|
+
return "\n\n---SAMPLE SEPARATOR---\n\n".join(samples)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def suggest_column_mappings(
|
|
154
|
+
model_properties: Dict[str, any],
|
|
155
|
+
data_columns: List[str],
|
|
156
|
+
field_descriptions: Dict[str, Dict[str, any]],
|
|
157
|
+
) -> Dict[str, List[str]]:
|
|
158
|
+
"""
|
|
159
|
+
Create intelligent mapping suggestions between model fields and data columns.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
model_properties: Properties from the model schema
|
|
163
|
+
data_columns: Available column names in the dataset
|
|
164
|
+
field_descriptions: Descriptions and types for model fields
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Dictionary mapping model field names to potential column names
|
|
168
|
+
"""
|
|
169
|
+
mapping_suggestions = {}
|
|
170
|
+
|
|
171
|
+
for field_name in model_properties.keys():
|
|
172
|
+
potential_columns = []
|
|
173
|
+
|
|
174
|
+
# Find columns that might match this field based on name similarity
|
|
175
|
+
field_terms = set(field_name.lower().replace("_", " ").split())
|
|
176
|
+
field_description = (
|
|
177
|
+
field_descriptions.get(field_name, {}).get("description", "").lower()
|
|
178
|
+
)
|
|
179
|
+
field_desc_terms = set(
|
|
180
|
+
field_description.replace(",", " ").replace(".", " ").split()
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
for column in data_columns:
|
|
184
|
+
col_terms = set(column.lower().replace("_", " ").split())
|
|
185
|
+
|
|
186
|
+
# Check for direct matches or substring matches
|
|
187
|
+
if (
|
|
188
|
+
field_name.lower() in column.lower()
|
|
189
|
+
or column.lower() in field_name.lower()
|
|
190
|
+
or any(term in column.lower() for term in field_terms)
|
|
191
|
+
or any(term in column.lower() for term in field_desc_terms)
|
|
192
|
+
):
|
|
193
|
+
potential_columns.append(column)
|
|
194
|
+
|
|
195
|
+
# If no matches found through name/description similarity, suggest all columns
|
|
196
|
+
# as the field might be extracted from any text column
|
|
197
|
+
if not potential_columns:
|
|
198
|
+
# Add text columns or if not found, just add all columns
|
|
199
|
+
text_columns = [
|
|
200
|
+
col
|
|
201
|
+
for col in data_columns
|
|
202
|
+
if "text" in col.lower() or "description" in col.lower()
|
|
203
|
+
]
|
|
204
|
+
potential_columns = text_columns if text_columns else data_columns
|
|
205
|
+
|
|
206
|
+
mapping_suggestions[field_name] = potential_columns
|
|
207
|
+
|
|
208
|
+
return mapping_suggestions
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class DataProcessor:
|
|
212
|
+
"""
|
|
213
|
+
Handles data preparation and batch processing for extractions.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(self, max_threads: int = 10, batch_size: int = 100):
|
|
217
|
+
"""
|
|
218
|
+
Initialize data processor.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
max_threads: Maximum number of concurrent threads
|
|
222
|
+
batch_size: Size of batches for processing
|
|
223
|
+
"""
|
|
224
|
+
self.max_threads = max_threads
|
|
225
|
+
self.batch_size = batch_size
|
|
226
|
+
|
|
227
|
+
@handle_errors(error_message="Data preparation failed", error_type=ExtractionError)
|
|
228
|
+
def prepare_data(
|
|
229
|
+
self, data: Union[str, Path, pd.DataFrame, List[Dict[str, str]]], **kwargs: Any
|
|
230
|
+
) -> pd.DataFrame:
|
|
231
|
+
"""
|
|
232
|
+
Convert input data to DataFrame.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
data: Input data (file path, DataFrame, list of dicts, or raw text)
|
|
236
|
+
**kwargs: Additional options for file reading
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
DataFrame with data
|
|
240
|
+
"""
|
|
241
|
+
if isinstance(data, pd.DataFrame):
|
|
242
|
+
df = data
|
|
243
|
+
elif isinstance(data, list) and all(isinstance(item, dict) for item in data):
|
|
244
|
+
df = pd.DataFrame(data)
|
|
245
|
+
elif isinstance(data, (str, Path)) and Path(str(data)).exists():
|
|
246
|
+
df = FileReader.read_file(data, **kwargs)
|
|
247
|
+
elif isinstance(data, str):
|
|
248
|
+
# Raw text processing using unified multimodal PDF pipeline
|
|
249
|
+
import tempfile
|
|
250
|
+
|
|
251
|
+
# Create a temporary text file
|
|
252
|
+
with tempfile.NamedTemporaryFile(
|
|
253
|
+
mode="w", suffix=".txt", delete=False, encoding="utf-8"
|
|
254
|
+
) as temp_file:
|
|
255
|
+
temp_file.write(data)
|
|
256
|
+
temp_path = temp_file.name
|
|
257
|
+
|
|
258
|
+
df = FileReader.read_file(temp_path, mode="multimodal_pdf", **kwargs)
|
|
259
|
+
df.loc[:, "source"] = temp_path # Set source to temp file path
|
|
260
|
+
|
|
261
|
+
else:
|
|
262
|
+
raise ValueError(f"Unsupported data type: {type(data)}")
|
|
263
|
+
|
|
264
|
+
# Ensure text column exists
|
|
265
|
+
if "text" not in df.columns and len(df.columns) == 1:
|
|
266
|
+
df["text"] = df[df.columns[0]]
|
|
267
|
+
|
|
268
|
+
return df
|
|
269
|
+
|
|
270
|
+
def process_batch(
|
|
271
|
+
self,
|
|
272
|
+
batch: pd.DataFrame,
|
|
273
|
+
worker_fn: Callable,
|
|
274
|
+
target_columns: List[str],
|
|
275
|
+
) -> None:
|
|
276
|
+
"""
|
|
277
|
+
Process a batch of data using threads.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
batch: Batch of data to process
|
|
281
|
+
worker_fn: Worker function to execute
|
|
282
|
+
target_columns: Target columns for processing
|
|
283
|
+
"""
|
|
284
|
+
semaphore = threading.Semaphore(self.max_threads)
|
|
285
|
+
threads = []
|
|
286
|
+
|
|
287
|
+
with tqdm(total=len(batch), desc=f"Processing batch", unit="row") as pbar:
|
|
288
|
+
# Create and start threads for batch
|
|
289
|
+
for idx, row in batch.iterrows():
|
|
290
|
+
# Check if this is a multimodal PDF row
|
|
291
|
+
if (
|
|
292
|
+
"multimodal" in row
|
|
293
|
+
and row["multimodal"]
|
|
294
|
+
and row.get("file_type") == "pdf"
|
|
295
|
+
):
|
|
296
|
+
row_data = {
|
|
297
|
+
"pdf_path": row["pdf_path"],
|
|
298
|
+
"multimodal": True,
|
|
299
|
+
"file_type": "pdf",
|
|
300
|
+
"source": row.get("source", ""),
|
|
301
|
+
}
|
|
302
|
+
else:
|
|
303
|
+
# Regular text processing
|
|
304
|
+
row_data = row[target_columns].to_markdown()
|
|
305
|
+
|
|
306
|
+
thread = threading.Thread(
|
|
307
|
+
target=worker_fn,
|
|
308
|
+
args=(row_data, idx, semaphore, pbar),
|
|
309
|
+
)
|
|
310
|
+
thread.start()
|
|
311
|
+
threads.append(thread)
|
|
312
|
+
|
|
313
|
+
# Wait for batch threads to complete
|
|
314
|
+
for thread in threads:
|
|
315
|
+
thread.join()
|
|
316
|
+
|
|
317
|
+
def process_in_batches(
|
|
318
|
+
self,
|
|
319
|
+
df: pd.DataFrame,
|
|
320
|
+
worker_fn: Callable,
|
|
321
|
+
target_columns: List[str],
|
|
322
|
+
) -> None:
|
|
323
|
+
"""
|
|
324
|
+
Process DataFrame in batches.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
df: DataFrame to process
|
|
328
|
+
worker_fn: Worker function to execute
|
|
329
|
+
target_columns: Target columns for processing
|
|
330
|
+
"""
|
|
331
|
+
# Process in batches
|
|
332
|
+
for batch_start in range(0, len(df), self.batch_size):
|
|
333
|
+
batch_end = min(batch_start + self.batch_size, len(df))
|
|
334
|
+
batch = df.iloc[batch_start:batch_end]
|
|
335
|
+
self.process_batch(batch, worker_fn, target_columns)
|
|
336
|
+
|
|
337
|
+
async def run_async(self, func: Callable, *args: Any, **kwargs: Any) -> Any:
|
|
338
|
+
"""
|
|
339
|
+
Run a function asynchronously in a thread pool.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
func: Function to run
|
|
343
|
+
*args: Positional arguments for the function
|
|
344
|
+
**kwargs: Keyword arguments for the function
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Result of the function
|
|
348
|
+
"""
|
|
349
|
+
# Use functools.partial to create a callable with all arguments
|
|
350
|
+
wrapped_func = partial(func, *args, **kwargs)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
# Try to get the running loop
|
|
354
|
+
loop = asyncio.get_running_loop()
|
|
355
|
+
except RuntimeError:
|
|
356
|
+
# No running loop, create a new one
|
|
357
|
+
loop = asyncio.new_event_loop()
|
|
358
|
+
asyncio.set_event_loop(loop)
|
|
359
|
+
|
|
360
|
+
# Since we created a new loop, we need to run and close it
|
|
361
|
+
try:
|
|
362
|
+
return await loop.run_in_executor(None, wrapped_func)
|
|
363
|
+
finally:
|
|
364
|
+
loop.close()
|
|
365
|
+
else:
|
|
366
|
+
# We got an existing loop, just use it
|
|
367
|
+
return await loop.run_in_executor(None, wrapped_func)
|