Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/ai-service/api/v1/fraud.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi import APIRouter, HTTPException

from schemas.fraud import FraudDetectionRequest, FraudDetectionResponse
from services.fraud_detection import detect_fraud
from services.fraud_detection import detect_fraud, MODEL_VERSION

logger = logging.getLogger(__name__)

Expand All @@ -28,6 +28,7 @@ async def detect_fraud_endpoint(request: FraudDetectionRequest) -> FraudDetectio
return FraudDetectionResponse(
results=results,
flagged_count=sum(r.is_flagged for r in results),
model_version=MODEL_VERSION,
)
except Exception as exc:
logger.error("Fraud detection failed: %s", exc)
Expand Down
1 change: 1 addition & 0 deletions app/ai-service/schemas/fraud.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ class ClaimFraudResult(BaseModel):
class FraudDetectionResponse(BaseModel):
results: List[ClaimFraudResult]
flagged_count: int
model_version: Optional[str] = None
25 changes: 17 additions & 8 deletions app/ai-service/services/fraud_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
# Claims with LOF score above this threshold are flagged
_OUTLIER_THRESHOLD = -1.5

# Version of the scoring pipeline — surfaced in the response so downstream
# consumers can detect silent model drift. Bump when the feature
# representation or decision rule changes.
MODEL_VERSION = "fraud-v1.1"


def _vectorize(claims: List[ClaimMetadata]) -> np.ndarray:
"""Convert claim metadata into a numeric feature matrix."""
Expand All @@ -36,11 +41,11 @@ def _vectorize(claims: List[ClaimMetadata]) -> np.ndarray:
loc_enc.fit(locs)

return np.column_stack([
ip_enc.transform(ips),
hash_enc.transform(hashes),
loc_enc.transform(locs),
amounts,
]).astype(float)
ip_enc.transform(ips).astype(float),
hash_enc.transform(hashes).astype(float),
loc_enc.transform(locs).astype(float),
np.array(amounts, dtype=float),
])


def detect_fraud(claims: List[ClaimMetadata]) -> List[ClaimFraudResult]:
Expand All @@ -55,17 +60,21 @@ def detect_fraud(claims: List[ClaimMetadata]) -> List[ClaimFraudResult]:
return [ClaimFraudResult(claim_id=claims[0].claim_id, fraud_risk_score=0.0, is_flagged=False)]

X = _vectorize(claims)

# Add tiny random noise to prevent identical point degeneracy and zero-distance division issues
np.random.seed(42)
X_noise = X + np.random.normal(0, 1e-5, X.shape)

n_neighbors = min(20, max(2, len(claims) // 2))
# n_neighbors must be strictly less than n_samples for LOF.
# Use the original heuristic but cap at n_samples - 1 so 2-claim
# batches (len//2 = 1) still get a valid neighbor count.
n_samples = len(claims)
n_neighbors = min(20, max(2, n_samples // 2), n_samples - 1)
lof = LocalOutlierFactor(n_neighbors=n_neighbors, contamination="auto")
lof.fit_predict(X_noise)
raw_scores: np.ndarray = lof.negative_outlier_factor_ # negative; more negative = more anomalous

# Normalise to [0, 1]: most anomalous 1, most normal 0
# Normalise to [0, 1]: most anomalous -> 1, most normal -> 0
min_s, max_s = raw_scores.min(), raw_scores.max()
if max_s == min_s:
normalised = np.zeros(len(raw_scores))
Expand Down
56 changes: 56 additions & 0 deletions app/ai-service/tests/test_fraud_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@ def test_outlier_gets_higher_score(self):
results = {r["claim_id"]: r["fraud_risk_score"] for r in resp.json()["results"]}
assert results["outlier"] > results["c0"]

def test_two_claim_batch_does_not_crash(self):
"""Two-claim batch must not raise ValueError from LOF."""
payload = {"claims": [
{"claim_id": "a", "ip_address": "1.2.3.4", "amount": 100.0},
{"claim_id": "b", "ip_address": "5.6.7.8", "amount": 200.0},
]}
resp = client.post("/v1/fraud/detect", json=payload)
assert resp.status_code == 200
assert len(resp.json()["results"]) == 2

def test_three_claim_batch_does_not_crash(self):
"""Three-claim batch must not raise ValueError from LOF."""
payload = {"claims": _make_claims(3)}
resp = client.post("/v1/fraud/detect", json=payload)
assert resp.status_code == 200
assert len(resp.json()["results"]) == 3

def test_model_version_in_response(self):
payload = {"claims": _make_claims(3)}
resp = client.post("/v1/fraud/detect", json=payload)
data = resp.json()
assert "model_version" in data
assert data["model_version"] is not None


class TestFraudDetectionService:
def test_single_claim(self):
Expand All @@ -70,3 +94,35 @@ def test_scores_in_range(self):
results = detect_fraud(claims)
for r in results:
assert 0.0 <= r.fraud_risk_score <= 1.0

def test_two_claims_no_crash(self):
claims = [
ClaimMetadata(claim_id="a", ip_address="1.1.1.1", amount=10.0),
ClaimMetadata(claim_id="b", ip_address="2.2.2.2", amount=20.0),
]
results = detect_fraud(claims)
assert len(results) == 2
for r in results:
assert 0.0 <= r.fraud_risk_score <= 1.0

def test_three_claims_no_crash(self):
claims = [
ClaimMetadata(claim_id="a", ip_address="1.1.1.1", amount=10.0),
ClaimMetadata(claim_id="b", ip_address="2.2.2.2", amount=20.0),
ClaimMetadata(claim_id="c", ip_address="3.3.3.3", amount=30.0),
]
results = detect_fraud(claims)
assert len(results) == 3
for r in results:
assert 0.0 <= r.fraud_risk_score <= 1.0

def test_outlier_flagged_in_batch(self):
"""A constructed outlier should be flagged, homogeneous batch should not."""
claims = [
ClaimMetadata(claim_id=f"n{i}", ip_address="1.1.1.1", amount=100.0)
for i in range(8)
]
claims.append(ClaimMetadata(claim_id="outlier", ip_address="99.99.99.99", amount=99999.0))
results = detect_fraud(claims)
by_id = {r.claim_id: r for r in results}
assert by_id["outlier"].fraud_risk_score > by_id["n0"].fraud_risk_score
Loading