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:
hailin 2026-01-24 06:32:07 -08:00
parent 8352578bd3
commit cd5399eac3
7 changed files with 2561 additions and 2 deletions

View File

@ -20,6 +20,46 @@ export const ConversationStatus = {
export type ConversationStatusType =
(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')
export class ConversationEntity {
@PrimaryGeneratedColumn('uuid')
@ -43,6 +83,77 @@ export class ConversationEntity {
@Column({ name: 'message_count', default: 0 })
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' })
createdAt: Date;

View File

@ -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,
};
}
}

View File

@ -2,8 +2,10 @@ import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClaudeAgentService } from './claude-agent.service';
import { ClaudeAgentServiceV2 } from './claude-agent-v2.service';
import { ImmigrationToolsService } from './tools/immigration-tools.service';
import { TokenUsageService } from './token-usage.service';
import { StrategyEngineService } from './strategy/strategy-engine.service';
import { TokenUsageEntity } from '../../domain/entities/token-usage.entity';
import { KnowledgeModule } from '../knowledge/knowledge.module';
@ -14,7 +16,19 @@ import { KnowledgeModule } from '../knowledge/knowledge.module';
KnowledgeModule,
TypeOrmModule.forFeature([TokenUsageEntity]),
],
providers: [ClaudeAgentService, ImmigrationToolsService, TokenUsageService],
exports: [ClaudeAgentService, ImmigrationToolsService, TokenUsageService],
providers: [
ClaudeAgentService,
ClaudeAgentServiceV2,
ImmigrationToolsService,
TokenUsageService,
StrategyEngineService,
],
exports: [
ClaudeAgentService,
ClaudeAgentServiceV2,
ImmigrationToolsService,
TokenUsageService,
StrategyEngineService,
],
})
export class ClaudeModule {}

View File

@ -0,0 +1,6 @@
/**
*
*/
export * from './default-strategy';
export * from './strategy-engine.service';

View File

@ -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,
};
}
}

View File

@ -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"`);
}
}