diff --git a/app/ai-service/api/v1/fraud.py b/app/ai-service/api/v1/fraud.py index b5fee119..e59129a7 100644 --- a/app/ai-service/api/v1/fraud.py +++ b/app/ai-service/api/v1/fraud.py @@ -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__) @@ -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) diff --git a/app/ai-service/schemas/fraud.py b/app/ai-service/schemas/fraud.py index 8fa5c2a1..53e4aa9b 100644 --- a/app/ai-service/schemas/fraud.py +++ b/app/ai-service/schemas/fraud.py @@ -25,3 +25,4 @@ class ClaimFraudResult(BaseModel): class FraudDetectionResponse(BaseModel): results: List[ClaimFraudResult] flagged_count: int + model_version: Optional[str] = None diff --git a/app/ai-service/services/fraud_detection.py b/app/ai-service/services/fraud_detection.py index c8f3134e..147712a5 100644 --- a/app/ai-service/services/fraud_detection.py +++ b/app/ai-service/services/fraud_detection.py @@ -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.""" @@ -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]: @@ -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)) diff --git a/app/ai-service/tests/test_fraud_detection.py b/app/ai-service/tests/test_fraud_detection.py index 4b9ab8c6..b6f44e09 100644 --- a/app/ai-service/tests/test_fraud_detection.py +++ b/app/ai-service/tests/test_fraud_detection.py @@ -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): @@ -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