feat: 更新AI教练服务,增强用户体重记录和分析功能
- 新增流式聊天处理逻辑,支持用户选择和指令解析,提升交互体验。 - 实现体重记录的确认和趋势分析功能,用户可查看体重变化及健康建议。 - 扩展DTO,增加交互类型以支持新的功能,确保数据结构的完整性。 - 优化错误处理和日志记录,提升系统稳定性和可维护性。
This commit is contained in:
@@ -148,59 +148,7 @@ export class AiCoachService {
|
|||||||
return messages;
|
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,197 +161,373 @@ export class AiCoachService {
|
|||||||
selectedChoiceId?: string;
|
selectedChoiceId?: string;
|
||||||
confirmationData?: any;
|
confirmationData?: any;
|
||||||
}): Promise<Readable | { type: 'structured'; data: any }> {
|
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||||||
// 解析指令(如果以 # 开头)
|
try {
|
||||||
const commandResult = this.parseCommand(params.userContent);
|
|
||||||
|
|
||||||
// 上下文:系统提示 + 历史 + 当前用户消息
|
// 1. 优先处理用户选择(选择逻辑)
|
||||||
const messages = await this.buildChatHistory(params.userId, params.conversationId);
|
if (params.selectedChoiceId) {
|
||||||
if (params.systemNotice) {
|
return await this.handleUserChoice({
|
||||||
messages.unshift({ role: 'system', content: params.systemNotice });
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 处理指令
|
||||||
|
if (commandResult.command) {
|
||||||
|
return await this.handleCommand(commandResult, params, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 处理普通对话(包括营养话题检测)
|
||||||
|
return await this.handleNormalChat(params, messages);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`streamChat error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return this.createStreamFromText('处理失败,请稍后重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理用户选择
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`commandResult: ${JSON.stringify(commandResult, null, 2)}`);
|
// 处理饮食确认选择
|
||||||
|
if (params.selectedChoiceId && params.confirmationData) {
|
||||||
|
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') {
|
if (commandResult.command === 'weight') {
|
||||||
const textWeightInfo = await this.processWeightFromText(params.userId, params.userContent);
|
return await this.handleWeightCommand(params);
|
||||||
if (textWeightInfo.systemNotice) {
|
}
|
||||||
params.systemNotice = textWeightInfo.systemNotice;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为体重相关话题提供用户体重信息上下文
|
if (commandResult.command === 'diet') {
|
||||||
const weightContext = await this.buildUserWeightContext(params.userId);
|
return await this.handleDietCommand(commandResult, params, messages);
|
||||||
if (weightContext) {
|
}
|
||||||
messages.unshift({ role: 'system', content: weightContext });
|
|
||||||
|
// 其他指令处理...
|
||||||
|
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' }
|
||||||
}
|
}
|
||||||
} else if (commandResult.command === 'diet') {
|
};
|
||||||
// 处理饮食记录指令
|
}
|
||||||
if (params.selectedChoiceId && params.confirmationData) {
|
|
||||||
// 第二阶段:用户已确认选择,记录饮食
|
/**
|
||||||
// confirmationData应该包含 { selectedOption: FoodConfirmationOption, imageUrl: string }
|
* 处理饮食确认选择
|
||||||
const { selectedOption, imageUrl } = params.confirmationData;
|
*/
|
||||||
const createDto = await this.dietAnalysisService.createDietRecordFromConfirmation(
|
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,
|
||||||
|
selectedOption,
|
||||||
|
imageUrl || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: `用户确认记录饮食:${selectedOption.label}`
|
||||||
|
});
|
||||||
|
|
||||||
|
messages.unshift({
|
||||||
|
role: 'system',
|
||||||
|
content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt()
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理饮食指令
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
|
||||||
|
const responseContent = `我识别到了以下食物,请选择要记录的内容:\n\n${recognitionResult.analysisText}`;
|
||||||
|
|
||||||
|
await AiMessage.create({
|
||||||
|
conversationId: params.conversationId,
|
||||||
|
userId: params.userId,
|
||||||
|
role: RoleType.Assistant,
|
||||||
|
content: responseContent,
|
||||||
|
metadata: {
|
||||||
|
model: this.model,
|
||||||
|
interactionType: 'food_confirmation',
|
||||||
|
choices: choices.length
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await AiConversation.update(
|
||||||
|
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(responseContent) },
|
||||||
|
{ where: { id: params.conversationId, userId: params.userId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'structured',
|
||||||
|
data: {
|
||||||
|
content: responseContent,
|
||||||
|
choices,
|
||||||
|
interactionType: 'food_confirmation',
|
||||||
|
pendingData: {
|
||||||
|
imageUrl: params.imageUrls[0],
|
||||||
|
recognitionResult
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
command: 'diet',
|
||||||
|
step: 'confirmation'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} 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,
|
params.userId,
|
||||||
selectedOption,
|
textAnalysisResult,
|
||||||
imageUrl || ''
|
''
|
||||||
);
|
);
|
||||||
|
|
||||||
if (createDto) {
|
if (createDto) {
|
||||||
// 构建用户的最近饮食上下文用于营养分析
|
|
||||||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||||||
if (nutritionContext) {
|
if (nutritionContext) {
|
||||||
messages.unshift({ role: 'system', content: nutritionContext });
|
messages.unshift({ role: 'system', content: nutritionContext });
|
||||||
}
|
}
|
||||||
|
|
||||||
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
|
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `用户确认记录饮食:${selectedOption.label}`
|
content: `用户通过文本记录饮食:${textAnalysisResult.analysisText}`
|
||||||
});
|
|
||||||
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
|
|
||||||
}
|
|
||||||
} else 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 // 第一个选项为推荐
|
|
||||||
}));
|
|
||||||
|
|
||||||
const responseContent = `我识别到了以下食物,请选择要记录的内容:\n\n${recognitionResult.analysisText}`;
|
|
||||||
|
|
||||||
// 保存AI助手的响应消息到数据库
|
|
||||||
await AiMessage.create({
|
|
||||||
conversationId: params.conversationId,
|
|
||||||
userId: params.userId,
|
|
||||||
role: RoleType.Assistant,
|
|
||||||
content: responseContent,
|
|
||||||
metadata: {
|
|
||||||
model: this.model,
|
|
||||||
interactionType: 'food_confirmation',
|
|
||||||
choices: choices.length
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新对话的最后消息时间
|
|
||||||
await AiConversation.update(
|
|
||||||
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(responseContent) },
|
|
||||||
{ where: { id: params.conversationId, userId: params.userId } }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'structured',
|
|
||||||
data: {
|
|
||||||
content: responseContent,
|
|
||||||
choices,
|
|
||||||
interactionType: 'food_confirmation',
|
|
||||||
pendingData: {
|
|
||||||
imageUrl: params.imageUrls[0],
|
|
||||||
recognitionResult
|
|
||||||
},
|
|
||||||
context: {
|
|
||||||
command: 'diet',
|
|
||||||
step: 'confirmation'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// 识别失败,返回普通文本响应
|
|
||||||
messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `用户尝试记录饮食但识别失败:${recognitionResult.analysisText}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} 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}`
|
|
||||||
});
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检测是否为饮食相关话题但不是指令形式
|
|
||||||
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() });
|
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 置信度不够或无法识别具体食物,提供营养分析模式
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: `用户提到饮食相关内容:${commandResult.cleanText}。分析结果:${textAnalysisResult.analysisText}`
|
||||||
|
});
|
||||||
|
|
||||||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||||||
if (nutritionContext) {
|
if (nutritionContext) {
|
||||||
messages.unshift({ role: 'system', content: nutritionContext });
|
messages.unshift({ role: 'system', content: nutritionContext });
|
||||||
}
|
}
|
||||||
messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT });
|
messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理普通对话
|
||||||
|
*/
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`messages: ${JSON.stringify(messages)}`);
|
return this.generateAIResponse(params.conversationId, params.userId, 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({
|
const stream = await this.client.chat.completions.create({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
messages,
|
messages,
|
||||||
@@ -424,15 +548,19 @@ export class AiCoachService {
|
|||||||
readable.push(delta);
|
readable.push(delta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 结束:将assistant消息入库
|
|
||||||
await AiMessage.create({
|
await AiMessage.create({
|
||||||
conversationId: params.conversationId,
|
conversationId,
|
||||||
userId: params.userId,
|
userId,
|
||||||
role: RoleType.Assistant,
|
role: RoleType.Assistant,
|
||||||
content: assistantContent,
|
content: assistantContent,
|
||||||
metadata: { model: this.model },
|
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) {
|
} catch (error) {
|
||||||
this.logger.error(`stream error: ${error?.message || error}`);
|
this.logger.error(`stream error: ${error?.message || error}`);
|
||||||
readable.push('\n[对话发生错误,请稍后重试]');
|
readable.push('\n[对话发生错误,请稍后重试]');
|
||||||
@@ -444,6 +572,32 @@ export class AiCoachService {
|
|||||||
return readable;
|
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(
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户体重历史记录
|
* 生成体重趋势分析
|
||||||
*/
|
*/
|
||||||
async getUserWeightHistory(userId: string, limit: number = 10): Promise<{
|
async generateWeightTrendAnalysis(userId: string, weightRecordData: any): Promise<string> {
|
||||||
currentWeight?: number;
|
|
||||||
history: Array<{ weight: number; source: string; createdAt: Date }>;
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
// 获取当前体重
|
const { newWeight } = weightRecordData;
|
||||||
const profile = await UserProfile.findOne({ where: { userId } });
|
|
||||||
const currentWeight = profile?.weight;
|
|
||||||
|
|
||||||
// 获取体重历史
|
const weightAnalysisPrompt = `用户刚刚记录了体重${newWeight}kg,请提供体重趋势分析、健康建议和鼓励。语言风格:亲切、专业、鼓励性。`;
|
||||||
const history = await this.usersService.getWeightHistory(userId, { limit });
|
|
||||||
|
|
||||||
return {
|
const completion = await this.client.chat.completions.create({
|
||||||
currentWeight: currentWeight || undefined,
|
model: this.model,
|
||||||
history
|
messages: [
|
||||||
};
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: weightAnalysisPrompt }
|
||||||
|
],
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
return completion.choices?.[0]?.message?.content || '体重趋势分析生成失败,请稍后重试。';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`获取用户体重历史失败: ${error instanceof Error ? error.message : String(error)}`);
|
this.logger.error(`生成体重趋势分析失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return { history: [] };
|
return '体重趋势分析生成失败,请稍后重试。';
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建体重更新的系统提示信息
|
|
||||||
*/
|
|
||||||
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 {};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ export class AiResponseDataDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
choices?: AiChoiceOptionDto[];
|
choices?: AiChoiceOptionDto[];
|
||||||
|
|
||||||
@ApiProperty({ description: '交互类型', enum: ['text', 'food_confirmation', 'selection'], required: false })
|
@ApiProperty({ description: '交互类型', enum: ['text', 'food_confirmation', 'weight_record_success', 'selection'], required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
interactionType?: 'text' | 'food_confirmation' | 'selection';
|
interactionType?: 'text' | 'food_confirmation' | 'weight_record_success' | 'selection';
|
||||||
|
|
||||||
@ApiProperty({ description: '需要用户确认的数据(可选)', required: false })
|
@ApiProperty({ description: '需要用户确认的数据(可选)', required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
Reference in New Issue
Block a user