iconsulting/packages/services/conversation-service/src/infrastructure/claude/tools/immigration-tools.service.ts

1660 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { TenantContextService } from '@iconsulting/shared';
import { ConversationContext } from '../claude-agent.service';
import { KnowledgeClientService } from '../../knowledge/knowledge-client.service';
import { PaymentClientService } from '../../payment/payment-client.service';
import { AssessmentExpertService } from '../../agents/specialists/assessment-expert.service';
import { ConversationORM } from '../../database/postgres/entities/conversation.orm';
import { UserArtifactORM } from '../../database/postgres/entities/user-artifact.orm';
export interface Tool {
name: string;
description: string;
input_schema: {
type: string;
properties: Record<string, unknown>;
required?: string[];
};
}
@Injectable()
export class ImmigrationToolsService {
constructor(
private knowledgeClient: KnowledgeClientService,
private configService: ConfigService,
@InjectRepository(ConversationORM)
private conversationRepo: Repository<ConversationORM>,
@InjectRepository(UserArtifactORM)
private artifactRepo: Repository<UserArtifactORM>,
private tenantContext: TenantContextService,
@Optional() private paymentClient?: PaymentClientService,
@Optional() private assessmentExpert?: AssessmentExpertService,
) {}
/**
* Get all available tools for the agent
*/
getTools(): Tool[] {
return [
{
name: 'search_knowledge',
description: '搜索香港移民相关知识库,获取最新的政策信息和常见问题解答',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: '搜索查询内容',
},
category: {
type: 'string',
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
description: '移民类别代码(可选)',
},
},
required: ['query'],
},
},
{
name: 'check_off_topic',
description: '检查用户的问题是否与香港移民相关,用于判断是否需要拒绝回答',
input_schema: {
type: 'object',
properties: {
question: {
type: 'string',
description: '用户的问题',
},
},
required: ['question'],
},
},
{
name: 'collect_assessment_info',
description: '收集用户的个人信息用于移民资格评估',
input_schema: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
description: '用户感兴趣的移民类别',
},
age: {
type: 'number',
description: '用户年龄',
},
education: {
type: 'string',
description: '最高学历',
},
university: {
type: 'string',
description: '毕业院校',
},
yearsOfExperience: {
type: 'number',
description: '工作年限',
},
currentJobTitle: {
type: 'string',
description: '当前职位',
},
industry: {
type: 'string',
description: '所属行业',
},
annualIncome: {
type: 'number',
description: '年收入(人民币)',
},
hasHKEmployer: {
type: 'boolean',
description: '是否有香港雇主',
},
},
required: ['category'],
},
},
{
name: 'generate_payment',
description: '为付费评估服务生成支付二维码',
input_schema: {
type: 'object',
properties: {
serviceType: {
type: 'string',
enum: ['ASSESSMENT'],
description: '服务类型',
},
category: {
type: 'string',
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
description: '移民类别',
},
paymentMethod: {
type: 'string',
enum: ['ALIPAY', 'WECHAT', 'CREDIT_CARD'],
description: '支付方式',
},
},
required: ['serviceType', 'category', 'paymentMethod'],
},
},
{
name: 'check_payment_status',
description: '查询订单支付状态。当用户询问支付结果、是否支付成功时使用。',
input_schema: {
type: 'object',
properties: {
orderId: {
type: 'string',
description: '订单ID',
},
},
required: ['orderId'],
},
},
{
name: 'query_order_history',
description: '查询用户的历史订单列表。当用户想查看购买记录、订单状态时使用。',
input_schema: {
type: 'object',
properties: {},
},
},
{
name: 'cancel_order',
description: '取消未支付的订单。仅限状态为待支付的订单可以取消。当用户明确表示要取消订单时使用。',
input_schema: {
type: 'object',
properties: {
orderId: {
type: 'string',
description: '要取消的订单ID',
},
},
required: ['orderId'],
},
},
{
name: 'save_user_memory',
description: '保存用户的重要信息到长期记忆,以便后续对话中记住用户情况',
input_schema: {
type: 'object',
properties: {
memoryType: {
type: 'string',
enum: ['FACT', 'PREFERENCE', 'INTENT'],
description: '记忆类型FACT-用户陈述的事实PREFERENCE-用户偏好INTENT-用户意图',
},
content: {
type: 'string',
description: '要记住的内容',
},
category: {
type: 'string',
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
description: '相关的移民类别(可选)',
},
},
required: ['memoryType', 'content'],
},
},
{
name: 'get_user_context',
description: '获取用户的历史背景信息和之前的对话记忆,帮助提供更个性化的建议',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: '当前问题,用于检索相关记忆',
},
},
required: ['query'],
},
},
// ========== 实时工具 ==========
{
name: 'get_current_datetime',
description: '获取当前的日期和时间,用于回答与时间相关的问题',
input_schema: {
type: 'object',
properties: {
timezone: {
type: 'string',
description: '时区,默认为 Asia/Hong_Kong',
},
format: {
type: 'string',
enum: ['full', 'date', 'time'],
description: '返回格式full-完整日期时间date-仅日期time-仅时间',
},
},
},
},
{
name: 'web_search',
description: '搜索互联网获取最新信息,用于查询移民政策变更、新闻动态等实时内容',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: '搜索关键词',
},
site: {
type: 'string',
description: '限定搜索网站,如 immd.gov.hk香港入境处',
},
language: {
type: 'string',
enum: ['zh-CN', 'zh-TW', 'en'],
description: '搜索语言,默认 zh-CN',
},
},
required: ['query'],
},
},
{
name: 'get_exchange_rate',
description: '查询实时货币汇率,用于投资移民金额换算等场景',
input_schema: {
type: 'object',
properties: {
from: {
type: 'string',
description: '源货币代码,如 CNY人民币、USD美元',
},
to: {
type: 'string',
description: '目标货币代码,如 HKD港币',
},
amount: {
type: 'number',
description: '要转换的金额(可选)',
},
},
required: ['from', 'to'],
},
},
{
name: 'fetch_immigration_news',
description: '获取香港移民相关的最新新闻和政策公告',
input_schema: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS', 'ALL'],
description: '移民类别ALL表示所有类别',
},
limit: {
type: 'number',
description: '返回条数默认5条',
},
},
},
},
];
}
/**
* Execute a tool and return the result
*/
async executeTool(
toolName: string,
input: Record<string, unknown>,
context: ConversationContext,
onProgress?: (text: string) => void,
abortSignal?: AbortSignal,
): Promise<unknown> {
console.log(`[Tool] Executing ${toolName} with input:`, JSON.stringify(input));
switch (toolName) {
case 'search_knowledge':
return this.searchKnowledge(input, context);
case 'check_off_topic':
return this.checkOffTopic(input);
case 'collect_assessment_info':
return this.collectAssessmentInfo(input, context);
case 'generate_payment':
return this.generatePayment(input, context);
case 'check_payment_status':
return this.checkPaymentStatus(input);
case 'query_order_history':
return this.queryOrderHistory(context);
case 'cancel_order':
return this.cancelOrder(input);
case 'save_user_memory':
return this.saveUserMemory(input, context);
case 'get_user_context':
return this.getUserContext(input, context);
case 'get_current_datetime':
return this.getCurrentDatetime(input);
case 'web_search':
return this.webSearch(input);
case 'get_exchange_rate':
return this.getExchangeRate(input);
case 'fetch_immigration_news':
return this.fetchImmigrationNews(input);
case 'query_user_profile':
return this.queryUserProfile(context);
case 'generate_document':
return this.generateDocument(input, context);
case 'manage_checklist':
return this.manageChecklist(input, context);
case 'create_timeline':
return this.createTimeline(input, context);
case 'query_user_artifacts':
return this.queryUserArtifacts(input, context);
case 'run_professional_assessment':
return this.runProfessionalAssessment(input, context, onProgress, abortSignal);
default:
return { error: `Unknown tool: ${toolName}` };
}
}
/**
* Search knowledge base - 调用 knowledge-service RAG API
* 级联 Fallback: 知识库(置信度检查) → 网络搜索 → 标注来源的内置知识
*/
private async searchKnowledge(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
const { query, category } = input as { query: string; category?: string };
const RAG_CONFIDENCE_THRESHOLD = 0.55;
console.log(`[Tool:search_knowledge] Query: "${query}", Category: ${category || 'all'}`);
// ── Step 1: 知识库 RAG 搜索 ──
try {
const result = await this.knowledgeClient.retrieveKnowledge({
query,
userId: context.userId,
category,
includeMemories: true,
includeExperiences: true,
});
if (result && result.content) {
const hasConfidentSource = result.sources?.some(
(s) => s.similarity >= RAG_CONFIDENCE_THRESHOLD,
);
if (hasConfidentSource) {
return {
success: true,
content: result.content,
sources: result.sources,
sourceType: 'KNOWLEDGE_BASE',
userContext: result.userMemories,
relatedExperiences: result.systemExperiences,
message: `[来源: 知识库] 找到 ${result.sources?.length || 0} 条相关知识`,
};
}
console.log(`[Tool:search_knowledge] Low confidence scores, cascading to web_search`);
}
} catch (error) {
console.error('[Tool:search_knowledge] Knowledge base error:', error);
}
// ── Step 2: 网络搜索 Fallback ──
try {
const webResult = await this.webSearch({ query: `香港移民 ${query}`, language: 'zh-CN' });
const web = webResult as { success?: boolean; results?: Array<{ title: string; url: string; snippet: string }> };
if (web.success && web.results && web.results.length > 0) {
return {
success: true,
content: web.results
.map((r) => `**${r.title}**\n${r.snippet}\n来源: ${r.url}`)
.join('\n\n'),
sources: web.results.map((r) => ({
title: r.title,
url: r.url,
type: 'web',
})),
sourceType: 'WEB_SEARCH',
message: '[来源: 网络搜索] 知识库未找到高置信度内容,以下来自网络搜索结果,信息仅供参考,请注意核实。',
};
}
} catch (error) {
console.error('[Tool:search_knowledge] Web search fallback failed:', error);
}
// ── Step 3: 内置知识 Fallback明确标注来源 ──
return {
success: false,
content: null,
sourceType: 'BUILT_IN_KNOWLEDGE',
message: '[来源: AI内置知识] 知识库和网络搜索均未找到相关信息。如需回答请务必在回复中明确告知用户此信息基于AI训练数据可能不是最新信息仅供参考。',
};
}
/**
* Check if question is off-topic - 调用 knowledge-service API
*/
private async checkOffTopic(input: Record<string, unknown>): Promise<unknown> {
// coordinator-tools.ts sends `query`, legacy getTools() sent `question`
const question = (input.query as string) || (input.question as string) || '';
console.log(`[Tool:check_off_topic] Checking: "${question}"`);
// 调用 knowledge-service 离题检测 API
const result = await this.knowledgeClient.checkOffTopic(question);
if (result.isOffTopic) {
return {
isOffTopic: true,
confidence: result.confidence,
suggestion: result.reason || '这个问题似乎与香港移民无关。作为移民咨询顾问,我专注于香港各类移民政策的咨询。请问您有香港移民相关的问题吗?',
};
}
return {
isOffTopic: false,
confidence: result.confidence,
suggestion: null,
};
}
/**
* Collect assessment info - 收集评估信息并保存到记忆
*/
private async collectAssessmentInfo(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
// coordinator-tools.ts sends { userId, field, value } (single-field mode)
// Legacy getTools() sent { category, age, education, ... } (batch mode)
// Support both formats
let info: Record<string, unknown>;
if (input.field && input.value) {
// Single-field mode from coordinator-tools.ts
info = { [input.field as string]: input.value };
} else {
// Batch mode from legacy getTools()
const { userId: _userId, ...rest } = input;
info = rest;
}
const category = (info.category as string) || '';
console.log(`[Tool:collect_assessment_info] User ${context.userId} - Fields: ${Object.keys(info).join(', ')}`);
// 保存收集到的信息到用户记忆
const memoryContent = Object.entries(info)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
if (memoryContent) {
await this.knowledgeClient.saveUserMemory({
userId: context.userId,
memoryType: 'FACT',
content: `用户评估信息 - ${memoryContent}`,
sourceConversationId: context.conversationId,
relatedCategory: category,
importance: 80,
});
}
return {
success: true,
collectedInfo: info,
message: '已记录您的信息。如需完整评估,请选择付费评估服务。',
nextStep: 'payment',
};
}
/**
* Generate payment QR code — 调用 payment-service 创建订单并生成支付
*/
private async generatePayment(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
const { serviceType, category, paymentMethod } = input as {
serviceType: string;
category: string;
paymentMethod: string;
};
console.log(
`[Tool:generate_payment] User ${context.userId} - ${serviceType} for ${category} via ${paymentMethod}`,
);
// 记录用户付费意向
await this.knowledgeClient.saveUserMemory({
userId: context.userId,
memoryType: 'INTENT',
content: `用户希望购买${category}类别的${serviceType}服务,选择${paymentMethod}支付`,
sourceConversationId: context.conversationId,
relatedCategory: category,
importance: 90,
});
// 调用 payment-service 创建订单 + 生成支付
if (!this.paymentClient) {
return { success: false, error: '支付服务暂不可用,请稍后重试' };
}
// Step 1: 创建订单
const order = await this.paymentClient.createOrder({
userId: context.userId,
serviceType,
serviceCategory: category,
conversationId: context.conversationId,
});
if (!order) {
return { success: false, error: '创建订单失败,请稍后重试' };
}
// Step 2: 创建支付(生成二维码/支付链接)
const payment = await this.paymentClient.createPayment({
orderId: order.id,
method: paymentMethod,
});
if (!payment) {
return { success: false, error: '生成支付失败,请稍后重试', orderId: order.id };
}
// paymentUrl 是支付链接(短文本),前端用 QRCodeSVG 从它生成二维码
// qrCodeUrl 是 base64 data URL~数KB绝不能传给 AI会被塞进文本回复
// 只返回 paymentUrl永远不返回 qrCodeUrlbase64
return {
success: true,
orderId: order.id,
paymentId: payment.paymentId,
amount: payment.amount,
currency: order.currency,
paymentMethod: payment.method,
paymentUrl: payment.paymentUrl,
expiresAt: payment.expiresAt,
_ui_hint: '前端已自动渲染支付二维码回复中不要包含任何链接、URL或二维码数据',
};
}
/**
* Check payment status — 查询订单支付状态
*/
private async checkPaymentStatus(
input: Record<string, unknown>,
): Promise<unknown> {
const { orderId } = input as { orderId: string };
console.log(`[Tool:check_payment_status] Order: ${orderId}`);
if (!this.paymentClient) {
return { success: false, error: '支付服务暂不可用' };
}
const status = await this.paymentClient.getOrderStatus(orderId);
if (!status) {
return { success: false, error: '查询订单状态失败,请确认订单号是否正确' };
}
const statusLabels: Record<string, string> = {
CREATED: '已创建,等待支付',
PENDING_PAYMENT: '等待支付中',
PAID: '已支付成功',
PROCESSING: '处理中',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDED: '已退款',
};
return {
success: true,
orderId: status.orderId,
status: status.status,
statusLabel: statusLabels[status.status] || status.status,
paidAt: status.paidAt,
completedAt: status.completedAt,
};
}
/**
* Query order history — 查询用户历史订单
*/
private async queryOrderHistory(
context: ConversationContext,
): Promise<unknown> {
console.log(`[Tool:query_order_history] User: ${context.userId}`);
if (!this.paymentClient) {
return { success: false, error: '支付服务暂不可用' };
}
const orders = await this.paymentClient.getUserOrders(context.userId);
return {
success: true,
totalOrders: orders.length,
orders: orders.map((o) => ({
orderId: o.id,
serviceType: o.serviceType,
serviceCategory: o.serviceCategory,
amount: o.amount,
currency: o.currency,
status: o.status,
paidAt: o.paidAt,
createdAt: o.createdAt,
})),
};
}
/**
* Cancel order — 取消未支付的订单
*/
private async cancelOrder(
input: Record<string, unknown>,
): Promise<unknown> {
const { orderId } = input as { orderId: string };
console.log(`[Tool:cancel_order] Order: ${orderId}`);
if (!this.paymentClient) {
return { success: false, error: '支付服务暂不可用' };
}
const result = await this.paymentClient.cancelOrder(orderId);
if (!result) {
return {
success: false,
error: '取消订单失败。只有未支付的订单才能取消,请确认订单状态。',
};
}
return {
success: true,
orderId: result.orderId,
status: result.status,
message: '订单已成功取消',
};
}
/**
* Save user memory - 调用 knowledge-service Memory API
*/
private async saveUserMemory(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
const { memoryType, content, category } = input as {
memoryType: 'FACT' | 'PREFERENCE' | 'INTENT';
content: string;
category?: string;
};
console.log(
`[Tool:save_user_memory] User ${context.userId} - Type: ${memoryType}, Content: "${content}"`,
);
// 调用 knowledge-service Memory API
const memory = await this.knowledgeClient.saveUserMemory({
userId: context.userId,
memoryType,
content,
sourceConversationId: context.conversationId,
relatedCategory: category,
importance: memoryType === 'FACT' ? 70 : memoryType === 'INTENT' ? 80 : 60,
});
if (memory) {
return {
success: true,
memoryId: memory.id,
message: '已记住您的信息,下次对话时我会记得',
};
}
return {
success: false,
message: '记忆保存失败,但不影响当前对话',
};
}
/**
* Get user context - 获取用户历史背景
*/
private async getUserContext(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
const { query } = input as { query: string };
console.log(`[Tool:get_user_context] User ${context.userId} - Query: "${query}"`);
// 并行获取相关记忆和重要记忆
const [relevantMemories, topMemories] = await Promise.all([
this.knowledgeClient.searchUserMemories({
userId: context.userId,
query,
limit: 3,
}),
this.knowledgeClient.getUserTopMemories(context.userId, 5),
]);
const allMemories = [...relevantMemories];
// 添加不重复的重要记忆
for (const mem of topMemories) {
if (!allMemories.find(m => m.id === mem.id)) {
allMemories.push(mem);
}
}
if (allMemories.length > 0) {
return {
success: true,
memories: allMemories.map(m => ({
type: m.memoryType,
content: m.content,
importance: m.importance,
})),
message: `找到 ${allMemories.length} 条用户背景信息`,
};
}
return {
success: true,
memories: [],
message: '这是新用户,暂无历史信息',
};
}
// ========== 实时工具实现 ==========
/**
* Get current datetime - 获取当前日期时间
*/
private getCurrentDatetime(input: Record<string, unknown>): unknown {
const { timezone = 'Asia/Hong_Kong', format = 'full' } = input as {
timezone?: string;
format?: 'full' | 'date' | 'time';
};
console.log(`[Tool:get_current_datetime] Timezone: ${timezone}, Format: ${format}`);
const now = new Date();
const options: Intl.DateTimeFormatOptions = {
timeZone: timezone,
};
let result: string;
switch (format) {
case 'date':
options.year = 'numeric';
options.month = 'long';
options.day = 'numeric';
options.weekday = 'long';
result = now.toLocaleDateString('zh-CN', options);
break;
case 'time':
options.hour = '2-digit';
options.minute = '2-digit';
options.second = '2-digit';
options.hour12 = false;
result = now.toLocaleTimeString('zh-CN', options);
break;
case 'full':
default:
options.year = 'numeric';
options.month = 'long';
options.day = 'numeric';
options.weekday = 'long';
options.hour = '2-digit';
options.minute = '2-digit';
options.second = '2-digit';
options.hour12 = false;
result = now.toLocaleString('zh-CN', options);
break;
}
return {
success: true,
datetime: result,
timezone,
timestamp: now.toISOString(),
message: `当前${timezone}时间: ${result}`,
};
}
/**
* Web search - 搜索互联网获取最新信息
* 使用 Google Custom Search API 或其他搜索服务
*/
private async webSearch(input: Record<string, unknown>): Promise<unknown> {
const { query, site, language = 'zh-CN' } = input as {
query: string;
site?: string;
language?: string;
};
console.log(`[Tool:web_search] Query: "${query}", Site: ${site || 'any'}, Language: ${language}`);
// 构建搜索查询
let searchQuery = query;
if (site) {
searchQuery = `site:${site} ${query}`;
}
// 尝试使用 Google Custom Search API
const googleApiKey = this.configService.get<string>('GOOGLE_SEARCH_API_KEY');
const googleCseId = this.configService.get<string>('GOOGLE_CSE_ID');
if (googleApiKey && googleCseId) {
try {
const url = new URL('https://www.googleapis.com/customsearch/v1');
url.searchParams.set('key', googleApiKey);
url.searchParams.set('cx', googleCseId);
url.searchParams.set('q', searchQuery);
url.searchParams.set('lr', `lang_${language.split('-')[0]}`);
url.searchParams.set('num', '5');
const response = await fetch(url.toString());
if (response.ok) {
const data = (await response.json()) as { items?: Array<{ title: string; link: string; snippet: string }> };
const results = (data.items || []).map((item: { title: string; link: string; snippet: string }) => ({
title: item.title,
url: item.link,
snippet: item.snippet,
}));
return {
success: true,
query: searchQuery,
results,
resultCount: results.length,
message: `找到 ${results.length} 条相关结果`,
};
}
} catch (error) {
console.error('[Tool:web_search] Google API error:', error);
}
}
// 降级:返回建议用户自行搜索的信息
return {
success: false,
query: searchQuery,
results: [],
message: '搜索服务暂时不可用。建议您访问以下官方网站获取最新信息:',
suggestedSites: [
{ name: '香港入境事务处', url: 'https://www.immd.gov.hk' },
{ name: '香港人才服务窗口', url: 'https://www.talentservicewindow.gov.hk' },
{ name: '投资推广署', url: 'https://www.investhk.gov.hk' },
],
};
}
/**
* Get exchange rate - 获取实时汇率
* 使用免费的汇率 API
*/
private async getExchangeRate(input: Record<string, unknown>): Promise<unknown> {
const { from, to, amount } = input as {
from: string;
to: string;
amount?: number;
};
console.log(`[Tool:get_exchange_rate] From: ${from}, To: ${to}, Amount: ${amount || 'N/A'}`);
// 使用 Exchange Rate API (免费层)
const apiKey = this.configService.get<string>('EXCHANGE_RATE_API_KEY');
const baseUrl = apiKey
? `https://v6.exchangerate-api.com/v6/${apiKey}/pair/${from}/${to}`
: `https://api.exchangerate-api.com/v4/latest/${from}`;
try {
const response = await fetch(baseUrl);
if (response.ok) {
const data = (await response.json()) as {
conversion_rate?: number;
rates?: Record<string, number>;
time_last_update_utc?: string;
};
let rate: number | undefined;
if (apiKey) {
// v6 API response
rate = data.conversion_rate;
} else {
// v4 API response (free, no key needed)
rate = data.rates?.[to];
}
if (rate) {
const convertedAmount = amount ? amount * rate : null;
return {
success: true,
from,
to,
rate,
amount: amount || null,
convertedAmount,
lastUpdate: data.time_last_update_utc || new Date().toISOString(),
message: amount
? `${amount} ${from} = ${convertedAmount?.toFixed(2)} ${to} (汇率: ${rate.toFixed(4)})`
: `1 ${from} = ${rate.toFixed(4)} ${to}`,
};
}
}
} catch (error) {
console.error('[Tool:get_exchange_rate] API error:', error);
}
// 降级:使用固定参考汇率
const fallbackRates: Record<string, Record<string, number>> = {
CNY: { HKD: 1.08, USD: 0.14 },
USD: { HKD: 7.78, CNY: 7.24 },
HKD: { CNY: 0.93, USD: 0.13 },
};
const fallbackRate = fallbackRates[from]?.[to];
if (fallbackRate) {
const convertedAmount = amount ? amount * fallbackRate : null;
return {
success: true,
from,
to,
rate: fallbackRate,
amount: amount || null,
convertedAmount,
isEstimate: true,
message: `参考汇率 (可能有偏差): 1 ${from}${fallbackRate} ${to}${amount ? `${amount} ${from}${convertedAmount?.toFixed(2)} ${to}` : ''}`,
};
}
return {
success: false,
message: `暂不支持 ${from}${to} 的汇率查询`,
};
}
/**
* Fetch immigration news - 获取移民相关新闻
* 从官方渠道或新闻源获取
*/
private async fetchImmigrationNews(input: Record<string, unknown>): Promise<unknown> {
const { category = 'ALL', limit = 5 } = input as {
category?: string;
limit?: number;
};
console.log(`[Tool:fetch_immigration_news] Category: ${category}, Limit: ${limit}`);
// 尝试从 knowledge-service 获取最新新闻(如果有此功能)
try {
const response = await fetch(
`${this.configService.get<string>('KNOWLEDGE_SERVICE_URL') || 'http://knowledge-service:3003'}/api/v1/news/immigration?category=${category}&limit=${limit}`,
);
if (response.ok) {
const data = (await response.json()) as { success?: boolean; data?: Array<unknown> };
if (data.success && data.data) {
return {
success: true,
category,
news: data.data,
message: `获取到 ${data.data.length}${category === 'ALL' ? '' : category + '相关'}移民新闻`,
};
}
}
} catch {
console.log('[Tool:fetch_immigration_news] Knowledge service news not available');
}
// 降级:返回静态参考信息和官方渠道
const categoryNews: Record<string, Array<{ title: string; summary: string; date: string; source: string }>> = {
QMAS: [
{
title: '优才计划持续接受申请',
summary: '香港优才计划无配额限制,全年接受申请。申请人需符合基本资格要求,并通过计分制评核。',
date: '2024-01',
source: '香港入境事务处',
},
],
TTPS: [
{
title: '高才通计划最新动态',
summary: '高端人才通行证计划持续运作A/B/C三类申请通道开放。',
date: '2024-01',
source: '香港入境事务处',
},
],
CIES: [
{
title: '新资本投资者入境计划',
summary: '投资门槛为3000万港币需投资于许可资产类别。',
date: '2024-03',
source: '香港入境事务处',
},
],
};
const newsItems = category === 'ALL'
? Object.values(categoryNews).flat()
: categoryNews[category] || [];
return {
success: true,
category,
news: newsItems.slice(0, limit),
isStatic: true,
message: newsItems.length > 0
? `以下是${category === 'ALL' ? '' : category + '类别'}的参考信息。如需最新资讯,请访问香港入境事务处官网。`
: '暂无相关新闻,建议访问香港入境事务处官网获取最新信息。',
officialSources: [
{ name: '香港入境事务处', url: 'https://www.immd.gov.hk/hks/services/visas/admission_talents.html' },
{ name: '香港人才服务窗口', url: 'https://www.talentservicewindow.gov.hk' },
],
};
}
/**
* Query user profile — 查询用户在系统中的完整信息档案
* 包括咨询次数、对话主题、用户记忆等
*/
private async queryUserProfile(
context: ConversationContext,
): Promise<unknown> {
console.log(`[Tool:query_user_profile] User: ${context.userId}`);
const tenantId = this.tenantContext.getCurrentTenantId() || '';
// 并行查询:对话历史 + 用户记忆 + 订单历史
const [conversations, topMemories, orders] = await Promise.all([
// 查询该用户的所有非删除对话
this.conversationRepo.find({
where: {
userId: context.userId,
tenantId,
status: Not('DELETED'),
},
order: { createdAt: 'DESC' },
select: ['id', 'title', 'status', 'category', 'messageCount', 'consultingStage', 'createdAt', 'updatedAt'],
}),
// 获取用户最重要的记忆
this.knowledgeClient.getUserTopMemories(context.userId, 15).catch(() => []),
// 获取订单历史
this.paymentClient?.getUserOrders(context.userId).catch(() => []) ?? Promise.resolve([]),
]);
// 统计信息
const totalConsultations = conversations.length;
const firstConsultation = conversations.length > 0
? conversations[conversations.length - 1].createdAt
: null;
const lastConsultation = conversations.length > 0
? conversations[0].createdAt
: null;
// 对话主题摘要(最近 10 个)
const recentConversations = conversations.slice(0, 10).map(c => ({
title: c.title,
category: c.category || '未分类',
stage: c.consultingStage || '未知',
messageCount: c.messageCount,
date: c.createdAt,
}));
// 分类统计
const categoryStats: Record<string, number> = {};
for (const c of conversations) {
const cat = c.category || '未分类';
categoryStats[cat] = (categoryStats[cat] || 0) + 1;
}
// 用户记忆分类
const facts = topMemories.filter(m => m.memoryType === 'FACT');
const preferences = topMemories.filter(m => m.memoryType === 'PREFERENCE');
const intents = topMemories.filter(m => m.memoryType === 'INTENT');
// 订单统计
const orderStats = {
total: orders.length,
paid: orders.filter((o: any) => o.status === 'PAID' || o.status === 'COMPLETED').length,
pending: orders.filter((o: any) => o.status === 'CREATED' || o.status === 'PENDING_PAYMENT').length,
};
return {
success: true,
profile: {
// 咨询统计
totalConsultations,
currentConsultationNumber: totalConsultations, // "这是第 N 次咨询"
firstConsultationDate: firstConsultation,
lastConsultationDate: lastConsultation,
categoryDistribution: categoryStats,
// 最近对话
recentConversations,
// 用户画像(来自记忆系统)
userFacts: facts.map(m => m.content),
userPreferences: preferences.map(m => m.content),
userIntents: intents.map(m => m.content),
// 订单统计
orderStats,
},
message: `用户已累计咨询 ${totalConsultations} 次,系统记录了 ${topMemories.length} 条用户信息`,
};
}
// ========== 任务执行工具 ==========
/**
* Generate document — 生成结构化文档并持久化
*/
private async generateDocument(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
const { title, content, documentType } = input as {
title: string;
content: string;
documentType: string;
};
console.log(`[Tool:generate_document] User ${context.userId} - "${title}" (${documentType})`);
const tenantId = this.tenantContext.getCurrentTenantId() || '';
// 检查是否已存在同标题同类型的工件 → 更新而非重复创建
const existing = await this.artifactRepo.findOne({
where: { userId: context.userId, tenantId, artifactType: 'document', title },
});
if (existing) {
existing.content = content;
existing.documentType = documentType;
existing.conversationId = context.conversationId;
await this.artifactRepo.save(existing);
return {
success: true,
artifactId: existing.id,
title,
content,
documentType,
updated: true,
createdAt: existing.createdAt.toISOString(),
updatedAt: new Date().toISOString(),
_ui_hint: '前端已渲染文档卡片,回复中简要说明文档内容即可,不要重复文档全文',
};
}
const artifact = this.artifactRepo.create({
tenantId,
userId: context.userId,
conversationId: context.conversationId,
artifactType: 'document',
title,
documentType,
content,
});
const saved = await this.artifactRepo.save(artifact);
return {
success: true,
artifactId: saved.id,
title,
content,
documentType,
updated: false,
createdAt: saved.createdAt.toISOString(),
_ui_hint: '前端已渲染文档卡片,回复中简要说明文档内容即可,不要重复文档全文',
};
}
/**
* Manage checklist — 创建/更新待办事项清单并持久化
*/
private async manageChecklist(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
const { title, items } = input as {
title: string;
items: Array<{ text: string; completed?: boolean; category?: string; note?: string }>;
};
console.log(`[Tool:manage_checklist] User ${context.userId} - "${title}" (${items.length} items)`);
const tenantId = this.tenantContext.getCurrentTenantId() || '';
const normalized = items.map(item => ({
...item,
completed: item.completed ?? false,
}));
// 检查是否已存在同标题的清单 → 更新
const existing = await this.artifactRepo.findOne({
where: { userId: context.userId, tenantId, artifactType: 'checklist', title },
});
if (existing) {
existing.content = JSON.stringify(normalized);
existing.conversationId = context.conversationId;
await this.artifactRepo.save(existing);
return {
success: true,
artifactId: existing.id,
title,
items: normalized,
totalItems: normalized.length,
completedCount: normalized.filter(i => i.completed).length,
updated: true,
_ui_hint: '前端已渲染清单卡片,回复中简要说明清单用途即可',
};
}
const artifact = this.artifactRepo.create({
tenantId,
userId: context.userId,
conversationId: context.conversationId,
artifactType: 'checklist',
title,
documentType: null,
content: JSON.stringify(normalized),
});
const saved = await this.artifactRepo.save(artifact);
return {
success: true,
artifactId: saved.id,
title,
items: normalized,
totalItems: normalized.length,
completedCount: normalized.filter(i => i.completed).length,
updated: false,
_ui_hint: '前端已渲染清单卡片,回复中简要说明清单用途即可',
};
}
/**
* Create timeline — 创建/更新时间线并持久化
*/
private async createTimeline(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
const { title, milestones } = input as {
title: string;
milestones: Array<{ title: string; description: string; duration?: string; status?: string }>;
};
console.log(`[Tool:create_timeline] User ${context.userId} - "${title}" (${milestones.length} milestones)`);
const tenantId = this.tenantContext.getCurrentTenantId() || '';
const normalized = milestones.map(m => ({
...m,
status: m.status || 'pending',
}));
// 检查是否已存在同标题的时间线 → 更新
const existing = await this.artifactRepo.findOne({
where: { userId: context.userId, tenantId, artifactType: 'timeline', title },
});
if (existing) {
existing.content = JSON.stringify(normalized);
existing.conversationId = context.conversationId;
await this.artifactRepo.save(existing);
return {
success: true,
artifactId: existing.id,
title,
milestones: normalized,
totalSteps: normalized.length,
updated: true,
_ui_hint: '前端已渲染时间线卡片,回复中简要总结时间规划即可',
};
}
const artifact = this.artifactRepo.create({
tenantId,
userId: context.userId,
conversationId: context.conversationId,
artifactType: 'timeline',
title,
documentType: null,
content: JSON.stringify(normalized),
});
const saved = await this.artifactRepo.save(artifact);
return {
success: true,
artifactId: saved.id,
title,
milestones: normalized,
totalSteps: normalized.length,
updated: false,
_ui_hint: '前端已渲染时间线卡片,回复中简要总结时间规划即可',
};
}
/**
* Query user artifacts — 查询用户的所有工件(文档、清单、时间线)
*/
private async queryUserArtifacts(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
const { artifactType } = input as { artifactType?: string };
console.log(`[Tool:query_user_artifacts] User ${context.userId} - Type: ${artifactType || 'all'}`);
const tenantId = this.tenantContext.getCurrentTenantId() || '';
const where: Record<string, unknown> = {
userId: context.userId,
tenantId,
};
if (artifactType) {
where.artifactType = artifactType;
}
const artifacts = await this.artifactRepo.find({
where,
order: { updatedAt: 'DESC' },
take: 20,
});
return {
success: true,
totalArtifacts: artifacts.length,
artifacts: artifacts.map(a => ({
artifactId: a.id,
artifactType: a.artifactType,
title: a.title,
documentType: a.documentType,
updatedAt: a.updatedAt.toISOString(),
createdAt: a.createdAt.toISOString(),
// For checklists/timelines, parse the JSON content to provide summary
...(a.artifactType === 'checklist' ? (() => {
try {
const items = JSON.parse(a.content) as Array<{ completed?: boolean }>;
return { totalItems: items.length, completedCount: items.filter(i => i.completed).length };
} catch { return {}; }
})() : {}),
...(a.artifactType === 'timeline' ? (() => {
try {
const milestones = JSON.parse(a.content) as Array<unknown>;
return { totalSteps: milestones.length };
} catch { return {}; }
})() : {}),
...(a.artifactType === 'document' ? { contentPreview: a.content.slice(0, 100) + (a.content.length > 100 ? '...' : '') } : {}),
})),
message: artifacts.length > 0
? `找到 ${artifacts.length} 个已保存的工件`
: '暂无已保存的工件',
};
}
/**
* Run professional assessment — 完整的付费评估管线
*
* Pipeline:
* 1. Check existing assessment (avoid duplicate within 30 days)
* 2. Validate payment (ASSESSMENT order with PAID/COMPLETED status)
* 3. Validate info completeness (age, nationality, education_level, work_experience_years)
* 4. Call AssessmentExpertService.executeAssessment()
* 5. Persist result as UserArtifact (artifactType: 'assessment_report')
*/
private async runProfessionalAssessment(
input: Record<string, unknown>,
context: ConversationContext,
onProgress?: (text: string) => void,
abortSignal?: AbortSignal,
): Promise<unknown> {
const { userInfo, targetCategories, conversationContext, forceReassess } = input as {
userInfo: Record<string, unknown>;
targetCategories?: string[];
conversationContext?: string;
forceReassess?: boolean;
};
const tenantId = this.tenantContext.getCurrentTenantId() || '';
const userId = context.userId;
console.log(`[Tool:run_professional_assessment] User ${userId} - Starting assessment pipeline (force=${!!forceReassess})`);
// ── Step 1: Check existing assessment ──
const existingReport = await this.artifactRepo.findOne({
where: { userId, tenantId, artifactType: 'assessment_report' },
order: { updatedAt: 'DESC' },
});
if (existingReport && !forceReassess) {
const daysSinceReport = (Date.now() - existingReport.updatedAt.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceReport < 30) {
let parsedContent: unknown = null;
try { parsedContent = JSON.parse(existingReport.content); } catch { /* leave null */ }
console.log(`[Tool:run_professional_assessment] Existing report found (${daysSinceReport.toFixed(1)} days old)`);
return {
status: 'already_assessed',
message: `您在 ${Math.floor(daysSinceReport)} 天前已完成专业评估。如需重新评估请30天后再试或联系顾问手动更新。`,
existingReport: parsedContent,
artifactId: existingReport.id,
assessedAt: existingReport.updatedAt.toISOString(),
_ui_hint: '前端已渲染评估报告卡片',
};
}
console.log(`[Tool:run_professional_assessment] Existing report is ${daysSinceReport.toFixed(1)} days old, allowing re-assessment`);
}
if (forceReassess && existingReport) {
console.log(`[Tool:run_professional_assessment] Force re-assessment requested — skipping 30-day check`);
}
// ── Step 2: Validate payment ──
if (!this.paymentClient) {
return {
status: 'payment_service_unavailable',
message: '支付服务暂时不可用,请稍后再试。',
};
}
const orders = await this.paymentClient.getUserOrders(userId);
const paidAssessmentOrder = orders.find(
(o) => o.serviceType === 'ASSESSMENT' && (o.status === 'PAID' || o.status === 'COMPLETED'),
);
if (!paidAssessmentOrder) {
const pendingOrder = orders.find(
(o) => o.serviceType === 'ASSESSMENT' && (o.status === 'CREATED' || o.status === 'PENDING_PAYMENT'),
);
return {
status: 'payment_required',
message: '专业评估服务需要支付99元评估费一次性终身有效。请先完成支付后再进行评估。',
hasPendingOrder: !!pendingOrder,
pendingOrderId: pendingOrder?.id || null,
_ui_hint: '提示用户付费,可使用 generate_payment 工具生成支付链接',
};
}
// ── Step 3: Validate info completeness ──
const REQUIRED_FIELDS = ['age', 'nationality', 'education_level', 'work_experience_years'];
const FIELD_LABELS: Record<string, string> = {
age: '年龄',
nationality: '国籍/户籍',
education_level: '最高学历',
work_experience_years: '工作年限',
};
const missingFields: string[] = [];
const collectedFields: string[] = [];
for (const field of REQUIRED_FIELDS) {
if (userInfo[field] !== undefined && userInfo[field] !== null && userInfo[field] !== '') {
collectedFields.push(field);
} else {
missingFields.push(field);
}
}
if (missingFields.length > 0) {
return {
status: 'info_incomplete',
message: `还需要以下信息才能进行准确评估:${missingFields.map(f => FIELD_LABELS[f] || f).join('、')}`,
missingFields,
missingFieldLabels: missingFields.map(f => FIELD_LABELS[f] || f),
collectedFields,
_ui_hint: '提示用户提供缺失信息',
};
}
// ── Step 4: Run assessment expert ──
if (!this.assessmentExpert) {
return {
status: 'assessment_error',
message: '评估服务暂时不可用,请稍后再试。',
};
}
console.log(`[Tool:run_professional_assessment] Payment verified, info complete. Running assessment...`);
let assessmentResult: string;
try {
const opts = (onProgress || abortSignal) ? { onProgress, abortSignal } : undefined;
assessmentResult = await this.assessmentExpert.executeAssessment({
userInfo,
targetCategories,
conversationContext,
}, opts);
} catch (error) {
console.error('[Tool:run_professional_assessment] Assessment expert failed:', error);
return {
status: 'assessment_error',
message: '评估过程中出现错误,请稍后重试。如问题持续,请联系客服。',
};
}
// Parse the JSON result from assessment expert
let parsedResult: unknown;
try {
parsedResult = typeof assessmentResult === 'string'
? JSON.parse(assessmentResult)
: assessmentResult;
} catch {
console.error('[Tool:run_professional_assessment] Failed to parse assessment result');
parsedResult = null;
}
// ── Step 4.5: QMAS Threshold Post-validation ──
// Safety net: ensure assessment expert's QMAS score is consistent with 12-item threshold check
const pr = parsedResult as Record<string, any> | null;
if (pr?.assessments && Array.isArray(pr.assessments)) {
const qmasAssessment = pr.assessments.find((a: any) => a.category === 'QMAS');
if (qmasAssessment) {
const tc = qmasAssessment.thresholdCheck;
if (tc && typeof tc.metCount === 'number' && typeof tc.requiredCount === 'number') {
const passed = tc.metCount >= tc.requiredCount;
tc.passed = passed;
if (!passed && qmasAssessment.score > 29) {
console.warn(
`[QMAS Threshold] Score ${qmasAssessment.score} inconsistent with failed threshold ` +
`(${tc.metCount}/${tc.requiredCount}). Capping to 29.`,
);
qmasAssessment.score = 29;
qmasAssessment.eligible = false;
if (!qmasAssessment.concerns) qmasAssessment.concerns = [];
qmasAssessment.concerns.unshift(
`基本门槛评核未通过:仅满足 ${tc.metCount}/12 项需至少6项`,
);
}
if (passed && !qmasAssessment.eligible && qmasAssessment.score > 29) {
qmasAssessment.eligible = true;
}
} else {
console.warn('[QMAS Threshold] Assessment expert did not return valid thresholdCheck for QMAS');
}
}
}
// ── Step 5: Persist as artifact ──
const contentToSave = parsedResult ? JSON.stringify(parsedResult) : assessmentResult;
const title = '移民资格评估报告';
if (existingReport) {
// Update existing report (re-assessment after 30+ days)
existingReport.content = contentToSave;
existingReport.conversationId = context.conversationId;
await this.artifactRepo.save(existingReport);
console.log(`[Tool:run_professional_assessment] Updated existing report: ${existingReport.id}`);
return {
status: 'completed',
assessment: parsedResult || assessmentResult,
artifactId: existingReport.id,
isReassessment: true,
paidOrderId: paidAssessmentOrder.id,
assessedAt: new Date().toISOString(),
_ui_hint: '前端已渲染评估报告卡片。评估完成后建议自动创建材料清单和时间线。',
};
}
const artifact = this.artifactRepo.create({
tenantId,
userId,
conversationId: context.conversationId,
artifactType: 'assessment_report',
title,
documentType: 'report',
content: contentToSave,
});
const saved = await this.artifactRepo.save(artifact);
console.log(`[Tool:run_professional_assessment] Assessment saved: ${saved.id}`);
return {
status: 'completed',
assessment: parsedResult || assessmentResult,
artifactId: saved.id,
isReassessment: false,
paidOrderId: paidAssessmentOrder.id,
assessedAt: new Date().toISOString(),
_ui_hint: '前端已渲染评估报告卡片。评估完成后建议自动创建材料清单和时间线。',
};
}
}