diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 0c05ced..6c8fb94 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -23,7 +23,10 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma- import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client'; import { HttpAVMService } from './infrastructure/services/http-avm.service'; import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service'; -import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service'; +import { + HttpNeighborhoodScoreService, + PrismaNeighborhoodScoreService, +} from './infrastructure/services/neighborhood-score.service'; import { PrismaAVMService } from './infrastructure/services/prisma-avm.service'; import { AnalyticsController } from './presentation/controllers/analytics.controller'; import { AvmController } from './presentation/controllers/avm.controller'; @@ -66,8 +69,9 @@ const EventHandlers = [ PrismaAVMService, { provide: AVM_SERVICE, useClass: HttpAVMService }, - // Neighborhood scoring - { provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl }, + // Neighborhood scoring: HTTP proxy → Python AI service, falls back to Prisma scoring + PrismaNeighborhoodScoreService, + { provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService }, // Cron MarketIndexCronService, diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts index 3fff29d..d96a99d 100644 --- a/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts @@ -1,4 +1,8 @@ -import { NeighborhoodScoreServiceImpl } from '../services/neighborhood-score.service'; +import { + HttpNeighborhoodScoreService, + NeighborhoodScoreServiceImpl, + PrismaNeighborhoodScoreService, +} from '../services/neighborhood-score.service'; describe('NeighborhoodScoreServiceImpl', () => { let service: NeighborhoodScoreServiceImpl; @@ -130,3 +134,83 @@ describe('NeighborhoodScoreServiceImpl', () => { }); }); }); + +describe('HttpNeighborhoodScoreService', () => { + let httpService: HttpNeighborhoodScoreService; + let prismaFallback: PrismaNeighborhoodScoreService; + let mockPrisma: { + neighborhoodScore: { findUnique: ReturnType; upsert: ReturnType }; + pOI: { count: ReturnType }; + }; + let mockLogger: { log: ReturnType; warn: ReturnType }; + let mockAiClient: { scoreNeighborhood: ReturnType }; + + beforeEach(() => { + mockPrisma = { + neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() }, + pOI: { count: vi.fn() }, + }; + mockLogger = { log: vi.fn(), warn: vi.fn() }; + mockAiClient = { scoreNeighborhood: vi.fn() }; + prismaFallback = new PrismaNeighborhoodScoreService( + mockPrisma as any, + mockLogger as any, + ); + httpService = new HttpNeighborhoodScoreService( + mockPrisma as any, + mockLogger as any, + mockAiClient as any, + prismaFallback, + ); + }); + + it('persists AI service response when scoreNeighborhood succeeds', async () => { + mockPrisma.pOI.count.mockResolvedValue(6); + mockAiClient.scoreNeighborhood.mockResolvedValue({ + district: 'Quận 1', + city: 'Hồ Chí Minh', + education_score: 8.5, + healthcare_score: 7, + transport_score: 9, + shopping_score: 6, + greenery_score: 5.5, + safety_score: 4, + total_score: 71.2, + poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 }, + algorithm_version: 'neighborhood-heuristic-v1', + }); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create)); + + const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh'); + + expect(mockAiClient.scoreNeighborhood).toHaveBeenCalledOnce(); + expect(result.totalScore).toBe(71.2); + expect(result.educationScore).toBe(8.5); + expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce(); + }); + + it('falls back to prisma scoring when AI service throws', async () => { + mockPrisma.pOI.count.mockResolvedValue(0); + mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down')); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create)); + + const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh'); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('falling back to prisma scoring'), + 'NeighborhoodScoreService', + ); + expect(result.totalScore).toBe(0); + expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce(); + }); + + it('delegates getScore to prisma fallback', async () => { + mockPrisma.neighborhoodScore.findUnique.mockResolvedValue(null); + + const result = await httpService.getScore('Quận 99', 'Hồ Chí Minh'); + + expect(result).toBeNull(); + expect(mockPrisma.neighborhoodScore.findUnique).toHaveBeenCalledOnce(); + expect(mockAiClient.scoreNeighborhood).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts index c7dd2e8..4321051 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts @@ -91,12 +91,42 @@ export interface AiModerationResponse { cleaned_text: string | null; } +export interface AiNeighborhoodPOICounts { + education: number; + healthcare: number; + transport: number; + shopping: number; + greenery: number; + safety: number; +} + +export interface AiNeighborhoodScoreRequest { + district: string; + city: string; + poi_counts: AiNeighborhoodPOICounts; +} + +export interface AiNeighborhoodScoreResponse { + district: string; + city: string; + education_score: number; + healthcare_score: number; + transport_score: number; + shopping_score: number; + greenery_score: number; + safety_score: number; + total_score: number; + poi_counts: Record; + algorithm_version: string; +} + export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT'); export interface IAiServiceClient { predict(req: AiPredictRequest): Promise; predictIndustrial(req: AiIndustrialPredictRequest): Promise; moderate(req: AiModerationRequest): Promise; + scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise; isAvailable(): Promise; } @@ -124,6 +154,12 @@ export class AiServiceClient implements IAiServiceClient { return this.post('/moderation/check', req); } + async scoreNeighborhood( + req: AiNeighborhoodScoreRequest, + ): Promise { + return this.post('/neighborhood/score', req); + } + async isAvailable(): Promise { try { const response = await fetch(`${this.baseUrl}/health`, { diff --git a/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts b/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts index fe64db1..ab0182a 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts @@ -1,13 +1,20 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { POIType } from '@prisma/client'; import { type PrismaService, type LoggerService } from '@modules/shared'; import { type INeighborhoodScoreService, type NeighborhoodScoreResult, } from '../../domain/services/neighborhood-score.service'; +import { + AI_SERVICE_CLIENT, + type AiNeighborhoodPOICounts, + type IAiServiceClient, +} from './ai-service.client'; /** * Scoring weights for each POI category. * Sum = 100 (total score is 0–100 weighted average). + * Mirrors the Python heuristic in libs/ai-services/app/services/neighborhood_service.py. */ const CATEGORY_WEIGHTS = { education: 20, @@ -16,20 +23,20 @@ const CATEGORY_WEIGHTS = { shopping: 15, greenery: 15, safety: 10, -}; +} as const; /** POI types grouped by scoring category. */ -const CATEGORY_POI_TYPES: Record = { - education: ['SCHOOL', 'UNIVERSITY'], - healthcare: ['HOSPITAL', 'CLINIC'], - transport: ['METRO_STATION', 'BUS_STOP'], - shopping: ['MALL', 'MARKET', 'SUPERMARKET'], - greenery: ['PARK'], - safety: ['POLICE_STATION', 'FIRE_STATION'], +const CATEGORY_POI_TYPES: Record = { + education: [POIType.SCHOOL, POIType.UNIVERSITY], + healthcare: [POIType.HOSPITAL, POIType.CLINIC], + transport: [POIType.METRO_STATION, POIType.BUS_STOP], + shopping: [POIType.MALL, POIType.MARKET, POIType.SUPERMARKET], + greenery: [POIType.PARK], + safety: [POIType.POLICE_STATION, POIType.FIRE_STATION], }; /** Max count per category that yields a 10/10 score. */ -const MAX_COUNTS: Record = { +const MAX_COUNTS: Record = { education: 15, healthcare: 8, transport: 12, @@ -38,8 +45,11 @@ const MAX_COUNTS: Record = { safety: 4, }; +type CategoryKey = keyof typeof CATEGORY_WEIGHTS; +const CATEGORY_KEYS = Object.keys(CATEGORY_WEIGHTS) as CategoryKey[]; + @Injectable() -export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService { +export class PrismaNeighborhoodScoreService implements INeighborhoodScoreService { constructor( private readonly prisma: PrismaService, private readonly logger: LoggerService, @@ -52,91 +62,179 @@ export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService { if (!existing) return null; - return { - district: existing.district, - city: existing.city, - educationScore: existing.educationScore, - healthcareScore: existing.healthcareScore, - transportScore: existing.transportScore, - shoppingScore: existing.shoppingScore, - greeneryScore: existing.greeneryScore, - safetyScore: existing.safetyScore, - totalScore: existing.totalScore, - poiCounts: existing.poiCounts as Record, - calculatedAt: existing.calculatedAt, - }; + return mapRecord(existing); } async calculateAndSave(district: string, city: string): Promise { - // Count POIs per category for this district - const poiCounts: Record = {}; - const categoryScores: Record = {}; + const counts = await countPOIs(this.prisma, district, city); + const subScores = scoreFromCounts(counts); + const totalScore = weightedTotal(subScores); - for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) { - const count = await this.prisma.pOI.count({ + const result = await upsertScore(this.prisma, district, city, subScores, totalScore, counts); + this.logger.log( + `Neighborhood score (prisma) calculated: ${district}, ${city} → total=${result.totalScore}`, + 'NeighborhoodScoreService', + ); + return mapRecord(result); + } +} + +/** + * Calls the Python AI service to compute scores; falls back to local Prisma scoring + * when the service is unavailable or the call times out. Persists to NeighborhoodScore. + */ +@Injectable() +export class HttpNeighborhoodScoreService implements INeighborhoodScoreService { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + @Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient, + private readonly fallback: PrismaNeighborhoodScoreService, + ) {} + + async getScore(district: string, city: string): Promise { + return this.fallback.getScore(district, city); + } + + async calculateAndSave(district: string, city: string): Promise { + const counts = await countPOIs(this.prisma, district, city); + + try { + const aiResult = await this.aiClient.scoreNeighborhood({ + district, + city, + poi_counts: counts, + }); + + const subScores: Record = { + education: aiResult.education_score, + healthcare: aiResult.healthcare_score, + transport: aiResult.transport_score, + shopping: aiResult.shopping_score, + greenery: aiResult.greenery_score, + safety: aiResult.safety_score, + }; + + const result = await upsertScore( + this.prisma, + district, + city, + subScores, + aiResult.total_score, + counts, + ); + this.logger.log( + `Neighborhood score (ai=${aiResult.algorithm_version}): ${district}, ${city} → total=${result.totalScore}`, + 'NeighborhoodScoreService', + ); + return mapRecord(result); + } catch (err) { + this.logger.warn( + `AI neighborhood score unavailable, falling back to prisma scoring: ${(err as Error).message}`, + 'NeighborhoodScoreService', + ); + return this.fallback.calculateAndSave(district, city); + } + } +} + +async function countPOIs( + prisma: PrismaService, + district: string, + city: string, +): Promise { + const entries = await Promise.all( + CATEGORY_KEYS.map(async (cat) => { + const count = await prisma.pOI.count({ where: { district, city, - type: { in: poiTypes as any }, + type: { in: CATEGORY_POI_TYPES[cat] }, }, }); + return [cat, count] as const; + }), + ); - poiCounts[category] = count; - // Score 0–10: linear scale capped at MAX_COUNTS - const maxCount = MAX_COUNTS[category]!; - categoryScores[category] = Math.min(10, (count / maxCount) * 10); - } - - // Weighted total score (0–100) - const totalScore = Object.entries(CATEGORY_WEIGHTS).reduce((sum, [cat, weight]) => { - return sum + (categoryScores[cat]! * weight) / 10; - }, 0); - - const result = await this.prisma.neighborhoodScore.upsert({ - where: { district_city: { district, city } }, - create: { - district, - city, - educationScore: categoryScores['education']!, - healthcareScore: categoryScores['healthcare']!, - transportScore: categoryScores['transport']!, - shoppingScore: categoryScores['shopping']!, - greeneryScore: categoryScores['greenery']!, - safetyScore: categoryScores['safety']!, - totalScore: Math.round(totalScore * 10) / 10, - poiCounts, - calculatedAt: new Date(), - }, - update: { - educationScore: categoryScores['education']!, - healthcareScore: categoryScores['healthcare']!, - transportScore: categoryScores['transport']!, - shoppingScore: categoryScores['shopping']!, - greeneryScore: categoryScores['greenery']!, - safetyScore: categoryScores['safety']!, - totalScore: Math.round(totalScore * 10) / 10, - poiCounts, - calculatedAt: new Date(), - }, - }); - - this.logger.log( - `Neighborhood score calculated: ${district}, ${city} → total=${result.totalScore}`, - 'NeighborhoodScoreService', - ); - - return { - district: result.district, - city: result.city, - educationScore: result.educationScore, - healthcareScore: result.healthcareScore, - transportScore: result.transportScore, - shoppingScore: result.shoppingScore, - greeneryScore: result.greeneryScore, - safetyScore: result.safetyScore, - totalScore: result.totalScore, - poiCounts: result.poiCounts as Record, - calculatedAt: result.calculatedAt, - }; - } + return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts; } + +function scoreFromCounts(counts: AiNeighborhoodPOICounts): Record { + return Object.fromEntries( + CATEGORY_KEYS.map((cat) => { + const raw = counts[cat] ?? 0; + const max = MAX_COUNTS[cat]; + return [cat, Math.min(10, (raw / max) * 10)]; + }), + ) as Record; +} + +function weightedTotal(subScores: Record): number { + const sum = CATEGORY_KEYS.reduce( + (acc, cat) => acc + (subScores[cat] * CATEGORY_WEIGHTS[cat]) / 10, + 0, + ); + return Math.round(sum * 10) / 10; +} + +async function upsertScore( + prisma: PrismaService, + district: string, + city: string, + subScores: Record, + totalScore: number, + counts: AiNeighborhoodPOICounts, +) { + const calculatedAt = new Date(); + const data = { + educationScore: subScores.education, + healthcareScore: subScores.healthcare, + transportScore: subScores.transport, + shoppingScore: subScores.shopping, + greeneryScore: subScores.greenery, + safetyScore: subScores.safety, + totalScore, + poiCounts: counts as unknown as Record, + calculatedAt, + }; + + return prisma.neighborhoodScore.upsert({ + where: { district_city: { district, city } }, + create: { district, city, ...data }, + update: data, + }); +} + +function mapRecord(record: { + district: string; + city: string; + educationScore: number; + healthcareScore: number; + transportScore: number; + shoppingScore: number; + greeneryScore: number; + safetyScore: number; + totalScore: number; + poiCounts: unknown; + calculatedAt: Date; +}): NeighborhoodScoreResult { + return { + district: record.district, + city: record.city, + educationScore: record.educationScore, + healthcareScore: record.healthcareScore, + transportScore: record.transportScore, + shoppingScore: record.shoppingScore, + greeneryScore: record.greeneryScore, + safetyScore: record.safetyScore, + totalScore: record.totalScore, + poiCounts: record.poiCounts as Record, + calculatedAt: record.calculatedAt, + }; +} + +/** + * @deprecated Use HttpNeighborhoodScoreService (binds AI proxy + prisma fallback). + * Kept exported for backward compatibility with callers/tests. + */ +export { PrismaNeighborhoodScoreService as NeighborhoodScoreServiceImpl }; diff --git a/libs/ai-services/app/main.py b/libs/ai-services/app/main.py index c1541ec..324a7ac 100644 --- a/libs/ai-services/app/main.py +++ b/libs/ai-services/app/main.py @@ -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) diff --git a/libs/ai-services/app/models/neighborhood.py b/libs/ai-services/app/models/neighborhood.py new file mode 100644 index 0000000..ef8bb60 --- /dev/null +++ b/libs/ai-services/app/models/neighborhood.py @@ -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 diff --git a/libs/ai-services/app/routers/neighborhood.py b/libs/ai-services/app/routers/neighborhood.py new file mode 100644 index 0000000..ee78414 --- /dev/null +++ b/libs/ai-services/app/routers/neighborhood.py @@ -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) diff --git a/libs/ai-services/app/services/neighborhood_service.py b/libs/ai-services/app/services/neighborhood_service.py new file mode 100644 index 0000000..e4f0475 --- /dev/null +++ b/libs/ai-services/app/services/neighborhood_service.py @@ -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() diff --git a/libs/ai-services/tests/test_neighborhood.py b/libs/ai-services/tests/test_neighborhood.py new file mode 100644 index 0000000..9794e54 --- /dev/null +++ b/libs/ai-services/tests/test_neighborhood.py @@ -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