增强营养分析功能,更新系统提示以支持营养分析师角色,添加相关DTO以处理饮食记录和营养目标。同时,优化AI教练服务逻辑,识别营养相关话题并调整响应内容。
This commit is contained in:
@@ -8,7 +8,73 @@ 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';
|
||||||
|
|
||||||
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()
|
@Injectable()
|
||||||
export class AiCoachService {
|
export class AiCoachService {
|
||||||
@@ -76,13 +142,16 @@ export class AiCoachService {
|
|||||||
if (params.systemNotice) {
|
if (params.systemNotice) {
|
||||||
messages.unshift({ role: 'system', content: 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({
|
const stream = await this.client.chat.completions.create({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
messages,
|
messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 1024,
|
max_tokens: 800,
|
||||||
});
|
});
|
||||||
|
|
||||||
const readable = new Readable({ read() { } });
|
const readable = new Readable({ read() { } });
|
||||||
@@ -117,6 +186,51 @@ export class AiCoachService {
|
|||||||
return readable;
|
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 {
|
private deriveTitleIfEmpty(assistantReply: string): string | null {
|
||||||
if (!assistantReply) return null;
|
if (!assistantReply) return null;
|
||||||
const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || '';
|
const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || '';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
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 {
|
export class AiChatMessageDto {
|
||||||
@ApiProperty({ enum: ['user', 'assistant', 'system'] })
|
@ApiProperty({ enum: ['user', 'assistant', 'system'] })
|
||||||
@@ -39,5 +39,118 @@ export class AiChatResponseDto {
|
|||||||
conversationId: string;
|
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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user