perf:修复健康数据
This commit is contained in:
@@ -7,7 +7,7 @@ import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
|||||||
import { AiCoachService } from './ai-coach.service';
|
import { AiCoachService } from './ai-coach.service';
|
||||||
import { AiChatRequestDto, AiChatResponseDto, AiResponseDataDto } from './dto/ai-chat.dto';
|
import { AiChatRequestDto, AiChatResponseDto, AiResponseDataDto } from './dto/ai-chat.dto';
|
||||||
import { PostureAssessmentRequestDto, PostureAssessmentResponseDto } from './dto/posture-assessment.dto';
|
import { PostureAssessmentRequestDto, PostureAssessmentResponseDto } from './dto/posture-assessment.dto';
|
||||||
import { FoodRecognitionRequestDto, FoodRecognitionResponseDto } from './dto/food-recognition.dto';
|
import { FoodRecognitionRequestDto, FoodRecognitionResponseDto, TextFoodAnalysisRequestDto } from './dto/food-recognition.dto';
|
||||||
import { DietAnalysisService } from './services/diet-analysis.service';
|
import { DietAnalysisService } from './services/diet-analysis.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
@@ -207,6 +207,51 @@ export class AiCoachController {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('text-food-analysis')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '文本食物分析服务',
|
||||||
|
description: '分析用户口述的饮食文本内容,识别食物并返回数组格式的选项列表。支持中文食物描述,如"吃了一碗米饭"、"喝了一杯牛奶"等。返回数据结构与图片识别接口保持一致。'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: TextFoodAnalysisRequestDto })
|
||||||
|
async analyzeTextFood(
|
||||||
|
@Body() body: TextFoodAnalysisRequestDto,
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
): Promise<FoodRecognitionResponseDto> {
|
||||||
|
this.logger.log(`Text food analysis request from user: ${user.sub}, text: "${body.text}"`);
|
||||||
|
|
||||||
|
const result = await this.dietAnalysisService.analyzeTextFoodForConfirmation(body.text);
|
||||||
|
|
||||||
|
// 转换为DTO格式
|
||||||
|
const response: FoodRecognitionResponseDto = {
|
||||||
|
items: result.items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
foodName: item.foodName,
|
||||||
|
portion: item.portion,
|
||||||
|
calories: item.calories,
|
||||||
|
mealType: item.mealType,
|
||||||
|
nutritionData: {
|
||||||
|
proteinGrams: item.nutritionData.proteinGrams,
|
||||||
|
carbohydrateGrams: item.nutritionData.carbohydrateGrams,
|
||||||
|
fatGrams: item.nutritionData.fatGrams,
|
||||||
|
fiberGrams: item.nutritionData.fiberGrams,
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
analysisText: result.analysisText,
|
||||||
|
confidence: result.confidence,
|
||||||
|
isFoodDetected: result.isFoodDetected,
|
||||||
|
nonFoodMessage: result.nonFoodMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!result.isFoodDetected) {
|
||||||
|
this.logger.log(`Non-food detected in text for user: ${user.sub}, message: ${result.nonFoodMessage}`);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`Text food analysis completed: ${result.items.length} items recognized`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,3 +133,16 @@ export class FoodConfirmationRequestDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本食物分析请求DTO
|
||||||
|
*/
|
||||||
|
export class TextFoodAnalysisRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '用户描述的饮食文本内容',
|
||||||
|
example: '今天早餐吃了一碗燕麦粥加香蕉'
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
@@ -261,6 +261,39 @@ export class DietAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析文本中的食物用于用户确认 - 与图片识别接口保持数据结构一致
|
||||||
|
* @param userText 用户输入的文本描述
|
||||||
|
* @returns 食物识别确认结果
|
||||||
|
*/
|
||||||
|
async analyzeTextFoodForConfirmation(userText: string): Promise<FoodRecognitionResult> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`Text food analysis request: ${userText}`);
|
||||||
|
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
const suggestedMealType = this.getSuggestedMealType(currentHour);
|
||||||
|
|
||||||
|
// 使用专门的多食物文本分析 prompt
|
||||||
|
const prompt = this.buildMultiFoodTextAnalysisPrompt(suggestedMealType);
|
||||||
|
const completion = await this.makeTextApiCall(prompt, userText);
|
||||||
|
|
||||||
|
const rawResult = completion.choices?.[0]?.message?.content || '{}';
|
||||||
|
this.logger.log(`Multi-food text analysis result: ${rawResult}`);
|
||||||
|
|
||||||
|
// 直接解析为多食物结构
|
||||||
|
return this.parseMultiFoodTextResult(rawResult, suggestedMealType, userText);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`文本食物分析失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
analysisText: '文本食物分析失败,请稍后重试',
|
||||||
|
confidence: 0,
|
||||||
|
isFoodDetected: false,
|
||||||
|
nonFoodMessage: '服务暂时不可用,请稍后重试'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从用户确认的选项创建饮食记录
|
* 从用户确认的选项创建饮食记录
|
||||||
* @param userId 用户ID
|
* @param userId 用户ID
|
||||||
@@ -524,6 +557,112 @@ export class DietAnalysisService {
|
|||||||
4. analysisText要详细说明识别的食物和营养分析`;
|
4. analysisText要详细说明识别的食物和营养分析`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 DietAnalysisResult 转换为 FoodRecognitionResult
|
||||||
|
* @param analysisResult 饮食分析结果
|
||||||
|
* @param originalText 原始用户文本
|
||||||
|
* @returns 食物识别结果
|
||||||
|
*/
|
||||||
|
private convertDietAnalysisToFoodRecognition(
|
||||||
|
analysisResult: DietAnalysisResult,
|
||||||
|
originalText: string
|
||||||
|
): FoodRecognitionResult {
|
||||||
|
// 如果分析失败或不应该记录,返回未检测到食物的结果
|
||||||
|
if (!analysisResult.shouldRecord || !analysisResult.extractedData) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
analysisText: analysisResult.analysisText || '未能从文本中识别到具体食物信息',
|
||||||
|
confidence: analysisResult.confidence,
|
||||||
|
isFoodDetected: false,
|
||||||
|
nonFoodMessage: '请描述更具体的食物信息,如"吃了一碗米饭"、"喝了一杯牛奶"等'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为食物确认选项格式
|
||||||
|
const foodOption: FoodConfirmationOption = {
|
||||||
|
id: `text_food_${Date.now()}`,
|
||||||
|
label: `${analysisResult.extractedData.foodName} ${analysisResult.extractedData.estimatedCalories || 0}卡`,
|
||||||
|
foodName: analysisResult.extractedData.foodName,
|
||||||
|
portion: analysisResult.extractedData.portionDescription || '1份',
|
||||||
|
calories: analysisResult.extractedData.estimatedCalories || 0,
|
||||||
|
mealType: analysisResult.extractedData.mealType,
|
||||||
|
nutritionData: {
|
||||||
|
proteinGrams: analysisResult.extractedData.proteinGrams,
|
||||||
|
carbohydrateGrams: analysisResult.extractedData.carbohydrateGrams,
|
||||||
|
fatGrams: analysisResult.extractedData.fatGrams,
|
||||||
|
fiberGrams: analysisResult.extractedData.fiberGrams,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [foodOption],
|
||||||
|
analysisText: analysisResult.analysisText || `基于您的描述"${originalText}",识别出以下食物`,
|
||||||
|
confidence: analysisResult.confidence,
|
||||||
|
isFoodDetected: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建多食物文本分析提示 - 支持识别多种食物
|
||||||
|
* @param suggestedMealType 建议的餐次类型
|
||||||
|
* @returns 提示文本
|
||||||
|
*/
|
||||||
|
private buildMultiFoodTextAnalysisPrompt(suggestedMealType: MealType): string {
|
||||||
|
return `作为专业营养分析师,请分析用户描述的饮食内容,支持识别多种食物。
|
||||||
|
|
||||||
|
当前时间建议餐次:${suggestedMealType}
|
||||||
|
|
||||||
|
请返回以下格式的JSON(不要包含其他文本):
|
||||||
|
{
|
||||||
|
"confidence": number, // 整体识别置信度 0-100
|
||||||
|
"analysisText": string, // 简短的识别说明文字
|
||||||
|
"isFoodDetected": boolean, // 是否检测到食物
|
||||||
|
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息
|
||||||
|
"recognizedItems": [ // 识别的食物列表
|
||||||
|
{
|
||||||
|
"id": string, // 唯一标识符(使用 food_1, food_2 等)
|
||||||
|
"foodName": string, // 食物名称(简洁)
|
||||||
|
"portion": string, // 份量描述(如"1碗"、"1份"等)
|
||||||
|
"calories": number, // 估算热量
|
||||||
|
"mealType": "${suggestedMealType}", // 餐次类型
|
||||||
|
"label": string, // 显示给用户的完整选项文本(如"一碗米饭 280卡")
|
||||||
|
"nutritionData": {
|
||||||
|
"proteinGrams": number, // 蛋白质
|
||||||
|
"carbohydrateGrams": number, // 碳水化合物
|
||||||
|
"fatGrams": number, // 脂肪
|
||||||
|
"fiberGrams": number // 膳食纤维
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
**分析要求:**
|
||||||
|
1. **多食物识别:**
|
||||||
|
- 如果用户描述包含多种食物(如"一碗米饭,一份麻辣香锅"),为每种主要食物创建独立的选项
|
||||||
|
- 每个食物项目都要有准确的营养数据估算
|
||||||
|
- 最多识别8种不同的食物
|
||||||
|
|
||||||
|
2. **营养数据估算:**
|
||||||
|
- 根据食物种类、份量、烹饪方式合理估算营养成分
|
||||||
|
- 热量要基于常见份量进行估算
|
||||||
|
- 营养数据要符合该食物的实际营养特征
|
||||||
|
|
||||||
|
3. **处理复合菜品:**
|
||||||
|
- 对于复合菜品(如"麻辣香锅"、"宫保鸡丁"),可以作为整体记录
|
||||||
|
- 也可以分解为主要组成部分(如肉类、蔬菜、主食等)
|
||||||
|
- 优先选择用户更容易理解的记录方式
|
||||||
|
|
||||||
|
4. **非食物处理:**
|
||||||
|
- 如果描述不包含具体食物,设置 isFoodDetected: false
|
||||||
|
- 提供友好的提示信息
|
||||||
|
|
||||||
|
**示例输入处理:**
|
||||||
|
- "今天中午吃了一碗米饭,一份麻辣香锅" → 识别为2个选项
|
||||||
|
- "早餐吃了燕麦粥加香蕉和牛奶" → 可识别为1个复合选项或3个独立选项
|
||||||
|
- "晚上吃了牛肉面" → 识别为1个选项(面条+牛肉的复合菜品)
|
||||||
|
- "喝了水" → isFoodDetected: false(水不是营养食物)`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建文本饮食分析提示
|
* 构建文本饮食分析提示
|
||||||
* @param suggestedMealType 建议的餐次类型
|
* @param suggestedMealType 建议的餐次类型
|
||||||
@@ -572,6 +711,75 @@ export class DietAnalysisService {
|
|||||||
- "刚吃了两个苹果当零食"`;
|
- "刚吃了两个苹果当零食"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析多食物文本分析结果
|
||||||
|
* @param rawResult 原始结果字符串
|
||||||
|
* @param suggestedMealType 建议的餐次类型
|
||||||
|
* @param originalText 原始用户文本
|
||||||
|
* @returns 解析后的识别结果
|
||||||
|
*/
|
||||||
|
private parseMultiFoodTextResult(rawResult: string, suggestedMealType: MealType, originalText: string): 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 || `text_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 || `基于您的描述"${originalText}",识别出 ${recognizedItems.length} 种食物`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: recognizedItems,
|
||||||
|
analysisText,
|
||||||
|
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
|
||||||
|
isFoodDetected,
|
||||||
|
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || '请描述更具体的食物信息,如"吃了一碗米饭"、"喝了一杯牛奶"等') : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析食物识别结果
|
* 解析食物识别结果
|
||||||
* @param rawResult 原始结果字符串
|
* @param rawResult 原始结果字符串
|
||||||
|
|||||||
Reference in New Issue
Block a user