1660 lines
54 KiB
TypeScript
1660 lines
54 KiB
TypeScript
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,永远不返回 qrCodeUrl(base64)
|
||
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: '前端已渲染评估报告卡片。评估完成后建议自动创建材料清单和时间线。',
|
||
};
|
||
}
|
||
}
|