Hamza4100 commited on
Commit
2472683
·
verified ·
1 Parent(s): 0fef756

Delete all files

Browse files
Dockerfile DELETED
@@ -1,33 +0,0 @@
1
- FROM python:3.11-slim
2
-
3
- # Set working directory
4
- WORKDIR /app
5
-
6
- # Install system dependencies
7
- RUN apt-get update && apt-get install -y \
8
- tesseract-ocr \
9
- tesseract-ocr-eng \
10
- poppler-utils \
11
- && rm -rf /var/lib/apt/lists/*
12
-
13
- # Copy requirements first for better caching
14
- COPY requirements.txt .
15
-
16
- # Install Python dependencies
17
- RUN pip install --no-cache-dir -r requirements.txt
18
-
19
- # Copy application code
20
- COPY . .
21
-
22
- # Create necessary directories
23
- RUN mkdir -p uploads outputs static config
24
-
25
- # Expose port (Hugging Face Spaces uses port 7860)
26
- EXPOSE 7860
27
-
28
- # Set environment variables for Gradio
29
- ENV GRADIO_SERVER_NAME="0.0.0.0" \
30
- GRADIO_SERVER_PORT=7860
31
-
32
- # Run the application
33
- CMD ["python", "app_gradio.py"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md DELETED
@@ -1,39 +0,0 @@
1
- ---
2
- title: T1 Tax PDF Processor
3
- emoji: 📄
4
- colorFrom: purple
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 4.19.2
8
- app_file: app_gradio.py
9
- pinned: false
10
- license: mit
11
- ---
12
-
13
- # T1 Tax PDF Processor
14
-
15
- Automatically extract data from Canadian T1 tax return PDFs and fill PDF forms.
16
-
17
- ## How to Use
18
-
19
- 1. **Upload Source PDF** - Your T1 tax return (with line numbers and data)
20
- 2. **Upload Template PDF** - Empty fillable form you want to fill
21
- 3. **Click "Process PDFs"** - Automatic extraction and filling
22
- 4. **Download Result** - Get your filled PDF
23
-
24
- ## Features
25
-
26
- - 📄 Extract text from T1 PDFs
27
- - 🔍 Smart T1 line detection (15000, 23600, 26000, etc.)
28
- - 🤖 OCR support for scanned PDFs
29
- - 📝 Automatic form filling
30
- - 🗺️ Configurable field mappings
31
-
32
- ## Tech Stack
33
-
34
- Python · FastAPI · Gradio · pdfplumber · Tesseract OCR
35
-
36
- ## ⚠️ Privacy Notice
37
-
38
- **Demo Only** - This is a demonstration application.
39
- Do not upload real tax documents with sensitive personal information to public demos.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/__init__.py DELETED
@@ -1,7 +0,0 @@
1
- """
2
- T1 Tax PDF Processor Application.
3
-
4
- A complete automation system for extracting data from Canadian T1 tax return PDFs
5
- and filling other PDF forms automatically.
6
- """
7
- __version__ = "1.0.0"
 
 
 
 
 
 
 
 
app/api/__init__.py DELETED
@@ -1,4 +0,0 @@
1
- """API module with FastAPI routes."""
2
- from app.api.routes import router
3
-
4
- __all__ = ["router"]
 
 
 
 
 
app/api/models.py DELETED
@@ -1,120 +0,0 @@
1
- """
2
- Pydantic models for API request/response validation.
3
- """
4
- from datetime import datetime
5
- from typing import Optional
6
- from pydantic import BaseModel, Field
7
-
8
-
9
- class UploadResponse(BaseModel):
10
- """Response model for file upload endpoint."""
11
- success: bool
12
- session_id: str = Field(..., description="Unique session identifier for tracking files")
13
- source_filename: str
14
- target_filename: Optional[str] = None
15
- message: str
16
-
17
-
18
- class ExtractedData(BaseModel):
19
- """Model for extracted T1 tax data."""
20
- line_values: dict[str, Optional[str]] = Field(
21
- default_factory=dict,
22
- description="Extracted line numbers and their values"
23
- )
24
- extraction_method: str = Field(
25
- default="text",
26
- description="Method used: 'text' or 'ocr'"
27
- )
28
- has_text: bool = Field(
29
- default=True,
30
- description="Whether PDF had extractable text"
31
- )
32
- raw_text: Optional[str] = Field(
33
- default=None,
34
- description="Raw extracted text (optional)"
35
- )
36
-
37
-
38
- class ProcessingRequest(BaseModel):
39
- """Request model for processing endpoint."""
40
- session_id: str = Field(..., description="Session ID from upload")
41
- line_numbers: Optional[list[str]] = Field(
42
- default=None,
43
- description="Specific line numbers to extract. If None, extracts all."
44
- )
45
- use_ocr: bool = Field(
46
- default=False,
47
- description="Force OCR processing"
48
- )
49
- include_raw_text: bool = Field(
50
- default=False,
51
- description="Include raw extracted text in response"
52
- )
53
-
54
-
55
- class ProcessingResponse(BaseModel):
56
- """Response model for processing endpoint."""
57
- success: bool
58
- session_id: str
59
- extracted_data: Optional[ExtractedData] = None
60
- mapped_fields: Optional[dict[str, str]] = None
61
- output_filename: Optional[str] = None
62
- errors: list[str] = Field(default_factory=list)
63
- warnings: list[str] = Field(default_factory=list)
64
-
65
-
66
- class BatchUploadItem(BaseModel):
67
- """Model for a single item in batch upload."""
68
- source_filename: str
69
- target_filename: Optional[str] = None
70
-
71
-
72
- class BatchProcessingRequest(BaseModel):
73
- """Request model for batch processing."""
74
- session_ids: list[str] = Field(..., description="List of session IDs to process")
75
- line_numbers: Optional[list[str]] = None
76
- use_ocr: bool = False
77
-
78
-
79
- class BatchProcessingResponse(BaseModel):
80
- """Response model for batch processing."""
81
- success: bool
82
- total: int
83
- processed: int
84
- failed: int
85
- results: list[ProcessingResponse]
86
-
87
-
88
- class FieldMapping(BaseModel):
89
- """Model for a field mapping entry."""
90
- line_number: str
91
- field_name: str
92
- description: Optional[str] = None
93
-
94
-
95
- class MappingConfigRequest(BaseModel):
96
- """Request model for updating mapping configuration."""
97
- mappings: list[FieldMapping]
98
-
99
-
100
- class MappingConfigResponse(BaseModel):
101
- """Response model for mapping configuration."""
102
- success: bool
103
- mappings: dict[str, dict]
104
- message: str
105
-
106
-
107
- class HealthResponse(BaseModel):
108
- """Response model for health check endpoint."""
109
- status: str
110
- version: str
111
- timestamp: datetime
112
- tesseract_available: bool
113
- openai_configured: bool
114
-
115
-
116
- class ErrorResponse(BaseModel):
117
- """Standard error response model."""
118
- success: bool = False
119
- error: str
120
- details: Optional[dict] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/processor_service.py DELETED
@@ -1,274 +0,0 @@
1
- """
2
- PDF processing service that orchestrates extraction, mapping, and filling.
3
- """
4
- import uuid
5
- import shutil
6
- from pathlib import Path
7
- from typing import Optional
8
- from datetime import datetime
9
-
10
- from app.config import settings
11
- from app.extractor import PDFExtractor
12
- from app.ocr import OCRProcessor
13
- from app.mapper import FieldMapper
14
- from app.filler import PDFFiller
15
- from app.utils.logging import get_logger
16
- from app.utils.exceptions import (
17
- PDFProcessorError,
18
- PDFExtractionError,
19
- OCRError,
20
- MappingError,
21
- PDFFillingError,
22
- FileValidationError
23
- )
24
-
25
- logger = get_logger(__name__)
26
-
27
-
28
- class ProcessingSession:
29
- """Represents a single PDF processing session."""
30
-
31
- def __init__(self, session_id: str):
32
- self.session_id = session_id
33
- self.created_at = datetime.utcnow()
34
- self.source_pdf: Optional[Path] = None
35
- self.template_pdf: Optional[Path] = None
36
- self.output_pdf: Optional[Path] = None
37
- self.extracted_data: Optional[dict] = None
38
- self.mapped_fields: Optional[dict] = None
39
- self.status: str = "initialized"
40
- self.errors: list[str] = []
41
- self.warnings: list[str] = []
42
-
43
- @property
44
- def session_dir(self) -> Path:
45
- return settings.upload_dir / self.session_id
46
-
47
-
48
- class PDFProcessorService:
49
- """
50
- Main service class that coordinates PDF processing.
51
- """
52
-
53
- def __init__(self, mapping_config_path: Optional[Path] = None):
54
- self.extractor = PDFExtractor()
55
- self.filler = PDFFiller()
56
- self.mapper = FieldMapper(config_path=mapping_config_path)
57
-
58
- # Initialize OCR (may not be available)
59
- try:
60
- self.ocr = OCRProcessor()
61
- self.ocr_available = self.ocr.is_available()
62
- except Exception as e:
63
- logger.warning(f"OCR not available: {e}")
64
- self.ocr = None
65
- self.ocr_available = False
66
-
67
- # Track active sessions
68
- self.sessions: dict[str, ProcessingSession] = {}
69
-
70
- def create_session(self) -> ProcessingSession:
71
- """Create a new processing session."""
72
- session_id = str(uuid.uuid4())
73
- session = ProcessingSession(session_id)
74
- session.session_dir.mkdir(parents=True, exist_ok=True)
75
- self.sessions[session_id] = session
76
- logger.info(f"Created new session: {session_id}")
77
- return session
78
-
79
- def get_session(self, session_id: str) -> ProcessingSession:
80
- """Get an existing session."""
81
- if session_id not in self.sessions:
82
- # Try to restore from disk
83
- session_dir = settings.upload_dir / session_id
84
- if session_dir.exists():
85
- session = ProcessingSession(session_id)
86
- self.sessions[session_id] = session
87
- return session
88
- raise FileValidationError(f"Session not found: {session_id}")
89
- return self.sessions[session_id]
90
-
91
- def save_uploaded_file(
92
- self,
93
- session: ProcessingSession,
94
- file_content: bytes,
95
- filename: str,
96
- file_type: str
97
- ) -> Path:
98
- """
99
- Save an uploaded file to the session directory.
100
-
101
- Args:
102
- session: Processing session.
103
- file_content: File content as bytes.
104
- filename: Original filename.
105
- file_type: 'source' or 'template'.
106
-
107
- Returns:
108
- Path to saved file.
109
- """
110
- # Validate extension
111
- ext = Path(filename).suffix.lower()
112
- if ext not in settings.allowed_extensions:
113
- raise FileValidationError(
114
- f"Invalid file extension: {ext}. Allowed: {settings.allowed_extensions}"
115
- )
116
-
117
- # Validate file size
118
- size_mb = len(file_content) / (1024 * 1024)
119
- if size_mb > settings.max_file_size_mb:
120
- raise FileValidationError(
121
- f"File too large: {size_mb:.2f}MB. Max: {settings.max_file_size_mb}MB"
122
- )
123
-
124
- # Save file
125
- safe_filename = f"{file_type}_{filename}"
126
- file_path = session.session_dir / safe_filename
127
- file_path.write_bytes(file_content)
128
-
129
- if file_type == "source":
130
- session.source_pdf = file_path
131
- elif file_type == "template":
132
- session.template_pdf = file_path
133
-
134
- logger.info(f"Saved {file_type} file: {file_path}")
135
- return file_path
136
-
137
- def process(
138
- self,
139
- session: ProcessingSession,
140
- line_numbers: Optional[list[str]] = None,
141
- use_ocr: Optional[bool] = None,
142
- flatten: bool = False
143
- ) -> dict:
144
- """
145
- Process the uploaded PDFs.
146
-
147
- Args:
148
- session: Processing session.
149
- line_numbers: Specific line numbers to extract.
150
- use_ocr: Force OCR processing.
151
- flatten: Flatten output PDF form.
152
-
153
- Returns:
154
- Processing result dictionary.
155
- """
156
- if not session.source_pdf:
157
- raise FileValidationError("No source PDF uploaded")
158
- if not session.template_pdf:
159
- raise FileValidationError("No template PDF uploaded")
160
-
161
- session.status = "processing"
162
-
163
- try:
164
- # Step 1: Extract data from source PDF
165
- extracted = self._extract_data(session, line_numbers, use_ocr)
166
- session.extracted_data = extracted
167
-
168
- # Step 2: Map extracted values to form fields
169
- mapped = self.mapper.map_values(extracted["line_values"])
170
- session.mapped_fields = mapped
171
-
172
- # Step 3: Fill the template PDF
173
- output_filename = f"filled_{session.session_id}.pdf"
174
- output_path = settings.output_dir / output_filename
175
-
176
- self.filler.fill_form(
177
- template_path=session.template_pdf,
178
- output_path=output_path,
179
- field_values=mapped,
180
- flatten=flatten
181
- )
182
- session.output_pdf = output_path
183
-
184
- session.status = "completed"
185
- logger.info(f"Processing completed for session {session.session_id}")
186
-
187
- return {
188
- "status": "success",
189
- "extracted_data": extracted,
190
- "mapped_fields": mapped,
191
- "output_filename": output_filename
192
- }
193
-
194
- except Exception as e:
195
- session.status = "failed"
196
- session.errors.append(str(e))
197
- logger.error(f"Processing failed for session {session.session_id}: {e}")
198
- raise
199
-
200
- def _extract_data(
201
- self,
202
- session: ProcessingSession,
203
- line_numbers: Optional[list[str]] = None,
204
- use_ocr: Optional[bool] = None
205
- ) -> dict:
206
- """Extract data from source PDF."""
207
- # First try text extraction
208
- has_text = self.extractor.has_text_content(session.source_pdf)
209
-
210
- if has_text and not use_ocr:
211
- # Use text extraction
212
- result = self.extractor.extract_all_data(session.source_pdf, line_numbers)
213
- result["extraction_method"] = "text"
214
- elif self.ocr_available:
215
- # Use OCR
216
- logger.info("Using OCR for extraction")
217
- text = self.ocr.process_pdf(session.source_pdf)
218
- line_values = self.extractor.extract_line_values(text, line_numbers)
219
- result = {
220
- "text": text,
221
- "line_values": line_values,
222
- "has_text": False,
223
- "extraction_method": "ocr"
224
- }
225
- else:
226
- # No text and no OCR available
227
- session.warnings.append("PDF appears to be scanned but OCR is not available")
228
- result = self.extractor.extract_all_data(session.source_pdf, line_numbers)
229
- result["extraction_method"] = "text_fallback"
230
-
231
- # Add text preview (first 500 chars)
232
- if result.get("text"):
233
- result["text_preview"] = result["text"][:500]
234
-
235
- return result
236
-
237
- def get_output_path(self, session: ProcessingSession) -> Optional[Path]:
238
- """Get the output PDF path for a session."""
239
- if session.output_pdf and session.output_pdf.exists():
240
- return session.output_pdf
241
- return None
242
-
243
- def get_template_fields(self, session: ProcessingSession) -> dict:
244
- """Get form fields from the template PDF."""
245
- if not session.template_pdf:
246
- raise FileValidationError("No template PDF uploaded")
247
- return self.filler.get_form_fields(session.template_pdf)
248
-
249
- def update_mapping(self, line_number: str, field_name: str, description: str = "") -> None:
250
- """Update field mapping."""
251
- self.mapper.add_mapping(line_number, field_name, description)
252
-
253
- def get_all_mappings(self) -> dict:
254
- """Get all current mappings."""
255
- return self.mapper.get_all_mappings()
256
-
257
- def save_mapping_config(self, path: Optional[Path] = None) -> None:
258
- """Save current mappings to config file."""
259
- if path is None:
260
- path = settings.config_dir / settings.default_mapping_file
261
- self.mapper.save_config(path)
262
-
263
- def cleanup_session(self, session_id: str) -> None:
264
- """Clean up session files."""
265
- if session_id in self.sessions:
266
- session = self.sessions[session_id]
267
- if session.session_dir.exists():
268
- shutil.rmtree(session.session_dir)
269
- del self.sessions[session_id]
270
- logger.info(f"Cleaned up session: {session_id}")
271
-
272
-
273
- # Global service instance
274
- processor_service = PDFProcessorService()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/routes.py DELETED
@@ -1,286 +0,0 @@
1
- """
2
- FastAPI route handlers for PDF processing API.
3
- """
4
- from pathlib import Path
5
- from typing import Optional
6
-
7
- from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks
8
- from fastapi.responses import FileResponse
9
-
10
- from app.api.schemas import (
11
- ProcessingRequest,
12
- ProcessingResult,
13
- UploadResponse,
14
- BatchProcessingRequest,
15
- BatchProcessingResult,
16
- MappingConfig,
17
- MappingEntry,
18
- TemplateValidation,
19
- FormFieldInfo,
20
- ExtractedData,
21
- HealthResponse
22
- )
23
- from app.api.processor_service import processor_service
24
- from app.config import settings
25
- from app.utils.logging import get_logger
26
- from app.utils.exceptions import PDFProcessorError, FileValidationError
27
-
28
- logger = get_logger(__name__)
29
-
30
- router = APIRouter(prefix="/api/v1", tags=["pdf"])
31
-
32
-
33
- @router.get("/health", response_model=HealthResponse)
34
- async def health_check():
35
- """Check API health and service availability."""
36
- return HealthResponse(
37
- status="healthy",
38
- version=settings.app_version,
39
- ocr_available=processor_service.ocr_available,
40
- gemini_available=settings.google_api_key is not None
41
- )
42
-
43
-
44
- @router.post("/upload", response_model=UploadResponse)
45
- async def upload_files(
46
- source_pdf: UploadFile = File(..., description="T1 tax return PDF to extract data from"),
47
- template_pdf: UploadFile = File(..., description="Target PDF form to fill")
48
- ):
49
- """
50
- Upload source T1 PDF and target template PDF.
51
-
52
- Returns a session ID for subsequent processing.
53
- """
54
- try:
55
- # Create new session
56
- session = processor_service.create_session()
57
-
58
- # Save source PDF
59
- source_content = await source_pdf.read()
60
- processor_service.save_uploaded_file(
61
- session, source_content, source_pdf.filename, "source"
62
- )
63
-
64
- # Save template PDF
65
- template_content = await template_pdf.read()
66
- processor_service.save_uploaded_file(
67
- session, template_content, template_pdf.filename, "template"
68
- )
69
-
70
- return UploadResponse(
71
- session_id=session.session_id,
72
- source_filename=source_pdf.filename,
73
- template_filename=template_pdf.filename,
74
- message="Files uploaded successfully. Use /process to extract and fill data."
75
- )
76
-
77
- except FileValidationError as e:
78
- raise HTTPException(status_code=400, detail=str(e))
79
- except Exception as e:
80
- logger.error(f"Upload failed: {e}")
81
- raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
82
-
83
-
84
- @router.post("/process", response_model=ProcessingResult)
85
- async def process_pdfs(request: ProcessingRequest):
86
- """
87
- Process uploaded PDFs: extract data and fill template.
88
-
89
- Extracts T1 tax line values from the source PDF and fills the template PDF form.
90
- """
91
- try:
92
- session = processor_service.get_session(request.session_id)
93
-
94
- result = processor_service.process(
95
- session=session,
96
- line_numbers=request.line_numbers,
97
- use_ocr=request.use_ocr,
98
- flatten=request.flatten_output
99
- )
100
-
101
- extracted = result["extracted_data"]
102
- return ProcessingResult(
103
- session_id=session.session_id,
104
- status="success",
105
- extracted_data=ExtractedData(
106
- line_values=extracted["line_values"],
107
- extraction_method=extracted["extraction_method"],
108
- has_text_content=extracted["has_text"],
109
- raw_text_preview=extracted.get("text_preview")
110
- ),
111
- mapped_fields=result["mapped_fields"],
112
- output_filename=result["output_filename"],
113
- errors=session.errors,
114
- warnings=session.warnings
115
- )
116
-
117
- except FileValidationError as e:
118
- raise HTTPException(status_code=400, detail=str(e))
119
- except PDFProcessorError as e:
120
- logger.error(f"Processing error: {e}")
121
- raise HTTPException(status_code=422, detail=str(e))
122
- except Exception as e:
123
- logger.error(f"Unexpected error: {e}")
124
- raise HTTPException(status_code=500, detail=f"Processing failed: {str(e)}")
125
-
126
-
127
- @router.get("/download/{session_id}")
128
- async def download_result(session_id: str):
129
- """
130
- Download the filled PDF for a completed session.
131
- """
132
- try:
133
- session = processor_service.get_session(session_id)
134
- output_path = processor_service.get_output_path(session)
135
-
136
- if not output_path:
137
- raise HTTPException(
138
- status_code=404,
139
- detail="Output file not found. Ensure processing is complete."
140
- )
141
-
142
- return FileResponse(
143
- path=output_path,
144
- filename=f"filled_t1_{session_id[:8]}.pdf",
145
- media_type="application/pdf"
146
- )
147
-
148
- except FileValidationError as e:
149
- raise HTTPException(status_code=404, detail=str(e))
150
- except Exception as e:
151
- logger.error(f"Download error: {e}")
152
- raise HTTPException(status_code=500, detail=str(e))
153
-
154
-
155
- @router.post("/batch/process", response_model=BatchProcessingResult)
156
- async def batch_process(request: BatchProcessingRequest):
157
- """
158
- Process multiple uploaded PDF sessions in batch.
159
- """
160
- results = []
161
- successful = 0
162
- failed = 0
163
-
164
- for session_id in request.session_ids:
165
- try:
166
- session = processor_service.get_session(session_id)
167
- result = processor_service.process(
168
- session=session,
169
- line_numbers=request.line_numbers,
170
- use_ocr=request.use_ocr,
171
- flatten=request.flatten_output
172
- )
173
-
174
- extracted = result["extracted_data"]
175
- results.append(ProcessingResult(
176
- session_id=session_id,
177
- status="success",
178
- extracted_data=ExtractedData(
179
- line_values=extracted["line_values"],
180
- extraction_method=extracted["extraction_method"],
181
- has_text_content=extracted["has_text"],
182
- raw_text_preview=extracted.get("text_preview")
183
- ),
184
- mapped_fields=result["mapped_fields"],
185
- output_filename=result["output_filename"],
186
- errors=session.errors,
187
- warnings=session.warnings
188
- ))
189
- successful += 1
190
-
191
- except Exception as e:
192
- logger.error(f"Batch processing failed for {session_id}: {e}")
193
- results.append(ProcessingResult(
194
- session_id=session_id,
195
- status="failed",
196
- errors=[str(e)]
197
- ))
198
- failed += 1
199
-
200
- return BatchProcessingResult(
201
- total=len(request.session_ids),
202
- successful=successful,
203
- failed=failed,
204
- results=results
205
- )
206
-
207
-
208
- @router.get("/template/fields/{session_id}", response_model=TemplateValidation)
209
- async def get_template_fields(session_id: str):
210
- """
211
- Get form fields from the uploaded template PDF.
212
- """
213
- try:
214
- session = processor_service.get_session(session_id)
215
- fields = processor_service.get_template_fields(session)
216
-
217
- field_list = [
218
- FormFieldInfo(
219
- name=name,
220
- field_type=str(info.get("type", "Unknown")),
221
- current_value=str(info.get("value", "")) if info.get("value") else None
222
- )
223
- for name, info in fields.items()
224
- ]
225
-
226
- # Check which mapped fields are missing
227
- all_mappings = processor_service.get_all_mappings()
228
- mapped_field_names = {m["field"] for m in all_mappings.values()}
229
- existing_field_names = set(fields.keys())
230
- missing = list(mapped_field_names - existing_field_names)
231
-
232
- return TemplateValidation(
233
- valid=len(missing) == 0,
234
- total_fields=len(fields),
235
- fields=field_list,
236
- missing_fields=missing
237
- )
238
-
239
- except FileValidationError as e:
240
- raise HTTPException(status_code=400, detail=str(e))
241
- except Exception as e:
242
- logger.error(f"Template fields error: {e}")
243
- raise HTTPException(status_code=500, detail=str(e))
244
-
245
-
246
- @router.get("/mappings")
247
- async def get_mappings():
248
- """
249
- Get all current T1 line to field mappings.
250
- """
251
- return processor_service.get_all_mappings()
252
-
253
-
254
- @router.post("/mappings")
255
- async def update_mappings(config: MappingConfig):
256
- """
257
- Update T1 line to field mappings.
258
- """
259
- try:
260
- for entry in config.mappings:
261
- processor_service.update_mapping(
262
- entry.line_number,
263
- entry.field_name,
264
- entry.description or ""
265
- )
266
-
267
- # Save to config file
268
- processor_service.save_mapping_config()
269
-
270
- return {"message": f"Updated {len(config.mappings)} mappings"}
271
-
272
- except Exception as e:
273
- logger.error(f"Mapping update error: {e}")
274
- raise HTTPException(status_code=500, detail=str(e))
275
-
276
-
277
- @router.delete("/session/{session_id}")
278
- async def cleanup_session(session_id: str, background_tasks: BackgroundTasks):
279
- """
280
- Clean up session files and data.
281
- """
282
- try:
283
- background_tasks.add_task(processor_service.cleanup_session, session_id)
284
- return {"message": f"Session {session_id} cleanup scheduled"}
285
- except Exception as e:
286
- raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/schemas.py DELETED
@@ -1,108 +0,0 @@
1
- """
2
- Pydantic models for API request/response validation.
3
- """
4
- from pathlib import Path
5
- from typing import Optional
6
- from pydantic import BaseModel, Field
7
-
8
-
9
- class ProcessingRequest(BaseModel):
10
- """Request model for PDF processing."""
11
- session_id: str = Field(..., description="Session ID from upload")
12
- line_numbers: Optional[list[str]] = Field(
13
- None,
14
- description="Specific T1 line numbers to extract. If None, extracts all."
15
- )
16
- use_ocr: Optional[bool] = Field(
17
- None,
18
- description="Force OCR processing. Auto-detected if None."
19
- )
20
- flatten_output: bool = Field(
21
- False,
22
- description="Flatten the output PDF form fields."
23
- )
24
-
25
-
26
- class ExtractedData(BaseModel):
27
- """Model for extracted T1 tax data."""
28
- line_values: dict[str, Optional[str]]
29
- extraction_method: str
30
- has_text_content: bool
31
- raw_text_preview: Optional[str] = None
32
-
33
-
34
- class ProcessingResult(BaseModel):
35
- """Response model for processing results."""
36
- session_id: str
37
- status: str
38
- extracted_data: Optional[ExtractedData] = None
39
- mapped_fields: Optional[dict[str, str]] = None
40
- output_filename: Optional[str] = None
41
- errors: list[str] = []
42
- warnings: list[str] = []
43
-
44
-
45
- class UploadResponse(BaseModel):
46
- """Response model for file upload."""
47
- session_id: str
48
- source_filename: str
49
- template_filename: str
50
- message: str
51
-
52
-
53
- class DownloadInfo(BaseModel):
54
- """Response model for download information."""
55
- session_id: str
56
- filename: str
57
- download_url: str
58
-
59
-
60
- class BatchProcessingRequest(BaseModel):
61
- """Request model for batch processing."""
62
- session_ids: list[str]
63
- line_numbers: Optional[list[str]] = None
64
- use_ocr: Optional[bool] = None
65
- flatten_output: bool = False
66
-
67
-
68
- class BatchProcessingResult(BaseModel):
69
- """Response model for batch processing."""
70
- total: int
71
- successful: int
72
- failed: int
73
- results: list[ProcessingResult]
74
-
75
-
76
- class MappingEntry(BaseModel):
77
- """Model for a single mapping entry."""
78
- line_number: str
79
- field_name: str
80
- description: Optional[str] = ""
81
-
82
-
83
- class MappingConfig(BaseModel):
84
- """Model for mapping configuration."""
85
- mappings: list[MappingEntry]
86
-
87
-
88
- class FormFieldInfo(BaseModel):
89
- """Model for PDF form field information."""
90
- name: str
91
- field_type: str
92
- current_value: Optional[str] = None
93
-
94
-
95
- class TemplateValidation(BaseModel):
96
- """Response model for template validation."""
97
- valid: bool
98
- total_fields: int
99
- fields: list[FormFieldInfo]
100
- missing_fields: list[str] = []
101
-
102
-
103
- class HealthResponse(BaseModel):
104
- """Health check response."""
105
- status: str
106
- version: str
107
- ocr_available: bool
108
- gemini_available: bool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/config.py DELETED
@@ -1,53 +0,0 @@
1
- """
2
- Application configuration settings.
3
- """
4
- import os
5
- from pathlib import Path
6
- from typing import Optional
7
-
8
- from pydantic_settings import BaseSettings
9
-
10
-
11
- class Settings(BaseSettings):
12
- """Application settings loaded from environment variables."""
13
-
14
- # Application
15
- app_name: str = "T1 Tax PDF Processor"
16
- app_version: str = "1.0.0"
17
- debug: bool = False
18
-
19
- # Paths
20
- base_dir: Path = Path(__file__).parent.parent
21
- upload_dir: Path = base_dir / "uploads"
22
- output_dir: Path = base_dir / "outputs"
23
- config_dir: Path = base_dir / "config"
24
- static_dir: Path = base_dir / "static"
25
- templates_dir: Path = base_dir / "templates"
26
-
27
- # OCR Settings
28
- tesseract_cmd: Optional[str] = None # Path to tesseract executable
29
- ocr_language: str = "eng"
30
- ocr_dpi: int = 300
31
-
32
- # PDF Processing
33
- max_file_size_mb: int = 50
34
- allowed_extensions: list[str] = [".pdf"]
35
-
36
- # Google Generative AI (optional)
37
- google_api_key: Optional[str] = None
38
- gemini_model: str = "gemini-2.5-flash"
39
-
40
- # Mapping
41
- default_mapping_file: str = "t1_mapping.json"
42
-
43
- class Config:
44
- env_file = ".env"
45
- env_file_encoding = "utf-8"
46
-
47
-
48
- settings = Settings()
49
-
50
- # Create directories if they don't exist
51
- settings.upload_dir.mkdir(parents=True, exist_ok=True)
52
- settings.output_dir.mkdir(parents=True, exist_ok=True)
53
- settings.config_dir.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/extractor/__init__.py DELETED
@@ -1,6 +0,0 @@
1
- """
2
- PDF Extractor module for extracting text and data from Canadian T1 tax PDFs.
3
- """
4
- from .pdf_extractor import PDFExtractor
5
-
6
- __all__ = ["PDFExtractor"]
 
 
 
 
 
 
 
app/extractor/pdf_extractor.py DELETED
@@ -1,186 +0,0 @@
1
- """
2
- PDF text extraction module.
3
- Extracts text from PDFs using pdfplumber with PyMuPDF as fallback.
4
- """
5
- import re
6
- from pathlib import Path
7
- from typing import Optional
8
-
9
- import pdfplumber
10
- import fitz # PyMuPDF
11
-
12
- from app.utils.logging import get_logger
13
- from app.utils.exceptions import PDFExtractionError
14
-
15
- logger = get_logger(__name__)
16
-
17
-
18
- class PDFExtractor:
19
- """
20
- Extracts text content and specific T1 tax line values from PDFs.
21
- Uses pdfplumber as primary extractor with PyMuPDF fallback.
22
- """
23
-
24
- # Common T1 tax form line patterns
25
- # Lines can appear in various formats:
26
- # "15000 Total income: 50000.00"
27
- # "Line 15000: $50,000.00"
28
- # "15000 50000"
29
- # "Total income (line 15000) 50000.00"
30
- LINE_PATTERNS = [
31
- # Pattern: "15000 Description: Value" or "15000 Value"
32
- r"(?:line\s*)?(\d{5})\s*[:\-]?\s*(?:[A-Za-z\s,]+[:\-])?\s*\$?\s*([\d,]+\.?\d*)",
33
- # Pattern: "Description (line 15000): Value"
34
- r"[A-Za-z\s]+\(line\s*(\d{5})\)[:\s]*\$?\s*([\d,]+\.?\d*)",
35
- # Pattern: "Line 15000 Description Value"
36
- r"(?:Line|line)\s+(\d{5})\s+[A-Za-z\s]*?\s+\$?\s*([\d,]+\.?\d*)",
37
- # Pattern: Simple "15000 50000.00"
38
- r"^(\d{5})\s+\$?\s*([\d,]+\.?\d*)$",
39
- # Pattern: With dollar sign "15000 $50,000.00"
40
- r"(\d{5})\s+\$\s*([\d,]+\.?\d*)",
41
- ]
42
-
43
- def __init__(self):
44
- self.compiled_patterns = [re.compile(p, re.MULTILINE | re.IGNORECASE) for p in self.LINE_PATTERNS]
45
-
46
- def extract_text(self, pdf_path: Path) -> str:
47
- """
48
- Extract all text from a PDF file.
49
-
50
- Args:
51
- pdf_path: Path to the PDF file.
52
-
53
- Returns:
54
- Extracted text content.
55
-
56
- Raises:
57
- PDFExtractionError: If extraction fails.
58
- """
59
- pdf_path = Path(pdf_path)
60
- if not pdf_path.exists():
61
- raise PDFExtractionError(f"PDF file not found: {pdf_path}")
62
-
63
- text = self._extract_with_pdfplumber(pdf_path)
64
-
65
- # If pdfplumber returns empty or minimal text, try PyMuPDF
66
- if not text or len(text.strip()) < 50:
67
- logger.info("pdfplumber returned minimal text, trying PyMuPDF")
68
- text_pymupdf = self._extract_with_pymupdf(pdf_path)
69
- if len(text_pymupdf) > len(text):
70
- text = text_pymupdf
71
-
72
- return text
73
-
74
- def _extract_with_pdfplumber(self, pdf_path: Path) -> str:
75
- """Extract text using pdfplumber."""
76
- try:
77
- text_parts = []
78
- with pdfplumber.open(pdf_path) as pdf:
79
- for page_num, page in enumerate(pdf.pages, 1):
80
- page_text = page.extract_text() or ""
81
- text_parts.append(page_text)
82
- logger.debug(f"Extracted {len(page_text)} chars from page {page_num}")
83
- return "\n".join(text_parts)
84
- except Exception as e:
85
- logger.warning(f"pdfplumber extraction failed: {e}")
86
- return ""
87
-
88
- def _extract_with_pymupdf(self, pdf_path: Path) -> str:
89
- """Extract text using PyMuPDF (fitz)."""
90
- try:
91
- text_parts = []
92
- with fitz.open(pdf_path) as doc:
93
- for page_num, page in enumerate(doc, 1):
94
- page_text = page.get_text()
95
- text_parts.append(page_text)
96
- logger.debug(f"PyMuPDF extracted {len(page_text)} chars from page {page_num}")
97
- return "\n".join(text_parts)
98
- except Exception as e:
99
- logger.warning(f"PyMuPDF extraction failed: {e}")
100
- return ""
101
-
102
- def has_text_content(self, pdf_path: Path) -> bool:
103
- """
104
- Check if PDF has extractable text content.
105
- Used to determine if OCR is needed.
106
-
107
- Args:
108
- pdf_path: Path to the PDF file.
109
-
110
- Returns:
111
- True if PDF has text content, False if likely scanned.
112
- """
113
- text = self.extract_text(pdf_path)
114
- # If we got less than 100 characters, probably needs OCR
115
- return len(text.strip()) > 100
116
-
117
- def extract_line_values(self, text: str, line_numbers: Optional[list[str]] = None) -> dict[str, Optional[str]]:
118
- """
119
- Extract specific T1 tax line values from text.
120
-
121
- Args:
122
- text: Text content from PDF.
123
- line_numbers: List of line numbers to extract (e.g., ["15000", "23600"]).
124
- If None, extracts all found line values.
125
-
126
- Returns:
127
- Dictionary mapping line numbers to their values.
128
- """
129
- extracted = {}
130
-
131
- # If specific lines requested, initialize them
132
- if line_numbers:
133
- for line in line_numbers:
134
- extracted[line] = None
135
-
136
- # Apply all patterns
137
- for pattern in self.compiled_patterns:
138
- matches = pattern.findall(text)
139
- for match in matches:
140
- if len(match) >= 2:
141
- line_num = match[0]
142
- value = self._clean_value(match[1])
143
-
144
- # Only include if no filter or line is in filter
145
- if line_numbers is None or line_num in line_numbers:
146
- # Keep the value if we found one (don't overwrite with None)
147
- if value and (line_num not in extracted or extracted[line_num] is None):
148
- extracted[line_num] = value
149
- logger.debug(f"Extracted line {line_num}: {value}")
150
-
151
- return extracted
152
-
153
- def _clean_value(self, value: str) -> Optional[str]:
154
- """Clean extracted value by removing formatting."""
155
- if not value:
156
- return None
157
- # Remove commas and extra spaces
158
- cleaned = value.replace(",", "").strip()
159
- # Verify it's a valid number
160
- try:
161
- float(cleaned)
162
- return cleaned
163
- except ValueError:
164
- return None
165
-
166
- def extract_all_data(self, pdf_path: Path, line_numbers: Optional[list[str]] = None) -> dict:
167
- """
168
- Extract all data from a PDF including text and line values.
169
-
170
- Args:
171
- pdf_path: Path to the PDF file.
172
- line_numbers: Optional list of specific line numbers to extract.
173
-
174
- Returns:
175
- Dictionary with 'text', 'line_values', and 'has_text' keys.
176
- """
177
- text = self.extract_text(pdf_path)
178
- has_text = len(text.strip()) > 100
179
- line_values = self.extract_line_values(text, line_numbers)
180
-
181
- return {
182
- "text": text,
183
- "line_values": line_values,
184
- "has_text": has_text,
185
- "extraction_method": "text"
186
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/filler/__init__.py DELETED
@@ -1,4 +0,0 @@
1
- """PDF Filler module for populating PDF forms."""
2
- from app.filler.pdf_filler import PDFFiller
3
-
4
- __all__ = ["PDFFiller"]
 
 
 
 
 
app/filler/pdf_filler.py DELETED
@@ -1,252 +0,0 @@
1
- """
2
- PDF form filler module using pypdf and pdfrw.
3
- """
4
- from pathlib import Path
5
- from typing import Optional
6
- import io
7
-
8
- from pypdf import PdfReader, PdfWriter
9
- from pypdf.generic import NameObject, BooleanObject
10
-
11
- from app.utils.logging import get_logger
12
- from app.utils.exceptions import PDFFillingError
13
-
14
- logger = get_logger(__name__)
15
-
16
-
17
- class PDFFiller:
18
- """
19
- Fills PDF form fields with extracted T1 tax data.
20
- Supports AcroForm fillable PDFs.
21
- """
22
-
23
- def __init__(self):
24
- pass
25
-
26
- def get_form_fields(self, pdf_path: Path) -> dict[str, dict]:
27
- """
28
- Get all form fields from a PDF.
29
-
30
- Args:
31
- pdf_path: Path to the PDF file.
32
-
33
- Returns:
34
- Dictionary of field names to field info.
35
- """
36
- pdf_path = Path(pdf_path)
37
- if not pdf_path.exists():
38
- raise PDFFillingError(f"PDF file not found: {pdf_path}")
39
-
40
- try:
41
- reader = PdfReader(pdf_path)
42
- fields = reader.get_fields()
43
-
44
- if not fields:
45
- logger.warning(f"No form fields found in {pdf_path}")
46
- return {}
47
-
48
- field_info = {}
49
- for name, field in fields.items():
50
- field_info[name] = {
51
- "type": field.get("/FT", "Unknown"),
52
- "value": field.get("/V", ""),
53
- "default": field.get("/DV", ""),
54
- }
55
- logger.info(f"Found {len(field_info)} form fields in {pdf_path}")
56
- return field_info
57
-
58
- except Exception as e:
59
- raise PDFFillingError(f"Failed to read form fields: {e}")
60
-
61
- def fill_form(
62
- self,
63
- template_path: Path,
64
- output_path: Path,
65
- field_values: dict[str, str],
66
- flatten: bool = False
67
- ) -> Path:
68
- """
69
- Fill PDF form fields with values.
70
-
71
- Args:
72
- template_path: Path to the template PDF with form fields.
73
- output_path: Path for the output filled PDF.
74
- field_values: Dictionary mapping field names to values.
75
- flatten: If True, flatten the form (make fields non-editable).
76
-
77
- Returns:
78
- Path to the filled PDF file.
79
- """
80
- template_path = Path(template_path)
81
- output_path = Path(output_path)
82
-
83
- if not template_path.exists():
84
- raise PDFFillingError(f"Template PDF not found: {template_path}")
85
-
86
- output_path.parent.mkdir(parents=True, exist_ok=True)
87
-
88
- try:
89
- reader = PdfReader(template_path)
90
- writer = PdfWriter()
91
-
92
- # Copy all pages
93
- for page in reader.pages:
94
- writer.add_page(page)
95
-
96
- # Get existing form fields
97
- existing_fields = reader.get_fields() or {}
98
-
99
- # Update fields with values
100
- fields_filled = 0
101
- fields_missing = []
102
-
103
- for field_name, value in field_values.items():
104
- if field_name in existing_fields:
105
- writer.update_page_form_field_values(
106
- writer.pages[0],
107
- {field_name: str(value)},
108
- auto_regenerate=True
109
- )
110
- fields_filled += 1
111
- logger.debug(f"Filled field '{field_name}' with '{value}'")
112
- else:
113
- fields_missing.append(field_name)
114
- logger.warning(f"Field '{field_name}' not found in PDF")
115
-
116
- if fields_missing:
117
- logger.info(f"Missing fields: {fields_missing}")
118
-
119
- # Flatten if requested
120
- if flatten:
121
- for page in writer.pages:
122
- if "/Annots" in page:
123
- for annotation in page["/Annots"]:
124
- annotation_obj = annotation.get_object()
125
- if annotation_obj.get("/Subtype") == "/Widget":
126
- annotation_obj[NameObject("/Ff")] = BooleanObject(True)
127
-
128
- # Write output
129
- with open(output_path, "wb") as f:
130
- writer.write(f)
131
-
132
- logger.info(f"Filled {fields_filled} fields, saved to {output_path}")
133
- return output_path
134
-
135
- except Exception as e:
136
- raise PDFFillingError(f"Failed to fill PDF form: {e}")
137
-
138
- def fill_form_bytes(
139
- self,
140
- template_bytes: bytes,
141
- field_values: dict[str, str],
142
- flatten: bool = False
143
- ) -> bytes:
144
- """
145
- Fill PDF form from bytes and return filled PDF as bytes.
146
-
147
- Args:
148
- template_bytes: Template PDF as bytes.
149
- field_values: Dictionary mapping field names to values.
150
- flatten: If True, flatten the form.
151
-
152
- Returns:
153
- Filled PDF as bytes.
154
- """
155
- try:
156
- reader = PdfReader(io.BytesIO(template_bytes))
157
- writer = PdfWriter()
158
-
159
- for page in reader.pages:
160
- writer.add_page(page)
161
-
162
- existing_fields = reader.get_fields() or {}
163
-
164
- for field_name, value in field_values.items():
165
- if field_name in existing_fields:
166
- writer.update_page_form_field_values(
167
- writer.pages[0],
168
- {field_name: str(value)},
169
- auto_regenerate=True
170
- )
171
-
172
- output = io.BytesIO()
173
- writer.write(output)
174
- return output.getvalue()
175
-
176
- except Exception as e:
177
- raise PDFFillingError(f"Failed to fill PDF from bytes: {e}")
178
-
179
- def validate_template(self, pdf_path: Path, required_fields: list[str]) -> dict:
180
- """
181
- Validate that a PDF template has the required form fields.
182
-
183
- Args:
184
- pdf_path: Path to the template PDF.
185
- required_fields: List of required field names.
186
-
187
- Returns:
188
- Dictionary with validation results.
189
- """
190
- existing_fields = self.get_form_fields(pdf_path)
191
- existing_names = set(existing_fields.keys())
192
- required_names = set(required_fields)
193
-
194
- present = required_names & existing_names
195
- missing = required_names - existing_names
196
- extra = existing_names - required_names
197
-
198
- return {
199
- "valid": len(missing) == 0,
200
- "present_fields": list(present),
201
- "missing_fields": list(missing),
202
- "extra_fields": list(extra),
203
- "total_fields": len(existing_fields)
204
- }
205
-
206
- def create_field_mapping_report(
207
- self,
208
- pdf_path: Path,
209
- line_values: dict[str, str],
210
- field_mapping: dict[str, str]
211
- ) -> dict:
212
- """
213
- Create a report showing how T1 lines map to PDF fields.
214
-
215
- Args:
216
- pdf_path: Path to the template PDF.
217
- line_values: Extracted T1 line values.
218
- field_mapping: Mapping from line numbers to field names.
219
-
220
- Returns:
221
- Report dictionary.
222
- """
223
- existing_fields = self.get_form_fields(pdf_path)
224
-
225
- report = {
226
- "mappable_lines": [],
227
- "unmappable_lines": [],
228
- "unfilled_fields": []
229
- }
230
-
231
- mapped_fields = set()
232
- for line_num, value in line_values.items():
233
- field_name = field_mapping.get(line_num)
234
- if field_name and field_name in existing_fields:
235
- report["mappable_lines"].append({
236
- "line": line_num,
237
- "field": field_name,
238
- "value": value
239
- })
240
- mapped_fields.add(field_name)
241
- else:
242
- report["unmappable_lines"].append({
243
- "line": line_num,
244
- "value": value,
245
- "reason": "No matching field" if not field_name else "Field not in PDF"
246
- })
247
-
248
- for field_name in existing_fields:
249
- if field_name not in mapped_fields:
250
- report["unfilled_fields"].append(field_name)
251
-
252
- return report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/main.py DELETED
@@ -1,63 +0,0 @@
1
- """
2
- Main FastAPI application entry point.
3
- """
4
- from fastapi import FastAPI
5
- from fastapi.middleware.cors import CORSMiddleware
6
- from fastapi.staticfiles import StaticFiles
7
- from fastapi.responses import FileResponse
8
-
9
- from app.api import router
10
- from app.config import settings
11
- from app.utils.logging import setup_logging
12
-
13
- # Setup logging
14
- setup_logging()
15
-
16
- # Create FastAPI app
17
- app = FastAPI(
18
- title=settings.app_name,
19
- version=settings.app_version,
20
- description="API for extracting data from Canadian T1 tax return PDFs and filling target PDF forms.",
21
- docs_url="/docs",
22
- redoc_url="/redoc"
23
- )
24
-
25
- # CORS middleware for frontend
26
- app.add_middleware(
27
- CORSMiddleware,
28
- allow_origins=["*"],
29
- allow_credentials=True,
30
- allow_methods=["*"],
31
- allow_headers=["*"],
32
- )
33
-
34
- # Include API routes
35
- app.include_router(router)
36
-
37
- # Mount static files
38
- app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static")
39
-
40
-
41
- @app.get("/")
42
- async def root():
43
- """Serve the main frontend page."""
44
- return FileResponse(settings.templates_dir / "index.html")
45
-
46
-
47
- @app.get("/favicon.ico")
48
- async def favicon():
49
- """Serve favicon."""
50
- favicon_path = settings.static_dir / "favicon.ico"
51
- if favicon_path.exists():
52
- return FileResponse(favicon_path)
53
- return {"detail": "Not found"}
54
-
55
-
56
- if __name__ == "__main__":
57
- import uvicorn
58
- uvicorn.run(
59
- "app.main:app",
60
- host="0.0.0.0",
61
- port=8000,
62
- reload=settings.debug
63
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/mapper/__init__.py DELETED
@@ -1,4 +0,0 @@
1
- """Mapper module for T1 line to PDF field mapping."""
2
- from app.mapper.field_mapper import FieldMapper
3
-
4
- __all__ = ["FieldMapper"]
 
 
 
 
 
app/mapper/field_mapper.py DELETED
@@ -1,135 +0,0 @@
1
- """
2
- Field mapping module for T1 tax line to PDF form field mapping.
3
- """
4
- import json
5
- from pathlib import Path
6
- from typing import Optional
7
-
8
- from app.config import settings
9
- from app.utils.logging import get_logger
10
- from app.utils.exceptions import MappingError
11
-
12
- logger = get_logger(__name__)
13
-
14
-
15
- class FieldMapper:
16
- """
17
- Maps T1 tax line numbers to PDF form field names.
18
- Supports loading mappings from JSON configuration files.
19
- """
20
-
21
- DEFAULT_MAPPINGS = {
22
- "10100": {"field": "employment_income", "description": "Employment income"},
23
- "10400": {"field": "other_employment_income", "description": "Other employment income"},
24
- "11500": {"field": "other_pensions", "description": "Other pensions and superannuation"},
25
- "12100": {"field": "interest_income", "description": "Interest and other investment income"},
26
- "12700": {"field": "taxable_capital_gains", "description": "Taxable capital gains"},
27
- "13000": {"field": "other_income", "description": "Other income"},
28
- "13500": {"field": "self_employment_income", "description": "Self-employment income"},
29
- "14400": {"field": "workers_compensation", "description": "Workers compensation benefits"},
30
- "14500": {"field": "social_assistance", "description": "Social assistance payments"},
31
- "15000": {"field": "total_income", "description": "Total income"},
32
- "20700": {"field": "registered_pension_plan", "description": "Registered pension plan deduction"},
33
- "20800": {"field": "rrsp_deduction", "description": "RRSP/PRPP deduction"},
34
- "21200": {"field": "union_dues", "description": "Annual union, professional, or like dues"},
35
- "21900": {"field": "moving_expenses", "description": "Moving expenses"},
36
- "22000": {"field": "support_payments", "description": "Support payments made"},
37
- "22100": {"field": "carrying_charges", "description": "Carrying charges and interest expenses"},
38
- "22900": {"field": "other_employment_expenses", "description": "Other employment expenses"},
39
- "23200": {"field": "other_deductions", "description": "Other deductions"},
40
- "23300": {"field": "cpp_self_employed", "description": "CPP/QPP contributions on self-employment"},
41
- "23600": {"field": "net_income", "description": "Net income"},
42
- "26000": {"field": "taxable_income", "description": "Taxable income"},
43
- "30000": {"field": "basic_personal_amount", "description": "Basic personal amount"},
44
- "31200": {"field": "ei_premiums", "description": "Employment insurance premiums"},
45
- "31800": {"field": "disability_amount_self", "description": "Disability amount (for self)"},
46
- "33500": {"field": "medical_expenses", "description": "Medical expenses"},
47
- "34900": {"field": "donations_gifts", "description": "Donations and gifts"},
48
- "35000": {"field": "total_non_refundable_credits", "description": "Total federal non-refundable tax credits"},
49
- "42000": {"field": "net_federal_tax", "description": "Net federal tax"},
50
- "43500": {"field": "total_payable", "description": "Total payable"},
51
- "43700": {"field": "total_income_tax_deducted", "description": "Total income tax deducted"},
52
- "48400": {"field": "refund", "description": "Refund"},
53
- "48500": {"field": "balance_owing", "description": "Balance owing"}
54
- }
55
-
56
- def __init__(self, config_path: Optional[Path] = None):
57
- self.mappings: dict[str, dict[str, str]] = {}
58
- self._load_default_mappings()
59
- if config_path:
60
- self.load_config(config_path)
61
-
62
- def _load_default_mappings(self) -> None:
63
- self.mappings = self.DEFAULT_MAPPINGS.copy()
64
- logger.debug(f"Loaded {len(self.mappings)} default mappings")
65
-
66
- def load_config(self, config_path: Path) -> None:
67
- config_path = Path(config_path)
68
- if not config_path.exists():
69
- raise MappingError(f"Mapping config file not found: {config_path}")
70
-
71
- try:
72
- with open(config_path, "r", encoding="utf-8") as f:
73
- config = json.load(f)
74
-
75
- for line_num, value in config.items():
76
- if isinstance(value, str):
77
- self.mappings[line_num] = {"field": value, "description": ""}
78
- elif isinstance(value, dict):
79
- self.mappings[line_num] = value
80
- else:
81
- logger.warning(f"Invalid mapping format for line {line_num}")
82
-
83
- logger.info(f"Loaded mapping config from {config_path}: {len(config)} mappings")
84
- except json.JSONDecodeError as e:
85
- raise MappingError(f"Invalid JSON in config file: {e}")
86
- except Exception as e:
87
- raise MappingError(f"Failed to load config: {e}")
88
-
89
- def save_config(self, config_path: Path) -> None:
90
- config_path = Path(config_path)
91
- config_path.parent.mkdir(parents=True, exist_ok=True)
92
- with open(config_path, "w", encoding="utf-8") as f:
93
- json.dump(self.mappings, f, indent=2)
94
- logger.info(f"Saved mapping config to {config_path}")
95
-
96
- def get_field_name(self, line_number: str) -> Optional[str]:
97
- mapping = self.mappings.get(line_number)
98
- return mapping["field"] if mapping else None
99
-
100
- def get_mapping(self, line_number: str) -> Optional[dict[str, str]]:
101
- return self.mappings.get(line_number)
102
-
103
- def map_values(self, line_values: dict[str, str]) -> dict[str, str]:
104
- mapped = {}
105
- unmapped_lines = []
106
- for line_num, value in line_values.items():
107
- if value is None:
108
- continue
109
- field_name = self.get_field_name(line_num)
110
- if field_name:
111
- mapped[field_name] = value
112
- logger.debug(f"Mapped line {line_num} -> {field_name}: {value}")
113
- else:
114
- unmapped_lines.append(line_num)
115
- logger.warning(f"No mapping found for line {line_num}")
116
- if unmapped_lines:
117
- logger.info(f"Unmapped lines: {unmapped_lines}")
118
- return mapped
119
-
120
- def add_mapping(self, line_number: str, field_name: str, description: str = "") -> None:
121
- self.mappings[line_number] = {"field": field_name, "description": description}
122
- logger.debug(f"Added mapping: {line_number} -> {field_name}")
123
-
124
- def remove_mapping(self, line_number: str) -> bool:
125
- if line_number in self.mappings:
126
- del self.mappings[line_number]
127
- logger.debug(f"Removed mapping for line {line_number}")
128
- return True
129
- return False
130
-
131
- def get_all_mappings(self) -> dict[str, dict[str, str]]:
132
- return self.mappings.copy()
133
-
134
- def get_required_lines(self) -> list[str]:
135
- return list(self.mappings.keys())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/ocr/__init__.py DELETED
@@ -1,4 +0,0 @@
1
- """OCR module for processing scanned PDFs."""
2
- from app.ocr.ocr_processor import OCRProcessor
3
-
4
- __all__ = ["OCRProcessor"]
 
 
 
 
 
app/ocr/ocr_processor.py DELETED
@@ -1,188 +0,0 @@
1
- """
2
- OCR module for processing scanned PDF documents using Tesseract.
3
- """
4
- import tempfile
5
- from pathlib import Path
6
- from typing import Optional
7
-
8
- import pytesseract
9
- from pdf2image import convert_from_path
10
- from PIL import Image
11
-
12
- from app.config import settings
13
- from app.utils.logging import get_logger
14
- from app.utils.exceptions import OCRError
15
-
16
- logger = get_logger(__name__)
17
-
18
-
19
- class OCRProcessor:
20
- """
21
- Processes scanned PDFs using Tesseract OCR.
22
- Converts PDF pages to images and extracts text via OCR.
23
- """
24
-
25
- def __init__(self, tesseract_cmd: Optional[str] = None, language: str = "eng"):
26
- """
27
- Initialize the OCR processor.
28
-
29
- Args:
30
- tesseract_cmd: Path to tesseract executable. Auto-detected if None.
31
- language: OCR language code (default: "eng" for English).
32
- """
33
- self.language = language
34
- self.dpi = settings.ocr_dpi
35
-
36
- # Configure tesseract path if provided
37
- if tesseract_cmd or settings.tesseract_cmd:
38
- pytesseract.pytesseract.tesseract_cmd = tesseract_cmd or settings.tesseract_cmd
39
-
40
- # Verify tesseract is available
41
- self._verify_tesseract()
42
-
43
- def _verify_tesseract(self) -> None:
44
- """Verify that Tesseract is installed and accessible."""
45
- try:
46
- version = pytesseract.get_tesseract_version()
47
- logger.info(f"Tesseract version: {version}")
48
- except Exception as e:
49
- logger.warning(
50
- f"Tesseract not found or not accessible: {e}. "
51
- "OCR will fail if invoked. Install Tesseract to enable OCR support."
52
- )
53
-
54
- def process_pdf(self, pdf_path: Path) -> str:
55
- """
56
- Extract text from a scanned PDF using OCR.
57
-
58
- Args:
59
- pdf_path: Path to the PDF file.
60
-
61
- Returns:
62
- Extracted text from all pages.
63
-
64
- Raises:
65
- OCRError: If OCR processing fails.
66
- """
67
- pdf_path = Path(pdf_path)
68
- if not pdf_path.exists():
69
- raise OCRError(f"PDF file not found: {pdf_path}")
70
-
71
- logger.info(f"Starting OCR processing for: {pdf_path}")
72
-
73
- try:
74
- # Convert PDF pages to images
75
- images = self._pdf_to_images(pdf_path)
76
- logger.info(f"Converted PDF to {len(images)} images")
77
-
78
- # Process each image with OCR
79
- text_parts = []
80
- for i, image in enumerate(images, 1):
81
- page_text = self._ocr_image(image)
82
- text_parts.append(page_text)
83
- logger.debug(f"OCR extracted {len(page_text)} chars from page {i}")
84
-
85
- full_text = "\n\n".join(text_parts)
86
- logger.info(f"OCR completed. Total characters extracted: {len(full_text)}")
87
- return full_text
88
-
89
- except Exception as e:
90
- raise OCRError(f"OCR processing failed: {str(e)}", {"pdf_path": str(pdf_path)})
91
-
92
- def _pdf_to_images(self, pdf_path: Path) -> list[Image.Image]:
93
- """
94
- Convert PDF pages to PIL images.
95
-
96
- Args:
97
- pdf_path: Path to the PDF file.
98
-
99
- Returns:
100
- List of PIL Image objects, one per page.
101
- """
102
- try:
103
- with tempfile.TemporaryDirectory() as temp_dir:
104
- images = convert_from_path(
105
- pdf_path,
106
- dpi=self.dpi,
107
- output_folder=temp_dir,
108
- fmt="png",
109
- thread_count=2,
110
- )
111
- # Convert to list to ensure images are loaded before temp dir cleanup
112
- return list(images)
113
- except Exception as e:
114
- raise OCRError(f"Failed to convert PDF to images: {str(e)}")
115
-
116
- def _ocr_image(self, image: Image.Image) -> str:
117
- """
118
- Perform OCR on a single image.
119
-
120
- Args:
121
- image: PIL Image object.
122
-
123
- Returns:
124
- Extracted text from the image.
125
- """
126
- # Preprocess image for better OCR results
127
- processed = self._preprocess_image(image)
128
-
129
- # Perform OCR
130
- custom_config = r"--oem 3 --psm 6"
131
- text = pytesseract.image_to_string(
132
- processed,
133
- lang=self.language,
134
- config=custom_config
135
- )
136
- return text
137
-
138
- def _preprocess_image(self, image: Image.Image) -> Image.Image:
139
- """
140
- Preprocess image to improve OCR accuracy.
141
-
142
- Args:
143
- image: Original PIL Image.
144
-
145
- Returns:
146
- Preprocessed PIL Image.
147
- """
148
- # Convert to grayscale
149
- if image.mode != "L":
150
- image = image.convert("L")
151
-
152
- # Optional: Apply additional preprocessing
153
- # - Contrast enhancement
154
- # - Noise reduction
155
- # - Deskewing
156
-
157
- return image
158
-
159
- def process_image(self, image_path: Path) -> str:
160
- """
161
- Extract text from a single image file.
162
-
163
- Args:
164
- image_path: Path to the image file.
165
-
166
- Returns:
167
- Extracted text.
168
-
169
- Raises:
170
- OCRError: If OCR processing fails.
171
- """
172
- image_path = Path(image_path)
173
- if not image_path.exists():
174
- raise OCRError(f"Image file not found: {image_path}")
175
-
176
- try:
177
- with Image.open(image_path) as image:
178
- return self._ocr_image(image)
179
- except Exception as e:
180
- raise OCRError(f"Failed to process image: {str(e)}")
181
-
182
- def is_available(self) -> bool:
183
- """Check if Tesseract OCR is available."""
184
- try:
185
- pytesseract.get_tesseract_version()
186
- return True
187
- except Exception:
188
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/utils/__init__.py DELETED
@@ -1,25 +0,0 @@
1
- """
2
- Utility modules for the T1 PDF Processor.
3
- """
4
- from .exceptions import (
5
- PDFProcessorError,
6
- PDFExtractionError,
7
- OCRError,
8
- MappingError,
9
- PDFFillingError,
10
- FileValidationError,
11
- ConfigurationError,
12
- )
13
- from .logging import setup_logging, get_logger
14
-
15
- __all__ = [
16
- "PDFProcessorError",
17
- "PDFExtractionError",
18
- "OCRError",
19
- "MappingError",
20
- "PDFFillingError",
21
- "FileValidationError",
22
- "ConfigurationError",
23
- "setup_logging",
24
- "get_logger",
25
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/utils/exceptions.py DELETED
@@ -1,43 +0,0 @@
1
- """
2
- Custom exception classes for the application.
3
- """
4
- from typing import Optional, Any
5
-
6
-
7
- class PDFProcessorError(Exception):
8
- """Base exception for PDF processor errors."""
9
-
10
- def __init__(self, message: str, details: Optional[dict[str, Any]] = None):
11
- super().__init__(message)
12
- self.message = message
13
- self.details = details or {}
14
-
15
-
16
- class PDFExtractionError(PDFProcessorError):
17
- """Raised when PDF text extraction fails."""
18
- pass
19
-
20
-
21
- class OCRError(PDFProcessorError):
22
- """Raised when OCR processing fails."""
23
- pass
24
-
25
-
26
- class MappingError(PDFProcessorError):
27
- """Raised when field mapping fails."""
28
- pass
29
-
30
-
31
- class PDFFillingError(PDFProcessorError):
32
- """Raised when PDF form filling fails."""
33
- pass
34
-
35
-
36
- class FileValidationError(PDFProcessorError):
37
- """Raised when file validation fails."""
38
- pass
39
-
40
-
41
- class ConfigurationError(PDFProcessorError):
42
- """Raised when configuration is invalid."""
43
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/utils/gemini_parser.py DELETED
@@ -1,129 +0,0 @@
1
- """
2
- Google Gemini-based fallback parser for when regex extraction fails.
3
- """
4
- import json
5
- from typing import Optional
6
-
7
- from app.config import settings
8
- from app.utils.logging import get_logger
9
- from app.utils.exceptions import PDFExtractionError
10
-
11
- logger = get_logger(__name__)
12
-
13
-
14
- class GeminiParser:
15
- """
16
- Uses Google Gemini models to extract T1 tax line values when regex fails.
17
- """
18
-
19
- def __init__(self, api_key: Optional[str] = None):
20
- self.api_key = api_key or settings.google_api_key
21
- self.model_name = settings.gemini_model
22
- self._model = None
23
-
24
- if self.api_key:
25
- try:
26
- import google.generativeai as genai
27
- genai.configure(api_key=self.api_key)
28
- self._model = genai.GenerativeModel(self.model_name)
29
- logger.info(f"Gemini client initialized with model: {self.model_name}")
30
- except ImportError:
31
- logger.warning("google-generativeai package not installed")
32
- except Exception as e:
33
- logger.warning(f"Failed to initialize Gemini client: {e}")
34
-
35
- def is_available(self) -> bool:
36
- """Check if Gemini parsing is available."""
37
- return self._model is not None
38
-
39
- def extract_line_values(
40
- self,
41
- text: str,
42
- line_numbers: Optional[list[str]] = None
43
- ) -> dict[str, Optional[str]]:
44
- """
45
- Use Gemini to extract T1 tax line values from text.
46
-
47
- Args:
48
- text: Text content from PDF.
49
- line_numbers: Specific line numbers to extract.
50
-
51
- Returns:
52
- Dictionary mapping line numbers to values.
53
- """
54
- if not self._model:
55
- raise PDFExtractionError("Gemini client not available")
56
-
57
- if line_numbers:
58
- lines_str = ", ".join(line_numbers)
59
- target_lines = f"Extract values for these specific lines: {lines_str}"
60
- else:
61
- target_lines = "Extract all T1 tax line values you can find"
62
-
63
- prompt = f"""You are a Canadian T1 tax form data extractor.
64
-
65
- Given the following text extracted from a T1 tax return PDF, extract the line values.
66
-
67
- {target_lines}
68
-
69
- T1 tax lines are 5-digit numbers (like 15000, 23600, 26000) followed by dollar amounts.
70
- The format may vary:
71
- - "15000 Total Income: $50,000.00"
72
- - "Line 15000 50000"
73
- - "Total income (line 15000) 50,000"
74
-
75
- Return ONLY a valid JSON object mapping line numbers to their numeric values (without $ or commas).
76
- Example: {{"15000": "50000.00", "23600": "45000.00"}}
77
-
78
- If a line is not found, omit it from the response.
79
-
80
- TEXT:
81
- {text[:8000]}
82
- """
83
-
84
- try:
85
- response = self._model.generate_content(
86
- prompt,
87
- generation_config={
88
- "temperature": 0.1,
89
- "max_output_tokens": 1000,
90
- }
91
- )
92
-
93
- result_text = response.text.strip()
94
-
95
- # Clean up markdown code blocks if present
96
- if result_text.startswith("```"):
97
- result_text = result_text.split("```")[1]
98
- if result_text.startswith("json"):
99
- result_text = result_text[4:]
100
- result_text = result_text.strip()
101
-
102
- extracted = json.loads(result_text)
103
- logger.info(f"Gemini extracted {len(extracted)} line values")
104
-
105
- # Clean and validate values
106
- cleaned = {}
107
- for line_num, value in extracted.items():
108
- if isinstance(value, (int, float)):
109
- cleaned[line_num] = str(value)
110
- elif isinstance(value, str):
111
- clean_value = value.replace(",", "").replace("$", "").strip()
112
- try:
113
- float(clean_value)
114
- cleaned[line_num] = clean_value
115
- except ValueError:
116
- logger.warning(f"Invalid value for line {line_num}: {value}")
117
-
118
- return cleaned
119
-
120
- except json.JSONDecodeError as e:
121
- logger.error(f"Failed to parse Gemini response as JSON: {e}")
122
- raise PDFExtractionError(f"Gemini returned invalid JSON: {e}")
123
- except Exception as e:
124
- logger.error(f"Gemini extraction failed: {e}")
125
- raise PDFExtractionError(f"Gemini extraction failed: {e}")
126
-
127
-
128
- # Global parser instance
129
- gemini_parser = GeminiParser()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/utils/logging.py DELETED
@@ -1,41 +0,0 @@
1
- """
2
- Logging utility module for consistent logging across the application.
3
- """
4
- import sys
5
- from pathlib import Path
6
-
7
- from loguru import logger
8
-
9
- from app.config import settings
10
-
11
-
12
- def setup_logging() -> None:
13
- """Configure application logging with loguru."""
14
- # Remove default handler
15
- logger.remove()
16
-
17
- # Console handler
18
- logger.add(
19
- sys.stderr,
20
- format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
21
- level="DEBUG" if settings.debug else "INFO",
22
- colorize=True,
23
- )
24
-
25
- # File handler
26
- log_file = settings.base_dir / "logs" / "app.log"
27
- log_file.parent.mkdir(parents=True, exist_ok=True)
28
-
29
- logger.add(
30
- log_file,
31
- format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
32
- level="DEBUG",
33
- rotation="10 MB",
34
- retention="7 days",
35
- compression="zip",
36
- )
37
-
38
-
39
- def get_logger(name: str):
40
- """Get a logger instance with the given name."""
41
- return logger.bind(name=name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_gradio.py DELETED
@@ -1,231 +0,0 @@
1
- """
2
- Gradio UI for T1 Tax PDF Processor - Hugging Face Spaces Version
3
- """
4
- import gradio as gr
5
- import tempfile
6
- import json
7
- from pathlib import Path
8
-
9
- from app.extractor import PDFExtractor
10
- from app.ocr import OCRProcessor
11
- from app.mapper import FieldMapper
12
- from app.filler import PDFFiller
13
- from app.utils.logging import get_logger
14
-
15
- logger = get_logger(__name__)
16
-
17
- # Initialize components
18
- extractor = PDFExtractor()
19
- mapper = FieldMapper()
20
- filler = PDFFiller()
21
-
22
- # Try to initialize OCR
23
- try:
24
- ocr = OCRProcessor()
25
- ocr_available = ocr.is_available()
26
- except:
27
- ocr = None
28
- ocr_available = False
29
-
30
-
31
- def process_pdfs(source_pdf, template_pdf, use_ocr=False, flatten_output=False):
32
- """
33
- Process PDFs: extract from source and fill template.
34
-
35
- Args:
36
- source_pdf: Path to source T1 PDF
37
- template_pdf: Path to template PDF form
38
- use_ocr: Force OCR processing
39
- flatten_output: Flatten output PDF
40
-
41
- Returns:
42
- tuple: (output_pdf_path, extraction_table_data, status_message)
43
- """
44
- try:
45
- if not source_pdf or not template_pdf:
46
- return None, [], "❌ Please upload both PDFs"
47
-
48
- source_path = Path(source_pdf.name)
49
- template_path = Path(template_pdf.name)
50
-
51
- # Step 1: Extract data
52
- has_text = extractor.has_text_content(source_path)
53
-
54
- if has_text and not use_ocr:
55
- result = extractor.extract_all_data(source_path)
56
- method = "text"
57
- elif ocr_available:
58
- text = ocr.process_pdf(source_path)
59
- line_values = extractor.extract_line_values(text)
60
- result = {
61
- "text": text,
62
- "line_values": line_values,
63
- "has_text": False,
64
- "extraction_method": "ocr"
65
- }
66
- method = "ocr"
67
- else:
68
- result = extractor.extract_all_data(source_path)
69
- method = "text_fallback"
70
-
71
- line_values = result["line_values"]
72
-
73
- if not line_values:
74
- return None, [], "⚠️ No T1 line values found in source PDF"
75
-
76
- # Step 2: Map values
77
- mapped = mapper.map_values(line_values)
78
-
79
- # Step 3: Fill template
80
- with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as output_file:
81
- output_path = Path(output_file.name)
82
-
83
- filler.fill_form(
84
- template_path=template_path,
85
- output_path=output_path,
86
- field_values=mapped,
87
- flatten=flatten_output
88
- )
89
-
90
- # Create table data
91
- table_data = []
92
- for line_num, value in line_values.items():
93
- if value:
94
- field_name = mapper.get_field_name(line_num) or "unmapped"
95
- table_data.append([line_num, field_name, value])
96
-
97
- status = f"✅ Success! Extracted {len(line_values)} lines using {method} method"
98
-
99
- return str(output_path), table_data, status
100
-
101
- except Exception as e:
102
- logger.error(f"Processing error: {e}")
103
- return None, [], f"❌ Error: {str(e)}"
104
-
105
-
106
- def get_current_mappings():
107
- """Get current field mappings as formatted text."""
108
- mappings = mapper.get_all_mappings()
109
- output = "## Current Field Mappings\n\n"
110
- output += "| Line # | Field Name | Description |\n"
111
- output += "|--------|------------|-------------|\n"
112
-
113
- for line_num, info in sorted(mappings.items())[:20]: # Show first 20
114
- field = info.get("field", "")
115
- desc = info.get("description", "")
116
- output += f"| {line_num} | {field} | {desc} |\n"
117
-
118
- if len(mappings) > 20:
119
- output += f"\n... and {len(mappings) - 20} more mappings"
120
-
121
- return output
122
-
123
-
124
- # Create Gradio Interface
125
- with gr.Blocks(theme=gr.themes.Soft(), title="T1 Tax PDF Processor") as demo:
126
-
127
- gr.Markdown("""
128
- # 📄 T1 Tax PDF Processor
129
-
130
- Extract data from Canadian T1 tax return PDFs and automatically fill PDF forms.
131
-
132
- **How it works:**
133
- 1. Upload your T1 tax return (source) and target form template (template)
134
- 2. Click "Process PDFs" to extract and fill
135
- 3. Download the filled PDF
136
-
137
- ⚠️ **Demo Only** - Do not upload real sensitive tax documents to public demos!
138
- """)
139
-
140
- with gr.Row():
141
- with gr.Column():
142
- source_pdf = gr.File(
143
- label="📄 Source T1 PDF (with data)",
144
- file_types=[".pdf"],
145
- type="filepath"
146
- )
147
-
148
- template_pdf = gr.File(
149
- label="📝 Target Template PDF (to fill)",
150
- file_types=[".pdf"],
151
- type="filepath"
152
- )
153
-
154
- with gr.Row():
155
- use_ocr = gr.Checkbox(
156
- label="Force OCR (for scanned PDFs)",
157
- value=False,
158
- info="Enable if source PDF is scanned/image-based"
159
- )
160
-
161
- flatten_output = gr.Checkbox(
162
- label="Flatten output PDF",
163
- value=False,
164
- info="Make filled fields non-editable"
165
- )
166
-
167
- process_btn = gr.Button("🚀 Process PDFs", variant="primary", size="lg")
168
-
169
- status_text = gr.Markdown("Ready to process...")
170
-
171
- with gr.Row():
172
- output_pdf = gr.File(
173
- label="📥 Download Filled PDF",
174
- type="filepath"
175
- )
176
-
177
- with gr.Row():
178
- extraction_table = gr.Dataframe(
179
- headers=["Line Number", "Field Name", "Value"],
180
- label="📊 Extracted T1 Data",
181
- wrap=True
182
- )
183
-
184
- # Process button action
185
- process_btn.click(
186
- fn=process_pdfs,
187
- inputs=[source_pdf, template_pdf, use_ocr, flatten_output],
188
- outputs=[output_pdf, extraction_table, status_text]
189
- )
190
-
191
- # Mappings section
192
- with gr.Accordion("🗺️ Field Mappings Configuration", open=False):
193
- gr.Markdown("""
194
- View current T1 line number to PDF field mappings.
195
- To modify mappings, update `config/t1_mapping.json` in the repository.
196
- """)
197
-
198
- mappings_display = gr.Markdown(get_current_mappings())
199
-
200
- gr.Markdown("""
201
- ---
202
-
203
- ### 📚 Common T1 Line Numbers
204
-
205
- | Line | Description |
206
- |------|-------------|
207
- | 10100 | Employment income |
208
- | 15000 | Total income |
209
- | 23600 | Net income |
210
- | 26000 | Taxable income |
211
- | 42000 | Net federal tax |
212
- | 48400 | Refund |
213
- | 48500 | Balance owing |
214
-
215
- ### 🔧 Tech Stack
216
-
217
- Built with: FastAPI, pdfplumber, PyMuPDF, Tesseract OCR, Gradio
218
-
219
- ### ⚠️ Privacy Notice
220
-
221
- This is a demonstration application. Files are processed temporarily and not stored permanently.
222
- **Do not upload real tax documents containing sensitive personal information.**
223
- """)
224
-
225
- # Launch
226
- if __name__ == "__main__":
227
- demo.launch(
228
- server_name="0.0.0.0",
229
- server_port=7860,
230
- share=False
231
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config/t1_mapping.json DELETED
@@ -1,50 +0,0 @@
1
- {
2
- "15000": {
3
- "field": "total_income",
4
- "description": "Total income before deductions"
5
- },
6
- "23600": {
7
- "field": "net_income",
8
- "description": "Net income after deductions"
9
- },
10
- "26000": {
11
- "field": "taxable_income",
12
- "description": "Taxable income"
13
- },
14
- "10100": {
15
- "field": "employment_income",
16
- "description": "Employment income (line 10100)"
17
- },
18
- "12100": {
19
- "field": "interest_income",
20
- "description": "Interest and other investment income"
21
- },
22
- "20800": {
23
- "field": "rrsp_deduction",
24
- "description": "RRSP/PRPP deduction"
25
- },
26
- "30000": {
27
- "field": "basic_personal_amount",
28
- "description": "Basic personal amount"
29
- },
30
- "42000": {
31
- "field": "net_federal_tax",
32
- "description": "Net federal tax"
33
- },
34
- "43500": {
35
- "field": "total_payable",
36
- "description": "Total tax payable"
37
- },
38
- "43700": {
39
- "field": "total_tax_deducted",
40
- "description": "Total income tax deducted"
41
- },
42
- "48400": {
43
- "field": "refund",
44
- "description": "Refund amount"
45
- },
46
- "48500": {
47
- "field": "balance_owing",
48
- "description": "Balance owing"
49
- }
50
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt DELETED
@@ -1,38 +0,0 @@
1
- # Web Framework
2
- fastapi==0.109.2
3
- uvicorn[standard]==0.27.1
4
- python-multipart==0.0.9
5
- gradio==5.0.2
6
-
7
- # PDF Processing
8
- pdfplumber==0.10.4
9
- PyMuPDF==1.24.14
10
- pdfrw==0.4
11
- pypdf==4.0.2
12
- reportlab==4.1.0
13
-
14
- # OCR
15
- pytesseract==0.3.10
16
- pdf2image==1.17.0
17
- Pillow==10.4.0
18
-
19
-
20
- # Data Processing
21
- pydantic==2.8.2
22
- pydantic-settings==2.4.0
23
-
24
- # Google Generative AI (optional fallback)
25
- google-generativeai==0.4.1
26
-
27
- # Utilities
28
- python-dotenv==1.0.1
29
- aiofiles==23.2.1
30
- jinja2==3.1.3
31
-
32
- # Logging
33
- loguru==0.7.2
34
-
35
- # Testing
36
- pytest==8.0.1
37
- pytest-asyncio==0.23.5
38
- httpx==0.26.0