Files
plates-server/src/ai-coach/services/diet-analysis.service.ts

821 lines
30 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 { DietRecordsService } from '../../diet-records/diet-records.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;
}
/**
* 食物确认选项接口
*/
export interface FoodConfirmationOption {
id: string;
label: string;
foodName: string;
portion: string;
calories: number;
mealType: MealType;
nutritionData: {
proteinGrams?: number;
carbohydrateGrams?: number;
fatGrams?: number;
fiberGrams?: number;
};
}
/**
* 食物识别确认结果接口 - 现在总是返回数组结构
*/
export interface FoodRecognitionResult {
items: FoodConfirmationOption[]; // 修改统一使用items作为数组字段名
analysisText: string;
confidence: number;
isFoodDetected: boolean; // 是否识别到食物
nonFoodMessage?: string; // 非食物提示信息
}
/**
* 饮食分析服务
* 负责处理饮食相关的AI分析、营养评估和上下文构建
*
* 支持多种AI模型
* - GLM-4.5V (智谱AI) - 设置 AI_VISION_PROVIDER=glm
* - Qwen VL (阿里云DashScope) - 设置 AI_VISION_PROVIDER=dashscope (默认)
*/
@Injectable()
export class DietAnalysisService {
private readonly logger = new Logger(DietAnalysisService.name);
private readonly client: OpenAI;
private readonly visionModel: string;
private readonly model: string;
private readonly apiProvider: string;
constructor(
private readonly configService: ConfigService,
private readonly dietRecordsService: DietRecordsService,
) {
// Support both GLM-4.5V and DashScope (Qwen) models
this.apiProvider = this.configService.get<string>('AI_VISION_PROVIDER') || 'dashscope';
if (this.apiProvider === 'glm') {
// GLM-4.5V Configuration
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
const glmBaseURL = this.configService.get<string>('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4';
this.client = new OpenAI({
apiKey: glmApiKey,
baseURL: glmBaseURL,
});
this.model = this.configService.get<string>('GLM_MODEL') || 'glm-4-flash';
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
} else {
// DashScope Configuration (default)
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.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-flash';
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
}
}
/**
* 制作视觉模型API调用 - 兼容GLM-4.5V和DashScope
* @param prompt 提示文本
* @param imageUrls 图片URL数组
* @returns API响应
*/
private async makeVisionApiCall(prompt: string, imageUrls: string[]) {
const baseParams = {
model: this.visionModel,
temperature: 0.3,
response_format: { type: 'json_object' } as any,
};
if (this.apiProvider === 'glm') {
// GLM-4.5V format
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...imageUrls.map((imageUrl) => ({
type: 'image_url',
image_url: { url: imageUrl }
} as any)),
] as any,
},
],
} as any);
} else {
// DashScope format (default)
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...imageUrls.map((imageUrl) => ({ type: 'image_url', image_url: { url: imageUrl } as any })),
] as any,
},
],
});
}
}
/**
* 制作文本模型API调用 - 兼容GLM-4.5和DashScope
* @param prompt 提示文本
* @param userText 用户文本
* @returns API响应
*/
private async makeTextApiCall(prompt: string, userText: string) {
const baseParams = {
model: this.model,
temperature: 0.3,
response_format: { type: 'json_object' } as any,
};
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: `${prompt}\n\n用户描述${userText}`
}
],
});
}
/**
* 食物识别用于用户确认 - 新的确认流程
* @param imageUrls 图片URL数组
* @returns 食物识别确认结果
*/
async recognizeFoodForConfirmation(imageUrls: string[]): Promise<FoodRecognitionResult> {
try {
const currentHour = new Date().getHours();
const suggestedMealType = this.getSuggestedMealType(currentHour);
const prompt = this.buildFoodRecognitionPrompt(suggestedMealType);
const completion = await this.makeVisionApiCall(prompt, imageUrls);
const rawResult = completion.choices?.[0]?.message?.content || '{}';
this.logger.log(`Food recognition result: ${rawResult}`);
return this.parseRecognitionResult(rawResult, suggestedMealType);
} catch (error) {
this.logger.error(`食物识别失败: ${error instanceof Error ? error.message : String(error)}`);
return {
items: [],
analysisText: '食物识别失败,请稍后重试',
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '服务暂时不可用,请稍后重试'
};
}
}
/**
* 增强版饮食图片分析 - 返回结构化数据
* @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.makeVisionApiCall(prompt, imageUrls);
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 userText 用户输入的文本
* @returns 饮食分析结果
*/
async analyzeDietFromText(userText: string): Promise<DietAnalysisResult> {
try {
const currentHour = new Date().getHours();
const suggestedMealType = this.getSuggestedMealType(currentHour);
const prompt = this.buildTextDietAnalysisPrompt(suggestedMealType);
const completion = await this.makeTextApiCall(prompt, userText);
const rawResult = completion.choices?.[0]?.message?.content || '{}';
this.logger.log(`Text 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 confirmedOption 用户确认的食物选项
* @param imageUrl 图片URL
* @returns 饮食记录响应
*/
async createDietRecordFromConfirmation(
userId: string,
confirmedOption: FoodConfirmationOption,
imageUrl: string
): Promise<CreateDietRecordDto | null> {
try {
const createDto: CreateDietRecordDto = {
mealType: confirmedOption.mealType,
foodName: confirmedOption.foodName,
portionDescription: confirmedOption.portion,
estimatedCalories: confirmedOption.calories,
proteinGrams: confirmedOption.nutritionData.proteinGrams,
carbohydrateGrams: confirmedOption.nutritionData.carbohydrateGrams,
fatGrams: confirmedOption.nutritionData.fatGrams,
fiberGrams: confirmedOption.nutritionData.fiberGrams,
source: DietRecordSource.Vision,
imageUrl: imageUrl,
aiAnalysisResult: {
shouldRecord: true,
confidence: 95, // 用户确认后置信度很高
extractedData: {
foodName: confirmedOption.foodName,
mealType: confirmedOption.mealType,
portionDescription: confirmedOption.portion,
estimatedCalories: confirmedOption.calories,
proteinGrams: confirmedOption.nutritionData.proteinGrams,
carbohydrateGrams: confirmedOption.nutritionData.carbohydrateGrams,
fatGrams: confirmedOption.nutritionData.fatGrams,
fiberGrams: confirmedOption.nutritionData.fiberGrams,
},
analysisText: `用户确认记录:${confirmedOption.label}`
}
};
await this.dietRecordsService.addDietRecord(userId, createDto);
return createDto;
} catch (error) {
this.logger.error(`用户确认添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* 处理饮食记录并添加到数据库
* @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 {
// 根据是否有图片URL来确定数据源
const source = imageUrl ? DietRecordSource.Vision : DietRecordSource.Manual;
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: source,
imageUrl: imageUrl || undefined,
aiAnalysisResult: analysisResult,
};
await this.dietRecordsService.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.dietRecordsService.getDietHistory(userId, { limit: 10 });
if (recentDietHistory.total === 0) {
return '\n\n=== 用户营养信息 ===\n这是用户的第一次饮食记录请给予鼓励并介绍饮食记录的价值。\n';
}
let context = '\n\n=== 用户最近饮食记录分析 ===\n';
// 获取营养汇总
const nutritionSummary = await this.dietRecordsService.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 buildFoodRecognitionPrompt(suggestedMealType: MealType): string {
return `作为专业营养分析师,请分析这张图片并判断是否包含食物。
当前时间建议餐次:${suggestedMealType}
**首先判断图片内容:**
- 如果图片中包含食物,请识别并生成确认选项
- 如果图片中不包含食物(如风景、人物、物品、文字等),请明确标识
返回以下格式的JSON
{
"confidence": number, // 整体识别置信度 0-100
"analysisText": string, // 简短的识别说明文字
"isFoodDetected": boolean, // 是否检测到食物
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息
"recognizedItems": [ // 识别的食物列表(如果是食物才有内容)
{
"id": string, // 唯一标识符
"foodName": string, // 食物名称
"portion": string, // 份量描述(如"1碗"、"150g"等)
"calories": number, // 估算热量
"mealType": "${suggestedMealType}", // 餐次类型
"label": string, // 显示给用户的完整选项文本(如"一条鱼 200卡"
"nutritionData": {
"proteinGrams": number, // 蛋白质
"carbohydrateGrams": number, // 碳水化合物
"fatGrams": number, // 脂肪
"fiberGrams": number // 膳食纤维
}
}
]
}
**识别规则:**
1. **非食物情况:**
- 如果图片中没有食物,设置 isFoodDetected: false
- recognizedItems 返回空数组
- nonFoodMessage 提供友好的提示,如"图片中未检测到食物,请上传包含食物的图片"
- confidence 设置为对判断的置信度
2. **食物识别情况:**
- 设置 isFoodDetected: true
- nonFoodMessage 可以为空或不设置
- 如果图片中有多种食物,为每种主要食物生成一个选项
- 如果图片中只有一种食物,也要生成一个包含该食物的数组选项
- label字段要简洁易懂格式如"一条鱼 200卡"、"一碗米饭 150卡"
- 营养数据要合理估算
- 最多生成8个选项优先选择主要食物
- 对于复合菜品(如盖浇饭、汤面等),既可以作为整体记录,也可以分解为主要组成部分
3. **模糊情况:**
- 如果图片模糊但能看出是食物相关,设置 isFoodDetected: true但返回空的recognizedItems数组
- analysisText 说明"图片模糊,无法准确识别食物"`;
}
/**
* 构建饮食分析提示
* @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 suggestedMealType 建议的餐次类型
* @returns 提示文本
*/
private buildTextDietAnalysisPrompt(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. 仔细识别用户描述中的食物名称、数量、烹饪方式等信息
2. 如果描述模糊或不包含具体食物设置shouldRecord为false
3. 营养数据要基于识别的食物种类和分量合理估算
4. foodName要简洁明了便于记录和查找
5. 支持中文食物描述,如"一碗米饭"、"两个鸡蛋"、"一份青菜"等
6. analysisText要详细说明识别的食物和营养分析
7. 如果用户提到多种食物,选择主要的食物作为记录对象,或合并为一餐记录
示例用户输入:
- "今天早餐吃了一碗燕麦粥加香蕉"
- "午餐点了一份鸡胸肉沙拉"
- "晚上吃了牛肉面,还有小菜"
- "刚吃了两个苹果当零食"`;
}
/**
* 解析食物识别结果
* @param rawResult 原始结果字符串
* @param suggestedMealType 建议的餐次类型
* @returns 解析后的识别结果
*/
private parseRecognitionResult(rawResult: string, suggestedMealType: MealType): FoodRecognitionResult {
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
} catch (parseError) {
this.logger.error(`食物识别JSON解析失败: ${parseError}`);
return {
items: [],
analysisText: '图片分析失败:无法解析识别结果',
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '图片分析失败,请重新上传图片'
};
}
// 检查是否识别到食物
const isFoodDetected = parsedResult.isFoodDetected !== false; // 默认为true除非明确设置为false
const nonFoodMessage = parsedResult.nonFoodMessage || '';
const recognizedItems: FoodConfirmationOption[] = [];
// 只有在识别到食物时才处理食物项目
if (isFoodDetected && parsedResult.recognizedItems && Array.isArray(parsedResult.recognizedItems)) {
parsedResult.recognizedItems.forEach((item: any, index: number) => {
if (item.foodName && item.calories) {
recognizedItems.push({
id: item.id || `food_${index}`,
label: item.label || `${item.foodName} ${item.calories}`,
foodName: item.foodName,
portion: item.portion || '1份',
calories: this.validateNumber(item.calories, 1, 2000) || 0,
mealType: this.validateMealType(item.mealType) || suggestedMealType,
nutritionData: {
proteinGrams: this.validateNumber(item.nutritionData?.proteinGrams, 0, 200),
carbohydrateGrams: this.validateNumber(item.nutritionData?.carbohydrateGrams, 0, 500),
fatGrams: this.validateNumber(item.nutritionData?.fatGrams, 0, 200),
fiberGrams: this.validateNumber(item.nutritionData?.fiberGrams, 0, 50),
}
});
}
});
}
// 根据是否识别到食物设置不同的分析文本
let analysisText = parsedResult.analysisText || '';
if (!isFoodDetected) {
analysisText = analysisText || '图片中未检测到食物';
} else if (recognizedItems.length === 0) {
analysisText = analysisText || '图片模糊,无法准确识别食物';
} else {
analysisText = analysisText || '已识别图片中的食物';
}
return {
items: recognizedItems,
analysisText,
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
isFoodDetected,
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || '图片中未检测到食物,请上传包含食物的图片') : undefined
};
}
/**
* 解析和验证分析结果
* @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));
}
}