feat(ai-services): add AVM v2 A/B comparison endpoint and tests

Add POST /avm/v2/compare-v1 endpoint that runs both v1 (single-model)
and v2 (ensemble) AVM predictions on the same property and returns a
side-by-side comparison with price diff, confidence delta, and a
recommendation on which model to prefer.

- ABComparisonRequest/Response schemas in avm_v2 models
- compare_v1() method in AVMv2EnsembleService
- 4 new integration tests for the comparison endpoint
- All 47 Python tests pass

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 17:35:30 +07:00
parent 74804757c5
commit a6e53e3d06
4 changed files with 235 additions and 0 deletions

View File

@@ -183,3 +183,64 @@ class AVMv2ModelInfo(BaseModel):
metrics: dict
is_active: bool = Field(True)
ab_test_traffic_pct: float = Field(0.0, ge=0, le=1)
class AVMv1Summary(BaseModel):
"""Compact summary of a v1 prediction for comparison."""
estimated_price_vnd: float
confidence: float
price_per_m2: float
price_range_low: float
price_range_high: float
class AVMv2Summary(BaseModel):
"""Compact summary of a v2 prediction for comparison."""
estimated_price_vnd: float
confidence: float
price_per_m2_vnd: float
price_range_low_vnd: float
price_range_high_vnd: float
model_version: str
ensemble_method: str
class ABComparisonRequest(BaseModel):
"""Request for A/B comparison between v1 and v2."""
district: str = Field(..., min_length=1)
city: str = Field(..., min_length=1)
property_type: str = Field(...)
area_m2: float = Field(..., gt=0)
rooms: int = Field(0, ge=0)
bedrooms: int = Field(0, ge=0, description="Alias for rooms, used by v1")
floors: int = Field(0, ge=0)
frontage: float = Field(0.0, ge=0)
has_legal_paper: bool = Field(True)
# v2-specific features (optional, defaults applied)
distance_to_cbd_km: float = Field(0.0, ge=0)
distance_to_metro_km: float = Field(0.0, ge=0)
flood_zone_risk: float = Field(0.0, ge=0, le=1)
building_age_years: int = Field(0, ge=0)
has_elevator: bool = Field(False)
has_parking: bool = Field(False)
has_pool: bool = Field(False)
renovation_score: float = Field(0.5, ge=0, le=1)
view_quality: float = Field(0.5, ge=0, le=1)
interior_quality: float = Field(0.5, ge=0, le=1)
month: int = Field(1, ge=1, le=12)
quarter: int = Field(1, ge=1, le=4)
is_year_end: bool = Field(False)
class ABComparisonResponse(BaseModel):
"""Side-by-side A/B comparison of v1 vs v2 predictions."""
v1: AVMv1Summary
v2: AVMv2Summary
price_diff_vnd: float = Field(..., description="v2 - v1 price difference")
price_diff_pct: float = Field(..., description="Percentage difference ((v2-v1)/v1 * 100)")
confidence_diff: float = Field(..., description="v2 - v1 confidence difference")
recommendation: str = Field(..., description="Which model to prefer and why")