Spaces:
Running
Running
Delete all files
Browse files- Dockerfile +0 -33
- README.md +0 -39
- app/__init__.py +0 -7
- app/api/__init__.py +0 -4
- app/api/models.py +0 -120
- app/api/processor_service.py +0 -274
- app/api/routes.py +0 -286
- app/api/schemas.py +0 -108
- app/config.py +0 -53
- app/extractor/__init__.py +0 -6
- app/extractor/pdf_extractor.py +0 -186
- app/filler/__init__.py +0 -4
- app/filler/pdf_filler.py +0 -252
- app/main.py +0 -63
- app/mapper/__init__.py +0 -4
- app/mapper/field_mapper.py +0 -135
- app/ocr/__init__.py +0 -4
- app/ocr/ocr_processor.py +0 -188
- app/utils/__init__.py +0 -25
- app/utils/exceptions.py +0 -43
- app/utils/gemini_parser.py +0 -129
- app/utils/logging.py +0 -41
- app_gradio.py +0 -231
- config/t1_mapping.json +0 -50
- requirements.txt +0 -38
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|