Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 5s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 38s
Deploy / Build API Image (push) Failing after 23s
Deploy / Build Web Image (push) Failing after 11s
Deploy / Build AI Services Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 9s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 30s
Security Scanning / Trivy Scan — Web Image (push) Failing after 25s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 20s
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Security Scanning / Trivy Filesystem Scan (push) Failing after 29s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 2s
Deploy / Rollback Production (push) Has been skipped
New endpoint POST /analytics/listings/:id/ai-advice (JwtAuthGuard).
Orchestrates a single-listing AI analysis in Vietnamese via Anthropic
Claude, using the key/URL/model configured in admin settings.
Backend
-------
- New CQRS: get-listing-ai-advice/{query,handler}.ts under analytics.
Injects LISTING_REPOSITORY, QueryBus (for nearby POIs + neighborhood
score), SystemSettingsService (from @modules/admin), LoggerService.
- Controller @Post('listings/:id/ai-advice') in analytics.controller.ts.
- analytics.module.ts now imports ListingsModule + AdminModule.
- Anthropic call: native fetch to ${apiUrl}/messages with
x-api-key + anthropic-version: 2023-06-01 +
anthropic-beta: prompt-caching-2024-07-31. System block marked
cache_control:{type:'ephemeral'} for cheap subsequent cache hits.
30s AbortController timeout.
- Response validation without adding zod to the API workspace —
lightweight isRecord/asInt/asString/asStringArray helpers.
Strips ```json fences before JSON.parse.
- Error handling:
* 503 AI_NOT_CONFIGURED when the admin hasn't saved an API key.
* 502 AI_PROVIDER_ERROR on non-2xx, parse failure, or timeout.
* Key never logged.
* POI / score fetch failures are soft — prompt is built without
them and the model still runs.
- New error codes AI_NOT_CONFIGURED / AI_PROVIDER_ERROR in
shared/domain/error-codes.ts.
Response shape (returned unchanged to the client):
```
{
valuation: { estimateVND, lowVND, highVND, confidence, rationale },
advice: { summary, pros[], cons[], suitableFor[] },
model, cacheHit
}
```
Frontend
--------
- analytics-api.ts: exports AiConfidence, ListingAiValuation,
ListingAiAdviceBody, ListingAiAdvice + getListingAiAdvice(id).
- New components/listings/ai-advice-cards.tsx.
* Default state: outline <Button><Sparkles/> Xem phân tích AI</Button>
* On click: useMutation fires + skeleton with Sparkles spinner.
* On success: two sidebar cards:
- "AI định giá" — big mid VND, low–high range, Low/Medium/High
confidence badge, rationale with line-clamp-3.
- "AI nhận định" — 2-sentence summary + two-column Pros/Cons
(Check / AlertTriangle icons) + "AI gợi ý" chips for extra
personas, plus a "Làm mới" link that re-triggers the mutation.
* 503 → amber banner. ADMIN users see a link to /admin/settings/ai.
* Other errors → red banner with retry.
- listing-detail-client.tsx mounts <AiAdviceCards listingId=... /> in
the sidebar between the social-share card and the stats block.
Existing <AiEstimateButton> kept untouched next to it.
Constraints preserved
---------------------
- No new npm packages; no @anthropic-ai/sdk.
- Runtime imports for NestJS DI classes.
- API key read at request time only — nothing persists it outside
SystemSetting.
Verification
------------
- API typecheck clean; 1975 / 1975 tests pass.
- Web typecheck clean in touched files; 624 / 624 tests pass.
- AiAdviceCards spec-mocked in listing-detail-client.spec so
QueryClientProvider isn't required.
User can now set their Anthropic key via /admin/settings/ai and click
"Xem phân tích AI" on any listing detail to get valuation + advice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
3.6 KiB
TypeScript
154 lines
3.6 KiB
TypeScript
import { apiClient } from './api-client';
|
|
|
|
export interface MarketReportDistrict {
|
|
district: string;
|
|
city: string;
|
|
propertyType: string;
|
|
period: string;
|
|
medianPrice: string;
|
|
avgPriceM2: number;
|
|
totalListings: number;
|
|
daysOnMarket: number;
|
|
inventoryLevel: number;
|
|
absorptionRate: number | null;
|
|
yoyChange: number | null;
|
|
}
|
|
|
|
export interface MarketReportResponse {
|
|
city: string;
|
|
period: string;
|
|
districts: MarketReportDistrict[];
|
|
}
|
|
|
|
export interface HeatmapDataPoint {
|
|
district: string;
|
|
city: string;
|
|
avgPriceM2: number;
|
|
totalListings: number;
|
|
medianPrice: string;
|
|
}
|
|
|
|
export interface HeatmapResponse {
|
|
city: string;
|
|
period: string;
|
|
dataPoints: HeatmapDataPoint[];
|
|
}
|
|
|
|
export interface PriceTrendPoint {
|
|
period: string;
|
|
medianPrice: string;
|
|
avgPriceM2: number;
|
|
totalListings: number;
|
|
}
|
|
|
|
export interface PriceTrendResponse {
|
|
district: string;
|
|
city: string;
|
|
propertyType: string;
|
|
trend: PriceTrendPoint[];
|
|
}
|
|
|
|
export interface DistrictStats {
|
|
district: string;
|
|
city: string;
|
|
propertyType: string;
|
|
medianPrice: string;
|
|
avgPriceM2: number;
|
|
totalListings: number;
|
|
daysOnMarket: number;
|
|
inventoryLevel: number;
|
|
absorptionRate: number | null;
|
|
yoyChange: number | null;
|
|
}
|
|
|
|
export interface DistrictStatsResponse {
|
|
city: string;
|
|
period: string;
|
|
districts: DistrictStats[];
|
|
}
|
|
|
|
export type NearbyPOICategory =
|
|
| 'school'
|
|
| 'hospital'
|
|
| 'transit'
|
|
| 'shopping'
|
|
| 'restaurant'
|
|
| 'park';
|
|
|
|
export interface NearbyPOI {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
category: NearbyPOICategory;
|
|
lat: number;
|
|
lng: number;
|
|
distance: number;
|
|
address: string | null;
|
|
}
|
|
|
|
export interface NearbyPOIsResponse {
|
|
pois: NearbyPOI[];
|
|
center: { lat: number; lng: number };
|
|
}
|
|
|
|
export type AiConfidence = 'low' | 'medium' | 'high';
|
|
|
|
export interface ListingAiValuation {
|
|
estimateVND: number;
|
|
lowVND: number;
|
|
highVND: number;
|
|
confidence: AiConfidence;
|
|
rationale: string;
|
|
}
|
|
|
|
export interface ListingAiAdviceBody {
|
|
summary: string;
|
|
pros: string[];
|
|
cons: string[];
|
|
suitableFor: string[];
|
|
}
|
|
|
|
export interface ListingAiAdvice {
|
|
valuation: ListingAiValuation;
|
|
advice: ListingAiAdviceBody;
|
|
model: string;
|
|
cacheHit: boolean;
|
|
cacheUsage?: {
|
|
input: number;
|
|
cacheCreation: number;
|
|
cacheRead: number;
|
|
output: number;
|
|
};
|
|
}
|
|
|
|
export const analyticsApi = {
|
|
getMarketReport: (city: string, period: string, propertyType?: string) => {
|
|
const params = new URLSearchParams({ city, period });
|
|
if (propertyType) params.set('propertyType', propertyType);
|
|
return apiClient.get<MarketReportResponse>(`/analytics/market-report?${params}`);
|
|
},
|
|
|
|
getHeatmap: (city: string, period: string) => {
|
|
const params = new URLSearchParams({ city, period });
|
|
return apiClient.get<HeatmapResponse>(`/analytics/heatmap?${params}`);
|
|
},
|
|
|
|
getPriceTrend: (district: string, city: string, propertyType: string, periods: string[]) => {
|
|
const params = new URLSearchParams({ district, city, propertyType, periods: periods.join(',') });
|
|
return apiClient.get<PriceTrendResponse>(`/analytics/price-trend?${params}`);
|
|
},
|
|
|
|
getDistrictStats: (city: string, period: string) => {
|
|
const params = new URLSearchParams({ city, period });
|
|
return apiClient.get<DistrictStatsResponse>(`/analytics/district-stats?${params}`);
|
|
},
|
|
|
|
getNearbyPOIs: (lat: number, lng: number, radius = 2000, limit = 30) =>
|
|
apiClient.get<NearbyPOIsResponse>(
|
|
`/analytics/pois/nearby?lat=${lat}&lng=${lng}&radius=${radius}&limit=${limit}`,
|
|
),
|
|
|
|
getListingAiAdvice: (listingId: string) =>
|
|
apiClient.post<ListingAiAdvice>(`/analytics/listings/${listingId}/ai-advice`),
|
|
};
|