feat: 实现饮食记录确认流程
- 新增饮食记录确认流程,将自动记录模式升级为用户确认模式,提升用户交互体验。 - 实现两阶段饮食记录流程,支持AI识别食物并生成确认选项,用户选择后记录到数据库并提供营养分析。 - 扩展DTO层,新增相关数据结构以支持确认流程。 - 更新服务层,新增处理确认逻辑的方法,优化饮食记录的创建流程。 - 增强API文档,详细说明新流程及使用建议,确保开发者理解和使用新功能。
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<Readable> {
|
||||
selectedChoiceId?: string;
|
||||
confirmationData?: any;
|
||||
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||||
// 解析指令(如果以 # 开头)
|
||||
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}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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数组
|
||||
@@ -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
|
||||
@@ -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 原始结果字符串
|
||||
|
||||
Reference in New Issue
Block a user