import { Injectable } from '@nestjs/common'; import { LoggerService } from '@modules/shared'; export interface AiPredictRequest { area: number; district: string; city: string; property_type: string; bedrooms?: number; bathrooms?: number; floors?: number; frontage?: number; road_width?: number; year_built?: number | null; has_legal_paper?: boolean; } export interface AiPredictResponse { estimated_price_vnd: number; confidence: number; price_per_m2: number; price_range_low: number; price_range_high: number; } /** * AVM v2 request — extended feature set for residential ensemble. * Matches `AVMv2PredictRequest` in libs/ai-services/app/models/avm_v2.py. */ export interface AiPredictV2Request { district: string; city: string; property_type: string; area_m2: number; distance_to_cbd_km?: number; distance_to_metro_km?: number; distance_to_school_km?: number; distance_to_hospital_km?: number; distance_to_park_km?: number; distance_to_mall_km?: number; flood_zone_risk?: number; neighborhood_score?: number; rooms?: number; floor_level?: number; total_floors?: number; direction?: string; floor_ratio?: number; building_age_years?: number; has_elevator?: boolean; has_parking?: boolean; has_pool?: boolean; has_legal_paper?: boolean; developer_reputation?: number; avg_price_district_3m_vnd_m2?: number; listing_density?: number; absorption_rate?: number; dom_avg?: number; price_momentum_30d?: number; yoy_change?: number; renovation_score?: number; view_quality?: number; interior_quality?: number; noise_level?: number; natural_light?: number; month?: number; quarter?: number; is_year_end?: boolean; } export interface AiPredictV2FeatureImportance { feature: string; importance: number; } export interface AiPredictV2Comparable { district: string; property_type: string; area_m2: number; price_vnd: number; price_per_m2_vnd: number; similarity_score: number; } export interface AiPredictV2Response { estimated_price_vnd: number; confidence: number; price_per_m2_vnd: number; price_range_low_vnd: number; price_range_high_vnd: number; drivers?: AiPredictV2FeatureImportance[]; comparables?: AiPredictV2Comparable[]; model_version?: string; ensemble_method?: string; } export interface AiIndustrialPredictRequest { province: string; region: string; park_occupancy_rate: number; park_area_ha: number; park_age_years: number; distance_to_port_km: number; distance_to_airport_km: number; distance_to_highway_km: number; property_type: string; area_m2: number; ceiling_height_m?: number; floor_load_ton_m2?: number; power_capacity_kva?: number; building_coverage?: number; loading_docks?: number; zoning?: string; industry_demand_index?: number; fdi_province_musd?: number; labor_cost_province_vnd?: number; logistics_connectivity_score?: number; } export interface AiIndustrialComparable { park_name: string; province: string; property_type: string; area_m2: number; rent_usd_m2: number; similarity_score: number; } export interface AiIndustrialFeatureImportance { feature: string; importance: number; } export interface AiIndustrialPredictResponse { estimated_rent_usd_m2: number; confidence: number; rent_range_low_usd_m2: number; rent_range_high_usd_m2: number; annual_rent_usd_m2: number; total_monthly_rent_usd: number; comparables: AiIndustrialComparable[]; drivers: AiIndustrialFeatureImportance[]; model_version: string; } export interface AiModerationRequest { text: string; context?: string; } export interface AiModerationFlag { category: string; severity: string; matched_text: string; reason: string; } export interface AiModerationResponse { is_flagged: boolean; score: number; flags: AiModerationFlag[]; 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; algorithm_version: string; } export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT'); export interface IAiServiceClient { predict(req: AiPredictRequest): Promise; predictV2(req: AiPredictV2Request): Promise; predictIndustrial(req: AiIndustrialPredictRequest): Promise; moderate(req: AiModerationRequest): Promise; scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise; isAvailable(): Promise; } @Injectable() export class AiServiceClient implements IAiServiceClient { private readonly baseUrl: string; private readonly apiKey: string; private readonly timeoutMs: number; constructor(private readonly logger: LoggerService) { this.baseUrl = process.env['AI_SERVICE_URL'] ?? 'http://localhost:8000'; this.apiKey = process.env['AI_SERVICE_API_KEY'] ?? ''; this.timeoutMs = Number(process.env['AI_SERVICE_TIMEOUT_MS']) || 5000; } async predict(req: AiPredictRequest): Promise { return this.post('/avm/predict', req); } async predictV2(req: AiPredictV2Request): Promise { return this.post('/avm/v2/predict', req); } async predictIndustrial(req: AiIndustrialPredictRequest): Promise { return this.post('/avm/industrial/predict', req); } async moderate(req: AiModerationRequest): Promise { return this.post('/moderation/check', req); } async scoreNeighborhood( req: AiNeighborhoodScoreRequest, ): Promise { return this.post('/neighborhood/score', req); } async isAvailable(): Promise { try { const response = await fetch(`${this.baseUrl}/health`, { method: 'GET', signal: AbortSignal.timeout(2000), }); return response.ok; } catch { return false; } } private async post(path: string, body: unknown): Promise { const url = `${this.baseUrl}${path}`; const headers: Record = { 'Content-Type': 'application/json', }; if (this.apiKey) { headers['X-API-Key'] = this.apiKey; } const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs), }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(`AI service ${path} returned ${response.status}: ${text}`); } return response.json() as Promise; } }