feat(diet-records): 新增营养成分表图片分析功能
- 添加营养成分表图片识别API接口,支持通过AI模型分析食物营养成分 - 新增NutritionAnalysisService服务,集成GLM-4.5V和Qwen VL视觉模型 - 实现营养成分提取和健康建议生成功能 - 添加完整的API文档和TypeScript类型定义 - 支持多种营养素类型识别,包括热量、蛋白质、脂肪等20+种营养素
This commit is contained in:
@@ -15,7 +15,10 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||
import { DietRecordsService } from './diet-records.service';
|
||||
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
|
||||
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto';
|
||||
import { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.dto';
|
||||
import { NutritionAnalysisRequestDto } from './dto/nutrition-analysis-request.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||
@@ -27,6 +30,7 @@ export class DietRecordsController {
|
||||
|
||||
constructor(
|
||||
private readonly dietRecordsService: DietRecordsService,
|
||||
private readonly nutritionAnalysisService: NutritionAnalysisService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -161,4 +165,57 @@ export class DietRecordsController {
|
||||
requestDto.mealType
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析食物营养成分表图片
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('analyze-nutrition-image')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '分析食物营养成分表图片' })
|
||||
@ApiBody({ type: NutritionAnalysisRequestDto })
|
||||
@ApiResponse({ status: 200, description: '成功分析营养成分表', type: NutritionAnalysisResponseDto })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 401, description: '未授权访问' })
|
||||
@ApiResponse({ status: 500, description: '服务器内部错误' })
|
||||
async analyzeNutritionImage(
|
||||
@Body() requestDto: NutritionAnalysisRequestDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<NutritionAnalysisResponseDto> {
|
||||
this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
|
||||
|
||||
if (!requestDto.imageUrl) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '请提供图片URL'
|
||||
};
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(requestDto.imageUrl);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '图片URL格式不正确'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl);
|
||||
|
||||
this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表分析失败,请稍后重试'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { DietRecordsController } from './diet-records.controller';
|
||||
import { DietRecordsService } from './diet-records.service';
|
||||
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
|
||||
import { UserDietHistory } from '../users/models/user-diet-history.model';
|
||||
import { ActivityLog } from '../activity-logs/models/activity-log.model';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
@@ -14,7 +15,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module';
|
||||
forwardRef(() => AiCoachModule),
|
||||
],
|
||||
controllers: [DietRecordsController],
|
||||
providers: [DietRecordsService],
|
||||
exports: [DietRecordsService],
|
||||
providers: [DietRecordsService, NutritionAnalysisService],
|
||||
exports: [DietRecordsService, NutritionAnalysisService],
|
||||
})
|
||||
export class DietRecordsModule { }
|
||||
13
src/diet-records/dto/nutrition-analysis-request.dto.ts
Normal file
13
src/diet-records/dto/nutrition-analysis-request.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 营养成分分析请求DTO
|
||||
*/
|
||||
export class NutritionAnalysisRequestDto {
|
||||
@ApiProperty({
|
||||
description: '营养成分表图片URL',
|
||||
example: 'https://example.com/nutrition-label.jpg',
|
||||
required: true
|
||||
})
|
||||
imageUrl: string;
|
||||
}
|
||||
32
src/diet-records/dto/nutrition-analysis.dto.ts
Normal file
32
src/diet-records/dto/nutrition-analysis.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 营养成分分析结果项
|
||||
*/
|
||||
export class NutritionAnalysisItemDto {
|
||||
@ApiProperty({ description: '营养素的唯一标识', example: 'energy_kcal' })
|
||||
key: string;
|
||||
|
||||
@ApiProperty({ description: '营养素的中文名称', example: '热量' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '从图片中识别的原始值和单位', example: '840千焦' })
|
||||
value: string;
|
||||
|
||||
@ApiProperty({ description: '针对该营养素的详细健康建议', example: '840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。' })
|
||||
analysis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养成分分析响应DTO
|
||||
*/
|
||||
export class NutritionAnalysisResponseDto {
|
||||
@ApiProperty({ description: '操作是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '营养成分分析结果数组', type: [NutritionAnalysisItemDto] })
|
||||
data: NutritionAnalysisItemDto[];
|
||||
|
||||
@ApiProperty({ description: '响应消息', required: false })
|
||||
message?: string;
|
||||
}
|
||||
282
src/diet-records/services/nutrition-analysis.service.ts
Normal file
282
src/diet-records/services/nutrition-analysis.service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OpenAI } from 'openai';
|
||||
|
||||
/**
|
||||
* 营养成分分析结果接口
|
||||
*/
|
||||
export interface NutritionAnalysisResult {
|
||||
key: string; // 营养素的唯一标识,如 energy_kcal
|
||||
name: string; // 营养素的中文名称,如"热量"
|
||||
value: string; // 从图片中识别的原始值和单位,如"840千焦"
|
||||
analysis: string; // 针对该营养素的详细健康建议
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养成分分析响应接口
|
||||
*/
|
||||
export interface NutritionAnalysisResponse {
|
||||
success: boolean;
|
||||
data: NutritionAnalysisResult[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养成分分析服务
|
||||
* 负责处理食物营养成分表的AI分析
|
||||
*
|
||||
* 支持多种AI模型:
|
||||
* - GLM-4.5V (智谱AI) - 设置 AI_VISION_PROVIDER=glm
|
||||
* - Qwen VL (阿里云DashScope) - 设置 AI_VISION_PROVIDER=dashscope (默认)
|
||||
*/
|
||||
@Injectable()
|
||||
export class NutritionAnalysisService {
|
||||
private readonly logger = new Logger(NutritionAnalysisService.name);
|
||||
private readonly client: OpenAI;
|
||||
private readonly visionModel: string;
|
||||
private readonly apiProvider: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// 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.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.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析食物营养成分表图片
|
||||
* @param imageUrl 图片URL
|
||||
* @returns 营养成分分析结果
|
||||
*/
|
||||
async analyzeNutritionImage(imageUrl: string): Promise<NutritionAnalysisResponse> {
|
||||
try {
|
||||
this.logger.log(`开始分析营养成分表图片: ${imageUrl}`);
|
||||
|
||||
const prompt = this.buildNutritionAnalysisPrompt();
|
||||
|
||||
const completion = await this.makeVisionApiCall(prompt, [imageUrl]);
|
||||
|
||||
const rawResult = completion.choices?.[0]?.message?.content || '[]';
|
||||
this.logger.log(`营养成分分析原始结果: ${rawResult}`);
|
||||
|
||||
return this.parseNutritionAnalysisResult(rawResult);
|
||||
} catch (error) {
|
||||
this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表分析失败,请稍后重试'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 制作视觉模型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,
|
||||
};
|
||||
|
||||
// 处理图片URL
|
||||
const processedImages = imageUrls.map((imageUrl) => ({
|
||||
type: 'image_url',
|
||||
image_url: { url: imageUrl } 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 },
|
||||
...processedImages,
|
||||
] as any,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
} else {
|
||||
// DashScope format (default)
|
||||
return await this.client.chat.completions.create({
|
||||
...baseParams,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
...processedImages,
|
||||
] as any,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建营养成分分析提示
|
||||
* @returns 提示文本
|
||||
*/
|
||||
private buildNutritionAnalysisPrompt(): string {
|
||||
return `作为专业的营养分析师,请仔细分析这张图片中的营养成分表。
|
||||
|
||||
**任务要求:**
|
||||
1. 识别图片中的营养成分表,提取所有可见的营养素信息
|
||||
2. 为每个营养素提供详细的健康建议和分析
|
||||
3. 返回严格的JSON数组格式,不包含任何额外的解释或对话文本
|
||||
|
||||
**输出格式要求:**
|
||||
请严格按照以下JSON数组格式返回,每个对象包含四个字段:
|
||||
[
|
||||
{
|
||||
"key": "energy_kcal",
|
||||
"name": "热量",
|
||||
"value": "840千焦",
|
||||
"analysis": "840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。"
|
||||
},
|
||||
{
|
||||
"key": "protein",
|
||||
"name": "蛋白质",
|
||||
"value": "12.5g",
|
||||
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
|
||||
}
|
||||
]
|
||||
|
||||
**营养素标识符对照表:**
|
||||
- 热量/能量: energy_kcal
|
||||
- 蛋白质: protein
|
||||
- 脂肪: fat
|
||||
- 碳水化合物: carbohydrate
|
||||
- 膳食纤维: fiber
|
||||
- 钠: sodium
|
||||
- 钙: calcium
|
||||
- 铁: iron
|
||||
- 锌: zinc
|
||||
- 维生素C: vitamin_c
|
||||
- 维生素A: vitamin_a
|
||||
- 维生素D: vitamin_d
|
||||
- 维生素E: vitamin_e
|
||||
- 维生素B1: vitamin_b1
|
||||
- 维生素B2: vitamin_b2
|
||||
- 维生素B6: vitamin_b6
|
||||
- 维生素B12: vitamin_b12
|
||||
- 叶酸: folic_acid
|
||||
- 胆固醇: cholesterol
|
||||
- 饱和脂肪: saturated_fat
|
||||
- 反式脂肪: trans_fat
|
||||
- 糖: sugar
|
||||
- 其他营养素: other_nutrient
|
||||
|
||||
**分析要求:**
|
||||
1. 如果图片中没有营养成分表,返回空数组 []
|
||||
2. 为每个识别到的营养素提供具体的健康建议
|
||||
3. 建议应包含营养素的作用、摄入量参考和健康影响
|
||||
4. 数值分析要准确,建议要专业且实用
|
||||
5. 只返回JSON数组,不要包含任何其他文本
|
||||
|
||||
**重要提醒:**
|
||||
- 严格按照JSON数组格式返回
|
||||
- 不要添加任何解释性文字或对话内容
|
||||
- 确保JSON格式正确,可以被直接解析`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析营养成分分析结果
|
||||
* @param rawResult 原始结果字符串
|
||||
* @returns 解析后的分析结果
|
||||
*/
|
||||
private parseNutritionAnalysisResult(rawResult: string): NutritionAnalysisResponse {
|
||||
try {
|
||||
// 尝试解析JSON
|
||||
let parsedResult: any;
|
||||
try {
|
||||
parsedResult = JSON.parse(rawResult);
|
||||
} catch (parseError) {
|
||||
this.logger.error(`营养成分分析JSON解析失败: ${parseError}`);
|
||||
this.logger.error(`原始结果: ${rawResult}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表解析失败,无法识别有效的营养信息'
|
||||
};
|
||||
}
|
||||
|
||||
// 确保结果是数组
|
||||
if (!Array.isArray(parsedResult)) {
|
||||
this.logger.error(`营养成分分析结果不是数组格式: ${typeof parsedResult}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表格式错误,无法识别有效的营养信息'
|
||||
};
|
||||
}
|
||||
|
||||
// 验证和标准化每个营养素项
|
||||
const nutritionData: NutritionAnalysisResult[] = [];
|
||||
|
||||
for (const item of parsedResult) {
|
||||
if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) {
|
||||
nutritionData.push({
|
||||
key: String(item.key).trim(),
|
||||
name: String(item.name).trim(),
|
||||
value: String(item.value).trim(),
|
||||
analysis: String(item.analysis).trim()
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`跳过无效的营养素项: ${JSON.stringify(item)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (nutritionData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '图片中未检测到有效的营养成分表信息'
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log(`成功解析 ${nutritionData.length} 项营养素信息`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: nutritionData
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: '营养成分表处理失败,请稍后重试'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user