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,233 @@
1
+ """
2
+ Model operations including schema generation and custom model processing.
3
+ """
4
+
5
+ import json
6
+ from typing import List, Tuple, Type
7
+
8
+ from loguru import logger
9
+ from pydantic import BaseModel
10
+
11
+ from structx.core.exceptions import ExtractionError
12
+ from structx.core.models import ExtractionGuide, ExtractionRequest, QueryRefinement
13
+ from structx.extraction.core.llm_core import LLMCore
14
+ from structx.extraction.core.model_utils import ModelUtils
15
+ from structx.extraction.generator import ModelGenerator
16
+ from structx.extraction.processors.data_content import ContentAnalyzer
17
+ from structx.utils.helpers import (
18
+ convert_pydantic_v1_to_v2,
19
+ handle_errors,
20
+ sanitize_regex_patterns,
21
+ )
22
+ from structx.utils.prompts import (
23
+ custom_model_guide_template,
24
+ refinement_system_prompt,
25
+ refinement_template,
26
+ schema_system_prompt,
27
+ schema_template,
28
+ )
29
+ from structx.utils.usage import ExtractionStep
30
+
31
+
32
+ class ModelOperations:
33
+ """
34
+ Handles all model-related operations including schema generation and custom model processing.
35
+ """
36
+
37
+ def __init__(self, llm_core: LLMCore):
38
+ """
39
+ Initialize model operations.
40
+
41
+ Args:
42
+ llm_core: LLM core for completions
43
+ """
44
+ self.llm_core = llm_core
45
+
46
+ @handle_errors(error_message="Schema generation failed", error_type=ExtractionError)
47
+ def generate_extraction_schema(
48
+ self, sample_text: str, refined_query: QueryRefinement, guide: ExtractionGuide
49
+ ) -> ExtractionRequest:
50
+ """
51
+ Generate schema with enforced structure.
52
+
53
+ Args:
54
+ sample_text: Sample content for context
55
+ refined_query: Refined query with details
56
+ guide: Extraction guide with patterns
57
+
58
+ Returns:
59
+ Extraction request with model schema
60
+ """
61
+ return self.llm_core.complete(
62
+ messages=[
63
+ {"role": "system", "content": schema_system_prompt},
64
+ {
65
+ "role": "user",
66
+ "content": schema_template.substitute(
67
+ refined_query=refined_query.refined_query,
68
+ data_characteristics=refined_query.data_characteristics,
69
+ structural_requirements=refined_query.structural_requirements,
70
+ organization_principles=guide.organization_principles,
71
+ sample_text=sample_text,
72
+ ),
73
+ },
74
+ ],
75
+ response_model=ExtractionRequest,
76
+ config=self.llm_core.config.refinement,
77
+ step=ExtractionStep.SCHEMA_GENERATION,
78
+ )
79
+
80
+ def create_model_from_schema(
81
+ self, schema_request: ExtractionRequest
82
+ ) -> Type[BaseModel]:
83
+ """
84
+ Create Pydantic model from extraction request.
85
+
86
+ Args:
87
+ schema_request: Request containing model schema
88
+
89
+ Returns:
90
+ Generated Pydantic model class
91
+ """
92
+ extraction_model = ModelGenerator.from_extraction_request(schema_request)
93
+ logger.info("Generated Model Schema:")
94
+ logger.info(json.dumps(extraction_model.model_json_schema(), indent=2))
95
+ return extraction_model
96
+
97
+ def refine_existing_model(
98
+ self,
99
+ model: Type[BaseModel],
100
+ instructions: str,
101
+ model_name: str = None,
102
+ ) -> Type[BaseModel]:
103
+ """
104
+ Refine an existing data model based on natural language instructions.
105
+
106
+ Args:
107
+ model: Existing Pydantic model to refine
108
+ instructions: Natural language instructions for refinement
109
+ model_name: Optional name for the refined model
110
+
111
+ Returns:
112
+ A new refined Pydantic model
113
+ """
114
+ # Default model name if not provided
115
+ if model_name is None:
116
+ model_name = f"Refined{model.__name__}"
117
+
118
+ # Get the schema of the existing model
119
+ model_schema = model.model_json_schema()
120
+ model_schema_str = json.dumps(model_schema, indent=2)
121
+
122
+ # Generate schema for the refined model directly
123
+ extraction_request = self.llm_core.complete(
124
+ response_model=ExtractionRequest,
125
+ messages=[
126
+ {
127
+ "role": "system",
128
+ "content": refinement_system_prompt,
129
+ },
130
+ {
131
+ "role": "user",
132
+ "content": refinement_template.substitute(
133
+ model_schema=model_schema_str,
134
+ instructions=instructions,
135
+ ),
136
+ },
137
+ ],
138
+ config=self.llm_core.config.refinement,
139
+ step=ExtractionStep.SCHEMA_GENERATION,
140
+ )
141
+
142
+ # Set the model name if specified
143
+ if model_name:
144
+ extraction_request.model_name = model_name
145
+
146
+ # Sanitize regex patterns to prevent validation errors
147
+ sanitized_request = sanitize_regex_patterns(extraction_request)
148
+
149
+ # Convert from v1 to v2 if needed and generate model
150
+ converted_request = convert_pydantic_v1_to_v2(sanitized_request)
151
+ refined_model = ModelGenerator.from_extraction_request(converted_request)
152
+
153
+ return refined_model
154
+
155
+ def generate_from_custom_model(
156
+ self,
157
+ model: Type[BaseModel],
158
+ query: str,
159
+ data_columns: List[str],
160
+ ) -> Tuple[QueryRefinement, ExtractionGuide]:
161
+ """
162
+ Generate refinement and guide from a provided custom model.
163
+
164
+ When a custom model is provided, we reverse engineer the refinement and guide
165
+ to match the model structure, rather than generating them from the query.
166
+
167
+ Args:
168
+ model: The provided custom model
169
+ query: The original query (used as context)
170
+ data_columns: Available columns in the dataset
171
+
172
+ Returns:
173
+ Tuple of refined_query and extraction_guide
174
+ """
175
+ # Get model schema and description
176
+ model_description = ModelUtils.get_model_description(model)
177
+ model_schema = model.model_json_schema()
178
+ model_properties = model_schema.get("properties", {})
179
+
180
+ # Extract data characteristics from the model properties
181
+ data_characteristics = ModelUtils.extract_field_characteristics(model)
182
+
183
+ # Extract structure requirements
184
+ structural_requirements = ModelUtils.extract_structural_requirements(model)
185
+
186
+ # Create a simplified query refinement with explicit field mapping instructions
187
+ model_fields = list(model_properties.keys())
188
+ refined_query = QueryRefinement(
189
+ refined_query=f"Extract {model_description} as specified in the provided model, filling all fields with relevant data from the appropriate columns. Original query: {query}",
190
+ data_characteristics=data_characteristics,
191
+ structural_requirements=structural_requirements,
192
+ )
193
+
194
+ # Create custom field-to-column mapping suggestions based on field names and data columns
195
+ field_descriptions = {}
196
+ for prop_name, prop_info in model_properties.items():
197
+ field_descriptions[prop_name] = {
198
+ "description": prop_info.get("description", ""),
199
+ "type": prop_info.get("type", ""),
200
+ "enum": prop_info.get("enum", []),
201
+ }
202
+
203
+ column_suggestions = ContentAnalyzer.suggest_column_mappings(
204
+ model_properties, data_columns, field_descriptions
205
+ )
206
+
207
+ # Generate guide with enhanced column mapping
208
+ guide_messages = [
209
+ {"role": "system", "content": "You are an extraction guide generator."},
210
+ {
211
+ "role": "user",
212
+ "content": custom_model_guide_template.substitute(
213
+ data_characteristics=data_characteristics,
214
+ available_columns=data_columns,
215
+ model_fields=model_fields,
216
+ column_suggestions=json.dumps(column_suggestions, indent=2),
217
+ ),
218
+ },
219
+ ]
220
+
221
+ guide = self.llm_core.complete(
222
+ messages=guide_messages,
223
+ response_model=ExtractionGuide,
224
+ config=self.llm_core.config.refinement,
225
+ step=ExtractionStep.GUIDE,
226
+ )
227
+
228
+ logger.info(f"Extraction Columns: {guide.target_columns}")
229
+ logger.info(
230
+ f"Generated refinement and guide from custom model: {model.__name__}"
231
+ )
232
+
233
+ return refined_query, guide
@@ -0,0 +1,125 @@
1
+ """
2
+ Result management for extraction operations.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List
7
+
8
+ import pandas as pd
9
+ from loguru import logger
10
+ from pydantic import BaseModel
11
+
12
+ from structx.utils.helpers import flatten_extracted_data
13
+
14
+
15
+ class ResultManager:
16
+ """
17
+ Manages extraction results, errors, and statistics.
18
+ """
19
+
20
+ @staticmethod
21
+ def initialize_results(
22
+ df: pd.DataFrame, extraction_model: type[BaseModel]
23
+ ) -> tuple[pd.DataFrame, List[Any], List[Dict]]:
24
+ """
25
+ Initialize result containers.
26
+
27
+ Args:
28
+ df: Original DataFrame
29
+ extraction_model: Pydantic model for extraction
30
+
31
+ Returns:
32
+ Tuple of (result_df, result_list, failed_rows)
33
+ """
34
+ result_df = df.copy()
35
+ result_list = []
36
+ failed_rows = []
37
+
38
+ # Initialize extraction columns
39
+ for field_name in extraction_model.model_fields:
40
+ result_df[field_name] = None
41
+ result_df["extraction_status"] = None
42
+
43
+ return result_df, result_list, failed_rows
44
+
45
+ @staticmethod
46
+ def update_dataframe(
47
+ result_df: pd.DataFrame,
48
+ items: List[BaseModel],
49
+ row_idx: int,
50
+ expand_nested: bool,
51
+ ) -> None:
52
+ """
53
+ Update DataFrame with extracted items.
54
+
55
+ Args:
56
+ result_df: DataFrame to update
57
+ items: Extracted model instances
58
+ row_idx: Row index to update
59
+ expand_nested: Whether to flatten nested structures
60
+ """
61
+ for i, item in enumerate(items):
62
+ # Flatten if needed
63
+ item_data = (
64
+ flatten_extracted_data(item.model_dump())
65
+ if expand_nested
66
+ else item.model_dump()
67
+ )
68
+
69
+ # For multiple items, append index to field names
70
+ if i > 0:
71
+ item_data = {f"{k}_{i}": v for k, v in item_data.items()}
72
+
73
+ # Update result dataframe
74
+ for field_name, value in item_data.items():
75
+ result_df.at[row_idx, field_name] = value
76
+
77
+ result_df.at[row_idx, "extraction_status"] = "Success"
78
+
79
+ @staticmethod
80
+ def handle_extraction_error(
81
+ result_df: pd.DataFrame,
82
+ failed_rows: List[Dict],
83
+ row_idx: int,
84
+ row_text: str,
85
+ error: Exception,
86
+ ) -> None:
87
+ """
88
+ Handle and log extraction errors.
89
+
90
+ Args:
91
+ result_df: DataFrame to update with error status
92
+ failed_rows: List to append failed row information
93
+ row_idx: Row index that failed
94
+ row_text: Text that failed to extract
95
+ error: Exception that occurred
96
+ """
97
+ failed_rows.append(
98
+ {
99
+ "index": row_idx,
100
+ "text": row_text,
101
+ "error": str(error),
102
+ "timestamp": datetime.now().isoformat(),
103
+ }
104
+ )
105
+ result_df.at[row_idx, "extraction_status"] = f"Failed: {str(error)}"
106
+
107
+ @staticmethod
108
+ def log_extraction_stats(total_rows: int, failed_rows: List[Dict]) -> None:
109
+ """
110
+ Log extraction statistics.
111
+
112
+ Args:
113
+ total_rows: Total number of rows processed
114
+ failed_rows: List of failed rows
115
+ """
116
+ success_count = total_rows - len(failed_rows)
117
+ logger.info("\nExtraction Statistics:")
118
+ logger.info(f"Total rows: {total_rows}")
119
+ logger.info(
120
+ f"Successfully processed: {success_count} "
121
+ f"({success_count/total_rows*100:.2f}%)"
122
+ )
123
+ logger.info(
124
+ f"Failed: {len(failed_rows)} " f"({len(failed_rows)/total_rows*100:.2f}%)"
125
+ )
File without changes
@@ -0,0 +1,36 @@
1
+ from datetime import date, datetime, time
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ TYPE_ALIASES = {
5
+ "string": "str",
6
+ "integer": "int",
7
+ "numeric": "float",
8
+ "number": "float",
9
+ "boolean": "bool",
10
+ "array": "List",
11
+ "object": "dict",
12
+ "date-time": "datetime", # Add this mapping
13
+ "datetime": "datetime", # Add this for consistency
14
+ "date": "date", # Add this for completeness
15
+ "time": "time", # Add this for completeness
16
+ "decimal": "float", # Assuming decimal is represented as float
17
+ }
18
+
19
+ SAFE_TYPES = {
20
+ # Python built-ins
21
+ "str": str,
22
+ "int": int,
23
+ "float": float,
24
+ "bool": bool,
25
+ "list": list,
26
+ "dict": dict,
27
+ # Common types
28
+ "datetime": datetime,
29
+ "date": date,
30
+ "time": time,
31
+ # Generic type constructors
32
+ "List": List,
33
+ "Dict": Dict,
34
+ "Optional": Optional,
35
+ "Any": Any,
36
+ }
@@ -0,0 +1,298 @@
1
+ import tempfile
2
+ from pathlib import Path
3
+ from typing import Any, Callable, Dict, List, Union
4
+
5
+ import pandas as pd
6
+
7
+ from structx.core.exceptions import FileError
8
+
9
+
10
+ class FileReader:
11
+ """
12
+ Handles reading different file formats with a unified approach for unstructured documents.
13
+
14
+ For unstructured documents (TXT, DOCX, PDF), the default strategy is to convert
15
+ everything to PDF and use instructor's multimodal PDF support. This eliminates
16
+ the need for manual chunking and provides the best context preservation.
17
+ """
18
+
19
+ STRUCTURED_EXTENSIONS: Dict[
20
+ str, Callable[[Union[str, Path], Dict], pd.DataFrame]
21
+ ] = {
22
+ ".csv": pd.read_csv,
23
+ ".xlsx": pd.read_excel,
24
+ ".xls": pd.read_excel,
25
+ ".json": pd.read_json,
26
+ ".parquet": pd.read_parquet,
27
+ ".feather": pd.read_feather,
28
+ }
29
+
30
+ TEXT_EXTENSIONS: List[str] = [".txt", ".md", ".py", ".html", ".xml", ".log", ".rst"]
31
+ DOCUMENT_EXTENSIONS: List[str] = [".pdf", ".docx", ".doc"]
32
+
33
+ @staticmethod
34
+ def read_file(file_path: Union[str, Path], **kwargs: Any) -> pd.DataFrame:
35
+ """
36
+ Read a file and return its content based on the specified mode.
37
+
38
+ For unstructured documents (TXT, DOCX, PDF), the default approach is to
39
+ convert everything to PDF and use instructor's multimodal PDF support.
40
+ This eliminates the need for manual chunking and provides the best
41
+ context preservation.
42
+
43
+ Args:
44
+ file_path: Path to the file to read
45
+ **kwargs: Additional options for file reading including:
46
+ - mode: Reading mode - 'multimodal_pdf' (default), 'simple_text', or 'simple_pdf'
47
+ - use_multimodal: Use instructor's multimodal support (default: True)
48
+ - file_options: Additional options for reading the file
49
+
50
+ Returns:
51
+ pandas DataFrame with the appropriate structure for the specified mode
52
+
53
+ Raises:
54
+ FileError: If file cannot be read or processed
55
+ """
56
+ # Extract parameters from kwargs
57
+ mode = kwargs.get("mode", "multimodal_pdf")
58
+ use_multimodal = kwargs.get("use_multimodal", True)
59
+ file_options = kwargs.get("file_options", {})
60
+
61
+ # Handle legacy parameter structure
62
+ if use_multimodal and mode == "multimodal_pdf":
63
+ mode = "multimodal_pdf"
64
+ elif not use_multimodal:
65
+ mode = "simple_text"
66
+ try:
67
+ file_path = Path(file_path)
68
+ if not file_path.exists():
69
+ raise FileError(f"File not found: {file_path}")
70
+
71
+ file_extension = file_path.suffix.lower()
72
+
73
+ # Handle structured files (return DataFrame)
74
+ if file_extension in FileReader.STRUCTURED_EXTENSIONS:
75
+ read_func = FileReader.STRUCTURED_EXTENSIONS[file_extension]
76
+ return read_func(file_path, **file_options)
77
+
78
+ # Handle unstructured files
79
+ if (
80
+ file_extension in FileReader.TEXT_EXTENSIONS
81
+ or file_extension in FileReader.DOCUMENT_EXTENSIONS
82
+ ):
83
+ if mode == "multimodal_pdf":
84
+ # Convert all unstructured documents to PDF for instructor's multimodal support
85
+ pdf_path = FileReader._convert_to_pdf(file_path)
86
+ # Return DataFrame with required structure for multimodal processing
87
+ return pd.DataFrame(
88
+ {
89
+ "pdf_path": [str(pdf_path)],
90
+ "source": [str(file_path)],
91
+ "multimodal": [True],
92
+ "file_type": ["pdf"],
93
+ }
94
+ )
95
+ elif mode == "simple_text":
96
+ # Fallback: simple text reading with chunking
97
+ return FileReader._read_as_text_chunks(file_path, kwargs)
98
+ elif mode == "simple_pdf":
99
+ # Fallback: simple PDF reading (if it's already a PDF)
100
+ if file_extension == ".pdf":
101
+ return FileReader._read_pdf_chunks(file_path, kwargs)
102
+ else:
103
+ # Convert to PDF first, then read simply
104
+ pdf_path = FileReader._convert_to_pdf(file_path)
105
+ return FileReader._read_pdf_chunks(Path(pdf_path), kwargs)
106
+
107
+ raise FileError(f"Unsupported file type: {file_extension}")
108
+
109
+ except Exception as e:
110
+ raise FileError(f"Error reading file {file_path}: {str(e)}")
111
+
112
+ @staticmethod
113
+ def _convert_to_pdf(file_path: Path) -> str:
114
+ """
115
+ Convert any supported document to PDF using docling -> markdown -> PDF pipeline.
116
+
117
+ Returns the path to the generated PDF file for use with instructor's multimodal support.
118
+ """
119
+ try:
120
+ file_extension = file_path.suffix.lower()
121
+
122
+ # If it's already a PDF, return as-is
123
+ if file_extension == ".pdf":
124
+ return str(file_path)
125
+
126
+ # For simple text files, read directly
127
+ if file_extension in FileReader.TEXT_EXTENSIONS:
128
+ with open(file_path, "r", encoding="utf-8") as f:
129
+ content = f.read()
130
+ return FileReader._markdown_to_pdf(content, file_path.stem)
131
+
132
+ # For document files, use docling to convert to markdown first
133
+ elif (
134
+ file_extension in FileReader.DOCUMENT_EXTENSIONS
135
+ and file_extension != ".pdf"
136
+ ):
137
+ try:
138
+ from docling.document_converter import DocumentConverter
139
+
140
+ converter = DocumentConverter()
141
+ result = converter.convert(str(file_path))
142
+ markdown_content = result.document.export_to_markdown()
143
+
144
+ # Convert markdown to PDF
145
+ return FileReader._markdown_to_pdf(markdown_content, file_path.stem)
146
+
147
+ except ImportError:
148
+ raise FileError(
149
+ f"docling not available for {file_extension} conversion"
150
+ )
151
+ else:
152
+ raise FileError(
153
+ f"Unsupported file type for conversion: {file_extension}"
154
+ )
155
+
156
+ except Exception as e:
157
+ raise FileError(f"Error converting {file_path} to PDF: {str(e)}")
158
+
159
+ @staticmethod
160
+ def _markdown_to_pdf(markdown_content: str, filename: str) -> str:
161
+ """Convert markdown content to PDF and return the path."""
162
+
163
+ import markdown
164
+ import weasyprint
165
+
166
+ # Convert markdown to HTML
167
+ md = markdown.Markdown(extensions=["extra", "codehilite"])
168
+ html_content = md.convert(markdown_content)
169
+
170
+ # Add basic CSS styling
171
+ html_with_css = f"""
172
+ <!DOCTYPE html>
173
+ <html>
174
+ <head>
175
+ <meta charset="utf-8">
176
+ <style>
177
+ body {{ font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }}
178
+ h1, h2, h3 {{ color: #333; }}
179
+ pre {{ background-color: #f4f4f4; padding: 10px; border-radius: 5px; }}
180
+ code {{ background-color: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
181
+ </style>
182
+ </head>
183
+ <body>
184
+ {html_content}
185
+ </body>
186
+ </html>
187
+ """
188
+
189
+ # Create temporary PDF file
190
+ with tempfile.NamedTemporaryFile(
191
+ delete=False, suffix=".pdf", prefix=f"{filename}_"
192
+ ) as tmp_file:
193
+ pdf_path = tmp_file.name
194
+
195
+ # Generate PDF with weasyprint
196
+ weasyprint.HTML(string=html_with_css).write_pdf(pdf_path)
197
+ return pdf_path
198
+
199
+ @staticmethod
200
+ def _read_as_text_chunks(file_path: Path, kwargs: Dict[str, Any]) -> pd.DataFrame:
201
+ """Simple text reading fallback with chunking."""
202
+ try:
203
+ file_extension = file_path.suffix.lower()
204
+ chunk_size = kwargs.get("chunk_size", 1000)
205
+ chunk_overlap = kwargs.get("chunk_overlap", 200)
206
+
207
+ if file_extension in FileReader.TEXT_EXTENSIONS:
208
+ with open(file_path, "r", encoding="utf-8") as f:
209
+ content = f.read()
210
+ elif file_extension == ".docx":
211
+ try:
212
+ from docx import Document
213
+
214
+ doc = Document(file_path)
215
+ content = "\n".join(
216
+ [paragraph.text for paragraph in doc.paragraphs]
217
+ )
218
+ except ImportError:
219
+ raise FileError("python-docx not available for DOCX reading")
220
+ elif file_extension == ".pdf":
221
+ content = FileReader._extract_pdf_text(file_path)
222
+ else:
223
+ raise FileError(f"Cannot read {file_extension} as simple text")
224
+
225
+ # Simple chunking
226
+ chunks = []
227
+ for i in range(0, len(content), chunk_size - chunk_overlap):
228
+ chunks.append(content[i : i + chunk_size])
229
+
230
+ return pd.DataFrame(
231
+ {
232
+ "text": chunks,
233
+ "chunk_id": range(len(chunks)),
234
+ "source": str(file_path),
235
+ "processing_method": ["simple_text"] * len(chunks),
236
+ }
237
+ )
238
+
239
+ except Exception as e:
240
+ raise FileError(f"Error reading {file_path} as text: {str(e)}")
241
+
242
+ @staticmethod
243
+ def _read_pdf_chunks(file_path: Path, kwargs: Dict[str, Any]) -> pd.DataFrame:
244
+ """Simple PDF text extraction fallback with chunking."""
245
+ try:
246
+ chunk_size = kwargs.get("chunk_size", 1000)
247
+ chunk_overlap = kwargs.get("chunk_overlap", 200)
248
+
249
+ content = FileReader._extract_pdf_text(file_path)
250
+
251
+ # Simple chunking
252
+ chunks = []
253
+ for i in range(0, len(content), chunk_size - chunk_overlap):
254
+ chunks.append(content[i : i + chunk_size])
255
+
256
+ return pd.DataFrame(
257
+ {
258
+ "text": chunks,
259
+ "chunk_id": range(len(chunks)),
260
+ "source": str(file_path),
261
+ "processing_method": ["simple_pdf"] * len(chunks),
262
+ }
263
+ )
264
+
265
+ except Exception as e:
266
+ raise FileError(f"Error reading PDF {file_path}: {str(e)}")
267
+
268
+ @staticmethod
269
+ def _extract_pdf_text(file_path: Path) -> str:
270
+ """Extract text from PDF file using PyPDF2."""
271
+ try:
272
+ import PyPDF2
273
+
274
+ with open(file_path, "rb") as file:
275
+ pdf_reader = PyPDF2.PdfReader(file)
276
+ text = ""
277
+ for page in pdf_reader.pages:
278
+ text += page.extract_text() + "\n"
279
+ return text
280
+
281
+ except ImportError:
282
+ raise FileError("PyPDF2 not available for simple PDF reading")
283
+ except Exception as e:
284
+ raise FileError(f"Error reading PDF {file_path}: {str(e)}")
285
+
286
+ @staticmethod
287
+ def get_file_type(file_path: Union[str, Path]) -> str:
288
+ """Get the type of file based on its extension"""
289
+ file_extension = Path(file_path).suffix.lower()
290
+
291
+ if file_extension in FileReader.STRUCTURED_EXTENSIONS:
292
+ return "structured"
293
+ elif file_extension in FileReader.TEXT_EXTENSIONS:
294
+ return "text"
295
+ elif file_extension in FileReader.DOCUMENT_EXTENSIONS:
296
+ return "document"
297
+ else:
298
+ return "unknown"