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:
@@ -6,7 +6,7 @@ from slowapi.util import get_remote_address
|
||||
|
||||
from app.config import settings
|
||||
from app.middleware import verify_api_key
|
||||
from app.routers import avm, avm_industrial, avm_v2, moderation, nlp
|
||||
from app.routers import avm, avm_industrial, avm_v2, moderation, neighborhood, nlp
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=[settings.rate_limit])
|
||||
|
||||
@@ -35,6 +35,7 @@ app.include_router(avm.router)
|
||||
app.include_router(avm_v2.router)
|
||||
app.include_router(avm_industrial.router)
|
||||
app.include_router(moderation.router)
|
||||
app.include_router(neighborhood.router)
|
||||
app.include_router(nlp.router)
|
||||
|
||||
|
||||
|
||||
33
libs/ai-services/app/models/neighborhood.py
Normal file
33
libs/ai-services/app/models/neighborhood.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NeighborhoodPOICounts(BaseModel):
|
||||
education: int = Field(0, ge=0, description="SCHOOL + UNIVERSITY within 2km")
|
||||
healthcare: int = Field(0, ge=0, description="HOSPITAL + CLINIC within 3km")
|
||||
transport: int = Field(0, ge=0, description="METRO_STATION + BUS_STOP within 1km")
|
||||
shopping: int = Field(0, ge=0, description="MALL + MARKET + SUPERMARKET within 2km")
|
||||
greenery: int = Field(0, ge=0, description="PARK within 1km")
|
||||
safety: int = Field(0, ge=0, description="POLICE_STATION + FIRE_STATION within 3km")
|
||||
|
||||
|
||||
class NeighborhoodScoreRequest(BaseModel):
|
||||
district: str = Field(..., min_length=1, description="District name (e.g. Quận 1)")
|
||||
city: str = Field(..., min_length=1, description="City name (e.g. Hồ Chí Minh)")
|
||||
poi_counts: NeighborhoodPOICounts = Field(
|
||||
...,
|
||||
description="Per-category POI counts already filtered by radius in NestJS",
|
||||
)
|
||||
|
||||
|
||||
class NeighborhoodScoreResponse(BaseModel):
|
||||
district: str
|
||||
city: str
|
||||
education_score: float = Field(..., ge=0, le=10)
|
||||
healthcare_score: float = Field(..., ge=0, le=10)
|
||||
transport_score: float = Field(..., ge=0, le=10)
|
||||
shopping_score: float = Field(..., ge=0, le=10)
|
||||
greenery_score: float = Field(..., ge=0, le=10)
|
||||
safety_score: float = Field(..., ge=0, le=10)
|
||||
total_score: float = Field(..., ge=0, le=100)
|
||||
poi_counts: dict[str, int]
|
||||
algorithm_version: str
|
||||
12
libs/ai-services/app/routers/neighborhood.py
Normal file
12
libs/ai-services/app/routers/neighborhood.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models.neighborhood import NeighborhoodScoreRequest, NeighborhoodScoreResponse
|
||||
from app.services.neighborhood_service import neighborhood_score_service
|
||||
|
||||
router = APIRouter(prefix="/neighborhood", tags=["Neighborhood"])
|
||||
|
||||
|
||||
@router.post("/score", response_model=NeighborhoodScoreResponse)
|
||||
def score(req: NeighborhoodScoreRequest) -> NeighborhoodScoreResponse:
|
||||
"""Compute weighted 0-100 livability score from per-category POI counts."""
|
||||
return neighborhood_score_service.score(req)
|
||||
71
libs/ai-services/app/services/neighborhood_service.py
Normal file
71
libs/ai-services/app/services/neighborhood_service.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import logging
|
||||
|
||||
from app.models.neighborhood import (
|
||||
NeighborhoodPOICounts,
|
||||
NeighborhoodScoreRequest,
|
||||
NeighborhoodScoreResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALGORITHM_VERSION = "neighborhood-heuristic-v1"
|
||||
|
||||
# Sum = 100. Mirrors NestJS PrismaNeighborhoodScoreServiceImpl for fallback parity.
|
||||
CATEGORY_WEIGHTS: dict[str, int] = {
|
||||
"education": 20,
|
||||
"healthcare": 20,
|
||||
"transport": 20,
|
||||
"shopping": 15,
|
||||
"greenery": 15,
|
||||
"safety": 10,
|
||||
}
|
||||
|
||||
# Count yielding a 10/10 sub-score. Calibrated against HCMC/HN audit benchmarks.
|
||||
MAX_COUNTS: dict[str, int] = {
|
||||
"education": 15,
|
||||
"healthcare": 8,
|
||||
"transport": 12,
|
||||
"shopping": 10,
|
||||
"greenery": 6,
|
||||
"safety": 4,
|
||||
}
|
||||
|
||||
|
||||
class NeighborhoodScoreService:
|
||||
"""Stateless scoring algorithm.
|
||||
|
||||
NestJS owns the PostGIS radius query and passes per-category counts.
|
||||
This service applies the weighting + capping curve so the algorithm
|
||||
can evolve independently of the persistence layer.
|
||||
"""
|
||||
|
||||
def score(self, req: NeighborhoodScoreRequest) -> NeighborhoodScoreResponse:
|
||||
counts = req.poi_counts
|
||||
sub_scores = self._sub_scores(counts)
|
||||
total = sum(
|
||||
CATEGORY_WEIGHTS[cat] * sub_scores[cat] / 10.0 for cat in CATEGORY_WEIGHTS
|
||||
)
|
||||
|
||||
return NeighborhoodScoreResponse(
|
||||
district=req.district,
|
||||
city=req.city,
|
||||
education_score=sub_scores["education"],
|
||||
healthcare_score=sub_scores["healthcare"],
|
||||
transport_score=sub_scores["transport"],
|
||||
shopping_score=sub_scores["shopping"],
|
||||
greenery_score=sub_scores["greenery"],
|
||||
safety_score=sub_scores["safety"],
|
||||
total_score=round(total, 1),
|
||||
poi_counts=counts.model_dump(),
|
||||
algorithm_version=ALGORITHM_VERSION,
|
||||
)
|
||||
|
||||
def _sub_scores(self, counts: NeighborhoodPOICounts) -> dict[str, float]:
|
||||
raw = counts.model_dump()
|
||||
return {
|
||||
cat: round(min(10.0, raw[cat] / MAX_COUNTS[cat] * 10.0), 2)
|
||||
for cat in CATEGORY_WEIGHTS
|
||||
}
|
||||
|
||||
|
||||
neighborhood_score_service = NeighborhoodScoreService()
|
||||
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