From c2c8f2dd04471a659d8387b99c42931c2b7b3dbf Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 3 Feb 2026 22:29:53 +0100 Subject: [PATCH] WIP --- .coverage | Bin 53248 -> 86016 bytes packages/backend/backend/domain/__init__.py | 25 ++ .../backend/domain/document_classifier.py | 108 ++++++++ .../backend/domain/invoice_validator.py | 141 +++++++++++ packages/backend/backend/domain/utils.py | 23 ++ .../backend/backend/web/services/inference.py | 120 ++++----- tests/domain/__init__.py | 1 + tests/domain/test_document_classifier.py | 176 +++++++++++++ tests/domain/test_invoice_validator.py | 232 ++++++++++++++++++ tests/web/test_inference_service.py | 28 ++- 10 files changed, 786 insertions(+), 68 deletions(-) create mode 100644 packages/backend/backend/domain/__init__.py create mode 100644 packages/backend/backend/domain/document_classifier.py create mode 100644 packages/backend/backend/domain/invoice_validator.py create mode 100644 packages/backend/backend/domain/utils.py create mode 100644 tests/domain/__init__.py create mode 100644 tests/domain/test_document_classifier.py create mode 100644 tests/domain/test_invoice_validator.py diff --git a/.coverage b/.coverage index d71ec3394e0912a489247098c325d6fb422973c9..7898222f65eec2bf8ce84f008ad9321084216d12 100644 GIT binary patch literal 86016 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 SFv#dTD=gN5wPBkcEd2k`gD^V) delta 534 zcmZozz}m2Yd4fD6??eSBrrw_$Q)cTk8Z9yq5a4BCU|``3Vc?(7Z^3t*FJ!ZzfD0c} zAoD;DZX H`Md@II_I8Y diff --git a/packages/backend/backend/domain/__init__.py b/packages/backend/backend/domain/__init__.py new file mode 100644 index 0000000..90fa440 --- /dev/null +++ b/packages/backend/backend/domain/__init__.py @@ -0,0 +1,25 @@ +""" +Domain Layer + +Business logic separated from technical implementation. +Contains document classification and invoice validation logic. +""" +from backend.domain.document_classifier import ( + ClassificationResult, + DocumentClassifier, +) +from backend.domain.invoice_validator import ( + InvoiceValidator, + ValidationIssue, + ValidationResult, +) +from backend.domain.utils import has_value + +__all__ = [ + "ClassificationResult", + "DocumentClassifier", + "InvoiceValidator", + "ValidationIssue", + "ValidationResult", + "has_value", +] diff --git a/packages/backend/backend/domain/document_classifier.py b/packages/backend/backend/domain/document_classifier.py new file mode 100644 index 0000000..2ae7e16 --- /dev/null +++ b/packages/backend/backend/domain/document_classifier.py @@ -0,0 +1,108 @@ +""" +Document Classifier + +Business logic for classifying documents based on extracted fields. +Separates classification logic from inference pipeline. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from backend.domain.utils import has_value + + +@dataclass(frozen=True) +class ClassificationResult: + """ + Immutable result of document classification. + + Attributes: + document_type: Either "invoice" or "letter" + confidence: Confidence score between 0.0 and 1.0 + reason: Human-readable explanation of classification + """ + + document_type: str + confidence: float + reason: str + + +class DocumentClassifier: + """ + Classifies documents as invoice or letter based on extracted fields. + + Classification Rules: + 1. If payment_line is present -> invoice (high confidence) + 2. If 2+ invoice indicators present -> invoice (medium confidence) + 3. If 1 invoice indicator present -> invoice (lower confidence) + 4. Otherwise -> letter + + Invoice indicator fields: + - payment_line (strongest indicator) + - OCR + - Amount + - Bankgiro + - Plusgiro + - InvoiceNumber + """ + + INVOICE_INDICATOR_FIELDS: frozenset[str] = frozenset( + { + "payment_line", + "OCR", + "Amount", + "Bankgiro", + "Plusgiro", + "InvoiceNumber", + } + ) + + def classify(self, fields: dict[str, str | None]) -> ClassificationResult: + """ + Classify document type based on extracted fields. + + Args: + fields: Dictionary of field names to extracted values. + Empty strings or whitespace-only strings are treated as missing. + + Returns: + Immutable ClassificationResult with type, confidence, and reason. + """ + # Rule 1: payment_line is the strongest indicator + if has_value(fields.get("payment_line")): + return ClassificationResult( + document_type="invoice", + confidence=0.95, + reason="payment_line detected", + ) + + # Count present invoice indicators (excluding payment_line already checked) + present_indicators = [ + field + for field in self.INVOICE_INDICATOR_FIELDS + if field != "payment_line" and has_value(fields.get(field)) + ] + indicator_count = len(present_indicators) + + # Rule 2: Multiple indicators -> invoice with medium-high confidence + if indicator_count >= 2: + return ClassificationResult( + document_type="invoice", + confidence=0.8, + reason=f"{indicator_count} invoice indicators present: {', '.join(present_indicators)}", + ) + + # Rule 3: Single indicator -> invoice with lower confidence + if indicator_count == 1: + return ClassificationResult( + document_type="invoice", + confidence=0.6, + reason=f"1 invoice indicator present: {present_indicators[0]}", + ) + + # Rule 4: No indicators -> letter + return ClassificationResult( + document_type="letter", + confidence=0.7, + reason="no invoice indicators found", + ) diff --git a/packages/backend/backend/domain/invoice_validator.py b/packages/backend/backend/domain/invoice_validator.py new file mode 100644 index 0000000..45caa86 --- /dev/null +++ b/packages/backend/backend/domain/invoice_validator.py @@ -0,0 +1,141 @@ +""" +Invoice Validator + +Business logic for validating extracted invoice fields. +Checks for required fields, format validity, and confidence thresholds. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from backend.domain.utils import has_value + + +@dataclass(frozen=True) +class ValidationIssue: + """ + Single validation issue. + + Attributes: + field: Name of the field with the issue + severity: One of "error", "warning", "info" + message: Human-readable description of the issue + """ + + field: str + severity: str + message: str + + +@dataclass(frozen=True) +class ValidationResult: + """ + Immutable result of invoice validation. + + Attributes: + is_valid: True if no errors (warnings are allowed) + issues: Tuple of validation issues found + confidence: Average confidence score of validated fields + """ + + is_valid: bool + issues: tuple[ValidationIssue, ...] + confidence: float + + +class InvoiceValidator: + """ + Validates extracted invoice fields for completeness and consistency. + + Validation Rules: + 1. Required fields must be present (Amount) + 2. At least one payment reference should be present (warning if missing) + 3. Field confidence should be above threshold (warning if below) + + Required fields: + - Amount + + Payment reference fields (at least one expected): + - OCR + - Bankgiro + - Plusgiro + - payment_line + """ + + REQUIRED_FIELDS: tuple[str, ...] = ("Amount",) + PAYMENT_REF_FIELDS: tuple[str, ...] = ("OCR", "Bankgiro", "Plusgiro", "payment_line") + DEFAULT_MIN_CONFIDENCE: float = 0.5 + + def __init__(self, min_confidence: float = DEFAULT_MIN_CONFIDENCE) -> None: + """ + Initialize validator. + + Args: + min_confidence: Minimum confidence threshold for valid fields. + Fields below this threshold produce warnings. + """ + self._min_confidence = min_confidence + + def validate( + self, + fields: dict[str, str | None], + confidence: dict[str, float], + ) -> ValidationResult: + """ + Validate extracted invoice fields. + + Args: + fields: Dictionary of field names to extracted values + confidence: Dictionary of field names to confidence scores + + Returns: + Immutable ValidationResult with validity status and issues + """ + issues: list[ValidationIssue] = [] + + # Check required fields + for field in self.REQUIRED_FIELDS: + if not has_value(fields.get(field)): + issues.append( + ValidationIssue( + field=field, + severity="error", + message=f"Required field '{field}' is missing", + ) + ) + + # Check payment reference (at least one expected) + has_payment_ref = any( + has_value(fields.get(f)) for f in self.PAYMENT_REF_FIELDS + ) + if not has_payment_ref: + issues.append( + ValidationIssue( + field="payment_reference", + severity="warning", + message="No payment reference (OCR, Bankgiro, Plusgiro, or payment_line)", + ) + ) + + # Check confidence thresholds + for field, conf in confidence.items(): + if conf < self._min_confidence: + issues.append( + ValidationIssue( + field=field, + severity="warning", + message=f"Low confidence ({conf:.2f}) for field '{field}'", + ) + ) + + # Calculate overall validity + has_errors = any(i.severity == "error" for i in issues) + avg_confidence = ( + sum(confidence.values()) / len(confidence) if confidence else 0.0 + ) + + return ValidationResult( + is_valid=not has_errors, + issues=tuple(issues), + confidence=avg_confidence, + ) diff --git a/packages/backend/backend/domain/utils.py b/packages/backend/backend/domain/utils.py new file mode 100644 index 0000000..53f4fca --- /dev/null +++ b/packages/backend/backend/domain/utils.py @@ -0,0 +1,23 @@ +""" +Domain Layer Utilities + +Shared helper functions for domain layer classes. +""" +from __future__ import annotations + + +def has_value(value: str | None) -> bool: + """ + Check if a field value is present and non-empty. + + Args: + value: Field value to check + + Returns: + True if value is a non-empty, non-whitespace string + """ + if value is None: + return False + if not isinstance(value, str): + return bool(value) + return bool(value.strip()) diff --git a/packages/backend/backend/web/services/inference.py b/packages/backend/backend/web/services/inference.py index 576c2ee..fb645d9 100644 --- a/packages/backend/backend/web/services/inference.py +++ b/packages/backend/backend/web/services/inference.py @@ -1,21 +1,24 @@ """ -Inference Service +Inference Service (Adapter Layer) -Business logic for invoice field extraction. +Orchestrates technical pipeline and business domain logic. +Acts as adapter between API layer and internal components. """ from __future__ import annotations +import io import logging import time import uuid +from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, Generator -import numpy as np from PIL import Image +from backend.domain.document_classifier import DocumentClassifier from backend.web.services.storage_helpers import get_storage_helper if TYPE_CHECKING: @@ -50,9 +53,12 @@ class ServiceResult: class InferenceService: """ - Service for running invoice field extraction. + Service for running invoice field extraction (Adapter Pattern). + + Orchestrates: + - Technical layer: InferencePipeline, YOLODetector + - Business layer: DocumentClassifier - Encapsulates YOLO detection and OCR extraction logic. Supports dynamic model loading from database. """ @@ -61,6 +67,7 @@ class InferenceService: model_config: ModelConfig, storage_config: StorageConfig, model_path_resolver: ModelPathResolver | None = None, + document_classifier: DocumentClassifier | None = None, ) -> None: """ Initialize inference service. @@ -71,12 +78,19 @@ class InferenceService: model_path_resolver: Optional function to resolve model path from database. If provided, will be called to get active model path. If returns None, falls back to model_config.model_path. + document_classifier: Optional custom classifier (uses default if None) """ self.model_config = model_config self.storage_config = storage_config self._model_path_resolver = model_path_resolver + + # Technical layer (lazy initialized) self._pipeline = None self._detector = None + + # Business layer (eagerly initialized, no heavy resources) + self._classifier = document_classifier or DocumentClassifier() + self._is_initialized = False self._current_model_path: Path | None = None self._business_features_enabled = False @@ -219,22 +233,12 @@ class InferenceService: result.success = pipeline_result.success result.errors = pipeline_result.errors - # Determine document type based on payment_line presence - # If no payment_line found, it's likely a letter, not an invoice - if not result.fields.get('payment_line'): - result.document_type = "letter" - else: - result.document_type = "invoice" + # Business layer: classify document type + classification = self._classifier.classify(result.fields) + result.document_type = classification.document_type # Get raw detections for visualization - result.detections = [ - { - "field": d.class_name, - "confidence": d.confidence, - "bbox": list(d.bbox), - } - for d in pipeline_result.raw_detections - ] + result.detections = self._format_detections(pipeline_result.raw_detections) # Save visualization if requested if save_visualization and pipeline_result.raw_detections: @@ -293,22 +297,12 @@ class InferenceService: result.success = pipeline_result.success result.errors = pipeline_result.errors - # Determine document type based on payment_line presence - # If no payment_line found, it's likely a letter, not an invoice - if not result.fields.get('payment_line'): - result.document_type = "letter" - else: - result.document_type = "invoice" + # Business layer: classify document type + classification = self._classifier.classify(result.fields) + result.document_type = classification.document_type # Get raw detections - result.detections = [ - { - "field": d.class_name, - "confidence": d.confidence, - "bbox": list(d.bbox), - } - for d in pipeline_result.raw_detections - ] + result.detections = self._format_detections(pipeline_result.raw_detections) # Include business features if extracted if extract_line_items: @@ -329,10 +323,19 @@ class InferenceService: result.processing_time_ms = (time.time() - start_time) * 1000 return result - def _save_visualization(self, image_path: Path, doc_id: str) -> Path: - """Save visualization image with detections.""" - from ultralytics import YOLO + def _format_detections(self, raw_detections: list) -> list[dict]: + """Format raw detections for response.""" + return [ + { + "field": d.class_name, + "confidence": d.confidence, + "bbox": list(d.bbox), + } + for d in raw_detections + ] + def _save_visualization(self, image_path: Path, doc_id: str) -> Path | None: + """Save visualization image with detections using existing detector.""" # Get storage helper for results directory storage = get_storage_helper() results_dir = storage.get_results_base_path() @@ -340,9 +343,8 @@ class InferenceService: logger.warning("Cannot save visualization: local storage not available") return None - # Load model and run prediction with visualization - model = YOLO(str(self.model_config.model_path)) - results = model.predict(str(image_path), verbose=False) + # Reuse self._detector instead of creating new YOLO instance + results = self._detector.model.predict(str(image_path), verbose=False) # Save annotated image output_path = results_dir / f"{doc_id}_result.png" @@ -351,11 +353,20 @@ class InferenceService: return output_path - def _save_pdf_visualization(self, pdf_path: Path, doc_id: str) -> Path: - """Save visualization for PDF (first page).""" + @contextmanager + def _temp_image_file( + self, results_dir: Path, doc_id: str + ) -> Generator[Path, None, None]: + """Context manager for temporary image file with guaranteed cleanup.""" + temp_path = results_dir / f"{doc_id}_temp.png" + try: + yield temp_path + finally: + temp_path.unlink(missing_ok=True) + + def _save_pdf_visualization(self, pdf_path: Path, doc_id: str) -> Path | None: + """Save visualization for PDF (first page) using existing detector.""" from shared.pdf.renderer import render_pdf_to_images - from ultralytics import YOLO - import io # Get storage helper for results directory storage = get_storage_helper() @@ -369,20 +380,19 @@ class InferenceService: pdf_path, dpi=self.model_config.dpi ): image = Image.open(io.BytesIO(image_bytes)) - temp_path = results_dir / f"{doc_id}_temp.png" - image.save(temp_path) - # Run YOLO and save visualization - model = YOLO(str(self.model_config.model_path)) - results = model.predict(str(temp_path), verbose=False) + # Use context manager for temp file to guarantee cleanup + with self._temp_image_file(results_dir, doc_id) as temp_path: + image.save(temp_path) - output_path = results_dir / f"{doc_id}_result.png" - for r in results: - r.save(filename=str(output_path)) + # Reuse self._detector instead of creating new YOLO instance + results = self._detector.model.predict(str(temp_path), verbose=False) - # Cleanup temp file - temp_path.unlink(missing_ok=True) - return output_path + output_path = results_dir / f"{doc_id}_result.png" + for r in results: + r.save(filename=str(output_path)) + + return output_path # If no pages rendered return None diff --git a/tests/domain/__init__.py b/tests/domain/__init__.py new file mode 100644 index 0000000..bf25238 --- /dev/null +++ b/tests/domain/__init__.py @@ -0,0 +1 @@ +# Domain layer tests diff --git a/tests/domain/test_document_classifier.py b/tests/domain/test_document_classifier.py new file mode 100644 index 0000000..3f20887 --- /dev/null +++ b/tests/domain/test_document_classifier.py @@ -0,0 +1,176 @@ +""" +Tests for DocumentClassifier - TDD RED phase. + +Test document type classification based on extracted fields. +""" +import pytest + +from backend.domain.document_classifier import DocumentClassifier, ClassificationResult + + +class TestDocumentClassifier: + """Test document classification logic.""" + + @pytest.fixture + def classifier(self) -> DocumentClassifier: + """Create classifier instance.""" + return DocumentClassifier() + + # ==================== Invoice Detection Tests ==================== + + def test_classify_with_payment_line_returns_invoice( + self, classifier: DocumentClassifier + ) -> None: + """Payment line is the strongest invoice indicator.""" + fields = {"payment_line": "# 123456 # 100 00 5 > 308-2963#"} + + result = classifier.classify(fields) + + assert result.document_type == "invoice" + assert result.confidence >= 0.9 + assert "payment_line" in result.reason + + def test_classify_with_multiple_indicators_returns_invoice( + self, classifier: DocumentClassifier + ) -> None: + """Multiple invoice indicators -> invoice with medium confidence.""" + fields = { + "Amount": "1200.00", + "Bankgiro": "123-4567", + "payment_line": None, + } + + result = classifier.classify(fields) + + assert result.document_type == "invoice" + assert result.confidence >= 0.7 + + def test_classify_with_ocr_and_amount_returns_invoice( + self, classifier: DocumentClassifier + ) -> None: + """OCR + Amount is typical invoice pattern.""" + fields = { + "OCR": "123456789012", + "Amount": "500.00", + } + + result = classifier.classify(fields) + + assert result.document_type == "invoice" + assert result.confidence >= 0.7 + + def test_classify_with_single_indicator_returns_invoice_lower_confidence( + self, classifier: DocumentClassifier + ) -> None: + """Single indicator -> invoice but lower confidence.""" + fields = {"Amount": "100.00"} + + result = classifier.classify(fields) + + assert result.document_type == "invoice" + assert 0.5 <= result.confidence < 0.8 + + def test_classify_with_invoice_number_only( + self, classifier: DocumentClassifier + ) -> None: + """Invoice number alone suggests invoice.""" + fields = {"InvoiceNumber": "INV-2024-001"} + + result = classifier.classify(fields) + + assert result.document_type == "invoice" + + # ==================== Letter Detection Tests ==================== + + def test_classify_with_no_indicators_returns_letter( + self, classifier: DocumentClassifier + ) -> None: + """No invoice indicators -> letter.""" + fields: dict[str, str | None] = {} + + result = classifier.classify(fields) + + assert result.document_type == "letter" + assert result.confidence >= 0.5 + + def test_classify_with_empty_fields_returns_letter( + self, classifier: DocumentClassifier + ) -> None: + """All fields empty or None -> letter.""" + fields = { + "payment_line": None, + "OCR": None, + "Amount": None, + "Bankgiro": None, + } + + result = classifier.classify(fields) + + assert result.document_type == "letter" + + def test_classify_with_only_non_indicator_fields_returns_letter( + self, classifier: DocumentClassifier + ) -> None: + """Fields that don't indicate invoice -> letter.""" + fields = { + "CustomerNumber": "C12345", + "SupplierOrgNumber": "556677-8899", + } + + result = classifier.classify(fields) + + assert result.document_type == "letter" + + # ==================== Edge Cases ==================== + + def test_classify_with_empty_string_fields_returns_letter( + self, classifier: DocumentClassifier + ) -> None: + """Empty strings should be treated as missing.""" + fields = { + "payment_line": "", + "Amount": "", + } + + result = classifier.classify(fields) + + assert result.document_type == "letter" + + def test_classify_with_whitespace_only_fields_returns_letter( + self, classifier: DocumentClassifier + ) -> None: + """Whitespace-only strings should be treated as missing.""" + fields = { + "payment_line": " ", + "Amount": "\t\n", + } + + result = classifier.classify(fields) + + assert result.document_type == "letter" + + # ==================== ClassificationResult Immutability ==================== + + def test_classification_result_is_immutable( + self, classifier: DocumentClassifier + ) -> None: + """ClassificationResult should be a frozen dataclass.""" + fields = {"payment_line": "test"} + result = classifier.classify(fields) + + with pytest.raises((AttributeError, TypeError)): + result.document_type = "modified" # type: ignore + + def test_classification_result_has_required_fields( + self, classifier: DocumentClassifier + ) -> None: + """ClassificationResult must have document_type, confidence, reason.""" + fields = {"Amount": "100.00"} + result = classifier.classify(fields) + + assert hasattr(result, "document_type") + assert hasattr(result, "confidence") + assert hasattr(result, "reason") + assert isinstance(result.document_type, str) + assert isinstance(result.confidence, float) + assert isinstance(result.reason, str) diff --git a/tests/domain/test_invoice_validator.py b/tests/domain/test_invoice_validator.py new file mode 100644 index 0000000..369ecdc --- /dev/null +++ b/tests/domain/test_invoice_validator.py @@ -0,0 +1,232 @@ +""" +Tests for InvoiceValidator - TDD RED phase. + +Test invoice field validation logic. +""" +import pytest + +from backend.domain.invoice_validator import ( + InvoiceValidator, + ValidationResult, + ValidationIssue, +) + + +class TestInvoiceValidator: + """Test invoice validation logic.""" + + @pytest.fixture + def validator(self) -> InvoiceValidator: + """Create validator instance with default settings.""" + return InvoiceValidator() + + @pytest.fixture + def validator_strict(self) -> InvoiceValidator: + """Create validator with strict confidence threshold.""" + return InvoiceValidator(min_confidence=0.8) + + # ==================== Valid Invoice Tests ==================== + + def test_validate_complete_invoice_is_valid( + self, validator: InvoiceValidator + ) -> None: + """Complete invoice with all required fields is valid.""" + fields = { + "Amount": "1200.00", + "OCR": "123456789012", + "Bankgiro": "123-4567", + } + confidence = { + "Amount": 0.95, + "OCR": 0.90, + "Bankgiro": 0.85, + } + + result = validator.validate(fields, confidence) + + assert result.is_valid is True + assert len([i for i in result.issues if i.severity == "error"]) == 0 + + def test_validate_invoice_with_payment_line_is_valid( + self, validator: InvoiceValidator + ) -> None: + """Invoice with payment_line as payment reference is valid.""" + fields = { + "Amount": "500.00", + "payment_line": "# 123 # 500 00 5 > 308#", + } + confidence = {"Amount": 0.9, "payment_line": 0.85} + + result = validator.validate(fields, confidence) + + assert result.is_valid is True + + # ==================== Invalid Invoice Tests ==================== + + def test_validate_missing_amount_is_invalid( + self, validator: InvoiceValidator + ) -> None: + """Missing Amount field should produce error.""" + fields = { + "OCR": "123456789012", + "Bankgiro": "123-4567", + } + confidence = {"OCR": 0.9, "Bankgiro": 0.85} + + result = validator.validate(fields, confidence) + + assert result.is_valid is False + error_fields = [i.field for i in result.issues if i.severity == "error"] + assert "Amount" in error_fields + + def test_validate_missing_payment_reference_produces_warning( + self, validator: InvoiceValidator + ) -> None: + """Missing all payment references should produce warning.""" + fields = {"Amount": "1200.00"} + confidence = {"Amount": 0.9} + + result = validator.validate(fields, confidence) + + # Missing payment ref is warning, not error + warning_fields = [i.field for i in result.issues if i.severity == "warning"] + assert "payment_reference" in warning_fields + + # ==================== Confidence Threshold Tests ==================== + + def test_validate_low_confidence_produces_warning( + self, validator: InvoiceValidator + ) -> None: + """Fields below confidence threshold should produce warning.""" + fields = { + "Amount": "1200.00", + "OCR": "123456789012", + } + confidence = { + "Amount": 0.9, + "OCR": 0.3, # Below default threshold of 0.5 + } + + result = validator.validate(fields, confidence) + + low_conf_warnings = [ + i for i in result.issues + if i.severity == "warning" and "confidence" in i.message.lower() + ] + assert len(low_conf_warnings) > 0 + + def test_validate_strict_threshold_more_warnings( + self, validator_strict: InvoiceValidator + ) -> None: + """Strict validator should produce more warnings.""" + fields = { + "Amount": "1200.00", + "OCR": "123456789012", + } + confidence = { + "Amount": 0.7, # Below 0.8 threshold + "OCR": 0.6, # Below 0.8 threshold + } + + result = validator_strict.validate(fields, confidence) + + low_conf_warnings = [ + i for i in result.issues + if i.severity == "warning" and "confidence" in i.message.lower() + ] + assert len(low_conf_warnings) >= 2 + + # ==================== Edge Cases ==================== + + def test_validate_empty_fields_is_invalid( + self, validator: InvoiceValidator + ) -> None: + """Empty fields dict should be invalid.""" + fields: dict[str, str | None] = {} + confidence: dict[str, float] = {} + + result = validator.validate(fields, confidence) + + assert result.is_valid is False + + def test_validate_none_field_values_treated_as_missing( + self, validator: InvoiceValidator + ) -> None: + """None values should be treated as missing.""" + fields = { + "Amount": None, + "OCR": "123456789012", + } + confidence = {"OCR": 0.9} + + result = validator.validate(fields, confidence) + + assert result.is_valid is False + error_fields = [i.field for i in result.issues if i.severity == "error"] + assert "Amount" in error_fields + + def test_validate_empty_string_treated_as_missing( + self, validator: InvoiceValidator + ) -> None: + """Empty string should be treated as missing.""" + fields = { + "Amount": "", + "OCR": "123456789012", + } + confidence = {"OCR": 0.9} + + result = validator.validate(fields, confidence) + + assert result.is_valid is False + + # ==================== ValidationResult Properties ==================== + + def test_validation_result_is_immutable( + self, validator: InvoiceValidator + ) -> None: + """ValidationResult should be a frozen dataclass.""" + fields = {"Amount": "100.00", "OCR": "123"} + confidence = {"Amount": 0.9, "OCR": 0.9} + result = validator.validate(fields, confidence) + + with pytest.raises((AttributeError, TypeError)): + result.is_valid = False # type: ignore + + def test_validation_result_issues_is_tuple( + self, validator: InvoiceValidator + ) -> None: + """Issues should be a tuple (immutable).""" + fields = {"Amount": "100.00"} + confidence = {"Amount": 0.9} + result = validator.validate(fields, confidence) + + assert isinstance(result.issues, tuple) + + def test_validation_result_has_confidence( + self, validator: InvoiceValidator + ) -> None: + """ValidationResult should have confidence score.""" + fields = {"Amount": "100.00", "OCR": "123"} + confidence = {"Amount": 0.9, "OCR": 0.8} + result = validator.validate(fields, confidence) + + assert hasattr(result, "confidence") + assert 0.0 <= result.confidence <= 1.0 + + # ==================== ValidationIssue Tests ==================== + + def test_validation_issue_has_required_fields( + self, validator: InvoiceValidator + ) -> None: + """ValidationIssue must have field, severity, message.""" + fields: dict[str, str | None] = {} + confidence: dict[str, float] = {} + result = validator.validate(fields, confidence) + + assert len(result.issues) > 0 + issue = result.issues[0] + + assert hasattr(issue, "field") + assert hasattr(issue, "severity") + assert hasattr(issue, "message") + assert issue.severity in ("error", "warning", "info") diff --git a/tests/web/test_inference_service.py b/tests/web/test_inference_service.py index 69f439a..93c6399 100644 --- a/tests/web/test_inference_service.py +++ b/tests/web/test_inference_service.py @@ -232,10 +232,8 @@ class TestInferenceServicePDFRendering: @patch('backend.pipeline.pipeline.InferencePipeline') @patch('backend.pipeline.yolo_detector.YOLODetector') @patch('shared.pdf.renderer.render_pdf_to_images') - @patch('ultralytics.YOLO') def test_pdf_visualization_imports_correctly( self, - mock_yolo_class, mock_render_pdf, mock_yolo_detector, mock_pipeline, @@ -248,12 +246,22 @@ class TestInferenceServicePDFRendering: This catches the import error we had with: from ..pdf.renderer (wrong) vs from shared.pdf.renderer (correct) """ - # Setup mocks + # Setup mocks for detector mock_detector_instance = Mock() - mock_pipeline_instance = Mock() + mock_model = Mock() + mock_result = Mock() + mock_result.save = Mock() + mock_model.predict.return_value = [mock_result] + mock_detector_instance.model = mock_model mock_yolo_detector.return_value = mock_detector_instance + + # Setup mock for pipeline + mock_pipeline_instance = Mock() mock_pipeline.return_value = mock_pipeline_instance + # Initialize service to setup _detector + inference_service.initialize() + # Create a fake PDF path pdf_path = tmp_path / "test.pdf" pdf_path.touch() @@ -264,18 +272,12 @@ class TestInferenceServicePDFRendering: img.save(image_bytes, format='PNG') mock_render_pdf.return_value = [(1, image_bytes.getvalue())] - # Mock YOLO - mock_model_instance = Mock() - mock_result = Mock() - mock_result.save = Mock() - mock_model_instance.predict.return_value = [mock_result] - mock_yolo_class.return_value = mock_model_instance - - # This should not raise ImportError + # This should not raise ImportError and should use self._detector.model result_path = inference_service._save_pdf_visualization(pdf_path, "test123") - # Verify import was successful + # Verify import was successful and detector.model was used mock_render_pdf.assert_called_once() + mock_model.predict.assert_called_once() assert result_path is not None