feat(mcp): add industrial parks and reports MCP tool servers
Add IndustrialParkServer for KCN/KCX search and analytics, and ReportsServer for market report generation. Include unit tests for industrial parks server. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
285
libs/mcp-servers/src/__tests__/industrial-parks.server.test.ts
Normal file
285
libs/mcp-servers/src/__tests__/industrial-parks.server.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createIndustrialParksServer } from '../industrial-parks/industrial-parks.server';
|
||||
import type { IndustrialParksDeps } from '../shared/types';
|
||||
|
||||
type ToolResult = { content: { type: string; text: string }[]; isError?: boolean };
|
||||
|
||||
function createMockClient(searchResult: unknown = { hits: [], found: 0 }) {
|
||||
const search = vi.fn().mockResolvedValue(searchResult);
|
||||
return {
|
||||
collections: vi.fn().mockReturnValue({
|
||||
documents: vi.fn().mockReturnValue({ search }),
|
||||
}),
|
||||
_search: search,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(client: ReturnType<typeof createMockClient>): IndustrialParksDeps {
|
||||
return {
|
||||
typesenseClient: client as unknown as IndustrialParksDeps['typesenseClient'],
|
||||
collectionName: 'test-industrial-parks',
|
||||
aiServiceBaseUrl: 'http://localhost:8000',
|
||||
};
|
||||
}
|
||||
|
||||
function makeHits(docs: Record<string, unknown>[]) {
|
||||
return { hits: docs.map((d) => ({ document: d })), found: docs.length, search_time_ms: 3 };
|
||||
}
|
||||
|
||||
function getToolHandler(server: ReturnType<typeof createIndustrialParksServer>, name: string) {
|
||||
const tools = (server as unknown as { _registeredTools: Record<string, { handler: (p: unknown) => Promise<ToolResult> }> })._registeredTools;
|
||||
const entry = tools[name];
|
||||
if (!entry) throw new Error(`Tool "${name}" not found`);
|
||||
return entry.handler;
|
||||
}
|
||||
|
||||
function parseResult(result: ToolResult) {
|
||||
return JSON.parse(result.content[0].text) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const samplePark = {
|
||||
parkId: 'park-001',
|
||||
name: 'KCN VSIP Bình Dương',
|
||||
nameEn: 'VSIP Binh Duong',
|
||||
developer: 'VSIP Group',
|
||||
province: 'Bình Dương',
|
||||
region: 'south',
|
||||
status: 'operational',
|
||||
totalAreaHa: 500,
|
||||
remainingAreaHa: 80,
|
||||
occupancyRate: 84,
|
||||
landRentUsdM2Year: 85,
|
||||
rbfRentUsdM2Month: 4.5,
|
||||
rbwRentUsdM2Month: 3.8,
|
||||
targetIndustries: ['electronics', 'logistics'],
|
||||
tenantCount: 120,
|
||||
};
|
||||
|
||||
describe('IndustrialParksServer', () => {
|
||||
let client: ReturnType<typeof createMockClient>;
|
||||
let server: ReturnType<typeof createIndustrialParksServer>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createMockClient(makeHits([samplePark]));
|
||||
server = createIndustrialParksServer(makeDeps(client));
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates server with correct name', () => {
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
describe('search_industrial_parks', () => {
|
||||
it('searches with basic query', async () => {
|
||||
const handler = getToolHandler(server, 'search_industrial_parks');
|
||||
const result = await handler({ query: 'KCN Bình Dương', page: 1, perPage: 20 });
|
||||
|
||||
expect(client.collections).toHaveBeenCalledWith('test-industrial-parks');
|
||||
expect(client._search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
q: 'KCN Bình Dương',
|
||||
query_by: 'name,nameEn,developer,operator,province,targetIndustries',
|
||||
}),
|
||||
);
|
||||
|
||||
const data = parseResult(result);
|
||||
expect(data.totalFound).toBe(1);
|
||||
expect(data.results).toHaveLength(1);
|
||||
expect((data.results as Record<string, unknown>[])[0].parkId).toBe('park-001');
|
||||
});
|
||||
|
||||
it('applies province and status filters', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_industrial_parks');
|
||||
|
||||
await handler({
|
||||
query: '*',
|
||||
province: 'Bình Dương',
|
||||
status: 'operational',
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
});
|
||||
|
||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||
expect(filterBy).toContain('province:=Bình Dương');
|
||||
expect(filterBy).toContain('status:=operational');
|
||||
});
|
||||
|
||||
it('applies area and rent filters', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_industrial_parks');
|
||||
|
||||
await handler({
|
||||
query: '*',
|
||||
minAreaHa: 10,
|
||||
maxRentUsdM2: 100,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
});
|
||||
|
||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||
expect(filterBy).toContain('remainingAreaHa:>=10');
|
||||
expect(filterBy).toContain('landRentUsdM2Year:<=100');
|
||||
});
|
||||
|
||||
it('applies geo filter when coordinates provided', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_industrial_parks');
|
||||
|
||||
await handler({
|
||||
query: '*',
|
||||
latitude: 11.0,
|
||||
longitude: 106.6,
|
||||
radiusKm: 30,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
});
|
||||
|
||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||
expect(filterBy).toContain('location:(11, 106.6, 30 km)');
|
||||
});
|
||||
|
||||
it('sorts by rent ascending', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_industrial_parks');
|
||||
|
||||
await handler({ query: '*', sortBy: 'rent_asc', page: 1, perPage: 20 });
|
||||
expect(client._search.mock.calls[0][0].sort_by).toBe('landRentUsdM2Year:asc');
|
||||
});
|
||||
|
||||
it('sorts by remaining area descending', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_industrial_parks');
|
||||
|
||||
await handler({ query: '*', sortBy: 'area_desc', page: 1, perPage: 20 });
|
||||
expect(client._search.mock.calls[0][0].sort_by).toBe('remainingAreaHa:desc');
|
||||
});
|
||||
|
||||
it('returns empty results gracefully', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_industrial_parks');
|
||||
const result = await handler({ query: 'nonexistent', page: 1, perPage: 20 });
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.totalFound).toBe(0);
|
||||
expect(data.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze_industrial_location', () => {
|
||||
it('calls AI service and returns analysis', async () => {
|
||||
const mockResponse = {
|
||||
overall_score: 8.5,
|
||||
connectivity: {
|
||||
nearest_port: { name: 'Cảng Cát Lái', distanceKm: 25 },
|
||||
nearest_airport: { name: 'Tân Sơn Nhất', distanceKm: 30 },
|
||||
nearest_highway: { name: 'QL1A', distanceKm: 2 },
|
||||
},
|
||||
infrastructure: {
|
||||
power_availability: '110kV substation on-site',
|
||||
water_supply: 'Municipal + on-site treatment',
|
||||
wastewater_treatment: 'Central WWTP 5000m³/day',
|
||||
telecom: 'Fiber optic available',
|
||||
},
|
||||
labor_market: {
|
||||
worker_pool_radius_30km: 500000,
|
||||
average_wage_usd: 280,
|
||||
nearby_universities: ['ĐH Bình Dương', 'ĐH Thủ Dầu Một'],
|
||||
},
|
||||
incentives: ['CIT 10% for 15 years', 'Import duty exemption'],
|
||||
risks: ['Flooding risk in rainy season'],
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'analyze_industrial_location');
|
||||
const result = await handler({ latitude: 11.0, longitude: 106.6 });
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.overallScore).toBe(8.5);
|
||||
expect((data.connectivity as Record<string, unknown>).nearestPort).toEqual({ name: 'Cảng Cát Lái', distanceKm: 25 });
|
||||
expect((data.laborMarket as Record<string, unknown>).nearbyUniversities).toHaveLength(2);
|
||||
expect(data.incentives).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns error on AI service failure', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'analyze_industrial_location');
|
||||
const result = await handler({ latitude: 11.0, longitude: 106.6 });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('Location analysis error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimate_industrial_rent', () => {
|
||||
it('calls AI service and returns rent estimate', async () => {
|
||||
const mockResponse = {
|
||||
estimated_rent_usd_m2: 4.2,
|
||||
pricing_unit: 'USD/m²/month',
|
||||
total_monthly_usd: 42000,
|
||||
total_lease_usd: 5040000,
|
||||
management_fee_usd_m2: 0.5,
|
||||
deposit_months: 3,
|
||||
market_comparison: {
|
||||
province_low: 3.0,
|
||||
province_high: 6.5,
|
||||
province_avg: 4.5,
|
||||
},
|
||||
breakdown: [
|
||||
{ item: 'Base rent', amount: 35000 },
|
||||
{ item: 'Management fee', amount: 5000 },
|
||||
{ item: 'Utilities surcharge', amount: 2000 },
|
||||
],
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'estimate_industrial_rent');
|
||||
const result = await handler({
|
||||
province: 'Bình Dương',
|
||||
propertyType: 'ready_built_factory',
|
||||
areaM2: 10000,
|
||||
leaseDurationYears: 10,
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.estimatedRentUsdM2).toBe(4.2);
|
||||
expect(data.totalMonthlyUsd).toBe(42000);
|
||||
expect((data.marketComparison as Record<string, unknown>).provinceAvg).toBe(4.5);
|
||||
expect(data.breakdown).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns error on AI service failure', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => 'Invalid province',
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'estimate_industrial_rent');
|
||||
const result = await handler({
|
||||
province: 'Invalid',
|
||||
propertyType: 'industrial_land',
|
||||
areaM2: 5000,
|
||||
leaseDurationYears: 10,
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('Rent estimation error');
|
||||
});
|
||||
});
|
||||
});
|
||||
273
libs/mcp-servers/src/__tests__/reports.server.test.ts
Normal file
273
libs/mcp-servers/src/__tests__/reports.server.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createReportsServer } from '../reports/reports.server';
|
||||
import type { ReportsDeps } from '../shared/types';
|
||||
|
||||
type ToolResult = { content: { type: string; text: string }[]; isError?: boolean };
|
||||
|
||||
function makeDeps(): ReportsDeps {
|
||||
return { aiServiceBaseUrl: 'http://localhost:8000' };
|
||||
}
|
||||
|
||||
function getToolHandler(server: ReturnType<typeof createReportsServer>, name: string) {
|
||||
const tools = (server as unknown as { _registeredTools: Record<string, { handler: (p: unknown) => Promise<ToolResult> }> })._registeredTools;
|
||||
const entry = tools[name];
|
||||
if (!entry) throw new Error(`Tool "${name}" not found`);
|
||||
return entry.handler;
|
||||
}
|
||||
|
||||
function parseResult(result: ToolResult) {
|
||||
return JSON.parse(result.content[0].text) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('ReportsServer', () => {
|
||||
let server: ReturnType<typeof createReportsServer>;
|
||||
|
||||
beforeEach(() => {
|
||||
server = createReportsServer(makeDeps());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates server with correct name', () => {
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generate_report', () => {
|
||||
it('generates a market overview report', async () => {
|
||||
const mockResponse = {
|
||||
report_id: 'rpt-001',
|
||||
report_type: 'market_overview',
|
||||
title: 'Báo cáo thị trường BĐS Quận 7',
|
||||
location: 'Quận 7, Hồ Chí Minh',
|
||||
generated_at: '2026-04-16T10:00:00Z',
|
||||
summary: 'Thị trường Q7 tiếp tục tăng trưởng ổn định...',
|
||||
sections: [
|
||||
{
|
||||
title: 'Tổng quan',
|
||||
content: 'Quận 7 ghi nhận 1,200 giao dịch...',
|
||||
charts: [{ type: 'line', title: 'Giá trung bình', data: [] }],
|
||||
},
|
||||
],
|
||||
key_metrics: {
|
||||
avgPriceVND: 4_500_000_000,
|
||||
totalListings: 1200,
|
||||
avgPricePerM2: 55_000_000,
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'generate_report');
|
||||
const result = await handler({
|
||||
reportType: 'market_overview',
|
||||
location: 'Quận 7, Hồ Chí Minh',
|
||||
period: '1y',
|
||||
includeForecasts: false,
|
||||
includeMacro: false,
|
||||
language: 'vi',
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.reportId).toBe('rpt-001');
|
||||
expect(data.reportType).toBe('market_overview');
|
||||
expect(data.title).toContain('Quận 7');
|
||||
expect(data.sections).toHaveLength(1);
|
||||
expect((data.keyMetrics as Record<string, unknown>).totalListings).toBe(1200);
|
||||
});
|
||||
|
||||
it('generates report with forecasts', async () => {
|
||||
const mockResponse = {
|
||||
report_id: 'rpt-002',
|
||||
report_type: 'price_forecast',
|
||||
title: 'Price Forecast - District 2',
|
||||
location: 'Quận 2',
|
||||
generated_at: '2026-04-16T10:00:00Z',
|
||||
summary: 'Prices expected to increase 5-8% over next 12 months.',
|
||||
sections: [],
|
||||
key_metrics: {},
|
||||
forecasts: {
|
||||
price_trend: [
|
||||
{ period: 'Q3 2026', predicted_change_pct: 2.5 },
|
||||
{ period: 'Q4 2026', predicted_change_pct: 3.1 },
|
||||
],
|
||||
demand_trend: [
|
||||
{ period: 'Q3 2026', predicted_change_pct: 1.8 },
|
||||
],
|
||||
confidence: 0.82,
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'generate_report');
|
||||
const result = await handler({
|
||||
reportType: 'price_forecast',
|
||||
location: 'Quận 2',
|
||||
includeForecasts: true,
|
||||
includeMacro: false,
|
||||
period: '1y',
|
||||
language: 'en',
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.forecasts).toBeDefined();
|
||||
const forecasts = data.forecasts as Record<string, unknown>;
|
||||
expect(forecasts.confidence).toBe(0.82);
|
||||
expect((forecasts.price_trend as unknown[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it('sends correct request body to AI service', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
report_id: 'rpt-003',
|
||||
report_type: 'industrial_zone',
|
||||
title: 'Test',
|
||||
location: 'Bình Dương',
|
||||
generated_at: '2026-04-16T10:00:00Z',
|
||||
summary: 'Test',
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'generate_report');
|
||||
await handler({
|
||||
reportType: 'industrial_zone',
|
||||
location: 'Bình Dương',
|
||||
propertyType: 'industrial',
|
||||
period: '6m',
|
||||
includeForecasts: true,
|
||||
includeMacro: true,
|
||||
language: 'vi',
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/reports/generate',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string) as Record<string, unknown>;
|
||||
expect(body.report_type).toBe('industrial_zone');
|
||||
expect(body.location).toBe('Bình Dương');
|
||||
expect(body.property_type).toBe('industrial');
|
||||
expect(body.period).toBe('6m');
|
||||
expect(body.include_forecasts).toBe(true);
|
||||
expect(body.include_macro).toBe(true);
|
||||
expect(body.language).toBe('vi');
|
||||
});
|
||||
|
||||
it('returns error on service failure', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'Service Unavailable',
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'generate_report');
|
||||
const result = await handler({
|
||||
reportType: 'market_overview',
|
||||
location: 'Test',
|
||||
period: '1y',
|
||||
includeForecasts: false,
|
||||
includeMacro: false,
|
||||
language: 'vi',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('Report generation error (503)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_macro_data', () => {
|
||||
it('retrieves GDP and population data', async () => {
|
||||
const mockResponse = {
|
||||
province: 'Bình Dương',
|
||||
data: {
|
||||
gdp: [
|
||||
{ year: 2023, value: 18.5, unit: 'billion USD', yoy_change: 7.2 },
|
||||
{ year: 2024, value: 20.1, unit: 'billion USD', yoy_change: 8.6 },
|
||||
],
|
||||
population: [
|
||||
{ year: 2023, value: 2800000, unit: 'people', yoy_change: 3.1 },
|
||||
{ year: 2024, value: 2890000, unit: 'people', yoy_change: 3.2 },
|
||||
],
|
||||
},
|
||||
highlights: [
|
||||
'GDP growth exceeds national average',
|
||||
'Strong FDI inflow from electronics sector',
|
||||
],
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'get_macro_data');
|
||||
const result = await handler({
|
||||
province: 'Bình Dương',
|
||||
categories: ['gdp', 'population'],
|
||||
fromYear: 2023,
|
||||
toYear: 2024,
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.province).toBe('Bình Dương');
|
||||
const macroData = data.data as Record<string, unknown[]>;
|
||||
expect(macroData.gdp).toHaveLength(2);
|
||||
expect(macroData.population).toHaveLength(2);
|
||||
expect((macroData.gdp[0] as Record<string, unknown>).year).toBe(2023);
|
||||
expect((macroData.gdp[0] as Record<string, unknown>).yoyChange).toBe(7.2);
|
||||
expect(data.highlights).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sends correct request body', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ province: 'Hồ Chí Minh', data: {}, highlights: [] }),
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'get_macro_data');
|
||||
await handler({
|
||||
province: 'Hồ Chí Minh',
|
||||
categories: ['fdi', 'infrastructure'],
|
||||
fromYear: 2020,
|
||||
toYear: 2025,
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string) as Record<string, unknown>;
|
||||
expect(body.province).toBe('Hồ Chí Minh');
|
||||
expect(body.categories).toEqual(['fdi', 'infrastructure']);
|
||||
expect(body.from_year).toBe(2020);
|
||||
expect(body.to_year).toBe(2025);
|
||||
});
|
||||
|
||||
it('returns error on service failure', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => 'Province not found',
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'get_macro_data');
|
||||
const result = await handler({
|
||||
province: 'Unknown',
|
||||
categories: ['gdp'],
|
||||
fromYear: 2020,
|
||||
toYear: 2025,
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('Macro data error (404)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
export { createPropertySearchServer } from './property-search/property-search.server';
|
||||
export { createMarketAnalyticsServer } from './market-analytics/market-analytics.server';
|
||||
export { createValuationServer } from './valuation/valuation.server';
|
||||
export { createIndustrialParksServer } from './industrial-parks/industrial-parks.server';
|
||||
export { createReportsServer } from './reports/reports.server';
|
||||
|
||||
// MCP SDK re-exports (for host apps that build custom controllers)
|
||||
export { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
@@ -16,6 +18,8 @@ export type {
|
||||
PropertySearchDeps,
|
||||
MarketAnalyticsDeps,
|
||||
ValuationDeps,
|
||||
IndustrialParksDeps,
|
||||
ReportsDeps,
|
||||
McpServerConfig,
|
||||
PropertySummary,
|
||||
PropertyComparisonResult,
|
||||
@@ -23,4 +27,5 @@ export type {
|
||||
PriceTrend,
|
||||
ValuationEstimate,
|
||||
FeatureExtractionResult,
|
||||
IndustrialParkSummary,
|
||||
} from './shared/types';
|
||||
|
||||
307
libs/mcp-servers/src/industrial-parks/industrial-parks.server.ts
Normal file
307
libs/mcp-servers/src/industrial-parks/industrial-parks.server.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod/v3';
|
||||
import type { IndustrialParksDeps } from '../shared/types';
|
||||
|
||||
const DEFAULT_COLLECTION = 'industrial_parks';
|
||||
|
||||
const INDUSTRIAL_PROPERTY_TYPES = [
|
||||
'industrial_land',
|
||||
'ready_built_factory',
|
||||
'ready_built_warehouse',
|
||||
'logistics_center',
|
||||
'office_in_park',
|
||||
'data_center',
|
||||
] as const;
|
||||
|
||||
const PARK_STATUSES = ['planning', 'under_construction', 'operational', 'full'] as const;
|
||||
|
||||
const REGIONS = ['north', 'central', 'south'] as const;
|
||||
|
||||
const SearchIndustrialParksSchema = {
|
||||
query: z.string().describe('Natural language search query (e.g. "KCN Bình Dương còn đất trống")'),
|
||||
province: z.string().optional().describe('Filter by province (e.g. "Bình Dương", "Đồng Nai")'),
|
||||
region: z.enum(REGIONS).optional().describe('Filter by region'),
|
||||
status: z.enum(PARK_STATUSES).optional().describe('Filter by park status'),
|
||||
minAreaHa: z.number().optional().describe('Minimum remaining leasable area in hectares'),
|
||||
maxRentUsdM2: z.number().optional().describe('Maximum land rent in USD/m²/year'),
|
||||
targetIndustry: z.string().optional().describe('Target industry (e.g. "electronics", "logistics", "food processing")'),
|
||||
hasReadyBuilt: z.boolean().optional().describe('Filter parks with ready-built factory/warehouse'),
|
||||
latitude: z.number().optional().describe('Center latitude for geo search'),
|
||||
longitude: z.number().optional().describe('Center longitude for geo search'),
|
||||
radiusKm: z.number().optional().describe('Radius in km for geo search'),
|
||||
sortBy: z.enum(['relevance', 'rent_asc', 'rent_desc', 'occupancy_asc', 'area_desc', 'distance']).optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(50).default(20),
|
||||
};
|
||||
|
||||
const AnalyzeIndustrialLocationSchema = {
|
||||
parkName: z.string().optional().describe('Industrial park name to analyze'),
|
||||
latitude: z.number().describe('Latitude of the location'),
|
||||
longitude: z.number().describe('Longitude of the location'),
|
||||
targetIndustry: z.string().optional().describe('Industry type for relevance scoring'),
|
||||
};
|
||||
|
||||
const EstimateIndustrialRentSchema = {
|
||||
province: z.string().describe('Province name'),
|
||||
propertyType: z.enum(INDUSTRIAL_PROPERTY_TYPES).describe('Type of industrial property'),
|
||||
areaM2: z.number().positive().describe('Required area in m²'),
|
||||
leaseDurationYears: z.number().int().min(1).max(70).default(10).describe('Lease duration in years'),
|
||||
parkName: z.string().optional().describe('Specific industrial park name for precise estimate'),
|
||||
requiresCrane: z.boolean().optional().describe('Requires overhead crane'),
|
||||
requiredPowerKva: z.number().optional().describe('Required power capacity in KVA'),
|
||||
requiresWastewater: z.boolean().optional().describe('Requires wastewater treatment'),
|
||||
};
|
||||
|
||||
export function createIndustrialParksServer(deps: IndustrialParksDeps): McpServer {
|
||||
const collectionName = deps.collectionName ?? DEFAULT_COLLECTION;
|
||||
const client = deps.typesenseClient;
|
||||
const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, '');
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'goodgo-industrial-parks',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'search_industrial_parks',
|
||||
'Search Vietnamese industrial parks and zones with filters for province, area, rent, and target industry.',
|
||||
SearchIndustrialParksSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof SearchIndustrialParksSchema>>) => {
|
||||
const filters: string[] = [];
|
||||
|
||||
if (params.province) filters.push(`province:=${params.province}`);
|
||||
if (params.region) filters.push(`region:=${params.region}`);
|
||||
if (params.status) filters.push(`status:=${params.status}`);
|
||||
if (params.minAreaHa != null) filters.push(`remainingAreaHa:>=${params.minAreaHa}`);
|
||||
if (params.maxRentUsdM2 != null) filters.push(`landRentUsdM2Year:<=${params.maxRentUsdM2}`);
|
||||
if (params.targetIndustry) filters.push(`targetIndustries:=${params.targetIndustry}`);
|
||||
if (params.hasReadyBuilt) filters.push(`hasReadyBuilt:=true`);
|
||||
|
||||
let filterBy = filters.join(' && ');
|
||||
|
||||
if (params.latitude != null && params.longitude != null && params.radiusKm) {
|
||||
const geoFilter = `location:(${params.latitude}, ${params.longitude}, ${params.radiusKm} km)`;
|
||||
filterBy = filterBy ? `${filterBy} && ${geoFilter}` : geoFilter;
|
||||
}
|
||||
|
||||
let sortBy = 'occupancyRate:asc';
|
||||
if (params.sortBy === 'rent_asc') sortBy = 'landRentUsdM2Year:asc';
|
||||
else if (params.sortBy === 'rent_desc') sortBy = 'landRentUsdM2Year:desc';
|
||||
else if (params.sortBy === 'occupancy_asc') sortBy = 'occupancyRate:asc';
|
||||
else if (params.sortBy === 'area_desc') sortBy = 'remainingAreaHa:desc';
|
||||
else if (params.sortBy === 'distance' && params.latitude != null && params.longitude != null) {
|
||||
sortBy = `location(${params.latitude}, ${params.longitude}):asc`;
|
||||
} else if (params.sortBy === 'relevance' && params.query) {
|
||||
sortBy = '_text_match:desc,occupancyRate:asc';
|
||||
}
|
||||
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: params.query || '*',
|
||||
query_by: 'name,nameEn,developer,operator,province,targetIndustries',
|
||||
query_by_weights: '5,4,2,2,3,3',
|
||||
filter_by: filterBy || undefined,
|
||||
sort_by: sortBy,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
});
|
||||
|
||||
const hits = (result.hits ?? []).map((hit) => {
|
||||
const doc = hit.document as Record<string, unknown>;
|
||||
return {
|
||||
parkId: doc['parkId'],
|
||||
name: doc['name'],
|
||||
nameEn: doc['nameEn'] ?? null,
|
||||
developer: doc['developer'],
|
||||
province: doc['province'],
|
||||
region: doc['region'],
|
||||
status: doc['status'],
|
||||
totalAreaHa: doc['totalAreaHa'],
|
||||
remainingAreaHa: doc['remainingAreaHa'],
|
||||
occupancyRate: doc['occupancyRate'],
|
||||
landRentUsdM2Year: doc['landRentUsdM2Year'] ?? null,
|
||||
rbfRentUsdM2Month: doc['rbfRentUsdM2Month'] ?? null,
|
||||
rbwRentUsdM2Month: doc['rbwRentUsdM2Month'] ?? null,
|
||||
targetIndustries: doc['targetIndustries'] ?? [],
|
||||
tenantCount: doc['tenantCount'] ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
totalFound: result.found ?? 0,
|
||||
page: params.page,
|
||||
perPage: params.perPage,
|
||||
searchTimeMs: result.search_time_ms ?? 0,
|
||||
results: hits,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'analyze_industrial_location',
|
||||
'Analyze an industrial location for connectivity, infrastructure, and suitability scoring.',
|
||||
AnalyzeIndustrialLocationSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof AnalyzeIndustrialLocationSchema>>) => {
|
||||
const response = await fetch(`${baseUrl}/industrial/analyze-location`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
park_name: params.parkName ?? null,
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
target_industry: params.targetIndustry ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ error: `Location analysis error (${response.status}): ${errorText}` }),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as IndustrialLocationAnalysis;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
overallScore: data.overall_score,
|
||||
connectivity: {
|
||||
nearestPort: data.connectivity?.nearest_port ?? null,
|
||||
nearestAirport: data.connectivity?.nearest_airport ?? null,
|
||||
nearestHighway: data.connectivity?.nearest_highway ?? null,
|
||||
nearestRailway: data.connectivity?.nearest_railway ?? null,
|
||||
},
|
||||
infrastructure: {
|
||||
powerAvailability: data.infrastructure?.power_availability ?? null,
|
||||
waterSupply: data.infrastructure?.water_supply ?? null,
|
||||
wastewaterTreatment: data.infrastructure?.wastewater_treatment ?? null,
|
||||
telecom: data.infrastructure?.telecom ?? null,
|
||||
},
|
||||
laborMarket: {
|
||||
workerPoolRadius30km: data.labor_market?.worker_pool_radius_30km ?? null,
|
||||
averageWageUsd: data.labor_market?.average_wage_usd ?? null,
|
||||
nearbyUniversities: data.labor_market?.nearby_universities ?? [],
|
||||
},
|
||||
incentives: data.incentives ?? [],
|
||||
risks: data.risks ?? [],
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'estimate_industrial_rent',
|
||||
'Estimate rental costs for industrial property based on province, type, area, and requirements.',
|
||||
EstimateIndustrialRentSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof EstimateIndustrialRentSchema>>) => {
|
||||
const response = await fetch(`${baseUrl}/industrial/estimate-rent`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
province: params.province,
|
||||
property_type: params.propertyType,
|
||||
area_m2: params.areaM2,
|
||||
lease_duration_years: params.leaseDurationYears,
|
||||
park_name: params.parkName ?? null,
|
||||
requires_crane: params.requiresCrane ?? false,
|
||||
required_power_kva: params.requiredPowerKva ?? null,
|
||||
requires_wastewater: params.requiresWastewater ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ error: `Rent estimation error (${response.status}): ${errorText}` }),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as IndustrialRentEstimate;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
estimatedRentUsdM2: data.estimated_rent_usd_m2,
|
||||
pricingUnit: data.pricing_unit,
|
||||
totalMonthlyUsd: data.total_monthly_usd,
|
||||
totalLeaseUsd: data.total_lease_usd,
|
||||
managementFeeUsdM2: data.management_fee_usd_m2 ?? null,
|
||||
depositMonths: data.deposit_months ?? null,
|
||||
marketComparison: {
|
||||
provinceLow: data.market_comparison?.province_low ?? null,
|
||||
provinceHigh: data.market_comparison?.province_high ?? null,
|
||||
provinceAvg: data.market_comparison?.province_avg ?? null,
|
||||
},
|
||||
breakdown: data.breakdown ?? [],
|
||||
input: {
|
||||
province: params.province,
|
||||
propertyType: params.propertyType,
|
||||
areaM2: params.areaM2,
|
||||
leaseDurationYears: params.leaseDurationYears,
|
||||
},
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// Response types from the AI service
|
||||
interface IndustrialLocationAnalysis {
|
||||
overall_score: number;
|
||||
connectivity?: {
|
||||
nearest_port?: { name: string; distanceKm: number };
|
||||
nearest_airport?: { name: string; distanceKm: number };
|
||||
nearest_highway?: { name: string; distanceKm: number };
|
||||
nearest_railway?: { name: string; distanceKm: number };
|
||||
};
|
||||
infrastructure?: {
|
||||
power_availability?: string;
|
||||
water_supply?: string;
|
||||
wastewater_treatment?: string;
|
||||
telecom?: string;
|
||||
};
|
||||
labor_market?: {
|
||||
worker_pool_radius_30km?: number;
|
||||
average_wage_usd?: number;
|
||||
nearby_universities?: string[];
|
||||
};
|
||||
incentives?: string[];
|
||||
risks?: string[];
|
||||
}
|
||||
|
||||
interface IndustrialRentEstimate {
|
||||
estimated_rent_usd_m2: number;
|
||||
pricing_unit: string;
|
||||
total_monthly_usd: number;
|
||||
total_lease_usd: number;
|
||||
management_fee_usd_m2?: number;
|
||||
deposit_months?: number;
|
||||
market_comparison?: {
|
||||
province_low?: number;
|
||||
province_high?: number;
|
||||
province_avg?: number;
|
||||
};
|
||||
breakdown?: { item: string; amount: number }[];
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export class McpRegistryService implements OnModuleInit {
|
||||
const { createPropertySearchServer } = await import('../property-search/property-search.server');
|
||||
const { createMarketAnalyticsServer } = await import('../market-analytics/market-analytics.server');
|
||||
const { createValuationServer } = await import('../valuation/valuation.server');
|
||||
const { createIndustrialParksServer } = await import('../industrial-parks/industrial-parks.server');
|
||||
const { createReportsServer } = await import('../reports/reports.server');
|
||||
|
||||
// Typesense client is injected from the host app via setTypesenseClient
|
||||
// If not set by the time servers are needed, tools that require it will fail gracefully
|
||||
@@ -45,6 +47,23 @@ export class McpRegistryService implements OnModuleInit {
|
||||
aiServiceBaseUrl: this.options.aiServiceBaseUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.typesenseClient) {
|
||||
this.servers.set(
|
||||
'industrial-parks',
|
||||
createIndustrialParksServer({
|
||||
typesenseClient: this.typesenseClient,
|
||||
aiServiceBaseUrl: this.options.aiServiceBaseUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.servers.set(
|
||||
'reports',
|
||||
createReportsServer({
|
||||
aiServiceBaseUrl: this.options.aiServiceBaseUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setTypesenseClient(client: TypesenseClient): void {
|
||||
|
||||
195
libs/mcp-servers/src/reports/reports.server.ts
Normal file
195
libs/mcp-servers/src/reports/reports.server.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod/v3';
|
||||
import type { ReportsDeps } from '../shared/types';
|
||||
|
||||
const REPORT_TYPES = [
|
||||
'market_overview',
|
||||
'district_analysis',
|
||||
'industrial_zone',
|
||||
'investment_feasibility',
|
||||
'price_forecast',
|
||||
] as const;
|
||||
|
||||
const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse', 'industrial'] as const;
|
||||
|
||||
const MACRO_DATA_CATEGORIES = [
|
||||
'gdp',
|
||||
'population',
|
||||
'fdi',
|
||||
'infrastructure',
|
||||
'real_estate_index',
|
||||
'construction_permits',
|
||||
'urbanization',
|
||||
] as const;
|
||||
|
||||
const GenerateReportSchema = {
|
||||
reportType: z.enum(REPORT_TYPES).describe('Type of report to generate'),
|
||||
location: z.string().describe('City, district, or province for the report'),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional().describe('Filter by property type'),
|
||||
period: z.enum(['1m', '3m', '6m', '1y', '2y', '5y']).default('1y').describe('Time period for data analysis'),
|
||||
includeForecasts: z.boolean().default(false).describe('Include price/demand forecasts'),
|
||||
includeMacro: z.boolean().default(false).describe('Include macro-economic data in the report'),
|
||||
language: z.enum(['vi', 'en']).default('vi').describe('Report language'),
|
||||
};
|
||||
|
||||
const GetMacroDataSchema = {
|
||||
province: z.string().describe('Province name (e.g. "Bình Dương", "Hồ Chí Minh")'),
|
||||
categories: z.array(z.enum(MACRO_DATA_CATEGORIES)).min(1).describe('Data categories to retrieve'),
|
||||
fromYear: z.number().int().min(2010).default(2020).describe('Start year'),
|
||||
toYear: z.number().int().max(2030).default(2025).describe('End year'),
|
||||
};
|
||||
|
||||
export function createReportsServer(deps: ReportsDeps): McpServer {
|
||||
const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, '');
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'goodgo-reports',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'generate_report',
|
||||
'Generate a comprehensive real estate market report for a given location and property type.',
|
||||
GenerateReportSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof GenerateReportSchema>>) => {
|
||||
const response = await fetch(`${baseUrl}/reports/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
report_type: params.reportType,
|
||||
location: params.location,
|
||||
property_type: params.propertyType ?? null,
|
||||
period: params.period,
|
||||
include_forecasts: params.includeForecasts,
|
||||
include_macro: params.includeMacro,
|
||||
language: params.language,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ error: `Report generation error (${response.status}): ${errorText}` }),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GeneratedReport;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
reportId: data.report_id,
|
||||
reportType: data.report_type,
|
||||
title: data.title,
|
||||
location: data.location,
|
||||
generatedAt: data.generated_at,
|
||||
summary: data.summary,
|
||||
sections: (data.sections ?? []).map((s) => ({
|
||||
title: s.title,
|
||||
content: s.content,
|
||||
charts: s.charts ?? [],
|
||||
})),
|
||||
keyMetrics: data.key_metrics ?? {},
|
||||
forecasts: data.forecasts ?? null,
|
||||
macroData: data.macro_data ?? null,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_macro_data',
|
||||
'Retrieve macro-economic data (GDP, population, FDI, infrastructure) for a Vietnamese province.',
|
||||
GetMacroDataSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof GetMacroDataSchema>>) => {
|
||||
const response = await fetch(`${baseUrl}/reports/macro-data`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
province: params.province,
|
||||
categories: params.categories,
|
||||
from_year: params.fromYear,
|
||||
to_year: params.toYear,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ error: `Macro data error (${response.status}): ${errorText}` }),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MacroDataResponse;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
province: data.province,
|
||||
period: { from: params.fromYear, to: params.toYear },
|
||||
data: Object.fromEntries(
|
||||
Object.entries(data.data ?? {}).map(([category, series]) => [
|
||||
category,
|
||||
(series as MacroDataPoint[]).map((point) => ({
|
||||
year: point.year,
|
||||
value: point.value,
|
||||
unit: point.unit,
|
||||
yoyChange: point.yoy_change ?? null,
|
||||
})),
|
||||
]),
|
||||
),
|
||||
highlights: data.highlights ?? [],
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// Response types from the AI service
|
||||
interface GeneratedReport {
|
||||
report_id: string;
|
||||
report_type: string;
|
||||
title: string;
|
||||
location: string;
|
||||
generated_at: string;
|
||||
summary: string;
|
||||
sections?: {
|
||||
title: string;
|
||||
content: string;
|
||||
charts?: { type: string; title: string; data: unknown }[];
|
||||
}[];
|
||||
key_metrics?: Record<string, unknown>;
|
||||
forecasts?: {
|
||||
price_trend: { period: string; predicted_change_pct: number }[];
|
||||
demand_trend: { period: string; predicted_change_pct: number }[];
|
||||
confidence: number;
|
||||
};
|
||||
macro_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface MacroDataPoint {
|
||||
year: number;
|
||||
value: number;
|
||||
unit: string;
|
||||
yoy_change?: number;
|
||||
}
|
||||
|
||||
interface MacroDataResponse {
|
||||
province: string;
|
||||
data?: Record<string, MacroDataPoint[]>;
|
||||
highlights?: string[];
|
||||
}
|
||||
@@ -14,6 +14,16 @@ export interface ValuationDeps {
|
||||
aiServiceBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface IndustrialParksDeps {
|
||||
typesenseClient: TypesenseClient;
|
||||
collectionName?: string;
|
||||
aiServiceBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface ReportsDeps {
|
||||
aiServiceBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface McpServerConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -70,6 +80,24 @@ export interface ValuationEstimate {
|
||||
priceRangeHigh: number;
|
||||
}
|
||||
|
||||
export interface IndustrialParkSummary {
|
||||
parkId: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
developer: string;
|
||||
province: string;
|
||||
region: string;
|
||||
status: string;
|
||||
totalAreaHa: number;
|
||||
remainingAreaHa: number;
|
||||
occupancyRate: number;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
targetIndustries: string[];
|
||||
tenantCount: number;
|
||||
}
|
||||
|
||||
export interface FeatureExtractionResult {
|
||||
features: {
|
||||
area: number | null;
|
||||
|
||||
Reference in New Issue
Block a user