Spaces:
Sleeping
Sleeping
| """Model-serving API for fraud prediction. | |
| Flat-layout entrypoint for deployments that keep source files at repository root. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| from contextlib import asynccontextmanager | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| import firebase_admin | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| from dotenv import load_dotenv | |
| from fastapi import FastAPI, HTTPException | |
| from firebase_admin import credentials, firestore | |
| from pydantic import BaseModel, Field | |
| from xgboost import XGBClassifier | |
| from decision import build_decision_output | |
| from risk_explainability import build_explainer, explain_prediction | |
| from reasoning import build_reasoning_output | |
| load_dotenv() | |
| ROOT_DIR = Path(__file__).resolve().parent | |
| MODEL_DIR = ROOT_DIR / "models" | |
| if not MODEL_DIR.exists(): | |
| MODEL_DIR = ROOT_DIR / "backend" / "models" | |
| XGB_PKL_PATH = MODEL_DIR / "xgboost_model.pkl" | |
| XGB_JSON_PATH = MODEL_DIR / "xgb_fraud_model.json" | |
| PHASE6_METADATA_PATH = MODEL_DIR / "phase6_training_metadata.json" | |
| PHASE7_METRICS_PATH = MODEL_DIR / "phase7_metrics.json" | |
| class PredictRequest(BaseModel): | |
| transaction_id: str | None = None | |
| features: dict[str, float] = Field(..., description="Feature dict for model inference") | |
| meta: dict[str, Any] = Field(default_factory=dict) | |
| class PredictData(BaseModel): | |
| transaction_id: str | None = None | |
| fraud_probability: float | |
| risk_score: float | |
| classification: str | |
| fraud_threshold: float | |
| suspicious_threshold: float | |
| missing_handled_as_nan: list[str] | |
| ignored_extra_features: list[str] | |
| aligned_features: dict[str, float | None] | |
| explainability: dict[str, Any] | |
| class PredictResponse(BaseModel): | |
| status: str = "success" | |
| data: PredictData | |
| class ExplainData(BaseModel): | |
| transaction_id: str | None = None | |
| classification: str | |
| fraud_probability: float | |
| risk_score: float | |
| explainability: dict[str, Any] | |
| class ExplainResponse(BaseModel): | |
| status: str = "success" | |
| data: ExplainData | |
| class ReasonResponse(BaseModel): | |
| status: str = "success" | |
| data: dict[str, Any] | |
| class DecisionResponse(BaseModel): | |
| status: str = "success" | |
| data: dict[str, Any] | |
| class FraudFeedbackRequest(BaseModel): | |
| transaction_id: str | |
| amount: float | None = None | |
| classification: str | |
| action: str | None = None | |
| risk_score: float | None = None | |
| fraud_probability: float | None = None | |
| fraud_type: str | None = None | |
| explainability_summary: str | None = None | |
| meta: dict[str, Any] = Field(default_factory=dict) | |
| class FraudFeedbackResponse(BaseModel): | |
| status: str = "success" | |
| data: dict[str, Any] | |
| class BatchPredictRequest(BaseModel): | |
| batch: list[PredictRequest] = Field(..., min_length=1, max_length=1000) | |
| class BatchPredictData(BaseModel): | |
| count: int | |
| results: list[PredictData] | |
| class BatchPredictResponse(BaseModel): | |
| status: str = "success" | |
| data: BatchPredictData | |
| def _load_model() -> XGBClassifier: | |
| if XGB_PKL_PATH.exists(): | |
| model = joblib.load(XGB_PKL_PATH) | |
| if not isinstance(model, XGBClassifier): | |
| raise RuntimeError(f"Unexpected model type in {XGB_PKL_PATH}: {type(model)}") | |
| return model | |
| if XGB_JSON_PATH.exists(): | |
| model = XGBClassifier() | |
| model.load_model(str(XGB_JSON_PATH)) | |
| return model | |
| raise FileNotFoundError(f"No model found. Checked: {XGB_PKL_PATH} and {XGB_JSON_PATH}") | |
| def _load_feature_columns() -> list[str]: | |
| if PHASE6_METADATA_PATH.exists(): | |
| metadata = json.loads(PHASE6_METADATA_PATH.read_text(encoding="utf-8")) | |
| columns = metadata.get("feature_columns") | |
| if isinstance(columns, list) and columns: | |
| return [str(c) for c in columns] | |
| raise FileNotFoundError(f"Missing or invalid feature metadata: {PHASE6_METADATA_PATH}") | |
| def _load_thresholds() -> tuple[float, float]: | |
| fraud_threshold = 0.15 | |
| suspicious_threshold = 0.05 | |
| if PHASE7_METRICS_PATH.exists(): | |
| metrics = json.loads(PHASE7_METRICS_PATH.read_text(encoding="utf-8")) | |
| fraud_threshold = float(metrics.get("best_threshold", fraud_threshold)) | |
| suspicious_threshold = min(suspicious_threshold, fraud_threshold) | |
| return fraud_threshold, suspicious_threshold | |
| def _classify(prob: float, fraud_threshold: float, suspicious_threshold: float) -> str: | |
| if prob >= fraud_threshold: | |
| return "fraud" | |
| if prob >= suspicious_threshold: | |
| return "suspicious" | |
| return "legitimate" | |
| async def lifespan(app_: FastAPI): | |
| model = _load_model() | |
| feature_columns = _load_feature_columns() | |
| fraud_threshold, suspicious_threshold = _load_thresholds() | |
| explainer = build_explainer(model) | |
| if not firebase_admin._apps: | |
| service_account_json = os.getenv("FIREBASE_SERVICE_ACCOUNT") | |
| if service_account_json: | |
| service_account_dict = json.loads(service_account_json) | |
| cred = credentials.Certificate(service_account_dict) | |
| firebase_admin.initialize_app(cred) | |
| else: | |
| firebase_admin.initialize_app(options={"projectId": "interceptai-e82e5"}) | |
| app_.state.model = model | |
| app_.state.explainer = explainer | |
| app_.state.feature_columns = feature_columns | |
| app_.state.fraud_threshold = fraud_threshold | |
| app_.state.suspicious_threshold = suspicious_threshold | |
| app_.state.db = firestore.client() | |
| yield | |
| app = FastAPI( | |
| title="Fraud Decision Engine API", | |
| version="1.0.0", | |
| description="Fraud detection API", | |
| lifespan=lifespan, | |
| ) | |
| def health() -> dict[str, Any]: | |
| return { | |
| "status": "ok", | |
| "model_loaded": getattr(app.state, "model", None) is not None, | |
| "feature_count": len(getattr(app.state, "feature_columns", [])), | |
| "fraud_threshold": getattr(app.state, "fraud_threshold", None), | |
| "suspicious_threshold": getattr(app.state, "suspicious_threshold", None), | |
| } | |
| def model_info() -> dict[str, Any]: | |
| return { | |
| "model_type": type(app.state.model).__name__, | |
| "feature_count": len(app.state.feature_columns), | |
| "feature_columns": app.state.feature_columns, | |
| "fraud_threshold": app.state.fraud_threshold, | |
| "suspicious_threshold": app.state.suspicious_threshold, | |
| } | |
| def _persist_fraud_transaction(user_id: str, record: dict[str, Any]) -> dict[str, Any]: | |
| db = app.state.db | |
| transaction_id = record.get("transaction_id") | |
| if not transaction_id: | |
| raise ValueError("transaction_id is required for persistence") | |
| doc_ref = db.collection("fraud_transaction").document(user_id).collection("transactions").document(transaction_id) | |
| doc_ref.set(record) | |
| return { | |
| "collection": "fraud_transaction", | |
| "subcollection": "transactions", | |
| "document_id": transaction_id, | |
| "user_id": user_id, | |
| "storage_mode": "firestore_direct", | |
| } | |
| def _predict_one(payload: PredictRequest) -> PredictData: | |
| if not payload.features: | |
| raise HTTPException(status_code=422, detail="'features' must not be empty") | |
| required = app.state.feature_columns | |
| incoming = payload.features | |
| missing = sorted(set(required) - set(incoming)) | |
| extra = sorted(set(incoming) - set(required)) | |
| aligned = {col: (float(incoming[col]) if col in incoming else np.nan) for col in required} | |
| features_df = pd.DataFrame([aligned], columns=required).astype(np.float32) | |
| probability = float(app.state.model.predict_proba(features_df)[0, 1]) | |
| risk_score = round(probability * 100, 2) | |
| classification = _classify(probability, app.state.fraud_threshold, app.state.suspicious_threshold) | |
| transaction_id = payload.transaction_id or payload.meta.get("transaction_id") | |
| aligned_features_json = {key: (None if pd.isna(value) else float(value)) for key, value in aligned.items()} | |
| explainability = explain_prediction( | |
| explainer=app.state.explainer, | |
| features_df=features_df, | |
| feature_columns=required, | |
| aligned_features=aligned_features_json, | |
| classification=classification, | |
| fraud_probability=probability, | |
| ) | |
| return PredictData( | |
| transaction_id=transaction_id, | |
| fraud_probability=round(probability, 6), | |
| risk_score=risk_score, | |
| classification=classification, | |
| fraud_threshold=app.state.fraud_threshold, | |
| suspicious_threshold=app.state.suspicious_threshold, | |
| missing_handled_as_nan=missing, | |
| ignored_extra_features=extra, | |
| aligned_features=aligned_features_json, | |
| explainability=explainability, | |
| ) | |
| def predict(payload: PredictRequest) -> PredictResponse: | |
| return PredictResponse(data=_predict_one(payload)) | |
| def explain(payload: PredictRequest) -> ExplainResponse: | |
| prediction = _predict_one(payload) | |
| return ExplainResponse( | |
| data=ExplainData( | |
| transaction_id=prediction.transaction_id, | |
| classification=prediction.classification, | |
| fraud_probability=prediction.fraud_probability, | |
| risk_score=prediction.risk_score, | |
| explainability=prediction.explainability, | |
| ) | |
| ) | |
| def reason(payload: PredictRequest) -> ReasonResponse: | |
| prediction = _predict_one(payload) | |
| decision = build_decision_output( | |
| transaction_id=prediction.transaction_id, | |
| classification=prediction.classification, | |
| fraud_probability=prediction.fraud_probability, | |
| risk_score=prediction.risk_score, | |
| fraud_threshold=prediction.fraud_threshold, | |
| suspicious_threshold=prediction.suspicious_threshold, | |
| explainability=prediction.explainability, | |
| ) | |
| reasoning = build_reasoning_output( | |
| transaction_id=prediction.transaction_id, | |
| classification=prediction.classification, | |
| fraud_probability=prediction.fraud_probability, | |
| risk_score=prediction.risk_score, | |
| fraud_threshold=prediction.fraud_threshold, | |
| suspicious_threshold=prediction.suspicious_threshold, | |
| explainability=prediction.explainability, | |
| decision_action=decision["action"], | |
| ) | |
| return ReasonResponse(data=reasoning) | |
| def decision(payload: PredictRequest) -> DecisionResponse: | |
| prediction = _predict_one(payload) | |
| output = build_decision_output( | |
| transaction_id=prediction.transaction_id, | |
| classification=prediction.classification, | |
| fraud_probability=prediction.fraud_probability, | |
| risk_score=prediction.risk_score, | |
| fraud_threshold=prediction.fraud_threshold, | |
| suspicious_threshold=prediction.suspicious_threshold, | |
| explainability=prediction.explainability, | |
| ) | |
| return DecisionResponse(data=output) | |
| def feedback_fraud(user_id: str, payload: FraudFeedbackRequest) -> FraudFeedbackResponse: | |
| if payload.classification.lower() != "fraud": | |
| raise HTTPException(status_code=422, detail="Only fraud transactions are accepted by this endpoint") | |
| record = { | |
| "transaction_id": payload.transaction_id, | |
| "amount": payload.amount, | |
| "classification": payload.classification, | |
| "action": payload.action, | |
| "risk_score": payload.risk_score, | |
| "fraud_probability": payload.fraud_probability, | |
| "fraud_type": payload.fraud_type, | |
| "explainability_summary": payload.explainability_summary, | |
| "meta": payload.meta, | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| } | |
| persistence = _persist_fraud_transaction(user_id=user_id, record=record) | |
| return FraudFeedbackResponse( | |
| data={ | |
| "user_id": user_id, | |
| "saved": True, | |
| "record": record, | |
| "persistence": persistence, | |
| } | |
| ) | |
| def predict_batch(payload: BatchPredictRequest) -> BatchPredictResponse: | |
| results = [_predict_one(item) for item in payload.batch] | |
| return BatchPredictResponse(data=BatchPredictData(count=len(results), results=results)) | |