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 };
|
||||
|
||||
Reference in New Issue
Block a user