feat(analytics): integrate AI/ML services — AVM endpoint, moderation pipeline, market index cron
- Add AiServiceClient HTTP client for Python FastAPI AI service with timeout and fallback - Add HttpAVMService that calls Python AVM endpoint, falls back to PrismaAVMService on failure - Add ListingCreatedModerationHandler: auto-flags suspicious listings via AI moderation on create - Add MarketIndexCronService: daily cron job aggregating market stats per district/city/type - Wire ScheduleModule and new providers into AnalyticsModule and AppModule - Add unit tests for AiServiceClient, HttpAVMService, and moderation handler (all passing) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type IAVMService,
|
||||
type AVMParams,
|
||||
type ValuationResult,
|
||||
type Comparable,
|
||||
} from '../../domain/services/avm-service';
|
||||
import {
|
||||
AI_SERVICE_CLIENT,
|
||||
type IAiServiceClient,
|
||||
type AiPredictRequest,
|
||||
} from './ai-service.client';
|
||||
import { type PrismaAVMService } from './prisma-avm.service';
|
||||
|
||||
@Injectable()
|
||||
export class HttpAVMService implements IAVMService {
|
||||
private readonly logger = new Logger(HttpAVMService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
|
||||
private readonly fallback: PrismaAVMService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async estimateValue(params: AVMParams): Promise<ValuationResult> {
|
||||
try {
|
||||
return await this.estimateViaAi(params);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`AI AVM service unavailable, falling back to comparables-based estimation: ${(err as Error).message}`,
|
||||
);
|
||||
return this.fallback.estimateValue(params);
|
||||
}
|
||||
}
|
||||
|
||||
async getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]> {
|
||||
return this.fallback.getComparables(propertyId, radiusMeters);
|
||||
}
|
||||
|
||||
private async estimateViaAi(params: AVMParams): Promise<ValuationResult> {
|
||||
const propertyData = params.propertyId
|
||||
? await this.getPropertyDetails(params.propertyId)
|
||||
: null;
|
||||
|
||||
const request: AiPredictRequest = {
|
||||
area: params.areaM2 ?? propertyData?.areaM2 ?? 0,
|
||||
district: propertyData?.district ?? '',
|
||||
city: propertyData?.city ?? '',
|
||||
property_type: (params.propertyType ?? propertyData?.propertyType ?? 'house').toLowerCase(),
|
||||
bedrooms: propertyData?.bedrooms ?? 0,
|
||||
bathrooms: propertyData?.bathrooms ?? 0,
|
||||
floors: propertyData?.floors ?? 0,
|
||||
frontage: 0,
|
||||
road_width: 0,
|
||||
year_built: params.yearBuilt ?? propertyData?.yearBuilt,
|
||||
has_legal_paper: propertyData?.hasLegalPaper ?? true,
|
||||
};
|
||||
|
||||
const aiResult = await this.aiClient.predict(request);
|
||||
|
||||
// Also fetch comparables from the local PostGIS service for context
|
||||
let comparables: Comparable[] = [];
|
||||
try {
|
||||
if (params.propertyId) {
|
||||
comparables = await this.fallback.getComparables(params.propertyId, 2000);
|
||||
}
|
||||
} catch {
|
||||
// Comparables are supplementary — don't fail the valuation
|
||||
}
|
||||
|
||||
return {
|
||||
estimatedPrice: Math.round(aiResult.estimated_price_vnd).toString(),
|
||||
confidence: aiResult.confidence,
|
||||
pricePerM2: Math.round(aiResult.price_per_m2),
|
||||
comparables,
|
||||
modelVersion: 'ai-service-v1.0',
|
||||
};
|
||||
}
|
||||
|
||||
private async getPropertyDetails(propertyId: string) {
|
||||
const row = await this.prisma.property.findUnique({
|
||||
where: { id: propertyId },
|
||||
select: {
|
||||
areaM2: true,
|
||||
district: true,
|
||||
city: true,
|
||||
propertyType: true,
|
||||
bedrooms: true,
|
||||
bathrooms: true,
|
||||
floors: true,
|
||||
yearBuilt: true,
|
||||
legalStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
areaM2: row.areaM2,
|
||||
district: row.district,
|
||||
city: row.city,
|
||||
propertyType: row.propertyType,
|
||||
bedrooms: row.bedrooms ?? 0,
|
||||
bathrooms: row.bathrooms ?? 0,
|
||||
floors: row.floors ?? 0,
|
||||
yearBuilt: row.yearBuilt,
|
||||
hasLegalPaper: row.legalStatus === 'SO_DO' || row.legalStatus === 'SO_HONG',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user