821 lines
30 KiB
TypeScript
821 lines
30 KiB
TypeScript
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));
|
||
}
|
||
}
|