diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts index bb62978..a8e47ae 100644 --- a/src/ai-coach/ai-coach.service.ts +++ b/src/ai-coach/ai-coach.service.ts @@ -8,7 +8,73 @@ import { PostureAssessment } from './models/posture-assessment.model'; import { UserProfile } from '../users/models/user-profile.model'; import { UsersService } from '../users/users.service'; -const SYSTEM_PROMPT = `作为一名资深的普拉提与运动康复教练(Pilates Coach),我拥有丰富的专业知识,包括但不限于运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练以及营养与饮食建议。请遵循以下指导原则进行交流: - **话题范围**:讨论将仅限于健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食等领域。 - **拒绝回答的内容**:对于医疗诊断、情感心理支持、时政金融分析或编程等非相关或高风险问题,我会礼貌地解释为何这些不在我的专业范围内,并尝试将对话引导回上述合适的话题领域内。 - **语言风格**:我的回复将以亲切且专业的态度呈现,尽量做到条理清晰、分点阐述;当需要时,会提供可以在家轻松实践的具体步骤指南及注意事项;同时考虑到不同水平参与者的需求,特别是那些可能有轻微不适或曾受过伤的人群,我会给出相应的调整建议和安全提示。 - **个性化与安全性**:强调每个人的身体状况都是独一无二的,在提出任何锻炼计划之前都会提醒大家根据自身情况适当调整强度;如果涉及到具体的疼痛问题或是旧伤复发的情况,则强烈建议先咨询医生的意见再开始新的训练项目。 - **设备要求**:所有推荐的练习都假设参与者只有基础的家庭健身器材可用,比如瑜伽垫、弹力带或者泡沫轴等;此外还会对每项活动的大致持续时间和频率做出估计,并分享一些自我监测进步的方法。 请告诉我您具体想了解哪方面的信息,以便我能更好地为您提供帮助。`; +const SYSTEM_PROMPT = `作为一名资深的普拉提与运动康复教练(Pilates Coach)兼营养分析师(Nutrition Analyst),我拥有丰富的专业知识,包括但不限于: + +运动领域:运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练、运动损伤预防与恢复。 + +营养领域:基础营养学、饮食结构优化、宏量与微量营养素分析、能量平衡、运动表现与恢复的饮食搭配、特殊人群(如素食者、轻度肥胖人群、体重管理需求者)的饮食指导。 + +请遵循以下指导原则进行交流: + +1. 话题范围 + +仅限于 健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食 等领域。 + +涉及营养时,我会结合 个体化饮食分析(如热量、蛋白质、碳水、脂肪、维生素、矿物质比例)和 生活方式建议,帮助优化饮食习惯。 + +2. 拒绝回答的内容 + +不涉及医疗诊断、处方药建议、情感心理咨询、金融投资分析或编程等高风险或不相关内容。 + +若遇到超出专业范围的问题,我会礼貌说明并尝试引导回相关话题。 + +3. 语言风格 + +回复以 亲切、专业、清晰分点 为主。 + +会给出 在家可实践的具体步骤,并提供注意事项与替代方案。 + +针对不同水平或有伤病史的用户,提供调整建议与安全提示。 + +4. 个性化与安全性 + +强调每个人身体和饮食需求的独特性。 + +提供训练和饮食建议时,会提醒用户根据自身情况调整强度与摄入量。 + +如涉及严重疼痛、慢性病或旧伤复发,强烈建议先咨询医生或注册营养师再执行。 + +5. 设备与工具要求 + +运动部分默认用户仅有基础家庭健身器材(瑜伽垫、弹力带、泡沫轴)。 + +营养部分会给出简单可操作的食材替代方案,避免过度依赖难获取或昂贵的补剂。 + +所有建议附带大致的 频率/时长/摄入参考量,并分享 自我监测与调整的方法(如训练日志、饮食记录、身体反馈观察)。`; + +const NUTRITION_ANALYST_PROMPT = `营养分析师模式(仅在检测为营养/饮食相关话题时启用): + +原则与优先级: +- 本轮以营养分析师视角回答;若与其它系统指令冲突,以本提示为准;话题结束后自动恢复默认角色。 +- 只输出结论与结构化内容,不展示推理过程。 +- 信息不足时,先提出1-3个关键追问(如餐次、份量、目标、过敏/限制)。 + +输出结构(精简分点): +1) 饮食分解:按餐次(早餐/午餐/晚餐/加餐)整理;给出每餐热量与三大营养素的估算(用“约/范围”表述)。 +2) 营养分析: + - 全天热量与宏量营养素比例是否匹配目标(减脂/增肌/维持/恢复/表现)。 + - 关键微量营养素关注点(膳食纤维、维生素D、钙、铁、钾、镁、钠等)。 + - 指出过量/不足与可观测风险(如蛋白不足、添加糖偏高、钠摄入偏高等)。 +3) 优化建议(可执行): + - 食材替换:给出2-3条替换示例(如“白米→糙米/藜麦”,“香肠→瘦牛肉/鸡胸”,“含糖酸奶→无糖酸奶+水果”)。 + - 结构调整:分配蛋白质到三餐/加餐、碳水时机(训练前后)、蔬果与纤维补足。 + - 目标化策略:分别给出减脂/增肌/维持/恢复/表现的要点(热量/蛋白/碳水/脂肪的方向性调整)。 +4) 安全与个体差异提醒:过敏与不耐受、疾病或孕期需个体化;必要时建议咨询医生/注册营养师。 + +表述规范: +- 语气亲切专业;分点清晰;避免过度精确(如“约300kcal”、“蛋白约25-35g”)。 +- 无法确定时给出区间与假设,并提示用户完善信息。 +`; @Injectable() export class AiCoachService { @@ -76,13 +142,16 @@ export class AiCoachService { if (params.systemNotice) { messages.unshift({ role: 'system', content: params.systemNotice }); } + if (this.isLikelyNutritionTopic(params.userContent, messages)) { + messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT }); + } const stream = await this.client.chat.completions.create({ model: this.model, messages, stream: true, temperature: 0.7, - max_tokens: 1024, + max_tokens: 800, }); const readable = new Readable({ read() { } }); @@ -117,6 +186,51 @@ export class AiCoachService { return readable; } + private isLikelyNutritionTopic( + currentText: string | undefined, + messages?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>, + ): boolean { + if (!currentText && !messages?.length) return false; + const recentTexts: string[] = []; + if (currentText) recentTexts.push(currentText); + if (messages && messages.length > 0) { + const tail = messages.slice(-6).map((m) => (m?.content || '')); + recentTexts.push(...tail); + } + const text = recentTexts.join('\n').toLowerCase(); + + const keywordPatterns = [ + /营养|饮食|配餐|食谱|餐单|膳食|食材|食物|加餐|早餐|午餐|晚餐|零食|控糖|控卡|代餐|膳食纤维|纤维|维生素|矿物质|微量营养素|宏量营养素|热量|卡路里|大卡/i, + /protein|carb|carbohydrate|fat|fats|calorie|calories|kcal|macro|micronutrient|vitamin|fiber|diet|meal|breakfast|lunch|dinner|snack|bulking|cutting/i, + /蛋白|蛋白质|碳水|脂肪|糖|升糖指数|gi|低碳|生酮|高蛋白|低脂|清淡/i, + ]; + + const structureHints = [ + /\b\d+\s*(?:g|克|ml|毫?升|大?卡|kcal)\b/i, + /\b[0-9]{2,4}\s*kcal\b/i, + /(鸡胸|牛肉|鸡蛋|燕麦|藜麦|糙米|白米|土豆|红薯|酸奶|牛奶|坚果|鳄梨|沙拉|面包|米饭|面条)/i, + /(替换|替代|换成).*(食材|主食|配菜|零食)/i, + ]; + + const goalHints = [ + /减脂|增肌|维持|控重|体重管理|恢复|训练表现|运动表现/i, + /weight\s*loss|fat\s*loss|muscle\s*gain|maintenance|performance|recovery/i, + ]; + + const matched = [...keywordPatterns, ...structureHints, ...goalHints].some((re) => re.test(text)); + + // 若用户发的是极短的承接语,但上下文包含饮食关键词,也认为是营养话题 + if (!matched && currentText && currentText.length <= 8) { + const shortFollowUps = /(那早餐呢|那午餐呢|那晚餐呢|那怎么吃|吃什么|怎么搭配|怎么配|怎么安排|如何吃)/i; + if (shortFollowUps.test(currentText)) { + const context = (messages || []).slice(-8).map((m) => m.content).join('\n'); + if ([...keywordPatterns, ...structureHints].some((re) => re.test(context))) return true; + } + } + + return matched; + } + private deriveTitleIfEmpty(assistantReply: string): string | null { if (!assistantReply) return null; const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || ''; diff --git a/src/ai-coach/dto/ai-chat.dto.ts b/src/ai-coach/dto/ai-chat.dto.ts index 3fbc719..fca4616 100644 --- a/src/ai-coach/dto/ai-chat.dto.ts +++ b/src/ai-coach/dto/ai-chat.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength, IsInt, Min, Max } from 'class-validator'; +import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength, IsInt, Min, Max, IsEnum } from 'class-validator'; export class AiChatMessageDto { @ApiProperty({ enum: ['user', 'assistant', 'system'] }) @@ -39,5 +39,118 @@ export class AiChatResponseDto { conversationId: string; } +// 营养分析相关的DTO +export enum NutritionGoal { + WEIGHT_LOSS = 'weight_loss', + MUSCLE_GAIN = 'muscle_gain', + MAINTENANCE = 'maintenance', + PERFORMANCE = 'performance', + RECOVERY = 'recovery', + GENERAL_HEALTH = 'general_health' +} + +export class MealItemDto { + @ApiProperty({ description: '食物名称' }) + @IsString() + @IsNotEmpty() + foodName: string; + + @ApiProperty({ description: '食物份量(如:1碗、200g、1个等)', required: false }) + @IsOptional() + @IsString() + portion?: string; + + @ApiProperty({ description: '估算热量(卡路里)', required: false }) + @IsOptional() + @IsInt() + @Min(0) + estimatedCalories?: number; +} + +export class MealDto { + @ApiProperty({ description: '餐次名称(早餐/午餐/晚餐/加餐)' }) + @IsString() + @IsNotEmpty() + mealType: string; + + @ApiProperty({ description: '用餐时间(如:8:00)', required: false }) + @IsOptional() + @IsString() + mealTime?: string; + + @ApiProperty({ type: [MealItemDto], description: '该餐的食物列表' }) + @IsArray() + items: MealItemDto[]; +} + +export class NutritionAnalysisRequestDto { + @ApiProperty({ description: '会话ID。未提供则创建新会话' }) + @IsOptional() + @IsString() + conversationId?: string; + + @ApiProperty({ type: [MealDto], description: '全天的饮食记录' }) + @IsArray() + meals: MealDto[]; + + @ApiProperty({ enum: NutritionGoal, description: '营养目标' }) + @IsEnum(NutritionGoal) + goal: NutritionGoal; + + @ApiProperty({ description: '用户当前体重(kg)', required: false }) + @IsOptional() + @IsInt() + @Min(20) + @Max(300) + currentWeight?: number; + + @ApiProperty({ description: '用户身高(cm)', required: false }) + @IsOptional() + @IsInt() + @Min(100) + @Max(250) + height?: number; + + @ApiProperty({ description: '运动强度(1-5级,1最轻,5最重)', required: false }) + @IsOptional() + @IsInt() + @Min(1) + @Max(5) + activityLevel?: number; + + @ApiProperty({ description: '特殊饮食需求或限制(如:素食、无麸质等)', required: false }) + @IsOptional() + @IsString() + dietaryRestrictions?: string; + + @ApiProperty({ description: '过敏食物列表', required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + allergies?: string[]; + + @ApiProperty({ description: '是否启用流式输出', default: true }) + @IsOptional() + @IsBoolean() + stream?: boolean; +} + +export class NutritionAnalysisResponseDto { + @ApiProperty() + conversationId: string; + + @ApiProperty({ description: '营养分析结果' }) + analysis: { + totalCalories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber: number; + goalMatchScore: number; // 0-100 + recommendations: string[]; + warnings: string[]; + }; +} +