feat(avm): end-to-end AVM v2 schema + POST /analytics/valuation endpoint
Some checks failed
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m31s
Deploy / Build API Image (push) Failing after 25s
E2E Tests / Playwright E2E (push) Failing after 23s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Deploy / Build Web Image (push) Failing after 17s
Deploy / Build AI Services Image (push) Failing after 13s
Security Scanning / Trivy Scan — Web Image (push) Failing after 58s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 51s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m55s
Security Scanning / Trivy Filesystem Scan (push) Failing after 45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 3s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped

Closes the last gap from the tec-2725 branch: the valuation form's v2
extended-features section and POST endpoint can now submit real
predictions through to the Python ensemble model.

Backend
- New DTO apps/api/src/modules/analytics/presentation/dto/predict-valuation.dto.ts
  with all v1 fields + 8 v2 fields (useV2 toggle, distanceToHospital/Park/
  Mall in km, floodZoneRisk enum NONE|LOW|MEDIUM|HIGH, hasElevator/
  Parking/Pool booleans).
- New CQRS handler apps/api/src/modules/analytics/application/queries/
  predict-valuation/ that routes to AVM_SERVICE.estimateValue() with the
  full request body.
- Extend AVMParams (domain) with the same v2 fields + inline v1 fields
  (district, city, bedrooms, bathrooms, floors, frontage, roadWidth,
  hasLegalPaper, projectId, imageUrl, description, deepAnalysis).
- HttpAVMService.estimateViaAi now branches on `useV2`: v2 calls the new
  aiClient.predictV2() → POST /avm/v2/predict on the Python service,
  mapping floodZoneRisk enum → 0..1 float and computing
  building_age_years from yearBuilt. v1 path gets all the inline
  descriptors wired through so non-propertyId calls no longer lose
  context.
- AiServiceClient gets AiPredictV2Request / AiPredictV2Response types
  mirroring libs/ai-services/app/models/avm_v2.py::AVMv2PredictRequest
  (which already accepts all 7 numeric/boolean v2 fields — no Python
  change needed).
- Register PredictValuationHandler in AnalyticsModule.
- New route POST /analytics/valuation on AnalyticsController:
  JwtAuthGuard + QuotaGuard + EndpointRateLimitGuard (10/min),
  @RequireQuota('analytics_queries'), full Swagger doc. Total endpoint
  count 179 → 180.

Frontend
- Extend ValuationRequest with useV2, 3 distance-km fields,
  floodZoneRisk, hasElevator/Parking/Pool + export FloodZoneRisk type
  and FLOOD_RISK_OPTIONS.
- valuationApi.predict() body mapping now includes v2 fields and renames
  'areaM2' → 'area' to match the backend DTO contract.
- valuationFormSchema gains matching optional Zod fields + exports
  FLOOD_RISK_OPTIONS for the form.
- valuation-form.tsx gets:
  * Image upload hardening: MIME+size validation (JPG/PNG ≤5MB) before
    preview, role="progressbar" + aria-labels on the progress bar,
    role="alert" + data-testid="image-upload-error" on errors. Matches
    the upload-progress part of the task/tec-2725 commit 4ee0129 that
    was previously parked as blocked.
  * New Sparkles-branded "Mô hình v2 (Ensemble)" toggle alongside the
    existing Bot-branded "Phân tích chuyên sâu" toggle.
  * Collapsible "Đặc trưng mở rộng (AVM v2)" section with distance
    inputs, flood-risk select, and three amenity checkboxes.
  * handleFormSubmit passes all v2 fields through to onSubmit.

Python service unchanged — AVMv2PredictRequest already has every field
we send (distance_to_hospital_km, flood_zone_risk as float,
has_elevator/parking/pool, etc.).

Typecheck clean for the valuation surface. Pre-existing errors in
metadata.spec.ts and transfer-wizard-client.tsx are unrelated and left
for a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 06:49:57 +07:00
parent 58b0e6ba12
commit 79e173938b
12 changed files with 736 additions and 19 deletions

View File

