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,540 @@
1
+ """
2
+ Refactored main extractor class - now acts as an orchestrator with better organization.
3
+ """
4
+
5
+ import copy
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Type, Union
9
+
10
+ import pandas as pd
11
+ from instructor import Instructor
12
+ from loguru import logger
13
+ from pydantic import BaseModel
14
+
15
+ from structx.core.config import ExtractionConfig
16
+ from structx.core.exceptions import ConfigurationError, ExtractionError
17
+ from structx.core.models import ExtractionResult
18
+ from structx.extraction.core.llm_core import LLMCore
19
+ from structx.extraction.engines.extraction_engine import ExtractionEngine
20
+ from structx.extraction.processors.data_content import ContentAnalyzer, DataProcessor
21
+ from structx.extraction.processors.model_operations import ModelOperations
22
+ from structx.extraction.result_manager import ResultManager
23
+ from structx.utils.helpers import handle_errors
24
+
25
+
26
+ class Extractor:
27
+ """
28
+ Main class for structured data extraction - now acts as an orchestrator.
29
+
30
+ This class coordinates the various specialized components to perform
31
+ structured data extraction from different types of sources.
32
+
33
+ Args:
34
+ client: Instructor-patched client
35
+ model_name: Name of the model to use
36
+ config: Configuration for extraction steps
37
+ max_threads: Maximum number of concurrent threads
38
+ batch_size: Size of batches for processing
39
+ max_retries: Maximum number of retries for extraction
40
+ min_wait: Minimum seconds to wait between retries
41
+ max_wait: Maximum seconds to wait between retries
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ client: Instructor,
47
+ model_name: str,
48
+ config: Optional[Union[Dict, str, Path, ExtractionConfig]] = None,
49
+ max_threads: int = 10,
50
+ batch_size: int = 100,
51
+ max_retries: int = 3,
52
+ min_wait: int = 1,
53
+ max_wait: int = 10,
54
+ ):
55
+ """Initialize extractor."""
56
+ self.model_name = model_name
57
+
58
+ # Setup configuration
59
+ if not config:
60
+ self.config = ExtractionConfig()
61
+ elif isinstance(config, (dict, str, Path)):
62
+ self.config = ExtractionConfig(
63
+ config=config if isinstance(config, dict) else None,
64
+ config_path=config if isinstance(config, (str, Path)) else None,
65
+ )
66
+ elif isinstance(config, ExtractionConfig):
67
+ self.config = config
68
+ else:
69
+ raise ConfigurationError("Invalid configuration type")
70
+
71
+ # Initialize core components
72
+ self.llm_core = LLMCore(
73
+ client=client,
74
+ model_name=model_name,
75
+ config=self.config,
76
+ max_retries=max_retries,
77
+ min_wait=min_wait,
78
+ max_wait=max_wait,
79
+ )
80
+
81
+ # Initialize specialized processors
82
+ self.model_operations = ModelOperations(self.llm_core)
83
+ self.extraction_engine = ExtractionEngine(self.llm_core)
84
+ self.data_processor = DataProcessor(max_threads, batch_size)
85
+ self.result_manager = ResultManager()
86
+ self.content_analyzer = ContentAnalyzer()
87
+
88
+ logger.info(f"Initialized Extractor with configuration: {self.config.conf}")
89
+
90
+ def _initialize_extraction(
91
+ self, df: pd.DataFrame, query: str, generate_model: bool = True
92
+ ) -> tuple[Any, Any, Optional[Type[BaseModel]]]:
93
+ """Initialize the extraction process by refining query and generating models if needed."""
94
+ # Refine query
95
+ refined_query = self.llm_core.refine_query(query)
96
+ logger.info(f"Refined Query: {refined_query.refined_query}")
97
+
98
+ # Generate guide
99
+ guide = self.llm_core.generate_extraction_guide(
100
+ refined_query, df.columns.tolist()
101
+ )
102
+ logger.info(f"Target Columns: {guide.target_columns}")
103
+
104
+ if not generate_model:
105
+ return refined_query, guide, None
106
+
107
+ # Get sample text for schema generation
108
+ # Check if this is file-based extraction (contains pdf_path or source columns)
109
+ is_file_based = "pdf_path" in df.columns or "source" in df.columns
110
+
111
+ if is_file_based:
112
+ # For file-based extractions, extract actual content samples
113
+ sample_text = self.content_analyzer.extract_content_sample_for_schema(df)
114
+ # Add context about the content type
115
+ content_context = self.content_analyzer.detect_content_type_and_context(df)
116
+ sample_text = f"Content type: {content_context}\n\n{sample_text}"
117
+ else:
118
+ # For traditional tabular data, use the existing approach
119
+ sample_text = df[guide.target_columns].iloc[0]
120
+
121
+ # Generate model
122
+ schema_request = self.model_operations.generate_extraction_schema(
123
+ sample_text, refined_query, guide
124
+ )
125
+ extraction_model = self.model_operations.create_model_from_schema(
126
+ schema_request
127
+ )
128
+
129
+ return refined_query, guide, extraction_model
130
+
131
+ def _create_extraction_worker(
132
+ self,
133
+ extraction_model: Type[BaseModel],
134
+ refined_query: Any,
135
+ guide: Any,
136
+ result_df: pd.DataFrame,
137
+ result_list: List[Any],
138
+ failed_rows: List[Dict],
139
+ return_df: bool,
140
+ expand_nested: bool,
141
+ is_custom_model: bool = False,
142
+ ):
143
+ """Create a worker function for threaded extraction."""
144
+
145
+ def extract_worker(
146
+ row_data: Union[str, Dict],
147
+ row_idx: int,
148
+ semaphore: threading.Semaphore,
149
+ pbar,
150
+ ):
151
+ with semaphore:
152
+ try:
153
+ items = self.extraction_engine.extract_from_row_data(
154
+ row_data=row_data,
155
+ extraction_model=extraction_model,
156
+ refined_query=refined_query,
157
+ guide=guide,
158
+ is_custom_model=is_custom_model,
159
+ )
160
+
161
+ if return_df:
162
+ self.result_manager.update_dataframe(
163
+ result_df, items, row_idx, expand_nested
164
+ )
165
+ else:
166
+ result_list.extend(items)
167
+
168
+ except Exception as e:
169
+ row_text = row_data if isinstance(row_data, str) else str(row_data)
170
+ self.result_manager.handle_extraction_error(
171
+ result_df, failed_rows, row_idx, row_text, e
172
+ )
173
+ finally:
174
+ pbar.update(1)
175
+
176
+ return extract_worker
177
+
178
+ @handle_errors(error_message="Data processing failed", error_type=ExtractionError)
179
+ def _process_data(
180
+ self,
181
+ df: pd.DataFrame,
182
+ query: str,
183
+ return_df: bool,
184
+ expand_nested: bool = False,
185
+ extraction_model: Optional[Type[BaseModel]] = None,
186
+ ) -> ExtractionResult:
187
+ """Process DataFrame with extraction."""
188
+ # Reset usage tracking
189
+ self.llm_core.reset_usage()
190
+
191
+ # Initialize extraction
192
+ if extraction_model:
193
+ # When a custom model is provided, generate refinement and guide from the model
194
+ # instead of from the query to avoid conflicts
195
+ refined_query, guide = self.model_operations.generate_from_custom_model(
196
+ model=extraction_model, query=query, data_columns=df.columns.tolist()
197
+ )
198
+ ExtractionModel = extraction_model
199
+ else:
200
+ refined_query, guide, ExtractionModel = self._initialize_extraction(
201
+ df, query, generate_model=True
202
+ )
203
+
204
+ # Initialize results
205
+ result_df, result_list, failed_rows = self.result_manager.initialize_results(
206
+ df, ExtractionModel
207
+ )
208
+
209
+ # Create worker function - pass is_custom_model flag when using a provided model
210
+ worker_fn = self._create_extraction_worker(
211
+ extraction_model=ExtractionModel,
212
+ refined_query=refined_query,
213
+ guide=guide,
214
+ result_df=result_df,
215
+ result_list=result_list,
216
+ failed_rows=failed_rows,
217
+ return_df=return_df,
218
+ expand_nested=expand_nested,
219
+ is_custom_model=extraction_model is not None,
220
+ )
221
+
222
+ # Process in batches
223
+ self.data_processor.process_in_batches(df, worker_fn, guide.target_columns)
224
+
225
+ # Log statistics
226
+ self.result_manager.log_extraction_stats(len(df), failed_rows)
227
+
228
+ # Create a deep copy of usage for the result
229
+ result_usage = copy.deepcopy(self.llm_core.get_usage())
230
+
231
+ # Reset the extractor's usage for the next operation
232
+ self.llm_core.reset_usage()
233
+
234
+ # Return results
235
+ return ExtractionResult(
236
+ data=result_df if return_df else result_list,
237
+ failed=pd.DataFrame(failed_rows),
238
+ model=ExtractionModel,
239
+ usage=result_usage,
240
+ )
241
+
242
+ @handle_errors(error_message="Extraction failed", error_type=ExtractionError)
243
+ def extract(
244
+ self,
245
+ *,
246
+ data: Union[str, Path, pd.DataFrame, List[Dict[str, str]]],
247
+ query: str,
248
+ model: Optional[Type[BaseModel]] = None,
249
+ return_df: bool = False,
250
+ expand_nested: bool = False,
251
+ **kwargs: Any,
252
+ ) -> ExtractionResult:
253
+ """
254
+ Extract structured data from text.
255
+
256
+ Args:
257
+ data: Input data (file path, DataFrame, list of dicts, or raw text)
258
+ query: Natural language query
259
+ model: Optional pre-generated Pydantic model class (if None, a model will be generated)
260
+ return_df: Whether to return DataFrame
261
+ expand_nested: Whether to flatten nested structures
262
+ **kwargs: Additional options for file reading
263
+
264
+ Returns:
265
+ Extraction result with extracted data, failed rows, and model (if requested)
266
+ """
267
+ df = self.data_processor.prepare_data(data, **kwargs)
268
+ return self._process_data(df, query, return_df, expand_nested, model)
269
+
270
+ async def extract_async(
271
+ self,
272
+ *,
273
+ data: Union[str, Path, pd.DataFrame, List[Dict[str, str]]],
274
+ query: str,
275
+ return_df: bool = False,
276
+ expand_nested: bool = False,
277
+ **kwargs: Any,
278
+ ) -> ExtractionResult:
279
+ """
280
+ Asynchronous version of `extract`.
281
+
282
+ Args:
283
+ data: Input data (file path, DataFrame, list of dicts, or raw text)
284
+ query: Natural language query
285
+ return_df: Whether to return DataFrame
286
+ expand_nested: Whether to flatten nested structures
287
+ **kwargs: Additional options for file reading
288
+
289
+ Returns:
290
+ ExtractionResult containing extracted data, failed rows, and the model
291
+ """
292
+ return await self.data_processor.run_async(
293
+ self.extract, data, query, None, return_df, expand_nested, **kwargs
294
+ )
295
+
296
+ @handle_errors(error_message="Batch extraction failed", error_type=ExtractionError)
297
+ def extract_queries(
298
+ self,
299
+ *,
300
+ data: Union[str, Path, pd.DataFrame, List[Dict[str, str]]],
301
+ queries: List[str],
302
+ return_df: bool = True,
303
+ expand_nested: bool = False,
304
+ **kwargs: Any,
305
+ ) -> Dict[str, ExtractionResult]:
306
+ """
307
+ Process multiple queries on the same data.
308
+
309
+ Args:
310
+ data: Input data (file path, DataFrame, list of dicts, or raw text)
311
+ queries: List of queries to process
312
+ return_df: Whether to return DataFrame
313
+ expand_nested: Whether to flatten nested structures
314
+ **kwargs: Additional options for file reading
315
+
316
+ Returns:
317
+ Dictionary mapping queries to their results (extracted data and failed extractions)
318
+ """
319
+ results = {}
320
+
321
+ for query in queries:
322
+ logger.info(f"\nProcessing query: {query}")
323
+ result = self.extract(
324
+ data=data,
325
+ query=query,
326
+ return_df=return_df,
327
+ expand_nested=expand_nested,
328
+ **kwargs,
329
+ )
330
+ results[query] = result
331
+
332
+ return results
333
+
334
+ async def extract_queries_async(
335
+ self,
336
+ *,
337
+ data: Union[str, Path, pd.DataFrame, List[Dict[str, str]]],
338
+ queries: List[str],
339
+ return_df: bool = False,
340
+ expand_nested: bool = False,
341
+ **kwargs: Any,
342
+ ) -> Dict[str, ExtractionResult]:
343
+ """
344
+ Asynchronous version of `extract_queries`.
345
+
346
+ Args:
347
+ data: Input data
348
+ queries: List of queries
349
+ return_df: Whether to return DataFrame
350
+ expand_nested: Whether to flatten nested structures
351
+ **kwargs: Additional options
352
+
353
+ Returns:
354
+ Dictionary mapping queries to ExtractionResult objects
355
+ """
356
+ return await self.data_processor.run_async(
357
+ self.extract_queries, data, queries, return_df, expand_nested, **kwargs
358
+ )
359
+
360
+ @handle_errors(error_message="Schema generation failed", error_type=ExtractionError)
361
+ def get_schema(
362
+ self,
363
+ *,
364
+ data: Union[str, Path, pd.DataFrame, List[Dict[str, str]]],
365
+ query: str,
366
+ **kwargs: Any,
367
+ ) -> Type[BaseModel]:
368
+ """
369
+ Get extraction model without performing extraction.
370
+
371
+ Args:
372
+ query: Natural language query
373
+ data: Input data (file path, DataFrame, list of dicts, or raw text)
374
+ **kwargs: Additional options for file reading
375
+
376
+ Returns:
377
+ Pydantic model for extraction with `.usage` attribute for token tracking
378
+ """
379
+ if isinstance(data, str) and not Path(data).exists():
380
+ sample_text = data
381
+ columns = ["text"]
382
+ else:
383
+ df = self.data_processor.prepare_data(data, **kwargs)
384
+ is_file_based = "pdf_path" in df.columns or "source" in df.columns
385
+ columns = df.columns.tolist()
386
+
387
+ if is_file_based:
388
+ sample_text = self.content_analyzer.extract_content_sample_for_schema(
389
+ df
390
+ )
391
+ content_context = self.content_analyzer.detect_content_type_and_context(
392
+ df
393
+ )
394
+ sample_text = f"Content type: {content_context}\n\n{sample_text}"
395
+ else:
396
+ # For traditional tabular data, create a representative sample
397
+ sample_text = "\n".join(df.head().to_string(index=False).splitlines())
398
+
399
+ # Refine query
400
+ refined_query = self.llm_core.refine_query(query)
401
+
402
+ # Generate guide
403
+ guide = self.llm_core.generate_extraction_guide(refined_query, columns)
404
+
405
+ # Generate schema
406
+ schema_request = self.model_operations.generate_extraction_schema(
407
+ sample_text, refined_query, guide
408
+ )
409
+
410
+ # Create model
411
+ extraction_model = self.model_operations.create_model_from_schema(
412
+ schema_request
413
+ )
414
+
415
+ # Create a deep copy of usage for the model
416
+ model_usage = copy.deepcopy(self.llm_core.get_usage())
417
+
418
+ # Reset the extractor's usage for the next operation
419
+ self.llm_core.reset_usage()
420
+
421
+ # Add usage to model
422
+ extraction_model.usage = model_usage
423
+
424
+ return extraction_model
425
+
426
+ async def get_schema_async(
427
+ self,
428
+ *,
429
+ data: Union[str, Path, pd.DataFrame, List[Dict[str, str]]],
430
+ query: str,
431
+ **kwargs: Any,
432
+ ) -> Type[BaseModel]:
433
+ """
434
+ Asynchronous version of `get_schema`.
435
+
436
+ Args:
437
+ query: Natural language query
438
+ data: Input data (file path, DataFrame, list of dicts, or raw text)
439
+ **kwargs: Additional options for file reading
440
+
441
+ Returns:
442
+ Dynamically generated Pydantic model class
443
+ """
444
+ return await self.data_processor.run_async(
445
+ self.get_schema, query, data, **kwargs
446
+ )
447
+
448
+ def refine_data_model(
449
+ self,
450
+ *,
451
+ model: Type[BaseModel],
452
+ refinement_instructions: str,
453
+ model_name: Optional[str] = None,
454
+ ) -> Type[BaseModel]:
455
+ """
456
+ Refine an existing data model based on natural language instructions.
457
+
458
+ Args:
459
+ model: Existing Pydantic model to refine
460
+ refinement_instructions: Natural language instructions for refinement
461
+ model_name: Optional name for the refined model (defaults to original name with 'Refined' prefix)
462
+
463
+ Returns:
464
+ A new refined Pydantic model with `.usage` attribute for token tracking
465
+ """
466
+ # Default model name if not provided
467
+ if model_name is None:
468
+ model_name = f"Refined{model.__name__}"
469
+
470
+ refined_model = self.model_operations.refine_existing_model(
471
+ model, refinement_instructions, model_name
472
+ )
473
+
474
+ # Create a deep copy of usage for the model
475
+ model_usage = copy.deepcopy(self.llm_core.get_usage())
476
+
477
+ # Reset the extractor's usage for the next operation
478
+ self.llm_core.reset_usage()
479
+
480
+ # Add usage to model
481
+ refined_model.usage = model_usage
482
+
483
+ return refined_model
484
+
485
+ @classmethod
486
+ def from_litellm(
487
+ cls,
488
+ *,
489
+ model: str,
490
+ api_key: Optional[str] = None,
491
+ config: Optional[Union[Dict, str]] = None,
492
+ max_threads: int = 10,
493
+ batch_size: int = 100,
494
+ max_retries: int = 3,
495
+ min_wait: int = 1,
496
+ max_wait: int = 10,
497
+ **litellm_kwargs: Any,
498
+ ) -> "Extractor":
499
+ """
500
+ Create Extractor instance using litellm.
501
+
502
+ Args:
503
+ model: Model identifier (e.g., "gpt-4", "claude-2", "azure/gpt-4")
504
+ api_key: API key for the model provider
505
+ config: Extraction configuration
506
+ max_threads: Maximum number of concurrent threads
507
+ batch_size: Size of processing batches
508
+ max_retries: Maximum number of retries for extraction
509
+ min_wait: Minimum seconds to wait between retries
510
+ max_wait: Maximum seconds to wait between retries
511
+ **litellm_kwargs: Additional kwargs for litellm (e.g., api_base, organization)
512
+ """
513
+ import instructor
514
+ import litellm
515
+ from litellm import completion
516
+
517
+ # Set up litellm
518
+ if api_key:
519
+ litellm.api_key = api_key
520
+
521
+ # drop unnecessary parameters
522
+ litellm.drop_params = True
523
+
524
+ # Set additional litellm configs
525
+ for key, value in litellm_kwargs.items():
526
+ setattr(litellm, key, value)
527
+
528
+ # Create patched client
529
+ client = instructor.from_litellm(completion)
530
+
531
+ return cls(
532
+ client=client,
533
+ model_name=model,
534
+ config=config,
535
+ max_threads=max_threads,
536
+ batch_size=batch_size,
537
+ max_retries=max_retries,
538
+ min_wait=min_wait,
539
+ max_wait=max_wait,
540
+ )