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,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"
|