@@ -0,0 +1,174 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export type FloodZoneRisk = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
export const FLOOD_ZONE_RISK_VALUES: FloodZoneRisk[] = ['NONE', 'LOW', 'MEDIUM', 'HIGH'];
export class PredictValuationDto {
// ── Core v1 fields ─────────────────────────────────────────────────
@ApiProperty({ enum: PropertyType, description: 'Loại bất động sản' })
@IsEnum(PropertyType)
propertyType!: PropertyType;
@ApiProperty({ description: 'Diện tích (m²)', example: 75 })
@IsNumber()
@Min(1)
@Type(() => Number)
area!: number;
@ApiProperty({ description: 'Quận/Huyện', example: 'Quận 1' })
@IsString()
district!: string;
@ApiProperty({ description: 'Tỉnh/Thành phố', example: 'Hồ Chí Minh' })
@IsString()
city!: string;
@ApiPropertyOptional({ description: 'Số phòng ngủ', example: 2 })
@IsOptional()
@IsInt()
@Min(0)
@Max(20)
@Type(() => Number)
bedrooms?: number;
@ApiPropertyOptional({ description: 'Số phòng tắm' })
@IsOptional()
@IsInt()
@Min(0)
@Max(20)
@Type(() => Number)
bathrooms?: number;
@ApiPropertyOptional({ description: 'Số tầng' })
@IsOptional()
@IsInt()
@Min(0)
@Max(200)
@Type(() => Number)
floors?: number;
@ApiPropertyOptional({ description: 'Mặt tiền (m)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
frontage?: number;
@ApiPropertyOptional({ description: 'Độ rộng đường trước nhà (m)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
roadWidth?: number;
@ApiPropertyOptional({ description: 'Năm xây' })
@IsOptional()
@IsInt()
@Min(1900)
@Max(2100)
@Type(() => Number)
yearBuilt?: number;
@ApiPropertyOptional({ description: 'Có giấy tờ pháp lý đầy đủ' })
@IsOptional()
@IsBoolean()
hasLegalPaper?: boolean;
@ApiPropertyOptional({ description: 'Vĩ độ' })
@IsOptional()
@IsNumber()
@Type(() => Number)
latitude?: number;
@ApiPropertyOptional({ description: 'Kinh độ' })
@IsOptional()
@IsNumber()
@Type(() => Number)
longitude?: number;
@ApiPropertyOptional({ description: 'ID dự án (nếu thuộc dự án)' })
@IsOptional()
@IsString()
projectId?: string;
@ApiPropertyOptional({ description: 'URL ảnh BĐS để phân tích' })
@IsOptional()
@IsString()
imageUrl?: string;
@ApiPropertyOptional({ description: 'Mô tả bổ sung' })
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@ApiPropertyOptional({ description: 'Bật phân tích chuyên sâu (Claude)' })
@IsOptional()
@IsBoolean()
deepAnalysis?: boolean;
// ── AVM v2 fields ──────────────────────────────────────────────────
@ApiPropertyOptional({ description: 'Dùng mô hình AVM v2 (ensemble + đặc trưng mở rộng)' })
@IsOptional()
@IsBoolean()
useV2?: boolean;
@ApiPropertyOptional({ description: 'Khoảng cách tới bệnh viện (km)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
distanceToHospitalKm?: number;
@ApiPropertyOptional({ description: 'Khoảng cách tới công viên (km)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
distanceToParkKm?: number;
@ApiPropertyOptional({ description: 'Khoảng cách tới trung tâm thương mại (km)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
distanceToMallKm?: number;
@ApiPropertyOptional({
enum: FLOOD_ZONE_RISK_VALUES,
description: 'Mức độ nguy cơ ngập lụt',
})
@IsOptional()
@IsIn(FLOOD_ZONE_RISK_VALUES)
floodZoneRisk?: FloodZoneRisk;
@ApiPropertyOptional({ description: 'Có thang máy' })
@IsOptional()
@IsBoolean()
hasElevator?: boolean;
@ApiPropertyOptional({ description: 'Có chỗ đậu xe' })
@IsOptional()
@IsBoolean()
hasParking?: boolean;
@ApiPropertyOptional({ description: 'Có hồ bơi' })
@IsOptional()
@IsBoolean()
hasPool?: boolean;
}