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.
@@ -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,6 @@
1
+ """Processor components for data and model operations."""
2
+
3
+ from .data_content import ContentAnalyzer, DataProcessor
4
+ from .model_operations import ModelOperations
5
+
6
+ __all__ = ["ContentAnalyzer", "DataProcessor", "ModelOperations"]
@@ -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)