"""Offline ML inference for document field extraction and routing decisions. Model artifacts are trusted, locally trained joblib objects. No rule-based or configuration-based classifier is used when an artifact is unavailable. Each single-label artifact exposes predict([sample]) and, optionally, predict_proba([sample]). The group selector returns an iterable of group labels for a (company, document_type) sample. """ from __future__ import annotations import logging import os import re from pathlib import Path from typing import Any logger = logging.getLogger(__name__) class ModelUnavailableError(RuntimeError): """Raised when a required local ML model is missing or invalid.""" class AmbiguousPredictionError(RuntimeError): """Raised when an ML prediction does not meet the configured confidence.""" class LocalMLPipeline: """Load local model artifacts and provide confidence-gated predictions.""" def __init__(self, model_dir: str | None = None, confidence_threshold: float | None = None): self.model_dir = Path(model_dir or os.environ.get("MODEL_DIR", "./models")) self.confidence_threshold = ( float(confidence_threshold) if confidence_threshold is not None else float(os.environ.get("CONFIDENCE_THRESHOLD", "0.8")) ) self._models: dict[str, Any] = {} self._model_versions: dict[str, str] = {} def _load(self, name: str) -> Any: active_path = self.model_dir / "active_models.json" try: active = __import__("json").loads(active_path.read_text(encoding="utf-8")) except (OSError, ValueError): active = {} version = active.get(name) if not version: raise ModelUnavailableError( f"No active model artifact for {name}; retrain all models from Maintenance." ) if name in self._models and self._model_versions.get(name) == version: return self._models[name] path = self.model_dir / "sets" / version / f"{name}.joblib" if not path.is_file(): # Keep the pre-retraining model layout usable until the first full replacement. path = self.model_dir / "versions" / name / f"{version}.joblib" if not path.is_file(): raise ModelUnavailableError( f"Active model artifact is missing for {name} version {version}." ) try: import joblib model = joblib.load(path) except Exception as exc: raise ModelUnavailableError(f"Could not load ML model {path.name}: {exc}") from exc self._models[name] = model self._model_versions[name] = version return model def _predict_label(self, name: str, sample: Any) -> tuple[str, float]: model = self._load(name) try: predictions = model.predict([sample]) logger.debug("ML model %s predicted %s for sample=%r", name, predictions, sample) if len(predictions) != 1: raise ValueError("Expected exactly one prediction") label = predictions[0] if not isinstance(label, str) or not label.strip(): raise ValueError("The model returned an empty or non-string label") confidence = 0.0 if hasattr(model, "predict_proba"): probabilities = model.predict_proba([sample]) confidence = float(max(probabilities[0])) elif hasattr(model, "decision_function"): # Decision scores are not calibrated probabilities; do not # misrepresent them as confidence. raise ModelUnavailableError( f"Model {name} must expose calibrated predict_proba() scores" ) else: raise ModelUnavailableError( f"Model {name} must expose predict_proba() for confidence gating" ) if confidence < self.confidence_threshold: raise AmbiguousPredictionError( f"Model {name} confidence {confidence:.3f} is below " f"threshold {self.confidence_threshold:.3f}" ) return label.strip(), confidence except (ModelUnavailableError, AmbiguousPredictionError) as exc: logger.debug("ML model %s failed for sample=%r: %s", name, sample, exc) raise except Exception as exc: raise ModelUnavailableError(f"ML inference failed for {name}: {exc}") from exc def predict_labels( self, fields: dict[str, tuple[str, Any]], ) -> tuple[dict[str, tuple[str, float]], dict[str, Exception]]: """Predict independent fields without letting one failure block the others.""" predictions: dict[str, tuple[str, float]] = {} errors: dict[str, Exception] = {} for field, (model_name, sample) in fields.items(): try: predictions[field] = self._predict_label(model_name, sample) except (ModelUnavailableError, AmbiguousPredictionError) as exc: errors[field] = exc return predictions, errors def extract_initial_fields(self, text: str) -> dict[str, tuple[str, float]]: """Predict common fields directly from full document text.""" fields, errors = self.predict_labels({ field: (f"field_{field}", text) for field in ("emitting_company", "document_type") }) if errors: raise next(iter(errors.values())) logger.debug("Extracted initial fields %r from text=%r", fields, text) return fields def predict_additional_groups(self, company: str, document_type: str) -> tuple[list[str], float]: """Predict applicable additional-field groups from company and type.""" model = self._load("additional_field_groups") sample = f"{company}\n{document_type}" try: predictions = model.predict([sample]) if len(predictions) != 1: raise ValueError("Expected exactly one group prediction") predicted = predictions[0] if isinstance(predicted, str): groups = [predicted] if predicted.strip() else [] else: groups = [str(value).strip() for value in predicted if str(value).strip()] if not groups: raise AmbiguousPredictionError("ML model predicted no additional-field groups") if not hasattr(model, "predict_proba"): raise ModelUnavailableError( "Model additional_field_groups must expose calibrated predict_proba() scores" ) probabilities = model.predict_proba([sample]) first = probabilities[0] if hasattr(first, "toarray"): first = first.toarray().ravel() try: confidence = float(max(first)) except (TypeError, ValueError): confidence = float(getattr(model, "confidence_threshold", 0.0)) if confidence < self.confidence_threshold: raise AmbiguousPredictionError( f"Additional-field group confidence {confidence:.3f} is below " f"threshold {self.confidence_threshold:.3f}" ) logger.debug('Predicted additional-field group %r (confidence=%.3f)', groups[0], confidence) return groups, confidence except (ModelUnavailableError, AmbiguousPredictionError): raise except Exception as exc: raise ModelUnavailableError(f"ML inference failed for additional_field_groups: {exc}") from exc def predict_additional_values( self, field_names: list[str], text: str, *, allow_partial: bool = False, ) -> dict[str, tuple[str, float]]: """Extract requested fields, optionally omitting individual low-confidence results.""" model = self._load("additional_field_values") if not hasattr(model, "predict_values"): raise ModelUnavailableError( "The active additional_field_values artifact does not support shared extraction; retrain it." ) try: predictions = model.predict_values(text, field_names) results: dict[str, tuple[str, float]] = {} for field_name in field_names: value, confidence = predictions[field_name] if not value or confidence < self.confidence_threshold: error = AmbiguousPredictionError( f"Additional field '{field_name}' confidence {confidence:.3f} is below " f"threshold {self.confidence_threshold:.3f}" ) if allow_partial: logger.debug("Omitting low-confidence additional field %s: %s", field_name, error) continue raise error results[field_name] = (str(value), float(confidence)) logger.debug('Predicted additional field %s=%r (confidence=%.3f)', field_name, value, confidence) return results except (ModelUnavailableError, AmbiguousPredictionError): raise except ValueError as exc: raise ModelUnavailableError(str(exc)) from exc except Exception as exc: raise ModelUnavailableError(f"ML inference failed for additional_field_values: {exc}") from exc def predict_template_and_period_format( self, emitting_company: str, document_type: str, additional_fields: dict[str, str], ) -> tuple[str, float, str, float]: """Predict template and period format from company, type, and extracted fields.""" fields_text = "\n".join(f"{key}: {value}" for key, value in sorted(additional_fields.items())) sample = f"{emitting_company}\n{document_type}\n{fields_text}" predictions, errors = self.predict_labels({ "template_name": ("template_name", sample), "period_format": ("period_format", sample), }) if errors: raise next(iter(errors.values())) template, template_confidence = predictions["template_name"] period_format, period_confidence = predictions["period_format"] logger.debug( "Predicted template=%s (confidence=%.3f), period_format=%s (confidence=%.3f) " "for company=%r, document_type=%r, additional_fields=%r", template, template_confidence, period_format, period_confidence, emitting_company, document_type, additional_fields, ) return template, template_confidence, period_format, period_confidence @staticmethod def match_configured_additional_value( text: str, predicted_value: str, configured_values: list[str], ) -> str | None: """Resolve an extracted span to a complete, unambiguous configured value.""" matched_values = [] for raw_value in configured_values: value = str(raw_value).strip() words = value.split() if not words: continue pattern = r"(? tuple[dict[str, dict[str, Any]], dict[str, str]]: """Collect independent model and configured-value suggestions for one document.""" suggestions: dict[str, dict[str, Any]] = {} decisions = { "emitting_company": "skipped: model not evaluated", "document_type": "skipped: model not evaluated", "template_name": "skipped: prerequisites not met", "period_format": "skipped: prerequisites not met", "additional_fields": "skipped: groups not evaluated", } initial, initial_errors = self.predict_labels({ "emitting_company": ("field_emitting_company", text), "document_type": ("field_document_type", text), }) for field, (value, confidence) in initial.items(): suggestions[field] = {"value": value, "confidence": confidence, "source": "model"} decisions[field] = f"suggested: {value!r} ({confidence:.3f})" for field, error in initial_errors.items(): decisions[field] = f"unavailable: {error}" if isinstance(error, ModelUnavailableError) else f"ambiguous: {error}" suggested_company = initial.get("emitting_company", (company, 0.0))[0] suggested_type = initial.get("document_type", (document_type, 0.0))[0] if not suggested_company or not suggested_type: missing = [ name for name, value in (("emitting_company", suggested_company), ("document_type", suggested_type)) if not value ] reason = f"skipped: missing {', '.join(missing)}" decisions["template_name"] = reason decisions["period_format"] = reason return suggestions, decisions try: groups, _ = self.predict_additional_groups(suggested_company, suggested_type) value_model = self._load("additional_field_values") trained_fields = getattr(value_model, "trained_fields", None) if trained_fields is None: extractable_groups = groups unsupported_groups: list[str] = [] else: supported_fields = set(trained_fields) extractable_groups = [name for name in groups if name in supported_fields] unsupported_groups = sorted(set(groups) - supported_fields) decisions["additional_fields"] = ( f"predicted={groups}; extracting={extractable_groups}; unsupported={unsupported_groups}" ) extracted = self.predict_additional_values( extractable_groups, text, allow_partial=True ) if extractable_groups else {} matched = { name: value for name, (span, _confidence) in extracted.items() if (value := self.match_configured_additional_value( text, span, configured_values.get(name, []) )) is not None } if matched: suggestions["optional_fields"] = {"value": matched, "source": "configured"} model_spans = { name: {"value": value, "confidence": confidence} for name, (value, confidence) in extracted.items() } decisions["additional_fields"] += ( f"; model_spans={model_spans}; configured_matches={matched}; " f"omitted_low_confidence={sorted(set(extractable_groups) - set(extracted))}; " f"omitted_without_configured_match={sorted(set(extracted) - set(matched))}" ) inference_fields = {**matched, **current_fields} fields_text = "\n".join( f"{key}: {value}" for key, value in sorted(inference_fields.items()) ) sample = f"{suggested_company}\n{suggested_type}\n{fields_text}" final, final_errors = self.predict_labels({ "template_name": ("template_name", sample), "period_format": ("period_format", sample), }) for field, (value, confidence) in final.items(): suggestions[field] = {"value": value, "confidence": confidence, "source": "model"} decisions[field] = f"suggested: {value!r} ({confidence:.3f})" for field, error in final_errors.items(): decisions[field] = f"unavailable: {error}" if isinstance(error, ModelUnavailableError) else f"ambiguous: {error}" except (ModelUnavailableError, AmbiguousPredictionError) as exc: reason = f"skipped: additional-field inference failed ({exc})" decisions["additional_fields"] = f"unavailable: {exc}" decisions["template_name"] = reason decisions["period_format"] = reason return suggestions, decisions