feat(diet-records): 新增营养成分表图片分析功能
- 添加营养成分表图片识别API接口,支持通过AI模型分析食物营养成分 - 新增NutritionAnalysisService服务,集成GLM-4.5V和Qwen VL视觉模型 - 实现营养成分提取和健康建议生成功能 - 添加完整的API文档和TypeScript类型定义 - 支持多种营养素类型识别,包括热量、蛋白质、脂肪等20+种营养素
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
# rule.md
|
# rule.md
|
||||||
|
|
||||||
这是一个 nodejs 基于 nestjs 框架的项目
|
你是一名拥有 20 年服务端开发经验的 javascript 工程师,这是一个 nodejs 基于 nestjs 框架的项目,与健康、健身、减肥相关
|
||||||
|
|
||||||
## 指导原则
|
## 指导原则
|
||||||
|
|
||||||
- 不要随意新增 markdown 文档
|
- 不要随意新增 markdown 文档
|
||||||
- 代码提交 message 用中文
|
- 代码提交 message 用中文
|
||||||
|
- 注意代码的可读性、架构实现要清晰
|
||||||
|
|||||||
295
docs/nutrition-analysis-api.md
Normal file
295
docs/nutrition-analysis-api.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# 营养成分表分析 API 文档
|
||||||
|
|
||||||
|
## 接口概述
|
||||||
|
|
||||||
|
本接口用于分析食物营养成分表图片,通过AI大模型智能识别图片中的营养成分信息,并为每个营养素提供详细的健康建议。
|
||||||
|
|
||||||
|
## 接口信息
|
||||||
|
|
||||||
|
- **接口地址**: `POST /diet-records/analyze-nutrition-image`
|
||||||
|
- **请求方式**: POST
|
||||||
|
- **内容类型**: `application/json`
|
||||||
|
- **认证方式**: Bearer Token (JWT)
|
||||||
|
|
||||||
|
## 请求参数
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| Authorization | string | 是 | JWT认证令牌,格式:`Bearer {token}` |
|
||||||
|
| Content-Type | string | 是 | 固定值:`application/json` |
|
||||||
|
|
||||||
|
### Body 参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|
||||||
|
|--------|------|------|------|------|
|
||||||
|
| imageUrl | string | 是 | 营养成分表图片的URL地址 | `https://example.com/nutrition-label.jpg` |
|
||||||
|
|
||||||
|
#### 请求示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"imageUrl": "https://example.com/nutrition-label.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"key": "energy_kcal",
|
||||||
|
"name": "热量",
|
||||||
|
"value": "840千焦",
|
||||||
|
"analysis": "840千焦约等于201卡路里,占成人每日推荐摄入总热量的10%,属于中等热量水平。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "protein",
|
||||||
|
"name": "蛋白质",
|
||||||
|
"value": "12.5g",
|
||||||
|
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "fat",
|
||||||
|
"name": "脂肪",
|
||||||
|
"value": "6.8g",
|
||||||
|
"analysis": "6.8克脂肪含量适中,主要包含不饱和脂肪酸,有助于维持正常的生理功能。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "carbohydrate",
|
||||||
|
"name": "碳水化合物",
|
||||||
|
"value": "28.5g",
|
||||||
|
"analysis": "28.5克碳水化合物提供主要能量来源,建议搭配运动以充分利用能量。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "sodium",
|
||||||
|
"name": "钠",
|
||||||
|
"value": "480mg",
|
||||||
|
"analysis": "480毫克钠含量适中,约占成人每日推荐摄入量的20%,高血压患者需注意控制总钠摄入。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": [],
|
||||||
|
"message": "错误描述信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应字段说明
|
||||||
|
|
||||||
|
### 通用字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| success | boolean | 操作是否成功 |
|
||||||
|
| data | array | 营养成分分析结果数组 |
|
||||||
|
| message | string | 错误信息(仅在失败时返回) |
|
||||||
|
|
||||||
|
### 营养成分项字段 (data数组中的对象)
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 | 示例 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| key | string | 营养素的唯一标识符 | `energy_kcal` |
|
||||||
|
| name | string | 营养素的中文名称 | `热量` |
|
||||||
|
| value | string | 从图片中识别的原始值和单位 | `840千焦` |
|
||||||
|
| analysis | string | 针对该营养素的详细健康建议 | `840千焦约等于201卡路里...` |
|
||||||
|
|
||||||
|
## 支持的营养素类型
|
||||||
|
|
||||||
|
| 营养素 | key值 | 中文名称 |
|
||||||
|
|--------|-------|----------|
|
||||||
|
| 热量/能量 | energy_kcal | 热量 |
|
||||||
|
| 蛋白质 | protein | 蛋白质 |
|
||||||
|
| 脂肪 | fat | 脂肪 |
|
||||||
|
| 碳水化合物 | carbohydrate | 碳水化合物 |
|
||||||
|
| 膳食纤维 | fiber | 膳食纤维 |
|
||||||
|
| 钠 | sodium | 钠 |
|
||||||
|
| 钙 | calcium | 钙 |
|
||||||
|
| 铁 | iron | 铁 |
|
||||||
|
| 锌 | zinc | 锌 |
|
||||||
|
| 维生素C | vitamin_c | 维生素C |
|
||||||
|
| 维生素A | vitamin_a | 维生素A |
|
||||||
|
| 维生素D | vitamin_d | 维生素D |
|
||||||
|
| 维生素E | vitamin_e | 维生素E |
|
||||||
|
| 维生素B1 | vitamin_b1 | 维生素B1 |
|
||||||
|
| 维生素B2 | vitamin_b2 | 维生素B2 |
|
||||||
|
| 维生素B6 | vitamin_b6 | 维生素B6 |
|
||||||
|
| 维生素B12 | vitamin_b12 | 维生素B12 |
|
||||||
|
| 叶酸 | folic_acid | 叶酸 |
|
||||||
|
| 胆固醇 | cholesterol | 胆固醇 |
|
||||||
|
| 饱和脂肪 | saturated_fat | 饱和脂肪 |
|
||||||
|
| 反式脂肪 | trans_fat | 反式脂肪 |
|
||||||
|
| 糖 | sugar | 糖 |
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
| HTTP状态码 | 错误信息 | 说明 |
|
||||||
|
|------------|----------|------|
|
||||||
|
| 400 | 请提供图片URL | 请求体中缺少imageUrl参数 |
|
||||||
|
| 400 | 图片URL格式不正确 | 提供的URL格式无效 |
|
||||||
|
| 401 | 未授权访问 | 缺少或无效的JWT令牌 |
|
||||||
|
| 500 | 营养成分表分析失败,请稍后重试 | AI模型调用失败或服务器内部错误 |
|
||||||
|
| 500 | 图片中未检测到有效的营养成分表信息 | 图片中未识别到营养成分表 |
|
||||||
|
|
||||||
|
## 使用注意事项
|
||||||
|
|
||||||
|
### 图片要求
|
||||||
|
|
||||||
|
1. **图片格式**: 支持 JPG、PNG、WebP 格式
|
||||||
|
2. **图片内容**: 必须包含清晰的营养成分表
|
||||||
|
3. **图片质量**: 建议使用高清、无模糊、光线充足的图片
|
||||||
|
4. **URL要求**: 图片URL必须是公网可访问的地址
|
||||||
|
|
||||||
|
### 最佳实践
|
||||||
|
|
||||||
|
1. **URL有效性**: 确保提供的图片URL在分析期间保持可访问
|
||||||
|
2. **图片预处理**: 建议在客户端对图片进行适当的裁剪,突出营养成分表部分
|
||||||
|
3. **错误处理**: 客户端应妥善处理各种错误情况,提供友好的用户提示
|
||||||
|
4. **重试机制**: 对于网络或服务器错误,建议实现适当的重试机制
|
||||||
|
|
||||||
|
### 限制说明
|
||||||
|
|
||||||
|
1. **调用频率**: 建议客户端控制调用频率,避免过于频繁的请求
|
||||||
|
2. **图片大小**: 虽然不直接限制图片大小,但过大的图片可能影响处理速度
|
||||||
|
3. **并发限制**: 服务端可能有并发请求限制,建议客户端实现队列机制
|
||||||
|
|
||||||
|
## 客户端集成示例
|
||||||
|
|
||||||
|
### JavaScript/TypeScript 示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NutritionAnalysisRequest {
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NutritionAnalysisItem {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
analysis: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NutritionAnalysisResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: NutritionAnalysisItem[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeNutritionImage(
|
||||||
|
imageUrl: string,
|
||||||
|
token: string
|
||||||
|
): Promise<NutritionAnalysisResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/diet-records/analyze-nutrition-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ imageUrl })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || '请求失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('营养成分分析失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
const token = 'your-jwt-token';
|
||||||
|
const imageUrl = 'https://example.com/nutrition-label.jpg';
|
||||||
|
|
||||||
|
analyzeNutritionImage(imageUrl, token)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
console.log('识别到营养素数量:', result.data.length);
|
||||||
|
result.data.forEach(item => {
|
||||||
|
console.log(`${item.name}: ${item.value}`);
|
||||||
|
console.log(`建议: ${item.analysis}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('分析失败:', result.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('请求异常:', error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Swift 示例
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct NutritionAnalysisRequest: Codable {
|
||||||
|
let imageUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NutritionAnalysisItem: Codable {
|
||||||
|
let key: String
|
||||||
|
let name: String
|
||||||
|
let value: String
|
||||||
|
let analysis: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NutritionAnalysisResponse: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let data: [NutritionAnalysisItem]
|
||||||
|
let message: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
class NutritionAnalysisService {
|
||||||
|
func analyzeNutritionImage(imageUrl: String, token: String) async throws -> NutritionAnalysisResponse {
|
||||||
|
guard let url = URL(string: "/diet-records/analyze-nutrition-image") else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let requestBody = NutritionAnalysisRequest(imageUrl: imageUrl)
|
||||||
|
request.httpBody = try JSONEncoder().encode(requestBody)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
throw NSError(domain: "APIError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP Error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = try JSONDecoder().decode(NutritionAnalysisResponse.self, from: data)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
| 版本 | 日期 | 更新内容 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 1.0.0 | 2024-10-16 | 初始版本,支持营养成分表图片分析功能 |
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
如有技术问题或集成困难,请联系开发团队获取支持。
|
||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
|
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||||
import { DietRecordsService } from './diet-records.service';
|
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 { 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 { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||||
@@ -27,6 +30,7 @@ export class DietRecordsController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dietRecordsService: DietRecordsService,
|
private readonly dietRecordsService: DietRecordsService,
|
||||||
|
private readonly nutritionAnalysisService: NutritionAnalysisService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,4 +165,57 @@ export class DietRecordsController {
|
|||||||
requestDto.mealType
|
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 { SequelizeModule } from '@nestjs/sequelize';
|
||||||
import { DietRecordsController } from './diet-records.controller';
|
import { DietRecordsController } from './diet-records.controller';
|
||||||
import { DietRecordsService } from './diet-records.service';
|
import { DietRecordsService } from './diet-records.service';
|
||||||
|
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
|
||||||
import { UserDietHistory } from '../users/models/user-diet-history.model';
|
import { UserDietHistory } from '../users/models/user-diet-history.model';
|
||||||
import { ActivityLog } from '../activity-logs/models/activity-log.model';
|
import { ActivityLog } from '../activity-logs/models/activity-log.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
@@ -14,7 +15,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module';
|
|||||||
forwardRef(() => AiCoachModule),
|
forwardRef(() => AiCoachModule),
|
||||||
],
|
],
|
||||||
controllers: [DietRecordsController],
|
controllers: [DietRecordsController],
|
||||||
providers: [DietRecordsService],
|
providers: [DietRecordsService, NutritionAnalysisService],
|
||||||
exports: [DietRecordsService],
|
exports: [DietRecordsService, NutritionAnalysisService],
|
||||||
})
|
})
|
||||||
export class DietRecordsModule { }
|
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