from __future__ import annotations import json import os from dataclasses import dataclass from pathlib import Path import joblib import mlflow import pandas as pd from mlflow.tracking import MlflowClient from credexp.config import settings from credexp.data.io import processed_dir @dataclass(frozen=True) class ModelBundle: """Container for the loaded scoring model and its serving metadata.""" pipe: object threshold: float model_name: str model_version: str feature_columns: list[str] def _model_dir() -> Path: """Return the default local model artifact directory.""" return settings.artifacts_dir / "models" def _first_existing_path(candidates: list[str | Path | None]) -> Path | None: """Return the first existing path from a list of optional candidates.""" for candidate in candidates: if candidate is None: continue path = Path(candidate) if path.exists(): return path return None def _load_threshold() -> float: """Load the business decision threshold. Priority: 1. THRESHOLD_PATH environment variable 2. artifacts/models/threshold.json 3. DEFAULT_THRESHOLD environment variable 4. 0.5 fallback """ threshold_path = _first_existing_path( [ os.getenv("THRESHOLD_PATH"), _model_dir() / "threshold.json", ] ) if threshold_path is not None: payload = json.loads(threshold_path.read_text(encoding="utf-8")) return float(payload["threshold"]) return float(os.getenv("DEFAULT_THRESHOLD", "0.5")) def _load_feature_columns(pipe: object | None = None) -> list[str]: """Load the exact feature column order expected by the model. Priority: 1. FEATURE_COLUMNS_PATH environment variable 2. artifacts/models/feature_columns.json 3. data/processed/api_holdout.parquet 4. data/processed/features.parquet 5. pipe.feature_names_in_ if available The JSON option is preferred for remote deployment, because processed datasets are not necessarily shipped with the deployed API image. """ feature_columns_path = _first_existing_path( [ os.getenv("FEATURE_COLUMNS_PATH"), _model_dir() / "feature_columns.json", ] ) if feature_columns_path is not None: columns = json.loads(feature_columns_path.read_text(encoding="utf-8")) return [str(col) for col in columns] holdout_path = processed_dir() / "api_holdout.parquet" if holdout_path.exists(): df = pd.read_parquet(holdout_path) return [col for col in df.columns if col != "TARGET"] features_path = processed_dir() / "features.parquet" if features_path.exists(): df = pd.read_parquet(features_path) return [col for col in df.columns if col != "TARGET"] if pipe is not None and hasattr(pipe, "feature_names_in_"): return [str(col) for col in pipe.feature_names_in_] raise FileNotFoundError( "Could not load feature columns. Provide one of: " "FEATURE_COLUMNS_PATH, artifacts/models/feature_columns.json, " "data/processed/api_holdout.parquet, data/processed/features.parquet, " "or a pipeline exposing feature_names_in_." ) def _load_local_joblib_model() -> object: """Load a local joblib pipeline. Used for: - Hugging Face Space deployment - local debug without MLflow - fallback when MLflow Registry is unavailable """ model_path = _first_existing_path( [ os.getenv("MODEL_JOBLIB_PATH"), os.getenv("PIPELINE_PATH"), _model_dir() / "pipeline.joblib", ] ) if model_path is None: raise FileNotFoundError( "Local model not found. Expected one of: " "MODEL_JOBLIB_PATH, PIPELINE_PATH, artifacts/models/pipeline.joblib." ) return joblib.load(model_path) def _resolve_model_version(model_name: str) -> str: """Resolve model version from MLflow Registry when available. This function is best-effort. If the registry is unavailable, it returns 'unknown' instead of breaking the API startup. """ try: client = MlflowClient( tracking_uri=settings.mlflow_tracking_uri, registry_uri=settings.mlflow_registry_uri, ) versions = list(client.search_model_versions(f"name='{model_name}'")) for version in versions: aliases = getattr(version, "aliases", []) or [] current_stage = getattr(version, "current_stage", "") if "Production" in aliases or current_stage == "Production": return str(version.version) if versions: latest = sorted(versions, key=lambda item: int(item.version), reverse=True)[0] return str(latest.version) except Exception: return "unknown" return "unknown" def _load_mlflow_model(model_uri: str, model_name: str) -> tuple[object, str]: """Load model from MLflow Registry or MLflow model URI.""" mlflow.set_tracking_uri(settings.mlflow_tracking_uri) mlflow.set_registry_uri(settings.mlflow_registry_uri) pipe = mlflow.sklearn.load_model(model_uri) model_version = _resolve_model_version(model_name) return pipe, model_version def _should_use_joblib(load_mode: str, model_uri: str) -> bool: """Return whether the loader should bypass MLflow and use joblib directly.""" use_local_model = os.getenv("USE_LOCAL_MODEL", "false").lower() == "true" return use_local_model or load_mode in {"joblib", "local"} or not model_uri def load_model_bundle() -> ModelBundle: """Load model, threshold and feature schema. Local full-stack mode: MODEL_LOAD_MODE=auto MODEL_URI=models:/credit_scoring_model/Production Remote Hugging Face mode: MODEL_LOAD_MODE=joblib MODEL_JOBLIB_PATH=/app/artifacts/models/pipeline.joblib THRESHOLD_PATH=/app/artifacts/models/threshold.json FEATURE_COLUMNS_PATH=/app/artifacts/models/feature_columns.json Backward compatibility: USE_LOCAL_MODEL=true and PIPELINE_PATH are also supported. """ model_name = os.getenv("MODEL_NAME", "credit_scoring_model") model_uri = os.getenv("MODEL_URI", getattr(settings, "model_uri", "")).strip() load_mode = os.getenv("MODEL_LOAD_MODE", "auto").lower() model_version = os.getenv("MODEL_VERSION", "unknown") if _should_use_joblib(load_mode=load_mode, model_uri=model_uri): pipe = _load_local_joblib_model() model_version = os.getenv("MODEL_VERSION", "joblib") else: try: pipe, model_version = _load_mlflow_model( model_uri=model_uri, model_name=model_name, ) except Exception: pipe = _load_local_joblib_model() model_version = os.getenv("MODEL_VERSION", "joblib-fallback") threshold = _load_threshold() feature_columns = _load_feature_columns(pipe=pipe) return ModelBundle( pipe=pipe, threshold=threshold, model_name=model_name, model_version=str(model_version), feature_columns=feature_columns, )