feat(agents): add query_user_profile tool for user info lookup

新增 query_user_profile 工具,让 AI agent 能回答用户关于自身信息的查询,
例如"这是我第几次咨询?"、"你记得我的信息吗?"、"我之前咨询过什么?"

## 问题背景
当用户问"这是我第几次跟你咨询?"时,AI 无法回答,因为没有任何工具
能查询用户的历史咨询数据。

## 实现方案:双层设计

### 第一层:被动上下文注入(Context Injector)
- context-injector.service.ts 注入 ConversationORM repo + TenantContextService
- buildConversationStatsBlock() 现在自动查询用户累计咨询次数
- 每次对话自动注入 `用户累计咨询次数: N 次(含本次对话)`
- 简单问题("这是第几次?")AI 可直接从上下文回答,零工具调用

### 第二层:主动工具调用(query_user_profile)
用户需要详细信息时,AI 调用此工具,返回完整档案:
- 咨询统计:累计次数、首次/最近咨询时间、类别分布
- 最近对话:最近 10 个对话的标题、类别、阶段
- 用户画像:系统记忆中的事实(学历/年龄/职业)、偏好、意图
- 订单统计:总单数、已支付、待支付

## 修改文件
- agents.module.ts: 添加 ConversationORM 到 TypeORM imports
- coordinator-tools.ts: 新增 query_user_profile 工具定义(只读)
- immigration-tools.service.ts: 注入 ConversationORM repo + TenantContextService,
  实现 queryUserProfile() 方法(并行查询对话+记忆+订单)
- coordinator-system-prompt.ts: 第3.3节添加工具文档和使用指引
- context-injector.service.ts: 注入 repo,conversation_stats 块添加累计咨询次数

## 依赖关系
- 无循环依赖:直接使用 TypeORM Repository<ConversationORM>(数据访问层),
  不依赖 ConversationService(避免 AgentsModule ↔ ConversationModule 循环)
- TenantContextService 全局可用,确保多租户隔离

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-07 12:17:23 -08:00
parent 389f975e33
commit 43d4102e1f
5 changed files with 155 additions and 3 deletions

View File

