"""Routing engine for conditional document-to-pipeline assignment. Evaluates a set of :class:`PipelineRoutingRule` rows against document properties and returns the first matching target pipeline (if any). Supported document fields ------------------------- * ``file_type`` – MIME type of the file (e.g. ``application/pdf``) * ``filename`` – original filename * ``size`` – file size in bytes (numeric comparison) * ``document_type`` – AI-classified document type (e.g. ``Invoice``) * ``category`` – alias for ``document_type`` * ``metadata.`` – arbitrary key inside the AI-extracted JSON metadata Supported comparison operators ------------------------------ * ``equals`` / ``not_equals`` * ``contains`` / ``not_contains`` (substring match, case-insensitive) * ``regex`` (Python ``re`` full-match, case-insensitive) * ``gt`` / ``lt`` / ``gte`` / ``lte`` (numeric comparison) """ import json import logging import re from typing import Any from sqlalchemy.orm import Session from app.models import Pipeline, PipelineRoutingRule logger = logging.getLogger(__name__) # Operators recognised by the engine. VALID_OPERATORS: frozenset[str] = frozenset( { "equals", "not_equals", "contains", "not_contains", "regex", "gt", "lt", "gte", "lte", } ) # Fields that are resolved directly from the FileRecord. BUILTIN_FIELDS: frozenset[str] = frozenset( { "file_type", "filename", "size", "document_type", "category", } ) def _resolve_field(field: str, doc_props: dict[str, Any]) -> Any: """Resolve a *field* name to its actual value from *doc_props*. ``doc_props`` is expected to contain top-level keys that mirror the built-in field names **plus** a ``metadata`` dict with the parsed AI metadata JSON. """ if field == "category": # ``category`` is an alias for ``document_type``. field = "document_type" if field.startswith("metadata."): meta_key = field[len("metadata.") :] metadata = doc_props.get("metadata") or {} return metadata.get(meta_key) return doc_props.get(field) def _to_float(value: Any) -> float | None: """Try to convert *value* to a float for numeric comparison.""" if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _evaluate_condition(actual: Any, operator: str, expected: str) -> bool: """Return ``True`` when *actual* satisfies *operator* against *expected*. All string comparisons are case-insensitive. Numeric operators (``gt``, ``lt``, ``gte``, ``lte``) attempt to cast both sides to ``float``. """ if actual is None: # If the document property is missing, the rule cannot match # (except for ``not_equals`` / ``not_contains`` which should match). if operator == "not_equals": return True if operator == "not_contains": return True return False actual_str = str(actual).lower() expected_lower = expected.lower() if operator == "equals": return actual_str == expected_lower if operator == "not_equals": return actual_str != expected_lower if operator == "contains": return expected_lower in actual_str if operator == "not_contains": return expected_lower not in actual_str if operator == "regex": try: return bool(re.fullmatch(expected, str(actual), flags=re.IGNORECASE)) except re.error: logger.warning("Invalid regex in routing rule: %s", expected) return False # Numeric operators actual_num = _to_float(actual) expected_num = _to_float(expected) if actual_num is None or expected_num is None: return False if operator == "gt": return actual_num > expected_num if operator == "lt": return actual_num < expected_num if operator == "gte": return actual_num >= expected_num if operator == "lte": return actual_num <= expected_num return False def build_document_properties(file_record: Any) -> dict[str, Any]: """Build the property dict that the engine evaluates against. Args: file_record: A :class:`FileRecord` instance (or any object with the same attributes). Returns: A dict with ``file_type``, ``filename``, ``size``, ``document_type``, and ``metadata`` keys. """ metadata: dict[str, Any] = {} raw_meta = getattr(file_record, "ai_metadata", None) if raw_meta: try: metadata = json.loads(raw_meta) if isinstance(raw_meta, str) else raw_meta except (json.JSONDecodeError, TypeError): metadata = {} return { "file_type": getattr(file_record, "mime_type", None), "filename": getattr(file_record, "original_filename", None), "size": getattr(file_record, "file_size", None), "document_type": metadata.get("document_type"), "metadata": metadata, } def evaluate_routing_rules( db: Session, owner_id: str | None, doc_props: dict[str, Any], ) -> Pipeline | None: """Evaluate routing rules and return the first matching pipeline. Rules are fetched for the given *owner_id* **plus** any system-wide rules (``owner_id IS NULL``). Owner rules are evaluated first (by position), then system rules. Args: db: Active database session. owner_id: The document owner's identifier (may be ``None``). doc_props: Document property dict as produced by :func:`build_document_properties`. Returns: The first matching :class:`Pipeline`, or ``None`` when no rule matches (caller should fall back to the default pipeline). """ # Fetch active rules for the owner + system rules, ordered by position. rules = ( db.query(PipelineRoutingRule) .filter( PipelineRoutingRule.is_active.is_(True), (PipelineRoutingRule.owner_id == owner_id) | (PipelineRoutingRule.owner_id.is_(None)), ) .order_by( # Owner-specific rules take priority over system rules. PipelineRoutingRule.owner_id.is_(None).asc(), PipelineRoutingRule.position.asc(), ) .all() ) for rule in rules: actual = _resolve_field(rule.field, doc_props) if _evaluate_condition(actual, rule.operator, rule.value): pipeline = db.query(Pipeline).filter(Pipeline.id == rule.target_pipeline_id).first() if pipeline and pipeline.is_active: logger.info( "Routing rule matched: rule_id=%s, name=%s, target_pipeline=%s", rule.id, rule.name, rule.target_pipeline_id, ) return pipeline logger.warning( "Routing rule %s matched but target pipeline %s is inactive or missing", rule.id, rule.target_pipeline_id, ) return None