feat(projects): bring residential-project detail to parity with listings (4 phases)
Some checks failed
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 9s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 13s
Deploy / Build Web Image (push) Failing after 9s
Deploy / Build AI Services Image (push) Failing after 11s
E2E Tests / Playwright E2E (push) Failing after 10s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — API Image (push) Failing after 50s
Security Scanning / Trivy Scan — Web Image (push) Failing after 41s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 31s
Security Scanning / Trivy Filesystem Scan (push) Failing after 23s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped

Phase 1 — live POI + neighborhood score on project detail
- du-an-detail-client fetches `/analytics/pois/nearby` + `/analytics/neighborhoods/:district/score`
- Falls back to admin-entered `project.pois` / `neighborhoodScores` when endpoint returns nothing
- Adds total-score badge next to the radar chart (matches listings)

Phase 2 — project personas derivation (`lib/project-personas.ts`)
- Derives 8 personas from project-specific signals: property-type mix, amenity keywords,
  developer reputation, completion timing, status, live score + POIs
- Merges admin-authored `suitableFor` chips (badged "Chủ đầu tư chọn") with derived chips
- `composeWhyThisProject()` narrative used as fallback when admin hasn't authored one;
  badged "Tự động tổng hợp" so users know it's derived

Phase 3 — AI advisor for projects
- Extract shared Anthropic transport + JSON parsers to
  `analytics/application/queries/_shared/ai-json-client.ts` (dual auth: x-api-key +
  Bearer for proxy gateways)
- Refactor `GetListingAiAdviceHandler` to use the shared client
- New `GetProjectAiAdviceHandler` (CQRS) pulls project detail + optional POIs + score,
  builds project-flavored prompt, returns `{ advice: { summary, pros, cons, suitableFor } }`.
  No valuation block — project price is a range, not a single unit.
- `POST /analytics/projects/:id/ai-advice` endpoint (JWT-guarded)
- `ErrorCode.PROJECT_NOT_FOUND` added
- Frontend: `ProjectAiAdviceCard` mirrors listings card minus valuation, with loading /
  not-configured (503) / error states; dedupes AI-suggested personas against existing chips

Phase 4 — Mapbox LocationPicker in project create form
- New project page now renders `<LocationPicker>` with Vietnam-scoped geocoder; click /
  drag / search autofills lat+lng and (when empty) address/ward/district/city
- Edit page notes location immutability — backend `UpdateProjectCommand` does not yet
  accept lat/lng/address mutations (follow-up needed to enable editing coords)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-20 17:53:19 +07:00
parent 03f8674024
commit dd3ad4aeca
13 changed files with 1486 additions and 273 deletions

View File

@@ -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

View File

