feat: 实现饮食记录确认流程
- 新增饮食记录确认流程,将自动记录模式升级为用户确认模式,提升用户交互体验。 - 实现两阶段饮食记录流程,支持AI识别食物并生成确认选项,用户选择后记录到数据库并提供营养分析。 - 扩展DTO层,新增相关数据结构以支持确认流程。 - 更新服务层,新增处理确认逻辑的方法,优化饮食记录的创建流程。 - 增强API文档,详细说明新流程及使用建议,确保开发者理解和使用新功能。
This commit is contained in:
148
docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md
Normal file
148
docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# 饮食记录确认流程实现总结
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
将原有的饮食记录功能从"自动记录模式"升级为"用户确认模式",类似于 Cline、Kilo 等开源 AI 工具的交互体验。
|
||||||
|
|
||||||
|
## 实现的功能
|
||||||
|
|
||||||
|
### 1. 两阶段饮食记录流程
|
||||||
|
|
||||||
|
- **第一阶段**:AI识别图片中的食物,生成多个确认选项
|
||||||
|
- **第二阶段**:用户选择确认选项后,系统记录到数据库并提供营养分析
|
||||||
|
|
||||||
|
### 2. 新增数据结构
|
||||||
|
|
||||||
|
#### AiChoiceOptionDto
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string; // 选项唯一标识符
|
||||||
|
label: string; // 显示给用户的文本(如"一条鱼 200卡")
|
||||||
|
value: any; // 选项对应的数据
|
||||||
|
recommended?: boolean; // 是否为推荐选项
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AiResponseDataDto
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
content: string; // AI回复的文本内容
|
||||||
|
choices?: AiChoiceOptionDto[]; // 选择选项(可选)
|
||||||
|
interactionType?: string; // 交互类型
|
||||||
|
pendingData?: any; // 需要用户确认的数据
|
||||||
|
context?: any; // 上下文信息
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FoodConfirmationOption
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
foodName: string;
|
||||||
|
portion: string;
|
||||||
|
calories: number;
|
||||||
|
mealType: MealType;
|
||||||
|
nutritionData: { ... };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. API 增强
|
||||||
|
|
||||||
|
#### 请求参数新增
|
||||||
|
- `selectedChoiceId?: string` - 用户选择的选项ID
|
||||||
|
- `confirmationData?: any` - 用户确认的数据
|
||||||
|
|
||||||
|
#### 响应结构新增
|
||||||
|
- 支持返回结构化数据(选择选项)
|
||||||
|
- 支持返回传统流式文本
|
||||||
|
|
||||||
|
## 修改的文件
|
||||||
|
|
||||||
|
### 1. DTO 层
|
||||||
|
- **src/ai-coach/dto/ai-chat.dto.ts**
|
||||||
|
- 新增 `AiChoiceOptionDto`
|
||||||
|
- 新增 `AiResponseDataDto`
|
||||||
|
- 扩展 `AiChatRequestDto` 和 `AiChatResponseDto`
|
||||||
|
|
||||||
|
### 2. 服务层
|
||||||
|
- **src/ai-coach/services/diet-analysis.service.ts**
|
||||||
|
- 新增 `FoodConfirmationOption` 接口
|
||||||
|
- 新增 `FoodRecognitionResult` 接口
|
||||||
|
- 新增 `recognizeFoodForConfirmation()` 方法
|
||||||
|
- 新增 `createDietRecordFromConfirmation()` 方法
|
||||||
|
- 新增 `buildFoodRecognitionPrompt()` 方法
|
||||||
|
- 新增 `parseRecognitionResult()` 方法
|
||||||
|
|
||||||
|
- **src/ai-coach/ai-coach.service.ts**
|
||||||
|
- 更新 `streamChat()` 方法参数和返回类型
|
||||||
|
- 重构饮食记录逻辑,支持两阶段确认流程
|
||||||
|
- 新增结构化数据返回逻辑
|
||||||
|
|
||||||
|
### 3. 控制器层
|
||||||
|
- **src/ai-coach/ai-coach.controller.ts**
|
||||||
|
- 更新 `chat()` 方法,支持结构化响应
|
||||||
|
- 新增确认数据处理逻辑
|
||||||
|
|
||||||
|
### 4. 文档
|
||||||
|
- **docs/diet-confirmation-flow-api.md** - 新增API使用文档
|
||||||
|
- **docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md** - 本总结文档
|
||||||
|
|
||||||
|
## 流程示例
|
||||||
|
|
||||||
|
### 用户上传图片
|
||||||
|
1. 用户发送 `#记饮食` 指令并上传图片
|
||||||
|
2. AI识别食物,返回确认选项:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{"id": "food_0", "label": "一条鱼 200卡", "value": {...}},
|
||||||
|
{"id": "food_1", "label": "一根玉米 40卡", "value": {...}}
|
||||||
|
],
|
||||||
|
"interactionType": "food_confirmation"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户确认选择
|
||||||
|
1. 用户选择某个选项
|
||||||
|
2. 客户端发送确认请求,包含 `selectedChoiceId` 和 `confirmationData`
|
||||||
|
3. 系统记录到数据库,返回营养分析
|
||||||
|
|
||||||
|
## 技术特点
|
||||||
|
|
||||||
|
### 1. 向后兼容
|
||||||
|
- 保留原有的自动记录逻辑(`analyzeDietImageEnhanced` 方法)
|
||||||
|
- 新流程不影响其他功能
|
||||||
|
|
||||||
|
### 2. 类型安全
|
||||||
|
- 所有新增接口都有完整的 TypeScript 类型定义
|
||||||
|
- 使用 class-validator 进行数据验证
|
||||||
|
|
||||||
|
### 3. 错误处理
|
||||||
|
- 图片识别失败时回退到普通文本响应
|
||||||
|
- 确认数据无效时提供友好错误提示
|
||||||
|
|
||||||
|
### 4. 用户体验
|
||||||
|
- 类似 Cline/Kilo 的交互体验
|
||||||
|
- 清晰的选项展示(如"一条鱼 200卡")
|
||||||
|
- 推荐选项标识
|
||||||
|
|
||||||
|
## 部署说明
|
||||||
|
|
||||||
|
1. 代码已通过编译测试,无 TypeScript 错误
|
||||||
|
2. 保持向后兼容性,可以平滑部署
|
||||||
|
3. 建议先在测试环境验证新流程
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
1. **客户端适配**:需要客户端支持处理结构化响应和选择选项
|
||||||
|
2. **图片质量**:提醒用户上传清晰的食物图片
|
||||||
|
3. **用户引导**:在界面上提供使用说明
|
||||||
|
|
||||||
|
## 后续优化方向
|
||||||
|
|
||||||
|
1. 支持批量选择多个食物
|
||||||
|
2. 支持用户自定义修改份量和热量
|
||||||
|
3. 添加更多营养素信息展示
|
||||||
|
4. 支持语音确认
|
||||||
|
5. 添加食物历史记录快速选择
|
||||||
239
docs/diet-confirmation-flow-api.md
Normal file
239
docs/diet-confirmation-flow-api.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# 饮食记录确认流程 API 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
新的饮食记录流程分为两个阶段:
|
||||||
|
1. **图片识别阶段**:AI识别食物并返回确认选项
|
||||||
|
2. **用户确认阶段**:用户选择确认选项后记录到数据库
|
||||||
|
|
||||||
|
## 重要说明
|
||||||
|
|
||||||
|
⚠️ **流式响应兼容性**:当系统需要返回确认选项时,会自动使用非流式模式返回JSON结构,即使客户端请求了 `stream: true`。这确保了确认选项的正确显示。
|
||||||
|
|
||||||
|
## API 流程
|
||||||
|
|
||||||
|
### 第一阶段:图片识别(返回确认选项)
|
||||||
|
|
||||||
|
**请求示例:**
|
||||||
|
```json
|
||||||
|
POST /ai-coach/chat
|
||||||
|
{
|
||||||
|
"conversationId": "user123-1234567890",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "#记饮食"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"imageUrls": ["https://example.com/food-image.jpg"],
|
||||||
|
"stream": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conversationId": "user123-1234567890",
|
||||||
|
"data": {
|
||||||
|
"content": "我识别到了以下食物,请选择要记录的内容:\n\n图片中识别到烤鱼和米饭,看起来是一份营养均衡的晚餐。",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": "food_0",
|
||||||
|
"label": "一条烤鱼 220卡",
|
||||||
|
"value": {
|
||||||
|
"id": "food_0",
|
||||||
|
"foodName": "烤鱼",
|
||||||
|
"portion": "1条",
|
||||||
|
"calories": 220,
|
||||||
|
"mealType": "dinner",
|
||||||
|
"nutritionData": {
|
||||||
|
"proteinGrams": 35,
|
||||||
|
"carbohydrateGrams": 2,
|
||||||
|
"fatGrams": 8,
|
||||||
|
"fiberGrams": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "food_1",
|
||||||
|
"label": "一碗米饭 150卡",
|
||||||
|
"value": {
|
||||||
|
"id": "food_1",
|
||||||
|
"foodName": "米饭",
|
||||||
|
"portion": "1碗",
|
||||||
|
"calories": 150,
|
||||||
|
"mealType": "dinner",
|
||||||
|
"nutritionData": {
|
||||||
|
"proteinGrams": 3,
|
||||||
|
"carbohydrateGrams": 32,
|
||||||
|
"fatGrams": 0.5,
|
||||||
|
"fiberGrams": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recommended": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactionType": "food_confirmation",
|
||||||
|
"pendingData": {
|
||||||
|
"imageUrl": "https://example.com/food-image.jpg",
|
||||||
|
"recognitionResult": {
|
||||||
|
"recognizedItems": [...],
|
||||||
|
"analysisText": "图片中识别到烤鱼和米饭...",
|
||||||
|
"confidence": 85
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"command": "diet",
|
||||||
|
"step": "confirmation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二阶段:用户确认选择
|
||||||
|
|
||||||
|
**请求示例:**
|
||||||
|
```json
|
||||||
|
POST /ai-coach/chat
|
||||||
|
{
|
||||||
|
"conversationId": "user123-1234567890",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "我选择记录烤鱼"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"selectedChoiceId": "food_0",
|
||||||
|
"confirmationData": {
|
||||||
|
"selectedOption": {
|
||||||
|
"id": "food_0",
|
||||||
|
"foodName": "烤鱼",
|
||||||
|
"portion": "1条",
|
||||||
|
"calories": 220,
|
||||||
|
"mealType": "dinner",
|
||||||
|
"nutritionData": {
|
||||||
|
"proteinGrams": 35,
|
||||||
|
"carbohydrateGrams": 2,
|
||||||
|
"fatGrams": 8,
|
||||||
|
"fiberGrams": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imageUrl": "https://example.com/food-image.jpg"
|
||||||
|
},
|
||||||
|
"stream": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conversationId": "user123-1234567890",
|
||||||
|
"text": "很好!我已经为您记录了这份烤鱼(1条,约220卡路里)。\n\n根据您的饮食记录,这是一份优质的蛋白质来源,包含35克蛋白质,脂肪含量适中。建议搭配一些蔬菜来增加膳食纤维的摄入。\n\n您今天的饮食营养搭配看起来不错,记得保持均衡的饮食习惯!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据结构说明
|
||||||
|
|
||||||
|
### AiChoiceOptionDto
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string; // 选项唯一标识符
|
||||||
|
label: string; // 显示给用户的文本(如"一条鱼 200卡")
|
||||||
|
value: any; // 选项对应的数据
|
||||||
|
recommended?: boolean; // 是否为推荐选项
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AiResponseDataDto
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
content: string; // AI回复的文本内容
|
||||||
|
choices?: AiChoiceOptionDto[]; // 选择选项(可选)
|
||||||
|
interactionType?: string; // 交互类型:'text' | 'food_confirmation' | 'selection'
|
||||||
|
pendingData?: any; // 需要用户确认的数据(可选)
|
||||||
|
context?: any; // 上下文信息(可选)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FoodConfirmationOption
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string; // 唯一标识符
|
||||||
|
label: string; // 显示文本
|
||||||
|
foodName: string; // 食物名称
|
||||||
|
portion: string; // 份量描述
|
||||||
|
calories: number; // 估算热量
|
||||||
|
mealType: MealType; // 餐次类型
|
||||||
|
nutritionData: { // 营养数据
|
||||||
|
proteinGrams?: number; // 蛋白质(克)
|
||||||
|
carbohydrateGrams?: number; // 碳水化合物(克)
|
||||||
|
fatGrams?: number; // 脂肪(克)
|
||||||
|
fiberGrams?: number; // 膳食纤维(克)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 图片识别失败
|
||||||
|
如果图片模糊或无法识别食物,API会返回正常的文本响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conversationId": "user123-1234567890",
|
||||||
|
"text": "抱歉,我无法清晰地识别图片中的食物。请确保图片清晰,光线充足,食物在画面中清晰可见,然后重新上传。"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 无效的确认数据
|
||||||
|
如果第二阶段的确认数据无效,系统会返回错误提示:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conversationId": "user123-1234567890",
|
||||||
|
"text": "确认数据无效,请重新选择要记录的食物。"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
1. **图片质量**:确保上传的图片清晰,光线充足,食物在画面中清晰可见
|
||||||
|
2. **选择确认**:用户可以选择多个食物选项,每次确认记录一种食物
|
||||||
|
3. **营养分析**:系统会基于用户的历史饮食记录提供个性化的营养分析和建议
|
||||||
|
4. **流式响应处理**:
|
||||||
|
- 客户端应该检查响应的 `Content-Type`
|
||||||
|
- `application/json`:结构化数据(确认选项)
|
||||||
|
- `text/plain`:流式文本
|
||||||
|
- 当返回确认选项时,系统会忽略 `stream` 参数并返回JSON
|
||||||
|
|
||||||
|
## 客户端适配指南
|
||||||
|
|
||||||
|
### 响应类型检测
|
||||||
|
```javascript
|
||||||
|
// 检查响应类型
|
||||||
|
if (response.headers['content-type'].includes('application/json')) {
|
||||||
|
// 处理结构化数据(确认选项)
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.data && data.data.choices) {
|
||||||
|
// 显示选择选项
|
||||||
|
showFoodConfirmationOptions(data.data.choices);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 处理流式文本
|
||||||
|
handleStreamResponse(response);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 确认选择发送
|
||||||
|
```javascript
|
||||||
|
// 用户选择后发送确认
|
||||||
|
const confirmationRequest = {
|
||||||
|
conversationId: "user123-1234567890",
|
||||||
|
messages: [{ role: "user", content: "我选择记录烤鱼" }],
|
||||||
|
selectedChoiceId: "food_0",
|
||||||
|
confirmationData: {
|
||||||
|
selectedOption: selectedFoodOption,
|
||||||
|
imageUrl: originalImageUrl
|
||||||
|
},
|
||||||
|
stream: true // 第二阶段可以使用流式
|
||||||
|
};
|
||||||
|
```
|
||||||
151
docs/stream-response-solution-options.md
Normal file
151
docs/stream-response-solution-options.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# 流式响应与结构化数据冲突解决方案
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
当前实现中,`#记饮食` 指令在第一阶段需要返回结构化数据(确认选项),但客户端可能设置了 `stream: true`,导致响应类型冲突。
|
||||||
|
|
||||||
|
## 解决方案对比
|
||||||
|
|
||||||
|
### 方案1:强制非流式模式 ⭐ (当前实现)
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- 实现简单,改动最小
|
||||||
|
- 完全向后兼容
|
||||||
|
- 客户端只需检查 Content-Type
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
- 行为不够明确(忽略stream参数)
|
||||||
|
- 客户端需要额外处理响应类型检测
|
||||||
|
|
||||||
|
**实现:**
|
||||||
|
```typescript
|
||||||
|
// 当需要返回确认选项时,自动使用JSON响应
|
||||||
|
if (typeof result === 'object' && 'type' in result) {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.send({ conversationId, data: result.data });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案2:分离API端点
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- API语义清晰
|
||||||
|
- 响应类型明确
|
||||||
|
- 易于测试和维护
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
- 需要新增API端点
|
||||||
|
- 客户端需要适配新API
|
||||||
|
|
||||||
|
**建议API设计:**
|
||||||
|
```typescript
|
||||||
|
// 专门的食物识别API
|
||||||
|
@Post('analyze-food')
|
||||||
|
async analyzeFood(body: { imageUrls: string[] }): Promise<FoodRecognitionResponseDto>
|
||||||
|
|
||||||
|
// 确认并记录API
|
||||||
|
@Post('confirm-food-record')
|
||||||
|
async confirmFoodRecord(body: { selectedOption: any, imageUrl: string }): Promise<DietRecordResponseDto>
|
||||||
|
|
||||||
|
// 原有聊天API保持纯文本
|
||||||
|
@Post('chat')
|
||||||
|
async chat(): Promise<StreamableFile | { text: string }>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案3:统一JSON响应格式
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- 响应格式统一
|
||||||
|
- 可以在JSON中指示是否需要流式处理
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
- 破坏向后兼容性
|
||||||
|
- 所有客户端都需要修改
|
||||||
|
|
||||||
|
**实现示例:**
|
||||||
|
```typescript
|
||||||
|
// 统一响应格式
|
||||||
|
{
|
||||||
|
conversationId: string;
|
||||||
|
responseType: 'text' | 'choices' | 'stream';
|
||||||
|
data: {
|
||||||
|
content?: string;
|
||||||
|
choices?: any[];
|
||||||
|
streamUrl?: string; // 流式数据的WebSocket URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案4:SSE (Server-Sent Events) 统一
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- 可以发送不同类型的事件
|
||||||
|
- 保持连接状态
|
||||||
|
- 支持实时交互
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
- 实现复杂度高
|
||||||
|
- 需要客户端支持SSE
|
||||||
|
|
||||||
|
**实现示例:**
|
||||||
|
```typescript
|
||||||
|
// SSE事件类型
|
||||||
|
event: text
|
||||||
|
data: {"chunk": "AI回复的文本片段"}
|
||||||
|
|
||||||
|
event: choices
|
||||||
|
data: {"choices": [...], "content": "请选择食物"}
|
||||||
|
|
||||||
|
event: complete
|
||||||
|
data: {"conversationId": "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 推荐方案
|
||||||
|
|
||||||
|
### 短期:方案1 (当前实现) ✅
|
||||||
|
- 快速解决问题
|
||||||
|
- 最小化影响
|
||||||
|
- 保持兼容性
|
||||||
|
|
||||||
|
### 长期:方案2 (分离API端点)
|
||||||
|
- 更清晰的API设计
|
||||||
|
- 更好的可维护性
|
||||||
|
- 更明确的职责分离
|
||||||
|
|
||||||
|
## 当前方案的客户端适配
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function sendDietRequest(imageUrls, conversationId, stream = true) {
|
||||||
|
const response = await fetch('/ai-coach/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversationId,
|
||||||
|
messages: [{ role: 'user', content: '#记饮食' }],
|
||||||
|
imageUrls,
|
||||||
|
stream
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查响应类型
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
// 结构化数据(确认选项)
|
||||||
|
const data = await response.json();
|
||||||
|
return { type: 'choices', data };
|
||||||
|
} else {
|
||||||
|
// 流式文本
|
||||||
|
return { type: 'stream', stream: response.body };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
当前的方案1实现简单有效,能够解决流式响应冲突问题。虽然在语义上不够完美,但在实际使用中是可行的。建议:
|
||||||
|
|
||||||
|
1. **立即采用方案1**,解决当前问题
|
||||||
|
2. **文档中明确说明**响应类型检测的必要性
|
||||||
|
3. **后续版本考虑方案2**,提供更清晰的API设计
|
||||||
@@ -5,7 +5,7 @@ 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';
|
||||||
import { AiCoachService } from './ai-coach.service';
|
import { AiCoachService } from './ai-coach.service';
|
||||||
import { AiChatRequestDto, AiChatResponseDto } 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';
|
||||||
|
|
||||||
@ApiTags('ai-coach')
|
@ApiTags('ai-coach')
|
||||||
@@ -36,14 +36,31 @@ export class AiCoachController {
|
|||||||
// 体重和饮食指令处理现在已经集成到 streamChat 方法中
|
// 体重和饮食指令处理现在已经集成到 streamChat 方法中
|
||||||
// 通过 # 字符开头的指令系统进行统一处理
|
// 通过 # 字符开头的指令系统进行统一处理
|
||||||
|
|
||||||
|
const result = await this.aiCoachService.streamChat({
|
||||||
|
userId,
|
||||||
|
conversationId,
|
||||||
|
userContent,
|
||||||
|
imageUrls: body.imageUrls,
|
||||||
|
selectedChoiceId: body.selectedChoiceId,
|
||||||
|
confirmationData: body.confirmationData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否返回结构化数据(如确认选项)
|
||||||
|
// 结构化数据必须使用非流式模式返回
|
||||||
|
if (typeof result === 'object' && 'type' in result) {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.send({
|
||||||
|
conversationId,
|
||||||
|
data: result.data
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通流式/非流式响应
|
||||||
|
const readable = result as any;
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
// 非流式:聚合后一次性返回文本
|
// 非流式:聚合后一次性返回文本
|
||||||
const readable = await this.aiCoachService.streamChat({
|
|
||||||
userId,
|
|
||||||
conversationId,
|
|
||||||
userContent,
|
|
||||||
imageUrls: body.imageUrls,
|
|
||||||
});
|
|
||||||
let text = '';
|
let text = '';
|
||||||
for await (const chunk of readable) {
|
for await (const chunk of readable) {
|
||||||
text += chunk.toString();
|
text += chunk.toString();
|
||||||
@@ -58,13 +75,6 @@ export class AiCoachController {
|
|||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Transfer-Encoding', 'chunked');
|
res.setHeader('Transfer-Encoding', 'chunked');
|
||||||
|
|
||||||
const readable = await this.aiCoachService.streamChat({
|
|
||||||
userId,
|
|
||||||
conversationId,
|
|
||||||
userContent,
|
|
||||||
imageUrls: body.imageUrls,
|
|
||||||
});
|
|
||||||
|
|
||||||
readable.on('data', (chunk) => {
|
readable.on('data', (chunk) => {
|
||||||
res.write(chunk);
|
res.write(chunk);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { AiConversation } from './models/ai-conversation.model';
|
|||||||
import { PostureAssessment } from './models/posture-assessment.model';
|
import { PostureAssessment } from './models/posture-assessment.model';
|
||||||
import { UserProfile } from '../users/models/user-profile.model';
|
import { UserProfile } from '../users/models/user-profile.model';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { DietAnalysisService, DietAnalysisResult } from './services/diet-analysis.service';
|
import { DietAnalysisService, DietAnalysisResult, FoodRecognitionResult, FoodConfirmationOption } from './services/diet-analysis.service';
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师(Nutrition Analyst)和健身教练,我拥有丰富的专业知识,包括但不限于:
|
const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师(Nutrition Analyst)和健身教练,我拥有丰富的专业知识,包括但不限于:
|
||||||
|
|
||||||
@@ -210,7 +210,9 @@ export class AiCoachService {
|
|||||||
userContent: string;
|
userContent: string;
|
||||||
systemNotice?: string;
|
systemNotice?: string;
|
||||||
imageUrls?: string[];
|
imageUrls?: string[];
|
||||||
}): Promise<Readable> {
|
selectedChoiceId?: string;
|
||||||
|
confirmationData?: any;
|
||||||
|
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||||||
// 解析指令(如果以 # 开头)
|
// 解析指令(如果以 # 开头)
|
||||||
const commandResult = this.parseCommand(params.userContent);
|
const commandResult = this.parseCommand(params.userContent);
|
||||||
|
|
||||||
@@ -235,15 +237,15 @@ export class AiCoachService {
|
|||||||
messages.unshift({ role: 'system', content: weightContext });
|
messages.unshift({ role: 'system', content: weightContext });
|
||||||
}
|
}
|
||||||
} else if (commandResult.command === 'diet') {
|
} else if (commandResult.command === 'diet') {
|
||||||
// 使用饮食分析服务处理图片
|
// 处理饮食记录指令
|
||||||
if (params.imageUrls) {
|
if (params.selectedChoiceId && params.confirmationData) {
|
||||||
const dietAnalysisResult = await this.dietAnalysisService.analyzeDietImageEnhanced(params.imageUrls);
|
// 第二阶段:用户已确认选择,记录饮食
|
||||||
|
// confirmationData应该包含 { selectedOption: FoodConfirmationOption, imageUrl: string }
|
||||||
// 如果AI确定应该记录饮食,则自动添加到数据库
|
const { selectedOption, imageUrl } = params.confirmationData;
|
||||||
const createDto = await this.dietAnalysisService.processDietRecord(
|
const createDto = await this.dietAnalysisService.createDietRecordFromConfirmation(
|
||||||
params.userId,
|
params.userId,
|
||||||
dietAnalysisResult,
|
selectedOption,
|
||||||
params.imageUrls[0]
|
imageUrl || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
if (createDto) {
|
if (createDto) {
|
||||||
@@ -254,13 +256,70 @@ export class AiCoachService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
|
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `用户通过拍照记录饮食,AI分析结果:\n${dietAnalysisResult.analysisText}`
|
content: `用户确认记录饮食:${selectedOption.label}`
|
||||||
});
|
});
|
||||||
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
|
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
|
||||||
|
}
|
||||||
|
} else if (params.imageUrls) {
|
||||||
|
// 第一阶段:图片识别,返回确认选项
|
||||||
|
const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation(params.imageUrls);
|
||||||
|
|
||||||
|
if (recognitionResult.recognizedItems.length > 0) {
|
||||||
|
// 返回结构化数据供用户确认
|
||||||
|
const choices = recognitionResult.recognizedItems.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
value: item,
|
||||||
|
recommended: recognitionResult.recognizedItems.indexOf(item) === 0 // 第一个选项为推荐
|
||||||
|
}));
|
||||||
|
|
||||||
|
const responseContent = `我识别到了以下食物,请选择要记录的内容:\n\n${recognitionResult.analysisText}`;
|
||||||
|
|
||||||
|
// 保存AI助手的响应消息到数据库
|
||||||
|
await AiMessage.create({
|
||||||
|
conversationId: params.conversationId,
|
||||||
|
userId: params.userId,
|
||||||
|
role: RoleType.Assistant,
|
||||||
|
content: responseContent,
|
||||||
|
metadata: {
|
||||||
|
model: this.model,
|
||||||
|
interactionType: 'food_confirmation',
|
||||||
|
choices: choices.length
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新对话的最后消息时间
|
||||||
|
await AiConversation.update(
|
||||||
|
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(responseContent) },
|
||||||
|
{ where: { id: params.conversationId, userId: params.userId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'structured',
|
||||||
|
data: {
|
||||||
|
content: responseContent,
|
||||||
|
choices,
|
||||||
|
interactionType: 'food_confirmation',
|
||||||
|
pendingData: {
|
||||||
|
imageUrl: params.imageUrls[0],
|
||||||
|
recognitionResult
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
command: 'diet',
|
||||||
|
step: 'confirmation'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 识别失败,返回普通文本响应
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: `用户尝试记录饮食但识别失败:${recognitionResult.analysisText}`
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,71 @@ export class AiChatRequestDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '用户选择的选项ID(用于确认流程)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
selectedChoiceId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '用户确认的数据(用于确认流程)' })
|
||||||
|
@IsOptional()
|
||||||
|
confirmationData?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择选项
|
||||||
|
export class AiChoiceOptionDto {
|
||||||
|
@ApiProperty({ description: '选项ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '选项显示文本' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '选项值/数据' })
|
||||||
|
@IsOptional()
|
||||||
|
value?: any;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否为推荐选项', default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
recommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展的AI响应数据
|
||||||
|
export class AiResponseDataDto {
|
||||||
|
@ApiProperty({ description: 'AI回复的文本内容' })
|
||||||
|
@IsString()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [AiChoiceOptionDto], description: '选择选项(可选)', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
choices?: AiChoiceOptionDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '交互类型', enum: ['text', 'food_confirmation', 'selection'], required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
interactionType?: 'text' | 'food_confirmation' | 'selection';
|
||||||
|
|
||||||
|
@ApiProperty({ description: '需要用户确认的数据(可选)', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
pendingData?: any;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '上下文信息(可选)', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
context?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AiChatResponseDto {
|
export class AiChatResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: AiResponseDataDto, description: '响应数据(非流式时返回)', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
data?: AiResponseDataDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 营养分析相关的DTO
|
// 营养分析相关的DTO
|
||||||
|
|||||||
@@ -25,6 +25,33 @@ export interface DietAnalysisResult {
|
|||||||
analysisText: string;
|
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 {
|
||||||
|
recognizedItems: FoodConfirmationOption[];
|
||||||
|
analysisText: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 饮食分析服务
|
* 饮食分析服务
|
||||||
* 负责处理饮食相关的AI分析、营养评估和上下文构建
|
* 负责处理饮食相关的AI分析、营养评估和上下文构建
|
||||||
@@ -50,6 +77,47 @@ export class DietAnalysisService {
|
|||||||
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
|
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 食物识别用于用户确认 - 新的确认流程
|
||||||
|
* @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.client.chat.completions.create({
|
||||||
|
model: this.visionModel,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: prompt },
|
||||||
|
...imageUrls.map((imageUrl) => ({ type: 'image_url', image_url: { url: imageUrl } as any })),
|
||||||
|
] as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
response_format: { type: 'json_object' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
recognizedItems: [],
|
||||||
|
analysisText: '食物识别失败,请稍后重试',
|
||||||
|
confidence: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 增强版饮食图片分析 - 返回结构化数据
|
* 增强版饮食图片分析 - 返回结构化数据
|
||||||
* @param imageUrls 图片URL数组
|
* @param imageUrls 图片URL数组
|
||||||
@@ -91,6 +159,55 @@ export class DietAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从用户确认的选项创建饮食记录
|
||||||
|
* @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.usersService.addDietRecord(userId, createDto);
|
||||||
|
return createDto;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`用户确认添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理饮食记录并添加到数据库
|
* 处理饮食记录并添加到数据库
|
||||||
* @param userId 用户ID
|
* @param userId 用户ID
|
||||||
@@ -203,6 +320,46 @@ export class DietAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建食物识别提示(用于确认流程)
|
||||||
|
* @param suggestedMealType 建议的餐次类型
|
||||||
|
* @returns 提示文本
|
||||||
|
*/
|
||||||
|
private buildFoodRecognitionPrompt(suggestedMealType: MealType): string {
|
||||||
|
return `作为专业营养分析师,请分析这张食物图片并生成用户确认选项。
|
||||||
|
|
||||||
|
当前时间建议餐次:${suggestedMealType}
|
||||||
|
|
||||||
|
请识别图片中的食物,并为每种食物生成确认选项。返回以下格式的JSON:
|
||||||
|
{
|
||||||
|
"confidence": number, // 整体识别置信度 0-100
|
||||||
|
"analysisText": string, // 简短的识别说明文字
|
||||||
|
"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. 如果图片中有多种食物,为每种主要食物生成一个选项
|
||||||
|
2. label字段要简洁易懂,格式如"一条鱼 200卡"、"一碗米饭 150卡"
|
||||||
|
3. 营养数据要合理估算
|
||||||
|
4. 如果图片模糊或无法识别,返回空的recognizedItems数组
|
||||||
|
5. 最多生成5个选项,优先选择主要食物`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建饮食分析提示
|
* 构建饮食分析提示
|
||||||
* @param suggestedMealType 建议的餐次类型
|
* @param suggestedMealType 建议的餐次类型
|
||||||
@@ -242,6 +399,55 @@ export class DietAnalysisService {
|
|||||||
4. analysisText要详细说明识别的食物和营养分析`;
|
4. analysisText要详细说明识别的食物和营养分析`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析食物识别结果
|
||||||
|
* @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 {
|
||||||
|
recognizedItems: [],
|
||||||
|
analysisText: '图片分析失败:无法解析识别结果',
|
||||||
|
confidence: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recognizedItems: FoodConfirmationOption[] = [];
|
||||||
|
|
||||||
|
if (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),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recognizedItems,
|
||||||
|
analysisText: parsedResult.analysisText || '已识别图片中的食物',
|
||||||
|
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析和验证分析结果
|
* 解析和验证分析结果
|
||||||
* @param rawResult 原始结果字符串
|
* @param rawResult 原始结果字符串
|
||||||
|
|||||||
Reference in New Issue
Block a user