@ -32,6 +32,7 @@ import { TokenUsageService } from '../claude/token-usage.service';
import { ImmigrationToolsService } from '../claude/tools/immigration-tools.service'; import { ImmigrationToolsService } from '../claude/tools/immigration-tools.service';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm'; import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm';
import { ConversationORM } from '../database/postgres/entities/conversation.orm';
// MCP Integration // MCP Integration
import { McpModule } from './mcp/mcp.module'; import { McpModule } from './mcp/mcp.module';
@ -75,7 +76,7 @@ const AnthropicClientProvider = {
imports: [ imports: [
ConfigModule, ConfigModule,
KnowledgeModule, KnowledgeModule,
TypeOrmModule.forFeature([TokenUsageORM, EvaluationRuleORM]), TypeOrmModule.forFeature([TokenUsageORM, EvaluationRuleORM, ConversationORM]),
McpModule, McpModule,
PaymentModule, PaymentModule,
], ],

View File

@ -14,6 +14,9 @@
*/ */
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { TenantContextService } from '@iconsulting/shared';
import Anthropic from '@anthropic-ai/sdk'; import Anthropic from '@anthropic-ai/sdk';
import { import {
ContextType, ContextType,
@ -27,6 +30,7 @@ import {
} from '../types/context.types'; } from '../types/context.types';
import { ClaudeMessage } from '../types/agent.types'; import { ClaudeMessage } from '../types/agent.types';
import { KnowledgeClientService } from '../../knowledge/knowledge-client.service'; import { KnowledgeClientService } from '../../knowledge/knowledge-client.service';
import { ConversationORM } from '../../database/postgres/entities/conversation.orm';
@Injectable() @Injectable()
export class ContextInjectorService { export class ContextInjectorService {
@ -36,6 +40,9 @@ export class ContextInjectorService {
constructor( constructor(
private readonly knowledgeClient: KnowledgeClientService, private readonly knowledgeClient: KnowledgeClientService,
@InjectRepository(ConversationORM)
private readonly conversationRepo: Repository<ConversationORM>,
private readonly tenantContext: TenantContextService,
) {} ) {}
/** /**
@ -163,12 +170,28 @@ export class ContextInjectorService {
? Math.round((Date.now() - new Date(firstMsgTime).getTime()) / 60000) ? Math.round((Date.now() - new Date(firstMsgTime).getTime()) / 60000)
: 0; : 0;
// 查询用户累计咨询次数
let totalConsultations = 0;
try {
const tenantId = this.tenantContext.getCurrentTenantId() || '';
totalConsultations = await this.conversationRepo.count({
where: {
userId: ctx.userId,
tenantId,
status: Not('DELETED'),
},
});
} catch (err) {
this.logger.warn(`Failed to count consultations: ${err}`);
}
const content = [ const content = [
`<conversation_stats>`, `<conversation_stats>`,
`对话统计: ${userMsgs} 条用户消息, ${assistantMsgs} 条助手消息, 持续 ${durationMin} 分钟`, `本次对话: ${userMsgs} 条用户消息, ${assistantMsgs} 条助手消息, 持续 ${durationMin} 分钟`,
`当前阶段: ${ctx.consultingState?.currentStage || '未知'}`, `当前阶段: ${ctx.consultingState?.currentStage || '未知'}`,
totalConsultations > 0 ? `用户累计咨询次数: ${totalConsultations} 次(含本次对话)` : '',
`</conversation_stats>`, `</conversation_stats>`,
].join('\n'); ].filter(Boolean).join('\n');
return { return {
contextType: ContextType.CONVERSATION_STATS, contextType: ContextType.CONVERSATION_STATS,

View File

@ -417,6 +417,21 @@ ${companyName} 是${companyDescription}。
- -
- 99 - 99
### query_user_profile
- ****
- ****
- "这是我第几次咨询?""我来过几次?"
- "你记得我的信息吗?""你知道我是谁吗?"
- "我之前咨询过什么?""我上次问了什么?"
-
- ****
-
-
-
-
-
- **使**
--- ---
# #

View File

@ -408,6 +408,18 @@ export const DIRECT_TOOLS: ToolDefinition[] = [
}, },
isConcurrencySafe: false, // 写操作 isConcurrencySafe: false, // 写操作
}, },
{
name: 'query_user_profile',
description:
'查询用户在系统中的完整信息档案。包括累计咨询次数、首次咨询时间、' +
'最近咨询时间、历史对话主题摘要、已收集的个人信息、用户记忆等。' +
'当用户问"这是我第几次咨询"、"你记得我的信息吗"、"我之前咨询过什么"等问题时使用。',
input_schema: {
type: 'object',
properties: {},
},
isConcurrencySafe: true, // 只读
},
]; ];
// ============================================================ // ============================================================

View File

@ -1,8 +1,12 @@
import { Injectable, Optional } from '@nestjs/common'; import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 { ConversationContext } from '../claude-agent.service';
import { KnowledgeClientService } from '../../knowledge/knowledge-client.service'; import { KnowledgeClientService } from '../../knowledge/knowledge-client.service';
import { PaymentClientService } from '../../payment/payment-client.service'; import { PaymentClientService } from '../../payment/payment-client.service';
import { ConversationORM } from '../../database/postgres/entities/conversation.orm';
export interface Tool { export interface Tool {
name: string; name: string;
@ -19,6 +23,9 @@ export class ImmigrationToolsService {
constructor( constructor(
private knowledgeClient: KnowledgeClientService, private knowledgeClient: KnowledgeClientService,
private configService: ConfigService, private configService: ConfigService,
@InjectRepository(ConversationORM)
private conversationRepo: Repository<ConversationORM>,
private tenantContext: TenantContextService,
@Optional() private paymentClient?: PaymentClientService, @Optional() private paymentClient?: PaymentClientService,
) {} ) {}
@ -341,6 +348,9 @@ export class ImmigrationToolsService {
case 'fetch_immigration_news': case 'fetch_immigration_news':
return this.fetchImmigrationNews(input); return this.fetchImmigrationNews(input);
case 'query_user_profile':
return this.queryUserProfile(context);
default: default:
return { error: `Unknown tool: ${toolName}` }; return { error: `Unknown tool: ${toolName}` };
} }
@ -1048,4 +1058,95 @@ export class ImmigrationToolsService {
], ],
}; };
} }
/**
* 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} 条用户信息`,
};
}
} }