@@ -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<CallAnthropicResult> {
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<string, unknown> {
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<string, unknown> {
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;
}

View File

@@ -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<GetListingAiAdviceQuery, ListingAiAdviceResponse>
@@ -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<AnthropicResponse> {
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<string, number> {
}
// --------------------------------------------------------------------------
// 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<string, unknown> {
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,
);
}

View File

@@ -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": ["<cụm ngắn>", ...], // 3-5 mục
"cons": ["<cụm ngắn>", ...], // 2-4 mục
"suitableFor": ["<nhãn persona>", ...] // 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<GetProjectAiAdviceQuery, ProjectAiAdviceResponse>
{
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<ProjectAiAdviceResponse> {
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<NearbyPOIsResultDto | null> {
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<NeighborhoodScoreResult | null> {
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}`);
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 010)');
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<string, number> {
const out: Record<string, number> = {};
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, unknown>): 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 };
}

View File

@@ -0,0 +1,3 @@
export class GetProjectAiAdviceQuery {
constructor(public readonly projectId: string) {}
}

View File

@@ -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<ListingAiAdviceResponse> {
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<ProjectAiAdviceResponse> {
return this.queryBus.execute(new GetProjectAiAdviceQuery(id));
}
}

View File

@@ -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',

View File

@@ -336,16 +336,26 @@ export default function EditProjectPage() {
</div>
</FormSection>
{/* Vị trí */}
{/* Vị trí (hiện tại chỉ hiển thị; backend chưa hỗ trợ sửa toạ độ sau khi tạo) */}
<FormSection title="Vị trí">
<div className="space-y-1.5 sm:col-span-2 text-sm text-muted-foreground">
{project.address}
<br />
{project.district}, {project.city}
<br />
<span className="text-xs">
(Vị trí đa không thể chỉnh sửa sau khi tạo.)
</span>
<div className="sm:col-span-2 space-y-2 text-sm">
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{project.address}</span>
<br />
{project.district}, {project.city}
{typeof project.latitude === 'number' && typeof project.longitude === 'number' && (
<>
<br />
<span className="text-xs">
({project.latitude.toFixed(6)}, {project.longitude.toFixed(6)})
</span>
</>
)}
</div>
<p className="text-xs text-muted-foreground">
Vị trí đa 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 tạo lại.
</p>
</div>
</FormSection>

View File

@@ -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: () => (
<div className="flex h-[320px] items-center justify-center rounded-lg border border-dashed bg-muted text-sm text-muted-foreground">
Đang tải bản đ
</div>
),
},
);
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<ProjectFormData>({
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í */}
<FormSection title="Vị trí">
<div className="sm:col-span-2">
<Label>Chọn vị trí trên bản đ</Label>
<p className="mb-2 text-xs text-muted-foreground">
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.
</p>
<LocationPicker
lat={Number.isFinite(latNum) ? latNum : null}
lng={Number.isFinite(lngNum) ? lngNum : null}
onChange={handlePickLocation}
height="360px"
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="address">
Đa chỉ <span className="text-destructive">*</span>

View File

@@ -10,20 +10,21 @@ import {
Home,
MapPin,
Phone,
Sparkles,
X,
} from 'lucide-react';
import dynamic from 'next/dynamic';
import Image from 'next/image';
import * as React from 'react';
import { ProjectAiAdviceCard } from '@/components/du-an/project-ai-advice-card';
import { ImageGallery } from '@/components/listings/image-gallery';
import type { POICategory } from '@/components/neighborhood';
import type { POICategory, POIItem } from '@/components/neighborhood';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { analyticsApi, type NearbyPOI } from '@/lib/analytics-api';
import { formatPrice } from '@/lib/currency';
import {
PROJECT_PROPERTY_TYPE_LABELS,
@@ -32,8 +33,25 @@ import {
duAnApi,
type ProjectDetail,
} from '@/lib/du-an-api';
import { listingsApi, type NeighborhoodScoreResult } from '@/lib/listings-api';
import {
composeWhyThisProject,
deriveProjectPersonas,
type ProjectPersona,
} from '@/lib/project-personas';
import { cn } from '@/lib/utils';
function mapScoreToCategories(result: NeighborhoodScoreResult) {
return [
{ category: 'education', label: 'Giáo dục', score: result.educationScore },
{ category: 'healthcare', label: 'Y tế', score: result.healthcareScore },
{ category: 'transport', label: 'Giao thông', score: result.transportScore },
{ category: 'shopping', label: 'Mua sắm', score: result.shoppingScore },
{ category: 'environment', label: 'Môi trường', score: result.greeneryScore },
{ category: 'safety', label: 'An ninh', score: result.safetyScore },
];
}
const PriceTrendChart = dynamic(
() => import('@/components/charts/price-trend-chart').then((m) => m.PriceTrendChart),
{ ssr: false },
@@ -72,6 +90,39 @@ export function DuAnDetailClient({ project }: DuAnDetailClientProps) {
'idle' | 'loading' | 'success' | 'error'
>('idle');
// Live enrichments — fetched from analytics endpoints. Both degrade
// gracefully: if either endpoint fails, we fall back to the
// admin-entered `project.pois` / `project.neighborhoodScores` payload.
const [liveScore, setLiveScore] = React.useState<NeighborhoodScoreResult | null>(null);
const [livePois, setLivePois] = React.useState<POIItem[] | null>(null);
React.useEffect(() => {
if (!project.district || !project.city) return;
listingsApi
.getNeighborhoodScore(project.district, project.city)
.then(setLiveScore)
.catch(() => {/* silent — LocationTab falls back to admin payload */});
}, [project.district, project.city]);
React.useEffect(() => {
const { latitude, longitude } = project;
if (latitude == null || longitude == null) return;
analyticsApi
.getNearbyPOIs(latitude, longitude)
.then((res) => {
const mapped: POIItem[] = res.pois.map((p: NearbyPOI) => ({
id: p.id,
name: p.name,
category: p.category,
lat: p.lat,
lng: p.lng,
distance: p.distance,
}));
setLivePois(mapped);
})
.catch(() => {/* silent — map still renders without POIs */});
}, [project.latitude, project.longitude]);
const statusLabel = PROJECT_STATUS_LABELS[project.status];
const statusColor = PROJECT_STATUS_COLORS[project.status];
@@ -155,11 +206,26 @@ export function DuAnDetailClient({ project }: DuAnDetailClientProps) {
/>
</div>
{/* Persona fit — "Phù hợp với ai" (CĐT chọn) + AI placeholder */}
<ProjectPersonaFitCard project={project} />
{/* Persona fit — admin chips (CĐT chọn) merged with derived personas */}
<ProjectPersonaFitCard project={project} score={liveScore} pois={livePois ?? []} />
{/* "Vì sao nên chọn dự án này" narrative — admin authored */}
<ProjectWhyLocationCard project={project} />
{/* AI advisor card — on-demand Claude call for summary + pros/cons + personas */}
<div className="my-6">
<ProjectAiAdviceCard
projectId={project.id}
existingPersonas={[
...(project.suitableFor ?? []),
...deriveProjectPersonas(
project,
liveScore,
(livePois ?? []).map((p) => ({ category: p.category })),
).map((d) => d.label),
]}
/>
</div>
{/* "Vì sao nên chọn dự án này" — admin narrative (preferred) or derived */}
<ProjectWhyLocationCard project={project} score={liveScore} pois={livePois ?? []} />
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
@@ -228,7 +294,9 @@ export function DuAnDetailClient({ project }: DuAnDetailClientProps) {
<div className="mt-4">
{activeTab === 'amenities' && <AmenitiesTab project={project} />}
{activeTab === 'location' && <LocationTab project={project} />}
{activeTab === 'location' && (
<LocationTab project={project} liveScore={liveScore} livePois={livePois} />
)}
{activeTab === 'price' && <PriceTab project={project} />}
{activeTab === 'listings' && <ListingsTab project={project} />}
{activeTab === 'documents' && <DocumentsTab project={project} />}
@@ -410,18 +478,41 @@ const POI_TYPE_MAP: Record<string, POICategory> = {
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 (
<div className="space-y-6">
<p className="text-sm">
@@ -429,45 +520,46 @@ function LocationTab({ project }: { project: ProjectDetail }) {
</p>
{/* Map */}
{hasCoordinates && (
<NeighborhoodPOIMap
center={{ lat: project.latitude!, lng: project.longitude! }}
pois={mapPois}
height="400px"
/>
)}
{/* Neighborhood scores radar chart */}
{project.neighborhoodScores.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-medium">Đánh giá khu vực</h4>
<NeighborhoodRadarChart
categories={project.neighborhoodScores.map((s) => ({
category: s.category,
label: s.label,
score: s.score,
}))}
height={300}
{hasCoordinates ? (
<>
<NeighborhoodPOIMap
center={{ lat: project.latitude!, lng: project.longitude! }}
pois={mapPois}
height="400px"
/>
<p className="text-sm text-muted-foreground">
{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`}
</p>
</>
) : (
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
<p className="text-sm text-muted-foreground">Chưa tọa đ cho dự án này</p>
</div>
)}
{/* POI list fallback (when no map) */}
{!hasCoordinates && project.pois.length > 0 && (
{/* Neighborhood score */}
{scoreCategories.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-medium">Tiện ích lân cận</h4>
<div className="space-y-2">
{project.pois.slice(0, 10).map((poi) => (
<div key={poi.id} className="flex items-center justify-between text-sm">
<span>{poi.name}</span>
<span className="text-muted-foreground">
{poi.distance < 1000
? `${poi.distance}m`
: `${(poi.distance / 1000).toFixed(1)}km`}
</span>
</div>
))}
<div className="mb-3 flex items-center gap-2">
<h4 className="text-sm font-medium">Đánh giá khu vực</h4>
{liveScore && (
<Badge
variant={
liveScore.totalScore > 7
? 'success'
: liveScore.totalScore >= 5
? 'warning'
: 'destructive'
}
className="px-2.5 py-0.5 text-sm font-bold"
>
{liveScore.totalScore.toFixed(1)}/10
</Badge>
)}
</div>
<NeighborhoodRadarChart categories={scoreCategories} height={300} />
</div>
)}
</div>
@@ -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 (
<Card className="my-6 border-primary/30 bg-primary/5">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Phù hợp với ai?</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{suitableFor.length > 0 ? (
<div className="flex flex-wrap gap-2">
{suitableFor.map((label) => (
<CardContent>
<div className="flex flex-wrap gap-2">
{/* Admin-authored chips first, marked with badge. */}
{adminLabels.map((label) => (
<div
key={`admin-${label}`}
className="inline-flex items-center gap-1.5 rounded-full border border-primary/50 bg-primary/10 px-3 py-1.5 text-sm shadow-sm"
>
<span className="font-medium">{label}</span>
<span className="rounded bg-primary/20 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-primary">
Chủ đu chọn
</span>
</div>
))}
{/* Derived personas — include short reason on hover via title. */}
{derivedFiltered.map((p) => {
const Icon = p.icon;
return (
<div
key={`admin-${label}`}
className="inline-flex items-center gap-1.5 rounded-full border border-primary/50 bg-primary/10 px-3 py-1.5 text-sm shadow-sm"
key={`derived-${p.key}`}
title={p.reason}
className="inline-flex items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm shadow-sm"
>
<span className="font-medium">{label}</span>
<span className="rounded bg-primary/20 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-primary">
Chủ đu chọn
</span>
<Icon className="h-3.5 w-3.5 text-primary" aria-hidden="true" />
<span className="font-medium">{p.label}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Chủ đu chưa chỉ đnh nhóm khách phù hợp.
</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. */}
<div className="pt-1">
<Button type="button" variant="outline" disabled className="gap-2">
<Sparkles className="h-4 w-4" />
AI nhận đnh dự án (sắp ra mắt)
</Button>
);
})}
</div>
{/* Per-persona reasons — expanded list so users see the "vì sao". */}
{derivedFiltered.length > 0 && (
<ul className="mt-4 space-y-1.5 text-sm text-muted-foreground">
{derivedFiltered.map((p) => (
<li key={`reason-${p.key}`} className="flex items-start gap-2">
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-primary" aria-hidden="true" />
<span>
<span className="font-medium text-foreground">{p.label}:</span> {p.reason}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
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 (
<Card className="my-6">
<CardHeader className="pb-3">
<CardTitle className="text-lg"> sao nên chọn dự án này?</CardTitle>
<CardTitle className="flex items-center gap-2 text-lg">
sao nên chọn dự án này?
{!adminNarrative && (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">
Tự đng tổng hợp
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{narrative}</p>

View File

@@ -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<ProjectAiAdvice, unknown, void>({
mutationFn: () => analyticsApi.getProjectAiAdvice(projectId),
});
const { data, error, isPending, isSuccess } = mutation;
// Initial state — show trigger button.
if (!isSuccess && !isPending && !error) {
return (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="py-4">
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={() => mutation.mutate()}
>
<Sparkles className="h-4 w-4" />
Xem phân tích AI về dự án
</Button>
</CardContent>
</Card>
);
}
// Loading — skeleton.
if (isPending) {
return (
<Card className="border-primary/30 bg-primary/5">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 animate-pulse text-primary" />
AI đang phân tích dự án
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-5/6 animate-pulse rounded bg-muted" />
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
);
}
// Error state.
if (error) {
const apiErr = error instanceof ApiError ? error : null;
const status = apiErr?.status ?? 0;
const notConfigured = status === 503;
if (notConfigured) {
return (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40">
<CardContent className="space-y-2 py-4">
<p className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<span>AI chưa đưc cấu hình. Liên hệ quản trị viên.</span>
</p>
{isAdmin && (
<Link
href="/admin/settings/ai"
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline"
>
Cấu hình Claude API
</Link>
)}
</CardContent>
</Card>
);
}
return (
<Card className="border-destructive/40 bg-destructive/5">
<CardContent className="space-y-2 py-4">
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Không lấy đưc phân tích AI. {apiErr?.message ?? 'Vui lòng thử lại.'}
</span>
</p>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={() => mutation.mutate()}
>
<RefreshCw className="h-3.5 w-3.5" />
Thử lại
</Button>
</CardContent>
</Card>
);
}
if (!data) return null;
const { advice } = data;
const extraPersonas = advice.suitableFor.filter(
(p) => !existingPersonas.includes(p),
);
return (
<Card className="border-primary/30 bg-primary/5">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-primary" />
AI nhận đnh dự án
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{advice.summary && <p className="text-sm leading-relaxed">{advice.summary}</p>}
<div className="grid gap-4 sm:grid-cols-2">
{advice.pros.length > 0 && (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Điểm mạnh
</p>
<ul className="space-y-1.5">
{advice.pros.map((p, i) => (
<li key={`pro-${i}`} className="flex items-start gap-1.5 text-sm">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
<span>{p}</span>
</li>
))}
</ul>
</div>
)}
{advice.cons.length > 0 && (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Cần cân nhắc
</p>
<ul className="space-y-1.5">
{advice.cons.map((c, i) => (
<li key={`con-${i}`} className="flex items-start gap-1.5 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<span>{c}</span>
</li>
))}
</ul>
</div>
)}
</div>
{extraPersonas.length > 0 && (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Phù hợp với
</p>
<div className="flex flex-wrap gap-2">
{extraPersonas.map((p) => (
<div
key={`ai-persona-${p}`}
className="inline-flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/10 px-3 py-1 text-xs"
>
<span className="font-medium">{p}</span>
<span className="rounded bg-primary/20 px-1 py-0.5 text-[9px] uppercase tracking-wide text-primary">
AI gợi ý
</span>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-2 text-right">
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={() => mutation.mutate()}
>
<RefreshCw className="h-3 w-3" />
Làm mới
</button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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<ListingAiAdvice>(`/analytics/listings/${listingId}/ai-advice`),
getProjectAiAdvice: (projectId: string) =>
apiClient.post<ProjectAiAdvice>(`/analytics/projects/${projectId}/ai-advice`),
};

View File

@@ -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<ProjectPersonaKey, { label: string; icon: LucideIcon }> = {
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<Record<NearbyPOICategory, number>>;
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<ProjectDetail['propertyTypes'][number]>,
): 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(`${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
? `${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('. ') + '.';
}