增强营养分析功能,更新系统提示以支持营养分析师角色,添加相关DTO以处理饮食记录和营养目标。同时,优化AI教练服务逻辑,识别营养相关话题并调整响应内容。

This commit is contained in:
2025-08-17 20:29:35 +08:00
parent e719c959aa
commit e358b3d2fd
2 changed files with 230 additions and 3 deletions

View File

@@ -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) || '';

View File

@@ -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[];
};
}