"""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" @asynccontextmanager 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, ) @app.get("/health") 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), } @app.get("/model-info") 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, ) @app.post("/predict", response_model=PredictResponse) def predict(payload: PredictRequest) -> PredictResponse: return PredictResponse(data=_predict_one(payload)) @app.post("/explain", response_model=ExplainResponse) 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, ) ) @app.post("/reason", response_model=ReasonResponse) 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) @app.post("/decision", response_model=DecisionResponse) 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) @app.post("/feedback/fraud/{user_id}", response_model=FraudFeedbackResponse) 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, } ) @app.post("/predict/batch", response_model=BatchPredictResponse) def predict_batch(payload: BatchPredictRequest) -> BatchPredictResponse: results = [_predict_one(item) for item in payload.batch] return BatchPredictResponse(data=BatchPredictData(count=len(results), results=results))