From ede5730647b87839ecfcdbe854bc77ffe36a8d76 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 18 Aug 2025 18:59:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=A5=AE=E9=A3=9F?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=A1=AE=E8=AE=A4=E6=B5=81=E7=A8=8B=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A5=AE=E9=A3=9F=E8=AE=B0=E5=BD=95=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=B5=81=E7=A8=8B=EF=BC=8C=E5=B0=86=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=A8=A1=E5=BC=8F=E5=8D=87=E7=BA=A7=E4=B8=BA?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=A1=AE=E8=AE=A4=E6=A8=A1=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E7=94=A8=E6=88=B7=E4=BA=A4=E4=BA=92=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82=20-=20=E5=AE=9E=E7=8E=B0=E4=B8=A4=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E9=A5=AE=E9=A3=9F=E8=AE=B0=E5=BD=95=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81AI=E8=AF=86=E5=88=AB=E9=A3=9F?= =?UTF-8?q?=E7=89=A9=E5=B9=B6=E7=94=9F=E6=88=90=E7=A1=AE=E8=AE=A4=E9=80=89?= =?UTF-8?q?=E9=A1=B9=EF=BC=8C=E7=94=A8=E6=88=B7=E9=80=89=E6=8B=A9=E5=90=8E?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=B0=E6=95=B0=E6=8D=AE=E5=BA=93=E5=B9=B6?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E8=90=A5=E5=85=BB=E5=88=86=E6=9E=90=E3=80=82?= =?UTF-8?q?=20-=20=E6=89=A9=E5=B1=95DTO=E5=B1=82=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9B=B8=E5=85=B3=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E7=A1=AE=E8=AE=A4=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E3=80=82=20-=20=E6=9B=B4=E6=96=B0=E6=9C=8D=E5=8A=A1=E5=B1=82?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=A4=84=E7=90=86=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E7=9A=84=E6=96=B9=E6=B3=95=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=A5=AE=E9=A3=9F=E8=AE=B0=E5=BD=95=E7=9A=84=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E6=B5=81=E7=A8=8B=E3=80=82=20-=20=E5=A2=9E=E5=BC=BAAP?= =?UTF-8?q?I=E6=96=87=E6=A1=A3=EF=BC=8C=E8=AF=A6=E7=BB=86=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=96=B0=E6=B5=81=E7=A8=8B=E5=8F=8A=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=80=85=E7=90=86=E8=A7=A3=E5=92=8C=E4=BD=BF=E7=94=A8=E6=96=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...IET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md | 148 +++++++++++ docs/diet-confirmation-flow-api.md | 239 ++++++++++++++++++ docs/stream-response-solution-options.md | 151 +++++++++++ src/ai-coach/ai-coach.controller.ts | 38 ++- src/ai-coach/ai-coach.service.ts | 91 +++++-- src/ai-coach/dto/ai-chat.dto.ts | 60 +++++ .../services/diet-analysis.service.ts | 206 +++++++++++++++ 7 files changed, 903 insertions(+), 30 deletions(-) create mode 100644 docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md create mode 100644 docs/diet-confirmation-flow-api.md create mode 100644 docs/stream-response-solution-options.md diff --git a/docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md b/docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..fc79ec2 --- /dev/null +++ b/docs/DIET_CONFIRMATION_IMPLEMENTATION_SUMMARY.md @@ -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. 添加食物历史记录快速选择 diff --git a/docs/diet-confirmation-flow-api.md b/docs/diet-confirmation-flow-api.md new file mode 100644 index 0000000..e34a3c9 --- /dev/null +++ b/docs/diet-confirmation-flow-api.md @@ -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 // 第二阶段可以使用流式 +}; +``` diff --git a/docs/stream-response-solution-options.md b/docs/stream-response-solution-options.md new file mode 100644 index 0000000..e20f762 --- /dev/null +++ b/docs/stream-response-solution-options.md @@ -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 + +// 确认并记录API +@Post('confirm-food-record') +async confirmFoodRecord(body: { selectedOption: any, imageUrl: string }): Promise + +// 原有聊天API保持纯文本 +@Post('chat') +async chat(): Promise +``` + +### 方案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设计 diff --git a/src/ai-coach/ai-coach.controller.ts b/src/ai-coach/ai-coach.controller.ts index ed1cffd..00ebd1d 100644 --- a/src/ai-coach/ai-coach.controller.ts +++ b/src/ai-coach/ai-coach.controller.ts @@ -5,7 +5,7 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from '../users/services/apple-auth.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'; @ApiTags('ai-coach') @@ -36,14 +36,31 @@ export class AiCoachController { // 体重和饮食指令处理现在已经集成到 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) { // 非流式:聚合后一次性返回文本 - const readable = await this.aiCoachService.streamChat({ - userId, - conversationId, - userContent, - imageUrls: body.imageUrls, - }); let text = ''; for await (const chunk of readable) { text += chunk.toString(); @@ -58,13 +75,6 @@ export class AiCoachController { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Transfer-Encoding', 'chunked'); - const readable = await this.aiCoachService.streamChat({ - userId, - conversationId, - userContent, - imageUrls: body.imageUrls, - }); - readable.on('data', (chunk) => { res.write(chunk); }); diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts index ed0b622..3d272d1 100644 --- a/src/ai-coach/ai-coach.service.ts +++ b/src/ai-coach/ai-coach.service.ts @@ -7,7 +7,7 @@ import { AiConversation } from './models/ai-conversation.model'; import { PostureAssessment } from './models/posture-assessment.model'; import { UserProfile } from '../users/models/user-profile.model'; 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)和健身教练,我拥有丰富的专业知识,包括但不限于: @@ -210,7 +210,9 @@ export class AiCoachService { userContent: string; systemNotice?: string; imageUrls?: string[]; - }): Promise { + selectedChoiceId?: string; + confirmationData?: any; + }): Promise { // 解析指令(如果以 # 开头) const commandResult = this.parseCommand(params.userContent); @@ -235,15 +237,15 @@ export class AiCoachService { messages.unshift({ role: 'system', content: weightContext }); } } else if (commandResult.command === 'diet') { - // 使用饮食分析服务处理图片 - if (params.imageUrls) { - const dietAnalysisResult = await this.dietAnalysisService.analyzeDietImageEnhanced(params.imageUrls); - - // 如果AI确定应该记录饮食,则自动添加到数据库 - const createDto = await this.dietAnalysisService.processDietRecord( + // 处理饮食记录指令 + if (params.selectedChoiceId && params.confirmationData) { + // 第二阶段:用户已确认选择,记录饮食 + // confirmationData应该包含 { selectedOption: FoodConfirmationOption, imageUrl: string } + const { selectedOption, imageUrl } = params.confirmationData; + const createDto = await this.dietAnalysisService.createDietRecordFromConfirmation( params.userId, - dietAnalysisResult, - params.imageUrls[0] + selectedOption, + imageUrl || '' ); if (createDto) { @@ -254,13 +256,70 @@ export class AiCoachService { } params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`; - } - messages.push({ - role: 'user', - content: `用户通过拍照记录饮食,AI分析结果:\n${dietAnalysisResult.analysisText}` - }); - messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() }); + messages.push({ + role: 'user', + content: `用户确认记录饮食:${selectedOption.label}` + }); + 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}` + }); + } } } diff --git a/src/ai-coach/dto/ai-chat.dto.ts b/src/ai-coach/dto/ai-chat.dto.ts index 7a4c5b9..5e97ed6 100644 --- a/src/ai-coach/dto/ai-chat.dto.ts +++ b/src/ai-coach/dto/ai-chat.dto.ts @@ -33,11 +33,71 @@ export class AiChatRequestDto { @IsOptional() @IsBoolean() 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 { @ApiProperty() conversationId: string; + + @ApiProperty({ type: AiResponseDataDto, description: '响应数据(非流式时返回)', required: false }) + @IsOptional() + data?: AiResponseDataDto; } // 营养分析相关的DTO diff --git a/src/ai-coach/services/diet-analysis.service.ts b/src/ai-coach/services/diet-analysis.service.ts index 44e1107..e4b9345 100644 --- a/src/ai-coach/services/diet-analysis.service.ts +++ b/src/ai-coach/services/diet-analysis.service.ts @@ -25,6 +25,33 @@ export interface DietAnalysisResult { 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分析、营养评估和上下文构建 @@ -50,6 +77,47 @@ export class DietAnalysisService { this.visionModel = this.configService.get('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max'; } + /** + * 食物识别用于用户确认 - 新的确认流程 + * @param imageUrls 图片URL数组 + * @returns 食物识别确认结果 + */ + async recognizeFoodForConfirmation(imageUrls: string[]): Promise { + 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数组 @@ -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 { + 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 @@ -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 建议的餐次类型 @@ -242,6 +399,55 @@ export class DietAnalysisService { 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 原始结果字符串