feat: 新增食物识别API,调整字段名,扩展识别功能与提示逻辑

This commit is contained in:
richarjiang
2025-09-04 09:36:07 +08:00
parent 02f21f0858
commit d34f752776
5 changed files with 253 additions and 27 deletions

View File

@@ -7,6 +7,8 @@ import { AccessTokenPayload } from '../users/services/apple-auth.service';
import { AiCoachService } from './ai-coach.service';
import { AiChatRequestDto, AiChatResponseDto, AiResponseDataDto } from './dto/ai-chat.dto';
import { PostureAssessmentRequestDto, PostureAssessmentResponseDto } from './dto/posture-assessment.dto';
import { FoodRecognitionRequestDto, FoodRecognitionResponseDto } from './dto/food-recognition.dto';
import { DietAnalysisService } from './services/diet-analysis.service';
import { UsersService } from '../users/users.service';
@ApiTags('ai-coach')
@@ -16,6 +18,7 @@ export class AiCoachController {
private readonly logger = new Logger(AiCoachController.name);
constructor(
private readonly aiCoachService: AiCoachService,
private readonly dietAnalysisService: DietAnalysisService,
private readonly usersService: UsersService,
) { }
@@ -159,6 +162,51 @@ export class AiCoachController {
});
return res as any;
}
@Post('food-recognition')
@ApiOperation({
summary: '食物识别服务',
description: '识别图片中的食物并返回数组格式的选项列表,支持多食物识别。如果图片中不包含食物,会返回相应提示信息。'
})
@ApiBody({ type: FoodRecognitionRequestDto })
async recognizeFood(
@Body() body: FoodRecognitionRequestDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<FoodRecognitionResponseDto> {
this.logger.log(`Food recognition request from user: ${user.sub}, images: ${body.imageUrls?.length || 0}`);
const result = await this.dietAnalysisService.recognizeFoodForConfirmation(body.imageUrls);
// 转换为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 for user: ${user.sub}, message: ${result.nonFoodMessage}`);
} else {
this.logger.log(`Food recognition completed: ${result.items.length} items recognized`);
}
return response;
}
}

View File

