diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 2a23ec5..2142920 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { AdminModule } from '@modules/admin'; import { ListingsModule } from '@modules/listings'; +import { ProjectsModule } from '@modules/projects'; import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler'; import { TrackEventHandler } from './application/commands/track-event/track-event.handler'; import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler'; @@ -12,6 +13,7 @@ import { GetDistrictStatsHandler } from './application/queries/get-district-stat import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler'; import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler'; import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler'; +import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler'; import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler'; import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler'; import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler'; @@ -58,6 +60,7 @@ const QueryHandlers = [ GetNearbyPOIsHandler, IndustrialValuationHandler, GetListingAiAdviceHandler, + GetProjectAiAdviceHandler, ]; const EventHandlers = [ @@ -65,7 +68,7 @@ const EventHandlers = [ ]; @Module({ - imports: [CqrsModule, ListingsModule, AdminModule], + imports: [CqrsModule, ListingsModule, AdminModule, ProjectsModule], controllers: [AnalyticsController, AvmController], providers: [ // AI service client diff --git a/apps/api/src/modules/analytics/application/queries/_shared/ai-json-client.ts b/apps/api/src/modules/analytics/application/queries/_shared/ai-json-client.ts new file mode 100644 index 0000000..fd4fd3b --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/_shared/ai-json-client.ts @@ -0,0 +1,208 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException, ErrorCode, LoggerService } from '@modules/shared'; + +/** + * Shared transport + JSON-parsing helpers for Anthropic-compatible AI endpoints. + * Used by both `get-listing-ai-advice` and `get-project-ai-advice`. + * + * Sends BOTH `x-api-key` (native Anthropic) and `Authorization: Bearer` (proxy + * gateways like chat.trollllm.xyz). Anthropic ignores unknown headers so the + * duplicate is harmless. + */ + +const ANTHROPIC_TIMEOUT_MS = 30_000; + +export interface AnthropicUsage { + input: number; + cacheCreation: number; + cacheRead: number; + output: number; +} + +interface AnthropicMessagePart { + type: string; + text?: string; +} + +interface AnthropicRawResponse { + content?: AnthropicMessagePart[]; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; +} + +export interface CallAnthropicArgs { + apiUrl: string; + apiKey: string; + model: string; + systemPrompt: string; + userPrompt: string; + /** Optional max_tokens, default 1024. */ + maxTokens?: number; + /** Logger for non-2xx responses and timeouts. */ + logger: LoggerService; + /** Used in log tags, e.g. "ai-advice" or "project-ai-advice". */ + tag?: string; +} + +export interface CallAnthropicResult { + /** The decoded first text content block, with any ```json fence stripped. */ + text: string; + usage: AnthropicUsage; +} + +export async function callAnthropicJson( + args: CallAnthropicArgs, +): Promise { + const tag = args.tag ?? 'ai-json'; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ANTHROPIC_TIMEOUT_MS); + + try { + const res = await fetch(`${args.apiUrl.replace(/\/$/, '')}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Dual auth: native Anthropic uses `x-api-key`; OpenAI-style proxies + // use `Authorization: Bearer`. Sending both is safe. + 'x-api-key': args.apiKey, + Authorization: `Bearer ${args.apiKey}`, + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'prompt-caching-2024-07-31', + }, + signal: controller.signal, + body: JSON.stringify({ + model: args.model, + max_tokens: args.maxTokens ?? 1024, + system: [ + { + type: 'text', + text: args.systemPrompt, + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [{ role: 'user', content: args.userPrompt }], + }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + args.logger.error( + `[${tag}] Anthropic non-2xx ${res.status}: ${body.slice(0, 500)}`, + ); + throw new DomainException( + ErrorCode.AI_PROVIDER_ERROR, + 'Không gọi được dịch vụ AI. Vui lòng thử lại sau.', + HttpStatus.BAD_GATEWAY, + ); + } + + const raw = (await res.json()) as AnthropicRawResponse; + const block = raw.content?.find((c) => c.type === 'text'); + const text = block?.text?.trim(); + if (!text) { + throw new DomainException( + ErrorCode.AI_PROVIDER_ERROR, + 'AI trả về nội dung trống.', + HttpStatus.BAD_GATEWAY, + ); + } + + return { + text: stripJsonFence(text), + usage: { + input: raw.usage?.input_tokens ?? 0, + cacheCreation: raw.usage?.cache_creation_input_tokens ?? 0, + cacheRead: raw.usage?.cache_read_input_tokens ?? 0, + output: raw.usage?.output_tokens ?? 0, + }, + }; + } catch (err) { + if (err instanceof DomainException) throw err; + const isAbort = + err instanceof Error && (err.name === 'AbortError' || /aborted/i.test(err.message)); + args.logger.error( + `[${tag}] fetch failed (${isAbort ? 'timeout' : 'network'}): ${err instanceof Error ? err.message : err}`, + ); + throw new DomainException( + ErrorCode.AI_PROVIDER_ERROR, + isAbort + ? 'AI không phản hồi kịp. Vui lòng thử lại.' + : 'Không gọi được dịch vụ AI. Vui lòng thử lại sau.', + HttpStatus.BAD_GATEWAY, + ); + } finally { + clearTimeout(timer); + } +} + +export function parseJsonObject(text: string): Record { + let obj: unknown; + try { + obj = JSON.parse(text); + } catch { + throw new DomainException( + ErrorCode.AI_PROVIDER_ERROR, + 'AI trả về JSON không hợp lệ.', + HttpStatus.BAD_GATEWAY, + ); + } + if (!isRecord(obj)) { + throw new DomainException( + ErrorCode.AI_PROVIDER_ERROR, + 'AI trả về sai cấu trúc JSON.', + HttpStatus.BAD_GATEWAY, + ); + } + return obj; +} + +export function jsonShapeError(field?: string): DomainException { + return new DomainException( + ErrorCode.AI_PROVIDER_ERROR, + field + ? `AI trả về sai cấu trúc trường '${field}'.` + : 'AI trả về sai cấu trúc JSON.', + HttpStatus.BAD_GATEWAY, + ); +} + +// -------------------------------------------------------------------------- +// Primitive coercers — shared between listing + project handlers. +// -------------------------------------------------------------------------- + +export function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v != null && !Array.isArray(v); +} + +export function asInt(v: unknown): number | null { + if (typeof v === 'number' && Number.isFinite(v)) return Math.round(v); + if (typeof v === 'string') { + const n = Number(v.replace(/[^\d.-]/g, '')); + if (Number.isFinite(n)) return Math.round(n); + } + return null; +} + +export function asString(v: unknown): string | null { + if (typeof v === 'string' && v.trim().length > 0) return v.trim(); + return null; +} + +export function asStringArray(v: unknown): string[] | null { + if (!Array.isArray(v)) return null; + const out: string[] = []; + for (const item of v) { + if (typeof item === 'string' && item.trim().length > 0) out.push(item.trim()); + } + return out; +} + +function stripJsonFence(text: string): string { + const fenceMatch = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); + if (fenceMatch && fenceMatch[1]) return fenceMatch[1].trim(); + return text; +} diff --git a/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts b/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts index 18c2ad0..24967d0 100644 --- a/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts @@ -14,6 +14,15 @@ import { import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query'; import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service'; import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query'; +import { + asInt, + asString, + asStringArray, + callAnthropicJson, + isRecord, + jsonShapeError, + parseJsonObject, +} from '../_shared/ai-json-client'; import { GetListingAiAdviceQuery } from './get-listing-ai-advice.query'; /** Shape returned by Anthropic (parsed from first content block). */ @@ -74,23 +83,6 @@ Bắt buộc trả về **JSON thuần** đúng schema, KHÔNG bọc markdown, K Đưa ra estimateVND nằm giữa lowVND và highVND. Dải low/high nên phản ánh độ bất định: confidence=high dải ~±8%, medium ~±15%, low ~±25%.`; -interface AnthropicMessage { - type: string; - text?: string; -} - -interface AnthropicResponse { - content?: AnthropicMessage[]; - usage?: { - input_tokens?: number; - output_tokens?: number; - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; - }; -} - -const ANTHROPIC_TIMEOUT_MS = 30_000; - @QueryHandler(GetListingAiAdviceQuery) export class GetListingAiAdviceHandler implements IQueryHandler @@ -132,29 +124,26 @@ export class GetListingAiAdviceHandler const userPrompt = buildUserPrompt(listing, poisResult, score); - const raw = await this.callAnthropic({ + const { text, usage } = await callAnthropicJson({ apiUrl: settings.apiUrl, apiKey: settings.apiKey, model: settings.model, + systemPrompt: SYSTEM_PROMPT, userPrompt, + logger: this.logger, + tag: 'ai-advice', }); - const parsed = parseAndValidate(raw); - - const cacheRead = raw.usage?.cache_read_input_tokens ?? 0; - const cacheCreation = raw.usage?.cache_creation_input_tokens ?? 0; + const obj = parseJsonObject(text); + const valuation = parseValuation(obj['valuation']); + const advice = parseAdvice(obj['advice']); return { - valuation: parsed.valuation, - advice: parsed.advice, + valuation, + advice, model: settings.model, - cacheHit: cacheRead > 0, - cacheUsage: { - input: raw.usage?.input_tokens ?? 0, - cacheCreation, - cacheRead, - output: raw.usage?.output_tokens ?? 0, - }, + cacheHit: usage.cacheRead > 0, + cacheUsage: usage, }; } @@ -194,74 +183,6 @@ export class GetListingAiAdviceHandler } } - private async callAnthropic(args: { - apiUrl: string; - apiKey: string; - model: string; - userPrompt: string; - }): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), ANTHROPIC_TIMEOUT_MS); - try { - const res = await fetch(`${args.apiUrl.replace(/\/$/, '')}/messages`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - // Support both native Anthropic (x-api-key) and proxy-style gateways - // that expect Bearer auth (e.g. chat.trollllm.xyz). Anthropic ignores - // unknown headers, so sending both is safe. - 'x-api-key': args.apiKey, - Authorization: `Bearer ${args.apiKey}`, - 'anthropic-version': '2023-06-01', - 'anthropic-beta': 'prompt-caching-2024-07-31', - }, - signal: controller.signal, - body: JSON.stringify({ - model: args.model, - max_tokens: 1024, - system: [ - { - type: 'text', - text: SYSTEM_PROMPT, - cache_control: { type: 'ephemeral' }, - }, - ], - messages: [{ role: 'user', content: args.userPrompt }], - }), - }); - - if (!res.ok) { - const bodySnippet = await res.text().catch(() => ''); - this.logger.error( - `[ai-advice] Anthropic non-2xx ${res.status}: ${bodySnippet.slice(0, 500)}`, - ); - throw new DomainException( - ErrorCode.AI_PROVIDER_ERROR, - 'Không gọi được dịch vụ AI. Vui lòng thử lại sau.', - HttpStatus.BAD_GATEWAY, - ); - } - - return (await res.json()) as AnthropicResponse; - } catch (err) { - if (err instanceof DomainException) throw err; - const isAbort = - err instanceof Error && - (err.name === 'AbortError' || /aborted/i.test(err.message)); - this.logger.error( - `[ai-advice] fetch failed (${isAbort ? 'timeout' : 'network'}): ${err instanceof Error ? err.message : err}`, - ); - throw new DomainException( - ErrorCode.AI_PROVIDER_ERROR, - isAbort - ? 'AI không phản hồi kịp. Vui lòng thử lại.' - : 'Không gọi được dịch vụ AI. Vui lòng thử lại sau.', - HttpStatus.BAD_GATEWAY, - ); - } finally { - clearTimeout(timer); - } - } } // -------------------------------------------------------------------------- @@ -345,41 +266,9 @@ function countByCategory(pois: NearbyPOIDto[]): Record { } // -------------------------------------------------------------------------- -// Response parsing / validation (lightweight — no new deps) +// Response validation (parsers specific to the listing response shape) // -------------------------------------------------------------------------- -function parseAndValidate(raw: AnthropicResponse): { - valuation: ListingAiValuation; - advice: ListingAiAdvice; -} { - const block = raw.content?.find((c) => c.type === 'text'); - const text = block?.text?.trim(); - if (!text) { - throw new DomainException( - ErrorCode.AI_PROVIDER_ERROR, - 'AI trả về nội dung trống.', - HttpStatus.BAD_GATEWAY, - ); - } - - const jsonText = stripJsonFence(text); - let obj: unknown; - try { - obj = JSON.parse(jsonText); - } catch { - throw new DomainException( - ErrorCode.AI_PROVIDER_ERROR, - 'AI trả về JSON không hợp lệ.', - HttpStatus.BAD_GATEWAY, - ); - } - - if (!isRecord(obj)) throw jsonShapeError(); - const valuation = parseValuation(obj['valuation']); - const advice = parseAdvice(obj['advice']); - return { valuation, advice }; -} - function parseValuation(v: unknown): ListingAiValuation { if (!isRecord(v)) throw jsonShapeError('valuation'); const estimateVND = asInt(v['estimateVND']); @@ -413,46 +302,3 @@ function parseAdvice(v: unknown): ListingAiAdvice { } return { summary, pros, cons, suitableFor }; } - -function stripJsonFence(text: string): string { - const fenceMatch = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); - if (fenceMatch && fenceMatch[1]) return fenceMatch[1].trim(); - return text; -} - -function isRecord(v: unknown): v is Record { - return typeof v === 'object' && v != null && !Array.isArray(v); -} - -function asInt(v: unknown): number | null { - if (typeof v === 'number' && Number.isFinite(v)) return Math.round(v); - if (typeof v === 'string') { - const n = Number(v.replace(/[^\d.-]/g, '')); - if (Number.isFinite(n)) return Math.round(n); - } - return null; -} - -function asString(v: unknown): string | null { - if (typeof v === 'string' && v.trim().length > 0) return v.trim(); - return null; -} - -function asStringArray(v: unknown): string[] | null { - if (!Array.isArray(v)) return null; - const out: string[] = []; - for (const item of v) { - if (typeof item === 'string' && item.trim().length > 0) out.push(item.trim()); - } - return out; -} - -function jsonShapeError(field?: string): DomainException { - return new DomainException( - ErrorCode.AI_PROVIDER_ERROR, - field - ? `AI trả về sai cấu trúc trường '${field}'.` - : 'AI trả về sai cấu trúc JSON.', - HttpStatus.BAD_GATEWAY, - ); -} diff --git a/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.handler.ts b/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.handler.ts new file mode 100644 index 0000000..0ac8260 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.handler.ts @@ -0,0 +1,318 @@ +import { HttpStatus, Inject } from '@nestjs/common'; +import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, ErrorCode, LoggerService } from '@modules/shared'; +import { SystemSettingsService } from '@modules/admin'; +import { + PROJECT_REPOSITORY, + type IProjectRepository, + type ProjectDetailData, +} from '@modules/projects'; +import { type AnthropicUsage } from '../_shared/ai-json-client'; +import { + asString, + asStringArray, + callAnthropicJson, + isRecord, + jsonShapeError, + parseJsonObject, +} from '../_shared/ai-json-client'; +import { + type NearbyPOIDto, + type NearbyPOIsResultDto, +} from '../get-nearby-pois/get-nearby-pois.handler'; +import { GetNearbyPOIsQuery } from '../get-nearby-pois/get-nearby-pois.query'; +import { type NeighborhoodScoreResult } from '../../../domain/services/neighborhood-score.service'; +import { GetNeighborhoodScoreQuery } from '../get-neighborhood-score/get-neighborhood-score.query'; +import { GetProjectAiAdviceQuery } from './get-project-ai-advice.query'; + +/** + * AI advisor for a Residential Project. Mirrors `get-listing-ai-advice` but + * keys off project signals (developer reputation, amenity mix, status, + * completion timing, price range) and omits the `valuation` block — a project + * is a price range, not a single unit. + */ + +export interface ProjectAiAdvice { + summary: string; + pros: string[]; + cons: string[]; + suitableFor: string[]; +} + +export interface ProjectAiAdviceResponse { + advice: ProjectAiAdvice; + model: string; + cacheHit: boolean; + cacheUsage?: AnthropicUsage; +} + +const SYSTEM_PROMPT = `Bạn là chuyên gia phân tích dự án bất động sản Việt Nam (HCM & Hà Nội). Dựa trên dữ liệu về dự án, chủ đầu tư, tiện ích và khu vực, hãy đưa ra nhận định ngắn gọn về dự án. + +Bối cảnh thị trường Việt Nam: +- Dự án sơ cấp (primary market) thường mở bán theo giai đoạn, giá tăng dần theo tiến độ. +- Uy tín chủ đầu tư quyết định chất lượng bàn giao & khả năng giữ giá; CĐT >=10 dự án thường ít rủi ro pháp lý. +- Tiện ích nội khu (hồ bơi, gym, công viên, trường, shophouse) tăng sức hấp dẫn cho gia đình và cho thuê. +- Dự án đang xây (UNDER_CONSTRUCTION) phù hợp nhà đầu tư dài hạn; đã hoàn thiện (COMPLETED) phù hợp ở ngay. +- Các rủi ro phổ biến: tiến độ bàn giao trễ, tranh chấp pháp lý, mật độ xây dựng cao, giá thứ cấp khó tăng. + +Bắt buộc trả về **JSON thuần** đúng schema, KHÔNG bọc markdown, KHÔNG giải thích thêm: +{ + "advice": { + "summary": "<≤ 2 câu tổng quan tiếng Việt>", + "pros": ["", ...], // 3-5 mục + "cons": ["", ...], // 2-4 mục + "suitableFor": ["", ...] // 2-4 mục, tiếng Việt + } +} + +Các nhãn persona tham khảo: "Gia đình có con nhỏ", "Gia đình trẻ", "Người đi làm xa", "Người trẻ/độc thân", "Yêu thiên nhiên", "Nhà đầu tư dài hạn", "Ưu tiên an ninh", "Người lớn tuổi". Không bắt buộc dùng đúng từ, có thể tuỳ biến cho phù hợp.`; + +@QueryHandler(GetProjectAiAdviceQuery) +export class GetProjectAiAdviceHandler + implements IQueryHandler +{ + constructor( + @Inject(PROJECT_REPOSITORY) + private readonly projectRepo: IProjectRepository, + private readonly queryBus: QueryBus, + private readonly systemSettings: SystemSettingsService, + private readonly logger: LoggerService, + ) {} + + async execute( + query: GetProjectAiAdviceQuery, + ): Promise { + const project = await this.projectRepo.findDetailById(query.projectId); + if (!project) { + throw new DomainException( + ErrorCode.PROJECT_NOT_FOUND, + `Không tìm thấy dự án ${query.projectId}`, + HttpStatus.NOT_FOUND, + ); + } + + const [poisResult, score] = await Promise.all([ + this.fetchPois(project), + this.fetchScore(project), + ]); + + const settings = await this.systemSettings.getAiSettings(); + if (!settings.apiKey) { + throw new DomainException( + ErrorCode.AI_NOT_CONFIGURED, + 'Quản trị viên chưa cấu hình Claude API. Vào /admin/settings/ai để thiết lập.', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + const userPrompt = buildUserPrompt(project, poisResult, score); + + const { text, usage } = await callAnthropicJson({ + apiUrl: settings.apiUrl, + apiKey: settings.apiKey, + model: settings.model, + systemPrompt: SYSTEM_PROMPT, + userPrompt, + logger: this.logger, + tag: 'project-ai-advice', + }); + + const obj = parseJsonObject(text); + const advice = parseAdvice(obj['advice']); + + return { + advice, + model: settings.model, + cacheHit: usage.cacheRead > 0, + cacheUsage: usage, + }; + } + + private async fetchPois( + project: ProjectDetailData, + ): Promise { + const { latitude, longitude } = project; + if (latitude == null || longitude == null) return null; + try { + return await this.queryBus.execute< + GetNearbyPOIsQuery, + NearbyPOIsResultDto + >(new GetNearbyPOIsQuery(latitude, longitude, 2000, 30)); + } catch (err) { + this.logger.warn( + `[project-ai-advice] fetchPois failed: ${err instanceof Error ? err.message : err}`, + ); + return null; + } + } + + private async fetchScore( + project: ProjectDetailData, + ): Promise { + const { district, city } = project; + if (!district || !city) return null; + try { + return await this.queryBus.execute< + GetNeighborhoodScoreQuery, + NeighborhoodScoreResult | null + >(new GetNeighborhoodScoreQuery(district, city)); + } catch (err) { + this.logger.warn( + `[project-ai-advice] fetchScore failed: ${err instanceof Error ? err.message : err}`, + ); + return null; + } + } +} + +// -------------------------------------------------------------------------- +// Prompt construction +// -------------------------------------------------------------------------- + +function buildUserPrompt( + project: ProjectDetailData, + poisResult: NearbyPOIsResultDto | null, + score: NeighborhoodScoreResult | null, +): string { + const lines: string[] = []; + + lines.push('### THÔNG TIN DỰ ÁN'); + lines.push(`- Tên dự án: ${project.name}`); + lines.push(`- Chủ đầu tư: ${project.developer}`); + lines.push(`- Trạng thái: ${project.status}`); + lines.push(`- Địa chỉ: ${project.address}, ${project.ward ?? ''}, ${project.district}, ${project.city}`); + if (project.totalUnits) lines.push(`- Tổng số căn: ${project.totalUnits}`); + if (project.completedUnits != null) + lines.push(`- Đã hoàn thành: ${project.completedUnits} căn`); + if (project.totalArea != null) lines.push(`- Tổng diện tích: ${project.totalArea} m²`); + if (project.buildingCount != null) lines.push(`- Số block: ${project.buildingCount}`); + if (project.floorCount != null) lines.push(`- Số tầng: ${project.floorCount}`); + if (project.startDate) { + lines.push(`- Khởi công: ${project.startDate.toISOString().slice(0, 10)}`); + } + if (project.completionDate) { + lines.push(`- Dự kiến bàn giao: ${project.completionDate.toISOString().slice(0, 10)}`); + } + if (project.minPrice != null && project.maxPrice != null) { + lines.push( + `- Dải giá: ${project.minPrice.toString()} – ${project.maxPrice.toString()} VND`, + ); + } + if (project.tags && project.tags.length > 0) { + lines.push(`- Tags: ${project.tags.join(', ')}`); + } + if (project.isVerified) lines.push('- Đã xác minh pháp lý: Có'); + + if (project.description) { + const snippet = project.description.slice(0, 600); + lines.push(''); + lines.push('### MÔ TẢ DỰ ÁN (rút gọn)'); + lines.push(snippet); + } + + if (project.amenities && isRecord(project.amenities)) { + const amenityList = flattenAmenities(project.amenities).slice(0, 20); + if (amenityList.length > 0) { + lines.push(''); + lines.push('### TIỆN ÍCH NỘI KHU'); + lines.push(`- ${amenityList.join(', ')}`); + } + } + + if (project.unitTypes && isRecord(project.unitTypes)) { + const types = Object.keys(project.unitTypes); + if (types.length > 0) { + lines.push(''); + lines.push('### LOẠI HÌNH CĂN HỘ'); + lines.push(`- ${types.join(', ')}`); + } + } + + if (project.suitableFor && project.suitableFor.length > 0) { + lines.push(''); + lines.push(`### CHỦ ĐẦU TƯ GỢI Ý PHÙ HỢP VỚI: ${project.suitableFor.join(', ')}`); + } + if (project.whyThisLocation) { + lines.push(''); + lines.push(`### GHI CHÚ CĐT VỀ VỊ TRÍ: ${project.whyThisLocation}`); + } + + if (poisResult && poisResult.pois.length > 0) { + const counts = countByCategory(poisResult.pois); + const closest = poisResult.pois.slice(0, 5); + lines.push(''); + lines.push('### ĐIỂM QUAN TÂM LÂN CẬN (bán kính 2km)'); + const countLine = Object.entries(counts) + .map(([cat, n]) => `${cat}: ${n}`) + .join(', '); + lines.push(`- Số lượng theo nhóm: ${countLine}`); + lines.push('- 5 điểm gần nhất:'); + for (const poi of closest) { + lines.push(` • ${poi.name} (${poi.category}) — ${Math.round(poi.distance)} m`); + } + } + + if (score) { + lines.push(''); + lines.push('### ĐIỂM KHU VỰC (thang 0–10)'); + lines.push(`- Giáo dục: ${score.educationScore.toFixed(1)}`); + lines.push(`- Y tế: ${score.healthcareScore.toFixed(1)}`); + lines.push(`- Giao thông: ${score.transportScore.toFixed(1)}`); + lines.push(`- Mua sắm: ${score.shoppingScore.toFixed(1)}`); + lines.push(`- Môi trường: ${score.greeneryScore.toFixed(1)}`); + lines.push(`- An ninh: ${score.safetyScore.toFixed(1)}`); + lines.push(`- Tổng: ${score.totalScore.toFixed(1)}`); + } + + lines.push(''); + lines.push('Hãy trả về JSON đúng schema đã quy định.'); + return lines.join('\n'); +} + +function countByCategory(pois: NearbyPOIDto[]): Record { + const out: Record = {}; + for (const p of pois) { + out[p.category] = (out[p.category] ?? 0) + 1; + } + return out; +} + +/** + * `amenities` in the DB is a free-form JSONB. Accept both a flat map of + * {category: string[]} and an array of {name, category} objects; flatten to + * a list of human-readable strings so the prompt stays compact. + */ +function flattenAmenities(obj: Record): string[] { + const out: string[] = []; + for (const [key, val] of Object.entries(obj)) { + if (Array.isArray(val)) { + for (const item of val) { + if (typeof item === 'string') { + out.push(item); + } else if (isRecord(item)) { + const name = typeof item['name'] === 'string' ? item['name'] : null; + if (name) out.push(name); + } + } + } else if (typeof val === 'string') { + out.push(`${key}: ${val}`); + } + } + return out; +} + +// -------------------------------------------------------------------------- +// Response parsing +// -------------------------------------------------------------------------- + +function parseAdvice(v: unknown): ProjectAiAdvice { + if (!isRecord(v)) throw jsonShapeError('advice'); + const summary = asString(v['summary']); + const pros = asStringArray(v['pros']); + const cons = asStringArray(v['cons']); + const suitableFor = asStringArray(v['suitableFor']); + if (summary == null || !pros || !cons || !suitableFor) { + throw jsonShapeError('advice'); + } + return { summary, pros, cons, suitableFor }; +} diff --git a/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.query.ts b/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.query.ts new file mode 100644 index 0000000..f9b8bb8 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.query.ts @@ -0,0 +1,3 @@ +export class GetProjectAiAdviceQuery { + constructor(public readonly projectId: string) {} +} diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts index ec6f4ff..66ced76 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -22,6 +22,10 @@ import { type ListingAiAdviceResponse, } from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.handler'; import { GetListingAiAdviceQuery } from '../../application/queries/get-listing-ai-advice/get-listing-ai-advice.query'; +import { + type ProjectAiAdviceResponse, +} from '../../application/queries/get-project-ai-advice/get-project-ai-advice.handler'; +import { GetProjectAiAdviceQuery } from '../../application/queries/get-project-ai-advice/get-project-ai-advice.query'; import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler'; import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query'; import { type NearbyPOIsResultDto } from '../../application/queries/get-nearby-pois/get-nearby-pois.handler'; @@ -283,4 +287,24 @@ export class AnalyticsController { ): Promise { return this.queryBus.execute(new GetListingAiAdviceQuery(id)); } + + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard) + @Post('projects/:id/ai-advice') + @ApiOperation({ + summary: 'AI nhận định dự án BĐS (Claude)', + description: + 'Gọi Claude API để trả về JSON gồm tóm tắt, điểm mạnh/yếu và nhóm khách phù hợp cho một dự án của chủ đầu tư. Không kèm ước tính giá (dự án là dải giá, không phải một căn cụ thể).', + }) + @ApiParam({ name: 'id', description: 'Project ID' }) + @ApiResponse({ status: 200, description: 'AI advice generated' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 404, description: 'Project not found' }) + @ApiResponse({ status: 502, description: 'AI provider error' }) + @ApiResponse({ status: 503, description: 'AI chưa được cấu hình' }) + async getProjectAiAdvice( + @Param('id') id: string, + ): Promise { + return this.queryBus.execute(new GetProjectAiAdviceQuery(id)); + } } diff --git a/apps/api/src/modules/shared/domain/error-codes.ts b/apps/api/src/modules/shared/domain/error-codes.ts index 77f9d86..3dd8547 100644 --- a/apps/api/src/modules/shared/domain/error-codes.ts +++ b/apps/api/src/modules/shared/domain/error-codes.ts @@ -38,6 +38,9 @@ export enum ErrorCode { // Property PROPERTY_NOT_FOUND = 'PROPERTY_NOT_FOUND', + // Project (residential development) + PROJECT_NOT_FOUND = 'PROJECT_NOT_FOUND', + // Media MEDIA_UPLOAD_FAILED = 'MEDIA_UPLOAD_FAILED', MEDIA_LIMIT_EXCEEDED = 'MEDIA_LIMIT_EXCEEDED', diff --git a/apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx b/apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx index 1bb7f88..bb8436c 100644 --- a/apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/projects/[id]/edit/page.tsx @@ -336,16 +336,26 @@ export default function EditProjectPage() { - {/* Vị trí */} + {/* Vị trí (hiện tại chỉ hiển thị; backend chưa hỗ trợ sửa toạ độ sau khi tạo) */} -
- {project.address} -
- {project.district}, {project.city} -
- - (Vị trí địa lý không thể chỉnh sửa sau khi tạo.) - +
+
+ {project.address} +
+ {project.district}, {project.city} + {typeof project.latitude === 'number' && typeof project.longitude === 'number' && ( + <> +
+ + ({project.latitude.toFixed(6)}, {project.longitude.toFixed(6)}) + + + )} +
+

+ Vị trí địa lý hiện chưa thể chỉnh sửa sau khi tạo. Nếu cần cập nhật, + vui lòng xoá dự án và tạo lại. +

diff --git a/apps/web/app/[locale]/(dashboard)/projects/new/page.tsx b/apps/web/app/[locale]/(dashboard)/projects/new/page.tsx index aa0af06..0d48265 100644 --- a/apps/web/app/[locale]/(dashboard)/projects/new/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/projects/new/page.tsx @@ -3,6 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowLeft } from 'lucide-react'; import Link from 'next/link'; +import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import * as React from 'react'; import { useForm } from 'react-hook-form'; @@ -15,6 +16,18 @@ import { Select } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { duAnApi, type CreateProjectPayload } from '@/lib/du-an-api'; +const LocationPicker = dynamic( + () => import('@/components/map/location-picker').then((m) => m.LocationPicker), + { + ssr: false, + loading: () => ( +
+ Đang tải bản đồ… +
+ ), + }, +); + const SLUG_REGEX = /^[a-z0-9-]+$/; const projectSchema = z.object({ @@ -126,6 +139,8 @@ export default function CreateProjectPage() { const { register, handleSubmit, + setValue, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(projectSchema), @@ -135,6 +150,36 @@ export default function CreateProjectPage() { }, }); + const latStr = watch('latitude'); + const lngStr = watch('longitude'); + const latNum = Number.parseFloat(latStr ?? ''); + const lngNum = Number.parseFloat(lngStr ?? ''); + + const handlePickLocation = React.useCallback( + ( + coords: { lat: number; lng: number }, + resolved?: { address?: string; ward?: string; district?: string; city?: string }, + ) => { + setValue('latitude', coords.lat.toFixed(6), { shouldValidate: true }); + setValue('longitude', coords.lng.toFixed(6), { shouldValidate: true }); + // Only autofill address fields when currently empty — don't clobber what + // the admin typed intentionally. + if (resolved?.address && !watch('address')) { + setValue('address', resolved.address, { shouldValidate: true }); + } + if (resolved?.ward && !watch('ward')) { + setValue('ward', resolved.ward, { shouldValidate: true }); + } + if (resolved?.district && !watch('district')) { + setValue('district', resolved.district, { shouldValidate: true }); + } + if (resolved?.city && !watch('city')) { + setValue('city', resolved.city, { shouldValidate: true }); + } + }, + [setValue, watch], + ); + const onSubmit = async (data: ProjectFormData) => { setIsSubmitting(true); setError(null); @@ -309,6 +354,19 @@ export default function CreateProjectPage() { {/* Vị trí */} +
+ +

+ Nhấp vào bản đồ hoặc kéo pin để xác định toạ độ. Ô địa chỉ / phường / + quận / thành phố bên dưới sẽ tự điền nếu đang trống. +

+ +
- {/* Persona fit — "Phù hợp với ai" (CĐT chọn) + AI placeholder */} - + {/* Persona fit — admin chips (CĐT chọn) merged with derived personas */} + - {/* "Vì sao nên chọn dự án này" narrative — admin authored */} - + {/* AI advisor card — on-demand Claude call for summary + pros/cons + personas */} +
+ ({ category: p.category })), + ).map((d) => d.label), + ]} + /> +
+ + {/* "Vì sao nên chọn dự án này" — admin narrative (preferred) or derived */} +
{/* Main content */} @@ -228,7 +294,9 @@ export function DuAnDetailClient({ project }: DuAnDetailClientProps) {
{activeTab === 'amenities' && } - {activeTab === 'location' && } + {activeTab === 'location' && ( + + )} {activeTab === 'price' && } {activeTab === 'listings' && } {activeTab === 'documents' && } @@ -410,18 +478,41 @@ const POI_TYPE_MAP: Record = { park: 'park', }; -function LocationTab({ project }: { project: ProjectDetail }) { - const mapPois = project.pois.map((poi) => ({ - id: poi.id, - name: poi.name, - category: (POI_TYPE_MAP[poi.type] || 'shopping') as POICategory, - lat: poi.latitude, - lng: poi.longitude, - distance: poi.distance, - })); - +function LocationTab({ + project, + liveScore, + livePois, +}: { + project: ProjectDetail; + liveScore: NeighborhoodScoreResult | null; + livePois: POIItem[] | null; +}) { const hasCoordinates = project.latitude != null && project.longitude != null; + // Prefer live POIs from the analytics endpoint; fall back to the admin-entered + // `project.pois` payload (useful before POIs are seeded for the district). + const mapPois: POIItem[] = + livePois && livePois.length > 0 + ? livePois + : project.pois.map((poi) => ({ + id: poi.id, + name: poi.name, + category: (POI_TYPE_MAP[poi.type] || 'shopping') as POICategory, + lat: poi.latitude, + lng: poi.longitude, + distance: poi.distance, + })); + + // Prefer live neighborhood score from the analytics endpoint; fall back to + // whatever the detail payload embedded (category-level scores only). + const scoreCategories = liveScore + ? mapScoreToCategories(liveScore) + : project.neighborhoodScores.map((s) => ({ + category: s.category, + label: s.label, + score: s.score, + })); + return (

@@ -429,45 +520,46 @@ function LocationTab({ project }: { project: ProjectDetail }) {

{/* Map */} - {hasCoordinates && ( - - )} - - {/* Neighborhood scores radar chart */} - {project.neighborhoodScores.length > 0 && ( -
-

Đánh giá khu vực

- ({ - category: s.category, - label: s.label, - score: s.score, - }))} - height={300} + {hasCoordinates ? ( + <> + +

+ {livePois + ? `Tìm thấy ${mapPois.length} điểm quan tâm trong bán kính 2 km` + : `Hiển thị ${mapPois.length} điểm đã được cập nhật thủ công`} +

+ + ) : ( +
+

Chưa có tọa độ cho dự án này

)} - {/* POI list fallback (when no map) */} - {!hasCoordinates && project.pois.length > 0 && ( + {/* Neighborhood score */} + {scoreCategories.length > 0 && (
-

Tiện ích lân cận

-
- {project.pois.slice(0, 10).map((poi) => ( -
- {poi.name} - - {poi.distance < 1000 - ? `${poi.distance}m` - : `${(poi.distance / 1000).toFixed(1)}km`} - -
- ))} +
+

Đánh giá khu vực

+ {liveScore && ( + 7 + ? 'success' + : liveScore.totalScore >= 5 + ? 'warning' + : 'destructive' + } + className="px-2.5 py-0.5 text-sm font-bold" + > + {liveScore.totalScore.toFixed(1)}/10 + + )}
+
)}
@@ -624,65 +716,121 @@ function ListingsTab({ project }: { project: ProjectDetail }) { ); } -function ProjectPersonaFitCard({ project }: { project: ProjectDetail }) { - const suitableFor = project.suitableFor ?? []; +function ProjectPersonaFitCard({ + project, + score, + pois, +}: { + project: ProjectDetail; + score: NeighborhoodScoreResult | null; + pois: POIItem[]; +}) { + const adminLabels = project.suitableFor ?? []; + const derived: ProjectPersona[] = React.useMemo( + () => + deriveProjectPersonas( + project, + score, + pois.map((p) => ({ category: p.category })), + ), + [project, score, pois], + ); - // Always render the card so the "AI nhận định" CTA is visible even without - // admin-authored chips. If we eventually have nothing to show at all (no - // chips and we drop the placeholder), bail out early. - if (suitableFor.length === 0) { - // Still render with the AI placeholder so users see the intent. - } + // Deduplicate: if an admin chip matches a derived persona label, keep the + // admin chip (it wins; CĐT knows their target audience best). + const adminSet = new Set(adminLabels.map((l) => l.toLowerCase())); + const derivedFiltered = derived.filter((p) => !adminSet.has(p.label.toLowerCase())); + + if (adminLabels.length === 0 && derivedFiltered.length === 0) return null; return ( Phù hợp với ai? - - {suitableFor.length > 0 ? ( -
- {suitableFor.map((label) => ( + +
+ {/* Admin-authored chips first, marked with badge. */} + {adminLabels.map((label) => ( +
+ {label} + + Chủ đầu tư chọn + +
+ ))} + {/* Derived personas — include short reason on hover via title. */} + {derivedFiltered.map((p) => { + const Icon = p.icon; + return (
- {label} - - Chủ đầu tư chọn - +
- ))} -
- ) : ( -

- Chủ đầu tư chưa chỉ định nhóm khách phù hợp. -

- )} - - {/* TODO: replace with a real project-AI advisor endpoint (see the - listings flow: apps/api/src/modules/analytics/application/queries/ - get-listing-ai-advice/). For now this is a disabled placeholder to - signal intent without shipping a half-built integration. */} -
- + ); + })}
+ + {/* Per-persona reasons — expanded list so users see the "vì sao". */} + {derivedFiltered.length > 0 && ( +
    + {derivedFiltered.map((p) => ( +
  • +
  • + ))} +
+ )}
); } -function ProjectWhyLocationCard({ project }: { project: ProjectDetail }) { - const narrative = project.whyThisLocation?.trim(); +function ProjectWhyLocationCard({ + project, + score, + pois, +}: { + project: ProjectDetail; + score: NeighborhoodScoreResult | null; + pois: POIItem[]; +}) { + const adminNarrative = project.whyThisLocation?.trim(); + const derivedNarrative = React.useMemo( + () => + composeWhyThisProject( + project, + score, + pois.map((p) => ({ category: p.category })), + ), + [project, score, pois], + ); + + // Prefer admin-authored narrative; fall back to derived. Bail out if neither. + const narrative = adminNarrative || derivedNarrative; if (!narrative) return null; return ( - Vì sao nên chọn dự án này? + + Vì sao nên chọn dự án này? + {!adminNarrative && ( + + Tự động tổng hợp + + )} +

{narrative}

diff --git a/apps/web/components/du-an/project-ai-advice-card.tsx b/apps/web/components/du-an/project-ai-advice-card.tsx new file mode 100644 index 0000000..bb2e8ce --- /dev/null +++ b/apps/web/components/du-an/project-ai-advice-card.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; +import { AlertTriangle, Check, RefreshCw, Sparkles } from 'lucide-react'; +import Link from 'next/link'; +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { analyticsApi, type ProjectAiAdvice } from '@/lib/analytics-api'; +import { ApiError } from '@/lib/api-client'; +import { useAuthStore } from '@/lib/auth-store'; + +interface ProjectAiAdviceCardProps { + projectId: string; + /** + * Persona labels already rendered by ProjectPersonaFitCard. We de-dupe + * before showing AI-suggested ones so users don't see the same chip twice. + */ + existingPersonas?: string[]; +} + +export function ProjectAiAdviceCard({ + projectId, + existingPersonas = [], +}: ProjectAiAdviceCardProps) { + const user = useAuthStore((s) => s.user); + const isAdmin = user?.role === 'ADMIN'; + + const mutation = useMutation({ + mutationFn: () => analyticsApi.getProjectAiAdvice(projectId), + }); + + const { data, error, isPending, isSuccess } = mutation; + + // Initial state — show trigger button. + if (!isSuccess && !isPending && !error) { + return ( + + + + + + ); + } + + // Loading — skeleton. + if (isPending) { + return ( + + + + + AI đang phân tích dự án… + + + +
+
+
+
+ + + ); + } + + // Error state. + if (error) { + const apiErr = error instanceof ApiError ? error : null; + const status = apiErr?.status ?? 0; + const notConfigured = status === 503; + + if (notConfigured) { + return ( + + +

+ + AI chưa được cấu hình. Liên hệ quản trị viên. +

+ {isAdmin && ( + + Cấu hình Claude API → + + )} +
+
+ ); + } + + return ( + + +

+ + + Không lấy được phân tích AI. {apiErr?.message ?? 'Vui lòng thử lại.'} + +

+ +
+
+ ); + } + + if (!data) return null; + + const { advice } = data; + const extraPersonas = advice.suitableFor.filter( + (p) => !existingPersonas.includes(p), + ); + + return ( + + + + + AI nhận định dự án + + + + {advice.summary &&

{advice.summary}

} + +
+ {advice.pros.length > 0 && ( +
+

+ Điểm mạnh +

+
    + {advice.pros.map((p, i) => ( +
  • + + {p} +
  • + ))} +
+
+ )} + + {advice.cons.length > 0 && ( +
+

+ Cần cân nhắc +

+
    + {advice.cons.map((c, i) => ( +
  • + + {c} +
  • + ))} +
+
+ )} +
+ + {extraPersonas.length > 0 && ( +
+

+ Phù hợp với +

+
+ {extraPersonas.map((p) => ( +
+ {p} + + AI gợi ý + +
+ ))} +
+
+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/web/lib/analytics-api.ts b/apps/web/lib/analytics-api.ts index 99dcd84..eb9d15f 100644 --- a/apps/web/lib/analytics-api.ts +++ b/apps/web/lib/analytics-api.ts @@ -121,6 +121,19 @@ export interface ListingAiAdvice { }; } +/** Project AI advice — same advice block as listings but no valuation. */ +export interface ProjectAiAdvice { + 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 }); @@ -150,4 +163,7 @@ export const analyticsApi = { getListingAiAdvice: (listingId: string) => apiClient.post(`/analytics/listings/${listingId}/ai-advice`), + + getProjectAiAdvice: (projectId: string) => + apiClient.post(`/analytics/projects/${projectId}/ai-advice`), }; diff --git a/apps/web/lib/project-personas.ts b/apps/web/lib/project-personas.ts new file mode 100644 index 0000000..aebd606 --- /dev/null +++ b/apps/web/lib/project-personas.ts @@ -0,0 +1,365 @@ +/** + * Derive "Phù hợp với ai" personas and compose a "Vì sao nên chọn dự án này" + * narrative for a Residential Project (dự án của chủ đầu tư). + * + * Mirrors `lib/listing-personas.ts` but keys off project-specific signals — + * developer reputation, amenity mix, status, completion timing, property-type + * composition, price tier — instead of per-unit fields (bedrooms/area). + * + * Purely client-side; no new backend fields required. Admin-authored + * `project.suitableFor` / `project.whyThisLocation` are merged on top. + */ + +import { + Baby, + HeartPulse, + Home, + Laptop, + Shield, + TrainFront, + Trees, + TrendingUp, + type LucideIcon, +} from 'lucide-react'; +import type { NearbyPOICategory } from './analytics-api'; +import type { ProjectDetail } from './du-an-api'; +import type { NeighborhoodScoreResult } from './listings-api'; + +export type ProjectPersonaKey = + | 'family_with_kids' + | 'young_family' + | 'commuter' + | 'single_young' + | 'nature_lover' + | 'long_term_investor' + | 'safety_first' + | 'senior'; + +export interface ProjectPersona { + key: ProjectPersonaKey; + label: string; + icon: LucideIcon; + reason: string; +} + +const PERSONA_LABELS: Record = { + family_with_kids: { label: 'Gia đình có con nhỏ', icon: Baby }, + young_family: { label: 'Gia đình trẻ', icon: Home }, + commuter: { label: 'Người đi làm xa', icon: TrainFront }, + single_young: { label: 'Người trẻ / độc thân', icon: Laptop }, + nature_lover: { label: 'Yêu thiên nhiên', icon: Trees }, + long_term_investor: { label: 'Nhà đầu tư dài hạn', icon: TrendingUp }, + safety_first: { label: 'Ưu tiên an ninh', icon: Shield }, + senior: { label: 'Người lớn tuổi', icon: HeartPulse }, +}; + +type POICountByCategory = Partial>; + +function countPOIs(pois: Array<{ category: NearbyPOICategory }>): POICountByCategory { + const counts: POICountByCategory = {}; + for (const p of pois) { + counts[p.category] = (counts[p.category] ?? 0) + 1; + } + return counts; +} + +/** Case-insensitive keyword hit across amenity.name + amenity.category. */ +function amenityMatches(project: ProjectDetail, keywords: string[]): number { + const hay = project.amenities + .flatMap((a) => [a.name, a.category]) + .join(' | ') + .toLowerCase(); + let hits = 0; + for (const kw of keywords) { + if (hay.includes(kw.toLowerCase())) hits += 1; + } + return hits; +} + +function hasPropertyType( + project: ProjectDetail, + types: Array, +): boolean { + return project.propertyTypes.some((t) => types.includes(t)); +} + +/** Months until completionDate, or null if missing / already passed. */ +function monthsUntilCompletion(project: ProjectDetail): number | null { + if (!project.completionDate) return null; + const target = new Date(project.completionDate).getTime(); + const now = Date.now(); + if (Number.isNaN(target) || target <= now) return null; + return Math.round((target - now) / (1000 * 60 * 60 * 24 * 30)); +} + +export function deriveProjectPersonas( + project: ProjectDetail, + score: NeighborhoodScoreResult | null, + pois: Array<{ category: NearbyPOICategory }>, +): ProjectPersona[] { + const poiCount = countPOIs(pois); + const out: ProjectPersona[] = []; + + // ─── Gia đình có con nhỏ ────────────────────────────────── + // Điểm giáo dục tốt + dự án có loại hình phù hợp cho gia đình (VILLA / + // TOWNHOUSE / căn hộ) hoặc amenities có khu vui chơi / mẫu giáo. + const kidAmenities = amenityMatches(project, [ + 'trẻ em', + 'mẫu giáo', + 'trường', + 'kids', + 'playground', + 'khu vui chơi', + ]); + if ( + (score && score.educationScore >= 7) || + kidAmenities > 0 || + hasPropertyType(project, ['VILLA', 'TOWNHOUSE']) + ) { + const schools = poiCount.school ?? 0; + const reasonBits: string[] = []; + if (score && score.educationScore >= 7) + reasonBits.push(`điểm giáo dục ${score.educationScore.toFixed(1)}/10`); + if (schools > 0) reasonBits.push(`${schools} trường học gần`); + if (kidAmenities > 0) reasonBits.push(`có ${kidAmenities} tiện ích cho trẻ em`); + if (hasPropertyType(project, ['VILLA', 'TOWNHOUSE'])) + reasonBits.push('có biệt thự / nhà phố'); + out.push({ + ...PERSONA_LABELS.family_with_kids, + key: 'family_with_kids', + reason: reasonBits.length > 0 + ? `${reasonBits.slice(0, 2).join(', ')}.` + : 'Phù hợp cho gia đình có con nhỏ.', + }); + } + + // ─── Gia đình trẻ ────────────────────────────────────────── + // APARTMENT + y tế tốt (2BR không hard-code vì project là tổ hợp). + if ( + hasPropertyType(project, ['APARTMENT']) && + score && + score.healthcareScore >= 7 + ) { + const hospitals = poiCount.hospital ?? 0; + out.push({ + ...PERSONA_LABELS.young_family, + key: 'young_family', + reason: hospitals > 0 + ? `Có ${hospitals} bệnh viện/phòng khám gần, y tế ${score.healthcareScore.toFixed(1)}/10.` + : `Y tế khu vực đạt ${score.healthcareScore.toFixed(1)}/10.`, + }); + } + + // ─── Người đi làm xa ─────────────────────────────────────── + // Giao thông tốt hoặc có nhiều điểm metro/bus gần. + const transitCount = poiCount.transit ?? 0; + if ((score && score.transportScore >= 7) || transitCount >= 2) { + out.push({ + ...PERSONA_LABELS.commuter, + key: 'commuter', + reason: transitCount >= 2 + ? `${transitCount} điểm metro/bus trong 2km, giao thông ${score?.transportScore.toFixed(1) ?? '?'}/10.` + : `Giao thông ${score?.transportScore.toFixed(1) ?? '?'}/10.`, + }); + } + + // ─── Người trẻ / độc thân ───────────────────────────────── + // APARTMENT + shopping/restaurants tốt. + const restaurantCount = poiCount.restaurant ?? 0; + const shoppingCount = poiCount.shopping ?? 0; + if ( + hasPropertyType(project, ['APARTMENT']) && + score && + (score.shoppingScore >= 7 || restaurantCount >= 2) + ) { + const detail: string[] = []; + if (restaurantCount >= 2) detail.push(`${restaurantCount} nhà hàng/quán cafe`); + if (shoppingCount >= 1) detail.push(`${shoppingCount} TTTM/siêu thị`); + detail.push(`mua sắm ${score.shoppingScore.toFixed(1)}/10`); + out.push({ + ...PERSONA_LABELS.single_young, + key: 'single_young', + reason: `Gần ${detail.slice(0, 2).join(', ')}.`, + }); + } + + // ─── Yêu thiên nhiên ────────────────────────────────────── + // Môi trường tốt HOẶC có công viên gần HOẶC amenities có hồ bơi/công viên/cây xanh. + const greenAmenities = amenityMatches(project, [ + 'hồ bơi', + 'công viên', + 'cây xanh', + 'hồ cảnh quan', + 'pool', + 'garden', + ]); + const parkCount = poiCount.park ?? 0; + if ((score && score.greeneryScore >= 7) || parkCount >= 1 || greenAmenities >= 2) { + const bits: string[] = []; + if (score && score.greeneryScore >= 7) + bits.push(`môi trường ${score.greeneryScore.toFixed(1)}/10`); + if (parkCount > 0) bits.push(`${parkCount} công viên gần`); + if (greenAmenities > 0) bits.push(`${greenAmenities} tiện ích xanh nội khu`); + out.push({ + ...PERSONA_LABELS.nature_lover, + key: 'nature_lover', + reason: bits.slice(0, 2).join(', ') + '.', + }); + } + + // ─── Ưu tiên an ninh ────────────────────────────────────── + // Safety score cao hoặc amenities có security/camera/bảo vệ 24/24. + const safetyAmenities = amenityMatches(project, [ + 'bảo vệ', + 'an ninh', + 'camera', + 'security', + '24/7', + '24/24', + ]); + if ((score && score.safetyScore >= 8) || safetyAmenities >= 2) { + const parts: string[] = []; + if (score && score.safetyScore >= 8) parts.push(`an ninh ${score.safetyScore.toFixed(1)}/10`); + if (safetyAmenities > 0) parts.push(`${safetyAmenities} lớp bảo vệ nội khu`); + out.push({ + ...PERSONA_LABELS.safety_first, + key: 'safety_first', + reason: parts.join(', ') + '.', + }); + } + + // ─── Người lớn tuổi ─────────────────────────────────────── + // Y tế + môi trường đều cao, ưu tiên dự án đã hoặc sắp bàn giao. + const hospitals = poiCount.hospital ?? 0; + const handoverSoon = + project.status === 'HANDOVER' || + project.status === 'COMPLETED' || + (monthsUntilCompletion(project) ?? 99) <= 6; + if ( + score && + score.healthcareScore >= 8 && + score.greeneryScore >= 6 && + handoverSoon + ) { + out.push({ + ...PERSONA_LABELS.senior, + key: 'senior', + reason: hospitals > 0 + ? `${hospitals} bệnh viện gần, y tế ${score.healthcareScore.toFixed(1)}/10, môi trường ${score.greeneryScore.toFixed(1)}/10.` + : `Y tế ${score.healthcareScore.toFixed(1)}/10, môi trường ${score.greeneryScore.toFixed(1)}/10.`, + }); + } + + // ─── Nhà đầu tư dài hạn ─────────────────────────────────── + // Dự án đang xây / quy hoạch + CĐT có uy tín (>=5 dự án) + khu vực tốt + // + giao thông >= 7 (tiềm năng cho thuê / tăng giá). + const developerProven = project.developer.totalProjects >= 5; + const earlyStage = + project.status === 'PLANNING' || project.status === 'UNDER_CONSTRUCTION'; + if ( + earlyStage && + developerProven && + score && + score.totalScore >= 7 && + score.transportScore >= 7 + ) { + const months = monthsUntilCompletion(project); + const timingBit = months != null ? `bàn giao sau ~${months} tháng` : 'bàn giao tương lai'; + out.push({ + ...PERSONA_LABELS.long_term_investor, + key: 'long_term_investor', + reason: `CĐT ${project.developer.totalProjects} dự án, khu vực ${score.totalScore.toFixed(1)}/10, ${timingBit}.`, + }); + } + + return out; +} + +/** + * Compose a short narrative highlighting the strongest 2-3 reasons to choose + * this project. Returns null when there isn't enough signal. Blends live + * neighborhood score with project-specific advantages (developer, amenity mix, + * status) so the narrative is distinct from the listings version. + */ +export function composeWhyThisProject( + project: ProjectDetail, + score: NeighborhoodScoreResult | null, + pois: Array<{ category: NearbyPOICategory }>, +): string | null { + const parts: string[] = []; + const poiCount = countPOIs(pois); + + // Location highlights from the score radar. + if (score) { + const scoreEntries: Array<{ label: string; score: number; detail: string }> = [ + { + label: 'giáo dục', + score: score.educationScore, + detail: poiCount.school + ? `${poiCount.school} trường học gần` + : 'hệ thống trường đa dạng', + }, + { + label: 'y tế', + score: score.healthcareScore, + detail: poiCount.hospital + ? `${poiCount.hospital} bệnh viện trong 2km` + : 'tiện ích y tế đầy đủ', + }, + { + label: 'giao thông', + score: score.transportScore, + detail: poiCount.transit + ? `${poiCount.transit} điểm metro/bus gần` + : 'dễ kết nối các khu vực khác', + }, + { + label: 'mua sắm', + score: score.shoppingScore, + detail: poiCount.shopping + ? `${poiCount.shopping} siêu thị/TTTM gần` + : 'nhiều lựa chọn mua sắm', + }, + { + label: 'môi trường', + score: score.greeneryScore, + detail: poiCount.park ? `${poiCount.park} công viên gần` : 'không gian xanh tốt', + }, + { label: 'an ninh', score: score.safetyScore, detail: 'khu dân cư yên tĩnh' }, + ]; + const highlights = scoreEntries + .filter((e) => e.score >= 7) + .sort((a, b) => b.score - a.score) + .slice(0, 2); + for (const h of highlights) { + parts.push( + `${h.label.charAt(0).toUpperCase()}${h.label.slice(1)} đạt ${h.score.toFixed(1)}/10 (${h.detail})`, + ); + } + } + + // Project-level advantage: developer reputation. + if (project.developer.totalProjects >= 5) { + parts.push( + `CĐT ${project.developer.name} đã triển khai ${project.developer.totalProjects} dự án`, + ); + } + + // Amenity density highlight. + if (project.amenities.length >= 10) { + parts.push(`${project.amenities.length} tiện ích nội khu đa dạng`); + } + + // Timing advantage. + const months = monthsUntilCompletion(project); + if (project.status === 'COMPLETED') { + parts.push('dự án đã hoàn thành, vào ở ngay'); + } else if (project.status === 'HANDOVER') { + parts.push('đang bàn giao, sẵn sàng nhận nhà'); + } else if (months != null && months <= 12) { + parts.push(`dự kiến bàn giao sau ~${months} tháng`); + } + + if (parts.length === 0) return null; + return parts.slice(0, 4).join('. ') + '.'; +}