From cd29cfbbc3a819ce4b026b839c817e487e2daf54 Mon Sep 17 00:00:00 2001 From: Narayanan Madaswamy Date: Sun, 2 Aug 2026 19:25:37 +0530 Subject: [PATCH] Fixed - Template Mapping Issue - Tested Invoice JSON Extraction - Failed Other Type Document Scenario --- .DS_Store | Bin 8196 -> 8196 bytes docengine/app/api/v1/documents.py | 39 +++ docengine/app/core/config.py | 13 +- docengine/app/schemas/document.py | 2 +- docengine/app/services/extraction_service.py | 295 ++++++++++++++++++ docengine/app/services/matching_service.py | 2 +- docengine/app/tasks/document_tasks.py | 2 +- .../app/templates/templates.component.html | 2 +- .../app/templates/templates.component.scss | 97 +++--- .../src/app/templates/templates.component.ts | 2 +- 10 files changed, 407 insertions(+), 47 deletions(-) create mode 100644 docengine/app/services/extraction_service.py diff --git a/.DS_Store b/.DS_Store index 899ff80fd910ad1459a9c26ce9f9bcf2266332e2..ab67dbd4e5c479f159d83a208c42d0becdf07e5b 100644 GIT binary patch delta 33 ocmZp1XmQxES3tzV#8OAW*utb%N1@u#(8NGT!Q6cFMFC@e0H^B-L;wH) delta 33 ocmZp1XmQxES3tzXz*0xS$k3oxN1@u#+{92v!Nhp;MFC@e0H$jRFaQ7m diff --git a/docengine/app/api/v1/documents.py b/docengine/app/api/v1/documents.py index 43a5884..9292b23 100644 --- a/docengine/app/api/v1/documents.py +++ b/docengine/app/api/v1/documents.py @@ -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, diff --git a/docengine/app/core/config.py b/docengine/app/core/config.py index e6ebb6e..517c700 100644 --- a/docengine/app/core/config.py +++ b/docengine/app/core/config.py @@ -83,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: diff --git a/docengine/app/schemas/document.py b/docengine/app/schemas/document.py index cc439a1..0e9711a 100644 --- a/docengine/app/schemas/document.py +++ b/docengine/app/schemas/document.py @@ -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) diff --git a/docengine/app/services/extraction_service.py b/docengine/app/services/extraction_service.py new file mode 100644 index 0000000..c2d3772 --- /dev/null +++ b/docengine/app/services/extraction_service.py @@ -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 diff --git a/docengine/app/services/matching_service.py b/docengine/app/services/matching_service.py index a17e85f..a39c8aa 100644 --- a/docengine/app/services/matching_service.py +++ b/docengine/app/services/matching_service.py @@ -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.""" diff --git a/docengine/app/tasks/document_tasks.py b/docengine/app/tasks/document_tasks.py index 1c76a39..134a871 100644 --- a/docengine/app/tasks/document_tasks.py +++ b/docengine/app/tasks/document_tasks.py @@ -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.""" diff --git a/frontend/src/app/templates/templates.component.html b/frontend/src/app/templates/templates.component.html index fc6a23e..de20857 100644 --- a/frontend/src/app/templates/templates.component.html +++ b/frontend/src/app/templates/templates.component.html @@ -1,6 +1,6 @@
- + diff --git a/frontend/src/app/templates/templates.component.scss b/frontend/src/app/templates/templates.component.scss index 152b485..5e17d3d 100644 --- a/frontend/src/app/templates/templates.component.scss +++ b/frontend/src/app/templates/templates.component.scss @@ -59,23 +59,23 @@ /* Draggable Nodes */ .layout-node { position: absolute; - padding: 0.1rem; - background: rgba(255, 255, 255, 0.85); - border: 1px solid var(--surface-border); - border-radius: 2px; + 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; - &:hover { - box-shadow: 0 4px 8px rgba(0,0,0,0.2); - z-index: 10; - border-color: var(--primary-color); - } - &:active { cursor: grabbing; } @@ -93,34 +93,48 @@ } .node-text { - font-size: 0.7rem; + font-size: 0.65rem; color: var(--text-color); - line-height: 1.1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + 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 { border-color: #3B82F6; } - &.vendor { border-color: #8B5CF6; } - &.table_header { border-color: #F59E0B; } - &.total { border-color: #10B981; } - &.tax { border-color: #EF4444; } + &.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: 2px; - left: 2px; - font-size: 0.6rem; + top: 0px; + left: 0px; + font-size: 0.55rem; color: var(--text-color-secondary); - background: rgba(255, 255, 255, 0.9); - border-radius: 2px; - padding: 2px; + 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; - z-index: 20; + 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; @@ -128,7 +142,7 @@ &:hover { color: var(--primary-color); - background: var(--primary-50); + transform: scale(1.1); } } @@ -138,21 +152,26 @@ .close-icon { position: absolute; - top: 2px; - right: 2px; - font-size: 0.6rem; - color: var(--text-color-secondary); - background: rgba(255, 255, 255, 0.9); + top: 0px; + right: 0px; + font-size: 0.55rem; + color: white; + background: #EF4444; border-radius: 50%; - padding: 2px; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; cursor: pointer; opacity: 0; - transition: opacity 0.2s; - z-index: 20; + transition: opacity 0.2s, transform 0.2s; + z-index: 25; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); &:hover { - color: var(--red-500); - background: var(--red-50); + background: #DC2626; + transform: scale(1.1); } } diff --git a/frontend/src/app/templates/templates.component.ts b/frontend/src/app/templates/templates.component.ts index 615b3b8..4e32d38 100644 --- a/frontend/src/app/templates/templates.component.ts +++ b/frontend/src/app/templates/templates.component.ts @@ -134,7 +134,7 @@ export class TemplatesComponent implements OnInit { if (!this.documentId) return; this.templateService.recognizeTemplate(this.documentId).subscribe({ next: (matches) => { - if (matches && matches.length > 0 && matches[0].confidence_score > 0.5) { + 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)}%` });