@@ -415,12 +415,12 @@ export class AiCoachService {
// 处理图片饮食记录
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation(params.imageUrls);
if (recognitionResult.recognizedItems.length > 0) {
const choices = recognitionResult.recognizedItems.map(item => ({
if (recognitionResult.items.length > 0) {
const choices = recognitionResult.items.map(item => ({
id: item.id,
label: item.label,
value: item,
recommended: recognitionResult.recognizedItems.indexOf(item) === 0
recommended: recognitionResult.items.indexOf(item) === 0
}));
const responseContent = `我识别到了以下食物,请选择要记录的内容:\n\n${recognitionResult.analysisText}`;

View File

@@ -0,0 +1,135 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
/**
* 食物营养数据DTO
*/
export class FoodNutritionDataDto {
@ApiProperty({ description: '蛋白质含量(克)', required: false })
@IsOptional()
@IsInt()
@Min(0)
proteinGrams?: number;
@ApiProperty({ description: '碳水化合物含量(克)', required: false })
@IsOptional()
@IsInt()
@Min(0)
carbohydrateGrams?: number;
@ApiProperty({ description: '脂肪含量(克)', required: false })
@IsOptional()
@IsInt()
@Min(0)
fatGrams?: number;
@ApiProperty({ description: '膳食纤维含量(克)', required: false })
@IsOptional()
@IsInt()
@Min(0)
fiberGrams?: number;
}
/**
* 食物确认选项DTO
*/
export class FoodConfirmationOptionDto {
@ApiProperty({ description: '食物选项唯一标识符' })
@IsString()
@IsNotEmpty()
id: string;
@ApiProperty({ description: '显示给用户的完整选项文本' })
@IsString()
@IsNotEmpty()
label: string;
@ApiProperty({ description: '食物名称' })
@IsString()
@IsNotEmpty()
foodName: string;
@ApiProperty({ description: '份量描述' })
@IsString()
@IsNotEmpty()
portion: string;
@ApiProperty({ description: '估算热量' })
@IsInt()
@Min(0)
@Max(5000)
calories: number;
@ApiProperty({ description: '餐次类型' })
@IsString()
@IsNotEmpty()
mealType: string;
@ApiProperty({ description: '营养数据', type: FoodNutritionDataDto })
nutritionData: FoodNutritionDataDto;
}
/**
* 食物识别请求DTO
*/
export class FoodRecognitionRequestDto {
@ApiProperty({ description: '图片URL数组' })
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
imageUrls: string[];
}
/**
* 食物识别响应DTO - 总是返回数组结构
*/
export class FoodRecognitionResponseDto {
@ApiProperty({
description: '识别到的食物列表,即使只有一种食物也返回数组格式',
type: [FoodConfirmationOptionDto]
})
@IsArray()
items: FoodConfirmationOptionDto[];
@ApiProperty({ description: '识别说明文字' })
@IsString()
analysisText: string;
@ApiProperty({
description: '识别置信度',
minimum: 0,
maximum: 100
})
@IsInt()
@Min(0)
@Max(100)
confidence: number;
@ApiProperty({
description: '是否识别到食物',
default: true
})
@IsBoolean()
isFoodDetected: boolean;
@ApiProperty({
description: '非食物提示信息当isFoodDetected为false时显示',
required: false
})
@IsOptional()
@IsString()
nonFoodMessage?: string;
}
/**
* 食物确认请求DTO
*/
export class FoodConfirmationRequestDto {
@ApiProperty({ description: '用户选择的食物选项' })
selectedOption: FoodConfirmationOptionDto;
@ApiProperty({ description: '图片URL', required: false })
@IsOptional()
@IsString()
imageUrl?: string;
}

View File

@@ -44,12 +44,14 @@ export interface FoodConfirmationOption {
}
/**
* 食物识别确认结果接口
* 食物识别确认结果接口 - 现在总是返回数组结构
*/
export interface FoodRecognitionResult {
recognizedItems: FoodConfirmationOption[];
items: FoodConfirmationOption[]; // 修改统一使用items作为数组字段名
analysisText: string;
confidence: number;
isFoodDetected: boolean; // 是否识别到食物
nonFoodMessage?: string; // 非食物提示信息
}
/**
@@ -113,9 +115,11 @@ export class DietAnalysisService {
} catch (error) {
this.logger.error(`食物识别失败: ${error instanceof Error ? error.message : String(error)}`);
return {
recognizedItems: [],
items: [],
analysisText: '食物识别失败,请稍后重试',
confidence: 0
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '服务暂时不可用,请稍后重试'
};
}
}
@@ -369,15 +373,21 @@ export class DietAnalysisService {
* @returns 提示文本
*/
private buildFoodRecognitionPrompt(suggestedMealType: MealType): string {
return `作为专业营养分析师,请分析这张食物图片并生成用户确认选项
return `作为专业营养分析师,请分析这张图片并判断是否包含食物
当前时间建议餐次:${suggestedMealType}
请识别图片中的食物并为每种食物生成确认选项。返回以下格式的JSON
**首先判断图片内容:**
- 如果图片中包含食物,请识别并生成确认选项
- 如果图片中不包含食物(如风景、人物、物品、文字等),请明确标识
返回以下格式的JSON
{
"confidence": number, // 整体识别置信度 0-100
"analysisText": string, // 简短的识别说明文字
"recognizedItems": [ // 识别的食物列表
"isFoodDetected": boolean, // 是否检测到食物
"nonFoodMessage": string, // 当isFoodDetected为false时的提示信息
"recognizedItems": [ // 识别的食物列表(如果是食物才有内容)
{
"id": string, // 唯一标识符
"foodName": string, // 食物名称
@@ -395,12 +405,26 @@ export class DietAnalysisService {
]
}
要求:
1. 如果图片中有多种食物,为每种主要食物生成一个选项
2. label字段要简洁易懂格式如"一条鱼 200卡"、"一碗米饭 150卡"
3. 营养数据要合理估算
4. 如果图片模糊或无法识别返回空的recognizedItems数组
5. 最多生成5个选项优先选择主要食物`;
**识别规则:**
1. **非食物情况:**
- 如果图片中没有食物,设置 isFoodDetected: false
- recognizedItems 返回空数组
- nonFoodMessage 提供友好的提示,如"图片中未检测到食物,请上传包含食物的图片"
- confidence 设置为对判断的置信度
2. **食物识别情况:**
- 设置 isFoodDetected: true
- nonFoodMessage 可以为空或不设置
- 如果图片中有多种食物,为每种主要食物生成一个选项
- 如果图片中只有一种食物,也要生成一个包含该食物的数组选项
- label字段要简洁易懂格式如"一条鱼 200卡"、"一碗米饭 150卡"
- 营养数据要合理估算
- 最多生成8个选项优先选择主要食物
- 对于复合菜品(如盖浇饭、汤面等),既可以作为整体记录,也可以分解为主要组成部分
3. **模糊情况:**
- 如果图片模糊但能看出是食物相关,设置 isFoodDetected: true但返回空的recognizedItems数组
- analysisText 说明"图片模糊,无法准确识别食物"`;
}
/**
@@ -503,15 +527,22 @@ export class DietAnalysisService {
} catch (parseError) {
this.logger.error(`食物识别JSON解析失败: ${parseError}`);
return {
recognizedItems: [],
items: [],
analysisText: '图片分析失败:无法解析识别结果',
confidence: 0
confidence: 0,
isFoodDetected: false,
nonFoodMessage: '图片分析失败,请重新上传图片'
};
}
// 检查是否识别到食物
const isFoodDetected = parsedResult.isFoodDetected !== false; // 默认为true除非明确设置为false
const nonFoodMessage = parsedResult.nonFoodMessage || '';
const recognizedItems: FoodConfirmationOption[] = [];
if (parsedResult.recognizedItems && Array.isArray(parsedResult.recognizedItems)) {
// 只有在识别到食物时才处理食物项目
if (isFoodDetected && parsedResult.recognizedItems && Array.isArray(parsedResult.recognizedItems)) {
parsedResult.recognizedItems.forEach((item: any, index: number) => {
if (item.foodName && item.calories) {
recognizedItems.push({
@@ -532,10 +563,22 @@ export class DietAnalysisService {
});
}
// 根据是否识别到食物设置不同的分析文本
let analysisText = parsedResult.analysisText || '';
if (!isFoodDetected) {
analysisText = analysisText || '图片中未检测到食物';
} else if (recognizedItems.length === 0) {
analysisText = analysisText || '图片模糊,无法准确识别食物';
} else {
analysisText = analysisText || '已识别图片中的食物';
}
return {
recognizedItems,
analysisText: parsedResult.analysisText || '已识别图片中的食物',
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0))
items: recognizedItems,
analysisText,
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
isFoodDetected,
nonFoodMessage: !isFoodDetected ? (nonFoodMessage || '图片中未检测到食物,请上传包含食物的图片') : undefined
};
}

View File

@@ -306,7 +306,7 @@ export class DietRecordsService {
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]);
// 将识别结果转换为 CreateDietRecordDto 格式
const dietRecords: CreateDietRecordDto[] = recognitionResult.recognizedItems.map(item => ({
const dietRecords: CreateDietRecordDto[] = recognitionResult.items.map(item => ({
mealType: suggestedMealType || item.mealType,
foodName: item.foodName,
portionDescription: item.portion,
@@ -355,13 +355,13 @@ export class DietRecordsService {
// 如果指定了建议的餐次类型,更新所有识别项的餐次类型
if (suggestedMealType) {
recognitionResult.recognizedItems.forEach(item => {
recognitionResult.items.forEach(item => {
item.mealType = suggestedMealType;
});
}
return {
recognizedItems: recognitionResult.recognizedItems,
recognizedItems: recognitionResult.items,
analysisText: recognitionResult.analysisText,
confidence: recognitionResult.confidence,
imageUrl: imageUrl