Samyak000's picture
Update app.py
21d0928 verified
Raw
History Blame Contribute Delete
11.4 kB
"""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))