Files
plates-server/src/ai-coach/ai-coach.service.ts
richarjiang c3961150ab feat: 优化AI教练聊天逻辑,增加用户聊天次数检查和响应内容
- 在AI教练控制器中添加用户聊天次数检查,若次数用完则返回相应提示信息。
- 更新AI聊天响应DTO,新增用户剩余聊天次数和AI回复文本字段,提升用户体验。
- 修改用户服务,支持初始体重和目标体重字段的更新,增强用户资料的完整性。
2025-08-27 14:22:25 +08:00

939 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '体重趋势分析生成失败,请稍后重试。';
}
}
}