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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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<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 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<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 };
|
||||
|
||||
@@ -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