Compare commits
12 Commits
ai_backed_
...
fix_templa
| Author | SHA1 | Date | |
|---|---|---|---|
| cd29cfbbc3 | |||
| 85614c922d | |||
| b812d3cb82 | |||
| c725014788 | |||
| 360022820d | |||
| 460a1c5c51 | |||
| fb0a78a405 | |||
| 6fd90ef7d4 | |||
| ec16c50e17 | |||
| f28035487f | |||
| f478ab72fd | |||
| 3d6614f408 |
42
SESSION_HANDOFF.md
Normal file
42
SESSION_HANDOFF.md
Normal 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."*
|
||||
@@ -1,7 +1,7 @@
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=M@tr!x#149@dm!N
|
||||
DB_HOST=192.168.0.111
|
||||
DB_PORT=7925
|
||||
DB_PASSWORD=M@triXPostgr3s@6202
|
||||
DB_HOST=103.125.129.116
|
||||
DB_PORT=5432
|
||||
DB_NAME=ocr
|
||||
|
||||
# Mail Configuration (Gmail)
|
||||
|
||||
Binary file not shown.
149
backend/alembic.ini
Normal file
149
backend/alembic.ini
Normal 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
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
BIN
backend/alembic/__pycache__/env.cpython-313.pyc
Normal file
BIN
backend/alembic/__pycache__/env.cpython-313.pyc
Normal file
Binary file not shown.
87
backend/alembic/env.py
Normal file
87
backend/alembic/env.py
Normal 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()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal 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"}
|
||||
@@ -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.
BIN
backend/api/__pycache__/documents.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/documents.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/templates.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/templates.cpython-313.pyc
Normal file
Binary file not shown.
54
backend/api/documents.py
Normal file
54
backend/api/documents.py
Normal 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
53
backend/api/templates.py
Normal 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)
|
||||
Binary file not shown.
217
backend/engine/ocr/document_processor.py
Normal file
217
backend/engine/ocr/document_processor.py
Normal 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"
|
||||
Binary file not shown.
174
backend/engine/recognition/template_engine.py
Normal file
174
backend/engine/recognition/template_engine.py
Normal 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
|
||||
@@ -48,6 +48,10 @@ from database import get_db, Email, Vendor, Document
|
||||
from scheduler import start_scheduler, stop_scheduler
|
||||
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
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -59,6 +63,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Include Routers
|
||||
app.include_router(templates_router)
|
||||
app.include_router(documents_router)
|
||||
|
||||
# CORS configuration
|
||||
origins = [
|
||||
"http://localhost",
|
||||
|
||||
BIN
backend/models/__pycache__/template_models.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/template_models.cpython-313.pyc
Normal file
Binary file not shown.
105
backend/models/template_models.py
Normal file
105
backend/models/template_models.py
Normal 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())
|
||||
Binary file not shown.
75
backend/repositories/template_repository.py
Normal file
75
backend/repositories/template_repository.py
Normal 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
|
||||
@@ -11,3 +11,7 @@ apscheduler
|
||||
python-dotenv
|
||||
pdfplumber
|
||||
ollama
|
||||
numpy
|
||||
opencv-python-headless
|
||||
pdf2image
|
||||
|
||||
|
||||
BIN
backend/schemas/__pycache__/template_schemas.cpython-313.pyc
Normal file
BIN
backend/schemas/__pycache__/template_schemas.cpython-313.pyc
Normal file
Binary file not shown.
80
backend/schemas/template_schemas.py
Normal file
80
backend/schemas/template_schemas.py
Normal 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] = []
|
||||
BIN
backend/services/__pycache__/template_service.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/template_service.cpython-313.pyc
Normal file
Binary file not shown.
25
backend/services/template_service.py
Normal file
25
backend/services/template_service.py
Normal 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)
|
||||
107
backend/sql/01_templates_schema.sql
Normal file
107
backend/sql/01_templates_schema.sql
Normal 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);
|
||||
BIN
backend/uploads/Invoice For Mar 2026.pdf
Normal file
BIN
backend/uploads/Invoice For Mar 2026.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/Invoice For Mar 2026.pdf.jpg
Normal file
BIN
backend/uploads/Invoice For Mar 2026.pdf.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
BIN
backend/uploads/sample-pdf-invoice.pdf.jpg
Normal file
BIN
backend/uploads/sample-pdf-invoice.pdf.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
16
check_matches.py
Normal file
16
check_matches.py
Normal 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
26
debug_match.py
Normal 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}")
|
||||
@@ -10,7 +10,7 @@ APP_WORKERS=4
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=document_engine
|
||||
DB_NAME=ocr
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=changeme
|
||||
DB_SCHEMA=admin
|
||||
@@ -48,7 +48,7 @@ LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:8080"]
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:8080","http://localhost:4200"]
|
||||
CORS_ALLOW_CREDENTIALS=true
|
||||
|
||||
# Rate Limiting
|
||||
|
||||
2
docengine/.gitignore
vendored
2
docengine/.gitignore
vendored
@@ -58,7 +58,7 @@ ENV/
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
storage/
|
||||
/storage/
|
||||
*.pid
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
@@ -13,7 +13,7 @@ A production-ready system for scanning documents, detecting layouts, extracting
|
||||
▼ ▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ PostgreSQL (Schema: admin) │
|
||||
│ 192.168.0.111:7925 │
|
||||
│ 192.168.0.111:5432 │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -92,7 +92,7 @@ docengine/
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- PostgreSQL 16 (running at `192.168.0.111:7925`)
|
||||
- PostgreSQL 16 (running at `192.168.0.111:5432`)
|
||||
- Redis (for Celery)
|
||||
- `poppler-utils` and `ghostscript` (for pdf2image/camelot)
|
||||
|
||||
@@ -116,8 +116,8 @@ mkdir -p storage/{documents,templates,images,temp,rendered}
|
||||
alembic upgrade head
|
||||
|
||||
# (Optional) Seed default data
|
||||
psql -h 192.168.0.111 -p 7925 -U postgres -d document_engine -f sql/003_seed_data.sql
|
||||
psql -h 192.168.0.111 -p 7925 -U postgres -d document_engine -f sql/004_indexes.sql
|
||||
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
|
||||
@@ -142,7 +142,7 @@ docker compose up --build -d
|
||||
docker compose exec app alembic upgrade head
|
||||
|
||||
# Seed data
|
||||
docker compose exec app bash -c "psql -h db -U postgres -d document_engine -f sql/003_seed_data.sql"
|
||||
docker compose exec app bash -c "psql -h db -U postgres -d ocr -f sql/003_seed_data.sql"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -403,7 +403,7 @@ When a document is uploaded, the following Celery task pipeline executes asynchr
|
||||
|
||||
## Database
|
||||
|
||||
**Connection**: `postgresql://postgres:***@192.168.0.111:7925/document_engine`
|
||||
**Connection**: `postgresql://postgres:***@192.168.0.111:5432/ocr`
|
||||
**Schema**: `admin`
|
||||
|
||||
### Migrations
|
||||
@@ -474,11 +474,11 @@ All configuration is via environment variables (`.env` file). Key settings:
|
||||
|------------------------------------|------------------------|---------------------------------|
|
||||
| `APP_PORT` | `7989` | Application port |
|
||||
| `DB_HOST` | `192.168.0.111` | PostgreSQL host |
|
||||
| `DB_PORT` | `7925` | PostgreSQL port |
|
||||
| `DB_NAME` | `document_engine` | Database name |
|
||||
| `DB_PORT` | `5432` | PostgreSQL port |
|
||||
| `DB_NAME` | `ocr` | Database name |
|
||||
| `DB_SCHEMA` | `admin` | PostgreSQL schema |
|
||||
| `REDIS_HOST` | `localhost` | Redis host |
|
||||
| `CELERY_BROKER_URL` | `redis://localhost:6379/0` | Celery broker |
|
||||
| `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 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+psycopg2://postgres:M%%40tr%%21x%%23149%%40dm%%21N@192.168.0.111:7925/document_engine
|
||||
sqlalchemy.url = postgresql+psycopg2://postgres:M%%40triXPostgr3s%%406202@192.168.0.111:5432/ocr
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Override the database URL from settings
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url.replace('%', '%%'))
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
|
||||
@@ -235,6 +235,45 @@ def get_document_template_matches(
|
||||
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,
|
||||
|
||||
@@ -10,7 +10,13 @@ from app.core.database import get_db
|
||||
from app.core.dependencies import CurrentUser
|
||||
from app.core.logging_config import get_logger
|
||||
from app.repositories.document_repository import DocumentRepository, TemplateMatchRepository
|
||||
from app.repositories.template_repository import TemplateRepository
|
||||
from app.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 (
|
||||
@@ -18,6 +24,8 @@ from app.schemas.template import (
|
||||
TemplateRenderRequest,
|
||||
TemplateRenderResponse,
|
||||
TemplateResponse,
|
||||
TemplateCreateRequest,
|
||||
TemplateMappingSaveRequest,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -25,6 +33,198 @@ 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],
|
||||
@@ -32,9 +232,9 @@ router = APIRouter(prefix="/templates", tags=["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),
|
||||
current_user: CurrentUser = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PaginatedResponse[TemplateListResponse]:
|
||||
"""List all active templates."""
|
||||
@@ -61,7 +261,7 @@ def list_templates(
|
||||
)
|
||||
def get_template(
|
||||
template_id: uuid.UUID,
|
||||
current_user: CurrentUser = None,
|
||||
current_user: CurrentUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> TemplateResponse:
|
||||
"""Get a template by ID."""
|
||||
@@ -83,7 +283,7 @@ def get_template(
|
||||
)
|
||||
def delete_template(
|
||||
template_id: uuid.UUID,
|
||||
current_user: CurrentUser = None,
|
||||
current_user: CurrentUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> SuccessResponse:
|
||||
"""Soft-delete a template."""
|
||||
@@ -109,7 +309,7 @@ def delete_template(
|
||||
)
|
||||
def match_template(
|
||||
payload: TemplateMatchRequest,
|
||||
current_user: CurrentUser = None,
|
||||
current_user: CurrentUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[TemplateMatchResponse]:
|
||||
"""Match a document against existing templates."""
|
||||
@@ -162,7 +362,7 @@ def match_template(
|
||||
)
|
||||
def render_template(
|
||||
payload: TemplateRenderRequest,
|
||||
current_user: CurrentUser = None,
|
||||
current_user: CurrentUser,
|
||||
db: Session = Depends(get_db),
|
||||
) -> TemplateRenderResponse:
|
||||
"""Render a template to PDF."""
|
||||
@@ -199,8 +399,8 @@ def render_template(
|
||||
)
|
||||
def download_rendered_pdf(
|
||||
template_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
filename: str = Query(..., description="Filename of the rendered PDF"),
|
||||
current_user: CurrentUser = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Download a rendered PDF."""
|
||||
|
||||
38
docengine/app/core/aes.py
Normal file
38
docengine/app/core/aes.py
Normal 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
|
||||
@@ -28,30 +28,32 @@ class Settings(BaseSettings):
|
||||
|
||||
# Database
|
||||
db_host: str = "192.168.0.111"
|
||||
db_port: int = 7925
|
||||
db_name: str = "document_engine"
|
||||
db_port: int = 5432
|
||||
db_name: str = "ocr"
|
||||
db_user: str = "postgres"
|
||||
db_password: str = "M@tr!x#149@dm!N"
|
||||
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 = "localhost"
|
||||
redis_port: int = 6379
|
||||
redis_host: str = "192.168.0.111"
|
||||
redis_port: int = 7901
|
||||
redis_db: int = 0
|
||||
redis_password: str = ""
|
||||
redis_password: str = "M@triXR3d1s@6202"
|
||||
|
||||
# Celery
|
||||
celery_broker_url: str = "redis://localhost:6379/0"
|
||||
celery_result_backend: str = "redis://localhost:6379/1"
|
||||
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"
|
||||
@@ -67,7 +69,7 @@ class Settings(BaseSettings):
|
||||
log_format: str = "json"
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8080"]
|
||||
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8080", "http://localhost:4200"]
|
||||
cors_allow_credentials: bool = True
|
||||
|
||||
# Rate Limiting
|
||||
@@ -81,13 +83,20 @@ class Settings(BaseSettings):
|
||||
@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 parsed
|
||||
return [str(item).strip() for item in parsed]
|
||||
elif isinstance(parsed, str):
|
||||
return [parsed.strip()]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
||||
return v
|
||||
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:
|
||||
|
||||
@@ -11,17 +11,63 @@ from app.core.security import InvalidTokenError, decode_token
|
||||
from app.models.user import User
|
||||
from app.repositories.user_repository import UserRepository
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=True)
|
||||
import uuid
|
||||
from app.core.config import settings
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security_scheme)],
|
||||
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",
|
||||
|
||||
@@ -8,6 +8,7 @@ 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")
|
||||
|
||||
@@ -44,8 +45,14 @@ def create_refresh_token(data: dict[str, Any], expires_delta: timedelta | None =
|
||||
|
||||
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(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ logger = get_logger(__name__)
|
||||
def on_startup() -> None:
|
||||
"""Application startup event handler."""
|
||||
setup_logging()
|
||||
logger.info("application_starting", event="startup")
|
||||
logger.info("application_starting", phase="startup")
|
||||
|
||||
# Ensure storage directories exist
|
||||
from app.storage.provider import get_storage_provider
|
||||
@@ -25,15 +25,15 @@ def on_startup() -> None:
|
||||
else:
|
||||
logger.error("database_connection_failed")
|
||||
|
||||
logger.info("application_started", event="startup_complete")
|
||||
logger.info("application_started", phase="startup_complete")
|
||||
|
||||
|
||||
def on_shutdown() -> None:
|
||||
"""Application shutdown event handler."""
|
||||
logger.info("application_shutting_down", event="shutdown")
|
||||
logger.info("application_shutting_down", phase="shutdown")
|
||||
|
||||
# Cleanup resources
|
||||
from app.core.database import engine
|
||||
engine.dispose()
|
||||
|
||||
logger.info("application_stopped", event="shutdown_complete")
|
||||
logger.info("application_stopped", phase="shutdown_complete")
|
||||
|
||||
@@ -37,9 +37,9 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
# Setup middleware (order matters: last added = first executed)
|
||||
setup_cors(app)
|
||||
app.add_middleware(AuditMiddleware)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
setup_cors(app)
|
||||
|
||||
# Setup Prometheus metrics
|
||||
setup_metrics(app)
|
||||
|
||||
@@ -13,13 +13,7 @@ def setup_cors(app: FastAPI) -> None:
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=[
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"X-Request-ID",
|
||||
"X-Requested-With",
|
||||
],
|
||||
allow_headers=["*"],
|
||||
expose_headers=[
|
||||
"X-Request-ID",
|
||||
"X-Process-Time",
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Table, Text, func
|
||||
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
|
||||
|
||||
@@ -13,8 +13,8 @@ from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
user_roles_table = Table(
|
||||
"user_roles",
|
||||
Base.metadata,
|
||||
mapped_column("user_id", UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
mapped_column("role_id", UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ class TemplateRepository(BaseRepository[DocumentFormat]):
|
||||
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(
|
||||
@@ -85,6 +86,7 @@ class TemplateRepository(BaseRepository[DocumentFormat]):
|
||||
fingerprint=fingerprint,
|
||||
source_document_id=source_document_id,
|
||||
created_by=created_by,
|
||||
is_active=is_active,
|
||||
)
|
||||
return self.create(template)
|
||||
|
||||
|
||||
@@ -130,5 +130,5 @@ class TemplateMatchRequest(BaseSchema):
|
||||
"""Request to match a document against templates."""
|
||||
|
||||
document_id: uuid.UUID
|
||||
min_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
min_confidence: float = Field(default=0.75, ge=0.0, le=1.0)
|
||||
max_results: int = Field(default=5, ge=1, le=20)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
@@ -248,3 +248,30 @@ class TemplateRenderResponse(BaseSchema):
|
||||
file_size: int
|
||||
page_count: int
|
||||
rendered_at: datetime
|
||||
|
||||
|
||||
class TemplateFieldCreate(BaseSchema):
|
||||
field_label: str
|
||||
field_type: str = "text"
|
||||
display_order: int = 0
|
||||
required_flag: bool = False
|
||||
|
||||
class TemplateCreateRequest(BaseSchema):
|
||||
template_name: str
|
||||
source_document_id: Optional[str] = None
|
||||
fields: List[TemplateFieldCreate] = Field(default_factory=list)
|
||||
|
||||
class MappingNodeRequest(BaseSchema):
|
||||
pk_document_data_id: Union[int, str, None] = None
|
||||
x_coordinate: float
|
||||
y_coordinate: float
|
||||
width: float
|
||||
height: float
|
||||
text_value: Optional[str] = None
|
||||
page_width: Optional[float] = None
|
||||
page_height: Optional[float] = None
|
||||
page_no: Optional[int] = None
|
||||
|
||||
class TemplateMappingSaveRequest(BaseSchema):
|
||||
field_name: str
|
||||
mapped_nodes: List[MappingNodeRequest] = Field(default_factory=list)
|
||||
|
||||
@@ -54,13 +54,11 @@ class DocumentProcessingService:
|
||||
|
||||
# Step 2: Analyze layout
|
||||
layout_results = self.layout_service.analyze_document_layout(document)
|
||||
document.document_metadata = document.document_metadata or {}
|
||||
document.document_metadata["layout"] = layout_results
|
||||
metadata = document.document_metadata or {}
|
||||
metadata["layout"] = layout_results
|
||||
document.document_metadata = metadata
|
||||
|
||||
# Step 3: Generate template
|
||||
template = self.template_service.generate_template(document)
|
||||
|
||||
# Step 4: Update document status
|
||||
# Step 3: Update document status
|
||||
self.doc_repo.update_status(document_id, "completed")
|
||||
self.db.commit()
|
||||
|
||||
@@ -68,7 +66,6 @@ class DocumentProcessingService:
|
||||
"processing_completed",
|
||||
document_id=str(document_id),
|
||||
pages=document.page_count,
|
||||
template_id=str(template.id),
|
||||
)
|
||||
|
||||
return document
|
||||
|
||||
295
docengine/app/services/extraction_service.py
Normal file
295
docengine/app/services/extraction_service.py
Normal file
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging_config import get_logger
|
||||
from app.repositories.document_repository import DocumentRepository
|
||||
from app.repositories.template_repository import TemplateRepository
|
||||
from app.services.matching_service import MatchingService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ExtractionService:
|
||||
"""Extract document values using matched template coordinates and structures."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.matching_service = MatchingService(db)
|
||||
self.doc_repo = DocumentRepository(db)
|
||||
self.template_repo = TemplateRepository(db)
|
||||
|
||||
def extract_document_data(self, document_id: uuid.UUID) -> dict[str, Any]:
|
||||
"""Perform template matching on the document and extract structured field values."""
|
||||
document = self.doc_repo.get_with_pages(document_id)
|
||||
if not document:
|
||||
raise ValueError(f"Document '{document_id}' not found")
|
||||
|
||||
# 1. Match document against existing templates
|
||||
matches = self.matching_service.match_document(document_id, min_confidence=0.75)
|
||||
if not matches:
|
||||
logger.info("extraction_failed_no_match", document_id=str(document_id))
|
||||
return {
|
||||
"template_matched": False,
|
||||
"template_id": None,
|
||||
"template_name": None,
|
||||
"confidence_score": 0.0,
|
||||
"extracted_data": {},
|
||||
}
|
||||
|
||||
best_match = matches[0]
|
||||
template = self.template_repo.get_by_id(best_match.format_id)
|
||||
if not template:
|
||||
raise ValueError(f"Matched template '{best_match.format_id}' not found")
|
||||
|
||||
logger.info(
|
||||
"extraction_matched_template",
|
||||
document_id=str(document_id),
|
||||
template_id=str(template.id),
|
||||
template_name=template.name,
|
||||
score=best_match.confidence_score,
|
||||
)
|
||||
|
||||
extracted_data: dict[str, Any] = {}
|
||||
|
||||
# Load all mapped regions for the template
|
||||
regions = [r for r in template.regions if r.region_type == "field_mapping"]
|
||||
regions_by_field: dict[str, list[Any]] = {}
|
||||
for r in regions:
|
||||
field_name = r.content.get("field_name") if r.content else None
|
||||
if field_name:
|
||||
regions_by_field.setdefault(field_name, []).append(r)
|
||||
|
||||
# 2. Divide fields into Scalar vs Table Column types
|
||||
scalar_cells = [cell for cell in template.cells if cell.data_type != "TABLE_COLUMN"]
|
||||
table_column_cells = [cell for cell in template.cells if cell.data_type == "TABLE_COLUMN"]
|
||||
|
||||
# 3. Extract Scalar Fields
|
||||
for cell in scalar_cells:
|
||||
field_name = cell.field_name
|
||||
if not field_name:
|
||||
continue
|
||||
|
||||
field_regions = regions_by_field.get(field_name, [])
|
||||
if not field_regions:
|
||||
extracted_data[field_name] = ""
|
||||
continue
|
||||
|
||||
extracted_values = []
|
||||
extracted_block_ids = set()
|
||||
sorted_regions = sorted(field_regions, key=lambda r: (r.page_number, r.sequence, r.y, r.x))
|
||||
|
||||
for region in sorted_regions:
|
||||
page = next((p for p in document.pages if p.page_number == region.page_number), None)
|
||||
if not page:
|
||||
continue
|
||||
best_block = self._find_best_overlapping_block(page, region)
|
||||
if best_block and best_block.id not in extracted_block_ids:
|
||||
extracted_block_ids.add(best_block.id)
|
||||
val = best_block.text.strip()
|
||||
if val:
|
||||
extracted_values.append(val)
|
||||
|
||||
extracted_data[field_name] = " ".join(extracted_values)
|
||||
|
||||
# 4. Extract Table Column Fields
|
||||
if table_column_cells:
|
||||
table_column_names = {cell.field_name for cell in table_column_cells if cell.field_name}
|
||||
table_regions = [
|
||||
r for r in regions
|
||||
if r.content and r.content.get("field_name") in table_column_names
|
||||
]
|
||||
|
||||
if table_regions:
|
||||
# Determine vertical boundaries of the table area
|
||||
table_start_y = min((r.y for r in table_regions), default=0.0)
|
||||
table_start_y = max(0.0, table_start_y - 10.0) # subtract buffer
|
||||
|
||||
# Process page where the table coordinates are mapped
|
||||
page_number = min((r.page_number for r in table_regions), default=1)
|
||||
page = next((p for p in document.pages if p.page_number == page_number), None)
|
||||
|
||||
# Determine table vertical end Y by finding any summary scalar fields below the table
|
||||
summary_keywords = {"tax", "total", "shipping", "discount", "vat", "handling", "duty", "subtotal", "grand"}
|
||||
summary_regions = []
|
||||
for col_name, regs in regions_by_field.items():
|
||||
if col_name in table_column_names:
|
||||
continue
|
||||
for r in regs:
|
||||
if r.y > table_start_y and any(kw in col_name.lower() for kw in summary_keywords):
|
||||
# Skip left-aligned metadata fields (like Shipping Method)
|
||||
if page and r.x < page.width * 0.4:
|
||||
continue
|
||||
summary_regions.append(r)
|
||||
|
||||
table_end_y = min((r.y for r in summary_regions), default=99999.0)
|
||||
|
||||
# Check if template has a footer to define the end vertical boundary
|
||||
footer_regions = [r for r in template.regions if r.region_type == "footer"]
|
||||
if footer_regions:
|
||||
table_end_y = min(table_end_y, min(r.y for r in footer_regions))
|
||||
|
||||
# Determine horizontal ranges (X span) for each column in the template
|
||||
col_x_spans: dict[str, tuple[float, float]] = {}
|
||||
for col_name in table_column_names:
|
||||
col_regs = [r for r in table_regions if r.content and r.content.get("field_name") == col_name]
|
||||
if col_regs:
|
||||
x_min = min(r.x for r in col_regs)
|
||||
x_max = max(r.x + r.width for r in col_regs)
|
||||
# Add a 15px margin to accommodate layout differences
|
||||
col_x_spans[col_name] = (max(0.0, x_min - 15.0), x_max + 15.0)
|
||||
else:
|
||||
col_x_spans[col_name] = (0.0, 0.0)
|
||||
|
||||
if page:
|
||||
# Check for summary keyword text blocks (e.g. Total, Tax) as vertical boundary fallback
|
||||
summary_labels = {"subtotal", "total", "grand total", "tax", "shipping & handling", "discount"}
|
||||
for block in page.text_blocks:
|
||||
if block.y > table_start_y:
|
||||
# Skip left-aligned text blocks
|
||||
if block.x < page.width * 0.4:
|
||||
continue
|
||||
block_text_clean = block.text.strip().lower()
|
||||
if any(label in block_text_clean for label in summary_labels):
|
||||
table_end_y = min(table_end_y, block.y)
|
||||
|
||||
# Collect all document blocks inside Y range
|
||||
candidate_blocks = [
|
||||
b for b in page.text_blocks
|
||||
if b.y >= table_start_y and b.y < table_end_y
|
||||
]
|
||||
# Sort blocks by Y coordinate
|
||||
sorted_blocks = sorted(candidate_blocks, key=lambda b: b.y)
|
||||
|
||||
# Group blocks into rows by vertical center alignment (15px threshold)
|
||||
rows: list[list[Any]] = []
|
||||
current_row: list[Any] = []
|
||||
current_y_center = None
|
||||
|
||||
for block in sorted_blocks:
|
||||
if not block.text.strip():
|
||||
continue
|
||||
block_y_center = block.y + block.height / 2
|
||||
if current_y_center is None:
|
||||
current_row.append(block)
|
||||
current_y_center = block_y_center
|
||||
elif abs(block_y_center - current_y_center) < 15.0:
|
||||
current_row.append(block)
|
||||
else:
|
||||
rows.append(current_row)
|
||||
current_row = [block]
|
||||
current_y_center = block_y_center
|
||||
if current_row:
|
||||
rows.append(current_row)
|
||||
|
||||
# Map candidate blocks in each row to columns
|
||||
rows_data: list[dict[str, str]] = []
|
||||
for row_blocks in rows:
|
||||
row_dict: dict[str, list[Any]] = {name: [] for name in table_column_names}
|
||||
for block in row_blocks:
|
||||
best_col = None
|
||||
best_overlap = 0.0
|
||||
for col_name, (x_min, x_max) in col_x_spans.items():
|
||||
overlap = max(
|
||||
0.0,
|
||||
min(x_max, block.x + block.width) - max(x_min, block.x)
|
||||
)
|
||||
overlap_ratio = overlap / block.width if block.width > 0 else 0.0
|
||||
if overlap_ratio > 0.2 and overlap_ratio > best_overlap:
|
||||
best_overlap = overlap_ratio
|
||||
best_col = col_name
|
||||
if best_col:
|
||||
row_dict[best_col].append(block)
|
||||
|
||||
# Construct row values
|
||||
row_values: dict[str, str] = {}
|
||||
for col_name, blocks in row_dict.items():
|
||||
sorted_blocks_in_col = sorted(blocks, key=lambda b: b.x)
|
||||
row_values[col_name] = " ".join(
|
||||
b.text.strip() for b in sorted_blocks_in_col
|
||||
)
|
||||
|
||||
# Filter out table header rows
|
||||
is_header = False
|
||||
for col_name, val in row_values.items():
|
||||
val_lower = val.lower()
|
||||
if (
|
||||
val_lower == col_name.lower() or
|
||||
val_lower in [
|
||||
"item", "items", "qty", "quantity", "price",
|
||||
"amount", "total", "subtotal", "description"
|
||||
]
|
||||
):
|
||||
is_header = True
|
||||
break
|
||||
|
||||
# Append if not header and at least one cell has a value
|
||||
if not is_header and any(row_values.values()):
|
||||
rows_data.append(row_values)
|
||||
|
||||
# Merge continuation lines (descriptions spanning multiple rows with empty sibling columns)
|
||||
merged_rows: list[dict[str, str]] = []
|
||||
for row in rows_data:
|
||||
non_empty_cols = [k for k, v in row.items() if v.strip()]
|
||||
if len(merged_rows) > 0 and len(non_empty_cols) == 1:
|
||||
col_name = non_empty_cols[0]
|
||||
last_row = merged_rows[-1]
|
||||
if last_row.get(col_name):
|
||||
last_row[col_name] = last_row[col_name] + " " + row[col_name]
|
||||
else:
|
||||
last_row[col_name] = row[col_name]
|
||||
else:
|
||||
merged_rows.append(row.copy())
|
||||
|
||||
rows_data = merged_rows
|
||||
|
||||
# Pivot list of rows into parallel arrays under column names
|
||||
for col_name in table_column_names:
|
||||
extracted_data[col_name] = []
|
||||
for row in rows_data:
|
||||
for col_name in table_column_names:
|
||||
extracted_data[col_name].append(row.get(col_name, ""))
|
||||
|
||||
return {
|
||||
"template_matched": True,
|
||||
"template_id": str(template.id),
|
||||
"template_name": template.name,
|
||||
"confidence_score": best_match.confidence_score,
|
||||
"extracted_data": extracted_data,
|
||||
}
|
||||
|
||||
def _find_best_overlapping_block(self, page: Any, region: Any) -> Any | None:
|
||||
"""Find the document text block overlapping most with the template region."""
|
||||
best_block = None
|
||||
best_overlap = 0.0
|
||||
for block in page.text_blocks:
|
||||
x_overlap = max(
|
||||
0.0,
|
||||
min(region.x + region.width, block.x + block.width) - max(region.x, block.x)
|
||||
)
|
||||
y_overlap = max(
|
||||
0.0,
|
||||
min(region.y + region.height, block.y + block.height) - max(region.y, block.y)
|
||||
)
|
||||
overlap = x_overlap * y_overlap
|
||||
if overlap > best_overlap:
|
||||
best_overlap = overlap
|
||||
best_block = block
|
||||
|
||||
# Fallback: if no overlapping block, find the closest center-to-center
|
||||
if not best_block:
|
||||
min_dist = 100.0 # max 100px center-to-center distance
|
||||
for block in page.text_blocks:
|
||||
c_rx = region.x + region.width / 2
|
||||
c_ry = region.y + region.height / 2
|
||||
c_bx = block.x + block.width / 2
|
||||
c_by = block.y + block.height / 2
|
||||
dist = ((c_rx - c_bx) ** 2 + (c_ry - c_by) ** 2) ** 0.5
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
best_block = block
|
||||
|
||||
return best_block
|
||||
@@ -145,6 +145,11 @@ class FingerprintService:
|
||||
if not template.table_formats:
|
||||
return None
|
||||
|
||||
# Filter out dummy tables created by manual mappings (width=1000, height=1000)
|
||||
valid_tables = [tf for tf in template.table_formats if tf.width != 1000.0 and tf.height != 1000.0]
|
||||
if not valid_tables:
|
||||
return None
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
@@ -156,15 +161,36 @@ class FingerprintService:
|
||||
"rows": tf.rows,
|
||||
"columns": tf.columns,
|
||||
}
|
||||
for tf in template.table_formats
|
||||
for tf in valid_tables
|
||||
]
|
||||
}
|
||||
|
||||
def _extract_cell_coordinates(self, template: DocumentFormat) -> dict[str, Any] | None:
|
||||
"""Extract cell coordinates from template."""
|
||||
# For manually mapped templates, prefer the actual mapped regions
|
||||
field_regions = [r for r in template.regions if r.region_type == "field_mapping"]
|
||||
if field_regions:
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"page": r.page_number,
|
||||
"x": r.x,
|
||||
"y": r.y,
|
||||
"width": r.width,
|
||||
"height": r.height,
|
||||
}
|
||||
for r in field_regions
|
||||
]
|
||||
}
|
||||
|
||||
# Otherwise fallback to template cells, but skip if they are all 0,0 (unmapped placeholders)
|
||||
if not template.cells:
|
||||
return None
|
||||
|
||||
has_real_coords = any(c.width > 0 or c.height > 0 for c in template.cells)
|
||||
if not has_real_coords:
|
||||
return None
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
@@ -187,7 +213,7 @@ class FingerprintService:
|
||||
serialized = json.dumps(normalized, sort_keys=True, default=str)
|
||||
return hashlib.sha256(serialized.encode()).hexdigest()
|
||||
|
||||
def _normalize_coordinates(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
def _normalize_coordinates(self, data: Any) -> Any:
|
||||
"""Normalize coordinates by rounding to reduce sensitivity to small variations."""
|
||||
if isinstance(data, dict):
|
||||
return {k: self._normalize_coordinates(v) for k, v in data.items()}
|
||||
@@ -215,44 +241,49 @@ class FingerprintService:
|
||||
weights.append(3.0)
|
||||
|
||||
# Logo coordinates similarity
|
||||
logo_score = self._compare_coordinates(
|
||||
fingerprint1.logo_coordinates,
|
||||
fingerprint2_data.get("logo_coordinates"),
|
||||
)
|
||||
scores.append(logo_score)
|
||||
weights.append(2.0)
|
||||
if fingerprint1.logo_coordinates and fingerprint1.logo_coordinates.get("items"):
|
||||
logo_score = self._compare_coordinates(
|
||||
fingerprint1.logo_coordinates,
|
||||
fingerprint2_data.get("logo_coordinates"),
|
||||
)
|
||||
scores.append(logo_score)
|
||||
weights.append(2.0)
|
||||
|
||||
# Header coordinates similarity
|
||||
header_score = self._compare_coordinates(
|
||||
fingerprint1.header_coordinates,
|
||||
fingerprint2_data.get("header_coordinates"),
|
||||
)
|
||||
scores.append(header_score)
|
||||
weights.append(2.0)
|
||||
if fingerprint1.header_coordinates and fingerprint1.header_coordinates.get("items"):
|
||||
header_score = self._compare_coordinates(
|
||||
fingerprint1.header_coordinates,
|
||||
fingerprint2_data.get("header_coordinates"),
|
||||
)
|
||||
scores.append(header_score)
|
||||
weights.append(2.0)
|
||||
|
||||
# Footer coordinates similarity
|
||||
footer_score = self._compare_coordinates(
|
||||
fingerprint1.footer_coordinates,
|
||||
fingerprint2_data.get("footer_coordinates"),
|
||||
)
|
||||
scores.append(footer_score)
|
||||
weights.append(1.5)
|
||||
if fingerprint1.footer_coordinates and fingerprint1.footer_coordinates.get("items"):
|
||||
footer_score = self._compare_coordinates(
|
||||
fingerprint1.footer_coordinates,
|
||||
fingerprint2_data.get("footer_coordinates"),
|
||||
)
|
||||
scores.append(footer_score)
|
||||
weights.append(1.5)
|
||||
|
||||
# Table coordinates similarity
|
||||
table_score = self._compare_coordinates(
|
||||
fingerprint1.table_coordinates,
|
||||
fingerprint2_data.get("table_coordinates"),
|
||||
)
|
||||
scores.append(table_score)
|
||||
weights.append(2.5)
|
||||
if fingerprint1.table_coordinates and fingerprint1.table_coordinates.get("items"):
|
||||
table_score = self._compare_coordinates(
|
||||
fingerprint1.table_coordinates,
|
||||
fingerprint2_data.get("table_coordinates"),
|
||||
)
|
||||
scores.append(table_score)
|
||||
weights.append(2.0)
|
||||
|
||||
# Cell coordinates similarity
|
||||
cell_score = self._compare_coordinates(
|
||||
fingerprint1.cell_coordinates,
|
||||
fingerprint2_data.get("cell_coordinates"),
|
||||
)
|
||||
scores.append(cell_score)
|
||||
weights.append(1.5)
|
||||
if fingerprint1.cell_coordinates and fingerprint1.cell_coordinates.get("items"):
|
||||
cell_score = self._compare_coordinates(
|
||||
fingerprint1.cell_coordinates,
|
||||
fingerprint2_data.get("cell_coordinates"),
|
||||
)
|
||||
scores.append(cell_score)
|
||||
weights.append(1.5)
|
||||
|
||||
# Weighted average
|
||||
total_weight = sum(weights)
|
||||
|
||||
@@ -29,7 +29,7 @@ class MatchingService:
|
||||
def match_document(
|
||||
self,
|
||||
document_id: uuid.UUID,
|
||||
min_confidence: float = 0.5,
|
||||
min_confidence: float = 0.75,
|
||||
max_results: int = 5,
|
||||
) -> list[TemplateMatch]:
|
||||
"""Match a document against all existing templates."""
|
||||
|
||||
@@ -107,49 +107,94 @@ class NativePDFService:
|
||||
|
||||
def _extract_text_blocks(self, doc_page: DocumentPage, page: fitz.Page) -> None:
|
||||
"""Extract text blocks with positioning and font information."""
|
||||
blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
|
||||
blocks = page.get_text("rawdict")["blocks"]
|
||||
sequence = 0
|
||||
chunks = []
|
||||
|
||||
for block in blocks:
|
||||
if block["type"] != 0: # Skip non-text blocks
|
||||
if block["type"] != 0:
|
||||
continue
|
||||
|
||||
block_text_parts = []
|
||||
font_info = {"family": None, "size": None, "color": None, "style": None}
|
||||
current_chunk = None
|
||||
space_count = 0
|
||||
|
||||
for line in block.get("lines", []):
|
||||
for span in line.get("spans", []):
|
||||
text = span.get("text", "").strip()
|
||||
if text:
|
||||
block_text_parts.append(text)
|
||||
# Capture font info from the first non-empty span
|
||||
if font_info["family"] is None:
|
||||
font_info["family"] = span.get("font", None)
|
||||
font_info["size"] = span.get("size", None)
|
||||
color_int = span.get("color", 0)
|
||||
font_info["color"] = f"#{color_int:06x}" if isinstance(color_int, int) else None
|
||||
flags = span.get("flags", 0)
|
||||
styles = []
|
||||
if flags & 1:
|
||||
styles.append("superscript")
|
||||
if flags & 2:
|
||||
styles.append("italic")
|
||||
if flags & 4:
|
||||
styles.append("serif")
|
||||
if flags & 8:
|
||||
styles.append("monospace")
|
||||
if flags & 16:
|
||||
styles.append("bold")
|
||||
font_info["style"] = ",".join(styles) if styles else "regular"
|
||||
font_size = span.get("size", 12.0)
|
||||
|
||||
full_text = " ".join(block_text_parts)
|
||||
if not full_text.strip():
|
||||
continue
|
||||
# Ignore massive text (like diagonal watermarks)
|
||||
if font_size > 60:
|
||||
continue
|
||||
|
||||
space_threshold = font_size * 1.5
|
||||
|
||||
font_family = span.get("font", None)
|
||||
color_int = span.get("color", 0)
|
||||
font_color = f"#{color_int:06x}" if isinstance(color_int, int) else None
|
||||
|
||||
flags = span.get("flags", 0)
|
||||
styles = []
|
||||
if flags & 1: styles.append("superscript")
|
||||
if flags & 2: styles.append("italic")
|
||||
if flags & 4: styles.append("serif")
|
||||
if flags & 8: styles.append("monospace")
|
||||
if flags & 16: styles.append("bold")
|
||||
font_style = ",".join(styles) if styles else "regular"
|
||||
|
||||
font_info = {
|
||||
"family": font_family,
|
||||
"size": font_size,
|
||||
"color": font_color,
|
||||
"style": font_style
|
||||
}
|
||||
|
||||
for char in span.get("chars", []):
|
||||
c = char["c"]
|
||||
bbox = char["bbox"]
|
||||
|
||||
if c == ' ':
|
||||
space_count += 1
|
||||
if space_count >= 2:
|
||||
if current_chunk and current_chunk["text"].strip():
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = None
|
||||
elif current_chunk:
|
||||
current_chunk["text"] += c
|
||||
current_chunk["bbox"][2] = max(current_chunk["bbox"][2], bbox[2])
|
||||
current_chunk["bbox"][3] = max(current_chunk["bbox"][3], bbox[3])
|
||||
continue
|
||||
else:
|
||||
space_count = 0
|
||||
|
||||
if current_chunk is None:
|
||||
current_chunk = {"text": c, "bbox": list(bbox), "font_info": font_info}
|
||||
continue
|
||||
|
||||
prev_x1 = current_chunk["bbox"][2]
|
||||
distance = bbox[0] - prev_x1
|
||||
|
||||
if distance > space_threshold:
|
||||
if current_chunk["text"].strip():
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = {"text": c, "bbox": list(bbox), "font_info": font_info}
|
||||
else:
|
||||
current_chunk["text"] += c
|
||||
current_chunk["bbox"][2] = max(current_chunk["bbox"][2], bbox[2])
|
||||
current_chunk["bbox"][3] = max(current_chunk["bbox"][3], bbox[3])
|
||||
current_chunk["bbox"][1] = min(current_chunk["bbox"][1], bbox[1])
|
||||
current_chunk["bbox"][0] = min(current_chunk["bbox"][0], bbox[0])
|
||||
|
||||
if current_chunk and current_chunk["text"].strip():
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = None
|
||||
|
||||
for chunk in chunks:
|
||||
bbox = chunk["bbox"]
|
||||
font_info = chunk["font_info"]
|
||||
|
||||
bbox = block["bbox"]
|
||||
self.text_block_repo.create_text_block(
|
||||
page_id=doc_page.id,
|
||||
text=full_text,
|
||||
text=chunk["text"].strip(),
|
||||
x=bbox[0],
|
||||
y=bbox[1],
|
||||
width=bbox[2] - bbox[0],
|
||||
|
||||
@@ -80,6 +80,7 @@ class TemplateService:
|
||||
description=f"Auto-generated template from {document.original_filename}",
|
||||
source_document_id=document.id,
|
||||
created_by=user_id,
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
# Process each page
|
||||
|
||||
1
docengine/app/storage/__init__.py
Normal file
1
docengine/app/storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Storage module
|
||||
163
docengine/app/storage/provider.py
Normal file
163
docengine/app/storage/provider.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import StorageError
|
||||
from app.core.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class StorageProvider(ABC):
|
||||
"""Abstract base class for storage providers."""
|
||||
|
||||
@abstractmethod
|
||||
def save_file(self, file_data: bytes, directory: str, filename: str | None = None) -> str:
|
||||
"""Save file data and return the storage path."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def read_file(self, storage_path: str) -> bytes:
|
||||
"""Read file data from storage."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def delete_file(self, storage_path: str) -> bool:
|
||||
"""Delete a file from storage. Returns True if successful."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def file_exists(self, storage_path: str) -> bool:
|
||||
"""Check if a file exists in storage."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_file_size(self, storage_path: str) -> int:
|
||||
"""Get the file size in bytes."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_absolute_path(self, storage_path: str) -> str:
|
||||
"""Get the absolute filesystem path for a storage path."""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def compute_checksum(data: bytes, algorithm: str = "sha256") -> str:
|
||||
"""Compute checksum of file data."""
|
||||
hasher = hashlib.new(algorithm)
|
||||
hasher.update(data)
|
||||
return hasher.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def generate_filename(original_filename: str) -> str:
|
||||
"""Generate a unique filename preserving the original extension."""
|
||||
ext = Path(original_filename).suffix.lower()
|
||||
return f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
|
||||
class LocalStorageProvider(StorageProvider):
|
||||
"""Local filesystem storage provider."""
|
||||
|
||||
def __init__(self, base_path: str | None = None) -> None:
|
||||
self.base_path = Path(base_path or settings.storage_local_path).resolve()
|
||||
self._ensure_directories()
|
||||
|
||||
def _ensure_directories(self) -> None:
|
||||
"""Create required storage directories."""
|
||||
for subdir in ("documents", "templates", "images", "temp", "rendered"):
|
||||
(self.base_path / subdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _resolve_path(self, storage_path: str) -> Path:
|
||||
"""Resolve a storage path to an absolute path."""
|
||||
resolved = (self.base_path / storage_path).resolve()
|
||||
if not str(resolved).startswith(str(self.base_path)):
|
||||
raise StorageError(f"Path traversal detected: {storage_path}")
|
||||
return resolved
|
||||
|
||||
def save_file(self, file_data: bytes, directory: str, filename: str | None = None) -> str:
|
||||
"""Save file data to local storage."""
|
||||
if filename is None:
|
||||
filename = f"{uuid.uuid4().hex}.bin"
|
||||
|
||||
dir_path = self.base_path / directory
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = dir_path / filename
|
||||
try:
|
||||
file_path.write_bytes(file_data)
|
||||
storage_path = str(file_path.relative_to(self.base_path))
|
||||
logger.info("file_saved", storage_path=storage_path, size=len(file_data))
|
||||
return storage_path
|
||||
except OSError as e:
|
||||
raise StorageError(f"Failed to save file: {e}") from e
|
||||
|
||||
def read_file(self, storage_path: str) -> bytes:
|
||||
"""Read file data from local storage."""
|
||||
file_path = self._resolve_path(storage_path)
|
||||
if not file_path.exists():
|
||||
raise StorageError(f"File not found: {storage_path}")
|
||||
try:
|
||||
return file_path.read_bytes()
|
||||
except OSError as e:
|
||||
raise StorageError(f"Failed to read file: {e}") from e
|
||||
|
||||
def delete_file(self, storage_path: str) -> bool:
|
||||
"""Delete a file from local storage."""
|
||||
file_path = self._resolve_path(storage_path)
|
||||
if not file_path.exists():
|
||||
return False
|
||||
try:
|
||||
file_path.unlink()
|
||||
logger.info("file_deleted", storage_path=storage_path)
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error("file_delete_failed", storage_path=storage_path, error=str(e))
|
||||
raise StorageError(f"Failed to delete file: {e}") from e
|
||||
|
||||
def file_exists(self, storage_path: str) -> bool:
|
||||
"""Check if a file exists in local storage."""
|
||||
file_path = self._resolve_path(storage_path)
|
||||
return file_path.exists()
|
||||
|
||||
def get_file_size(self, storage_path: str) -> int:
|
||||
"""Get the file size in bytes."""
|
||||
file_path = self._resolve_path(storage_path)
|
||||
if not file_path.exists():
|
||||
raise StorageError(f"File not found: {storage_path}")
|
||||
return file_path.stat().st_size
|
||||
|
||||
def get_absolute_path(self, storage_path: str) -> str:
|
||||
"""Get the absolute filesystem path."""
|
||||
return str(self._resolve_path(storage_path))
|
||||
|
||||
def save_temp_file(self, file_data: bytes, filename: str) -> str:
|
||||
"""Save a temporary file."""
|
||||
return self.save_file(file_data, "temp", filename)
|
||||
|
||||
def cleanup_temp(self) -> int:
|
||||
"""Remove all files in the temp directory."""
|
||||
temp_dir = self.base_path / "temp"
|
||||
count = 0
|
||||
if temp_dir.exists():
|
||||
for item in temp_dir.iterdir():
|
||||
if item.is_file():
|
||||
item.unlink()
|
||||
count += 1
|
||||
elif item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
count += 1
|
||||
logger.info("temp_cleanup", files_removed=count)
|
||||
return count
|
||||
|
||||
|
||||
def get_storage_provider() -> StorageProvider:
|
||||
"""Factory function to get the configured storage provider."""
|
||||
if settings.storage_provider == "local":
|
||||
return LocalStorageProvider()
|
||||
raise StorageError(f"Unknown storage provider: {settings.storage_provider}")
|
||||
@@ -57,7 +57,7 @@ def process_document_task(self, document_id: str) -> dict: # noqa: ANN001
|
||||
def match_document_task(
|
||||
self, # noqa: ANN001
|
||||
document_id: str,
|
||||
min_confidence: float = 0.5,
|
||||
min_confidence: float = 0.75,
|
||||
max_results: int = 5,
|
||||
) -> dict:
|
||||
"""Celery task to match a document against templates."""
|
||||
|
||||
62
docengine/app/utils/sanitizers.py
Normal file
62
docengine/app/utils/sanitizers.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from dateutil import parser
|
||||
|
||||
def sanitize_amount(text: str) -> Optional[float]:
|
||||
"""
|
||||
Sanitizes a string representing an amount/number (e.g., "$1,234.56", "€ 50,000", "50 USD")
|
||||
by removing currency symbols, commas, and other non-numeric characters (except for the decimal separator),
|
||||
and casts it to a float.
|
||||
|
||||
Args:
|
||||
text (str): The raw extracted text from the document.
|
||||
|
||||
Returns:
|
||||
Optional[float]: The sanitized numeric value, or None if no number could be extracted.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# Remove obvious alphabetic currency codes, spaces, and commas
|
||||
# We keep digits, period, and minus sign
|
||||
cleaned_text = re.sub(r'[^\d\.-]', '', text)
|
||||
|
||||
if not cleaned_text:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Handle cases where multiple periods or dashes might exist incorrectly
|
||||
# We just try to cast to float. If the OCR produced something like "1.23.45", this will fail.
|
||||
# A more robust regex can handle exact capture if needed.
|
||||
# But this basic cast covers 95% of standard sanitized strings.
|
||||
return float(cleaned_text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def sanitize_date(text: str, field_type: str = 'DATE') -> Optional[str]:
|
||||
"""
|
||||
Sanitizes a string representing a date or datetime and converts it to ISO 8601 format.
|
||||
|
||||
Args:
|
||||
text (str): The raw extracted text from the document.
|
||||
field_type (str): 'DATE' or 'DATETIME'. Determines the output format.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The sanitized date string in ISO format, or None if it could not be parsed.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
# dateutil.parser is very robust at handling formats like "Dec 11, 2020", "11/12/2020", etc.
|
||||
# fuzzy=True allows it to ignore extra words/characters around the date
|
||||
parsed_date = parser.parse(text, fuzzy=True)
|
||||
|
||||
if field_type == 'DATETIME':
|
||||
return parsed_date.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
else:
|
||||
return parsed_date.strftime('%Y-%m-%d')
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return None
|
||||
|
||||
@@ -25,6 +25,7 @@ celery_app.conf.update(
|
||||
task_reject_on_worker_lost=True,
|
||||
broker_connection_retry_on_startup=True,
|
||||
result_expires=86400,
|
||||
task_default_queue="docengine_default",
|
||||
task_routes={
|
||||
"app.tasks.document_tasks.*": {"queue": "document_processing"},
|
||||
},
|
||||
@@ -40,4 +41,9 @@ celery_app.conf.update(
|
||||
},
|
||||
)
|
||||
|
||||
celery_app.autodiscover_tasks(["app.tasks"])
|
||||
celery_app.conf.update(
|
||||
imports=[
|
||||
"app.tasks.document_tasks",
|
||||
"app.tasks.maintenance_tasks"
|
||||
]
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
server.port=7989
|
||||
|
||||
db.host=192.168.0.111
|
||||
db.port=7925
|
||||
db.port=5432
|
||||
db.user=postgres
|
||||
db.password=M@tr!x#149@dm!N
|
||||
db.password=M@triXPostgr3s@6202
|
||||
db.schema=admin
|
||||
|
||||
@@ -7,14 +7,14 @@ services:
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||
POSTGRES_DB: document_engine
|
||||
POSTGRES_DB: ocr
|
||||
ports:
|
||||
- "7925:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- docengine_pgdata_prod:/var/lib/postgresql/data
|
||||
- ./sql/001_create_schema.sql:/docker-entrypoint-initdb.d/001_create_schema.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d document_engine"]
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d ocr"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -6,15 +6,15 @@ services:
|
||||
container_name: docengine_db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: "M@tr!x#149@dm!N"
|
||||
POSTGRES_DB: document_engine
|
||||
POSTGRES_PASSWORD: "M@triXPostgr3s@6202"
|
||||
POSTGRES_DB: ocr
|
||||
ports:
|
||||
- "7925:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- docengine_pgdata:/var/lib/postgresql/data
|
||||
- ./sql/001_create_schema.sql:/docker-entrypoint-initdb.d/001_create_schema.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d document_engine"]
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d ocr"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -21,7 +21,7 @@ bcrypt==4.2.1
|
||||
# Document Processing
|
||||
PyMuPDF==1.25.3
|
||||
paddleocr==2.9.1
|
||||
paddlepaddle==3.0.0b1
|
||||
paddlepaddle>=3.0.0
|
||||
layoutparser==0.3.4
|
||||
opencv-python-headless==4.10.0.84
|
||||
camelot-py[cv]==0.11.0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
CREATE SCHEMA IF NOT EXISTS admin;
|
||||
|
||||
-- Set the default search path
|
||||
ALTER DATABASE document_engine SET search_path TO admin, public;
|
||||
ALTER DATABASE ocr SET search_path TO admin, public;
|
||||
|
||||
-- Grant privileges
|
||||
GRANT ALL ON SCHEMA admin TO postgres;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- DocEngine: Complete table creation script
|
||||
-- Schema: admin
|
||||
-- Database: document_engine
|
||||
-- Database: ocr
|
||||
|
||||
SET search_path TO admin, public;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LoginComponent } from './login/login.component';
|
||||
import { DashboardComponent } from './pages/dashboard/dashboard.component';
|
||||
import { OcrComponent } from './ocr/ocr.component';
|
||||
import { MailboxComponent } from './mailbox/mailbox.component';
|
||||
import { TemplatesComponent } from './templates/templates.component';
|
||||
|
||||
import { AuthorizeComponent } from './pages/session/auth/authorize.component';
|
||||
import { AuthorizeGuard } from './interceptors/authorize.guard';
|
||||
@@ -23,7 +24,8 @@ export const routes: Routes = [
|
||||
children: [
|
||||
{ path: 'profile', component: ProfileComponent },
|
||||
{ path: 'mailbox', component: MailboxComponent },
|
||||
{ path: 'ocr', component: OcrComponent }
|
||||
{ path: 'ocr', component: OcrComponent },
|
||||
{ path: 'templates', component: TemplatesComponent }
|
||||
]
|
||||
},
|
||||
{ path: 'account',
|
||||
|
||||
103
frontend/src/app/services/template.service.ts
Normal file
103
frontend/src/app/services/template.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface DocumentLayout {
|
||||
pk_document_data_id: number;
|
||||
fk_document_id: number;
|
||||
page_no: number;
|
||||
text_value: string;
|
||||
block_type: string;
|
||||
parent_block_id?: number;
|
||||
x_coordinate: number;
|
||||
y_coordinate: number;
|
||||
width: number;
|
||||
height: number;
|
||||
confidence: number;
|
||||
sequence_no: number;
|
||||
page_width: number;
|
||||
page_height: number;
|
||||
}
|
||||
|
||||
export interface TemplateField {
|
||||
pk_template_field_id?: number;
|
||||
field_label: string;
|
||||
field_type: string;
|
||||
display_order: number;
|
||||
required_flag: boolean;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
pk_template_id: number;
|
||||
template_name: string;
|
||||
fields: TemplateField[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TemplateService {
|
||||
private apiUrl = environment.docEngineService;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
uploadDocument(file: File): Observable<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post(`${this.apiUrl}/documents/upload`, formData);
|
||||
}
|
||||
|
||||
getDocumentLayout(documentId: string): Observable<DocumentLayout[]> {
|
||||
return this.http.get<any>(`${this.apiUrl}/documents/${documentId}`).pipe(
|
||||
map(doc => {
|
||||
const layouts: DocumentLayout[] = [];
|
||||
if (doc.pages) {
|
||||
doc.pages.forEach((page: any) => {
|
||||
if (page.text_blocks) {
|
||||
page.text_blocks.forEach((tb: any) => {
|
||||
layouts.push({
|
||||
pk_document_data_id: tb.id,
|
||||
fk_document_id: doc.id,
|
||||
page_no: page.page_number,
|
||||
text_value: tb.text,
|
||||
block_type: tb.block_type,
|
||||
x_coordinate: tb.x,
|
||||
y_coordinate: tb.y,
|
||||
width: tb.width,
|
||||
height: tb.height,
|
||||
confidence: tb.confidence || 0,
|
||||
sequence_no: tb.sequence || 0,
|
||||
page_width: page.width || 1000,
|
||||
page_height: page.height || 1000
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return layouts;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createTemplate(payload: any): Observable<any> {
|
||||
return this.http.post<any>(`${this.apiUrl}/templates`, payload);
|
||||
}
|
||||
|
||||
updateTemplate(templateId: string, payload: any): Observable<any> {
|
||||
return this.http.put<any>(`${this.apiUrl}/templates/${templateId}`, payload);
|
||||
}
|
||||
|
||||
saveMappings(templateId: string, mappings: any[]): Observable<any> {
|
||||
return this.http.post(`${this.apiUrl}/templates/${templateId}/mappings/save`, mappings);
|
||||
}
|
||||
|
||||
getTemplate(templateId: string): Observable<any> {
|
||||
return this.http.get(`${this.apiUrl}/templates/${templateId}`);
|
||||
}
|
||||
|
||||
recognizeTemplate(documentId: string): Observable<any[]> {
|
||||
return this.http.post<any[]>(`${this.apiUrl}/templates/match`, { document_id: documentId });
|
||||
}
|
||||
}
|
||||
144
frontend/src/app/templates/templates.component.html
Normal file
144
frontend/src/app/templates/templates.component.html
Normal file
@@ -0,0 +1,144 @@
|
||||
<p-toast></p-toast>
|
||||
<div class="template-mapping-container">
|
||||
<p-splitter [panelSizes]="[70, 30]" [minSizes]="[50, 20]" styleClass="h-full w-full">
|
||||
|
||||
<!-- LEFT PANEL: DOCUMENT PREVIEW -->
|
||||
<ng-template pTemplate>
|
||||
<div class="panel-container">
|
||||
<div class="header-section">
|
||||
<h2>Document Preview</h2>
|
||||
<div class="upload-section">
|
||||
<input type="file" #fileInput style="display: none" (change)="onFileUpload($event)" accept=".pdf,.png,.jpg,.jpeg,.tiff">
|
||||
<p-button label="Upload Document" icon="pi pi-upload" (onClick)="fileInput.click()"></p-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-area p-4" style="overflow-y: auto; position: relative;">
|
||||
<div *ngIf="layoutNodes.length === 0 && !isScanning" class="empty-state">
|
||||
<i class="pi pi-file text-4xl mb-3"></i>
|
||||
<p>Upload a document to view its extracted layout structure.</p>
|
||||
</div>
|
||||
|
||||
<!-- Scanning Overlay -->
|
||||
<div *ngIf="isScanning" class="scanning-overlay">
|
||||
<div class="scanner-container">
|
||||
<i class="pi pi-file text-6xl text-gray-400 mb-4 scanner-doc"></i>
|
||||
<div class="laser-beam"></div>
|
||||
<div class="scanner-grid"></div>
|
||||
</div>
|
||||
<div class="scanning-text mt-4">
|
||||
<i class="pi pi-spin pi-cog mr-2"></i>
|
||||
<span class="font-semibold text-lg text-primary">Analyzing Document Layout...</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">Extracting text, tables, and regions</p>
|
||||
</div>
|
||||
|
||||
<div *ngFor="let page of pages; let i = index" class="document-page-container mb-5">
|
||||
<div class="page-header text-sm font-semibold text-gray-500 mb-2">Page {{ page.page_number }}</div>
|
||||
|
||||
<div class="document-canvas"
|
||||
[id]="'document-layout-list-' + i"
|
||||
cdkDropList
|
||||
[cdkDropListData]="page.nodes"
|
||||
[cdkDropListConnectedTo]="getConnectedDropLists()"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
[style.aspect-ratio]="page.width + ' / ' + page.height">
|
||||
|
||||
<div *ngFor="let node of page.nodes; let j = index"
|
||||
cdkDrag
|
||||
[cdkDragData]="node"
|
||||
class="layout-node"
|
||||
(mouseup)="onNodeMouseUp(node, $event)"
|
||||
[ngClass]="node.block_type.toLowerCase()"
|
||||
[style.left.%]="(node.x_coordinate / node.page_width) * 100"
|
||||
[style.top.%]="(node.y_coordinate / node.page_height) * 100"
|
||||
[style.width.%]="(node.width / node.page_width) * 100"
|
||||
[style.height.%]="(node.height / node.page_height) * 100">
|
||||
<i class="pi pi-bars drag-handle" cdkDragHandle title="Drag Item"></i>
|
||||
<i class="pi pi-times close-icon" (click)="removeNode(i, j, $event)" title="Delete Item"></i>
|
||||
<div class="node-type" *ngIf="node.height > 20">{{ node.block_type }}</div>
|
||||
<div class="node-text">{{ node.text_value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- RIGHT PANEL: TEMPLATE MAPPING -->
|
||||
<ng-template pTemplate>
|
||||
<div class="panel-container">
|
||||
<div class="header-section">
|
||||
<div class="template-controls">
|
||||
<span class="p-input-icon-left w-full">
|
||||
<i class="pi pi-file"></i>
|
||||
<input pInputText type="text" [(ngModel)]="templateName" placeholder="Template Name" class="w-full" style="padding-left: 2.5rem;" />
|
||||
</span>
|
||||
<div class="button-group mt-2 flex gap-2">
|
||||
<p-button label="Add Field" icon="pi pi-plus" styleClass="p-button-outlined" (onClick)="showAddFieldDialog()"></p-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapping-area">
|
||||
<div *ngIf="templateFields.length === 0" class="empty-state">
|
||||
<p>Add template fields to start mapping.</p>
|
||||
</div>
|
||||
|
||||
<div class="field-list">
|
||||
<div *ngFor="let field of templateFields" class="template-field-container">
|
||||
<div class="field-header flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{{ field.field_label }}</strong>
|
||||
<span class="badge ml-2">{{ field.field_type }}</span>
|
||||
</div>
|
||||
<p-button icon="pi pi-trash" styleClass="p-button-rounded p-button-danger p-button-text p-button-sm" (onClick)="removeField(field.pk_template_field_id)"></p-button>
|
||||
</div>
|
||||
|
||||
<div class="drop-zone"
|
||||
[id]="'field-' + field.pk_template_field_id"
|
||||
cdkDropList
|
||||
[cdkDropListData]="mappings[field.pk_template_field_id]"
|
||||
[cdkDropListConnectedTo]="getCanvasDropLists()"
|
||||
(cdkDropListDropped)="drop($event, field.pk_template_field_id)">
|
||||
|
||||
<div class="placeholder" *ngIf="mappings[field.pk_template_field_id].length === 0">
|
||||
Drop value here...
|
||||
</div>
|
||||
|
||||
<div *ngFor="let mappedNode of mappings[field.pk_template_field_id]; let k = index" cdkDrag [cdkDragData]="mappedNode" class="mapped-node">
|
||||
<i class="pi pi-arrow-left text-xs mr-2"></i>
|
||||
<span class="flex-1 overflow-hidden white-space-nowrap text-overflow-ellipsis">{{ mappedNode.text_value }}</span>
|
||||
<i class="pi pi-times cursor-pointer text-red-500 hover:text-red-700 ml-2" (click)="removeFromField(field.pk_template_field_id!, k)" title="Remove"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-section p-3 border-t">
|
||||
<p-button label="Save Template & Mappings" icon="pi pi-save" styleClass="w-full p-button-success" (onClick)="saveTemplateAndMappings()" [disabled]="templateFields.length === 0"></p-button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
</p-splitter>
|
||||
</div>
|
||||
|
||||
<!-- Add Field Dialog -->
|
||||
<p-dialog header="Add Template Field" [(visible)]="displayAddField" [modal]="true" [style]="{width: '400px'}">
|
||||
<div class="flex flex-column gap-4 py-3">
|
||||
<div class="field flex flex-column gap-2">
|
||||
<label for="fieldLabel" class="font-semibold">Field Label</label>
|
||||
<input id="fieldLabel" type="text" pInputText [(ngModel)]="newField.field_label" class="w-full" autofocus />
|
||||
</div>
|
||||
<div class="field flex flex-column gap-2">
|
||||
<label for="fieldType" class="font-semibold">Field Type</label>
|
||||
<p-dropdown id="fieldType" [options]="fieldTypes" [(ngModel)]="newField.field_type" [style]="{'width':'100%'}" appendTo="body"></p-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template pTemplate="footer">
|
||||
<p-button label="Cancel" icon="pi pi-times" (onClick)="displayAddField = false" styleClass="p-button-text"></p-button>
|
||||
<p-button label="Save" icon="pi pi-check" (onClick)="addField()" [disabled]="!newField.field_label"></p-button>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
317
frontend/src/app/templates/templates.component.scss
Normal file
317
frontend/src/app/templates/templates.component.scss
Normal file
@@ -0,0 +1,317 @@
|
||||
.template-mapping-container {
|
||||
height: calc(100vh - 100px);
|
||||
padding: 1rem;
|
||||
background-color: var(--surface-ground);
|
||||
}
|
||||
|
||||
.panel-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 1rem);
|
||||
width: calc(100% - 1rem);
|
||||
margin: 0.5rem;
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-area, .mapping-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background-color: var(--surface-ground);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Draggable Nodes */
|
||||
.layout-node {
|
||||
position: absolute;
|
||||
box-sizing: content-box !important;
|
||||
border: 6px solid transparent;
|
||||
margin: -6px !important;
|
||||
background-clip: padding-box;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 60px;
|
||||
min-height: 25px;
|
||||
padding: 4px 6px;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.node-type {
|
||||
font-size: 0.5rem;
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.1rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.node-text {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Color coding by block type - use borders now instead of thick left border */
|
||||
&.header { box-shadow: inset 0 0 0 1.5px #3B82F6; }
|
||||
&.vendor { box-shadow: inset 0 0 0 1.5px #8B5CF6; }
|
||||
&.table_header { box-shadow: inset 0 0 0 1.5px #F59E0B; }
|
||||
&.total { box-shadow: inset 0 0 0 1.5px #10B981; }
|
||||
&.tax { box-shadow: inset 0 0 0 1.5px #EF4444; }
|
||||
|
||||
&:hover {
|
||||
z-index: 10;
|
||||
&.header { box-shadow: inset 0 0 0 2px #3B82F6, 0 4px 8px rgba(59, 130, 246, 0.2); }
|
||||
&.vendor { box-shadow: inset 0 0 0 2px #8B5CF6, 0 4px 8px rgba(139, 92, 246, 0.2); }
|
||||
&.table_header { box-shadow: inset 0 0 0 2px #F59E0B, 0 4px 8px rgba(245, 158, 11, 0.2); }
|
||||
&.total { box-shadow: inset 0 0 0 2px #10B981, 0 4px 8px rgba(16, 185, 129, 0.2); }
|
||||
&.tax { box-shadow: inset 0 0 0 2px #EF4444, 0 4px 8px rgba(239, 68, 68, 0.2); }
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
font-size: 0.55rem;
|
||||
color: var(--text-color-secondary);
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
z-index: 25;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
font-size: 0.55rem;
|
||||
color: white;
|
||||
background: #EF4444;
|
||||
border-radius: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
z-index: 25;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
|
||||
&:hover {
|
||||
background: #DC2626;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .close-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Template Mapping Side */
|
||||
.template-field-container {
|
||||
background: white;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
.field-header {
|
||||
background: var(--surface-ground);
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-color-text);
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
padding: 1rem;
|
||||
min-height: 60px;
|
||||
|
||||
.placeholder {
|
||||
color: var(--text-color-secondary);
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mapped-node {
|
||||
background: var(--primary-50);
|
||||
color: var(--primary-900);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--primary-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* CDK Drag Drop */
|
||||
.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
|
||||
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Scanning Overlay Animation */
|
||||
.scanning-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.scanner-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f8f9fa;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.05);
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.scanner-doc {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.laser-beam {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
box-shadow: 0 0 10px 2px var(--primary-color, #3b82f6);
|
||||
z-index: 2;
|
||||
animation: scan-laser 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.scanner-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: linear-gradient(var(--primary-100, #dbeafe) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--primary-100, #dbeafe) 1px, transparent 1px);
|
||||
background-size: 10px 10px;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
animation: scan-grid 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scan-laser {
|
||||
0% { top: -5%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { top: 105%; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes scan-grid {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 20px 20px; }
|
||||
}
|
||||
394
frontend/src/app/templates/templates.component.ts
Normal file
394
frontend/src/app/templates/templates.component.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DragDropModule, CdkDragDrop, moveItemInArray, transferArrayItem, copyArrayItem } from '@angular/cdk/drag-drop';
|
||||
import { SplitterModule } from 'primeng/splitter';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { DropdownModule } from 'primeng/dropdown';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { MessageService } from 'primeng/api';
|
||||
|
||||
import { TemplateService, DocumentLayout, TemplateField } from '../services/template.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-templates',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
DragDropModule,
|
||||
SplitterModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
InputTextModule,
|
||||
DropdownModule,
|
||||
TableModule,
|
||||
ToastModule
|
||||
],
|
||||
providers: [MessageService],
|
||||
templateUrl: './templates.component.html',
|
||||
styleUrls: ['./templates.component.scss']
|
||||
})
|
||||
export class TemplatesComponent implements OnInit {
|
||||
// Document State
|
||||
documentId: string | null = null;
|
||||
layoutNodes: DocumentLayout[] = [];
|
||||
pages: { page_number: number, nodes: DocumentLayout[], width: number, height: number }[] = [];
|
||||
pageWidth: number = 800;
|
||||
pageHeight: number = 1100;
|
||||
|
||||
// Template State
|
||||
templateName: string = '';
|
||||
templateFields: TemplateField[] = [];
|
||||
mappings: { [fieldId: number]: DocumentLayout[] } = {};
|
||||
|
||||
// UI State
|
||||
displayAddField: boolean = false;
|
||||
isScanning: boolean = false;
|
||||
scanProgress: number = 0;
|
||||
currentTemplateId: string | null = null;
|
||||
newField: TemplateField = { field_label: '', field_type: 'TEXT', display_order: 0, required_flag: false };
|
||||
fieldTypes = [
|
||||
{ label: 'TEXT', value: 'TEXT' },
|
||||
{ label: 'NUMBER', value: 'NUMBER' },
|
||||
{ label: 'DATE', value: 'DATE' },
|
||||
{ label: 'DATETIME', value: 'DATETIME' },
|
||||
{ label: 'AMOUNT', value: 'AMOUNT' },
|
||||
{ label: 'ADDRESS', value: 'ADDRESS' },
|
||||
{ label: 'TABLE_COLUMN', value: 'TABLE_COLUMN' }
|
||||
];
|
||||
|
||||
constructor(
|
||||
private templateService: TemplateService,
|
||||
private messageService: MessageService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
onFileUpload(event: any) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.currentTemplateId = null;
|
||||
this.templateName = '';
|
||||
this.templateFields = [];
|
||||
this.mappings = {};
|
||||
|
||||
this.templateService.uploadDocument(file).subscribe({
|
||||
next: (res) => {
|
||||
this.documentId = res.id || res.pk_document_id;
|
||||
this.messageService.add({ severity: 'success', summary: 'Uploaded', detail: 'Document uploaded successfully.' });
|
||||
this.isScanning = true;
|
||||
this.fetchLayout();
|
||||
},
|
||||
error: (err) => {
|
||||
this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Upload failed.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fetchLayout() {
|
||||
if (!this.documentId) return;
|
||||
this.templateService.getDocumentLayout(this.documentId).subscribe({
|
||||
next: (layouts) => {
|
||||
if (layouts.length === 0) {
|
||||
// Document might still be processing in the background Celery worker
|
||||
// Poll again after 2 seconds
|
||||
setTimeout(() => this.fetchLayout(), 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isScanning = false;
|
||||
this.layoutNodes = layouts;
|
||||
|
||||
const pageMap = new Map<number, { page_number: number, nodes: DocumentLayout[], width: number, height: number }>();
|
||||
|
||||
layouts.forEach(node => {
|
||||
if (!pageMap.has(node.page_no)) {
|
||||
pageMap.set(node.page_no, {
|
||||
page_number: node.page_no,
|
||||
nodes: [],
|
||||
width: node.page_width,
|
||||
height: node.page_height
|
||||
});
|
||||
}
|
||||
pageMap.get(node.page_no)!.nodes.push(node);
|
||||
});
|
||||
|
||||
this.pages = Array.from(pageMap.values()).sort((a, b) => a.page_number - b.page_number);
|
||||
|
||||
if (this.pages.length > 0) {
|
||||
this.pageWidth = this.pages[0].width;
|
||||
this.pageHeight = this.pages[0].height;
|
||||
}
|
||||
// Trigger auto-recognition
|
||||
this.autoRecognize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
autoRecognize() {
|
||||
if (!this.documentId) return;
|
||||
this.templateService.recognizeTemplate(this.documentId).subscribe({
|
||||
next: (matches) => {
|
||||
if (matches && matches.length > 0 && matches[0].confidence_score >= 0.75) {
|
||||
const match = matches[0];
|
||||
this.messageService.add({ severity: 'info', summary: 'Template Recognized', detail: `Confidence: ${(match.confidence_score * 100).toFixed(1)}%` });
|
||||
|
||||
this.templateService.getTemplate(match.format_id).subscribe({
|
||||
next: (templateData) => {
|
||||
this.templateName = templateData.name;
|
||||
this.currentTemplateId = match.format_id;
|
||||
this.templateFields = [];
|
||||
this.mappings = {};
|
||||
|
||||
if (templateData.cells) {
|
||||
templateData.cells.forEach((cell: any) => {
|
||||
if (cell.is_dynamic) {
|
||||
const fieldId = new Date().getTime() + Math.random();
|
||||
this.templateFields.push({
|
||||
pk_template_field_id: fieldId as any,
|
||||
field_label: cell.field_name,
|
||||
field_type: cell.data_type,
|
||||
display_order: cell.sequence,
|
||||
required_flag: false
|
||||
});
|
||||
this.mappings[fieldId] = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (templateData.regions) {
|
||||
templateData.regions.forEach((region: any) => {
|
||||
if (region.region_type === 'field_mapping' && region.content && region.content.field_name) {
|
||||
const field = this.templateFields.find(f => f.field_label === region.content.field_name);
|
||||
if (field && field.pk_template_field_id) {
|
||||
// Find the corresponding page in the current document
|
||||
const page = this.pages.find(p => p.page_number === region.page_number);
|
||||
if (page) {
|
||||
let bestMatchIdx = -1;
|
||||
let bestOverlap = 0;
|
||||
|
||||
// Find the text node on the canvas that overlaps most with this region's bounding box
|
||||
for (let i = 0; i < page.nodes.length; i++) {
|
||||
const node = page.nodes[i];
|
||||
const x_overlap = Math.max(0, Math.min(region.x + region.width, node.x_coordinate + node.width) - Math.max(region.x, node.x_coordinate));
|
||||
const y_overlap = Math.max(0, Math.min(region.y + region.height, node.y_coordinate + node.height) - Math.max(region.y, node.y_coordinate));
|
||||
const overlapArea = x_overlap * y_overlap;
|
||||
|
||||
if (overlapArea > bestOverlap) {
|
||||
bestOverlap = overlapArea;
|
||||
bestMatchIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a matching node (at least some overlap), clone it to mappings
|
||||
if (bestMatchIdx !== -1) {
|
||||
const matchedNode = page.nodes[bestMatchIdx];
|
||||
|
||||
// Because *ngFor tracks objects by reference, we clone it
|
||||
this.mappings[field.pk_template_field_id].push({...matchedNode});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showAddFieldDialog() {
|
||||
this.newField = { field_label: '', field_type: 'TEXT', display_order: this.templateFields.length, required_flag: false };
|
||||
this.displayAddField = true;
|
||||
}
|
||||
|
||||
addField() {
|
||||
if (!this.newField.field_label) return;
|
||||
this.newField.pk_template_field_id = new Date().getTime(); // mock ID until saved
|
||||
this.templateFields.push({ ...this.newField });
|
||||
this.mappings[this.newField.pk_template_field_id] = [];
|
||||
this.displayAddField = false;
|
||||
}
|
||||
|
||||
removeField(fieldId?: number) {
|
||||
if (!fieldId) return;
|
||||
this.templateFields = this.templateFields.filter(f => f.pk_template_field_id !== fieldId);
|
||||
delete this.mappings[fieldId];
|
||||
}
|
||||
|
||||
saveTemplateAndMappings() {
|
||||
if (!this.templateName || this.templateName.trim() === '') {
|
||||
this.messageService.add({ severity: 'warn', summary: 'Warning', detail: 'Please provide a template name.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.templateFields.length === 0) {
|
||||
this.messageService.add({ severity: 'warn', summary: 'Warning', detail: 'Please add at least one template field.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
template_name: this.templateName,
|
||||
source_document_id: this.documentId || undefined,
|
||||
fields: this.templateFields
|
||||
};
|
||||
|
||||
const saveRequest = this.currentTemplateId
|
||||
? this.templateService.updateTemplate(this.currentTemplateId, payload)
|
||||
: this.templateService.createTemplate(payload);
|
||||
|
||||
saveRequest.subscribe({
|
||||
next: (res: any) => {
|
||||
const templateId = res.pk_template_id;
|
||||
|
||||
// Construct the mappings payload
|
||||
const mappingsPayload: any[] = [];
|
||||
this.templateFields.forEach(field => {
|
||||
const fieldId = field.pk_template_field_id;
|
||||
if (fieldId && this.mappings[fieldId] && this.mappings[fieldId].length > 0) {
|
||||
mappingsPayload.push({
|
||||
field_name: field.field_label,
|
||||
mapped_nodes: this.mappings[fieldId]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (mappingsPayload.length > 0) {
|
||||
this.templateService.saveMappings(templateId, mappingsPayload).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({ severity: 'success', summary: 'Success', detail: 'Template & Mappings saved successfully.' });
|
||||
},
|
||||
error: (err) => {
|
||||
this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to save mappings.' });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.messageService.add({ severity: 'success', summary: 'Success', detail: 'Template saved successfully (No mappings).' });
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.messageService.add({ severity: 'error', summary: 'Error', detail: 'Failed to save template.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag and Drop Logic
|
||||
drop(event: CdkDragDrop<DocumentLayout[]>, fieldId?: number) {
|
||||
if (event.previousContainer === event.container) {
|
||||
// Do nothing! This allows the item to naturally snap back to its original position
|
||||
// since it's a failed drop (didn't land in a mapping field).
|
||||
} else {
|
||||
const isFromCanvas = event.previousContainer.id.startsWith('document-layout-list');
|
||||
const isToCanvas = event.container.id.startsWith('document-layout-list');
|
||||
|
||||
if (isFromCanvas && !isToCanvas) {
|
||||
// Drag from Canvas -> Field
|
||||
// We use event.item.data which perfectly tracks the dragged object regardless of DOM indexes
|
||||
const clonedNode = JSON.parse(JSON.stringify(event.item.data));
|
||||
|
||||
// Sanitize the value if dropped on an AMOUNT or NUMBER field
|
||||
if (fieldId) {
|
||||
const field = this.templateFields.find(f => f.pk_template_field_id === fieldId);
|
||||
if (field && (field.field_type === 'AMOUNT' || field.field_type === 'NUMBER') && clonedNode.text_value) {
|
||||
const numericText = clonedNode.text_value.replace(/[^\d\.-]/g, '');
|
||||
const floatValue = parseFloat(numericText);
|
||||
if (!isNaN(floatValue)) {
|
||||
clonedNode.text_value = floatValue.toString();
|
||||
} else {
|
||||
clonedNode.text_value = '';
|
||||
}
|
||||
} else if (field && (field.field_type === 'DATE' || field.field_type === 'DATETIME') && clonedNode.text_value) {
|
||||
// Attempt to parse the date and output standard format
|
||||
const timestamp = Date.parse(clonedNode.text_value);
|
||||
if (!isNaN(timestamp)) {
|
||||
const parsedDate = new Date(timestamp);
|
||||
// Extract YYYY-MM-DD
|
||||
const yyyy = parsedDate.getFullYear();
|
||||
const mm = String(parsedDate.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(parsedDate.getDate()).padStart(2, '0');
|
||||
if (field.field_type === 'DATE') {
|
||||
clonedNode.text_value = `${yyyy}-${mm}-${dd}`;
|
||||
} else {
|
||||
const hh = String(parsedDate.getHours()).padStart(2, '0');
|
||||
const min = String(parsedDate.getMinutes()).padStart(2, '0');
|
||||
const ss = String(parsedDate.getSeconds()).padStart(2, '0');
|
||||
clonedNode.text_value = `${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}`;
|
||||
}
|
||||
} else {
|
||||
// If we can't parse it reliably on frontend, we leave it or let backend handle it
|
||||
// We'll leave it as is to give user visual feedback, backend parser is more robust (dateutil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the clone into the destination mapping field
|
||||
event.container.data.splice(event.currentIndex, 0, clonedNode);
|
||||
|
||||
// Force Angular to completely recreate the DOM elements for this specific canvas page.
|
||||
// By using .map(node => ({...node})), we change every object's identity.
|
||||
// This forces Angular to destroy the corrupted DOM element (which CDK moved and left a translate3d on)
|
||||
// and recreate it fresh with its original absolute coordinates!
|
||||
const pageIndex = parseInt(event.previousContainer.id.split('-').pop() || '0');
|
||||
if (!isNaN(pageIndex) && this.pages[pageIndex]) {
|
||||
this.pages[pageIndex].nodes = this.pages[pageIndex].nodes.map(node => ({...node}));
|
||||
|
||||
// Also explicitly clear the transform on the dragged element just in case CDK holds a ref to it
|
||||
event.item.element.nativeElement.style.transform = '';
|
||||
}
|
||||
|
||||
} else if (!isFromCanvas && isToCanvas) {
|
||||
// Drag from Field -> Canvas (Delete from Field)
|
||||
event.previousContainer.data.splice(event.previousIndex, 1);
|
||||
} else {
|
||||
// Drag from Field -> Field (Move)
|
||||
transferArrayItem(
|
||||
event.previousContainer.data,
|
||||
event.container.data,
|
||||
event.previousIndex,
|
||||
event.currentIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeMouseUp(node: DocumentLayout, event: MouseEvent) {
|
||||
// If the user resized the node using the native CSS resize handle, save the new size
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
if (el.style.width && el.style.width.endsWith('px')) {
|
||||
const parentRect = el.parentElement!.getBoundingClientRect();
|
||||
node.width = (el.offsetWidth / parentRect.width) * node.page_width;
|
||||
node.height = (el.offsetHeight / parentRect.height) * node.page_height;
|
||||
|
||||
// Clear the inline pixel styles so Angular bindings take over smoothly
|
||||
el.style.width = '';
|
||||
el.style.height = '';
|
||||
}
|
||||
}
|
||||
|
||||
getConnectedDropLists() {
|
||||
// Return all field ID strings as drop lists
|
||||
return this.templateFields.map(f => 'field-' + f.pk_template_field_id);
|
||||
}
|
||||
|
||||
getCanvasDropLists() {
|
||||
return this.pages.map((_, i) => 'document-layout-list-' + i);
|
||||
}
|
||||
|
||||
removeNode(pageIndex: number, nodeIndex: number, event: Event) {
|
||||
event.stopPropagation();
|
||||
this.pages[pageIndex].nodes.splice(nodeIndex, 1);
|
||||
}
|
||||
|
||||
removeFromField(fieldId: number, nodeIndex: number) {
|
||||
if (this.mappings[fieldId]) {
|
||||
this.mappings[fieldId].splice(nodeIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
frontend/src/app/templates/templates.html
Normal file
1
frontend/src/app/templates/templates.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>templates works!</p>
|
||||
11
frontend/src/app/templates/templates.ts
Normal file
11
frontend/src/app/templates/templates.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-templates',
|
||||
imports: [],
|
||||
templateUrl: './templates.html',
|
||||
styleUrl: './templates.scss',
|
||||
})
|
||||
export class Templates {
|
||||
|
||||
}
|
||||
@@ -6,5 +6,6 @@ export const environment = {
|
||||
userService: 'http://localhost:1701/cygnus/app/api/v1/user',
|
||||
masterService: 'http://localhost:1702/cygnus/app/api/v1/master',
|
||||
toolsService: 'http://localhost:1703/cygnus/app/api/v1/tools',
|
||||
docEngineService: 'http://localhost:7989/api/v1',
|
||||
rsaPublicKey: `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq3RFV/f6ybsOF2m7NBLPUTMBq9b0frJG1HdIDYmrD9Wr1/aGBxTSJwq8IHFlatNpBF3OlJv9uEOybWMM1vXli4IgsuPPmcTOZsQ/O/9UGyBSL6apevNCw6pC1oa0MVLaN6COMAhDr+ri/PYiPQUcYsjDqghmAghMk99umHGUihz/oY/qgxzO+Q9cqePmjpH5c5RaXGBrOQxKoPlm7Uj6MqAfBhLC360VbcMot4XDoV+VeQXMzH0o6e870jdClsLOq1VsCA27jVvafj+HwaJ15ny9UWWilDuS/X8Sd7v+Rmd+qNezi6ROcglyaisXwKfeTWM8/o7HiUco2fL230+jEQIDAQAB`
|
||||
};
|
||||
11
list_templates.py
Normal file
11
list_templates.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.join(os.getcwd(), 'docengine'))
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.template import DocumentFormat
|
||||
|
||||
db = SessionLocal()
|
||||
templates = db.query(DocumentFormat).all()
|
||||
for t in templates:
|
||||
print(f"ID: {t.id}, Name: {t.name}, Source: {t.source_document_id}, Active: {t.is_active}")
|
||||
BIN
ocr.backup
Normal file
BIN
ocr.backup
Normal file
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
DB_HOST=192.168.0.111
|
||||
DB_PORT=7925
|
||||
DB_NAME=ocr
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=M@tr!x#149@dm!N
|
||||
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AdminService.Infrastructure\AdminService.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\AdminService.Domain\AdminService.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared.Commons\Shared.Commons.csproj" />
|
||||
<ProjectReference Include="..\AdminService.Application\AdminService.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
@AdminService.API_HostAddress = http://localhost:5066
|
||||
|
||||
GET {{AdminService.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -1,26 +0,0 @@
|
||||
using AdminService.Infrastructure;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:20091",
|
||||
"sslPort": 44325
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5066",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7244;http://localhost:5066",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=192.168.0.111;Port=7925;Database=ocr;Username=postgres;Password=M@tr!x#149@dm!N"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Binary file not shown.
@@ -1,968 +0,0 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v8.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {
|
||||
"AdminService.API/1.0.0": {
|
||||
"dependencies": {
|
||||
"AdminService.Application": "1.0.0",
|
||||
"AdminService.Domain": "1.0.0",
|
||||
"AdminService.Infrastructure": "1.0.0",
|
||||
"Microsoft.AspNetCore.OpenApi": "8.0.27",
|
||||
"Microsoft.EntityFrameworkCore.Design": "8.0.8",
|
||||
"Shared.Commons": "1.0.0",
|
||||
"Shared.Contracts": "1.0.0",
|
||||
"Swashbuckle.AspNetCore": "6.6.2"
|
||||
},
|
||||
"runtime": {
|
||||
"AdminService.API.dll": {}
|
||||
}
|
||||
},
|
||||
"Humanizer.Core/2.14.1": {
|
||||
"runtime": {
|
||||
"lib/net6.0/Humanizer.dll": {
|
||||
"assemblyVersion": "2.14.0.0",
|
||||
"fileVersion": "2.14.1.48190"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.AspNetCore.OpenApi/8.0.27": {
|
||||
"dependencies": {
|
||||
"Microsoft.OpenApi": "1.6.14"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.AspNetCore.OpenApi.dll": {
|
||||
"assemblyVersion": "8.0.27.0",
|
||||
"fileVersion": "8.0.2726.23008"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Bcl.AsyncInterfaces/6.0.0": {
|
||||
"runtime": {
|
||||
"lib/netstandard2.1/Microsoft.Bcl.AsyncInterfaces.dll": {
|
||||
"assemblyVersion": "6.0.0.0",
|
||||
"fileVersion": "6.0.21.52210"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Analyzers/3.3.3": {},
|
||||
"Microsoft.CodeAnalysis.Common/4.5.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.CodeAnalysis.Analyzers": "3.3.3",
|
||||
"System.Collections.Immutable": "6.0.0",
|
||||
"System.Reflection.Metadata": "6.0.1",
|
||||
"System.Runtime.CompilerServices.Unsafe": "6.0.0",
|
||||
"System.Text.Encoding.CodePages": "6.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/netcoreapp3.1/Microsoft.CodeAnalysis.dll": {
|
||||
"assemblyVersion": "4.5.0.0",
|
||||
"fileVersion": "4.500.23.10905"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"lib/netcoreapp3.1/pt-BR/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"lib/netcoreapp3.1/zh-Hans/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"lib/netcoreapp3.1/zh-Hant/Microsoft.CodeAnalysis.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.CSharp/4.5.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.CodeAnalysis.Common": "4.5.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/netcoreapp3.1/Microsoft.CodeAnalysis.CSharp.dll": {
|
||||
"assemblyVersion": "4.5.0.0",
|
||||
"fileVersion": "4.500.23.10905"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"lib/netcoreapp3.1/pt-BR/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"lib/netcoreapp3.1/zh-Hans/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"lib/netcoreapp3.1/zh-Hant/Microsoft.CodeAnalysis.CSharp.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.CSharp.Workspaces/4.5.0": {
|
||||
"dependencies": {
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"Microsoft.CodeAnalysis.CSharp": "4.5.0",
|
||||
"Microsoft.CodeAnalysis.Common": "4.5.0",
|
||||
"Microsoft.CodeAnalysis.Workspaces.Common": "4.5.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/netcoreapp3.1/Microsoft.CodeAnalysis.CSharp.Workspaces.dll": {
|
||||
"assemblyVersion": "4.5.0.0",
|
||||
"fileVersion": "4.500.23.10905"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"lib/netcoreapp3.1/pt-BR/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"lib/netcoreapp3.1/zh-Hans/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"lib/netcoreapp3.1/zh-Hant/Microsoft.CodeAnalysis.CSharp.Workspaces.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Workspaces.Common/4.5.0": {
|
||||
"dependencies": {
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"Microsoft.Bcl.AsyncInterfaces": "6.0.0",
|
||||
"Microsoft.CodeAnalysis.Common": "4.5.0",
|
||||
"System.Composition": "6.0.0",
|
||||
"System.IO.Pipelines": "6.0.3",
|
||||
"System.Threading.Channels": "6.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/netcoreapp3.1/Microsoft.CodeAnalysis.Workspaces.dll": {
|
||||
"assemblyVersion": "4.5.0.0",
|
||||
"fileVersion": "4.500.23.10905"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"lib/netcoreapp3.1/cs/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"lib/netcoreapp3.1/de/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"lib/netcoreapp3.1/es/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"lib/netcoreapp3.1/fr/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"lib/netcoreapp3.1/it/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"lib/netcoreapp3.1/ja/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"lib/netcoreapp3.1/ko/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"lib/netcoreapp3.1/pl/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"lib/netcoreapp3.1/pt-BR/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"lib/netcoreapp3.1/ru/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"lib/netcoreapp3.1/tr/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"lib/netcoreapp3.1/zh-Hans/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"lib/netcoreapp3.1/zh-Hant/Microsoft.CodeAnalysis.Workspaces.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore/8.0.8": {
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": "8.0.8",
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": "8.0.8",
|
||||
"Microsoft.Extensions.Caching.Memory": "8.0.0",
|
||||
"Microsoft.Extensions.Logging": "8.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.EntityFrameworkCore.dll": {
|
||||
"assemblyVersion": "8.0.8.0",
|
||||
"fileVersion": "8.0.824.36704"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Abstractions/8.0.8": {
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.EntityFrameworkCore.Abstractions.dll": {
|
||||
"assemblyVersion": "8.0.8.0",
|
||||
"fileVersion": "8.0.824.36704"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Analyzers/8.0.8": {},
|
||||
"Microsoft.EntityFrameworkCore.Design/8.0.8": {
|
||||
"dependencies": {
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"Microsoft.CodeAnalysis.CSharp.Workspaces": "4.5.0",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "8.0.8",
|
||||
"Microsoft.Extensions.DependencyModel": "8.0.1",
|
||||
"Mono.TextTemplating": "2.2.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.EntityFrameworkCore.Design.dll": {
|
||||
"assemblyVersion": "8.0.8.0",
|
||||
"fileVersion": "8.0.824.36704"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Relational/8.0.8": {
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "8.0.8",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.EntityFrameworkCore.Relational.dll": {
|
||||
"assemblyVersion": "8.0.8.0",
|
||||
"fileVersion": "8.0.824.36704"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.ApiDescription.Server/6.0.5": {},
|
||||
"Microsoft.Extensions.Caching.Abstractions/8.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Memory/8.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Caching.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Options": "8.0.0",
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions/8.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection/8.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions/8.0.0": {},
|
||||
"Microsoft.Extensions.DependencyModel/8.0.1": {
|
||||
"dependencies": {
|
||||
"System.Text.Encodings.Web": "8.0.0",
|
||||
"System.Text.Json": "8.0.4"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.Extensions.DependencyModel.dll": {
|
||||
"assemblyVersion": "8.0.0.1",
|
||||
"fileVersion": "8.0.724.31311"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging/8.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "8.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Options": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions/8.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options/8.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives/8.0.0": {},
|
||||
"Microsoft.OpenApi/1.6.14": {
|
||||
"runtime": {
|
||||
"lib/netstandard2.0/Microsoft.OpenApi.dll": {
|
||||
"assemblyVersion": "1.6.14.0",
|
||||
"fileVersion": "1.6.14.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Mono.TextTemplating/2.2.1": {
|
||||
"dependencies": {
|
||||
"System.CodeDom": "4.4.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/netstandard2.0/Mono.TextTemplating.dll": {
|
||||
"assemblyVersion": "2.2.0.0",
|
||||
"fileVersion": "2.2.1.1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Npgsql/8.0.4": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Npgsql.dll": {
|
||||
"assemblyVersion": "8.0.4.0",
|
||||
"fileVersion": "8.0.4.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL/8.0.8": {
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "8.0.8",
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": "8.0.8",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "8.0.8",
|
||||
"Npgsql": "8.0.4"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Npgsql.EntityFrameworkCore.PostgreSQL.dll": {
|
||||
"assemblyVersion": "8.0.8.0",
|
||||
"fileVersion": "8.0.8.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Swashbuckle.AspNetCore/6.6.2": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.ApiDescription.Server": "6.0.5",
|
||||
"Swashbuckle.AspNetCore.Swagger": "6.6.2",
|
||||
"Swashbuckle.AspNetCore.SwaggerGen": "6.6.2",
|
||||
"Swashbuckle.AspNetCore.SwaggerUI": "6.6.2"
|
||||
}
|
||||
},
|
||||
"Swashbuckle.AspNetCore.Swagger/6.6.2": {
|
||||
"dependencies": {
|
||||
"Microsoft.OpenApi": "1.6.14"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Swashbuckle.AspNetCore.Swagger.dll": {
|
||||
"assemblyVersion": "6.6.2.0",
|
||||
"fileVersion": "6.6.2.401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Swashbuckle.AspNetCore.SwaggerGen/6.6.2": {
|
||||
"dependencies": {
|
||||
"Swashbuckle.AspNetCore.Swagger": "6.6.2"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Swashbuckle.AspNetCore.SwaggerGen.dll": {
|
||||
"assemblyVersion": "6.6.2.0",
|
||||
"fileVersion": "6.6.2.401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Swashbuckle.AspNetCore.SwaggerUI/6.6.2": {
|
||||
"runtime": {
|
||||
"lib/net8.0/Swashbuckle.AspNetCore.SwaggerUI.dll": {
|
||||
"assemblyVersion": "6.6.2.0",
|
||||
"fileVersion": "6.6.2.401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.CodeDom/4.4.0": {
|
||||
"runtime": {
|
||||
"lib/netstandard2.0/System.CodeDom.dll": {
|
||||
"assemblyVersion": "4.0.0.0",
|
||||
"fileVersion": "4.6.25519.3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Collections.Immutable/6.0.0": {
|
||||
"dependencies": {
|
||||
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
|
||||
}
|
||||
},
|
||||
"System.Composition/6.0.0": {
|
||||
"dependencies": {
|
||||
"System.Composition.AttributedModel": "6.0.0",
|
||||
"System.Composition.Convention": "6.0.0",
|
||||
"System.Composition.Hosting": "6.0.0",
|
||||
"System.Composition.Runtime": "6.0.0",
|
||||
"System.Composition.TypedParts": "6.0.0"
|
||||
}
|
||||
},
|
||||
"System.Composition.AttributedModel/6.0.0": {
|
||||
"runtime": {
|
||||
"lib/net6.0/System.Composition.AttributedModel.dll": {
|
||||
"assemblyVersion": "6.0.0.0",
|
||||
"fileVersion": "6.0.21.52210"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Composition.Convention/6.0.0": {
|
||||
"dependencies": {
|
||||
"System.Composition.AttributedModel": "6.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/System.Composition.Convention.dll": {
|
||||
"assemblyVersion": "6.0.0.0",
|
||||
"fileVersion": "6.0.21.52210"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Composition.Hosting/6.0.0": {
|
||||
"dependencies": {
|
||||
"System.Composition.Runtime": "6.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/System.Composition.Hosting.dll": {
|
||||
"assemblyVersion": "6.0.0.0",
|
||||
"fileVersion": "6.0.21.52210"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Composition.Runtime/6.0.0": {
|
||||
"runtime": {
|
||||
"lib/net6.0/System.Composition.Runtime.dll": {
|
||||
"assemblyVersion": "6.0.0.0",
|
||||
"fileVersion": "6.0.21.52210"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Composition.TypedParts/6.0.0": {
|
||||
"dependencies": {
|
||||
"System.Composition.AttributedModel": "6.0.0",
|
||||
"System.Composition.Hosting": "6.0.0",
|
||||
"System.Composition.Runtime": "6.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/System.Composition.TypedParts.dll": {
|
||||
"assemblyVersion": "6.0.0.0",
|
||||
"fileVersion": "6.0.21.52210"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.IO.Pipelines/6.0.3": {},
|
||||
"System.Reflection.Metadata/6.0.1": {
|
||||
"dependencies": {
|
||||
"System.Collections.Immutable": "6.0.0"
|
||||
}
|
||||
},
|
||||
"System.Runtime.CompilerServices.Unsafe/6.0.0": {},
|
||||
"System.Text.Encoding.CodePages/6.0.0": {
|
||||
"dependencies": {
|
||||
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
|
||||
}
|
||||
},
|
||||
"System.Text.Encodings.Web/8.0.0": {},
|
||||
"System.Text.Json/8.0.4": {
|
||||
"dependencies": {
|
||||
"System.Text.Encodings.Web": "8.0.0"
|
||||
}
|
||||
},
|
||||
"System.Threading.Channels/6.0.0": {},
|
||||
"AdminService.Application/1.0.0": {
|
||||
"dependencies": {
|
||||
"AdminService.Domain": "1.0.0",
|
||||
"AdminService.Infrastructure": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"AdminService.Application.dll": {
|
||||
"assemblyVersion": "1.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AdminService.Domain/1.0.0": {
|
||||
"dependencies": {
|
||||
"Shared.Commons": "1.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"AdminService.Domain.dll": {
|
||||
"assemblyVersion": "1.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AdminService.Infrastructure/1.0.0": {
|
||||
"dependencies": {
|
||||
"AdminService.Domain": "1.0.0",
|
||||
"Microsoft.EntityFrameworkCore": "8.0.8",
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL": "8.0.8"
|
||||
},
|
||||
"runtime": {
|
||||
"AdminService.Infrastructure.dll": {
|
||||
"assemblyVersion": "1.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Shared.Commons/1.0.0": {
|
||||
"runtime": {
|
||||
"Shared.Commons.dll": {
|
||||
"assemblyVersion": "1.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Shared.Contracts/1.0.0": {
|
||||
"runtime": {
|
||||
"Shared.Contracts.dll": {
|
||||
"assemblyVersion": "1.0.0",
|
||||
"fileVersion": "1.0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"AdminService.API/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Humanizer.Core/2.14.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==",
|
||||
"path": "humanizer.core/2.14.1",
|
||||
"hashPath": "humanizer.core.2.14.1.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.AspNetCore.OpenApi/8.0.27": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-lbjwtlQ1ICGKp3UyhoF9i4APpzX01CqtY8vVba2vL32Nm3v5ir9/FJ0PHXfxgsO5sr1Bb4KkCOH7FSauEHUgRQ==",
|
||||
"path": "microsoft.aspnetcore.openapi/8.0.27",
|
||||
"hashPath": "microsoft.aspnetcore.openapi.8.0.27.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Bcl.AsyncInterfaces/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==",
|
||||
"path": "microsoft.bcl.asyncinterfaces/6.0.0",
|
||||
"hashPath": "microsoft.bcl.asyncinterfaces.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Analyzers/3.3.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-j/rOZtLMVJjrfLRlAMckJLPW/1rze9MT1yfWqSIbUPGRu1m1P0fuo9PmqapwsmePfGB5PJrudQLvmUOAMF0DqQ==",
|
||||
"path": "microsoft.codeanalysis.analyzers/3.3.3",
|
||||
"hashPath": "microsoft.codeanalysis.analyzers.3.3.3.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Common/4.5.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-lwAbIZNdnY0SUNoDmZHkVUwLO8UyNnyyh1t/4XsbFxi4Ounb3xszIYZaWhyj5ZjyfcwqwmtMbE7fUTVCqQEIdQ==",
|
||||
"path": "microsoft.codeanalysis.common/4.5.0",
|
||||
"hashPath": "microsoft.codeanalysis.common.4.5.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.CodeAnalysis.CSharp/4.5.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-cM59oMKAOxvdv76bdmaKPy5hfj+oR+zxikWoueEB7CwTko7mt9sVKZI8Qxlov0C/LuKEG+WQwifepqL3vuTiBQ==",
|
||||
"path": "microsoft.codeanalysis.csharp/4.5.0",
|
||||
"hashPath": "microsoft.codeanalysis.csharp.4.5.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.CodeAnalysis.CSharp.Workspaces/4.5.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-h74wTpmGOp4yS4hj+EvNzEiPgg/KVs2wmSfTZ81upJZOtPkJsVkgfsgtxxqmAeapjT/vLKfmYV0bS8n5MNVP+g==",
|
||||
"path": "microsoft.codeanalysis.csharp.workspaces/4.5.0",
|
||||
"hashPath": "microsoft.codeanalysis.csharp.workspaces.4.5.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Workspaces.Common/4.5.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-l4dDRmGELXG72XZaonnOeORyD/T5RpEu5LGHOUIhnv+MmUWDY/m1kWXGwtcgQ5CJ5ynkFiRnIYzTKXYjUs7rbw==",
|
||||
"path": "microsoft.codeanalysis.workspaces.common/4.5.0",
|
||||
"hashPath": "microsoft.codeanalysis.workspaces.common.4.5.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore/8.0.8": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-iK+jrJzkfbIxutB7or808BPmJtjUEi5O+eSM7cLDwsyde6+3iOujCSfWnrHrLxY3u+EQrJD+aD8DJ6ogPA2Rtw==",
|
||||
"path": "microsoft.entityframeworkcore/8.0.8",
|
||||
"hashPath": "microsoft.entityframeworkcore.8.0.8.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Abstractions/8.0.8": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-9mMQkZsfL1c2iifBD8MWRmwy59rvsVtR9NOezJj7+g1j4P7g49MJHd8k8faC/v7d5KuHkQ6KOQiSItvoRt9PXA==",
|
||||
"path": "microsoft.entityframeworkcore.abstractions/8.0.8",
|
||||
"hashPath": "microsoft.entityframeworkcore.abstractions.8.0.8.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Analyzers/8.0.8": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-OlAXMU+VQgLz5y5/SBkLvAa9VeiR3dlJqgIebEEH2M2NGA3evm68/Tv7SLWmSxwnEAtA3nmDEZF2pacK6eXh4Q==",
|
||||
"path": "microsoft.entityframeworkcore.analyzers/8.0.8",
|
||||
"hashPath": "microsoft.entityframeworkcore.analyzers.8.0.8.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Design/8.0.8": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-MmQAMHdjZR8Iyn/FVQrh9weJQTn0HqtKa3vELS9ffQJat/qXgnTam9M9jqvePphjkYp5Scee+Hy+EJR4nmWmOA==",
|
||||
"path": "microsoft.entityframeworkcore.design/8.0.8",
|
||||
"hashPath": "microsoft.entityframeworkcore.design.8.0.8.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Relational/8.0.8": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-3WnrwdXxKg4L98cDx0lNEEau8U2lsfuBJCs0Yzht+5XVTmahboM7MukKfQHAzVsHUPszm6ci929S7Qas0WfVHA==",
|
||||
"path": "microsoft.entityframeworkcore.relational/8.0.8",
|
||||
"hashPath": "microsoft.entityframeworkcore.relational.8.0.8.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.ApiDescription.Server/6.0.5": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-Ckb5EDBUNJdFWyajfXzUIMRkhf52fHZOQuuZg/oiu8y7zDCVwD0iHhew6MnThjHmevanpxL3f5ci2TtHQEN6bw==",
|
||||
"path": "microsoft.extensions.apidescription.server/6.0.5",
|
||||
"hashPath": "microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Abstractions/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==",
|
||||
"path": "microsoft.extensions.caching.abstractions/8.0.0",
|
||||
"hashPath": "microsoft.extensions.caching.abstractions.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Memory/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-7pqivmrZDzo1ADPkRwjy+8jtRKWRCPag9qPI+p7sgu7Q4QreWhcvbiWXsbhP+yY8XSiDvZpu2/LWdBv7PnmOpQ==",
|
||||
"path": "microsoft.extensions.caching.memory/8.0.0",
|
||||
"hashPath": "microsoft.extensions.caching.memory.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
|
||||
"path": "microsoft.extensions.configuration.abstractions/8.0.0",
|
||||
"hashPath": "microsoft.extensions.configuration.abstractions.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==",
|
||||
"path": "microsoft.extensions.dependencyinjection/8.0.0",
|
||||
"hashPath": "microsoft.extensions.dependencyinjection.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==",
|
||||
"path": "microsoft.extensions.dependencyinjection.abstractions/8.0.0",
|
||||
"hashPath": "microsoft.extensions.dependencyinjection.abstractions.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel/8.0.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-5Ou6varcxLBzQ+Agfm0k0pnH7vrEITYlXMDuE6s7ZHlZHz6/G8XJ3iISZDr5rfwfge6RnXJ1+Wc479mMn52vjA==",
|
||||
"path": "microsoft.extensions.dependencymodel/8.0.1",
|
||||
"hashPath": "microsoft.extensions.dependencymodel.8.0.1.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.Logging/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==",
|
||||
"path": "microsoft.extensions.logging/8.0.0",
|
||||
"hashPath": "microsoft.extensions.logging.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
|
||||
"path": "microsoft.extensions.logging.abstractions/8.0.0",
|
||||
"hashPath": "microsoft.extensions.logging.abstractions.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.Options/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==",
|
||||
"path": "microsoft.extensions.options/8.0.0",
|
||||
"hashPath": "microsoft.extensions.options.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Extensions.Primitives/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==",
|
||||
"path": "microsoft.extensions.primitives/8.0.0",
|
||||
"hashPath": "microsoft.extensions.primitives.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.OpenApi/1.6.14": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-tTaBT8qjk3xINfESyOPE2rIellPvB7qpVqiWiyA/lACVvz+xOGiXhFUfohcx82NLbi5avzLW0lx+s6oAqQijfw==",
|
||||
"path": "microsoft.openapi/1.6.14",
|
||||
"hashPath": "microsoft.openapi.1.6.14.nupkg.sha512"
|
||||
},
|
||||
"Mono.TextTemplating/2.2.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-KZYeKBET/2Z0gY1WlTAK7+RHTl7GSbtvTLDXEZZojUdAPqpQNDL6tHv7VUpqfX5VEOh+uRGKaZXkuD253nEOBQ==",
|
||||
"path": "mono.texttemplating/2.2.1",
|
||||
"hashPath": "mono.texttemplating.2.2.1.nupkg.sha512"
|
||||
},
|
||||
"Npgsql/8.0.4": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-vaYEUlF/pB9m8bs21wQv3Da0kMHT4A9USe47VfY/L2BO97xz5KfIxhEu22QS9d68ZrLxvtL3wQDfDLPr2OjbjA==",
|
||||
"path": "npgsql/8.0.4",
|
||||
"hashPath": "npgsql.8.0.4.nupkg.sha512"
|
||||
},
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL/8.0.8": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-D5WWJZJTgZYUmGv66BARXbTlinp2a5f5RueJqGYoHuWJw02J0i2va/RA+8N4A5hLORK5YKMRqXhFWtKsZdrksw==",
|
||||
"path": "npgsql.entityframeworkcore.postgresql/8.0.8",
|
||||
"hashPath": "npgsql.entityframeworkcore.postgresql.8.0.8.nupkg.sha512"
|
||||
},
|
||||
"Swashbuckle.AspNetCore/6.6.2": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-+NB4UYVYN6AhDSjW0IJAd1AGD8V33gemFNLPaxKTtPkHB+HaKAKf9MGAEUPivEWvqeQfcKIw8lJaHq6LHljRuw==",
|
||||
"path": "swashbuckle.aspnetcore/6.6.2",
|
||||
"hashPath": "swashbuckle.aspnetcore.6.6.2.nupkg.sha512"
|
||||
},
|
||||
"Swashbuckle.AspNetCore.Swagger/6.6.2": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-ovgPTSYX83UrQUWiS5vzDcJ8TEX1MAxBgDFMK45rC24MorHEPQlZAHlaXj/yth4Zf6xcktpUgTEBvffRQVwDKA==",
|
||||
"path": "swashbuckle.aspnetcore.swagger/6.6.2",
|
||||
"hashPath": "swashbuckle.aspnetcore.swagger.6.6.2.nupkg.sha512"
|
||||
},
|
||||
"Swashbuckle.AspNetCore.SwaggerGen/6.6.2": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-zv4ikn4AT1VYuOsDCpktLq4QDq08e7Utzbir86M5/ZkRaLXbCPF11E1/vTmOiDzRTl0zTZINQU2qLKwTcHgfrA==",
|
||||
"path": "swashbuckle.aspnetcore.swaggergen/6.6.2",
|
||||
"hashPath": "swashbuckle.aspnetcore.swaggergen.6.6.2.nupkg.sha512"
|
||||
},
|
||||
"Swashbuckle.AspNetCore.SwaggerUI/6.6.2": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-mBBb+/8Hm2Q3Wygag+hu2jj69tZW5psuv0vMRXY07Wy+Rrj40vRP8ZTbKBhs91r45/HXT4aY4z0iSBYx1h6JvA==",
|
||||
"path": "swashbuckle.aspnetcore.swaggerui/6.6.2",
|
||||
"hashPath": "swashbuckle.aspnetcore.swaggerui.6.6.2.nupkg.sha512"
|
||||
},
|
||||
"System.CodeDom/4.4.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-2sCCb7doXEwtYAbqzbF/8UAeDRMNmPaQbU2q50Psg1J9KzumyVVCgKQY8s53WIPTufNT0DpSe9QRvVjOzfDWBA==",
|
||||
"path": "system.codedom/4.4.0",
|
||||
"hashPath": "system.codedom.4.4.0.nupkg.sha512"
|
||||
},
|
||||
"System.Collections.Immutable/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==",
|
||||
"path": "system.collections.immutable/6.0.0",
|
||||
"hashPath": "system.collections.immutable.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Composition/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-d7wMuKQtfsxUa7S13tITC8n1cQzewuhD5iDjZtK2prwFfKVzdYtgrTHgjaV03Zq7feGQ5gkP85tJJntXwInsJA==",
|
||||
"path": "system.composition/6.0.0",
|
||||
"hashPath": "system.composition.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Composition.AttributedModel/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-WK1nSDLByK/4VoC7fkNiFuTVEiperuCN/Hyn+VN30R+W2ijO1d0Z2Qm0ScEl9xkSn1G2MyapJi8xpf4R8WRa/w==",
|
||||
"path": "system.composition.attributedmodel/6.0.0",
|
||||
"hashPath": "system.composition.attributedmodel.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Composition.Convention/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-XYi4lPRdu5bM4JVJ3/UIHAiG6V6lWWUlkhB9ab4IOq0FrRsp0F4wTyV4Dj+Ds+efoXJ3qbLqlvaUozDO7OLeXA==",
|
||||
"path": "system.composition.convention/6.0.0",
|
||||
"hashPath": "system.composition.convention.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Composition.Hosting/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-w/wXjj7kvxuHPLdzZ0PAUt++qJl03t7lENmb2Oev0n3zbxyNULbWBlnd5J5WUMMv15kg5o+/TCZFb6lSwfaUUQ==",
|
||||
"path": "system.composition.hosting/6.0.0",
|
||||
"hashPath": "system.composition.hosting.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Composition.Runtime/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-qkRH/YBaMPTnzxrS5RDk1juvqed4A6HOD/CwRcDGyPpYps1J27waBddiiq1y93jk2ZZ9wuA/kynM+NO0kb3PKg==",
|
||||
"path": "system.composition.runtime/6.0.0",
|
||||
"hashPath": "system.composition.runtime.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Composition.TypedParts/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-iUR1eHrL8Cwd82neQCJ00MpwNIBs4NZgXzrPqx8NJf/k4+mwBO0XCRmHYJT4OLSwDDqh5nBLJWkz5cROnrGhRA==",
|
||||
"path": "system.composition.typedparts/6.0.0",
|
||||
"hashPath": "system.composition.typedparts.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.IO.Pipelines/6.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==",
|
||||
"path": "system.io.pipelines/6.0.3",
|
||||
"hashPath": "system.io.pipelines.6.0.3.nupkg.sha512"
|
||||
},
|
||||
"System.Reflection.Metadata/6.0.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-III/lNMSn0ZRBuM9m5Cgbiho5j81u0FAEagFX5ta2DKbljZ3T0IpD8j+BIiHQPeKqJppWS9bGEp6JnKnWKze0g==",
|
||||
"path": "system.reflection.metadata/6.0.1",
|
||||
"hashPath": "system.reflection.metadata.6.0.1.nupkg.sha512"
|
||||
},
|
||||
"System.Runtime.CompilerServices.Unsafe/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==",
|
||||
"path": "system.runtime.compilerservices.unsafe/6.0.0",
|
||||
"hashPath": "system.runtime.compilerservices.unsafe.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Text.Encoding.CodePages/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==",
|
||||
"path": "system.text.encoding.codepages/6.0.0",
|
||||
"hashPath": "system.text.encoding.codepages.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Text.Encodings.Web/8.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==",
|
||||
"path": "system.text.encodings.web/8.0.0",
|
||||
"hashPath": "system.text.encodings.web.8.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Text.Json/8.0.4": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==",
|
||||
"path": "system.text.json/8.0.4",
|
||||
"hashPath": "system.text.json.8.0.4.nupkg.sha512"
|
||||
},
|
||||
"System.Threading.Channels/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-TY8/9+tI0mNaUMgntOxxaq2ndTkdXqLSxvPmas7XEqOlv9lQtB7wLjYGd756lOaO7Dvb5r/WXhluM+0Xe87v5Q==",
|
||||
"path": "system.threading.channels/6.0.0",
|
||||
"hashPath": "system.threading.channels.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"AdminService.Application/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"AdminService.Domain/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"AdminService.Infrastructure/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Shared.Commons/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Shared.Contracts/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net8.0",
|
||||
"frameworks": [
|
||||
{
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "8.0.0"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft.AspNetCore.App",
|
||||
"version": "8.0.0"
|
||||
}
|
||||
],
|
||||
"configProperties": {
|
||||
"System.GC.Server": true,
|
||||
"System.Reflection.NullabilityInfoContext.IsSupported": true,
|
||||
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user