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:
Ho Ngoc Hai
2026-04-18 15:07:02 +07:00
parent 329a821b4a
commit 2c1e3771e9
9 changed files with 551 additions and 93 deletions

View 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