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
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} 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<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 };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetProjectAiAdviceQuery {
|
||||
constructor(public readonly projectId: string) {}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user