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 };