Merge branch 'fix_template_preview'

This commit is contained in:
2026-08-09 15:32:25 +05:30
251 changed files with 22733 additions and 164 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"dotrush.roslyn.projectOrSolutionFiles": []
}

42
SESSION_HANDOFF.md Normal file
View File

@@ -0,0 +1,42 @@
# Session Handoff & Context
**Date Saved**: June 7, 2026
**Conversation ID**: 43f3aa27-9124-446e-9713-4e31e083b4f5
## Project: Template Mapping Engine (OCR)
A system designed to extract document layouts via OCR and allow users to map specific bounding box regions to template fields using a drag-and-drop UI. Future documents from the same vendor are automatically recognized and mapped using a heuristic template engine.
## What Has Been Completed So Far
### 1. Backend Startup & Dependency Fixes:
- **Auto-Bootstrapping**: Updated `run_docengine.sh` and `run_backend.sh` to automatically set up virtual environments and install missing dependencies if key modules (`uvicorn`, `numpy`, etc.) are missing.
- **Python 3.13 Compatibility**: Upgraded `paddlepaddle` in `docengine/requirements.txt` to `>=3.0.0` to support Python 3.13 on macOS ARM64. Added missing dependencies (`numpy`, `opencv-python-headless`, `pdf2image`) to `backend/requirements.txt`.
- **CORS Config Fix**: Corrected a missing double-quote in `CORS_ORIGINS` in both `.env` and `.env.example` in `docengine/`.
### 2. Backend Code Integrity & Framework Alignment:
- **SQLAlchemy 2.0 / Imperative Mapping Fix**: Updated `user_roles_table` in `docengine/app/models/user.py` to use SQLAlchemy `Column` objects instead of `mapped_column`, preventing a database initialization crash.
- **Structlog Parameter Fix**: Replaced the `event` keyword argument with `phase` in `docengine/app/events/handlers.py` to prevent a duplicate parameter `TypeError` from the structlog library.
- **Optional Development Auth Bypass**: Modified `get_current_user` in `docengine/app/core/dependencies.py` to automatically fallback to the first database user (or a fake dev admin model) when no authorization token is supplied and `APP_ENV` is set to `"development"`. This prevents `401 Unauthorized` errors when testing endpoints locally.
### 3. Frontend Angular Implementation:
- **API Endpoint Alignments**: Removed the duplicate `/api` prefix from endpoints in `TemplateService.ts` (e.g. changing `${this.apiUrl}/api/documents/upload` to `${this.apiUrl}/documents/upload`). This resolves the `404 Not Found` error by aligning with the backend prefix `/api/v1/documents/upload`.
---
## Where to Pick Up Next
1. **Initialize Database Tables**:
- The database currently lacks the necessary schema tables (evidenced by the error: `relation "admin.users" does not exist`).
- Run the setup command to build the tables and apply migrations:
```bash
./run_docengine.sh setup
```
2. **Retest Upload Flow**:
- Once database tables exist, perform a document upload from the Angular frontend to visually verify the OCR bounding boxes and layout mapper.
3. **Verify Heuristic Template Recognition**:
- Test the template matching and layout coordinate storage workflows.
---
## How to Resume with AI Assistant
To resume this session in a new chat, you can tell the AI:
*"Please read `SESSION_HANDOFF.md` in the root of the OCR project to get context on the Template Engine we were building and the startup fixes, and let's continue."*

View File

@@ -1,7 +1,7 @@
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=M@tr!x#149@dm!N DB_PASSWORD=M@triXPostgr3s@6202
DB_HOST=192.168.0.111 DB_HOST=103.125.129.116
DB_PORT=7925 DB_PORT=5432
DB_NAME=ocr DB_NAME=ocr
# Mail Configuration (Gmail) # Mail Configuration (Gmail)

Binary file not shown.

149
backend/alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
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

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

Binary file not shown.

