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

@@ -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)

View 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

View 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)

View 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()