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

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

View File

@@ -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<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
pOI: { count: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
let mockAiClient: { scoreNeighborhood: ReturnType<typeof vi.fn> };
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();
});
});

View File

@@ -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<string, number>;
algorithm_version: string;
}
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
export interface IAiServiceClient {
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise<AiNeighborhoodScoreResponse>;
isAvailable(): Promise<boolean>;
}
@@ -124,6 +154,12 @@ export class AiServiceClient implements IAiServiceClient {
return this.post<AiModerationResponse>('/moderation/check', req);
}
async scoreNeighborhood(
req: AiNeighborhoodScoreRequest,
): Promise<AiNeighborhoodScoreResponse> {
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req);
}
async isAvailable(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {

View File

@@ -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 0100 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<string, string[]> = {
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<keyof typeof CATEGORY_WEIGHTS, POIType[]> = {
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<string, number> = {
const MAX_COUNTS: Record<keyof typeof CATEGORY_WEIGHTS, number> = {
education: 15,
healthcare: 8,
transport: 12,
@@ -38,8 +45,11 @@ const MAX_COUNTS: Record<string, number> = {
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<string, number>,
calculatedAt: existing.calculatedAt,
};
return mapRecord(existing);
}
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
// Count POIs per category for this district
const poiCounts: Record<string, number> = {};
const categoryScores: Record<string, number> = {};
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<NeighborhoodScoreResult | null> {
return this.fallback.getScore(district, city);
}
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
const counts = await countPOIs(this.prisma, district, city);
try {
const aiResult = await this.aiClient.scoreNeighborhood({
district,
city,
poi_counts: counts,
});
const subScores: Record<CategoryKey, number> = {
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<AiNeighborhoodPOICounts> {
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 010: linear scale capped at MAX_COUNTS
const maxCount = MAX_COUNTS[category]!;
categoryScores[category] = Math.min(10, (count / maxCount) * 10);
}
// Weighted total score (0100)
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<string, number>,
calculatedAt: result.calculatedAt,
};
}
return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts;
}
function scoreFromCounts(counts: AiNeighborhoodPOICounts): Record<CategoryKey, number> {
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<CategoryKey, number>;
}
function weightedTotal(subScores: Record<CategoryKey, number>): 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<CategoryKey, number>,
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<string, number>,
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<string, number>,
calculatedAt: record.calculatedAt,
};
}
/**
* @deprecated Use HttpNeighborhoodScoreService (binds AI proxy + prisma fallback).
* Kept exported for backward compatibility with callers/tests.
*/
export { PrismaNeighborhoodScoreService as NeighborhoodScoreServiceImpl };

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

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