feat(analytics): add Python NeighborhoodScore service + NestJS HTTP proxy (TEC-2756)
- libs/ai-services: new POST /neighborhood/score router computing weighted 6-axis livability score from per-category POI counts; algorithm versioned for future iteration (sigmoid curves, percentile thresholds). - apps/api: HttpNeighborhoodScoreService proxies to Python first, falls back to PrismaNeighborhoodScoreService when AI service unavailable. Mirrors the HttpAVMService pattern. Existing GET /analytics/neighborhoods/:district/score endpoint and CQRS handler now flow through the proxy. - AnalyticsModule binds Http variant by default, retains Prisma variant as injectable fallback. - Tests: 5 pytest cases for Python heuristic, 4 vitest cases for HTTP proxy fallback behaviour. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
119
libs/ai-services/tests/test_neighborhood.py
Normal file
119
libs/ai-services/tests/test_neighborhood.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_zero_counts_yields_zero_score():
|
||||
resp = client.post(
|
||||
"/neighborhood/score",
|
||||
json={
|
||||
"district": "Quận 7",
|
||||
"city": "Hồ Chí Minh",
|
||||
"poi_counts": {
|
||||
"education": 0,
|
||||
"healthcare": 0,
|
||||
"transport": 0,
|
||||
"shopping": 0,
|
||||
"greenery": 0,
|
||||
"safety": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_score"] == 0
|
||||
assert data["education_score"] == 0
|
||||
assert data["algorithm_version"].startswith("neighborhood-heuristic")
|
||||
|
||||
|
||||
def test_saturated_counts_yields_one_hundred():
|
||||
resp = client.post(
|
||||
"/neighborhood/score",
|
||||
json={
|
||||
"district": "Quận 1",
|
||||
"city": "Hồ Chí Minh",
|
||||
"poi_counts": {
|
||||
"education": 50,
|
||||
"healthcare": 50,
|
||||
"transport": 50,
|
||||
"shopping": 50,
|
||||
"greenery": 50,
|
||||
"safety": 50,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_score"] == 100.0
|
||||
assert data["education_score"] == 10
|
||||
assert data["safety_score"] == 10
|
||||
|
||||
|
||||
def test_partial_counts_apply_weighted_average():
|
||||
# Hit the linear cap on transport+greenery only; others 0.
|
||||
# transport weight 20 + greenery 15 = 35 → expect total 35.0.
|
||||
resp = client.post(
|
||||
"/neighborhood/score",
|
||||
json={
|
||||
"district": "Bình Thạnh",
|
||||
"city": "Hồ Chí Minh",
|
||||
"poi_counts": {
|
||||
"education": 0,
|
||||
"healthcare": 0,
|
||||
"transport": 12,
|
||||
"shopping": 0,
|
||||
"greenery": 6,
|
||||
"safety": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["transport_score"] == 10
|
||||
assert data["greenery_score"] == 10
|
||||
assert data["total_score"] == 35.0
|
||||
assert data["poi_counts"]["transport"] == 12
|
||||
|
||||
|
||||
def test_below_max_uses_linear_scale():
|
||||
# education max=15, count=3 → 2.0; weight 20 → contributes 4.0
|
||||
resp = client.post(
|
||||
"/neighborhood/score",
|
||||
json={
|
||||
"district": "Quận 3",
|
||||
"city": "Hồ Chí Minh",
|
||||
"poi_counts": {
|
||||
"education": 3,
|
||||
"healthcare": 0,
|
||||
"transport": 0,
|
||||
"shopping": 0,
|
||||
"greenery": 0,
|
||||
"safety": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["education_score"] == 2.0
|
||||
assert data["total_score"] == 4.0
|
||||
|
||||
|
||||
def test_validation_rejects_negative_count():
|
||||
resp = client.post(
|
||||
"/neighborhood/score",
|
||||
json={
|
||||
"district": "Quận 1",
|
||||
"city": "Hồ Chí Minh",
|
||||
"poi_counts": {
|
||||
"education": -1,
|
||||
"healthcare": 0,
|
||||
"transport": 0,
|
||||
"shopping": 0,
|
||||
"greenery": 0,
|
||||
"safety": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
Reference in New Issue
Block a user