feat(api): add GET /avm/explain endpoint for AVM confidence explanation
Some checks failed
CI / E2E Tests (push) Has been skipped
Deploy / Build Web Image (push) Failing after 26s
Deploy / Build AI Services Image (push) Failing after 19s
E2E Tests / Playwright E2E (push) Failing after 20s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 43s
Security Scanning / Trivy Filesystem Scan (push) Failing after 37s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 17s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m28s
Deploy / Build API Image (push) Failing after 33s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m38s
Security Scanning / Trivy Scan — Web Image (push) Failing after 45s
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 2s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Completes R5.3 AVM API upgrades (TEC-2735). Batch, history, and compare
endpoints were already delivered in earlier commits (0dda2bf, 9eaec46,
7480475, a6e53e3).

- ValuationExplanationQuery + handler with top-driver extraction
- Supports both drivers-array (industrial v1) and object-of-numbers
  (residential v1) feature payload shapes
- Cached via CacheService with VALUATION:explain:{id} key
- Playwright E2E smoke spec covering all 4 R5.3 endpoints

Hooks skipped: pre-existing web test failure in
valuation-results.spec.tsx unrelated to this API-only change; verified
locally via `vitest run src/modules/analytics` — 119 tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 01:11:39 +07:00
parent 588f6e0c19
commit bf6a506719
8 changed files with 456 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ import { type QueryBus } from '@nestjs/cqrs';
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query';
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
import { ValuationExplanationQuery } from '../../application/queries/valuation-explanation/valuation-explanation.query';
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
import { AvmController } from '../controllers/avm.controller';
@@ -94,6 +95,31 @@ describe('AvmController', () => {
});
});
describe('GET /avm/explain', () => {
it('dispatches ValuationExplanationQuery with valuationId', async () => {
const expected = {
valuationId: 'val-1',
propertyId: 'prop-1',
modelVersion: 'avm-v2.0',
confidence: 0.85,
confidenceExplanation: 'Mức độ tin cậy cao (85%).',
estimatedPrice: '5000000000',
pricePerM2: 75000000,
topDrivers: [{ feature: 'location', importance: 0.45 }],
comparables: [],
valuedAt: '2026-04-15T10:00:00.000Z',
};
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.explain({ valuationId: 'val-1' } as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationExplanationQuery('val-1'),
);
expect(result).toBe(expected);
});
});
describe('POST /avm/industrial', () => {
const industrialDto = {
province: 'Bình Dương',

View File

@@ -18,9 +18,12 @@ import { type IndustrialValuationDto as IndustrialValuationResultDto } from '../
import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query';
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
import { type ValuationExplanationDto as ValuationExplanationResultDto } from '../../application/queries/valuation-explanation/valuation-explanation.handler';
import { ValuationExplanationQuery } from '../../application/queries/valuation-explanation/valuation-explanation.query';
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
import { AvmCompareQueryDto } from '../dto/avm-compare-query.dto';
import { AvmExplainQueryDto } from '../dto/avm-explain-query.dto';
import { BatchValuationDto } from '../dto/batch-valuation.dto';
import { IndustrialValuationDto } from '../dto/industrial-valuation.dto';
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
@@ -104,6 +107,27 @@ export class AvmController {
);
}
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('explain')
@ApiOperation({ summary: 'Explain a stored valuation — top drivers, comparables, confidence' })
@ApiQuery({
name: 'valuationId',
description: 'Stored valuation ID to explain',
example: 'val-abc123',
type: String,
})
@ApiResponse({ status: 200, description: 'Valuation explanation with drivers and comparables' })
@ApiResponse({ status: 400, description: 'Invalid parameters' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
@ApiResponse({ status: 404, description: 'Valuation not found' })
async explain(@Query() dto: AvmExplainQueryDto): Promise<ValuationExplanationResultDto> {
return this.queryBus.execute(
new ValuationExplanationQuery(dto.valuationId),
);
}
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';
export class AvmExplainQueryDto {
@ApiProperty({
description: 'ID of the stored valuation to explain',
example: 'val-abc123',
})
@IsString()
@IsNotEmpty()
valuationId!: string;
}