feat(agent): implement consulting strategy engine with V2 agent service
- Add 8-stage consulting workflow (greeting → handoff) - Create StrategyEngineService for state management and transitions - Add ClaudeAgentServiceV2 with integrated strategy guidance - Support old user recognition via get_user_context tool - Add device info (IP, fingerprint) for new user icebreaking - Extend ConversationEntity with consulting state fields - Add database migration for new JSONB columns Stages: greeting, needs_discovery, info_collection, assessment, recommendation, objection_handling, conversion, handoff Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8352578bd3
commit
cd5399eac3
|
|
@ -20,6 +20,46 @@ export const ConversationStatus = {
|
||||||
export type ConversationStatusType =
|
export type ConversationStatusType =
|
||||||
(typeof ConversationStatus)[keyof typeof ConversationStatus];
|
(typeof ConversationStatus)[keyof typeof ConversationStatus];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 咨询阶段常量
|
||||||
|
*/
|
||||||
|
export const ConsultingStage = {
|
||||||
|
GREETING: 'greeting',
|
||||||
|
NEEDS_DISCOVERY: 'needs_discovery',
|
||||||
|
INFO_COLLECTION: 'info_collection',
|
||||||
|
ASSESSMENT: 'assessment',
|
||||||
|
RECOMMENDATION: 'recommendation',
|
||||||
|
OBJECTION_HANDLING: 'objection_handling',
|
||||||
|
CONVERSION: 'conversion',
|
||||||
|
HANDOFF: 'handoff',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ConsultingStageType =
|
||||||
|
(typeof ConsultingStage)[keyof typeof ConsultingStage];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 咨询状态结构(存储为JSON)
|
||||||
|
*/
|
||||||
|
export interface ConsultingStateJson {
|
||||||
|
strategyId: string;
|
||||||
|
currentStageId: string;
|
||||||
|
stageTurnCount: number;
|
||||||
|
collectedInfo: Record<string, unknown>;
|
||||||
|
assessmentResult?: {
|
||||||
|
recommendedPrograms: string[];
|
||||||
|
suitabilityScore: number;
|
||||||
|
highlights: string[];
|
||||||
|
concerns: string[];
|
||||||
|
};
|
||||||
|
conversionPath?: string;
|
||||||
|
stageHistory: Array<{
|
||||||
|
stageId: string;
|
||||||
|
enteredAt: string;
|
||||||
|
exitedAt?: string;
|
||||||
|
turnsInStage: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('conversations')
|
@Entity('conversations')
|
||||||
export class ConversationEntity {
|
export class ConversationEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
|
@ -43,6 +83,77 @@ export class ConversationEntity {
|
||||||
@Column({ name: 'message_count', default: 0 })
|
@Column({ name: 'message_count', default: 0 })
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
|
|
||||||
|
// ========== V2新增:咨询流程字段 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前咨询阶段
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'consulting_stage',
|
||||||
|
length: 30,
|
||||||
|
default: 'greeting',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
consultingStage: ConsultingStageType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 咨询状态(完整的状态对象,包含收集的信息、评估结果等)
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'consulting_state',
|
||||||
|
type: 'jsonb',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
consultingState: ConsultingStateJson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已收集的用户信息(快速查询用,与consultingState中的collectedInfo同步)
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'collected_info',
|
||||||
|
type: 'jsonb',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
collectedInfo: Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐的移民方案(评估后填充)
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'recommended_programs',
|
||||||
|
type: 'text',
|
||||||
|
array: true,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
recommendedPrograms: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转化路径(用户选择的下一步)
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'conversion_path',
|
||||||
|
length: 30,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
conversionPath: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户设备信息(用于破冰)
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
name: 'device_info',
|
||||||
|
type: 'jsonb',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
deviceInfo: {
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
region?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 原有字段 ==========
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,608 @@
|
||||||
|
/**
|
||||||
|
* Claude Agent Service V2 - 集成策略引擎
|
||||||
|
*
|
||||||
|
* 相比V1的主要改进:
|
||||||
|
* 1. 集成策略引擎,按老板定义的流程引导用户
|
||||||
|
* 2. 对话有明确的阶段和目标
|
||||||
|
* 3. 自动收集和保存用户信息
|
||||||
|
* 4. 在关键节点自动调用必需的工具
|
||||||
|
* 5. 支持老用户识别和个性化破冰
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import { ImmigrationToolsService } from './tools/immigration-tools.service';
|
||||||
|
import { TokenUsageService } from './token-usage.service';
|
||||||
|
import { buildSystemPrompt, SystemPromptConfig } from './prompts/system-prompt';
|
||||||
|
import { KnowledgeClientService } from '../knowledge/knowledge-client.service';
|
||||||
|
import { intentClassifier, IntentResult, IntentType } from './intent-classifier';
|
||||||
|
import { responseGate } from './response-gate';
|
||||||
|
import {
|
||||||
|
StrategyEngineService,
|
||||||
|
ConsultingState,
|
||||||
|
} from './strategy/strategy-engine.service';
|
||||||
|
import {
|
||||||
|
StageId,
|
||||||
|
getStageById,
|
||||||
|
DEFAULT_CONSULTING_STRATEGY,
|
||||||
|
} from './strategy/default-strategy';
|
||||||
|
|
||||||
|
export interface FileAttachment {
|
||||||
|
id: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
type: 'image' | 'document' | 'audio' | 'video' | 'other';
|
||||||
|
size: number;
|
||||||
|
downloadUrl?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationContext {
|
||||||
|
userId: string;
|
||||||
|
conversationId: string;
|
||||||
|
userMemory?: string[];
|
||||||
|
previousMessages?: Array<{
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
attachments?: FileAttachment[];
|
||||||
|
}>;
|
||||||
|
// V2新增:咨询状态
|
||||||
|
consultingState?: ConsultingState;
|
||||||
|
// V2新增:用户设备信息(用于新用户破冰)
|
||||||
|
deviceInfo?: {
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamChunk {
|
||||||
|
type: 'text' | 'tool_use' | 'tool_result' | 'end' | 'stage_change' | 'state_update';
|
||||||
|
content?: string;
|
||||||
|
toolName?: string;
|
||||||
|
toolInput?: Record<string, unknown>;
|
||||||
|
toolResult?: unknown;
|
||||||
|
// V2新增
|
||||||
|
stageName?: string;
|
||||||
|
newState?: ConsultingState;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClaudeAgentServiceV2 implements OnModuleInit {
|
||||||
|
private client: Anthropic;
|
||||||
|
private systemPromptConfig: SystemPromptConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private immigrationToolsService: ImmigrationToolsService,
|
||||||
|
private knowledgeClient: KnowledgeClientService,
|
||||||
|
private tokenUsageService: TokenUsageService,
|
||||||
|
private strategyEngine: StrategyEngineService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const baseUrl = this.configService.get<string>('ANTHROPIC_BASE_URL');
|
||||||
|
const isProxyUrl = baseUrl && (baseUrl.includes('67.223.119.33') || baseUrl.match(/^\d+\.\d+\.\d+\.\d+/));
|
||||||
|
|
||||||
|
if (isProxyUrl) {
|
||||||
|
console.log(`Using Anthropic proxy (TLS verification disabled): ${baseUrl}`);
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new Anthropic({
|
||||||
|
apiKey: this.configService.get<string>('ANTHROPIC_API_KEY'),
|
||||||
|
baseURL: baseUrl || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.systemPromptConfig = {
|
||||||
|
identity: '专业、友善、耐心的香港移民顾问',
|
||||||
|
conversationStyle: '专业但不生硬,用简洁明了的语言解答',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化或获取咨询状态
|
||||||
|
*/
|
||||||
|
initializeOrGetState(context: ConversationContext): ConsultingState {
|
||||||
|
if (context.consultingState) {
|
||||||
|
return context.consultingState;
|
||||||
|
}
|
||||||
|
return this.strategyEngine.initializeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息并获取流式响应 - V2版本
|
||||||
|
*
|
||||||
|
* 核心改进:
|
||||||
|
* 1. 在对话开始时检查用户历史(老用户/新用户)
|
||||||
|
* 2. 根据当前阶段注入行为指令
|
||||||
|
* 3. 自动判断阶段转移
|
||||||
|
* 4. 从对话中提取用户信息
|
||||||
|
*/
|
||||||
|
async *sendMessage(
|
||||||
|
message: string,
|
||||||
|
context: ConversationContext,
|
||||||
|
attachments?: FileAttachment[],
|
||||||
|
): AsyncGenerator<StreamChunk> {
|
||||||
|
// ========== 1. 初始化咨询状态 ==========
|
||||||
|
let state = this.initializeOrGetState(context);
|
||||||
|
const isFirstMessage = !context.previousMessages || context.previousMessages.length === 0;
|
||||||
|
|
||||||
|
console.log(`[ClaudeAgentV2] Stage: ${state.currentStageId}, Turn: ${state.stageTurnCount}, IsFirst: ${isFirstMessage}`);
|
||||||
|
|
||||||
|
// ========== 2. 检查阶段转移 ==========
|
||||||
|
if (!isFirstMessage) {
|
||||||
|
const transitionResult = await this.strategyEngine.evaluateTransition(state, message);
|
||||||
|
if (transitionResult.shouldTransition && transitionResult.targetStageId) {
|
||||||
|
console.log(`[ClaudeAgentV2] Stage transition: ${state.currentStageId} -> ${transitionResult.targetStageId} (${transitionResult.reason})`);
|
||||||
|
state = this.strategyEngine.transitionToStage(state, transitionResult.targetStageId);
|
||||||
|
yield {
|
||||||
|
type: 'stage_change',
|
||||||
|
stageName: getStageById(transitionResult.targetStageId)?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 3. 意图分类(保留原有能力) ==========
|
||||||
|
const conversationHistory = context.previousMessages?.map(msg => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
})) || [];
|
||||||
|
const intent = intentClassifier.classify(message, conversationHistory);
|
||||||
|
|
||||||
|
console.log(`[ClaudeAgentV2] Intent: ${intent.type}, Stage: ${state.currentStageId}`);
|
||||||
|
|
||||||
|
// ========== 4. 构建增强的System Prompt ==========
|
||||||
|
const tools = this.immigrationToolsService.getTools();
|
||||||
|
const accumulatedExperience = await this.getAccumulatedExperience(message);
|
||||||
|
|
||||||
|
// 构建阶段引导
|
||||||
|
const stageGuidance = this.strategyEngine.buildStageGuidance(state);
|
||||||
|
|
||||||
|
// 构建设备信息提示(用于新用户破冰)
|
||||||
|
const deviceContext = this.buildDeviceContext(context, isFirstMessage);
|
||||||
|
|
||||||
|
const dynamicConfig: SystemPromptConfig = {
|
||||||
|
...this.systemPromptConfig,
|
||||||
|
accumulatedExperience,
|
||||||
|
intentHint: intent,
|
||||||
|
};
|
||||||
|
let systemPrompt = buildSystemPrompt(dynamicConfig);
|
||||||
|
|
||||||
|
// 注入策略引导到System Prompt
|
||||||
|
systemPrompt = this.injectStrategyGuidance(systemPrompt, stageGuidance, deviceContext, state);
|
||||||
|
|
||||||
|
// ========== 5. 构建消息数组 ==========
|
||||||
|
const messages: Anthropic.MessageParam[] = [];
|
||||||
|
|
||||||
|
if (context.previousMessages) {
|
||||||
|
for (const msg of context.previousMessages) {
|
||||||
|
if (msg.attachments && msg.attachments.length > 0 && msg.role === 'user') {
|
||||||
|
const multimodalContent = await this.buildMultimodalContent(msg.content, msg.attachments);
|
||||||
|
messages.push({ role: msg.role, content: multimodalContent });
|
||||||
|
} else {
|
||||||
|
messages.push({ role: msg.role, content: msg.content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
const multimodalContent = await this.buildMultimodalContent(message, attachments);
|
||||||
|
messages.push({ role: 'user', content: multimodalContent });
|
||||||
|
} else {
|
||||||
|
messages.push({ role: 'user', content: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 6. 调用Claude API ==========
|
||||||
|
const maxIterations = 10;
|
||||||
|
let iterations = 0;
|
||||||
|
const maxTokens = this.calculateMaxTokens(intent, state);
|
||||||
|
|
||||||
|
const systemWithCache: Anthropic.TextBlockParam[] = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: systemPrompt,
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let fullResponseText = '';
|
||||||
|
const startTime = Date.now();
|
||||||
|
let totalInputTokens = 0;
|
||||||
|
let totalOutputTokens = 0;
|
||||||
|
let totalCacheCreationTokens = 0;
|
||||||
|
let totalCacheReadTokens = 0;
|
||||||
|
let toolCallCount = 0;
|
||||||
|
|
||||||
|
while (iterations < maxIterations) {
|
||||||
|
iterations++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await this.client.messages.stream({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
system: systemWithCache,
|
||||||
|
messages,
|
||||||
|
tools: tools as Anthropic.Tool[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentToolUse: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
inputJson: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = [];
|
||||||
|
const assistantContent: Anthropic.ContentBlockParam[] = [];
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
if (event.type === 'content_block_start') {
|
||||||
|
if (event.content_block.type === 'tool_use') {
|
||||||
|
currentToolUse = {
|
||||||
|
id: event.content_block.id,
|
||||||
|
name: event.content_block.name,
|
||||||
|
inputJson: '',
|
||||||
|
input: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (event.type === 'content_block_delta') {
|
||||||
|
if (event.delta.type === 'text_delta') {
|
||||||
|
fullResponseText += event.delta.text;
|
||||||
|
yield { type: 'text', content: event.delta.text };
|
||||||
|
} else if (event.delta.type === 'input_json_delta' && currentToolUse) {
|
||||||
|
currentToolUse.inputJson += event.delta.partial_json || '';
|
||||||
|
}
|
||||||
|
} else if (event.type === 'content_block_stop') {
|
||||||
|
if (currentToolUse) {
|
||||||
|
try {
|
||||||
|
currentToolUse.input = JSON.parse(currentToolUse.inputJson || '{}');
|
||||||
|
} catch {
|
||||||
|
currentToolUse.input = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
toolUses.push({
|
||||||
|
id: currentToolUse.id,
|
||||||
|
name: currentToolUse.name,
|
||||||
|
input: currentToolUse.input,
|
||||||
|
});
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'tool_use',
|
||||||
|
toolName: currentToolUse.name,
|
||||||
|
toolInput: currentToolUse.input,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentToolUse = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalMsg = await stream.finalMessage();
|
||||||
|
|
||||||
|
if (finalMsg.usage) {
|
||||||
|
totalInputTokens += finalMsg.usage.input_tokens || 0;
|
||||||
|
totalOutputTokens += finalMsg.usage.output_tokens || 0;
|
||||||
|
const usage = finalMsg.usage as unknown as Record<string, number>;
|
||||||
|
totalCacheCreationTokens += usage.cache_creation_input_tokens || 0;
|
||||||
|
totalCacheReadTokens += usage.cache_read_input_tokens || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有工具调用,完成
|
||||||
|
if (toolUses.length === 0) {
|
||||||
|
// ========== 7. 回复后处理 ==========
|
||||||
|
|
||||||
|
// 7.1 质量门控
|
||||||
|
if (fullResponseText) {
|
||||||
|
const gateResult = responseGate.check(fullResponseText, intent, message);
|
||||||
|
console.log(`[ClaudeAgentV2] Response gate: passed=${gateResult.passed}, length=${fullResponseText.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7.2 提取用户信息
|
||||||
|
const newInfo = await this.strategyEngine.extractUserInfo(
|
||||||
|
message,
|
||||||
|
fullResponseText,
|
||||||
|
state.collectedInfo,
|
||||||
|
);
|
||||||
|
if (Object.keys(newInfo).length > 0) {
|
||||||
|
console.log(`[ClaudeAgentV2] Extracted user info:`, newInfo);
|
||||||
|
state = this.strategyEngine.updateCollectedInfo(state, newInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7.3 如果在评估阶段且信息充足,执行评估
|
||||||
|
if (state.currentStageId === StageId.ASSESSMENT && !state.assessmentResult) {
|
||||||
|
const coreInfoCount = ['age', 'education', 'workYears', 'annualIncome']
|
||||||
|
.filter(key => state.collectedInfo[key] !== undefined).length;
|
||||||
|
if (coreInfoCount >= 3) {
|
||||||
|
const assessmentResult = await this.strategyEngine.performAssessment(state.collectedInfo);
|
||||||
|
state = this.strategyEngine.setAssessmentResult(state, assessmentResult);
|
||||||
|
console.log(`[ClaudeAgentV2] Assessment completed:`, assessmentResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7.4 增加轮次计数
|
||||||
|
state = this.strategyEngine.incrementTurnCount(state);
|
||||||
|
|
||||||
|
// 7.5 记录Token使用量
|
||||||
|
const latencyMs = Date.now() - startTime;
|
||||||
|
this.tokenUsageService.recordUsage({
|
||||||
|
userId: context.userId,
|
||||||
|
conversationId: context.conversationId,
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
inputTokens: totalInputTokens,
|
||||||
|
outputTokens: totalOutputTokens,
|
||||||
|
cacheCreationTokens: totalCacheCreationTokens,
|
||||||
|
cacheReadTokens: totalCacheReadTokens,
|
||||||
|
intentType: intent.type,
|
||||||
|
toolCalls: toolCallCount,
|
||||||
|
responseLength: fullResponseText.length,
|
||||||
|
latencyMs,
|
||||||
|
}).catch(err => console.error('[ClaudeAgentV2] Failed to record token usage:', err));
|
||||||
|
|
||||||
|
// 7.6 返回更新后的状态
|
||||||
|
yield { type: 'state_update', newState: state };
|
||||||
|
yield { type: 'end' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工具调用
|
||||||
|
toolCallCount += toolUses.length;
|
||||||
|
|
||||||
|
for (const block of finalMsg.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
assistantContent.push({ type: 'text', text: block.text });
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
assistantContent.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
id: block.id,
|
||||||
|
name: block.name,
|
||||||
|
input: block.input as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({ role: 'assistant', content: assistantContent });
|
||||||
|
|
||||||
|
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||||
|
for (const toolUse of toolUses) {
|
||||||
|
const result = await this.immigrationToolsService.executeTool(
|
||||||
|
toolUse.name,
|
||||||
|
toolUse.input,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'tool_result',
|
||||||
|
toolName: toolUse.name,
|
||||||
|
toolResult: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
toolResults.push({
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: toolUse.id,
|
||||||
|
content: JSON.stringify(result),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果是get_user_context工具,从结果中提取已有信息
|
||||||
|
if (toolUse.name === 'get_user_context' && result) {
|
||||||
|
const userContextResult = result as { memories?: Array<{ type: string; content: string }> };
|
||||||
|
if (userContextResult.memories && userContextResult.memories.length > 0) {
|
||||||
|
console.log(`[ClaudeAgentV2] Found existing user memories:`, userContextResult.memories.length);
|
||||||
|
// 可以从记忆中提取已知信息更新state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({ role: 'user', content: toolResults });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ClaudeAgentV2] Claude API error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[ClaudeAgentV2] Tool loop exceeded maximum iterations');
|
||||||
|
yield { type: 'end' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入策略引导到System Prompt
|
||||||
|
*/
|
||||||
|
private injectStrategyGuidance(
|
||||||
|
basePrompt: string,
|
||||||
|
stageGuidance: string,
|
||||||
|
deviceContext: string,
|
||||||
|
state: ConsultingState,
|
||||||
|
): string {
|
||||||
|
const strategy = this.strategyEngine.getStrategy();
|
||||||
|
const stage = this.strategyEngine.getCurrentStage(state);
|
||||||
|
|
||||||
|
const injectedContent = `
|
||||||
|
${basePrompt}
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
【重要】咨询流程控制 - 你必须按照以下策略执行
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
${stageGuidance}
|
||||||
|
|
||||||
|
${deviceContext}
|
||||||
|
|
||||||
|
## 工具调用要求
|
||||||
|
当前阶段建议使用的工具:
|
||||||
|
${stage.suggestedTools.map(t => {
|
||||||
|
const priorityLabel = t.priority === 'required' ? '【必须调用】' : t.priority === 'recommended' ? '【建议调用】' : '【可选】';
|
||||||
|
return `- ${priorityLabel} ${t.toolName}: ${t.description}\n 触发条件: ${t.triggerCondition}`;
|
||||||
|
}).join('\n')}
|
||||||
|
|
||||||
|
## 非移民话题处理
|
||||||
|
如果用户询问与香港移民无关的问题:
|
||||||
|
1. 调用 check_off_topic 工具确认是否离题
|
||||||
|
2. 如果确认离题,礼貌拒绝并引导回移民话题
|
||||||
|
3. 示例回复:"抱歉,我是专门的香港移民咨询顾问,对于[用户问题的领域]我不太了解。如果您有香港移民相关的问题,我很乐意帮您解答。"
|
||||||
|
|
||||||
|
## 专家对接信息
|
||||||
|
- 人类专家微信: ${strategy.expertContact.wechat}
|
||||||
|
- 专家电话: ${strategy.expertContact.phone}
|
||||||
|
- 工作时间: ${strategy.expertContact.workingHours}
|
||||||
|
|
||||||
|
## 付费服务信息
|
||||||
|
- 评估服务价格: ¥${strategy.paidServices.assessmentPrice}
|
||||||
|
- 服务说明: ${strategy.paidServices.description}
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
`;
|
||||||
|
|
||||||
|
return injectedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建设备上下文信息(用于新用户破冰)
|
||||||
|
*/
|
||||||
|
private buildDeviceContext(context: ConversationContext, isFirstMessage: boolean): string {
|
||||||
|
if (!isFirstMessage || !context.deviceInfo) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = ['## 用户设备信息(可用于破冰参考)'];
|
||||||
|
|
||||||
|
if (context.deviceInfo.ip) {
|
||||||
|
// 可以根据IP判断地区
|
||||||
|
parts.push(`- IP地址: ${context.deviceInfo.ip} (可用于判断用户所在地区)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.deviceInfo.userAgent) {
|
||||||
|
const ua = context.deviceInfo.userAgent;
|
||||||
|
if (ua.includes('Mobile')) {
|
||||||
|
parts.push('- 设备类型: 移动设备');
|
||||||
|
} else {
|
||||||
|
parts.push('- 设备类型: 电脑');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.deviceInfo.fingerprint) {
|
||||||
|
parts.push(`- 设备指纹: ${context.deviceInfo.fingerprint.slice(0, 8)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push('');
|
||||||
|
parts.push('提示: 可以根据设备信息进行自然的寒暄,如"在手机上咨询呢"等');
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据意图和阶段计算max_tokens
|
||||||
|
*/
|
||||||
|
private calculateMaxTokens(intent: IntentResult, state: ConsultingState): number {
|
||||||
|
const stage = this.strategyEngine.getCurrentStage(state);
|
||||||
|
const baseLength = stage.suggestedResponseLength;
|
||||||
|
|
||||||
|
// 中文约 1.8 tokens/字符
|
||||||
|
const tokensPerChar = 1.8;
|
||||||
|
const baseTokens = Math.round(baseLength * tokensPerChar);
|
||||||
|
|
||||||
|
// 根据阶段调整
|
||||||
|
switch (state.currentStageId) {
|
||||||
|
case StageId.GREETING:
|
||||||
|
return Math.min(400, baseTokens);
|
||||||
|
case StageId.NEEDS_DISCOVERY:
|
||||||
|
return Math.min(500, baseTokens);
|
||||||
|
case StageId.INFO_COLLECTION:
|
||||||
|
return Math.min(450, baseTokens);
|
||||||
|
case StageId.ASSESSMENT:
|
||||||
|
return Math.min(800, baseTokens);
|
||||||
|
case StageId.RECOMMENDATION:
|
||||||
|
return Math.min(1000, baseTokens);
|
||||||
|
case StageId.OBJECTION_HANDLING:
|
||||||
|
return Math.min(600, baseTokens);
|
||||||
|
case StageId.CONVERSION:
|
||||||
|
return Math.min(600, baseTokens);
|
||||||
|
case StageId.HANDOFF:
|
||||||
|
return Math.min(500, baseTokens);
|
||||||
|
default:
|
||||||
|
return 800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已积累的经验
|
||||||
|
*/
|
||||||
|
private async getAccumulatedExperience(query: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const experiences = await this.knowledgeClient.searchExperiences({
|
||||||
|
query,
|
||||||
|
activeOnly: true,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (experiences.length === 0) {
|
||||||
|
return '暂无';
|
||||||
|
}
|
||||||
|
|
||||||
|
return experiences
|
||||||
|
.map((exp, index) => `${index + 1}. [${exp.experienceType}] ${exp.content}`)
|
||||||
|
.join('\n');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ClaudeAgentV2] Failed to fetch experiences:', error);
|
||||||
|
return '暂无';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建多模态内容
|
||||||
|
*/
|
||||||
|
private async buildMultimodalContent(
|
||||||
|
text: string,
|
||||||
|
attachments?: FileAttachment[],
|
||||||
|
): Promise<Anthropic.ContentBlockParam[]> {
|
||||||
|
const content: Anthropic.ContentBlockParam[] = [];
|
||||||
|
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (attachment.type === 'image' && attachment.downloadUrl) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(attachment.downloadUrl);
|
||||||
|
if (response.ok) {
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const base64Data = Buffer.from(buffer).toString('base64');
|
||||||
|
const mediaType = attachment.mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
||||||
|
|
||||||
|
content.push({
|
||||||
|
type: 'image',
|
||||||
|
source: {
|
||||||
|
type: 'base64',
|
||||||
|
media_type: mediaType,
|
||||||
|
data: base64Data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch image ${attachment.originalName}:`, error);
|
||||||
|
}
|
||||||
|
} else if (attachment.type === 'document') {
|
||||||
|
content.push({
|
||||||
|
type: 'text',
|
||||||
|
text: `[Attached document: ${attachment.originalName}]`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
content.push({ type: 'text', text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新系统提示配置
|
||||||
|
*/
|
||||||
|
updateSystemPromptConfig(config: Partial<SystemPromptConfig>) {
|
||||||
|
this.systemPromptConfig = {
|
||||||
|
...this.systemPromptConfig,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,10 @@ import { Module, Global } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ClaudeAgentService } from './claude-agent.service';
|
import { ClaudeAgentService } from './claude-agent.service';
|
||||||
|
import { ClaudeAgentServiceV2 } from './claude-agent-v2.service';
|
||||||
import { ImmigrationToolsService } from './tools/immigration-tools.service';
|
import { ImmigrationToolsService } from './tools/immigration-tools.service';
|
||||||
import { TokenUsageService } from './token-usage.service';
|
import { TokenUsageService } from './token-usage.service';
|
||||||
|
import { StrategyEngineService } from './strategy/strategy-engine.service';
|
||||||
import { TokenUsageEntity } from '../../domain/entities/token-usage.entity';
|
import { TokenUsageEntity } from '../../domain/entities/token-usage.entity';
|
||||||
import { KnowledgeModule } from '../knowledge/knowledge.module';
|
import { KnowledgeModule } from '../knowledge/knowledge.module';
|
||||||
|
|
||||||
|
|
@ -14,7 +16,19 @@ import { KnowledgeModule } from '../knowledge/knowledge.module';
|
||||||
KnowledgeModule,
|
KnowledgeModule,
|
||||||
TypeOrmModule.forFeature([TokenUsageEntity]),
|
TypeOrmModule.forFeature([TokenUsageEntity]),
|
||||||
],
|
],
|
||||||
providers: [ClaudeAgentService, ImmigrationToolsService, TokenUsageService],
|
providers: [
|
||||||
exports: [ClaudeAgentService, ImmigrationToolsService, TokenUsageService],
|
ClaudeAgentService,
|
||||||
|
ClaudeAgentServiceV2,
|
||||||
|
ImmigrationToolsService,
|
||||||
|
TokenUsageService,
|
||||||
|
StrategyEngineService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ClaudeAgentService,
|
||||||
|
ClaudeAgentServiceV2,
|
||||||
|
ImmigrationToolsService,
|
||||||
|
TokenUsageService,
|
||||||
|
StrategyEngineService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ClaudeModule {}
|
export class ClaudeModule {}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
||||||
|
/**
|
||||||
|
* 策略模块导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './default-strategy';
|
||||||
|
export * from './strategy-engine.service';
|
||||||
|
|
@ -0,0 +1,575 @@
|
||||||
|
/**
|
||||||
|
* 策略执行引擎
|
||||||
|
*
|
||||||
|
* 负责:
|
||||||
|
* 1. 管理对话的咨询阶段状态
|
||||||
|
* 2. 判断阶段转移
|
||||||
|
* 3. 生成阶段引导指令(注入到System Prompt)
|
||||||
|
* 4. 从对话中提取用户信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import {
|
||||||
|
DEFAULT_CONSULTING_STRATEGY,
|
||||||
|
ConsultingStage,
|
||||||
|
ConsultingStrategy,
|
||||||
|
StageId,
|
||||||
|
DirectiveType,
|
||||||
|
RequiredInfo,
|
||||||
|
TransitionCondition,
|
||||||
|
ConversionPath,
|
||||||
|
getStageById,
|
||||||
|
getNextStage,
|
||||||
|
getUncollectedInfo,
|
||||||
|
detectUserIntents,
|
||||||
|
CORE_USER_INFO,
|
||||||
|
} from './default-strategy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对话的咨询状态
|
||||||
|
*/
|
||||||
|
export interface ConsultingState {
|
||||||
|
// 当前使用的策略ID
|
||||||
|
strategyId: string;
|
||||||
|
|
||||||
|
// 当前阶段
|
||||||
|
currentStageId: StageId;
|
||||||
|
|
||||||
|
// 当前阶段已进行的轮次
|
||||||
|
stageTurnCount: number;
|
||||||
|
|
||||||
|
// 已收集的用户信息
|
||||||
|
collectedInfo: Record<string, unknown>;
|
||||||
|
|
||||||
|
// 评估结果(如果已评估)
|
||||||
|
assessmentResult?: {
|
||||||
|
recommendedPrograms: string[]; // 推荐的移民类型
|
||||||
|
suitabilityScore: number; // 匹配度 0-100
|
||||||
|
highlights: string[]; // 优势
|
||||||
|
concerns: string[]; // 需要注意的点
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用户选择的转化路径
|
||||||
|
conversionPath?: ConversionPath;
|
||||||
|
|
||||||
|
// 阶段历史
|
||||||
|
stageHistory: Array<{
|
||||||
|
stageId: StageId;
|
||||||
|
enteredAt: Date;
|
||||||
|
exitedAt?: Date;
|
||||||
|
turnsInStage: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 策略引擎服务
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class StrategyEngineService {
|
||||||
|
private client: Anthropic;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
const baseUrl = this.configService.get<string>('ANTHROPIC_BASE_URL');
|
||||||
|
const isProxyUrl = baseUrl && baseUrl.match(/^\d+\.\d+\.\d+\.\d+/);
|
||||||
|
if (isProxyUrl) {
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new Anthropic({
|
||||||
|
apiKey: this.configService.get<string>('ANTHROPIC_API_KEY'),
|
||||||
|
baseURL: baseUrl || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化新对话的咨询状态
|
||||||
|
*/
|
||||||
|
initializeState(): ConsultingState {
|
||||||
|
return {
|
||||||
|
strategyId: DEFAULT_CONSULTING_STRATEGY.id,
|
||||||
|
currentStageId: StageId.GREETING,
|
||||||
|
stageTurnCount: 0,
|
||||||
|
collectedInfo: {},
|
||||||
|
stageHistory: [
|
||||||
|
{
|
||||||
|
stageId: StageId.GREETING,
|
||||||
|
enteredAt: new Date(),
|
||||||
|
turnsInStage: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前策略
|
||||||
|
*/
|
||||||
|
getStrategy(): ConsultingStrategy {
|
||||||
|
// 目前只有默认策略,后续可从数据库加载
|
||||||
|
return DEFAULT_CONSULTING_STRATEGY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前阶段
|
||||||
|
*/
|
||||||
|
getCurrentStage(state: ConsultingState): ConsultingStage {
|
||||||
|
const stage = getStageById(state.currentStageId);
|
||||||
|
if (!stage) {
|
||||||
|
// 如果阶段无效,回到开场
|
||||||
|
return getStageById(StageId.GREETING)!;
|
||||||
|
}
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评估是否需要转移阶段
|
||||||
|
*/
|
||||||
|
async evaluateTransition(
|
||||||
|
state: ConsultingState,
|
||||||
|
userMessage: string,
|
||||||
|
): Promise<{ shouldTransition: boolean; targetStageId?: StageId; reason?: string }> {
|
||||||
|
const currentStage = this.getCurrentStage(state);
|
||||||
|
const userIntents = detectUserIntents(userMessage);
|
||||||
|
|
||||||
|
// 按优先级检查每个转移条件
|
||||||
|
const sortedTransitions = [...currentStage.transitions].sort((a, b) => b.priority - a.priority);
|
||||||
|
|
||||||
|
for (const transition of sortedTransitions) {
|
||||||
|
const conditionsMet = await this.checkConditions(
|
||||||
|
transition.conditions,
|
||||||
|
state,
|
||||||
|
userMessage,
|
||||||
|
userIntents,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conditionsMet) {
|
||||||
|
return {
|
||||||
|
shouldTransition: true,
|
||||||
|
targetStageId: transition.targetStageId,
|
||||||
|
reason: transition.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldTransition: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查转移条件是否满足
|
||||||
|
*/
|
||||||
|
private async checkConditions(
|
||||||
|
conditions: TransitionCondition[],
|
||||||
|
state: ConsultingState,
|
||||||
|
userMessage: string,
|
||||||
|
userIntents: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
// 所有条件都要满足(AND 逻辑)
|
||||||
|
for (const condition of conditions) {
|
||||||
|
const met = await this.checkSingleCondition(condition, state, userMessage, userIntents);
|
||||||
|
if (!met) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查单个条件
|
||||||
|
*/
|
||||||
|
private async checkSingleCondition(
|
||||||
|
condition: TransitionCondition,
|
||||||
|
state: ConsultingState,
|
||||||
|
userMessage: string,
|
||||||
|
userIntents: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'INFO_COLLECTED':
|
||||||
|
if (condition.infoKeys) {
|
||||||
|
const collectedCount = condition.infoKeys.filter(
|
||||||
|
key => state.collectedInfo[key] !== undefined
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (condition.minCount) {
|
||||||
|
return collectedCount >= condition.minCount;
|
||||||
|
}
|
||||||
|
return collectedCount === condition.infoKeys.length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'USER_INTENT':
|
||||||
|
if (condition.intents) {
|
||||||
|
return condition.intents.some(intent => userIntents.includes(intent));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'TURNS_EXCEEDED':
|
||||||
|
if (condition.maxTurns !== undefined) {
|
||||||
|
return state.stageTurnCount >= condition.maxTurns;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'KEYWORD':
|
||||||
|
if (condition.keywords) {
|
||||||
|
const lowerMessage = userMessage.toLowerCase();
|
||||||
|
return condition.keywords.some(kw => lowerMessage.includes(kw.toLowerCase()));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'ASSESSMENT_DONE':
|
||||||
|
return state.assessmentResult !== undefined;
|
||||||
|
|
||||||
|
case 'CUSTOM':
|
||||||
|
// 自定义条件用LLM判断
|
||||||
|
if (condition.customCondition) {
|
||||||
|
return this.evaluateCustomCondition(condition.customCondition, userMessage, state);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用LLM评估自定义条件
|
||||||
|
*/
|
||||||
|
private async evaluateCustomCondition(
|
||||||
|
condition: string,
|
||||||
|
userMessage: string,
|
||||||
|
state: ConsultingState,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 10,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `判断以下条件是否满足,只回答 true 或 false:
|
||||||
|
|
||||||
|
条件:${condition}
|
||||||
|
|
||||||
|
用户最新消息:${userMessage}
|
||||||
|
|
||||||
|
已收集的用户信息:${JSON.stringify(state.collectedInfo)}
|
||||||
|
|
||||||
|
回答(true/false):`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.content[0].type === 'text' ? response.content[0].text : '';
|
||||||
|
return text.toLowerCase().includes('true');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行阶段转移
|
||||||
|
*/
|
||||||
|
transitionToStage(state: ConsultingState, targetStageId: StageId): ConsultingState {
|
||||||
|
// 更新历史记录
|
||||||
|
const currentHistory = state.stageHistory[state.stageHistory.length - 1];
|
||||||
|
if (currentHistory) {
|
||||||
|
currentHistory.exitedAt = new Date();
|
||||||
|
currentHistory.turnsInStage = state.stageTurnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新状态
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStageId: targetStageId,
|
||||||
|
stageTurnCount: 0,
|
||||||
|
stageHistory: [
|
||||||
|
...state.stageHistory,
|
||||||
|
{
|
||||||
|
stageId: targetStageId,
|
||||||
|
enteredAt: new Date(),
|
||||||
|
turnsInStage: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加阶段轮次
|
||||||
|
*/
|
||||||
|
incrementTurnCount(state: ConsultingState): ConsultingState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stageTurnCount: state.stageTurnCount + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从对话中提取用户信息
|
||||||
|
*/
|
||||||
|
async extractUserInfo(
|
||||||
|
userMessage: string,
|
||||||
|
assistantMessage: string,
|
||||||
|
existingInfo: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const infoToExtract = CORE_USER_INFO.filter(info => !existingInfo[info.key]);
|
||||||
|
|
||||||
|
if (infoToExtract.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 500,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `从以下对话中提取用户信息。只提取能明确确定的信息,不要猜测。
|
||||||
|
|
||||||
|
用户消息:${userMessage}
|
||||||
|
助手回复:${assistantMessage}
|
||||||
|
|
||||||
|
需要提取的字段:
|
||||||
|
${infoToExtract.map(i => `- ${i.key}: ${i.description}`).join('\n')}
|
||||||
|
|
||||||
|
返回JSON格式,只包含能确定的字段。如果没有任何信息可提取,返回 {}
|
||||||
|
示例:{"age": 35, "education": "硕士"}
|
||||||
|
|
||||||
|
JSON:`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.content[0].type === 'text' ? response.content[0].text : '{}';
|
||||||
|
// 提取JSON部分
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
return JSON.parse(jsonMatch[0]);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[StrategyEngine] Failed to extract user info:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行资格评估
|
||||||
|
*/
|
||||||
|
async performAssessment(
|
||||||
|
collectedInfo: Record<string, unknown>,
|
||||||
|
): Promise<ConsultingState['assessmentResult']> {
|
||||||
|
const age = collectedInfo.age as number | undefined;
|
||||||
|
const education = collectedInfo.education as string | undefined;
|
||||||
|
const university = collectedInfo.university as string | undefined;
|
||||||
|
const workYears = collectedInfo.workYears as number | undefined;
|
||||||
|
const annualIncome = collectedInfo.annualIncome as number | undefined;
|
||||||
|
|
||||||
|
const recommendedPrograms: string[] = [];
|
||||||
|
const highlights: string[] = [];
|
||||||
|
const concerns: string[] = [];
|
||||||
|
let suitabilityScore = 50;
|
||||||
|
|
||||||
|
// 高才通A类:年薪250万港币以上(约220万人民币)
|
||||||
|
if (annualIncome && annualIncome >= 2200000) {
|
||||||
|
recommendedPrograms.push('高才通A类');
|
||||||
|
highlights.push('年薪达到高才通A类标准(250万港币)');
|
||||||
|
suitabilityScore += 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高才通B类:全球百强大学+3年以上工作经验
|
||||||
|
const top100Universities = ['清华', '北大', '复旦', '上海交通', '浙大', '中科大', '南京大学', '香港大学', '港中文', '港科大'];
|
||||||
|
const isTop100 = university && top100Universities.some(u => university.includes(u));
|
||||||
|
if (isTop100 && workYears && workYears >= 3) {
|
||||||
|
recommendedPrograms.push('高才通B类');
|
||||||
|
highlights.push('名校毕业+工作经验充足');
|
||||||
|
suitabilityScore += 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高才通C类:全球百强大学毕业,工作经验不足3年
|
||||||
|
if (isTop100 && (!workYears || workYears < 3)) {
|
||||||
|
recommendedPrograms.push('高才通C类');
|
||||||
|
highlights.push('名校毕业');
|
||||||
|
concerns.push('工作经验较少,C类有年度名额限制');
|
||||||
|
suitabilityScore += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优才计划
|
||||||
|
if (education && ['硕士', '博士', 'MBA'].some(e => education.includes(e))) {
|
||||||
|
recommendedPrograms.push('优才计划');
|
||||||
|
highlights.push('高学历加分');
|
||||||
|
suitabilityScore += 15;
|
||||||
|
}
|
||||||
|
if (workYears && workYears >= 5) {
|
||||||
|
if (!recommendedPrograms.includes('优才计划')) {
|
||||||
|
recommendedPrograms.push('优才计划');
|
||||||
|
}
|
||||||
|
highlights.push('工作经验丰富');
|
||||||
|
suitabilityScore += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 专才计划:需要香港雇主
|
||||||
|
if (collectedInfo.hasHKConnection) {
|
||||||
|
recommendedPrograms.push('专才计划');
|
||||||
|
highlights.push('有香港雇主/工作机会');
|
||||||
|
suitabilityScore += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年龄因素
|
||||||
|
if (age) {
|
||||||
|
if (age >= 18 && age <= 39) {
|
||||||
|
highlights.push('年龄优势明显');
|
||||||
|
suitabilityScore += 10;
|
||||||
|
} else if (age >= 40 && age <= 50) {
|
||||||
|
concerns.push('年龄在优才计划中扣分');
|
||||||
|
} else if (age > 50) {
|
||||||
|
concerns.push('年龄偏大,建议尽快申请');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有明确推荐,默认推荐优才
|
||||||
|
if (recommendedPrograms.length === 0) {
|
||||||
|
recommendedPrograms.push('优才计划');
|
||||||
|
concerns.push('需要更多信息做精准评估');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recommendedPrograms: [...new Set(recommendedPrograms)], // 去重
|
||||||
|
suitabilityScore: Math.min(100, suitabilityScore),
|
||||||
|
highlights,
|
||||||
|
concerns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建阶段引导(注入到System Prompt)
|
||||||
|
*/
|
||||||
|
buildStageGuidance(state: ConsultingState): string {
|
||||||
|
const strategy = this.getStrategy();
|
||||||
|
const stage = this.getCurrentStage(state);
|
||||||
|
const uncollectedInfo = getUncollectedInfo(stage.id, state.collectedInfo);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// ========== 当前阶段信息 ==========
|
||||||
|
parts.push('## 【咨询流程控制】');
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`### 当前阶段:${stage.name}(第${stage.order}阶段,共8阶段)`);
|
||||||
|
parts.push(`**阶段目标**:${stage.goal}`);
|
||||||
|
parts.push(`**当前轮次**:${state.stageTurnCount + 1}/${stage.maxTurns}`);
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
// ========== 行为指令 ==========
|
||||||
|
parts.push('### 行为指令(必须遵守)');
|
||||||
|
const allDirectives = [...strategy.globalDirectives, ...stage.directives]
|
||||||
|
.sort((a, b) => b.priority - a.priority);
|
||||||
|
|
||||||
|
for (const directive of allDirectives) {
|
||||||
|
const prefix = {
|
||||||
|
[DirectiveType.MUST_DO]: '✓ 必须',
|
||||||
|
[DirectiveType.SHOULD_DO]: '○ 建议',
|
||||||
|
[DirectiveType.MUST_NOT]: '✗ 禁止',
|
||||||
|
[DirectiveType.PREFER]: '☆ 优先',
|
||||||
|
}[directive.type];
|
||||||
|
parts.push(`${prefix}:${directive.content}`);
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
// ========== 信息收集进度 ==========
|
||||||
|
if (Object.keys(state.collectedInfo).length > 0 || uncollectedInfo.length > 0) {
|
||||||
|
parts.push('### 用户信息收集进度');
|
||||||
|
|
||||||
|
// 已收集的
|
||||||
|
for (const [key, value] of Object.entries(state.collectedInfo)) {
|
||||||
|
const info = CORE_USER_INFO.find(i => i.key === key);
|
||||||
|
parts.push(`✓ ${info?.label || key}:${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未收集的(当前阶段需要的)
|
||||||
|
for (const info of uncollectedInfo.slice(0, 3)) { // 最多显示3个
|
||||||
|
parts.push(`○ ${info.label}:待收集(建议问法:"${info.askingStrategy}")`);
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 评估结果 ==========
|
||||||
|
if (state.assessmentResult) {
|
||||||
|
parts.push('### 评估结果(基于已收集信息)');
|
||||||
|
parts.push(`**推荐方案**:${state.assessmentResult.recommendedPrograms.join('、')}`);
|
||||||
|
parts.push(`**匹配度**:${state.assessmentResult.suitabilityScore}分`);
|
||||||
|
if (state.assessmentResult.highlights.length > 0) {
|
||||||
|
parts.push(`**优势**:${state.assessmentResult.highlights.join(';')}`);
|
||||||
|
}
|
||||||
|
if (state.assessmentResult.concerns.length > 0) {
|
||||||
|
parts.push(`**注意事项**:${state.assessmentResult.concerns.join(';')}`);
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 阶段转移提示 ==========
|
||||||
|
parts.push('### 阶段转移判断');
|
||||||
|
parts.push('根据用户回复,判断是否满足以下转移条件:');
|
||||||
|
for (const transition of stage.transitions.slice(0, 3)) {
|
||||||
|
parts.push(`- 如果【${transition.description}】→ 进入【${getStageById(transition.targetStageId)?.name}】阶段`);
|
||||||
|
}
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
// ========== 专家联系方式(如果在转化/对接阶段)==========
|
||||||
|
if ([StageId.CONVERSION, StageId.HANDOFF].includes(state.currentStageId)) {
|
||||||
|
parts.push('### 可用资源');
|
||||||
|
parts.push(`- 付费评估服务:¥${strategy.paidServices.assessmentPrice},${strategy.paidServices.description}`);
|
||||||
|
parts.push(`- 人类专家微信:${strategy.expertContact.wechat}`);
|
||||||
|
parts.push(`- 专家电话:${strategy.expertContact.phone}`);
|
||||||
|
parts.push(`- 工作时间:${strategy.expertContact.workingHours}`);
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 回复要求 ==========
|
||||||
|
parts.push('### 回复要求');
|
||||||
|
parts.push(`- 建议长度:${stage.suggestedResponseLength}字左右`);
|
||||||
|
parts.push('- 结尾必须有推进对话的问题或明确的下一步引导');
|
||||||
|
parts.push('- 不要一次给太多信息,要循序渐进');
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新收集的信息
|
||||||
|
*/
|
||||||
|
updateCollectedInfo(
|
||||||
|
state: ConsultingState,
|
||||||
|
newInfo: Record<string, unknown>,
|
||||||
|
): ConsultingState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
collectedInfo: {
|
||||||
|
...state.collectedInfo,
|
||||||
|
...newInfo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置评估结果
|
||||||
|
*/
|
||||||
|
setAssessmentResult(
|
||||||
|
state: ConsultingState,
|
||||||
|
result: ConsultingState['assessmentResult'],
|
||||||
|
): ConsultingState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assessmentResult: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置转化路径
|
||||||
|
*/
|
||||||
|
setConversionPath(
|
||||||
|
state: ConsultingState,
|
||||||
|
path: ConversionPath,
|
||||||
|
): ConsultingState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
conversionPath: path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加咨询状态字段到conversations表
|
||||||
|
*
|
||||||
|
* 新增字段:
|
||||||
|
* - consulting_stage: 当前咨询阶段
|
||||||
|
* - consulting_state: 完整的咨询状态JSON
|
||||||
|
* - collected_info: 已收集的用户信息
|
||||||
|
* - recommended_programs: 推荐的移民方案
|
||||||
|
* - conversion_path: 转化路径
|
||||||
|
* - device_info: 用户设备信息
|
||||||
|
*/
|
||||||
|
export class AddConsultingStateToConversation1706100000000 implements MigrationInterface {
|
||||||
|
name = 'AddConsultingStateToConversation1706100000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 添加咨询阶段字段
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "conversations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "consulting_stage" VARCHAR(30) DEFAULT 'greeting'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 添加咨询状态JSON字段
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "conversations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "consulting_state" JSONB
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 添加已收集信息字段
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "conversations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "collected_info" JSONB
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 添加推荐方案字段
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "conversations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "recommended_programs" TEXT[]
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 添加转化路径字段
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "conversations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "conversion_path" VARCHAR(30)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 添加设备信息字段
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "conversations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "device_info" JSONB
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 创建索引:按咨询阶段查询
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_conversations_consulting_stage"
|
||||||
|
ON "conversations" ("consulting_stage")
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 创建索引:按转化路径查询
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_conversations_conversion_path"
|
||||||
|
ON "conversations" ("conversion_path")
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 创建GIN索引:按收集的信息查询(JSONB)
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_conversations_collected_info"
|
||||||
|
ON "conversations" USING GIN ("collected_info")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 删除索引
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "idx_conversations_collected_info"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "idx_conversations_conversion_path"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "idx_conversations_consulting_stage"`);
|
||||||
|
|
||||||
|
// 删除字段
|
||||||
|
await queryRunner.query(`ALTER TABLE "conversations" DROP COLUMN IF EXISTS "device_info"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "conversations" DROP COLUMN IF EXISTS "conversion_path"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "conversations" DROP COLUMN IF EXISTS "recommended_programs"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "conversations" DROP COLUMN IF EXISTS "collected_info"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "conversations" DROP COLUMN IF EXISTS "consulting_state"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "conversations" DROP COLUMN IF EXISTS "consulting_stage"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue