576 lines
16 KiB
TypeScript
576 lines
16 KiB
TypeScript
/**
|
||
* 策略执行引擎
|
||
*
|
||
* 负责:
|
||
* 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,
|
||
};
|
||
}
|
||
}
|