Files
plates-server/src/ai-coach/services/diet-analysis.service.ts
richarjiang 485ba1f67c feat: 新增饮食记录和分析功能
- 创建饮食记录相关的数据库模型、DTO和API接口,支持用户手动添加和AI视觉识别记录饮食。
- 实现饮食分析服务,提供营养分析和健康建议,优化AI教练服务以集成饮食分析功能。
- 更新用户控制器,添加饮食记录的增删查改接口,增强用户饮食管理体验。
- 提供详细的API使用指南和数据库创建脚本,确保功能的完整性和可用性。
2025-08-18 16:27:01 +08:00

423 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
import { UsersService } from '../../users/users.service';
import { CreateDietRecordDto } from '../../users/dto/diet-record.dto';
import { MealType, DietRecordSource } from '../../users/models/user-diet-history.model';
/**
* 饮食分析结果接口
*/
export interface DietAnalysisResult {
shouldRecord: boolean;
confidence: number;
extractedData?: {
foodName: string;
mealType: MealType;
portionDescription?: string;
estimatedCalories?: number;
proteinGrams?: number;
carbohydrateGrams?: number;
fatGrams?: number;
fiberGrams?: number;
nutritionDetails?: any;
};
analysisText: string;
}
/**
* 饮食分析服务
* 负责处理饮食相关的AI分析、营养评估和上下文构建
*/
@Injectable()
export class DietAnalysisService {
private readonly logger = new Logger(DietAnalysisService.name);
private readonly client: OpenAI;
private readonly visionModel: string;
constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
this.client = new OpenAI({
apiKey: dashScopeApiKey,
baseURL,
});
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
}
/**
* 增强版饮食图片分析 - 返回结构化数据
* @param imageUrls 图片URL数组
* @returns 结构化的饮食分析结果
*/
async analyzeDietImageEnhanced(imageUrls: string[]): Promise<DietAnalysisResult> {
try {
const currentHour = new Date().getHours();
const suggestedMealType = this.getSuggestedMealType(currentHour);
const prompt = this.buildDietAnalysisPrompt(suggestedMealType);
const completion = await this.client.chat.completions.create({
model: this.visionModel,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...imageUrls.map((imageUrl) => ({ type: 'image_url', image_url: { url: imageUrl } as any })),
] as any,
},
],
temperature: 0.3,
response_format: { type: 'json_object' } as any,
});
const rawResult = completion.choices?.[0]?.message?.content || '{}';
this.logger.log(`Enhanced diet analysis result: ${rawResult}`);
return this.parseAndValidateResult(rawResult, suggestedMealType);
} catch (error) {
this.logger.error(`增强版饮食图片分析失败: ${error instanceof Error ? error.message : String(error)}`);
return {
shouldRecord: false,
confidence: 0,
analysisText: '饮食图片分析失败,请稍后重试'
};
}
}
/**
* 处理饮食记录并添加到数据库
* @param userId 用户ID
* @param analysisResult 分析结果
* @param imageUrl 图片URL
* @returns 饮食记录响应
*/
async processDietRecord(userId: string, analysisResult: DietAnalysisResult, imageUrl: string): Promise<CreateDietRecordDto | null> {
if (!analysisResult.shouldRecord || !analysisResult.extractedData) {
return null;
}
try {
const createDto: CreateDietRecordDto = {
mealType: analysisResult.extractedData.mealType,
foodName: analysisResult.extractedData.foodName,
portionDescription: analysisResult.extractedData.portionDescription,
estimatedCalories: analysisResult.extractedData.estimatedCalories,
proteinGrams: analysisResult.extractedData.proteinGrams,
carbohydrateGrams: analysisResult.extractedData.carbohydrateGrams,
fatGrams: analysisResult.extractedData.fatGrams,
fiberGrams: analysisResult.extractedData.fiberGrams,
source: DietRecordSource.Vision,
imageUrl: imageUrl,
aiAnalysisResult: analysisResult,
};
await this.usersService.addDietRecord(userId, createDto);
return createDto;
} catch (error) {
this.logger.error(`自动添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* 构建包含用户营养信息的系统提示
* @param userId 用户ID
* @returns 营养上下文字符串
*/
async buildUserNutritionContext(userId: string): Promise<string> {
try {
// 获取最近10顿饮食记录
const recentDietHistory = await this.usersService.getDietHistory(userId, { limit: 10 });
if (recentDietHistory.total === 0) {
return '\n\n=== 用户营养信息 ===\n这是用户的第一次饮食记录请给予鼓励并介绍饮食记录的价值。\n';
}
let context = '\n\n=== 用户最近饮食记录分析 ===\n';
// 获取营养汇总
const nutritionSummary = await this.usersService.getRecentNutritionSummary(userId, 10);
context += this.buildNutritionSummaryText(nutritionSummary);
context += this.buildMealDistributionText(recentDietHistory.records);
context += this.buildRecentMealsText(recentDietHistory.records);
context += this.buildNutritionTrendText(nutritionSummary);
context += `\n请基于用户的饮食记录历史提供个性化的营养分析和健康建议。`;
return context;
} catch (error) {
this.logger.error(`构建用户营养上下文失败: ${error instanceof Error ? error.message : String(error)}`);
return '';
}
}
/**
* 构建增强版饮食分析提示
* @returns 增强版饮食分析提示文本
*/
buildEnhancedDietAnalysisPrompt(): string {
return `增强版饮食分析专家模式:
你是一位资深的营养分析师专门负责处理用户的饮食记录和营养分析。用户已通过AI视觉识别记录了饮食信息你需要
1. 综合分析:
- 结合用户最近的饮食记录趋势
- 评估当前这餐在整体饮食结构中的作用
- 分析营养素搭配的合理性
2. 个性化建议:
- 基于用户的历史饮食记录给出针对性建议
- 考虑营养平衡、热量控制、健康目标等因素
- 提供具体可执行的改善方案
3. 健康指导:
- 如果发现营养不均衡,给出调整建议
- 推荐搭配食物或下一餐的建议
- 强调长期健康饮食习惯的重要性
请以温暖、专业、实用的语言风格回复,让用户感受到个性化的关怀和专业的指导。`;
}
/**
* 根据时间推断餐次类型
* @param currentHour 当前小时
* @returns 建议的餐次类型
*/
private getSuggestedMealType(currentHour: number): MealType {
if (currentHour >= 6 && currentHour < 10) {
return MealType.Breakfast;
} else if (currentHour >= 11 && currentHour < 15) {
return MealType.Lunch;
} else if (currentHour >= 17 && currentHour < 21) {
return MealType.Dinner;
} else {
return MealType.Snack;
}
}
/**
* 构建饮食分析提示
* @param suggestedMealType 建议的餐次类型
* @returns 提示文本
*/
private buildDietAnalysisPrompt(suggestedMealType: MealType): string {
return `作为专业营养分析师请分析这张食物图片并以严格JSON格式返回结果。
当前时间建议餐次:${suggestedMealType}
请返回以下格式的JSON不要包含其他文本
{
"shouldRecord": boolean, // 是否应该记录如果图片清晰且包含食物则为true
"confidence": number, // 识别置信度 0-100
"extractedData": {
"foodName": string, // 主要食物名称(简洁)
"mealType": "${suggestedMealType}", // 餐次类型,优先使用建议值
"portionDescription": string, // 份量描述(如"1碗"、"200g"等)
"estimatedCalories": number, // 估算总热量
"proteinGrams": number, // 蛋白质含量(克)
"carbohydrateGrams": number, // 碳水化合物含量(克)
"fatGrams": number, // 脂肪含量(克)
"fiberGrams": number, // 膳食纤维含量(克)
"nutritionDetails": { // 其他营养信息
"mainIngredients": string[], // 主要食材列表
"cookingMethod": string, // 烹饪方式
"foodCategories": string[] // 食物分类(如"主食"、"蛋白质"等)
}
},
"analysisText": string // 详细的文字分析说明
}
重要提示:
1. 如果图片模糊、无食物或无法识别设置shouldRecord为false
2. 营养数据要基于识别的食物种类和分量合理估算
3. foodName要简洁明了便于记录和查找
4. analysisText要详细说明识别的食物和营养分析`;
}
/**
* 解析和验证分析结果
* @param rawResult 原始结果字符串
* @param suggestedMealType 建议的餐次类型
* @returns 验证后的分析结果
*/
private parseAndValidateResult(rawResult: string, suggestedMealType: MealType): DietAnalysisResult {
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
} catch (parseError) {
this.logger.error(`JSON解析失败: ${parseError}`);
return {
shouldRecord: false,
confidence: 0,
analysisText: '图片分析失败:无法解析分析结果'
};
}
// 验证和标准化结果
const result: DietAnalysisResult = {
shouldRecord: parsedResult.shouldRecord || false,
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
analysisText: parsedResult.analysisText || '未提供分析说明'
};
if (result.shouldRecord && parsedResult.extractedData) {
const data = parsedResult.extractedData;
result.extractedData = {
foodName: data.foodName || '未知食物',
mealType: this.validateMealType(data.mealType) || suggestedMealType,
portionDescription: data.portionDescription,
estimatedCalories: this.validateNumber(data.estimatedCalories, 0, 2000),
proteinGrams: this.validateNumber(data.proteinGrams, 0, 200),
carbohydrateGrams: this.validateNumber(data.carbohydrateGrams, 0, 500),
fatGrams: this.validateNumber(data.fatGrams, 0, 200),
fiberGrams: this.validateNumber(data.fiberGrams, 0, 50),
nutritionDetails: data.nutritionDetails
};
}
return result;
}
/**
* 构建营养汇总文本
* @param nutritionSummary 营养汇总数据
* @returns 汇总文本
*/
private buildNutritionSummaryText(nutritionSummary: any): string {
let text = `最近${nutritionSummary.recordCount}顿饮食汇总:\n`;
text += `- 总热量:${nutritionSummary.totalCalories.toFixed(0)}卡路里\n`;
text += `- 蛋白质:${nutritionSummary.totalProtein.toFixed(1)}g\n`;
text += `- 碳水化合物:${nutritionSummary.totalCarbohydrates.toFixed(1)}g\n`;
text += `- 脂肪:${nutritionSummary.totalFat.toFixed(1)}g\n`;
if (nutritionSummary.totalFiber > 0) {
text += `- 膳食纤维:${nutritionSummary.totalFiber.toFixed(1)}g\n`;
}
return text;
}
/**
* 构建餐次分布文本
* @param records 饮食记录
* @returns 分布文本
*/
private buildMealDistributionText(records: any[]): string {
const mealTypeCount: Record<string, number> = {};
records.forEach(record => {
mealTypeCount[record.mealType] = (mealTypeCount[record.mealType] || 0) + 1;
});
if (Object.keys(mealTypeCount).length === 0) {
return '';
}
let text = `\n餐次分布`;
Object.entries(mealTypeCount).forEach(([mealType, count]) => {
const mealTypeName = this.getMealTypeName(mealType);
text += ` ${mealTypeName}${count}`;
});
text += `\n`;
return text;
}
/**
* 构建最近饮食详情文本
* @param records 饮食记录
* @returns 详情文本
*/
private buildRecentMealsText(records: any[]): string {
if (records.length === 0) {
return '';
}
let text = `\n最近饮食记录\n`;
const recentMeals = records.slice(0, 3);
recentMeals.forEach((record, index) => {
const date = new Date(record.createdAt).toLocaleDateString('zh-CN');
const time = new Date(record.createdAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
const mealTypeName = this.getMealTypeName(record.mealType);
text += `${index + 1}. ${date} ${time} ${mealTypeName}${record.foodName}`;
if (record.estimatedCalories) {
text += ` (约${record.estimatedCalories}卡路里)`;
}
text += `\n`;
});
return text;
}
/**
* 构建营养趋势分析文本
* @param nutritionSummary 营养汇总数据
* @returns 趋势分析文本
*/
private buildNutritionTrendText(nutritionSummary: any): string {
const avgCaloriesPerMeal = nutritionSummary.totalCalories / nutritionSummary.recordCount;
const avgProteinPerMeal = nutritionSummary.totalProtein / nutritionSummary.recordCount;
let text = `\n营养趋势分析\n`;
if (avgCaloriesPerMeal < 300) {
text += `- 平均每餐热量偏低(${avgCaloriesPerMeal.toFixed(0)}卡路里),建议增加营养密度\n`;
} else if (avgCaloriesPerMeal > 800) {
text += `- 平均每餐热量较高(${avgCaloriesPerMeal.toFixed(0)}卡路里),建议注意控制分量\n`;
} else {
text += `- 平均每餐热量适中(${avgCaloriesPerMeal.toFixed(0)}卡路里)\n`;
}
if (avgProteinPerMeal < 15) {
text += `- 蛋白质摄入偏低,建议增加优质蛋白质食物\n`;
} else {
text += `- 蛋白质摄入良好\n`;
}
return text;
}
/**
* 获取餐次类型的中文名称
* @param mealType 餐次类型
* @returns 中文名称
*/
private getMealTypeName(mealType: string): string {
const mealTypeNames: Record<string, string> = {
[MealType.Breakfast]: '早餐',
[MealType.Lunch]: '午餐',
[MealType.Dinner]: '晚餐',
[MealType.Snack]: '加餐',
[MealType.Other]: '其他'
};
return mealTypeNames[mealType] || mealType;
}
/**
* 验证餐次类型
* @param mealType 餐次类型字符串
* @returns 验证后的餐次类型或null
*/
private validateMealType(mealType: string): MealType | null {
const validTypes = Object.values(MealType);
return validTypes.includes(mealType as MealType) ? (mealType as MealType) : null;
}
/**
* 验证数字范围
* @param value 值
* @param min 最小值
* @param max 最大值
* @returns 验证后的数字或undefined
*/
private validateNumber(value: any, min: number, max: number): number | undefined {
const num = parseFloat(value);
if (isNaN(num)) return undefined;
return Math.max(min, Math.min(max, num));
}
}