Files
goodgo-platform/apps/web/lib/analytics-api.ts
Ho Ngoc Hai 631e1200a1
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
feat(listings): AI advisor on listing detail — valuation + qualitative advice
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>
2026-04-19 16:20:24 +07:00

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`),
};