Compare commits
4 Commits
text_extra
...
ai_backed_
| Author | SHA1 | Date | |
|---|---|---|---|
| 3163bb213e | |||
| 8537c653c1 | |||
| 26478608b1 | |||
| f50dd4692d |
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"dotrush.roslyn.projectOrSolutionFiles": []
|
||||||
|
}
|
||||||
Binary file not shown.
BIN
backend/__pycache__/llm_service.cpython-313.pyc
Normal file
BIN
backend/__pycache__/llm_service.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, ForeignKey, LargeBinary
|
import urllib.parse
|
||||||
|
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, ForeignKey, LargeBinary, Enum as SqlEnum
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker, relationship
|
from sqlalchemy.orm import sessionmaker, relationship
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -7,22 +9,11 @@ from dotenv import load_dotenv
|
|||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
DB_USER = os.getenv("DB_USER")
|
DB_USER = os.getenv("DB_USER", "postgres")
|
||||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "M@tr!x#149@dm!N")
|
||||||
DB_HOST = os.getenv("DB_HOST")
|
DB_HOST = os.getenv("DB_HOST", "192.168.0.111")
|
||||||
DB_PORT = os.getenv("DB_PORT")
|
DB_PORT = os.getenv("DB_PORT", "7925")
|
||||||
import urllib.parse
|
DB_NAME = os.getenv("DB_NAME", "ocr")
|
||||||
|
|
||||||
# ... (imports)
|
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
DB_USER = os.getenv("DB_USER")
|
|
||||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
|
||||||
DB_HOST = os.getenv("DB_HOST")
|
|
||||||
DB_PORT = os.getenv("DB_PORT")
|
|
||||||
DB_NAME = os.getenv("DB_NAME")
|
|
||||||
|
|
||||||
encoded_user = urllib.parse.quote_plus(DB_USER)
|
encoded_user = urllib.parse.quote_plus(DB_USER)
|
||||||
encoded_password = urllib.parse.quote_plus(DB_PASSWORD)
|
encoded_password = urllib.parse.quote_plus(DB_PASSWORD)
|
||||||
@@ -35,6 +26,26 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class Vendor(Base):
|
||||||
|
__tablename__ = "vendors"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, unique=True, index=True)
|
||||||
|
default_model = Column(String) # 'text' or 'vision'
|
||||||
|
created_at = Column(DateTime)
|
||||||
|
|
||||||
|
class Document(Base):
|
||||||
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True)
|
||||||
|
filename = Column(String)
|
||||||
|
upload_date = Column(DateTime)
|
||||||
|
status = Column(String) # 'pending', 'verified'
|
||||||
|
processed_data = Column(JSONB) # Store the verified extraction results
|
||||||
|
|
||||||
|
vendor = relationship("Vendor")
|
||||||
|
|
||||||
class Email(Base):
|
class Email(Base):
|
||||||
__tablename__ = "emails"
|
__tablename__ = "emails"
|
||||||
|
|
||||||
@@ -54,7 +65,7 @@ class Attachment(Base):
|
|||||||
email_id = Column(Integer, ForeignKey("emails.id"))
|
email_id = Column(Integer, ForeignKey("emails.id"))
|
||||||
filename = Column(String)
|
filename = Column(String)
|
||||||
content_type = Column(String)
|
content_type = Column(String)
|
||||||
file_content = Column(LargeBinary) # Storing content directly in DB as requested
|
file_content = Column(LargeBinary)
|
||||||
|
|
||||||
email = relationship("Email", back_populates="attachments")
|
email = relationship("Email", back_populates="attachments")
|
||||||
|
|
||||||
|
|||||||
116
backend/llm_service.py
Normal file
116
backend/llm_service.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import ollama
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
INVOICE_SCHEMA = {
|
||||||
|
"document_type": None,
|
||||||
|
"invoice_number": None,
|
||||||
|
"invoice_date": None,
|
||||||
|
"due_date": None,
|
||||||
|
"purchase_order_number": None,
|
||||||
|
"vendor": {
|
||||||
|
"name": None,
|
||||||
|
"address": None,
|
||||||
|
"email": None,
|
||||||
|
"phone": None,
|
||||||
|
"gstin": None,
|
||||||
|
"tax_id": None,
|
||||||
|
"website": None
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"name": None,
|
||||||
|
"address": None,
|
||||||
|
"gstin": None
|
||||||
|
},
|
||||||
|
"amounts": {
|
||||||
|
"subtotal": None,
|
||||||
|
"tax": None,
|
||||||
|
"discount": None,
|
||||||
|
"shipping": None,
|
||||||
|
"round_off": None,
|
||||||
|
"total": None,
|
||||||
|
"amount_paid": None,
|
||||||
|
"balance_due": None,
|
||||||
|
"currency": None
|
||||||
|
},
|
||||||
|
"tax_breakdown": [
|
||||||
|
{
|
||||||
|
"type": None,
|
||||||
|
"rate": None,
|
||||||
|
"amount": None
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"line_items": [
|
||||||
|
{
|
||||||
|
"line_no": None,
|
||||||
|
"description": None,
|
||||||
|
"product_code": None,
|
||||||
|
"hsn_sac": None,
|
||||||
|
"quantity": None,
|
||||||
|
"unit": None,
|
||||||
|
"unit_price": None,
|
||||||
|
"discount": None,
|
||||||
|
"tax_rate": None,
|
||||||
|
"tax_amount": None,
|
||||||
|
"total": None
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"payment_information": {
|
||||||
|
"bank_name": None,
|
||||||
|
"account_number": None,
|
||||||
|
"ifsc": None,
|
||||||
|
"upi_id": None
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"pages": None,
|
||||||
|
"ocr_confidence": None,
|
||||||
|
"language": None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract_data(text: str = None, image_path: str = None, model_type: str = "text") -> dict:
|
||||||
|
"""
|
||||||
|
Extracts structured data using either Text (Gemma) or Vision (Qwen) models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
You are an expert data extraction assistant.
|
||||||
|
Extract every possible detail from the provided document and return it strictly as a SINGLE VALID JSON OBJECT matching the following schema structure:
|
||||||
|
|
||||||
|
{json.dumps(INVOICE_SCHEMA, indent=4)}
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Return ONLY the JSON. No markdown formatting, no explanations, no prefix.
|
||||||
|
- If a field is not found or data is not available, use null.
|
||||||
|
"""
|
||||||
|
|
||||||
|
messages = [{'role': 'user', 'content': prompt}]
|
||||||
|
model = 'gemma:2b'
|
||||||
|
|
||||||
|
if model_type == 'vision':
|
||||||
|
if not image_path:
|
||||||
|
return {"error": "Image path required for vision mode"}
|
||||||
|
|
||||||
|
# Qwen-VL handles images passed in the message
|
||||||
|
model = 'qwen2.5vl:7b' # Using the installed model ID
|
||||||
|
messages[0]['images'] = [image_path]
|
||||||
|
messages[0]['content'] = "Analyze this image. " + prompt
|
||||||
|
else:
|
||||||
|
# Text Mode
|
||||||
|
if not text:
|
||||||
|
return {"error": "Text required for text mode"}
|
||||||
|
messages[0]['content'] += f"\n\n---\n{text}\n---"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = ollama.chat(model=model, messages=messages)
|
||||||
|
content = response['message']['content']
|
||||||
|
|
||||||
|
# Clean up markdown
|
||||||
|
content = content.replace("```json", "").replace("```", "").strip()
|
||||||
|
|
||||||
|
return json.loads(content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"LLM Extraction Error ({model_type}): {e}")
|
||||||
|
return {"error": str(e), "raw_output": content if 'content' in locals() else ""}
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ def extract_text_from_pdf(file_bytes: bytes) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Internal modules
|
# Internal modules
|
||||||
from database import get_db, Email
|
from database import get_db, Email, Vendor, Document
|
||||||
from scheduler import start_scheduler, stop_scheduler
|
from scheduler import start_scheduler, stop_scheduler
|
||||||
from mail_service import fetch_and_store_emails
|
from mail_service import fetch_and_store_emails
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ class LoginResponse(BaseModel):
|
|||||||
|
|
||||||
class NERResponse(BaseModel):
|
class NERResponse(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
file_path: str
|
||||||
|
|
||||||
|
|
||||||
def extract_text_from_image(file_bytes: bytes) -> str:
|
def extract_text_from_image(file_bytes: bytes) -> str:
|
||||||
@@ -104,13 +104,41 @@ async def extract_text(file: UploadFile = File(...)):
|
|||||||
content = await file.read()
|
content = await file.read()
|
||||||
filename = file.filename.lower()
|
filename = file.filename.lower()
|
||||||
|
|
||||||
|
# Save file for Vision mode
|
||||||
|
file_path = f"uploads/{file.filename}"
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
extracted_text = ""
|
extracted_text = ""
|
||||||
|
|
||||||
if filename.endswith(".pdf"):
|
if filename.endswith(".pdf"):
|
||||||
# Try text extraction first
|
# Try text extraction first
|
||||||
extracted_text = extract_text_from_pdf(content)
|
with pdfplumber.open(io.BytesIO(content)) as pdf:
|
||||||
|
try:
|
||||||
|
text = ""
|
||||||
|
for page in pdf.pages:
|
||||||
|
page_text = page.extract_text(layout=True)
|
||||||
|
if page_text:
|
||||||
|
text += page_text + "\n"
|
||||||
|
if text.strip():
|
||||||
|
extracted_text = text.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# If text is empty, it might be a scanned PDF.
|
if not extracted_text:
|
||||||
|
try:
|
||||||
|
# Fallback to pypdf
|
||||||
|
reader = PdfReader(io.BytesIO(content))
|
||||||
|
text = ""
|
||||||
|
for page in reader.pages:
|
||||||
|
page_text = page.extract_text()
|
||||||
|
if page_text:
|
||||||
|
text += page_text + "\n"
|
||||||
|
extracted_text = text.strip()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If text is still empty, it might be a scanned PDF.
|
||||||
if not extracted_text.strip():
|
if not extracted_text.strip():
|
||||||
try:
|
try:
|
||||||
images = convert_from_bytes(content)
|
images = convert_from_bytes(content)
|
||||||
@@ -125,7 +153,35 @@ async def extract_text(file: UploadFile = File(...)):
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Unsupported file type")
|
raise HTTPException(status_code=400, detail="Unsupported file type")
|
||||||
|
|
||||||
return NERResponse(text=extracted_text)
|
return NERResponse(text=extracted_text, file_path=file_path)
|
||||||
|
|
||||||
|
# 3. AI Extraction Module
|
||||||
|
from llm_service import extract_data
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
|
||||||
|
class AITextRequest(BaseModel):
|
||||||
|
text: Optional[str] = None
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
model_type: str = "text"
|
||||||
|
|
||||||
|
@app.post("/api/extract/ai")
|
||||||
|
def extract_ai_data(request: AITextRequest):
|
||||||
|
final_image_path = request.file_path
|
||||||
|
|
||||||
|
if request.model_type == "vision" and request.file_path and request.file_path.endswith(".pdf"):
|
||||||
|
# Convert PDF first page to image
|
||||||
|
try:
|
||||||
|
images = convert_from_path(request.file_path)
|
||||||
|
if images:
|
||||||
|
# Save temp image
|
||||||
|
temp_img_path = request.file_path + ".jpg"
|
||||||
|
images[0].save(temp_img_path, "JPEG")
|
||||||
|
final_image_path = temp_img_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error converting PDF for vision: {e}")
|
||||||
|
|
||||||
|
data = extract_data(text=request.text, image_path=final_image_path, model_type=request.model_type)
|
||||||
|
return data
|
||||||
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -255,3 +311,36 @@ def sync_emails():
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {"message": "OCR Backend API is running"}
|
return {"message": "OCR Backend API is running"}
|
||||||
|
|
||||||
|
class DocumentSaveRequest(BaseModel):
|
||||||
|
vendor_name: str
|
||||||
|
file_path: str
|
||||||
|
model_type: str
|
||||||
|
data: dict
|
||||||
|
|
||||||
|
@app.post("/api/documents/save")
|
||||||
|
def save_document(request: DocumentSaveRequest, db: Session = Depends(get_db)):
|
||||||
|
# 1. Find or Create Vendor
|
||||||
|
vendor = db.query(Vendor).filter(Vendor.name == request.vendor_name).first()
|
||||||
|
if not vendor:
|
||||||
|
vendor = Vendor(name=request.vendor_name, default_model=request.model_type)
|
||||||
|
db.add(vendor)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(vendor)
|
||||||
|
else:
|
||||||
|
# Update preference
|
||||||
|
vendor.default_model = request.model_type
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Save Document
|
||||||
|
filename = request.file_path.split('/')[-1]
|
||||||
|
doc = Document(
|
||||||
|
vendor_id=vendor.id,
|
||||||
|
filename=filename,
|
||||||
|
status="verified",
|
||||||
|
processed_data=request.data
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Document saved and Vendor preference updated", "vendor_id": vendor.id}
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ imap-tools
|
|||||||
apscheduler
|
apscheduler
|
||||||
python-dotenv
|
python-dotenv
|
||||||
pdfplumber
|
pdfplumber
|
||||||
|
ollama
|
||||||
|
|||||||
BIN
backend/uploads/Invoice For Jan-Feb-2026.pdf
Normal file
BIN
backend/uploads/Invoice For Jan-Feb-2026.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/Invoice For Jan-Feb-2026.pdf.jpg
Normal file
BIN
backend/uploads/Invoice For Jan-Feb-2026.pdf.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
BIN
backend/uploads/Invoice For Oct-Nov-2025.pdf
Normal file
BIN
backend/uploads/Invoice For Oct-Nov-2025.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/Invoice For Oct-Nov-2025.pdf.jpg
Normal file
BIN
backend/uploads/Invoice For Oct-Nov-2025.pdf.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
BIN
backend/uploads/Purchase-Order-Template-01-TemplateLab.pdf
Normal file
BIN
backend/uploads/Purchase-Order-Template-01-TemplateLab.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/invoice-stripes.png
Normal file
BIN
backend/uploads/invoice-stripes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
BIN
backend/uploads/invoice_Aaron Bergman_36258.pdf
Normal file
BIN
backend/uploads/invoice_Aaron Bergman_36258.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/invoice_Aaron Hawkins_40101.pdf
Normal file
BIN
backend/uploads/invoice_Aaron Hawkins_40101.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/invoice_Aaron Hawkins_40101.pdf.jpg
Normal file
BIN
backend/uploads/invoice_Aaron Hawkins_40101.pdf.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
backend/uploads/invoice_Aaron Hawkins_4820.pdf
Normal file
BIN
backend/uploads/invoice_Aaron Hawkins_4820.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/invoice_Aaron Hawkins_4820.pdf.jpg
Normal file
BIN
backend/uploads/invoice_Aaron Hawkins_4820.pdf.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
BIN
backend/uploads/invoice_Aaron Hawkins_6817.pdf
Normal file
BIN
backend/uploads/invoice_Aaron Hawkins_6817.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/invoice_Aaron Hawkins_6817.pdf.jpg
Normal file
BIN
backend/uploads/invoice_Aaron Hawkins_6817.pdf.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
BIN
backend/uploads/sample-pdf-invoice.pdf
Normal file
BIN
backend/uploads/sample-pdf-invoice.pdf
Normal file
Binary file not shown.
26
docengine/.dockerignore
Normal file
26
docengine/.dockerignore
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.Python
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
*.egg-info
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
*.md
|
||||||
|
*.rst
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
*.log
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
59
docengine/.env.example
Normal file
59
docengine/.env.example
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Application
|
||||||
|
APP_NAME=DocEngine
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
APP_ENV=development
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=7989
|
||||||
|
APP_WORKERS=4
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=document_engine
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=changeme
|
||||||
|
DB_SCHEMA=admin
|
||||||
|
DB_POOL_SIZE=20
|
||||||
|
DB_MAX_OVERFLOW=10
|
||||||
|
DB_ECHO=false
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
STORAGE_PROVIDER=local
|
||||||
|
STORAGE_LOCAL_PATH=./storage
|
||||||
|
STORAGE_MAX_FILE_SIZE_MB=100
|
||||||
|
|
||||||
|
# OCR
|
||||||
|
OCR_LANGUAGE=en
|
||||||
|
OCR_USE_GPU=false
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=["http://localhost:3000","http://localhost:8080"]
|
||||||
|
CORS_ALLOW_CREDENTIALS=true
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_REQUESTS=100
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
|
|
||||||
|
# Prometheus
|
||||||
|
PROMETHEUS_ENABLED=true
|
||||||
64
docengine/.gitignore
vendored
Normal file
64
docengine/.gitignore
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
.scrapy
|
||||||
|
docs/_build/
|
||||||
|
target/
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
storage/
|
||||||
|
*.pid
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
39
docengine/Dockerfile
Normal file
39
docengine/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
libgl1-mesa-glx \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libsm6 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender-dev \
|
||||||
|
libgomp1 \
|
||||||
|
poppler-utils \
|
||||||
|
ghostscript \
|
||||||
|
libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/storage/documents /app/storage/templates /app/storage/images /app/storage/temp
|
||||||
|
|
||||||
|
FROM base AS app
|
||||||
|
EXPOSE 7989
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7989", "--workers", "4"]
|
||||||
|
|
||||||
|
FROM base AS worker
|
||||||
|
CMD ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=4"]
|
||||||
|
|
||||||
|
FROM base AS beat
|
||||||
|
CMD ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info"]
|
||||||
522
docengine/README.md
Normal file
522
docengine/README.md
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
# DocEngine — Document Template Recognition & Reconstruction System
|
||||||
|
|
||||||
|
A production-ready system for scanning documents, detecting layouts, extracting content, generating reusable templates, matching future uploads against stored templates, and reconstructing original layouts as PDF output.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────┐ ┌───────────────┐ ┌──────────────┐
|
||||||
|
│ FastAPI App │─────▶│ Celery │─────▶│ Redis │
|
||||||
|
│ (Port 7989) │ │ Worker(s) │ │ (Broker) │
|
||||||
|
└───────┬───────┘ └───────┬───────┘ └──────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────────────────────────┐
|
||||||
|
│ PostgreSQL (Schema: admin) │
|
||||||
|
│ 192.168.0.111:7925 │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stack**: Python 3.12, FastAPI, SQLAlchemy 2.x, Pydantic V2, Celery, Redis, PaddleOCR, PyMuPDF, OpenCV, ReportLab, PostgreSQL 16.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Capability | Implementation |
|
||||||
|
|----------------------------------|--------------------------------------------|
|
||||||
|
| Scanned image OCR | PaddleOCR (CPU/GPU) |
|
||||||
|
| Native PDF text extraction | PyMuPDF (fitz) |
|
||||||
|
| Layout detection | OpenCV + LayoutParser |
|
||||||
|
| Table extraction | Camelot-py + OpenCV contour detection |
|
||||||
|
| Header/footer detection | Positional heuristics |
|
||||||
|
| Watermark detection | Transparency + large-font analysis |
|
||||||
|
| Font info extraction | PyMuPDF text dict parsing |
|
||||||
|
| Template generation & storage | PostgreSQL (admin schema) |
|
||||||
|
| Template fingerprinting | SHA-256 structural hashing |
|
||||||
|
| Template matching | Multi-signal similarity scoring |
|
||||||
|
| PDF reconstruction | ReportLab from template definitions |
|
||||||
|
| Async processing | Celery + Redis |
|
||||||
|
| Authentication | JWT (access + refresh tokens, bcrypt) |
|
||||||
|
| Monitoring | Prometheus + structlog JSON logging |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
docengine/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI application entry
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── router.py # Top-level API router
|
||||||
|
│ │ └── v1/
|
||||||
|
│ │ ├── auth.py # Auth endpoints
|
||||||
|
│ │ ├── documents.py # Document endpoints
|
||||||
|
│ │ ├── health.py # Health check
|
||||||
|
│ │ └── templates.py # Template endpoints
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── config.py # Pydantic Settings
|
||||||
|
│ │ ├── database.py # SQLAlchemy engine & session
|
||||||
|
│ │ ├── dependencies.py # FastAPI DI
|
||||||
|
│ │ ├── exceptions.py # Custom exception hierarchy
|
||||||
|
│ │ ├── logging_config.py # structlog configuration
|
||||||
|
│ │ └── security.py # JWT & bcrypt helpers
|
||||||
|
│ ├── models/ # SQLAlchemy ORM models
|
||||||
|
│ ├── schemas/ # Pydantic request/response schemas
|
||||||
|
│ ├── repositories/ # Data access layer
|
||||||
|
│ ├── services/ # Business logic
|
||||||
|
│ │ ├── document_service.py # Orchestration pipeline
|
||||||
|
│ │ ├── ocr_service.py # PaddleOCR integration
|
||||||
|
│ │ ├── pdf_service.py # PyMuPDF native PDF parsing
|
||||||
|
│ │ ├── layout_service.py # OpenCV layout detection
|
||||||
|
│ │ ├── template_service.py # Template generation
|
||||||
|
│ │ ├── fingerprint_service.py
|
||||||
|
│ │ ├── matching_service.py
|
||||||
|
│ │ └── reconstruction_service.py
|
||||||
|
│ ├── middleware/ # CORS, audit, metrics, rate limit
|
||||||
|
│ ├── storage/ # File storage abstraction
|
||||||
|
│ ├── tasks/ # Celery async tasks
|
||||||
|
│ ├── workers/ # Celery app configuration
|
||||||
|
│ └── events/ # App lifecycle handlers
|
||||||
|
├── alembic/ # Database migrations
|
||||||
|
├── sql/ # Raw SQL scripts
|
||||||
|
├── tests/ # Test suite
|
||||||
|
├── docker-compose.yml # Dev stack
|
||||||
|
├── docker-compose.prod.yml # Production stack
|
||||||
|
├── Dockerfile # Multi-stage build
|
||||||
|
├── requirements.txt
|
||||||
|
└── .env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- PostgreSQL 16 (running at `192.168.0.111:7925`)
|
||||||
|
- Redis (for Celery)
|
||||||
|
- `poppler-utils` and `ghostscript` (for pdf2image/camelot)
|
||||||
|
|
||||||
|
### Local Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone & enter
|
||||||
|
cd docengine
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Create storage directories
|
||||||
|
mkdir -p storage/{documents,templates,images,temp,rendered}
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# (Optional) Seed default data
|
||||||
|
psql -h 192.168.0.111 -p 7925 -U postgres -d document_engine -f sql/003_seed_data.sql
|
||||||
|
psql -h 192.168.0.111 -p 7925 -U postgres -d document_engine -f sql/004_indexes.sql
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
The API is now available at `http://localhost:7989`. Interactive docs at `http://localhost:7989/docs`.
|
||||||
|
|
||||||
|
### Start Celery Worker (separate terminal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
celery -A app.workers.celery_app worker --loglevel=info --concurrency=4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and start all services (app + worker + db + redis)
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Run migrations inside the container
|
||||||
|
docker compose exec app alembic upgrade head
|
||||||
|
|
||||||
|
# Seed data
|
||||||
|
docker compose exec app bash -c "psql -h db -U postgres -d document_engine -f sql/003_seed_data.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
Base URL: `http://localhost:7989/api/v1`
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:7989/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
#### Register
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7989/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "john",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"password": "SecurePass123!",
|
||||||
|
"full_name": "John Doe"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7989/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "john",
|
||||||
|
"password": "SecurePass123!"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 1800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Current User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:7989/api/v1/auth/me \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Refresh Token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7989/api/v1/auth/refresh \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"refresh_token": "<refresh_token>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change Password
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7989/api/v1/auth/change-password \
|
||||||
|
-H "Authorization: Bearer <access_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"current_password": "SecurePass123!",
|
||||||
|
"new_password": "NewSecurePass456!"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Logout
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7989/api/v1/auth/logout \
|
||||||
|
-H "Authorization: Bearer <access_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"refresh_token": "<refresh_token>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documents
|
||||||
|
|
||||||
|
#### Upload Document
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upload a PDF
|
||||||
|
curl -X POST http://localhost:7989/api/v1/documents/upload \
|
||||||
|
-H "Authorization: Bearer <access_token>" \
|
||||||
|
-F "file=@/path/to/document.pdf"
|
||||||
|
|
||||||
|
# Upload a scanned image
|
||||||
|
curl -X POST http://localhost:7989/api/v1/documents/upload \
|
||||||
|
-H "Authorization: Bearer <access_token>" \
|
||||||
|
-F "file=@/path/to/scan.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"filename": "abc123_document.pdf",
|
||||||
|
"original_filename": "document.pdf",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"file_size": 245760,
|
||||||
|
"checksum": "e3b0c44298fc1c149afbf4c8996fb924...",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-06-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Document
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:7989/api/v1/documents/<document_id> \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List Documents
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With pagination
|
||||||
|
curl "http://localhost:7989/api/v1/documents?page=1&page_size=20" \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
|
||||||
|
# Filter by status
|
||||||
|
curl "http://localhost:7989/api/v1/documents?status=completed" \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Delete Document
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:7989/api/v1/documents/<document_id> \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Template Matches for Document
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:7989/api/v1/documents/<document_id>/template \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
|
||||||
|
#### List Templates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:7989/api/v1/templates?page=1&page_size=20" \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Template
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:7989/api/v1/templates/<template_id> \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Delete (Deactivate) Template
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:7989/api/v1/templates/<template_id> \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Match Document to Templates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7989/api/v1/templates/match \
|
||||||
|
-H "Authorization: Bearer <access_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"document_id": "<document_id>",
|
||||||
|
"min_confidence": 0.5,
|
||||||
|
"max_results": 5
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"document_id": "...",
|
||||||
|
"format_id": "...",
|
||||||
|
"confidence_score": 0.92,
|
||||||
|
"match_details": { "dimension_score": 1.0, "header_score": 0.85 },
|
||||||
|
"selected": true,
|
||||||
|
"template_name": "Invoice Template v1",
|
||||||
|
"created_at": "2026-06-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Render Template to PDF
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7989/api/v1/templates/render \
|
||||||
|
-H "Authorization: Bearer <access_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"template_id": "<template_id>",
|
||||||
|
"data": {
|
||||||
|
"company_name": "Acme Corp",
|
||||||
|
"invoice_number": "INV-2026-001",
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"total": "$1,250.00"
|
||||||
|
},
|
||||||
|
"output_filename": "invoice_output.pdf"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"output_path": "rendered/invoice_output.pdf",
|
||||||
|
"filename": "invoice_output.pdf",
|
||||||
|
"file_size": 32768,
|
||||||
|
"page_count": 1,
|
||||||
|
"rendered_at": "2026-06-01T12:05:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Download Rendered PDF
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -O http://localhost:7989/api/v1/templates/<template_id>/download?filename=invoice_output.pdf \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Processing Pipeline
|
||||||
|
|
||||||
|
When a document is uploaded, the following Celery task pipeline executes asynchronously:
|
||||||
|
|
||||||
|
1. **File Type Detection** — Determine if the document is a native PDF or scanned image.
|
||||||
|
2. **Page Extraction** — Convert PDF pages to images (for scanned docs) or parse directly (for native PDFs).
|
||||||
|
3. **OCR** — Run PaddleOCR on scanned pages to extract text blocks with coordinates, confidence, and bounding boxes.
|
||||||
|
4. **Native PDF Parsing** — Use PyMuPDF to extract text, fonts, images, and tables from native PDFs.
|
||||||
|
5. **Layout Analysis** — Detect headers, footers, watermarks, tables, and image regions using OpenCV heuristics.
|
||||||
|
6. **Template Generation** — Build a reusable template definition from the detected layout, stored in PostgreSQL.
|
||||||
|
7. **Fingerprint Generation** — Compute a structural fingerprint (SHA-256) for future matching.
|
||||||
|
8. **Status Update** — Mark the document as `completed` (or `failed` with error details).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
**Connection**: `postgresql://postgres:***@192.168.0.111:7925/document_engine`
|
||||||
|
**Schema**: `admin`
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new migration
|
||||||
|
alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Rollback one step
|
||||||
|
alembic downgrade -1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|--------------------------|----------------------------------------------|
|
||||||
|
| `users` | User accounts |
|
||||||
|
| `roles` | Role definitions (admin, user, viewer) |
|
||||||
|
| `user_roles` | User-role mapping (M2M) |
|
||||||
|
| `refresh_tokens` | JWT refresh token storage |
|
||||||
|
| `audit_logs` | Action audit trail |
|
||||||
|
| `documents` | Uploaded document records |
|
||||||
|
| `document_pages` | Per-page data (dimensions, images) |
|
||||||
|
| `document_text_blocks` | Extracted text with position & font info |
|
||||||
|
| `document_images` | Extracted images with position |
|
||||||
|
| `document_tables` | Extracted tables with cell data (JSONB) |
|
||||||
|
| `document_formats` | Template definitions |
|
||||||
|
| `document_cells` | Template cell layout definitions |
|
||||||
|
| `document_regions` | Template region definitions |
|
||||||
|
| `table_formats` | Template table structure definitions |
|
||||||
|
| `table_columns` | Template table column definitions |
|
||||||
|
| `table_rows` | Template table row definitions |
|
||||||
|
| `watermarks` | Template watermark definitions |
|
||||||
|
| `image_regions` | Template image region definitions |
|
||||||
|
| `template_fingerprints` | Structural fingerprints for matching |
|
||||||
|
| `template_matches` | Document-to-template match results |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dev dependencies
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=app --cov-report=term-missing
|
||||||
|
|
||||||
|
# Run specific test categories
|
||||||
|
pytest tests/unit/
|
||||||
|
pytest tests/api/
|
||||||
|
pytest tests/repositories/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is via environment variables (`.env` file). Key settings:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|------------------------------------|------------------------|---------------------------------|
|
||||||
|
| `APP_PORT` | `7989` | Application port |
|
||||||
|
| `DB_HOST` | `192.168.0.111` | PostgreSQL host |
|
||||||
|
| `DB_PORT` | `7925` | PostgreSQL port |
|
||||||
|
| `DB_NAME` | `document_engine` | Database name |
|
||||||
|
| `DB_SCHEMA` | `admin` | PostgreSQL schema |
|
||||||
|
| `REDIS_HOST` | `localhost` | Redis host |
|
||||||
|
| `CELERY_BROKER_URL` | `redis://localhost:6379/0` | Celery broker |
|
||||||
|
| `JWT_SECRET_KEY` | *(see .env)* | JWT signing key |
|
||||||
|
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access token TTL |
|
||||||
|
| `STORAGE_LOCAL_PATH` | `./storage` | Local file storage path |
|
||||||
|
| `STORAGE_MAX_FILE_SIZE_MB` | `100` | Max upload size |
|
||||||
|
| `OCR_LANGUAGE` | `en` | PaddleOCR language |
|
||||||
|
| `OCR_USE_GPU` | `false` | Enable GPU for OCR |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using production compose file
|
||||||
|
docker compose -f docker-compose.prod.yml up --build -d
|
||||||
|
|
||||||
|
# Scale workers
|
||||||
|
docker compose -f docker-compose.prod.yml up --scale worker=4 -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Production compose includes:
|
||||||
|
- Resource limits (CPU/memory)
|
||||||
|
- Redis authentication
|
||||||
|
- App replicas
|
||||||
|
- Persistent named volumes
|
||||||
|
- Auto-restart policies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Default Credentials
|
||||||
|
|
||||||
|
| Username | Password | Role |
|
||||||
|
|----------|---------------|-------|
|
||||||
|
| `admin` | `Admin@123!` | admin |
|
||||||
|
|
||||||
|
> ⚠️ **Change the default admin password immediately in production.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Proprietary — All rights reserved.
|
||||||
41
docengine/alembic.ini
Normal file
41
docengine/alembic.ini
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
sqlalchemy.url = postgresql+psycopg2://postgres:M%%40tr%%21x%%23149%%40dm%%21N@192.168.0.111:7925/document_engine
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
67
docengine/alembic/env.py
Normal file
67
docengine/alembic/env.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool, text
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
# Import all models so Alembic can detect them
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Override the database URL from settings
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode."""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
version_table_schema=settings.db_schema,
|
||||||
|
include_schemas=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
# Ensure schema exists
|
||||||
|
connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {settings.db_schema}"))
|
||||||
|
connection.execute(text(f"SET search_path TO {settings.db_schema}, public"))
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
version_table_schema=settings.db_schema,
|
||||||
|
include_schemas=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
docengine/alembic/script.py.mako
Normal file
26
docengine/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
398
docengine/alembic/versions/001_initial.py
Normal file
398
docengine/alembic/versions/001_initial.py
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 001_initial
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-31 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "001_initial"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
SCHEMA = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create schema
|
||||||
|
op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")
|
||||||
|
|
||||||
|
# Users table
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("username", sa.String(150), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("email", sa.String(255), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||||
|
sa.Column("full_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
|
||||||
|
sa.Column("is_superuser", sa.Boolean, default=False, nullable=False),
|
||||||
|
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Roles table
|
||||||
|
op.create_table(
|
||||||
|
"roles",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(50), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# User roles (many-to-many)
|
||||||
|
op.create_table(
|
||||||
|
"user_roles",
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.roles.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh tokens
|
||||||
|
op.create_table(
|
||||||
|
"refresh_tokens",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("token", sa.String(512), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked", sa.Boolean, default=False, nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit logs
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.users.id", ondelete="SET NULL"), nullable=True, index=True),
|
||||||
|
sa.Column("action", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column("resource_type", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column("resource_id", sa.String(255), nullable=True),
|
||||||
|
sa.Column("details", sa.Text, nullable=True),
|
||||||
|
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(512), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
op.create_table(
|
||||||
|
"documents",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("filename", sa.String(500), nullable=False),
|
||||||
|
sa.Column("original_filename", sa.String(500), nullable=False),
|
||||||
|
sa.Column("content_type", sa.String(100), nullable=False),
|
||||||
|
sa.Column("file_size", sa.BigInteger, nullable=False),
|
||||||
|
sa.Column("checksum", sa.String(128), nullable=False, index=True),
|
||||||
|
sa.Column("storage_path", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("status", sa.String(50), default="pending", nullable=False, index=True),
|
||||||
|
sa.Column("page_count", sa.Integer, nullable=True),
|
||||||
|
sa.Column("is_scanned", sa.Boolean, nullable=True),
|
||||||
|
sa.Column("document_metadata", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text, nullable=True),
|
||||||
|
sa.Column("uploaded_by", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.users.id", ondelete="SET NULL"), nullable=True, index=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Document pages
|
||||||
|
op.create_table(
|
||||||
|
"document_pages",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("document_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.documents.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("page_number", sa.Integer, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("image_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("text_content", sa.Text, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Document text blocks
|
||||||
|
op.create_table(
|
||||||
|
"document_text_blocks",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("page_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_pages.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("text", sa.Text, nullable=False),
|
||||||
|
sa.Column("x", sa.Float, nullable=False),
|
||||||
|
sa.Column("y", sa.Float, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("confidence", sa.Float, nullable=True),
|
||||||
|
sa.Column("font_family", sa.String(255), nullable=True),
|
||||||
|
sa.Column("font_size", sa.Float, nullable=True),
|
||||||
|
sa.Column("font_color", sa.String(50), nullable=True),
|
||||||
|
sa.Column("font_style", sa.String(50), nullable=True),
|
||||||
|
sa.Column("block_type", sa.String(50), default="text", nullable=False),
|
||||||
|
sa.Column("sequence", sa.Integer, default=0, nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Document images
|
||||||
|
op.create_table(
|
||||||
|
"document_images",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("page_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_pages.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("x", sa.Float, nullable=False),
|
||||||
|
sa.Column("y", sa.Float, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("image_path", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("image_type", sa.String(50), default="figure", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Document tables
|
||||||
|
op.create_table(
|
||||||
|
"document_tables",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("page_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_pages.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("x", sa.Float, nullable=False),
|
||||||
|
sa.Column("y", sa.Float, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("rows", sa.Integer, nullable=False),
|
||||||
|
sa.Column("columns", sa.Integer, nullable=False),
|
||||||
|
sa.Column("data", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Document formats (templates)
|
||||||
|
op.create_table(
|
||||||
|
"document_formats",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False, index=True),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("page_width", sa.Float, nullable=False),
|
||||||
|
sa.Column("page_height", sa.Float, nullable=False),
|
||||||
|
sa.Column("page_count", sa.Integer, default=1, nullable=False),
|
||||||
|
sa.Column("margin_top", sa.Float, default=72.0, nullable=False),
|
||||||
|
sa.Column("margin_right", sa.Float, default=72.0, nullable=False),
|
||||||
|
sa.Column("margin_bottom", sa.Float, default=72.0, nullable=False),
|
||||||
|
sa.Column("margin_left", sa.Float, default=72.0, nullable=False),
|
||||||
|
sa.Column("fingerprint", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("source_document_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.documents.id", ondelete="SET NULL"), nullable=True, index=True),
|
||||||
|
sa.Column("version", sa.Integer, default=1, nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean, default=True, nullable=False, index=True),
|
||||||
|
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Document cells
|
||||||
|
op.create_table(
|
||||||
|
"document_cells",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_formats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("page_number", sa.Integer, nullable=False),
|
||||||
|
sa.Column("x", sa.Float, nullable=False),
|
||||||
|
sa.Column("y", sa.Float, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("row_no", sa.Integer, default=0, nullable=False),
|
||||||
|
sa.Column("column_no", sa.Integer, default=0, nullable=False),
|
||||||
|
sa.Column("data_type", sa.String(50), default="text", nullable=False),
|
||||||
|
sa.Column("font_family", sa.String(255), nullable=True),
|
||||||
|
sa.Column("font_size", sa.Float, nullable=True),
|
||||||
|
sa.Column("font_style", sa.String(50), nullable=True),
|
||||||
|
sa.Column("font_color", sa.String(50), nullable=True),
|
||||||
|
sa.Column("background_color", sa.String(50), nullable=True),
|
||||||
|
sa.Column("border_top", sa.String(100), nullable=True),
|
||||||
|
sa.Column("border_right", sa.String(100), nullable=True),
|
||||||
|
sa.Column("border_bottom", sa.String(100), nullable=True),
|
||||||
|
sa.Column("border_left", sa.String(100), nullable=True),
|
||||||
|
sa.Column("padding_top", sa.Float, default=0.0, nullable=False),
|
||||||
|
sa.Column("padding_right", sa.Float, default=0.0, nullable=False),
|
||||||
|
sa.Column("padding_bottom", sa.Float, default=0.0, nullable=False),
|
||||||
|
sa.Column("padding_left", sa.Float, default=0.0, nullable=False),
|
||||||
|
sa.Column("alignment", sa.String(20), default="left", nullable=False),
|
||||||
|
sa.Column("vertical_alignment", sa.String(20), default="top", nullable=False),
|
||||||
|
sa.Column("rowspan", sa.Integer, default=1, nullable=False),
|
||||||
|
sa.Column("colspan", sa.Integer, default=1, nullable=False),
|
||||||
|
sa.Column("static_text", sa.Text, nullable=True),
|
||||||
|
sa.Column("field_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("sequence", sa.Integer, default=0, nullable=False),
|
||||||
|
sa.Column("is_dynamic", sa.Boolean, default=False, nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Document regions
|
||||||
|
op.create_table(
|
||||||
|
"document_regions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_formats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("page_number", sa.Integer, nullable=False),
|
||||||
|
sa.Column("region_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("x", sa.Float, nullable=False),
|
||||||
|
sa.Column("y", sa.Float, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("content", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("sequence", sa.Integer, default=0, nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table formats
|
||||||
|
op.create_table(
|
||||||
|
"table_formats",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_formats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("page_number", sa.Integer, nullable=False),
|
||||||
|
sa.Column("x", sa.Float, nullable=False),
|
||||||
|
sa.Column("y", sa.Float, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("rows", sa.Integer, nullable=False),
|
||||||
|
sa.Column("columns", sa.Integer, nullable=False),
|
||||||
|
sa.Column("border_style", sa.String(50), default="solid", nullable=False),
|
||||||
|
sa.Column("border_width", sa.Float, default=1.0, nullable=False),
|
||||||
|
sa.Column("border_color", sa.String(50), default="#000000", nullable=False),
|
||||||
|
sa.Column("header_rows", sa.Integer, default=1, nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table columns
|
||||||
|
op.create_table(
|
||||||
|
"table_columns",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("table_format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.table_formats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("column_index", sa.Integer, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("header_text", sa.String(500), nullable=True),
|
||||||
|
sa.Column("data_type", sa.String(50), default="text", nullable=False),
|
||||||
|
sa.Column("alignment", sa.String(20), default="left", nullable=False),
|
||||||
|
sa.Column("font_family", sa.String(255), nullable=True),
|
||||||
|
sa.Column("font_size", sa.Float, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table rows
|
||||||
|
op.create_table(
|
||||||
|
"table_rows",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("table_format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.table_formats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("row_index", sa.Integer, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, default=20.0, nullable=False),
|
||||||
|
sa.Column("is_header", sa.Boolean, default=False, nullable=False),
|
||||||
|
sa.Column("background_color", sa.String(50), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Watermarks
|
||||||
|
op.create_table(
|
||||||
|
"watermarks",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_formats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("page_number", sa.Integer, nullable=True),
|
||||||
|
sa.Column("text", sa.String(500), nullable=True),
|
||||||
|
sa.Column("image_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("x", sa.Float, nullable=False),
|
||||||
|
sa.Column("y", sa.Float, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("opacity", sa.Float, default=0.3, nullable=False),
|
||||||
|
sa.Column("rotation", sa.Float, default=0.0, nullable=False),
|
||||||
|
sa.Column("font_family", sa.String(255), nullable=True),
|
||||||
|
sa.Column("font_size", sa.Float, nullable=True),
|
||||||
|
sa.Column("font_color", sa.String(50), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image regions
|
||||||
|
op.create_table(
|
||||||
|
"image_regions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_formats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("page_number", sa.Integer, nullable=False),
|
||||||
|
sa.Column("x", sa.Float, nullable=False),
|
||||||
|
sa.Column("y", sa.Float, nullable=False),
|
||||||
|
sa.Column("width", sa.Float, nullable=False),
|
||||||
|
sa.Column("height", sa.Float, nullable=False),
|
||||||
|
sa.Column("image_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("image_type", sa.String(50), default="figure", nullable=False),
|
||||||
|
sa.Column("is_static", sa.Boolean, default=True, nullable=False),
|
||||||
|
sa.Column("field_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Template fingerprints
|
||||||
|
op.create_table(
|
||||||
|
"template_fingerprints",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_formats.id", ondelete="CASCADE"), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("page_dimensions", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("logo_coordinates", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("header_coordinates", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("footer_coordinates", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("table_coordinates", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("cell_coordinates", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("fingerprint_hash", sa.String(256), nullable=False, index=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Template matches
|
||||||
|
op.create_table(
|
||||||
|
"template_matches",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("document_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.documents.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("format_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.document_formats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("confidence_score", sa.Float, nullable=False),
|
||||||
|
sa.Column("match_details", postgresql.JSONB, nullable=True),
|
||||||
|
sa.Column("selected", sa.Boolean, default=False, nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Additional indexes
|
||||||
|
op.create_index("ix_documents_status_created", "documents", ["status", "created_at"], schema=SCHEMA)
|
||||||
|
op.create_index("ix_document_pages_doc_page", "document_pages", ["document_id", "page_number"], schema=SCHEMA)
|
||||||
|
op.create_index("ix_document_text_blocks_type", "document_text_blocks", ["page_id", "block_type"], schema=SCHEMA)
|
||||||
|
op.create_index("ix_document_cells_format_page", "document_cells", ["format_id", "page_number"], schema=SCHEMA)
|
||||||
|
op.create_index("ix_template_matches_doc_score", "template_matches", ["document_id", "confidence_score"], schema=SCHEMA)
|
||||||
|
op.create_index("ix_audit_logs_resource", "audit_logs", ["resource_type", "resource_id"], schema=SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("template_matches", schema=SCHEMA)
|
||||||
|
op.drop_table("template_fingerprints", schema=SCHEMA)
|
||||||
|
op.drop_table("image_regions", schema=SCHEMA)
|
||||||
|
op.drop_table("watermarks", schema=SCHEMA)
|
||||||
|
op.drop_table("table_rows", schema=SCHEMA)
|
||||||
|
op.drop_table("table_columns", schema=SCHEMA)
|
||||||
|
op.drop_table("table_formats", schema=SCHEMA)
|
||||||
|
op.drop_table("document_regions", schema=SCHEMA)
|
||||||
|
op.drop_table("document_cells", schema=SCHEMA)
|
||||||
|
op.drop_table("document_formats", schema=SCHEMA)
|
||||||
|
op.drop_table("document_tables", schema=SCHEMA)
|
||||||
|
op.drop_table("document_images", schema=SCHEMA)
|
||||||
|
op.drop_table("document_text_blocks", schema=SCHEMA)
|
||||||
|
op.drop_table("document_pages", schema=SCHEMA)
|
||||||
|
op.drop_table("documents", schema=SCHEMA)
|
||||||
|
op.drop_table("audit_logs", schema=SCHEMA)
|
||||||
|
op.drop_table("refresh_tokens", schema=SCHEMA)
|
||||||
|
op.drop_table("user_roles", schema=SCHEMA)
|
||||||
|
op.drop_table("roles", schema=SCHEMA)
|
||||||
|
op.drop_table("users", schema=SCHEMA)
|
||||||
11
docengine/app.py
Normal file
11
docengine/app.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status":"UP"}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=7989)
|
||||||
1
docengine/app/__init__.py
Normal file
1
docengine/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# DocEngine - Document Template Recognition and Reconstruction System
|
||||||
15
docengine/app/api/router.py
Normal file
15
docengine/app/api/router.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1.auth import router as auth_router
|
||||||
|
from app.api.v1.documents import router as documents_router
|
||||||
|
from app.api.v1.health import router as health_router
|
||||||
|
from app.api.v1.templates import router as templates_router
|
||||||
|
|
||||||
|
api_v1_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
api_v1_router.include_router(health_router)
|
||||||
|
api_v1_router.include_router(auth_router)
|
||||||
|
api_v1_router.include_router(documents_router)
|
||||||
|
api_v1_router.include_router(templates_router)
|
||||||
0
docengine/app/api/v1/__init__.py
Normal file
0
docengine/app/api/v1/__init__.py
Normal file
246
docengine/app/api/v1/auth.py
Normal file
246
docengine/app/api/v1/auth.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser
|
||||||
|
from app.core.security import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
InvalidTokenError,
|
||||||
|
)
|
||||||
|
from app.repositories.user_repository import RefreshTokenRepository, UserRepository
|
||||||
|
from app.schemas.auth import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
RefreshTokenRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
TokenResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.common import SuccessResponse
|
||||||
|
from app.schemas.user import UserResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/register",
|
||||||
|
response_model=UserResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Register User",
|
||||||
|
description="Register a new user account.",
|
||||||
|
)
|
||||||
|
def register(
|
||||||
|
payload: RegisterRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> UserResponse:
|
||||||
|
"""Register a new user."""
|
||||||
|
user_repo = UserRepository(db)
|
||||||
|
|
||||||
|
# Check for existing user
|
||||||
|
if user_repo.get_by_username(payload.username):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Username '{payload.username}' is already taken",
|
||||||
|
)
|
||||||
|
if user_repo.get_by_email(payload.email):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Email '{payload.email}' is already registered",
|
||||||
|
)
|
||||||
|
|
||||||
|
hashed = hash_password(payload.password)
|
||||||
|
user = user_repo.create_user(
|
||||||
|
username=payload.username,
|
||||||
|
email=payload.email,
|
||||||
|
hashed_password=hashed,
|
||||||
|
full_name=payload.full_name,
|
||||||
|
role_names=["user"],
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/login",
|
||||||
|
response_model=TokenResponse,
|
||||||
|
summary="Login",
|
||||||
|
description="Authenticate with username and password to obtain JWT tokens.",
|
||||||
|
)
|
||||||
|
def login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> TokenResponse:
|
||||||
|
"""Authenticate user and return JWT tokens."""
|
||||||
|
user_repo = UserRepository(db)
|
||||||
|
refresh_repo = RefreshTokenRepository(db)
|
||||||
|
|
||||||
|
user = user_repo.get_by_username(payload.username)
|
||||||
|
if not user or not verify_password(payload.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User account is deactivated",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate tokens
|
||||||
|
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
|
||||||
|
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
|
# Store refresh token
|
||||||
|
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
|
||||||
|
refresh_repo.create_token(
|
||||||
|
user_id=user.id,
|
||||||
|
token=refresh_token_str,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last login
|
||||||
|
user_repo.update_last_login(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token_str,
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/refresh",
|
||||||
|
response_model=TokenResponse,
|
||||||
|
summary="Refresh Token",
|
||||||
|
description="Obtain a new access token using a valid refresh token.",
|
||||||
|
)
|
||||||
|
def refresh_token(
|
||||||
|
payload: RefreshTokenRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> TokenResponse:
|
||||||
|
"""Refresh access token using a refresh token."""
|
||||||
|
refresh_repo = RefreshTokenRepository(db)
|
||||||
|
user_repo = UserRepository(db)
|
||||||
|
|
||||||
|
# Validate the refresh token
|
||||||
|
try:
|
||||||
|
token_payload = decode_token(payload.refresh_token)
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired refresh token",
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token type",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if token exists in database and is not revoked
|
||||||
|
stored_token = refresh_repo.get_by_token(payload.refresh_token)
|
||||||
|
if not stored_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Refresh token not found or revoked",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = user_repo.get_by_id(token_payload["sub"])
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found or deactivated",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revoke old refresh token
|
||||||
|
refresh_repo.revoke_token(payload.refresh_token)
|
||||||
|
|
||||||
|
# Generate new tokens
|
||||||
|
new_access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
|
||||||
|
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
|
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
|
||||||
|
refresh_repo.create_token(
|
||||||
|
user_id=user.id,
|
||||||
|
token=new_refresh_token,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=new_access_token,
|
||||||
|
refresh_token=new_refresh_token,
|
||||||
|
token_type="bearer",
|
||||||
|
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/logout",
|
||||||
|
response_model=SuccessResponse,
|
||||||
|
summary="Logout",
|
||||||
|
description="Revoke the current refresh token.",
|
||||||
|
)
|
||||||
|
def logout(
|
||||||
|
payload: RefreshTokenRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> SuccessResponse:
|
||||||
|
"""Logout by revoking the refresh token."""
|
||||||
|
refresh_repo = RefreshTokenRepository(db)
|
||||||
|
refresh_repo.revoke_token(payload.refresh_token)
|
||||||
|
db.commit()
|
||||||
|
return SuccessResponse(message="Successfully logged out")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/change-password",
|
||||||
|
response_model=SuccessResponse,
|
||||||
|
summary="Change Password",
|
||||||
|
description="Change the current user's password.",
|
||||||
|
)
|
||||||
|
def change_password(
|
||||||
|
payload: ChangePasswordRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> SuccessResponse:
|
||||||
|
"""Change user password."""
|
||||||
|
if not verify_password(payload.current_password, current_user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Current password is incorrect",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_user.hashed_password = hash_password(payload.new_password)
|
||||||
|
|
||||||
|
# Revoke all refresh tokens for security
|
||||||
|
refresh_repo = RefreshTokenRepository(db)
|
||||||
|
refresh_repo.revoke_all_user_tokens(current_user.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return SuccessResponse(message="Password changed successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/me",
|
||||||
|
response_model=UserResponse,
|
||||||
|
summary="Get Current User",
|
||||||
|
description="Get the currently authenticated user's profile.",
|
||||||
|
)
|
||||||
|
def get_me(current_user: CurrentUser) -> UserResponse:
|
||||||
|
"""Get current authenticated user profile."""
|
||||||
|
return UserResponse.model_validate(current_user)
|
||||||
268
docengine/app/api/v1/documents.py
Normal file
268
docengine/app/api/v1/documents.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser
|
||||||
|
from app.core.exceptions import FileSizeError, UnsupportedFileTypeError
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.document import Document
|
||||||
|
from app.repositories.document_repository import DocumentRepository
|
||||||
|
from app.schemas.common import PaginatedResponse, SuccessResponse
|
||||||
|
from app.schemas.document import (
|
||||||
|
DocumentListResponse,
|
||||||
|
DocumentResponse,
|
||||||
|
DocumentUploadResponse,
|
||||||
|
TemplateMatchRequest,
|
||||||
|
TemplateMatchResponse,
|
||||||
|
)
|
||||||
|
from app.storage.provider import LocalStorageProvider, get_storage_provider
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/documents", tags=["Documents"])
|
||||||
|
|
||||||
|
ALLOWED_CONTENT_TYPES = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/tiff": "tiff",
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".tif", ".pdf"}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_file(file: UploadFile) -> str:
|
||||||
|
"""Validate uploaded file type and size. Returns the content type."""
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Filename is required",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check extension
|
||||||
|
from pathlib import Path
|
||||||
|
ext = Path(file.filename).suffix.lower()
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise UnsupportedFileTypeError(ext)
|
||||||
|
|
||||||
|
# Determine content type
|
||||||
|
content_type = file.content_type or ""
|
||||||
|
if content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
|
# Try to infer from extension
|
||||||
|
ext_to_ct = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
}
|
||||||
|
content_type = ext_to_ct.get(ext, "")
|
||||||
|
if not content_type:
|
||||||
|
raise UnsupportedFileTypeError(file.content_type or "unknown")
|
||||||
|
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/upload",
|
||||||
|
response_model=DocumentUploadResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Upload Document",
|
||||||
|
description="Upload a document (JPG, JPEG, PNG, TIFF, or PDF) for processing.",
|
||||||
|
)
|
||||||
|
async def upload_document(
|
||||||
|
file: UploadFile = File(..., description="Document file to upload"),
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> DocumentUploadResponse:
|
||||||
|
"""Upload a document for processing."""
|
||||||
|
content_type = _validate_file(file)
|
||||||
|
|
||||||
|
# Read file data
|
||||||
|
file_data = await file.read()
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
if len(file_data) > settings.storage_max_file_size_bytes:
|
||||||
|
raise FileSizeError(settings.storage_max_file_size_mb)
|
||||||
|
|
||||||
|
# Store file
|
||||||
|
storage = get_storage_provider()
|
||||||
|
checksum = storage.compute_checksum(file_data)
|
||||||
|
safe_filename = file.filename or "unknown"
|
||||||
|
stored_filename = storage.generate_filename(safe_filename)
|
||||||
|
storage_path = storage.save_file(file_data, "documents", stored_filename)
|
||||||
|
|
||||||
|
# Create document record
|
||||||
|
doc_repo = DocumentRepository(db)
|
||||||
|
document = Document(
|
||||||
|
filename=stored_filename,
|
||||||
|
original_filename=safe_filename,
|
||||||
|
content_type=content_type,
|
||||||
|
file_size=len(file_data),
|
||||||
|
checksum=checksum,
|
||||||
|
storage_path=storage_path,
|
||||||
|
status="pending",
|
||||||
|
uploaded_by=current_user.id if current_user else None,
|
||||||
|
)
|
||||||
|
doc_repo.create(document)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(document)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"document_uploaded",
|
||||||
|
document_id=str(document.id),
|
||||||
|
filename=safe_filename,
|
||||||
|
size=len(file_data),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger async processing via Celery
|
||||||
|
try:
|
||||||
|
from app.tasks.document_tasks import process_document_task
|
||||||
|
process_document_task.delay(str(document.id))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("celery_dispatch_failed", error=str(e), document_id=str(document.id))
|
||||||
|
|
||||||
|
return DocumentUploadResponse.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{document_id}",
|
||||||
|
response_model=DocumentResponse,
|
||||||
|
summary="Get Document",
|
||||||
|
description="Retrieve a document by its ID with all extracted content.",
|
||||||
|
)
|
||||||
|
def get_document(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> DocumentResponse:
|
||||||
|
"""Get a document by ID."""
|
||||||
|
doc_repo = DocumentRepository(db)
|
||||||
|
document = doc_repo.get_with_pages(document_id)
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Document '{document_id}' not found",
|
||||||
|
)
|
||||||
|
return DocumentResponse.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[DocumentListResponse],
|
||||||
|
summary="List Documents",
|
||||||
|
description="List all documents with pagination.",
|
||||||
|
)
|
||||||
|
def list_documents(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> PaginatedResponse[DocumentListResponse]:
|
||||||
|
"""List documents with pagination and optional status filter."""
|
||||||
|
doc_repo = DocumentRepository(db)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
filters = {}
|
||||||
|
if status_filter:
|
||||||
|
filters["status"] = status_filter
|
||||||
|
|
||||||
|
documents = doc_repo.get_all(
|
||||||
|
offset=offset,
|
||||||
|
limit=page_size,
|
||||||
|
filters=filters,
|
||||||
|
order_by="created_at",
|
||||||
|
order_desc=True,
|
||||||
|
)
|
||||||
|
total = doc_repo.count(filters=filters)
|
||||||
|
|
||||||
|
items = [DocumentListResponse.model_validate(doc) for doc in documents]
|
||||||
|
return PaginatedResponse.create(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{document_id}/template",
|
||||||
|
response_model=list[TemplateMatchResponse],
|
||||||
|
summary="Get Document Template Matches",
|
||||||
|
description="Get template matching results for a document.",
|
||||||
|
)
|
||||||
|
def get_document_template_matches(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[TemplateMatchResponse]:
|
||||||
|
"""Get template matches for a document."""
|
||||||
|
from app.repositories.document_repository import TemplateMatchRepository
|
||||||
|
|
||||||
|
doc_repo = DocumentRepository(db)
|
||||||
|
document = doc_repo.get_by_id(document_id)
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Document '{document_id}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
match_repo = TemplateMatchRepository(db)
|
||||||
|
matches = match_repo.get_document_matches(document_id)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for match in matches:
|
||||||
|
resp = TemplateMatchResponse(
|
||||||
|
id=match.id,
|
||||||
|
document_id=match.document_id,
|
||||||
|
format_id=match.format_id,
|
||||||
|
confidence_score=match.confidence_score,
|
||||||
|
match_details=match.match_details,
|
||||||
|
selected=match.selected,
|
||||||
|
template_name=match.template.name if match.template else None,
|
||||||
|
created_at=match.created_at,
|
||||||
|
)
|
||||||
|
results.append(resp)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{document_id}",
|
||||||
|
response_model=SuccessResponse,
|
||||||
|
summary="Delete Document",
|
||||||
|
description="Delete a document and its associated data.",
|
||||||
|
)
|
||||||
|
def delete_document(
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> SuccessResponse:
|
||||||
|
"""Delete a document."""
|
||||||
|
doc_repo = DocumentRepository(db)
|
||||||
|
document = doc_repo.get_by_id(document_id)
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Document '{document_id}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete stored file
|
||||||
|
try:
|
||||||
|
storage = get_storage_provider()
|
||||||
|
storage.delete_file(document.storage_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("file_delete_failed", error=str(e), path=document.storage_path)
|
||||||
|
|
||||||
|
doc_repo.delete(document)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return SuccessResponse(message=f"Document '{document_id}' deleted successfully")
|
||||||
51
docengine/app/api/v1/health.py
Normal file
51
docengine/app/api/v1/health.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import redis
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import check_database_connection
|
||||||
|
from app.schemas.common import HealthResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/health",
|
||||||
|
response_model=HealthResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Health Check",
|
||||||
|
description="Check the health status of the application and its dependencies.",
|
||||||
|
)
|
||||||
|
async def health_check() -> HealthResponse:
|
||||||
|
"""Perform health check on all system components."""
|
||||||
|
# Check database
|
||||||
|
db_status = "healthy" if check_database_connection() else "unhealthy"
|
||||||
|
|
||||||
|
# Check Redis
|
||||||
|
redis_status = "healthy"
|
||||||
|
try:
|
||||||
|
r = redis.Redis(
|
||||||
|
host=settings.redis_host,
|
||||||
|
port=settings.redis_port,
|
||||||
|
db=settings.redis_db,
|
||||||
|
password=settings.redis_password or None,
|
||||||
|
socket_timeout=3,
|
||||||
|
)
|
||||||
|
r.ping()
|
||||||
|
r.close()
|
||||||
|
except Exception:
|
||||||
|
redis_status = "unhealthy"
|
||||||
|
|
||||||
|
overall_status = "healthy" if db_status == "healthy" and redis_status == "healthy" else "degraded"
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status=overall_status,
|
||||||
|
version=settings.app_version,
|
||||||
|
environment=settings.app_env,
|
||||||
|
database=db_status,
|
||||||
|
redis=redis_status,
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
)
|
||||||
223
docengine/app/api/v1/templates.py
Normal file
223
docengine/app/api/v1/templates.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.repositories.document_repository import DocumentRepository, TemplateMatchRepository
|
||||||
|
from app.repositories.template_repository import TemplateRepository
|
||||||
|
from app.schemas.common import PaginatedResponse, SuccessResponse
|
||||||
|
from app.schemas.document import TemplateMatchRequest, TemplateMatchResponse
|
||||||
|
from app.schemas.template import (
|
||||||
|
TemplateListResponse,
|
||||||
|
TemplateRenderRequest,
|
||||||
|
TemplateRenderResponse,
|
||||||
|
TemplateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/templates", tags=["Templates"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[TemplateListResponse],
|
||||||
|
summary="List Templates",
|
||||||
|
description="List all active templates with pagination.",
|
||||||
|
)
|
||||||
|
def list_templates(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> PaginatedResponse[TemplateListResponse]:
|
||||||
|
"""List all active templates."""
|
||||||
|
template_repo = TemplateRepository(db)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
templates = template_repo.get_active_templates(offset=offset, limit=page_size)
|
||||||
|
total = template_repo.count_active()
|
||||||
|
|
||||||
|
items = [TemplateListResponse.model_validate(t) for t in templates]
|
||||||
|
return PaginatedResponse.create(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{template_id}",
|
||||||
|
response_model=TemplateResponse,
|
||||||
|
summary="Get Template",
|
||||||
|
description="Retrieve a template by ID with all its components.",
|
||||||
|
)
|
||||||
|
def get_template(
|
||||||
|
template_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> TemplateResponse:
|
||||||
|
"""Get a template by ID."""
|
||||||
|
template_repo = TemplateRepository(db)
|
||||||
|
template = template_repo.get_by_id(template_id)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Template '{template_id}' not found",
|
||||||
|
)
|
||||||
|
return TemplateResponse.model_validate(template)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{template_id}",
|
||||||
|
response_model=SuccessResponse,
|
||||||
|
summary="Delete Template",
|
||||||
|
description="Soft-delete a template by deactivating it.",
|
||||||
|
)
|
||||||
|
def delete_template(
|
||||||
|
template_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> SuccessResponse:
|
||||||
|
"""Soft-delete a template."""
|
||||||
|
template_repo = TemplateRepository(db)
|
||||||
|
template = template_repo.get_by_id(template_id)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Template '{template_id}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
template_repo.deactivate_template(template_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return SuccessResponse(message=f"Template '{template_id}' deactivated successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/match",
|
||||||
|
response_model=list[TemplateMatchResponse],
|
||||||
|
summary="Match Document to Templates",
|
||||||
|
description="Match a document against existing templates and return ranked results.",
|
||||||
|
)
|
||||||
|
def match_template(
|
||||||
|
payload: TemplateMatchRequest,
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[TemplateMatchResponse]:
|
||||||
|
"""Match a document against existing templates."""
|
||||||
|
doc_repo = DocumentRepository(db)
|
||||||
|
document = doc_repo.get_by_id(payload.document_id)
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Document '{payload.document_id}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if document.status != "completed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Document must be in 'completed' status. Current status: '{document.status}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform template matching
|
||||||
|
from app.services.matching_service import MatchingService
|
||||||
|
matching_service = MatchingService(db)
|
||||||
|
matches = matching_service.match_document(
|
||||||
|
document_id=payload.document_id,
|
||||||
|
min_confidence=payload.min_confidence,
|
||||||
|
max_results=payload.max_results,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for match in matches:
|
||||||
|
resp = TemplateMatchResponse(
|
||||||
|
id=match.id,
|
||||||
|
document_id=match.document_id,
|
||||||
|
format_id=match.format_id,
|
||||||
|
confidence_score=match.confidence_score,
|
||||||
|
match_details=match.match_details,
|
||||||
|
selected=match.selected,
|
||||||
|
template_name=match.template.name if match.template else None,
|
||||||
|
created_at=match.created_at,
|
||||||
|
)
|
||||||
|
results.append(resp)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/render",
|
||||||
|
response_model=TemplateRenderResponse,
|
||||||
|
summary="Render Template to PDF",
|
||||||
|
description="Generate a PDF from a stored template with supplied data.",
|
||||||
|
)
|
||||||
|
def render_template(
|
||||||
|
payload: TemplateRenderRequest,
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> TemplateRenderResponse:
|
||||||
|
"""Render a template to PDF."""
|
||||||
|
template_repo = TemplateRepository(db)
|
||||||
|
template = template_repo.get_by_id(payload.template_id)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Template '{payload.template_id}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not template.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Template is deactivated",
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.services.reconstruction_service import ReconstructionService
|
||||||
|
reconstruction_service = ReconstructionService(db)
|
||||||
|
result = reconstruction_service.render_template(
|
||||||
|
template=template,
|
||||||
|
data=payload.data,
|
||||||
|
output_filename=payload.output_filename,
|
||||||
|
images=payload.images,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{template_id}/download",
|
||||||
|
summary="Download Rendered PDF",
|
||||||
|
description="Download a previously rendered PDF.",
|
||||||
|
)
|
||||||
|
def download_rendered_pdf(
|
||||||
|
template_id: uuid.UUID,
|
||||||
|
filename: str = Query(..., description="Filename of the rendered PDF"),
|
||||||
|
current_user: CurrentUser = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""Download a rendered PDF."""
|
||||||
|
from app.storage.provider import get_storage_provider
|
||||||
|
|
||||||
|
storage = get_storage_provider()
|
||||||
|
storage_path = f"rendered/{filename}"
|
||||||
|
|
||||||
|
if not storage.file_exists(storage_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Rendered PDF '{filename}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
absolute_path = storage.get_absolute_path(storage_path)
|
||||||
|
return FileResponse(
|
||||||
|
path=absolute_path,
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
0
docengine/app/core/__init__.py
Normal file
0
docengine/app/core/__init__.py
Normal file
119
docengine/app/core/config.py
Normal file
119
docengine/app/core/config.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application configuration loaded from environment variables."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Application
|
||||||
|
app_name: str = "DocEngine"
|
||||||
|
app_version: str = "1.0.0"
|
||||||
|
app_env: str = "development"
|
||||||
|
app_debug: bool = True
|
||||||
|
app_host: str = "0.0.0.0"
|
||||||
|
app_port: int = 7989
|
||||||
|
app_workers: int = 4
|
||||||
|
|
||||||
|
# Database
|
||||||
|
db_host: str = "192.168.0.111"
|
||||||
|
db_port: int = 7925
|
||||||
|
db_name: str = "document_engine"
|
||||||
|
db_user: str = "postgres"
|
||||||
|
db_password: str = "M@tr!x#149@dm!N"
|
||||||
|
db_schema: str = "admin"
|
||||||
|
db_pool_size: int = 20
|
||||||
|
db_max_overflow: int = 10
|
||||||
|
db_echo: bool = False
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_host: str = "localhost"
|
||||||
|
redis_port: int = 6379
|
||||||
|
redis_db: int = 0
|
||||||
|
redis_password: str = ""
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
celery_broker_url: str = "redis://localhost:6379/0"
|
||||||
|
celery_result_backend: str = "redis://localhost:6379/1"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
jwt_secret_key: str = "a7f3c9e1d4b8f2a6c0e5d7b3a9f1c4e8d2b6a0f5c3e7d1b9a4f8c2e6d0b5a3"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_access_token_expire_minutes: int = 30
|
||||||
|
jwt_refresh_token_expire_days: int = 7
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
storage_provider: str = "local"
|
||||||
|
storage_local_path: str = "./storage"
|
||||||
|
storage_max_file_size_mb: int = 100
|
||||||
|
|
||||||
|
# OCR
|
||||||
|
ocr_language: str = "en"
|
||||||
|
ocr_use_gpu: bool = False
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_level: str = "INFO"
|
||||||
|
log_format: str = "json"
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8080"]
|
||||||
|
cors_allow_credentials: bool = True
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
rate_limit_requests: int = 100
|
||||||
|
rate_limit_window_seconds: int = 60
|
||||||
|
|
||||||
|
# Prometheus
|
||||||
|
prometheus_enabled: bool = True
|
||||||
|
|
||||||
|
@field_validator("cors_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_cors_origins(cls, v: Any) -> list[str]:
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(v)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
||||||
|
return v
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url(self) -> str:
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
password = quote_plus(self.db_password)
|
||||||
|
return f"postgresql+psycopg2://{self.db_user}:{password}@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def async_database_url(self) -> str:
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
password = quote_plus(self.db_password)
|
||||||
|
return f"postgresql+asyncpg://{self.db_user}:{password}@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis_url(self) -> str:
|
||||||
|
if self.redis_password:
|
||||||
|
return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||||
|
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.app_env == "production"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def storage_max_file_size_bytes(self) -> int:
|
||||||
|
return self.storage_max_file_size_mb * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
88
docengine/app/core/database.py
Normal file
88
docengine/app/core/database.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import MetaData, create_engine, event, text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = MetaData(
|
||||||
|
naming_convention=NAMING_CONVENTION,
|
||||||
|
schema=settings.db_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.database_url,
|
||||||
|
pool_size=settings.db_pool_size,
|
||||||
|
max_overflow=settings.db_max_overflow,
|
||||||
|
echo=settings.db_echo,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
connect_args={
|
||||||
|
"options": f"-c search_path={settings.db_schema},public"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def set_search_path(dbapi_connection: object, connection_record: object) -> None:
|
||||||
|
cursor = dbapi_connection.cursor() # type: ignore[union-attr]
|
||||||
|
cursor.execute(f"SET search_path TO {settings.db_schema}, public")
|
||||||
|
cursor.close()
|
||||||
|
dbapi_connection.commit() # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
bind=engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all SQLAlchemy models."""
|
||||||
|
|
||||||
|
metadata = metadata
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
"""Dependency to get database session."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db_context() -> Generator[Session, None, None]:
|
||||||
|
"""Context manager for database session (used outside request scope)."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def check_database_connection() -> bool:
|
||||||
|
"""Verify database connectivity."""
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
97
docengine/app/core/dependencies.py
Normal file
97
docengine/app/core/dependencies.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import InvalidTokenError, decode_token
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
security_scheme = HTTPBearer(auto_error=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security_scheme)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> User:
|
||||||
|
"""Extract and validate the current user from the JWT token."""
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
token_type = payload.get("type")
|
||||||
|
if token_type != "access":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token type. Access token required.",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id: str | None = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token payload missing subject",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_repo = UserRepository(db)
|
||||||
|
user = user_repo.get_by_id(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User account is deactivated",
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_active_user(
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
) -> User:
|
||||||
|
"""Ensure the current user is active."""
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User account is deactivated",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(required_roles: list[str]): # noqa: ANN201
|
||||||
|
"""Dependency factory to require specific roles."""
|
||||||
|
|
||||||
|
def role_checker(
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
) -> User:
|
||||||
|
user_roles = {role.name for role in current_user.roles}
|
||||||
|
if not user_roles.intersection(required_roles):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"One of the following roles required: {', '.join(required_roles)}",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
ActiveUser = Annotated[User, Depends(get_current_active_user)]
|
||||||
|
AdminUser = Annotated[User, Depends(require_role(["admin"]))]
|
||||||
|
DBSession = Annotated[Session, Depends(get_db)]
|
||||||
106
docengine/app/core/exceptions.py
Normal file
106
docengine/app/core/exceptions.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class DocEngineException(Exception):
|
||||||
|
"""Base exception for DocEngine application."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str, status_code: int = 500, extra: dict[str, Any] | None = None) -> None:
|
||||||
|
self.detail = detail
|
||||||
|
self.status_code = status_code
|
||||||
|
self.extra = extra or {}
|
||||||
|
super().__init__(self.detail)
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(DocEngineException):
|
||||||
|
"""Resource not found."""
|
||||||
|
|
||||||
|
def __init__(self, resource: str, identifier: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
detail=f"{resource} with identifier '{identifier}' not found",
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
self.resource = resource
|
||||||
|
self.identifier = identifier
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateError(DocEngineException):
|
||||||
|
"""Resource already exists."""
|
||||||
|
|
||||||
|
def __init__(self, resource: str, field: str, value: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
detail=f"{resource} with {field} '{value}' already exists",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(DocEngineException):
|
||||||
|
"""Input validation error."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str, errors: list[dict[str, Any]] | None = None) -> None:
|
||||||
|
super().__init__(detail=detail, status_code=422)
|
||||||
|
self.errors = errors or []
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(DocEngineException):
|
||||||
|
"""Authentication failed."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = "Authentication failed") -> None:
|
||||||
|
super().__init__(detail=detail, status_code=401)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationError(DocEngineException):
|
||||||
|
"""Authorization failed."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = "Insufficient permissions") -> None:
|
||||||
|
super().__init__(detail=detail, status_code=403)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageError(DocEngineException):
|
||||||
|
"""Storage operation failed."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str) -> None:
|
||||||
|
super().__init__(detail=detail, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingError(DocEngineException):
|
||||||
|
"""Document processing failed."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str, document_id: str | None = None) -> None:
|
||||||
|
super().__init__(detail=detail, status_code=500)
|
||||||
|
self.document_id = document_id
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateError(DocEngineException):
|
||||||
|
"""Template operation failed."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str) -> None:
|
||||||
|
super().__init__(detail=detail, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(DocEngineException):
|
||||||
|
"""Rate limit exceeded."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = "Rate limit exceeded. Please try again later.") -> None:
|
||||||
|
super().__init__(detail=detail, status_code=429)
|
||||||
|
|
||||||
|
|
||||||
|
class FileSizeError(DocEngineException):
|
||||||
|
"""File exceeds maximum allowed size."""
|
||||||
|
|
||||||
|
def __init__(self, max_size_mb: int) -> None:
|
||||||
|
super().__init__(
|
||||||
|
detail=f"File size exceeds maximum allowed size of {max_size_mb}MB",
|
||||||
|
status_code=413,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedFileTypeError(DocEngineException):
|
||||||
|
"""File type not supported."""
|
||||||
|
|
||||||
|
def __init__(self, file_type: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
detail=f"File type '{file_type}' is not supported. Supported types: jpg, jpeg, png, tiff, pdf",
|
||||||
|
status_code=415,
|
||||||
|
)
|
||||||
61
docengine/app/core/logging_config.py
Normal file
61
docengine/app/core/logging_config.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure structlog for structured JSON logging."""
|
||||||
|
shared_processors: list[structlog.types.Processor] = [
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.UnicodeDecoder(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.log_format == "json":
|
||||||
|
renderer: structlog.types.Processor = structlog.processors.JSONRenderer()
|
||||||
|
else:
|
||||||
|
renderer = structlog.dev.ConsoleRenderer(colors=True)
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
*shared_processors,
|
||||||
|
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||||
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = structlog.stdlib.ProcessorFormatter(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||||
|
renderer,
|
||||||
|
],
|
||||||
|
foreign_pre_chain=shared_processors,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
root_logger.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
|
||||||
|
|
||||||
|
# Reduce noise from third-party libraries
|
||||||
|
for logger_name in ("uvicorn.access", "sqlalchemy.engine", "celery"):
|
||||||
|
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
||||||
|
"""Get a structlog logger instance."""
|
||||||
|
return structlog.get_logger(name)
|
||||||
59
docengine/app/core/security.py
Normal file
59
docengine/app/core/security.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password using bcrypt."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a plain password against a hashed password."""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
||||||
|
"""Create a JWT access token."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(UTC) + (expires_delta or timedelta(minutes=settings.jwt_access_token_expire_minutes))
|
||||||
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
|
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
||||||
|
"""Create a JWT refresh token."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(UTC) + (expires_delta or timedelta(days=settings.jwt_refresh_token_expire_days))
|
||||||
|
to_encode.update({
|
||||||
|
"exp": expire,
|
||||||
|
"type": "refresh",
|
||||||
|
"jti": str(uuid.uuid4()),
|
||||||
|
})
|
||||||
|
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
|
"""Decode and validate a JWT token."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
|
return payload
|
||||||
|
except JWTError as e:
|
||||||
|
raise InvalidTokenError(str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTokenError(Exception):
|
||||||
|
"""Raised when a JWT token is invalid or expired."""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = "Invalid or expired token") -> None:
|
||||||
|
self.detail = detail
|
||||||
|
super().__init__(self.detail)
|
||||||
0
docengine/app/domain/__init__.py
Normal file
0
docengine/app/domain/__init__.py
Normal file
0
docengine/app/events/__init__.py
Normal file
0
docengine/app/events/__init__.py
Normal file
39
docengine/app/events/handlers.py
Normal file
39
docengine/app/events/handlers.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger, setup_logging
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def on_startup() -> None:
|
||||||
|
"""Application startup event handler."""
|
||||||
|
setup_logging()
|
||||||
|
logger.info("application_starting", event="startup")
|
||||||
|
|
||||||
|
# Ensure storage directories exist
|
||||||
|
from app.storage.provider import get_storage_provider
|
||||||
|
try:
|
||||||
|
get_storage_provider()
|
||||||
|
logger.info("storage_initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("storage_init_failed", error=str(e))
|
||||||
|
|
||||||
|
# Verify database connection
|
||||||
|
from app.core.database import check_database_connection
|
||||||
|
if check_database_connection():
|
||||||
|
logger.info("database_connected")
|
||||||
|
else:
|
||||||
|
logger.error("database_connection_failed")
|
||||||
|
|
||||||
|
logger.info("application_started", event="startup_complete")
|
||||||
|
|
||||||
|
|
||||||
|
def on_shutdown() -> None:
|
||||||
|
"""Application shutdown event handler."""
|
||||||
|
logger.info("application_shutting_down", event="shutdown")
|
||||||
|
|
||||||
|
# Cleanup resources
|
||||||
|
from app.core.database import engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
logger.info("application_stopped", event="shutdown_complete")
|
||||||
0
docengine/app/infrastructure/__init__.py
Normal file
0
docengine/app/infrastructure/__init__.py
Normal file
107
docengine/app/main.py
Normal file
107
docengine/app/main.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api.router import api_v1_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.exceptions import DocEngineException
|
||||||
|
from app.events.handlers import on_shutdown, on_startup
|
||||||
|
from app.middleware.audit import AuditMiddleware
|
||||||
|
from app.middleware.cors import setup_cors
|
||||||
|
from app.middleware.metrics import setup_metrics
|
||||||
|
from app.middleware.rate_limit import RateLimitMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
"""Application lifespan manager."""
|
||||||
|
on_startup()
|
||||||
|
yield
|
||||||
|
on_shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
description="Document Template Recognition and Reconstruction System",
|
||||||
|
version=settings.app_version,
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc",
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup middleware (order matters: last added = first executed)
|
||||||
|
setup_cors(app)
|
||||||
|
app.add_middleware(AuditMiddleware)
|
||||||
|
app.add_middleware(RateLimitMiddleware)
|
||||||
|
|
||||||
|
# Setup Prometheus metrics
|
||||||
|
setup_metrics(app)
|
||||||
|
|
||||||
|
# Include API routes
|
||||||
|
app.include_router(api_v1_router)
|
||||||
|
|
||||||
|
|
||||||
|
# Exception handlers
|
||||||
|
@app.exception_handler(DocEngineException)
|
||||||
|
async def docengine_exception_handler(request: Request, exc: DocEngineException) -> JSONResponse:
|
||||||
|
"""Handle application-specific exceptions."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"detail": exc.detail,
|
||||||
|
"error_code": type(exc).__name__,
|
||||||
|
"extra": exc.extra if exc.extra else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
"""Handle request validation errors."""
|
||||||
|
errors = []
|
||||||
|
for error in exc.errors():
|
||||||
|
errors.append({
|
||||||
|
"field": ".".join(str(loc) for loc in error.get("loc", [])),
|
||||||
|
"message": error.get("msg", ""),
|
||||||
|
"type": error.get("type", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
content={
|
||||||
|
"detail": "Request validation failed",
|
||||||
|
"error_code": "ValidationError",
|
||||||
|
"errors": errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
"""Handle unexpected exceptions."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={
|
||||||
|
"detail": "An unexpected error occurred" if settings.is_production else str(exc),
|
||||||
|
"error_code": "InternalServerError",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host=settings.app_host,
|
||||||
|
port=settings.app_port,
|
||||||
|
reload=not settings.is_production,
|
||||||
|
workers=1 if settings.app_debug else settings.app_workers,
|
||||||
|
)
|
||||||
0
docengine/app/middleware/__init__.py
Normal file
0
docengine/app/middleware/__init__.py
Normal file
71
docengine/app/middleware/audit.py
Normal file
71
docengine/app/middleware/audit.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Middleware to log all API requests for audit purposes."""
|
||||||
|
|
||||||
|
EXCLUDED_PATHS = {"/api/v1/health", "/metrics", "/docs", "/openapi.json", "/redoc"}
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
if request.url.path in self.EXCLUDED_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
start_time = time.monotonic()
|
||||||
|
|
||||||
|
# Extract client info
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
user_agent = request.headers.get("user-agent", "unknown")
|
||||||
|
|
||||||
|
# Add request ID to request state
|
||||||
|
request.state.request_id = request_id
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"request_started",
|
||||||
|
request_id=request_id,
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=user_agent[:200],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
duration_ms = (time.monotonic() - start_time) * 1000
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"request_completed",
|
||||||
|
request_id=request_id,
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
status_code=response.status_code,
|
||||||
|
duration_ms=round(duration_ms, 2),
|
||||||
|
client_ip=client_ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
response.headers["X-Process-Time"] = f"{duration_ms:.2f}ms"
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
duration_ms = (time.monotonic() - start_time) * 1000
|
||||||
|
logger.exception(
|
||||||
|
"request_failed",
|
||||||
|
request_id=request_id,
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
duration_ms=round(duration_ms, 2),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise
|
||||||
31
docengine/app/middleware/cors.py
Normal file
31
docengine/app/middleware/cors.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def setup_cors(app: FastAPI) -> None:
|
||||||
|
"""Configure CORS middleware."""
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=settings.cors_allow_credentials,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=[
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"Accept",
|
||||||
|
"X-Request-ID",
|
||||||
|
"X-Requested-With",
|
||||||
|
],
|
||||||
|
expose_headers=[
|
||||||
|
"X-Request-ID",
|
||||||
|
"X-Process-Time",
|
||||||
|
"X-RateLimit-Limit",
|
||||||
|
"X-RateLimit-Remaining",
|
||||||
|
"X-RateLimit-Reset",
|
||||||
|
],
|
||||||
|
max_age=600,
|
||||||
|
)
|
||||||
29
docengine/app/middleware/metrics.py
Normal file
29
docengine/app/middleware/metrics.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def setup_metrics(app: FastAPI) -> None:
|
||||||
|
"""Configure Prometheus metrics instrumentation."""
|
||||||
|
if not settings.prometheus_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
instrumentator = Instrumentator(
|
||||||
|
should_group_status_codes=True,
|
||||||
|
should_ignore_untemplated=True,
|
||||||
|
should_respect_env_var=False,
|
||||||
|
excluded_handlers=["/metrics", "/api/v1/health", "/docs", "/openapi.json"],
|
||||||
|
env_var_name="PROMETHEUS_ENABLED",
|
||||||
|
inprogress_name="docengine_inprogress_requests",
|
||||||
|
inprogress_labels=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
instrumentator.instrument(app).expose(
|
||||||
|
app,
|
||||||
|
endpoint="/metrics",
|
||||||
|
include_in_schema=False,
|
||||||
|
should_gzip=True,
|
||||||
|
)
|
||||||
72
docengine/app/middleware/rate_limit.py
Normal file
72
docengine/app/middleware/rate_limit.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from fastapi import Request, Response, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Token bucket rate limiter per client IP."""
|
||||||
|
|
||||||
|
EXCLUDED_PATHS = {"/api/v1/health", "/metrics", "/docs", "/openapi.json", "/redoc"}
|
||||||
|
|
||||||
|
def __init__(self, app, max_requests: int | None = None, window_seconds: int | None = None) -> None: # noqa: ANN001
|
||||||
|
super().__init__(app)
|
||||||
|
self.max_requests = max_requests or settings.rate_limit_requests
|
||||||
|
self.window_seconds = window_seconds or settings.rate_limit_window_seconds
|
||||||
|
self._requests: dict[str, list[float]] = defaultdict(list)
|
||||||
|
|
||||||
|
def _clean_old_requests(self, client_ip: str, now: float) -> None:
|
||||||
|
"""Remove requests outside the current window."""
|
||||||
|
cutoff = now - self.window_seconds
|
||||||
|
self._requests[client_ip] = [
|
||||||
|
ts for ts in self._requests[client_ip] if ts > cutoff
|
||||||
|
]
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
if request.url.path in self.EXCLUDED_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
self._clean_old_requests(client_ip, now)
|
||||||
|
|
||||||
|
if len(self._requests[client_ip]) >= self.max_requests:
|
||||||
|
logger.warning(
|
||||||
|
"rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
path=request.url.path,
|
||||||
|
request_count=len(self._requests[client_ip]),
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
content={
|
||||||
|
"detail": "Rate limit exceeded. Please try again later.",
|
||||||
|
"retry_after_seconds": self.window_seconds,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Retry-After": str(self.window_seconds),
|
||||||
|
"X-RateLimit-Limit": str(self.max_requests),
|
||||||
|
"X-RateLimit-Remaining": "0",
|
||||||
|
"X-RateLimit-Reset": str(int(now + self.window_seconds)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._requests[client_ip].append(now)
|
||||||
|
remaining = self.max_requests - len(self._requests[client_ip])
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||||
|
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||||
|
response.headers["X-RateLimit-Reset"] = str(int(now + self.window_seconds))
|
||||||
|
|
||||||
|
return response
|
||||||
43
docengine/app/models/__init__.py
Normal file
43
docengine/app/models/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from app.models.user import AuditLog, RefreshToken, Role, User, user_roles_table
|
||||||
|
from app.models.document import (
|
||||||
|
Document,
|
||||||
|
DocumentImage,
|
||||||
|
DocumentPage,
|
||||||
|
DocumentTable,
|
||||||
|
DocumentTextBlock,
|
||||||
|
TemplateMatch,
|
||||||
|
)
|
||||||
|
from app.models.template import (
|
||||||
|
DocumentCell,
|
||||||
|
DocumentFormat,
|
||||||
|
DocumentRegion,
|
||||||
|
ImageRegion,
|
||||||
|
TableColumn,
|
||||||
|
TableFormat,
|
||||||
|
TableRow,
|
||||||
|
TemplateFingerprint,
|
||||||
|
Watermark,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Role",
|
||||||
|
"RefreshToken",
|
||||||
|
"AuditLog",
|
||||||
|
"user_roles_table",
|
||||||
|
"Document",
|
||||||
|
"DocumentPage",
|
||||||
|
"DocumentTextBlock",
|
||||||
|
"DocumentImage",
|
||||||
|
"DocumentTable",
|
||||||
|
"TemplateMatch",
|
||||||
|
"DocumentFormat",
|
||||||
|
"DocumentCell",
|
||||||
|
"DocumentRegion",
|
||||||
|
"TableFormat",
|
||||||
|
"TableColumn",
|
||||||
|
"TableRow",
|
||||||
|
"Watermark",
|
||||||
|
"ImageRegion",
|
||||||
|
"TemplateFingerprint",
|
||||||
|
]
|
||||||
39
docengine/app/models/base.py
Normal file
39
docengine/app/models/base.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""Mixin providing created_at and updated_at timestamps."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKeyMixin:
|
||||||
|
"""Mixin providing a UUID primary key."""
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
244
docengine/app/models/document.py
Normal file
244
docengine/app/models/document.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Document(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
"""Uploaded document record."""
|
||||||
|
|
||||||
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
content_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
checksum: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
default="pending",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
page_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
is_scanned: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
document_metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
uploaded_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pages: Mapped[list[DocumentPage]] = relationship(
|
||||||
|
"DocumentPage",
|
||||||
|
back_populates="document",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="DocumentPage.page_number",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
template_matches: Mapped[list[TemplateMatch]] = relationship(
|
||||||
|
"TemplateMatch",
|
||||||
|
back_populates="document",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Document(id={self.id}, filename={self.original_filename}, status={self.status})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPage(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Individual page within a document."""
|
||||||
|
|
||||||
|
__tablename__ = "document_pages"
|
||||||
|
|
||||||
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
page_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
image_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
text_content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
document: Mapped[Document] = relationship("Document", back_populates="pages")
|
||||||
|
text_blocks: Mapped[list[DocumentTextBlock]] = relationship(
|
||||||
|
"DocumentTextBlock",
|
||||||
|
back_populates="page",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="DocumentTextBlock.sequence",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
images: Mapped[list[DocumentImage]] = relationship(
|
||||||
|
"DocumentImage",
|
||||||
|
back_populates="page",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
tables: Mapped[list[DocumentTable]] = relationship(
|
||||||
|
"DocumentTable",
|
||||||
|
back_populates="page",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DocumentPage(id={self.id}, document_id={self.document_id}, page={self.page_number})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTextBlock(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Extracted text block from a document page."""
|
||||||
|
|
||||||
|
__tablename__ = "document_text_blocks"
|
||||||
|
|
||||||
|
page_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_pages.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
font_family: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
font_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
font_color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
font_style: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
block_type: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
default="text",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
page: Mapped[DocumentPage] = relationship("DocumentPage", back_populates="text_blocks")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DocumentTextBlock(id={self.id}, type={self.block_type}, text={self.text[:50]})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentImage(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Extracted image from a document page."""
|
||||||
|
|
||||||
|
__tablename__ = "document_images"
|
||||||
|
|
||||||
|
page_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_pages.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
image_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
image_type: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
default="figure",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
page: Mapped[DocumentPage] = relationship("DocumentPage", back_populates="images")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DocumentImage(id={self.id}, type={self.image_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTable(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Extracted table from a document page."""
|
||||||
|
|
||||||
|
__tablename__ = "document_tables"
|
||||||
|
|
||||||
|
page_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_pages.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
rows: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
columns: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
page: Mapped[DocumentPage] = relationship("DocumentPage", back_populates="tables")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DocumentTable(id={self.id}, rows={self.rows}, cols={self.columns})>"
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateMatch(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Template matching result for a document."""
|
||||||
|
|
||||||
|
__tablename__ = "template_matches"
|
||||||
|
|
||||||
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
confidence_score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
match_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
selected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
document: Mapped[Document] = relationship("Document", back_populates="template_matches")
|
||||||
|
template: Mapped[DocumentFormat] = relationship("DocumentFormat")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TemplateMatch(id={self.id}, doc={self.document_id}, score={self.confidence_score})>"
|
||||||
378
docengine/app/models/template.py
Normal file
378
docengine/app/models/template.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentFormat(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
"""Reusable document template format."""
|
||||||
|
|
||||||
|
__tablename__ = "document_formats"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
page_width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
page_height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
page_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
margin_top: Mapped[float] = mapped_column(Float, default=72.0, nullable=False)
|
||||||
|
margin_right: Mapped[float] = mapped_column(Float, default=72.0, nullable=False)
|
||||||
|
margin_bottom: Mapped[float] = mapped_column(Float, default=72.0, nullable=False)
|
||||||
|
margin_left: Mapped[float] = mapped_column(Float, default=72.0, nullable=False)
|
||||||
|
fingerprint: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
source_document_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("documents.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cells: Mapped[list[DocumentCell]] = relationship(
|
||||||
|
"DocumentCell",
|
||||||
|
back_populates="format",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="DocumentCell.sequence",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
regions: Mapped[list[DocumentRegion]] = relationship(
|
||||||
|
"DocumentRegion",
|
||||||
|
back_populates="format",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="DocumentRegion.sequence",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
table_formats: Mapped[list[TableFormat]] = relationship(
|
||||||
|
"TableFormat",
|
||||||
|
back_populates="format",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
watermarks: Mapped[list[Watermark]] = relationship(
|
||||||
|
"Watermark",
|
||||||
|
back_populates="format",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
image_regions: Mapped[list[ImageRegion]] = relationship(
|
||||||
|
"ImageRegion",
|
||||||
|
back_populates="format",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
fingerprint_record: Mapped[TemplateFingerprint | None] = relationship(
|
||||||
|
"TemplateFingerprint",
|
||||||
|
back_populates="format",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DocumentFormat(id={self.id}, name={self.name}, v{self.version})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCell(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Cell definition within a document template."""
|
||||||
|
|
||||||
|
__tablename__ = "document_cells"
|
||||||
|
|
||||||
|
format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
page_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
row_no: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
column_no: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
data_type: Mapped[str] = mapped_column(String(50), default="text", nullable=False)
|
||||||
|
font_family: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
font_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
font_style: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
font_color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
background_color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
border_top: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
border_right: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
border_bottom: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
border_left: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
padding_top: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||||
|
padding_right: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||||
|
padding_bottom: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||||
|
padding_left: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||||
|
alignment: Mapped[str] = mapped_column(String(20), default="left", nullable=False)
|
||||||
|
vertical_alignment: Mapped[str] = mapped_column(String(20), default="top", nullable=False)
|
||||||
|
rowspan: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
colspan: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
static_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
field_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
is_dynamic: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
format: Mapped[DocumentFormat] = relationship("DocumentFormat", back_populates="cells")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DocumentCell(id={self.id}, page={self.page_number}, row={self.row_no}, col={self.column_no})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRegion(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Region definition within a document template."""
|
||||||
|
|
||||||
|
__tablename__ = "document_regions"
|
||||||
|
|
||||||
|
format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
page_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
region_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
content: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
format: Mapped[DocumentFormat] = relationship("DocumentFormat", back_populates="regions")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DocumentRegion(id={self.id}, type={self.region_type}, page={self.page_number})>"
|
||||||
|
|
||||||
|
|
||||||
|
class TableFormat(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Table definition within a document template."""
|
||||||
|
|
||||||
|
__tablename__ = "table_formats"
|
||||||
|
|
||||||
|
format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
page_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
rows: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
columns: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
border_style: Mapped[str] = mapped_column(String(50), default="solid", nullable=False)
|
||||||
|
border_width: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
|
||||||
|
border_color: Mapped[str] = mapped_column(String(50), default="#000000", nullable=False)
|
||||||
|
header_rows: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
format: Mapped[DocumentFormat] = relationship("DocumentFormat", back_populates="table_formats")
|
||||||
|
table_columns: Mapped[list[TableColumn]] = relationship(
|
||||||
|
"TableColumn",
|
||||||
|
back_populates="table_format",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="TableColumn.column_index",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
table_rows: Mapped[list[TableRow]] = relationship(
|
||||||
|
"TableRow",
|
||||||
|
back_populates="table_format",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="TableRow.row_index",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TableFormat(id={self.id}, rows={self.rows}, cols={self.columns})>"
|
||||||
|
|
||||||
|
|
||||||
|
class TableColumn(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Column definition within a table format."""
|
||||||
|
|
||||||
|
__tablename__ = "table_columns"
|
||||||
|
|
||||||
|
table_format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("table_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
column_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
header_text: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
data_type: Mapped[str] = mapped_column(String(50), default="text", nullable=False)
|
||||||
|
alignment: Mapped[str] = mapped_column(String(20), default="left", nullable=False)
|
||||||
|
font_family: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
font_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
table_format: Mapped[TableFormat] = relationship("TableFormat", back_populates="table_columns")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TableColumn(id={self.id}, index={self.column_index}, header={self.header_text})>"
|
||||||
|
|
||||||
|
|
||||||
|
class TableRow(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Row definition within a table format."""
|
||||||
|
|
||||||
|
__tablename__ = "table_rows"
|
||||||
|
|
||||||
|
table_format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("table_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
row_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, default=20.0, nullable=False)
|
||||||
|
is_header: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
background_color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
table_format: Mapped[TableFormat] = relationship("TableFormat", back_populates="table_rows")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TableRow(id={self.id}, index={self.row_index}, is_header={self.is_header})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Watermark(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Watermark definition within a document template."""
|
||||||
|
|
||||||
|
__tablename__ = "watermarks"
|
||||||
|
|
||||||
|
format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
page_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
text: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
image_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
opacity: Mapped[float] = mapped_column(Float, default=0.3, nullable=False)
|
||||||
|
rotation: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||||
|
font_family: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
font_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
font_color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
format: Mapped[DocumentFormat] = relationship("DocumentFormat", back_populates="watermarks")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Watermark(id={self.id}, text={self.text})>"
|
||||||
|
|
||||||
|
|
||||||
|
class ImageRegion(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Image region within a document template."""
|
||||||
|
|
||||||
|
__tablename__ = "image_regions"
|
||||||
|
|
||||||
|
format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
page_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
width: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
height: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
image_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
image_type: Mapped[str] = mapped_column(String(50), default="figure", nullable=False)
|
||||||
|
is_static: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
field_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
format: Mapped[DocumentFormat] = relationship("DocumentFormat", back_populates="image_regions")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ImageRegion(id={self.id}, type={self.image_type}, page={self.page_number})>"
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateFingerprint(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Layout fingerprint for template matching."""
|
||||||
|
|
||||||
|
__tablename__ = "template_fingerprints"
|
||||||
|
|
||||||
|
format_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("document_formats.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
page_dimensions: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
logo_coordinates: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
header_coordinates: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
footer_coordinates: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
table_coordinates: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
cell_coordinates: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
fingerprint_hash: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
format: Mapped[DocumentFormat] = relationship("DocumentFormat", back_populates="fingerprint_record")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TemplateFingerprint(id={self.id}, format_id={self.format_id}, hash={self.fingerprint_hash[:16]})>"
|
||||||
136
docengine/app/models/user.py
Normal file
136
docengine/app/models/user.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Table, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
user_roles_table = Table(
|
||||||
|
"user_roles",
|
||||||
|
Base.metadata,
|
||||||
|
mapped_column("user_id", UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
mapped_column("role_id", UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
"""User account model."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
username: Mapped[str] = mapped_column(String(150), unique=True, nullable=False, index=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
roles: Mapped[list[Role]] = relationship(
|
||||||
|
"Role",
|
||||||
|
secondary=user_roles_table,
|
||||||
|
back_populates="users",
|
||||||
|
lazy="joined",
|
||||||
|
)
|
||||||
|
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
||||||
|
"RefreshToken",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="dynamic",
|
||||||
|
)
|
||||||
|
audit_logs: Mapped[list[AuditLog]] = relationship(
|
||||||
|
"AuditLog",
|
||||||
|
back_populates="user",
|
||||||
|
lazy="dynamic",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User(id={self.id}, username={self.username})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""User role model."""
|
||||||
|
|
||||||
|
__tablename__ = "roles"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
users: Mapped[list[User]] = relationship(
|
||||||
|
"User",
|
||||||
|
secondary=user_roles_table,
|
||||||
|
back_populates="roles",
|
||||||
|
lazy="dynamic",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Role(id={self.id}, name={self.name})>"
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""JWT refresh token storage."""
|
||||||
|
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
token: Mapped[str] = mapped_column(String(512), unique=True, nullable=False, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship("User", back_populates="refresh_tokens")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<RefreshToken(id={self.id}, user_id={self.user_id}, revoked={self.revoked})>"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base, UUIDPrimaryKeyMixin):
|
||||||
|
"""Audit trail for user actions."""
|
||||||
|
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
details: Mapped[dict | None] = mapped_column(type_=Text, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=func.now(),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped[User | None] = relationship("User", back_populates="audit_logs")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<AuditLog(id={self.id}, action={self.action}, resource={self.resource_type})>"
|
||||||
0
docengine/app/repositories/__init__.py
Normal file
0
docengine/app/repositories/__init__.py
Normal file
114
docengine/app/repositories/base.py
Normal file
114
docengine/app/repositories/base.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
ModelType = TypeVar("ModelType", bound=Base)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRepository(Generic[ModelType]):
|
||||||
|
"""Base repository with common CRUD operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session, model: type[ModelType]) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
def get_by_id(self, entity_id: str | uuid.UUID) -> ModelType | None:
|
||||||
|
"""Get an entity by its primary key."""
|
||||||
|
if isinstance(entity_id, str):
|
||||||
|
entity_id = uuid.UUID(entity_id)
|
||||||
|
return self.db.get(self.model, entity_id)
|
||||||
|
|
||||||
|
def get_all(
|
||||||
|
self,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
filters: dict[str, Any] | None = None,
|
||||||
|
order_by: str | None = None,
|
||||||
|
order_desc: bool = False,
|
||||||
|
) -> list[ModelType]:
|
||||||
|
"""Get all entities with optional filtering, pagination, and ordering."""
|
||||||
|
query = select(self.model)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
for key, value in filters.items():
|
||||||
|
if hasattr(self.model, key) and value is not None:
|
||||||
|
query = query.where(getattr(self.model, key) == value)
|
||||||
|
|
||||||
|
if order_by and hasattr(self.model, order_by):
|
||||||
|
col = getattr(self.model, order_by)
|
||||||
|
query = query.order_by(col.desc() if order_desc else col.asc())
|
||||||
|
|
||||||
|
query = query.offset(offset).limit(limit)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def count(self, filters: dict[str, Any] | None = None) -> int:
|
||||||
|
"""Count entities with optional filtering."""
|
||||||
|
query = select(func.count()).select_from(self.model)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
for key, value in filters.items():
|
||||||
|
if hasattr(self.model, key) and value is not None:
|
||||||
|
query = query.where(getattr(self.model, key) == value)
|
||||||
|
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
def create(self, entity: ModelType) -> ModelType:
|
||||||
|
"""Create a new entity."""
|
||||||
|
self.db.add(entity)
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def create_many(self, entities: list[ModelType]) -> list[ModelType]:
|
||||||
|
"""Create multiple entities."""
|
||||||
|
self.db.add_all(entities)
|
||||||
|
self.db.flush()
|
||||||
|
for entity in entities:
|
||||||
|
self.db.refresh(entity)
|
||||||
|
return entities
|
||||||
|
|
||||||
|
def update(self, entity: ModelType, update_data: dict[str, Any]) -> ModelType:
|
||||||
|
"""Update an entity with given data."""
|
||||||
|
for key, value in update_data.items():
|
||||||
|
if hasattr(entity, key) and value is not None:
|
||||||
|
setattr(entity, key, value)
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def delete(self, entity: ModelType) -> None:
|
||||||
|
"""Delete an entity."""
|
||||||
|
self.db.delete(entity)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
def delete_by_id(self, entity_id: str | uuid.UUID) -> bool:
|
||||||
|
"""Delete an entity by its ID. Returns True if deleted."""
|
||||||
|
entity = self.get_by_id(entity_id)
|
||||||
|
if entity:
|
||||||
|
self.delete(entity)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def exists(self, entity_id: str | uuid.UUID) -> bool:
|
||||||
|
"""Check if an entity exists by ID."""
|
||||||
|
if isinstance(entity_id, str):
|
||||||
|
entity_id = uuid.UUID(entity_id)
|
||||||
|
query = select(func.count()).select_from(self.model).where(self.model.id == entity_id)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalar_one() > 0
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
"""Commit the current transaction."""
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
"""Rollback the current transaction."""
|
||||||
|
self.db.rollback()
|
||||||
335
docengine/app/repositories/document_repository.py
Normal file
335
docengine/app/repositories/document_repository.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.document import (
|
||||||
|
Document,
|
||||||
|
DocumentImage,
|
||||||
|
DocumentPage,
|
||||||
|
DocumentTable,
|
||||||
|
DocumentTextBlock,
|
||||||
|
TemplateMatch,
|
||||||
|
)
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRepository(BaseRepository[Document]):
|
||||||
|
"""Repository for Document operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, Document)
|
||||||
|
|
||||||
|
def get_by_checksum(self, checksum: str) -> Document | None:
|
||||||
|
"""Get document by file checksum."""
|
||||||
|
query = select(Document).where(Document.checksum == checksum)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def get_by_status(self, status: str, offset: int = 0, limit: int = 100) -> list[Document]:
|
||||||
|
"""Get documents by processing status."""
|
||||||
|
query = (
|
||||||
|
select(Document)
|
||||||
|
.where(Document.status == status)
|
||||||
|
.order_by(Document.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_user_documents(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[Document]:
|
||||||
|
"""Get documents uploaded by a specific user."""
|
||||||
|
query = (
|
||||||
|
select(Document)
|
||||||
|
.where(Document.uploaded_by == user_id)
|
||||||
|
.order_by(Document.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
status: str,
|
||||||
|
error_message: str | None = None,
|
||||||
|
) -> Document | None:
|
||||||
|
"""Update document processing status."""
|
||||||
|
document = self.get_by_id(document_id)
|
||||||
|
if document:
|
||||||
|
document.status = status
|
||||||
|
if error_message:
|
||||||
|
document.error_message = error_message
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(document)
|
||||||
|
return document
|
||||||
|
|
||||||
|
def get_with_pages(self, document_id: uuid.UUID) -> Document | None:
|
||||||
|
"""Get document with all pages eagerly loaded."""
|
||||||
|
return self.get_by_id(document_id)
|
||||||
|
|
||||||
|
def get_pending_documents(self, limit: int = 10) -> list[Document]:
|
||||||
|
"""Get pending documents for processing."""
|
||||||
|
return self.get_by_status("pending", limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPageRepository(BaseRepository[DocumentPage]):
|
||||||
|
"""Repository for DocumentPage operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, DocumentPage)
|
||||||
|
|
||||||
|
def get_document_pages(self, document_id: uuid.UUID) -> list[DocumentPage]:
|
||||||
|
"""Get all pages for a document ordered by page number."""
|
||||||
|
query = (
|
||||||
|
select(DocumentPage)
|
||||||
|
.where(DocumentPage.document_id == document_id)
|
||||||
|
.order_by(DocumentPage.page_number)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_page_by_number(self, document_id: uuid.UUID, page_number: int) -> DocumentPage | None:
|
||||||
|
"""Get a specific page by document ID and page number."""
|
||||||
|
query = select(DocumentPage).where(
|
||||||
|
DocumentPage.document_id == document_id,
|
||||||
|
DocumentPage.page_number == page_number,
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def create_page(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
page_number: int,
|
||||||
|
width: float,
|
||||||
|
height: float,
|
||||||
|
image_path: str | None = None,
|
||||||
|
text_content: str | None = None,
|
||||||
|
) -> DocumentPage:
|
||||||
|
"""Create a new document page."""
|
||||||
|
page = DocumentPage(
|
||||||
|
document_id=document_id,
|
||||||
|
page_number=page_number,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
image_path=image_path,
|
||||||
|
text_content=text_content,
|
||||||
|
)
|
||||||
|
return self.create(page)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTextBlockRepository(BaseRepository[DocumentTextBlock]):
|
||||||
|
"""Repository for DocumentTextBlock operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, DocumentTextBlock)
|
||||||
|
|
||||||
|
def get_page_text_blocks(self, page_id: uuid.UUID) -> list[DocumentTextBlock]:
|
||||||
|
"""Get all text blocks for a page."""
|
||||||
|
query = (
|
||||||
|
select(DocumentTextBlock)
|
||||||
|
.where(DocumentTextBlock.page_id == page_id)
|
||||||
|
.order_by(DocumentTextBlock.sequence)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_by_block_type(self, page_id: uuid.UUID, block_type: str) -> list[DocumentTextBlock]:
|
||||||
|
"""Get text blocks by type (header, footer, watermark, text)."""
|
||||||
|
query = (
|
||||||
|
select(DocumentTextBlock)
|
||||||
|
.where(
|
||||||
|
DocumentTextBlock.page_id == page_id,
|
||||||
|
DocumentTextBlock.block_type == block_type,
|
||||||
|
)
|
||||||
|
.order_by(DocumentTextBlock.sequence)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_text_block(
|
||||||
|
self,
|
||||||
|
page_id: uuid.UUID,
|
||||||
|
text: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
width: float,
|
||||||
|
height: float,
|
||||||
|
confidence: float | None = None,
|
||||||
|
font_family: str | None = None,
|
||||||
|
font_size: float | None = None,
|
||||||
|
font_color: str | None = None,
|
||||||
|
font_style: str | None = None,
|
||||||
|
block_type: str = "text",
|
||||||
|
sequence: int = 0,
|
||||||
|
) -> DocumentTextBlock:
|
||||||
|
"""Create a new text block."""
|
||||||
|
text_block = DocumentTextBlock(
|
||||||
|
page_id=page_id,
|
||||||
|
text=text,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
confidence=confidence,
|
||||||
|
font_family=font_family,
|
||||||
|
font_size=font_size,
|
||||||
|
font_color=font_color,
|
||||||
|
font_style=font_style,
|
||||||
|
block_type=block_type,
|
||||||
|
sequence=sequence,
|
||||||
|
)
|
||||||
|
return self.create(text_block)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentImageRepository(BaseRepository[DocumentImage]):
|
||||||
|
"""Repository for DocumentImage operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, DocumentImage)
|
||||||
|
|
||||||
|
def get_page_images(self, page_id: uuid.UUID) -> list[DocumentImage]:
|
||||||
|
"""Get all images for a page."""
|
||||||
|
query = select(DocumentImage).where(DocumentImage.page_id == page_id)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_image(
|
||||||
|
self,
|
||||||
|
page_id: uuid.UUID,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
width: float,
|
||||||
|
height: float,
|
||||||
|
image_path: str,
|
||||||
|
image_type: str = "figure",
|
||||||
|
) -> DocumentImage:
|
||||||
|
"""Create a new document image record."""
|
||||||
|
image = DocumentImage(
|
||||||
|
page_id=page_id,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
image_path=image_path,
|
||||||
|
image_type=image_type,
|
||||||
|
)
|
||||||
|
return self.create(image)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTableRepository(BaseRepository[DocumentTable]):
|
||||||
|
"""Repository for DocumentTable operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, DocumentTable)
|
||||||
|
|
||||||
|
def get_page_tables(self, page_id: uuid.UUID) -> list[DocumentTable]:
|
||||||
|
"""Get all tables for a page."""
|
||||||
|
query = select(DocumentTable).where(DocumentTable.page_id == page_id)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_table(
|
||||||
|
self,
|
||||||
|
page_id: uuid.UUID,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
width: float,
|
||||||
|
height: float,
|
||||||
|
rows: int,
|
||||||
|
columns: int,
|
||||||
|
data: dict | None = None,
|
||||||
|
) -> DocumentTable:
|
||||||
|
"""Create a new document table record."""
|
||||||
|
table = DocumentTable(
|
||||||
|
page_id=page_id,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
rows=rows,
|
||||||
|
columns=columns,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
return self.create(table)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateMatchRepository(BaseRepository[TemplateMatch]):
|
||||||
|
"""Repository for TemplateMatch operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, TemplateMatch)
|
||||||
|
|
||||||
|
def get_document_matches(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
min_confidence: float = 0.0,
|
||||||
|
) -> list[TemplateMatch]:
|
||||||
|
"""Get all template matches for a document."""
|
||||||
|
query = (
|
||||||
|
select(TemplateMatch)
|
||||||
|
.where(
|
||||||
|
TemplateMatch.document_id == document_id,
|
||||||
|
TemplateMatch.confidence_score >= min_confidence,
|
||||||
|
)
|
||||||
|
.order_by(TemplateMatch.confidence_score.desc())
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_selected_match(self, document_id: uuid.UUID) -> TemplateMatch | None:
|
||||||
|
"""Get the selected template match for a document."""
|
||||||
|
query = select(TemplateMatch).where(
|
||||||
|
TemplateMatch.document_id == document_id,
|
||||||
|
TemplateMatch.selected.is_(True),
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def select_match(self, match_id: uuid.UUID) -> TemplateMatch | None:
|
||||||
|
"""Select a template match (deselecting all others for the same document)."""
|
||||||
|
match = self.get_by_id(match_id)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Deselect all other matches for this document
|
||||||
|
query = select(TemplateMatch).where(
|
||||||
|
TemplateMatch.document_id == match.document_id,
|
||||||
|
TemplateMatch.selected.is_(True),
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
for existing_match in result.scalars().all():
|
||||||
|
existing_match.selected = False
|
||||||
|
|
||||||
|
match.selected = True
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(match)
|
||||||
|
return match
|
||||||
|
|
||||||
|
def create_match(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
format_id: uuid.UUID,
|
||||||
|
confidence_score: float,
|
||||||
|
match_details: dict | None = None,
|
||||||
|
selected: bool = False,
|
||||||
|
) -> TemplateMatch:
|
||||||
|
"""Create a new template match."""
|
||||||
|
template_match = TemplateMatch(
|
||||||
|
document_id=document_id,
|
||||||
|
format_id=format_id,
|
||||||
|
confidence_score=confidence_score,
|
||||||
|
match_details=match_details,
|
||||||
|
selected=selected,
|
||||||
|
)
|
||||||
|
return self.create(template_match)
|
||||||
353
docengine/app/repositories/template_repository.py
Normal file
353
docengine/app/repositories/template_repository.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.template import (
|
||||||
|
DocumentCell,
|
||||||
|
DocumentFormat,
|
||||||
|
DocumentRegion,
|
||||||
|
ImageRegion,
|
||||||
|
TableColumn,
|
||||||
|
TableFormat,
|
||||||
|
TableRow,
|
||||||
|
TemplateFingerprint,
|
||||||
|
Watermark,
|
||||||
|
)
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRepository(BaseRepository[DocumentFormat]):
|
||||||
|
"""Repository for DocumentFormat (template) operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, DocumentFormat)
|
||||||
|
|
||||||
|
def get_active_templates(self, offset: int = 0, limit: int = 100) -> list[DocumentFormat]:
|
||||||
|
"""Get all active templates."""
|
||||||
|
query = (
|
||||||
|
select(DocumentFormat)
|
||||||
|
.where(DocumentFormat.is_active.is_(True))
|
||||||
|
.order_by(DocumentFormat.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def count_active(self) -> int:
|
||||||
|
"""Count active templates."""
|
||||||
|
return self.count(filters={"is_active": True})
|
||||||
|
|
||||||
|
def get_by_name(self, name: str) -> DocumentFormat | None:
|
||||||
|
"""Get template by name."""
|
||||||
|
query = select(DocumentFormat).where(DocumentFormat.name == name)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def get_by_source_document(self, document_id: uuid.UUID) -> DocumentFormat | None:
|
||||||
|
"""Get template generated from a specific source document."""
|
||||||
|
query = select(DocumentFormat).where(
|
||||||
|
DocumentFormat.source_document_id == document_id,
|
||||||
|
DocumentFormat.is_active.is_(True),
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def create_template(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
page_width: float,
|
||||||
|
page_height: float,
|
||||||
|
page_count: int = 1,
|
||||||
|
description: str | None = None,
|
||||||
|
margin_top: float = 72.0,
|
||||||
|
margin_right: float = 72.0,
|
||||||
|
margin_bottom: float = 72.0,
|
||||||
|
margin_left: float = 72.0,
|
||||||
|
fingerprint: dict | None = None,
|
||||||
|
source_document_id: uuid.UUID | None = None,
|
||||||
|
created_by: uuid.UUID | None = None,
|
||||||
|
) -> DocumentFormat:
|
||||||
|
"""Create a new template."""
|
||||||
|
template = DocumentFormat(
|
||||||
|
name=name,
|
||||||
|
page_width=page_width,
|
||||||
|
page_height=page_height,
|
||||||
|
page_count=page_count,
|
||||||
|
description=description,
|
||||||
|
margin_top=margin_top,
|
||||||
|
margin_right=margin_right,
|
||||||
|
margin_bottom=margin_bottom,
|
||||||
|
margin_left=margin_left,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
source_document_id=source_document_id,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
return self.create(template)
|
||||||
|
|
||||||
|
def deactivate_template(self, template_id: uuid.UUID) -> DocumentFormat | None:
|
||||||
|
"""Soft-delete a template by deactivating it."""
|
||||||
|
template = self.get_by_id(template_id)
|
||||||
|
if template:
|
||||||
|
template.is_active = False
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(template)
|
||||||
|
return template
|
||||||
|
|
||||||
|
def get_all_with_fingerprints(self) -> list[DocumentFormat]:
|
||||||
|
"""Get all active templates with their fingerprints."""
|
||||||
|
query = (
|
||||||
|
select(DocumentFormat)
|
||||||
|
.where(DocumentFormat.is_active.is_(True))
|
||||||
|
.order_by(DocumentFormat.created_at.desc())
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCellRepository(BaseRepository[DocumentCell]):
|
||||||
|
"""Repository for DocumentCell operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, DocumentCell)
|
||||||
|
|
||||||
|
def get_template_cells(self, format_id: uuid.UUID) -> list[DocumentCell]:
|
||||||
|
"""Get all cells for a template."""
|
||||||
|
query = (
|
||||||
|
select(DocumentCell)
|
||||||
|
.where(DocumentCell.format_id == format_id)
|
||||||
|
.order_by(DocumentCell.page_number, DocumentCell.sequence)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_page_cells(self, format_id: uuid.UUID, page_number: int) -> list[DocumentCell]:
|
||||||
|
"""Get cells for a specific page of a template."""
|
||||||
|
query = (
|
||||||
|
select(DocumentCell)
|
||||||
|
.where(
|
||||||
|
DocumentCell.format_id == format_id,
|
||||||
|
DocumentCell.page_number == page_number,
|
||||||
|
)
|
||||||
|
.order_by(DocumentCell.sequence)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_dynamic_cells(self, format_id: uuid.UUID) -> list[DocumentCell]:
|
||||||
|
"""Get all dynamic cells for a template."""
|
||||||
|
query = (
|
||||||
|
select(DocumentCell)
|
||||||
|
.where(
|
||||||
|
DocumentCell.format_id == format_id,
|
||||||
|
DocumentCell.is_dynamic.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(DocumentCell.page_number, DocumentCell.sequence)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_cell(self, format_id: uuid.UUID, **kwargs) -> DocumentCell: # noqa: ANN003
|
||||||
|
"""Create a new cell for a template."""
|
||||||
|
cell = DocumentCell(format_id=format_id, **kwargs)
|
||||||
|
return self.create(cell)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRegionRepository(BaseRepository[DocumentRegion]):
|
||||||
|
"""Repository for DocumentRegion operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, DocumentRegion)
|
||||||
|
|
||||||
|
def get_template_regions(self, format_id: uuid.UUID) -> list[DocumentRegion]:
|
||||||
|
"""Get all regions for a template."""
|
||||||
|
query = (
|
||||||
|
select(DocumentRegion)
|
||||||
|
.where(DocumentRegion.format_id == format_id)
|
||||||
|
.order_by(DocumentRegion.page_number, DocumentRegion.sequence)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_regions_by_type(self, format_id: uuid.UUID, region_type: str) -> list[DocumentRegion]:
|
||||||
|
"""Get regions of a specific type."""
|
||||||
|
query = (
|
||||||
|
select(DocumentRegion)
|
||||||
|
.where(
|
||||||
|
DocumentRegion.format_id == format_id,
|
||||||
|
DocumentRegion.region_type == region_type,
|
||||||
|
)
|
||||||
|
.order_by(DocumentRegion.page_number, DocumentRegion.sequence)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_region(self, format_id: uuid.UUID, **kwargs) -> DocumentRegion: # noqa: ANN003
|
||||||
|
"""Create a new region for a template."""
|
||||||
|
region = DocumentRegion(format_id=format_id, **kwargs)
|
||||||
|
return self.create(region)
|
||||||
|
|
||||||
|
|
||||||
|
class TableFormatRepository(BaseRepository[TableFormat]):
|
||||||
|
"""Repository for TableFormat operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, TableFormat)
|
||||||
|
|
||||||
|
def get_template_tables(self, format_id: uuid.UUID) -> list[TableFormat]:
|
||||||
|
"""Get all table formats for a template."""
|
||||||
|
query = (
|
||||||
|
select(TableFormat)
|
||||||
|
.where(TableFormat.format_id == format_id)
|
||||||
|
.order_by(TableFormat.page_number)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_table_format(self, format_id: uuid.UUID, **kwargs) -> TableFormat: # noqa: ANN003
|
||||||
|
"""Create a new table format."""
|
||||||
|
table_format = TableFormat(format_id=format_id, **kwargs)
|
||||||
|
return self.create(table_format)
|
||||||
|
|
||||||
|
|
||||||
|
class TableColumnRepository(BaseRepository[TableColumn]):
|
||||||
|
"""Repository for TableColumn operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, TableColumn)
|
||||||
|
|
||||||
|
def get_table_columns(self, table_format_id: uuid.UUID) -> list[TableColumn]:
|
||||||
|
"""Get all columns for a table format."""
|
||||||
|
query = (
|
||||||
|
select(TableColumn)
|
||||||
|
.where(TableColumn.table_format_id == table_format_id)
|
||||||
|
.order_by(TableColumn.column_index)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_column(self, table_format_id: uuid.UUID, **kwargs) -> TableColumn: # noqa: ANN003
|
||||||
|
"""Create a new table column."""
|
||||||
|
column = TableColumn(table_format_id=table_format_id, **kwargs)
|
||||||
|
return self.create(column)
|
||||||
|
|
||||||
|
|
||||||
|
class TableRowRepository(BaseRepository[TableRow]):
|
||||||
|
"""Repository for TableRow operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, TableRow)
|
||||||
|
|
||||||
|
def get_table_rows(self, table_format_id: uuid.UUID) -> list[TableRow]:
|
||||||
|
"""Get all rows for a table format."""
|
||||||
|
query = (
|
||||||
|
select(TableRow)
|
||||||
|
.where(TableRow.table_format_id == table_format_id)
|
||||||
|
.order_by(TableRow.row_index)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_row(self, table_format_id: uuid.UUID, **kwargs) -> TableRow: # noqa: ANN003
|
||||||
|
"""Create a new table row."""
|
||||||
|
row = TableRow(table_format_id=table_format_id, **kwargs)
|
||||||
|
return self.create(row)
|
||||||
|
|
||||||
|
|
||||||
|
class WatermarkRepository(BaseRepository[Watermark]):
|
||||||
|
"""Repository for Watermark operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, Watermark)
|
||||||
|
|
||||||
|
def get_template_watermarks(self, format_id: uuid.UUID) -> list[Watermark]:
|
||||||
|
"""Get all watermarks for a template."""
|
||||||
|
query = select(Watermark).where(Watermark.format_id == format_id)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_watermark(self, format_id: uuid.UUID, **kwargs) -> Watermark: # noqa: ANN003
|
||||||
|
"""Create a new watermark."""
|
||||||
|
watermark = Watermark(format_id=format_id, **kwargs)
|
||||||
|
return self.create(watermark)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageRegionRepository(BaseRepository[ImageRegion]):
|
||||||
|
"""Repository for ImageRegion operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, ImageRegion)
|
||||||
|
|
||||||
|
def get_template_images(self, format_id: uuid.UUID) -> list[ImageRegion]:
|
||||||
|
"""Get all image regions for a template."""
|
||||||
|
query = select(ImageRegion).where(ImageRegion.format_id == format_id)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_static_images(self, format_id: uuid.UUID) -> list[ImageRegion]:
|
||||||
|
"""Get static image regions."""
|
||||||
|
query = select(ImageRegion).where(
|
||||||
|
ImageRegion.format_id == format_id,
|
||||||
|
ImageRegion.is_static.is_(True),
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_image_region(self, format_id: uuid.UUID, **kwargs) -> ImageRegion: # noqa: ANN003
|
||||||
|
"""Create a new image region."""
|
||||||
|
image_region = ImageRegion(format_id=format_id, **kwargs)
|
||||||
|
return self.create(image_region)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateFingerprintRepository(BaseRepository[TemplateFingerprint]):
|
||||||
|
"""Repository for TemplateFingerprint operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, TemplateFingerprint)
|
||||||
|
|
||||||
|
def get_by_format_id(self, format_id: uuid.UUID) -> TemplateFingerprint | None:
|
||||||
|
"""Get fingerprint by template format ID."""
|
||||||
|
query = select(TemplateFingerprint).where(TemplateFingerprint.format_id == format_id)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def get_by_hash(self, fingerprint_hash: str) -> TemplateFingerprint | None:
|
||||||
|
"""Get fingerprint by hash."""
|
||||||
|
query = select(TemplateFingerprint).where(
|
||||||
|
TemplateFingerprint.fingerprint_hash == fingerprint_hash
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def get_all_fingerprints(self) -> list[TemplateFingerprint]:
|
||||||
|
"""Get all fingerprints."""
|
||||||
|
query = select(TemplateFingerprint)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def create_fingerprint(
|
||||||
|
self,
|
||||||
|
format_id: uuid.UUID,
|
||||||
|
fingerprint_hash: str,
|
||||||
|
page_dimensions: dict | None = None,
|
||||||
|
logo_coordinates: dict | None = None,
|
||||||
|
header_coordinates: dict | None = None,
|
||||||
|
footer_coordinates: dict | None = None,
|
||||||
|
table_coordinates: dict | None = None,
|
||||||
|
cell_coordinates: dict | None = None,
|
||||||
|
) -> TemplateFingerprint:
|
||||||
|
"""Create a new template fingerprint."""
|
||||||
|
fp = TemplateFingerprint(
|
||||||
|
format_id=format_id,
|
||||||
|
fingerprint_hash=fingerprint_hash,
|
||||||
|
page_dimensions=page_dimensions,
|
||||||
|
logo_coordinates=logo_coordinates,
|
||||||
|
header_coordinates=header_coordinates,
|
||||||
|
footer_coordinates=footer_coordinates,
|
||||||
|
table_coordinates=table_coordinates,
|
||||||
|
cell_coordinates=cell_coordinates,
|
||||||
|
)
|
||||||
|
return self.create(fp)
|
||||||
236
docengine/app/repositories/user_repository.py
Normal file
236
docengine/app/repositories/user_repository.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.user import AuditLog, RefreshToken, Role, User, user_roles_table
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class UserRepository(BaseRepository[User]):
|
||||||
|
"""Repository for User operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, User)
|
||||||
|
|
||||||
|
def get_by_username(self, username: str) -> User | None:
|
||||||
|
"""Get user by username."""
|
||||||
|
query = select(User).where(User.username == username)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def get_by_email(self, email: str) -> User | None:
|
||||||
|
"""Get user by email."""
|
||||||
|
query = select(User).where(User.email == email)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
email: str,
|
||||||
|
hashed_password: str,
|
||||||
|
full_name: str | None = None,
|
||||||
|
is_active: bool = True,
|
||||||
|
is_superuser: bool = False,
|
||||||
|
role_names: list[str] | None = None,
|
||||||
|
) -> User:
|
||||||
|
"""Create a new user with optional roles."""
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
full_name=full_name,
|
||||||
|
is_active=is_active,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
)
|
||||||
|
|
||||||
|
if role_names:
|
||||||
|
roles = self.get_roles_by_names(role_names)
|
||||||
|
user.roles = roles
|
||||||
|
|
||||||
|
return self.create(user)
|
||||||
|
|
||||||
|
def update_last_login(self, user: User) -> User:
|
||||||
|
"""Update user's last login timestamp."""
|
||||||
|
user.last_login = datetime.now(UTC)
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def get_roles_by_names(self, role_names: list[str]) -> list[Role]:
|
||||||
|
"""Get roles by their names."""
|
||||||
|
query = select(Role).where(Role.name.in_(role_names))
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def assign_roles(self, user: User, role_names: list[str]) -> User:
|
||||||
|
"""Assign roles to a user, replacing existing roles."""
|
||||||
|
roles = self.get_roles_by_names(role_names)
|
||||||
|
user.roles = roles
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def get_active_users(self, offset: int = 0, limit: int = 100) -> list[User]:
|
||||||
|
"""Get all active users."""
|
||||||
|
query = select(User).where(User.is_active.is_(True)).offset(offset).limit(limit)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRepository(BaseRepository[Role]):
|
||||||
|
"""Repository for Role operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, Role)
|
||||||
|
|
||||||
|
def get_by_name(self, name: str) -> Role | None:
|
||||||
|
"""Get role by name."""
|
||||||
|
query = select(Role).where(Role.name == name)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def create_role(self, name: str, description: str | None = None) -> Role:
|
||||||
|
"""Create a new role."""
|
||||||
|
role = Role(name=name, description=description)
|
||||||
|
return self.create(role)
|
||||||
|
|
||||||
|
def get_all_roles(self) -> list[Role]:
|
||||||
|
"""Get all roles."""
|
||||||
|
query = select(Role).order_by(Role.name)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenRepository(BaseRepository[RefreshToken]):
|
||||||
|
"""Repository for RefreshToken operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, RefreshToken)
|
||||||
|
|
||||||
|
def get_by_token(self, token: str) -> RefreshToken | None:
|
||||||
|
"""Get refresh token by token string."""
|
||||||
|
query = select(RefreshToken).where(
|
||||||
|
RefreshToken.token == token,
|
||||||
|
RefreshToken.revoked.is_(False),
|
||||||
|
RefreshToken.expires_at > datetime.now(UTC),
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
def create_token(self, user_id: uuid.UUID, token: str, expires_at: datetime) -> RefreshToken:
|
||||||
|
"""Create a new refresh token."""
|
||||||
|
refresh_token = RefreshToken(
|
||||||
|
user_id=user_id,
|
||||||
|
token=token,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
return self.create(refresh_token)
|
||||||
|
|
||||||
|
def revoke_token(self, token: str) -> bool:
|
||||||
|
"""Revoke a refresh token."""
|
||||||
|
refresh_token = self.get_by_token(token)
|
||||||
|
if refresh_token:
|
||||||
|
refresh_token.revoked = True
|
||||||
|
self.db.flush()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def revoke_all_user_tokens(self, user_id: uuid.UUID) -> int:
|
||||||
|
"""Revoke all refresh tokens for a user."""
|
||||||
|
query = select(RefreshToken).where(
|
||||||
|
RefreshToken.user_id == user_id,
|
||||||
|
RefreshToken.revoked.is_(False),
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
tokens = result.scalars().all()
|
||||||
|
count = 0
|
||||||
|
for token in tokens:
|
||||||
|
token.revoked = True
|
||||||
|
count += 1
|
||||||
|
self.db.flush()
|
||||||
|
return count
|
||||||
|
|
||||||
|
def cleanup_expired_tokens(self) -> int:
|
||||||
|
"""Remove expired or revoked tokens."""
|
||||||
|
query = select(RefreshToken).where(
|
||||||
|
(RefreshToken.expires_at <= datetime.now(UTC)) | (RefreshToken.revoked.is_(True))
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
tokens = result.scalars().all()
|
||||||
|
count = len(tokens)
|
||||||
|
for token in tokens:
|
||||||
|
self.db.delete(token)
|
||||||
|
self.db.flush()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogRepository(BaseRepository[AuditLog]):
|
||||||
|
"""Repository for AuditLog operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
super().__init__(db, AuditLog)
|
||||||
|
|
||||||
|
def log_action(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
user_id: uuid.UUID | None = None,
|
||||||
|
details: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> AuditLog:
|
||||||
|
"""Create an audit log entry."""
|
||||||
|
audit_log = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
return self.create(audit_log)
|
||||||
|
|
||||||
|
def get_user_logs(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[AuditLog]:
|
||||||
|
"""Get audit logs for a specific user."""
|
||||||
|
query = (
|
||||||
|
select(AuditLog)
|
||||||
|
.where(AuditLog.user_id == user_id)
|
||||||
|
.order_by(AuditLog.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_resource_logs(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[AuditLog]:
|
||||||
|
"""Get audit logs for a specific resource."""
|
||||||
|
query = (
|
||||||
|
select(AuditLog)
|
||||||
|
.where(
|
||||||
|
AuditLog.resource_type == resource_type,
|
||||||
|
AuditLog.resource_id == resource_id,
|
||||||
|
)
|
||||||
|
.order_by(AuditLog.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
0
docengine/app/schemas/__init__.py
Normal file
0
docengine/app/schemas/__init__.py
Normal file
43
docengine/app/schemas/auth.py
Normal file
43
docengine/app/schemas/auth.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
from app.schemas.common import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
"""Login credentials."""
|
||||||
|
|
||||||
|
username: str = Field(..., min_length=3, max_length=150)
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
"""User registration payload."""
|
||||||
|
|
||||||
|
username: str = Field(..., min_length=3, max_length=150)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
full_name: str | None = Field(None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseSchema):
|
||||||
|
"""JWT token pair response."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenRequest(BaseModel):
|
||||||
|
"""Refresh token request payload."""
|
||||||
|
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
"""Change password payload."""
|
||||||
|
|
||||||
|
current_password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
new_password: str = Field(..., min_length=8, max_length=128)
|
||||||
84
docengine/app/schemas/common.py
Normal file
84
docengine/app/schemas/common.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSchema(BaseModel):
|
||||||
|
"""Base schema with common configuration."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
populate_by_name=True,
|
||||||
|
str_strip_whitespace=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationParams(BaseModel):
|
||||||
|
"""Pagination query parameters."""
|
||||||
|
|
||||||
|
page: int = Field(default=1, ge=1, description="Page number")
|
||||||
|
page_size: int = Field(default=20, ge=1, le=100, description="Items per page")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offset(self) -> int:
|
||||||
|
return (self.page - 1) * self.page_size
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseSchema, Generic[T]):
|
||||||
|
"""Paginated response wrapper."""
|
||||||
|
|
||||||
|
items: list[T]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, items: list[T], total: int, page: int, page_size: int) -> PaginatedResponse[T]:
|
||||||
|
total_pages = (total + page_size - 1) // page_size if page_size > 0 else 0
|
||||||
|
return cls(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseSchema):
|
||||||
|
"""Standard error response."""
|
||||||
|
|
||||||
|
detail: str
|
||||||
|
error_code: str | None = None
|
||||||
|
errors: list[dict[str, Any]] | None = None
|
||||||
|
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessResponse(BaseSchema):
|
||||||
|
"""Standard success response."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseSchema):
|
||||||
|
"""Health check response."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
environment: str
|
||||||
|
database: str
|
||||||
|
redis: str
|
||||||
|
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class IDResponse(BaseSchema):
|
||||||
|
"""Response containing just an ID."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
134
docengine/app/schemas/document.py
Normal file
134
docengine/app/schemas/document.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.schemas.common import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentUploadResponse(BaseSchema):
|
||||||
|
"""Response after document upload."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
filename: str
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
file_size: int
|
||||||
|
checksum: str
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TextBlockResponse(BaseSchema):
|
||||||
|
"""Extracted text block."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
text: str
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
confidence: float | None
|
||||||
|
font_family: str | None
|
||||||
|
font_size: float | None
|
||||||
|
font_color: str | None
|
||||||
|
font_style: str | None
|
||||||
|
block_type: str
|
||||||
|
sequence: int
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentImageResponse(BaseSchema):
|
||||||
|
"""Extracted document image."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
image_path: str
|
||||||
|
image_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTableResponse(BaseSchema):
|
||||||
|
"""Extracted document table."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
rows: int
|
||||||
|
columns: int
|
||||||
|
data: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPageResponse(BaseSchema):
|
||||||
|
"""Document page with extracted content."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
page_number: int
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
image_path: str | None
|
||||||
|
text_content: str | None
|
||||||
|
text_blocks: list[TextBlockResponse] = Field(default_factory=list)
|
||||||
|
images: list[DocumentImageResponse] = Field(default_factory=list)
|
||||||
|
tables: list[DocumentTableResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentResponse(BaseSchema):
|
||||||
|
"""Full document response."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
filename: str
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
file_size: int
|
||||||
|
checksum: str
|
||||||
|
storage_path: str
|
||||||
|
status: str
|
||||||
|
page_count: int | None
|
||||||
|
is_scanned: bool | None
|
||||||
|
document_metadata: dict[str, Any] | None
|
||||||
|
error_message: str | None
|
||||||
|
uploaded_by: uuid.UUID | None
|
||||||
|
pages: list[DocumentPageResponse] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentListResponse(BaseSchema):
|
||||||
|
"""Minimal document response for lists."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
file_size: int
|
||||||
|
status: str
|
||||||
|
page_count: int | None
|
||||||
|
is_scanned: bool | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateMatchResponse(BaseSchema):
|
||||||
|
"""Template match result."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
document_id: uuid.UUID
|
||||||
|
format_id: uuid.UUID
|
||||||
|
confidence_score: float
|
||||||
|
match_details: dict[str, Any] | None
|
||||||
|
selected: bool
|
||||||
|
template_name: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateMatchRequest(BaseSchema):
|
||||||
|
"""Request to match a document against templates."""
|
||||||
|
|
||||||
|
document_id: uuid.UUID
|
||||||
|
min_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||||
|
max_results: int = Field(default=5, ge=1, le=20)
|
||||||
250
docengine/app/schemas/template.py
Normal file
250
docengine/app/schemas/template.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.schemas.common import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCellResponse(BaseSchema):
|
||||||
|
"""Document cell in a template."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
format_id: uuid.UUID
|
||||||
|
page_number: int
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
row_no: int
|
||||||
|
column_no: int
|
||||||
|
data_type: str
|
||||||
|
font_family: str | None
|
||||||
|
font_size: float | None
|
||||||
|
font_style: str | None
|
||||||
|
font_color: str | None
|
||||||
|
background_color: str | None
|
||||||
|
border_top: str | None
|
||||||
|
border_right: str | None
|
||||||
|
border_bottom: str | None
|
||||||
|
border_left: str | None
|
||||||
|
padding_top: float
|
||||||
|
padding_right: float
|
||||||
|
padding_bottom: float
|
||||||
|
padding_left: float
|
||||||
|
alignment: str
|
||||||
|
vertical_alignment: str
|
||||||
|
rowspan: int
|
||||||
|
colspan: int
|
||||||
|
static_text: str | None
|
||||||
|
field_name: str | None
|
||||||
|
sequence: int
|
||||||
|
is_dynamic: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentCellCreate(BaseSchema):
|
||||||
|
"""Create a document cell."""
|
||||||
|
|
||||||
|
page_number: int = Field(..., ge=1)
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float = Field(..., gt=0)
|
||||||
|
height: float = Field(..., gt=0)
|
||||||
|
row_no: int = 0
|
||||||
|
column_no: int = 0
|
||||||
|
data_type: str = "text"
|
||||||
|
font_family: str | None = None
|
||||||
|
font_size: float | None = None
|
||||||
|
font_style: str | None = None
|
||||||
|
font_color: str | None = None
|
||||||
|
background_color: str | None = None
|
||||||
|
border_top: str | None = None
|
||||||
|
border_right: str | None = None
|
||||||
|
border_bottom: str | None = None
|
||||||
|
border_left: str | None = None
|
||||||
|
padding_top: float = 0.0
|
||||||
|
padding_right: float = 0.0
|
||||||
|
padding_bottom: float = 0.0
|
||||||
|
padding_left: float = 0.0
|
||||||
|
alignment: str = "left"
|
||||||
|
vertical_alignment: str = "top"
|
||||||
|
rowspan: int = 1
|
||||||
|
colspan: int = 1
|
||||||
|
static_text: str | None = None
|
||||||
|
field_name: str | None = None
|
||||||
|
sequence: int = 0
|
||||||
|
is_dynamic: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRegionResponse(BaseSchema):
|
||||||
|
"""Region in a template."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
format_id: uuid.UUID
|
||||||
|
page_number: int
|
||||||
|
region_type: str
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
content: dict[str, Any] | None
|
||||||
|
sequence: int
|
||||||
|
|
||||||
|
|
||||||
|
class TableColumnResponse(BaseSchema):
|
||||||
|
"""Table column definition."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
table_format_id: uuid.UUID
|
||||||
|
column_index: int
|
||||||
|
width: float
|
||||||
|
header_text: str | None
|
||||||
|
data_type: str
|
||||||
|
alignment: str
|
||||||
|
font_family: str | None
|
||||||
|
font_size: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class TableRowResponse(BaseSchema):
|
||||||
|
"""Table row definition."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
table_format_id: uuid.UUID
|
||||||
|
row_index: int
|
||||||
|
height: float
|
||||||
|
is_header: bool
|
||||||
|
background_color: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class TableFormatResponse(BaseSchema):
|
||||||
|
"""Table format in a template."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
format_id: uuid.UUID
|
||||||
|
page_number: int
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
rows: int
|
||||||
|
columns: int
|
||||||
|
border_style: str
|
||||||
|
border_width: float
|
||||||
|
border_color: str
|
||||||
|
header_rows: int
|
||||||
|
table_columns: list[TableColumnResponse] = Field(default_factory=list)
|
||||||
|
table_rows: list[TableRowResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WatermarkResponse(BaseSchema):
|
||||||
|
"""Watermark in a template."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
format_id: uuid.UUID
|
||||||
|
page_number: int | None
|
||||||
|
text: str | None
|
||||||
|
image_path: str | None
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
opacity: float
|
||||||
|
rotation: float
|
||||||
|
font_family: str | None
|
||||||
|
font_size: float | None
|
||||||
|
font_color: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ImageRegionResponse(BaseSchema):
|
||||||
|
"""Image region in a template."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
format_id: uuid.UUID
|
||||||
|
page_number: int
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
image_path: str | None
|
||||||
|
image_type: str
|
||||||
|
is_static: bool
|
||||||
|
field_name: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateFingerprintResponse(BaseSchema):
|
||||||
|
"""Template fingerprint."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
format_id: uuid.UUID
|
||||||
|
page_dimensions: dict[str, Any] | None
|
||||||
|
logo_coordinates: dict[str, Any] | None
|
||||||
|
header_coordinates: dict[str, Any] | None
|
||||||
|
footer_coordinates: dict[str, Any] | None
|
||||||
|
table_coordinates: dict[str, Any] | None
|
||||||
|
cell_coordinates: dict[str, Any] | None
|
||||||
|
fingerprint_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateResponse(BaseSchema):
|
||||||
|
"""Full template response."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
page_width: float
|
||||||
|
page_height: float
|
||||||
|
page_count: int
|
||||||
|
margin_top: float
|
||||||
|
margin_right: float
|
||||||
|
margin_bottom: float
|
||||||
|
margin_left: float
|
||||||
|
fingerprint: dict[str, Any] | None
|
||||||
|
source_document_id: uuid.UUID | None
|
||||||
|
version: int
|
||||||
|
is_active: bool
|
||||||
|
created_by: uuid.UUID | None
|
||||||
|
cells: list[DocumentCellResponse] = Field(default_factory=list)
|
||||||
|
regions: list[DocumentRegionResponse] = Field(default_factory=list)
|
||||||
|
table_formats: list[TableFormatResponse] = Field(default_factory=list)
|
||||||
|
watermarks: list[WatermarkResponse] = Field(default_factory=list)
|
||||||
|
image_regions: list[ImageRegionResponse] = Field(default_factory=list)
|
||||||
|
fingerprint_record: TemplateFingerprintResponse | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateListResponse(BaseSchema):
|
||||||
|
"""Minimal template response for lists."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
page_width: float
|
||||||
|
page_height: float
|
||||||
|
page_count: int
|
||||||
|
version: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderRequest(BaseSchema):
|
||||||
|
"""Request to render a template to PDF."""
|
||||||
|
|
||||||
|
template_id: uuid.UUID
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict, description="Data to populate dynamic fields")
|
||||||
|
output_filename: str | None = Field(None, max_length=255, description="Output filename for generated PDF")
|
||||||
|
images: dict[str, str] | None = Field(None, description="Mapping of field_name to image path for dynamic images")
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderResponse(BaseSchema):
|
||||||
|
"""Response after rendering a template."""
|
||||||
|
|
||||||
|
output_path: str
|
||||||
|
filename: str
|
||||||
|
file_size: int
|
||||||
|
page_count: int
|
||||||
|
rendered_at: datetime
|
||||||
69
docengine/app/schemas/user.py
Normal file
69
docengine/app/schemas/user.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import EmailStr, Field
|
||||||
|
|
||||||
|
from app.schemas.common import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseSchema):
|
||||||
|
"""Base user fields."""
|
||||||
|
|
||||||
|
username: str = Field(..., min_length=3, max_length=150)
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str | None = Field(None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
"""User creation payload."""
|
||||||
|
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
is_active: bool = True
|
||||||
|
is_superuser: bool = False
|
||||||
|
role_names: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseSchema):
|
||||||
|
"""User update payload."""
|
||||||
|
|
||||||
|
email: EmailStr | None = None
|
||||||
|
full_name: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
is_superuser: bool | None = None
|
||||||
|
role_names: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoleResponse(BaseSchema):
|
||||||
|
"""Role response."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseSchema):
|
||||||
|
"""Full user response."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
full_name: str | None
|
||||||
|
is_active: bool
|
||||||
|
is_superuser: bool
|
||||||
|
roles: list[RoleResponse] = Field(default_factory=list)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
last_login: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseSchema):
|
||||||
|
"""Minimal user response for lists."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
full_name: str | None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
0
docengine/app/services/__init__.py
Normal file
0
docengine/app/services/__init__.py
Normal file
107
docengine/app/services/document_service.py
Normal file
107
docengine/app/services/document_service.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.document import Document
|
||||||
|
from app.repositories.document_repository import DocumentRepository
|
||||||
|
from app.services.layout_service import LayoutService
|
||||||
|
from app.services.ocr_service import OCRService
|
||||||
|
from app.services.pdf_service import NativePDFService
|
||||||
|
from app.services.template_service import TemplateService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentProcessingService:
|
||||||
|
"""Orchestrates the full document processing pipeline."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.doc_repo = DocumentRepository(db)
|
||||||
|
self.pdf_service = NativePDFService(db)
|
||||||
|
self.ocr_service = OCRService(db)
|
||||||
|
self.layout_service = LayoutService(db)
|
||||||
|
self.template_service = TemplateService(db)
|
||||||
|
|
||||||
|
def process_document(self, document_id: str | uuid.UUID) -> Document:
|
||||||
|
"""Process a document through the full pipeline."""
|
||||||
|
if isinstance(document_id, str):
|
||||||
|
document_id = uuid.UUID(document_id)
|
||||||
|
|
||||||
|
document = self.doc_repo.get_by_id(document_id)
|
||||||
|
if not document:
|
||||||
|
raise ValueError(f"Document '{document_id}' not found")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"processing_started",
|
||||||
|
document_id=str(document_id),
|
||||||
|
content_type=document.content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update status to processing
|
||||||
|
self.doc_repo.update_status(document_id, "processing")
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Extract content based on document type
|
||||||
|
if document.content_type == "application/pdf":
|
||||||
|
document = self._process_pdf(document)
|
||||||
|
else:
|
||||||
|
document = self._process_image(document)
|
||||||
|
|
||||||
|
# Step 2: Analyze layout
|
||||||
|
layout_results = self.layout_service.analyze_document_layout(document)
|
||||||
|
document.document_metadata = document.document_metadata or {}
|
||||||
|
document.document_metadata["layout"] = layout_results
|
||||||
|
|
||||||
|
# Step 3: Generate template
|
||||||
|
template = self.template_service.generate_template(document)
|
||||||
|
|
||||||
|
# Step 4: Update document status
|
||||||
|
self.doc_repo.update_status(document_id, "completed")
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"processing_completed",
|
||||||
|
document_id=str(document_id),
|
||||||
|
pages=document.page_count,
|
||||||
|
template_id=str(template.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return document
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
"processing_failed",
|
||||||
|
document_id=str(document_id),
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
self.doc_repo.update_status(document_id, "failed", error_message=str(e))
|
||||||
|
self.db.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _process_pdf(self, document: Document) -> Document:
|
||||||
|
"""Process a PDF document - either native or scanned."""
|
||||||
|
# First, try native PDF extraction
|
||||||
|
document = self.pdf_service.process_pdf(document)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
# If scanned, also run OCR
|
||||||
|
if document.is_scanned:
|
||||||
|
logger.info(
|
||||||
|
"scanned_pdf_detected",
|
||||||
|
document_id=str(document.id),
|
||||||
|
)
|
||||||
|
document = self.ocr_service.process_scanned_pdf(document)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
return document
|
||||||
|
|
||||||
|
def _process_image(self, document: Document) -> Document:
|
||||||
|
"""Process an image document with OCR."""
|
||||||
|
document = self.ocr_service.process_image(document)
|
||||||
|
self.db.flush()
|
||||||
|
return document
|
||||||
343
docengine/app/services/fingerprint_service.py
Normal file
343
docengine/app/services/fingerprint_service.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.template import DocumentFormat, TemplateFingerprint
|
||||||
|
from app.repositories.template_repository import TemplateFingerprintRepository, TemplateRepository
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FingerprintService:
|
||||||
|
"""Generate and manage layout fingerprints for template matching."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.fingerprint_repo = TemplateFingerprintRepository(db)
|
||||||
|
self.template_repo = TemplateRepository(db)
|
||||||
|
|
||||||
|
def generate_fingerprint(self, template: DocumentFormat) -> TemplateFingerprint:
|
||||||
|
"""Generate a layout fingerprint for a template."""
|
||||||
|
# Collect page dimensions
|
||||||
|
page_dimensions = {
|
||||||
|
"width": template.page_width,
|
||||||
|
"height": template.page_height,
|
||||||
|
"page_count": template.page_count,
|
||||||
|
"margins": {
|
||||||
|
"top": template.margin_top,
|
||||||
|
"right": template.margin_right,
|
||||||
|
"bottom": template.margin_bottom,
|
||||||
|
"left": template.margin_left,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect logo coordinates
|
||||||
|
logo_coordinates = self._extract_logo_coordinates(template)
|
||||||
|
|
||||||
|
# Collect header coordinates
|
||||||
|
header_coordinates = self._extract_region_coordinates(template, "header")
|
||||||
|
|
||||||
|
# Collect footer coordinates
|
||||||
|
footer_coordinates = self._extract_region_coordinates(template, "footer")
|
||||||
|
|
||||||
|
# Collect table coordinates
|
||||||
|
table_coordinates = self._extract_table_coordinates(template)
|
||||||
|
|
||||||
|
# Collect cell coordinates
|
||||||
|
cell_coordinates = self._extract_cell_coordinates(template)
|
||||||
|
|
||||||
|
# Compute fingerprint hash
|
||||||
|
fingerprint_data = {
|
||||||
|
"page_dimensions": page_dimensions,
|
||||||
|
"logo_coordinates": logo_coordinates,
|
||||||
|
"header_coordinates": header_coordinates,
|
||||||
|
"footer_coordinates": footer_coordinates,
|
||||||
|
"table_coordinates": table_coordinates,
|
||||||
|
"cell_coordinates": cell_coordinates,
|
||||||
|
}
|
||||||
|
fingerprint_hash = self._compute_hash(fingerprint_data)
|
||||||
|
|
||||||
|
# Check for existing fingerprint
|
||||||
|
existing = self.fingerprint_repo.get_by_format_id(template.id)
|
||||||
|
if existing:
|
||||||
|
# Update existing
|
||||||
|
existing.page_dimensions = page_dimensions
|
||||||
|
existing.logo_coordinates = logo_coordinates
|
||||||
|
existing.header_coordinates = header_coordinates
|
||||||
|
existing.footer_coordinates = footer_coordinates
|
||||||
|
existing.table_coordinates = table_coordinates
|
||||||
|
existing.cell_coordinates = cell_coordinates
|
||||||
|
existing.fingerprint_hash = fingerprint_hash
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Create new fingerprint
|
||||||
|
fingerprint = self.fingerprint_repo.create_fingerprint(
|
||||||
|
format_id=template.id,
|
||||||
|
fingerprint_hash=fingerprint_hash,
|
||||||
|
page_dimensions=page_dimensions,
|
||||||
|
logo_coordinates=logo_coordinates,
|
||||||
|
header_coordinates=header_coordinates,
|
||||||
|
footer_coordinates=footer_coordinates,
|
||||||
|
table_coordinates=table_coordinates,
|
||||||
|
cell_coordinates=cell_coordinates,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"fingerprint_generated",
|
||||||
|
template_id=str(template.id),
|
||||||
|
hash=fingerprint_hash[:16],
|
||||||
|
)
|
||||||
|
|
||||||
|
return fingerprint
|
||||||
|
|
||||||
|
def _extract_logo_coordinates(self, template: DocumentFormat) -> dict[str, Any] | None:
|
||||||
|
"""Extract logo image coordinates from template."""
|
||||||
|
logos = [ir for ir in template.image_regions if ir.image_type == "logo"]
|
||||||
|
if not logos:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"page": ir.page_number,
|
||||||
|
"x": ir.x,
|
||||||
|
"y": ir.y,
|
||||||
|
"width": ir.width,
|
||||||
|
"height": ir.height,
|
||||||
|
}
|
||||||
|
for ir in logos
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_region_coordinates(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
region_type: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Extract coordinates for a specific region type."""
|
||||||
|
regions = [r for r in template.regions if r.region_type == region_type]
|
||||||
|
if not regions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"page": r.page_number,
|
||||||
|
"x": r.x,
|
||||||
|
"y": r.y,
|
||||||
|
"width": r.width,
|
||||||
|
"height": r.height,
|
||||||
|
}
|
||||||
|
for r in regions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_table_coordinates(self, template: DocumentFormat) -> dict[str, Any] | None:
|
||||||
|
"""Extract table coordinates from template."""
|
||||||
|
if not template.table_formats:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"page": tf.page_number,
|
||||||
|
"x": tf.x,
|
||||||
|
"y": tf.y,
|
||||||
|
"width": tf.width,
|
||||||
|
"height": tf.height,
|
||||||
|
"rows": tf.rows,
|
||||||
|
"columns": tf.columns,
|
||||||
|
}
|
||||||
|
for tf in template.table_formats
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_cell_coordinates(self, template: DocumentFormat) -> dict[str, Any] | None:
|
||||||
|
"""Extract cell coordinates from template."""
|
||||||
|
if not template.cells:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"page": c.page_number,
|
||||||
|
"x": c.x,
|
||||||
|
"y": c.y,
|
||||||
|
"width": c.width,
|
||||||
|
"height": c.height,
|
||||||
|
"row": c.row_no,
|
||||||
|
"col": c.column_no,
|
||||||
|
}
|
||||||
|
for c in template.cells
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compute_hash(self, data: dict[str, Any]) -> str:
|
||||||
|
"""Compute a deterministic hash of the fingerprint data."""
|
||||||
|
# Normalize coordinates to reduce sensitivity to minor variations
|
||||||
|
normalized = self._normalize_coordinates(data)
|
||||||
|
serialized = json.dumps(normalized, sort_keys=True, default=str)
|
||||||
|
return hashlib.sha256(serialized.encode()).hexdigest()
|
||||||
|
|
||||||
|
def _normalize_coordinates(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Normalize coordinates by rounding to reduce sensitivity to small variations."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {k: self._normalize_coordinates(v) for k, v in data.items()}
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return [self._normalize_coordinates(item) for item in data]
|
||||||
|
elif isinstance(data, float):
|
||||||
|
return round(data, 1)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def compute_similarity(
|
||||||
|
self,
|
||||||
|
fingerprint1: TemplateFingerprint,
|
||||||
|
fingerprint2_data: dict[str, Any],
|
||||||
|
) -> float:
|
||||||
|
"""Compute similarity score between a stored fingerprint and new document data."""
|
||||||
|
scores: list[float] = []
|
||||||
|
weights: list[float] = []
|
||||||
|
|
||||||
|
# Page dimensions similarity (high weight)
|
||||||
|
dim_score = self._compare_dimensions(
|
||||||
|
fingerprint1.page_dimensions,
|
||||||
|
fingerprint2_data.get("page_dimensions"),
|
||||||
|
)
|
||||||
|
scores.append(dim_score)
|
||||||
|
weights.append(3.0)
|
||||||
|
|
||||||
|
# Logo coordinates similarity
|
||||||
|
logo_score = self._compare_coordinates(
|
||||||
|
fingerprint1.logo_coordinates,
|
||||||
|
fingerprint2_data.get("logo_coordinates"),
|
||||||
|
)
|
||||||
|
scores.append(logo_score)
|
||||||
|
weights.append(2.0)
|
||||||
|
|
||||||
|
# Header coordinates similarity
|
||||||
|
header_score = self._compare_coordinates(
|
||||||
|
fingerprint1.header_coordinates,
|
||||||
|
fingerprint2_data.get("header_coordinates"),
|
||||||
|
)
|
||||||
|
scores.append(header_score)
|
||||||
|
weights.append(2.0)
|
||||||
|
|
||||||
|
# Footer coordinates similarity
|
||||||
|
footer_score = self._compare_coordinates(
|
||||||
|
fingerprint1.footer_coordinates,
|
||||||
|
fingerprint2_data.get("footer_coordinates"),
|
||||||
|
)
|
||||||
|
scores.append(footer_score)
|
||||||
|
weights.append(1.5)
|
||||||
|
|
||||||
|
# Table coordinates similarity
|
||||||
|
table_score = self._compare_coordinates(
|
||||||
|
fingerprint1.table_coordinates,
|
||||||
|
fingerprint2_data.get("table_coordinates"),
|
||||||
|
)
|
||||||
|
scores.append(table_score)
|
||||||
|
weights.append(2.5)
|
||||||
|
|
||||||
|
# Cell coordinates similarity
|
||||||
|
cell_score = self._compare_coordinates(
|
||||||
|
fingerprint1.cell_coordinates,
|
||||||
|
fingerprint2_data.get("cell_coordinates"),
|
||||||
|
)
|
||||||
|
scores.append(cell_score)
|
||||||
|
weights.append(1.5)
|
||||||
|
|
||||||
|
# Weighted average
|
||||||
|
total_weight = sum(weights)
|
||||||
|
if total_weight == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
weighted_sum = sum(s * w for s, w in zip(scores, weights))
|
||||||
|
return weighted_sum / total_weight
|
||||||
|
|
||||||
|
def _compare_dimensions(
|
||||||
|
self,
|
||||||
|
dims1: dict[str, Any] | None,
|
||||||
|
dims2: dict[str, Any] | None,
|
||||||
|
) -> float:
|
||||||
|
"""Compare page dimensions similarity."""
|
||||||
|
if not dims1 or not dims2:
|
||||||
|
return 0.0 if (dims1 or dims2) else 1.0
|
||||||
|
|
||||||
|
width_ratio = min(dims1.get("width", 0), dims2.get("width", 0)) / max(
|
||||||
|
dims1.get("width", 1), dims2.get("width", 1)
|
||||||
|
)
|
||||||
|
height_ratio = min(dims1.get("height", 0), dims2.get("height", 0)) / max(
|
||||||
|
dims1.get("height", 1), dims2.get("height", 1)
|
||||||
|
)
|
||||||
|
page_count_match = 1.0 if dims1.get("page_count") == dims2.get("page_count") else 0.5
|
||||||
|
|
||||||
|
return (width_ratio + height_ratio + page_count_match) / 3.0
|
||||||
|
|
||||||
|
def _compare_coordinates(
|
||||||
|
self,
|
||||||
|
coords1: dict[str, Any] | None,
|
||||||
|
coords2: dict[str, Any] | None,
|
||||||
|
) -> float:
|
||||||
|
"""Compare coordinate sets for similarity."""
|
||||||
|
if not coords1 and not coords2:
|
||||||
|
return 1.0
|
||||||
|
if not coords1 or not coords2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
items1 = coords1.get("items", [])
|
||||||
|
items2 = coords2.get("items", [])
|
||||||
|
|
||||||
|
if not items1 and not items2:
|
||||||
|
return 1.0
|
||||||
|
if not items1 or not items2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Compare number of items
|
||||||
|
count_ratio = min(len(items1), len(items2)) / max(len(items1), len(items2))
|
||||||
|
|
||||||
|
# Compare positions of matched items
|
||||||
|
position_scores = []
|
||||||
|
for item1 in items1:
|
||||||
|
best_match = 0.0
|
||||||
|
for item2 in items2:
|
||||||
|
if item1.get("page") != item2.get("page"):
|
||||||
|
continue
|
||||||
|
score = self._compute_bbox_iou(item1, item2)
|
||||||
|
best_match = max(best_match, score)
|
||||||
|
position_scores.append(best_match)
|
||||||
|
|
||||||
|
avg_position_score = sum(position_scores) / len(position_scores) if position_scores else 0.0
|
||||||
|
|
||||||
|
return (count_ratio + avg_position_score) / 2.0
|
||||||
|
|
||||||
|
def _compute_bbox_iou(self, bbox1: dict[str, Any], bbox2: dict[str, Any]) -> float:
|
||||||
|
"""Compute Intersection over Union for two bounding boxes."""
|
||||||
|
x1 = max(bbox1.get("x", 0), bbox2.get("x", 0))
|
||||||
|
y1 = max(bbox1.get("y", 0), bbox2.get("y", 0))
|
||||||
|
x2 = min(
|
||||||
|
bbox1.get("x", 0) + bbox1.get("width", 0),
|
||||||
|
bbox2.get("x", 0) + bbox2.get("width", 0),
|
||||||
|
)
|
||||||
|
y2 = min(
|
||||||
|
bbox1.get("y", 0) + bbox1.get("height", 0),
|
||||||
|
bbox2.get("y", 0) + bbox2.get("height", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
intersection = max(0, x2 - x1) * max(0, y2 - y1)
|
||||||
|
|
||||||
|
area1 = bbox1.get("width", 0) * bbox1.get("height", 0)
|
||||||
|
area2 = bbox2.get("width", 0) * bbox2.get("height", 0)
|
||||||
|
union = area1 + area2 - intersection
|
||||||
|
|
||||||
|
if union == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return intersection / union
|
||||||
317
docengine/app/services/layout_service.py
Normal file
317
docengine/app/services/layout_service.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.document import Document, DocumentPage
|
||||||
|
from app.repositories.document_repository import (
|
||||||
|
DocumentPageRepository,
|
||||||
|
DocumentTableRepository,
|
||||||
|
DocumentTextBlockRepository,
|
||||||
|
)
|
||||||
|
from app.storage.provider import get_storage_provider
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutService:
|
||||||
|
"""Document layout analysis service using OpenCV-based detection."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.storage = get_storage_provider()
|
||||||
|
self.page_repo = DocumentPageRepository(db)
|
||||||
|
self.text_block_repo = DocumentTextBlockRepository(db)
|
||||||
|
self.table_repo = DocumentTableRepository(db)
|
||||||
|
|
||||||
|
def analyze_document_layout(self, document: Document) -> dict[str, Any]:
|
||||||
|
"""Analyze the layout of all pages in a document."""
|
||||||
|
layout_results: dict[str, Any] = {"pages": []}
|
||||||
|
|
||||||
|
for page in document.pages:
|
||||||
|
page_layout = self._analyze_page_layout(page)
|
||||||
|
layout_results["pages"].append(page_layout)
|
||||||
|
|
||||||
|
return layout_results
|
||||||
|
|
||||||
|
def _analyze_page_layout(self, page: DocumentPage) -> dict[str, Any]:
|
||||||
|
"""Analyze layout of a single page."""
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"page_number": page.page_number,
|
||||||
|
"width": page.width,
|
||||||
|
"height": page.height,
|
||||||
|
"tables": [],
|
||||||
|
"lines": [],
|
||||||
|
"rectangles": [],
|
||||||
|
"text_regions": [],
|
||||||
|
"image_regions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not page.image_path:
|
||||||
|
return result
|
||||||
|
|
||||||
|
image_path = self.storage.get_absolute_path(page.image_path)
|
||||||
|
image = cv2.imread(image_path)
|
||||||
|
if image is None:
|
||||||
|
logger.warning("layout_image_read_failed", page_id=str(page.id))
|
||||||
|
return result
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Detect lines
|
||||||
|
result["lines"] = self._detect_lines(gray)
|
||||||
|
|
||||||
|
# Detect rectangles (potential table cells/borders)
|
||||||
|
result["rectangles"] = self._detect_rectangles(gray)
|
||||||
|
|
||||||
|
# Detect tables
|
||||||
|
tables = self._detect_tables(gray, image.shape)
|
||||||
|
result["tables"] = tables
|
||||||
|
|
||||||
|
# Store detected tables in the database
|
||||||
|
for table_data in tables:
|
||||||
|
self.table_repo.create_table(
|
||||||
|
page_id=page.id,
|
||||||
|
x=table_data["x"],
|
||||||
|
y=table_data["y"],
|
||||||
|
width=table_data["width"],
|
||||||
|
height=table_data["height"],
|
||||||
|
rows=table_data["rows"],
|
||||||
|
columns=table_data["columns"],
|
||||||
|
data=table_data.get("cells"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect watermarks
|
||||||
|
watermark = self._detect_watermark(gray, image.shape)
|
||||||
|
if watermark:
|
||||||
|
result["watermark"] = watermark
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _detect_lines(self, gray: np.ndarray) -> list[dict[str, Any]]:
|
||||||
|
"""Detect horizontal and vertical lines in the image."""
|
||||||
|
lines_detected: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Apply edge detection
|
||||||
|
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
|
||||||
|
|
||||||
|
# Detect lines using Hough transform
|
||||||
|
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=100, minLineLength=50, maxLineGap=10)
|
||||||
|
|
||||||
|
if lines is not None:
|
||||||
|
for line in lines:
|
||||||
|
x1, y1, x2, y2 = line[0]
|
||||||
|
length = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
|
||||||
|
|
||||||
|
# Classify as horizontal or vertical
|
||||||
|
angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
|
||||||
|
if abs(angle) < 5 or abs(angle - 180) < 5:
|
||||||
|
orientation = "horizontal"
|
||||||
|
elif abs(angle - 90) < 5 or abs(angle + 90) < 5:
|
||||||
|
orientation = "vertical"
|
||||||
|
else:
|
||||||
|
orientation = "diagonal"
|
||||||
|
|
||||||
|
lines_detected.append({
|
||||||
|
"x1": float(x1),
|
||||||
|
"y1": float(y1),
|
||||||
|
"x2": float(x2),
|
||||||
|
"y2": float(y2),
|
||||||
|
"length": float(length),
|
||||||
|
"orientation": orientation,
|
||||||
|
})
|
||||||
|
|
||||||
|
return lines_detected
|
||||||
|
|
||||||
|
def _detect_rectangles(self, gray: np.ndarray) -> list[dict[str, Any]]:
|
||||||
|
"""Detect rectangular regions in the image."""
|
||||||
|
rectangles: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Binary threshold
|
||||||
|
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
|
||||||
|
|
||||||
|
# Find contours
|
||||||
|
contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
for contour in contours:
|
||||||
|
# Approximate the contour
|
||||||
|
peri = cv2.arcLength(contour, True)
|
||||||
|
approx = cv2.approxPolyDP(contour, 0.02 * peri, True)
|
||||||
|
|
||||||
|
# If approximation has 4 vertices, it's likely a rectangle
|
||||||
|
if len(approx) == 4:
|
||||||
|
x, y, w, h = cv2.boundingRect(approx)
|
||||||
|
# Filter out very small or very large rectangles
|
||||||
|
area = w * h
|
||||||
|
if area > 500 and w > 10 and h > 10:
|
||||||
|
rectangles.append({
|
||||||
|
"x": float(x),
|
||||||
|
"y": float(y),
|
||||||
|
"width": float(w),
|
||||||
|
"height": float(h),
|
||||||
|
"area": float(area),
|
||||||
|
})
|
||||||
|
|
||||||
|
return rectangles
|
||||||
|
|
||||||
|
def _detect_tables(self, gray: np.ndarray, image_shape: tuple) -> list[dict[str, Any]]:
|
||||||
|
"""Detect table structures using morphological operations."""
|
||||||
|
tables: list[dict[str, Any]] = []
|
||||||
|
h, w = image_shape[:2]
|
||||||
|
|
||||||
|
# Binary threshold
|
||||||
|
_, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
|
||||||
|
|
||||||
|
# Detect horizontal lines
|
||||||
|
horizontal_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(w // 30, 1), 1))
|
||||||
|
horizontal = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horizontal_kernel, iterations=2)
|
||||||
|
|
||||||
|
# Detect vertical lines
|
||||||
|
vertical_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(h // 30, 1)))
|
||||||
|
vertical = cv2.morphologyEx(binary, cv2.MORPH_OPEN, vertical_kernel, iterations=2)
|
||||||
|
|
||||||
|
# Combine horizontal and vertical lines to find intersections
|
||||||
|
table_mask = cv2.add(horizontal, vertical)
|
||||||
|
|
||||||
|
# Find contours of table regions
|
||||||
|
contours, _ = cv2.findContours(table_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
for contour in contours:
|
||||||
|
x, y, cw, ch = cv2.boundingRect(contour)
|
||||||
|
area = cw * ch
|
||||||
|
|
||||||
|
# Filter: table should be reasonably sized
|
||||||
|
if area < 5000 or cw < 50 or ch < 30:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Estimate rows and columns
|
||||||
|
rows, columns = self._estimate_table_dimensions(
|
||||||
|
table_mask[y:y+ch, x:x+cw], cw, ch
|
||||||
|
)
|
||||||
|
|
||||||
|
if rows >= 1 and columns >= 1:
|
||||||
|
# Extract cell contents
|
||||||
|
cells = self._extract_table_cells(
|
||||||
|
gray[y:y+ch, x:x+cw], rows, columns, cw, ch
|
||||||
|
)
|
||||||
|
|
||||||
|
tables.append({
|
||||||
|
"x": float(x),
|
||||||
|
"y": float(y),
|
||||||
|
"width": float(cw),
|
||||||
|
"height": float(ch),
|
||||||
|
"rows": rows,
|
||||||
|
"columns": columns,
|
||||||
|
"cells": cells,
|
||||||
|
})
|
||||||
|
|
||||||
|
return tables
|
||||||
|
|
||||||
|
def _estimate_table_dimensions(
|
||||||
|
self,
|
||||||
|
table_region: np.ndarray,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Estimate the number of rows and columns in a table region."""
|
||||||
|
# Project horizontal lines
|
||||||
|
h_projection = np.sum(table_region, axis=1)
|
||||||
|
h_peaks = self._count_peaks(h_projection, height)
|
||||||
|
|
||||||
|
# Project vertical lines
|
||||||
|
v_projection = np.sum(table_region, axis=0)
|
||||||
|
v_peaks = self._count_peaks(v_projection, width)
|
||||||
|
|
||||||
|
rows = max(1, h_peaks - 1)
|
||||||
|
columns = max(1, v_peaks - 1)
|
||||||
|
|
||||||
|
return rows, columns
|
||||||
|
|
||||||
|
def _count_peaks(self, projection: np.ndarray, total_length: int) -> int:
|
||||||
|
"""Count significant peaks in a projection array."""
|
||||||
|
if len(projection) == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
threshold = np.max(projection) * 0.3
|
||||||
|
above_threshold = projection > threshold
|
||||||
|
|
||||||
|
# Count transitions from below to above threshold
|
||||||
|
peaks = 0
|
||||||
|
in_peak = False
|
||||||
|
for val in above_threshold:
|
||||||
|
if val and not in_peak:
|
||||||
|
peaks += 1
|
||||||
|
in_peak = True
|
||||||
|
elif not val:
|
||||||
|
in_peak = False
|
||||||
|
|
||||||
|
return peaks
|
||||||
|
|
||||||
|
def _extract_table_cells(
|
||||||
|
self,
|
||||||
|
table_gray: np.ndarray,
|
||||||
|
rows: int,
|
||||||
|
columns: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Extract cell structure data from a table region."""
|
||||||
|
cell_height = height / max(rows, 1)
|
||||||
|
cell_width = width / max(columns, 1)
|
||||||
|
|
||||||
|
cells: dict[str, Any] = {"rows": rows, "columns": columns, "data": []}
|
||||||
|
|
||||||
|
for r in range(rows):
|
||||||
|
row_data = []
|
||||||
|
for c in range(columns):
|
||||||
|
cell_x = int(c * cell_width)
|
||||||
|
cell_y = int(r * cell_height)
|
||||||
|
cell_w = int(cell_width)
|
||||||
|
cell_h = int(cell_height)
|
||||||
|
|
||||||
|
row_data.append({
|
||||||
|
"row": r,
|
||||||
|
"col": c,
|
||||||
|
"x": cell_x,
|
||||||
|
"y": cell_y,
|
||||||
|
"width": cell_w,
|
||||||
|
"height": cell_h,
|
||||||
|
})
|
||||||
|
cells["data"].append(row_data)
|
||||||
|
|
||||||
|
return cells
|
||||||
|
|
||||||
|
def _detect_watermark(self, gray: np.ndarray, image_shape: tuple) -> dict[str, Any] | None:
|
||||||
|
"""Detect potential watermark regions."""
|
||||||
|
h, w = image_shape[:2]
|
||||||
|
|
||||||
|
# Look for semi-transparent or light text in the center region
|
||||||
|
center_region = gray[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4]
|
||||||
|
|
||||||
|
# Apply adaptive threshold to find light text
|
||||||
|
_, binary = cv2.threshold(center_region, 230, 255, cv2.THRESH_BINARY)
|
||||||
|
|
||||||
|
# Find contours in the center region
|
||||||
|
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
for contour in contours:
|
||||||
|
x, y, cw, ch = cv2.boundingRect(contour)
|
||||||
|
area = cw * ch
|
||||||
|
# Watermark typically covers a significant portion of the center
|
||||||
|
center_area = (w // 2) * (h // 2)
|
||||||
|
if area > center_area * 0.1:
|
||||||
|
return {
|
||||||
|
"x": float(x + w // 4),
|
||||||
|
"y": float(y + h // 4),
|
||||||
|
"width": float(cw),
|
||||||
|
"height": float(ch),
|
||||||
|
"detected": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
217
docengine/app/services/matching_service.py
Normal file
217
docengine/app/services/matching_service.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.document import Document, TemplateMatch
|
||||||
|
from app.models.template import DocumentFormat
|
||||||
|
from app.repositories.document_repository import DocumentRepository, TemplateMatchRepository
|
||||||
|
from app.repositories.template_repository import TemplateFingerprintRepository, TemplateRepository
|
||||||
|
from app.services.fingerprint_service import FingerprintService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MatchingService:
|
||||||
|
"""Match documents against existing templates using fingerprint comparison."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.doc_repo = DocumentRepository(db)
|
||||||
|
self.template_repo = TemplateRepository(db)
|
||||||
|
self.match_repo = TemplateMatchRepository(db)
|
||||||
|
self.fingerprint_repo = TemplateFingerprintRepository(db)
|
||||||
|
self.fingerprint_service = FingerprintService(db)
|
||||||
|
|
||||||
|
def match_document(
|
||||||
|
self,
|
||||||
|
document_id: uuid.UUID,
|
||||||
|
min_confidence: float = 0.5,
|
||||||
|
max_results: int = 5,
|
||||||
|
) -> list[TemplateMatch]:
|
||||||
|
"""Match a document against all existing templates."""
|
||||||
|
document = self.doc_repo.get_with_pages(document_id)
|
||||||
|
if not document:
|
||||||
|
raise ValueError(f"Document '{document_id}' not found")
|
||||||
|
|
||||||
|
if not document.pages:
|
||||||
|
raise ValueError(f"Document '{document_id}' has no processed pages")
|
||||||
|
|
||||||
|
# Generate document fingerprint data
|
||||||
|
doc_fingerprint_data = self._build_document_fingerprint(document)
|
||||||
|
|
||||||
|
# Get all templates with fingerprints
|
||||||
|
templates = self.template_repo.get_all_with_fingerprints()
|
||||||
|
fingerprints = self.fingerprint_repo.get_all_fingerprints()
|
||||||
|
|
||||||
|
# Map format_id -> fingerprint
|
||||||
|
fp_map = {fp.format_id: fp for fp in fingerprints}
|
||||||
|
|
||||||
|
matches: list[tuple[DocumentFormat, float, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
for template in templates:
|
||||||
|
fp = fp_map.get(template.id)
|
||||||
|
if not fp:
|
||||||
|
continue
|
||||||
|
|
||||||
|
score = self.fingerprint_service.compute_similarity(fp, doc_fingerprint_data)
|
||||||
|
if score >= min_confidence:
|
||||||
|
match_details = {
|
||||||
|
"page_dimensions_score": self.fingerprint_service._compare_dimensions(
|
||||||
|
fp.page_dimensions, doc_fingerprint_data.get("page_dimensions")
|
||||||
|
),
|
||||||
|
"logo_score": self.fingerprint_service._compare_coordinates(
|
||||||
|
fp.logo_coordinates, doc_fingerprint_data.get("logo_coordinates")
|
||||||
|
),
|
||||||
|
"header_score": self.fingerprint_service._compare_coordinates(
|
||||||
|
fp.header_coordinates, doc_fingerprint_data.get("header_coordinates")
|
||||||
|
),
|
||||||
|
"footer_score": self.fingerprint_service._compare_coordinates(
|
||||||
|
fp.footer_coordinates, doc_fingerprint_data.get("footer_coordinates")
|
||||||
|
),
|
||||||
|
"table_score": self.fingerprint_service._compare_coordinates(
|
||||||
|
fp.table_coordinates, doc_fingerprint_data.get("table_coordinates")
|
||||||
|
),
|
||||||
|
"cell_score": self.fingerprint_service._compare_coordinates(
|
||||||
|
fp.cell_coordinates, doc_fingerprint_data.get("cell_coordinates")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
matches.append((template, score, match_details))
|
||||||
|
|
||||||
|
# Sort by score descending
|
||||||
|
matches.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
matches = matches[:max_results]
|
||||||
|
|
||||||
|
# Store match results
|
||||||
|
result_matches: list[TemplateMatch] = []
|
||||||
|
for idx, (template, score, details) in enumerate(matches):
|
||||||
|
template_match = self.match_repo.create_match(
|
||||||
|
document_id=document_id,
|
||||||
|
format_id=template.id,
|
||||||
|
confidence_score=score,
|
||||||
|
match_details=details,
|
||||||
|
selected=(idx == 0), # Auto-select best match
|
||||||
|
)
|
||||||
|
result_matches.append(template_match)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"document_matched",
|
||||||
|
document_id=str(document_id),
|
||||||
|
matches_found=len(result_matches),
|
||||||
|
best_score=result_matches[0].confidence_score if result_matches else 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result_matches
|
||||||
|
|
||||||
|
def _build_document_fingerprint(self, document: Document) -> dict[str, Any]:
|
||||||
|
"""Build fingerprint data from a document for comparison."""
|
||||||
|
first_page = document.pages[0] if document.pages else None
|
||||||
|
|
||||||
|
page_dimensions = None
|
||||||
|
if first_page:
|
||||||
|
page_dimensions = {
|
||||||
|
"width": first_page.width,
|
||||||
|
"height": first_page.height,
|
||||||
|
"page_count": document.page_count or len(document.pages),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract logo coordinates from images
|
||||||
|
logo_coordinates = None
|
||||||
|
logos = []
|
||||||
|
for page in document.pages:
|
||||||
|
for img in page.images:
|
||||||
|
if img.image_type == "logo":
|
||||||
|
logos.append({
|
||||||
|
"page": page.page_number,
|
||||||
|
"x": img.x,
|
||||||
|
"y": img.y,
|
||||||
|
"width": img.width,
|
||||||
|
"height": img.height,
|
||||||
|
})
|
||||||
|
if logos:
|
||||||
|
logo_coordinates = {"items": logos}
|
||||||
|
|
||||||
|
# Extract header coordinates
|
||||||
|
header_coordinates = None
|
||||||
|
headers = []
|
||||||
|
for page in document.pages:
|
||||||
|
header_blocks = [b for b in page.text_blocks if b.block_type == "header"]
|
||||||
|
if header_blocks:
|
||||||
|
min_x = min(b.x for b in header_blocks)
|
||||||
|
min_y = min(b.y for b in header_blocks)
|
||||||
|
max_x = max(b.x + b.width for b in header_blocks)
|
||||||
|
max_y = max(b.y + b.height for b in header_blocks)
|
||||||
|
headers.append({
|
||||||
|
"page": page.page_number,
|
||||||
|
"x": min_x,
|
||||||
|
"y": min_y,
|
||||||
|
"width": max_x - min_x,
|
||||||
|
"height": max_y - min_y,
|
||||||
|
})
|
||||||
|
if headers:
|
||||||
|
header_coordinates = {"items": headers}
|
||||||
|
|
||||||
|
# Extract footer coordinates
|
||||||
|
footer_coordinates = None
|
||||||
|
footers = []
|
||||||
|
for page in document.pages:
|
||||||
|
footer_blocks = [b for b in page.text_blocks if b.block_type == "footer"]
|
||||||
|
if footer_blocks:
|
||||||
|
min_x = min(b.x for b in footer_blocks)
|
||||||
|
min_y = min(b.y for b in footer_blocks)
|
||||||
|
max_x = max(b.x + b.width for b in footer_blocks)
|
||||||
|
max_y = max(b.y + b.height for b in footer_blocks)
|
||||||
|
footers.append({
|
||||||
|
"page": page.page_number,
|
||||||
|
"x": min_x,
|
||||||
|
"y": min_y,
|
||||||
|
"width": max_x - min_x,
|
||||||
|
"height": max_y - min_y,
|
||||||
|
})
|
||||||
|
if footers:
|
||||||
|
footer_coordinates = {"items": footers}
|
||||||
|
|
||||||
|
# Extract table coordinates
|
||||||
|
table_coordinates = None
|
||||||
|
tables = []
|
||||||
|
for page in document.pages:
|
||||||
|
for table in page.tables:
|
||||||
|
tables.append({
|
||||||
|
"page": page.page_number,
|
||||||
|
"x": table.x,
|
||||||
|
"y": table.y,
|
||||||
|
"width": table.width,
|
||||||
|
"height": table.height,
|
||||||
|
"rows": table.rows,
|
||||||
|
"columns": table.columns,
|
||||||
|
})
|
||||||
|
if tables:
|
||||||
|
table_coordinates = {"items": tables}
|
||||||
|
|
||||||
|
# Extract cell coordinates from text blocks
|
||||||
|
cell_coordinates = None
|
||||||
|
cells = []
|
||||||
|
for page in document.pages:
|
||||||
|
for block in page.text_blocks:
|
||||||
|
if block.block_type == "text":
|
||||||
|
cells.append({
|
||||||
|
"page": page.page_number,
|
||||||
|
"x": block.x,
|
||||||
|
"y": block.y,
|
||||||
|
"width": block.width,
|
||||||
|
"height": block.height,
|
||||||
|
})
|
||||||
|
if cells:
|
||||||
|
cell_coordinates = {"items": cells}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"page_dimensions": page_dimensions,
|
||||||
|
"logo_coordinates": logo_coordinates,
|
||||||
|
"header_coordinates": header_coordinates,
|
||||||
|
"footer_coordinates": footer_coordinates,
|
||||||
|
"table_coordinates": table_coordinates,
|
||||||
|
"cell_coordinates": cell_coordinates,
|
||||||
|
}
|
||||||
210
docengine/app/services/ocr_service.py
Normal file
210
docengine/app/services/ocr_service.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from paddleocr import PaddleOCR
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.document import Document, DocumentPage
|
||||||
|
from app.repositories.document_repository import (
|
||||||
|
DocumentPageRepository,
|
||||||
|
DocumentRepository,
|
||||||
|
DocumentTextBlockRepository,
|
||||||
|
)
|
||||||
|
from app.storage.provider import get_storage_provider
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_ocr_instance: PaddleOCR | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ocr_engine() -> PaddleOCR:
|
||||||
|
"""Get or create singleton PaddleOCR instance."""
|
||||||
|
global _ocr_instance
|
||||||
|
if _ocr_instance is None:
|
||||||
|
_ocr_instance = PaddleOCR(
|
||||||
|
use_angle_cls=True,
|
||||||
|
lang=settings.ocr_language,
|
||||||
|
use_gpu=settings.ocr_use_gpu,
|
||||||
|
show_log=False,
|
||||||
|
det_db_thresh=0.3,
|
||||||
|
det_db_box_thresh=0.5,
|
||||||
|
rec_batch_num=6,
|
||||||
|
)
|
||||||
|
return _ocr_instance
|
||||||
|
|
||||||
|
|
||||||
|
class OCRService:
|
||||||
|
"""OCR processing service using PaddleOCR for scanned documents."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.storage = get_storage_provider()
|
||||||
|
self.doc_repo = DocumentRepository(db)
|
||||||
|
self.page_repo = DocumentPageRepository(db)
|
||||||
|
self.text_block_repo = DocumentTextBlockRepository(db)
|
||||||
|
|
||||||
|
def process_image(self, document: Document) -> Document:
|
||||||
|
"""Process a scanned image document with OCR."""
|
||||||
|
file_path = self.storage.get_absolute_path(document.storage_path)
|
||||||
|
image = cv2.imread(file_path)
|
||||||
|
if image is None:
|
||||||
|
raise ValueError(f"Failed to read image: {file_path}")
|
||||||
|
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
document.page_count = 1
|
||||||
|
document.is_scanned = True
|
||||||
|
|
||||||
|
# Save page image
|
||||||
|
image_filename = f"{document.id}_page_1.png"
|
||||||
|
image_bytes = cv2.imencode(".png", image)[1].tobytes()
|
||||||
|
image_path = self.storage.save_file(image_bytes, "images", image_filename)
|
||||||
|
|
||||||
|
# Create page record
|
||||||
|
doc_page = self.page_repo.create_page(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
width=float(width),
|
||||||
|
height=float(height),
|
||||||
|
image_path=image_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run OCR
|
||||||
|
self._run_ocr_on_page(doc_page, file_path)
|
||||||
|
|
||||||
|
return document
|
||||||
|
|
||||||
|
def process_scanned_pdf(self, document: Document) -> Document:
|
||||||
|
"""Process a scanned PDF document - convert pages to images and OCR each."""
|
||||||
|
file_path = self.storage.get_absolute_path(document.storage_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
images = convert_from_path(file_path, dpi=300)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("pdf_to_image_failed", document_id=str(document.id), error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
document.page_count = len(images)
|
||||||
|
document.is_scanned = True
|
||||||
|
|
||||||
|
for page_num, pil_image in enumerate(images, start=1):
|
||||||
|
# Convert PIL to OpenCV format
|
||||||
|
np_image = np.array(pil_image)
|
||||||
|
cv_image = cv2.cvtColor(np_image, cv2.COLOR_RGB2BGR)
|
||||||
|
height, width = cv_image.shape[:2]
|
||||||
|
|
||||||
|
# Save page image
|
||||||
|
image_filename = f"{document.id}_page_{page_num}.png"
|
||||||
|
image_bytes = cv2.imencode(".png", cv_image)[1].tobytes()
|
||||||
|
image_path = self.storage.save_file(image_bytes, "images", image_filename)
|
||||||
|
|
||||||
|
# Create page record
|
||||||
|
doc_page = self.page_repo.create_page(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=page_num,
|
||||||
|
width=float(width),
|
||||||
|
height=float(height),
|
||||||
|
image_path=image_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run OCR on saved image
|
||||||
|
temp_path = self.storage.get_absolute_path(image_path)
|
||||||
|
self._run_ocr_on_page(doc_page, temp_path)
|
||||||
|
|
||||||
|
return document
|
||||||
|
|
||||||
|
def _run_ocr_on_page(self, doc_page: DocumentPage, image_path: str) -> None:
|
||||||
|
"""Run PaddleOCR on a single page image and store results."""
|
||||||
|
ocr = get_ocr_engine()
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = ocr.ocr(image_path, cls=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ocr_failed", page_id=str(doc_page.id), error=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not results or not results[0]:
|
||||||
|
logger.info("ocr_no_results", page_id=str(doc_page.id))
|
||||||
|
return
|
||||||
|
|
||||||
|
full_text_parts = []
|
||||||
|
sequence = 0
|
||||||
|
|
||||||
|
for line in results[0]:
|
||||||
|
if not line or len(line) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bbox_points = line[0] # List of 4 corner points
|
||||||
|
text_info = line[1] # (text, confidence)
|
||||||
|
|
||||||
|
text = text_info[0] if isinstance(text_info, (list, tuple)) else str(text_info)
|
||||||
|
confidence = float(text_info[1]) if isinstance(text_info, (list, tuple)) and len(text_info) > 1 else 0.0
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert bbox points to x, y, width, height
|
||||||
|
xs = [p[0] for p in bbox_points]
|
||||||
|
ys = [p[1] for p in bbox_points]
|
||||||
|
x = min(xs)
|
||||||
|
y = min(ys)
|
||||||
|
width = max(xs) - x
|
||||||
|
height = max(ys) - y
|
||||||
|
|
||||||
|
# Determine block type based on position
|
||||||
|
block_type = self._classify_text_block(
|
||||||
|
y, height, doc_page.height, text
|
||||||
|
)
|
||||||
|
|
||||||
|
self.text_block_repo.create_text_block(
|
||||||
|
page_id=doc_page.id,
|
||||||
|
text=text,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
confidence=confidence,
|
||||||
|
block_type=block_type,
|
||||||
|
sequence=sequence,
|
||||||
|
)
|
||||||
|
full_text_parts.append(text)
|
||||||
|
sequence += 1
|
||||||
|
|
||||||
|
# Update page text content
|
||||||
|
doc_page.text_content = "\n".join(full_text_parts)
|
||||||
|
|
||||||
|
def _classify_text_block(
|
||||||
|
self,
|
||||||
|
y: float,
|
||||||
|
height: float,
|
||||||
|
page_height: float,
|
||||||
|
text: str,
|
||||||
|
) -> str:
|
||||||
|
"""Classify a text block as header, footer, watermark, or regular text."""
|
||||||
|
if page_height <= 0:
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
relative_y = y / page_height
|
||||||
|
|
||||||
|
# Header: top 10%
|
||||||
|
if relative_y < 0.10:
|
||||||
|
return "header"
|
||||||
|
|
||||||
|
# Footer: bottom 10%
|
||||||
|
if relative_y > 0.90:
|
||||||
|
return "footer"
|
||||||
|
|
||||||
|
# Watermark detection heuristic: large text in center
|
||||||
|
if 0.3 < relative_y < 0.7 and height > page_height * 0.05:
|
||||||
|
# Check for common watermark words
|
||||||
|
watermark_keywords = {"confidential", "draft", "copy", "sample", "watermark", "void"}
|
||||||
|
if text.strip().lower() in watermark_keywords:
|
||||||
|
return "watermark"
|
||||||
|
|
||||||
|
return "text"
|
||||||
292
docengine/app/services/pdf_service.py
Normal file
292
docengine/app/services/pdf_service.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
import numpy as np
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.document import Document, DocumentImage, DocumentPage, DocumentTable, DocumentTextBlock
|
||||||
|
from app.repositories.document_repository import (
|
||||||
|
DocumentImageRepository,
|
||||||
|
DocumentPageRepository,
|
||||||
|
DocumentRepository,
|
||||||
|
DocumentTableRepository,
|
||||||
|
DocumentTextBlockRepository,
|
||||||
|
)
|
||||||
|
from app.storage.provider import get_storage_provider
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NativePDFService:
|
||||||
|
"""Extract text, fonts, images, and layout from native (non-scanned) PDFs using PyMuPDF."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.storage = get_storage_provider()
|
||||||
|
self.doc_repo = DocumentRepository(db)
|
||||||
|
self.page_repo = DocumentPageRepository(db)
|
||||||
|
self.text_block_repo = DocumentTextBlockRepository(db)
|
||||||
|
self.image_repo = DocumentImageRepository(db)
|
||||||
|
self.table_repo = DocumentTableRepository(db)
|
||||||
|
|
||||||
|
def process_pdf(self, document: Document) -> Document:
|
||||||
|
"""Process a native PDF document, extracting all content."""
|
||||||
|
file_path = self.storage.get_absolute_path(document.storage_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_doc = fitz.open(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("pdf_open_failed", document_id=str(document.id), error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
document.page_count = len(pdf_doc)
|
||||||
|
document.is_scanned = self._is_scanned_pdf(pdf_doc)
|
||||||
|
|
||||||
|
for page_num in range(len(pdf_doc)):
|
||||||
|
page = pdf_doc[page_num]
|
||||||
|
self._process_page(document, page, page_num + 1)
|
||||||
|
|
||||||
|
pdf_doc.close()
|
||||||
|
return document
|
||||||
|
|
||||||
|
def _is_scanned_pdf(self, pdf_doc: fitz.Document) -> bool:
|
||||||
|
"""Determine if a PDF is scanned (image-based) or native."""
|
||||||
|
total_text_chars = 0
|
||||||
|
total_images = 0
|
||||||
|
for page_num in range(min(len(pdf_doc), 3)):
|
||||||
|
page = pdf_doc[page_num]
|
||||||
|
text = page.get_text("text")
|
||||||
|
total_text_chars += len(text.strip())
|
||||||
|
total_images += len(page.get_images(full=True))
|
||||||
|
|
||||||
|
# If very little text but has images, likely scanned
|
||||||
|
if total_text_chars < 50 and total_images > 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _process_page(self, document: Document, page: fitz.Page, page_number: int) -> DocumentPage:
|
||||||
|
"""Process a single PDF page."""
|
||||||
|
rect = page.rect
|
||||||
|
width = rect.width
|
||||||
|
height = rect.height
|
||||||
|
|
||||||
|
# Save page as image for reference
|
||||||
|
pix = page.get_pixmap(dpi=150)
|
||||||
|
image_filename = f"{document.id}_page_{page_number}.png"
|
||||||
|
image_data = pix.tobytes("png")
|
||||||
|
image_path = self.storage.save_file(image_data, "images", image_filename)
|
||||||
|
|
||||||
|
# Get full text content
|
||||||
|
text_content = page.get_text("text")
|
||||||
|
|
||||||
|
doc_page = self.page_repo.create_page(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=page_number,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
image_path=image_path,
|
||||||
|
text_content=text_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract text blocks with font information
|
||||||
|
self._extract_text_blocks(doc_page, page)
|
||||||
|
|
||||||
|
# Extract images
|
||||||
|
self._extract_images(doc_page, page, document)
|
||||||
|
|
||||||
|
# Detect headers and footers
|
||||||
|
self._detect_headers_footers(doc_page, page)
|
||||||
|
|
||||||
|
return doc_page
|
||||||
|
|
||||||
|
def _extract_text_blocks(self, doc_page: DocumentPage, page: fitz.Page) -> None:
|
||||||
|
"""Extract text blocks with positioning and font information."""
|
||||||
|
blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
|
||||||
|
sequence = 0
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
if block["type"] != 0: # Skip non-text blocks
|
||||||
|
continue
|
||||||
|
|
||||||
|
block_text_parts = []
|
||||||
|
font_info = {"family": None, "size": None, "color": None, "style": None}
|
||||||
|
|
||||||
|
for line in block.get("lines", []):
|
||||||
|
for span in line.get("spans", []):
|
||||||
|
text = span.get("text", "").strip()
|
||||||
|
if text:
|
||||||
|
block_text_parts.append(text)
|
||||||
|
# Capture font info from the first non-empty span
|
||||||
|
if font_info["family"] is None:
|
||||||
|
font_info["family"] = span.get("font", None)
|
||||||
|
font_info["size"] = span.get("size", None)
|
||||||
|
color_int = span.get("color", 0)
|
||||||
|
font_info["color"] = f"#{color_int:06x}" if isinstance(color_int, int) else None
|
||||||
|
flags = span.get("flags", 0)
|
||||||
|
styles = []
|
||||||
|
if flags & 1:
|
||||||
|
styles.append("superscript")
|
||||||
|
if flags & 2:
|
||||||
|
styles.append("italic")
|
||||||
|
if flags & 4:
|
||||||
|
styles.append("serif")
|
||||||
|
if flags & 8:
|
||||||
|
styles.append("monospace")
|
||||||
|
if flags & 16:
|
||||||
|
styles.append("bold")
|
||||||
|
font_info["style"] = ",".join(styles) if styles else "regular"
|
||||||
|
|
||||||
|
full_text = " ".join(block_text_parts)
|
||||||
|
if not full_text.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
bbox = block["bbox"]
|
||||||
|
self.text_block_repo.create_text_block(
|
||||||
|
page_id=doc_page.id,
|
||||||
|
text=full_text,
|
||||||
|
x=bbox[0],
|
||||||
|
y=bbox[1],
|
||||||
|
width=bbox[2] - bbox[0],
|
||||||
|
height=bbox[3] - bbox[1],
|
||||||
|
font_family=font_info["family"],
|
||||||
|
font_size=font_info["size"],
|
||||||
|
font_color=font_info["color"],
|
||||||
|
font_style=font_info["style"],
|
||||||
|
block_type="text",
|
||||||
|
sequence=sequence,
|
||||||
|
)
|
||||||
|
sequence += 1
|
||||||
|
|
||||||
|
def _extract_images(self, doc_page: DocumentPage, page: fitz.Page, document: Document) -> None:
|
||||||
|
"""Extract embedded images from a PDF page."""
|
||||||
|
image_list = page.get_images(full=True)
|
||||||
|
|
||||||
|
for img_index, img_info in enumerate(image_list):
|
||||||
|
xref = img_info[0]
|
||||||
|
try:
|
||||||
|
base_image = page.parent.extract_image(xref)
|
||||||
|
if not base_image:
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_bytes = base_image["image"]
|
||||||
|
ext = base_image.get("ext", "png")
|
||||||
|
img_filename = f"{document.id}_page_{doc_page.page_number}_img_{img_index}.{ext}"
|
||||||
|
img_storage_path = self.storage.save_file(image_bytes, "images", img_filename)
|
||||||
|
|
||||||
|
# Try to get image position on page
|
||||||
|
img_rects = page.get_image_rects(xref)
|
||||||
|
if img_rects:
|
||||||
|
rect = img_rects[0]
|
||||||
|
x, y, x1, y1 = rect.x0, rect.y0, rect.x1, rect.y1
|
||||||
|
else:
|
||||||
|
x, y, x1, y1 = 0, 0, base_image.get("width", 100), base_image.get("height", 100)
|
||||||
|
|
||||||
|
# Determine image type based on position
|
||||||
|
page_height = doc_page.height
|
||||||
|
page_width = doc_page.width
|
||||||
|
image_type = self._classify_image_type(x, y, x1, y1, page_width, page_height)
|
||||||
|
|
||||||
|
self.image_repo.create_image(
|
||||||
|
page_id=doc_page.id,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=x1 - x,
|
||||||
|
height=y1 - y,
|
||||||
|
image_path=img_storage_path,
|
||||||
|
image_type=image_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"image_extraction_failed",
|
||||||
|
page_id=str(doc_page.id),
|
||||||
|
img_index=img_index,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _classify_image_type(
|
||||||
|
self,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
page_width: float,
|
||||||
|
page_height: float,
|
||||||
|
) -> str:
|
||||||
|
"""Classify an image as logo, figure, background, or stamp based on position and size."""
|
||||||
|
width = x1 - x
|
||||||
|
height = y1 - y
|
||||||
|
area_ratio = (width * height) / (page_width * page_height) if page_width > 0 and page_height > 0 else 0
|
||||||
|
|
||||||
|
# Background: covers most of the page
|
||||||
|
if area_ratio > 0.8:
|
||||||
|
return "background"
|
||||||
|
|
||||||
|
# Logo: small image in top portion
|
||||||
|
if y < page_height * 0.15 and area_ratio < 0.05:
|
||||||
|
return "logo"
|
||||||
|
|
||||||
|
# Stamp: small image in bottom-right
|
||||||
|
if x > page_width * 0.6 and y > page_height * 0.7 and area_ratio < 0.05:
|
||||||
|
return "stamp"
|
||||||
|
|
||||||
|
return "figure"
|
||||||
|
|
||||||
|
def _detect_headers_footers(self, doc_page: DocumentPage, page: fitz.Page) -> None:
|
||||||
|
"""Detect header and footer regions based on vertical position."""
|
||||||
|
page_height = page.rect.height
|
||||||
|
header_threshold = page_height * 0.1
|
||||||
|
footer_threshold = page_height * 0.9
|
||||||
|
|
||||||
|
blocks = page.get_text("dict")["blocks"]
|
||||||
|
header_seq = 0
|
||||||
|
footer_seq = 0
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
if block["type"] != 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bbox = block["bbox"]
|
||||||
|
block_y = bbox[1]
|
||||||
|
|
||||||
|
text_parts = []
|
||||||
|
for line in block.get("lines", []):
|
||||||
|
for span in line.get("spans", []):
|
||||||
|
t = span.get("text", "").strip()
|
||||||
|
if t:
|
||||||
|
text_parts.append(t)
|
||||||
|
|
||||||
|
full_text = " ".join(text_parts)
|
||||||
|
if not full_text.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if block_y < header_threshold:
|
||||||
|
self.text_block_repo.create_text_block(
|
||||||
|
page_id=doc_page.id,
|
||||||
|
text=full_text,
|
||||||
|
x=bbox[0],
|
||||||
|
y=bbox[1],
|
||||||
|
width=bbox[2] - bbox[0],
|
||||||
|
height=bbox[3] - bbox[1],
|
||||||
|
block_type="header",
|
||||||
|
sequence=header_seq,
|
||||||
|
)
|
||||||
|
header_seq += 1
|
||||||
|
elif block_y > footer_threshold:
|
||||||
|
self.text_block_repo.create_text_block(
|
||||||
|
page_id=doc_page.id,
|
||||||
|
text=full_text,
|
||||||
|
x=bbox[0],
|
||||||
|
y=bbox[1],
|
||||||
|
width=bbox[2] - bbox[0],
|
||||||
|
height=bbox[3] - bbox[1],
|
||||||
|
block_type="footer",
|
||||||
|
sequence=footer_seq,
|
||||||
|
)
|
||||||
|
footer_seq += 1
|
||||||
455
docengine/app/services/reconstruction_service.py
Normal file
455
docengine/app/services/reconstruction_service.py
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import letter
|
||||||
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||||
|
from reportlab.lib.units import inch, mm
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
from reportlab.platypus import (
|
||||||
|
BaseDocTemplate,
|
||||||
|
Frame,
|
||||||
|
Image,
|
||||||
|
NextPageTemplate,
|
||||||
|
PageBreak,
|
||||||
|
PageTemplate,
|
||||||
|
Paragraph,
|
||||||
|
SimpleDocTemplate,
|
||||||
|
Spacer,
|
||||||
|
Table,
|
||||||
|
TableStyle,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.template import DocumentFormat
|
||||||
|
from app.schemas.template import TemplateRenderResponse
|
||||||
|
from app.storage.provider import get_storage_provider
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ReconstructionService:
|
||||||
|
"""Reconstruct documents from templates using ReportLab."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.storage = get_storage_provider()
|
||||||
|
self.styles = getSampleStyleSheet()
|
||||||
|
self._register_fonts()
|
||||||
|
|
||||||
|
def _register_fonts(self) -> None:
|
||||||
|
"""Register additional fonts if available."""
|
||||||
|
# ReportLab includes Helvetica, Times-Roman, Courier by default
|
||||||
|
# Custom fonts can be registered here
|
||||||
|
pass
|
||||||
|
|
||||||
|
def render_template(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
data: dict[str, Any],
|
||||||
|
output_filename: str | None = None,
|
||||||
|
images: dict[str, str] | None = None,
|
||||||
|
) -> TemplateRenderResponse:
|
||||||
|
"""Render a template to PDF with supplied data."""
|
||||||
|
if output_filename is None:
|
||||||
|
output_filename = f"{template.name}_{uuid.uuid4().hex[:8]}.pdf"
|
||||||
|
|
||||||
|
if not output_filename.endswith(".pdf"):
|
||||||
|
output_filename += ".pdf"
|
||||||
|
|
||||||
|
# Determine output path
|
||||||
|
output_storage_path = f"rendered/{output_filename}"
|
||||||
|
absolute_output_path = self.storage.get_absolute_path(output_storage_path)
|
||||||
|
|
||||||
|
# Ensure the rendered directory exists
|
||||||
|
os.makedirs(os.path.dirname(absolute_output_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Build the PDF
|
||||||
|
self._build_pdf(template, data, absolute_output_path, images)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(absolute_output_path)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"template_rendered",
|
||||||
|
template_id=str(template.id),
|
||||||
|
output=output_filename,
|
||||||
|
size=file_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TemplateRenderResponse(
|
||||||
|
output_path=output_storage_path,
|
||||||
|
filename=output_filename,
|
||||||
|
file_size=file_size,
|
||||||
|
page_count=template.page_count,
|
||||||
|
rendered_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_pdf(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
data: dict[str, Any],
|
||||||
|
output_path: str,
|
||||||
|
images: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Build a PDF document from template definition."""
|
||||||
|
page_width = template.page_width
|
||||||
|
page_height = template.page_height
|
||||||
|
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
output_path,
|
||||||
|
pagesize=(page_width, page_height),
|
||||||
|
topMargin=template.margin_top,
|
||||||
|
rightMargin=template.margin_right,
|
||||||
|
bottomMargin=template.margin_bottom,
|
||||||
|
leftMargin=template.margin_left,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build story (content elements)
|
||||||
|
story: list[Any] = []
|
||||||
|
|
||||||
|
for page_num in range(1, template.page_count + 1):
|
||||||
|
if page_num > 1:
|
||||||
|
story.append(PageBreak())
|
||||||
|
|
||||||
|
# Add page content
|
||||||
|
page_elements = self._build_page_content(template, page_num, data, images)
|
||||||
|
story.extend(page_elements)
|
||||||
|
|
||||||
|
# Build with watermark/header/footer callbacks
|
||||||
|
def on_page(canvas, doc_obj): # noqa: ANN001, ANN202
|
||||||
|
self._draw_watermarks(canvas, template, doc_obj.page)
|
||||||
|
self._draw_headers_footers(canvas, template, doc_obj.page, page_width, page_height)
|
||||||
|
|
||||||
|
def on_page_later(canvas, doc_obj): # noqa: ANN001, ANN202
|
||||||
|
self._draw_watermarks(canvas, template, doc_obj.page)
|
||||||
|
self._draw_headers_footers(canvas, template, doc_obj.page, page_width, page_height)
|
||||||
|
|
||||||
|
doc.build(story, onFirstPage=on_page, onLaterPages=on_page_later)
|
||||||
|
|
||||||
|
def _build_page_content(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
page_number: int,
|
||||||
|
data: dict[str, Any],
|
||||||
|
images: dict[str, str] | None = None,
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Build content elements for a specific page."""
|
||||||
|
elements: list[Any] = []
|
||||||
|
|
||||||
|
# Get cells for this page, sorted by sequence
|
||||||
|
page_cells = sorted(
|
||||||
|
[c for c in template.cells if c.page_number == page_number],
|
||||||
|
key=lambda c: c.sequence,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get table formats for this page
|
||||||
|
page_tables = [t for t in template.table_formats if t.page_number == page_number]
|
||||||
|
|
||||||
|
# Get image regions for this page
|
||||||
|
page_images = [i for i in template.image_regions if i.page_number == page_number]
|
||||||
|
|
||||||
|
# Add static and dynamic text cells
|
||||||
|
for cell in page_cells:
|
||||||
|
text = self._resolve_cell_text(cell, data)
|
||||||
|
if text:
|
||||||
|
style = self._create_cell_style(cell)
|
||||||
|
para = Paragraph(text, style)
|
||||||
|
elements.append(para)
|
||||||
|
elements.append(Spacer(1, 2))
|
||||||
|
|
||||||
|
# Add tables
|
||||||
|
for table_format in page_tables:
|
||||||
|
table_element = self._build_table(table_format, data)
|
||||||
|
if table_element:
|
||||||
|
elements.append(table_element)
|
||||||
|
elements.append(Spacer(1, 6))
|
||||||
|
|
||||||
|
# Add images
|
||||||
|
for img_region in page_images:
|
||||||
|
img_element = self._build_image(img_region, images)
|
||||||
|
if img_element:
|
||||||
|
elements.append(img_element)
|
||||||
|
elements.append(Spacer(1, 6))
|
||||||
|
|
||||||
|
if not elements:
|
||||||
|
elements.append(Spacer(1, 12))
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
def _resolve_cell_text(self, cell: Any, data: dict[str, Any]) -> str:
|
||||||
|
"""Resolve cell text from static content or dynamic data."""
|
||||||
|
if cell.is_dynamic and cell.field_name:
|
||||||
|
value = data.get(cell.field_name, "")
|
||||||
|
return str(value) if value else ""
|
||||||
|
return cell.static_text or ""
|
||||||
|
|
||||||
|
def _create_cell_style(self, cell: Any) -> ParagraphStyle:
|
||||||
|
"""Create a ReportLab paragraph style from cell properties."""
|
||||||
|
font_name = "Helvetica"
|
||||||
|
if cell.font_family:
|
||||||
|
family = cell.font_family.lower()
|
||||||
|
if "times" in family or "serif" in family:
|
||||||
|
font_name = "Times-Roman"
|
||||||
|
elif "courier" in family or "mono" in family:
|
||||||
|
font_name = "Courier"
|
||||||
|
|
||||||
|
font_size = cell.font_size or 10
|
||||||
|
if cell.font_style and "bold" in (cell.font_style or ""):
|
||||||
|
if font_name == "Helvetica":
|
||||||
|
font_name = "Helvetica-Bold"
|
||||||
|
elif font_name == "Times-Roman":
|
||||||
|
font_name = "Times-Bold"
|
||||||
|
elif font_name == "Courier":
|
||||||
|
font_name = "Courier-Bold"
|
||||||
|
|
||||||
|
text_color = colors.black
|
||||||
|
if cell.font_color:
|
||||||
|
try:
|
||||||
|
text_color = colors.HexColor(cell.font_color)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
text_color = colors.black
|
||||||
|
|
||||||
|
alignment_map = {"left": 0, "center": 1, "right": 2, "justify": 4}
|
||||||
|
alignment = alignment_map.get(cell.alignment, 0)
|
||||||
|
|
||||||
|
style = ParagraphStyle(
|
||||||
|
name=f"cell_{cell.id}",
|
||||||
|
parent=self.styles["Normal"],
|
||||||
|
fontName=font_name,
|
||||||
|
fontSize=font_size,
|
||||||
|
textColor=text_color,
|
||||||
|
alignment=alignment,
|
||||||
|
leading=font_size * 1.2,
|
||||||
|
spaceBefore=cell.padding_top,
|
||||||
|
spaceAfter=cell.padding_bottom,
|
||||||
|
leftIndent=cell.padding_left,
|
||||||
|
rightIndent=cell.padding_right,
|
||||||
|
)
|
||||||
|
|
||||||
|
return style
|
||||||
|
|
||||||
|
def _build_table(self, table_format: Any, data: dict[str, Any]) -> Table | None:
|
||||||
|
"""Build a ReportLab table from a table format definition."""
|
||||||
|
rows = table_format.rows
|
||||||
|
columns = table_format.columns
|
||||||
|
|
||||||
|
if rows <= 0 or columns <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build table data
|
||||||
|
table_data: list[list[str]] = []
|
||||||
|
|
||||||
|
# Header row
|
||||||
|
if table_format.table_columns:
|
||||||
|
header_row = [col.header_text or f"Col {col.column_index + 1}" for col in table_format.table_columns]
|
||||||
|
table_data.append(header_row)
|
||||||
|
else:
|
||||||
|
table_data.append([f"Column {i + 1}" for i in range(columns)])
|
||||||
|
|
||||||
|
# Data rows from supplied data
|
||||||
|
table_field_name = f"table_{table_format.id}"
|
||||||
|
table_rows_data = data.get(table_field_name, data.get("table_data", []))
|
||||||
|
|
||||||
|
if isinstance(table_rows_data, list):
|
||||||
|
for row_data in table_rows_data:
|
||||||
|
if isinstance(row_data, list):
|
||||||
|
# Pad or trim to match column count
|
||||||
|
row = row_data[:columns]
|
||||||
|
while len(row) < columns:
|
||||||
|
row.append("")
|
||||||
|
table_data.append([str(v) for v in row])
|
||||||
|
elif isinstance(row_data, dict):
|
||||||
|
row = []
|
||||||
|
for col in table_format.table_columns:
|
||||||
|
key = col.header_text or f"col_{col.column_index}"
|
||||||
|
row.append(str(row_data.get(key, "")))
|
||||||
|
table_data.append(row)
|
||||||
|
|
||||||
|
# If no data rows, add empty rows
|
||||||
|
if len(table_data) <= 1:
|
||||||
|
for _ in range(max(rows - 1, 1)):
|
||||||
|
table_data.append([""] * columns)
|
||||||
|
|
||||||
|
# Determine column widths
|
||||||
|
col_widths = []
|
||||||
|
if table_format.table_columns:
|
||||||
|
col_widths = [col.width for col in table_format.table_columns]
|
||||||
|
else:
|
||||||
|
col_width = table_format.width / columns
|
||||||
|
col_widths = [col_width] * columns
|
||||||
|
|
||||||
|
# Build table
|
||||||
|
table = Table(table_data, colWidths=col_widths)
|
||||||
|
|
||||||
|
# Apply table style
|
||||||
|
border_color = colors.black
|
||||||
|
if table_format.border_color:
|
||||||
|
try:
|
||||||
|
border_color = colors.HexColor(table_format.border_color)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
style_commands = [
|
||||||
|
("GRID", (0, 0), (-1, -1), table_format.border_width, border_color),
|
||||||
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||||
|
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||||
|
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Header row background
|
||||||
|
if table_format.table_rows:
|
||||||
|
for row in table_format.table_rows:
|
||||||
|
if row.is_header and row.background_color:
|
||||||
|
try:
|
||||||
|
bg_color = colors.HexColor(row.background_color)
|
||||||
|
style_commands.append(
|
||||||
|
("BACKGROUND", (0, row.row_index), (-1, row.row_index), bg_color)
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
style_commands.append(("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E0E0E0")))
|
||||||
|
|
||||||
|
table.setStyle(TableStyle(style_commands))
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
def _build_image(self, img_region: Any, images: dict[str, str] | None = None) -> Image | None:
|
||||||
|
"""Build a ReportLab image from an image region definition."""
|
||||||
|
image_path = None
|
||||||
|
|
||||||
|
# Check dynamic images first
|
||||||
|
if not img_region.is_static and img_region.field_name and images:
|
||||||
|
image_path = images.get(img_region.field_name)
|
||||||
|
|
||||||
|
# Fall back to stored image
|
||||||
|
if not image_path and img_region.image_path:
|
||||||
|
try:
|
||||||
|
image_path = self.storage.get_absolute_path(img_region.image_path)
|
||||||
|
except Exception:
|
||||||
|
image_path = None
|
||||||
|
|
||||||
|
if not image_path or not os.path.exists(image_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image(image_path, width=img_region.width, height=img_region.height)
|
||||||
|
return img
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("image_build_failed", error=str(e), path=image_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _draw_watermarks(self, canvas: Any, template: DocumentFormat, current_page: int) -> None:
|
||||||
|
"""Draw watermarks on the canvas."""
|
||||||
|
for watermark in template.watermarks:
|
||||||
|
# Apply to all pages if page_number is None, or specific page
|
||||||
|
if watermark.page_number is not None and watermark.page_number != current_page:
|
||||||
|
continue
|
||||||
|
|
||||||
|
canvas.saveState()
|
||||||
|
|
||||||
|
# Set opacity
|
||||||
|
canvas.setFillAlpha(watermark.opacity)
|
||||||
|
|
||||||
|
if watermark.text:
|
||||||
|
# Text watermark
|
||||||
|
font_name = "Helvetica"
|
||||||
|
if watermark.font_family:
|
||||||
|
family = watermark.font_family.lower()
|
||||||
|
if "times" in family:
|
||||||
|
font_name = "Times-Roman"
|
||||||
|
elif "courier" in family:
|
||||||
|
font_name = "Courier"
|
||||||
|
|
||||||
|
font_size = watermark.font_size or 48
|
||||||
|
|
||||||
|
if watermark.font_color:
|
||||||
|
try:
|
||||||
|
canvas.setFillColor(colors.HexColor(watermark.font_color))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
canvas.setFillColor(colors.grey)
|
||||||
|
else:
|
||||||
|
canvas.setFillColor(colors.grey)
|
||||||
|
|
||||||
|
canvas.setFont(font_name, font_size)
|
||||||
|
|
||||||
|
# Position and rotate
|
||||||
|
canvas.translate(
|
||||||
|
watermark.x + watermark.width / 2,
|
||||||
|
watermark.y + watermark.height / 2,
|
||||||
|
)
|
||||||
|
canvas.rotate(watermark.rotation)
|
||||||
|
canvas.drawCentredString(0, 0, watermark.text)
|
||||||
|
|
||||||
|
elif watermark.image_path:
|
||||||
|
# Image watermark
|
||||||
|
try:
|
||||||
|
img_path = self.storage.get_absolute_path(watermark.image_path)
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
canvas.drawImage(
|
||||||
|
img_path,
|
||||||
|
watermark.x,
|
||||||
|
watermark.y,
|
||||||
|
width=watermark.width,
|
||||||
|
height=watermark.height,
|
||||||
|
mask="auto",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("watermark_image_failed", error=str(e))
|
||||||
|
|
||||||
|
canvas.restoreState()
|
||||||
|
|
||||||
|
def _draw_headers_footers(
|
||||||
|
self,
|
||||||
|
canvas: Any,
|
||||||
|
template: DocumentFormat,
|
||||||
|
current_page: int,
|
||||||
|
page_width: float,
|
||||||
|
page_height: float,
|
||||||
|
) -> None:
|
||||||
|
"""Draw header and footer regions on the canvas."""
|
||||||
|
for region in template.regions:
|
||||||
|
if region.page_number != current_page:
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = region.content or {}
|
||||||
|
blocks = content.get("blocks", [])
|
||||||
|
|
||||||
|
canvas.saveState()
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
text = block.get("text", "")
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
x = block.get("x", region.x)
|
||||||
|
y = page_height - block.get("y", region.y) - block.get("height", 12)
|
||||||
|
font_family = block.get("font_family", "Helvetica")
|
||||||
|
font_size = block.get("font_size", 10)
|
||||||
|
|
||||||
|
# Map font family
|
||||||
|
font_name = "Helvetica"
|
||||||
|
if font_family:
|
||||||
|
fl = font_family.lower()
|
||||||
|
if "times" in fl or "serif" in fl:
|
||||||
|
font_name = "Times-Roman"
|
||||||
|
elif "courier" in fl or "mono" in fl:
|
||||||
|
font_name = "Courier"
|
||||||
|
|
||||||
|
canvas.setFont(font_name, font_size)
|
||||||
|
canvas.setFillColor(colors.black)
|
||||||
|
canvas.drawString(x, y, text)
|
||||||
|
|
||||||
|
canvas.restoreState()
|
||||||
401
docengine/app/services/template_service.py
Normal file
401
docengine/app/services/template_service.py
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.models.document import Document
|
||||||
|
from app.models.template import (
|
||||||
|
DocumentCell,
|
||||||
|
DocumentFormat,
|
||||||
|
DocumentRegion,
|
||||||
|
ImageRegion,
|
||||||
|
TableColumn,
|
||||||
|
TableFormat,
|
||||||
|
TableRow,
|
||||||
|
Watermark,
|
||||||
|
)
|
||||||
|
from app.repositories.document_repository import DocumentRepository
|
||||||
|
from app.repositories.template_repository import (
|
||||||
|
DocumentCellRepository,
|
||||||
|
DocumentRegionRepository,
|
||||||
|
ImageRegionRepository,
|
||||||
|
TableColumnRepository,
|
||||||
|
TableFormatRepository,
|
||||||
|
TableRowRepository,
|
||||||
|
TemplateRepository,
|
||||||
|
WatermarkRepository,
|
||||||
|
)
|
||||||
|
from app.services.fingerprint_service import FingerprintService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateService:
|
||||||
|
"""Generate reusable document templates from processed documents."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.doc_repo = DocumentRepository(db)
|
||||||
|
self.template_repo = TemplateRepository(db)
|
||||||
|
self.cell_repo = DocumentCellRepository(db)
|
||||||
|
self.region_repo = DocumentRegionRepository(db)
|
||||||
|
self.table_format_repo = TableFormatRepository(db)
|
||||||
|
self.table_column_repo = TableColumnRepository(db)
|
||||||
|
self.table_row_repo = TableRowRepository(db)
|
||||||
|
self.watermark_repo = WatermarkRepository(db)
|
||||||
|
self.image_region_repo = ImageRegionRepository(db)
|
||||||
|
self.fingerprint_service = FingerprintService(db)
|
||||||
|
|
||||||
|
def generate_template(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
user_id: uuid.UUID | None = None,
|
||||||
|
) -> DocumentFormat:
|
||||||
|
"""Generate a reusable template from a processed document."""
|
||||||
|
if not document.pages:
|
||||||
|
raise ValueError(f"Document '{document.id}' has no processed pages")
|
||||||
|
|
||||||
|
# Check if template already exists for this document
|
||||||
|
existing = self.template_repo.get_by_source_document(document.id)
|
||||||
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
"template_already_exists",
|
||||||
|
document_id=str(document.id),
|
||||||
|
template_id=str(existing.id),
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
first_page = document.pages[0]
|
||||||
|
template_name = f"Template_{document.original_filename}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
# Create template
|
||||||
|
template = self.template_repo.create_template(
|
||||||
|
name=template_name,
|
||||||
|
page_width=first_page.width,
|
||||||
|
page_height=first_page.height,
|
||||||
|
page_count=document.page_count or len(document.pages),
|
||||||
|
description=f"Auto-generated template from {document.original_filename}",
|
||||||
|
source_document_id=document.id,
|
||||||
|
created_by=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process each page
|
||||||
|
for page in document.pages:
|
||||||
|
self._process_page_for_template(template, page)
|
||||||
|
|
||||||
|
# Generate fingerprint
|
||||||
|
self.fingerprint_service.generate_fingerprint(template)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"template_generated",
|
||||||
|
template_id=str(template.id),
|
||||||
|
document_id=str(document.id),
|
||||||
|
cells=len(template.cells),
|
||||||
|
regions=len(template.regions),
|
||||||
|
)
|
||||||
|
|
||||||
|
return template
|
||||||
|
|
||||||
|
def _process_page_for_template(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
page: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Process a document page and create template components."""
|
||||||
|
page_number = page.page_number
|
||||||
|
|
||||||
|
# Create cells from text blocks
|
||||||
|
self._create_cells_from_text_blocks(template, page, page_number)
|
||||||
|
|
||||||
|
# Create regions from headers, footers
|
||||||
|
self._create_regions(template, page, page_number)
|
||||||
|
|
||||||
|
# Create table formats
|
||||||
|
self._create_table_formats(template, page, page_number)
|
||||||
|
|
||||||
|
# Create image regions
|
||||||
|
self._create_image_regions(template, page, page_number)
|
||||||
|
|
||||||
|
# Detect watermarks from text blocks
|
||||||
|
self._create_watermarks(template, page, page_number)
|
||||||
|
|
||||||
|
def _create_cells_from_text_blocks(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
page: Any,
|
||||||
|
page_number: int,
|
||||||
|
) -> None:
|
||||||
|
"""Create template cells from extracted text blocks."""
|
||||||
|
for seq, block in enumerate(page.text_blocks):
|
||||||
|
if block.block_type in ("header", "footer", "watermark"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine if this is a dynamic field
|
||||||
|
is_dynamic = self._is_dynamic_field(block.text)
|
||||||
|
field_name = self._generate_field_name(block.text, seq) if is_dynamic else None
|
||||||
|
|
||||||
|
self.cell_repo.create_cell(
|
||||||
|
format_id=template.id,
|
||||||
|
page_number=page_number,
|
||||||
|
x=block.x,
|
||||||
|
y=block.y,
|
||||||
|
width=block.width,
|
||||||
|
height=block.height,
|
||||||
|
data_type=self._infer_data_type(block.text),
|
||||||
|
font_family=block.font_family,
|
||||||
|
font_size=block.font_size,
|
||||||
|
font_style=block.font_style,
|
||||||
|
font_color=block.font_color,
|
||||||
|
alignment=self._infer_alignment(block.x, template.page_width),
|
||||||
|
static_text=block.text if not is_dynamic else None,
|
||||||
|
field_name=field_name,
|
||||||
|
sequence=seq,
|
||||||
|
is_dynamic=is_dynamic,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_regions(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
page: Any,
|
||||||
|
page_number: int,
|
||||||
|
) -> None:
|
||||||
|
"""Create template regions from headers and footers."""
|
||||||
|
header_blocks = [b for b in page.text_blocks if b.block_type == "header"]
|
||||||
|
footer_blocks = [b for b in page.text_blocks if b.block_type == "footer"]
|
||||||
|
|
||||||
|
if header_blocks:
|
||||||
|
# Compute bounding box for all header blocks
|
||||||
|
min_x = min(b.x for b in header_blocks)
|
||||||
|
min_y = min(b.y for b in header_blocks)
|
||||||
|
max_x = max(b.x + b.width for b in header_blocks)
|
||||||
|
max_y = max(b.y + b.height for b in header_blocks)
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"text": b.text,
|
||||||
|
"x": b.x,
|
||||||
|
"y": b.y,
|
||||||
|
"width": b.width,
|
||||||
|
"height": b.height,
|
||||||
|
"font_family": b.font_family,
|
||||||
|
"font_size": b.font_size,
|
||||||
|
}
|
||||||
|
for b in header_blocks
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.region_repo.create_region(
|
||||||
|
format_id=template.id,
|
||||||
|
page_number=page_number,
|
||||||
|
region_type="header",
|
||||||
|
x=min_x,
|
||||||
|
y=min_y,
|
||||||
|
width=max_x - min_x,
|
||||||
|
height=max_y - min_y,
|
||||||
|
content=content,
|
||||||
|
sequence=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if footer_blocks:
|
||||||
|
min_x = min(b.x for b in footer_blocks)
|
||||||
|
min_y = min(b.y for b in footer_blocks)
|
||||||
|
max_x = max(b.x + b.width for b in footer_blocks)
|
||||||
|
max_y = max(b.y + b.height for b in footer_blocks)
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"text": b.text,
|
||||||
|
"x": b.x,
|
||||||
|
"y": b.y,
|
||||||
|
"width": b.width,
|
||||||
|
"height": b.height,
|
||||||
|
"font_family": b.font_family,
|
||||||
|
"font_size": b.font_size,
|
||||||
|
}
|
||||||
|
for b in footer_blocks
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.region_repo.create_region(
|
||||||
|
format_id=template.id,
|
||||||
|
page_number=page_number,
|
||||||
|
region_type="footer",
|
||||||
|
x=min_x,
|
||||||
|
y=min_y,
|
||||||
|
width=max_x - min_x,
|
||||||
|
height=max_y - min_y,
|
||||||
|
content=content,
|
||||||
|
sequence=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_table_formats(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
page: Any,
|
||||||
|
page_number: int,
|
||||||
|
) -> None:
|
||||||
|
"""Create table format definitions from detected tables."""
|
||||||
|
for table in page.tables:
|
||||||
|
table_format = self.table_format_repo.create_table_format(
|
||||||
|
format_id=template.id,
|
||||||
|
page_number=page_number,
|
||||||
|
x=table.x,
|
||||||
|
y=table.y,
|
||||||
|
width=table.width,
|
||||||
|
height=table.height,
|
||||||
|
rows=table.rows,
|
||||||
|
columns=table.columns,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create columns
|
||||||
|
col_width = table.width / max(table.columns, 1)
|
||||||
|
for col_idx in range(table.columns):
|
||||||
|
self.table_column_repo.create_column(
|
||||||
|
table_format_id=table_format.id,
|
||||||
|
column_index=col_idx,
|
||||||
|
width=col_width,
|
||||||
|
data_type="text",
|
||||||
|
alignment="left",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create rows
|
||||||
|
row_height = table.height / max(table.rows, 1)
|
||||||
|
for row_idx in range(table.rows):
|
||||||
|
self.table_row_repo.create_row(
|
||||||
|
table_format_id=table_format.id,
|
||||||
|
row_index=row_idx,
|
||||||
|
height=row_height,
|
||||||
|
is_header=(row_idx == 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_image_regions(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
page: Any,
|
||||||
|
page_number: int,
|
||||||
|
) -> None:
|
||||||
|
"""Create image region definitions from detected images."""
|
||||||
|
for img in page.images:
|
||||||
|
self.image_region_repo.create_image_region(
|
||||||
|
format_id=template.id,
|
||||||
|
page_number=page_number,
|
||||||
|
x=img.x,
|
||||||
|
y=img.y,
|
||||||
|
width=img.width,
|
||||||
|
height=img.height,
|
||||||
|
image_path=img.image_path,
|
||||||
|
image_type=img.image_type,
|
||||||
|
is_static=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_watermarks(
|
||||||
|
self,
|
||||||
|
template: DocumentFormat,
|
||||||
|
page: Any,
|
||||||
|
page_number: int,
|
||||||
|
) -> None:
|
||||||
|
"""Create watermark definitions from detected watermark text blocks."""
|
||||||
|
watermark_blocks = [b for b in page.text_blocks if b.block_type == "watermark"]
|
||||||
|
for block in watermark_blocks:
|
||||||
|
self.watermark_repo.create_watermark(
|
||||||
|
format_id=template.id,
|
||||||
|
page_number=page_number,
|
||||||
|
text=block.text,
|
||||||
|
x=block.x,
|
||||||
|
y=block.y,
|
||||||
|
width=block.width,
|
||||||
|
height=block.height,
|
||||||
|
opacity=0.3,
|
||||||
|
rotation=0.0,
|
||||||
|
font_family=block.font_family,
|
||||||
|
font_size=block.font_size,
|
||||||
|
font_color=block.font_color or "#CCCCCC",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_dynamic_field(self, text: str) -> bool:
|
||||||
|
"""Determine if a text block represents a dynamic (variable) field."""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Common patterns indicating dynamic content
|
||||||
|
dynamic_patterns = [
|
||||||
|
"{{", "}}", "${", "##",
|
||||||
|
"__________", "___", "...........",
|
||||||
|
]
|
||||||
|
for pattern in dynamic_patterns:
|
||||||
|
if pattern in text:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Short single-word values that might be labels are static
|
||||||
|
# Longer values with numbers/dates tend to be dynamic
|
||||||
|
import re
|
||||||
|
# Date patterns
|
||||||
|
if re.search(r"\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}", text):
|
||||||
|
return True
|
||||||
|
# Currency patterns
|
||||||
|
if re.search(r"[$€£¥]\s*[\d,]+\.?\d*", text):
|
||||||
|
return True
|
||||||
|
# Phone patterns
|
||||||
|
if re.search(r"\+?\d[\d\s\-()]{7,}", text):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _generate_field_name(self, text: str, sequence: int) -> str:
|
||||||
|
"""Generate a field name from text content."""
|
||||||
|
import re
|
||||||
|
# Clean text
|
||||||
|
clean = re.sub(r"[^a-zA-Z0-9\s]", "", text)
|
||||||
|
clean = clean.strip().lower()
|
||||||
|
words = clean.split()[:3]
|
||||||
|
if words:
|
||||||
|
return "_".join(words)
|
||||||
|
return f"field_{sequence}"
|
||||||
|
|
||||||
|
def _infer_data_type(self, text: str) -> str:
|
||||||
|
"""Infer the data type from text content."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
stripped = text.strip()
|
||||||
|
|
||||||
|
# Number
|
||||||
|
if re.match(r"^-?[\d,]+\.?\d*$", stripped.replace(",", "")):
|
||||||
|
return "number"
|
||||||
|
|
||||||
|
# Date
|
||||||
|
if re.search(r"\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}", stripped):
|
||||||
|
return "date"
|
||||||
|
|
||||||
|
# Currency
|
||||||
|
if re.search(r"^[$€£¥]\s*[\d,]+\.?\d*$", stripped):
|
||||||
|
return "currency"
|
||||||
|
|
||||||
|
# Email
|
||||||
|
if re.search(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", stripped):
|
||||||
|
return "email"
|
||||||
|
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
def _infer_alignment(self, x: float, page_width: float) -> str:
|
||||||
|
"""Infer text alignment based on horizontal position."""
|
||||||
|
if page_width <= 0:
|
||||||
|
return "left"
|
||||||
|
|
||||||
|
relative_x = x / page_width
|
||||||
|
|
||||||
|
if relative_x < 0.15:
|
||||||
|
return "left"
|
||||||
|
elif relative_x > 0.6:
|
||||||
|
return "right"
|
||||||
|
elif 0.35 < relative_x < 0.65:
|
||||||
|
return "center"
|
||||||
|
|
||||||
|
return "left"
|
||||||
0
docengine/app/tasks/__init__.py
Normal file
0
docengine/app/tasks/__init__.py
Normal file
94
docengine/app/tasks/document_tasks.py
Normal file
94
docengine/app/tasks/document_tasks.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
from app.core.database import get_db_context
|
||||||
|
from app.services.document_service import DocumentProcessingService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
name="app.tasks.document_tasks.process_document_task",
|
||||||
|
bind=True,
|
||||||
|
max_retries=3,
|
||||||
|
default_retry_delay=60,
|
||||||
|
acks_late=True,
|
||||||
|
)
|
||||||
|
def process_document_task(self, document_id: str) -> dict: # noqa: ANN001
|
||||||
|
"""Celery task to process a document asynchronously."""
|
||||||
|
logger.info("task_started", task_id=self.request.id, document_id=document_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc_uuid = uuid.UUID(document_id)
|
||||||
|
with get_db_context() as db:
|
||||||
|
service = DocumentProcessingService(db)
|
||||||
|
document = service.process_document(doc_uuid)
|
||||||
|
logger.info(
|
||||||
|
"task_completed",
|
||||||
|
task_id=self.request.id,
|
||||||
|
document_id=document_id,
|
||||||
|
status=document.status,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"document_id": document_id,
|
||||||
|
"status": document.status,
|
||||||
|
"page_count": document.page_count,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"task_failed",
|
||||||
|
task_id=self.request.id,
|
||||||
|
document_id=document_id,
|
||||||
|
error=str(exc),
|
||||||
|
retry=self.request.retries,
|
||||||
|
)
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
name="app.tasks.document_tasks.match_document_task",
|
||||||
|
bind=True,
|
||||||
|
max_retries=2,
|
||||||
|
default_retry_delay=30,
|
||||||
|
)
|
||||||
|
def match_document_task(
|
||||||
|
self, # noqa: ANN001
|
||||||
|
document_id: str,
|
||||||
|
min_confidence: float = 0.5,
|
||||||
|
max_results: int = 5,
|
||||||
|
) -> dict:
|
||||||
|
"""Celery task to match a document against templates."""
|
||||||
|
logger.info("match_task_started", task_id=self.request.id, document_id=document_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc_uuid = uuid.UUID(document_id)
|
||||||
|
with get_db_context() as db:
|
||||||
|
from app.services.matching_service import MatchingService
|
||||||
|
service = MatchingService(db)
|
||||||
|
matches = service.match_document(
|
||||||
|
document_id=doc_uuid,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
max_results=max_results,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"match_task_completed",
|
||||||
|
task_id=self.request.id,
|
||||||
|
document_id=document_id,
|
||||||
|
matches=len(matches),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"document_id": document_id,
|
||||||
|
"matches": len(matches),
|
||||||
|
"best_score": matches[0].confidence_score if matches else 0.0,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"match_task_failed",
|
||||||
|
task_id=self.request.id,
|
||||||
|
document_id=document_id,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise self.retry(exc=exc)
|
||||||
46
docengine/app/tasks/maintenance_tasks.py
Normal file
46
docengine/app/tasks/maintenance_tasks.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.logging_config import get_logger
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
from app.core.database import get_db_context
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
name="app.tasks.maintenance_tasks.cleanup_expired_tokens",
|
||||||
|
bind=True,
|
||||||
|
)
|
||||||
|
def cleanup_expired_tokens(self) -> dict: # noqa: ANN001
|
||||||
|
"""Cleanup expired and revoked refresh tokens."""
|
||||||
|
logger.info("cleanup_tokens_started", task_id=self.request.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_context() as db:
|
||||||
|
from app.repositories.user_repository import RefreshTokenRepository
|
||||||
|
repo = RefreshTokenRepository(db)
|
||||||
|
count = repo.cleanup_expired_tokens()
|
||||||
|
logger.info("cleanup_tokens_completed", removed=count)
|
||||||
|
return {"removed_tokens": count}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("cleanup_tokens_failed", error=str(exc))
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
name="app.tasks.maintenance_tasks.cleanup_temp_storage",
|
||||||
|
bind=True,
|
||||||
|
)
|
||||||
|
def cleanup_temp_storage(self) -> dict: # noqa: ANN001
|
||||||
|
"""Cleanup temporary storage files."""
|
||||||
|
logger.info("cleanup_temp_started", task_id=self.request.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.storage.provider import get_storage_provider
|
||||||
|
storage = get_storage_provider()
|
||||||
|
count = storage.cleanup_temp()
|
||||||
|
logger.info("cleanup_temp_completed", removed=count)
|
||||||
|
return {"removed_files": count}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("cleanup_temp_failed", error=str(exc))
|
||||||
|
return {"error": str(exc)}
|
||||||
0
docengine/app/templates/__init__.py
Normal file
0
docengine/app/templates/__init__.py
Normal file
0
docengine/app/utils/__init__.py
Normal file
0
docengine/app/utils/__init__.py
Normal file
0
docengine/app/workers/__init__.py
Normal file
0
docengine/app/workers/__init__.py
Normal file
43
docengine/app/workers/celery_app.py
Normal file
43
docengine/app/workers/celery_app.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"docengine",
|
||||||
|
broker=settings.celery_broker_url,
|
||||||
|
backend=settings.celery_result_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
timezone="UTC",
|
||||||
|
enable_utc=True,
|
||||||
|
task_track_started=True,
|
||||||
|
task_time_limit=3600,
|
||||||
|
task_soft_time_limit=3300,
|
||||||
|
worker_max_tasks_per_child=100,
|
||||||
|
worker_prefetch_multiplier=1,
|
||||||
|
task_acks_late=True,
|
||||||
|
task_reject_on_worker_lost=True,
|
||||||
|
broker_connection_retry_on_startup=True,
|
||||||
|
result_expires=86400,
|
||||||
|
task_routes={
|
||||||
|
"app.tasks.document_tasks.*": {"queue": "document_processing"},
|
||||||
|
},
|
||||||
|
beat_schedule={
|
||||||
|
"cleanup-expired-tokens": {
|
||||||
|
"task": "app.tasks.maintenance_tasks.cleanup_expired_tokens",
|
||||||
|
"schedule": 3600.0,
|
||||||
|
},
|
||||||
|
"cleanup-temp-storage": {
|
||||||
|
"task": "app.tasks.maintenance_tasks.cleanup_temp_storage",
|
||||||
|
"schedule": 7200.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.autodiscover_tasks(["app.tasks"])
|
||||||
8
docengine/application.properties
Normal file
8
docengine/application.properties
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
server.port=7989
|
||||||
|
|
||||||
|
db.host=192.168.0.111
|
||||||
|
db.port=7925
|
||||||
|
db.user=postgres
|
||||||
|
db.password=M@tr!x#149@dm!N
|
||||||
|
db.schema=admin
|
||||||
154
docengine/docker-compose.prod.yml
Normal file
154
docengine/docker-compose.prod.yml
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: docengine_db_prod
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
POSTGRES_DB: document_engine
|
||||||
|
ports:
|
||||||
|
- "7925:5432"
|
||||||
|
volumes:
|
||||||
|
- docengine_pgdata_prod:/var/lib/postgresql/data
|
||||||
|
- ./sql/001_create_schema.sql:/docker-entrypoint-initdb.d/001_create_schema.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d document_engine"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2G
|
||||||
|
cpus: "2.0"
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- docengine_net_prod
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: docengine_redis_prod
|
||||||
|
command: redis-server --requirepass "${REDIS_PASSWORD}" --appendonly yes
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- docengine_redis_data_prod:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: "1.0"
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- docengine_net_prod
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: app
|
||||||
|
container_name: docengine_app_prod
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
|
APP_DEBUG: "false"
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
REDIS_HOST: redis
|
||||||
|
CELERY_BROKER_URL: "redis://:${REDIS_PASSWORD}@redis:6379/0"
|
||||||
|
CELERY_RESULT_BACKEND: "redis://:${REDIS_PASSWORD}@redis:6379/1"
|
||||||
|
ports:
|
||||||
|
- "7989:7989"
|
||||||
|
volumes:
|
||||||
|
- docengine_storage_prod:/app/storage
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
cpus: "4.0"
|
||||||
|
replicas: 2
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- docengine_net_prod
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: worker
|
||||||
|
container_name: docengine_worker_prod
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
|
APP_DEBUG: "false"
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
REDIS_HOST: redis
|
||||||
|
CELERY_BROKER_URL: "redis://:${REDIS_PASSWORD}@redis:6379/0"
|
||||||
|
CELERY_RESULT_BACKEND: "redis://:${REDIS_PASSWORD}@redis:6379/1"
|
||||||
|
volumes:
|
||||||
|
- docengine_storage_prod:/app/storage
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
cpus: "4.0"
|
||||||
|
replicas: 2
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- docengine_net_prod
|
||||||
|
|
||||||
|
beat:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: beat
|
||||||
|
container_name: docengine_beat_prod
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
|
APP_DEBUG: "false"
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
REDIS_HOST: redis
|
||||||
|
CELERY_BROKER_URL: "redis://:${REDIS_PASSWORD}@redis:6379/0"
|
||||||
|
CELERY_RESULT_BACKEND: "redis://:${REDIS_PASSWORD}@redis:6379/1"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: "0.5"
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- docengine_net_prod
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
docengine_pgdata_prod:
|
||||||
|
docengine_redis_data_prod:
|
||||||
|
docengine_storage_prod:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docengine_net_prod:
|
||||||
|
driver: bridge
|
||||||
93
docengine/docker-compose.yml
Normal file
93
docengine/docker-compose.yml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: docengine_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: "M@tr!x#149@dm!N"
|
||||||
|
POSTGRES_DB: document_engine
|
||||||
|
ports:
|
||||||
|
- "7925:5432"
|
||||||
|
volumes:
|
||||||
|
- docengine_pgdata:/var/lib/postgresql/data
|
||||||
|
- ./sql/001_create_schema.sql:/docker-entrypoint-initdb.d/001_create_schema.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d document_engine"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- docengine_net
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: docengine_redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- docengine_redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- docengine_net
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: app
|
||||||
|
container_name: docengine_app
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
REDIS_HOST: redis
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://redis:6379/1
|
||||||
|
ports:
|
||||||
|
- "7989:7989"
|
||||||
|
volumes:
|
||||||
|
- ./storage:/app/storage
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- docengine_net
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: worker
|
||||||
|
container_name: docengine_worker
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
REDIS_HOST: redis
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://redis:6379/1
|
||||||
|
volumes:
|
||||||
|
- ./storage:/app/storage
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- docengine_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
docengine_pgdata:
|
||||||
|
docengine_redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docengine_net:
|
||||||
|
driver: bridge
|
||||||
45
docengine/pyproject.toml
Normal file
45
docengine/pyproject.toml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75.0", "wheel"]
|
||||||
|
build-backend = "setuptools.backends._legacy:_Backend"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "docengine"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Document Template Recognition and Reconstruction System"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = {text = "Proprietary"}
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = "-v --tb=short --cov=app --cov-report=term-missing --cov-report=html"
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
target-version = ["py312"]
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
line_length = 120
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "N", "UP", "S", "B", "A", "C4", "DTZ", "ISC", "PIE", "T20", "RSE", "RET", "SIM", "TCH", "ERA", "PGH", "PLC", "PLE", "PLR", "PLW", "TRY", "RUF"]
|
||||||
|
ignore = ["S101", "S603", "S607", "TRY003", "PLR0913", "B008"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
21
docengine/requirements-dev.txt
Normal file
21
docengine/requirements-dev.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-cov==6.0.0
|
||||||
|
pytest-asyncio==0.25.0
|
||||||
|
pytest-mock==3.14.0
|
||||||
|
httpx==0.28.1
|
||||||
|
factory-boy==3.3.1
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
ruff==0.8.6
|
||||||
|
mypy==1.14.1
|
||||||
|
black==24.10.0
|
||||||
|
isort==5.13.2
|
||||||
|
|
||||||
|
# Type Stubs
|
||||||
|
types-redis==4.6.0.20241004
|
||||||
|
types-python-dateutil==2.9.0.20241003
|
||||||
|
types-passlib==1.7.7.20240819
|
||||||
|
types-aiofiles==24.1.0.20240626
|
||||||
49
docengine/requirements.txt
Normal file
49
docengine/requirements.txt
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Core Framework
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
alembic==1.14.1
|
||||||
|
|
||||||
|
# Validation & Settings
|
||||||
|
pydantic==2.10.4
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
email-validator==2.2.0
|
||||||
|
|
||||||
|
# Authentication & Security
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.2.1
|
||||||
|
|
||||||
|
# Document Processing
|
||||||
|
PyMuPDF==1.25.3
|
||||||
|
paddleocr==2.9.1
|
||||||
|
paddlepaddle==3.0.0b1
|
||||||
|
layoutparser==0.3.4
|
||||||
|
opencv-python-headless==4.10.0.84
|
||||||
|
camelot-py[cv]==0.11.0
|
||||||
|
pdf2image==1.17.0
|
||||||
|
Pillow==11.1.0
|
||||||
|
|
||||||
|
# PDF Generation
|
||||||
|
reportlab==4.2.5
|
||||||
|
|
||||||
|
# Background Jobs
|
||||||
|
celery[redis]==5.4.0
|
||||||
|
redis==5.2.1
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
structlog==24.4.0
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
prometheus-client==0.21.1
|
||||||
|
prometheus-fastapi-instrumentator==7.0.2
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dateutil==2.9.0
|
||||||
|
aiofiles==24.1.0
|
||||||
|
httpx==0.28.1
|
||||||
|
numpy==1.26.4
|
||||||
12
docengine/sql/001_create_schema.sql
Normal file
12
docengine/sql/001_create_schema.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Create the admin schema for DocEngine
|
||||||
|
CREATE SCHEMA IF NOT EXISTS admin;
|
||||||
|
|
||||||
|
-- Set the default search path
|
||||||
|
ALTER DATABASE document_engine SET search_path TO admin, public;
|
||||||
|
|
||||||
|
-- Grant privileges
|
||||||
|
GRANT ALL ON SCHEMA admin TO postgres;
|
||||||
|
GRANT USAGE ON SCHEMA admin TO postgres;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA admin GRANT ALL ON TABLES TO postgres;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA admin GRANT ALL ON SEQUENCES TO postgres;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA admin GRANT ALL ON FUNCTIONS TO postgres;
|
||||||
16
docengine/sql/001_init.sql
Normal file
16
docengine/sql/001_init.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS admin;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_format(
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_cell(
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
format_id BIGINT REFERENCES admin.document_format(id),
|
||||||
|
data_type VARCHAR(50),
|
||||||
|
font_family VARCHAR(100),
|
||||||
|
font_size INTEGER
|
||||||
|
);
|
||||||
301
docengine/sql/002_create_tables.sql
Normal file
301
docengine/sql/002_create_tables.sql
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
-- DocEngine: Complete table creation script
|
||||||
|
-- Schema: admin
|
||||||
|
-- Database: document_engine
|
||||||
|
|
||||||
|
SET search_path TO admin, public;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Users & Authentication
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
username VARCHAR(150) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
hashed_password VARCHAR(255) NOT NULL,
|
||||||
|
full_name VARCHAR(255),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
last_login TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.user_roles (
|
||||||
|
user_id UUID NOT NULL REFERENCES admin.users(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES admin.roles(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES admin.users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(512) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES admin.users(id) ON DELETE SET NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
resource_type VARCHAR(100) NOT NULL,
|
||||||
|
resource_id VARCHAR(255),
|
||||||
|
details TEXT,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent VARCHAR(512),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Documents
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
filename VARCHAR(500) NOT NULL,
|
||||||
|
original_filename VARCHAR(500) NOT NULL,
|
||||||
|
content_type VARCHAR(100) NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL,
|
||||||
|
checksum VARCHAR(128) NOT NULL,
|
||||||
|
storage_path VARCHAR(1024) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||||
|
page_count INTEGER,
|
||||||
|
is_scanned BOOLEAN,
|
||||||
|
document_metadata JSONB,
|
||||||
|
error_message TEXT,
|
||||||
|
uploaded_by UUID REFERENCES admin.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_pages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
document_id UUID NOT NULL REFERENCES admin.documents(id) ON DELETE CASCADE,
|
||||||
|
page_number INTEGER NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
image_path VARCHAR(1024),
|
||||||
|
text_content TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_text_blocks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
page_id UUID NOT NULL REFERENCES admin.document_pages(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
confidence DOUBLE PRECISION,
|
||||||
|
font_family VARCHAR(255),
|
||||||
|
font_size DOUBLE PRECISION,
|
||||||
|
font_color VARCHAR(50),
|
||||||
|
font_style VARCHAR(50),
|
||||||
|
block_type VARCHAR(50) NOT NULL DEFAULT 'text',
|
||||||
|
sequence INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_images (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
page_id UUID NOT NULL REFERENCES admin.document_pages(id) ON DELETE CASCADE,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
image_path VARCHAR(1024) NOT NULL,
|
||||||
|
image_type VARCHAR(50) NOT NULL DEFAULT 'figure',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_tables (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
page_id UUID NOT NULL REFERENCES admin.document_pages(id) ON DELETE CASCADE,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
rows INTEGER NOT NULL,
|
||||||
|
columns INTEGER NOT NULL,
|
||||||
|
data JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Templates
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_formats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
page_width DOUBLE PRECISION NOT NULL,
|
||||||
|
page_height DOUBLE PRECISION NOT NULL,
|
||||||
|
page_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
margin_top DOUBLE PRECISION NOT NULL DEFAULT 72.0,
|
||||||
|
margin_right DOUBLE PRECISION NOT NULL DEFAULT 72.0,
|
||||||
|
margin_bottom DOUBLE PRECISION NOT NULL DEFAULT 72.0,
|
||||||
|
margin_left DOUBLE PRECISION NOT NULL DEFAULT 72.0,
|
||||||
|
fingerprint JSONB,
|
||||||
|
source_document_id UUID REFERENCES admin.documents(id) ON DELETE SET NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_by UUID REFERENCES admin.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_cells (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
format_id UUID NOT NULL REFERENCES admin.document_formats(id) ON DELETE CASCADE,
|
||||||
|
page_number INTEGER NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
row_no INTEGER NOT NULL DEFAULT 0,
|
||||||
|
column_no INTEGER NOT NULL DEFAULT 0,
|
||||||
|
data_type VARCHAR(50) NOT NULL DEFAULT 'text',
|
||||||
|
font_family VARCHAR(255),
|
||||||
|
font_size DOUBLE PRECISION,
|
||||||
|
font_style VARCHAR(50),
|
||||||
|
font_color VARCHAR(50),
|
||||||
|
background_color VARCHAR(50),
|
||||||
|
border_top VARCHAR(100),
|
||||||
|
border_right VARCHAR(100),
|
||||||
|
border_bottom VARCHAR(100),
|
||||||
|
border_left VARCHAR(100),
|
||||||
|
padding_top DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||||
|
padding_right DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||||
|
padding_bottom DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||||
|
padding_left DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||||
|
alignment VARCHAR(20) NOT NULL DEFAULT 'left',
|
||||||
|
vertical_alignment VARCHAR(20) NOT NULL DEFAULT 'top',
|
||||||
|
rowspan INTEGER NOT NULL DEFAULT 1,
|
||||||
|
colspan INTEGER NOT NULL DEFAULT 1,
|
||||||
|
static_text TEXT,
|
||||||
|
field_name VARCHAR(255),
|
||||||
|
sequence INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_dynamic BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.document_regions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
format_id UUID NOT NULL REFERENCES admin.document_formats(id) ON DELETE CASCADE,
|
||||||
|
page_number INTEGER NOT NULL,
|
||||||
|
region_type VARCHAR(50) NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
content JSONB,
|
||||||
|
sequence INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.table_formats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
format_id UUID NOT NULL REFERENCES admin.document_formats(id) ON DELETE CASCADE,
|
||||||
|
page_number INTEGER NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
rows INTEGER NOT NULL,
|
||||||
|
columns INTEGER NOT NULL,
|
||||||
|
border_style VARCHAR(50) NOT NULL DEFAULT 'solid',
|
||||||
|
border_width DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||||
|
border_color VARCHAR(50) NOT NULL DEFAULT '#000000',
|
||||||
|
header_rows INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.table_columns (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
table_format_id UUID NOT NULL REFERENCES admin.table_formats(id) ON DELETE CASCADE,
|
||||||
|
column_index INTEGER NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
header_text VARCHAR(500),
|
||||||
|
data_type VARCHAR(50) NOT NULL DEFAULT 'text',
|
||||||
|
alignment VARCHAR(20) NOT NULL DEFAULT 'left',
|
||||||
|
font_family VARCHAR(255),
|
||||||
|
font_size DOUBLE PRECISION,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.table_rows (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
table_format_id UUID NOT NULL REFERENCES admin.table_formats(id) ON DELETE CASCADE,
|
||||||
|
row_index INTEGER NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL DEFAULT 20.0,
|
||||||
|
is_header BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
background_color VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.watermarks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
format_id UUID NOT NULL REFERENCES admin.document_formats(id) ON DELETE CASCADE,
|
||||||
|
page_number INTEGER,
|
||||||
|
text VARCHAR(500),
|
||||||
|
image_path VARCHAR(1024),
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
opacity DOUBLE PRECISION NOT NULL DEFAULT 0.3,
|
||||||
|
rotation DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||||
|
font_family VARCHAR(255),
|
||||||
|
font_size DOUBLE PRECISION,
|
||||||
|
font_color VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.image_regions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
format_id UUID NOT NULL REFERENCES admin.document_formats(id) ON DELETE CASCADE,
|
||||||
|
page_number INTEGER NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
image_path VARCHAR(1024),
|
||||||
|
image_type VARCHAR(50) NOT NULL DEFAULT 'figure',
|
||||||
|
is_static BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
field_name VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.template_fingerprints (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
format_id UUID NOT NULL UNIQUE REFERENCES admin.document_formats(id) ON DELETE CASCADE,
|
||||||
|
page_dimensions JSONB,
|
||||||
|
logo_coordinates JSONB,
|
||||||
|
header_coordinates JSONB,
|
||||||
|
footer_coordinates JSONB,
|
||||||
|
table_coordinates JSONB,
|
||||||
|
cell_coordinates JSONB,
|
||||||
|
fingerprint_hash VARCHAR(256) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.template_matches (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
document_id UUID NOT NULL REFERENCES admin.documents(id) ON DELETE CASCADE,
|
||||||
|
format_id UUID NOT NULL REFERENCES admin.document_formats(id) ON DELETE CASCADE,
|
||||||
|
confidence_score DOUBLE PRECISION NOT NULL,
|
||||||
|
match_details JSONB,
|
||||||
|
selected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
0
docengine/sql/003_seed_data.sql
Normal file
0
docengine/sql/003_seed_data.sql
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user