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