feat: 更新AI教练服务,增强用户体重记录和分析功能

- 新增流式聊天处理逻辑,支持用户选择和指令解析,提升交互体验。
- 实现体重记录的确认和趋势分析功能,用户可查看体重变化及健康建议。
- 扩展DTO,增加交互类型以支持新的功能,确保数据结构的完整性。
- 优化错误处理和日志记录,提升系统稳定性和可维护性。
This commit is contained in:
richarjiang
2025-08-21 10:24:37 +08:00
parent 4cd8d59f12
commit 94e1b124df
2 changed files with 393 additions and 366 deletions

View File

@@ -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 '体重趋势分析生成失败,请稍后重试。';
}
}
}

View File

@@ -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()