feat: 更新AI教练服务,增强用户体重记录和分析功能
- 新增流式聊天处理逻辑,支持用户选择和指令解析,提升交互体验。 - 实现体重记录的确认和趋势分析功能,用户可查看体重变化及健康建议。 - 扩展DTO,增加交互类型以支持新的功能,确保数据结构的完整性。 - 优化错误处理和日志记录,提升系统稳定性和可维护性。
This commit is contained in:
@@ -148,59 +148,7 @@ export class AiCoachService {
|
||||
return messages;
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建包含用户体重信息的系统提示
|
||||
*/
|
||||
private async buildUserWeightContext(userId: string): Promise<string> {
|
||||
try {
|
||||
const { currentWeight, history } = await this.getUserWeightHistory(userId, 10);
|
||||
|
||||
if (!currentWeight && history.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let context = '\n\n=== 用户体重信息 ===\n';
|
||||
|
||||
if (currentWeight) {
|
||||
context += `当前体重:${currentWeight}kg\n`;
|
||||
}
|
||||
|
||||
if (history.length > 0) {
|
||||
context += `体重历史记录(最近${Math.min(history.length, 10)}次):\n`;
|
||||
|
||||
// 按时间倒序显示最近的记录
|
||||
const recentHistory = history.slice(0, 10).reverse();
|
||||
recentHistory.forEach((record, index) => {
|
||||
const date = new Date(record.createdAt).toLocaleDateString('zh-CN');
|
||||
context += `${index + 1}. ${date}: ${record.weight}kg (${record.source === 'manual' ? '手动记录' : record.source === 'vision' ? '视觉识别' : '其他'})\n`;
|
||||
});
|
||||
|
||||
// 分析体重趋势
|
||||
if (recentHistory.length >= 2) {
|
||||
const trend = this.analyzeWeightTrend(recentHistory, currentWeight || recentHistory[0].weight);
|
||||
context += `\n体重趋势分析:${trend}\n`;
|
||||
|
||||
// 计算变化幅度
|
||||
const firstWeight = recentHistory[recentHistory.length - 1].weight;
|
||||
const lastWeight = recentHistory[0].weight;
|
||||
const totalChange = lastWeight - firstWeight;
|
||||
const changePercent = ((totalChange / firstWeight) * 100).toFixed(1);
|
||||
|
||||
if (Math.abs(totalChange) > 0.5) {
|
||||
const changeDirection = totalChange > 0 ? '增加' : '减少';
|
||||
context += `总体变化:${changeDirection}${Math.abs(totalChange).toFixed(1)}kg (${changePercent}%)\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context += '\n请根据用户的体重记录趋势,提供个性化的健康建议。';
|
||||
|
||||
return context;
|
||||
} catch (error) {
|
||||
this.logger.error(`构建用户体重上下文失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -213,34 +161,212 @@ export class AiCoachService {
|
||||
selectedChoiceId?: string;
|
||||
confirmationData?: any;
|
||||
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||||
// 解析指令(如果以 # 开头)
|
||||
try {
|
||||
|
||||
// 1. 优先处理用户选择(选择逻辑)
|
||||
if (params.selectedChoiceId) {
|
||||
return await this.handleUserChoice({
|
||||
userId: params.userId,
|
||||
conversationId: params.conversationId,
|
||||
userContent: params.userContent,
|
||||
selectedChoiceId: params.selectedChoiceId,
|
||||
confirmationData: params.confirmationData
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 解析用户输入的指令
|
||||
const commandResult = this.parseCommand(params.userContent);
|
||||
|
||||
// 上下文:系统提示 + 历史 + 当前用户消息
|
||||
// 3. 构建基础消息上下文
|
||||
const messages = await this.buildChatHistory(params.userId, params.conversationId);
|
||||
if (params.systemNotice) {
|
||||
messages.unshift({ role: 'system', content: params.systemNotice });
|
||||
}
|
||||
|
||||
this.logger.log(`commandResult: ${JSON.stringify(commandResult, null, 2)}`);
|
||||
|
||||
// 处理体重记录指令
|
||||
if (commandResult.command === 'weight') {
|
||||
const textWeightInfo = await this.processWeightFromText(params.userId, params.userContent);
|
||||
if (textWeightInfo.systemNotice) {
|
||||
params.systemNotice = textWeightInfo.systemNotice;
|
||||
// 4. 处理指令
|
||||
if (commandResult.command) {
|
||||
return await this.handleCommand(commandResult, params, messages);
|
||||
}
|
||||
|
||||
// 为体重相关话题提供用户体重信息上下文
|
||||
const weightContext = await this.buildUserWeightContext(params.userId);
|
||||
if (weightContext) {
|
||||
messages.unshift({ role: 'system', content: weightContext });
|
||||
// 5. 处理普通对话(包括营养话题检测)
|
||||
return await this.handleNormalChat(params, messages);
|
||||
} catch (error) {
|
||||
this.logger.error(`streamChat error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return this.createStreamFromText('处理失败,请稍后重试');
|
||||
}
|
||||
} else if (commandResult.command === 'diet') {
|
||||
// 处理饮食记录指令
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户选择
|
||||
*/
|
||||
private async handleUserChoice(params: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
userContent: string;
|
||||
selectedChoiceId: string;
|
||||
confirmationData?: any;
|
||||
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||||
|
||||
// 处理体重趋势分析选择
|
||||
if (params.selectedChoiceId === 'trend_analysis' && params.confirmationData?.weightRecordData) {
|
||||
return await this.handleWeightTrendAnalysis({
|
||||
userId: params.userId,
|
||||
conversationId: params.conversationId,
|
||||
confirmationData: params.confirmationData
|
||||
});
|
||||
}
|
||||
|
||||
// 处理饮食确认选择
|
||||
if (params.selectedChoiceId && params.confirmationData) {
|
||||
// 第二阶段:用户已确认选择,记录饮食
|
||||
// confirmationData应该包含 { selectedOption: FoodConfirmationOption, imageUrl: string }
|
||||
return await this.handleDietConfirmation({
|
||||
userId: params.userId,
|
||||
conversationId: params.conversationId,
|
||||
selectedChoiceId: params.selectedChoiceId,
|
||||
confirmationData: params.confirmationData
|
||||
});
|
||||
}
|
||||
|
||||
// 其他选择类型的处理...
|
||||
throw new Error(`未知的选择类型: ${params.selectedChoiceId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理指令
|
||||
*/
|
||||
private async handleCommand(
|
||||
commandResult: CommandResult,
|
||||
params: any,
|
||||
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
|
||||
): Promise<Readable | { type: 'structured'; data: any }> {
|
||||
|
||||
if (commandResult.command === 'weight') {
|
||||
return await this.handleWeightCommand(params);
|
||||
}
|
||||
|
||||
if (commandResult.command === 'diet') {
|
||||
return await this.handleDietCommand(commandResult, params, messages);
|
||||
}
|
||||
|
||||
// 其他指令处理...
|
||||
throw new Error(`未知的指令: ${commandResult.command}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理体重趋势分析选择
|
||||
*/
|
||||
private async handleWeightTrendAnalysis(params: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
confirmationData: { weightRecordData: any };
|
||||
}): Promise<Readable> {
|
||||
const analysisContent = await this.generateWeightTrendAnalysis(
|
||||
params.userId,
|
||||
params.confirmationData.weightRecordData
|
||||
);
|
||||
|
||||
// 保存消息记录
|
||||
await Promise.all([
|
||||
AiMessage.create({
|
||||
conversationId: params.conversationId,
|
||||
userId: params.userId,
|
||||
role: RoleType.User,
|
||||
content: '用户选择查看体重趋势分析',
|
||||
metadata: null,
|
||||
}),
|
||||
AiMessage.create({
|
||||
conversationId: params.conversationId,
|
||||
userId: params.userId,
|
||||
role: RoleType.Assistant,
|
||||
content: analysisContent,
|
||||
metadata: { model: this.model, interactionType: 'weight_trend_analysis' },
|
||||
})
|
||||
]);
|
||||
|
||||
// 更新对话时间
|
||||
await AiConversation.update(
|
||||
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(analysisContent) },
|
||||
{ where: { id: params.conversationId, userId: params.userId } }
|
||||
);
|
||||
|
||||
return this.createStreamFromText(analysisContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理体重指令
|
||||
*/
|
||||
private async handleWeightCommand(params: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
userContent: string;
|
||||
}): Promise<{ type: 'structured'; data: any }> {
|
||||
const weightKg = this.extractWeightFromText(params.userContent);
|
||||
|
||||
if (!weightKg) {
|
||||
throw new Error('无法提取有效的体重数值');
|
||||
}
|
||||
|
||||
// 更新体重到数据库
|
||||
await this.usersService.addWeightByVision(params.userId, weightKg);
|
||||
|
||||
// 构建成功消息
|
||||
const responseContent = `已成功记录体重:${weightKg}kg`;
|
||||
|
||||
// 保存消息
|
||||
await AiMessage.create({
|
||||
conversationId: params.conversationId,
|
||||
userId: params.userId,
|
||||
role: RoleType.Assistant,
|
||||
content: responseContent,
|
||||
metadata: {
|
||||
model: this.model,
|
||||
interactionType: 'weight_record_success',
|
||||
weightData: { newWeight: weightKg, recordedAt: new Date().toISOString() }
|
||||
},
|
||||
});
|
||||
|
||||
// 更新对话时间
|
||||
await AiConversation.update(
|
||||
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(responseContent) },
|
||||
{ where: { id: params.conversationId, userId: params.userId } }
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'structured',
|
||||
data: {
|
||||
content: responseContent,
|
||||
choices: [
|
||||
{
|
||||
id: 'trend_analysis',
|
||||
label: '查看体重趋势分析',
|
||||
value: 'weight_trend_analysis',
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'continue_chat',
|
||||
label: '继续对话',
|
||||
value: 'continue_normal_chat',
|
||||
recommended: false
|
||||
}
|
||||
],
|
||||
interactionType: 'weight_record_success',
|
||||
pendingData: {
|
||||
weightRecordData: { newWeight: weightKg, recordedAt: new Date().toISOString() }
|
||||
},
|
||||
context: { command: 'weight', step: 'record_success' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理饮食确认选择
|
||||
*/
|
||||
private async handleDietConfirmation(params: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
selectedChoiceId: string;
|
||||
confirmationData: any;
|
||||
}): Promise<Readable> {
|
||||
// 饮食确认逻辑保持原样
|
||||
const { selectedOption, imageUrl } = params.confirmationData;
|
||||
const createDto = await this.dietAnalysisService.createDietRecordFromConfirmation(
|
||||
params.userId,
|
||||
@@ -248,37 +374,52 @@ export class AiCoachService {
|
||||
imageUrl || ''
|
||||
);
|
||||
|
||||
if (createDto) {
|
||||
// 构建用户的最近饮食上下文用于营养分析
|
||||
if (!createDto) {
|
||||
throw new Error('饮食记录创建失败');
|
||||
}
|
||||
|
||||
const messages = await this.buildChatHistory(params.userId, params.conversationId);
|
||||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||||
|
||||
if (nutritionContext) {
|
||||
messages.unshift({ role: 'system', content: nutritionContext });
|
||||
}
|
||||
|
||||
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `用户确认记录饮食:${selectedOption.label}`
|
||||
});
|
||||
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
|
||||
|
||||
messages.unshift({
|
||||
role: 'system',
|
||||
content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt()
|
||||
});
|
||||
|
||||
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||||
}
|
||||
} else if (params.imageUrls) {
|
||||
// 第一阶段:图片识别,返回确认选项
|
||||
|
||||
/**
|
||||
* 处理饮食指令
|
||||
*/
|
||||
private async handleDietCommand(
|
||||
commandResult: CommandResult,
|
||||
params: any,
|
||||
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
|
||||
): Promise<Readable | { type: 'structured'; data: any }> {
|
||||
if (params.imageUrls) {
|
||||
// 处理图片饮食记录
|
||||
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation(params.imageUrls);
|
||||
|
||||
if (recognitionResult.recognizedItems.length > 0) {
|
||||
// 返回结构化数据供用户确认
|
||||
const choices = recognitionResult.recognizedItems.map(item => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
value: item,
|
||||
recommended: recognitionResult.recognizedItems.indexOf(item) === 0 // 第一个选项为推荐
|
||||
recommended: recognitionResult.recognizedItems.indexOf(item) === 0
|
||||
}));
|
||||
|
||||
const responseContent = `我识别到了以下食物,请选择要记录的内容:\n\n${recognitionResult.analysisText}`;
|
||||
|
||||
// 保存AI助手的响应消息到数据库
|
||||
await AiMessage.create({
|
||||
conversationId: params.conversationId,
|
||||
userId: params.userId,
|
||||
@@ -291,7 +432,6 @@ export class AiCoachService {
|
||||
},
|
||||
});
|
||||
|
||||
// 更新对话的最后消息时间
|
||||
await AiConversation.update(
|
||||
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(responseContent) },
|
||||
{ where: { id: params.conversationId, userId: params.userId } }
|
||||
@@ -314,33 +454,30 @@ export class AiCoachService {
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// 识别失败,返回普通文本响应
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `用户尝试记录饮食但识别失败:${recognitionResult.analysisText}`
|
||||
});
|
||||
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||||
}
|
||||
} else {
|
||||
// 处理文本饮食记录:没有图片,分析用户文本中的饮食信息
|
||||
// 处理文本饮食记录
|
||||
const textAnalysisResult = await this.dietAnalysisService.analyzeDietFromText(commandResult.cleanText);
|
||||
|
||||
if (textAnalysisResult.shouldRecord && textAnalysisResult.extractedData) {
|
||||
// 自动记录饮食信息
|
||||
const createDto = await this.dietAnalysisService.processDietRecord(
|
||||
params.userId,
|
||||
textAnalysisResult,
|
||||
'' // 文本记录没有图片
|
||||
''
|
||||
);
|
||||
|
||||
if (createDto) {
|
||||
// 构建用户的最近饮食上下文用于营养分析
|
||||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||||
if (nutritionContext) {
|
||||
messages.unshift({ role: 'system', content: nutritionContext });
|
||||
}
|
||||
|
||||
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `用户通过文本记录饮食:${textAnalysisResult.analysisText}`
|
||||
@@ -348,62 +485,49 @@ export class AiCoachService {
|
||||
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
|
||||
}
|
||||
} else {
|
||||
// 分析失败或置信度不够,提供普通的饮食建议
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `用户提到饮食相关内容:${commandResult.cleanText}。分析结果:${textAnalysisResult.analysisText}`
|
||||
});
|
||||
|
||||
// 为饮食相关话题提供营养分析上下文
|
||||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||||
if (nutritionContext) {
|
||||
messages.unshift({ role: 'system', content: nutritionContext });
|
||||
}
|
||||
messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT });
|
||||
}
|
||||
|
||||
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否为饮食相关话题但不是指令形式
|
||||
if (!commandResult.isCommand && this.isLikelyNutritionTopic(params.userContent, messages)) {
|
||||
// 尝试从用户文本中分析饮食信息
|
||||
const textAnalysisResult = await this.dietAnalysisService.analyzeDietFromText(params.userContent);
|
||||
|
||||
if (textAnalysisResult.shouldRecord && textAnalysisResult.extractedData && textAnalysisResult.confidence > 70) {
|
||||
// 置信度较高,自动记录饮食信息
|
||||
const createDto = await this.dietAnalysisService.processDietRecord(
|
||||
params.userId,
|
||||
textAnalysisResult,
|
||||
'' // 文本记录没有图片
|
||||
);
|
||||
|
||||
if (createDto) {
|
||||
// 构建用户的最近饮食上下文用于营养分析
|
||||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||||
if (nutritionContext) {
|
||||
messages.unshift({ role: 'system', content: nutritionContext });
|
||||
}
|
||||
|
||||
params.systemNotice = `系统提示:检测到您提到了具体的饮食信息,已自动为您记录了${createDto.foodName}(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `${params.userContent}\n\n[系统已自动识别并记录饮食信息:${textAnalysisResult.analysisText}]`
|
||||
});
|
||||
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
|
||||
}
|
||||
} else {
|
||||
// 置信度不够或无法识别具体食物,提供营养分析模式
|
||||
/**
|
||||
* 处理普通对话
|
||||
*/
|
||||
private async handleNormalChat(
|
||||
params: any,
|
||||
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
|
||||
): Promise<Readable> {
|
||||
// 检测营养话题
|
||||
if (this.isLikelyNutritionTopic(params.userContent, messages)) {
|
||||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||||
if (nutritionContext) {
|
||||
messages.unshift({ role: 'system', content: nutritionContext });
|
||||
}
|
||||
messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT });
|
||||
}
|
||||
|
||||
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||||
}
|
||||
|
||||
this.logger.log(`messages: ${JSON.stringify(messages)}`);
|
||||
|
||||
/**
|
||||
* 生成AI响应的通用方法
|
||||
*/
|
||||
private async generateAIResponse(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
|
||||
): Promise<Readable> {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
messages,
|
||||
@@ -424,15 +548,19 @@ export class AiCoachService {
|
||||
readable.push(delta);
|
||||
}
|
||||
}
|
||||
// 结束:将assistant消息入库
|
||||
|
||||
await AiMessage.create({
|
||||
conversationId: params.conversationId,
|
||||
userId: params.userId,
|
||||
conversationId,
|
||||
userId,
|
||||
role: RoleType.Assistant,
|
||||
content: assistantContent,
|
||||
metadata: { model: this.model },
|
||||
});
|
||||
await AiConversation.update({ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(assistantContent) }, { where: { id: params.conversationId, userId: params.userId } });
|
||||
|
||||
await AiConversation.update(
|
||||
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(assistantContent) },
|
||||
{ where: { id: conversationId, userId } }
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`stream error: ${error?.message || error}`);
|
||||
readable.push('\n[对话发生错误,请稍后重试]');
|
||||
@@ -444,6 +572,32 @@ export class AiCoachService {
|
||||
return readable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本创建流式响应
|
||||
*/
|
||||
private createStreamFromText(text: string): Readable {
|
||||
const readable = new Readable({ read() { } });
|
||||
|
||||
setTimeout(() => {
|
||||
const chunks = text.split('');
|
||||
let index = 0;
|
||||
|
||||
const pushChunk = () => {
|
||||
if (index < chunks.length) {
|
||||
readable.push(chunks[index]);
|
||||
index++;
|
||||
setTimeout(pushChunk, 20);
|
||||
} else {
|
||||
readable.push(null);
|
||||
}
|
||||
};
|
||||
|
||||
pushChunk();
|
||||
}, 100);
|
||||
|
||||
return readable;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private isLikelyNutritionTopic(
|
||||
@@ -549,32 +703,7 @@ export class AiCoachService {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 构建饮食分析提示(保留原有方法用于兼容)
|
||||
* @returns 饮食分析提示文本
|
||||
*/
|
||||
private buildDietAnalysisPrompt(): string {
|
||||
return `饮食分析专家模式:
|
||||
|
||||
你是一位专业的营养分析师和饮食评估专家。用户将通过拍照记录饮食,你需要:
|
||||
|
||||
1. 饮食识别:
|
||||
- 仔细观察图片中的食物种类、分量
|
||||
- 识别主要食材和烹饪方式
|
||||
- 估算食物的重量或体积
|
||||
|
||||
2. 热量分析:
|
||||
- 基于识别的食物,计算总热量
|
||||
- 分析三大营养素比例(蛋白质、碳水化合物、脂肪)
|
||||
- 估算主要维生素和矿物质含量
|
||||
|
||||
3. 健康建议:
|
||||
- 评估该餐的营养均衡性
|
||||
- 提供个性化的饮食改进建议
|
||||
- 针对用户的健康目标给出指导
|
||||
|
||||
请以结构化、清晰的方式输出结果,使用亲切专业的语言风格。`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -775,135 +904,33 @@ export class AiCoachService {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取用户体重历史记录
|
||||
* 生成体重趋势分析
|
||||
*/
|
||||
async getUserWeightHistory(userId: string, limit: number = 10): Promise<{
|
||||
currentWeight?: number;
|
||||
history: Array<{ weight: number; source: string; createdAt: Date }>;
|
||||
}> {
|
||||
async generateWeightTrendAnalysis(userId: string, weightRecordData: any): Promise<string> {
|
||||
try {
|
||||
// 获取当前体重
|
||||
const profile = await UserProfile.findOne({ where: { userId } });
|
||||
const currentWeight = profile?.weight;
|
||||
const { newWeight } = weightRecordData;
|
||||
|
||||
// 获取体重历史
|
||||
const history = await this.usersService.getWeightHistory(userId, { limit });
|
||||
const weightAnalysisPrompt = `用户刚刚记录了体重${newWeight}kg,请提供体重趋势分析、健康建议和鼓励。语言风格:亲切、专业、鼓励性。`;
|
||||
|
||||
return {
|
||||
currentWeight: currentWeight || undefined,
|
||||
history
|
||||
};
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: weightAnalysisPrompt }
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 300,
|
||||
});
|
||||
|
||||
return completion.choices?.[0]?.message?.content || '体重趋势分析生成失败,请稍后重试。';
|
||||
} catch (error) {
|
||||
this.logger.error(`获取用户体重历史失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { history: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建体重更新的系统提示信息
|
||||
*/
|
||||
private buildWeightUpdateSystemNotice(
|
||||
newWeight: number,
|
||||
currentWeight?: number,
|
||||
history: Array<{ weight: number; source: string; createdAt: Date }> = []
|
||||
): string {
|
||||
let notice = `系统提示:已为你更新体重为${newWeight}kg。`;
|
||||
|
||||
if (currentWeight && currentWeight !== newWeight) {
|
||||
const diff = newWeight - currentWeight;
|
||||
const diffText = diff > 0 ? `增加了${diff.toFixed(1)}kg` : `减少了${Math.abs(diff).toFixed(1)}kg`;
|
||||
notice += `相比之前的${currentWeight}kg,你${diffText}。`;
|
||||
}
|
||||
|
||||
// 添加历史对比信息
|
||||
if (history.length > 0) {
|
||||
const recentWeights = history.slice(0, 3);
|
||||
if (recentWeights.length > 1) {
|
||||
const trend = this.analyzeWeightTrend(recentWeights, newWeight);
|
||||
notice += trend;
|
||||
}
|
||||
}
|
||||
|
||||
return notice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析体重趋势
|
||||
*/
|
||||
private analyzeWeightTrend(
|
||||
recentWeights: Array<{ weight: number; createdAt: Date }>,
|
||||
newWeight: number
|
||||
): string {
|
||||
if (recentWeights.length < 2) return '';
|
||||
|
||||
const weights = [newWeight, ...recentWeights.map(w => w.weight)];
|
||||
let trend = '';
|
||||
|
||||
// 计算最近几次的平均变化
|
||||
let totalChange = 0;
|
||||
for (let i = 0; i < weights.length - 1; i++) {
|
||||
totalChange += weights[i] - weights[i + 1];
|
||||
}
|
||||
const avgChange = totalChange / (weights.length - 1);
|
||||
|
||||
if (Math.abs(avgChange) < 0.5) {
|
||||
trend = '你的体重保持相对稳定,继续保持良好的生活习惯!';
|
||||
} else if (avgChange > 0) {
|
||||
trend = `最近体重呈上升趋势,建议加强运动和注意饮食控制。`;
|
||||
} else {
|
||||
trend = `最近体重呈下降趋势,很棒的进步!继续坚持健康的生活方式。`;
|
||||
}
|
||||
|
||||
return trend;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理体重记录和更新(无图片版本)
|
||||
* 从用户文本中识别体重,更新记录,并返回相关信息
|
||||
*/
|
||||
async processWeightFromText(userId: string, userText?: string): Promise<{
|
||||
weightKg?: number;
|
||||
systemNotice?: string;
|
||||
shouldSkipChat?: boolean;
|
||||
}> {
|
||||
if (!userText) return {};
|
||||
|
||||
|
||||
try {
|
||||
// 从文本中提取体重
|
||||
const extractedWeight = this.extractWeightFromText(userText);
|
||||
this.logger.log(`extractedWeight: ${extractedWeight}`);
|
||||
|
||||
if (!extractedWeight) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 获取用户体重历史
|
||||
const { currentWeight, history } = await this.getUserWeightHistory(userId);
|
||||
|
||||
// 更新体重到数据库
|
||||
await this.usersService.addWeightByVision(userId, extractedWeight);
|
||||
|
||||
this.logger.log(`currentWeight: ${currentWeight}`);
|
||||
this.logger.log(`history: ${JSON.stringify(history)}`);
|
||||
|
||||
// 构建系统提示
|
||||
const systemNotice = this.buildWeightUpdateSystemNotice(
|
||||
extractedWeight,
|
||||
currentWeight || undefined,
|
||||
history
|
||||
);
|
||||
|
||||
return {
|
||||
weightKg: extractedWeight,
|
||||
systemNotice,
|
||||
shouldSkipChat: false // 仍然需要与AI聊天,让AI给出激励回复
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`处理文本体重记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return {};
|
||||
this.logger.error(`生成体重趋势分析失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return '体重趋势分析生成失败,请稍后重试。';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +77,10 @@ export class AiResponseDataDto {
|
||||
@IsArray()
|
||||
choices?: AiChoiceOptionDto[];
|
||||
|
||||
@ApiProperty({ description: '交互类型', enum: ['text', 'food_confirmation', 'selection'], required: false })
|
||||
@ApiProperty({ description: '交互类型', enum: ['text', 'food_confirmation', 'weight_record_success', 'selection'], required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
interactionType?: 'text' | 'food_confirmation' | 'selection';
|
||||
interactionType?: 'text' | 'food_confirmation' | 'weight_record_success' | 'selection';
|
||||
|
||||
@ApiProperty({ description: '需要用户确认的数据(可选)', required: false })
|
||||
@IsOptional()
|
||||
|
||||
Reference in New Issue
Block a user