- 在AI教练控制器中添加用户聊天次数检查,若次数用完则返回相应提示信息。 - 更新AI聊天响应DTO,新增用户剩余聊天次数和AI回复文本字段,提升用户体验。 - 修改用户服务,支持初始体重和目标体重字段的更新,增强用户资料的完整性。
939 lines
32 KiB
TypeScript
939 lines
32 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { OpenAI } from 'openai';
|
||
import { Readable } from 'stream';
|
||
import { AiMessage, RoleType } from './models/ai-message.model';
|
||
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, FoodRecognitionResult, FoodConfirmationOption } from './services/diet-analysis.service';
|
||
|
||
const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师(Nutrition Analyst)和健身教练,我拥有丰富的专业知识,包括但不限于:
|
||
|
||
运动领域:运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练、运动损伤预防与恢复。
|
||
|
||
营养领域:基础营养学、饮食结构优化、宏量与微量营养素分析、能量平衡、运动表现与恢复的饮食搭配、特殊人群(如素食者、轻度肥胖人群、体重管理需求者)的饮食指导。
|
||
|
||
请遵循以下指导原则进行交流:
|
||
|
||
1. 话题范围
|
||
|
||
仅限于 健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食 等领域。
|
||
|
||
涉及营养时,我会结合 个体化饮食分析(如热量、蛋白质、碳水、脂肪、维生素、矿物质比例)和 生活方式建议,帮助优化饮食习惯。
|
||
|
||
2. 拒绝回答的内容
|
||
|
||
不涉及医疗诊断、处方药建议、情感心理咨询、金融投资分析或编程等高风险或不相关内容。
|
||
|
||
若遇到超出专业范围的问题,我会礼貌说明并尝试引导回相关话题。
|
||
|
||
3. 语言风格
|
||
|
||
回复以 亲切、专业、清晰分点 为主。
|
||
|
||
会给出 在家可实践的具体步骤,并提供注意事项与替代方案。
|
||
|
||
针对不同水平或有伤病史的用户,提供调整建议与安全提示。
|
||
|
||
4. 个性化与安全性
|
||
|
||
强调每个人身体和饮食需求的独特性。
|
||
|
||
提供训练和饮食建议时,会提醒用户根据自身情况调整强度与摄入量。
|
||
|
||
如涉及严重疼痛、慢性病或旧伤复发,强烈建议先咨询医生或注册营养师再执行。
|
||
|
||
5. 设备与工具要求
|
||
|
||
运动部分默认用户仅有基础家庭健身器材(瑜伽垫、弹力带、泡沫轴)。
|
||
|
||
营养部分会给出简单可操作的食材替代方案,避免过度依赖难获取或昂贵的补剂。
|
||
|
||
所有建议附带大致的 频率/时长/摄入参考量,并分享 自我监测与调整的方法(如训练日志、饮食记录、身体反馈观察)。`;
|
||
|
||
const NUTRITION_ANALYST_PROMPT = `营养分析师模式(仅在检测为营养/饮食相关话题时启用):
|
||
|
||
原则与优先级:
|
||
- 本轮以营养分析师视角回答;若与其它系统指令冲突,以本提示为准;话题结束后自动恢复默认角色。
|
||
- 只输出结论与结构化内容,不展示推理过程。
|
||
- 信息不足时,先提出1-3个关键追问(如餐次、份量、目标、过敏/限制)。
|
||
|
||
输出结构(精简分点):
|
||
1) 饮食分解:按餐次(早餐/午餐/晚餐/加餐)整理;给出每餐热量与三大营养素的估算(用“约/范围”表述)。
|
||
2) 营养分析:
|
||
- 全天热量与宏量营养素比例是否匹配目标(减脂/增肌/维持/恢复/表现)。
|
||
- 关键微量营养素关注点(膳食纤维、维生素D、钙、铁、钾、镁、钠等)。
|
||
- 指出过量/不足与可观测风险(如蛋白不足、添加糖偏高、钠摄入偏高等)。
|
||
3) 优化建议(可执行):
|
||
- 食材替换:给出2-3条替换示例(如“白米→糙米/藜麦”,“香肠→瘦牛肉/鸡胸”,“含糖酸奶→无糖酸奶+水果”)。
|
||
- 结构调整:分配蛋白质到三餐/加餐、碳水时机(训练前后)、蔬果与纤维补足。
|
||
- 目标化策略:分别给出减脂/增肌/维持/恢复/表现的要点(热量/蛋白/碳水/脂肪的方向性调整)。
|
||
4) 安全与个体差异提醒:过敏与不耐受、疾病或孕期需个体化;必要时建议咨询医生/注册营养师。
|
||
|
||
表述规范:
|
||
- 语气亲切专业;分点清晰;避免过度精确(如“约300kcal”、“蛋白约25-35g”)。
|
||
- 无法确定时给出区间与假设,并提示用户完善信息。
|
||
`;
|
||
|
||
/**
|
||
* 指令解析结果接口
|
||
*/
|
||
interface CommandResult {
|
||
isCommand: boolean;
|
||
command?: 'weight' | 'diet';
|
||
originalText: string;
|
||
cleanText: string;
|
||
}
|
||
|
||
|
||
|
||
@Injectable()
|
||
export class AiCoachService {
|
||
private readonly logger = new Logger(AiCoachService.name);
|
||
private readonly client: OpenAI;
|
||
private readonly model: string;
|
||
private readonly visionModel: string;
|
||
|
||
constructor(
|
||
private readonly configService: ConfigService,
|
||
private readonly usersService: UsersService,
|
||
private readonly dietAnalysisService: DietAnalysisService,
|
||
) {
|
||
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
|
||
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||
|
||
this.client = new OpenAI({
|
||
apiKey: dashScopeApiKey,
|
||
baseURL,
|
||
});
|
||
// 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖
|
||
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-flash';
|
||
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
|
||
}
|
||
|
||
async createOrAppendMessages(params: {
|
||
userId: string;
|
||
conversationId?: string;
|
||
userContent: string;
|
||
}): Promise<{ conversationId: string }> {
|
||
let conversationId = params.conversationId;
|
||
if (!conversationId) {
|
||
conversationId = `${params.userId}-${Date.now()}`;
|
||
await AiConversation.create({ id: conversationId, userId: params.userId, title: null, lastMessageAt: new Date() });
|
||
} else {
|
||
await AiConversation.upsert({ id: conversationId, userId: params.userId, lastMessageAt: new Date() });
|
||
}
|
||
await AiMessage.create({
|
||
conversationId,
|
||
userId: params.userId,
|
||
role: RoleType.User,
|
||
content: params.userContent,
|
||
metadata: null,
|
||
});
|
||
return { conversationId };
|
||
}
|
||
|
||
buildChatHistory = async (userId: string, conversationId: string) => {
|
||
const history = await AiMessage.findAll({
|
||
where: { userId, conversationId, deleted: false },
|
||
order: [['created_at', 'ASC']],
|
||
});
|
||
|
||
const messages = [
|
||
{ role: 'system' as const, content: SYSTEM_PROMPT },
|
||
...history.map((m) => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content })),
|
||
];
|
||
return messages;
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
async streamChat(params: {
|
||
userId: string;
|
||
conversationId: string;
|
||
userContent: string;
|
||
systemNotice?: string;
|
||
imageUrls?: string[];
|
||
selectedChoiceId?: string;
|
||
confirmationData?: any;
|
||
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||
try {
|
||
|
||
// 1. 优先处理用户选择(选择逻辑)
|
||
if (params.selectedChoiceId) {
|
||
return await this.handleUserChoice({
|
||
userId: params.userId,
|
||
conversationId: params.conversationId,
|
||
userContent: params.userContent,
|
||
selectedChoiceId: params.selectedChoiceId,
|
||
confirmationData: params.confirmationData
|
||
});
|
||
}
|
||
|
||
// 2. 解析用户输入的指令
|
||
const commandResult = this.parseCommand(params.userContent);
|
||
|
||
// 3. 构建基础消息上下文
|
||
const messages = await this.buildChatHistory(params.userId, params.conversationId);
|
||
if (params.systemNotice) {
|
||
messages.unshift({ role: 'system', content: params.systemNotice });
|
||
}
|
||
|
||
// 4. 处理指令
|
||
if (commandResult.command) {
|
||
return await this.handleCommand(commandResult, params, messages);
|
||
}
|
||
|
||
// 5. 处理普通对话(包括营养话题检测)
|
||
return await this.handleNormalChat(params, messages);
|
||
} catch (error) {
|
||
this.logger.error(`streamChat error: ${error instanceof Error ? error.message : String(error)}`);
|
||
return this.createStreamFromText('处理失败,请稍后重试');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理用户选择
|
||
*/
|
||
private async handleUserChoice(params: {
|
||
userId: string;
|
||
conversationId: string;
|
||
userContent: string;
|
||
selectedChoiceId: string;
|
||
confirmationData?: any;
|
||
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||
|
||
// 处理体重趋势分析选择
|
||
if (params.selectedChoiceId === 'trend_analysis' && params.confirmationData?.weightRecordData) {
|
||
return await this.handleWeightTrendAnalysis({
|
||
userId: params.userId,
|
||
conversationId: params.conversationId,
|
||
confirmationData: params.confirmationData
|
||
});
|
||
}
|
||
|
||
// 处理饮食确认选择
|
||
if (params.selectedChoiceId && params.confirmationData) {
|
||
return await this.handleDietConfirmation({
|
||
userId: params.userId,
|
||
conversationId: params.conversationId,
|
||
selectedChoiceId: params.selectedChoiceId,
|
||
confirmationData: params.confirmationData
|
||
});
|
||
}
|
||
|
||
// 其他选择类型的处理...
|
||
throw new Error(`未知的选择类型: ${params.selectedChoiceId}`);
|
||
}
|
||
|
||
/**
|
||
* 处理指令
|
||
*/
|
||
private async handleCommand(
|
||
commandResult: CommandResult,
|
||
params: any,
|
||
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
|
||
): Promise<Readable | { type: 'structured'; data: any }> {
|
||
|
||
if (commandResult.command === 'weight') {
|
||
return await this.handleWeightCommand(params);
|
||
}
|
||
|
||
if (commandResult.command === 'diet') {
|
||
return await this.handleDietCommand(commandResult, params, messages);
|
||
}
|
||
|
||
// 其他指令处理...
|
||
throw new Error(`未知的指令: ${commandResult.command}`);
|
||
}
|
||
|
||
/**
|
||
* 处理体重趋势分析选择
|
||
*/
|
||
private async handleWeightTrendAnalysis(params: {
|
||
userId: string;
|
||
conversationId: string;
|
||
confirmationData: { weightRecordData: any };
|
||
}): Promise<Readable> {
|
||
const analysisContent = await this.generateWeightTrendAnalysis(
|
||
params.userId,
|
||
params.confirmationData.weightRecordData
|
||
);
|
||
|
||
// 保存消息记录
|
||
await Promise.all([
|
||
AiMessage.create({
|
||
conversationId: params.conversationId,
|
||
userId: params.userId,
|
||
role: RoleType.User,
|
||
content: '用户选择查看体重趋势分析',
|
||
metadata: null,
|
||
}),
|
||
AiMessage.create({
|
||
conversationId: params.conversationId,
|
||
userId: params.userId,
|
||
role: RoleType.Assistant,
|
||
content: analysisContent,
|
||
metadata: { model: this.model, interactionType: 'weight_trend_analysis' },
|
||
})
|
||
]);
|
||
|
||
// 更新对话时间
|
||
await AiConversation.update(
|
||
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(analysisContent) },
|
||
{ where: { id: params.conversationId, userId: params.userId } }
|
||
);
|
||
|
||
return this.createStreamFromText(analysisContent);
|
||
}
|
||
|
||
/**
|
||
* 处理体重指令
|
||
*/
|
||
private async handleWeightCommand(params: {
|
||
userId: string;
|
||
conversationId: string;
|
||
userContent: string;
|
||
}): Promise<{ type: 'structured'; data: any }> {
|
||
const weightKg = this.extractWeightFromText(params.userContent);
|
||
|
||
if (!weightKg) {
|
||
throw new Error('无法提取有效的体重数值');
|
||
}
|
||
|
||
// 更新体重到数据库
|
||
await this.usersService.addWeightByVision(params.userId, weightKg);
|
||
|
||
// 构建成功消息
|
||
const responseContent = `已成功记录体重:${weightKg}kg`;
|
||
|
||
// 保存消息
|
||
await AiMessage.create({
|
||
conversationId: params.conversationId,
|
||
userId: params.userId,
|
||
role: RoleType.Assistant,
|
||
content: responseContent,
|
||
metadata: {
|
||
model: this.model,
|
||
interactionType: 'weight_record_success',
|
||
weightData: { newWeight: weightKg, recordedAt: new Date().toISOString() }
|
||
},
|
||
});
|
||
|
||
// 更新对话时间
|
||
await AiConversation.update(
|
||
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(responseContent) },
|
||
{ where: { id: params.conversationId, userId: params.userId } }
|
||
);
|
||
|
||
return {
|
||
type: 'structured',
|
||
data: {
|
||
content: responseContent,
|
||
choices: [
|
||
{
|
||
id: 'trend_analysis',
|
||
label: '查看体重趋势分析',
|
||
value: 'weight_trend_analysis',
|
||
recommended: true
|
||
},
|
||
{
|
||
id: 'continue_chat',
|
||
label: '继续对话',
|
||
value: 'continue_normal_chat',
|
||
recommended: false
|
||
}
|
||
],
|
||
interactionType: 'weight_record_success',
|
||
pendingData: {
|
||
weightRecordData: { newWeight: weightKg, recordedAt: new Date().toISOString() }
|
||
},
|
||
context: { command: 'weight', step: 'record_success' }
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 处理饮食确认选择
|
||
*/
|
||
private async handleDietConfirmation(params: {
|
||
userId: string;
|
||
conversationId: string;
|
||
selectedChoiceId: string;
|
||
confirmationData: any;
|
||
}): Promise<Readable> {
|
||
// 饮食确认逻辑保持原样
|
||
const { selectedOption, imageUrl } = params.confirmationData;
|
||
const createDto = await this.dietAnalysisService.createDietRecordFromConfirmation(
|
||
params.userId,
|
||
selectedOption,
|
||
imageUrl || ''
|
||
);
|
||
|
||
if (!createDto) {
|
||
throw new Error('饮食记录创建失败');
|
||
}
|
||
|
||
const messages = await this.buildChatHistory(params.userId, params.conversationId);
|
||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||
|
||
if (nutritionContext) {
|
||
messages.unshift({ role: 'system', content: nutritionContext });
|
||
}
|
||
|
||
messages.push({
|
||
role: 'user',
|
||
content: `用户确认记录饮食:${selectedOption.label}`
|
||
});
|
||
|
||
messages.unshift({
|
||
role: 'system',
|
||
content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt()
|
||
});
|
||
|
||
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||
}
|
||
|
||
/**
|
||
* 处理饮食指令
|
||
*/
|
||
private async handleDietCommand(
|
||
commandResult: CommandResult,
|
||
params: any,
|
||
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
|
||
): Promise<Readable | { type: 'structured'; data: any }> {
|
||
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}`;
|
||
|
||
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}`
|
||
});
|
||
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||
}
|
||
} else {
|
||
// 处理文本饮食记录
|
||
const textAnalysisResult = await this.dietAnalysisService.analyzeDietFromText(commandResult.cleanText);
|
||
|
||
if (textAnalysisResult.shouldRecord && textAnalysisResult.extractedData) {
|
||
const createDto = await this.dietAnalysisService.processDietRecord(
|
||
params.userId,
|
||
textAnalysisResult,
|
||
''
|
||
);
|
||
|
||
if (createDto) {
|
||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||
if (nutritionContext) {
|
||
messages.unshift({ role: 'system', content: nutritionContext });
|
||
}
|
||
|
||
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
|
||
messages.push({
|
||
role: 'user',
|
||
content: `用户通过文本记录饮食:${textAnalysisResult.analysisText}`
|
||
});
|
||
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
|
||
}
|
||
} else {
|
||
messages.push({
|
||
role: 'user',
|
||
content: `用户提到饮食相关内容:${commandResult.cleanText}。分析结果:${textAnalysisResult.analysisText}`
|
||
});
|
||
|
||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||
if (nutritionContext) {
|
||
messages.unshift({ role: 'system', content: nutritionContext });
|
||
}
|
||
messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT });
|
||
}
|
||
|
||
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理普通对话
|
||
*/
|
||
private async handleNormalChat(
|
||
params: any,
|
||
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
|
||
): Promise<Readable> {
|
||
// 检测营养话题
|
||
if (this.isLikelyNutritionTopic(params.userContent, messages)) {
|
||
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
|
||
if (nutritionContext) {
|
||
messages.unshift({ role: 'system', content: nutritionContext });
|
||
}
|
||
messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT });
|
||
}
|
||
|
||
return this.generateAIResponse(params.conversationId, params.userId, messages);
|
||
}
|
||
|
||
/**
|
||
* 生成AI响应的通用方法
|
||
*/
|
||
private async generateAIResponse(
|
||
conversationId: string,
|
||
userId: string,
|
||
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
|
||
): Promise<Readable> {
|
||
const stream = await this.client.chat.completions.create({
|
||
model: this.model,
|
||
messages,
|
||
stream: true,
|
||
temperature: 0.7,
|
||
max_tokens: 500,
|
||
});
|
||
|
||
const readable = new Readable({ read() { } });
|
||
let assistantContent = '';
|
||
|
||
(async () => {
|
||
try {
|
||
for await (const chunk of stream) {
|
||
const delta = chunk.choices?.[0]?.delta?.content || '';
|
||
if (delta) {
|
||
assistantContent += delta;
|
||
readable.push(delta);
|
||
}
|
||
}
|
||
|
||
await AiMessage.create({
|
||
conversationId,
|
||
userId,
|
||
role: RoleType.Assistant,
|
||
content: assistantContent,
|
||
metadata: { model: this.model },
|
||
});
|
||
|
||
await AiConversation.update(
|
||
{ lastMessageAt: new Date(), title: this.deriveTitleIfEmpty(assistantContent) },
|
||
{ where: { id: conversationId, userId } }
|
||
);
|
||
} catch (error) {
|
||
this.logger.error(`stream error: ${error?.message || error}`);
|
||
readable.push('\n[对话发生错误,请稍后重试]');
|
||
} finally {
|
||
readable.push(null);
|
||
}
|
||
})();
|
||
|
||
return readable;
|
||
}
|
||
|
||
/**
|
||
* 从文本创建流式响应
|
||
*/
|
||
private createStreamFromText(text: string): Readable {
|
||
const readable = new Readable({ read() { } });
|
||
|
||
setTimeout(() => {
|
||
const chunks = text.split('');
|
||
let index = 0;
|
||
|
||
const pushChunk = () => {
|
||
if (index < chunks.length) {
|
||
readable.push(chunks[index]);
|
||
index++;
|
||
setTimeout(pushChunk, 20);
|
||
} else {
|
||
readable.push(null);
|
||
}
|
||
};
|
||
|
||
pushChunk();
|
||
}, 100);
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 解析用户输入的指令(以 # 开头)
|
||
* @param text 用户输入文本
|
||
* @returns 指令解析结果
|
||
*/
|
||
private parseCommand(text: string): CommandResult {
|
||
if (!text || !text.trim().startsWith('#')) {
|
||
return {
|
||
isCommand: false,
|
||
originalText: text || '',
|
||
cleanText: text || ''
|
||
};
|
||
}
|
||
|
||
const trimmedText = text.trim();
|
||
const commandMatch = trimmedText.match(/^#([^\s::]+)[::]?\s*(.*)$/);
|
||
|
||
if (!commandMatch) {
|
||
return {
|
||
isCommand: false,
|
||
originalText: text,
|
||
cleanText: text
|
||
};
|
||
}
|
||
|
||
const [, commandPart, restText] = commandMatch;
|
||
const cleanText = restText.trim();
|
||
|
||
// 识别体重记录指令
|
||
if (/^记体重$/.test(commandPart)) {
|
||
return {
|
||
isCommand: true,
|
||
command: 'weight',
|
||
originalText: text,
|
||
cleanText: cleanText || '记录体重'
|
||
};
|
||
}
|
||
|
||
// 识别饮食记录指令
|
||
if (/^记饮食$/.test(commandPart)) {
|
||
return {
|
||
isCommand: true,
|
||
command: 'diet',
|
||
originalText: text,
|
||
cleanText: cleanText || '记录饮食'
|
||
};
|
||
}
|
||
|
||
// 未识别的指令,当作普通文本处理
|
||
return {
|
||
isCommand: false,
|
||
originalText: text,
|
||
cleanText: text
|
||
};
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
private deriveTitleIfEmpty(assistantReply: string): string | null {
|
||
if (!assistantReply) return null;
|
||
const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || '';
|
||
return firstLine.slice(0, 50) || null;
|
||
}
|
||
|
||
async listConversations(userId: string, params: { page?: number; pageSize?: number }) {
|
||
const page = Math.max(1, params.page || 1);
|
||
const pageSize = Math.min(50, Math.max(1, params.pageSize || 20));
|
||
const offset = (page - 1) * pageSize;
|
||
const { rows, count } = await AiConversation.findAndCountAll({
|
||
where: { userId, deleted: false },
|
||
order: [['last_message_at', 'DESC']],
|
||
offset,
|
||
limit: pageSize,
|
||
});
|
||
return {
|
||
page,
|
||
pageSize,
|
||
total: count,
|
||
items: rows.map((c) => ({
|
||
conversationId: c.id,
|
||
title: c.title,
|
||
lastMessageAt: c.lastMessageAt,
|
||
createdAt: c.createdAt,
|
||
})),
|
||
};
|
||
}
|
||
|
||
async getConversationDetail(userId: string, conversationId: string) {
|
||
const conv = await AiConversation.findOne({ where: { id: conversationId, userId, deleted: false } });
|
||
if (!conv) return null;
|
||
const messages = await AiMessage.findAll({
|
||
where: { userId, conversationId, deleted: false },
|
||
order: [['created_at', 'ASC']],
|
||
});
|
||
return {
|
||
conversationId: conv.id,
|
||
title: conv.title,
|
||
lastMessageAt: conv.lastMessageAt,
|
||
createdAt: conv.createdAt,
|
||
messages: messages.map((m) => ({ role: m.role, content: m.content, createdAt: m.createdAt })),
|
||
};
|
||
}
|
||
|
||
async deleteConversation(userId: string, conversationId: string): Promise<boolean> {
|
||
const conv = await AiConversation.findOne({ where: { id: conversationId, userId, deleted: false } });
|
||
if (!conv) return false;
|
||
await AiMessage.update({ deleted: true }, { where: { userId, conversationId } });
|
||
await AiConversation.update({ deleted: true }, { where: { id: conversationId, userId } });
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* AI体态评估:
|
||
* - 汇总用户身高体重
|
||
* - 使用视觉模型读取三张图片(正/侧/背)
|
||
* - 通过强约束的 JSON Schema 产出结构化结果
|
||
* - 存储评估记录并返回
|
||
*/
|
||
async assessPosture(params: {
|
||
userId: string;
|
||
frontImageUrl: string;
|
||
sideImageUrl: string;
|
||
backImageUrl: string;
|
||
heightCm?: number;
|
||
weightKg?: number;
|
||
}) {
|
||
// 获取默认身高体重
|
||
let heightCm: number | undefined = params.heightCm;
|
||
let weightKg: number | undefined = params.weightKg;
|
||
if (heightCm == null || weightKg == null) {
|
||
const profile = await UserProfile.findOne({ where: { userId: params.userId } });
|
||
if (heightCm == null) heightCm = profile?.height ?? undefined;
|
||
if (weightKg == null) weightKg = profile?.weight ?? undefined;
|
||
}
|
||
|
||
const schemaInstruction = `请以严格合法的JSON返回体态评估结果,键名与类型必须匹配以下Schema,不要输出多余文本:
|
||
{
|
||
"overallScore": number(0-5),
|
||
"radar": {
|
||
"骨盆中立": number(0-5),
|
||
"肩带稳": number(0-5),
|
||
"胸廓控": number(0-5),
|
||
"主排列": number(0-5),
|
||
"柔对线": number(0-5),
|
||
"核心": number(0-5)
|
||
},
|
||
"frontView": {
|
||
"描述": string,
|
||
"问题要点": string[],
|
||
"建议动作": string[]
|
||
},
|
||
"sideView": {
|
||
"描述": string,
|
||
"问题要点": string[],
|
||
"建议动作": string[]
|
||
},
|
||
"backView": {
|
||
"描述": string,
|
||
"问题要点": string[],
|
||
"建议动作": string[]
|
||
}
|
||
}`;
|
||
|
||
const persona = `你是一名资深体态评估与普拉提康复教练。结合用户提供的三张照片(正面/侧面/背面)进行体态评估。严格限制话题在健康、姿势、普拉提与训练建议范围内。用词亲切但专业,强调安全、循序渐进与个体差异。用户资料:身高${heightCm ?? '未知'}cm,体重${weightKg ?? '未知'}kg。`;
|
||
|
||
const completion = await this.client.chat.completions.create({
|
||
model: this.visionModel,
|
||
messages: [
|
||
{ role: 'system', content: persona },
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: schemaInstruction },
|
||
{ type: 'text', text: '这三张图分别是正面、侧面、背面:' },
|
||
{ type: 'image_url', image_url: { url: params.frontImageUrl } as any },
|
||
{ type: 'image_url', image_url: { url: params.sideImageUrl } as any },
|
||
{ type: 'image_url', image_url: { url: params.backImageUrl } as any },
|
||
] as any,
|
||
},
|
||
],
|
||
temperature: 0,
|
||
response_format: { type: 'json_object' } as any,
|
||
});
|
||
|
||
const raw = completion.choices?.[0]?.message?.content || '{}';
|
||
let result: any = {};
|
||
try { result = JSON.parse(raw); } catch { }
|
||
const overallScore = typeof result.overallScore === 'number' ? result.overallScore : null;
|
||
|
||
const rec = await PostureAssessment.create({
|
||
userId: params.userId,
|
||
frontImageUrl: params.frontImageUrl,
|
||
sideImageUrl: params.sideImageUrl,
|
||
backImageUrl: params.backImageUrl,
|
||
heightCm: heightCm != null ? heightCm : null,
|
||
weightKg: weightKg != null ? weightKg : null,
|
||
overallScore,
|
||
result,
|
||
});
|
||
|
||
return { id: rec.id, overallScore, result };
|
||
}
|
||
|
||
/**
|
||
* 从用户文本中识别体重信息
|
||
* 支持多种格式:65kg、65公斤、65.5kg、体重65等
|
||
*/
|
||
private extractWeightFromText(text: string | undefined): number | null {
|
||
if (!text) return null;
|
||
|
||
const t = text.toLowerCase();
|
||
|
||
// 匹配各种体重格式的正则表达式
|
||
const patterns = [
|
||
// 匹配 "#记体重:80 kg" 格式
|
||
/#(?:记体重|体重|称重|记录体重)[::]\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
|
||
|
||
// 匹配带单位的体重 "80kg", "80.5公斤", "80 kg"
|
||
/(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
|
||
|
||
// 匹配体重关键词后的数字 "体重65", "weight 70.5"
|
||
/(?:体重|称重|秤|重量|weight)[::]?\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)?/i,
|
||
|
||
// 匹配口语化表达 "我体重65", "我现在70kg"
|
||
/我(?:现在|今天)?(?:体重|重量|称重)?(?:是|为|有|:|:)?\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)?/i,
|
||
|
||
// 匹配简单数字+单位格式 "65.5kg"
|
||
/^(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)$/i,
|
||
|
||
// 匹配斤单位并转换为kg "130斤"
|
||
/(\d+(?:\.\d+)?)\s*斤/i,
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
const match = t.match(pattern);
|
||
if (match) {
|
||
let weight = parseFloat(match[1]);
|
||
|
||
// 如果是斤单位,转换为kg (1斤 = 0.5kg)
|
||
// 只有专门匹配斤单位的模式才进行转换,避免"公斤"等词被误判
|
||
if (pattern.source.includes('斤') && !pattern.source.includes('公斤')) {
|
||
weight = weight * 0.5;
|
||
}
|
||
|
||
// 合理的体重范围检查 (20-400kg)
|
||
if (weight >= 20 && weight <= 400) {
|
||
return weight;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
/**
|
||
* 生成体重趋势分析
|
||
*/
|
||
async generateWeightTrendAnalysis(userId: string, weightRecordData: any): Promise<string> {
|
||
try {
|
||
const { newWeight } = weightRecordData;
|
||
|
||
const weightAnalysisPrompt = `用户刚刚记录了体重${newWeight}kg,请提供体重趋势分析、健康建议和鼓励。语言风格:亲切、专业、鼓励性。`;
|
||
|
||
const completion = await this.client.chat.completions.create({
|
||
model: this.model,
|
||
messages: [
|
||
{ role: 'system', content: SYSTEM_PROMPT },
|
||
{ role: 'user', content: weightAnalysisPrompt }
|
||
],
|
||
temperature: 0.7,
|
||
max_tokens: 300,
|
||
});
|
||
|
||
return completion.choices?.[0]?.message?.content || '体重趋势分析生成失败,请稍后重试。';
|
||
} catch (error) {
|
||
this.logger.error(`生成体重趋势分析失败: ${error instanceof Error ? error.message : String(error)}`);
|
||
return '体重趋势分析生成失败,请稍后重试。';
|
||
}
|
||
}
|
||
}
|
||
|
||
|