87
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,87 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
import os
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import Base, DATABASE_URL
import models.template_models
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", DATABASE_URL.replace("%", "%%"))
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
include_schemas=True,
version_table_schema='public'
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${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, Sequence[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:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""Add templates schema
Revision ID: 91b2545f5a30
Revises:
Create Date: 2026-06-01 16:28:57.181829
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import os
# revision identifiers, used by Alembic.
revision: str = '91b2545f5a30'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Read and execute the SQL schema script
sql_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'sql', '01_templates_schema.sql')
with open(sql_path, 'r') as f:
sql = f.read()
op.execute(sql)
def downgrade() -> None:
op.execute("DROP SCHEMA IF EXISTS templates CASCADE;")

Binary file not shown.

Binary file not shown.

54
backend/api/documents.py Normal file
View File

@@ -0,0 +1,54 @@
import json
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.template_models import TemplateDocument, DocumentLayout
from schemas.template_schemas import DocumentSchema, DocumentLayoutSchema, TemplateRecognitionResult
from engine.ocr.document_processor import DocumentProcessor
from engine.recognition.template_engine import TemplateRecognitionEngine
router = APIRouter(prefix="/api/documents", tags=["Documents"])
@router.post("/upload", response_model=DocumentSchema, status_code=status.HTTP_201_CREATED)
async def upload_document(file: UploadFile = File(...), db: Session = Depends(get_db)):
# 1. Create document record
content = await file.read()
file_hash = hash(content) # Simple hash for demo
db_doc = TemplateDocument(
document_name=file.filename,
document_type=file.content_type,
file_name=file.filename,
file_hash=str(file_hash),
status="processing"
)
db.add(db_doc)
db.commit()
db.refresh(db_doc)
# 2. Process File (OCR & Layout)
processor = DocumentProcessor(db)
processor.process_file(db_doc.pk_document_id, file.filename, content)
db.refresh(db_doc)
return db_doc
@router.get("/{document_id}", response_model=DocumentSchema)
def get_document(document_id: int, db: Session = Depends(get_db)):
doc = db.query(TemplateDocument).filter(TemplateDocument.pk_document_id == document_id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return doc
@router.get("/{document_id}/layout", response_model=List[DocumentLayoutSchema])
def get_document_layout(document_id: int, db: Session = Depends(get_db)):
layouts = db.query(DocumentLayout).filter(DocumentLayout.fk_document_id == document_id).order_by(DocumentLayout.sequence_no).all()
return layouts
@router.post("/{document_id}/recognize", response_model=TemplateRecognitionResult)
def recognize_template(document_id: int, db: Session = Depends(get_db)):
engine = TemplateRecognitionEngine(db)
return engine.recognize_template(document_id)

53
backend/api/templates.py Normal file
View File

@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from schemas.template_schemas import (
TemplateCreate, TemplateSchema,
TemplateFieldCreate, TemplateFieldSchema,
TemplateFieldMappingCreate, TemplateFieldMappingSchema
)
from services.template_service import TemplateService
router = APIRouter(prefix="/api/templates", tags=["Templates"])
@router.post("", response_model=TemplateSchema, status_code=status.HTTP_201_CREATED)
def create_template(template_data: TemplateCreate, db: Session = Depends(get_db)):
service = TemplateService(db)
return service.create_template(template_data)
@router.get("", response_model=List[TemplateSchema])
def get_templates(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
service = TemplateService(db)
return service.get_all_templates(skip, limit)
@router.get("/{template_id}", response_model=TemplateSchema)
def get_template(template_id: int, db: Session = Depends(get_db)):
service = TemplateService(db)
template = service.get_template(template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
return template
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(template_id: int, db: Session = Depends(get_db)):
service = TemplateService(db)
if not service.delete_template(template_id):
raise HTTPException(status_code=404, detail="Template not found")
return None
@router.post("/{template_id}/fields", response_model=TemplateFieldSchema, status_code=status.HTTP_201_CREATED)
def add_template_field(template_id: int, field_data: TemplateFieldCreate, db: Session = Depends(get_db)):
service = TemplateService(db)
# Check if template exists
if not service.get_template(template_id):
raise HTTPException(status_code=404, detail="Template not found")
return service.add_template_field(template_id, field_data)
@router.post("/{template_id}/mappings/save", response_model=List[TemplateFieldMappingSchema])
def save_mappings(template_id: int, mappings: List[TemplateFieldMappingCreate], db: Session = Depends(get_db)):
service = TemplateService(db)
if not service.get_template(template_id):
raise HTTPException(status_code=404, detail="Template not found")
return service.save_mapping(template_id, mappings)

View File

@@ -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")

View File

@@ -0,0 +1,217 @@
import io
import re
from typing import List, Dict, Any
import numpy as np
import cv2
import pdfplumber
import pytesseract
from PIL import Image
from pdf2image import convert_from_bytes
from sqlalchemy.orm import Session
from models.template_models import DocumentLayout, TemplateDocument
from schemas.template_schemas import DocumentLayoutSchema
class DocumentProcessor:
def __init__(self, db: Session):
self.db = db
def process_file(self, document_id: int, filename: str, file_bytes: bytes) -> List[DocumentLayout]:
document = self.db.query(TemplateDocument).filter(TemplateDocument.pk_document_id == document_id).first()
if not document:
raise ValueError("Document not found")
layouts = []
if filename.lower().endswith('.pdf'):
layouts = self._process_pdf(document_id, file_bytes)
elif filename.lower().endswith(('.png', '.jpg', '.jpeg', '.tiff')):
layouts = self._process_image(document_id, file_bytes)
# Update page count
if layouts:
document.page_count = max([l.page_no for l in layouts])
document.status = "processed"
self.db.commit()
return layouts
def _process_pdf(self, document_id: int, file_bytes: bytes) -> List[DocumentLayout]:
layouts = []
try:
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
sequence_no = 1
for page_no, page in enumerate(pdf.pages, start=1):
# Check if it has text
text = page.extract_text()
if text and text.strip():
# Extract words with bounding boxes
words = page.extract_words()
# Group words into blocks using simple proximity heuristics
blocks = self._group_words_to_blocks(words, page.width, page.height)
for block in blocks:
db_layout = DocumentLayout(
fk_document_id=document_id,
page_no=page_no,
text_value=block['text'],
block_type=block['type'],
x_coordinate=block['x0'],
y_coordinate=block['top'],
width=block['x1'] - block['x0'],
height=block['bottom'] - block['top'],
confidence=100.0,
sequence_no=sequence_no
)
self.db.add(db_layout)
layouts.append(db_layout)
sequence_no += 1
else:
# Scanned PDF page -> convert to image and process
# Handled separately to avoid complexity in this mock
pass
except Exception as e:
print(f"Error processing PDF with pdfplumber: {e}")
if not layouts:
# Fallback to image-based processing for scanned PDFs
images = convert_from_bytes(file_bytes, dpi=300)
sequence_no = 1
for page_no, img in enumerate(images, start=1):
img_byte_arr = io.BytesIO()
img.save(img_byte_arr, format='PNG')
page_layouts = self._process_image(document_id, img_byte_arr.getvalue(), page_no=page_no, start_sequence=sequence_no)
layouts.extend(page_layouts)
sequence_no += len(page_layouts)
self.db.commit()
return layouts
def _process_image(self, document_id: int, file_bytes: bytes, page_no: int = 1, start_sequence: int = 1) -> List[DocumentLayout]:
layouts = []
try:
# Decode image
nparr = np.frombuffer(file_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
# Preprocessing
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Denoise & Threshold
gray = cv2.medianBlur(gray, 3)
gray = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
# OCR using PyTesseract with bounding boxes
data = pytesseract.image_to_data(gray, output_type=pytesseract.Output.DICT)
sequence_no = start_sequence
# Grouping words into lines/blocks simplified
height, width = gray.shape
blocks = self._tesseract_data_to_blocks(data, width, height)
for block in blocks:
db_layout = DocumentLayout(
fk_document_id=document_id,
page_no=page_no,
text_value=block['text'],
block_type=block['type'],
x_coordinate=block['x'],
y_coordinate=block['y'],
width=block['w'],
height=block['h'],
confidence=block['conf'],
sequence_no=sequence_no
)
self.db.add(db_layout)
layouts.append(db_layout)
sequence_no += 1
except Exception as e:
print(f"Error processing image with tesseract: {e}")
self.db.commit()
return layouts
def _group_words_to_blocks(self, words: List[Dict], page_width: float, page_height: float) -> List[Dict]:
"""Group nearby words into logical blocks and classify them."""
# Extremely simplified grouping by Y coordinate proximity
blocks = []
if not words:
return blocks
# Sort words by Y then X
words.sort(key=lambda w: (w['top'], w['x0']))
current_block = {
'text': words[0]['text'],
'x0': words[0]['x0'],
'top': words[0]['top'],
'x1': words[0]['x1'],
'bottom': words[0]['bottom']
}
for word in words[1:]:
# If word is roughly on the same line (y diff is small) and close horizontally
if abs(word['top'] - current_block['top']) < 10 and (word['x0'] - current_block['x1']) < 50:
current_block['text'] += ' ' + word['text']
current_block['x1'] = word['x1']
current_block['top'] = min(current_block['top'], word['top'])
current_block['bottom'] = max(current_block['bottom'], word['bottom'])
else:
current_block['type'] = self._classify_block(current_block, page_height)
blocks.append(current_block)
current_block = {
'text': word['text'],
'x0': word['x0'],
'top': word['top'],
'x1': word['x1'],
'bottom': word['bottom']
}
current_block['type'] = self._classify_block(current_block, page_height)
blocks.append(current_block)
return blocks
def _tesseract_data_to_blocks(self, data: Dict, page_width: float, page_height: float) -> List[Dict]:
blocks = []
n_boxes = len(data['text'])
current_line = []
for i in range(n_boxes):
if int(data['conf'][i]) > 10 and data['text'][i].strip():
# We can group by line_num
blocks.append({
'text': data['text'][i],
'x': data['left'][i],
'y': data['top'][i],
'w': data['width'][i],
'h': data['height'][i],
'conf': float(data['conf'][i]),
'type': self._classify_block({'top': data['top'][i], 'text': data['text'][i]}, page_height)
})
return blocks
def _classify_block(self, block: Dict, page_height: float) -> str:
y = block.get('top') or block.get('y') or 0
text = block.get('text', '').lower()
if y < page_height * 0.15:
return "HEADER"
elif y > page_height * 0.85:
return "FOOTER"
if "total" in text:
return "TOTAL"
if "tax" in text or "gst" in text or "vat" in text:
return "TAX"
if re.search(r'\b(vendor|from)\b', text):
return "VENDOR"
if re.search(r'\b(bill to|sold to)\b', text):
return "BILL_TO"
if re.search(r'\b(ship to)\b', text):
return "SHIP_TO"
# Very naive fallback for table check
if re.search(r'\b(qty|rate|amount|price|item)\b', text):
return "TABLE_HEADER"
return "TEXT"

View File

@@ -0,0 +1,174 @@
import hashlib
import json
from typing import List, Dict, Any, Tuple
from sqlalchemy.orm import Session
from models.template_models import Template, TemplateDocument, DocumentLayout, TemplateRecognitionHistory
from schemas.template_schemas import TemplateRecognitionResult
class TemplateRecognitionEngine:
def __init__(self, db: Session):
self.db = db
def generate_fingerprint(self, layouts: List[DocumentLayout]) -> str:
"""
Generate a structural fingerprint based on the document's layout.
We use relative positions of HEADER, VENDOR, TABLE_HEADER blocks.
"""
key_blocks = []
for layout in layouts:
if layout.block_type in ["HEADER", "VENDOR", "TABLE_HEADER", "TOTAL"]:
key_blocks.append({
"type": layout.block_type,
"text": layout.text_value[:50] if layout.text_value else "", # first 50 chars
"rx": round(float(layout.x_coordinate) / 100) if layout.x_coordinate else 0, # relative bucket x
"ry": round(float(layout.y_coordinate) / 100) if layout.y_coordinate else 0 # relative bucket y
})
# Sort top to bottom, left to right
key_blocks.sort(key=lambda b: (b['ry'], b['rx']))
fingerprint_data = json.dumps(key_blocks)
return hashlib.sha256(fingerprint_data.encode('utf-8')).hexdigest()
def recognize_template(self, document_id: int) -> TemplateRecognitionResult:
"""
Matches a document against existing templates using a 4-level weighted score:
Level 1: Vendor Match (40%)
Level 2: Header Similarity (20%)
Level 3: Layout/Fingerprint Similarity (20%)
Level 4: Coordinate Similarity (20%)
Threshold: 85%
"""
layouts = self.db.query(DocumentLayout).filter(DocumentLayout.fk_document_id == document_id).all()
if not layouts:
return TemplateRecognitionResult(templateMatched=False)
doc_fingerprint = self.generate_fingerprint(layouts)
# Extract features for scoring
doc_vendor = self._extract_vendor_name(layouts)
doc_headers = self._extract_headers(layouts)
templates = self.db.query(Template).filter(Template.active_flag == True).all()
best_match = None
highest_score = 0
for template in templates:
score = 0.0
# Level 1: Fingerprint Exact Match (counts for Layout + Vendor + Header if exact)
if template.template_fingerprint == doc_fingerprint:
score += 100.0
else:
# Need to load template sample mapping to compare heuristics
# In a real system, we'd compare against the mapped fields' coordinates
score += self._calculate_heuristic_score(template, doc_vendor, doc_headers, layouts)
if score > highest_score:
highest_score = score
best_match = template
# Store history
if best_match:
history = TemplateRecognitionHistory(
fk_template_id=best_match.pk_template_id,
fk_document_id=document_id,
recognition_score=highest_score,
matched_flag=(highest_score >= 85.0)
)
self.db.add(history)
self.db.commit()
if best_match and highest_score >= 85.0:
extracted_fields = self._auto_map_fields(best_match, layouts)
return TemplateRecognitionResult(
templateMatched=True,
templateId=best_match.pk_template_id,
confidence=highest_score,
extractedFields=extracted_fields
)
return TemplateRecognitionResult(templateMatched=False)
def _extract_vendor_name(self, layouts: List[DocumentLayout]) -> str:
for layout in layouts:
if layout.block_type == "VENDOR":
return layout.text_value.lower()
return ""
def _extract_headers(self, layouts: List[DocumentLayout]) -> List[str]:
headers = []
for layout in layouts:
if layout.block_type == "TABLE_HEADER" and layout.text_value:
headers.append(layout.text_value.lower())
return headers
def _calculate_heuristic_score(self, template: Template, doc_vendor: str, doc_headers: List[str], layouts: List[DocumentLayout]) -> float:
score = 0.0
# This requires the template to have some stored metadata or we check its fields
# E.g., if template has a field "Vendor Name" mapped to a specific text
# 1. Vendor Match (40%)
# For this mockup, we check if the template name matches the vendor
if template.template_name.lower() in doc_vendor or doc_vendor in template.template_name.lower():
score += 40.0
# 2. Header Similarity (20%)
# Check if template fields exist that match doc_headers
field_labels = [f.field_label.lower() for f in template.fields]
header_matches = sum(1 for h in doc_headers if any(h in fl or fl in h for fl in field_labels))
if len(doc_headers) > 0:
score += (header_matches / len(doc_headers)) * 20.0
# 3. Layout / Coordinate Similarity
# We can check the template's previous mappings coordinates against current document
# If coordinates are within a tolerance, we add score
mapping_count = 0
match_count = 0
for mapping in template.mappings:
mapping_count += 1
# Find a layout block in current document that is near mapping coordinates
for layout in layouts:
if layout.page_no == mapping.page_no:
# check distance
if mapping.x_coordinate and mapping.y_coordinate and layout.x_coordinate and layout.y_coordinate:
dx = abs(float(mapping.x_coordinate) - float(layout.x_coordinate))
dy = abs(float(mapping.y_coordinate) - float(layout.y_coordinate))
if dx < 50 and dy < 20: # arbitrary tolerance
match_count += 1
break
if mapping_count > 0:
coord_score = (match_count / mapping_count) * 40.0 # Layout 20% + Coord 20%
score += coord_score
return score
def _auto_map_fields(self, template: Template, layouts: List[DocumentLayout]) -> List[dict]:
extracted = []
for mapping in template.mappings:
# Find closest block in new document
best_block = None
min_dist = float('inf')
for layout in layouts:
if layout.page_no == mapping.page_no:
if mapping.x_coordinate and mapping.y_coordinate and layout.x_coordinate and layout.y_coordinate:
dx = abs(float(mapping.x_coordinate) - float(layout.x_coordinate))
dy = abs(float(mapping.y_coordinate) - float(layout.y_coordinate))
dist = dx**2 + dy**2
if dist < min_dist and dist < 5000: # tolerance squared
min_dist = dist
best_block = layout
if best_block:
extracted.append({
"field_id": mapping.fk_template_field_id,
"field_label": mapping.field.field_label if mapping.field else "",
"value": best_block.text_value,
"confidence": 95.0, # arbitrary high confidence for coordinate match
"layout_id": best_block.pk_document_data_id
})
return extracted

116
backend/llm_service.py Normal file
View 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 ""}

View File

@@ -44,10 +44,14 @@ 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
# Import the new routers
from api.templates import router as templates_router
from api.documents import router as documents_router
# Lifespan for Scheduler # Lifespan for Scheduler
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -59,6 +63,10 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
# Include Routers
app.include_router(templates_router)
app.include_router(documents_router)
# CORS configuration # CORS configuration
origins = [ origins = [
"http://localhost", "http://localhost",
@@ -87,7 +95,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 +112,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:
# If text is empty, it might be a scanned PDF. 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 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 +161,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 +319,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}

View File

@@ -0,0 +1,105 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Numeric
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from database import Base
class TemplateDocument(Base):
__tablename__ = "documents"
__table_args__ = {"schema": "templates"}
pk_document_id = Column(Integer, primary_key=True, index=True)
document_name = Column(String(500))
document_type = Column(String(100))
file_name = Column(String(500))
file_hash = Column(String(500), index=True)
page_count = Column(Integer)
status = Column(String(50))
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
layouts = relationship("DocumentLayout", back_populates="document", cascade="all, delete")
class DocumentLayout(Base):
__tablename__ = "document_layout"
__table_args__ = {"schema": "templates"}
pk_document_data_id = Column(Integer, primary_key=True, index=True)
fk_document_id = Column(Integer, ForeignKey("templates.documents.pk_document_id", ondelete="CASCADE"), nullable=False, index=True)
page_no = Column(Integer, nullable=False, index=True)
text_value = Column(Text)
block_type = Column(String(100))
parent_block_id = Column(Integer, ForeignKey("templates.document_layout.pk_document_data_id", ondelete="SET NULL"))
x_coordinate = Column(Numeric)
y_coordinate = Column(Numeric)
width = Column(Numeric)
height = Column(Numeric)
confidence = Column(Numeric)
sequence_no = Column(Integer)
layout_path = Column(Text)
created_at = Column(DateTime, server_default=func.now())
document = relationship("TemplateDocument", back_populates="layouts")
parent = relationship("DocumentLayout", remote_side=[pk_document_data_id])
mappings = relationship("TemplateFieldMapping", back_populates="document_layout", cascade="all, delete")
class Template(Base):
__tablename__ = "templates"
__table_args__ = {"schema": "templates"}
pk_template_id = Column(Integer, primary_key=True, index=True)
template_name = Column(String(255), nullable=False, index=True)
template_fingerprint = Column(Text)
active_flag = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
fields = relationship("TemplateField", back_populates="template", cascade="all, delete")
mappings = relationship("TemplateFieldMapping", back_populates="template", cascade="all, delete")
class TemplateField(Base):
__tablename__ = "template_fields"
__table_args__ = {"schema": "templates"}
pk_template_field_id = Column(Integer, primary_key=True, index=True)
fk_template_id = Column(Integer, ForeignKey("templates.templates.pk_template_id", ondelete="CASCADE"), nullable=False, index=True)
field_label = Column(String(255), nullable=False)
field_type = Column(String(100), nullable=False)
display_order = Column(Integer)
required_flag = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
template = relationship("Template", back_populates="fields")
mappings = relationship("TemplateFieldMapping", back_populates="field", cascade="all, delete")
class TemplateFieldMapping(Base):
__tablename__ = "template_fields_mapping"
__table_args__ = {"schema": "templates"}
pk_mapping_id = Column(Integer, primary_key=True, index=True)
fk_template_id = Column(Integer, ForeignKey("templates.templates.pk_template_id", ondelete="CASCADE"), nullable=False, index=True)
fk_template_field_id = Column(Integer, ForeignKey("templates.template_fields.pk_template_field_id", ondelete="CASCADE"), nullable=False, index=True)
fk_document_data_id = Column(Integer, ForeignKey("templates.document_layout.pk_document_data_id", ondelete="SET NULL"))
page_no = Column(Integer)
x_coordinate = Column(Numeric)
y_coordinate = Column(Numeric)
width = Column(Numeric)
height = Column(Numeric)
mapping_confidence = Column(Numeric)
layout_path = Column(Text)
created_at = Column(DateTime, server_default=func.now())
template = relationship("Template", back_populates="mappings")
field = relationship("TemplateField", back_populates="mappings")
document_layout = relationship("DocumentLayout", back_populates="mappings")
class TemplateRecognitionHistory(Base):
__tablename__ = "template_recognition_history"
__table_args__ = {"schema": "templates"}
pk_history_id = Column(Integer, primary_key=True, index=True)
fk_template_id = Column(Integer, ForeignKey("templates.templates.pk_template_id", ondelete="CASCADE"), nullable=False)
fk_document_id = Column(Integer, ForeignKey("templates.documents.pk_document_id", ondelete="CASCADE"), nullable=False, index=True)
recognition_score = Column(Numeric)
matched_flag = Column(Boolean)
created_at = Column(DateTime, server_default=func.now())

View File

@@ -0,0 +1,75 @@
from sqlalchemy.orm import Session
from sqlalchemy import desc
from models.template_models import Template, TemplateField, TemplateFieldMapping, TemplateRecognitionHistory
from schemas.template_schemas import TemplateCreate, TemplateFieldCreate, TemplateFieldMappingCreate
class TemplateRepository:
def __init__(self, db: Session):
self.db = db
def get_template(self, template_id: int):
return self.db.query(Template).filter(Template.pk_template_id == template_id).first()
def get_templates(self, skip: int = 0, limit: int = 100):
return self.db.query(Template).offset(skip).limit(limit).all()
def create_template(self, template_data: TemplateCreate):
db_template = Template(template_name=template_data.template_name)
self.db.add(db_template)
self.db.commit()
self.db.refresh(db_template)
# Create fields if provided
for field in template_data.fields:
db_field = TemplateField(
fk_template_id=db_template.pk_template_id,
field_label=field.field_label,
field_type=field.field_type,
display_order=field.display_order,
required_flag=field.required_flag
)
self.db.add(db_field)
self.db.commit()
self.db.refresh(db_template)
return db_template
def add_template_field(self, template_id: int, field_data: TemplateFieldCreate):
db_field = TemplateField(
fk_template_id=template_id,
field_label=field_data.field_label,
field_type=field_data.field_type,
display_order=field_data.display_order,
required_flag=field_data.required_flag
)
self.db.add(db_field)
self.db.commit()
self.db.refresh(db_field)
return db_field
def delete_template(self, template_id: int):
template = self.get_template(template_id)
if template:
self.db.delete(template)
self.db.commit()
return True
return False
def save_mapping(self, template_id: int, mappings: list[TemplateFieldMappingCreate]):
saved_mappings = []
for mapping in mappings:
db_mapping = TemplateFieldMapping(
fk_template_id=template_id,
fk_template_field_id=mapping.fk_template_field_id,
fk_document_data_id=mapping.fk_document_data_id,
page_no=mapping.page_no,
x_coordinate=mapping.x_coordinate,
y_coordinate=mapping.y_coordinate,
width=mapping.width,
height=mapping.height,
mapping_confidence=mapping.mapping_confidence
)
self.db.add(db_mapping)
saved_mappings.append(db_mapping)
self.db.commit()
return saved_mappings

View File

@@ -10,3 +10,8 @@ imap-tools
apscheduler apscheduler
python-dotenv python-dotenv
pdfplumber pdfplumber
ollama
numpy
opencv-python-headless
pdf2image

View File

@@ -0,0 +1,80 @@
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
# Common
class BaseSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
# Document
class DocumentLayoutSchema(BaseSchema):
pk_document_data_id: int
fk_document_id: int
page_no: int
text_value: Optional[str]
block_type: Optional[str]
parent_block_id: Optional[int]
x_coordinate: Optional[float]
y_coordinate: Optional[float]
width: Optional[float]
height: Optional[float]
confidence: Optional[float]
sequence_no: Optional[int]
class DocumentSchema(BaseSchema):
pk_document_id: int
document_name: Optional[str]
document_type: Optional[str]
file_name: Optional[str]
page_count: Optional[int]
status: Optional[str]
created_at: datetime
layouts: List[DocumentLayoutSchema] = []
# Template Field
class TemplateFieldCreate(BaseSchema):
field_label: str
field_type: str
display_order: Optional[int] = 0
required_flag: Optional[bool] = False
class TemplateFieldSchema(TemplateFieldCreate):
pk_template_field_id: int
fk_template_id: int
created_at: datetime
# Template Field Mapping
class TemplateFieldMappingCreate(BaseSchema):
fk_template_field_id: int
fk_document_data_id: Optional[int] = None
page_no: Optional[int] = None
x_coordinate: Optional[float] = None
y_coordinate: Optional[float] = None
width: Optional[float] = None
height: Optional[float] = None
mapping_confidence: Optional[float] = None
class TemplateFieldMappingSchema(TemplateFieldMappingCreate):
pk_mapping_id: int
fk_template_id: int
created_at: datetime
# Template
class TemplateCreate(BaseSchema):
template_name: str
fields: Optional[List[TemplateFieldCreate]] = []
class TemplateSchema(BaseSchema):
pk_template_id: int
template_name: str
template_fingerprint: Optional[str] = None
active_flag: bool
created_at: datetime
fields: List[TemplateFieldSchema] = []
mappings: List[TemplateFieldMappingSchema] = []
class TemplateRecognitionResult(BaseSchema):
templateMatched: bool
templateId: Optional[int] = None
confidence: Optional[float] = None
extractedFields: List[dict] = []

View File

@@ -0,0 +1,25 @@
from sqlalchemy.orm import Session
from schemas.template_schemas import TemplateCreate, TemplateFieldCreate, TemplateFieldMappingCreate
from repositories.template_repository import TemplateRepository
class TemplateService:
def __init__(self, db: Session):
self.repository = TemplateRepository(db)
def get_template(self, template_id: int):
return self.repository.get_template(template_id)
def get_all_templates(self, skip: int = 0, limit: int = 100):
return self.repository.get_templates(skip, limit)
def create_template(self, template_data: TemplateCreate):
return self.repository.create_template(template_data)
def add_template_field(self, template_id: int, field_data: TemplateFieldCreate):
return self.repository.add_template_field(template_id, field_data)
def delete_template(self, template_id: int):
return self.repository.delete_template(template_id)
def save_mapping(self, template_id: int, mappings: list[TemplateFieldMappingCreate]):
return self.repository.save_mapping(template_id, mappings)

View File

@@ -0,0 +1,107 @@
-- Schema for Template Engine
CREATE SCHEMA IF NOT EXISTS templates;
-- 1. documents table
CREATE TABLE IF NOT EXISTS templates.documents (
pk_document_id BIGSERIAL PRIMARY KEY,
document_name VARCHAR(500),
document_type VARCHAR(100),
file_name VARCHAR(500),
file_hash VARCHAR(500),
page_count INTEGER,
status VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for quick lookups
CREATE INDEX idx_documents_hash ON templates.documents(file_hash);
-- 2. document_layout table
CREATE TABLE IF NOT EXISTS templates.document_layout (
pk_document_data_id BIGSERIAL PRIMARY KEY,
fk_document_id BIGINT NOT NULL,
page_no INTEGER NOT NULL,
text_value TEXT,
block_type VARCHAR(100),
parent_block_id BIGINT,
x_coordinate NUMERIC,
y_coordinate NUMERIC,
width NUMERIC,
height NUMERIC,
confidence NUMERIC,
sequence_no INTEGER,
layout_path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_dl_document FOREIGN KEY (fk_document_id) REFERENCES templates.documents(pk_document_id) ON DELETE CASCADE,
CONSTRAINT fk_dl_parent FOREIGN KEY (parent_block_id) REFERENCES templates.document_layout(pk_document_data_id) ON DELETE SET NULL
);
CREATE INDEX idx_doc_layout_doc_id ON templates.document_layout(fk_document_id);
CREATE INDEX idx_doc_layout_page ON templates.document_layout(page_no);
-- 3. templates table
CREATE TABLE IF NOT EXISTS templates.templates (
pk_template_id BIGSERIAL PRIMARY KEY,
template_name VARCHAR(255) NOT NULL,
template_fingerprint TEXT,
active_flag BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_templates_name ON templates.templates(template_name);
-- 4. template_fields table
CREATE TABLE IF NOT EXISTS templates.template_fields (
pk_template_field_id BIGSERIAL PRIMARY KEY,
fk_template_id BIGINT NOT NULL,
field_label VARCHAR(255) NOT NULL,
field_type VARCHAR(100) NOT NULL,
display_order INTEGER,
required_flag BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tf_template FOREIGN KEY (fk_template_id) REFERENCES templates.templates(pk_template_id) ON DELETE CASCADE
);
CREATE INDEX idx_template_fields_tmpl_id ON templates.template_fields(fk_template_id);
-- 5. template_fields_mapping table
CREATE TABLE IF NOT EXISTS templates.template_fields_mapping (
pk_mapping_id BIGSERIAL PRIMARY KEY,
fk_template_id BIGINT NOT NULL,
fk_template_field_id BIGINT NOT NULL,
fk_document_data_id BIGINT,
page_no INTEGER,
x_coordinate NUMERIC,
y_coordinate NUMERIC,
width NUMERIC,
height NUMERIC,
mapping_confidence NUMERIC,
layout_path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tfm_template FOREIGN KEY (fk_template_id) REFERENCES templates.templates(pk_template_id) ON DELETE CASCADE,
CONSTRAINT fk_tfm_field FOREIGN KEY (fk_template_field_id) REFERENCES templates.template_fields(pk_template_field_id) ON DELETE CASCADE,
CONSTRAINT fk_tfm_doc_data FOREIGN KEY (fk_document_data_id) REFERENCES templates.document_layout(pk_document_data_id) ON DELETE SET NULL
);
CREATE INDEX idx_mapping_tmpl_id ON templates.template_fields_mapping(fk_template_id);
CREATE INDEX idx_mapping_field_id ON templates.template_fields_mapping(fk_template_field_id);
-- 6. template_recognition_history table
CREATE TABLE IF NOT EXISTS templates.template_recognition_history (
pk_history_id BIGSERIAL PRIMARY KEY,
fk_template_id BIGINT NOT NULL,
fk_document_id BIGINT NOT NULL,
recognition_score NUMERIC,
matched_flag BOOLEAN,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_trh_template FOREIGN KEY (fk_template_id) REFERENCES templates.templates(pk_template_id) ON DELETE CASCADE,
CONSTRAINT fk_trh_document FOREIGN KEY (fk_document_id) REFERENCES templates.documents(pk_document_id) ON DELETE CASCADE
);
CREATE INDEX idx_recognition_history_doc ON templates.template_recognition_history(fk_document_id);

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

16
check_matches.py Normal file
View File

@@ -0,0 +1,16 @@
import sys
import os
sys.path.append(os.path.join(os.getcwd(), 'docengine'))
from app.core.database import SessionLocal
from app.models.document import TemplateMatch
from app.models.template import DocumentFormat
db = SessionLocal()
matches = db.query(TemplateMatch).order_by(TemplateMatch.created_at.desc()).limit(10).all()
print(f"Found {len(matches)} matches.")
for m in matches:
fmt = db.query(DocumentFormat).filter(DocumentFormat.id == m.format_id).first()
fmt_name = fmt.name if fmt else 'Unknown'
print(f"Match: doc_id={m.document_id}, format_id={m.format_id}, name={fmt_name}, score={m.confidence_score}")
print(f"Details: {m.match_details}")

26
debug_match.py Normal file
View File

@@ -0,0 +1,26 @@
import sys
import os
sys.path.append(os.path.join(os.getcwd(), 'docengine'))
from app.core.database import SessionLocal
from app.models.document import Document
from app.services.matching_service import MatchingService
db = SessionLocal()
doc = db.query(Document).order_by(Document.created_at.desc()).first()
if not doc:
print("No documents found.")
sys.exit(0)
print(f"Latest document: {doc.id} (status: {doc.status})")
svc = MatchingService(db)
try:
# Match with min_confidence=0.0 so it returns EVERYTHING
matches = svc.match_document(doc.id, min_confidence=0.0)
print(f"Returned {len(matches)} matches.")
for m in matches:
print(f"Match: format_id={m.format_id}, score={m.confidence_score}")
print(f"Details: {m.match_details}")
except Exception as e:
print(f"Error matching: {e}")

26
docengine/.dockerignore Normal file
View 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
View 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=ocr
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","http://localhost:4200"]
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
View 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
View 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
View 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:5432 │
└───────────────────────────────────────┘
```
**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:5432`)
- 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 5432 -U postgres -d ocr -f sql/003_seed_data.sql
psql -h 192.168.0.111 -p 5432 -U postgres -d ocr -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 ocr -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:5432/ocr`
**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` | `5432` | PostgreSQL port |
| `DB_NAME` | `ocr` | Database name |
| `DB_SCHEMA` | `admin` | PostgreSQL schema |
| `REDIS_HOST` | `192.168.0.111` | Redis host |
| `CELERY_BROKER_URL` | `redis://:***@192.168.0.111:7901/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
View File

@@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+psycopg2://postgres:M%%40triXPostgr3s%%406202@192.168.0.111:5432/ocr
[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
View 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.replace('%', '%%'))
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()

View 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"}

View 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
View 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)

View File

@@ -0,0 +1 @@
# DocEngine - Document Template Recognition and Reconstruction System

View 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)

View File

View 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)

View File

@@ -0,0 +1,307 @@
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.post(
"/{document_id}/extraction",
response_model=dict,
summary="Extract Document Data",
description="Match a document against templates and extract key-value & table data.",
)
def extract_document_data(
document_id: uuid.UUID,
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> dict:
"""Extract document data using matched template mappings."""
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",
)
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}'",
)
from app.services.extraction_service import ExtractionService
extraction_service = ExtractionService(db)
try:
result = extraction_service.extract_document_data(document_id)
return result
except Exception as e:
logger.exception("extraction_endpoint_failed", document_id=str(document_id), error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Extraction failed: {str(e)}",
)
@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")

View 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(),
)

View File

@@ -0,0 +1,423 @@
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,
DocumentRegionRepository,
DocumentCellRepository,
TableFormatRepository,
TableColumnRepository
)
from app.schemas.common import PaginatedResponse, SuccessResponse
from app.schemas.document import TemplateMatchRequest, TemplateMatchResponse
from app.schemas.template import (
TemplateListResponse,
TemplateRenderRequest,
TemplateRenderResponse,
TemplateResponse,
TemplateCreateRequest,
TemplateMappingSaveRequest,
)
logger = get_logger(__name__)
router = APIRouter(prefix="/templates", tags=["Templates"])
@router.post(
"",
response_model=dict,
summary="Create Template",
description="Create a new template.",
)
def create_template(
payload: TemplateCreateRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
) -> dict:
"""Create a template."""
template_repo = TemplateRepository(db)
template = template_repo.create_template(
name=payload.template_name,
page_width=1000.0,
page_height=1000.0,
source_document_id=uuid.UUID(payload.source_document_id) if payload.source_document_id else None
)
cell_repo = DocumentCellRepository(db)
saved_fields = []
for index, field in enumerate(payload.fields):
cell = cell_repo.create_cell(
format_id=template.id,
page_number=1,
x=0.0,
y=0.0,
width=0.0,
height=0.0,
data_type=field.field_type,
field_name=field.field_label,
sequence=field.display_order or index,
is_dynamic=True
)
saved_fields.append({
"field_label": cell.field_name,
"field_type": cell.data_type
})
db.commit()
return {
"pk_template_id": str(template.id),
"template_name": template.name,
"fields": saved_fields
}
@router.put(
"/{template_id}",
response_model=dict,
summary="Update Template",
description="Update an existing template name and its fields.",
)
def update_template(
template_id: uuid.UUID,
payload: TemplateCreateRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
) -> dict:
"""Update 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",
)
if template.name != payload.template_name:
template.name = payload.template_name
from app.models.template import DocumentCell
db.query(DocumentCell).filter(DocumentCell.format_id == template_id).delete()
saved_fields = []
cell_repo = DocumentCellRepository(db)
for index, field in enumerate(payload.fields):
cell = cell_repo.create_cell(
format_id=template.id,
page_number=1,
row_no=0,
column_no=0,
field_name=field.field_label,
data_type=field.field_type,
x=0.0,
y=0.0,
width=0.0,
height=0.0,
is_dynamic=True
)
saved_fields.append({
"field_label": cell.field_name,
"field_type": cell.data_type
})
db.commit()
return {
"pk_template_id": str(template.id),
"template_name": template.name,
"fields": saved_fields
}
@router.post(
"/{template_id}/mappings/save",
response_model=SuccessResponse,
summary="Save Template Mappings",
description="Save the field mappings for a template.",
)
def save_mappings(
template_id: uuid.UUID,
payload: list[TemplateMappingSaveRequest],
current_user: CurrentUser,
db: Session = Depends(get_db),
) -> SuccessResponse:
"""Save template mappings."""
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",
)
region_repo = DocumentRegionRepository(db)
cell_repo = DocumentCellRepository(db)
table_repo = TableFormatRepository(db)
table_col_repo = TableColumnRepository(db)
# Delete existing field mappings and table formats for this template
from app.models.template import DocumentRegion, TableFormat
db.query(DocumentRegion).filter(
DocumentRegion.format_id == template_id,
DocumentRegion.region_type == "field_mapping"
).delete()
db.query(TableFormat).filter(TableFormat.format_id == template_id).delete()
db.flush()
cells = cell_repo.get_template_cells(template_id)
cell_map = {c.field_name: c for c in cells}
table_format = None
# Save each mapped node
for mapping in payload:
cell = cell_map.get(mapping.field_name)
is_table_column = cell and cell.data_type == 'TABLE_COLUMN'
for node_idx, node in enumerate(mapping.mapped_nodes):
if is_table_column:
if not table_format:
table_format = table_repo.create_table_format(
format_id=template_id,
page_number=node.page_no or 1,
x=0.0, y=0.0, width=1000.0, height=1000.0,
rows=1, columns=10
)
table_col_repo.create_column(
table_format_id=table_format.id,
column_index=node_idx,
width=node.width,
header_text=mapping.field_name,
data_type="text"
)
region_repo.create_region(
format_id=template_id,
page_number=node.page_no or 1,
region_type="field_mapping",
x=node.x_coordinate,
y=node.y_coordinate,
width=node.width,
height=node.height,
content={
"field_name": mapping.field_name,
"text_value": node.text_value
}
)
db.commit()
# Generate fingerprint now that mappings are populated
from app.services.fingerprint_service import FingerprintService
FingerprintService(db).generate_fingerprint(template)
db.commit()
return SuccessResponse(message="Mappings saved successfully")
@router.get(
"",
response_model=PaginatedResponse[TemplateListResponse],
summary="List Templates",
description="List all active templates with pagination.",
)
def list_templates(
current_user: CurrentUser,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
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,
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,
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,
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,
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,
current_user: CurrentUser,
filename: str = Query(..., description="Filename of the rendered PDF"),
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,
)

View File

38
docengine/app/core/aes.py Normal file
View File

@@ -0,0 +1,38 @@
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def decrypt(base64_input: str, secret: bytes) -> str | None:
try:
combined = base64.b64decode(base64_input)
# IV length is 12 bytes in the Java implementation
iv_length = 12
if len(combined) <= iv_length + 16:
return None
iv = combined[:iv_length]
# Java AES/GCM appends a 16-byte authentication tag at the end of cipherBytes
# cryptography library expects it to be passed into the modes.GCM(iv, tag)
cipher_bytes_with_tag = combined[iv_length:]
actual_ciphertext = cipher_bytes_with_tag[:-16]
tag = cipher_bytes_with_tag[-16:]
cipher = Cipher(algorithms.AES(secret), modes.GCM(iv, tag))
decryptor = cipher.decryptor()
plain_bytes = decryptor.update(actual_ciphertext) + decryptor.finalize()
return plain_bytes.decode('utf-8')
except Exception:
return None
def get_string_value(encrypted_id: str, secret: str, secret_key_internal: str) -> str | None:
if not secret or not secret_key_internal:
return None
try:
# Replicate Java's logic: (s + secretKeyInternal).substring(0, Math.min(..., 16))
combined_str = secret + secret_key_internal
combined_secret = combined_str[:16].encode('utf-8')
return decrypt(encrypted_id, combined_secret)
except Exception:
return None

View File

@@ -0,0 +1,128 @@
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 = 5432
db_name: str = "ocr"
db_user: str = "postgres"
db_password: str = "M@triXPostgr3s@6202"
db_schema: str = "admin"
db_pool_size: int = 20
db_max_overflow: int = 10
db_echo: bool = False
# Redis
redis_host: str = "192.168.0.111"
redis_port: int = 7901
redis_db: int = 0
redis_password: str = "M@triXR3d1s@6202"
# Celery
celery_broker_url: str = "redis://:M@triXR3d1s@6202@192.168.0.111:7901/0"
celery_result_backend: str = "redis://:M@triXR3d1s@6202@192.168.0.111:7901/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
session_encryption_secret: str = ""
session_encryption_secret_internal: str = ""
# 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", "http://localhost:4200"]
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):
if not v.strip():
return []
try:
parsed = json.loads(v)
if isinstance(parsed, list):
return [str(item).strip() for item in parsed]
elif isinstance(parsed, str):
return [parsed.strip()]
except (json.JSONDecodeError, TypeError):
pass
return [origin.strip() for origin in v.split(",") if origin.strip()]
if isinstance(v, list):
return [str(item).strip() for item in v]
return []
@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()

View 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

View File

@@ -0,0 +1,143 @@
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
import uuid
from app.core.config import settings
security_scheme = HTTPBearer(auto_error=False)
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security_scheme)],
db: Annotated[Session, Depends(get_db)],
) -> User:
"""Extract and validate the current user from the JWT token."""
if credentials is None:
if settings.app_env == "development":
# Auto-login as default dev admin user if no token provided in dev
user_repo = UserRepository(db)
user = db.query(User).first()
if user:
return user
dev_user = User(
id=uuid.uuid4(),
username="dev_admin",
email="admin@docengine.local",
hashed_password="mock_password",
is_active=True,
is_superuser=True,
)
db.add(dev_user)
db.commit()
db.refresh(dev_user)
return dev_user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(credentials.credentials)
except InvalidTokenError:
if settings.app_env == "development":
# Fallback to dev admin user on token decode failure in dev
user_repo = UserRepository(db)
user = db.query(User).first()
if user:
return user
dev_user = User(
id=uuid.uuid4(),
username="dev_admin",
email="admin@docengine.local",
hashed_password="mock_password",
is_active=True,
is_superuser=True,
)
db.add(dev_user)
db.commit()
db.refresh(dev_user)
return dev_user
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)]

View 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,
)

View 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)

View File

@@ -0,0 +1,66 @@
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
from app.core.aes import get_string_value
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."""
decrypted_token = get_string_value(
token,
settings.session_encryption_secret,
settings.session_encryption_secret_internal
)
final_token = decrypted_token if decrypted_token else token
try:
payload = jwt.decode(final_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)

View File

View File

View 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", phase="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", phase="startup_complete")
def on_shutdown() -> None:
"""Application shutdown event handler."""
logger.info("application_shutting_down", phase="shutdown")
# Cleanup resources
from app.core.database import engine
engine.dispose()
logger.info("application_stopped", phase="shutdown_complete")

View File

107
docengine/app/main.py Normal file
View 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)
app.add_middleware(AuditMiddleware)
app.add_middleware(RateLimitMiddleware)
setup_cors(app)
# 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,
)

View File

View 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

View File

@@ -0,0 +1,25 @@
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=["*"],
expose_headers=[
"X-Request-ID",
"X-Process-Time",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
],
max_age=600,
)

View 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,
)

View 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

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

View 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,
)

View 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})>"

View 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]})>"

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, 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,
Column("user_id", UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
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})>"

View File

View 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()

View 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)

View File

@@ -0,0 +1,355 @@
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,
is_active: bool = True,
) -> 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,
is_active=is_active,
)
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)

View 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())

Some files were not shown because too many files have changed in this diff Show More